@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.
- package/CHANGELOG.md +34 -0
- package/dist/types/chat/thinking-display.d.ts +2 -0
- package/dist/types/chrome/transcript-container.d.ts +15 -1
- package/dist/types/components/editor.d.ts +5 -0
- package/dist/types/loop-watchdog.d.ts +9 -0
- package/dist/types/overlays/model-browser.d.ts +15 -0
- package/dist/types/overlays/model-picker.d.ts +2 -1
- package/dist/types/overlays/rewind-selector.d.ts +2 -1
- package/dist/types/prompt/composer-attachments.d.ts +7 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
- package/dist/types/render/width-aware-text.d.ts +6 -0
- package/dist/types/setup/scenes/sign-in.d.ts +9 -9
- package/dist/types/setup/scenes/types.d.ts +0 -26
- package/dist/types/stdin-buffer.d.ts +10 -7
- package/dist/types/terminal.d.ts +16 -0
- package/dist/types/theme/color.d.ts +1 -0
- package/dist/types/theme/session-color.d.ts +3 -3
- package/dist/types/theme/shimmer.d.ts +1 -1
- package/dist/types/theme/theme-class.d.ts +1 -1
- package/dist/types/tools/web-search-types.d.ts +90 -0
- package/dist/types/tools/web-search.d.ts +1 -168
- package/dist/types/tui.d.ts +3 -2
- package/package.json +9 -9
- package/src/chat/assistant-message.ts +192 -40
- package/src/chat/thinking-display.ts +73 -11
- package/src/chat/tool-execution.ts +24 -4
- package/src/chrome/transcript-container.ts +178 -31
- package/src/components/editor.ts +51 -15
- package/src/components/image.ts +15 -7
- package/src/components/loader.ts +37 -17
- package/src/components/markdown.ts +110 -56
- package/src/loop-watchdog.ts +38 -10
- package/src/overlays/agent-hub.ts +16 -6
- package/src/overlays/copy-selector.ts +5 -0
- package/src/overlays/model-browser.ts +106 -25
- package/src/overlays/model-picker.ts +9 -6
- package/src/overlays/rewind-selector.ts +10 -2
- package/src/overlays/settings-selector.ts +2 -10
- package/src/prompt/composer-attachments.ts +54 -7
- package/src/prompt/composer.ts +5 -2
- package/src/prompt/custom-editor.ts +24 -6
- package/src/prompt/model-mention-autocomplete.ts +22 -10
- package/src/render/width-aware-text.ts +9 -0
- package/src/setup/scenes/sign-in.ts +17 -12
- package/src/setup/scenes/types.ts +0 -27
- package/src/setup/wizard.ts +1 -1
- package/src/status-line/component.ts +229 -134
- package/src/status-line/segments.ts +34 -29
- package/src/stdin-buffer.ts +27 -21
- package/src/terminal.ts +60 -20
- package/src/theme/color.ts +15 -0
- package/src/theme/mermaid-cache.ts +10 -1
- package/src/theme/session-color.ts +26 -4
- package/src/theme/shimmer.ts +55 -76
- package/src/theme/theme-class.ts +29 -12
- package/src/theme/tui-adapters.ts +14 -1
- package/src/tools/bash.ts +53 -13
- package/src/tools/edit.ts +110 -29
- package/src/tools/streaming-output.ts +79 -16
- package/src/tools/web-search-types.ts +99 -0
- package/src/tools/web-search.ts +1 -172
- package/src/tui.ts +143 -113
- package/src/utils.ts +15 -0
- package/dist/types/setup/scenes/providers.d.ts +0 -3
- package/dist/types/setup/scenes/web-search.d.ts +0 -22
- package/src/setup/scenes/providers.ts +0 -110
- 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;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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.
|
|
277
|
-
*
|
|
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.
|
|
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.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.4.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.4.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.4.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.4.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.4.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.4.
|
|
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
|
|
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
|
-
*
|
|
264
|
-
* for several
|
|
265
|
-
* projected
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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
|
-
#
|
|
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.#
|
|
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
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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.#
|
|
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
|
-
//
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
89
|
-
const
|
|
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 ?
|
|
194
|
-
|
|
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 (
|
|
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).
|