pi-supernova 0.0.4 → 0.0.6

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 CHANGED
@@ -6,12 +6,12 @@
6
6
  * so every width/truncate path here is self-contained and must never trust a host
7
7
  * truncate that appends ellipsis after cutting to maxWidth.
8
8
  *
9
- * OMP path uses rounded framedBlock chrome (same as native write/edit). Pi path
10
- * keeps the muted violet SafeText wash (no side rails).
9
+ * Pi and OMP share one self-owned result card. The call slot stays empty so the
10
+ * lifecycle never duplicates; mutating operations include bounded inline diffs.
11
11
  */
12
12
 
13
13
  import { stripVTControlCharacters } from "node:util";
14
- import { isString, isObject } from "./decode.js";
14
+ import { isString, isObject, isFunction } from "./decode.js";
15
15
  import {
16
16
  measureWidth,
17
17
  hardTruncate,
@@ -43,83 +43,18 @@ function fitOutputLines(text, width) {
43
43
  return out.length > 0 ? out : [""];
44
44
  }
45
45
 
46
- /**
47
- * Soft violet / grey-blue wash — same structure as Pi's standard tool Box
48
- * (padding + background), just purple-tinted instead of green.
49
- * Tuned for dark themes (tokyo-night and friends).
50
- */
51
- export const NOVA_CHROME = {
52
- pendingBg: [26, 28, 42], // deep grey-blue
53
- successBg: [24, 30, 46], // muted blue-purple
54
- errorBg: [40, 26, 34], // muted rose-purple
55
- };
56
-
57
- function bgRgb(rgb, text) {
58
- const [r, g, b] = rgb;
59
- return `\x1b[48;2;${r};${g};${b}m${text}\x1b[49m`;
60
- }
61
-
62
- function chromeBg(tone) {
63
- if (tone === "error") return NOVA_CHROME.errorBg;
64
- if (tone === "success") return NOVA_CHROME.successBg;
65
- return NOVA_CHROME.pendingBg;
66
- }
67
-
68
- /**
69
- * Paint one row like Pi's Box: 1-col pad + content, washed to full width.
70
- * No side-rail characters — the background block is the "border".
71
- * Final visible width is always ≤ `width` (Pi crash contract).
72
- */
73
- export function paintNovaRow(content, width, tone = "pending") {
74
- const w = Math.max(1, width | 0);
75
- const bg = chromeBg(tone);
76
- const padX = 1;
77
- const inner = Math.max(1, w - padX * 2);
78
- const body = clampLine(content, inner);
79
- const row = `${" ".repeat(padX)}${body}`;
80
- const pad = Math.max(0, w - measureWidth(row));
81
- const painted = bgRgb(bg, row + " ".repeat(pad));
82
- if (measureWidth(painted) <= w) return painted;
83
- return clampLine(stripVTControlCharacters(painted), w);
84
- }
85
-
86
- /**
87
- * Framed Text for supernova. Uses renderShell: "self" so we own the chrome
88
- * color (muted purple/grey-blue) instead of the host's green tool panels.
89
- * Structure matches Pi's standard Box (pad + bg), without side-rail characters.
90
- */
46
+ /** Compact bounded text; the host supplies the card background and borders. */
91
47
  export class SafeText {
92
48
  constructor(text = "") {
93
49
  this.text = text;
94
- this.tone = "pending";
95
- this.framing = true;
96
50
  }
97
51
  setText(text) {
98
52
  this.text = text;
99
53
  }
100
- setTone(tone) {
101
- if (tone === "error" || tone === "success" || tone === "pending") this.tone = tone;
102
- }
103
- setFraming(enabled) {
104
- this.framing = !!enabled;
105
- }
106
54
  invalidate() {}
107
55
  render(width = 80) {
108
- const w = Math.max(1, width | 0);
109
56
  const raw = String(this.text ?? "");
110
- if (!raw.trim()) return [];
111
-
112
- if (!this.framing) {
113
- return fitOutputLines(raw, w);
114
- }
115
-
116
- // Match Pi Box: 1-col horizontal pad, 1-row vertical pad, purple bg wash.
117
- const padX = 1;
118
- const inner = Math.max(1, w - padX * 2);
119
- const bodyLines = fitOutputLines(raw, inner);
120
- const empty = paintNovaRow("", w, this.tone);
121
- const painted = bodyLines.map((line) => paintNovaRow(line, w, this.tone));
122
- return [empty, ...painted, empty];
57
+ return raw.trim() ? fitOutputLines(raw, Math.max(1, width | 0)) : [];
123
58
  }
124
59
  }
125
60
 
@@ -127,24 +62,6 @@ export class SafeText {
127
62
  export const visibleWidth = measureWidth;
128
63
  export const truncateToWidth = hardTruncate;
129
64
 
130
- const ACTION_ICONS = {
131
- write: "✎ ",
132
- edit: "✎ ",
133
- apply_patch: "✎ ",
134
- patch: "✎ ",
135
- bash: "❯ ",
136
- exec: "❯ ",
137
- read: "▤ ",
138
- surface: "▤ ",
139
- search: "⌕ ",
140
- grep: "⌕ ",
141
- find: "⌕ ",
142
- ls: "▤ ",
143
- // Avoid double-width emoji (⚡) — measure disagreements with Pi caused 92>91 crashes.
144
- speculate: "✶ ",
145
- snap: "⌖ ",
146
- };
147
-
148
65
  export function extractOperationsFromCode(code) {
149
66
  const trimmed = String(code || "").trim();
150
67
  if (!trimmed) return [];
@@ -185,18 +102,20 @@ export function extractOperationsFromCode(code) {
185
102
 
186
103
  const namedCalls = [
187
104
  { regex: /(?:^|[^\w$.])(?:nova\.)?read\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "read", wrap: (p) => p },
188
- { regex: /(?:^|[^\w$.])(?:nova\.)?write\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
105
+ { regex: /(?:^|[^\w$.])(?:nova\.)?write\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "write", wrap: (p) => p },
189
106
  { regex: /(?:^|[^\w$.])(?:nova\.)?edit\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
190
- { regex: /(?:^|[^\w$.])(?:nova\.)?patch\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
107
+ { regex: /(?:^|[^\w$.])(?:nova\.)?patch\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "patch", wrap: (p) => p },
191
108
  {
192
109
  regex: /(?:^|[^\w$.])(?:nova\.)?bash\s*\(\s*["'`]([^"'`]+)["'`]/gm,
193
110
  tool: "bash",
194
111
  wrap: (c) => (c.length > 32 ? c.slice(0, 29) + "…" : c),
195
112
  },
196
- { regex: /(?:^|[^\w$.])(?:nova\.)?exec\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "bash", wrap: (c) => c },
197
- { regex: /(?:nova\.)?search\s*\(\s*["'`]([^"'`]+)["'`]/g, tool: "read", wrap: (q) => `"${q}"` },
198
- { regex: /(?:nova\.)?surface\s*\(\s*["'`]([^"'`]+)["'`]/g, tool: "read", wrap: (p) => p },
199
- { regex: /(?:nova\.)?snap\s*\(\s*["'`]([^"'`]+)["'`]/g, tool: "read", wrap: (q) => `"${q}"` },
113
+ { regex: /(?:^|[^\w$.])(?:nova\.)?exec\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "exec", wrap: (c) => c },
114
+ { regex: /(?:^|[^\w$.])(?:nova\.)?search\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "search", wrap: (q) => `"${q}"` },
115
+ { regex: /(?:^|[^\w$.])nova\.describe\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "describe", wrap: (name) => name },
116
+ { regex: /(?:^|[^\w$.])nova\.has\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "has", wrap: (name) => name },
117
+ { regex: /(?:^|[^\w$.])(?:nova\.)?surface\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "surface", wrap: (p) => p },
118
+ { regex: /(?:^|[^\w$.])(?:nova\.)?snap\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "snap", wrap: (q) => `"${q}"` },
200
119
  ];
201
120
  for (const item of namedCalls) {
202
121
  while ((match = item.regex.exec(trimmed)) !== null) {
@@ -211,77 +130,70 @@ export function extractOperationsFromCode(code) {
211
130
  return ops;
212
131
  }
213
132
 
214
- export function renderDiffBox(diff, theme, width = 60) {
215
- if (!diff || !Array.isArray(diff.lines) || diff.lines.length === 0) return "";
216
-
217
- const w = Math.max(20, width | 0);
218
- const cleanPath = String(diff.path || "").replace(/\\/g, "/");
219
- const baseName = cleanPath.split("/").pop() || cleanPath;
220
- const opLabel = diff.op === "edit" ? "Edit" : diff.op === "write" ? "Write" : "Patch";
221
-
222
- // Stats BEFORE path so +N/-N survive narrow-terminal truncation (prior test/crash footgun).
223
- const stats =
224
- theme.fg("dim", "⟨") +
225
- theme.fg("toolDiffAdded", `+${diff.added}`) +
226
- theme.fg("dim", "/") +
227
- theme.fg("toolDiffRemoved", `-${diff.removed}`) +
228
- theme.fg("dim", "⟩");
229
- // Do not clamp here — SafeText.render(terminalWidth) is the single choke point.
230
- // Pre-clamping with a guessed width ate filenames under mock/ANSI-marker themes.
231
- const header =
232
- theme.fg("accent", "✎ ") +
233
- theme.fg("toolTitle", theme.bold(`${opLabel} `)) +
234
- stats +
235
- " " +
236
- theme.fg("muted", baseName);
237
-
238
- const divWidth = Math.min(w, Math.max(20, Math.min(70, w)));
239
- const divider = theme.fg("borderMuted", "─".repeat(divWidth));
240
-
241
- const maxShown = 8;
242
- const shownLines = diff.lines.slice(0, maxShown);
133
+ function formatDiffRows(diff, theme, maxShown = 6) {
134
+ if (!diff || !Array.isArray(diff.lines) || diff.lines.length === 0) return [];
243
135
  const body = [];
244
-
245
- for (const item of shownLines) {
136
+ for (const item of diff.lines.slice(0, maxShown)) {
246
137
  const num = item.lineNum || 0;
247
- let row;
248
138
  if (item.type === "remove") {
249
139
  const gut = theme.fg("toolDiffRemoved", `-${num}`.padStart(5));
250
- const sep = theme.fg("borderMuted", " │ ");
251
- const txt = theme.fg("toolDiffRemoved", `- ${item.text}`);
252
- row = `${gut}${sep}${txt}`;
140
+ body.push(`${gut}${theme.fg("borderMuted", " │ ")}${theme.fg("toolDiffRemoved", `- ${cleanInlineText(item.text)}`)}`);
253
141
  } else if (item.type === "add") {
254
142
  const gut = theme.fg("toolDiffAdded", `+${num}`.padStart(5));
255
- const sep = theme.fg("borderMuted", " │ ");
256
- const txt = theme.fg("toolDiffAdded", `+ ${item.text}`);
257
- row = `${gut}${sep}${txt}`;
143
+ body.push(`${gut}${theme.fg("borderMuted", " │ ")}${theme.fg("toolDiffAdded", `+ ${cleanInlineText(item.text)}`)}`);
258
144
  } else {
259
145
  const gut = theme.fg("dim", ` ${num}`.padStart(5));
260
- const sep = theme.fg("borderMuted", " │ ");
261
- const txt = theme.fg("toolDiffContext", ` ${item.text}`);
262
- row = `${gut}${sep}${txt}`;
146
+ body.push(`${gut}${theme.fg("borderMuted", " │ ")}${theme.fg("toolDiffContext", ` ${cleanInlineText(item.text)}`)}`);
263
147
  }
264
- body.push(row);
265
148
  }
149
+ const displayLineCount = Number.isInteger(diff.displayLineCount) ? diff.displayLineCount : diff.lines.length;
150
+ if (displayLineCount > maxShown) {
151
+ body.push(theme.fg("dim", ` │ … ${displayLineCount - maxShown} more lines`));
152
+ }
153
+ return body;
154
+ }
155
+
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
+ }
266
167
 
267
- if (diff.lines.length > maxShown) {
268
- const remaining = diff.lines.length - maxShown;
269
- body.push(theme.fg("dim", ` │ ${remaining} more lines`));
168
+ function stripUnsafeControls(value) {
169
+ let clean = "";
170
+ for (const character of value) {
171
+ const codePoint = character.codePointAt(0);
172
+ const isC0 = codePoint <= 0x08 || codePoint === 0x0b || codePoint === 0x0c || (codePoint >= 0x0e && codePoint <= 0x1f);
173
+ const isDeleteOrC1 = codePoint >= 0x7f && codePoint <= 0x9f;
174
+ if (!isC0 && !isDeleteOrC1) clean += character;
270
175
  }
176
+ return clean;
177
+ }
178
+
179
+ function cleanBlockText(value) {
180
+ const normalized = stripVTControlCharacters(String(value ?? "")).replace(/\r\n?/g, "\n");
181
+ return stripUnsafeControls(normalized).replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
182
+ }
271
183
 
272
- return `${header}\n${divider}\n${body.join("\n")}\n${divider}`;
184
+ function cleanInlineText(value) {
185
+ return cleanBlockText(value).replace(/\s*\n\s*/g, " ").trim();
273
186
  }
274
187
 
275
- function displayOperation(tool, target) {
276
- if (["write", "edit", "apply_patch", "patch"].includes(tool)) return { tool: "edit", target };
277
- if (["bash", "exec"].includes(tool)) return { tool: "bash", target };
278
- if (["read", "surface", "snap", "search", "grep", "find", "ls"].includes(tool))
279
- return { tool: "read", target };
280
- return null;
188
+ function displayOperation(tool, target, diff, ok) {
189
+ const rawName = cleanInlineText(tool);
190
+ if (!rawName) return null;
191
+ const normalized = rawName === "apply_patch" ? "patch" : rawName;
192
+ return { tool: normalized, target, diff, ok };
281
193
  }
282
194
 
283
195
  function formatOpTarget(raw, tool) {
284
- const text = String(raw ?? "");
196
+ const text = cleanInlineText(raw);
285
197
  if (!text) return "";
286
198
  if (tool === "bash") {
287
199
  // Keep commands readable; wrap handles the rest at render time.
@@ -292,7 +204,7 @@ function formatOpTarget(raw, tool) {
292
204
  }
293
205
 
294
206
  function isTheme(value) {
295
- return !!value && typeof value === "object" && typeof value.fg === "function";
207
+ return isObject(value) && isFunction(value.fg);
296
208
  }
297
209
 
298
210
  /**
@@ -302,13 +214,13 @@ function isTheme(value) {
302
214
  */
303
215
  export function normalizeCallRenderArgs(a, b, c) {
304
216
  if (isTheme(b)) {
305
- const context = c && typeof c === "object" ? c : {};
306
- if (!context.state || typeof context.state !== "object") context.state = {};
217
+ const context = isObject(c) ? c : {};
218
+ if (!isObject(context.state)) context.state = {};
307
219
  return { args: a, theme: b, context, host: "pi" };
308
220
  }
309
221
  if (isTheme(c)) {
310
- const options = b && typeof b === "object" ? b : {};
311
- if (!options.state || typeof options.state !== "object") options.state = {};
222
+ const options = isObject(b) ? b : {};
223
+ if (!isObject(options.state)) options.state = {};
312
224
  const context = {
313
225
  ...options,
314
226
  state: options.state,
@@ -329,21 +241,19 @@ export function normalizeCallRenderArgs(a, b, c) {
329
241
  * Pi: (result, {expanded,isPartial}, theme, context)
330
242
  * OMP: (result, {expanded,isPartial}, theme, args) — 4th is args, not context
331
243
  *
332
- * Call shapes share the first three positions, so host is inferred from the 4th
333
- * arg / whether the OMP tui framedBlock import resolved.
244
+ * Call shapes share the first three positions, so host is inferred from the
245
+ * fourth argument's context-versus-args shape.
334
246
  */
335
247
  function detectResultHost(options, ctxOrArgs) {
336
248
  if (isTheme(options)) return "pi";
337
249
  if (
338
- ctxOrArgs &&
339
- typeof ctxOrArgs === "object" &&
250
+ isObject(ctxOrArgs) &&
340
251
  ("lastComponent" in ctxOrArgs || "invalidate" in ctxOrArgs)
341
252
  ) {
342
253
  return "pi";
343
254
  }
344
255
  if (
345
- ctxOrArgs &&
346
- typeof ctxOrArgs === "object" &&
256
+ isObject(ctxOrArgs) &&
347
257
  ("code" in ctxOrArgs || "timeoutMs" in ctxOrArgs)
348
258
  ) {
349
259
  return "omp";
@@ -353,39 +263,40 @@ function detectResultHost(options, ctxOrArgs) {
353
263
 
354
264
  export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
355
265
  if (isTheme(themeOrCtx)) {
356
- const opts = options && typeof options === "object" ? options : {};
266
+ const opts = isObject(options) ? options : {};
357
267
  let context;
358
268
  if (
359
- ctxOrArgs &&
360
- typeof ctxOrArgs === "object" &&
269
+ isObject(ctxOrArgs) &&
361
270
  !isTheme(ctxOrArgs) &&
362
271
  ("lastComponent" in ctxOrArgs || "state" in ctxOrArgs || "invalidate" in ctxOrArgs)
363
272
  ) {
364
273
  context = ctxOrArgs;
365
274
  } else {
366
- context = { ...(opts.state ? { state: opts.state } : {}), lastComponent: opts.lastComponent };
275
+ context = { state: opts.state, lastComponent: opts.lastComponent };
367
276
  }
368
- if (!context.state || typeof context.state !== "object") context.state = {};
277
+ if (!isObject(context.state)) context.state = {};
369
278
  return {
370
279
  result,
371
280
  expanded: !!opts.expanded,
372
281
  isPartial: !!opts.isPartial,
373
282
  theme: themeOrCtx,
374
283
  context,
284
+ args: ctxOrArgs?.code ? ctxOrArgs : context.args,
375
285
  host: detectResultHost(options, ctxOrArgs),
376
286
  options: opts,
377
287
  };
378
288
  }
379
289
  // Extremely defensive: (result, theme, context) oddball
380
290
  if (isTheme(options)) {
381
- const context = themeOrCtx && typeof themeOrCtx === "object" ? themeOrCtx : {};
382
- if (!context.state || typeof context.state !== "object") context.state = {};
291
+ const context = isObject(themeOrCtx) ? themeOrCtx : {};
292
+ if (!isObject(context.state)) context.state = {};
383
293
  return {
384
294
  result,
385
295
  expanded: !!context.expanded,
386
296
  isPartial: !!context.isPartial,
387
297
  theme: options,
388
298
  context,
299
+ args: context.args,
389
300
  host: "pi",
390
301
  options: {},
391
302
  };
@@ -393,225 +304,157 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
393
304
  throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
394
305
  }
395
306
 
396
- function collectCallOps(args, context) {
397
- const stateTrace = context?.state?.trace;
398
- if (Array.isArray(stateTrace) && stateTrace.length > 0) {
399
- return stateTrace
400
- .map((item) => {
401
- const tool = item?.name || "tool";
402
- let target = "";
403
- if (item?.args?.path) target = String(item.args.path);
404
- else if (item?.args?.command) target = String(item.args.command);
405
- else if (item?.args?.pattern) target = String(item.args.pattern);
406
- return displayOperation(tool, target);
407
- })
408
- .filter(Boolean);
409
- }
410
- return extractOperationsFromCode(args?.code)
411
- .map((op) => displayOperation(op.tool, op.target))
412
- .filter(Boolean);
413
- }
414
-
415
- function formatElapsed(state) {
416
- if (state?.wallMs != null) return `${state.wallMs}ms`;
417
- if (state?.startedAt != null) {
418
- const elapsed = Math.round(performance.now() - state.startedAt);
419
- // Suppress sub-100ms noise on the pending head (matches quiet Write/Edit calls).
420
- if (elapsed < 100) return "";
421
- return elapsed >= 1000 ? `${(elapsed / 1000).toFixed(1)}s` : `${elapsed}ms`;
422
- }
307
+ function operationTarget(item) {
308
+ 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;
313
+ }
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);
423
321
  return "";
424
322
  }
425
323
 
426
- function tickCallTimer(context) {
427
- const state = context?.state;
428
- if (state && state.startedAt == null) state.startedAt = performance.now();
429
- if (state && context?.executionStarted && state.wallMs == null && state.timer == null) {
430
- state.timer = setTimeout(() => {
431
- state.timer = null;
432
- context.invalidate?.();
433
- }, 100);
434
- }
435
- }
436
-
437
- function formatOpBodyLine(theme, op) {
438
- const icon = ACTION_ICONS[op.tool] || "✦ ";
439
- const bullet = theme.fg("accent", icon);
440
- const toolName = theme.fg("syntaxFunction", op.tool.padEnd(4, " "));
441
- const rawTarget = formatOpTarget(op.target, op.tool);
442
- const target = rawTarget ? " " + theme.fg("muted", rawTarget) : "";
443
- return `${bullet}${toolName}${target}`;
444
- }
445
-
446
- function shouldUseOmpFrame(host) {
447
- return host === "omp";
448
- }
449
-
450
- /**
451
- * OMP write/edit look: rounded framedBlock + status-line header.
452
- * Pending call has no hourglass on the head row (same as native Write/Edit).
453
- */
454
- function renderOmpCallCard(theme, { ops, timeStr, expanded, code }) {
455
- const opSummary =
456
- ops.length === 0
457
- ? "composing"
458
- : ops.map((op) => op.tool).join(theme.sep?.dot ? ` ${theme.sep.dot} ` : " · ");
459
- const description = timeStr ? `${opSummary} · ${timeStr}` : opSummary;
460
- // No pending icon on the framed head row — matches native Write/Edit.
461
- const header = novaStatusLine(theme, {
462
- title: "nova",
463
- description,
464
- });
465
- return novaFramedBlock(theme, (width) => {
466
- const bodyLines = ops.map((op) => formatOpBodyLine(theme, op));
467
- if (expanded && code) {
468
- bodyLines.push(theme.fg("dim", "── source ──"));
469
- for (const line of String(code).trim().split("\n")) {
470
- bodyLines.push(theme.fg("toolOutput", line));
471
- }
472
- }
473
- return {
474
- header,
475
- sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
476
- state: "pending",
477
- borderColor: "borderMuted",
478
- width,
479
- };
480
- });
481
- }
482
-
483
- function renderOmpResultCard(theme, { isErr, payload, expanded, bodyText, isPartial, spinnerFrame }) {
484
- if (isErr) {
485
- const errLines = [];
486
- if (payload?.error) errLines.push(theme.fg("error", String(payload.error)));
487
- if (expanded && payload?.logs?.length) {
488
- errLines.push(theme.fg("dim", "── logs ──"));
489
- for (const log of payload.logs) errLines.push(theme.fg("dim", String(log)));
324
+ function normalizeTraceDiff(item) {
325
+ const diff = item?.diff;
326
+ if (isObject(diff)) return diff;
327
+ if (!isString(diff) || !diff.trim()) return undefined;
328
+ const lines = [];
329
+ let added = 0;
330
+ let removed = 0;
331
+ 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;
490
339
  }
491
- const header = novaStatusLine(theme, {
492
- icon: "error",
493
- title: "nova",
494
- description: payload?.error ? String(payload.error).split("\n")[0] : "error",
495
- });
496
- return novaFramedBlock(theme, (width) => ({
497
- header,
498
- sections: errLines.length > 0 ? [{ lines: errLines }] : [],
499
- state: "error",
500
- borderColor: "error",
501
- width,
502
- }));
340
+ match = /^\s+(\d+)\s?(.*)$/.exec(rawLine);
341
+ if (match) lines.push({ type: "context", lineNum: Number(match[1]), text: match[2] });
503
342
  }
343
+ if (lines.length === 0) return undefined;
344
+ return { path: item?.args?.path || "", op: item?.name, added, removed, lines };
345
+ }
504
346
 
505
- const wall = payload?.wallMs != null ? `${payload.wallMs}ms` : "";
506
- const header = novaStatusLine(theme, {
507
- icon: isPartial ? "running" : undefined,
508
- spinnerFrame,
509
- title: "nova",
510
- description: wall || undefined,
511
- });
512
- const bodyLines = String(bodyText || "").split("\n");
513
- while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
514
- while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") bodyLines.pop();
515
- return novaFramedBlock(theme, (width) => ({
516
- header,
517
- sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
518
- state: isPartial ? "pending" : "success",
519
- borderColor: "borderMuted",
520
- width,
521
- }));
347
+ function operationsFromTrace(trace) {
348
+ if (!Array.isArray(trace)) return [];
349
+ return trace
350
+ .map((item) => displayOperation(item?.name || "tool", operationTarget(item), normalizeTraceDiff(item), item?.ok))
351
+ .filter(Boolean);
522
352
  }
523
353
 
524
354
  export function renderSupernovaCall(a, b, c) {
525
- const { args, theme, context, options, host } = normalizeCallRenderArgs(a, b, c);
526
- tickCallTimer(context);
527
- const ops = collectCallOps(args, context);
528
- const timeStr = formatElapsed(context?.state);
529
-
530
- if (shouldUseOmpFrame(host)) {
531
- return renderOmpCallCard(theme, {
532
- ops,
533
- timeStr,
534
- expanded: !!context?.expanded,
535
- code: args?.code,
536
- });
537
- }
538
-
355
+ const { context, options } = normalizeCallRenderArgs(a, b, c);
539
356
  const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
540
357
  if (options) options.lastComponent = comp;
541
358
  else if (context) context.lastComponent = comp;
359
+ comp.setText("");
360
+ return comp;
361
+ }
542
362
 
543
- // Compact aesthetic — never dump raw JSON args (the stock tool fallback).
544
- let out = theme.fg("toolTitle", theme.bold("nova"));
545
- if (timeStr) {
546
- out += " " + theme.fg("dim", `· ${timeStr}`);
547
- }
548
-
549
- if (ops.length === 0) {
550
- out += " " + theme.fg("dim", "· composing");
551
- } else {
552
- for (const op of ops) {
553
- out += `\n ${formatOpBodyLine(theme, op)}`;
554
- }
555
- }
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
+ }
556
367
 
557
- if (context?.expanded && args?.code) {
558
- out += "\n" + theme.fg("dim", "── source ──");
559
- out += "\n" + theme.fg("toolOutput", String(args.code).trim());
560
- }
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
+ }
561
382
 
562
- if (context?.isError) comp.setTone("error");
563
- else if (context?.isPartial) comp.setTone("pending");
564
- else comp.setTone("success");
565
- comp.setFraming(true);
566
- comp.setText(out);
567
- return comp;
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);
389
+ }
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);
568
394
  }
569
395
 
570
- function buildResultBody(theme, { payload, context, expanded }) {
396
+ function buildResultBody(theme, { payload, context, args, expanded, isPartial, isError }) {
571
397
  let out = "";
572
398
  const trace = payload?.trace || context?.state?.trace || [];
573
- const diffs = trace.filter((t) => t?.diff && isObject(t.diff)).map((t) => t.diff);
574
-
575
- if (diffs.length > 0) {
576
- const maxDiffsShown = expanded ? diffs.length : 2;
577
- const shownDiffs = diffs.slice(0, maxDiffsShown);
578
- for (const diff of shownDiffs) {
579
- const box = renderDiffBox(diff, theme, 120);
580
- if (box) out += (out ? "\n\n" : "") + box;
581
- }
582
- if (!expanded && diffs.length > maxDiffsShown) {
583
- const remaining = diffs.length - maxDiffsShown;
584
- out +=
585
- "\n\n" +
586
- theme.fg(
587
- "dim",
588
- `… ${remaining} more file edit${remaining === 1 ? "" : "s"} (press Enter to expand)`,
589
- );
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}`;
590
414
  }
591
415
  }
416
+ if (ops.length > maxOps) out += `\n│\n└─ ${theme.fg("dim", `… ${ops.length - maxOps} more calls`)}`;
592
417
 
593
418
  if (expanded) {
594
- const resVal = payload?.result;
595
- if (resVal !== undefined) {
596
- let formatted;
597
- try {
598
- formatted = isString(resVal) ? resVal : JSON.stringify(resVal, null, 2);
599
- } catch {
600
- formatted = String(resVal);
601
- }
419
+ if (payload?.result !== undefined) {
602
420
  out += (out ? "\n" : "") + theme.fg("dim", "── result ──");
603
- out += "\n" + theme.fg("toolOutput", formatted);
421
+ out += "\n" + theme.fg("toolOutput", boundedResult(payload.result));
604
422
  }
605
- if (payload?.logs?.length) {
423
+ if (!isError && payload?.logs?.length) {
606
424
  out += (out ? "\n" : "") + theme.fg("dim", "── logs ──");
607
- for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
425
+ for (const log of payload.logs.slice(0, 24)) out += `\n ${theme.fg("dim", cleanBlockText(log))}`;
608
426
  }
609
427
  }
610
- return out;
428
+ return { body: out, opCount: ops.length };
429
+ }
430
+
431
+ class UnifiedResultCard {
432
+ set(theme, model) {
433
+ this.theme = theme;
434
+ this.model = model;
435
+ }
436
+ invalidate() {}
437
+ render(width = 80) {
438
+ const { theme, model } = this;
439
+ 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);
453
+ }
611
454
  }
612
455
 
613
456
  export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextArg) {
614
- const { result, expanded, isPartial, theme, context, options, host } = normalizeResultRenderArgs(
457
+ const { result, expanded, isPartial, theme, context, args, options, host } = normalizeResultRenderArgs(
615
458
  resultArg,
616
459
  optionsArg,
617
460
  themeArg,
@@ -620,102 +463,33 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
620
463
 
621
464
  const payload = result?.details;
622
465
  if (context?.state && payload) {
623
- let changed = false;
624
466
  if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) {
625
467
  context.state.trace = payload.trace;
626
- changed = true;
627
468
  }
628
469
  if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) {
629
470
  context.state.wallMs = payload.wallMs;
630
- changed = true;
631
- }
632
- if (context.state.timer != null && !isPartial) {
633
- clearTimeout(context.state.timer);
634
- context.state.timer = null;
635
471
  }
636
- if (changed) context.invalidate?.();
637
472
  }
638
473
 
639
474
  const isErr = result?.isError || payload?.ok === false;
640
- const useOmp = shouldUseOmpFrame(host);
641
-
642
- if (useOmp) {
643
- if (isPartial && !isErr) {
644
- // Streaming partials stay quiet until body content exists — same as Pi.
645
- const partialBody = buildResultBody(theme, { payload, context, expanded });
646
- if (!partialBody.trim()) {
647
- return {
648
- render: () => [],
649
- invalidate() {},
650
- };
651
- }
652
- return renderOmpResultCard(theme, {
653
- isErr: false,
654
- payload,
655
- expanded,
656
- bodyText: partialBody,
657
- isPartial: true,
658
- spinnerFrame: options?.spinnerFrame,
659
- });
660
- }
661
- if (isErr) {
662
- return renderOmpResultCard(theme, {
663
- isErr: true,
664
- payload,
665
- expanded,
666
- bodyText: "",
667
- isPartial: false,
668
- spinnerFrame: options?.spinnerFrame,
669
- });
670
- }
671
- const out = buildResultBody(theme, { payload, context, expanded });
672
- if (!out.trim()) {
673
- return {
674
- render: () => [],
675
- invalidate() {},
676
- };
677
- }
678
- return renderOmpResultCard(theme, {
679
- isErr: false,
680
- payload,
681
- expanded,
682
- bodyText: out,
683
- isPartial: false,
684
- spinnerFrame: options?.spinnerFrame,
685
- });
686
- }
687
-
688
- const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
689
- if (options) options.lastComponent = comp;
690
- else if (context) context.lastComponent = comp;
691
-
692
- if (isPartial) {
693
- comp.setText("");
694
- return comp;
695
- }
475
+ const view = buildResultBody(theme, { payload, context, args, expanded, isPartial, isError: isErr });
696
476
 
477
+ let body = view.body;
697
478
  if (isErr) {
698
- let out = theme.fg("error", "✗ error");
699
- if (payload?.error) {
700
- out += `\n ${theme.fg("error", String(payload.error))}`;
701
- }
479
+ body += (body ? "\n" : "") + theme.fg("error", payload?.error ? cleanBlockText(payload.error) : "error");
702
480
  if (expanded && payload?.logs?.length) {
703
- out += `\n${theme.fg("dim", "── logs ──")}`;
704
- for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
481
+ body += `\n${theme.fg("dim", "── logs ──")}`;
482
+ for (const log of payload.logs.slice(0, 24)) body += `\n${theme.fg("dim", cleanBlockText(log))}`;
705
483
  }
706
- comp.setTone("error");
707
- comp.setFraming(true);
708
- comp.setText(out);
709
- return comp;
710
- }
711
-
712
- const out = buildResultBody(theme, { payload, context, expanded });
713
- if (!out.trim()) {
714
- comp.setText("");
715
- return comp;
716
484
  }
717
- comp.setTone("success");
718
- comp.setFraming(true);
719
- comp.setText(out);
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(" · ");
489
+ const previous = host === "omp" ? options?.lastComponent : context?.lastComponent;
490
+ const comp = previous instanceof UnifiedResultCard ? previous : new UnifiedResultCard();
491
+ if (host === "omp" && options) options.lastComponent = comp;
492
+ else if (context) context.lastComponent = comp;
493
+ comp.set(theme, { body, description, isError: isErr, isPartial });
720
494
  return comp;
721
495
  }