pi-supernova 0.0.7 → 0.0.8

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-measure.js CHANGED
@@ -25,29 +25,64 @@ export function measureWidth(text) {
25
25
  return width;
26
26
  }
27
27
 
28
+ const ZERO_RANGES = [
29
+ [0x00, 0x1f],
30
+ [0x7f, 0x9f],
31
+ [0x0300, 0x036f],
32
+ [0x1ab0, 0x1aff],
33
+ [0x1dc0, 0x1dff],
34
+ [0x20d0, 0x20ff],
35
+ [0xfe00, 0xfe0e],
36
+ [0xfe20, 0xfe2f],
37
+ ];
38
+
39
+ const WIDE_RANGES = [
40
+ [0x1100, 0x115f],
41
+ [0x2e80, 0xa4cf],
42
+ [0xac00, 0xd7a3],
43
+ [0xf900, 0xfaff],
44
+ [0xfe10, 0xfe19],
45
+ [0xfe30, 0xfe6f],
46
+ [0xff00, 0xff60],
47
+ [0xffe0, 0xffe6],
48
+ [0x1f000, 0x1faff],
49
+ [0x20000, 0x3fffd],
50
+ ];
51
+
52
+ const WIDE_SINGLES = [0x2329, 0x232a, 0x26a1, 0x2b50, 0x2728];
53
+
54
+ function inRanges(cp, ranges) {
55
+ for (const [lo, hi] of ranges) {
56
+ if (cp >= lo && cp <= hi) return true;
57
+ }
58
+ return false;
59
+ }
60
+
28
61
  function codePointWidth(cp) {
29
- if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) return 0;
30
- if (cp === 0xfe0f) return 1; // Emoji presentation can widen an otherwise narrow symbol.
31
- if (cp === 0x200d || (cp >= 0x0300 && cp <= 0x036f) || (cp >= 0x1ab0 && cp <= 0x1aff) ||
32
- (cp >= 0x1dc0 && cp <= 0x1dff) || (cp >= 0x20d0 && cp <= 0x20ff) ||
33
- (cp >= 0xfe00 && cp <= 0xfe0e) || (cp >= 0xfe20 && cp <= 0xfe2f)) 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 >= 0x1f000 && cp <= 0x1faff) return 2;
45
- if (cp >= 0x20000 && cp <= 0x3fffd) return 2;
46
- // Ambiguous emoji/symbols pi-tui treats as wide (⚡ U+26A1 was the 92>91 footgun).
47
- if (cp === 0x26a1 || cp === 0x2b50 || cp === 0x2728) return 2;
62
+ if (cp === 0xfe0f) return 1;
63
+ if (cp === 0x200d) return 0;
64
+ if (inRanges(cp, ZERO_RANGES)) return 0;
65
+ if (WIDE_SINGLES.includes(cp)) return 2;
66
+ if (inRanges(cp, WIDE_RANGES)) return 2;
48
67
  return 1;
49
68
  }
50
69
 
70
+ function takeChunk(text, start, width) {
71
+ let end = start;
72
+ let visible = 0;
73
+ let lastBreak = -1;
74
+ while (end < text.length) {
75
+ const cp = text.codePointAt(end);
76
+ const ch = cp > 0xffff ? text.slice(end, end + 2) : text[end];
77
+ const cw = measureWidth(ch);
78
+ if (visible + cw > width) break;
79
+ visible += cw;
80
+ end += ch.length;
81
+ if (ch === "/" || ch === " ") lastBreak = end;
82
+ }
83
+ return { end, lastBreak };
84
+ }
85
+
51
86
  /**
52
87
  * Truncate so the result's visible width is ALWAYS ≤ maxWidth, ellipsis included.
53
88
  * Strips ANSI in the truncated region (crash-safety > color fidelity on overflow).
@@ -57,25 +92,16 @@ export function hardTruncate(text, maxWidth, ellipsis = ELLIPSIS) {
57
92
  if (w === 0) return "";
58
93
  const raw = String(text ?? "").replace(/\t/g, " ");
59
94
  if (measureWidth(raw) <= w) return raw;
60
-
61
95
  const ell = String(ellipsis);
62
96
  const ellW = measureWidth(ell);
63
97
  if (ellW >= w) {
64
98
  if (ellW === 0) return "";
65
99
  return ell.slice(0, w);
66
100
  }
67
-
68
101
  const budget = w - ellW;
69
102
  const plain = stripVTControlCharacters(raw);
70
- let out = "";
71
- let visible = 0;
72
- for (const ch of plain) {
73
- const cw = measureWidth(ch);
74
- if (visible + cw > budget) break;
75
- out += ch;
76
- visible += cw;
77
- }
78
- return out + ell;
103
+ const { end } = takeChunk(plain, 0, budget);
104
+ return plain.slice(0, end) + ell;
79
105
  }
80
106
 
81
107
  /**
@@ -102,33 +128,22 @@ export function wrapPlainToWidth(plain, width) {
102
128
  const text = String(plain ?? "");
103
129
  if (text.length === 0) return [""];
104
130
  if (measureWidth(text) <= w) return [text];
105
-
106
131
  const lines = [];
107
132
  let i = 0;
108
133
  while (i < text.length) {
109
- let end = i;
110
- let visible = 0;
111
- let lastBreak = -1;
112
- while (end < text.length) {
113
- const cp = text.codePointAt(end);
114
- const ch = String.fromCodePoint(cp);
115
- const cw = measureWidth(ch);
116
- if (visible + cw > w) break;
117
- visible += cw;
118
- end += ch.length;
119
- if (ch === "/" || ch === " ") lastBreak = end;
120
- }
134
+ const { end, lastBreak } = takeChunk(text, i, w);
121
135
  if (end === i) {
122
- const ch = String.fromCodePoint(text.codePointAt(i));
136
+ const ch = text.codePointAt(i) > 0xffff ? text.slice(i, i + 2) : text[i];
123
137
  lines.push(hardTruncate(ch, w));
124
138
  i += ch.length;
125
139
  continue;
126
140
  }
127
- if (end < text.length && lastBreak > i + Math.floor(w * 0.35)) end = lastBreak;
128
- lines.push(text.slice(i, end));
129
- i = end;
141
+ let cut = end;
142
+ if (end < text.length && lastBreak > i + Math.floor(w * 0.35)) cut = lastBreak;
143
+ lines.push(text.slice(i, cut));
144
+ i = cut;
130
145
  }
131
- return lines.length > 0 ? lines : [""];
146
+ return lines;
132
147
  }
133
148
 
134
149
  /**
@@ -138,7 +153,6 @@ export function fitPath(pathText, budget) {
138
153
  const w = Math.max(1, budget | 0);
139
154
  let p = String(pathText ?? "").replace(/\\/g, "/");
140
155
  if (measureWidth(p) <= w) return p;
141
-
142
156
  const parts = p.split("/").filter(Boolean);
143
157
  const base = parts.length > 0 ? parts[parts.length - 1] : p;
144
158
  const suffix = parts.length > 1 ? `…/${base}` : base;
package/render.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  fitPath,
21
21
  } from "./render-measure.js";
22
22
  import { novaFramedBlock, novaStatusLine } from "./omp-frame.js";
23
+ import { formatValue } from "./format.js";
23
24
 
24
25
  export { measureWidth, hardTruncate, clampLine, wrapPlainToWidth, fitPath };
25
26
 
@@ -153,18 +154,6 @@ function formatDiffRows(diff, theme, maxShown = 6) {
153
154
  return body;
154
155
  }
155
156
 
156
- export function renderDiffBox(diff, theme, width = 60, maxShown = 6) {
157
- if (!diff || !Array.isArray(diff.lines) || diff.lines.length === 0) return "";
158
- const w = Math.max(20, width | 0);
159
- const cleanPath = String(diff.path || "").replace(/\\/g, "/");
160
- const baseName = cleanPath.split("/").pop() || cleanPath;
161
- const opLabel = diff.op === "edit" ? "Edit" : diff.op === "write" ? "Write" : "Patch";
162
- const stats = theme.fg("dim", "⟨") + theme.fg("toolDiffAdded", `+${diff.added}`) + theme.fg("dim", "/") + theme.fg("toolDiffRemoved", `-${diff.removed}`) + theme.fg("dim", "⟩");
163
- const header = theme.fg("accent", "✎ ") + theme.fg("toolTitle", theme.bold(`${opLabel} `)) + stats + " " + theme.fg("muted", baseName);
164
- const divider = theme.fg("borderMuted", "─".repeat(Math.min(70, w)));
165
- return `${header}\n${divider}\n${formatDiffRows(diff, theme, maxShown).join("\n")}\n${divider}`;
166
- }
167
-
168
157
  function stripUnsafeControls(value) {
169
158
  let clean = "";
170
159
  for (const character of value) {
@@ -185,22 +174,11 @@ function cleanInlineText(value) {
185
174
  return cleanBlockText(value).replace(/\s*\n\s*/g, " ").trim();
186
175
  }
187
176
 
188
- function displayOperation(tool, target, diff, ok) {
177
+ function displayOperation(tool, target, diff, ok, item) {
189
178
  const rawName = cleanInlineText(tool);
190
179
  if (!rawName) return null;
191
180
  const normalized = rawName === "apply_patch" ? "patch" : rawName;
192
- return { tool: normalized, target, diff, ok };
193
- }
194
-
195
- function formatOpTarget(raw, tool) {
196
- const text = cleanInlineText(raw);
197
- if (!text) return "";
198
- if (tool === "bash") {
199
- // Keep commands readable; wrap handles the rest at render time.
200
- return text.length > 80 ? `${text.slice(0, 77)}…` : text;
201
- }
202
- // Paths: normalize separators; SafeText wraps so we keep the full relative path.
203
- return text.replace(/\\/g, "/");
181
+ return { tool: normalized, target, diff, ok, ms: item?.ms, exitCode: item?.exitCode, time: item?.time };
204
182
  }
205
183
 
206
184
  function isTheme(value) {
@@ -244,36 +222,25 @@ export function normalizeCallRenderArgs(a, b, c) {
244
222
  * Call shapes share the first three positions, so host is inferred from the
245
223
  * fourth argument's context-versus-args shape.
246
224
  */
225
+ function contextFrom(opts, ctxOrArgs) {
226
+ if (isObject(ctxOrArgs) && !isTheme(ctxOrArgs)) {
227
+ if ("lastComponent" in ctxOrArgs || "state" in ctxOrArgs || "invalidate" in ctxOrArgs) return ctxOrArgs;
228
+ }
229
+ return { state: opts.state, lastComponent: opts.lastComponent };
230
+ }
231
+
247
232
  function detectResultHost(options, ctxOrArgs) {
248
233
  if (isTheme(options)) return "pi";
249
- if (
250
- isObject(ctxOrArgs) &&
251
- ("lastComponent" in ctxOrArgs || "invalidate" in ctxOrArgs)
252
- ) {
253
- return "pi";
254
- }
255
- if (
256
- isObject(ctxOrArgs) &&
257
- ("code" in ctxOrArgs || "timeoutMs" in ctxOrArgs)
258
- ) {
259
- return "omp";
260
- }
234
+ if (!isObject(ctxOrArgs)) return "pi";
235
+ if ("lastComponent" in ctxOrArgs || "invalidate" in ctxOrArgs) return "pi";
236
+ if ("code" in ctxOrArgs || "timeoutMs" in ctxOrArgs) return "omp";
261
237
  return "pi";
262
238
  }
263
239
 
264
240
  export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
265
241
  if (isTheme(themeOrCtx)) {
266
242
  const opts = isObject(options) ? options : {};
267
- let context;
268
- if (
269
- isObject(ctxOrArgs) &&
270
- !isTheme(ctxOrArgs) &&
271
- ("lastComponent" in ctxOrArgs || "state" in ctxOrArgs || "invalidate" in ctxOrArgs)
272
- ) {
273
- context = ctxOrArgs;
274
- } else {
275
- context = { state: opts.state, lastComponent: opts.lastComponent };
276
- }
243
+ const context = contextFrom(opts, ctxOrArgs);
277
244
  if (!isObject(context.state)) context.state = {};
278
245
  return {
279
246
  result,
@@ -304,23 +271,37 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
304
271
  throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
305
272
  }
306
273
 
274
+ const OPERATION_TARGETS = [
275
+ [(item) => item?.name === "snap", (item, args) => {
276
+ const query = args.query ? `"${args.query}"` : "";
277
+ if (!args.path) return query;
278
+ return `${query} → ${args.path}`;
279
+ }],
280
+ [(item) => item?.name === "search", (item, args) => (args.query ? `"${args.query}"` : "")],
281
+ [(item, args) => args.path, (item, args) => String(args.path)],
282
+ [(item) => item?.diff?.path, (item) => String(item.diff.path)],
283
+ [(item, args) => args.target && isString(args.target), (item, args) => args.target],
284
+ [(item, args) => args.command, (item, args) => String(args.command)],
285
+ [(item, args) => args.pattern, (item, args) => String(args.pattern)],
286
+ [(item, args) => args.query, (item, args) => String(args.query)],
287
+ ];
288
+
307
289
  function operationTarget(item) {
308
290
  const args = item?.args || {};
309
- const name = item?.name;
310
- if (name === "snap") {
311
- const query = args.query ? `"${args.query}"` : "";
312
- return args.path ? `${query} → ${args.path}` : query;
291
+ for (const [predicate, formatter] of OPERATION_TARGETS) {
292
+ if (predicate(item, args)) return formatter(item, args);
313
293
  }
314
- if (name === "search") return args.query ? `"${args.query}"` : "";
315
- if (args.path) return String(args.path);
316
- if (item?.diff?.path) return String(item.diff.path);
317
- if (args.target && isString(args.target)) return args.target;
318
- if (args.command) return String(args.command);
319
- if (args.pattern) return String(args.pattern);
320
- if (args.query) return String(args.query);
321
294
  return "";
322
295
  }
323
296
 
297
+ function parseDiffLine(rawLine) {
298
+ const signed = /^([+-])\s*(\d+)\s?(.*)$/.exec(rawLine);
299
+ if (signed) return { type: signed[1] === "+" ? "add" : "remove", lineNum: Number(signed[2]), text: signed[3] };
300
+ const contextual = /^\s+(\d+)\s?(.*)$/.exec(rawLine);
301
+ if (contextual) return { type: "context", lineNum: Number(contextual[1]), text: contextual[2] };
302
+ return null;
303
+ }
304
+
324
305
  function normalizeTraceDiff(item) {
325
306
  const diff = item?.diff;
326
307
  if (isObject(diff)) return diff;
@@ -329,16 +310,11 @@ function normalizeTraceDiff(item) {
329
310
  let added = 0;
330
311
  let removed = 0;
331
312
  for (const rawLine of cleanBlockText(diff).split("\n")) {
332
- let match = /^([+-])\s*(\d+)\s?(.*)$/.exec(rawLine);
333
- if (match) {
334
- const type = match[1] === "+" ? "add" : "remove";
335
- if (type === "add") added += 1;
336
- else removed += 1;
337
- lines.push({ type, lineNum: Number(match[2]), text: match[3] });
338
- continue;
339
- }
340
- match = /^\s+(\d+)\s?(.*)$/.exec(rawLine);
341
- if (match) lines.push({ type: "context", lineNum: Number(match[1]), text: match[2] });
313
+ const parsed = parseDiffLine(rawLine);
314
+ if (!parsed) continue;
315
+ if (parsed.type === "add") added += 1;
316
+ else if (parsed.type === "remove") removed += 1;
317
+ lines.push(parsed);
342
318
  }
343
319
  if (lines.length === 0) return undefined;
344
320
  return { path: item?.args?.path || "", op: item?.name, added, removed, lines };
@@ -347,7 +323,7 @@ function normalizeTraceDiff(item) {
347
323
  function operationsFromTrace(trace) {
348
324
  if (!Array.isArray(trace)) return [];
349
325
  return trace
350
- .map((item) => displayOperation(item?.name || "tool", operationTarget(item), normalizeTraceDiff(item), item?.ok))
326
+ .map((item) => displayOperation(item?.name || "tool", operationTarget(item), normalizeTraceDiff(item), item?.ok, item))
351
327
  .filter(Boolean);
352
328
  }
353
329
 
@@ -360,99 +336,171 @@ export function renderSupernovaCall(a, b, c) {
360
336
  return comp;
361
337
  }
362
338
 
363
- function formatDiffStats(theme, diff) {
364
- if (!diff || !isObject(diff)) return "";
365
- return " " + theme.fg("toolDiffAdded", `+${diff.added || 0}`) + theme.fg("dim", "/") + theme.fg("toolDiffRemoved", `-${diff.removed || 0}`);
366
- }
367
-
368
- function formatResultOperation(theme, op, isPartial, isError) {
369
- const marker = op.ok === false
370
- ? theme.fg("error", ")
371
- : isPartial && op.ok !== true
372
- ? theme.fg("dim", ")
373
- : isError && op.ok !== true
374
- ? theme.fg("error", "× ")
375
- : theme.fg("success", "✓ ");
376
- const tool = theme.fg("syntaxFunction", op.tool.padEnd(7, " "));
377
- const targetText = formatOpTarget(op.target, op.tool);
378
- const target = targetText ? theme.fg("muted", targetText) : theme.fg("dim", "done");
379
- const stats = formatDiffStats(theme, op.diff);
380
- return stats ? `${marker}${tool}${stats} ${target}` : `${marker}${tool} ${target}`;
381
- }
382
-
383
- function boundedResult(value) {
384
- let text;
385
- try {
386
- text = isString(value) ? value : JSON.stringify(value, null, 2);
387
- } catch {
388
- text = String(value);
339
+ const TOOL_COL = 7;
340
+ const DURATION_COL = 6;
341
+ const PREVIEW_LINES = 24;
342
+
343
+ export function formatDuration(ms) {
344
+ if (!Number.isFinite(ms) || ms < 0) return "";
345
+ if (ms < 1000) return `${Math.round(ms)}ms`;
346
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
347
+ const minutes = Math.floor(ms / 60000);
348
+ const seconds = Math.round((ms % 60000) / 1000);
349
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
350
+ }
351
+
352
+ /** First meaningful line of a shell command plus a count of the hidden remainder. */
353
+ function summarizeCommand(raw) {
354
+ const lines = cleanBlockText(raw).split("\n").map((line) => line.trim()).filter(Boolean);
355
+ if (lines.length === 0) return "";
356
+ const first = lines[0].replace(/\s+/g, " ");
357
+ return lines.length > 1 ? `${first} …+${lines.length - 1} lines` : first;
358
+ }
359
+
360
+ const SHELL_TOOLS = ["bash", "exec"];
361
+ const SEARCH_TOOLS = ["snap", "search"];
362
+
363
+ function formatTarget(op, budget) {
364
+ if (SHELL_TOOLS.includes(op.tool)) return clampLine(summarizeCommand(op.target), budget);
365
+ const text = cleanInlineText(op.target);
366
+ if (!text) return "";
367
+ if (SEARCH_TOOLS.includes(op.tool)) return clampLine(text, budget);
368
+ return fitPath(text, budget);
369
+ }
370
+
371
+ function opMarker(theme, op, isPartial, isError) {
372
+ if (op.ok === false) return theme.fg("error", "×");
373
+ if (op.ok === true) return theme.fg("success", "✓");
374
+ if (isPartial) return theme.fg("dim", "·");
375
+ if (isError) return theme.fg("error", "×");
376
+ return theme.fg("success", "✓");
377
+ }
378
+
379
+ function opDuration(op, isPartial) {
380
+ if (Number.isFinite(op.ms)) return formatDuration(op.ms);
381
+ if (isPartial && op.ok === undefined && Number.isFinite(op.time)) return formatDuration(Date.now() - op.time) + "…";
382
+ return "";
383
+ }
384
+
385
+ /**
386
+ * One aligned row: marker · tool · duration · [exit N] · [+a/-r] · target.
387
+ * Fixed columns keep a ledger of mixed calls scannable at a glance.
388
+ */
389
+ function formatOpRow(theme, op, width, isPartial, isError) {
390
+ const marker = opMarker(theme, op, isPartial, isError);
391
+ const toolText = op.tool.padEnd(TOOL_COL);
392
+ const tool = theme.fg("syntaxFunction", toolText);
393
+ const durationText = opDuration(op, isPartial);
394
+ const duration = theme.fg("dim", durationText.padStart(DURATION_COL));
395
+ let prefix = `${marker} ${tool} ${duration} `;
396
+ let used = 2 + toolText.length + 1 + DURATION_COL + 2;
397
+ if (Number.isInteger(op.exitCode)) {
398
+ const exit = `exit ${op.exitCode}`;
399
+ prefix += theme.fg("error", exit) + " ";
400
+ used += exit.length + 2;
389
401
  }
390
- const lines = cleanBlockText(text).split("\n");
391
- const clipped = lines.slice(0, 24).join("\n");
392
- const suffix = lines.length > 24 ? `\n… ${lines.length - 24} more lines` : "";
393
- return (clipped + suffix).slice(0, 4000);
402
+ if (op.diff && isObject(op.diff)) {
403
+ const added = `+${op.diff.added || 0}`;
404
+ const removed = `-${op.diff.removed || 0}`;
405
+ prefix += theme.fg("toolDiffAdded", added) + theme.fg("dim", "/") + theme.fg("toolDiffRemoved", removed) + " ";
406
+ used += added.length + 1 + removed.length + 1;
407
+ }
408
+ const target = formatTarget(op, Math.max(1, width - used));
409
+ return prefix + (target ? theme.fg("muted", target) : theme.fg("dim", "done"));
394
410
  }
395
411
 
396
- function buildResultBody(theme, { payload, context, args, expanded, isPartial, isError }) {
397
- let out = "";
412
+ function operationsFor(payload, context, args) {
398
413
  const trace = payload?.trace || context?.state?.trace || [];
399
- const tracedOps = operationsFromTrace(trace);
400
- const ops = tracedOps.length > 0
401
- ? tracedOps
402
- : extractOperationsFromCode(args?.code).map((op) => displayOperation(op.tool, op.target)).filter(Boolean);
403
- const maxOps = expanded ? 12 : 8;
404
- const maxDiffLines = expanded ? 24 : 8;
405
- const visibleOps = ops.slice(0, maxOps);
406
- for (const [index, op] of visibleOps.entries()) {
407
- if (index > 0) out += "\n│";
408
- const isLast = index === visibleOps.length - 1 && ops.length <= maxOps;
409
- const branch = isLast ? "└─" : "├─";
410
- out += (out ? "\n" : "") + `${branch} ${formatResultOperation(theme, op, isPartial, isError)}`;
411
- if (op.diff && isObject(op.diff)) {
412
- const continuation = isLast ? " " : "│ ";
413
- for (const row of formatDiffRows(op.diff, theme, maxDiffLines)) out += `\n${continuation}${row}`;
414
- }
414
+ const traced = operationsFromTrace(trace);
415
+ if (traced.length > 0) return traced;
416
+ return extractOperationsFromCode(args?.code).map((op) => displayOperation(op.tool, op.target)).filter(Boolean);
417
+ }
418
+
419
+ function resultLines(value, maxLines) {
420
+ const text = isString(value) ? value : formatValue(value);
421
+ const lines = cleanBlockText(text).split("\n");
422
+ const shown = lines.slice(0, maxLines);
423
+ if (lines.length > maxLines) shown.push(`… ${lines.length - maxLines} more lines`);
424
+ return shown;
425
+ }
426
+
427
+ function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError) {
428
+ for (const op of ops.slice(0, maxOps)) {
429
+ lines.push(formatOpRow(theme, op, width, isPartial, isError));
430
+ if (!op.diff || !isObject(op.diff)) continue;
431
+ for (const row of formatDiffRows(op.diff, theme, maxDiffLines)) lines.push(" " + row);
415
432
  }
416
- if (ops.length > maxOps) out += `\n│\n└─ ${theme.fg("dim", `… ${ops.length - maxOps} more calls`)}`;
433
+ if (ops.length > maxOps) lines.push(theme.fg("dim", ` … ${ops.length - maxOps} more calls`));
434
+ }
417
435
 
418
- if (expanded) {
419
- if (payload?.result !== undefined) {
420
- out += (out ? "\n" : "") + theme.fg("dim", "── result ──");
421
- out += "\n" + theme.fg("toolOutput", boundedResult(payload.result));
422
- }
423
- if (!isError && payload?.logs?.length) {
424
- out += (out ? "\n" : "") + theme.fg("dim", "── logs ──");
425
- for (const log of payload.logs.slice(0, 24)) out += `\n ${theme.fg("dim", cleanBlockText(log))}`;
426
- }
436
+ function appendTail(lines, theme, payload, expanded, isError) {
437
+ if (isError) lines.push(theme.fg("error", "✗ " + (payload?.error ? cleanBlockText(payload.error) : "error")));
438
+ else if (expanded && payload?.result !== undefined) {
439
+ lines.push(theme.fg("dim", "── result ──"));
440
+ for (const line of resultLines(payload.result, PREVIEW_LINES)) lines.push(theme.fg("toolOutput", line));
427
441
  }
428
- return { body: out, opCount: ops.length };
442
+ if (expanded && payload?.logs?.length) {
443
+ lines.push(theme.fg("dim", "── logs ──"));
444
+ for (const log of payload.logs.slice(0, PREVIEW_LINES)) lines.push(theme.fg("dim", cleanBlockText(log)));
445
+ }
446
+ }
447
+
448
+ function buildBodyLines(theme, width, { payload, context, args, expanded, isPartial, isError }) {
449
+ const ops = operationsFor(payload, context, args);
450
+ const maxOps = expanded ? 24 : 8;
451
+ const maxDiffLines = expanded ? 24 : 8;
452
+ const lines = [];
453
+ appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError);
454
+ appendTail(lines, theme, payload, expanded, isError);
455
+ return { lines, opCount: ops.length };
456
+ }
457
+
458
+ function describeCard(model, opCount) {
459
+ const wall = model.payload?.wallMs != null ? formatDuration(model.payload.wallMs) : "";
460
+ const calls = opCount > 0 ? `${opCount} call${opCount === 1 ? "" : "s"}` : "";
461
+ const status = model.isError ? "failed" : model.isPartial ? "running" : calls ? "" : "complete";
462
+ return [calls, status, wall].filter(Boolean).join(" · ");
429
463
  }
430
464
 
431
465
  class UnifiedResultCard {
432
466
  set(theme, model) {
433
467
  this.theme = theme;
434
468
  this.model = model;
469
+ this.frame = undefined;
470
+ }
471
+ invalidate() {
472
+ this.frame = undefined;
435
473
  }
436
- invalidate() {}
437
474
  render(width = 80) {
438
475
  const { theme, model } = this;
439
476
  if (!theme || !model) return [];
440
- const header = novaStatusLine(theme, {
441
- icon: model.isError ? "error" : model.isPartial ? "running" : undefined,
442
- title: "nova",
443
- description: model.description,
444
- });
445
- const lines = model.body ? model.body.split("\n") : [];
446
- return novaFramedBlock(theme, (frameWidth) => ({
447
- header,
448
- sections: lines.length > 0 ? [{ lines }] : [],
449
- state: model.isError ? "error" : model.isPartial ? "pending" : "success",
450
- borderColor: model.isError ? "error" : "borderMuted",
451
- width: frameWidth,
452
- })).render(width);
477
+ if (!this.frame) {
478
+ this.frame = novaFramedBlock(theme, (frameWidth) => {
479
+ const contentWidth = Math.max(1, frameWidth - 4);
480
+ const view = buildBodyLines(theme, contentWidth, model);
481
+ return {
482
+ header: novaStatusLine(theme, {
483
+ icon: model.isError ? "error" : model.isPartial ? "running" : undefined,
484
+ title: "nova",
485
+ description: describeCard(model, view.opCount),
486
+ }),
487
+ sections: view.lines.length > 0 ? [{ lines: view.lines }] : [],
488
+ state: model.isError ? "error" : model.isPartial ? "pending" : "success",
489
+ borderColor: model.isError ? "error" : "borderMuted",
490
+ width: frameWidth,
491
+ };
492
+ });
493
+ }
494
+ return this.frame.render(width);
453
495
  }
454
496
  }
455
497
 
498
+ function syncState(context, payload) {
499
+ if (!context?.state || !payload) return;
500
+ if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) context.state.trace = payload.trace;
501
+ if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) context.state.wallMs = payload.wallMs;
502
+ }
503
+
456
504
  export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextArg) {
457
505
  const { result, expanded, isPartial, theme, context, args, options, host } = normalizeResultRenderArgs(
458
506
  resultArg,
@@ -462,34 +510,13 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
462
510
  );
463
511
 
464
512
  const payload = result?.details;
465
- if (context?.state && payload) {
466
- if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) {
467
- context.state.trace = payload.trace;
468
- }
469
- if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) {
470
- context.state.wallMs = payload.wallMs;
471
- }
472
- }
473
-
474
- const isErr = result?.isError || payload?.ok === false;
475
- const view = buildResultBody(theme, { payload, context, args, expanded, isPartial, isError: isErr });
513
+ syncState(context, payload);
476
514
 
477
- let body = view.body;
478
- if (isErr) {
479
- body += (body ? "\n" : "") + theme.fg("error", payload?.error ? cleanBlockText(payload.error) : "error");
480
- if (expanded && payload?.logs?.length) {
481
- body += `\n${theme.fg("dim", "── logs ──")}`;
482
- for (const log of payload.logs.slice(0, 24)) body += `\n${theme.fg("dim", cleanBlockText(log))}`;
483
- }
484
- }
485
- const wall = payload?.wallMs != null ? `${payload.wallMs}ms` : "";
486
- const calls = view.opCount > 0 ? `${view.opCount} call${view.opCount === 1 ? "" : "s"}` : "";
487
- const status = isErr ? "failed" : isPartial ? "running" : calls ? "" : "complete";
488
- const description = [calls, status, wall].filter(Boolean).join(" · ");
515
+ const isError = result?.isError || payload?.ok === false;
489
516
  const previous = host === "omp" ? options?.lastComponent : context?.lastComponent;
490
517
  const comp = previous instanceof UnifiedResultCard ? previous : new UnifiedResultCard();
491
518
  if (host === "omp" && options) options.lastComponent = comp;
492
519
  else if (context) context.lastComponent = comp;
493
- comp.set(theme, { body, description, isError: isErr, isPartial });
520
+ comp.set(theme, { payload, context, args, expanded, isPartial, isError });
494
521
  return comp;
495
522
  }