@pi-archimedes/ui 2.9.0
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/LICENSE +21 -0
- package/README.md +60 -0
- package/package.json +44 -0
- package/src/bash/index.ts +10 -0
- package/src/bash/renderer.test.ts +424 -0
- package/src/bash/renderer.ts +301 -0
- package/src/bash/tool.test.ts +26 -0
- package/src/bash/tool.ts +17 -0
- package/src/codemode/index.ts +20 -0
- package/src/codemode/loader.test.ts +139 -0
- package/src/codemode/loader.ts +115 -0
- package/src/codemode/renderer.test.ts +717 -0
- package/src/codemode/renderer.ts +399 -0
- package/src/codemode/tool.test.ts +149 -0
- package/src/codemode/tool.ts +96 -0
- package/src/config.test.ts +199 -0
- package/src/config.ts +129 -0
- package/src/editor/index.test.ts +820 -0
- package/src/editor/index.ts +380 -0
- package/src/editor/spin-quips.test.ts +106 -0
- package/src/editor/spin-quips.ts +44 -0
- package/src/editor/spin.test.ts +763 -0
- package/src/editor/spin.ts +493 -0
- package/src/index.test.ts +402 -0
- package/src/index.ts +235 -0
- package/src/migration.test.ts +256 -0
- package/src/migration.ts +94 -0
- package/src/settings.test.ts +122 -0
- package/src/settings.ts +135 -0
- package/src/startup/capture.ts +47 -0
- package/src/startup/index.test.ts +175 -0
- package/src/startup/index.ts +348 -0
- package/src/startup/logo.test.ts +260 -0
- package/src/startup/logo.ts +85 -0
- package/src/startup/sections.test.ts +333 -0
- package/src/startup/sections.ts +306 -0
- package/src/startup/version.test.ts +98 -0
- package/src/startup/version.ts +26 -0
- package/src/thinking/patch.test.ts +857 -0
- package/src/thinking/patch.ts +407 -0
- package/src/thinking/theme.test.ts +216 -0
- package/src/thinking/theme.ts +156 -0
- package/src/thinking/transform.test.ts +89 -0
- package/src/thinking/transform.ts +21 -0
- package/src/thinking/unindent.test.ts +74 -0
- package/src/thinking/unindent.ts +74 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { AssistantMessageComponent, VERSION } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Markdown, type MarkdownOptions, type MarkdownTheme, MouseRegion, Spacer, Text, TruncatedText } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { ThinkingStyle } from "../config.js";
|
|
5
|
+
import { buildMutedMarkdownTheme } from "./theme.js";
|
|
6
|
+
|
|
7
|
+
// Track which pi version we patched against to detect incompatibility
|
|
8
|
+
const PATCHED_KEY = Symbol.for("archimedes:thinkingPatched");
|
|
9
|
+
const PATCH_VERSION_KEY = Symbol.for("archimedes:thinkingPatchVersion");
|
|
10
|
+
// THINKING_STATES_KEY is authoritative in compact mode; thinkingVisibilityOverrides
|
|
11
|
+
// is mirrored to keep external state consumers consistent.
|
|
12
|
+
const THINKING_STATES_KEY = Symbol.for("archimedes:thinkingStateOverrides");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Patches `AssistantMessageComponent.prototype.updateContent` so thinking
|
|
16
|
+
* blocks render with a muted `MarkdownTheme`. Called on every session_start
|
|
17
|
+
* to capture a fresh `getTheme` closure (required for /resume).
|
|
18
|
+
*
|
|
19
|
+
* Re-patches when pi version changes to catch breaking upstream changes.
|
|
20
|
+
*
|
|
21
|
+
* @param config Optional labelText/labelColor overrides for the thinking
|
|
22
|
+
* block header, and autoCollapseThinking flag. When omitted (or empty/invalid),
|
|
23
|
+
* the original defaults are used.
|
|
24
|
+
*/
|
|
25
|
+
export function patchThinkingRenderer(
|
|
26
|
+
getTheme: () => Theme,
|
|
27
|
+
config?: {
|
|
28
|
+
labelText?: string;
|
|
29
|
+
labelColor?: string;
|
|
30
|
+
autoCollapseThinking?: boolean;
|
|
31
|
+
thinkingStyle?: ThinkingStyle;
|
|
32
|
+
},
|
|
33
|
+
): void {
|
|
34
|
+
if (!AssistantMessageComponent) return;
|
|
35
|
+
|
|
36
|
+
const proto = AssistantMessageComponent.prototype;
|
|
37
|
+
if (
|
|
38
|
+
!proto ||
|
|
39
|
+
typeof proto.updateContent !== "function" ||
|
|
40
|
+
AssistantMessageComponent.name !== "AssistantMessageComponent"
|
|
41
|
+
) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const src = proto.updateContent.toString();
|
|
46
|
+
// NOTE: pi ships the interactive TUI in a minified bundle chunk at runtime, so
|
|
47
|
+
// the source we see via .toString() can be `content.type==="thinking"` (no
|
|
48
|
+
// spaces) even where dist is readable. The probe below must therefore be
|
|
49
|
+
// minification-safe: a whitespace-tolerant regex rather than an exact
|
|
50
|
+
// substring. A bare `space === "thinking"` (or any other field) still does
|
|
51
|
+
// not match, as required.
|
|
52
|
+
const hasThinkingCheck = /content\.type\s*===\s*["']thinking["']/.test(src);
|
|
53
|
+
const hasMarkdownTheme = src.includes("this.markdownTheme");
|
|
54
|
+
if (!hasThinkingCheck || !hasMarkdownTheme) {
|
|
55
|
+
console.warn(
|
|
56
|
+
`[archimedes] Skipping thinking renderer patch — signature mismatch
|
|
57
|
+
hasThinkingCheck: ${hasThinkingCheck}, hasMarkdownTheme: ${hasMarkdownTheme}
|
|
58
|
+
This likely means pi's AssistantMessageComponent changed. The muted theme
|
|
59
|
+
for thinking blocks will not be applied.`,
|
|
60
|
+
);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check if already patched against the current pi version
|
|
65
|
+
const currentVersion = VERSION ?? "unknown";
|
|
66
|
+
const patchVersion = (proto as any)[PATCH_VERSION_KEY] as string | undefined;
|
|
67
|
+
if ((proto as any)[PATCHED_KEY] && patchVersion === currentVersion) {
|
|
68
|
+
// Already patched for this version — just update the closure by re-patching
|
|
69
|
+
// (needed for /resume to get fresh getTheme)
|
|
70
|
+
} else {
|
|
71
|
+
// First patch or version changed — warn if version mismatch
|
|
72
|
+
if ((proto as any)[PATCHED_KEY] && patchVersion && patchVersion !== currentVersion) {
|
|
73
|
+
console.warn(`[archimedes] Re-patching thinking renderer: pi version changed ${patchVersion} → ${currentVersion}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Re-patched every session_start — /resume needs a fresh getTheme closure.
|
|
78
|
+
//
|
|
79
|
+
// Shape: 0.84.3 pi native updateContent:
|
|
80
|
+
// updateContent(message, isStreaming = this.isStreaming) {
|
|
81
|
+
// this.lastMessage = message;
|
|
82
|
+
// this.isStreaming = isStreaming;
|
|
83
|
+
// this.contentContainer.clear();
|
|
84
|
+
// ...
|
|
85
|
+
// // batches consecutive "thinking" parts into thinkingBlocks
|
|
86
|
+
// // (skipping empties), i-- after the inner loop,
|
|
87
|
+
// // renders as ONE Markdown section of thinkingBlocks.join("\n\n")
|
|
88
|
+
// // or ONE static Text label when hidden.
|
|
89
|
+
// // stop-reason: const hasToolCalls = content.some(...);
|
|
90
|
+
// // this.hasToolCalls = hasToolCalls; (render() uses for OSC-133 zones)
|
|
91
|
+
// // stopReason === "length" → Spacer + "truncated" Text
|
|
92
|
+
// // else if (!hasToolCalls) { aborted / error branches }
|
|
93
|
+
(proto as any).updateContent = function (this: any, message: any, isStreaming?: boolean): void {
|
|
94
|
+
this.lastMessage = message;
|
|
95
|
+
if (isStreaming !== undefined) this.isStreaming = isStreaming;
|
|
96
|
+
|
|
97
|
+
this.thinkingVisibilityOverrides = this.thinkingVisibilityOverrides ?? new Map<number, boolean>();
|
|
98
|
+
(this as any)[THINKING_STATES_KEY] =
|
|
99
|
+
(this as any)[THINKING_STATES_KEY] ?? new Map<number, "hidden" | "compact" | "full">();
|
|
100
|
+
const compactLines = config?.thinkingStyle === "Compact" ? 1 : 0;
|
|
101
|
+
this.markdownTheme.codeBlockIndent = "";
|
|
102
|
+
this.contentContainer.clear();
|
|
103
|
+
|
|
104
|
+
// Build the thinking-block header from the closure config once per render.
|
|
105
|
+
// Defaults to "Thinking..." in italic truecolor 255,215,0.
|
|
106
|
+
const buildThinkingLabel = (): string => {
|
|
107
|
+
const label = config?.labelText?.trim() ? config.labelText.trim() : "Thinking...";
|
|
108
|
+
const color = config?.labelColor?.trim() ? config.labelColor.trim() : "255,215,0";
|
|
109
|
+
const parts = color.split(",").map((p) => p.trim());
|
|
110
|
+
// Valid iff exactly three 0..255 components (an "R,G,B" triple).
|
|
111
|
+
const valid =
|
|
112
|
+
parts.length === 3 &&
|
|
113
|
+
parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) >= 0 && Number(p) <= 255);
|
|
114
|
+
const [r, g, b] = valid ? parts : ["255", "215", "0"];
|
|
115
|
+
return `\x1b[3m\x1b[38;2;${r};${g};${b}m${label}\x1b[39m\x1b[23m`;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const hasVisibleContent = message.content.some(
|
|
119
|
+
(c: any) =>
|
|
120
|
+
(c.type === "text" && c.text.trim()) ||
|
|
121
|
+
(c.type === "thinking" && c.thinking.trim()),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
if (hasVisibleContent) {
|
|
125
|
+
this.contentContainer.addChild(new Spacer(1));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Lazy muted theme: built once per updateContent call.
|
|
129
|
+
let mutedTheme: ReturnType<typeof buildMutedMarkdownTheme> | undefined;
|
|
130
|
+
let theme: Theme | undefined;
|
|
131
|
+
let themeFailed = false;
|
|
132
|
+
|
|
133
|
+
const ensureTheme = (): Theme | undefined => {
|
|
134
|
+
if (themeFailed) return undefined;
|
|
135
|
+
if (!theme) {
|
|
136
|
+
try {
|
|
137
|
+
theme = getTheme();
|
|
138
|
+
} catch {
|
|
139
|
+
themeFailed = true;
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return theme;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const ensureMuted = (): MarkdownTheme | undefined => {
|
|
147
|
+
if (!mutedTheme) {
|
|
148
|
+
const t = ensureTheme();
|
|
149
|
+
if (!t) return undefined;
|
|
150
|
+
mutedTheme = buildMutedMarkdownTheme(t);
|
|
151
|
+
}
|
|
152
|
+
return mutedTheme;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Pi's native updateContent passes a `transform` (createMarkdownTransform)
|
|
156
|
+
// to Markdown so the markdown-transformer pipeline runs — that is what
|
|
157
|
+
// renders Mermaid blocks to ASCII and applies any extension transformers.
|
|
158
|
+
// We must preserve it here, otherwise those transformers are silently
|
|
159
|
+
// dropped when this patch replaces updateContent.
|
|
160
|
+
//
|
|
161
|
+
// NOTE: `createMarkdownTransform` is not exported from pi-coding-agent, so
|
|
162
|
+
// we inline an equivalent pipeline over `this.markdownTransformers`.
|
|
163
|
+
//
|
|
164
|
+
// The `transform` option was added to @earendil-works/pi-tui in 0.84.1.
|
|
165
|
+
// At runtime older pi-tui ignores the field, 0.84.1+ honors it.
|
|
166
|
+
type MarkdownOptionsWithTransform = MarkdownOptions & {
|
|
167
|
+
transform?: (markdown: string, availableWidth: number) => string;
|
|
168
|
+
};
|
|
169
|
+
const transformFor =
|
|
170
|
+
(messageType: "assistant" | "assistant-thinking") =>
|
|
171
|
+
(markdown: string, availableWidth: number): string => {
|
|
172
|
+
let out = markdown;
|
|
173
|
+
for (const transformer of (this as any).markdownTransformers ?? []) {
|
|
174
|
+
try {
|
|
175
|
+
const transformed = transformer(out, {
|
|
176
|
+
messageType,
|
|
177
|
+
isStreaming: this.isStreaming,
|
|
178
|
+
availableWidth,
|
|
179
|
+
});
|
|
180
|
+
if (typeof transformed === "string") out = transformed;
|
|
181
|
+
} catch {
|
|
182
|
+
// Keep the current markdown and continue with the next transformer.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
let thinkingRunIndex = 0;
|
|
189
|
+
|
|
190
|
+
// Render content in order.
|
|
191
|
+
for (let i = 0; i < message.content.length; i++) {
|
|
192
|
+
const content = message.content[i];
|
|
193
|
+
if (content.type === "text" && content.text.trim()) {
|
|
194
|
+
this.contentContainer.addChild(
|
|
195
|
+
new Markdown(
|
|
196
|
+
content.text.trim(),
|
|
197
|
+
this.outputPad ?? 1,
|
|
198
|
+
0,
|
|
199
|
+
this.markdownTheme,
|
|
200
|
+
undefined,
|
|
201
|
+
{ transform: transformFor("assistant") } as MarkdownOptionsWithTransform,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
} else if (content.type === "thinking") {
|
|
205
|
+
// Batch a consecutive run of thinking parts into one section
|
|
206
|
+
// (mirrors 0.84.3 pi native behaviour: thinkBlocks, i-- on the
|
|
207
|
+
// inner loop, early continue on zero-length runs).
|
|
208
|
+
const thinkBlocks: string[] = [];
|
|
209
|
+
for (; i < message.content.length; i++) {
|
|
210
|
+
const thinkingPart = message.content[i];
|
|
211
|
+
if (thinkingPart.type !== "thinking") break;
|
|
212
|
+
const trimmed = thinkingPart.thinking.trim();
|
|
213
|
+
if (trimmed) thinkBlocks.push(trimmed);
|
|
214
|
+
}
|
|
215
|
+
i--;
|
|
216
|
+
if (thinkBlocks.length === 0) continue;
|
|
217
|
+
|
|
218
|
+
const runIndex = thinkingRunIndex++;
|
|
219
|
+
|
|
220
|
+
const hasVisibleContentAfter = message.content
|
|
221
|
+
.slice(i + 1)
|
|
222
|
+
.some(
|
|
223
|
+
(c: any) =>
|
|
224
|
+
(c.type === "text" && c.text.trim()) ||
|
|
225
|
+
(c.type === "thinking" && c.thinking.trim()) ||
|
|
226
|
+
c.type === "toolCall",
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (compactLines === 0) {
|
|
230
|
+
const userOverride = this.thinkingVisibilityOverrides.get(runIndex);
|
|
231
|
+
let hidden: boolean;
|
|
232
|
+
if (userOverride !== undefined) {
|
|
233
|
+
hidden = userOverride;
|
|
234
|
+
} else if (config?.autoCollapseThinking) {
|
|
235
|
+
// When auto-collapse is enabled, show thinking while it is actively streaming.
|
|
236
|
+
// Once thinking finishes (subsequent content arrives or streaming ends), collapse it.
|
|
237
|
+
const isThinkingActive = Boolean(this.isStreaming) && !hasVisibleContentAfter;
|
|
238
|
+
hidden = !isThinkingActive;
|
|
239
|
+
} else {
|
|
240
|
+
hidden = this.hideThinkingBlock;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let thinkingComponent: Text | Markdown;
|
|
244
|
+
if (hidden) {
|
|
245
|
+
// One static label for the whole run when hidden.
|
|
246
|
+
const t = ensureTheme();
|
|
247
|
+
if (!t) continue;
|
|
248
|
+
thinkingComponent = new Text(
|
|
249
|
+
t.italic(t.fg("thinkingText", this.hiddenThinkingLabel)),
|
|
250
|
+
this.outputPad ?? 1,
|
|
251
|
+
0,
|
|
252
|
+
);
|
|
253
|
+
} else {
|
|
254
|
+
let thinkingContent = thinkBlocks.join("\n\n");
|
|
255
|
+
const label = buildThinkingLabel();
|
|
256
|
+
if (!thinkingContent.startsWith(label)) {
|
|
257
|
+
thinkingContent = `${label}\n\n${thinkingContent}`;
|
|
258
|
+
}
|
|
259
|
+
const t = ensureTheme();
|
|
260
|
+
if (!t) continue;
|
|
261
|
+
const muted = ensureMuted();
|
|
262
|
+
thinkingComponent = new Markdown(
|
|
263
|
+
thinkingContent,
|
|
264
|
+
this.outputPad ?? 1,
|
|
265
|
+
0,
|
|
266
|
+
muted ?? this.markdownTheme,
|
|
267
|
+
{
|
|
268
|
+
color: (text: string) => t.fg("thinkingText", text),
|
|
269
|
+
italic: true,
|
|
270
|
+
},
|
|
271
|
+
{ transform: transformFor("assistant-thinking") } as MarkdownOptionsWithTransform,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.contentContainer.addChild(
|
|
276
|
+
new MouseRegion(thinkingComponent, (event) => {
|
|
277
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
278
|
+
this.thinkingVisibilityOverrides.set(runIndex, !hidden);
|
|
279
|
+
if (this.lastMessage) this.updateContent(this.lastMessage);
|
|
280
|
+
return { handled: true };
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
} else {
|
|
284
|
+
// THINKING_STATES_KEY is authoritative in compact mode; thinkingVisibilityOverrides
|
|
285
|
+
// is mirrored to keep external state consumers consistent.
|
|
286
|
+
const userState = (this as any)[THINKING_STATES_KEY].get(runIndex);
|
|
287
|
+
let state: "hidden" | "compact" | "full";
|
|
288
|
+
if (userState !== undefined) {
|
|
289
|
+
state = userState;
|
|
290
|
+
} else if (config?.autoCollapseThinking && (!Boolean(this.isStreaming) || hasVisibleContentAfter)) {
|
|
291
|
+
state = "hidden";
|
|
292
|
+
} else {
|
|
293
|
+
state = "compact";
|
|
294
|
+
}
|
|
295
|
+
this.thinkingVisibilityOverrides.set(runIndex, state === "hidden");
|
|
296
|
+
|
|
297
|
+
let thinkingComponent: Text | Markdown | TruncatedText;
|
|
298
|
+
if (state === "hidden") {
|
|
299
|
+
const t = ensureTheme();
|
|
300
|
+
if (!t) continue;
|
|
301
|
+
thinkingComponent = new Text(
|
|
302
|
+
t.italic(t.fg("thinkingText", this.hiddenThinkingLabel)),
|
|
303
|
+
this.outputPad ?? 1,
|
|
304
|
+
0,
|
|
305
|
+
);
|
|
306
|
+
} else if (state === "compact") {
|
|
307
|
+
const t = ensureTheme();
|
|
308
|
+
if (!t) continue;
|
|
309
|
+
const combined = thinkBlocks.join("\n\n").trimEnd();
|
|
310
|
+
const allLines = combined.replace(/\r\n?/g, "\n").split("\n");
|
|
311
|
+
const tailLines = allLines.slice(-compactLines);
|
|
312
|
+
const label = buildThinkingLabel();
|
|
313
|
+
let textContent: string;
|
|
314
|
+
if (compactLines === 1) {
|
|
315
|
+
const line = tailLines[0] ?? "";
|
|
316
|
+
textContent = `${label} ${t.italic(t.fg("thinkingText", line))}`;
|
|
317
|
+
thinkingComponent = new TruncatedText(textContent, this.outputPad ?? 1, 0);
|
|
318
|
+
} else {
|
|
319
|
+
const formattedLines = tailLines.map((l) => t.italic(t.fg("thinkingText", l))).join("\n");
|
|
320
|
+
textContent = `${label}\n${formattedLines}`;
|
|
321
|
+
thinkingComponent = new Text(textContent, this.outputPad ?? 1, 0);
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
let thinkingContent = thinkBlocks.join("\n\n");
|
|
325
|
+
const label = buildThinkingLabel();
|
|
326
|
+
if (!thinkingContent.startsWith(label)) {
|
|
327
|
+
thinkingContent = `${label}\n\n${thinkingContent}`;
|
|
328
|
+
}
|
|
329
|
+
const t = ensureTheme();
|
|
330
|
+
if (!t) continue;
|
|
331
|
+
const muted = ensureMuted();
|
|
332
|
+
thinkingComponent = new Markdown(
|
|
333
|
+
thinkingContent,
|
|
334
|
+
this.outputPad ?? 1,
|
|
335
|
+
0,
|
|
336
|
+
muted ?? this.markdownTheme,
|
|
337
|
+
{
|
|
338
|
+
color: (text: string) => t.fg("thinkingText", text),
|
|
339
|
+
italic: true,
|
|
340
|
+
},
|
|
341
|
+
{ transform: transformFor("assistant-thinking") } as MarkdownOptionsWithTransform,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
this.contentContainer.addChild(
|
|
346
|
+
new MouseRegion(thinkingComponent, (event) => {
|
|
347
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
348
|
+
let nextState: "hidden" | "compact" | "full";
|
|
349
|
+
if (state === "compact") {
|
|
350
|
+
nextState = "full";
|
|
351
|
+
} else if (state === "full") {
|
|
352
|
+
nextState = "compact";
|
|
353
|
+
} else {
|
|
354
|
+
// From hidden -> expand to full
|
|
355
|
+
nextState = "full";
|
|
356
|
+
}
|
|
357
|
+
(this as any)[THINKING_STATES_KEY].set(runIndex, nextState);
|
|
358
|
+
this.thinkingVisibilityOverrides.set(runIndex, false);
|
|
359
|
+
if (this.lastMessage) this.updateContent(this.lastMessage);
|
|
360
|
+
return { handled: true };
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (hasVisibleContentAfter) {
|
|
365
|
+
this.contentContainer.addChild(new Spacer(1));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Stop-reason handling — 0.84.3 pi shape. `hasToolCalls` is required by
|
|
371
|
+
// the component's render() for OSC-133 prompt zones, so it must be set.
|
|
372
|
+
const hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
|
|
373
|
+
this.hasToolCalls = hasToolCalls;
|
|
374
|
+
|
|
375
|
+
if (message.stopReason === "length") {
|
|
376
|
+
this.contentContainer.addChild(new Spacer(1));
|
|
377
|
+
const t = ensureTheme();
|
|
378
|
+
if (t)
|
|
379
|
+
this.contentContainer.addChild(
|
|
380
|
+
new Text(t.fg("error", "Response was truncated before completion."), this.outputPad ?? 1, 0),
|
|
381
|
+
);
|
|
382
|
+
} else if (!hasToolCalls) {
|
|
383
|
+
if (message.stopReason === "aborted") {
|
|
384
|
+
const abortMessage =
|
|
385
|
+
message.errorMessage && message.errorMessage !== "Request was aborted"
|
|
386
|
+
? message.errorMessage
|
|
387
|
+
: "Operation aborted";
|
|
388
|
+
this.contentContainer.addChild(new Spacer(1));
|
|
389
|
+
const t = ensureTheme();
|
|
390
|
+
if (t) this.contentContainer.addChild(new Text(t.fg("error", abortMessage), this.outputPad ?? 1, 0));
|
|
391
|
+
} else if (message.stopReason === "error") {
|
|
392
|
+
const errorMsg = message.errorMessage || "Unknown error";
|
|
393
|
+
this.contentContainer.addChild(new Spacer(1));
|
|
394
|
+
const t = ensureTheme();
|
|
395
|
+
if (t) {
|
|
396
|
+
this.contentContainer.addChild(
|
|
397
|
+
new Text(t.fg("error", `Error: ${errorMsg}`), this.outputPad ?? 1, 0),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// Mark as patched with version for incompatibility detection
|
|
405
|
+
(proto as any)[PATCHED_KEY] = true;
|
|
406
|
+
(proto as any)[PATCH_VERSION_KEY] = currentVersion;
|
|
407
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
import { dimAnsiLine, buildMutedMarkdownTheme } from "./theme.js";
|
|
4
|
+
import { stripAnsi } from "@pi-archimedes/core/text";
|
|
5
|
+
|
|
6
|
+
// ── dimAnsiLine ──────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe("dimAnsiLine", () => {
|
|
9
|
+
const makeCache = () => new Map<string, string>();
|
|
10
|
+
|
|
11
|
+
it("dims a single truecolor fg escape", () => {
|
|
12
|
+
const cache = makeCache();
|
|
13
|
+
const input = "\x1b[38;2;255;128;64mhello";
|
|
14
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
15
|
+
// Output should still be a truecolor fg escape (38;2;...)
|
|
16
|
+
expect(result).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
|
|
17
|
+
// But the color should be different (dimmed)
|
|
18
|
+
expect(result).not.toBe(input);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("dims multiple fg escapes in one line", () => {
|
|
22
|
+
const cache = makeCache();
|
|
23
|
+
const input = "\x1b[38;2;255;0;0mred\x1b[38;2;0;0;255mblue";
|
|
24
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
25
|
+
// Both escapes should be rewritten to dimmed versions
|
|
26
|
+
expect(result).not.toContain("\x1b[38;2;255;0;0m");
|
|
27
|
+
expect(result).not.toContain("\x1b[38;2;0;0;255m");
|
|
28
|
+
// Text content preserved
|
|
29
|
+
expect(stripAnsi(result)).toBe("redblue");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("leaves lines with no fg escapes unchanged", () => {
|
|
33
|
+
const cache = makeCache();
|
|
34
|
+
const input = "plain text without escapes";
|
|
35
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
36
|
+
expect(result).toBe(input);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("preserves non-fg escapes (bold, italic, reset)", () => {
|
|
40
|
+
const cache = makeCache();
|
|
41
|
+
const input = "\x1b[1mbold\x1b[0m \x1b[3mitalic\x1b[23m";
|
|
42
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
43
|
+
expect(result).toContain("\x1b[1m");
|
|
44
|
+
expect(result).toContain("\x1b[0m");
|
|
45
|
+
expect(result).toContain("\x1b[3m");
|
|
46
|
+
expect(result).toContain("\x1b[23m");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("cache hit returns same result", () => {
|
|
50
|
+
const cache = makeCache();
|
|
51
|
+
const escape = "\x1b[38;2;200;100;50m";
|
|
52
|
+
const input = `${escape}text`;
|
|
53
|
+
const first = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
54
|
+
const second = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
55
|
+
expect(first).toBe(second);
|
|
56
|
+
// Cache should have the entry
|
|
57
|
+
expect(cache.has(escape)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("passes through unrecognized escapes unchanged", () => {
|
|
61
|
+
const cache = makeCache();
|
|
62
|
+
// A bg escape (48;2;...) should not be matched by FG_COLOR_ESCAPE_RE
|
|
63
|
+
const input = "\x1b[48;2;10;10;10mbg text";
|
|
64
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
65
|
+
expect(result).toBe(input);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("dims 256-palette fg escapes to truecolor", () => {
|
|
69
|
+
const cache = makeCache();
|
|
70
|
+
const input = "\x1b[38;5;196mred"; // 256-palette red
|
|
71
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
72
|
+
// Output should be truecolor (38;2;...), not 256-palette
|
|
73
|
+
expect(result).toMatch(/\x1b\[38;2;\d+;\d+;\d+mred/);
|
|
74
|
+
expect(result).not.toContain("\x1b[38;5;");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("handles empty string", () => {
|
|
78
|
+
const cache = makeCache();
|
|
79
|
+
expect(dimAnsiLine("", 0.4, 0.5, cache)).toBe("");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("property: preserves non-ANSI text content", () => {
|
|
83
|
+
fc.assert(
|
|
84
|
+
fc.property(
|
|
85
|
+
fc.string({ maxLength: 200 }),
|
|
86
|
+
(s) => {
|
|
87
|
+
const cache = makeCache();
|
|
88
|
+
const result = dimAnsiLine(s, 0.4, 0.5, cache);
|
|
89
|
+
return stripAnsi(s) === stripAnsi(result);
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("property: never introduces 38;5; escapes (only produces truecolor 38;2;)", () => {
|
|
96
|
+
fc.assert(
|
|
97
|
+
fc.property(
|
|
98
|
+
fc.string({ maxLength: 200 }),
|
|
99
|
+
(s) => {
|
|
100
|
+
const cache = makeCache();
|
|
101
|
+
const result = dimAnsiLine(s, 0.4, 0.5, cache);
|
|
102
|
+
// Any 38;5; in output must have been in the input
|
|
103
|
+
// (dimAnsiLine only produces 38;2; output)
|
|
104
|
+
const inputHas256 = s.includes("38;5;");
|
|
105
|
+
const outputHas256 = result.includes("38;5;");
|
|
106
|
+
// If input didn't have 38;5;, output shouldn't either
|
|
107
|
+
// (because we only dim via truecolor)
|
|
108
|
+
if (!inputHas256) {
|
|
109
|
+
return !outputHas256;
|
|
110
|
+
}
|
|
111
|
+
// If input had 38;5;, they get replaced with 38;2;
|
|
112
|
+
// So output should NOT have 38;5; either
|
|
113
|
+
return !outputHas256;
|
|
114
|
+
},
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── buildMutedMarkdownTheme ──────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
describe("buildMutedMarkdownTheme", () => {
|
|
123
|
+
const mockTheme = {
|
|
124
|
+
fg: (token: string, text: string) => `\x1b[38;2;180;180;180m${text}\x1b[0m`,
|
|
125
|
+
getFgAnsi: (token: string) => "\x1b[38;2;180;180;180m",
|
|
126
|
+
italic: (text: string) => `\x1b[3m${text}\x1b[23m`,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
it("returns MarkdownTheme with all required fields", () => {
|
|
130
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
131
|
+
expect(typeof theme.codeBlockIndent).toBe("string");
|
|
132
|
+
expect(typeof theme.heading).toBe("function");
|
|
133
|
+
expect(typeof theme.link).toBe("function");
|
|
134
|
+
expect(typeof theme.linkUrl).toBe("function");
|
|
135
|
+
expect(typeof theme.code).toBe("function");
|
|
136
|
+
expect(typeof theme.codeBlock).toBe("function");
|
|
137
|
+
expect(typeof theme.codeBlockBorder).toBe("function");
|
|
138
|
+
expect(typeof theme.quote).toBe("function");
|
|
139
|
+
expect(typeof theme.quoteBorder).toBe("function");
|
|
140
|
+
expect(typeof theme.hr).toBe("function");
|
|
141
|
+
expect(typeof theme.listBullet).toBe("function");
|
|
142
|
+
expect(typeof theme.bold).toBe("function");
|
|
143
|
+
expect(typeof theme.italic).toBe("function");
|
|
144
|
+
expect(typeof theme.strikethrough).toBe("function");
|
|
145
|
+
expect(typeof theme.underline).toBe("function");
|
|
146
|
+
expect(typeof theme.highlightCode).toBe("function");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("heading uses gold color (#FFD700)", () => {
|
|
150
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
151
|
+
const result = theme.heading("Title");
|
|
152
|
+
// Gold is #FFD700 = rgb(255, 215, 0)
|
|
153
|
+
expect(result).toContain("\x1b[38;2;255;215;0m");
|
|
154
|
+
expect(result).toContain("Title");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("bold uses gold color (#FFD700)", () => {
|
|
158
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
159
|
+
const result = theme.bold("Strong");
|
|
160
|
+
expect(result).toContain("\x1b[38;2;255;215;0m");
|
|
161
|
+
expect(result).toContain("Strong");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("codeBlock uses thinkingText", () => {
|
|
165
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
166
|
+
const result = theme.codeBlock("code here");
|
|
167
|
+
expect(result).toContain("code here");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("italic wraps with ANSI italic codes", () => {
|
|
171
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
172
|
+
const result = theme.italic("slanted");
|
|
173
|
+
expect(result).toContain("\x1b[3m");
|
|
174
|
+
expect(result).toContain("\x1b[23m");
|
|
175
|
+
expect(result).toContain("slanted");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("codeBlockIndent is empty string", () => {
|
|
179
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
180
|
+
expect(theme.codeBlockIndent).toBe("");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("highlightCode returns array of strings", () => {
|
|
184
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
185
|
+
const result = theme.highlightCode!("const x = 1;", "javascript");
|
|
186
|
+
expect(Array.isArray(result)).toBe(true);
|
|
187
|
+
expect(result.length).toBeGreaterThan(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("highlightCode dims ANSI escapes in output", () => {
|
|
191
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
192
|
+
const result = theme.highlightCode!("const x = 1;", "javascript");
|
|
193
|
+
// Output should contain dimmed truecolor escapes
|
|
194
|
+
const joined = result.join("\n");
|
|
195
|
+
expect(joined).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("different saturationFactor produces different dimmed output", () => {
|
|
199
|
+
const cache = new Map<string, string>();
|
|
200
|
+
const input = "\x1b[38;2;255;128;64mhello";
|
|
201
|
+
const resultLow = dimAnsiLine(input, 0.4, 0.3, cache);
|
|
202
|
+
const cache2 = new Map<string, string>();
|
|
203
|
+
const resultHigh = dimAnsiLine(input, 0.4, 0.7, cache2);
|
|
204
|
+
// Same input with different saturation factors must produce different escapes
|
|
205
|
+
expect(resultLow).not.toBe(resultHigh);
|
|
206
|
+
// Both must still be valid truecolor fg escapes
|
|
207
|
+
expect(resultLow).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
|
|
208
|
+
expect(resultHigh).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("accepts custom codeDefaultLightness", () => {
|
|
212
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any, { codeDefaultLightness: 0.9 });
|
|
213
|
+
const result = theme.highlightCode!("test", "plaintext");
|
|
214
|
+
expect(Array.isArray(result)).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
});
|