@oh-my-pi/pi-tui 18.4.1 → 18.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/chat/thinking-display.d.ts +2 -0
  3. package/dist/types/chrome/transcript-container.d.ts +15 -1
  4. package/dist/types/components/editor.d.ts +5 -0
  5. package/dist/types/loop-watchdog.d.ts +9 -0
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/model-picker.d.ts +2 -1
  8. package/dist/types/overlays/rewind-selector.d.ts +2 -1
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  11. package/dist/types/render/width-aware-text.d.ts +6 -0
  12. package/dist/types/setup/scenes/sign-in.d.ts +9 -9
  13. package/dist/types/setup/scenes/types.d.ts +0 -26
  14. package/dist/types/stdin-buffer.d.ts +10 -7
  15. package/dist/types/terminal.d.ts +16 -0
  16. package/dist/types/theme/color.d.ts +1 -0
  17. package/dist/types/theme/session-color.d.ts +3 -3
  18. package/dist/types/theme/shimmer.d.ts +1 -1
  19. package/dist/types/theme/theme-class.d.ts +1 -1
  20. package/dist/types/tools/web-search-types.d.ts +90 -0
  21. package/dist/types/tools/web-search.d.ts +1 -168
  22. package/dist/types/tui.d.ts +3 -2
  23. package/package.json +9 -9
  24. package/src/chat/assistant-message.ts +192 -40
  25. package/src/chat/thinking-display.ts +73 -11
  26. package/src/chat/tool-execution.ts +24 -4
  27. package/src/chrome/transcript-container.ts +178 -31
  28. package/src/components/editor.ts +51 -15
  29. package/src/components/image.ts +15 -7
  30. package/src/components/loader.ts +37 -17
  31. package/src/components/markdown.ts +110 -56
  32. package/src/loop-watchdog.ts +38 -10
  33. package/src/overlays/agent-hub.ts +16 -6
  34. package/src/overlays/copy-selector.ts +5 -0
  35. package/src/overlays/model-browser.ts +106 -25
  36. package/src/overlays/model-picker.ts +9 -6
  37. package/src/overlays/rewind-selector.ts +10 -2
  38. package/src/overlays/settings-selector.ts +2 -10
  39. package/src/prompt/composer-attachments.ts +54 -7
  40. package/src/prompt/composer.ts +5 -2
  41. package/src/prompt/custom-editor.ts +24 -6
  42. package/src/prompt/model-mention-autocomplete.ts +22 -10
  43. package/src/render/width-aware-text.ts +9 -0
  44. package/src/setup/scenes/sign-in.ts +17 -12
  45. package/src/setup/scenes/types.ts +0 -27
  46. package/src/setup/wizard.ts +1 -1
  47. package/src/status-line/component.ts +229 -134
  48. package/src/status-line/segments.ts +34 -29
  49. package/src/stdin-buffer.ts +27 -21
  50. package/src/terminal.ts +60 -20
  51. package/src/theme/color.ts +15 -0
  52. package/src/theme/mermaid-cache.ts +10 -1
  53. package/src/theme/session-color.ts +26 -4
  54. package/src/theme/shimmer.ts +55 -76
  55. package/src/theme/theme-class.ts +29 -12
  56. package/src/theme/tui-adapters.ts +14 -1
  57. package/src/tools/bash.ts +53 -13
  58. package/src/tools/edit.ts +110 -29
  59. package/src/tools/streaming-output.ts +79 -16
  60. package/src/tools/web-search-types.ts +99 -0
  61. package/src/tools/web-search.ts +1 -172
  62. package/src/tui.ts +143 -113
  63. package/src/utils.ts +15 -0
  64. package/dist/types/setup/scenes/providers.d.ts +0 -3
  65. package/dist/types/setup/scenes/web-search.d.ts +0 -22
  66. package/src/setup/scenes/providers.ts +0 -110
  67. package/src/setup/scenes/web-search.ts +0 -165
@@ -6,6 +6,7 @@
6
6
  import type { Component } from "../index.js";
7
7
  import type { RenderResultOptions } from "./renderer.js";
8
8
  import { type Theme } from "../theme/theme.js";
9
+ import { type SearchResponse } from "./web-search-types.js";
9
10
  /** Search response and optional failure shown in the transcript. */
10
11
  export interface SearchRenderDetails {
11
12
  response: SearchResponse;
@@ -33,171 +34,3 @@ export declare const webSearchToolRenderer: {
33
34
  renderResult: typeof renderSearchResult;
34
35
  mergeCallAndResult: true;
35
36
  };
36
- /**
37
- * Web Search Types
38
- *
39
- * Unified types for web search responses across supported providers.
40
- */
41
- export declare const SEARCH_PROVIDER_OPTIONS: readonly [{
42
- readonly value: "auto";
43
- readonly label: "Auto";
44
- readonly description: "Automatically uses the first configured web-search provider";
45
- }, {
46
- readonly value: "parallel";
47
- readonly label: "Parallel";
48
- readonly description: "Uses API auth when configured; otherwise searches through the keyless public MCP";
49
- }, {
50
- readonly value: "perplexity";
51
- readonly label: "Perplexity";
52
- readonly description: "Uses auth when configured; explicit selection falls back to anonymous search";
53
- }, {
54
- readonly value: "gemini";
55
- readonly label: "Gemini";
56
- readonly description: "Google Search grounding via Gemini (uses google-gemini-cli or google-antigravity OAuth)";
57
- }, {
58
- readonly value: "anthropic";
59
- readonly label: "Anthropic";
60
- readonly description: "Claude's native web_search tool (uses Anthropic OAuth or ANTHROPIC_API_KEY)";
61
- }, {
62
- readonly value: "codex";
63
- readonly label: "OpenAI Codex";
64
- readonly description: "OpenAI's native web_search (uses ChatGPT OAuth via /login openai-codex)";
65
- }, {
66
- readonly value: "openai";
67
- readonly label: "OpenAI API";
68
- readonly description: "OpenAI hosted web_search via OPENAI_API_KEY or OpenAI API-key registry credentials; billed by API usage";
69
- }, {
70
- readonly value: "xai";
71
- readonly label: "xAI";
72
- readonly description: "Grok web search via xAI Responses API (uses SuperGrok/X Premium+ OAuth via /login xai-oauth, or XAI_API_KEY)";
73
- }, {
74
- readonly value: "openrouter";
75
- readonly label: "OpenRouter";
76
- readonly description: "OpenRouter plugins-based web search with model-selected grounding";
77
- }, {
78
- readonly value: "zai";
79
- readonly label: "Z.AI";
80
- readonly description: "Calls Z.AI webSearchPrime MCP";
81
- }, {
82
- readonly value: "exa";
83
- readonly label: "Exa";
84
- readonly description: "API via /login exa or EXA_API_KEY; explicit keyless fallback via MCP";
85
- }, {
86
- readonly value: "tinyfish";
87
- readonly label: "TinyFish";
88
- readonly description: "Requires TINYFISH_API_KEY";
89
- }, {
90
- readonly value: "jina";
91
- readonly label: "Jina";
92
- readonly description: "Requires JINA_API_KEY";
93
- }, {
94
- readonly value: "kagi";
95
- readonly label: "Kagi";
96
- readonly description: "Requires KAGI_API_KEY and Kagi Search API beta access";
97
- }, {
98
- readonly value: "tavily";
99
- readonly label: "Tavily";
100
- readonly description: "Requires TAVILY_API_KEY";
101
- }, {
102
- readonly value: "firecrawl";
103
- readonly label: "Firecrawl";
104
- readonly description: "Uses Firecrawl API when FIRECRAWL_API_KEY is set; falls back to keyless mode";
105
- }, {
106
- readonly value: "brave";
107
- readonly label: "Brave";
108
- readonly description: "Requires BRAVE_API_KEY";
109
- }, {
110
- readonly value: "kimi";
111
- readonly label: "Kimi";
112
- readonly description: "Kimi Code search (requires a Kimi Code Console key via KIMI_SEARCH_API_KEY/MOONSHOT_SEARCH_API_KEY or /login kimi-code; not MOONSHOT_API_KEY)";
113
- }, {
114
- readonly value: "synthetic";
115
- readonly label: "Synthetic";
116
- readonly description: "Requires SYNTHETIC_API_KEY";
117
- }, {
118
- readonly value: "ollama";
119
- readonly label: "Ollama";
120
- readonly description: "Requires OLLAMA_CLOUD_API_KEY";
121
- }, {
122
- readonly value: "searxng";
123
- readonly label: "SearXNG";
124
- readonly description: "Requires SEARXNG_ENDPOINT or searxng.endpoint";
125
- }, {
126
- readonly value: "startpage";
127
- readonly label: "Startpage";
128
- readonly description: "Credential-free scrape of Startpage (Google-backed) results; may be bot-challenged";
129
- }, {
130
- readonly value: "duckduckgo";
131
- readonly label: "DuckDuckGo";
132
- readonly description: "Credential-free best-effort fallback; may be bot-challenged on datacenter/shared-egress IPs";
133
- }, {
134
- readonly value: "ecosia";
135
- readonly label: "Ecosia";
136
- readonly description: "Credential-free browser-backed scrape of Ecosia (Google-backed) results";
137
- }, {
138
- readonly value: "google";
139
- readonly label: "Google";
140
- readonly description: "Credential-free browser-backed fallback; slower and may be bot-challenged";
141
- }, {
142
- readonly value: "mojeek";
143
- readonly label: "Mojeek";
144
- readonly description: "Credential-free browser-backed scrape of Mojeek's independent index";
145
- }, {
146
- readonly value: "public";
147
- readonly label: "Public Web";
148
- readonly description: "Queries every credential-free engine in parallel and consolidates deduplicated results";
149
- }];
150
- /** Supported web search providers (every option except `auto`). */
151
- export type SearchProviderId = Exclude<(typeof SEARCH_PROVIDER_OPTIONS)[number]["value"], "auto">;
152
- /** Display labels, derived from {@link SEARCH_PROVIDER_OPTIONS}. */
153
- export declare const SEARCH_PROVIDER_LABELS: Record<SearchProviderId, string>;
154
- /** Source returned by search (all providers) */
155
- export interface SearchSource {
156
- title: string;
157
- url: string;
158
- snippet?: string;
159
- /** ISO date string or relative ("2d ago") */
160
- publishedDate?: string;
161
- /** Age in seconds for consistent formatting */
162
- ageSeconds?: number;
163
- author?: string;
164
- }
165
- /** Citation with text reference (LLM-mediated providers) */
166
- export interface SearchCitation {
167
- url: string;
168
- title: string;
169
- citedText?: string;
170
- }
171
- /** Usage metrics */
172
- export interface SearchUsage {
173
- inputTokens?: number;
174
- outputTokens?: number;
175
- /** Anthropic: number of web search requests made */
176
- searchRequests?: number;
177
- /** Perplexity: combined token count */
178
- totalTokens?: number;
179
- }
180
- /** Unified response across providers */
181
- export interface SearchResponse {
182
- provider: SearchProviderId | "none";
183
- /** Synthesized answer text (LLM-mediated providers) */
184
- answer?: string;
185
- /** Search result sources */
186
- sources: SearchSource[];
187
- /** Text citations with context */
188
- citations?: SearchCitation[];
189
- /** Intermediate search queries (anthropic) */
190
- searchQueries?: string[];
191
- /** Follow-up question suggestions (provider-dependent) */
192
- relatedQuestions?: string[];
193
- /** Token usage metrics */
194
- usage?: SearchUsage;
195
- /** Model used */
196
- model?: string;
197
- /** Request ID for debugging */
198
- requestId?: string;
199
- /** Authentication mode used by the provider (e.g. oauth, api-key) */
200
- authMode?: string;
201
- }
202
- /** Cheap, sync metadata accessor — never triggers a provider load. */
203
- export declare function getSearchProviderLabel(id: SearchProviderId): string;
@@ -273,8 +273,9 @@ export declare class Container implements Component {
273
273
  /**
274
274
  * Merge runs of byte-adjacent SGR sequences (`CSI [0-9;:]* m`) into one. Only
275
275
  * CSI-SGR sequences are touched; text, cursor moves, OSC, hyperlinks and image
276
- * payloads pass through verbatim. Returns the original reference when nothing
277
- * merges, so SGR-light lines incur only a single `indexOf` scan.
276
+ * payloads pass through verbatim. Isolated sequences require no parameter
277
+ * slices or arrays; output is built only at merged boundaries or empty resets
278
+ * that need normalization within an adjacent run.
278
279
  */
279
280
  export declare function coalesceAdjacentSgr(line: string): string;
280
281
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.4.1",
4
+ "version": "18.4.3",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.4.1",
45
- "@oh-my-pi/pi-agent-core": "18.4.1",
46
- "@oh-my-pi/pi-ai": "18.4.1",
47
- "@oh-my-pi/pi-catalog": "18.4.1",
48
- "@oh-my-pi/pi-natives": "18.4.1",
49
- "@oh-my-pi/pi-utils": "18.4.1",
50
- "@oh-my-pi/pi-wire": "18.4.1",
51
- "@oh-my-pi/snapcompact": "18.4.1"
44
+ "@oh-my-pi/omptype": "18.4.3",
45
+ "@oh-my-pi/pi-agent-core": "18.4.3",
46
+ "@oh-my-pi/pi-ai": "18.4.3",
47
+ "@oh-my-pi/pi-catalog": "18.4.3",
48
+ "@oh-my-pi/pi-natives": "18.4.3",
49
+ "@oh-my-pi/pi-utils": "18.4.3",
50
+ "@oh-my-pi/pi-wire": "18.4.3",
51
+ "@oh-my-pi/snapcompact": "18.4.3"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -33,7 +33,8 @@ const EMPTY_STABLE_RENDER: readonly string[] = [];
33
33
 
34
34
  type ThinkingContentBlock = Extract<AssistantMessage["content"][number], { type: "thinking" }>;
35
35
  type DisplayThinkingContentBlock = ThinkingContentBlock & { rawThinking?: string };
36
- type StablePart = { kind: "thinking" | "text"; text: string } | { kind: "spacer" };
36
+ type StablePartKind = "thinking" | "text";
37
+ type StablePart = { kind: StablePartKind; text: string } | { kind: "spacer" };
37
38
 
38
39
  /**
39
40
  * One published prefix of the block's finished content. Later snapshots extend
@@ -47,6 +48,37 @@ interface StableSnapshot {
47
48
  readonly lastTextLength: number;
48
49
  }
49
50
 
51
+ /**
52
+ * Stable-row renders at one width. `rows` renders snapshot `newest`; every
53
+ * count in `ends` renders byte-identically to `rows.slice(0, end)` — checked
54
+ * against `rows` when recorded — so an earlier prefix is a slice of the newest
55
+ * render instead of a re-render, and one row array stays resident per width.
56
+ */
57
+ interface StableRowLedger {
58
+ newest: number;
59
+ rows: readonly string[];
60
+ readonly ends: Map<number, number>;
61
+ /** Rendered rows of parts a snapshot has closed (full text final), by part index. */
62
+ readonly parts: (
63
+ | { readonly kind: StablePartKind; readonly text: string; readonly rows: readonly string[] }
64
+ | undefined
65
+ )[];
66
+ }
67
+
68
+ /** One Markdown instance reused while a stable part's text grows between renders. */
69
+ interface StablePartRenderer {
70
+ readonly index: number;
71
+ readonly kind: StablePartKind;
72
+ readonly md: Markdown;
73
+ }
74
+
75
+ /** Theme inputs cached stable renders were produced with; any change drops them. */
76
+ interface StableRenderInputs {
77
+ readonly prose: MarkdownTheme;
78
+ readonly markdown: MarkdownTheme;
79
+ readonly color: ((text: string) => string) | undefined;
80
+ }
81
+
50
82
  function isSnapshotExtension(previous: readonly StablePart[], current: readonly StablePart[]): boolean {
51
83
  if (previous.length > current.length) return false;
52
84
  for (let index = 0; index < previous.length; index++) {
@@ -260,13 +292,20 @@ export class AssistantMessageComponent extends Container {
260
292
  #nextStableRowId = 0;
261
293
  #transcriptStableRows: TranscriptStableRow[] = [];
262
294
  /**
263
- * Rendered rows per published snapshot index and width. The container asks
264
- * for several different counts in one frame (emitted, offered end,
265
- * projected, pressure-loop +1s); a 2-entry LRU thrashes across those and
266
- * re-renders the whole prefix per miss. Sized generously so the live prefix
267
- * stays cached; cleared on reset/finalize like before.
295
+ * Verified stable-row renders per width ({@link StableRowLedger}). The
296
+ * container asks for several counts per frame (emitted, offered end,
297
+ * projected) and each publication re-checks the previous prefix; the ledger
298
+ * answers all of them from the newest render. A few widths cover resizes.
299
+ * Cleared on reset, finalize, and theme change.
268
300
  */
269
- #stableRenderCache = new LRUCache<string, readonly string[]>({ max: 64 });
301
+ #stableLedgers = new LRUCache<number, StableRowLedger>({ max: 4 });
302
+ /** Prefixes handed out as ledger slices or rendered off-ledger, by `${count}:${width}`. */
303
+ #stableRenderCache = new LRUCache<string, readonly string[]>({ max: 8 });
304
+ /** Reused for the growing final part of each publication candidate. */
305
+ #stableHeadRenderer: StablePartRenderer | undefined;
306
+ /** Reused for final parts of published snapshots rendered off-ledger (reflow, replay). */
307
+ #stableReplayRenderer: StablePartRenderer | undefined;
308
+ #stableRenderInputs: StableRenderInputs | undefined;
270
309
  /** Provider-reported tokens in the live thinking block — reasoning tokens when
271
310
  * the provider streams them, else total output — shown dimmed beside the
272
311
  * speed badge. 0 when no thinking is streaming. */
@@ -626,22 +665,33 @@ export class AssistantMessageComponent extends Container {
626
665
  this.#stableSnapshots = [];
627
666
  this.#stableParts = [];
628
667
  this.#transcriptStableRows = [];
629
- this.#stableRenderCache.clear();
668
+ this.#dropStableRenders();
630
669
  }
631
670
 
632
671
  renderTranscriptStableRows(count: number, width: number): readonly string[] {
633
672
  const index = Math.min(Math.trunc(count), this.#stableSnapshots.length);
634
673
  if (index <= 0) return EMPTY_STABLE_RENDER;
674
+ this.#syncStableRenderInputs();
675
+ const ledger = this.#stableLedgers.get(width);
676
+ if (ledger?.newest === index) return ledger.rows;
635
677
  const key = `${index}:${width}`;
636
678
  const cached = this.#stableRenderCache.get(key);
637
679
  if (cached) return cached;
638
- const snapshot = this.#stableSnapshots[index - 1]!;
639
- const parts = this.#stableParts.slice(0, snapshot.partCount);
640
- const last = parts.at(-1);
641
- if (last && last.kind !== "spacer") {
642
- parts[parts.length - 1] = { kind: last.kind, text: last.text.slice(0, snapshot.lastTextLength) };
680
+ const end = ledger?.ends.get(index);
681
+ let rows: readonly string[];
682
+ if (ledger !== undefined && end !== undefined) {
683
+ rows = ledger.rows.slice(0, end);
684
+ } else {
685
+ const snapshot = this.#stableSnapshots[index - 1]!;
686
+ rows = this.#renderStableParts(
687
+ this.#stableParts,
688
+ snapshot.partCount,
689
+ snapshot.lastTextLength,
690
+ width,
691
+ "replay",
692
+ );
693
+ if (this.#recordStableRows(index, width, rows)) return rows;
643
694
  }
644
- const rows = this.#renderStableSnapshot(parts, width);
645
695
  this.#stableRenderCache.set(key, rows);
646
696
  return rows;
647
697
  }
@@ -662,9 +712,12 @@ export class AssistantMessageComponent extends Container {
662
712
  if (!last || last.kind === "spacer") return;
663
713
  const snapshot = { partCount: parts.length, lastTextLength: last.text.length };
664
714
  const previous = this.#stableSnapshots.at(-1);
665
- if (previous && !isSnapshotExtension(this.#stableParts, parts)) return;
715
+ // An unmoved boundary publishes nothing whether or not it still extends
716
+ // the last snapshot, so most frames skip the whole-document comparison.
666
717
  if (previous?.partCount === snapshot.partCount && previous.lastTextLength === snapshot.lastTextLength) return;
667
- const currentRows = this.#renderStableSnapshot(parts, width);
718
+ if (previous && !isSnapshotExtension(this.#stableParts, parts)) return;
719
+ this.#syncStableRenderInputs();
720
+ const currentRows = this.#renderStableParts(parts, parts.length, last.text.length, width, "head");
668
721
  // The container verifies stable rows against the blank-trimmed render.
669
722
  if (!isRowPrefix(currentRows, trimBlankEdges(rendered))) return;
670
723
  const previousRows = previous
@@ -676,7 +729,7 @@ export class AssistantMessageComponent extends Container {
676
729
  this.#stableParts = parts;
677
730
  this.#stableSnapshots.push(snapshot);
678
731
  this.#transcriptStableRows.push({ key: `thinking:${this.#nextStableRowId++}` });
679
- this.#stableRenderCache.set(`${this.#stableSnapshots.length}:${width}`, currentRows);
732
+ this.#recordStableRows(this.#stableSnapshots.length, width, currentRows);
680
733
  }
681
734
 
682
735
  /**
@@ -726,37 +779,136 @@ export class AssistantMessageComponent extends Container {
726
779
  return parts;
727
780
  }
728
781
 
729
- #renderStableSnapshot(parts: readonly StablePart[], width: number): readonly string[] {
782
+ /**
783
+ * Render the first `partCount` stable parts, the final one cut to
784
+ * `lastLength`. Rows match fresh Markdown renders of each part byte for
785
+ * byte — {@link #createStableMarkdown} mirrors the live children — but a
786
+ * closed part renders once per width, and the growing final part reuses one
787
+ * Markdown instance so its already-frozen blocks are not re-lexed.
788
+ */
789
+ #renderStableParts(
790
+ parts: readonly StablePart[],
791
+ partCount: number,
792
+ lastLength: number,
793
+ width: number,
794
+ role: "head" | "replay",
795
+ ): readonly string[] {
796
+ const ledger = this.#stableLedger(width);
730
797
  const rows: string[] = [];
731
- for (const part of parts) {
798
+ const lastIndex = partCount - 1;
799
+ for (let index = 0; index < partCount; index++) {
800
+ const part = parts[index]!;
732
801
  if (part.kind === "spacer") {
733
802
  rows.push("");
734
803
  continue;
735
804
  }
736
- // Constructor args mirror the live child Markdown exactly so these
737
- // rows are byte-identical to the block render's prefix — including the
738
- // trim the live children apply, which drops the trailing blank line a
739
- // frozen prefix still carries.
740
- const text = part.text.trim();
741
- const markdown =
742
- part.kind === "text"
743
- ? new Markdown(
744
- text,
745
- 1,
746
- 0,
747
- this.#getProseTheme(),
748
- this.#textColorTransform ? { color: this.#textColorTransform } : undefined,
749
- 0,
750
- )
751
- : new Markdown(text, 1, 0, getMarkdownTheme(), {
752
- color: (value: string) => theme.fg("thinkingText", value),
753
- italic: true,
754
- });
755
- rows.push(...markdown.render(width));
805
+ const closed = index < lastIndex;
806
+ const text = closed || lastLength === part.text.length ? part.text : part.text.slice(0, lastLength);
807
+ const cached = ledger.parts[index];
808
+ let partRows: readonly string[];
809
+ if (cached?.kind === part.kind && cached.text === text) {
810
+ partRows = cached.rows;
811
+ } else {
812
+ partRows = this.#renderStablePart(index, part.kind, text, width, closed ? undefined : role);
813
+ if (closed) ledger.parts[index] = { kind: part.kind, text, rows: partRows };
814
+ }
815
+ for (const row of partRows) rows.push(row);
756
816
  }
757
817
  return rows;
758
818
  }
759
819
 
820
+ /**
821
+ * Render one part's Markdown. `role` names the instance reused for a final
822
+ * part as its text grows; a closed part continues whichever instance was
823
+ * already growing it, else renders once. Finalized blocks keep no instance.
824
+ */
825
+ #renderStablePart(
826
+ index: number,
827
+ kind: StablePartKind,
828
+ text: string,
829
+ width: number,
830
+ role: "head" | "replay" | undefined,
831
+ ): readonly string[] {
832
+ // Trim like the live children, dropping the trailing blank line a frozen
833
+ // prefix still carries.
834
+ const trimmed = text.trim();
835
+ if (this.#transcriptBlockFinalized) return this.#createStableMarkdown(kind, trimmed).render(width);
836
+ const head = this.#stableHeadRenderer;
837
+ const replay = this.#stableReplayRenderer;
838
+ const renderer = role === "head" ? head : role === "replay" ? replay : head?.index === index ? head : replay;
839
+ if (renderer?.index === index && renderer.kind === kind) {
840
+ renderer.md.setText(trimmed);
841
+ return renderer.md.render(width);
842
+ }
843
+ const md = this.#createStableMarkdown(kind, trimmed);
844
+ if (role === "head") this.#stableHeadRenderer = { index, kind, md };
845
+ else if (role === "replay") this.#stableReplayRenderer = { index, kind, md };
846
+ return md.render(width);
847
+ }
848
+
849
+ /** Constructor args mirror the live child Markdown so stable rows prefix the block render. */
850
+ #createStableMarkdown(kind: StablePartKind, text: string): Markdown {
851
+ return kind === "text"
852
+ ? new Markdown(
853
+ text,
854
+ 1,
855
+ 0,
856
+ this.#getProseTheme(),
857
+ this.#textColorTransform ? { color: this.#textColorTransform } : undefined,
858
+ 0,
859
+ )
860
+ : new Markdown(text, 1, 0, getMarkdownTheme(), {
861
+ color: (value: string) => theme.fg("thinkingText", value),
862
+ italic: true,
863
+ });
864
+ }
865
+
866
+ #stableLedger(width: number): StableRowLedger {
867
+ let ledger = this.#stableLedgers.get(width);
868
+ if (ledger === undefined) {
869
+ ledger = { newest: 0, rows: EMPTY_STABLE_RENDER, ends: new Map(), parts: [] };
870
+ this.#stableLedgers.set(width, ledger);
871
+ }
872
+ return ledger;
873
+ }
874
+
875
+ /**
876
+ * Remember `rows` as snapshot `index`'s render at `width`. A newer snapshot
877
+ * becomes the ledger's newest render — keeping earlier counts only when it
878
+ * extends their rows; an older one that prefixes the newest keeps just its
879
+ * row count. Returns whether `rows` is now the newest render.
880
+ */
881
+ #recordStableRows(index: number, width: number, rows: readonly string[]): boolean {
882
+ const ledger = this.#stableLedger(width);
883
+ if (index > ledger.newest) {
884
+ if (!isRowPrefix(ledger.rows, rows)) ledger.ends.clear();
885
+ ledger.newest = index;
886
+ ledger.rows = rows;
887
+ ledger.ends.set(index, rows.length);
888
+ return true;
889
+ }
890
+ if (index < ledger.newest && isRowPrefix(rows, ledger.rows)) ledger.ends.set(index, rows.length);
891
+ return false;
892
+ }
893
+
894
+ /** Drop cached stable renders once the themes they were rendered with change. */
895
+ #syncStableRenderInputs(): void {
896
+ const prose = this.#getProseTheme();
897
+ const markdown = getMarkdownTheme();
898
+ const color = this.#textColorTransform;
899
+ const inputs = this.#stableRenderInputs;
900
+ if (inputs?.prose === prose && inputs.markdown === markdown && inputs.color === color) return;
901
+ this.#dropStableRenders();
902
+ this.#stableRenderInputs = { prose, markdown, color };
903
+ }
904
+
905
+ #dropStableRenders(): void {
906
+ this.#stableLedgers.clear();
907
+ this.#stableRenderCache.clear();
908
+ this.#stableHeadRenderer = undefined;
909
+ this.#stableReplayRenderer = undefined;
910
+ }
911
+
760
912
  /** Render completed prose rather than an earlier thinking row under emergency viewport pressure. */
761
913
  renderTranscriptBlockEmergencyRow(width: number): string | undefined {
762
914
  if (!this.#transcriptBlockFinalized) return undefined;
@@ -769,7 +921,7 @@ export class AssistantMessageComponent extends Container {
769
921
 
770
922
  markTranscriptBlockFinalized(): void {
771
923
  this.#transcriptBlockFinalized = true;
772
- this.#stableRenderCache.clear();
924
+ this.#dropStableRenders();
773
925
  this.#stopThinkingAnimation();
774
926
  // If the live pulse was on screen when the block sealed, drop the fast path
775
927
  // and rebuild so the placeholder is removed — finalized blocks never animate.
@@ -1,13 +1,16 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import { FENCE_RE } from "../render/render-utils";
3
3
 
4
- // Single-slot-per-mode memo for formatThinkingForDisplay. During a streaming
4
+ // Small per-mode MRU memo for formatThinkingForDisplay. During a streaming
5
5
  // tick the same growing thinking text is formatted up to three times (reveal
6
6
  // count, reveal slice, component render); this collapses them to one
7
7
  // computation. Prose and raw modes produce different output for the same text,
8
- // so each mode keeps its own slot. One entry per mode is enough for the common
9
- // case of one active thinking block and never regresses (a miss recomputes
10
- // exactly as before).
8
+ // so each mode keeps its own slots. A message routinely holds several thinking
9
+ // blocks (thinking → text → thinking), and every tick formats each of them, so
10
+ // one slot per mode would thrash between blocks and turn every append into a
11
+ // full refold; {@link DISPLAY_CACHE_SLOTS} slots keep one checkpoint per live
12
+ // block. A miss recomputes exactly as before and evicts the least recently
13
+ // used slot.
11
14
  //
12
15
  // Each slot also carries the fold state for incremental extension: when the
13
16
  // incoming text is an append of the previously formatted text (streaming only
@@ -85,8 +88,69 @@ function freshDisplayCache(): DisplayCache {
85
88
  return { text: "", value: "", hadComment: false, startLineByte: 0, state: freshFoldState(), resumable: true };
86
89
  }
87
90
 
88
- const proseCache = freshDisplayCache();
89
- const rawCache = freshDisplayCache();
91
+ /** Slots per mode: enough for the thinking blocks of one streaming message. */
92
+ const DISPLAY_CACHE_SLOTS = 4;
93
+ // Most recently used first.
94
+ const proseSlots: DisplayCache[] = [];
95
+ const rawSlots: DisplayCache[] = [];
96
+
97
+ /** Drop every memo slot so the next call recomputes from scratch. Tests only. */
98
+ export function resetThinkingDisplayCacheForTests(): void {
99
+ proseSlots.length = 0;
100
+ rawSlots.length = 0;
101
+ }
102
+
103
+ /**
104
+ * Pick the slot to format `text` into and move it to the front: an exact memo
105
+ * hit, else the resumable slot holding the longest verbatim prefix of `text`
106
+ * (an append). A miss overwrites a non-resumable slot holding a prefix of
107
+ * `text` — the same stream's retired checkpoint, e.g. the raw identity
108
+ * shortcut — so such a stream never evicts the other blocks' checkpoints;
109
+ * otherwise the least recently used slot (or a new one below the cap).
110
+ * Append detection runs here once so the caller never re-verifies.
111
+ */
112
+ function acquireDisplayCache(
113
+ slots: DisplayCache[],
114
+ text: string,
115
+ ): { cache: DisplayCache; match: "hit" | "append" | "miss" } {
116
+ // Identity pass first: the repeated renders of one streaming tick must not
117
+ // pay any prefix verification against the other blocks' slots.
118
+ let index = slots.findIndex(slot => slot.text === text);
119
+ let match: "hit" | "append" | "miss" = index === -1 ? "miss" : "hit";
120
+ if (index === -1) {
121
+ let appendLength = 0;
122
+ let retired = -1;
123
+ let retiredLength = 0;
124
+ for (let i = 0; i < slots.length; i++) {
125
+ const slot = slots[i]!;
126
+ const length = slot.text.length;
127
+ if (length <= (slot.resumable ? appendLength : retiredLength) || !isAppend(slot, text)) continue;
128
+ if (slot.resumable) {
129
+ index = i;
130
+ match = "append";
131
+ appendLength = length;
132
+ } else {
133
+ retired = i;
134
+ retiredLength = length;
135
+ }
136
+ }
137
+ if (index === -1) index = retired;
138
+ }
139
+ if (index === -1) {
140
+ if (slots.length < DISPLAY_CACHE_SLOTS) {
141
+ const cache = freshDisplayCache();
142
+ slots.unshift(cache);
143
+ return { cache, match };
144
+ }
145
+ index = slots.length - 1;
146
+ }
147
+ const cache = slots[index]!;
148
+ if (index > 0) {
149
+ slots.splice(index, 1);
150
+ slots.unshift(cache);
151
+ }
152
+ return { cache, match };
153
+ }
90
154
 
91
155
  export function canonicalizeMessage(text: string | null | undefined): string {
92
156
  if (!text) return "";
@@ -190,14 +254,12 @@ function renderFold(state: FoldState): string {
190
254
  */
191
255
  export function formatThinkingForDisplay(text: string, proseOnly: boolean): string {
192
256
  if (!text) return text;
193
- const cache = proseOnly ? proseCache : rawCache;
194
- // Identity memo first: the 2nd and 3rd renders of one streaming tick pass
195
- // the same text and must not pay the prefix verification below.
196
- if (text === cache.text) return cache.value;
257
+ const { cache, match } = acquireDisplayCache(proseOnly ? proseSlots : rawSlots, text);
258
+ if (match === "hit") return cache.value;
197
259
  let hasComment: boolean;
198
260
  let fromByte: number;
199
261
  let state: FoldState;
200
- if (cache.resumable && isAppend(cache, text)) {
262
+ if (match === "append") {
201
263
  // Append: a `<!--` introduced by the suffix flips the noise gate, and a
202
264
  // marker straddling the seam can start at most 3 bytes back — identical
203
265
  // to rescanning the full text, at O(delta).