pi-supernova 0.0.1 → 0.0.3

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,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Fixed
6
+
7
+ - Native adapters are now included in `nova.search` / `nova.describe` with schemas matching the callable adapter, even when the host catalog omits parameters or the adapter name.
8
+
9
+ ## [0.0.3] - 2026-09-03
10
+
11
+ ### Changed
12
+
13
+ - OMP TUI: nova cards now use the same rounded `framedBlock` + status-line chrome as native write/edit (Pi keeps the muted violet wash).
14
+
15
+ ## [0.0.2] - 2026-09-03
16
+
17
+ ### Fixed
18
+
19
+ - OMP TUI: accept `(args, options, theme)` render signature so the custom nova card shows instead of the raw JSON args dump.
20
+
5
21
  ## [0.0.1] - 2026-09-03
6
22
 
7
23
  ### Added
package/catalog.js CHANGED
@@ -1,6 +1,84 @@
1
1
 
2
2
  import { isString, isObject } from "./decode.js";
3
3
 
4
+ export const NATIVE_TOOL_DEFINITIONS = [
5
+ {
6
+ name: "read",
7
+ description: "Read UTF-8 workspace files by path, or resolve a concept query to source. Supports path arrays, offset, and limit.",
8
+ parameters: { type: "object", properties: {
9
+ path: { type: "string", description: "Workspace-relative file path or concept query" },
10
+ target: { anyOf: [{ type: "string" }, { type: "array" }], description: "File path/query or array of paths" },
11
+ offset: { type: "number", description: "One-based starting line" },
12
+ limit: { type: "number", description: "Maximum lines to return" },
13
+ } },
14
+ },
15
+ {
16
+ name: "write", description: "Write UTF-8 content to a workspace file.",
17
+ parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] },
18
+ },
19
+ {
20
+ name: "edit", description: "Apply unique text replacements, or a unified diff, to a workspace file.",
21
+ parameters: { type: "object", properties: {
22
+ path: { type: "string" }, oldText: { type: "string" }, newText: { type: "string" }, edits: { type: "array" }, patch: { type: "string" },
23
+ }, required: ["path"] },
24
+ },
25
+ {
26
+ name: "apply_patch", description: "Apply a unified diff to one workspace file.",
27
+ parameters: { type: "object", properties: { path: { type: "string" }, patch: { type: "string" } }, required: ["patch"] },
28
+ },
29
+ {
30
+ name: "snap", description: "Resolve a concept query to the most relevant workspace source location.",
31
+ parameters: { type: "object", properties: { query: { type: "string" }, path: { type: "string" } }, required: ["query"] },
32
+ },
33
+ {
34
+ name: "surface", description: "Extract a structural outline from a workspace source file.",
35
+ parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
36
+ },
37
+ {
38
+ name: "bash", description: "Run a shell command inside the workspace and capture bounded output.",
39
+ parameters: { type: "object", properties: { command: { type: "string" }, cwd: { type: "string" }, timeoutMs: { type: "number" } }, required: ["command"] },
40
+ },
41
+ {
42
+ name: "grep", description: "Search workspace file contents by pattern.",
43
+ parameters: { type: "object", properties: {
44
+ pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string" }, caseSensitive: { type: "boolean" },
45
+ }, required: ["pattern"] },
46
+ },
47
+ {
48
+ name: "glob", description: "List workspace files matching a glob pattern.",
49
+ parameters: { type: "object", properties: { pattern: { type: "string" } }, required: ["pattern"] },
50
+ },
51
+ {
52
+ name: "find", description: "List workspace files, optionally constrained by path and pattern.",
53
+ parameters: { type: "object", properties: { path: { type: "string" }, pattern: { type: "string" }, glob: { type: "string" } } },
54
+ },
55
+ {
56
+ name: "ls", description: "List direct entries in a workspace directory.",
57
+ parameters: { type: "object", properties: { path: { type: "string" } } },
58
+ },
59
+ ];
60
+
61
+ export function mergeNativeToolDefinitions(tools, capturedNames = []) {
62
+ const nativeByName = new Map(NATIVE_TOOL_DEFINITIONS.map((tool) => [tool.name, tool]));
63
+ const captured = new Set(capturedNames);
64
+ const seen = new Set();
65
+ const merged = [];
66
+
67
+ for (const tool of tools || []) {
68
+ const fallback = nativeByName.get(tool?.name);
69
+ merged.push(fallback && !captured.has(tool.name)
70
+ ? { ...tool, ...fallback, sourceInfo: { path: "<native:" + tool.name + ">" } }
71
+ : tool);
72
+ if (tool?.name) seen.add(tool.name);
73
+ }
74
+ for (const fallback of NATIVE_TOOL_DEFINITIONS) {
75
+ if (!seen.has(fallback.name)) {
76
+ merged.push({ ...fallback, sourceInfo: { path: "<native:" + fallback.name + ">" } });
77
+ }
78
+ }
79
+ return merged;
80
+ }
81
+
4
82
  function normalizeTool(tool) {
5
83
  if (!tool || !isObject(tool)) return null;
6
84
  const name = isString(tool.name) ? tool.name : "";
package/index.js CHANGED
@@ -12,7 +12,7 @@ try {
12
12
  }
13
13
 
14
14
  import { isString, isFunction } from "./decode.js";
15
- import { buildCatalog, searchCatalog, describeTool } from "./catalog.js";
15
+ import { buildCatalog, searchCatalog, describeTool, mergeNativeToolDefinitions } from "./catalog.js";
16
16
  import { loadConfig } from "./config.js";
17
17
  import { createHostBridge } from "./host-bridge.js";
18
18
  import { runGuestProgram } from "./runtime.js";
@@ -61,7 +61,8 @@ export default function piSupernova(pi) {
61
61
  } catch {
62
62
  tools = [];
63
63
  }
64
- catalog = buildCatalog(tools, config.excludeTools || []);
64
+ const discoverable = mergeNativeToolDefinitions(tools, bridge.executors.keys());
65
+ catalog = buildCatalog(discoverable, config.excludeTools || []);
65
66
  return catalog;
66
67
  }
67
68
 
@@ -127,9 +128,10 @@ export default function piSupernova(pi) {
127
128
  }),
128
129
  ),
129
130
  }),
130
- // "self" = we paint a muted violet/grey-blue card (see SafeText framing).
131
- // "default" uses the host's loud green tool panels and can fall back to raw JSON args.
131
+ // "self" = we own chrome. OMP uses native framedBlock (write/edit look);
132
+ // Pi keeps the muted violet SafeText wash. "default" falls back to raw JSON.
132
133
  renderShell: "self",
134
+ mergeCallAndResult: true,
133
135
  renderCall: renderSupernovaCall,
134
136
  renderResult: renderSupernovaResult,
135
137
  async execute(_id, params, signal, onUpdate, ctx) {
package/omp-frame.js ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * OMP-native tool chrome for supernova.
3
+ *
4
+ * Prefer host `framedBlock` + `renderStatusLine` from
5
+ * `@oh-my-pi/pi-coding-agent/tui` (same path read/write/edit use). Fall back to a
6
+ * portable rounded frame that matches that look when the import is unavailable
7
+ * (Pi path-install / unit tests).
8
+ */
9
+
10
+ import { clampLine, measureWidth, wrapPlainToWidth } from "./render-measure.js";
11
+
12
+ const DEFAULT_BOX = {
13
+ topLeft: "╭",
14
+ topRight: "╮",
15
+ bottomLeft: "╰",
16
+ bottomRight: "╯",
17
+ horizontal: "─",
18
+ vertical: "│",
19
+ teeLeft: "┤",
20
+ teeRight: "├",
21
+ };
22
+
23
+ let hostFramedBlock = null;
24
+ let hostRenderStatusLine = null;
25
+ let hostMarkFramed = null;
26
+ let hostResolved = false;
27
+
28
+ export async function ensureOmpChrome() {
29
+ if (hostResolved) return;
30
+ hostResolved = true;
31
+ for (const spec of ["@oh-my-pi/pi-coding-agent/tui", "@oh-my-pi/pi-coding-agent/tui/index.js"]) {
32
+ try {
33
+ const tui = await import(spec);
34
+ if (typeof tui.framedBlock === "function") hostFramedBlock = tui.framedBlock;
35
+ if (typeof tui.renderStatusLine === "function") hostRenderStatusLine = tui.renderStatusLine;
36
+ if (typeof tui.markFramedBlockComponent === "function") hostMarkFramed = tui.markFramedBlockComponent;
37
+ if (hostFramedBlock && hostRenderStatusLine) return;
38
+ } catch {
39
+ // Pi / tests / plain node — use portable frame.
40
+ }
41
+ }
42
+ }
43
+
44
+ /** Kick off resolution at module load (extension hosts support top-level await). */
45
+ await ensureOmpChrome();
46
+
47
+ export function hasHostFramedBlock() {
48
+ return typeof hostFramedBlock === "function" && typeof hostRenderStatusLine === "function";
49
+ }
50
+
51
+ function boxOf(theme) {
52
+ const b = theme?.boxRound;
53
+ if (b && b.topLeft && b.horizontal && b.vertical) return b;
54
+ return DEFAULT_BOX;
55
+ }
56
+
57
+ function borderPaint(theme, state, borderColor) {
58
+ const key =
59
+ borderColor ||
60
+ (state === "error" ? "error" : state === "warning" ? "warning" : state === "running" || state === "pending" ? "accent" : "dim");
61
+ if (theme && typeof theme.fg === "function") {
62
+ try {
63
+ return (text) => theme.fg(key, text);
64
+ } catch {
65
+ /* fall through */
66
+ }
67
+ }
68
+ return (text) => text;
69
+ }
70
+
71
+ function statusHeader(theme, { title, description, state, spinnerFrame, icon, iconOverride }) {
72
+ // Match native Write/Edit: framed call heads omit the pending hourglass; only
73
+ // pass an icon when the caller sets one (error / running / success glyph).
74
+ const resolvedIcon =
75
+ iconOverride !== undefined
76
+ ? undefined
77
+ : icon !== undefined
78
+ ? icon
79
+ : state === "error"
80
+ ? "error"
81
+ : undefined;
82
+ if (hostRenderStatusLine) {
83
+ return hostRenderStatusLine(
84
+ {
85
+ icon: resolvedIcon,
86
+ iconOverride,
87
+ spinnerFrame,
88
+ title,
89
+ description,
90
+ },
91
+ theme,
92
+ );
93
+ }
94
+ const titleText = theme?.fg ? theme.fg("accent", title) : title;
95
+ const descText = description ? (theme?.fg ? theme.fg("muted", description) : description) : "";
96
+ const prefix = state === "error" ? (theme?.fg ? theme.fg("error", "✗ ") : "✗ ") : "";
97
+ return descText ? `${prefix}${titleText}: ${descText}` : `${prefix}${titleText}`;
98
+ }
99
+
100
+ function padLine(line, width, bgFn) {
101
+ const w = Math.max(0, width | 0);
102
+ const vis = measureWidth(line);
103
+ const pad = Math.max(0, w - vis);
104
+ const padded = line + " ".repeat(pad);
105
+ return bgFn ? bgFn(padded) : padded;
106
+ }
107
+
108
+ function bgFnForState(theme, state) {
109
+ if (!state || !theme) return undefined;
110
+ if (typeof theme.bg === "function") {
111
+ const key =
112
+ state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
113
+ try {
114
+ const probe = theme.bg(key, "x");
115
+ if (typeof probe !== "string") return undefined;
116
+ return (text) => {
117
+ const painted = theme.bg(key, text);
118
+ return typeof painted === "string" ? painted : text;
119
+ };
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ if (typeof theme.getBgAnsi === "function") {
125
+ try {
126
+ const key =
127
+ state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
128
+ const ansi = theme.getBgAnsi(key);
129
+ if (!ansi) return undefined;
130
+ return (text) => `${ansi}${text}\x1b[49m`;
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ /**
139
+ * Portable rounded frame matching OMP output-block geometry.
140
+ */
141
+ export function renderPortableFrame(theme, { header, sections = [], state = "pending", borderColor, width }) {
142
+ const w = Math.max(8, width | 0);
143
+ const box = boxOf(theme);
144
+ const border = borderPaint(theme, state, borderColor);
145
+ const bgFn = bgFnForState(theme, state);
146
+ const h = box.horizontal;
147
+ const v = box.vertical;
148
+ const cap = h.repeat(3);
149
+
150
+ const paintBar = (leftChar, rightChar, label) => {
151
+ const left = `${leftChar}${cap}`;
152
+ const right = rightChar;
153
+ if (!label) {
154
+ const fill = Math.max(0, w - measureWidth(left) - measureWidth(right));
155
+ return padLine(`${border(left)}${border(h.repeat(fill))}${border(right)}`, w, bgFn);
156
+ }
157
+ const rawLabel = ` ${label} `;
158
+ const maxLabel = Math.max(0, w - measureWidth(left) - measureWidth(right));
159
+ const trimmed = clampLine(rawLabel, maxLabel);
160
+ const fill = Math.max(0, w - measureWidth(left) - measureWidth(trimmed) - measureWidth(right));
161
+ return padLine(`${border(left)}${trimmed}${border(h.repeat(fill))}${border(right)}`, w, bgFn);
162
+ };
163
+
164
+ const contentWidth = Math.max(1, w - 2 - 2); // borders + 1-col pad each side
165
+ const lines = [];
166
+ lines.push(paintBar(box.topLeft, box.topRight, header));
167
+
168
+ const normalized = sections.length > 0 ? sections : [{ lines: [] }];
169
+ for (const section of normalized) {
170
+ if (section.label) {
171
+ lines.push(paintBar(box.teeRight || "├", box.teeLeft || "┤", section.label));
172
+ }
173
+ for (const raw of section.lines || []) {
174
+ for (const piece of String(raw).split("\n")) {
175
+ const body = clampLine(piece, contentWidth);
176
+ const pad = Math.max(0, contentWidth - measureWidth(body));
177
+ const inner = `${body}${" ".repeat(pad)}`;
178
+ lines.push(padLine(`${border(v)} ${inner} ${border(v)}`, w, bgFn));
179
+ }
180
+ }
181
+ }
182
+
183
+ lines.push(paintBar(box.bottomLeft, box.bottomRight, null));
184
+ return lines;
185
+ }
186
+
187
+ export function createPortableFramedComponent(theme, build) {
188
+ let cacheWidth;
189
+ let cacheKey;
190
+ let cacheLines;
191
+ const comp = {
192
+ render(width) {
193
+ const opts = build(width);
194
+ const key = `${opts.state}|${opts.borderColor}|${opts.header}|${(opts.sections || [])
195
+ .map((s) => (s.lines || []).join("\n"))
196
+ .join("||")}`;
197
+ if (cacheLines && cacheWidth === width && cacheKey === key) return cacheLines;
198
+ cacheLines = renderPortableFrame(theme, opts);
199
+ cacheWidth = width;
200
+ cacheKey = key;
201
+ return cacheLines;
202
+ },
203
+ invalidate() {
204
+ cacheLines = undefined;
205
+ cacheKey = undefined;
206
+ cacheWidth = undefined;
207
+ },
208
+ };
209
+ if (hostMarkFramed) return hostMarkFramed(comp);
210
+ return comp;
211
+ }
212
+
213
+ /**
214
+ * Build a framed nova card. Uses host framedBlock when available (OMP).
215
+ */
216
+ export function novaFramedBlock(theme, build) {
217
+ if (hostFramedBlock) {
218
+ return hostFramedBlock(theme, build);
219
+ }
220
+ return createPortableFramedComponent(theme, build);
221
+ }
222
+
223
+ export function novaStatusLine(theme, options) {
224
+ return statusHeader(theme, options);
225
+ }
226
+
227
+ export function wrapPlainLines(text, width) {
228
+ const w = Math.max(1, width | 0);
229
+ const out = [];
230
+ for (const line of String(text ?? "").split("\n")) {
231
+ for (const chunk of wrapPlainToWidth(line, w)) out.push(chunk);
232
+ }
233
+ return out.length ? out : [""];
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
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",
@@ -37,6 +37,8 @@
37
37
  "snap.js",
38
38
  "diff.js",
39
39
  "render.js",
40
+ "render-measure.js",
41
+ "omp-frame.js",
40
42
  "surface.js",
41
43
  "decode.js"
42
44
  ],
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Width / truncate primitives shared by render.js and omp-frame.js.
3
+ * Self-contained so path-install never depends on a host truncate that can
4
+ * append ellipsis after cutting to maxWidth (Pi 92>91 crash class).
5
+ */
6
+
7
+ import { stripVTControlCharacters } from "node:util";
8
+
9
+ const ELLIPSIS = "…";
10
+
11
+ /**
12
+ * Visible columns — ANSI/OSC stripped, tabs → 3 spaces.
13
+ * ASCII-fast; non-ASCII uses a wide-char heuristic aligned with typical terminal
14
+ * / pi-tui behavior (emoji & symbols like ⚡ are 2 cols — undercount ⇒ 92>91 crash).
15
+ */
16
+ export function measureWidth(text) {
17
+ const raw = String(text ?? "").replace(/\t/g, " ");
18
+ if (raw.length === 0) return 0;
19
+ const plain = raw.includes("\x1b") ? stripVTControlCharacters(raw) : raw;
20
+ if (/^[\x20-\x7e]*$/.test(plain)) return plain.length;
21
+ let width = 0;
22
+ for (const ch of plain) {
23
+ width += codePointWidth(ch.codePointAt(0));
24
+ }
25
+ return width;
26
+ }
27
+
28
+ function codePointWidth(cp) {
29
+ if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) return 0;
30
+ // Fullwidth / wide ranges (CJK, Hangul, emoji blocks we actually emit).
31
+ if (cp >= 0x1100 && cp <= 0x115f) return 2;
32
+ if (cp === 0x2329 || cp === 0x232a) return 2;
33
+ if (cp >= 0x2e80 && cp <= 0xa4cf) return 2;
34
+ if (cp >= 0xac00 && cp <= 0xd7a3) return 2;
35
+ if (cp >= 0xf900 && cp <= 0xfaff) return 2;
36
+ if (cp >= 0xfe10 && cp <= 0xfe19) return 2;
37
+ if (cp >= 0xfe30 && cp <= 0xfe6f) return 2;
38
+ if (cp >= 0xff00 && cp <= 0xff60) return 2;
39
+ if (cp >= 0xffe0 && cp <= 0xffe6) return 2;
40
+ if (cp >= 0x1f300 && cp <= 0x1f64f) return 2;
41
+ if (cp >= 0x1f900 && cp <= 0x1f9ff) return 2;
42
+ if (cp >= 0x20000 && cp <= 0x3fffd) return 2;
43
+ // Ambiguous emoji/symbols pi-tui treats as wide (⚡ U+26A1 was the 92>91 footgun).
44
+ if (cp === 0x26a1 || cp === 0x2b50 || cp === 0x2728) return 2;
45
+ return 1;
46
+ }
47
+
48
+ /**
49
+ * Truncate so the result's visible width is ALWAYS ≤ maxWidth, ellipsis included.
50
+ * Strips ANSI in the truncated region (crash-safety > color fidelity on overflow).
51
+ */
52
+ export function hardTruncate(text, maxWidth, ellipsis = ELLIPSIS) {
53
+ const w = Math.max(0, maxWidth | 0);
54
+ if (w === 0) return "";
55
+ const raw = String(text ?? "").replace(/\t/g, " ");
56
+ if (measureWidth(raw) <= w) return raw;
57
+
58
+ const ell = String(ellipsis);
59
+ const ellW = measureWidth(ell);
60
+ if (ellW >= w) {
61
+ if (ellW === 0) return "";
62
+ return ell.slice(0, w);
63
+ }
64
+
65
+ const budget = w - ellW;
66
+ const plain = stripVTControlCharacters(raw);
67
+ let out = "";
68
+ let visible = 0;
69
+ for (const ch of plain) {
70
+ const cw = measureWidth(ch);
71
+ if (visible + cw > budget) break;
72
+ out += ch;
73
+ visible += cw;
74
+ }
75
+ return out + ell;
76
+ }
77
+
78
+ /**
79
+ * Absolute clamp used by every renderer. Loops + hard truncate; never returns > width.
80
+ */
81
+ export function clampLine(line, width) {
82
+ const w = Math.max(1, width | 0);
83
+ let out = String(line ?? "").replace(/\t/g, " ");
84
+ if (measureWidth(out) <= w) return out;
85
+ out = hardTruncate(out, w, ELLIPSIS);
86
+ if (measureWidth(out) <= w) return out;
87
+ const plain = stripVTControlCharacters(out);
88
+ if (plain.length <= w) return plain;
89
+ if (w === 1) return ELLIPSIS;
90
+ return plain.slice(0, Math.max(0, w - 1)) + ELLIPSIS;
91
+ }
92
+
93
+ /**
94
+ * Wrap plain text to width, preferring breaks after `/` or space so paths stay readable.
95
+ * Every returned chunk is ≤ width (no ellipsis — caller clamps if needed).
96
+ */
97
+ export function wrapPlainToWidth(plain, width) {
98
+ const w = Math.max(1, width | 0);
99
+ const text = String(plain ?? "");
100
+ if (text.length === 0) return [""];
101
+ if (measureWidth(text) <= w) return [text];
102
+
103
+ const lines = [];
104
+ let i = 0;
105
+ while (i < text.length) {
106
+ let end = i;
107
+ let visible = 0;
108
+ let lastBreak = -1;
109
+ while (end < text.length) {
110
+ const ch = text[end];
111
+ const cw = measureWidth(ch);
112
+ if (visible + cw > w) break;
113
+ visible += cw;
114
+ if (ch === "/" || ch === " ") lastBreak = end + 1;
115
+ end++;
116
+ }
117
+ if (end === i) {
118
+ end = i + 1;
119
+ } else if (end < text.length && lastBreak > i + Math.floor(w * 0.35)) {
120
+ end = lastBreak;
121
+ }
122
+ lines.push(text.slice(i, end));
123
+ i = end;
124
+ }
125
+ return lines.length > 0 ? lines : [""];
126
+ }
127
+
128
+ /**
129
+ * Fit a filesystem path into `budget` columns, keeping the basename visible.
130
+ */
131
+ export function fitPath(pathText, budget) {
132
+ const w = Math.max(1, budget | 0);
133
+ let p = String(pathText ?? "").replace(/\\/g, "/");
134
+ if (measureWidth(p) <= w) return p;
135
+
136
+ const parts = p.split("/").filter(Boolean);
137
+ const base = parts.length > 0 ? parts[parts.length - 1] : p;
138
+ const suffix = parts.length > 1 ? `…/${base}` : base;
139
+ if (measureWidth(suffix) <= w) return suffix;
140
+ return hardTruncate(base, w);
141
+ }
142
+
143
+ export { ELLIPSIS };
package/render.js CHANGED
@@ -5,150 +5,23 @@
5
5
  * (classic failure: 92 > 91). Path-install often cannot resolve @earendil-works/pi-tui,
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
+ *
9
+ * OMP path uses rounded framedBlock chrome (same as native write/edit). Pi path
10
+ * keeps the muted violet SafeText wash (no side rails).
8
11
  */
9
12
 
10
13
  import { stripVTControlCharacters } from "node:util";
11
14
  import { isString, isObject } from "./decode.js";
15
+ import {
16
+ measureWidth,
17
+ hardTruncate,
18
+ clampLine,
19
+ wrapPlainToWidth,
20
+ fitPath,
21
+ } from "./render-measure.js";
22
+ import { ensureOmpChrome, hasHostFramedBlock, novaFramedBlock, novaStatusLine } from "./omp-frame.js";
12
23
 
13
- const ELLIPSIS = "…";
14
-
15
- /**
16
- * Visible columns — ANSI/OSC stripped, tabs → 3 spaces.
17
- * ASCII-fast; non-ASCII uses a wide-char heuristic aligned with typical terminal
18
- * / pi-tui behavior (emoji & symbols like ⚡ are 2 cols — undercount ⇒ 92>91 crash).
19
- */
20
- export function measureWidth(text) {
21
- const raw = String(text ?? "").replace(/\t/g, " ");
22
- if (raw.length === 0) return 0;
23
- const plain = raw.includes("\x1b") ? stripVTControlCharacters(raw) : raw;
24
- if (/^[\x20-\x7e]*$/.test(plain)) return plain.length;
25
- let width = 0;
26
- for (const ch of plain) {
27
- width += codePointWidth(ch.codePointAt(0));
28
- }
29
- return width;
30
- }
31
-
32
- function codePointWidth(cp) {
33
- if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) return 0;
34
- // Fullwidth / wide ranges (CJK, Hangul, emoji blocks we actually emit).
35
- if (cp >= 0x1100 && cp <= 0x115f) return 2;
36
- if (cp === 0x2329 || cp === 0x232a) return 2;
37
- if (cp >= 0x2e80 && cp <= 0xa4cf) return 2;
38
- if (cp >= 0xac00 && cp <= 0xd7a3) return 2;
39
- if (cp >= 0xf900 && cp <= 0xfaff) return 2;
40
- if (cp >= 0xfe10 && cp <= 0xfe19) return 2;
41
- if (cp >= 0xfe30 && cp <= 0xfe6f) return 2;
42
- if (cp >= 0xff00 && cp <= 0xff60) return 2;
43
- if (cp >= 0xffe0 && cp <= 0xffe6) return 2;
44
- if (cp >= 0x1f300 && cp <= 0x1f64f) return 2;
45
- if (cp >= 0x1f900 && cp <= 0x1f9ff) return 2;
46
- if (cp >= 0x20000 && cp <= 0x3fffd) return 2;
47
- // Ambiguous emoji/symbols pi-tui treats as wide (⚡ U+26A1 was the 92>91 footgun).
48
- if (cp === 0x26a1 || cp === 0x2b50 || cp === 0x2728) return 2;
49
- return 1;
50
- }
51
-
52
- /**
53
- * Truncate so the result's visible width is ALWAYS ≤ maxWidth, ellipsis included.
54
- * Strips ANSI in the truncated region (crash-safety > color fidelity on overflow).
55
- */
56
- export function hardTruncate(text, maxWidth, ellipsis = ELLIPSIS) {
57
- const w = Math.max(0, maxWidth | 0);
58
- if (w === 0) return "";
59
- const raw = String(text ?? "").replace(/\t/g, " ");
60
- if (measureWidth(raw) <= w) return raw;
61
-
62
- const ell = String(ellipsis);
63
- const ellW = measureWidth(ell);
64
- if (ellW >= w) {
65
- // Degenerate: return as many ellipsis columns as fit.
66
- if (ellW === 0) return "";
67
- return ell.slice(0, w);
68
- }
69
-
70
- const budget = w - ellW;
71
- const plain = stripVTControlCharacters(raw);
72
- let out = "";
73
- let visible = 0;
74
- for (const ch of plain) {
75
- const cw = measureWidth(ch);
76
- if (visible + cw > budget) break;
77
- out += ch;
78
- visible += cw;
79
- }
80
- return out + ell;
81
- }
82
-
83
- /**
84
- * Absolute clamp used by every renderer. Loops + hard truncate; never returns > width.
85
- * Exported for tests and as the single choke point for the Pi crash contract.
86
- */
87
- export function clampLine(line, width) {
88
- const w = Math.max(1, width | 0);
89
- let out = String(line ?? "").replace(/\t/g, " ");
90
- if (measureWidth(out) <= w) return out;
91
- out = hardTruncate(out, w, ELLIPSIS);
92
- // Belt-and-suspenders: if anything still disagrees, force plain slice.
93
- if (measureWidth(out) <= w) return out;
94
- const plain = stripVTControlCharacters(out);
95
- if (plain.length <= w) return plain;
96
- if (w === 1) return ELLIPSIS;
97
- return plain.slice(0, Math.max(0, w - 1)) + ELLIPSIS;
98
- }
99
-
100
- /**
101
- * Wrap plain text to width, preferring breaks after `/` or space so paths stay readable.
102
- * Every returned chunk is ≤ width (no ellipsis — caller clamps if needed).
103
- */
104
- export function wrapPlainToWidth(plain, width) {
105
- const w = Math.max(1, width | 0);
106
- const text = String(plain ?? "");
107
- if (text.length === 0) return [""];
108
- if (measureWidth(text) <= w) return [text];
109
-
110
- const lines = [];
111
- let i = 0;
112
- while (i < text.length) {
113
- let end = i;
114
- let visible = 0;
115
- let lastBreak = -1;
116
- while (end < text.length) {
117
- const ch = text[end];
118
- const cw = measureWidth(ch);
119
- if (visible + cw > w) break;
120
- visible += cw;
121
- // Only soft-break on path/word separators — never mid-filename (`host-` / `bridge`).
122
- if (ch === "/" || ch === " ") lastBreak = end + 1;
123
- end++;
124
- }
125
- if (end === i) {
126
- // Single wide char edge case — force one column advance.
127
- end = i + 1;
128
- } else if (end < text.length && lastBreak > i + Math.floor(w * 0.35)) {
129
- end = lastBreak;
130
- }
131
- lines.push(text.slice(i, end));
132
- i = end;
133
- }
134
- return lines.length > 0 ? lines : [""];
135
- }
136
-
137
- /**
138
- * Fit a filesystem path into `budget` columns, keeping the basename visible.
139
- * `packages/pi-supernova/host-bridge.js` → `…/host-bridge.js` when narrow.
140
- */
141
- export function fitPath(pathText, budget) {
142
- const w = Math.max(1, budget | 0);
143
- let p = String(pathText ?? "").replace(/\\/g, "/");
144
- if (measureWidth(p) <= w) return p;
145
-
146
- const parts = p.split("/").filter(Boolean);
147
- const base = parts.length > 0 ? parts[parts.length - 1] : p;
148
- const suffix = parts.length > 1 ? `…/${base}` : base;
149
- if (measureWidth(suffix) <= w) return suffix;
150
- return hardTruncate(base, w);
151
- }
24
+ export { measureWidth, hardTruncate, clampLine, wrapPlainToWidth, fitPath };
152
25
 
153
26
  function fitOutputLines(text, width) {
154
27
  const w = Math.max(1, width | 0);
@@ -418,13 +291,113 @@ function formatOpTarget(raw, tool) {
418
291
  return text.replace(/\\/g, "/");
419
292
  }
420
293
 
421
- export function renderSupernovaCall(args, theme, context) {
422
- const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
294
+ function isTheme(value) {
295
+ return !!value && typeof value === "object" && typeof value.fg === "function";
296
+ }
423
297
 
424
- let ops = [];
298
+ /**
299
+ * Dual-host renderCall args:
300
+ * Pi: (args, theme, context)
301
+ * OMP: (args, options/renderState, theme)
302
+ */
303
+ export function normalizeCallRenderArgs(a, b, c) {
304
+ if (isTheme(b)) {
305
+ const context = c && typeof c === "object" ? c : {};
306
+ if (!context.state || typeof context.state !== "object") context.state = {};
307
+ return { args: a, theme: b, context, host: "pi" };
308
+ }
309
+ if (isTheme(c)) {
310
+ const options = b && typeof b === "object" ? b : {};
311
+ if (!options.state || typeof options.state !== "object") options.state = {};
312
+ const context = {
313
+ ...options,
314
+ state: options.state,
315
+ expanded: options.expanded,
316
+ isPartial: options.isPartial,
317
+ executionStarted: options.executionStarted,
318
+ argsComplete: options.argsComplete,
319
+ lastComponent: options.lastComponent,
320
+ invalidate: options.invalidate,
321
+ };
322
+ return { args: a, theme: c, context, host: "omp", options };
323
+ }
324
+ throw new Error("supernova renderCall: theme missing (expected Pi or OMP signature)");
325
+ }
326
+
327
+ /**
328
+ * Dual-host renderResult args:
329
+ * Pi: (result, {expanded,isPartial}, theme, context)
330
+ * OMP: (result, {expanded,isPartial}, theme, args) — 4th is args, not context
331
+ *
332
+ * Call shapes share the first three positions, so host is inferred from the 4th
333
+ * arg / whether the OMP tui framedBlock import resolved.
334
+ */
335
+ function detectResultHost(options, ctxOrArgs) {
336
+ if (isTheme(options)) return "pi";
337
+ if (
338
+ ctxOrArgs &&
339
+ typeof ctxOrArgs === "object" &&
340
+ ("lastComponent" in ctxOrArgs || "invalidate" in ctxOrArgs)
341
+ ) {
342
+ return "pi";
343
+ }
344
+ if (
345
+ ctxOrArgs &&
346
+ typeof ctxOrArgs === "object" &&
347
+ ("code" in ctxOrArgs || "timeoutMs" in ctxOrArgs)
348
+ ) {
349
+ return "omp";
350
+ }
351
+ if (hasHostFramedBlock()) return "omp";
352
+ return "pi";
353
+ }
354
+
355
+ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
356
+ if (isTheme(themeOrCtx)) {
357
+ const opts = options && typeof options === "object" ? options : {};
358
+ let context;
359
+ if (
360
+ ctxOrArgs &&
361
+ typeof ctxOrArgs === "object" &&
362
+ !isTheme(ctxOrArgs) &&
363
+ ("lastComponent" in ctxOrArgs || "state" in ctxOrArgs || "invalidate" in ctxOrArgs)
364
+ ) {
365
+ context = ctxOrArgs;
366
+ } else {
367
+ context = { ...(opts.state ? { state: opts.state } : {}), lastComponent: opts.lastComponent };
368
+ }
369
+ if (!context.state || typeof context.state !== "object") context.state = {};
370
+ return {
371
+ result,
372
+ expanded: !!opts.expanded,
373
+ isPartial: !!opts.isPartial,
374
+ theme: themeOrCtx,
375
+ context,
376
+ host: detectResultHost(options, ctxOrArgs),
377
+ options: opts,
378
+ };
379
+ }
380
+ // Extremely defensive: (result, theme, context) oddball
381
+ if (isTheme(options)) {
382
+ const context = themeOrCtx && typeof themeOrCtx === "object" ? themeOrCtx : {};
383
+ if (!context.state || typeof context.state !== "object") context.state = {};
384
+ return {
385
+ result,
386
+ expanded: !!context.expanded,
387
+ isPartial: !!context.isPartial,
388
+ theme: options,
389
+ context,
390
+ host: "pi",
391
+ options: {},
392
+ };
393
+ }
394
+ throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
395
+ }
396
+
397
+ function collectCallOps(args, context) {
425
398
  const stateTrace = context?.state?.trace;
426
399
  if (Array.isArray(stateTrace) && stateTrace.length > 0) {
427
- ops = stateTrace
400
+ return stateTrace
428
401
  .map((item) => {
429
402
  const tool = item?.name || "tool";
430
403
  let target = "";
@@ -434,12 +407,24 @@ export function renderSupernovaCall(args, theme, context) {
434
407
  return displayOperation(tool, target);
435
408
  })
436
409
  .filter(Boolean);
437
- } else {
438
- ops = extractOperationsFromCode(args?.code)
439
- .map((op) => displayOperation(op.tool, op.target))
440
- .filter(Boolean);
441
410
  }
411
+ return extractOperationsFromCode(args?.code)
412
+ .map((op) => displayOperation(op.tool, op.target))
413
+ .filter(Boolean);
414
+ }
442
415
 
416
+ function formatElapsed(state) {
417
+ if (state?.wallMs != null) return `${state.wallMs}ms`;
418
+ if (state?.startedAt != null) {
419
+ const elapsed = Math.round(performance.now() - state.startedAt);
420
+ // Suppress sub-100ms noise on the pending head (matches quiet Write/Edit calls).
421
+ if (elapsed < 100) return "";
422
+ return elapsed >= 1000 ? `${(elapsed / 1000).toFixed(1)}s` : `${elapsed}ms`;
423
+ }
424
+ return "";
425
+ }
426
+
427
+ function tickCallTimer(context) {
443
428
  const state = context?.state;
444
429
  if (state && state.startedAt == null) state.startedAt = performance.now();
445
430
  if (state && context?.executionStarted && state.wallMs == null && state.timer == null) {
@@ -448,16 +433,116 @@ export function renderSupernovaCall(args, theme, context) {
448
433
  context.invalidate?.();
449
434
  }, 100);
450
435
  }
436
+ }
451
437
 
452
- let timeStr = "";
453
- if (state?.wallMs != null) {
454
- timeStr = `${state.wallMs}ms`;
455
- } else if (state?.startedAt != null) {
456
- const elapsed = Math.round(performance.now() - state.startedAt);
457
- timeStr = elapsed >= 1000 ? `${(elapsed / 1000).toFixed(1)}s` : `${elapsed}ms`;
438
+ function formatOpBodyLine(theme, op) {
439
+ const icon = ACTION_ICONS[op.tool] || "✦ ";
440
+ const bullet = theme.fg("accent", icon);
441
+ const toolName = theme.fg("syntaxFunction", op.tool.padEnd(4, " "));
442
+ const rawTarget = formatOpTarget(op.target, op.tool);
443
+ const target = rawTarget ? " " + theme.fg("muted", rawTarget) : "";
444
+ return `${bullet}${toolName}${target}`;
445
+ }
446
+
447
+ function shouldUseOmpFrame(host) {
448
+ return host === "omp" || hasHostFramedBlock();
449
+ }
450
+
451
+ /**
452
+ * OMP write/edit look: rounded framedBlock + status-line header.
453
+ * Pending call has no hourglass on the head row (same as native Write/Edit).
454
+ */
455
+ function renderOmpCallCard(theme, { ops, timeStr, expanded, code }) {
456
+ const opSummary =
457
+ ops.length === 0
458
+ ? "composing"
459
+ : ops.map((op) => op.tool).join(theme.sep?.dot ? ` ${theme.sep.dot} ` : " · ");
460
+ const description = timeStr ? `${opSummary} · ${timeStr}` : opSummary;
461
+ // No pending icon on the framed head row — matches native Write/Edit.
462
+ const header = novaStatusLine(theme, {
463
+ title: "nova",
464
+ description,
465
+ });
466
+ return novaFramedBlock(theme, (width) => {
467
+ const bodyLines = ops.map((op) => formatOpBodyLine(theme, op));
468
+ if (expanded && code) {
469
+ bodyLines.push(theme.fg("dim", "── source ──"));
470
+ for (const line of String(code).trim().split("\n")) {
471
+ bodyLines.push(theme.fg("toolOutput", line));
472
+ }
473
+ }
474
+ return {
475
+ header,
476
+ sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
477
+ state: "pending",
478
+ borderColor: "borderMuted",
479
+ width,
480
+ };
481
+ });
482
+ }
483
+
484
+ function renderOmpResultCard(theme, { isErr, payload, expanded, bodyText, isPartial, spinnerFrame }) {
485
+ if (isErr) {
486
+ const errLines = [];
487
+ if (payload?.error) errLines.push(theme.fg("error", String(payload.error)));
488
+ if (expanded && payload?.logs?.length) {
489
+ errLines.push(theme.fg("dim", "── logs ──"));
490
+ for (const log of payload.logs) errLines.push(theme.fg("dim", String(log)));
491
+ }
492
+ const header = novaStatusLine(theme, {
493
+ icon: "error",
494
+ title: "nova",
495
+ description: payload?.error ? String(payload.error).split("\n")[0] : "error",
496
+ });
497
+ return novaFramedBlock(theme, (width) => ({
498
+ header,
499
+ sections: errLines.length > 0 ? [{ lines: errLines }] : [],
500
+ state: "error",
501
+ borderColor: "error",
502
+ width,
503
+ }));
458
504
  }
459
505
 
460
- // Compact aesthetic never dump raw JSON args (the stock Pi tool fallback).
506
+ const wall = payload?.wallMs != null ? `${payload.wallMs}ms` : "";
507
+ const header = novaStatusLine(theme, {
508
+ icon: isPartial ? "running" : undefined,
509
+ spinnerFrame,
510
+ title: "nova",
511
+ description: wall || undefined,
512
+ });
513
+ const bodyLines = String(bodyText || "").split("\n");
514
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
515
+ while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") bodyLines.pop();
516
+ return novaFramedBlock(theme, (width) => ({
517
+ header,
518
+ sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
519
+ state: isPartial ? "pending" : "success",
520
+ borderColor: "borderMuted",
521
+ width,
522
+ }));
523
+ }
524
+
525
+ export function renderSupernovaCall(a, b, c) {
526
+ const { args, theme, context, options, host } = normalizeCallRenderArgs(a, b, c);
527
+ tickCallTimer(context);
528
+ const ops = collectCallOps(args, context);
529
+ const timeStr = formatElapsed(context?.state);
530
+
531
+ if (shouldUseOmpFrame(host)) {
532
+ void ensureOmpChrome();
533
+ return renderOmpCallCard(theme, {
534
+ ops,
535
+ timeStr,
536
+ expanded: !!context?.expanded,
537
+ code: args?.code,
538
+ });
539
+ }
540
+
541
+ const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
542
+ if (options) options.lastComponent = comp;
543
+ else if (context) context.lastComponent = comp;
544
+
545
+ // Compact aesthetic — never dump raw JSON args (the stock tool fallback).
461
546
  let out = theme.fg("toolTitle", theme.bold("nova"));
462
547
  if (timeStr) {
463
548
  out += " " + theme.fg("dim", `· ${timeStr}`);
@@ -467,12 +552,7 @@ export function renderSupernovaCall(args, theme, context) {
467
552
  out += " " + theme.fg("dim", "· composing");
468
553
  } else {
469
554
  for (const op of ops) {
470
- const icon = ACTION_ICONS[op.tool] || "✦ ";
471
- const bullet = theme.fg("accent", icon);
472
- const toolName = theme.fg("syntaxFunction", op.tool.padEnd(4, " "));
473
- const rawTarget = formatOpTarget(op.target, op.tool);
474
- const target = rawTarget ? " " + theme.fg("muted", rawTarget) : "";
475
- out += `\n ${bullet}${toolName}${target}`;
555
+ out += `\n ${formatOpBodyLine(theme, op)}`;
476
556
  }
477
557
  }
478
558
 
@@ -489,50 +569,8 @@ export function renderSupernovaCall(args, theme, context) {
489
569
  return comp;
490
570
  }
491
571
 
492
- export function renderSupernovaResult(result, { expanded, isPartial }, theme, context) {
493
- const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
494
-
495
- const payload = result?.details;
496
- if (context?.state && payload) {
497
- let changed = false;
498
- if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) {
499
- context.state.trace = payload.trace;
500
- changed = true;
501
- }
502
- if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) {
503
- context.state.wallMs = payload.wallMs;
504
- changed = true;
505
- }
506
- if (context.state.timer != null && !isPartial) {
507
- clearTimeout(context.state.timer);
508
- context.state.timer = null;
509
- }
510
- if (changed) context.invalidate?.();
511
- }
512
-
513
- if (isPartial) {
514
- comp.setText("");
515
- return comp;
516
- }
517
- const isErr = result?.isError || payload?.ok === false;
518
-
519
- if (isErr) {
520
- let out = theme.fg("error", "✗ error");
521
- if (payload?.error) {
522
- out += `\n ${theme.fg("error", String(payload.error))}`;
523
- }
524
- if (expanded && payload?.logs?.length) {
525
- out += `\n${theme.fg("dim", "── logs ──")}`;
526
- for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
527
- }
528
- comp.setTone("error");
529
- comp.setFraming(true);
530
- comp.setText(out);
531
- return comp;
532
- }
533
-
572
+ function buildResultBody(theme, { payload, context, expanded }) {
534
573
  let out = "";
535
-
536
574
  const trace = payload?.trace || context?.state?.trace || [];
537
575
  const diffs = trace.filter((t) => t?.diff && isObject(t.diff)).map((t) => t.diff);
538
576
 
@@ -540,7 +578,6 @@ export function renderSupernovaResult(result, { expanded, isPartial }, theme, co
540
578
  const maxDiffsShown = expanded ? diffs.length : 2;
541
579
  const shownDiffs = diffs.slice(0, maxDiffsShown);
542
580
  for (const diff of shownDiffs) {
543
- // Build unconstrained; SafeText.render(terminalWidth) is the hard clamp.
544
581
  const box = renderDiffBox(diff, theme, 120);
545
582
  if (box) out += (out ? "\n\n" : "") + box;
546
583
  }
@@ -572,7 +609,110 @@ export function renderSupernovaResult(result, { expanded, isPartial }, theme, co
572
609
  for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
573
610
  }
574
611
  }
612
+ return out;
613
+ }
614
+
615
+ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextArg) {
616
+ const { result, expanded, isPartial, theme, context, options, host } = normalizeResultRenderArgs(
617
+ resultArg,
618
+ optionsArg,
619
+ themeArg,
620
+ contextArg,
621
+ );
622
+
623
+ const payload = result?.details;
624
+ if (context?.state && payload) {
625
+ let changed = false;
626
+ if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) {
627
+ context.state.trace = payload.trace;
628
+ changed = true;
629
+ }
630
+ if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) {
631
+ context.state.wallMs = payload.wallMs;
632
+ changed = true;
633
+ }
634
+ if (context.state.timer != null && !isPartial) {
635
+ clearTimeout(context.state.timer);
636
+ context.state.timer = null;
637
+ }
638
+ if (changed) context.invalidate?.();
639
+ }
640
+
641
+ const isErr = result?.isError || payload?.ok === false;
642
+ const useOmp = shouldUseOmpFrame(host);
643
+
644
+ if (useOmp) {
645
+ void ensureOmpChrome();
646
+ if (isPartial && !isErr) {
647
+ // Streaming partials stay quiet until body content exists — same as Pi.
648
+ const partialBody = buildResultBody(theme, { payload, context, expanded });
649
+ if (!partialBody.trim()) {
650
+ return {
651
+ render: () => [],
652
+ invalidate() {},
653
+ };
654
+ }
655
+ return renderOmpResultCard(theme, {
656
+ isErr: false,
657
+ payload,
658
+ expanded,
659
+ bodyText: partialBody,
660
+ isPartial: true,
661
+ spinnerFrame: options?.spinnerFrame,
662
+ });
663
+ }
664
+ if (isErr) {
665
+ return renderOmpResultCard(theme, {
666
+ isErr: true,
667
+ payload,
668
+ expanded,
669
+ bodyText: "",
670
+ isPartial: false,
671
+ spinnerFrame: options?.spinnerFrame,
672
+ });
673
+ }
674
+ const out = buildResultBody(theme, { payload, context, expanded });
675
+ if (!out.trim()) {
676
+ return {
677
+ render: () => [],
678
+ invalidate() {},
679
+ };
680
+ }
681
+ return renderOmpResultCard(theme, {
682
+ isErr: false,
683
+ payload,
684
+ expanded,
685
+ bodyText: out,
686
+ isPartial: false,
687
+ spinnerFrame: options?.spinnerFrame,
688
+ });
689
+ }
690
+
691
+ const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
692
+ if (options) options.lastComponent = comp;
693
+ else if (context) context.lastComponent = comp;
694
+
695
+ if (isPartial) {
696
+ comp.setText("");
697
+ return comp;
698
+ }
699
+
700
+ if (isErr) {
701
+ let out = theme.fg("error", "✗ error");
702
+ if (payload?.error) {
703
+ out += `\n ${theme.fg("error", String(payload.error))}`;
704
+ }
705
+ if (expanded && payload?.logs?.length) {
706
+ out += `\n${theme.fg("dim", "── logs ──")}`;
707
+ for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
708
+ }
709
+ comp.setTone("error");
710
+ comp.setFraming(true);
711
+ comp.setText(out);
712
+ return comp;
713
+ }
575
714
 
715
+ const out = buildResultBody(theme, { payload, context, expanded });
576
716
  if (!out.trim()) {
577
717
  comp.setText("");
578
718
  return comp;