pi-supernova 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/render.js ADDED
@@ -0,0 +1,584 @@
1
+ /**
2
+ * Supernova TUI renderers.
3
+ *
4
+ * Pi kills the process if any rendered line's visible width exceeds the terminal
5
+ * (classic failure: 92 > 91). Path-install often cannot resolve @earendil-works/pi-tui,
6
+ * so every width/truncate path here is self-contained and must never trust a host
7
+ * truncate that appends ellipsis after cutting to maxWidth.
8
+ */
9
+
10
+ import { stripVTControlCharacters } from "node:util";
11
+ import { isString, isObject } from "./decode.js";
12
+
13
+ const ELLIPSIS = "…";
14
+
15
+ /**
16
+ * Visible columns — ANSI/OSC stripped, tabs → 3 spaces.
17
+ * ASCII-fast; non-ASCII uses a wide-char heuristic aligned with typical terminal
18
+ * / pi-tui behavior (emoji & symbols like ⚡ are 2 cols — undercount ⇒ 92>91 crash).
19
+ */
20
+ export function measureWidth(text) {
21
+ const raw = String(text ?? "").replace(/\t/g, " ");
22
+ if (raw.length === 0) return 0;
23
+ const plain = raw.includes("\x1b") ? stripVTControlCharacters(raw) : raw;
24
+ if (/^[\x20-\x7e]*$/.test(plain)) return plain.length;
25
+ let width = 0;
26
+ for (const ch of plain) {
27
+ width += codePointWidth(ch.codePointAt(0));
28
+ }
29
+ return width;
30
+ }
31
+
32
+ function codePointWidth(cp) {
33
+ if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) return 0;
34
+ // Fullwidth / wide ranges (CJK, Hangul, emoji blocks we actually emit).
35
+ if (cp >= 0x1100 && cp <= 0x115f) return 2;
36
+ if (cp === 0x2329 || cp === 0x232a) return 2;
37
+ if (cp >= 0x2e80 && cp <= 0xa4cf) return 2;
38
+ if (cp >= 0xac00 && cp <= 0xd7a3) return 2;
39
+ if (cp >= 0xf900 && cp <= 0xfaff) return 2;
40
+ if (cp >= 0xfe10 && cp <= 0xfe19) return 2;
41
+ if (cp >= 0xfe30 && cp <= 0xfe6f) return 2;
42
+ if (cp >= 0xff00 && cp <= 0xff60) return 2;
43
+ if (cp >= 0xffe0 && cp <= 0xffe6) return 2;
44
+ if (cp >= 0x1f300 && cp <= 0x1f64f) return 2;
45
+ if (cp >= 0x1f900 && cp <= 0x1f9ff) return 2;
46
+ if (cp >= 0x20000 && cp <= 0x3fffd) return 2;
47
+ // Ambiguous emoji/symbols pi-tui treats as wide (⚡ U+26A1 was the 92>91 footgun).
48
+ if (cp === 0x26a1 || cp === 0x2b50 || cp === 0x2728) return 2;
49
+ return 1;
50
+ }
51
+
52
+ /**
53
+ * Truncate so the result's visible width is ALWAYS ≤ maxWidth, ellipsis included.
54
+ * Strips ANSI in the truncated region (crash-safety > color fidelity on overflow).
55
+ */
56
+ export function hardTruncate(text, maxWidth, ellipsis = ELLIPSIS) {
57
+ const w = Math.max(0, maxWidth | 0);
58
+ if (w === 0) return "";
59
+ const raw = String(text ?? "").replace(/\t/g, " ");
60
+ if (measureWidth(raw) <= w) return raw;
61
+
62
+ const ell = String(ellipsis);
63
+ const ellW = measureWidth(ell);
64
+ if (ellW >= w) {
65
+ // Degenerate: return as many ellipsis columns as fit.
66
+ if (ellW === 0) return "";
67
+ return ell.slice(0, w);
68
+ }
69
+
70
+ const budget = w - ellW;
71
+ const plain = stripVTControlCharacters(raw);
72
+ let out = "";
73
+ let visible = 0;
74
+ for (const ch of plain) {
75
+ const cw = measureWidth(ch);
76
+ if (visible + cw > budget) break;
77
+ out += ch;
78
+ visible += cw;
79
+ }
80
+ return out + ell;
81
+ }
82
+
83
+ /**
84
+ * Absolute clamp used by every renderer. Loops + hard truncate; never returns > width.
85
+ * Exported for tests and as the single choke point for the Pi crash contract.
86
+ */
87
+ export function clampLine(line, width) {
88
+ const w = Math.max(1, width | 0);
89
+ let out = String(line ?? "").replace(/\t/g, " ");
90
+ if (measureWidth(out) <= w) return out;
91
+ out = hardTruncate(out, w, ELLIPSIS);
92
+ // Belt-and-suspenders: if anything still disagrees, force plain slice.
93
+ if (measureWidth(out) <= w) return out;
94
+ const plain = stripVTControlCharacters(out);
95
+ if (plain.length <= w) return plain;
96
+ if (w === 1) return ELLIPSIS;
97
+ return plain.slice(0, Math.max(0, w - 1)) + ELLIPSIS;
98
+ }
99
+
100
+ /**
101
+ * Wrap plain text to width, preferring breaks after `/` or space so paths stay readable.
102
+ * Every returned chunk is ≤ width (no ellipsis — caller clamps if needed).
103
+ */
104
+ export function wrapPlainToWidth(plain, width) {
105
+ const w = Math.max(1, width | 0);
106
+ const text = String(plain ?? "");
107
+ if (text.length === 0) return [""];
108
+ if (measureWidth(text) <= w) return [text];
109
+
110
+ const lines = [];
111
+ let i = 0;
112
+ while (i < text.length) {
113
+ let end = i;
114
+ let visible = 0;
115
+ let lastBreak = -1;
116
+ while (end < text.length) {
117
+ const ch = text[end];
118
+ const cw = measureWidth(ch);
119
+ if (visible + cw > w) break;
120
+ visible += cw;
121
+ // Only soft-break on path/word separators — never mid-filename (`host-` / `bridge`).
122
+ if (ch === "/" || ch === " ") lastBreak = end + 1;
123
+ end++;
124
+ }
125
+ if (end === i) {
126
+ // Single wide char edge case — force one column advance.
127
+ end = i + 1;
128
+ } else if (end < text.length && lastBreak > i + Math.floor(w * 0.35)) {
129
+ end = lastBreak;
130
+ }
131
+ lines.push(text.slice(i, end));
132
+ i = end;
133
+ }
134
+ return lines.length > 0 ? lines : [""];
135
+ }
136
+
137
+ /**
138
+ * Fit a filesystem path into `budget` columns, keeping the basename visible.
139
+ * `packages/pi-supernova/host-bridge.js` → `…/host-bridge.js` when narrow.
140
+ */
141
+ export function fitPath(pathText, budget) {
142
+ const w = Math.max(1, budget | 0);
143
+ let p = String(pathText ?? "").replace(/\\/g, "/");
144
+ if (measureWidth(p) <= w) return p;
145
+
146
+ const parts = p.split("/").filter(Boolean);
147
+ const base = parts.length > 0 ? parts[parts.length - 1] : p;
148
+ const suffix = parts.length > 1 ? `…/${base}` : base;
149
+ if (measureWidth(suffix) <= w) return suffix;
150
+ return hardTruncate(base, w);
151
+ }
152
+
153
+ function fitOutputLines(text, width) {
154
+ const w = Math.max(1, width | 0);
155
+ const out = [];
156
+ for (const line of String(text ?? "")
157
+ .replace(/\t/g, " ")
158
+ .split("\n")) {
159
+ if (measureWidth(line) <= w) {
160
+ out.push(line);
161
+ continue;
162
+ }
163
+ // Wrap on plain text so long paths continue on the next line instead of
164
+ // dying as `packages/pi-supern…`. ANSI is dropped on wrap (crash-safety).
165
+ const plain = stripVTControlCharacters(line);
166
+ for (const chunk of wrapPlainToWidth(plain, w)) {
167
+ out.push(clampLine(chunk, w));
168
+ }
169
+ }
170
+ return out.length > 0 ? out : [""];
171
+ }
172
+
173
+ /**
174
+ * Soft violet / grey-blue wash — same structure as Pi's standard tool Box
175
+ * (padding + background), just purple-tinted instead of green.
176
+ * Tuned for dark themes (tokyo-night and friends).
177
+ */
178
+ export const NOVA_CHROME = {
179
+ pendingBg: [26, 28, 42], // deep grey-blue
180
+ successBg: [24, 30, 46], // muted blue-purple
181
+ errorBg: [40, 26, 34], // muted rose-purple
182
+ };
183
+
184
+ function bgRgb(rgb, text) {
185
+ const [r, g, b] = rgb;
186
+ return `\x1b[48;2;${r};${g};${b}m${text}\x1b[49m`;
187
+ }
188
+
189
+ function chromeBg(tone) {
190
+ if (tone === "error") return NOVA_CHROME.errorBg;
191
+ if (tone === "success") return NOVA_CHROME.successBg;
192
+ return NOVA_CHROME.pendingBg;
193
+ }
194
+
195
+ /**
196
+ * Paint one row like Pi's Box: 1-col pad + content, washed to full width.
197
+ * No side-rail characters — the background block is the "border".
198
+ * Final visible width is always ≤ `width` (Pi crash contract).
199
+ */
200
+ export function paintNovaRow(content, width, tone = "pending") {
201
+ const w = Math.max(1, width | 0);
202
+ const bg = chromeBg(tone);
203
+ const padX = 1;
204
+ const inner = Math.max(1, w - padX * 2);
205
+ const body = clampLine(content, inner);
206
+ const row = `${" ".repeat(padX)}${body}`;
207
+ const pad = Math.max(0, w - measureWidth(row));
208
+ const painted = bgRgb(bg, row + " ".repeat(pad));
209
+ if (measureWidth(painted) <= w) return painted;
210
+ return clampLine(stripVTControlCharacters(painted), w);
211
+ }
212
+
213
+ /**
214
+ * Framed Text for supernova. Uses renderShell: "self" so we own the chrome
215
+ * color (muted purple/grey-blue) instead of the host's green tool panels.
216
+ * Structure matches Pi's standard Box (pad + bg), without side-rail characters.
217
+ */
218
+ export class SafeText {
219
+ constructor(text = "") {
220
+ this.text = text;
221
+ this.tone = "pending";
222
+ this.framing = true;
223
+ }
224
+ setText(text) {
225
+ this.text = text;
226
+ }
227
+ setTone(tone) {
228
+ if (tone === "error" || tone === "success" || tone === "pending") this.tone = tone;
229
+ }
230
+ setFraming(enabled) {
231
+ this.framing = !!enabled;
232
+ }
233
+ invalidate() {}
234
+ render(width = 80) {
235
+ const w = Math.max(1, width | 0);
236
+ const raw = String(this.text ?? "");
237
+ if (!raw.trim()) return [];
238
+
239
+ if (!this.framing) {
240
+ return fitOutputLines(raw, w);
241
+ }
242
+
243
+ // Match Pi Box: 1-col horizontal pad, 1-row vertical pad, purple bg wash.
244
+ const padX = 1;
245
+ const inner = Math.max(1, w - padX * 2);
246
+ const bodyLines = fitOutputLines(raw, inner);
247
+ const empty = paintNovaRow("", w, this.tone);
248
+ const painted = bodyLines.map((line) => paintNovaRow(line, w, this.tone));
249
+ return [empty, ...painted, empty];
250
+ }
251
+ }
252
+
253
+ // Keep names some tests / older call sites may import.
254
+ export const visibleWidth = measureWidth;
255
+ export const truncateToWidth = hardTruncate;
256
+
257
+ const ACTION_ICONS = {
258
+ write: "✎ ",
259
+ edit: "✎ ",
260
+ apply_patch: "✎ ",
261
+ patch: "✎ ",
262
+ bash: "❯ ",
263
+ exec: "❯ ",
264
+ read: "▤ ",
265
+ surface: "▤ ",
266
+ search: "⌕ ",
267
+ grep: "⌕ ",
268
+ find: "⌕ ",
269
+ ls: "▤ ",
270
+ // Avoid double-width emoji (⚡) — measure disagreements with Pi caused 92>91 crashes.
271
+ speculate: "✶ ",
272
+ snap: "⌖ ",
273
+ };
274
+
275
+ export function extractOperationsFromCode(code) {
276
+ const trimmed = String(code || "").trim();
277
+ if (!trimmed) return [];
278
+
279
+ const ops = [];
280
+ const seen = new Set();
281
+
282
+ const addOp = (tool, target) => {
283
+ const key = `${tool}:${target}`;
284
+ if (!seen.has(key)) {
285
+ seen.add(key);
286
+ ops.push({ tool, target });
287
+ }
288
+ };
289
+
290
+ const callRegex = /nova\.call\s*\(\s*["'`]([a-zA-Z0-9_-]+)["'`](?:\s*,\s*(\{[\s\S]*?\}))?/g;
291
+ let match;
292
+ while ((match = callRegex.exec(trimmed)) !== null) {
293
+ const tool = match[1];
294
+ let target = "";
295
+ if (match[2]) {
296
+ const pathMatch = /path\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
297
+ const cmdMatch = /command\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
298
+ const patMatch = /pattern\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
299
+ if (pathMatch) target = pathMatch[1];
300
+ else if (cmdMatch) target = cmdMatch[1].length > 30 ? cmdMatch[1].slice(0, 27) + "…" : cmdMatch[1];
301
+ else if (patMatch) target = patMatch[1];
302
+ }
303
+ addOp(tool, target);
304
+ }
305
+
306
+ const callManyRegex = /nova\.callMany\s*\(\s*\[([\s\S]*?)\]\s*\)/g;
307
+ while ((match = callManyRegex.exec(trimmed)) !== null) {
308
+ const inner = match[1];
309
+ const subCalls = inner.matchAll(/name\s*:\s*["'`]([a-zA-Z0-9_-]+)["'`]/g);
310
+ for (const sub of subCalls) addOp(sub[1], "");
311
+ }
312
+
313
+ const namedCalls = [
314
+ { regex: /(?:^|[^\w$.])(?:nova\.)?read\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "read", wrap: (p) => p },
315
+ { regex: /(?:^|[^\w$.])(?:nova\.)?write\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
316
+ { regex: /(?:^|[^\w$.])(?:nova\.)?edit\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
317
+ { regex: /(?:^|[^\w$.])(?:nova\.)?patch\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
318
+ {
319
+ regex: /(?:^|[^\w$.])(?:nova\.)?bash\s*\(\s*["'`]([^"'`]+)["'`]/gm,
320
+ tool: "bash",
321
+ wrap: (c) => (c.length > 32 ? c.slice(0, 29) + "…" : c),
322
+ },
323
+ { regex: /(?:^|[^\w$.])(?:nova\.)?exec\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "bash", wrap: (c) => c },
324
+ { regex: /(?:nova\.)?search\s*\(\s*["'`]([^"'`]+)["'`]/g, tool: "read", wrap: (q) => `"${q}"` },
325
+ { regex: /(?:nova\.)?surface\s*\(\s*["'`]([^"'`]+)["'`]/g, tool: "read", wrap: (p) => p },
326
+ { regex: /(?:nova\.)?snap\s*\(\s*["'`]([^"'`]+)["'`]/g, tool: "read", wrap: (q) => `"${q}"` },
327
+ ];
328
+ for (const item of namedCalls) {
329
+ while ((match = item.regex.exec(trimmed)) !== null) {
330
+ addOp(item.tool, item.wrap(match[1]));
331
+ }
332
+ }
333
+
334
+ if (/nova\.speculate\s*\(/.test(trimmed)) {
335
+ addOp("speculate", "(branch foam)");
336
+ }
337
+
338
+ return ops;
339
+ }
340
+
341
+ export function renderDiffBox(diff, theme, width = 60) {
342
+ if (!diff || !Array.isArray(diff.lines) || diff.lines.length === 0) return "";
343
+
344
+ const w = Math.max(20, width | 0);
345
+ const cleanPath = String(diff.path || "").replace(/\\/g, "/");
346
+ const baseName = cleanPath.split("/").pop() || cleanPath;
347
+ const opLabel = diff.op === "edit" ? "Edit" : diff.op === "write" ? "Write" : "Patch";
348
+
349
+ // Stats BEFORE path so +N/-N survive narrow-terminal truncation (prior test/crash footgun).
350
+ const stats =
351
+ theme.fg("dim", "⟨") +
352
+ theme.fg("toolDiffAdded", `+${diff.added}`) +
353
+ theme.fg("dim", "/") +
354
+ theme.fg("toolDiffRemoved", `-${diff.removed}`) +
355
+ theme.fg("dim", "⟩");
356
+ // Do not clamp here — SafeText.render(terminalWidth) is the single choke point.
357
+ // Pre-clamping with a guessed width ate filenames under mock/ANSI-marker themes.
358
+ const header =
359
+ theme.fg("accent", "✎ ") +
360
+ theme.fg("toolTitle", theme.bold(`${opLabel} `)) +
361
+ stats +
362
+ " " +
363
+ theme.fg("muted", baseName);
364
+
365
+ const divWidth = Math.min(w, Math.max(20, Math.min(70, w)));
366
+ const divider = theme.fg("borderMuted", "─".repeat(divWidth));
367
+
368
+ const maxShown = 8;
369
+ const shownLines = diff.lines.slice(0, maxShown);
370
+ const body = [];
371
+
372
+ for (const item of shownLines) {
373
+ const num = item.lineNum || 0;
374
+ let row;
375
+ if (item.type === "remove") {
376
+ const gut = theme.fg("toolDiffRemoved", `-${num}`.padStart(5));
377
+ const sep = theme.fg("borderMuted", " │ ");
378
+ const txt = theme.fg("toolDiffRemoved", `- ${item.text}`);
379
+ row = `${gut}${sep}${txt}`;
380
+ } else if (item.type === "add") {
381
+ const gut = theme.fg("toolDiffAdded", `+${num}`.padStart(5));
382
+ const sep = theme.fg("borderMuted", " │ ");
383
+ const txt = theme.fg("toolDiffAdded", `+ ${item.text}`);
384
+ row = `${gut}${sep}${txt}`;
385
+ } else {
386
+ const gut = theme.fg("dim", ` ${num}`.padStart(5));
387
+ const sep = theme.fg("borderMuted", " │ ");
388
+ const txt = theme.fg("toolDiffContext", ` ${item.text}`);
389
+ row = `${gut}${sep}${txt}`;
390
+ }
391
+ body.push(row);
392
+ }
393
+
394
+ if (diff.lines.length > maxShown) {
395
+ const remaining = diff.lines.length - maxShown;
396
+ body.push(theme.fg("dim", ` │ … ${remaining} more lines`));
397
+ }
398
+
399
+ return `${header}\n${divider}\n${body.join("\n")}\n${divider}`;
400
+ }
401
+
402
+ function displayOperation(tool, target) {
403
+ if (["write", "edit", "apply_patch", "patch"].includes(tool)) return { tool: "edit", target };
404
+ if (["bash", "exec"].includes(tool)) return { tool: "bash", target };
405
+ if (["read", "surface", "snap", "search", "grep", "find", "ls"].includes(tool))
406
+ return { tool: "read", target };
407
+ return null;
408
+ }
409
+
410
+ function formatOpTarget(raw, tool) {
411
+ const text = String(raw ?? "");
412
+ if (!text) return "";
413
+ if (tool === "bash") {
414
+ // Keep commands readable; wrap handles the rest at render time.
415
+ return text.length > 80 ? `${text.slice(0, 77)}…` : text;
416
+ }
417
+ // Paths: normalize separators; SafeText wraps so we keep the full relative path.
418
+ return text.replace(/\\/g, "/");
419
+ }
420
+
421
+ export function renderSupernovaCall(args, theme, context) {
422
+ const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
423
+
424
+ let ops = [];
425
+ const stateTrace = context?.state?.trace;
426
+ if (Array.isArray(stateTrace) && stateTrace.length > 0) {
427
+ ops = stateTrace
428
+ .map((item) => {
429
+ const tool = item?.name || "tool";
430
+ let target = "";
431
+ if (item?.args?.path) target = String(item.args.path);
432
+ else if (item?.args?.command) target = String(item.args.command);
433
+ else if (item?.args?.pattern) target = String(item.args.pattern);
434
+ return displayOperation(tool, target);
435
+ })
436
+ .filter(Boolean);
437
+ } else {
438
+ ops = extractOperationsFromCode(args?.code)
439
+ .map((op) => displayOperation(op.tool, op.target))
440
+ .filter(Boolean);
441
+ }
442
+
443
+ const state = context?.state;
444
+ if (state && state.startedAt == null) state.startedAt = performance.now();
445
+ if (state && context?.executionStarted && state.wallMs == null && state.timer == null) {
446
+ state.timer = setTimeout(() => {
447
+ state.timer = null;
448
+ context.invalidate?.();
449
+ }, 100);
450
+ }
451
+
452
+ let timeStr = "";
453
+ if (state?.wallMs != null) {
454
+ timeStr = `${state.wallMs}ms`;
455
+ } else if (state?.startedAt != null) {
456
+ const elapsed = Math.round(performance.now() - state.startedAt);
457
+ timeStr = elapsed >= 1000 ? `${(elapsed / 1000).toFixed(1)}s` : `${elapsed}ms`;
458
+ }
459
+
460
+ // Compact aesthetic — never dump raw JSON args (the stock Pi tool fallback).
461
+ let out = theme.fg("toolTitle", theme.bold("nova"));
462
+ if (timeStr) {
463
+ out += " " + theme.fg("dim", `· ${timeStr}`);
464
+ }
465
+
466
+ if (ops.length === 0) {
467
+ out += " " + theme.fg("dim", "· composing");
468
+ } else {
469
+ for (const op of ops) {
470
+ const icon = ACTION_ICONS[op.tool] || "✦ ";
471
+ const bullet = theme.fg("accent", icon);
472
+ const toolName = theme.fg("syntaxFunction", op.tool.padEnd(4, " "));
473
+ const rawTarget = formatOpTarget(op.target, op.tool);
474
+ const target = rawTarget ? " " + theme.fg("muted", rawTarget) : "";
475
+ out += `\n ${bullet}${toolName}${target}`;
476
+ }
477
+ }
478
+
479
+ if (context?.expanded && args?.code) {
480
+ out += "\n" + theme.fg("dim", "── source ──");
481
+ out += "\n" + theme.fg("toolOutput", String(args.code).trim());
482
+ }
483
+
484
+ if (context?.isError) comp.setTone("error");
485
+ else if (context?.isPartial) comp.setTone("pending");
486
+ else comp.setTone("success");
487
+ comp.setFraming(true);
488
+ comp.setText(out);
489
+ return comp;
490
+ }
491
+
492
+ export function renderSupernovaResult(result, { expanded, isPartial }, theme, context) {
493
+ const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
494
+
495
+ const payload = result?.details;
496
+ if (context?.state && payload) {
497
+ let changed = false;
498
+ if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) {
499
+ context.state.trace = payload.trace;
500
+ changed = true;
501
+ }
502
+ if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) {
503
+ context.state.wallMs = payload.wallMs;
504
+ changed = true;
505
+ }
506
+ if (context.state.timer != null && !isPartial) {
507
+ clearTimeout(context.state.timer);
508
+ context.state.timer = null;
509
+ }
510
+ if (changed) context.invalidate?.();
511
+ }
512
+
513
+ if (isPartial) {
514
+ comp.setText("");
515
+ return comp;
516
+ }
517
+ const isErr = result?.isError || payload?.ok === false;
518
+
519
+ if (isErr) {
520
+ let out = theme.fg("error", "✗ error");
521
+ if (payload?.error) {
522
+ out += `\n ${theme.fg("error", String(payload.error))}`;
523
+ }
524
+ if (expanded && payload?.logs?.length) {
525
+ out += `\n${theme.fg("dim", "── logs ──")}`;
526
+ for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
527
+ }
528
+ comp.setTone("error");
529
+ comp.setFraming(true);
530
+ comp.setText(out);
531
+ return comp;
532
+ }
533
+
534
+ let out = "";
535
+
536
+ const trace = payload?.trace || context?.state?.trace || [];
537
+ const diffs = trace.filter((t) => t?.diff && isObject(t.diff)).map((t) => t.diff);
538
+
539
+ if (diffs.length > 0) {
540
+ const maxDiffsShown = expanded ? diffs.length : 2;
541
+ const shownDiffs = diffs.slice(0, maxDiffsShown);
542
+ for (const diff of shownDiffs) {
543
+ // Build unconstrained; SafeText.render(terminalWidth) is the hard clamp.
544
+ const box = renderDiffBox(diff, theme, 120);
545
+ if (box) out += (out ? "\n\n" : "") + box;
546
+ }
547
+ if (!expanded && diffs.length > maxDiffsShown) {
548
+ const remaining = diffs.length - maxDiffsShown;
549
+ out +=
550
+ "\n\n" +
551
+ theme.fg(
552
+ "dim",
553
+ `… ${remaining} more file edit${remaining === 1 ? "" : "s"} (press Enter to expand)`,
554
+ );
555
+ }
556
+ }
557
+
558
+ if (expanded) {
559
+ const resVal = payload?.result;
560
+ if (resVal !== undefined) {
561
+ let formatted;
562
+ try {
563
+ formatted = isString(resVal) ? resVal : JSON.stringify(resVal, null, 2);
564
+ } catch {
565
+ formatted = String(resVal);
566
+ }
567
+ out += (out ? "\n" : "") + theme.fg("dim", "── result ──");
568
+ out += "\n" + theme.fg("toolOutput", formatted);
569
+ }
570
+ if (payload?.logs?.length) {
571
+ out += (out ? "\n" : "") + theme.fg("dim", "── logs ──");
572
+ for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
573
+ }
574
+ }
575
+
576
+ if (!out.trim()) {
577
+ comp.setText("");
578
+ return comp;
579
+ }
580
+ comp.setTone("success");
581
+ comp.setFraming(true);
582
+ comp.setText(out);
583
+ return comp;
584
+ }