mini-coder 0.5.9 → 0.5.11
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/README.md +1 -1
- package/package.json +1 -1
- package/src/agent.ts +5 -1
- package/src/input.ts +1 -1
- package/src/session.ts +15 -2
- package/src/ui/commands.test.ts +40 -289
- package/src/ui/commands.ts +7 -2
- package/src/ui/conversation.ts +18 -4
- package/src/ui/help.ts +47 -33
- package/src/ui.ts +8 -2
- package/src/ui/agent.test.ts +0 -49
- package/src/ui/conversation.test.ts +0 -1435
- package/src/ui/help.test.ts +0 -50
- package/src/ui/overlay.test.ts +0 -42
- package/src/ui/render-performance.test.ts +0 -444
- package/src/ui/status.test.ts +0 -489
|
@@ -1,1435 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
cel,
|
|
4
|
-
MockTerminal,
|
|
5
|
-
measureContentHeight,
|
|
6
|
-
Text,
|
|
7
|
-
VStack,
|
|
8
|
-
} from "@cel-tui/core";
|
|
9
|
-
import type { Node } from "@cel-tui/types";
|
|
10
|
-
import {
|
|
11
|
-
fauxAssistantMessage,
|
|
12
|
-
fauxText,
|
|
13
|
-
fauxThinking,
|
|
14
|
-
fauxToolCall,
|
|
15
|
-
} from "@mariozechner/pi-ai";
|
|
16
|
-
import { DEFAULT_THEME } from "../theme.ts";
|
|
17
|
-
import {
|
|
18
|
-
buildConversationLogNodes,
|
|
19
|
-
type PendingToolResult,
|
|
20
|
-
renderAssistantMessage,
|
|
21
|
-
renderToolResult,
|
|
22
|
-
resetConversationRenderCache,
|
|
23
|
-
} from "./conversation.ts";
|
|
24
|
-
|
|
25
|
-
const PREVIEW_WIDTH = 32;
|
|
26
|
-
const RENDER_OPTS = {
|
|
27
|
-
showReasoning: false,
|
|
28
|
-
verbose: false,
|
|
29
|
-
theme: DEFAULT_THEME,
|
|
30
|
-
previewWidth: PREVIEW_WIDTH,
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
function collectText(node: Node | null): string[] {
|
|
34
|
-
if (!node) {
|
|
35
|
-
return [];
|
|
36
|
-
}
|
|
37
|
-
if (node.type === "text") {
|
|
38
|
-
return [node.content];
|
|
39
|
-
}
|
|
40
|
-
if (node.type === "textinput") {
|
|
41
|
-
return [];
|
|
42
|
-
}
|
|
43
|
-
if (
|
|
44
|
-
node.type === "hstack" &&
|
|
45
|
-
node.children.every((child) => child.type === "text")
|
|
46
|
-
) {
|
|
47
|
-
return [node.children.map((child) => child.content).join("")];
|
|
48
|
-
}
|
|
49
|
-
return node.children.flatMap((child) => collectText(child));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function measureRenderedHeight(node: Node | null, width: number): number {
|
|
53
|
-
if (!node) {
|
|
54
|
-
return 0;
|
|
55
|
-
}
|
|
56
|
-
return measureContentHeight(VStack({}, [node]), { width });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function waitForCelRender(): Promise<void> {
|
|
60
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function renderBufferRows(
|
|
64
|
-
node: Node | null,
|
|
65
|
-
cols = PREVIEW_WIDTH,
|
|
66
|
-
rows = 24,
|
|
67
|
-
): Promise<
|
|
68
|
-
Array<{
|
|
69
|
-
text: string;
|
|
70
|
-
fgColors: Array<string | null>;
|
|
71
|
-
bold: boolean[];
|
|
72
|
-
italic: boolean[];
|
|
73
|
-
underline: boolean[];
|
|
74
|
-
}>
|
|
75
|
-
> {
|
|
76
|
-
if (!node) {
|
|
77
|
-
return [];
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const terminal = new MockTerminal(cols, rows);
|
|
81
|
-
cel.init(terminal);
|
|
82
|
-
cel.viewport(() => VStack({ width: cols, height: rows }, [node]));
|
|
83
|
-
await waitForCelRender();
|
|
84
|
-
|
|
85
|
-
const buffer = cel._getBuffer();
|
|
86
|
-
if (!buffer) {
|
|
87
|
-
throw new Error("Expected cel-tui to produce a render buffer");
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const snapshot: Array<{
|
|
91
|
-
text: string;
|
|
92
|
-
fgColors: Array<string | null>;
|
|
93
|
-
bold: boolean[];
|
|
94
|
-
italic: boolean[];
|
|
95
|
-
underline: boolean[];
|
|
96
|
-
}> = [];
|
|
97
|
-
for (let y = 0; y < rows; y++) {
|
|
98
|
-
let text = "";
|
|
99
|
-
const fgColors: Array<string | null> = [];
|
|
100
|
-
const bold: boolean[] = [];
|
|
101
|
-
const italic: boolean[] = [];
|
|
102
|
-
const underline: boolean[] = [];
|
|
103
|
-
for (let x = 0; x < cols; x++) {
|
|
104
|
-
const cell = buffer.get(x, y);
|
|
105
|
-
text += cell.char;
|
|
106
|
-
fgColors.push(cell.fgColor);
|
|
107
|
-
bold.push(cell.bold);
|
|
108
|
-
italic.push(cell.italic);
|
|
109
|
-
underline.push(cell.underline);
|
|
110
|
-
}
|
|
111
|
-
snapshot.push({ text, fgColors, bold, italic, underline });
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
cel.stop();
|
|
115
|
-
return snapshot;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function renderVisibleText(
|
|
119
|
-
node: Node | null,
|
|
120
|
-
cols = PREVIEW_WIDTH,
|
|
121
|
-
rows = 24,
|
|
122
|
-
): Promise<string[]> {
|
|
123
|
-
const snapshot = await renderBufferRows(node, cols, rows);
|
|
124
|
-
const lines: string[] = [];
|
|
125
|
-
|
|
126
|
-
for (const row of snapshot) {
|
|
127
|
-
const normalized = row.text.trim().replace(/^│\s*/, "");
|
|
128
|
-
if (normalized !== "") {
|
|
129
|
-
lines.push(normalized);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return lines;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
afterEach(() => {
|
|
137
|
-
resetConversationRenderCache();
|
|
138
|
-
cel.stop();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
describe("ui/conversation", () => {
|
|
142
|
-
test("renderAssistantMessage keeps raw markdown markers visible in assistant text", () => {
|
|
143
|
-
// Arrange
|
|
144
|
-
const message = fauxAssistantMessage(
|
|
145
|
-
"# Heading\n\nUse **bold** and `code`.\n- item",
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
// Act
|
|
149
|
-
const text = collectText(renderAssistantMessage(message, RENDER_OPTS));
|
|
150
|
-
|
|
151
|
-
// Assert
|
|
152
|
-
expect(text).toContain("# Heading");
|
|
153
|
-
expect(text).toContain("Use **bold** and `code`.");
|
|
154
|
-
expect(text).toContain("- item");
|
|
155
|
-
expect(text).not.toContain("Use bold and code.");
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
test("renderAssistantMessage with reasoning enabled shows thinking blocks", () => {
|
|
159
|
-
// Arrange
|
|
160
|
-
const message = fauxAssistantMessage([
|
|
161
|
-
fauxThinking("I should inspect the tests first."),
|
|
162
|
-
fauxText("Done."),
|
|
163
|
-
]);
|
|
164
|
-
|
|
165
|
-
// Act
|
|
166
|
-
const text = collectText(
|
|
167
|
-
renderAssistantMessage(message, {
|
|
168
|
-
...RENDER_OPTS,
|
|
169
|
-
showReasoning: true,
|
|
170
|
-
}),
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
// Assert
|
|
174
|
-
expect(text).toContain("I should inspect the tests first.");
|
|
175
|
-
expect(text).toContain("Done.");
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("renderAssistantMessage with reasoning hidden shows a thinking line-count placeholder", () => {
|
|
179
|
-
// Arrange
|
|
180
|
-
const message = fauxAssistantMessage([
|
|
181
|
-
fauxThinking("line one\nline two\nline three"),
|
|
182
|
-
fauxText("Done."),
|
|
183
|
-
]);
|
|
184
|
-
|
|
185
|
-
// Act
|
|
186
|
-
const text = collectText(
|
|
187
|
-
renderAssistantMessage(message, {
|
|
188
|
-
...RENDER_OPTS,
|
|
189
|
-
showReasoning: false,
|
|
190
|
-
}),
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
// Assert
|
|
194
|
-
expect(text).toContain("Thinking... 3 lines.");
|
|
195
|
-
expect(text).toContain("Done.");
|
|
196
|
-
expect(text).not.toContain("line one");
|
|
197
|
-
expect(text).not.toContain("line two");
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test("renderAssistantMessage with mixed top-level blocks keeps a single blank line between sections", () => {
|
|
201
|
-
// Arrange
|
|
202
|
-
const message = fauxAssistantMessage([
|
|
203
|
-
fauxThinking("Plan first."),
|
|
204
|
-
fauxText("Done."),
|
|
205
|
-
fauxToolCall("shell", { command: "echo hi" }, { id: "tool-1" }),
|
|
206
|
-
]);
|
|
207
|
-
|
|
208
|
-
// Act
|
|
209
|
-
const height = measureRenderedHeight(
|
|
210
|
-
renderAssistantMessage(message, {
|
|
211
|
-
...RENDER_OPTS,
|
|
212
|
-
showReasoning: true,
|
|
213
|
-
}),
|
|
214
|
-
PREVIEW_WIDTH,
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
// Assert
|
|
218
|
-
expect(height).toBe(6);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
test("renderAssistantMessage syntax-highlights markdown tokens with theme-derived colors", async () => {
|
|
222
|
-
// Arrange
|
|
223
|
-
const theme = {
|
|
224
|
-
...DEFAULT_THEME,
|
|
225
|
-
accentText: "color14",
|
|
226
|
-
secondaryAccentText: "color09",
|
|
227
|
-
diffAdded: "color10",
|
|
228
|
-
mutedText: "color13",
|
|
229
|
-
} satisfies typeof DEFAULT_THEME;
|
|
230
|
-
const message = fauxAssistantMessage("# Heading\n- item\n> quote\n`code`");
|
|
231
|
-
|
|
232
|
-
// Act
|
|
233
|
-
const rows = await renderBufferRows(
|
|
234
|
-
renderAssistantMessage(message, {
|
|
235
|
-
...RENDER_OPTS,
|
|
236
|
-
theme,
|
|
237
|
-
}),
|
|
238
|
-
32,
|
|
239
|
-
12,
|
|
240
|
-
);
|
|
241
|
-
const headingRow = rows.find((row) => row.text.includes("# Heading"));
|
|
242
|
-
const bulletRow = rows.find((row) => row.text.includes("- item"));
|
|
243
|
-
const quoteRow = rows.find((row) => row.text.includes("> quote"));
|
|
244
|
-
const codeRow = rows.find((row) => row.text.includes("`code`"));
|
|
245
|
-
|
|
246
|
-
// Assert
|
|
247
|
-
expect(headingRow).toBeDefined();
|
|
248
|
-
expect(bulletRow).toBeDefined();
|
|
249
|
-
expect(quoteRow).toBeDefined();
|
|
250
|
-
expect(codeRow).toBeDefined();
|
|
251
|
-
expect(headingRow?.fgColors[headingRow.text.indexOf("#")]).toBe(
|
|
252
|
-
theme.accentText ?? null,
|
|
253
|
-
);
|
|
254
|
-
expect(bulletRow?.fgColors[bulletRow.text.indexOf("-")]).toBe(
|
|
255
|
-
theme.secondaryAccentText ?? null,
|
|
256
|
-
);
|
|
257
|
-
expect(quoteRow?.fgColors[quoteRow.text.indexOf(">")]).toBe(
|
|
258
|
-
theme.mutedText ?? null,
|
|
259
|
-
);
|
|
260
|
-
expect(quoteRow?.italic[quoteRow.text.indexOf(">")]).toBe(true);
|
|
261
|
-
expect(codeRow?.fgColors[codeRow.text.indexOf("`")]).toBe(
|
|
262
|
-
theme.diffAdded ?? null,
|
|
263
|
-
);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
test("renderAssistantMessage syntax-highlights markdown emphasis and links with style cues", async () => {
|
|
267
|
-
// Arrange
|
|
268
|
-
const theme = {
|
|
269
|
-
...DEFAULT_THEME,
|
|
270
|
-
accentText: "color14",
|
|
271
|
-
diffAdded: "color10",
|
|
272
|
-
} satisfies typeof DEFAULT_THEME;
|
|
273
|
-
const message = fauxAssistantMessage(
|
|
274
|
-
"*italic* **bold** [label](https://example.com)",
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
// Act
|
|
278
|
-
const rows = await renderBufferRows(
|
|
279
|
-
renderAssistantMessage(message, {
|
|
280
|
-
...RENDER_OPTS,
|
|
281
|
-
theme,
|
|
282
|
-
}),
|
|
283
|
-
64,
|
|
284
|
-
12,
|
|
285
|
-
);
|
|
286
|
-
const contentRow = rows.find((row) =>
|
|
287
|
-
row.text.includes("*italic* **bold** [label](https://example.com)"),
|
|
288
|
-
);
|
|
289
|
-
|
|
290
|
-
// Assert
|
|
291
|
-
expect(contentRow).toBeDefined();
|
|
292
|
-
expect(contentRow?.italic[contentRow.text.indexOf("*italic*")]).toBe(true);
|
|
293
|
-
expect(contentRow?.bold[contentRow.text.indexOf("**bold**")]).toBe(true);
|
|
294
|
-
expect(contentRow?.fgColors[contentRow.text.indexOf("label")]).toBe(
|
|
295
|
-
theme.diffAdded ?? null,
|
|
296
|
-
);
|
|
297
|
-
expect(
|
|
298
|
-
contentRow?.fgColors[contentRow.text.indexOf("https://example.com")],
|
|
299
|
-
).toBe(theme.accentText ?? null);
|
|
300
|
-
expect(
|
|
301
|
-
contentRow?.underline[contentRow.text.indexOf("https://example.com")],
|
|
302
|
-
).toBe(true);
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
test("renderAssistantMessage with adjacent text blocks preserves markdown structure across the block boundary", async () => {
|
|
306
|
-
// Arrange
|
|
307
|
-
const singleBlock = {
|
|
308
|
-
content: [fauxText("```js\nconst x = 1;\n```")],
|
|
309
|
-
};
|
|
310
|
-
const splitBlocks = {
|
|
311
|
-
content: [fauxText("```js\n"), fauxText("const x = 1;\n```")],
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
// Act
|
|
315
|
-
const singleRows = await renderBufferRows(
|
|
316
|
-
renderAssistantMessage(singleBlock, RENDER_OPTS),
|
|
317
|
-
40,
|
|
318
|
-
12,
|
|
319
|
-
);
|
|
320
|
-
const splitRows = await renderBufferRows(
|
|
321
|
-
renderAssistantMessage(splitBlocks, RENDER_OPTS),
|
|
322
|
-
40,
|
|
323
|
-
12,
|
|
324
|
-
);
|
|
325
|
-
const singleHeight = measureRenderedHeight(
|
|
326
|
-
renderAssistantMessage(singleBlock, RENDER_OPTS),
|
|
327
|
-
40,
|
|
328
|
-
);
|
|
329
|
-
const splitHeight = measureRenderedHeight(
|
|
330
|
-
renderAssistantMessage(splitBlocks, RENDER_OPTS),
|
|
331
|
-
40,
|
|
332
|
-
);
|
|
333
|
-
const singleRow = singleRows.find((row) =>
|
|
334
|
-
row.text.includes("const x = 1;"),
|
|
335
|
-
);
|
|
336
|
-
const splitRow = splitRows.find((row) => row.text.includes("const x = 1;"));
|
|
337
|
-
|
|
338
|
-
// Assert
|
|
339
|
-
expect(singleRow).toBeDefined();
|
|
340
|
-
expect(splitRow).toBeDefined();
|
|
341
|
-
expect(singleHeight).toBe(splitHeight);
|
|
342
|
-
expect(singleRow?.fgColors[singleRow.text.indexOf("const")]).not.toBe(null);
|
|
343
|
-
expect(splitRow?.fgColors[splitRow.text.indexOf("const")]).toBe(
|
|
344
|
-
singleRow?.fgColors[singleRow.text.indexOf("const")],
|
|
345
|
-
);
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
test("buildConversationLogNodes with a pending shell result keeps the streamed call and result append-only", () => {
|
|
349
|
-
// Arrange
|
|
350
|
-
const pendingToolResults: PendingToolResult[] = [
|
|
351
|
-
{
|
|
352
|
-
toolCallId: "tool-1",
|
|
353
|
-
toolName: "shell",
|
|
354
|
-
content: [{ type: "text", text: "Exit code: 0\npartial output" }],
|
|
355
|
-
isError: false,
|
|
356
|
-
},
|
|
357
|
-
];
|
|
358
|
-
|
|
359
|
-
// Act
|
|
360
|
-
const nodes = buildConversationLogNodes(
|
|
361
|
-
{
|
|
362
|
-
messages: [
|
|
363
|
-
fauxAssistantMessage([
|
|
364
|
-
fauxText("Working..."),
|
|
365
|
-
fauxToolCall("shell", { command: "echo hi" }, { id: "tool-1" }),
|
|
366
|
-
]),
|
|
367
|
-
],
|
|
368
|
-
showReasoning: false,
|
|
369
|
-
verbose: false,
|
|
370
|
-
theme: DEFAULT_THEME,
|
|
371
|
-
},
|
|
372
|
-
{
|
|
373
|
-
isStreaming: true,
|
|
374
|
-
content: [],
|
|
375
|
-
pendingToolResults,
|
|
376
|
-
},
|
|
377
|
-
0,
|
|
378
|
-
PREVIEW_WIDTH,
|
|
379
|
-
);
|
|
380
|
-
const text = collectText({
|
|
381
|
-
type: "vstack",
|
|
382
|
-
props: {},
|
|
383
|
-
children: nodes,
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
// Assert
|
|
387
|
-
expect(text).toContain("Working...");
|
|
388
|
-
expect(text.filter((line) => line === "shell ->")).toHaveLength(1);
|
|
389
|
-
expect(text).toContain("echo hi");
|
|
390
|
-
expect(text).toContain("shell <-");
|
|
391
|
-
expect(text).toContain("partial output");
|
|
392
|
-
expect(text).not.toContain("Exit code: 0");
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
test("buildConversationLogNodes with a sliced window keeps hidden tool-call args available for compact edit results", () => {
|
|
396
|
-
// Arrange
|
|
397
|
-
const nodes = buildConversationLogNodes(
|
|
398
|
-
{
|
|
399
|
-
messages: [
|
|
400
|
-
fauxAssistantMessage([
|
|
401
|
-
fauxToolCall(
|
|
402
|
-
"edit",
|
|
403
|
-
{
|
|
404
|
-
path: "src/app.ts",
|
|
405
|
-
oldText: "before",
|
|
406
|
-
newText: "after",
|
|
407
|
-
},
|
|
408
|
-
{ id: "tool-1" },
|
|
409
|
-
),
|
|
410
|
-
]),
|
|
411
|
-
{
|
|
412
|
-
role: "toolResult" as const,
|
|
413
|
-
toolCallId: "tool-1",
|
|
414
|
-
toolName: "edit",
|
|
415
|
-
content: [{ type: "text" as const, text: "Edited src/app.ts" }],
|
|
416
|
-
isError: false,
|
|
417
|
-
timestamp: Date.now(),
|
|
418
|
-
},
|
|
419
|
-
],
|
|
420
|
-
showReasoning: false,
|
|
421
|
-
verbose: false,
|
|
422
|
-
theme: DEFAULT_THEME,
|
|
423
|
-
},
|
|
424
|
-
{
|
|
425
|
-
isStreaming: false,
|
|
426
|
-
content: [],
|
|
427
|
-
pendingToolResults: [],
|
|
428
|
-
},
|
|
429
|
-
1,
|
|
430
|
-
PREVIEW_WIDTH,
|
|
431
|
-
);
|
|
432
|
-
const text = collectText({
|
|
433
|
-
type: "vstack",
|
|
434
|
-
props: {},
|
|
435
|
-
children: nodes,
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
// Assert
|
|
439
|
-
expect(text).toContain("edit <-");
|
|
440
|
-
expect(text).toContain("~ src/app.ts");
|
|
441
|
-
expect(text).not.toContain("before");
|
|
442
|
-
expect(text).not.toContain("after");
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
test("buildConversationLogNodes with unchanged state reuses cached committed nodes", () => {
|
|
446
|
-
// Arrange
|
|
447
|
-
const state = {
|
|
448
|
-
messages: [fauxAssistantMessage("Committed response")],
|
|
449
|
-
showReasoning: false,
|
|
450
|
-
verbose: false,
|
|
451
|
-
theme: DEFAULT_THEME,
|
|
452
|
-
};
|
|
453
|
-
const streaming = {
|
|
454
|
-
isStreaming: false,
|
|
455
|
-
content: [],
|
|
456
|
-
pendingToolResults: [],
|
|
457
|
-
};
|
|
458
|
-
|
|
459
|
-
// Act
|
|
460
|
-
const first = buildConversationLogNodes(state, streaming, 0, PREVIEW_WIDTH);
|
|
461
|
-
const second = buildConversationLogNodes(
|
|
462
|
-
state,
|
|
463
|
-
streaming,
|
|
464
|
-
0,
|
|
465
|
-
PREVIEW_WIDTH,
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
// Assert
|
|
469
|
-
expect(second).toBe(first);
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
test("buildConversationLogNodes with only a new streaming tail reuses the committed prefix", () => {
|
|
473
|
-
// Arrange
|
|
474
|
-
const state = {
|
|
475
|
-
messages: [fauxAssistantMessage("Committed response")],
|
|
476
|
-
showReasoning: false,
|
|
477
|
-
verbose: false,
|
|
478
|
-
theme: DEFAULT_THEME,
|
|
479
|
-
};
|
|
480
|
-
|
|
481
|
-
// Act
|
|
482
|
-
const committed = buildConversationLogNodes(
|
|
483
|
-
state,
|
|
484
|
-
{
|
|
485
|
-
isStreaming: false,
|
|
486
|
-
content: [],
|
|
487
|
-
pendingToolResults: [],
|
|
488
|
-
},
|
|
489
|
-
0,
|
|
490
|
-
PREVIEW_WIDTH,
|
|
491
|
-
);
|
|
492
|
-
const withStreamingTail = buildConversationLogNodes(
|
|
493
|
-
state,
|
|
494
|
-
{
|
|
495
|
-
isStreaming: true,
|
|
496
|
-
content: [fauxText("Streaming tail")],
|
|
497
|
-
pendingToolResults: [],
|
|
498
|
-
},
|
|
499
|
-
0,
|
|
500
|
-
PREVIEW_WIDTH,
|
|
501
|
-
);
|
|
502
|
-
const text = collectText({
|
|
503
|
-
type: "vstack",
|
|
504
|
-
props: {},
|
|
505
|
-
children: withStreamingTail,
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
// Assert
|
|
509
|
-
expect(withStreamingTail[0]).toBe(committed[0]);
|
|
510
|
-
expect(text).toContain("Committed response");
|
|
511
|
-
expect(text).toContain("Streaming tail");
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
test("buildConversationLogNodes when verbose mode changes rebuilds cached tool nodes", async () => {
|
|
515
|
-
// Arrange
|
|
516
|
-
const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
|
|
517
|
-
"\n",
|
|
518
|
-
);
|
|
519
|
-
const state = {
|
|
520
|
-
messages: [
|
|
521
|
-
fauxAssistantMessage([
|
|
522
|
-
fauxToolCall("shell", { command: "seq 1 25" }, { id: "tool-1" }),
|
|
523
|
-
]),
|
|
524
|
-
{
|
|
525
|
-
role: "toolResult" as const,
|
|
526
|
-
toolCallId: "tool-1",
|
|
527
|
-
toolName: "shell",
|
|
528
|
-
content: [{ type: "text" as const, text: output }],
|
|
529
|
-
isError: false,
|
|
530
|
-
timestamp: Date.now(),
|
|
531
|
-
},
|
|
532
|
-
],
|
|
533
|
-
showReasoning: false,
|
|
534
|
-
verbose: false,
|
|
535
|
-
theme: DEFAULT_THEME,
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
// Act
|
|
539
|
-
const previewNodes = buildConversationLogNodes(
|
|
540
|
-
state,
|
|
541
|
-
{
|
|
542
|
-
isStreaming: false,
|
|
543
|
-
content: [],
|
|
544
|
-
pendingToolResults: [],
|
|
545
|
-
},
|
|
546
|
-
0,
|
|
547
|
-
PREVIEW_WIDTH,
|
|
548
|
-
);
|
|
549
|
-
const previewText = await renderVisibleText(
|
|
550
|
-
VStack({}, previewNodes),
|
|
551
|
-
PREVIEW_WIDTH,
|
|
552
|
-
24,
|
|
553
|
-
);
|
|
554
|
-
|
|
555
|
-
const verboseNodes = buildConversationLogNodes(
|
|
556
|
-
{ ...state, verbose: true },
|
|
557
|
-
{
|
|
558
|
-
isStreaming: false,
|
|
559
|
-
content: [],
|
|
560
|
-
pendingToolResults: [],
|
|
561
|
-
},
|
|
562
|
-
0,
|
|
563
|
-
PREVIEW_WIDTH,
|
|
564
|
-
);
|
|
565
|
-
const verboseText = await renderVisibleText(
|
|
566
|
-
VStack({}, verboseNodes),
|
|
567
|
-
PREVIEW_WIDTH,
|
|
568
|
-
40,
|
|
569
|
-
);
|
|
570
|
-
|
|
571
|
-
// Assert
|
|
572
|
-
expect(previewNodes).not.toBe(verboseNodes);
|
|
573
|
-
expect(previewText).toContain("line 18");
|
|
574
|
-
expect(previewText).toContain("line 25");
|
|
575
|
-
expect(previewText).toContain("And 17 lines more");
|
|
576
|
-
expect(previewText).not.toContain("line 17");
|
|
577
|
-
expect(verboseText).toContain("line 17");
|
|
578
|
-
expect(verboseText).toContain("line 25");
|
|
579
|
-
expect(verboseText).not.toContain("And 17 lines more");
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
test("buildConversationLogNodes when preview width changes rebuilds cached tool nodes", () => {
|
|
583
|
-
// Arrange
|
|
584
|
-
const state = {
|
|
585
|
-
messages: [
|
|
586
|
-
fauxAssistantMessage([
|
|
587
|
-
fauxToolCall(
|
|
588
|
-
"shell",
|
|
589
|
-
{
|
|
590
|
-
command:
|
|
591
|
-
"printf 'this is a very long wrapped line that depends on width'",
|
|
592
|
-
},
|
|
593
|
-
{ id: "tool-1" },
|
|
594
|
-
),
|
|
595
|
-
]),
|
|
596
|
-
],
|
|
597
|
-
showReasoning: false,
|
|
598
|
-
verbose: false,
|
|
599
|
-
theme: DEFAULT_THEME,
|
|
600
|
-
};
|
|
601
|
-
const streaming = {
|
|
602
|
-
isStreaming: false,
|
|
603
|
-
content: [],
|
|
604
|
-
pendingToolResults: [],
|
|
605
|
-
};
|
|
606
|
-
|
|
607
|
-
// Act
|
|
608
|
-
const wide = buildConversationLogNodes(state, streaming, 0, 40);
|
|
609
|
-
const narrow = buildConversationLogNodes(state, streaming, 0, 20);
|
|
610
|
-
|
|
611
|
-
// Assert
|
|
612
|
-
expect(narrow).not.toBe(wide);
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
test("renderAssistantMessage with in-progress reasoning visible shows thinking text", () => {
|
|
616
|
-
// Arrange
|
|
617
|
-
const assistant = {
|
|
618
|
-
content: [fauxThinking("Reasoning in progress")],
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
// Act
|
|
622
|
-
const text = collectText(
|
|
623
|
-
renderAssistantMessage(assistant, {
|
|
624
|
-
...RENDER_OPTS,
|
|
625
|
-
showReasoning: true,
|
|
626
|
-
}),
|
|
627
|
-
);
|
|
628
|
-
|
|
629
|
-
// Assert
|
|
630
|
-
expect(text).toContain("Reasoning in progress");
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
test("renderAssistantMessage with in-progress reasoning hidden shows a one-line placeholder", () => {
|
|
634
|
-
// Arrange
|
|
635
|
-
const assistant = {
|
|
636
|
-
content: [fauxThinking("some thinking")],
|
|
637
|
-
};
|
|
638
|
-
|
|
639
|
-
// Act
|
|
640
|
-
const text = collectText(
|
|
641
|
-
renderAssistantMessage(assistant, {
|
|
642
|
-
...RENDER_OPTS,
|
|
643
|
-
showReasoning: false,
|
|
644
|
-
}),
|
|
645
|
-
);
|
|
646
|
-
|
|
647
|
-
// Assert
|
|
648
|
-
expect(text).toContain("Thinking... 1 line.");
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
test("renderAssistantMessage for a shell tool call renders an unbracketed header inside the pill", () => {
|
|
652
|
-
// Arrange
|
|
653
|
-
const assistant = {
|
|
654
|
-
content: [
|
|
655
|
-
fauxToolCall("shell", { command: "echo hi" }, { id: "tool-1" }),
|
|
656
|
-
],
|
|
657
|
-
};
|
|
658
|
-
|
|
659
|
-
// Act
|
|
660
|
-
const node = renderAssistantMessage(assistant, RENDER_OPTS);
|
|
661
|
-
const text = collectText(node);
|
|
662
|
-
|
|
663
|
-
// Assert
|
|
664
|
-
expect(text).toContain("shell ->");
|
|
665
|
-
expect(text).not.toContain("[shell ->]");
|
|
666
|
-
expect(text).toContain("echo hi");
|
|
667
|
-
expect(text).not.toContain('"command": "echo hi"');
|
|
668
|
-
|
|
669
|
-
expect(node?.type).toBe("vstack");
|
|
670
|
-
if (!node || node.type !== "vstack") {
|
|
671
|
-
throw new Error("Expected the assistant node to be a vstack");
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
const toolBlock = node.children[0];
|
|
675
|
-
expect(toolBlock?.type).toBe("hstack");
|
|
676
|
-
if (!toolBlock || toolBlock.type !== "hstack") {
|
|
677
|
-
throw new Error("Expected the tool block to be an hstack");
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
const contentColumn = toolBlock.children[1];
|
|
681
|
-
expect(contentColumn?.type).toBe("vstack");
|
|
682
|
-
if (!contentColumn || contentColumn.type !== "vstack") {
|
|
683
|
-
throw new Error("Expected the tool content column to be a vstack");
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
const headerRow = contentColumn.children[0];
|
|
687
|
-
expect(headerRow?.type).toBe("hstack");
|
|
688
|
-
if (!headerRow || headerRow.type !== "hstack") {
|
|
689
|
-
throw new Error("Expected the tool header row to be an hstack");
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
const headerPill = headerRow.children[0];
|
|
693
|
-
expect(headerPill?.type).toBe("hstack");
|
|
694
|
-
if (!headerPill || headerPill.type !== "hstack") {
|
|
695
|
-
throw new Error("Expected the tool header pill to be an hstack");
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
expect(headerPill.props.bgColor).toBe(DEFAULT_THEME.toolBorder);
|
|
699
|
-
expect(headerPill.props.padding).toEqual({ x: 1 });
|
|
700
|
-
expect(collectText(headerPill)).toEqual(["shell ->"]);
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
test("renderAssistantMessage for a shell tool call syntax-highlights bash tokens", async () => {
|
|
704
|
-
// Arrange
|
|
705
|
-
const assistant = {
|
|
706
|
-
content: [
|
|
707
|
-
fauxToolCall(
|
|
708
|
-
"shell",
|
|
709
|
-
{ command: 'if true; then echo "$HOME"; fi' },
|
|
710
|
-
{ id: "tool-1" },
|
|
711
|
-
),
|
|
712
|
-
],
|
|
713
|
-
};
|
|
714
|
-
|
|
715
|
-
// Act
|
|
716
|
-
const rows = await renderBufferRows(
|
|
717
|
-
renderAssistantMessage(assistant, RENDER_OPTS),
|
|
718
|
-
48,
|
|
719
|
-
12,
|
|
720
|
-
);
|
|
721
|
-
const commandRow = rows.find((row) =>
|
|
722
|
-
row.text.includes('if true; then echo "$HOME"; fi'),
|
|
723
|
-
);
|
|
724
|
-
|
|
725
|
-
// Assert
|
|
726
|
-
expect(commandRow).toBeDefined();
|
|
727
|
-
expect(commandRow?.fgColors[commandRow.text.indexOf("if")]).toBe(
|
|
728
|
-
DEFAULT_THEME.secondaryAccentText ?? null,
|
|
729
|
-
);
|
|
730
|
-
expect(commandRow?.fgColors[commandRow.text.indexOf("echo")]).toBe(
|
|
731
|
-
DEFAULT_THEME.accentText ?? null,
|
|
732
|
-
);
|
|
733
|
-
expect(commandRow?.fgColors[commandRow.text.indexOf('"$HOME"')]).toBe(
|
|
734
|
-
DEFAULT_THEME.diffAdded ?? null,
|
|
735
|
-
);
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
test("renderAssistantMessage for a multiline shell tool call preserves syntax state across lines", async () => {
|
|
739
|
-
// Arrange
|
|
740
|
-
const assistant = {
|
|
741
|
-
content: [
|
|
742
|
-
fauxToolCall(
|
|
743
|
-
"shell",
|
|
744
|
-
{ command: "printf 'foo\nbar'" },
|
|
745
|
-
{ id: "tool-1" },
|
|
746
|
-
),
|
|
747
|
-
],
|
|
748
|
-
};
|
|
749
|
-
|
|
750
|
-
// Act
|
|
751
|
-
const rows = await renderBufferRows(
|
|
752
|
-
renderAssistantMessage(assistant, {
|
|
753
|
-
...RENDER_OPTS,
|
|
754
|
-
verbose: true,
|
|
755
|
-
}),
|
|
756
|
-
32,
|
|
757
|
-
12,
|
|
758
|
-
);
|
|
759
|
-
const firstRow = rows.find((row) => row.text.includes("printf 'foo"));
|
|
760
|
-
const secondRow = rows.find((row) => row.text.includes("bar'"));
|
|
761
|
-
|
|
762
|
-
// Assert
|
|
763
|
-
expect(firstRow).toBeDefined();
|
|
764
|
-
expect(secondRow).toBeDefined();
|
|
765
|
-
expect(firstRow?.fgColors[firstRow.text.indexOf("foo")]).toBe(
|
|
766
|
-
DEFAULT_THEME.diffAdded ?? null,
|
|
767
|
-
);
|
|
768
|
-
expect(secondRow?.fgColors[secondRow.text.indexOf("bar")]).toBe(
|
|
769
|
-
DEFAULT_THEME.diffAdded ?? null,
|
|
770
|
-
);
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
test("renderAssistantMessage for a shell tool call uses theme-derived syntax colors", async () => {
|
|
774
|
-
// Arrange
|
|
775
|
-
const theme = {
|
|
776
|
-
...DEFAULT_THEME,
|
|
777
|
-
accentText: "color14",
|
|
778
|
-
secondaryAccentText: "color09",
|
|
779
|
-
diffAdded: "color10",
|
|
780
|
-
mutedText: "color13",
|
|
781
|
-
toolText: "color15",
|
|
782
|
-
} satisfies typeof DEFAULT_THEME;
|
|
783
|
-
const assistant = {
|
|
784
|
-
content: [
|
|
785
|
-
fauxToolCall(
|
|
786
|
-
"shell",
|
|
787
|
-
{ command: 'if true; then echo "$HOME"; fi' },
|
|
788
|
-
{ id: "tool-1" },
|
|
789
|
-
),
|
|
790
|
-
],
|
|
791
|
-
};
|
|
792
|
-
|
|
793
|
-
// Act
|
|
794
|
-
const rows = await renderBufferRows(
|
|
795
|
-
renderAssistantMessage(assistant, {
|
|
796
|
-
...RENDER_OPTS,
|
|
797
|
-
theme,
|
|
798
|
-
}),
|
|
799
|
-
48,
|
|
800
|
-
12,
|
|
801
|
-
);
|
|
802
|
-
const commandRow = rows.find((row) =>
|
|
803
|
-
row.text.includes('if true; then echo "$HOME"; fi'),
|
|
804
|
-
);
|
|
805
|
-
|
|
806
|
-
// Assert
|
|
807
|
-
expect(commandRow).toBeDefined();
|
|
808
|
-
expect(commandRow?.fgColors[commandRow.text.indexOf("if")]).toBe(
|
|
809
|
-
theme.secondaryAccentText ?? null,
|
|
810
|
-
);
|
|
811
|
-
expect(commandRow?.fgColors[commandRow.text.indexOf("echo")]).toBe(
|
|
812
|
-
theme.accentText ?? null,
|
|
813
|
-
);
|
|
814
|
-
expect(commandRow?.fgColors[commandRow.text.indexOf('"$HOME"')]).toBe(
|
|
815
|
-
theme.diffAdded ?? null,
|
|
816
|
-
);
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
test("renderAssistantMessage for a long single-token shell argument wraps through the tail in verbose mode", async () => {
|
|
820
|
-
// Arrange
|
|
821
|
-
const command = `printf ${"x".repeat(60)}TAIL`;
|
|
822
|
-
const assistant = {
|
|
823
|
-
content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
|
|
824
|
-
};
|
|
825
|
-
|
|
826
|
-
// Act
|
|
827
|
-
const text = await renderVisibleText(
|
|
828
|
-
renderAssistantMessage(assistant, {
|
|
829
|
-
...RENDER_OPTS,
|
|
830
|
-
verbose: true,
|
|
831
|
-
previewWidth: 24,
|
|
832
|
-
}),
|
|
833
|
-
24,
|
|
834
|
-
20,
|
|
835
|
-
);
|
|
836
|
-
|
|
837
|
-
// Assert
|
|
838
|
-
expect(text.some((line) => line.includes("TAIL"))).toBe(true);
|
|
839
|
-
});
|
|
840
|
-
|
|
841
|
-
test("renderAssistantMessage for a long single-token shell argument wraps through the tail in a narrow viewport", async () => {
|
|
842
|
-
// Arrange
|
|
843
|
-
const command = `printf ${"x".repeat(40)}TAIL`;
|
|
844
|
-
const assistant = {
|
|
845
|
-
content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
|
|
846
|
-
};
|
|
847
|
-
|
|
848
|
-
// Act
|
|
849
|
-
const text = await renderVisibleText(
|
|
850
|
-
renderAssistantMessage(assistant, {
|
|
851
|
-
...RENDER_OPTS,
|
|
852
|
-
verbose: true,
|
|
853
|
-
previewWidth: 12,
|
|
854
|
-
}),
|
|
855
|
-
12,
|
|
856
|
-
20,
|
|
857
|
-
);
|
|
858
|
-
|
|
859
|
-
// Assert
|
|
860
|
-
expect(text.some((line) => line.includes("TAIL"))).toBe(true);
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
test("renderAssistantMessage for a long quoted shell string preserves string color across wrapped rows", async () => {
|
|
864
|
-
// Arrange
|
|
865
|
-
const command = `printf "${"x".repeat(80)}TAIL"`;
|
|
866
|
-
const assistant = {
|
|
867
|
-
content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
|
|
868
|
-
};
|
|
869
|
-
|
|
870
|
-
// Act
|
|
871
|
-
const rows = await renderBufferRows(
|
|
872
|
-
renderAssistantMessage(assistant, {
|
|
873
|
-
...RENDER_OPTS,
|
|
874
|
-
verbose: true,
|
|
875
|
-
previewWidth: 24,
|
|
876
|
-
}),
|
|
877
|
-
24,
|
|
878
|
-
20,
|
|
879
|
-
);
|
|
880
|
-
const headRowIndex = rows.findIndex((row) => row.text.includes('"x'));
|
|
881
|
-
const headRow = headRowIndex >= 0 ? rows[headRowIndex] : undefined;
|
|
882
|
-
const wrappedRow =
|
|
883
|
-
headRowIndex >= 0
|
|
884
|
-
? rows
|
|
885
|
-
.slice(headRowIndex + 1)
|
|
886
|
-
.find((row) => row.text.includes("xxxxxxxx"))
|
|
887
|
-
: undefined;
|
|
888
|
-
|
|
889
|
-
// Assert
|
|
890
|
-
expect(headRow).toBeDefined();
|
|
891
|
-
expect(wrappedRow).toBeDefined();
|
|
892
|
-
expect(headRow?.fgColors[headRow.text.indexOf('"')]).toBe(
|
|
893
|
-
DEFAULT_THEME.diffAdded ?? null,
|
|
894
|
-
);
|
|
895
|
-
expect(wrappedRow?.fgColors[wrappedRow.text.indexOf("x")]).toBe(
|
|
896
|
-
DEFAULT_THEME.diffAdded ?? null,
|
|
897
|
-
);
|
|
898
|
-
});
|
|
899
|
-
|
|
900
|
-
test("renderAssistantMessage for long inline markdown code keeps the tail visible", async () => {
|
|
901
|
-
// Arrange
|
|
902
|
-
const message = fauxAssistantMessage(`Use \`${"x".repeat(80)}TAIL\``);
|
|
903
|
-
|
|
904
|
-
// Act
|
|
905
|
-
const rows = await renderBufferRows(
|
|
906
|
-
renderAssistantMessage(message, {
|
|
907
|
-
...RENDER_OPTS,
|
|
908
|
-
previewWidth: 24,
|
|
909
|
-
}),
|
|
910
|
-
24,
|
|
911
|
-
20,
|
|
912
|
-
);
|
|
913
|
-
const codeRows = rows.filter(
|
|
914
|
-
(row) => row.text.includes("x") || row.text.includes("TAIL`"),
|
|
915
|
-
);
|
|
916
|
-
|
|
917
|
-
// Assert
|
|
918
|
-
expect(codeRows.length).toBeGreaterThan(1);
|
|
919
|
-
expect(rows.some((row) => row.text.includes("TAIL`"))).toBe(true);
|
|
920
|
-
expect(codeRows[0]?.fgColors[codeRows[0].text.indexOf("x")]).toBe(
|
|
921
|
-
DEFAULT_THEME.diffAdded ?? null,
|
|
922
|
-
);
|
|
923
|
-
expect(codeRows.at(-1)?.fgColors[codeRows.at(-1)!.text.indexOf("T")]).toBe(
|
|
924
|
-
DEFAULT_THEME.diffAdded ?? null,
|
|
925
|
-
);
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
test("renderAssistantMessage for a long single-token shell command uses wrapped preview height when verbose is off", () => {
|
|
929
|
-
// Arrange
|
|
930
|
-
const command = `printf ${"x".repeat(220)}TAIL`;
|
|
931
|
-
const assistant = {
|
|
932
|
-
content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
|
|
933
|
-
};
|
|
934
|
-
|
|
935
|
-
// Act
|
|
936
|
-
const height = measureRenderedHeight(
|
|
937
|
-
renderAssistantMessage(assistant, {
|
|
938
|
-
...RENDER_OPTS,
|
|
939
|
-
previewWidth: 24,
|
|
940
|
-
}),
|
|
941
|
-
24,
|
|
942
|
-
);
|
|
943
|
-
|
|
944
|
-
// Assert
|
|
945
|
-
expect(height).toBe(9);
|
|
946
|
-
});
|
|
947
|
-
|
|
948
|
-
test("renderAssistantMessage for a wrapped shell command keeps a fixed preview height when verbose is off", () => {
|
|
949
|
-
// Arrange
|
|
950
|
-
const command = Array.from(
|
|
951
|
-
{ length: 4 },
|
|
952
|
-
() =>
|
|
953
|
-
"printf 'this wrapped command line is intentionally long for the preview'",
|
|
954
|
-
).join("\n");
|
|
955
|
-
const assistant = {
|
|
956
|
-
content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
|
|
957
|
-
};
|
|
958
|
-
|
|
959
|
-
// Act
|
|
960
|
-
const node = renderAssistantMessage(assistant, {
|
|
961
|
-
...RENDER_OPTS,
|
|
962
|
-
previewWidth: 24,
|
|
963
|
-
});
|
|
964
|
-
const height = measureRenderedHeight(node, 24);
|
|
965
|
-
|
|
966
|
-
// Assert
|
|
967
|
-
expect(height).toBe(10);
|
|
968
|
-
});
|
|
969
|
-
|
|
970
|
-
test("renderAssistantMessage for a long single-line shell command keeps the command start visible in non-verbose mode", async () => {
|
|
971
|
-
// Arrange
|
|
972
|
-
const command = `IMPORTANT_PREFIX ${"x".repeat(400)}`;
|
|
973
|
-
const assistant = {
|
|
974
|
-
content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
|
|
975
|
-
};
|
|
976
|
-
|
|
977
|
-
// Act
|
|
978
|
-
const text = await renderVisibleText(
|
|
979
|
-
renderAssistantMessage(assistant, {
|
|
980
|
-
...RENDER_OPTS,
|
|
981
|
-
previewWidth: 24,
|
|
982
|
-
}),
|
|
983
|
-
24,
|
|
984
|
-
20,
|
|
985
|
-
);
|
|
986
|
-
|
|
987
|
-
// Assert
|
|
988
|
-
expect(text.some((line) => line.includes("IMPORTANT_PREFIX"))).toBe(true);
|
|
989
|
-
});
|
|
990
|
-
|
|
991
|
-
test("renderToolResult for a shell preview allows the outer conversation scroll to handle mouse wheel events", async () => {
|
|
992
|
-
// Arrange
|
|
993
|
-
const terminal = new MockTerminal(24, 10);
|
|
994
|
-
let outerScrollOffset = 0;
|
|
995
|
-
const toolNode = renderToolResult(
|
|
996
|
-
"shell",
|
|
997
|
-
{ command: "seq 1 25" },
|
|
998
|
-
Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n"),
|
|
999
|
-
false,
|
|
1000
|
-
{
|
|
1001
|
-
...RENDER_OPTS,
|
|
1002
|
-
previewWidth: 24,
|
|
1003
|
-
},
|
|
1004
|
-
);
|
|
1005
|
-
|
|
1006
|
-
cel.init(terminal);
|
|
1007
|
-
cel.viewport(() =>
|
|
1008
|
-
VStack(
|
|
1009
|
-
{
|
|
1010
|
-
width: 24,
|
|
1011
|
-
height: 10,
|
|
1012
|
-
overflow: "scroll",
|
|
1013
|
-
scrollOffset: outerScrollOffset,
|
|
1014
|
-
onScroll: (offset) => {
|
|
1015
|
-
outerScrollOffset = offset;
|
|
1016
|
-
},
|
|
1017
|
-
},
|
|
1018
|
-
[
|
|
1019
|
-
Text("before 1"),
|
|
1020
|
-
toolNode,
|
|
1021
|
-
Text("after 1"),
|
|
1022
|
-
Text("after 2"),
|
|
1023
|
-
Text("after 3"),
|
|
1024
|
-
Text("after 4"),
|
|
1025
|
-
Text("after 5"),
|
|
1026
|
-
],
|
|
1027
|
-
),
|
|
1028
|
-
);
|
|
1029
|
-
await waitForCelRender();
|
|
1030
|
-
|
|
1031
|
-
// Act
|
|
1032
|
-
terminal.sendInput("\x1b[<65;4;3M");
|
|
1033
|
-
await waitForCelRender();
|
|
1034
|
-
|
|
1035
|
-
// Assert
|
|
1036
|
-
expect(outerScrollOffset).toBeGreaterThan(0);
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
test("renderAssistantMessage for a readImage tool call shows the path rather than JSON", () => {
|
|
1040
|
-
// Arrange
|
|
1041
|
-
const assistant = {
|
|
1042
|
-
content: [
|
|
1043
|
-
fauxToolCall(
|
|
1044
|
-
"readImage",
|
|
1045
|
-
{ path: "assets/preview.png" },
|
|
1046
|
-
{ id: "tool-1" },
|
|
1047
|
-
),
|
|
1048
|
-
],
|
|
1049
|
-
};
|
|
1050
|
-
|
|
1051
|
-
// Act
|
|
1052
|
-
const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
|
|
1053
|
-
|
|
1054
|
-
// Assert
|
|
1055
|
-
expect(text).toContain("read image ->");
|
|
1056
|
-
expect(text).toContain("assets/preview.png");
|
|
1057
|
-
expect(text).not.toContain("{");
|
|
1058
|
-
});
|
|
1059
|
-
|
|
1060
|
-
test("renderAssistantMessage for a todoWrite tool call shows a todo-count summary instead of raw JSON", () => {
|
|
1061
|
-
// Arrange
|
|
1062
|
-
const assistant = {
|
|
1063
|
-
content: [
|
|
1064
|
-
fauxToolCall(
|
|
1065
|
-
"todoWrite",
|
|
1066
|
-
{
|
|
1067
|
-
todos: [
|
|
1068
|
-
{ content: "Inspect headless JSON output", status: "completed" },
|
|
1069
|
-
{ content: "Update the TUI preview", status: "in_progress" },
|
|
1070
|
-
],
|
|
1071
|
-
},
|
|
1072
|
-
{ id: "tool-1" },
|
|
1073
|
-
),
|
|
1074
|
-
],
|
|
1075
|
-
};
|
|
1076
|
-
|
|
1077
|
-
// Act
|
|
1078
|
-
const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
|
|
1079
|
-
|
|
1080
|
-
// Assert
|
|
1081
|
-
expect(text).toContain("todo write ->");
|
|
1082
|
-
expect(text).toContain("Updating todos... 2 todos updated");
|
|
1083
|
-
expect(text).not.toContain("{");
|
|
1084
|
-
expect(text).not.toContain('"todos"');
|
|
1085
|
-
});
|
|
1086
|
-
|
|
1087
|
-
test("renderAssistantMessage for an edit tool call shows both old and new content without diff prefixes", () => {
|
|
1088
|
-
// Arrange
|
|
1089
|
-
const assistant = {
|
|
1090
|
-
content: [
|
|
1091
|
-
fauxToolCall(
|
|
1092
|
-
"edit",
|
|
1093
|
-
{
|
|
1094
|
-
path: "src/file.ts",
|
|
1095
|
-
oldText: "old line",
|
|
1096
|
-
newText: "new line",
|
|
1097
|
-
},
|
|
1098
|
-
{ id: "tool-1" },
|
|
1099
|
-
),
|
|
1100
|
-
],
|
|
1101
|
-
};
|
|
1102
|
-
|
|
1103
|
-
// Act
|
|
1104
|
-
const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
|
|
1105
|
-
|
|
1106
|
-
// Assert
|
|
1107
|
-
expect(text).toContain("edit ->");
|
|
1108
|
-
expect(text).toContain("src/file.ts");
|
|
1109
|
-
expect(text).toContain("old line");
|
|
1110
|
-
expect(text).toContain("new line");
|
|
1111
|
-
expect(text).not.toContain("+new line");
|
|
1112
|
-
expect(text).not.toContain("-old line");
|
|
1113
|
-
});
|
|
1114
|
-
|
|
1115
|
-
test("renderAssistantMessage for an edit tool call colors old text red and new text green", async () => {
|
|
1116
|
-
// Arrange
|
|
1117
|
-
const assistant = {
|
|
1118
|
-
content: [
|
|
1119
|
-
fauxToolCall(
|
|
1120
|
-
"edit",
|
|
1121
|
-
{
|
|
1122
|
-
path: "src/file.ts",
|
|
1123
|
-
oldText: "old line",
|
|
1124
|
-
newText: "new line",
|
|
1125
|
-
},
|
|
1126
|
-
{ id: "tool-1" },
|
|
1127
|
-
),
|
|
1128
|
-
],
|
|
1129
|
-
};
|
|
1130
|
-
|
|
1131
|
-
// Act
|
|
1132
|
-
const rows = await renderBufferRows(
|
|
1133
|
-
renderAssistantMessage(assistant, RENDER_OPTS),
|
|
1134
|
-
PREVIEW_WIDTH,
|
|
1135
|
-
12,
|
|
1136
|
-
);
|
|
1137
|
-
const oldRow = rows.find((row) => row.text.includes("old line"));
|
|
1138
|
-
const newRow = rows.find((row) => row.text.includes("new line"));
|
|
1139
|
-
|
|
1140
|
-
// Assert
|
|
1141
|
-
expect(oldRow).toBeDefined();
|
|
1142
|
-
expect(newRow).toBeDefined();
|
|
1143
|
-
|
|
1144
|
-
const oldColor = oldRow?.fgColors[oldRow.text.indexOf("o")];
|
|
1145
|
-
const newColor = newRow?.fgColors[newRow.text.indexOf("n")];
|
|
1146
|
-
expect(oldColor).toBe(DEFAULT_THEME.diffRemoved ?? null);
|
|
1147
|
-
expect(newColor).toBe(DEFAULT_THEME.diffAdded ?? null);
|
|
1148
|
-
});
|
|
1149
|
-
|
|
1150
|
-
test("renderToolResult for shell output in non-verbose mode shows the visible tail under a result header", async () => {
|
|
1151
|
-
// Arrange
|
|
1152
|
-
const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
|
|
1153
|
-
"\n",
|
|
1154
|
-
);
|
|
1155
|
-
|
|
1156
|
-
// Act
|
|
1157
|
-
const text = await renderVisibleText(
|
|
1158
|
-
renderToolResult(
|
|
1159
|
-
"shell",
|
|
1160
|
-
{ command: "seq 1 25" },
|
|
1161
|
-
output,
|
|
1162
|
-
false,
|
|
1163
|
-
RENDER_OPTS,
|
|
1164
|
-
),
|
|
1165
|
-
PREVIEW_WIDTH,
|
|
1166
|
-
24,
|
|
1167
|
-
);
|
|
1168
|
-
|
|
1169
|
-
// Assert
|
|
1170
|
-
expect(text).toContain("shell <-");
|
|
1171
|
-
expect(text).toContain("line 18");
|
|
1172
|
-
expect(text).toContain("line 25");
|
|
1173
|
-
expect(text).toContain("And 17 lines more");
|
|
1174
|
-
expect(text).not.toContain("line 17");
|
|
1175
|
-
expect(text).not.toContain("seq 1 25");
|
|
1176
|
-
});
|
|
1177
|
-
|
|
1178
|
-
test("renderToolResult for a wrapped shell preview keeps the summary directly below the visible tail", async () => {
|
|
1179
|
-
// Arrange
|
|
1180
|
-
const output = [
|
|
1181
|
-
"line 1",
|
|
1182
|
-
"line 2",
|
|
1183
|
-
"line 3",
|
|
1184
|
-
"line 4",
|
|
1185
|
-
"this is a very long wrapped line that will take more than six rendered rows in the preview width so it gets dropped entirely",
|
|
1186
|
-
"tail A",
|
|
1187
|
-
"tail B",
|
|
1188
|
-
].join("\n");
|
|
1189
|
-
|
|
1190
|
-
// Act
|
|
1191
|
-
const rows = await renderBufferRows(
|
|
1192
|
-
renderToolResult("shell", { command: "demo" }, output, false, {
|
|
1193
|
-
...RENDER_OPTS,
|
|
1194
|
-
previewWidth: 24,
|
|
1195
|
-
}),
|
|
1196
|
-
24,
|
|
1197
|
-
20,
|
|
1198
|
-
);
|
|
1199
|
-
const tailRowIndex = rows.findIndex((row) => row.text.includes("tail B"));
|
|
1200
|
-
const summaryRowIndex = rows.findIndex(
|
|
1201
|
-
(row) => row.text.includes("And ") && row.text.includes(" lines more"),
|
|
1202
|
-
);
|
|
1203
|
-
|
|
1204
|
-
// Assert
|
|
1205
|
-
expect(tailRowIndex).toBeGreaterThan(-1);
|
|
1206
|
-
expect(summaryRowIndex).toBe(tailRowIndex + 1);
|
|
1207
|
-
});
|
|
1208
|
-
|
|
1209
|
-
test("renderToolResult for shell output in verbose mode shows the full stored output", () => {
|
|
1210
|
-
// Arrange
|
|
1211
|
-
const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
|
|
1212
|
-
"\n",
|
|
1213
|
-
);
|
|
1214
|
-
|
|
1215
|
-
// Act
|
|
1216
|
-
const text = collectText(
|
|
1217
|
-
renderToolResult("shell", { command: "seq 1 25" }, output, false, {
|
|
1218
|
-
...RENDER_OPTS,
|
|
1219
|
-
verbose: true,
|
|
1220
|
-
}),
|
|
1221
|
-
);
|
|
1222
|
-
|
|
1223
|
-
// Assert
|
|
1224
|
-
expect(text).toContain("line 17");
|
|
1225
|
-
expect(text).toContain("line 25");
|
|
1226
|
-
expect(text).not.toContain("And 17 lines more");
|
|
1227
|
-
});
|
|
1228
|
-
|
|
1229
|
-
test("renderToolResult for shell errors normalizes exit-code and stderr labels", () => {
|
|
1230
|
-
// Arrange
|
|
1231
|
-
const resultText = "Exit code: 42\n[stderr]\nboom";
|
|
1232
|
-
|
|
1233
|
-
// Act
|
|
1234
|
-
const text = collectText(
|
|
1235
|
-
renderToolResult(
|
|
1236
|
-
"shell",
|
|
1237
|
-
{ command: "exit 42" },
|
|
1238
|
-
resultText,
|
|
1239
|
-
true,
|
|
1240
|
-
RENDER_OPTS,
|
|
1241
|
-
),
|
|
1242
|
-
);
|
|
1243
|
-
|
|
1244
|
-
// Assert
|
|
1245
|
-
expect(text).toContain("shell <-");
|
|
1246
|
-
expect(text).toContain("exit 42");
|
|
1247
|
-
expect(text).toContain("boom");
|
|
1248
|
-
expect(text).not.toContain("Exit code: 42");
|
|
1249
|
-
expect(text).not.toContain("[stderr]");
|
|
1250
|
-
});
|
|
1251
|
-
|
|
1252
|
-
test("renderToolResult for a readImage success shows a compact path result", () => {
|
|
1253
|
-
// Arrange
|
|
1254
|
-
const args = { path: "diagram.png" };
|
|
1255
|
-
|
|
1256
|
-
// Act
|
|
1257
|
-
const text = collectText(
|
|
1258
|
-
renderToolResult("readImage", args, "", false, RENDER_OPTS),
|
|
1259
|
-
);
|
|
1260
|
-
|
|
1261
|
-
// Assert
|
|
1262
|
-
expect(text).toContain("read image <-");
|
|
1263
|
-
expect(text).toContain("diagram.png");
|
|
1264
|
-
expect(text).not.toContain("Read image.");
|
|
1265
|
-
});
|
|
1266
|
-
|
|
1267
|
-
test("renderToolResult for a readImage error shows the full error even when verbose is off", () => {
|
|
1268
|
-
// Arrange
|
|
1269
|
-
const errorText = Array.from(
|
|
1270
|
-
{ length: 25 },
|
|
1271
|
-
(_, i) => `error ${i + 1}`,
|
|
1272
|
-
).join("\n");
|
|
1273
|
-
|
|
1274
|
-
// Act
|
|
1275
|
-
const text = collectText(
|
|
1276
|
-
renderToolResult(
|
|
1277
|
-
"readImage",
|
|
1278
|
-
{ path: "diagram.png" },
|
|
1279
|
-
errorText,
|
|
1280
|
-
true,
|
|
1281
|
-
RENDER_OPTS,
|
|
1282
|
-
),
|
|
1283
|
-
);
|
|
1284
|
-
|
|
1285
|
-
// Assert
|
|
1286
|
-
expect(text).toContain("error 1");
|
|
1287
|
-
expect(text).toContain("error 25");
|
|
1288
|
-
expect(text.some((line) => /^And \d+ lines more$/.test(line))).toBe(false);
|
|
1289
|
-
});
|
|
1290
|
-
|
|
1291
|
-
test("renderToolResult for a successful edit stays compact regardless of verbose mode", () => {
|
|
1292
|
-
// Arrange
|
|
1293
|
-
const args = {
|
|
1294
|
-
path: "src/file.ts",
|
|
1295
|
-
oldText: "before",
|
|
1296
|
-
newText: "after",
|
|
1297
|
-
};
|
|
1298
|
-
|
|
1299
|
-
// Act
|
|
1300
|
-
const previewText = collectText(
|
|
1301
|
-
renderToolResult("edit", args, "Edited src/file.ts", false, RENDER_OPTS),
|
|
1302
|
-
);
|
|
1303
|
-
const verboseText = collectText(
|
|
1304
|
-
renderToolResult("edit", args, "Edited src/file.ts", false, {
|
|
1305
|
-
...RENDER_OPTS,
|
|
1306
|
-
verbose: true,
|
|
1307
|
-
}),
|
|
1308
|
-
);
|
|
1309
|
-
|
|
1310
|
-
// Assert
|
|
1311
|
-
expect(previewText).toContain("edit <-");
|
|
1312
|
-
expect(previewText).toContain("~ src/file.ts");
|
|
1313
|
-
expect(previewText).not.toContain("before");
|
|
1314
|
-
expect(previewText).not.toContain("after");
|
|
1315
|
-
expect(previewText).not.toContain("And 1 lines more");
|
|
1316
|
-
expect(verboseText).toEqual(previewText);
|
|
1317
|
-
});
|
|
1318
|
-
|
|
1319
|
-
test("renderToolResult for an edit error uses the preview policy in non-verbose mode", async () => {
|
|
1320
|
-
// Arrange
|
|
1321
|
-
const errorText = Array.from(
|
|
1322
|
-
{ length: 25 },
|
|
1323
|
-
(_, i) => `error ${i + 1}`,
|
|
1324
|
-
).join("\n");
|
|
1325
|
-
|
|
1326
|
-
// Act
|
|
1327
|
-
const text = await renderVisibleText(
|
|
1328
|
-
renderToolResult(
|
|
1329
|
-
"edit",
|
|
1330
|
-
{
|
|
1331
|
-
path: "src/file.ts",
|
|
1332
|
-
oldText: "before",
|
|
1333
|
-
newText: "after",
|
|
1334
|
-
},
|
|
1335
|
-
errorText,
|
|
1336
|
-
true,
|
|
1337
|
-
RENDER_OPTS,
|
|
1338
|
-
),
|
|
1339
|
-
PREVIEW_WIDTH,
|
|
1340
|
-
24,
|
|
1341
|
-
);
|
|
1342
|
-
|
|
1343
|
-
// Assert
|
|
1344
|
-
expect(text).toContain("edit <-");
|
|
1345
|
-
expect(text).toContain("error 18");
|
|
1346
|
-
expect(text).toContain("error 25");
|
|
1347
|
-
expect(text).toContain("And 17 lines more");
|
|
1348
|
-
expect(text).not.toContain("error 17");
|
|
1349
|
-
});
|
|
1350
|
-
|
|
1351
|
-
test("renderToolResult for todoWrite shows the full checklist even when verbose is off", async () => {
|
|
1352
|
-
const snapshot = JSON.stringify({
|
|
1353
|
-
todos: [
|
|
1354
|
-
{ content: "Review prompt wording", status: "completed" },
|
|
1355
|
-
{ content: "Implement todo tools", status: "in_progress" },
|
|
1356
|
-
{ content: "Add /todo command", status: "pending" },
|
|
1357
|
-
{ content: "Run the full verification suite", status: "pending" },
|
|
1358
|
-
],
|
|
1359
|
-
});
|
|
1360
|
-
|
|
1361
|
-
const text = await renderVisibleText(
|
|
1362
|
-
renderToolResult("todoWrite", {}, snapshot, false, RENDER_OPTS),
|
|
1363
|
-
PREVIEW_WIDTH,
|
|
1364
|
-
24,
|
|
1365
|
-
);
|
|
1366
|
-
|
|
1367
|
-
expect(text).toContain("todo write <-");
|
|
1368
|
-
expect(text).toContain("[x] Review prompt wording");
|
|
1369
|
-
expect(text).toContain("[~] Implement todo tools");
|
|
1370
|
-
expect(text).toContain("[ ] Add /todo command");
|
|
1371
|
-
expect(text.join(" ")).toContain("[ ] Run the full verification suite");
|
|
1372
|
-
expect(text.some((line) => line.startsWith("And "))).toBe(false);
|
|
1373
|
-
});
|
|
1374
|
-
|
|
1375
|
-
test("buildConversationLogNodes renders UI todo messages with the shared checklist block", async () => {
|
|
1376
|
-
const state = {
|
|
1377
|
-
messages: [
|
|
1378
|
-
{
|
|
1379
|
-
role: "ui" as const,
|
|
1380
|
-
kind: "todo" as const,
|
|
1381
|
-
todos: [
|
|
1382
|
-
{ content: "Review prompt wording", status: "completed" as const },
|
|
1383
|
-
{ content: "Implement todo tools", status: "in_progress" as const },
|
|
1384
|
-
{ content: "Add /todo command", status: "pending" as const },
|
|
1385
|
-
],
|
|
1386
|
-
timestamp: 1,
|
|
1387
|
-
},
|
|
1388
|
-
],
|
|
1389
|
-
showReasoning: false,
|
|
1390
|
-
verbose: false,
|
|
1391
|
-
theme: DEFAULT_THEME,
|
|
1392
|
-
};
|
|
1393
|
-
|
|
1394
|
-
const text = await renderVisibleText(
|
|
1395
|
-
VStack(
|
|
1396
|
-
{},
|
|
1397
|
-
buildConversationLogNodes(
|
|
1398
|
-
state,
|
|
1399
|
-
{ isStreaming: false, content: [], pendingToolResults: [] },
|
|
1400
|
-
0,
|
|
1401
|
-
PREVIEW_WIDTH,
|
|
1402
|
-
),
|
|
1403
|
-
),
|
|
1404
|
-
PREVIEW_WIDTH,
|
|
1405
|
-
24,
|
|
1406
|
-
);
|
|
1407
|
-
|
|
1408
|
-
expect(text).toContain("todo");
|
|
1409
|
-
expect(text).toContain("[x] Review prompt wording");
|
|
1410
|
-
expect(text).toContain("[~] Implement todo tools");
|
|
1411
|
-
expect(text).toContain("[ ] Add /todo command");
|
|
1412
|
-
expect(text.some((line) => line.includes('"todos"'))).toBe(false);
|
|
1413
|
-
});
|
|
1414
|
-
|
|
1415
|
-
test("renderToolResult for a generic plugin tool uses the shared result header", () => {
|
|
1416
|
-
// Arrange
|
|
1417
|
-
const args = { query: "session persistence sqlite turn numbering" };
|
|
1418
|
-
|
|
1419
|
-
// Act
|
|
1420
|
-
const text = collectText(
|
|
1421
|
-
renderToolResult(
|
|
1422
|
-
"mcp/search",
|
|
1423
|
-
args,
|
|
1424
|
-
"session persistence sqlite turn numbering",
|
|
1425
|
-
false,
|
|
1426
|
-
RENDER_OPTS,
|
|
1427
|
-
),
|
|
1428
|
-
);
|
|
1429
|
-
|
|
1430
|
-
// Assert
|
|
1431
|
-
expect(text).toContain("mcp/search <-");
|
|
1432
|
-
expect(text).toContain("session persistence sqlite turn numbering");
|
|
1433
|
-
expect(text).not.toContain('"query"');
|
|
1434
|
-
});
|
|
1435
|
-
});
|