mini-coder 0.5.10 → 0.5.12

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.
@@ -1,36 +1,24 @@
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";
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Node, TextNode } from "@cel-tui/types";
3
+ import type { AssistantMessage, ToolResultMessage } from "@mariozechner/pi-ai";
16
4
  import { DEFAULT_THEME } from "../theme.ts";
17
5
  import {
18
6
  buildConversationLogNodes,
19
- type PendingToolResult,
20
7
  renderAssistantMessage,
21
8
  renderToolResult,
22
- resetConversationRenderCache,
23
9
  } from "./conversation.ts";
24
10
 
25
- const PREVIEW_WIDTH = 32;
26
- const RENDER_OPTS = {
27
- showReasoning: false,
28
- verbose: false,
29
- theme: DEFAULT_THEME,
30
- previewWidth: PREVIEW_WIDTH,
31
- };
11
+ function collectInlineText(node: Node): string {
12
+ if (node.type === "text") {
13
+ return node.content;
14
+ }
15
+ if (node.type === "textinput") {
16
+ return "";
17
+ }
18
+ return node.children.map((child) => collectInlineText(child)).join("");
19
+ }
32
20
 
33
- function collectText(node: Node | null): string[] {
21
+ function collectRenderedLines(node: Node | null): string[] {
34
22
  if (!node) {
35
23
  return [];
36
24
  }
@@ -42,1445 +30,331 @@ function collectText(node: Node | null): string[] {
42
30
  }
43
31
  if (
44
32
  node.type === "hstack" &&
45
- node.children.every((child) => child.type === "text")
33
+ node.children.length === 2 &&
34
+ node.children[0]?.type === "text" &&
35
+ node.children[1]?.type === "vstack"
46
36
  ) {
47
- return [node.children.map((child) => child.content).join("")];
37
+ const prefix = node.children[0].content;
38
+ return collectRenderedLines(node.children[1]).map(
39
+ (line) => `${prefix}${line}`,
40
+ );
48
41
  }
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;
42
+ if (node.type === "hstack") {
43
+ return [collectInlineText(node)];
55
44
  }
56
- return measureContentHeight(VStack({}, [node]), { width });
57
- }
58
-
59
- async function waitForCelRender(): Promise<void> {
60
- await new Promise((resolve) => setTimeout(resolve, 10));
45
+ return node.children.flatMap((child) => collectRenderedLines(child));
61
46
  }
62
47
 
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) {
48
+ function collectTextNodes(node: Node | null): TextNode[] {
49
+ if (!node || node.type === "textinput") {
77
50
  return [];
78
51
  }
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 });
52
+ if (node.type === "text") {
53
+ return [node];
112
54
  }
113
-
114
- cel.stop();
115
- return snapshot;
55
+ return node.children.flatMap((child) => collectTextNodes(child));
116
56
  }
117
57
 
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
- }
58
+ function findTextNode(node: Node | null, content: string): TextNode {
59
+ const textNode = collectTextNodes(node).find(
60
+ (text) => text.content === content,
61
+ );
62
+ expect(textNode).toBeDefined();
63
+ if (!textNode) {
64
+ throw new Error(`Missing text node: ${content}`);
131
65
  }
66
+ return textNode;
67
+ }
132
68
 
133
- return lines;
69
+ function makeAssistantToolCallMessage(): AssistantMessage {
70
+ return {
71
+ role: "assistant",
72
+ content: [
73
+ {
74
+ type: "toolCall",
75
+ id: "call-read",
76
+ name: "read",
77
+ arguments: { path: "hint.txt", limit: 3 },
78
+ },
79
+ ],
80
+ api: "anthropic-messages",
81
+ provider: "anthropic",
82
+ model: "claude-sonnet-4-20250514",
83
+ usage: {
84
+ input: 0,
85
+ output: 0,
86
+ cacheRead: 0,
87
+ cacheWrite: 0,
88
+ totalTokens: 0,
89
+ cost: {
90
+ input: 0,
91
+ output: 0,
92
+ cacheRead: 0,
93
+ cacheWrite: 0,
94
+ total: 0,
95
+ },
96
+ },
97
+ stopReason: "toolUse",
98
+ timestamp: 1,
99
+ };
134
100
  }
135
101
 
136
- afterEach(() => {
137
- resetConversationRenderCache();
138
- cel.stop();
139
- });
102
+ function makeReadToolResultMessage(
103
+ content: ToolResultMessage["content"],
104
+ ): ToolResultMessage {
105
+ return {
106
+ role: "toolResult",
107
+ toolCallId: "call-read",
108
+ toolName: "read",
109
+ content,
110
+ isError: false,
111
+ timestamp: 2,
112
+ };
113
+ }
140
114
 
141
115
  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[] = [
116
+ test("read tool-call previews render structured arguments instead of raw JSON", () => {
117
+ const node = renderAssistantMessage(
351
118
  {
352
- toolCallId: "tool-1",
353
- toolName: "shell",
354
- content: [{ type: "text", text: "Exit code: 0\npartial output" }],
355
- isError: false,
119
+ content: [
120
+ {
121
+ type: "toolCall",
122
+ id: "call-read",
123
+ name: "read",
124
+ arguments: {
125
+ path: "src/ui/conversation.ts",
126
+ offset: 820,
127
+ limit: 80,
128
+ },
129
+ },
130
+ ],
356
131
  },
357
- ];
358
-
359
- // Act
360
- const nodes = buildConversationLogNodes(
361
132
  {
362
- messages: [
363
- fauxAssistantMessage([
364
- fauxText("Working..."),
365
- fauxToolCall("shell", { command: "echo hi" }, { id: "tool-1" }),
366
- ]),
367
- ],
368
- showReasoning: false,
133
+ showReasoning: true,
369
134
  verbose: false,
370
135
  theme: DEFAULT_THEME,
136
+ cwd: "/tmp/project",
137
+ previewWidth: 80,
371
138
  },
372
- {
373
- isStreaming: true,
374
- content: [],
375
- pendingToolResults,
376
- },
377
- 0,
378
- PREVIEW_WIDTH,
379
139
  );
380
- const text = collectText({
381
- type: "vstack",
382
- props: {},
383
- children: nodes,
384
- });
385
140
 
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");
141
+ const lines = collectRenderedLines(node);
142
+ expect(lines).toContain("│ read ->");
143
+ expect(lines).toContain("│ src/ui/conversation.ts");
144
+ expect(lines).toContain("│ offset: 820");
145
+ expect(lines).toContain("│ limit: 80");
146
+ expect(lines.join("\n")).not.toContain('"path"');
393
147
  });
394
148
 
395
- test("buildConversationLogNodes with a sliced window keeps hidden tool-call args available for compact edit results", () => {
396
- // Arrange
397
- const nodes = buildConversationLogNodes(
149
+ test("shell tool-call previews use cel-tui syntax scopes for custom colors", () => {
150
+ const node = renderAssistantMessage(
398
151
  {
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
- ]),
152
+ content: [
411
153
  {
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(),
154
+ type: "toolCall",
155
+ id: "call-shell",
156
+ name: "shell",
157
+ arguments: {
158
+ command: 'if true; then echo "$HOME"; fi',
159
+ },
418
160
  },
419
161
  ],
420
- showReasoning: false,
421
- verbose: false,
422
- theme: DEFAULT_THEME,
423
162
  },
424
163
  {
425
- isStreaming: false,
426
- content: [],
427
- pendingToolResults: [],
164
+ showReasoning: true,
165
+ verbose: true,
166
+ theme: DEFAULT_THEME,
167
+ cwd: "/tmp/project",
168
+ previewWidth: 80,
428
169
  },
429
- 1,
430
- PREVIEW_WIDTH,
431
170
  );
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
171
 
459
- // Act
460
- const first = buildConversationLogNodes(state, streaming, 0, PREVIEW_WIDTH);
461
- const second = buildConversationLogNodes(
462
- state,
463
- streaming,
464
- 0,
465
- PREVIEW_WIDTH,
172
+ expect(findTextNode(node, "true").props.fgColor).toBe(
173
+ DEFAULT_THEME.secondaryAccentText,
466
174
  );
467
-
468
- // Assert
469
- expect(second).toBe(first);
470
175
  });
471
176
 
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
- };
177
+ test("read tool results include the resolved path, hide model paging hints, and render fewer body lines when verbose is off", () => {
178
+ const fileBody =
179
+ Array.from({ length: 14 }, (_, index) => `line ${index + 1}`).join("\n") +
180
+ "\n\n[use offset=14 limit=14 to continue]";
480
181
 
481
- // Act
482
- const committed = buildConversationLogNodes(
483
- state,
484
- {
485
- isStreaming: false,
486
- content: [],
487
- pendingToolResults: [],
488
- },
489
- 0,
490
- PREVIEW_WIDTH,
182
+ const compactLines = collectRenderedLines(
183
+ renderToolResult(
184
+ "read",
185
+ { path: "src/ui/conversation.ts" },
186
+ fileBody,
187
+ false,
188
+ {
189
+ showReasoning: true,
190
+ verbose: false,
191
+ theme: DEFAULT_THEME,
192
+ cwd: "/tmp/project",
193
+ previewWidth: 48,
194
+ },
195
+ ),
491
196
  );
492
- const withStreamingTail = buildConversationLogNodes(
493
- state,
494
- {
495
- isStreaming: true,
496
- content: [fauxText("Streaming tail")],
497
- pendingToolResults: [],
498
- },
499
- 0,
500
- PREVIEW_WIDTH,
197
+ const verboseLines = collectRenderedLines(
198
+ renderToolResult(
199
+ "read",
200
+ { path: "src/ui/conversation.ts" },
201
+ fileBody,
202
+ false,
203
+ {
204
+ showReasoning: true,
205
+ verbose: true,
206
+ theme: DEFAULT_THEME,
207
+ cwd: "/tmp/project",
208
+ previewWidth: 48,
209
+ },
210
+ ),
501
211
  );
502
- const text = collectText({
503
- type: "vstack",
504
- props: {},
505
- children: withStreamingTail,
506
- });
507
212
 
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",
213
+ expect(compactLines[0]).toBe(
214
+ "│ read <- /tmp/project/src/ui/conversation.ts",
518
215
  );
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
- };
216
+ expect(compactLines.join("\n")).not.toContain("Use offset=14 limit=14");
217
+ expect(verboseLines.join("\n")).not.toContain("Use offset=14 limit=14");
218
+ expect(verboseLines).toContain("│ line 14");
219
+ expect(compactLines.length).toBeLessThan(verboseLines.length);
220
+ });
537
221
 
538
- // Act
539
- const previewNodes = buildConversationLogNodes(
540
- state,
222
+ test("read tool results preserve literal continuation-looking lines from the file body without showing the model paging hint", () => {
223
+ const nodes = buildConversationLogNodes(
541
224
  {
542
- isStreaming: false,
543
- content: [],
544
- pendingToolResults: [],
225
+ messages: [
226
+ makeAssistantToolCallMessage(),
227
+ makeReadToolResultMessage([
228
+ {
229
+ type: "text",
230
+ text: "alpha\n\n[use offset=99 limit=10 to continue]\n",
231
+ },
232
+ {
233
+ type: "text",
234
+ text: "[use offset=3 limit=3 to continue]",
235
+ },
236
+ ]),
237
+ ],
238
+ showReasoning: true,
239
+ verbose: true,
240
+ theme: DEFAULT_THEME,
241
+ cwd: "/tmp/project",
242
+ versionLabel: "test",
545
243
  },
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
244
  {
558
245
  isStreaming: false,
559
246
  content: [],
560
247
  pendingToolResults: [],
561
248
  },
562
249
  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,
250
+ 80,
835
251
  );
836
252
 
837
- // Assert
838
- expect(text.some((line) => line.includes("TAIL"))).toBe(true);
253
+ const lines = nodes.flatMap((node) => collectRenderedLines(node));
254
+ expect(lines).toContain("│ [use offset=99 limit=10 to continue]");
255
+ expect(lines.join("\n")).not.toContain("Use offset=3 limit=3 to continue.");
839
256
  });
840
257
 
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,
258
+ test("read tool fallback rendering normalizes CRLF line endings", () => {
259
+ const lines = collectRenderedLines(
260
+ renderToolResult(
261
+ "read",
262
+ { path: "notes.txt" },
263
+ "alpha\r\nbeta\r\n",
264
+ false,
265
+ {
266
+ showReasoning: true,
267
+ verbose: true,
268
+ theme: DEFAULT_THEME,
269
+ cwd: "/tmp/project",
270
+ previewWidth: 80,
271
+ },
272
+ ),
985
273
  );
986
274
 
987
- // Assert
988
- expect(text.some((line) => line.includes("IMPORTANT_PREFIX"))).toBe(true);
275
+ expect(lines).toContain("│ alpha");
276
+ expect(lines).toContain("│ beta");
277
+ expect(lines.some((line) => line.includes("\r"))).toBe(false);
989
278
  });
990
279
 
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"),
280
+ test("read tool results use direct syntax-highlight rendering without chunking long tokens", () => {
281
+ const node = renderToolResult(
282
+ "read",
283
+ { path: "src/example.ts" },
284
+ "const supercalifragilisticexpialidociousIdentifier = 42",
999
285
  false,
1000
286
  {
1001
- ...RENDER_OPTS,
1002
- previewWidth: 24,
287
+ showReasoning: true,
288
+ verbose: true,
289
+ theme: DEFAULT_THEME,
290
+ cwd: "/tmp/project",
291
+ previewWidth: 20,
1003
292
  },
1004
293
  );
1005
294
 
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
- ),
295
+ expect(findTextNode(node, "42").props.fgColor).toBe(
296
+ DEFAULT_THEME.secondaryAccentText,
1028
297
  );
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("{");
298
+ expect(
299
+ findTextNode(node, "supercalifragilisticexpialidociousIdentifier")
300
+ .content,
301
+ ).toBe("supercalifragilisticexpialidociousIdentifier");
302
+ expect(
303
+ findTextNode(node, "supercalifragilisticexpialidociousIdentifier").props
304
+ .fgColor,
305
+ ).toBeUndefined();
1058
306
  });
1059
307
 
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",
308
+ test("grep tool results render grouped files and lines instead of raw JSON", () => {
309
+ const resultText = JSON.stringify(
310
+ {
311
+ limit: 10,
312
+ truncated: false,
313
+ files: [
1066
314
  {
1067
- todos: [
1068
- { content: "Inspect headless JSON output", status: "completed" },
1069
- { content: "Update the TUI preview", status: "in_progress" },
315
+ path: "src/ui/conversation.ts",
316
+ lines: [
317
+ {
318
+ kind: "match",
319
+ lineNumber: 857,
320
+ text: "function renderToolBlock(\n",
321
+ },
322
+ {
323
+ kind: "context",
324
+ lineNumber: 858,
325
+ text: " spec: ToolBlockSpec,\n",
326
+ },
1070
327
  ],
1071
328
  },
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
- ),
329
+ ],
330
+ },
331
+ null,
332
+ 2,
1283
333
  );
1284
334
 
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,
335
+ const node = renderToolResult(
336
+ "grep",
337
+ { pattern: "renderToolBlock" },
338
+ resultText,
339
+ false,
340
+ {
341
+ showReasoning: true,
1306
342
  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 syntax-highlights markdown-formatted UI info messages", async () => {
1376
- const theme = {
1377
- ...DEFAULT_THEME,
1378
- accentText: "color14",
1379
- secondaryAccentText: "color09",
1380
- diffAdded: "color10",
1381
- } satisfies typeof DEFAULT_THEME;
1382
- const state = {
1383
- messages: [
1384
- {
1385
- role: "ui" as const,
1386
- kind: "info" as const,
1387
- format: "markdown" as const,
1388
- content: "# Help\n\n## Commands\n\n- `/model` — Select a model",
1389
- timestamp: 1,
1390
- },
1391
- ],
1392
- showReasoning: false,
1393
- verbose: false,
1394
- theme,
1395
- };
1396
-
1397
- const rows = await renderBufferRows(
1398
- VStack(
1399
- {},
1400
- buildConversationLogNodes(
1401
- state,
1402
- { isStreaming: false, content: [], pendingToolResults: [] },
1403
- 0,
1404
- PREVIEW_WIDTH,
1405
- ),
1406
- ),
1407
- PREVIEW_WIDTH,
1408
- 24,
1409
- );
1410
- const headingRow = rows.find((row) => row.text.includes("# Help"));
1411
- const bulletRow = rows.find((row) => row.text.includes("- `/model`"));
1412
-
1413
- expect(headingRow).toBeDefined();
1414
- expect(bulletRow).toBeDefined();
1415
- expect(headingRow?.fgColors[headingRow.text.indexOf("#")]).toBe(
1416
- theme.accentText ?? null,
1417
- );
1418
- expect(bulletRow?.fgColors[bulletRow.text.indexOf("-")]).toBe(
1419
- theme.secondaryAccentText ?? null,
1420
- );
1421
- expect(bulletRow?.fgColors[bulletRow.text.indexOf("`")]).toBe(
1422
- theme.diffAdded ?? null,
1423
- );
1424
- });
1425
-
1426
- test("buildConversationLogNodes renders UI todo messages with the shared checklist block", async () => {
1427
- const state = {
1428
- messages: [
1429
- {
1430
- role: "ui" as const,
1431
- kind: "todo" as const,
1432
- todos: [
1433
- { content: "Review prompt wording", status: "completed" as const },
1434
- { content: "Implement todo tools", status: "in_progress" as const },
1435
- { content: "Add /todo command", status: "pending" as const },
1436
- ],
1437
- timestamp: 1,
1438
- },
1439
- ],
1440
- showReasoning: false,
1441
- verbose: false,
1442
- theme: DEFAULT_THEME,
1443
- };
1444
-
1445
- const text = await renderVisibleText(
1446
- VStack(
1447
- {},
1448
- buildConversationLogNodes(
1449
- state,
1450
- { isStreaming: false, content: [], pendingToolResults: [] },
1451
- 0,
1452
- PREVIEW_WIDTH,
1453
- ),
1454
- ),
1455
- PREVIEW_WIDTH,
1456
- 24,
1457
- );
1458
-
1459
- expect(text).toContain("todo");
1460
- expect(text).toContain("[x] Review prompt wording");
1461
- expect(text).toContain("[~] Implement todo tools");
1462
- expect(text).toContain("[ ] Add /todo command");
1463
- expect(text.some((line) => line.includes('"todos"'))).toBe(false);
1464
- });
1465
-
1466
- test("renderToolResult for a generic plugin tool uses the shared result header", () => {
1467
- // Arrange
1468
- const args = { query: "session persistence sqlite turn numbering" };
1469
-
1470
- // Act
1471
- const text = collectText(
1472
- renderToolResult(
1473
- "mcp/search",
1474
- args,
1475
- "session persistence sqlite turn numbering",
1476
- false,
1477
- RENDER_OPTS,
1478
- ),
343
+ theme: DEFAULT_THEME,
344
+ cwd: "/tmp/project",
345
+ previewWidth: 80,
346
+ },
1479
347
  );
1480
348
 
1481
- // Assert
1482
- expect(text).toContain("mcp/search <-");
1483
- expect(text).toContain("session persistence sqlite turn numbering");
1484
- expect(text).not.toContain('"query"');
349
+ const lines = collectRenderedLines(node);
350
+ expect(lines).toContain("│ grep <-");
351
+ expect(lines).toContain("│ src/ui/conversation.ts");
352
+ expect(lines).toContain("│ 857: function renderToolBlock(");
353
+ expect(lines).toContain("│ 858: spec: ToolBlockSpec,");
354
+ expect(lines.join("\n")).not.toContain('"files"');
355
+ expect(lines.join("\n")).not.toContain('"kind"');
356
+ expect(
357
+ findTextNode(node, " 857: function renderToolBlock(").props.fgColor,
358
+ ).toBe(DEFAULT_THEME.toolText);
1485
359
  });
1486
360
  });