@pi-archimedes/ui 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/package.json +44 -0
  4. package/src/bash/index.ts +10 -0
  5. package/src/bash/renderer.test.ts +424 -0
  6. package/src/bash/renderer.ts +301 -0
  7. package/src/bash/tool.test.ts +26 -0
  8. package/src/bash/tool.ts +17 -0
  9. package/src/codemode/index.ts +20 -0
  10. package/src/codemode/loader.test.ts +139 -0
  11. package/src/codemode/loader.ts +115 -0
  12. package/src/codemode/renderer.test.ts +717 -0
  13. package/src/codemode/renderer.ts +399 -0
  14. package/src/codemode/tool.test.ts +149 -0
  15. package/src/codemode/tool.ts +96 -0
  16. package/src/config.test.ts +199 -0
  17. package/src/config.ts +129 -0
  18. package/src/editor/index.test.ts +820 -0
  19. package/src/editor/index.ts +380 -0
  20. package/src/editor/spin-quips.test.ts +106 -0
  21. package/src/editor/spin-quips.ts +44 -0
  22. package/src/editor/spin.test.ts +763 -0
  23. package/src/editor/spin.ts +493 -0
  24. package/src/index.test.ts +402 -0
  25. package/src/index.ts +235 -0
  26. package/src/migration.test.ts +256 -0
  27. package/src/migration.ts +94 -0
  28. package/src/settings.test.ts +122 -0
  29. package/src/settings.ts +135 -0
  30. package/src/startup/capture.ts +47 -0
  31. package/src/startup/index.test.ts +175 -0
  32. package/src/startup/index.ts +348 -0
  33. package/src/startup/logo.test.ts +260 -0
  34. package/src/startup/logo.ts +85 -0
  35. package/src/startup/sections.test.ts +333 -0
  36. package/src/startup/sections.ts +306 -0
  37. package/src/startup/version.test.ts +98 -0
  38. package/src/startup/version.ts +26 -0
  39. package/src/thinking/patch.test.ts +857 -0
  40. package/src/thinking/patch.ts +407 -0
  41. package/src/thinking/theme.test.ts +216 -0
  42. package/src/thinking/theme.ts +156 -0
  43. package/src/thinking/transform.test.ts +89 -0
  44. package/src/thinking/transform.ts +21 -0
  45. package/src/thinking/unindent.test.ts +74 -0
  46. package/src/thinking/unindent.ts +74 -0
@@ -0,0 +1,717 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ formatCodemodeDuration,
5
+ formatCost,
6
+ formatNestedCall,
7
+ getCodemodeOutput,
8
+ parseWallTimeMs,
9
+ renderCodemodeCall,
10
+ renderCodemodeResult,
11
+ clearActiveCodemodeIntervals,
12
+ } from "./renderer.js";
13
+
14
+ // Mock Text from @earendil-works/pi-tui while preserving real utility
15
+ // functions (imageFallback, getImageDimensions, …).
16
+ vi.mock("@earendil-works/pi-tui", async () => {
17
+ const actual =
18
+ await vi.importActual<typeof import("@earendil-works/pi-tui")>(
19
+ "@earendil-works/pi-tui",
20
+ );
21
+ class MockText {
22
+ private _content = "";
23
+
24
+ constructor(initial: string = "") {
25
+ this._content = initial;
26
+ }
27
+
28
+ setText(content: string): void {
29
+ this._content = content;
30
+ }
31
+
32
+ getContent(): string {
33
+ return this._content;
34
+ }
35
+
36
+ invalidate(): void {}
37
+
38
+ render(): string[] {
39
+ return this._content ? this._content.split("\n") : [""];
40
+ }
41
+ }
42
+ return {
43
+ ...actual,
44
+ Text: MockText,
45
+ };
46
+ });
47
+
48
+ // Mock highlightCode from the agent package (Shiki-backed, not available in
49
+ // unit tests) with a stable per-line marker.
50
+ vi.mock("@earendil-works/pi-coding-agent", async () => {
51
+ const actual =
52
+ await vi.importActual<typeof import("@earendil-works/pi-coding-agent")>(
53
+ "@earendil-works/pi-coding-agent",
54
+ );
55
+ return {
56
+ ...actual,
57
+ highlightCode: (code: string) =>
58
+ code.split("\n").map((line) => `<hl:${line}>`),
59
+ };
60
+ });
61
+
62
+ type ComponentShim = { render(width: number): string[] };
63
+ const lines = (c: unknown) => (c as unknown as ComponentShim).render(80);
64
+ const text = (c: unknown) => lines(c).join("\n");
65
+
66
+ // Test theme wrapping in markers
67
+ const theme = {
68
+ fg: (token: string, text?: string) =>
69
+ text === undefined ? `[${token}]` : `[${token}:${text}]`,
70
+ bold: (t: string) => `**${t}**`,
71
+ } as unknown as Theme;
72
+
73
+ describe("formatCodemodeDuration", () => {
74
+ it("formats milliseconds for under a second", () => {
75
+ expect(formatCodemodeDuration(0)).toBe("0ms");
76
+ expect(formatCodemodeDuration(42)).toBe("42ms");
77
+ expect(formatCodemodeDuration(999)).toBe("999ms");
78
+ });
79
+
80
+ it("formats seconds with one decimal at a second and up", () => {
81
+ expect(formatCodemodeDuration(1000)).toBe("1.0s");
82
+ expect(formatCodemodeDuration(1500)).toBe("1.5s");
83
+ expect(formatCodemodeDuration(12345)).toBe("12.3s");
84
+ });
85
+
86
+ it("returns an empty string for undefined and clamps negatives", () => {
87
+ expect(formatCodemodeDuration(undefined)).toBe("");
88
+ expect(formatCodemodeDuration(-5)).toBe("0ms");
89
+ });
90
+ });
91
+
92
+ describe("formatCost", () => {
93
+ it("formats dollars with two decimals at a cent and up", () => {
94
+ expect(formatCost(0.5)).toBe("$0.50");
95
+ expect(formatCost(1.234)).toBe("$1.23");
96
+ });
97
+
98
+ it("formats fractions of a cent with two significant digits", () => {
99
+ expect(formatCost(0.001234)).toBe("$0.0012");
100
+ expect(formatCost(0.0005)).toBe("$0.00050");
101
+ });
102
+ });
103
+
104
+ describe("formatNestedCall", () => {
105
+ it("renders glyph, name, args, duration, and cost", () => {
106
+ const line = formatNestedCall(
107
+ {
108
+ id: "1/1",
109
+ name: "mcp__postgres__run_select",
110
+ args: '{"sql":"SELECT 1"}',
111
+ status: "ok",
112
+ durationMs: 32,
113
+ cost: 0.0012,
114
+ },
115
+ theme,
116
+ false,
117
+ );
118
+ expect(line).toBe(
119
+ "[success:✓] [toolTitle:mcp__postgres__run_select] [muted:{\"sql\":\"SELECT 1\"}] [dim:32ms] [dim:$0.0012]",
120
+ );
121
+ });
122
+
123
+ it("truncates long args when collapsed but not when expanded", () => {
124
+ const args = "a".repeat(100);
125
+ const collapsed = formatNestedCall(
126
+ { id: "1", name: "t", args, status: "ok" },
127
+ theme,
128
+ false,
129
+ );
130
+ expect(collapsed).toContain(`[muted:${"a".repeat(77)}...]`);
131
+ const expanded = formatNestedCall(
132
+ { id: "1", name: "t", args, status: "ok" },
133
+ theme,
134
+ true,
135
+ );
136
+ expect(expanded).toContain(`[muted:${args}]`);
137
+ });
138
+
139
+ it("omits empty args, duration, and cost", () => {
140
+ const line = formatNestedCall(
141
+ { id: "1", name: "t", args: "", status: "running" },
142
+ theme,
143
+ false,
144
+ );
145
+ expect(line).toBe("[warning:…] [toolTitle:t]");
146
+ });
147
+
148
+ it("appends the indented error text only when expanded", () => {
149
+ const call = {
150
+ id: "1",
151
+ name: "t",
152
+ args: "",
153
+ status: "error" as const,
154
+ error: "line1\nline2",
155
+ };
156
+ expect(formatNestedCall(call, theme, false)).toBe("[error:✗] [toolTitle:t]");
157
+ // The whole multi-line error is one styled block, indented per line.
158
+ expect(formatNestedCall(call, theme, true)).toBe(
159
+ "[error:✗] [toolTitle:t]\n [error:line1\n line2]",
160
+ );
161
+ });
162
+
163
+ it("uses a muted glyph for cancelled calls", () => {
164
+ const line = formatNestedCall(
165
+ { id: "1", name: "t", args: "", status: "cancelled" },
166
+ theme,
167
+ false,
168
+ );
169
+ expect(line).toBe("[muted:⊘] [toolTitle:t]");
170
+ });
171
+ });
172
+
173
+ describe("parseWallTimeMs", () => {
174
+ it("parses the wall time from the result header", () => {
175
+ expect(
176
+ parseWallTimeMs([
177
+ { type: "text", text: "Script completed\nWall time 0.4 seconds\nOutput:\n" },
178
+ ]),
179
+ ).toBe(400);
180
+ });
181
+
182
+ it("parses failed-script headers too", () => {
183
+ expect(
184
+ parseWallTimeMs([
185
+ { type: "text", text: "Script failed\nWall time 12.5 seconds\nOutput:\n" },
186
+ ]),
187
+ ).toBe(12500);
188
+ });
189
+
190
+ it("returns undefined without a header or for non-text first blocks", () => {
191
+ expect(parseWallTimeMs([{ type: "text", text: "plain output" }])).toBe(
192
+ undefined,
193
+ );
194
+ expect(parseWallTimeMs([{ type: "image" }])).toBe(undefined);
195
+ expect(parseWallTimeMs([])).toBe(undefined);
196
+ });
197
+ });
198
+
199
+ describe("getCodemodeOutput", () => {
200
+ it("drops the script header when present", () => {
201
+ const out = getCodemodeOutput([
202
+ {
203
+ type: "text",
204
+ text: "Script completed\nWall time 0.1 seconds\nOutput:\n",
205
+ },
206
+ { type: "text", text: "line1\nline2" },
207
+ ]);
208
+ expect(out).toBe("line1\nline2");
209
+ });
210
+
211
+ it("keeps the first block when it has no header", () => {
212
+ const out = getCodemodeOutput([{ type: "text", text: "just output" }]);
213
+ expect(out).toBe("just output");
214
+ });
215
+
216
+ it("joins text blocks and appends a fallback indicator for image blocks", () => {
217
+ // The renderer is text-only, so image blocks ALWAYS get the fallback
218
+ // indicator (mime type + dimensions when known), regardless of the
219
+ // terminal's inline-image support.
220
+ const out = getCodemodeOutput([
221
+ { type: "text", text: "a" },
222
+ { type: "image", data: "x", mimeType: "image/png" },
223
+ { type: "text", text: "b" },
224
+ ]);
225
+ expect(out).toContain("a\nb");
226
+ expect(out).toContain("image/png");
227
+ });
228
+
229
+ it("returns only the indicators when there is no text output", () => {
230
+ const out = getCodemodeOutput([
231
+ { type: "image", data: "x", mimeType: "image/jpeg" },
232
+ ]);
233
+ expect(out).toContain("image/jpeg");
234
+ expect(out).not.toContain("undefined");
235
+ });
236
+ });
237
+
238
+ describe("renderCodemodeCall", () => {
239
+ it("renders the bold codemode header when code is absent", () => {
240
+ const out = renderCodemodeCall({}, theme, {});
241
+ expect(text(out)).toBe("[toolTitle:**codemode**]");
242
+ });
243
+
244
+ it("renders an invalid-arg marker for a non-string code", () => {
245
+ const out = renderCodemodeCall({ code: 42 }, theme, {});
246
+ expect(text(out)).toBe("[toolTitle:**codemode**] [error:[invalid arg]]");
247
+ });
248
+
249
+ it("falls back to context.args.code when absent from args (expanded)", () => {
250
+ const out = renderCodemodeCall(
251
+ {},
252
+ theme,
253
+ { args: { code: "console.log(1)" }, expanded: true },
254
+ );
255
+ expect(text(out)).toContain("<hl:console.log(1)>");
256
+ });
257
+
258
+ it("renders the full highlighted script when expanded", () => {
259
+ const out = renderCodemodeCall(
260
+ { code: "line1\nline2" },
261
+ theme,
262
+ { expanded: true },
263
+ );
264
+ expect(text(out)).toBe("[toolTitle:**codemode**]\n<hl:line1>\n<hl:line2>");
265
+ });
266
+
267
+ it("shows the header alone when collapsed", () => {
268
+ const code = Array.from({ length: 15 }, (_, i) => `l${i}`).join("\n");
269
+ const out = renderCodemodeCall({ code }, theme, {});
270
+ expect(text(out)).toBe("[toolTitle:**codemode**]");
271
+ });
272
+
273
+ it("shows the header alone when collapsed, even for short scripts", () => {
274
+ const out = renderCodemodeCall({ code: "a\nb" }, theme, {});
275
+ expect(text(out)).toBe("[toolTitle:**codemode**]");
276
+ });
277
+
278
+ it("normalizes tabs and carriage returns in the script (expanded)", () => {
279
+ const out = renderCodemodeCall(
280
+ { code: "a\tb\r\nc" },
281
+ theme,
282
+ { expanded: true },
283
+ );
284
+ expect(text(out)).toContain("<hl:a b>");
285
+ });
286
+ });
287
+
288
+ describe("renderCodemodeResult - Collapsed view (Minimal)", () => {
289
+ it("renders the success glyph and the wall time from the header", () => {
290
+ const out = renderCodemodeResult(
291
+ {
292
+ content: [
293
+ {
294
+ type: "text",
295
+ text: "Script completed\nWall time 0.4 seconds\nOutput:\n",
296
+ },
297
+ { type: "text", text: "done" },
298
+ ],
299
+ },
300
+ { expanded: false },
301
+ theme,
302
+ {},
303
+ );
304
+ expect(text(out)).toBe("[success:✓] [muted:400ms]");
305
+ });
306
+
307
+ it("shows the nested calls without a summary line when calls were made", () => {
308
+ const out = renderCodemodeResult(
309
+ {
310
+ content: [
311
+ {
312
+ type: "text",
313
+ text: "Script completed\nWall time 1.2 seconds\nOutput:\n",
314
+ },
315
+ ],
316
+ details: {
317
+ calls: [
318
+ { id: "1/1", name: "a", args: "x", status: "ok", durationMs: 30 },
319
+ { id: "1/2", name: "b", args: "y", status: "ok", durationMs: 40 },
320
+ ],
321
+ },
322
+ },
323
+ { expanded: false },
324
+ theme,
325
+ {},
326
+ );
327
+ expect(text(out)).toBe(
328
+ [
329
+ "[success:✓] [toolTitle:a] [muted:x] [dim:30ms]",
330
+ "[success:✓] [toolTitle:b] [muted:y] [dim:40ms]",
331
+ ].join("\n"),
332
+ );
333
+ });
334
+
335
+ it("previews the last 5 calls with a hint when more were made", () => {
336
+ const calls = Array.from({ length: 8 }, (_, i) => ({
337
+ id: `1/${i + 1}`,
338
+ name: `t${i}`,
339
+ args: "",
340
+ status: "ok" as const,
341
+ }));
342
+ const out = renderCodemodeResult(
343
+ {
344
+ content: [
345
+ {
346
+ type: "text",
347
+ text: "Script completed\nWall time 1.0 seconds\nOutput:\n",
348
+ },
349
+ ],
350
+ details: { calls },
351
+ },
352
+ { expanded: false },
353
+ theme,
354
+ {},
355
+ );
356
+ const outLines = lines(out);
357
+ // hint + last 5 calls (no summary line)
358
+ expect(outLines).toHaveLength(6);
359
+ expect(outLines[0]).toBe("[muted:... (3 earlier calls, ctrl+o to expand)]");
360
+ expect(outLines[1]).toBe("[success:✓] [toolTitle:t3]");
361
+ expect(outLines[5]).toBe("[success:✓] [toolTitle:t7]");
362
+ });
363
+
364
+ it("never shows an output preview in Minimal style, even with long output", () => {
365
+ const out = renderCodemodeResult(
366
+ {
367
+ content: [
368
+ {
369
+ type: "text",
370
+ text: "Script completed\nWall time 1.0 seconds\nOutput:\n",
371
+ },
372
+ { type: "text", text: Array.from({ length: 20 }, (_, i) => `o${i}`).join("\n") },
373
+ ],
374
+ details: { calls: [] },
375
+ },
376
+ { expanded: false },
377
+ theme,
378
+ {},
379
+ );
380
+ expect(text(out)).toBe("[success:✓] [muted:1.0s]");
381
+ });
382
+
383
+ it("shows in-flight calls with the running glyph while isPartial", () => {
384
+ const out = renderCodemodeResult(
385
+ {
386
+ details: {
387
+ calls: [{ id: "1/1", name: "slow_tool", args: "", status: "running" }],
388
+ },
389
+ },
390
+ { expanded: false, isPartial: true },
391
+ theme,
392
+ { state: { startedAt: Date.now() - 1500 } },
393
+ );
394
+ expect(text(out)).toBe("[warning:…] [toolTitle:slow_tool]");
395
+ });
396
+
397
+ it("renders the error glyph when the context marks the result an error", () => {
398
+ const out = renderCodemodeResult(
399
+ {
400
+ content: [
401
+ {
402
+ type: "text",
403
+ text: "Script failed\nWall time 0.2 seconds\nOutput:\n",
404
+ },
405
+ ],
406
+ },
407
+ { expanded: false },
408
+ theme,
409
+ { isError: true },
410
+ );
411
+ expect(text(out)).toBe("[error:✗] [muted:200ms]");
412
+ });
413
+
414
+ it("appends the failure marker when the script failed after successful calls", () => {
415
+ // A failed script whose nested calls succeeded must not look like a
416
+ // success in the collapsed view: the failure marker is appended after
417
+ // the call list.
418
+ const out = renderCodemodeResult(
419
+ {
420
+ content: [
421
+ {
422
+ type: "text",
423
+ text: "Script failed\nWall time 0.2 seconds\nOutput:\n",
424
+ },
425
+ ],
426
+ details: {
427
+ calls: [
428
+ { id: "1/1", name: "a", args: "", status: "ok", durationMs: 30 },
429
+ { id: "1/2", name: "b", args: "", status: "ok", durationMs: 40 },
430
+ ],
431
+ },
432
+ },
433
+ { expanded: false },
434
+ theme,
435
+ { isError: true },
436
+ );
437
+ const outLines = lines(out);
438
+ expect(outLines[outLines.length - 1]).toBe("[error:✗ Script failed]");
439
+ });
440
+
441
+ it("does not append the failure marker while the script is still running", () => {
442
+ const out = renderCodemodeResult(
443
+ {
444
+ details: {
445
+ calls: [{ id: "1/1", name: "a", args: "", status: "ok", durationMs: 30 }],
446
+ },
447
+ },
448
+ { expanded: false, isPartial: true },
449
+ theme,
450
+ { isError: true },
451
+ );
452
+ expect(text(out)).not.toContain("Script failed");
453
+ });
454
+
455
+ it("falls back to the timer when the header is missing", () => {
456
+ const out = renderCodemodeResult(
457
+ { content: [{ type: "text", text: "partial" }] },
458
+ { expanded: false },
459
+ theme,
460
+ { state: { startedAt: 1000, endedAt: 2500 } },
461
+ );
462
+ expect(text(out)).toBe("[success:✓] [muted:1.5s]");
463
+ });
464
+
465
+ it("renders the running glyph and live duration while isPartial", () => {
466
+ const out = renderCodemodeResult(
467
+ {},
468
+ { expanded: false, isPartial: true },
469
+ theme,
470
+ { state: { startedAt: Date.now() - 1500 } },
471
+ );
472
+ expect(text(out)).toBe("[warning:▸] [muted:1.5s]");
473
+ });
474
+ });
475
+
476
+ describe("renderCodemodeResult - Live timer lifecycle", () => {
477
+ beforeEach(() => {
478
+ vi.useFakeTimers();
479
+ });
480
+
481
+ afterEach(() => {
482
+ vi.useRealTimers();
483
+ });
484
+
485
+ it("starts the interval and invalidates the context while isPartial", () => {
486
+ const invalidate = vi.fn();
487
+ const context = {
488
+ executionStarted: true,
489
+ invalidate,
490
+ state: {} as any,
491
+ };
492
+
493
+ renderCodemodeResult({}, { isPartial: true }, theme, context);
494
+ expect(context.state.interval).toBeDefined();
495
+
496
+ vi.advanceTimersByTime(1000);
497
+ expect(invalidate).toHaveBeenCalledTimes(1);
498
+
499
+ vi.advanceTimersByTime(1000);
500
+ expect(invalidate).toHaveBeenCalledTimes(2);
501
+ });
502
+
503
+ it("clears the interval and sets endedAt when the script settles", () => {
504
+ const invalidate = vi.fn();
505
+ const context = {
506
+ executionStarted: true,
507
+ invalidate,
508
+ state: {} as any,
509
+ };
510
+
511
+ renderCodemodeResult({}, { isPartial: true }, theme, context);
512
+ expect(context.state.interval).toBeDefined();
513
+
514
+ renderCodemodeResult(
515
+ { content: [{ type: "text", text: "Script completed\nWall time 0.1 seconds\nOutput:\n" }] },
516
+ { isPartial: false },
517
+ theme,
518
+ context,
519
+ );
520
+ expect(context.state.interval).toBeUndefined();
521
+ expect(context.state.endedAt).toBeDefined();
522
+ });
523
+
524
+ it("clears active codemode intervals when clearActiveCodemodeIntervals is called", () => {
525
+ const context = {
526
+ executionStarted: true,
527
+ invalidate: vi.fn(),
528
+ state: {} as any,
529
+ };
530
+
531
+ renderCodemodeResult({}, { isPartial: true }, theme, context);
532
+ expect(context.state.interval).toBeDefined();
533
+
534
+ clearActiveCodemodeIntervals();
535
+ expect(context.state.interval).toBeUndefined();
536
+ vi.advanceTimersByTime(1000);
537
+ expect(context.invalidate).not.toHaveBeenCalled();
538
+ });
539
+
540
+ it("does not finalize the timer at call-render time (the call is in flight)", () => {
541
+ const invalidate = vi.fn();
542
+ const context = {
543
+ executionStarted: true,
544
+ invalidate,
545
+ state: {} as any,
546
+ };
547
+
548
+ // The call renderer runs while the script is running: it must NOT record
549
+ // endedAt (a fixed end time would freeze the elapsed timer).
550
+ renderCodemodeCall({ code: "await slow()" }, theme, context);
551
+ expect(context.state.endedAt).toBeUndefined();
552
+ expect(context.state.interval).toBeDefined();
553
+
554
+ // Partial results keep the timer advancing.
555
+ renderCodemodeResult({}, { isPartial: true }, theme, context);
556
+ vi.advanceTimersByTime(2000);
557
+ expect(invalidate).toHaveBeenCalledTimes(2);
558
+
559
+ // The final result settles the timer.
560
+ renderCodemodeResult(
561
+ { content: [{ type: "text", text: "Script completed\nWall time 0.1 seconds\nOutput:\n" }] },
562
+ { isPartial: false },
563
+ theme,
564
+ context,
565
+ );
566
+ expect(context.state.interval).toBeUndefined();
567
+ expect(context.state.endedAt).toBeDefined();
568
+ });
569
+
570
+ it("does not recreate the timer interval for a settled call re-render", () => {
571
+ const context = {
572
+ executionStarted: true,
573
+ invalidate: vi.fn(),
574
+ state: {} as any,
575
+ };
576
+
577
+ renderCodemodeResult(
578
+ { content: [{ type: "text", text: "Script completed\nWall time 0.1 seconds\nOutput:\n" }] },
579
+ { isPartial: false },
580
+ theme,
581
+ context,
582
+ );
583
+ expect(context.state.interval).toBeUndefined();
584
+
585
+ // Re-rendering the call after the result settled must not spawn a
586
+ // permanent interval (endedAt is already set).
587
+ renderCodemodeCall({ code: "x" }, theme, context);
588
+ expect(context.state.interval).toBeUndefined();
589
+ vi.advanceTimersByTime(1000);
590
+ expect(context.invalidate).not.toHaveBeenCalled();
591
+ });
592
+ });
593
+
594
+ describe("renderCodemodeResult - Expanded view", () => {
595
+ it("renders nested calls, output, timing, and ✓ Done on success", () => {
596
+ const out = renderCodemodeResult(
597
+ {
598
+ content: [
599
+ {
600
+ type: "text",
601
+ text: "Script completed\nWall time 0.3 seconds\nOutput:\n",
602
+ },
603
+ { type: "text", text: "line1\nline2" },
604
+ ],
605
+ details: {
606
+ calls: [
607
+ {
608
+ id: "1/1",
609
+ name: "mcp__postgres__run_select",
610
+ args: '{"sql":"SELECT 1"}',
611
+ status: "ok",
612
+ durationMs: 32,
613
+ },
614
+ ],
615
+ },
616
+ },
617
+ { expanded: true },
618
+ theme,
619
+ {},
620
+ );
621
+ const outText = text(out);
622
+
623
+ expect(outText).toContain(
624
+ "[success:✓] [toolTitle:mcp__postgres__run_select] [muted:{\"sql\":\"SELECT 1\"}] [dim:32ms]",
625
+ );
626
+ expect(outText).toContain("[toolOutput:line1]");
627
+ expect(outText).toContain("[toolOutput:line2]");
628
+ expect(outText).toContain("[muted:Took 300ms]");
629
+ expect(outText).toContain("[success:✓ Done]");
630
+ });
631
+
632
+ it("renders output lines with error styling and ✗ Script failed on failure", () => {
633
+ const out = renderCodemodeResult(
634
+ {
635
+ content: [
636
+ {
637
+ type: "text",
638
+ text: "Script failed\nWall time 0.1 seconds\nOutput:\n",
639
+ },
640
+ {
641
+ type: "text",
642
+ text: "Script error:\nReferenceError: x is not defined",
643
+ },
644
+ ],
645
+ },
646
+ { expanded: true },
647
+ theme,
648
+ { isError: true },
649
+ );
650
+ const outText = text(out);
651
+
652
+ expect(outText).toContain("[error:Script error:]");
653
+ expect(outText).toContain("[error:ReferenceError: x is not defined]");
654
+ expect(outText).toContain("[muted:Took 100ms]");
655
+ expect(outText).toContain("[error:✗ Script failed]");
656
+ });
657
+
658
+ it("renders the model-call total when more than one call has a cost", () => {
659
+ const out = renderCodemodeResult(
660
+ {
661
+ content: [
662
+ {
663
+ type: "text",
664
+ text: "Script completed\nWall time 2.0 seconds\nOutput:\n",
665
+ },
666
+ ],
667
+ details: {
668
+ calls: [
669
+ { id: "1/1", name: "a", args: "", status: "ok", cost: 0.001 },
670
+ { id: "1/2", name: "b", args: "", status: "ok", cost: 0.002 },
671
+ ],
672
+ },
673
+ },
674
+ { expanded: true },
675
+ theme,
676
+ {},
677
+ );
678
+ expect(text(out)).toContain("[muted:Model calls: $0.0030]");
679
+ });
680
+
681
+ it("renders the full-output path when the output was truncated", () => {
682
+ const out = renderCodemodeResult(
683
+ {
684
+ content: [
685
+ {
686
+ type: "text",
687
+ text: "Script completed\nWall time 1.0 seconds\nOutput:\n",
688
+ },
689
+ { type: "text", text: "partial" },
690
+ ],
691
+ details: {
692
+ calls: [],
693
+ fullOutputPath: "/tmp/pi-codemode-out-123.txt",
694
+ },
695
+ },
696
+ { expanded: true },
697
+ theme,
698
+ {},
699
+ );
700
+ expect(text(out)).toContain(
701
+ "[muted:Full output: /tmp/pi-codemode-out-123.txt]",
702
+ );
703
+ });
704
+
705
+ it("renders Elapsed without the final status while isPartial", () => {
706
+ const out = renderCodemodeResult(
707
+ { content: [{ type: "text", text: "working..." }] },
708
+ { expanded: true, isPartial: true },
709
+ theme,
710
+ { state: { startedAt: Date.now() - 3200 } },
711
+ );
712
+ const outText = text(out);
713
+ expect(outText).toContain("[muted:Elapsed 3.2s]");
714
+ expect(outText).not.toContain("✓ Done");
715
+ expect(outText).not.toContain("✗ Script failed");
716
+ });
717
+ });