pi-supernova 0.0.5 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.0.6] - 2026-09-03
6
+
7
+ ### Changed
8
+
9
+ - Pi and OMP now share one self-owned framed result card. The hidden call slot prevents duplicate lifecycle cards, and connected rows preserve visual separation between calls.
10
+
11
+ ### Fixed
12
+
13
+ - Edit, write, and patch calls now show bounded line-numbered removed/added hunks while collapsed, with a larger budget when expanded; captured host executors propagate their native diff metadata into the same UI.
14
+
15
+ - Direct `nova.snap()` and `nova.surface()` helpers now return structured objects instead of host result envelopes.
16
+ - Package metadata now matches the official Pi package contract: every `files` entry exists, and only the actually imported `typebox` host dependency remains declared as a peer.
17
+
5
18
  ## [0.0.5] - 2026-09-03
6
19
 
7
20
  ### Fixed
package/README.md CHANGED
@@ -48,17 +48,19 @@ async () => {
48
48
 
49
49
  Globals: `nova` / `tools`, `parallel`, `pipeline`, `console`, plus shorthand `read`, `write`, `edit`, `patch`, `surface`, `snap`, `bash`, and `exec`.
50
50
 
51
- Terminal card (muted violet / grey-blue not a raw JSON args dump):
51
+ Unified Pi/OMP card with bounded mutation diffs visible while collapsed:
52
52
 
53
53
  ```text
54
- ╭─── nova: 3 calls · 84ms ───────────────────────╮
55
- │ ✓ read packages/pi-supernova/host-bridge.js
56
- ✓ edit packages/pi-supernova/diff.js +2/-1
57
- │ ✓ snap "render lifecycle" → packages
58
- ╰────────────────────────────────────────────────╯
54
+ ╭─── nova: 2 calls · 84ms ───────────────────────────────╮
55
+ ├─ ✓ read packages/pi-supernova/host-bridge.js
56
+ │ │
57
+ └─ edit +1/-1 packages/pi-supernova/diff.js
58
+ │ -143 │ - const oldValue = before; │
59
+ │ +143 │ + const newValue = after; │
60
+ ╰─────────────────────────────────────────────────────────╯
59
61
  ```
60
62
 
61
- Collapsed cards keep this command ledger visible. Press Enter to inspect bounded diff hunks, logs, and the returned value.
63
+ Collapsed cards show bounded edit/write/patch hunks immediately. Press Enter for a larger hunk budget, logs, and the returned value.
62
64
 
63
65
  ---
64
66
 
package/diff.js CHANGED
@@ -2,12 +2,12 @@
2
2
  import { isString } from "./decode.js";
3
3
 
4
4
  export function buildEditDiff(filePath, originalText, oldText, newText) {
5
- const fileLines = isString(originalText) ? originalText.split("\n") : [];
5
+ const fileLines = contentLines(originalText);
6
6
  const idx = isString(originalText) ? originalText.indexOf(oldText) : -1;
7
7
 
8
8
  const startLine = idx >= 0 ? originalText.slice(0, idx).split("\n").length : 1;
9
- const oldLines = oldText.split("\n");
10
- const newLines = newText.split("\n");
9
+ const oldLines = contentLines(oldText);
10
+ const newLines = contentLines(newText);
11
11
 
12
12
  const lines = [];
13
13
  if (startLine > 1 && fileLines.length >= startLine - 1) {
@@ -21,9 +21,9 @@ export function buildEditDiff(filePath, originalText, oldText, newText) {
21
21
  lines.push({ type: "add", lineNum: startLine + i, text: newLines[i] });
22
22
  }
23
23
 
24
- const afterLine = startLine + oldLines.length;
25
- if (fileLines.length >= afterLine) {
26
- lines.push({ type: "context", lineNum: afterLine, text: fileLines[afterLine - 1] });
24
+ const afterSourceLine = startLine + oldLines.length;
25
+ if (fileLines.length >= afterSourceLine) {
26
+ lines.push({ type: "context", lineNum: startLine + newLines.length, text: fileLines[afterSourceLine - 1] });
27
27
  }
28
28
 
29
29
  return {
@@ -61,61 +61,53 @@ export function buildPatchDiff(filePath, patchText) {
61
61
  const lines = [];
62
62
  let added = 0;
63
63
  let removed = 0;
64
- let currentLineNum = 1;
64
+ let oldLineNum = 1;
65
+ let newLineNum = 1;
65
66
  let inHunk = false;
66
67
 
67
- for (const pLine of patchLines) {
68
- const headerMatch = /^@@\s+-(\d+)/.exec(pLine);
68
+ for (const patchLine of patchLines) {
69
+ const headerMatch = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)/.exec(patchLine);
69
70
  if (headerMatch) {
70
- currentLineNum = parseInt(headerMatch[1], 10);
71
+ oldLineNum = Number(headerMatch[1]);
72
+ newLineNum = Number(headerMatch[2]);
71
73
  inHunk = true;
72
74
  continue;
73
75
  }
74
- if (!inHunk || pLine.startsWith("\\")) continue;
75
- if (pLine.startsWith("-")) {
76
+ if (!inHunk || patchLine.startsWith("\\")) continue;
77
+ if (patchLine.startsWith("-")) {
76
78
  removed += 1;
77
- lines.push({ type: "remove", lineNum: currentLineNum, text: pLine.slice(1) });
78
- currentLineNum += 1;
79
- } else if (pLine.startsWith("+")) {
79
+ lines.push({ type: "remove", lineNum: oldLineNum, text: patchLine.slice(1) });
80
+ oldLineNum += 1;
81
+ } else if (patchLine.startsWith("+")) {
80
82
  added += 1;
81
- lines.push({ type: "add", lineNum: currentLineNum, text: pLine.slice(1) });
82
- } else if (pLine.startsWith(" ")) {
83
- lines.push({ type: "context", lineNum: currentLineNum, text: pLine.slice(1) });
84
- currentLineNum += 1;
83
+ lines.push({ type: "add", lineNum: newLineNum, text: patchLine.slice(1) });
84
+ newLineNum += 1;
85
+ } else if (patchLine.startsWith(" ")) {
86
+ lines.push({ type: "context", lineNum: newLineNum, text: patchLine.slice(1) });
87
+ oldLineNum += 1;
88
+ newLineNum += 1;
85
89
  }
86
90
  }
87
91
 
88
- return {
89
- path: filePath,
90
- op: "apply_patch",
91
- added,
92
- removed,
93
- lines,
94
- };
92
+ return { path: filePath, op: "apply_patch", added, removed, lines };
95
93
  }
96
94
 
97
- export function buildWriteDiff(filePath, previousText, newText) {
98
- const newLines = isString(newText) ? newText.split("\n") : [];
99
- const oldLines = isString(previousText) ? previousText.split("\n") : [];
100
-
101
- if (oldLines.length === 0 || (oldLines.length === 1 && oldLines[0] === "")) {
102
- const sampleCount = Math.min(newLines.length, 6);
103
- const lines = [];
104
- for (let i = 0; i < sampleCount; i++) {
105
- lines.push({ type: "add", lineNum: i + 1, text: newLines[i] });
106
- }
107
- return {
108
- path: filePath,
109
- op: "write",
110
- added: newLines.length,
111
- removed: 0,
112
- lines,
113
- };
114
- }
95
+ function contentLines(text) {
96
+ if (!isString(text) || text.length === 0) return [];
97
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
98
+ if (lines.at(-1) === "") lines.pop();
99
+ return lines;
100
+ }
115
101
 
116
- const sampleCount = Math.min(newLines.length, 6);
102
+ export function buildWriteDiff(filePath, previousText, newText) {
103
+ const newLines = contentLines(newText);
104
+ const oldLines = contentLines(previousText);
105
+ const maxStoredLines = 64;
117
106
  const lines = [];
118
- for (let i = 0; i < sampleCount; i++) {
107
+ for (let i = 0; i < oldLines.length && lines.length < maxStoredLines; i++) {
108
+ lines.push({ type: "remove", lineNum: i + 1, text: oldLines[i] });
109
+ }
110
+ for (let i = 0; i < newLines.length && lines.length < maxStoredLines; i++) {
119
111
  lines.push({ type: "add", lineNum: i + 1, text: newLines[i] });
120
112
  }
121
113
  return {
@@ -123,6 +115,7 @@ export function buildWriteDiff(filePath, previousText, newText) {
123
115
  op: "write",
124
116
  added: newLines.length,
125
117
  removed: oldLines.length,
118
+ displayLineCount: oldLines.length + newLines.length,
126
119
  lines,
127
120
  };
128
121
  }
package/host-bridge.js CHANGED
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { packageHostResult } from "./bottleneck.js";
6
- import { isString, isNumber, isFunction } from "./decode.js";
6
+ import { isString, isNumber, isFunction, isObject } from "./decode.js";
7
7
  import { isMutatingTool, runParallelWave } from "./parallel.js";
8
8
  import { extractStructuralSurface } from "./surface.js";
9
9
  import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
@@ -740,6 +740,18 @@ export function createHostBridge({ pi, config, getCwd }) {
740
740
  vfs.clear();
741
741
  }
742
742
 
743
+ function resultDiff(response) {
744
+ let details = response?.details;
745
+ if (isString(details)) {
746
+ try {
747
+ details = JSON.parse(details);
748
+ } catch {
749
+ return undefined;
750
+ }
751
+ }
752
+ return isObject(details) ? details.diff : undefined;
753
+ }
754
+
743
755
  function notifyCall(record) {
744
756
  if (!callListener) return;
745
757
  try {
@@ -766,13 +778,27 @@ export function createHostBridge({ pi, config, getCwd }) {
766
778
 
767
779
  const record = { name, args: args || {}, time: Date.now() };
768
780
  trace.push(record);
781
+ notifyCall(record);
769
782
 
770
783
  try {
771
784
  const exec = executors.get(name);
772
785
  if (exec) {
786
+ let fallbackDiff;
787
+ if (name === "write" && isString(args?.path) && isString(args?.content)) {
788
+ const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
789
+ let previous = "";
790
+ try {
791
+ previous = await vfs.read(target);
792
+ } catch (error) {
793
+ if (error?.code !== "ENOENT") throw error;
794
+ }
795
+ fallbackDiff = buildWriteDiff(target, previous, args.content);
796
+ }
773
797
  if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
774
798
  const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
799
+ const diff = resultDiff(res) || fallbackDiff;
775
800
  record.ok = res?.isError !== true && res?.details?.ok !== false;
801
+ if (diff && record.ok) record.diff = diff;
776
802
  notifyCall(record);
777
803
  return res;
778
804
  }
@@ -780,8 +806,9 @@ export function createHostBridge({ pi, config, getCwd }) {
780
806
  const native = natives[name];
781
807
  if (native) {
782
808
  const res = await native(args || {}, activeSignal);
783
- if (res?.details?.diff) record.diff = res.details.diff;
809
+ const diff = resultDiff(res);
784
810
  record.ok = res?.isError !== true && res?.details?.ok !== false;
811
+ if (diff && record.ok) record.diff = diff;
785
812
  notifyCall(record);
786
813
  return res;
787
814
  }
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { isString, isFunction } from "./decode.js";
2
+ import { isString, isFunction, isObject } from "./decode.js";
3
3
  import { buildCatalog, searchCatalog, describeTool, mergeNativeToolDefinitions } from "./catalog.js";
4
4
  import { loadConfig } from "./config.js";
5
5
  import { createHostBridge } from "./host-bridge.js";
@@ -30,6 +30,18 @@ try {
30
30
  function result(text, details) {
31
31
  return { content: [{ type: "text", text }], details };
32
32
  }
33
+ function unwrapStructuredResult(response, operation) {
34
+ if (response?.ok === false) {
35
+ throw new Error(response.value || response.error || `${operation} failed`);
36
+ }
37
+ const value = isObject(response) && "value" in response ? response.value : response;
38
+ if (!isString(value)) return value;
39
+ try {
40
+ return JSON.parse(value);
41
+ } catch {
42
+ return value;
43
+ }
44
+ }
33
45
 
34
46
  const TOOL_DESCRIPTION = `Execute JavaScript that orchestrates host tools in one shot (Code Mode).
35
47
 
@@ -101,10 +113,10 @@ export default function piSupernova(pi) {
101
113
  }
102
114
  },
103
115
  async surface(filePath) {
104
- return bridge.call("surface", { path: filePath });
116
+ return unwrapStructuredResult(await bridge.call("surface", { path: filePath }), "surface");
105
117
  },
106
118
  async snap(query, targetPath) {
107
- return bridge.call("snap", { query, path: targetPath });
119
+ return unwrapStructuredResult(await bridge.call("snap", { query, path: targetPath }), "snap");
108
120
  },
109
121
  has(name) {
110
122
  return bridge.hasExecutor(name) || catalog.some((t) => t.name === name);
@@ -134,8 +146,8 @@ export default function piSupernova(pi) {
134
146
  }),
135
147
  ),
136
148
  }),
137
- // "self" = we own chrome. OMP uses native framedBlock (write/edit look);
138
- // Pi keeps the muted violet SafeText wash. "default" falls back to raw JSON.
149
+ // One self-owned result frame is shared by Pi and OMP; renderCall stays empty
150
+ // so separate call/result slots cannot duplicate the lifecycle card.
139
151
  renderShell: "self",
140
152
  mergeCallAndResult: true,
141
153
  renderCall: renderSupernovaCall,
@@ -163,6 +175,15 @@ export default function piSupernova(pi) {
163
175
  }
164
176
  });
165
177
 
178
+ if (isFunction(onUpdate)) {
179
+ try {
180
+ onUpdate({
181
+ content: [{ type: "text", text: "" }],
182
+ details: { trace: [], running: true },
183
+ });
184
+ } catch {}
185
+ }
186
+
166
187
  const runConfig = {
167
188
  ...config,
168
189
  timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs,
package/omp-frame.js CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * OMP-style rounded tool chrome for supernova.
2
+ * Shared Pi/OMP rounded tool chrome for supernova.
3
3
  *
4
4
  * Intentionally self-contained — never dynamic-imports `@oh-my-pi/pi-coding-agent`
5
- * (that hung OMP plugin load). Geometry matches native write/edit framedBlock.
5
+ * (that hung OMP plugin load). Portable geometry matches native edit/write cards.
6
6
  */
7
7
 
8
- import { clampLine, measureWidth, wrapPlainToWidth } from "./render-measure.js";
8
+ import { clampLine, measureWidth } from "./render-measure.js";
9
+ import { isFunction, isString } from "./decode.js";
9
10
 
10
11
  const DEFAULT_BOX = {
11
12
  topLeft: "╭",
@@ -18,14 +19,6 @@ const DEFAULT_BOX = {
18
19
  teeRight: "├",
19
20
  };
20
21
 
21
- export function hasHostFramedBlock() {
22
- return false;
23
- }
24
-
25
- export async function ensureOmpChrome() {
26
- // No-op: portable frame only. Kept so call sites stay stable.
27
- }
28
-
29
22
  function boxOf(theme) {
30
23
  const b = theme?.boxRound;
31
24
  if (b && b.topLeft && b.horizontal && b.vertical) return b;
@@ -36,7 +29,7 @@ function borderPaint(theme, state, borderColor) {
36
29
  const key =
37
30
  borderColor ||
38
31
  (state === "error" ? "error" : state === "warning" ? "warning" : state === "running" || state === "pending" ? "accent" : "dim");
39
- if (theme && typeof theme.fg === "function") {
32
+ if (theme && isFunction(theme.fg)) {
40
33
  try {
41
34
  return (text) => theme.fg(key, text);
42
35
  } catch {
@@ -74,21 +67,21 @@ function padLine(line, width, bgFn) {
74
67
 
75
68
  function bgFnForState(theme, state) {
76
69
  if (!state || !theme) return undefined;
77
- if (typeof theme.bg === "function") {
70
+ if (isFunction(theme.bg)) {
78
71
  const key =
79
72
  state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
80
73
  try {
81
74
  const probe = theme.bg(key, "x");
82
- if (typeof probe !== "string") return undefined;
75
+ if (!isString(probe)) return undefined;
83
76
  return (text) => {
84
77
  const painted = theme.bg(key, text);
85
- return typeof painted === "string" ? painted : text;
78
+ return isString(painted) ? painted : text;
86
79
  };
87
80
  } catch {
88
81
  return undefined;
89
82
  }
90
83
  }
91
- if (typeof theme.getBgAnsi === "function") {
84
+ if (isFunction(theme.getBgAnsi)) {
92
85
  try {
93
86
  const key =
94
87
  state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
@@ -102,7 +95,7 @@ function bgFnForState(theme, state) {
102
95
  return undefined;
103
96
  }
104
97
 
105
- export function renderPortableFrame(theme, { header, sections = [], state = "pending", borderColor, width }) {
98
+ function renderPortableFrame(theme, { header, sections = [], state = "pending", borderColor, width }) {
106
99
  const w = Math.max(1, width | 0);
107
100
  if (w < 8) {
108
101
  const rawLines = [header];
@@ -156,7 +149,7 @@ export function renderPortableFrame(theme, { header, sections = [], state = "pen
156
149
  return lines;
157
150
  }
158
151
 
159
- export function createPortableFramedComponent(theme, build) {
152
+ function createPortableFramedComponent(theme, build) {
160
153
  let cacheWidth;
161
154
  let cacheKey;
162
155
  let cacheLines;
@@ -188,11 +181,3 @@ export function novaStatusLine(theme, options) {
188
181
  return statusHeader(theme, options);
189
182
  }
190
183
 
191
- export function wrapPlainLines(text, width) {
192
- const w = Math.max(1, width | 0);
193
- const out = [];
194
- for (const line of String(text ?? "").split("\n")) {
195
- for (const chunk of wrapPlainToWidth(line, w)) out.push(chunk);
196
- }
197
- return out.length ? out : [""];
198
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Dual-host CodeMode for Pi/OMP: progressive tool discovery, result bottleneck, and Amdahl Auto parallel.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
@@ -57,24 +57,15 @@
57
57
  ]
58
58
  },
59
59
  "peerDependencies": {
60
- "@earendil-works/pi-coding-agent": "*",
61
- "@earendil-works/pi-tui": "*",
62
60
  "typebox": "*"
63
61
  },
64
62
  "peerDependenciesMeta": {
65
- "@earendil-works/pi-coding-agent": {
66
- "optional": true
67
- },
68
- "@earendil-works/pi-tui": {
69
- "optional": true
70
- },
71
63
  "typebox": {
72
64
  "optional": true
73
65
  }
74
66
  },
75
67
  "devDependencies": {
76
- "typebox": "^1.0.0",
77
- "@earendil-works/pi-tui": "^0.84.0"
68
+ "typebox": "^1.0.0"
78
69
  },
79
70
  "publishConfig": {
80
71
  "access": "public"
package/render-measure.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Width / truncate primitives shared by render.js and omp-frame.js.
2
+ * Width / truncate primitives used by the compact renderer.
3
3
  * Self-contained so path-install never depends on a host truncate that can
4
4
  * append ellipsis after cutting to maxWidth (Pi 92>91 crash class).
5
5
  */
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 [];
@@ -213,72 +130,55 @@ export function extractOperationsFromCode(code) {
213
130
  return ops;
214
131
  }
215
132
 
216
- export function renderDiffBox(diff, theme, width = 60) {
217
- if (!diff || !Array.isArray(diff.lines) || diff.lines.length === 0) return "";
218
-
219
- const w = Math.max(20, width | 0);
220
- const cleanPath = String(diff.path || "").replace(/\\/g, "/");
221
- const baseName = cleanPath.split("/").pop() || cleanPath;
222
- const opLabel = diff.op === "edit" ? "Edit" : diff.op === "write" ? "Write" : "Patch";
223
-
224
- // Stats BEFORE path so +N/-N survive narrow-terminal truncation (prior test/crash footgun).
225
- const stats =
226
- theme.fg("dim", "⟨") +
227
- theme.fg("toolDiffAdded", `+${diff.added}`) +
228
- theme.fg("dim", "/") +
229
- theme.fg("toolDiffRemoved", `-${diff.removed}`) +
230
- theme.fg("dim", "⟩");
231
- // Do not clamp here — SafeText.render(terminalWidth) is the single choke point.
232
- // Pre-clamping with a guessed width ate filenames under mock/ANSI-marker themes.
233
- const header =
234
- theme.fg("accent", "✎ ") +
235
- theme.fg("toolTitle", theme.bold(`${opLabel} `)) +
236
- stats +
237
- " " +
238
- theme.fg("muted", baseName);
239
-
240
- const divWidth = Math.min(w, Math.max(20, Math.min(70, w)));
241
- const divider = theme.fg("borderMuted", "─".repeat(divWidth));
242
-
243
- const maxShown = 6;
244
- 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 [];
245
135
  const body = [];
246
-
247
- for (const item of shownLines) {
136
+ for (const item of diff.lines.slice(0, maxShown)) {
248
137
  const num = item.lineNum || 0;
249
- let row;
250
138
  if (item.type === "remove") {
251
139
  const gut = theme.fg("toolDiffRemoved", `-${num}`.padStart(5));
252
- const sep = theme.fg("borderMuted", " │ ");
253
- const txt = theme.fg("toolDiffRemoved", `- ${cleanInlineText(item.text)}`);
254
- row = `${gut}${sep}${txt}`;
140
+ body.push(`${gut}${theme.fg("borderMuted", " │ ")}${theme.fg("toolDiffRemoved", `- ${cleanInlineText(item.text)}`)}`);
255
141
  } else if (item.type === "add") {
256
142
  const gut = theme.fg("toolDiffAdded", `+${num}`.padStart(5));
257
- const sep = theme.fg("borderMuted", " │ ");
258
- const txt = theme.fg("toolDiffAdded", `+ ${cleanInlineText(item.text)}`);
259
- row = `${gut}${sep}${txt}`;
143
+ body.push(`${gut}${theme.fg("borderMuted", " │ ")}${theme.fg("toolDiffAdded", `+ ${cleanInlineText(item.text)}`)}`);
260
144
  } else {
261
145
  const gut = theme.fg("dim", ` ${num}`.padStart(5));
262
- const sep = theme.fg("borderMuted", " │ ");
263
- const txt = theme.fg("toolDiffContext", ` ${cleanInlineText(item.text)}`);
264
- row = `${gut}${sep}${txt}`;
146
+ body.push(`${gut}${theme.fg("borderMuted", " │ ")}${theme.fg("toolDiffContext", ` ${cleanInlineText(item.text)}`)}`);
265
147
  }
266
- body.push(row);
267
148
  }
268
-
269
- if (diff.lines.length > maxShown) {
270
- const remaining = diff.lines.length - maxShown;
271
- body.push(theme.fg("dim", ` │ … ${remaining} more lines`));
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`));
272
152
  }
153
+ return body;
154
+ }
273
155
 
274
- return `${header}\n${divider}\n${body.join("\n")}\n${divider}`;
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
+ 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;
175
+ }
176
+ return clean;
275
177
  }
276
178
 
277
179
  function cleanBlockText(value) {
278
- return stripVTControlCharacters(String(value ?? ""))
279
- .replace(/\r\n?/g, "\n")
280
- .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "")
281
- .replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
180
+ const normalized = stripVTControlCharacters(String(value ?? "")).replace(/\r\n?/g, "\n");
181
+ return stripUnsafeControls(normalized).replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
282
182
  }
283
183
 
284
184
  function cleanInlineText(value) {
@@ -304,7 +204,7 @@ function formatOpTarget(raw, tool) {
304
204
  }
305
205
 
306
206
  function isTheme(value) {
307
- return !!value && typeof value === "object" && typeof value.fg === "function";
207
+ return isObject(value) && isFunction(value.fg);
308
208
  }
309
209
 
310
210
  /**
@@ -314,13 +214,13 @@ function isTheme(value) {
314
214
  */
315
215
  export function normalizeCallRenderArgs(a, b, c) {
316
216
  if (isTheme(b)) {
317
- const context = c && typeof c === "object" ? c : {};
318
- if (!context.state || typeof context.state !== "object") context.state = {};
217
+ const context = isObject(c) ? c : {};
218
+ if (!isObject(context.state)) context.state = {};
319
219
  return { args: a, theme: b, context, host: "pi" };
320
220
  }
321
221
  if (isTheme(c)) {
322
- const options = b && typeof b === "object" ? b : {};
323
- if (!options.state || typeof options.state !== "object") options.state = {};
222
+ const options = isObject(b) ? b : {};
223
+ if (!isObject(options.state)) options.state = {};
324
224
  const context = {
325
225
  ...options,
326
226
  state: options.state,
@@ -341,21 +241,19 @@ export function normalizeCallRenderArgs(a, b, c) {
341
241
  * Pi: (result, {expanded,isPartial}, theme, context)
342
242
  * OMP: (result, {expanded,isPartial}, theme, args) — 4th is args, not context
343
243
  *
344
- * Call shapes share the first three positions, so host is inferred from the 4th
345
- * 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.
346
246
  */
347
247
  function detectResultHost(options, ctxOrArgs) {
348
248
  if (isTheme(options)) return "pi";
349
249
  if (
350
- ctxOrArgs &&
351
- typeof ctxOrArgs === "object" &&
250
+ isObject(ctxOrArgs) &&
352
251
  ("lastComponent" in ctxOrArgs || "invalidate" in ctxOrArgs)
353
252
  ) {
354
253
  return "pi";
355
254
  }
356
255
  if (
357
- ctxOrArgs &&
358
- typeof ctxOrArgs === "object" &&
256
+ isObject(ctxOrArgs) &&
359
257
  ("code" in ctxOrArgs || "timeoutMs" in ctxOrArgs)
360
258
  ) {
361
259
  return "omp";
@@ -365,19 +263,18 @@ function detectResultHost(options, ctxOrArgs) {
365
263
 
366
264
  export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
367
265
  if (isTheme(themeOrCtx)) {
368
- const opts = options && typeof options === "object" ? options : {};
266
+ const opts = isObject(options) ? options : {};
369
267
  let context;
370
268
  if (
371
- ctxOrArgs &&
372
- typeof ctxOrArgs === "object" &&
269
+ isObject(ctxOrArgs) &&
373
270
  !isTheme(ctxOrArgs) &&
374
271
  ("lastComponent" in ctxOrArgs || "state" in ctxOrArgs || "invalidate" in ctxOrArgs)
375
272
  ) {
376
273
  context = ctxOrArgs;
377
274
  } else {
378
- context = { ...(opts.state ? { state: opts.state } : {}), lastComponent: opts.lastComponent };
275
+ context = { state: opts.state, lastComponent: opts.lastComponent };
379
276
  }
380
- if (!context.state || typeof context.state !== "object") context.state = {};
277
+ if (!isObject(context.state)) context.state = {};
381
278
  return {
382
279
  result,
383
280
  expanded: !!opts.expanded,
@@ -391,8 +288,8 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
391
288
  }
392
289
  // Extremely defensive: (result, theme, context) oddball
393
290
  if (isTheme(options)) {
394
- const context = themeOrCtx && typeof themeOrCtx === "object" ? themeOrCtx : {};
395
- if (!context.state || typeof context.state !== "object") context.state = {};
291
+ const context = isObject(themeOrCtx) ? themeOrCtx : {};
292
+ if (!isObject(context.state)) context.state = {};
396
293
  return {
397
294
  result,
398
295
  expanded: !!context.expanded,
@@ -424,174 +321,42 @@ function operationTarget(item) {
424
321
  return "";
425
322
  }
426
323
 
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;
339
+ }
340
+ match = /^\s+(\d+)\s?(.*)$/.exec(rawLine);
341
+ if (match) lines.push({ type: "context", lineNum: Number(match[1]), text: match[2] });
342
+ }
343
+ if (lines.length === 0) return undefined;
344
+ return { path: item?.args?.path || "", op: item?.name, added, removed, lines };
345
+ }
346
+
427
347
  function operationsFromTrace(trace) {
428
348
  if (!Array.isArray(trace)) return [];
429
349
  return trace
430
- .map((item) => displayOperation(item?.name || "tool", operationTarget(item), item?.diff, item?.ok))
431
- .filter(Boolean);
432
- }
433
-
434
- function collectCallOps(args, context) {
435
- const traced = operationsFromTrace(context?.state?.trace);
436
- if (traced.length > 0) return traced;
437
- return extractOperationsFromCode(args?.code)
438
- .map((op) => displayOperation(op.tool, op.target))
350
+ .map((item) => displayOperation(item?.name || "tool", operationTarget(item), normalizeTraceDiff(item), item?.ok))
439
351
  .filter(Boolean);
440
352
  }
441
353
 
442
- function formatElapsed(state) {
443
- if (state?.wallMs != null) return `${state.wallMs}ms`;
444
- if (state?.startedAt != null) {
445
- const elapsed = Math.round(performance.now() - state.startedAt);
446
- // Suppress sub-100ms noise on the pending head (matches quiet Write/Edit calls).
447
- if (elapsed < 100) return "";
448
- return elapsed >= 1000 ? `${(elapsed / 1000).toFixed(1)}s` : `${elapsed}ms`;
449
- }
450
- return "";
451
- }
452
-
453
- function tickCallTimer(context) {
454
- const state = context?.state;
455
- if (state && state.startedAt == null) state.startedAt = performance.now();
456
- if (state && context?.executionStarted && state.wallMs == null && state.timer == null) {
457
- state.timer = setTimeout(() => {
458
- state.timer = null;
459
- context.invalidate?.();
460
- }, 100);
461
- }
462
- }
463
-
464
- function formatOpBodyLine(theme, op) {
465
- const icon = ACTION_ICONS[op.tool] || "✦ ";
466
- const bullet = theme.fg("accent", icon);
467
- const toolName = theme.fg("syntaxFunction", op.tool.padEnd(7, " "));
468
- const rawTarget = formatOpTarget(op.target, op.tool);
469
- const target = rawTarget ? " " + theme.fg("muted", rawTarget) : "";
470
- return `${bullet}${toolName}${target}`;
471
- }
472
-
473
- function shouldUseOmpFrame(host) {
474
- return host === "omp";
475
- }
476
-
477
- /**
478
- * OMP write/edit look: rounded framedBlock + status-line header.
479
- * Pending call has no hourglass on the head row (same as native Write/Edit).
480
- */
481
- function renderOmpCallCard(theme, { ops, timeStr, expanded, code }) {
482
- const opSummary = ops.length === 0 ? "composing" : `${ops.length} call${ops.length === 1 ? "" : "s"}`;
483
- const description = timeStr ? `${opSummary} · ${timeStr}` : opSummary;
484
- // No pending icon on the framed head row — matches native Write/Edit.
485
- const header = novaStatusLine(theme, {
486
- title: "nova",
487
- description,
488
- });
489
- return novaFramedBlock(theme, (width) => {
490
- const bodyLines = ops.map((op) => formatOpBodyLine(theme, op));
491
- if (expanded && code) {
492
- bodyLines.push(theme.fg("dim", "── source ──"));
493
- const sourceLines = cleanBlockText(code).trim().split("\n");
494
- for (const line of sourceLines.slice(0, 24)) bodyLines.push(theme.fg("toolOutput", line));
495
- if (sourceLines.length > 24) bodyLines.push(theme.fg("dim", `… ${sourceLines.length - 24} more lines`));
496
- }
497
- return {
498
- header,
499
- sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
500
- state: "pending",
501
- borderColor: "borderMuted",
502
- width,
503
- };
504
- });
505
- }
506
-
507
- function renderOmpResultCard(theme, { isErr, payload, expanded, bodyText, isPartial, spinnerFrame, opCount = 0 }) {
508
- if (isErr) {
509
- const errLines = String(bodyText || "").trim() ? String(bodyText).split("\n") : [];
510
- if (payload?.error) errLines.push(theme.fg("error", cleanBlockText(payload.error)));
511
- if (expanded && payload?.logs?.length) {
512
- errLines.push(theme.fg("dim", "── logs ──"));
513
- for (const log of payload.logs.slice(0, 24)) errLines.push(theme.fg("dim", cleanBlockText(log)));
514
- }
515
- const wall = payload?.wallMs != null ? `${payload.wallMs}ms` : "";
516
- const calls = opCount > 0 ? `${opCount} call${opCount === 1 ? "" : "s"}` : "";
517
- const header = novaStatusLine(theme, {
518
- icon: "error",
519
- title: "nova",
520
- description: [calls, "failed", wall].filter(Boolean).join(" · "),
521
- });
522
- return novaFramedBlock(theme, (width) => ({
523
- header,
524
- sections: errLines.length > 0 ? [{ lines: errLines }] : [],
525
- state: "error",
526
- borderColor: "error",
527
- width,
528
- }));
529
- }
530
-
531
- const wall = payload?.wallMs != null ? `${payload.wallMs}ms` : "";
532
- const calls = opCount > 0 ? `${opCount} call${opCount === 1 ? "" : "s"}` : "";
533
- const description = [calls, isPartial ? "running" : "", wall].filter(Boolean).join(" · ");
534
- const header = novaStatusLine(theme, {
535
- icon: isPartial ? "running" : undefined,
536
- spinnerFrame,
537
- title: "nova",
538
- description: description || undefined,
539
- });
540
- const bodyLines = String(bodyText || "").split("\n");
541
- while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
542
- while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") bodyLines.pop();
543
- return novaFramedBlock(theme, (width) => ({
544
- header,
545
- sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
546
- state: isPartial ? "pending" : "success",
547
- borderColor: "borderMuted",
548
- width,
549
- }));
550
- }
551
-
552
354
  export function renderSupernovaCall(a, b, c) {
553
- const { args, theme, context, options, host } = normalizeCallRenderArgs(a, b, c);
554
- tickCallTimer(context);
555
- const ops = collectCallOps(args, context);
556
- const timeStr = formatElapsed(context?.state);
557
-
558
- if (shouldUseOmpFrame(host)) {
559
- return renderOmpCallCard(theme, {
560
- ops,
561
- timeStr,
562
- expanded: !!context?.expanded,
563
- code: args?.code,
564
- });
565
- }
566
-
355
+ const { context, options } = normalizeCallRenderArgs(a, b, c);
567
356
  const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
568
357
  if (options) options.lastComponent = comp;
569
358
  else if (context) context.lastComponent = comp;
570
-
571
- // Compact aesthetic — never dump raw JSON args (the stock tool fallback).
572
- let out = theme.fg("toolTitle", theme.bold("nova"));
573
- if (timeStr) {
574
- out += " " + theme.fg("dim", `· ${timeStr}`);
575
- }
576
-
577
- if (ops.length === 0) {
578
- out += " " + theme.fg("dim", "· composing");
579
- } else {
580
- for (const op of ops) {
581
- out += `\n ${formatOpBodyLine(theme, op)}`;
582
- }
583
- }
584
-
585
- if (context?.expanded && args?.code) {
586
- out += "\n" + theme.fg("dim", "── source ──");
587
- out += "\n" + theme.fg("toolOutput", cleanBlockText(args.code).trim());
588
- }
589
-
590
- if (context?.isError) comp.setTone("error");
591
- else if (context?.isPartial) comp.setTone("pending");
592
- else comp.setTone("success");
593
- comp.setFraming(true);
594
- comp.setText(out);
359
+ comp.setText("");
595
360
  return comp;
596
361
  }
597
362
 
@@ -611,7 +376,8 @@ function formatResultOperation(theme, op, isPartial, isError) {
611
376
  const tool = theme.fg("syntaxFunction", op.tool.padEnd(7, " "));
612
377
  const targetText = formatOpTarget(op.target, op.tool);
613
378
  const target = targetText ? theme.fg("muted", targetText) : theme.fg("dim", "done");
614
- return `${marker}${tool} ${target}${formatDiffStats(theme, op.diff)}`;
379
+ const stats = formatDiffStats(theme, op.diff);
380
+ return stats ? `${marker}${tool}${stats} ${target}` : `${marker}${tool} ${target}`;
615
381
  }
616
382
 
617
383
  function boundedResult(value) {
@@ -635,21 +401,21 @@ function buildResultBody(theme, { payload, context, args, expanded, isPartial, i
635
401
  ? tracedOps
636
402
  : extractOperationsFromCode(args?.code).map((op) => displayOperation(op.tool, op.target)).filter(Boolean);
637
403
  const maxOps = expanded ? 12 : 8;
638
- for (const op of ops.slice(0, maxOps)) {
639
- out += (out ? "\n" : "") + formatResultOperation(theme, op, isPartial, isError);
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
+ }
640
415
  }
641
- if (ops.length > maxOps) out += `\n${theme.fg("dim", `… ${ops.length - maxOps} more calls`)}`;
416
+ if (ops.length > maxOps) out += `\n│\n└─ ${theme.fg("dim", `… ${ops.length - maxOps} more calls`)}`;
642
417
 
643
418
  if (expanded) {
644
- const diffs = ops.filter((op) => op.diff && isObject(op.diff)).map((op) => op.diff);
645
- const maxDiffs = 4;
646
- if (diffs.length > 0) out += (out ? "\n" : "") + theme.fg("dim", "── changes ──");
647
- for (const diff of diffs.slice(0, maxDiffs)) {
648
- const box = renderDiffBox(diff, theme, 120);
649
- if (box) out += "\n" + box;
650
- }
651
- if (diffs.length > maxDiffs) out += `\n${theme.fg("dim", `… ${diffs.length - maxDiffs} more changed files`)}`;
652
-
653
419
  if (payload?.result !== undefined) {
654
420
  out += (out ? "\n" : "") + theme.fg("dim", "── result ──");
655
421
  out += "\n" + theme.fg("toolOutput", boundedResult(payload.result));
@@ -662,6 +428,31 @@ function buildResultBody(theme, { payload, context, args, expanded, isPartial, i
662
428
  return { body: out, opCount: ops.length };
663
429
  }
664
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
+ }
454
+ }
455
+
665
456
  export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextArg) {
666
457
  const { result, expanded, isPartial, theme, context, args, options, host } = normalizeResultRenderArgs(
667
458
  resultArg,
@@ -672,58 +463,33 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
672
463
 
673
464
  const payload = result?.details;
674
465
  if (context?.state && payload) {
675
- let changed = false;
676
466
  if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) {
677
467
  context.state.trace = payload.trace;
678
- changed = true;
679
468
  }
680
469
  if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) {
681
470
  context.state.wallMs = payload.wallMs;
682
- changed = true;
683
- }
684
- if (context.state.timer != null && !isPartial) {
685
- clearTimeout(context.state.timer);
686
- context.state.timer = null;
687
471
  }
688
- if (changed) context.invalidate?.();
689
472
  }
690
473
 
691
474
  const isErr = result?.isError || payload?.ok === false;
692
475
  const view = buildResultBody(theme, { payload, context, args, expanded, isPartial, isError: isErr });
693
476
 
694
- if (shouldUseOmpFrame(host)) {
695
- return renderOmpResultCard(theme, {
696
- isErr,
697
- payload,
698
- expanded,
699
- bodyText: view.body,
700
- isPartial: isPartial && !isErr,
701
- spinnerFrame: options?.spinnerFrame,
702
- opCount: view.opCount,
703
- });
704
- }
705
-
706
- const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
707
- if (options) options.lastComponent = comp;
708
- else if (context) context.lastComponent = comp;
709
-
710
- const wall = payload?.wallMs != null ? `${payload.wallMs}ms` : "";
711
- const calls = view.opCount > 0 ? `${view.opCount} call${view.opCount === 1 ? "" : "s"}` : "complete";
712
- let out = theme.fg("toolTitle", theme.bold("nova"));
713
- out += " " + theme.fg("dim", `· ${[calls, isErr ? "failed" : isPartial ? "running" : "", wall].filter(Boolean).join(" · ")}`);
714
- if (view.body) out += `\n ${view.body.replaceAll("\n", "\n ")}`;
715
-
477
+ let body = view.body;
716
478
  if (isErr) {
717
- out += `\n ${theme.fg("error", payload?.error ? cleanBlockText(payload.error) : "error")}`;
479
+ body += (body ? "\n" : "") + theme.fg("error", payload?.error ? cleanBlockText(payload.error) : "error");
718
480
  if (expanded && payload?.logs?.length) {
719
- out += `\n${theme.fg("dim", "── logs ──")}`;
720
- for (const log of payload.logs.slice(0, 24)) out += `\n ${theme.fg("dim", cleanBlockText(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))}`;
721
483
  }
722
- comp.setTone("error");
723
- } else {
724
- comp.setTone(isPartial ? "pending" : "success");
725
484
  }
726
- comp.setFraming(true);
727
- 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 });
728
494
  return comp;
729
495
  }