mini-coder 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +87 -48
  2. package/assets/icon-1-minimal.svg +31 -0
  3. package/assets/icon-2-dark-terminal.svg +48 -0
  4. package/assets/icon-3-gradient-modern.svg +45 -0
  5. package/assets/icon-4-filled-bold.svg +54 -0
  6. package/assets/icon-5-community-badge.svg +63 -0
  7. package/assets/preview-0-5-0.png +0 -0
  8. package/assets/preview.gif +0 -0
  9. package/bin/mc.ts +14 -0
  10. package/bun.lock +438 -0
  11. package/package.json +12 -29
  12. package/src/agent.ts +592 -0
  13. package/src/cli.ts +124 -0
  14. package/src/git.ts +164 -0
  15. package/src/headless.ts +140 -0
  16. package/src/index.ts +645 -0
  17. package/src/input.ts +155 -0
  18. package/src/paths.ts +37 -0
  19. package/src/plugins.ts +183 -0
  20. package/src/prompt.ts +294 -0
  21. package/src/session.ts +838 -0
  22. package/src/settings.ts +184 -0
  23. package/src/skills.ts +258 -0
  24. package/src/submit.ts +323 -0
  25. package/src/theme.ts +147 -0
  26. package/src/tools.ts +636 -0
  27. package/src/ui/agent.test.ts +49 -0
  28. package/src/ui/agent.ts +210 -0
  29. package/src/ui/commands.test.ts +610 -0
  30. package/src/ui/commands.ts +638 -0
  31. package/src/ui/conversation.test.ts +892 -0
  32. package/src/ui/conversation.ts +926 -0
  33. package/src/ui/help.test.ts +26 -0
  34. package/src/ui/help.ts +119 -0
  35. package/src/ui/input.test.ts +74 -0
  36. package/src/ui/input.ts +138 -0
  37. package/src/ui/overlay.test.ts +42 -0
  38. package/src/ui/overlay.ts +59 -0
  39. package/src/ui/status.test.ts +450 -0
  40. package/src/ui/status.ts +357 -0
  41. package/src/ui.ts +615 -0
  42. package/.claude/settings.local.json +0 -54
  43. package/.prettierignore +0 -7
  44. package/dist/mc-edit.js +0 -275
  45. package/dist/mc.js +0 -7355
  46. package/docs/KNOWN_ISSUES.md +0 -13
  47. package/docs/design-decisions.md +0 -31
  48. package/docs/mini-coder.1.md +0 -227
  49. package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
  50. package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
  51. package/lefthook.yml +0 -4
@@ -0,0 +1,892 @@
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
+ return node.children.flatMap((child) => collectText(child));
44
+ }
45
+
46
+ function measureRenderedHeight(node: Node | null, width: number): number {
47
+ if (!node) {
48
+ return 0;
49
+ }
50
+ return measureContentHeight(VStack({}, [node]), { width });
51
+ }
52
+
53
+ async function waitForCelRender(): Promise<void> {
54
+ await new Promise((resolve) => setTimeout(resolve, 10));
55
+ }
56
+
57
+ async function renderBufferRows(
58
+ node: Node | null,
59
+ cols = PREVIEW_WIDTH,
60
+ rows = 24,
61
+ ): Promise<Array<{ text: string; fgColors: Array<string | null> }>> {
62
+ if (!node) {
63
+ return [];
64
+ }
65
+
66
+ const terminal = new MockTerminal(cols, rows);
67
+ cel.init(terminal);
68
+ cel.viewport(() => VStack({ width: cols, height: rows }, [node]));
69
+ await waitForCelRender();
70
+
71
+ const buffer = cel._getBuffer();
72
+ if (!buffer) {
73
+ throw new Error("Expected cel-tui to produce a render buffer");
74
+ }
75
+
76
+ const snapshot: Array<{ text: string; fgColors: Array<string | null> }> = [];
77
+ for (let y = 0; y < rows; y++) {
78
+ let text = "";
79
+ const fgColors: Array<string | null> = [];
80
+ for (let x = 0; x < cols; x++) {
81
+ const cell = buffer.get(x, y);
82
+ text += cell.char;
83
+ fgColors.push(cell.fgColor);
84
+ }
85
+ snapshot.push({ text, fgColors });
86
+ }
87
+
88
+ cel.stop();
89
+ return snapshot;
90
+ }
91
+
92
+ async function renderVisibleText(
93
+ node: Node | null,
94
+ cols = PREVIEW_WIDTH,
95
+ rows = 24,
96
+ ): Promise<string[]> {
97
+ const snapshot = await renderBufferRows(node, cols, rows);
98
+ const lines: string[] = [];
99
+
100
+ for (const row of snapshot) {
101
+ const normalized = row.text.trim().replace(/^│\s*/, "");
102
+ if (normalized !== "") {
103
+ lines.push(normalized);
104
+ }
105
+ }
106
+
107
+ return lines;
108
+ }
109
+
110
+ afterEach(() => {
111
+ resetConversationRenderCache();
112
+ cel.stop();
113
+ });
114
+
115
+ describe("ui/conversation", () => {
116
+ test("renderAssistantMessage preserves visible paragraph order in streamed markdown", () => {
117
+ // Arrange
118
+ const message = fauxAssistantMessage("First paragraph\n\nSecond paragraph");
119
+
120
+ // Act
121
+ const text = collectText(renderAssistantMessage(message, RENDER_OPTS));
122
+ const firstParagraphIndex = text.indexOf("First paragraph");
123
+ const secondParagraphIndex = text.indexOf("Second paragraph");
124
+
125
+ // Assert
126
+ expect(firstParagraphIndex).toBeGreaterThanOrEqual(0);
127
+ expect(secondParagraphIndex).toBeGreaterThan(firstParagraphIndex);
128
+ });
129
+
130
+ test("renderAssistantMessage with reasoning enabled shows thinking blocks", () => {
131
+ // Arrange
132
+ const message = fauxAssistantMessage([
133
+ fauxThinking("I should inspect the tests first."),
134
+ fauxText("Done."),
135
+ ]);
136
+
137
+ // Act
138
+ const text = collectText(
139
+ renderAssistantMessage(message, {
140
+ ...RENDER_OPTS,
141
+ showReasoning: true,
142
+ }),
143
+ );
144
+
145
+ // Assert
146
+ expect(text).toContain("I should inspect the tests first.");
147
+ expect(text).toContain("Done.");
148
+ });
149
+
150
+ test("renderAssistantMessage with reasoning hidden shows a thinking line-count placeholder", () => {
151
+ // Arrange
152
+ const message = fauxAssistantMessage([
153
+ fauxThinking("line one\nline two\nline three"),
154
+ fauxText("Done."),
155
+ ]);
156
+
157
+ // Act
158
+ const text = collectText(
159
+ renderAssistantMessage(message, {
160
+ ...RENDER_OPTS,
161
+ showReasoning: false,
162
+ }),
163
+ );
164
+
165
+ // Assert
166
+ expect(text).toContain("Thinking... 3 lines.");
167
+ expect(text).toContain("Done.");
168
+ expect(text).not.toContain("line one");
169
+ expect(text).not.toContain("line two");
170
+ });
171
+
172
+ test("renderAssistantMessage with mixed top-level blocks keeps a single blank line between sections", () => {
173
+ // Arrange
174
+ const message = fauxAssistantMessage([
175
+ fauxThinking("Plan first."),
176
+ fauxText("Done."),
177
+ fauxToolCall("shell", { command: "echo hi" }, { id: "tool-1" }),
178
+ ]);
179
+
180
+ // Act
181
+ const height = measureRenderedHeight(
182
+ renderAssistantMessage(message, {
183
+ ...RENDER_OPTS,
184
+ showReasoning: true,
185
+ }),
186
+ PREVIEW_WIDTH,
187
+ );
188
+
189
+ // Assert
190
+ expect(height).toBe(6);
191
+ });
192
+
193
+ test("renderAssistantMessage keeps markdown paragraph spacing inside a single text block", () => {
194
+ // Arrange
195
+ const message = fauxAssistantMessage("First paragraph\n\nSecond paragraph");
196
+
197
+ // Act
198
+ const height = measureRenderedHeight(
199
+ renderAssistantMessage(message, RENDER_OPTS),
200
+ PREVIEW_WIDTH,
201
+ );
202
+
203
+ // Assert
204
+ expect(height).toBe(3);
205
+ });
206
+
207
+ test("buildConversationLogNodes with a pending shell result keeps the streamed call and result append-only", () => {
208
+ // Arrange
209
+ const pendingToolResults: PendingToolResult[] = [
210
+ {
211
+ toolCallId: "tool-1",
212
+ toolName: "shell",
213
+ content: [{ type: "text", text: "Exit code: 0\npartial output" }],
214
+ isError: false,
215
+ },
216
+ ];
217
+
218
+ // Act
219
+ const nodes = buildConversationLogNodes(
220
+ {
221
+ messages: [
222
+ fauxAssistantMessage([
223
+ fauxText("Working..."),
224
+ fauxToolCall("shell", { command: "echo hi" }, { id: "tool-1" }),
225
+ ]),
226
+ ],
227
+ showReasoning: false,
228
+ verbose: false,
229
+ theme: DEFAULT_THEME,
230
+ },
231
+ {
232
+ isStreaming: true,
233
+ content: [],
234
+ pendingToolResults,
235
+ },
236
+ 0,
237
+ PREVIEW_WIDTH,
238
+ );
239
+ const text = collectText({
240
+ type: "vstack",
241
+ props: {},
242
+ children: nodes,
243
+ });
244
+
245
+ // Assert
246
+ expect(text).toContain("Working...");
247
+ expect(text.filter((line) => line === "[shell ->]")).toHaveLength(1);
248
+ expect(text).toContain("echo hi");
249
+ expect(text).toContain("[shell <-]");
250
+ expect(text).toContain("partial output");
251
+ expect(text).not.toContain("Exit code: 0");
252
+ });
253
+
254
+ test("buildConversationLogNodes with a sliced window keeps hidden tool-call args available for compact edit results", () => {
255
+ // Arrange
256
+ const nodes = buildConversationLogNodes(
257
+ {
258
+ messages: [
259
+ fauxAssistantMessage([
260
+ fauxToolCall(
261
+ "edit",
262
+ {
263
+ path: "src/app.ts",
264
+ oldText: "before",
265
+ newText: "after",
266
+ },
267
+ { id: "tool-1" },
268
+ ),
269
+ ]),
270
+ {
271
+ role: "toolResult" as const,
272
+ toolCallId: "tool-1",
273
+ toolName: "edit",
274
+ content: [{ type: "text" as const, text: "Edited src/app.ts" }],
275
+ isError: false,
276
+ timestamp: Date.now(),
277
+ },
278
+ ],
279
+ showReasoning: false,
280
+ verbose: false,
281
+ theme: DEFAULT_THEME,
282
+ },
283
+ {
284
+ isStreaming: false,
285
+ content: [],
286
+ pendingToolResults: [],
287
+ },
288
+ 1,
289
+ PREVIEW_WIDTH,
290
+ );
291
+ const text = collectText({
292
+ type: "vstack",
293
+ props: {},
294
+ children: nodes,
295
+ });
296
+
297
+ // Assert
298
+ expect(text).toContain("[edit <-]");
299
+ expect(text).toContain("~ src/app.ts");
300
+ expect(text).not.toContain("before");
301
+ expect(text).not.toContain("after");
302
+ });
303
+
304
+ test("buildConversationLogNodes with unchanged state reuses cached committed nodes", () => {
305
+ // Arrange
306
+ const state = {
307
+ messages: [fauxAssistantMessage("Committed response")],
308
+ showReasoning: false,
309
+ verbose: false,
310
+ theme: DEFAULT_THEME,
311
+ };
312
+ const streaming = {
313
+ isStreaming: false,
314
+ content: [],
315
+ pendingToolResults: [],
316
+ };
317
+
318
+ // Act
319
+ const first = buildConversationLogNodes(state, streaming, 0, PREVIEW_WIDTH);
320
+ const second = buildConversationLogNodes(
321
+ state,
322
+ streaming,
323
+ 0,
324
+ PREVIEW_WIDTH,
325
+ );
326
+
327
+ // Assert
328
+ expect(second).toBe(first);
329
+ });
330
+
331
+ test("buildConversationLogNodes with only a new streaming tail reuses the committed prefix", () => {
332
+ // Arrange
333
+ const state = {
334
+ messages: [fauxAssistantMessage("Committed response")],
335
+ showReasoning: false,
336
+ verbose: false,
337
+ theme: DEFAULT_THEME,
338
+ };
339
+
340
+ // Act
341
+ const committed = buildConversationLogNodes(
342
+ state,
343
+ {
344
+ isStreaming: false,
345
+ content: [],
346
+ pendingToolResults: [],
347
+ },
348
+ 0,
349
+ PREVIEW_WIDTH,
350
+ );
351
+ const withStreamingTail = buildConversationLogNodes(
352
+ state,
353
+ {
354
+ isStreaming: true,
355
+ content: [fauxText("Streaming tail")],
356
+ pendingToolResults: [],
357
+ },
358
+ 0,
359
+ PREVIEW_WIDTH,
360
+ );
361
+ const text = collectText({
362
+ type: "vstack",
363
+ props: {},
364
+ children: withStreamingTail,
365
+ });
366
+
367
+ // Assert
368
+ expect(withStreamingTail[0]).toBe(committed[0]);
369
+ expect(text).toContain("Committed response");
370
+ expect(text).toContain("Streaming tail");
371
+ });
372
+
373
+ test("buildConversationLogNodes when verbose mode changes rebuilds cached tool nodes", async () => {
374
+ // Arrange
375
+ const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
376
+ "\n",
377
+ );
378
+ const state = {
379
+ messages: [
380
+ fauxAssistantMessage([
381
+ fauxToolCall("shell", { command: "seq 1 25" }, { id: "tool-1" }),
382
+ ]),
383
+ {
384
+ role: "toolResult" as const,
385
+ toolCallId: "tool-1",
386
+ toolName: "shell",
387
+ content: [{ type: "text" as const, text: output }],
388
+ isError: false,
389
+ timestamp: Date.now(),
390
+ },
391
+ ],
392
+ showReasoning: false,
393
+ verbose: false,
394
+ theme: DEFAULT_THEME,
395
+ };
396
+
397
+ // Act
398
+ const previewNodes = buildConversationLogNodes(
399
+ state,
400
+ {
401
+ isStreaming: false,
402
+ content: [],
403
+ pendingToolResults: [],
404
+ },
405
+ 0,
406
+ PREVIEW_WIDTH,
407
+ );
408
+ const previewText = await renderVisibleText(
409
+ VStack({}, previewNodes),
410
+ PREVIEW_WIDTH,
411
+ 24,
412
+ );
413
+
414
+ const verboseNodes = buildConversationLogNodes(
415
+ { ...state, verbose: true },
416
+ {
417
+ isStreaming: false,
418
+ content: [],
419
+ pendingToolResults: [],
420
+ },
421
+ 0,
422
+ PREVIEW_WIDTH,
423
+ );
424
+ const verboseText = await renderVisibleText(
425
+ VStack({}, verboseNodes),
426
+ PREVIEW_WIDTH,
427
+ 40,
428
+ );
429
+
430
+ // Assert
431
+ expect(previewNodes).not.toBe(verboseNodes);
432
+ expect(previewText).toContain("line 18");
433
+ expect(previewText).toContain("line 25");
434
+ expect(previewText).toContain("And 17 lines more");
435
+ expect(previewText).not.toContain("line 17");
436
+ expect(verboseText).toContain("line 17");
437
+ expect(verboseText).toContain("line 25");
438
+ expect(verboseText).not.toContain("And 17 lines more");
439
+ });
440
+
441
+ test("buildConversationLogNodes when preview width changes rebuilds cached tool nodes", () => {
442
+ // Arrange
443
+ const state = {
444
+ messages: [
445
+ fauxAssistantMessage([
446
+ fauxToolCall(
447
+ "shell",
448
+ {
449
+ command:
450
+ "printf 'this is a very long wrapped line that depends on width'",
451
+ },
452
+ { id: "tool-1" },
453
+ ),
454
+ ]),
455
+ ],
456
+ showReasoning: false,
457
+ verbose: false,
458
+ theme: DEFAULT_THEME,
459
+ };
460
+ const streaming = {
461
+ isStreaming: false,
462
+ content: [],
463
+ pendingToolResults: [],
464
+ };
465
+
466
+ // Act
467
+ const wide = buildConversationLogNodes(state, streaming, 0, 40);
468
+ const narrow = buildConversationLogNodes(state, streaming, 0, 20);
469
+
470
+ // Assert
471
+ expect(narrow).not.toBe(wide);
472
+ });
473
+
474
+ test("renderAssistantMessage with in-progress reasoning visible shows thinking text", () => {
475
+ // Arrange
476
+ const assistant = {
477
+ content: [fauxThinking("Reasoning in progress")],
478
+ };
479
+
480
+ // Act
481
+ const text = collectText(
482
+ renderAssistantMessage(assistant, {
483
+ ...RENDER_OPTS,
484
+ showReasoning: true,
485
+ }),
486
+ );
487
+
488
+ // Assert
489
+ expect(text).toContain("Reasoning in progress");
490
+ });
491
+
492
+ test("renderAssistantMessage with in-progress reasoning hidden shows a one-line placeholder", () => {
493
+ // Arrange
494
+ const assistant = {
495
+ content: [fauxThinking("some thinking")],
496
+ };
497
+
498
+ // Act
499
+ const text = collectText(
500
+ renderAssistantMessage(assistant, {
501
+ ...RENDER_OPTS,
502
+ showReasoning: false,
503
+ }),
504
+ );
505
+
506
+ // Assert
507
+ expect(text).toContain("Thinking... 1 line.");
508
+ });
509
+
510
+ test("renderAssistantMessage for a shell tool call shows the command under the new header pill", () => {
511
+ // Arrange
512
+ const assistant = {
513
+ content: [
514
+ fauxToolCall("shell", { command: "echo hi" }, { id: "tool-1" }),
515
+ ],
516
+ };
517
+
518
+ // Act
519
+ const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
520
+
521
+ // Assert
522
+ expect(text).toContain("[shell ->]");
523
+ expect(text).toContain("echo hi");
524
+ expect(text).not.toContain('"command": "echo hi"');
525
+ });
526
+
527
+ test("renderAssistantMessage for a wrapped shell command keeps a fixed preview height when verbose is off", () => {
528
+ // Arrange
529
+ const command = Array.from(
530
+ { length: 4 },
531
+ () =>
532
+ "printf 'this wrapped command line is intentionally long for the preview'",
533
+ ).join("\n");
534
+ const assistant = {
535
+ content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
536
+ };
537
+
538
+ // Act
539
+ const node = renderAssistantMessage(assistant, {
540
+ ...RENDER_OPTS,
541
+ previewWidth: 24,
542
+ });
543
+ const height = measureRenderedHeight(node, 24);
544
+
545
+ // Assert
546
+ expect(height).toBe(10);
547
+ });
548
+
549
+ test("renderAssistantMessage for a long single-line shell command keeps the command start visible in non-verbose mode", async () => {
550
+ // Arrange
551
+ const command = `IMPORTANT_PREFIX ${"x".repeat(400)}`;
552
+ const assistant = {
553
+ content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
554
+ };
555
+
556
+ // Act
557
+ const text = await renderVisibleText(
558
+ renderAssistantMessage(assistant, {
559
+ ...RENDER_OPTS,
560
+ previewWidth: 24,
561
+ }),
562
+ 24,
563
+ 20,
564
+ );
565
+
566
+ // Assert
567
+ expect(text).toContain("IMPORTANT_PREFIX");
568
+ });
569
+
570
+ test("renderToolResult for a shell preview allows the outer conversation scroll to handle mouse wheel events", async () => {
571
+ // Arrange
572
+ const terminal = new MockTerminal(24, 10);
573
+ let outerScrollOffset = 0;
574
+ const toolNode = renderToolResult(
575
+ "shell",
576
+ { command: "seq 1 25" },
577
+ Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n"),
578
+ false,
579
+ {
580
+ ...RENDER_OPTS,
581
+ previewWidth: 24,
582
+ },
583
+ );
584
+
585
+ cel.init(terminal);
586
+ cel.viewport(() =>
587
+ VStack(
588
+ {
589
+ width: 24,
590
+ height: 10,
591
+ overflow: "scroll",
592
+ scrollOffset: outerScrollOffset,
593
+ onScroll: (offset) => {
594
+ outerScrollOffset = offset;
595
+ },
596
+ },
597
+ [
598
+ Text("before 1"),
599
+ toolNode,
600
+ Text("after 1"),
601
+ Text("after 2"),
602
+ Text("after 3"),
603
+ Text("after 4"),
604
+ Text("after 5"),
605
+ ],
606
+ ),
607
+ );
608
+ await waitForCelRender();
609
+
610
+ // Act
611
+ terminal.sendInput("\x1b[<65;4;3M");
612
+ await waitForCelRender();
613
+
614
+ // Assert
615
+ expect(outerScrollOffset).toBeGreaterThan(0);
616
+ });
617
+
618
+ test("renderAssistantMessage for a readImage tool call shows the path rather than JSON", () => {
619
+ // Arrange
620
+ const assistant = {
621
+ content: [
622
+ fauxToolCall(
623
+ "readImage",
624
+ { path: "assets/preview.png" },
625
+ { id: "tool-1" },
626
+ ),
627
+ ],
628
+ };
629
+
630
+ // Act
631
+ const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
632
+
633
+ // Assert
634
+ expect(text).toContain("[read image ->]");
635
+ expect(text).toContain("assets/preview.png");
636
+ expect(text).not.toContain("{");
637
+ });
638
+
639
+ test("renderAssistantMessage for an edit tool call shows both old and new content without diff prefixes", () => {
640
+ // Arrange
641
+ const assistant = {
642
+ content: [
643
+ fauxToolCall(
644
+ "edit",
645
+ {
646
+ path: "src/file.ts",
647
+ oldText: "old line",
648
+ newText: "new line",
649
+ },
650
+ { id: "tool-1" },
651
+ ),
652
+ ],
653
+ };
654
+
655
+ // Act
656
+ const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
657
+
658
+ // Assert
659
+ expect(text).toContain("[edit ->]");
660
+ expect(text).toContain("src/file.ts");
661
+ expect(text).toContain("old line");
662
+ expect(text).toContain("new line");
663
+ expect(text).not.toContain("+new line");
664
+ expect(text).not.toContain("-old line");
665
+ });
666
+
667
+ test("renderAssistantMessage for an edit tool call colors old text red and new text green", async () => {
668
+ // Arrange
669
+ const assistant = {
670
+ content: [
671
+ fauxToolCall(
672
+ "edit",
673
+ {
674
+ path: "src/file.ts",
675
+ oldText: "old line",
676
+ newText: "new line",
677
+ },
678
+ { id: "tool-1" },
679
+ ),
680
+ ],
681
+ };
682
+
683
+ // Act
684
+ const rows = await renderBufferRows(
685
+ renderAssistantMessage(assistant, RENDER_OPTS),
686
+ PREVIEW_WIDTH,
687
+ 12,
688
+ );
689
+ const oldRow = rows.find((row) => row.text.includes("old line"));
690
+ const newRow = rows.find((row) => row.text.includes("new line"));
691
+
692
+ // Assert
693
+ expect(oldRow).toBeDefined();
694
+ expect(newRow).toBeDefined();
695
+
696
+ const oldColor = oldRow?.fgColors[oldRow.text.indexOf("o")];
697
+ const newColor = newRow?.fgColors[newRow.text.indexOf("n")];
698
+ expect(oldColor).toBe(DEFAULT_THEME.diffRemoved ?? null);
699
+ expect(newColor).toBe(DEFAULT_THEME.diffAdded ?? null);
700
+ });
701
+
702
+ test("renderToolResult for shell output in non-verbose mode shows the visible tail under a result header", async () => {
703
+ // Arrange
704
+ const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
705
+ "\n",
706
+ );
707
+
708
+ // Act
709
+ const text = await renderVisibleText(
710
+ renderToolResult(
711
+ "shell",
712
+ { command: "seq 1 25" },
713
+ output,
714
+ false,
715
+ RENDER_OPTS,
716
+ ),
717
+ PREVIEW_WIDTH,
718
+ 24,
719
+ );
720
+
721
+ // Assert
722
+ expect(text).toContain("[shell <-]");
723
+ expect(text).toContain("line 18");
724
+ expect(text).toContain("line 25");
725
+ expect(text).toContain("And 17 lines more");
726
+ expect(text).not.toContain("line 17");
727
+ expect(text).not.toContain("seq 1 25");
728
+ });
729
+
730
+ test("renderToolResult for shell output in verbose mode shows the full stored output", () => {
731
+ // Arrange
732
+ const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
733
+ "\n",
734
+ );
735
+
736
+ // Act
737
+ const text = collectText(
738
+ renderToolResult("shell", { command: "seq 1 25" }, output, false, {
739
+ ...RENDER_OPTS,
740
+ verbose: true,
741
+ }),
742
+ );
743
+
744
+ // Assert
745
+ expect(text).toContain("line 17");
746
+ expect(text).toContain("line 25");
747
+ expect(text).not.toContain("And 17 lines more");
748
+ });
749
+
750
+ test("renderToolResult for shell errors normalizes exit-code and stderr labels", () => {
751
+ // Arrange
752
+ const resultText = "Exit code: 42\n[stderr]\nboom";
753
+
754
+ // Act
755
+ const text = collectText(
756
+ renderToolResult(
757
+ "shell",
758
+ { command: "exit 42" },
759
+ resultText,
760
+ true,
761
+ RENDER_OPTS,
762
+ ),
763
+ );
764
+
765
+ // Assert
766
+ expect(text).toContain("[shell <-]");
767
+ expect(text).toContain("exit 42");
768
+ expect(text).toContain("boom");
769
+ expect(text).not.toContain("Exit code: 42");
770
+ expect(text).not.toContain("[stderr]");
771
+ });
772
+
773
+ test("renderToolResult for a readImage success shows a compact path result", () => {
774
+ // Arrange
775
+ const args = { path: "diagram.png" };
776
+
777
+ // Act
778
+ const text = collectText(
779
+ renderToolResult("readImage", args, "", false, RENDER_OPTS),
780
+ );
781
+
782
+ // Assert
783
+ expect(text).toContain("[read image <-]");
784
+ expect(text).toContain("diagram.png");
785
+ expect(text).not.toContain("Read image.");
786
+ });
787
+
788
+ test("renderToolResult for a readImage error shows the full error even when verbose is off", () => {
789
+ // Arrange
790
+ const errorText = Array.from(
791
+ { length: 25 },
792
+ (_, i) => `error ${i + 1}`,
793
+ ).join("\n");
794
+
795
+ // Act
796
+ const text = collectText(
797
+ renderToolResult(
798
+ "readImage",
799
+ { path: "diagram.png" },
800
+ errorText,
801
+ true,
802
+ RENDER_OPTS,
803
+ ),
804
+ );
805
+
806
+ // Assert
807
+ expect(text).toContain("error 1");
808
+ expect(text).toContain("error 25");
809
+ expect(text.some((line) => /^And \d+ lines more$/.test(line))).toBe(false);
810
+ });
811
+
812
+ test("renderToolResult for a successful edit stays compact regardless of verbose mode", () => {
813
+ // Arrange
814
+ const args = {
815
+ path: "src/file.ts",
816
+ oldText: "before",
817
+ newText: "after",
818
+ };
819
+
820
+ // Act
821
+ const previewText = collectText(
822
+ renderToolResult("edit", args, "Edited src/file.ts", false, RENDER_OPTS),
823
+ );
824
+ const verboseText = collectText(
825
+ renderToolResult("edit", args, "Edited src/file.ts", false, {
826
+ ...RENDER_OPTS,
827
+ verbose: true,
828
+ }),
829
+ );
830
+
831
+ // Assert
832
+ expect(previewText).toContain("[edit <-]");
833
+ expect(previewText).toContain("~ src/file.ts");
834
+ expect(previewText).not.toContain("before");
835
+ expect(previewText).not.toContain("after");
836
+ expect(previewText).not.toContain("And 1 lines more");
837
+ expect(verboseText).toEqual(previewText);
838
+ });
839
+
840
+ test("renderToolResult for an edit error uses the preview policy in non-verbose mode", async () => {
841
+ // Arrange
842
+ const errorText = Array.from(
843
+ { length: 25 },
844
+ (_, i) => `error ${i + 1}`,
845
+ ).join("\n");
846
+
847
+ // Act
848
+ const text = await renderVisibleText(
849
+ renderToolResult(
850
+ "edit",
851
+ {
852
+ path: "src/file.ts",
853
+ oldText: "before",
854
+ newText: "after",
855
+ },
856
+ errorText,
857
+ true,
858
+ RENDER_OPTS,
859
+ ),
860
+ PREVIEW_WIDTH,
861
+ 24,
862
+ );
863
+
864
+ // Assert
865
+ expect(text).toContain("[edit <-]");
866
+ expect(text).toContain("error 18");
867
+ expect(text).toContain("error 25");
868
+ expect(text).toContain("And 17 lines more");
869
+ expect(text).not.toContain("error 17");
870
+ });
871
+
872
+ test("renderToolResult for a generic plugin tool uses the shared result header", () => {
873
+ // Arrange
874
+ const args = { query: "session persistence sqlite turn numbering" };
875
+
876
+ // Act
877
+ const text = collectText(
878
+ renderToolResult(
879
+ "mcp/search",
880
+ args,
881
+ "session persistence sqlite turn numbering",
882
+ false,
883
+ RENDER_OPTS,
884
+ ),
885
+ );
886
+
887
+ // Assert
888
+ expect(text).toContain("[mcp/search <-]");
889
+ expect(text).toContain("session persistence sqlite turn numbering");
890
+ expect(text).not.toContain('"query"');
891
+ });
892
+ });