pi-supernova 0.0.7 → 0.0.11

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, error: item?.error };
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,43 @@ 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
+ function batchTarget(paths) {
275
+ const names = paths.map((p) => String(p).replace(/\\/g, "/").split("/").pop());
276
+ return `${paths.length} files: ${names.join(", ")}`;
277
+ }
278
+
279
+ const OPERATION_TARGETS = [
280
+ [(item) => item?.name === "snap", (item, args) => {
281
+ const query = args.query ? `"${args.query}"` : "";
282
+ if (!args.path) return query;
283
+ return `${query} → ${args.path}`;
284
+ }],
285
+ [(item) => item?.name === "search", (item, args) => (args.query ? `"${args.query}"` : "")],
286
+ [(item, args) => Array.isArray(args.path), (item, args) => batchTarget(args.path)],
287
+ [(item, args) => args.path, (item, args) => String(args.path)],
288
+ [(item) => item?.diff?.path, (item) => String(item.diff.path)],
289
+ [(item, args) => args.target && isString(args.target), (item, args) => args.target],
290
+ [(item, args) => args.command, (item, args) => String(args.command)],
291
+ [(item, args) => args.pattern, (item, args) => String(args.pattern)],
292
+ [(item, args) => args.query, (item, args) => String(args.query)],
293
+ ];
294
+
307
295
  function operationTarget(item) {
308
296
  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;
297
+ for (const [predicate, formatter] of OPERATION_TARGETS) {
298
+ if (predicate(item, args)) return formatter(item, args);
313
299
  }
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
300
  return "";
322
301
  }
323
302
 
303
+ function parseDiffLine(rawLine) {
304
+ const signed = /^([+-])\s*(\d+)\s?(.*)$/.exec(rawLine);
305
+ if (signed) return { type: signed[1] === "+" ? "add" : "remove", lineNum: Number(signed[2]), text: signed[3] };
306
+ const contextual = /^\s+(\d+)\s?(.*)$/.exec(rawLine);
307
+ if (contextual) return { type: "context", lineNum: Number(contextual[1]), text: contextual[2] };
308
+ return null;
309
+ }
310
+
324
311
  function normalizeTraceDiff(item) {
325
312
  const diff = item?.diff;
326
313
  if (isObject(diff)) return diff;
@@ -329,16 +316,11 @@ function normalizeTraceDiff(item) {
329
316
  let added = 0;
330
317
  let removed = 0;
331
318
  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] });
319
+ const parsed = parseDiffLine(rawLine);
320
+ if (!parsed) continue;
321
+ if (parsed.type === "add") added += 1;
322
+ else if (parsed.type === "remove") removed += 1;
323
+ lines.push(parsed);
342
324
  }
343
325
  if (lines.length === 0) return undefined;
344
326
  return { path: item?.args?.path || "", op: item?.name, added, removed, lines };
@@ -347,7 +329,7 @@ function normalizeTraceDiff(item) {
347
329
  function operationsFromTrace(trace) {
348
330
  if (!Array.isArray(trace)) return [];
349
331
  return trace
350
- .map((item) => displayOperation(item?.name || "tool", operationTarget(item), normalizeTraceDiff(item), item?.ok))
332
+ .map((item) => displayOperation(item?.name || "tool", operationTarget(item), normalizeTraceDiff(item), item?.ok, item))
351
333
  .filter(Boolean);
352
334
  }
353
335
 
@@ -360,99 +342,175 @@ export function renderSupernovaCall(a, b, c) {
360
342
  return comp;
361
343
  }
362
344
 
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);
345
+ const TOOL_COL = 7;
346
+ const DURATION_COL = 6;
347
+ const PREVIEW_LINES = 24;
348
+
349
+ export function formatDuration(ms) {
350
+ if (!Number.isFinite(ms) || ms < 0) return "";
351
+ if (ms < 1000) return `${Math.round(ms)}ms`;
352
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
353
+ const minutes = Math.floor(ms / 60000);
354
+ const seconds = Math.round((ms % 60000) / 1000);
355
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
356
+ }
357
+
358
+ /** First meaningful line of a shell command plus a count of the hidden remainder. */
359
+ function summarizeCommand(raw) {
360
+ const lines = cleanBlockText(raw).split("\n").map((line) => line.trim()).filter(Boolean);
361
+ if (lines.length === 0) return "";
362
+ const first = lines[0].replace(/\s+/g, " ");
363
+ return lines.length > 1 ? `${first} …+${lines.length - 1} lines` : first;
364
+ }
365
+
366
+ const SHELL_TOOLS = ["bash", "exec"];
367
+ const SEARCH_TOOLS = ["snap", "search"];
368
+
369
+ function formatTarget(op, budget) {
370
+ if (SHELL_TOOLS.includes(op.tool)) return clampLine(summarizeCommand(op.target), budget);
371
+ const text = cleanInlineText(op.target);
372
+ if (!text) return "";
373
+ if (SEARCH_TOOLS.includes(op.tool)) return clampLine(text, budget);
374
+ return fitPath(text, budget);
375
+ }
376
+
377
+ function opMarker(theme, op, isPartial, isError) {
378
+ if (op.ok === false) return theme.fg("error", "×");
379
+ if (op.ok === true) return theme.fg("success", "✓");
380
+ if (isPartial) return theme.fg("dim", "·");
381
+ if (isError) return theme.fg("error", "×");
382
+ return theme.fg("success", "✓");
383
+ }
384
+
385
+ function opDuration(op, isPartial) {
386
+ if (Number.isFinite(op.ms)) return formatDuration(op.ms);
387
+ if (isPartial && op.ok === undefined && Number.isFinite(op.time)) return formatDuration(Date.now() - op.time) + "…";
388
+ return "";
389
+ }
390
+
391
+ /**
392
+ * One aligned row: marker · tool · duration · [exit N] · [+a/-r] · target.
393
+ * Fixed columns keep a ledger of mixed calls scannable at a glance.
394
+ */
395
+ function formatOpRow(theme, op, width, isPartial, isError) {
396
+ const marker = opMarker(theme, op, isPartial, isError);
397
+ const toolText = op.tool.padEnd(TOOL_COL);
398
+ const tool = theme.fg("syntaxFunction", toolText);
399
+ const durationText = opDuration(op, isPartial);
400
+ const duration = theme.fg("dim", durationText.padStart(DURATION_COL));
401
+ let prefix = `${marker} ${tool} ${duration} `;
402
+ let used = 2 + toolText.length + 1 + DURATION_COL + 2;
403
+ if (Number.isInteger(op.exitCode)) {
404
+ const exit = `exit ${op.exitCode}`;
405
+ prefix += theme.fg("error", exit) + " ";
406
+ used += exit.length + 2;
389
407
  }
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);
408
+ if (op.diff && isObject(op.diff)) {
409
+ const added = `+${op.diff.added || 0}`;
410
+ const removed = `-${op.diff.removed || 0}`;
411
+ prefix += theme.fg("toolDiffAdded", added) + theme.fg("dim", "/") + theme.fg("toolDiffRemoved", removed) + " ";
412
+ used += added.length + 1 + removed.length + 1;
413
+ }
414
+ const budget = Math.max(1, width - used);
415
+ const target = formatTarget(op, budget);
416
+ if (target) return prefix + theme.fg("muted", target);
417
+ if (op.ok === false && op.error) return prefix + theme.fg("error", clampLine(cleanInlineText(op.error), budget));
418
+ return prefix.trimEnd();
394
419
  }
395
420
 
396
- function buildResultBody(theme, { payload, context, args, expanded, isPartial, isError }) {
397
- let out = "";
421
+ function operationsFor(payload, context, args) {
398
422
  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
- }
423
+ const traced = operationsFromTrace(trace);
424
+ if (traced.length > 0) return traced;
425
+ return extractOperationsFromCode(args?.code).map((op) => displayOperation(op.tool, op.target)).filter(Boolean);
426
+ }
427
+
428
+ function resultLines(value, maxLines) {
429
+ const text = isString(value) ? value : formatValue(value);
430
+ const lines = cleanBlockText(text).split("\n");
431
+ const shown = lines.slice(0, maxLines);
432
+ if (lines.length > maxLines) shown.push(`… ${lines.length - maxLines} more lines`);
433
+ return shown;
434
+ }
435
+
436
+ function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError) {
437
+ for (const op of ops.slice(0, maxOps)) {
438
+ lines.push(formatOpRow(theme, op, width, isPartial, isError));
439
+ if (!op.diff || !isObject(op.diff)) continue;
440
+ for (const row of formatDiffRows(op.diff, theme, maxDiffLines)) lines.push(" " + row);
415
441
  }
416
- if (ops.length > maxOps) out += `\n│\n└─ ${theme.fg("dim", `… ${ops.length - maxOps} more calls`)}`;
442
+ if (ops.length > maxOps) lines.push(theme.fg("dim", ` … ${ops.length - maxOps} more calls`));
443
+ }
417
444
 
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
- }
445
+ function appendTail(lines, theme, payload, expanded, isError) {
446
+ if (isError) lines.push(theme.fg("error", "✗ " + (payload?.error ? cleanBlockText(payload.error) : "error")));
447
+ else if (expanded && payload?.result !== undefined) {
448
+ lines.push(theme.fg("dim", "── result ──"));
449
+ for (const line of resultLines(payload.result, PREVIEW_LINES)) lines.push(theme.fg("toolOutput", line));
450
+ }
451
+ if (expanded && payload?.logs?.length) {
452
+ lines.push(theme.fg("dim", "── logs ──"));
453
+ for (const log of payload.logs.slice(0, PREVIEW_LINES)) lines.push(theme.fg("dim", cleanBlockText(log)));
427
454
  }
428
- return { body: out, opCount: ops.length };
455
+ }
456
+
457
+ function buildBodyLines(theme, width, { payload, context, args, expanded, isPartial, isError }) {
458
+ const ops = operationsFor(payload, context, args);
459
+ const maxOps = expanded ? 24 : 8;
460
+ const maxDiffLines = expanded ? 24 : 8;
461
+ const lines = [];
462
+ appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError);
463
+ appendTail(lines, theme, payload, expanded, isError);
464
+ return { lines, opCount: ops.length };
465
+ }
466
+
467
+ function describeCard(model, opCount) {
468
+ const wall = model.payload?.wallMs != null ? formatDuration(model.payload.wallMs) : "";
469
+ const calls = opCount > 0 ? `${opCount} call${opCount === 1 ? "" : "s"}` : "";
470
+ const status = model.isError ? "failed" : model.isPartial ? "running" : calls ? "" : "complete";
471
+ return [calls, status, wall].filter(Boolean).join(" · ");
429
472
  }
430
473
 
431
474
  class UnifiedResultCard {
432
475
  set(theme, model) {
433
476
  this.theme = theme;
434
477
  this.model = model;
478
+ this.frame = undefined;
479
+ }
480
+ invalidate() {
481
+ this.frame = undefined;
435
482
  }
436
- invalidate() {}
437
483
  render(width = 80) {
438
484
  const { theme, model } = this;
439
485
  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);
486
+ if (!this.frame) {
487
+ this.frame = novaFramedBlock(theme, (frameWidth) => {
488
+ const contentWidth = Math.max(1, frameWidth - 4);
489
+ const view = buildBodyLines(theme, contentWidth, model);
490
+ return {
491
+ header: novaStatusLine(theme, {
492
+ icon: model.isError ? "error" : model.isPartial ? "running" : undefined,
493
+ title: "nova",
494
+ description: describeCard(model, view.opCount),
495
+ }),
496
+ sections: view.lines.length > 0 ? [{ lines: view.lines }] : [],
497
+ state: model.isError ? "error" : model.isPartial ? "pending" : "success",
498
+ // borderMuted is invisible on OMP's card background; dim matches the duration column.
499
+ borderColor: model.isError ? "error" : "dim",
500
+ width: frameWidth,
501
+ };
502
+ });
503
+ }
504
+ return this.frame.render(width);
453
505
  }
454
506
  }
455
507
 
508
+ function syncState(context, payload) {
509
+ if (!context?.state || !payload) return;
510
+ if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) context.state.trace = payload.trace;
511
+ if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) context.state.wallMs = payload.wallMs;
512
+ }
513
+
456
514
  export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextArg) {
457
515
  const { result, expanded, isPartial, theme, context, args, options, host } = normalizeResultRenderArgs(
458
516
  resultArg,
@@ -462,34 +520,13 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
462
520
  );
463
521
 
464
522
  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 });
523
+ syncState(context, payload);
476
524
 
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(" · ");
525
+ const isError = result?.isError || payload?.ok === false;
489
526
  const previous = host === "omp" ? options?.lastComponent : context?.lastComponent;
490
527
  const comp = previous instanceof UnifiedResultCard ? previous : new UnifiedResultCard();
491
528
  if (host === "omp" && options) options.lastComponent = comp;
492
529
  else if (context) context.lastComponent = comp;
493
- comp.set(theme, { body, description, isError: isErr, isPartial });
530
+ comp.set(theme, { payload, context, args, expanded, isPartial, isError });
494
531
  return comp;
495
532
  }