pi-supernova 0.0.1 → 0.0.4

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,28 @@
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.4] - 2026-09-03
10
+
11
+ ### Fixed
12
+
13
+ - OMP no longer hangs on "Loading plugins…" — removed top-level import of `@oh-my-pi/pi-coding-agent/tui` from the extension (portable framed chrome only).
14
+
15
+ ## [0.0.3] - 2026-09-03
16
+
17
+ ### Changed
18
+
19
+ - OMP TUI: nova cards now use the same rounded `framedBlock` + status-line chrome as native write/edit (Pi keeps the muted violet wash).
20
+
21
+ ## [0.0.2] - 2026-09-03
22
+
23
+ ### Fixed
24
+
25
+ - OMP TUI: accept `(args, options, theme)` render signature so the custom nova card shows instead of the raw JSON args dump.
26
+
5
27
  ## [0.0.1] - 2026-09-03
6
28
 
7
29
  ### 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
@@ -1,18 +1,6 @@
1
-
2
- let Type;
3
- try {
4
- Type = (await import("typebox")).Type;
5
- } catch {
6
- Type = {
7
- Object: (props, opts) => ({ type: "object", properties: props || {}, additionalProperties: false, ...opts }),
8
- String: (opts) => ({ type: "string", ...opts }),
9
- Integer: (opts) => ({ type: "integer", ...opts }),
10
- Optional: (s) => ({ ...s }),
11
- };
12
- }
13
-
1
+ import { createRequire } from "node:module";
14
2
  import { isString, isFunction } from "./decode.js";
15
- import { buildCatalog, searchCatalog, describeTool } from "./catalog.js";
3
+ import { buildCatalog, searchCatalog, describeTool, mergeNativeToolDefinitions } from "./catalog.js";
16
4
  import { loadConfig } from "./config.js";
17
5
  import { createHostBridge } from "./host-bridge.js";
18
6
  import { runGuestProgram } from "./runtime.js";
@@ -25,6 +13,20 @@ import {
25
13
 
26
14
  export { extractOperationsFromCode, renderSupernovaCall, renderSupernovaResult, SafeText };
27
15
 
16
+ // Sync only — never top-level await. Dynamic import of host/deps hung OMP plugin load.
17
+ const require = createRequire(import.meta.url);
18
+ let Type;
19
+ try {
20
+ Type = require("typebox").Type;
21
+ } catch {
22
+ Type = {
23
+ Object: (props, opts) => ({ type: "object", properties: props || {}, additionalProperties: false, ...opts }),
24
+ String: (opts) => ({ type: "string", ...opts }),
25
+ Integer: (opts) => ({ type: "integer", ...opts }),
26
+ Optional: (s) => ({ ...s }),
27
+ };
28
+ }
29
+
28
30
  function result(text, details) {
29
31
  return { content: [{ type: "text", text }], details };
30
32
  }
@@ -61,7 +63,8 @@ export default function piSupernova(pi) {
61
63
  } catch {
62
64
  tools = [];
63
65
  }
64
- catalog = buildCatalog(tools, config.excludeTools || []);
66
+ const discoverable = mergeNativeToolDefinitions(tools, bridge.executors.keys());
67
+ catalog = buildCatalog(discoverable, config.excludeTools || []);
65
68
  return catalog;
66
69
  }
67
70
 
@@ -127,9 +130,10 @@ export default function piSupernova(pi) {
127
130
  }),
128
131
  ),
129
132
  }),
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.
133
+ // "self" = we own chrome. OMP uses native framedBlock (write/edit look);
134
+ // Pi keeps the muted violet SafeText wash. "default" falls back to raw JSON.
132
135
  renderShell: "self",
136
+ mergeCallAndResult: true,
133
137
  renderCall: renderSupernovaCall,
134
138
  renderResult: renderSupernovaResult,
135
139
  async execute(_id, params, signal, onUpdate, ctx) {
package/omp-frame.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * OMP-style rounded tool chrome for supernova.
3
+ *
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.
6
+ */
7
+
8
+ import { clampLine, measureWidth, wrapPlainToWidth } from "./render-measure.js";
9
+
10
+ const DEFAULT_BOX = {
11
+ topLeft: "╭",
12
+ topRight: "╮",
13
+ bottomLeft: "╰",
14
+ bottomRight: "╯",
15
+ horizontal: "─",
16
+ vertical: "│",
17
+ teeLeft: "┤",
18
+ teeRight: "├",
19
+ };
20
+
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
+ function boxOf(theme) {
30
+ const b = theme?.boxRound;
31
+ if (b && b.topLeft && b.horizontal && b.vertical) return b;
32
+ return DEFAULT_BOX;
33
+ }
34
+
35
+ function borderPaint(theme, state, borderColor) {
36
+ const key =
37
+ borderColor ||
38
+ (state === "error" ? "error" : state === "warning" ? "warning" : state === "running" || state === "pending" ? "accent" : "dim");
39
+ if (theme && typeof theme.fg === "function") {
40
+ try {
41
+ return (text) => theme.fg(key, text);
42
+ } catch {
43
+ /* fall through */
44
+ }
45
+ }
46
+ return (text) => text;
47
+ }
48
+
49
+ function statusHeader(theme, { title, description, state, spinnerFrame, icon, iconOverride }) {
50
+ const resolvedIcon =
51
+ iconOverride !== undefined
52
+ ? undefined
53
+ : icon !== undefined
54
+ ? icon
55
+ : state === "error"
56
+ ? "error"
57
+ : undefined;
58
+ const titleText = theme?.fg ? theme.fg("accent", title) : title;
59
+ const descText = description ? (theme?.fg ? theme.fg("muted", description) : description) : "";
60
+ let prefix = "";
61
+ if (iconOverride) prefix = `${iconOverride} `;
62
+ else if (resolvedIcon === "error") prefix = theme?.fg ? theme.fg("error", "✗ ") : "✗ ";
63
+ else if (resolvedIcon === "running" || spinnerFrame) prefix = theme?.fg ? theme.fg("dim", "… ") : "… ";
64
+ return descText ? `${prefix}${titleText}: ${descText}` : `${prefix}${titleText}`;
65
+ }
66
+
67
+ function padLine(line, width, bgFn) {
68
+ const w = Math.max(0, width | 0);
69
+ const vis = measureWidth(line);
70
+ const pad = Math.max(0, w - vis);
71
+ const padded = line + " ".repeat(pad);
72
+ return bgFn ? bgFn(padded) : padded;
73
+ }
74
+
75
+ function bgFnForState(theme, state) {
76
+ if (!state || !theme) return undefined;
77
+ if (typeof theme.bg === "function") {
78
+ const key =
79
+ state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
80
+ try {
81
+ const probe = theme.bg(key, "x");
82
+ if (typeof probe !== "string") return undefined;
83
+ return (text) => {
84
+ const painted = theme.bg(key, text);
85
+ return typeof painted === "string" ? painted : text;
86
+ };
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+ if (typeof theme.getBgAnsi === "function") {
92
+ try {
93
+ const key =
94
+ state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
95
+ const ansi = theme.getBgAnsi(key);
96
+ if (!ansi) return undefined;
97
+ return (text) => `${ansi}${text}\x1b[49m`;
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ export function renderPortableFrame(theme, { header, sections = [], state = "pending", borderColor, width }) {
106
+ const w = Math.max(8, width | 0);
107
+ const box = boxOf(theme);
108
+ const border = borderPaint(theme, state, borderColor);
109
+ const bgFn = bgFnForState(theme, state);
110
+ const h = box.horizontal;
111
+ const v = box.vertical;
112
+ const cap = h.repeat(3);
113
+
114
+ const paintBar = (leftChar, rightChar, label) => {
115
+ const left = `${leftChar}${cap}`;
116
+ const right = rightChar;
117
+ if (!label) {
118
+ const fill = Math.max(0, w - measureWidth(left) - measureWidth(right));
119
+ return padLine(`${border(left)}${border(h.repeat(fill))}${border(right)}`, w, bgFn);
120
+ }
121
+ const rawLabel = ` ${label} `;
122
+ const maxLabel = Math.max(0, w - measureWidth(left) - measureWidth(right));
123
+ const trimmed = clampLine(rawLabel, maxLabel);
124
+ const fill = Math.max(0, w - measureWidth(left) - measureWidth(trimmed) - measureWidth(right));
125
+ return padLine(`${border(left)}${trimmed}${border(h.repeat(fill))}${border(right)}`, w, bgFn);
126
+ };
127
+
128
+ const contentWidth = Math.max(1, w - 2 - 2);
129
+ const lines = [];
130
+ lines.push(paintBar(box.topLeft, box.topRight, header));
131
+
132
+ const normalized = sections.length > 0 ? sections : [{ lines: [] }];
133
+ for (const section of normalized) {
134
+ if (section.label) {
135
+ lines.push(paintBar(box.teeRight || "├", box.teeLeft || "┤", section.label));
136
+ }
137
+ for (const raw of section.lines || []) {
138
+ for (const piece of String(raw).split("\n")) {
139
+ const body = clampLine(piece, contentWidth);
140
+ const pad = Math.max(0, contentWidth - measureWidth(body));
141
+ const inner = `${body}${" ".repeat(pad)}`;
142
+ lines.push(padLine(`${border(v)} ${inner} ${border(v)}`, w, bgFn));
143
+ }
144
+ }
145
+ }
146
+
147
+ lines.push(paintBar(box.bottomLeft, box.bottomRight, null));
148
+ return lines;
149
+ }
150
+
151
+ export function createPortableFramedComponent(theme, build) {
152
+ let cacheWidth;
153
+ let cacheKey;
154
+ let cacheLines;
155
+ return {
156
+ render(width) {
157
+ const opts = build(width);
158
+ const key = `${opts.state}|${opts.borderColor}|${opts.header}|${(opts.sections || [])
159
+ .map((s) => (s.lines || []).join("\n"))
160
+ .join("||")}`;
161
+ if (cacheLines && cacheWidth === width && cacheKey === key) return cacheLines;
162
+ cacheLines = renderPortableFrame(theme, opts);
163
+ cacheWidth = width;
164
+ cacheKey = key;
165
+ return cacheLines;
166
+ },
167
+ invalidate() {
168
+ cacheLines = undefined;
169
+ cacheKey = undefined;
170
+ cacheWidth = undefined;
171
+ },
172
+ };
173
+ }
174
+
175
+ export function novaFramedBlock(theme, build) {
176
+ return createPortableFramedComponent(theme, build);
177
+ }
178
+
179
+ export function novaStatusLine(theme, options) {
180
+ return statusHeader(theme, options);
181
+ }
182
+
183
+ export function wrapPlainLines(text, width) {
184
+ const w = Math.max(1, width | 0);
185
+ const out = [];
186
+ for (const line of String(text ?? "").split("\n")) {
187
+ for (const chunk of wrapPlainToWidth(line, w)) out.push(chunk);
188
+ }
189
+ return out.length ? out : [""];
190
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.0.1",
3
+ "version": "0.0.4",
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 { 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,112 @@ 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
+ return "pi";
352
+ }
353
+
354
+ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
355
+ if (isTheme(themeOrCtx)) {
356
+ const opts = options && typeof options === "object" ? options : {};
357
+ let context;
358
+ if (
359
+ ctxOrArgs &&
360
+ typeof ctxOrArgs === "object" &&
361
+ !isTheme(ctxOrArgs) &&
362
+ ("lastComponent" in ctxOrArgs || "state" in ctxOrArgs || "invalidate" in ctxOrArgs)
363
+ ) {
364
+ context = ctxOrArgs;
365
+ } else {
366
+ context = { ...(opts.state ? { state: opts.state } : {}), lastComponent: opts.lastComponent };
367
+ }
368
+ if (!context.state || typeof context.state !== "object") context.state = {};
369
+ return {
370
+ result,
371
+ expanded: !!opts.expanded,
372
+ isPartial: !!opts.isPartial,
373
+ theme: themeOrCtx,
374
+ context,
375
+ host: detectResultHost(options, ctxOrArgs),
376
+ options: opts,
377
+ };
378
+ }
379
+ // Extremely defensive: (result, theme, context) oddball
380
+ if (isTheme(options)) {
381
+ const context = themeOrCtx && typeof themeOrCtx === "object" ? themeOrCtx : {};
382
+ if (!context.state || typeof context.state !== "object") context.state = {};
383
+ return {
384
+ result,
385
+ expanded: !!context.expanded,
386
+ isPartial: !!context.isPartial,
387
+ theme: options,
388
+ context,
389
+ host: "pi",
390
+ options: {},
391
+ };
392
+ }
393
+ throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
394
+ }
395
+
396
+ function collectCallOps(args, context) {
425
397
  const stateTrace = context?.state?.trace;
426
398
  if (Array.isArray(stateTrace) && stateTrace.length > 0) {
427
- ops = stateTrace
399
+ return stateTrace
428
400
  .map((item) => {
429
401
  const tool = item?.name || "tool";
430
402
  let target = "";
@@ -434,12 +406,24 @@ export function renderSupernovaCall(args, theme, context) {
434
406
  return displayOperation(tool, target);
435
407
  })
436
408
  .filter(Boolean);
437
- } else {
438
- ops = extractOperationsFromCode(args?.code)
439
- .map((op) => displayOperation(op.tool, op.target))
440
- .filter(Boolean);
441
409
  }
410
+ return extractOperationsFromCode(args?.code)
411
+ .map((op) => displayOperation(op.tool, op.target))
412
+ .filter(Boolean);
413
+ }
442
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
+ }
423
+ return "";
424
+ }
425
+
426
+ function tickCallTimer(context) {
443
427
  const state = context?.state;
444
428
  if (state && state.startedAt == null) state.startedAt = performance.now();
445
429
  if (state && context?.executionStarted && state.wallMs == null && state.timer == null) {
@@ -448,16 +432,115 @@ export function renderSupernovaCall(args, theme, context) {
448
432
  context.invalidate?.();
449
433
  }, 100);
450
434
  }
435
+ }
451
436
 
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`;
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)));
490
+ }
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
+ }));
458
503
  }
459
504
 
460
- // Compact aesthetic never dump raw JSON args (the stock Pi tool fallback).
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
+ }));
522
+ }
523
+
524
+ 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
+
539
+ const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
540
+ if (options) options.lastComponent = comp;
541
+ else if (context) context.lastComponent = comp;
542
+
543
+ // Compact aesthetic — never dump raw JSON args (the stock tool fallback).
461
544
  let out = theme.fg("toolTitle", theme.bold("nova"));
462
545
  if (timeStr) {
463
546
  out += " " + theme.fg("dim", `· ${timeStr}`);
@@ -467,12 +550,7 @@ export function renderSupernovaCall(args, theme, context) {
467
550
  out += " " + theme.fg("dim", "· composing");
468
551
  } else {
469
552
  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}`;
553
+ out += `\n ${formatOpBodyLine(theme, op)}`;
476
554
  }
477
555
  }
478
556
 
@@ -489,50 +567,8 @@ export function renderSupernovaCall(args, theme, context) {
489
567
  return comp;
490
568
  }
491
569
 
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
-
570
+ function buildResultBody(theme, { payload, context, expanded }) {
534
571
  let out = "";
535
-
536
572
  const trace = payload?.trace || context?.state?.trace || [];
537
573
  const diffs = trace.filter((t) => t?.diff && isObject(t.diff)).map((t) => t.diff);
538
574
 
@@ -540,7 +576,6 @@ export function renderSupernovaResult(result, { expanded, isPartial }, theme, co
540
576
  const maxDiffsShown = expanded ? diffs.length : 2;
541
577
  const shownDiffs = diffs.slice(0, maxDiffsShown);
542
578
  for (const diff of shownDiffs) {
543
- // Build unconstrained; SafeText.render(terminalWidth) is the hard clamp.
544
579
  const box = renderDiffBox(diff, theme, 120);
545
580
  if (box) out += (out ? "\n\n" : "") + box;
546
581
  }
@@ -572,7 +607,109 @@ export function renderSupernovaResult(result, { expanded, isPartial }, theme, co
572
607
  for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
573
608
  }
574
609
  }
610
+ return out;
611
+ }
612
+
613
+ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextArg) {
614
+ const { result, expanded, isPartial, theme, context, options, host } = normalizeResultRenderArgs(
615
+ resultArg,
616
+ optionsArg,
617
+ themeArg,
618
+ contextArg,
619
+ );
620
+
621
+ const payload = result?.details;
622
+ if (context?.state && payload) {
623
+ let changed = false;
624
+ if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) {
625
+ context.state.trace = payload.trace;
626
+ changed = true;
627
+ }
628
+ if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) {
629
+ 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
+ }
636
+ if (changed) context.invalidate?.();
637
+ }
638
+
639
+ 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
+ }
696
+
697
+ if (isErr) {
698
+ let out = theme.fg("error", "✗ error");
699
+ if (payload?.error) {
700
+ out += `\n ${theme.fg("error", String(payload.error))}`;
701
+ }
702
+ 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))}`;
705
+ }
706
+ comp.setTone("error");
707
+ comp.setFraming(true);
708
+ comp.setText(out);
709
+ return comp;
710
+ }
575
711
 
712
+ const out = buildResultBody(theme, { payload, context, expanded });
576
713
  if (!out.trim()) {
577
714
  comp.setText("");
578
715
  return comp;