pi-supernova 0.0.3 → 0.0.5

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,9 +2,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.0.5] - 2026-09-03
6
+
5
7
  ### Fixed
6
8
 
7
9
  - 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.
10
+ - `snap` discovers explicitly targeted hidden paths outside `.git` and sees files written earlier in the same speculative invocation.
11
+ - Top-level `snap()` and `surface()` helpers return structured objects, enabling an immediate `edit(hit.path, ...)` handoff.
12
+ - Live command activity remains visible across partial updates and final merged cards.
13
+ - Collapsed cards show a compact, correctly labeled call ledger with paths and change counts; diff hunks are bounded and shown only when expanded.
14
+ - Multi-replacement edits report only the changed hunks instead of presenting the entire file as replaced.
15
+ - Syntax errors now roll back the outer VFS transaction instead of leaking speculative depth into the next Supernova call.
16
+ - Root-level Snap searches continue to ignore hidden files; hidden discovery is enabled only when the caller explicitly targets a hidden search root, while Git metadata remains excluded.
17
+ - Command cards cover custom tools, mark failed traces accurately, sanitize terminal controls, and remain width-safe for narrow terminals and wide emoji.
18
+
19
+ ## [0.0.4] - 2026-09-03
20
+
21
+ ### Fixed
22
+
23
+ - 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).
8
24
 
9
25
  ## [0.0.3] - 2026-09-03
10
26
 
package/README.md CHANGED
@@ -46,16 +46,20 @@ async () => {
46
46
  }
47
47
  ```
48
48
 
49
- Globals: `nova` / `tools`, `parallel`, `pipeline`, `console`, plus shorthand `read` / `write` / `edit` / `bash` /
49
+ Globals: `nova` / `tools`, `parallel`, `pipeline`, `console`, plus shorthand `read`, `write`, `edit`, `patch`, `surface`, `snap`, `bash`, and `exec`.
50
50
 
51
51
  Terminal card (muted violet / grey-blue — not a raw JSON args dump):
52
52
 
53
53
  ```text
54
- nova · 84ms
55
- read packages/pi-supernova/host-bridge.js
56
- edit packages/pi-supernova/diff.js
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
+ ╰────────────────────────────────────────────────╯
57
59
  ```
58
60
 
61
+ Collapsed cards keep this command ledger visible. Press Enter to inspect bounded diff hunks, logs, and the returned value.
62
+
59
63
  ---
60
64
 
61
65
  ## API
@@ -66,9 +70,14 @@ Terminal card (muted violet / grey-blue — not a raw JSON args dump):
66
70
  | `nova.describe(name)` | Parameter summary on demand |
67
71
  | `nova.call(name, args)` | Host tool or native adapter |
68
72
  | `nova.callMany([{name,args}])` | Auto parallel wave — iterable array with `.mode` / `.results` |
73
+ | `nova.surface(path)` | Structural outline for a source file |
74
+ | `nova.snap(query, searchRoot?)` | Most relevant source path, line, signature, confidence, and context |
75
+ | `nova.has(name)` | Whether a catalog or native tool is callable |
69
76
  | `parallel(thunks)` / `pipeline(items, …stages)` | Raw `Promise.all` helpers |
70
77
  | `nova.speculate(fn)` | Counterfactual branch (rollback / commit) |
71
78
 
79
+ Root Snap searches ignore hidden files. Passing a hidden search root includes hidden files beneath that root; Git metadata is always excluded.
80
+
72
81
  ---
73
82
 
74
83
  ## Configuration
@@ -126,7 +135,7 @@ Pair with DCE last if you use it: `omp install npm:pi-deferred-context-engine`.
126
135
 
127
136
  - Guest JS is **unsandboxed**. Adapter path jails are not a boundary against `import("node:fs")`.
128
137
  - `bash` / mutating tools flush speculative writes (transaction barrier); error rollback cannot undo that.
129
- - First public cut (`0.0.1`) — APIs and TUI will iterate.
138
+ - Pre-1.0 package — APIs and TUI may still evolve between minor releases.
130
139
 
131
140
  ## License
132
141
 
package/catalog.js CHANGED
@@ -28,7 +28,10 @@ export const NATIVE_TOOL_DEFINITIONS = [
28
28
  },
29
29
  {
30
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"] },
31
+ parameters: { type: "object", properties: {
32
+ query: { type: "string", description: "Source concept to resolve" },
33
+ path: { type: "string", description: "Optional workspace search root; explicitly targeting a hidden directory includes its hidden files, but Git metadata is always excluded" },
34
+ }, required: ["query"] },
32
35
  },
33
36
  {
34
37
  name: "surface", description: "Extract a structural outline from a workspace source file.",
package/diff.js CHANGED
@@ -35,6 +35,27 @@ export function buildEditDiff(filePath, originalText, oldText, newText) {
35
35
  };
36
36
  }
37
37
 
38
+ export function buildMultiEditDiff(filePath, originalText, replacements) {
39
+ const parts = replacements.map(({ oldText, newText }) =>
40
+ buildEditDiff(filePath, originalText, oldText, newText),
41
+ );
42
+ const lines = [];
43
+ for (const part of parts) {
44
+ for (const line of part.lines) {
45
+ const previous = lines.at(-1);
46
+ if (previous?.type === "context" && line.type === "context" && previous.lineNum === line.lineNum) continue;
47
+ lines.push(line);
48
+ }
49
+ }
50
+ return {
51
+ path: filePath,
52
+ op: "edit",
53
+ added: parts.reduce((sum, part) => sum + part.added, 0),
54
+ removed: parts.reduce((sum, part) => sum + part.removed, 0),
55
+ lines,
56
+ };
57
+ }
58
+
38
59
  export function buildPatchDiff(filePath, patchText) {
39
60
  const patchLines = isString(patchText) ? patchText.replace(/\r\n/g, "\n").split("\n") : [];
40
61
  const lines = [];
package/host-bridge.js CHANGED
@@ -6,7 +6,7 @@ import { packageHostResult } from "./bottleneck.js";
6
6
  import { isString, isNumber, isFunction } from "./decode.js";
7
7
  import { isMutatingTool, runParallelWave } from "./parallel.js";
8
8
  import { extractStructuralSurface } from "./surface.js";
9
- import { buildEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
9
+ import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
10
10
  import { executeSnap } from "./snap.js";
11
11
 
12
12
  function textResult(text, details) {
@@ -33,7 +33,7 @@ async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
33
33
  const resolvedCwd = getResolvedCwd(cwd);
34
34
  const target = path.resolve(resolvedCwd, inputPath.trim());
35
35
  const rel = path.relative(resolvedCwd, target);
36
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
36
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
37
37
  throw new Error(`${opName} path escapes workspace`);
38
38
  }
39
39
  if (!allowRoot && target === resolvedCwd) {
@@ -54,7 +54,7 @@ async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
54
54
  }
55
55
  }
56
56
  const realRel = path.relative(realRoot, probe);
57
- if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
57
+ if (realRel === ".." || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) {
58
58
  throw new Error(`${opName} path escapes workspace through symlink`);
59
59
  }
60
60
  return target;
@@ -242,6 +242,14 @@ class CausalVfs {
242
242
  return undefined;
243
243
  }
244
244
 
245
+ getOverlayPaths() {
246
+ const paths = new Set();
247
+ for (const overlay of this.overlays) {
248
+ for (const target of overlay.keys()) paths.add(target);
249
+ }
250
+ return [...paths];
251
+ }
252
+
245
253
  async read(target) {
246
254
  const overlay = this.getOverlay(target);
247
255
  if (overlay !== undefined) return overlay;
@@ -501,7 +509,7 @@ function createNativeAdapters(getCwd, vfs, config) {
501
509
  const diff =
502
510
  matches.length === 1
503
511
  ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
504
- : { ...buildWriteDiff(target, content, updated), op: "edit" };
512
+ : buildMultiEditDiff(target, content, matches);
505
513
  const tag = speculative ? " (speculative)" : "";
506
514
  return textResult(`edited ${target}${tag}`, { path: target, speculative, diff });
507
515
  },
@@ -537,9 +545,14 @@ function createNativeAdapters(getCwd, vfs, config) {
537
545
  }
538
546
  if (signal?.aborted) throw new Error("aborted");
539
547
  const snapTarget = params?.path ? await resolveWorkspacePath(cwd, params.path, "snap", true) : cwd;
548
+ const relativeRoot = path.relative(cwd, snapTarget);
549
+ const includeHidden = Boolean(params?.path) && relativeRoot
550
+ .split(path.sep)
551
+ .some((segment) => segment.startsWith(".") && segment.length > 1);
540
552
  const res = await executeSnap({
541
553
  query: params.query,
542
554
  searchDir: snapTarget,
555
+ includeHidden,
543
556
  vfs: {
544
557
  read: async (candidate) => {
545
558
  // Jail each snap candidate (symlink files must not escape the workspace).
@@ -548,6 +561,7 @@ function createNativeAdapters(getCwd, vfs, config) {
548
561
  },
549
562
  },
550
563
  runCommand: (argv, opts) => runCommand(argv, { cwd: snapTarget, signal, ...opts }),
564
+ pendingPaths: vfs.getOverlayPaths(),
551
565
  });
552
566
  return textResult(JSON.stringify(res, null, 2), res);
553
567
  },
@@ -726,6 +740,13 @@ export function createHostBridge({ pi, config, getCwd }) {
726
740
  vfs.clear();
727
741
  }
728
742
 
743
+ function notifyCall(record) {
744
+ if (!callListener) return;
745
+ try {
746
+ callListener(record, [...trace]);
747
+ } catch {}
748
+ }
749
+
729
750
  async function invokeRaw(name, args) {
730
751
  const maxCalls = config.maxBridgeCalls ?? 256;
731
752
  callCount += 1;
@@ -746,33 +767,33 @@ export function createHostBridge({ pi, config, getCwd }) {
746
767
  const record = { name, args: args || {}, time: Date.now() };
747
768
  trace.push(record);
748
769
 
749
- const exec = executors.get(name);
750
- if (exec) {
751
- if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
752
- const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
753
- if (callListener) {
754
- try {
755
- callListener(record, [...trace]);
756
- } catch {}
770
+ try {
771
+ const exec = executors.get(name);
772
+ if (exec) {
773
+ if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
774
+ const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
775
+ record.ok = res?.isError !== true && res?.details?.ok !== false;
776
+ notifyCall(record);
777
+ return res;
757
778
  }
758
- return res;
759
- }
760
779
 
761
- const native = natives[name];
762
- if (native) {
763
- const res = await native(args || {}, activeSignal);
764
- if (res?.details?.diff) record.diff = res.details.diff;
765
- if (callListener) {
766
- try {
767
- callListener(record, [...trace]);
768
- } catch {}
780
+ const native = natives[name];
781
+ if (native) {
782
+ const res = await native(args || {}, activeSignal);
783
+ if (res?.details?.diff) record.diff = res.details.diff;
784
+ record.ok = res?.isError !== true && res?.details?.ok !== false;
785
+ notifyCall(record);
786
+ return res;
769
787
  }
770
- return res;
771
- }
772
788
 
773
- throw new Error(
774
- `no executor for tool "${name}" (not captured via registerTool and no native adapter). Use nova.describe to inspect; ensure pi-supernova loads before other extensions, or call a core adapter: ${Object.keys(natives).join(", ")}`,
775
- );
789
+ throw new Error(
790
+ `no executor for tool "${name}" (not captured via registerTool and no native adapter). Use nova.describe to inspect; ensure pi-supernova loads before other extensions, or call a core adapter: ${Object.keys(natives).join(", ")}`,
791
+ );
792
+ } catch (error) {
793
+ record.ok = false;
794
+ notifyCall(record);
795
+ throw error;
796
+ }
776
797
  }
777
798
 
778
799
  async function call(name, args) {
package/index.js CHANGED
@@ -1,16 +1,4 @@
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
3
  import { buildCatalog, searchCatalog, describeTool, mergeNativeToolDefinitions } from "./catalog.js";
16
4
  import { loadConfig } from "./config.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
  }
@@ -36,8 +38,12 @@ Inside the program you get:
36
38
  nova.describe(name) — full parameter summary on demand
37
39
  nova.call(name, args) — invoke a host tool (or native adapter)
38
40
  nova.callMany([{name,args}]) — Auto parallel wave (serial if any mutating)
41
+ nova.snap(query, root?) — resolve a concept to a source location
42
+ nova.surface(path) — structural source outline
43
+ nova.has(name) — test host-tool availability
39
44
  parallel(thunks) / pipeline(items, ...stages)
40
45
 
46
+ Shorthand globals: read, write, edit, patch, exec, snap, surface.
41
47
  Prefer search→describe→call. Keep intermediates in the program; return a shaped value.
42
48
  Schemas are NOT dumped into the system prompt — discover them inside the runtime.`;
43
49
 
@@ -162,6 +168,7 @@ export default function piSupernova(pi) {
162
168
  timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs,
163
169
  };
164
170
 
171
+ const runStartedAt = performance.now();
165
172
  let outcome;
166
173
  try {
167
174
  outcome = await runGuestProgram({
@@ -171,7 +178,15 @@ export default function piSupernova(pi) {
171
178
  signal: runController.signal,
172
179
  onTimeout: abortRun,
173
180
  });
181
+ } catch (error) {
182
+ outcome = {
183
+ ok: false,
184
+ error: error instanceof Error ? error.message : String(error),
185
+ logs: [],
186
+ wallMs: Math.round(performance.now() - runStartedAt),
187
+ };
174
188
  } finally {
189
+ bridge.setCallListener(null);
175
190
  signal?.removeEventListener("abort", abortRun);
176
191
  }
177
192
 
package/omp-frame.js CHANGED
@@ -1,10 +1,8 @@
1
1
  /**
2
- * OMP-native tool chrome for supernova.
2
+ * OMP-style rounded tool chrome for supernova.
3
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).
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.
8
6
  */
9
7
 
10
8
  import { clampLine, measureWidth, wrapPlainToWidth } from "./render-measure.js";
@@ -20,32 +18,12 @@ const DEFAULT_BOX = {
20
18
  teeRight: "├",
21
19
  };
22
20
 
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
- }
21
+ export function hasHostFramedBlock() {
22
+ return false;
42
23
  }
43
24
 
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";
25
+ export async function ensureOmpChrome() {
26
+ // No-op: portable frame only. Kept so call sites stay stable.
49
27
  }
50
28
 
51
29
  function boxOf(theme) {
@@ -69,8 +47,6 @@ function borderPaint(theme, state, borderColor) {
69
47
  }
70
48
 
71
49
  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
50
  const resolvedIcon =
75
51
  iconOverride !== undefined
76
52
  ? undefined
@@ -79,21 +55,12 @@ function statusHeader(theme, { title, description, state, spinnerFrame, icon, ic
79
55
  : state === "error"
80
56
  ? "error"
81
57
  : 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
58
  const titleText = theme?.fg ? theme.fg("accent", title) : title;
95
59
  const descText = description ? (theme?.fg ? theme.fg("muted", description) : description) : "";
96
- const prefix = state === "error" ? (theme?.fg ? theme.fg("error", "✗ ") : "✗ ") : "";
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", "… ") : "… ";
97
64
  return descText ? `${prefix}${titleText}: ${descText}` : `${prefix}${titleText}`;
98
65
  }
99
66
 
@@ -135,11 +102,16 @@ function bgFnForState(theme, state) {
135
102
  return undefined;
136
103
  }
137
104
 
138
- /**
139
- * Portable rounded frame matching OMP output-block geometry.
140
- */
141
105
  export function renderPortableFrame(theme, { header, sections = [], state = "pending", borderColor, width }) {
142
- const w = Math.max(8, width | 0);
106
+ const w = Math.max(1, width | 0);
107
+ if (w < 8) {
108
+ const rawLines = [header];
109
+ for (const section of sections) {
110
+ if (section.label) rawLines.push(section.label);
111
+ rawLines.push(...(section.lines || []));
112
+ }
113
+ return rawLines.filter(Boolean).map((line) => clampLine(line, w));
114
+ }
143
115
  const box = boxOf(theme);
144
116
  const border = borderPaint(theme, state, borderColor);
145
117
  const bgFn = bgFnForState(theme, state);
@@ -161,7 +133,7 @@ export function renderPortableFrame(theme, { header, sections = [], state = "pen
161
133
  return padLine(`${border(left)}${trimmed}${border(h.repeat(fill))}${border(right)}`, w, bgFn);
162
134
  };
163
135
 
164
- const contentWidth = Math.max(1, w - 2 - 2); // borders + 1-col pad each side
136
+ const contentWidth = Math.max(1, w - 2 - 2);
165
137
  const lines = [];
166
138
  lines.push(paintBar(box.topLeft, box.topRight, header));
167
139
 
@@ -188,7 +160,7 @@ export function createPortableFramedComponent(theme, build) {
188
160
  let cacheWidth;
189
161
  let cacheKey;
190
162
  let cacheLines;
191
- const comp = {
163
+ return {
192
164
  render(width) {
193
165
  const opts = build(width);
194
166
  const key = `${opts.state}|${opts.borderColor}|${opts.header}|${(opts.sections || [])
@@ -206,17 +178,9 @@ export function createPortableFramedComponent(theme, build) {
206
178
  cacheWidth = undefined;
207
179
  },
208
180
  };
209
- if (hostMarkFramed) return hostMarkFramed(comp);
210
- return comp;
211
181
  }
212
182
 
213
- /**
214
- * Build a framed nova card. Uses host framedBlock when available (OMP).
215
- */
216
183
  export function novaFramedBlock(theme, build) {
217
- if (hostFramedBlock) {
218
- return hostFramedBlock(theme, build);
219
- }
220
184
  return createPortableFramedComponent(theme, build);
221
185
  }
222
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
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",
package/render-measure.js CHANGED
@@ -27,6 +27,10 @@ export function measureWidth(text) {
27
27
 
28
28
  function codePointWidth(cp) {
29
29
  if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) return 0;
30
+ if (cp === 0xfe0f) return 1; // Emoji presentation can widen an otherwise narrow symbol.
31
+ if (cp === 0x200d || (cp >= 0x0300 && cp <= 0x036f) || (cp >= 0x1ab0 && cp <= 0x1aff) ||
32
+ (cp >= 0x1dc0 && cp <= 0x1dff) || (cp >= 0x20d0 && cp <= 0x20ff) ||
33
+ (cp >= 0xfe00 && cp <= 0xfe0e) || (cp >= 0xfe20 && cp <= 0xfe2f)) return 0;
30
34
  // Fullwidth / wide ranges (CJK, Hangul, emoji blocks we actually emit).
31
35
  if (cp >= 0x1100 && cp <= 0x115f) return 2;
32
36
  if (cp === 0x2329 || cp === 0x232a) return 2;
@@ -37,8 +41,7 @@ function codePointWidth(cp) {
37
41
  if (cp >= 0xfe30 && cp <= 0xfe6f) return 2;
38
42
  if (cp >= 0xff00 && cp <= 0xff60) return 2;
39
43
  if (cp >= 0xffe0 && cp <= 0xffe6) return 2;
40
- if (cp >= 0x1f300 && cp <= 0x1f64f) return 2;
41
- if (cp >= 0x1f900 && cp <= 0x1f9ff) return 2;
44
+ if (cp >= 0x1f000 && cp <= 0x1faff) return 2;
42
45
  if (cp >= 0x20000 && cp <= 0x3fffd) return 2;
43
46
  // Ambiguous emoji/symbols pi-tui treats as wide (⚡ U+26A1 was the 92>91 footgun).
44
47
  if (cp === 0x26a1 || cp === 0x2b50 || cp === 0x2728) return 2;
@@ -107,18 +110,21 @@ export function wrapPlainToWidth(plain, width) {
107
110
  let visible = 0;
108
111
  let lastBreak = -1;
109
112
  while (end < text.length) {
110
- const ch = text[end];
113
+ const cp = text.codePointAt(end);
114
+ const ch = String.fromCodePoint(cp);
111
115
  const cw = measureWidth(ch);
112
116
  if (visible + cw > w) break;
113
117
  visible += cw;
114
- if (ch === "/" || ch === " ") lastBreak = end + 1;
115
- end++;
118
+ end += ch.length;
119
+ if (ch === "/" || ch === " ") lastBreak = end;
116
120
  }
117
121
  if (end === i) {
118
- end = i + 1;
119
- } else if (end < text.length && lastBreak > i + Math.floor(w * 0.35)) {
120
- end = lastBreak;
122
+ const ch = String.fromCodePoint(text.codePointAt(i));
123
+ lines.push(hardTruncate(ch, w));
124
+ i += ch.length;
125
+ continue;
121
126
  }
127
+ if (end < text.length && lastBreak > i + Math.floor(w * 0.35)) end = lastBreak;
122
128
  lines.push(text.slice(i, end));
123
129
  i = end;
124
130
  }
package/render.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  wrapPlainToWidth,
20
20
  fitPath,
21
21
  } from "./render-measure.js";
22
- import { ensureOmpChrome, hasHostFramedBlock, novaFramedBlock, novaStatusLine } from "./omp-frame.js";
22
+ import { novaFramedBlock, novaStatusLine } from "./omp-frame.js";
23
23
 
24
24
  export { measureWidth, hardTruncate, clampLine, wrapPlainToWidth, fitPath };
25
25
 
@@ -185,18 +185,20 @@ export function extractOperationsFromCode(code) {
185
185
 
186
186
  const namedCalls = [
187
187
  { regex: /(?:^|[^\w$.])(?:nova\.)?read\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "read", wrap: (p) => p },
188
- { regex: /(?:^|[^\w$.])(?:nova\.)?write\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
188
+ { regex: /(?:^|[^\w$.])(?:nova\.)?write\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "write", wrap: (p) => p },
189
189
  { regex: /(?:^|[^\w$.])(?:nova\.)?edit\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
190
- { regex: /(?:^|[^\w$.])(?:nova\.)?patch\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
190
+ { regex: /(?:^|[^\w$.])(?:nova\.)?patch\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "patch", wrap: (p) => p },
191
191
  {
192
192
  regex: /(?:^|[^\w$.])(?:nova\.)?bash\s*\(\s*["'`]([^"'`]+)["'`]/gm,
193
193
  tool: "bash",
194
194
  wrap: (c) => (c.length > 32 ? c.slice(0, 29) + "…" : c),
195
195
  },
196
- { regex: /(?:^|[^\w$.])(?:nova\.)?exec\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "bash", wrap: (c) => c },
197
- { regex: /(?:nova\.)?search\s*\(\s*["'`]([^"'`]+)["'`]/g, tool: "read", wrap: (q) => `"${q}"` },
198
- { regex: /(?:nova\.)?surface\s*\(\s*["'`]([^"'`]+)["'`]/g, tool: "read", wrap: (p) => p },
199
- { regex: /(?:nova\.)?snap\s*\(\s*["'`]([^"'`]+)["'`]/g, tool: "read", wrap: (q) => `"${q}"` },
196
+ { regex: /(?:^|[^\w$.])(?:nova\.)?exec\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "exec", wrap: (c) => c },
197
+ { regex: /(?:^|[^\w$.])(?:nova\.)?search\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "search", wrap: (q) => `"${q}"` },
198
+ { regex: /(?:^|[^\w$.])nova\.describe\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "describe", wrap: (name) => name },
199
+ { regex: /(?:^|[^\w$.])nova\.has\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "has", wrap: (name) => name },
200
+ { regex: /(?:^|[^\w$.])(?:nova\.)?surface\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "surface", wrap: (p) => p },
201
+ { regex: /(?:^|[^\w$.])(?:nova\.)?snap\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "snap", wrap: (q) => `"${q}"` },
200
202
  ];
201
203
  for (const item of namedCalls) {
202
204
  while ((match = item.regex.exec(trimmed)) !== null) {
@@ -238,7 +240,7 @@ export function renderDiffBox(diff, theme, width = 60) {
238
240
  const divWidth = Math.min(w, Math.max(20, Math.min(70, w)));
239
241
  const divider = theme.fg("borderMuted", "─".repeat(divWidth));
240
242
 
241
- const maxShown = 8;
243
+ const maxShown = 6;
242
244
  const shownLines = diff.lines.slice(0, maxShown);
243
245
  const body = [];
244
246
 
@@ -248,17 +250,17 @@ export function renderDiffBox(diff, theme, width = 60) {
248
250
  if (item.type === "remove") {
249
251
  const gut = theme.fg("toolDiffRemoved", `-${num}`.padStart(5));
250
252
  const sep = theme.fg("borderMuted", " │ ");
251
- const txt = theme.fg("toolDiffRemoved", `- ${item.text}`);
253
+ const txt = theme.fg("toolDiffRemoved", `- ${cleanInlineText(item.text)}`);
252
254
  row = `${gut}${sep}${txt}`;
253
255
  } else if (item.type === "add") {
254
256
  const gut = theme.fg("toolDiffAdded", `+${num}`.padStart(5));
255
257
  const sep = theme.fg("borderMuted", " │ ");
256
- const txt = theme.fg("toolDiffAdded", `+ ${item.text}`);
258
+ const txt = theme.fg("toolDiffAdded", `+ ${cleanInlineText(item.text)}`);
257
259
  row = `${gut}${sep}${txt}`;
258
260
  } else {
259
261
  const gut = theme.fg("dim", ` ${num}`.padStart(5));
260
262
  const sep = theme.fg("borderMuted", " │ ");
261
- const txt = theme.fg("toolDiffContext", ` ${item.text}`);
263
+ const txt = theme.fg("toolDiffContext", ` ${cleanInlineText(item.text)}`);
262
264
  row = `${gut}${sep}${txt}`;
263
265
  }
264
266
  body.push(row);
@@ -272,16 +274,26 @@ export function renderDiffBox(diff, theme, width = 60) {
272
274
  return `${header}\n${divider}\n${body.join("\n")}\n${divider}`;
273
275
  }
274
276
 
275
- function displayOperation(tool, target) {
276
- if (["write", "edit", "apply_patch", "patch"].includes(tool)) return { tool: "edit", target };
277
- if (["bash", "exec"].includes(tool)) return { tool: "bash", target };
278
- if (["read", "surface", "snap", "search", "grep", "find", "ls"].includes(tool))
279
- return { tool: "read", target };
280
- return null;
277
+ 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, "");
282
+ }
283
+
284
+ function cleanInlineText(value) {
285
+ return cleanBlockText(value).replace(/\s*\n\s*/g, " ").trim();
286
+ }
287
+
288
+ function displayOperation(tool, target, diff, ok) {
289
+ const rawName = cleanInlineText(tool);
290
+ if (!rawName) return null;
291
+ const normalized = rawName === "apply_patch" ? "patch" : rawName;
292
+ return { tool: normalized, target, diff, ok };
281
293
  }
282
294
 
283
295
  function formatOpTarget(raw, tool) {
284
- const text = String(raw ?? "");
296
+ const text = cleanInlineText(raw);
285
297
  if (!text) return "";
286
298
  if (tool === "bash") {
287
299
  // Keep commands readable; wrap handles the rest at render time.
@@ -348,7 +360,6 @@ function detectResultHost(options, ctxOrArgs) {
348
360
  ) {
349
361
  return "omp";
350
362
  }
351
- if (hasHostFramedBlock()) return "omp";
352
363
  return "pi";
353
364
  }
354
365
 
@@ -373,6 +384,7 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
373
384
  isPartial: !!opts.isPartial,
374
385
  theme: themeOrCtx,
375
386
  context,
387
+ args: ctxOrArgs?.code ? ctxOrArgs : context.args,
376
388
  host: detectResultHost(options, ctxOrArgs),
377
389
  options: opts,
378
390
  };
@@ -387,6 +399,7 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
387
399
  isPartial: !!context.isPartial,
388
400
  theme: options,
389
401
  context,
402
+ args: context.args,
390
403
  host: "pi",
391
404
  options: {},
392
405
  };
@@ -394,20 +407,33 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
394
407
  throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
395
408
  }
396
409
 
410
+ function operationTarget(item) {
411
+ const args = item?.args || {};
412
+ const name = item?.name;
413
+ if (name === "snap") {
414
+ const query = args.query ? `"${args.query}"` : "";
415
+ return args.path ? `${query} → ${args.path}` : query;
416
+ }
417
+ if (name === "search") return args.query ? `"${args.query}"` : "";
418
+ if (args.path) return String(args.path);
419
+ if (item?.diff?.path) return String(item.diff.path);
420
+ if (args.target && isString(args.target)) return args.target;
421
+ if (args.command) return String(args.command);
422
+ if (args.pattern) return String(args.pattern);
423
+ if (args.query) return String(args.query);
424
+ return "";
425
+ }
426
+
427
+ function operationsFromTrace(trace) {
428
+ if (!Array.isArray(trace)) return [];
429
+ return trace
430
+ .map((item) => displayOperation(item?.name || "tool", operationTarget(item), item?.diff, item?.ok))
431
+ .filter(Boolean);
432
+ }
433
+
397
434
  function collectCallOps(args, context) {
398
- const stateTrace = context?.state?.trace;
399
- if (Array.isArray(stateTrace) && stateTrace.length > 0) {
400
- return stateTrace
401
- .map((item) => {
402
- const tool = item?.name || "tool";
403
- let target = "";
404
- if (item?.args?.path) target = String(item.args.path);
405
- else if (item?.args?.command) target = String(item.args.command);
406
- else if (item?.args?.pattern) target = String(item.args.pattern);
407
- return displayOperation(tool, target);
408
- })
409
- .filter(Boolean);
410
- }
435
+ const traced = operationsFromTrace(context?.state?.trace);
436
+ if (traced.length > 0) return traced;
411
437
  return extractOperationsFromCode(args?.code)
412
438
  .map((op) => displayOperation(op.tool, op.target))
413
439
  .filter(Boolean);
@@ -438,14 +464,14 @@ function tickCallTimer(context) {
438
464
  function formatOpBodyLine(theme, op) {
439
465
  const icon = ACTION_ICONS[op.tool] || "✦ ";
440
466
  const bullet = theme.fg("accent", icon);
441
- const toolName = theme.fg("syntaxFunction", op.tool.padEnd(4, " "));
467
+ const toolName = theme.fg("syntaxFunction", op.tool.padEnd(7, " "));
442
468
  const rawTarget = formatOpTarget(op.target, op.tool);
443
469
  const target = rawTarget ? " " + theme.fg("muted", rawTarget) : "";
444
470
  return `${bullet}${toolName}${target}`;
445
471
  }
446
472
 
447
473
  function shouldUseOmpFrame(host) {
448
- return host === "omp" || hasHostFramedBlock();
474
+ return host === "omp";
449
475
  }
450
476
 
451
477
  /**
@@ -453,10 +479,7 @@ function shouldUseOmpFrame(host) {
453
479
  * Pending call has no hourglass on the head row (same as native Write/Edit).
454
480
  */
455
481
  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} ` : " · ");
482
+ const opSummary = ops.length === 0 ? "composing" : `${ops.length} call${ops.length === 1 ? "" : "s"}`;
460
483
  const description = timeStr ? `${opSummary} · ${timeStr}` : opSummary;
461
484
  // No pending icon on the framed head row — matches native Write/Edit.
462
485
  const header = novaStatusLine(theme, {
@@ -467,9 +490,9 @@ function renderOmpCallCard(theme, { ops, timeStr, expanded, code }) {
467
490
  const bodyLines = ops.map((op) => formatOpBodyLine(theme, op));
468
491
  if (expanded && code) {
469
492
  bodyLines.push(theme.fg("dim", "── source ──"));
470
- for (const line of String(code).trim().split("\n")) {
471
- bodyLines.push(theme.fg("toolOutput", line));
472
- }
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`));
473
496
  }
474
497
  return {
475
498
  header,
@@ -481,18 +504,20 @@ function renderOmpCallCard(theme, { ops, timeStr, expanded, code }) {
481
504
  });
482
505
  }
483
506
 
484
- function renderOmpResultCard(theme, { isErr, payload, expanded, bodyText, isPartial, spinnerFrame }) {
507
+ function renderOmpResultCard(theme, { isErr, payload, expanded, bodyText, isPartial, spinnerFrame, opCount = 0 }) {
485
508
  if (isErr) {
486
- const errLines = [];
487
- if (payload?.error) errLines.push(theme.fg("error", String(payload.error)));
509
+ const errLines = String(bodyText || "").trim() ? String(bodyText).split("\n") : [];
510
+ if (payload?.error) errLines.push(theme.fg("error", cleanBlockText(payload.error)));
488
511
  if (expanded && payload?.logs?.length) {
489
512
  errLines.push(theme.fg("dim", "── logs ──"));
490
- for (const log of payload.logs) errLines.push(theme.fg("dim", String(log)));
513
+ for (const log of payload.logs.slice(0, 24)) errLines.push(theme.fg("dim", cleanBlockText(log)));
491
514
  }
515
+ const wall = payload?.wallMs != null ? `${payload.wallMs}ms` : "";
516
+ const calls = opCount > 0 ? `${opCount} call${opCount === 1 ? "" : "s"}` : "";
492
517
  const header = novaStatusLine(theme, {
493
518
  icon: "error",
494
519
  title: "nova",
495
- description: payload?.error ? String(payload.error).split("\n")[0] : "error",
520
+ description: [calls, "failed", wall].filter(Boolean).join(" · "),
496
521
  });
497
522
  return novaFramedBlock(theme, (width) => ({
498
523
  header,
@@ -504,11 +529,13 @@ function renderOmpResultCard(theme, { isErr, payload, expanded, bodyText, isPart
504
529
  }
505
530
 
506
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(" · ");
507
534
  const header = novaStatusLine(theme, {
508
535
  icon: isPartial ? "running" : undefined,
509
536
  spinnerFrame,
510
537
  title: "nova",
511
- description: wall || undefined,
538
+ description: description || undefined,
512
539
  });
513
540
  const bodyLines = String(bodyText || "").split("\n");
514
541
  while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
@@ -529,7 +556,6 @@ export function renderSupernovaCall(a, b, c) {
529
556
  const timeStr = formatElapsed(context?.state);
530
557
 
531
558
  if (shouldUseOmpFrame(host)) {
532
- void ensureOmpChrome();
533
559
  return renderOmpCallCard(theme, {
534
560
  ops,
535
561
  timeStr,
@@ -558,7 +584,7 @@ export function renderSupernovaCall(a, b, c) {
558
584
 
559
585
  if (context?.expanded && args?.code) {
560
586
  out += "\n" + theme.fg("dim", "── source ──");
561
- out += "\n" + theme.fg("toolOutput", String(args.code).trim());
587
+ out += "\n" + theme.fg("toolOutput", cleanBlockText(args.code).trim());
562
588
  }
563
589
 
564
590
  if (context?.isError) comp.setTone("error");
@@ -569,51 +595,75 @@ export function renderSupernovaCall(a, b, c) {
569
595
  return comp;
570
596
  }
571
597
 
572
- function buildResultBody(theme, { payload, context, expanded }) {
598
+ function formatDiffStats(theme, diff) {
599
+ if (!diff || !isObject(diff)) return "";
600
+ return " " + theme.fg("toolDiffAdded", `+${diff.added || 0}`) + theme.fg("dim", "/") + theme.fg("toolDiffRemoved", `-${diff.removed || 0}`);
601
+ }
602
+
603
+ function formatResultOperation(theme, op, isPartial, isError) {
604
+ const marker = op.ok === false
605
+ ? theme.fg("error", "× ")
606
+ : isPartial && op.ok !== true
607
+ ? theme.fg("dim", "· ")
608
+ : isError && op.ok !== true
609
+ ? theme.fg("error", "× ")
610
+ : theme.fg("success", "✓ ");
611
+ const tool = theme.fg("syntaxFunction", op.tool.padEnd(7, " "));
612
+ const targetText = formatOpTarget(op.target, op.tool);
613
+ const target = targetText ? theme.fg("muted", targetText) : theme.fg("dim", "done");
614
+ return `${marker}${tool} ${target}${formatDiffStats(theme, op.diff)}`;
615
+ }
616
+
617
+ function boundedResult(value) {
618
+ let text;
619
+ try {
620
+ text = isString(value) ? value : JSON.stringify(value, null, 2);
621
+ } catch {
622
+ text = String(value);
623
+ }
624
+ const lines = cleanBlockText(text).split("\n");
625
+ const clipped = lines.slice(0, 24).join("\n");
626
+ const suffix = lines.length > 24 ? `\n… ${lines.length - 24} more lines` : "";
627
+ return (clipped + suffix).slice(0, 4000);
628
+ }
629
+
630
+ function buildResultBody(theme, { payload, context, args, expanded, isPartial, isError }) {
573
631
  let out = "";
574
632
  const trace = payload?.trace || context?.state?.trace || [];
575
- const diffs = trace.filter((t) => t?.diff && isObject(t.diff)).map((t) => t.diff);
633
+ const tracedOps = operationsFromTrace(trace);
634
+ const ops = tracedOps.length > 0
635
+ ? tracedOps
636
+ : extractOperationsFromCode(args?.code).map((op) => displayOperation(op.tool, op.target)).filter(Boolean);
637
+ const maxOps = expanded ? 12 : 8;
638
+ for (const op of ops.slice(0, maxOps)) {
639
+ out += (out ? "\n" : "") + formatResultOperation(theme, op, isPartial, isError);
640
+ }
641
+ if (ops.length > maxOps) out += `\n${theme.fg("dim", `… ${ops.length - maxOps} more calls`)}`;
576
642
 
577
- if (diffs.length > 0) {
578
- const maxDiffsShown = expanded ? diffs.length : 2;
579
- const shownDiffs = diffs.slice(0, maxDiffsShown);
580
- for (const diff of shownDiffs) {
643
+ 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)) {
581
648
  const box = renderDiffBox(diff, theme, 120);
582
- if (box) out += (out ? "\n\n" : "") + box;
583
- }
584
- if (!expanded && diffs.length > maxDiffsShown) {
585
- const remaining = diffs.length - maxDiffsShown;
586
- out +=
587
- "\n\n" +
588
- theme.fg(
589
- "dim",
590
- `… ${remaining} more file edit${remaining === 1 ? "" : "s"} (press Enter to expand)`,
591
- );
649
+ if (box) out += "\n" + box;
592
650
  }
593
- }
651
+ if (diffs.length > maxDiffs) out += `\n${theme.fg("dim", `… ${diffs.length - maxDiffs} more changed files`)}`;
594
652
 
595
- if (expanded) {
596
- const resVal = payload?.result;
597
- if (resVal !== undefined) {
598
- let formatted;
599
- try {
600
- formatted = isString(resVal) ? resVal : JSON.stringify(resVal, null, 2);
601
- } catch {
602
- formatted = String(resVal);
603
- }
653
+ if (payload?.result !== undefined) {
604
654
  out += (out ? "\n" : "") + theme.fg("dim", "── result ──");
605
- out += "\n" + theme.fg("toolOutput", formatted);
655
+ out += "\n" + theme.fg("toolOutput", boundedResult(payload.result));
606
656
  }
607
- if (payload?.logs?.length) {
657
+ if (!isError && payload?.logs?.length) {
608
658
  out += (out ? "\n" : "") + theme.fg("dim", "── logs ──");
609
- for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
659
+ for (const log of payload.logs.slice(0, 24)) out += `\n ${theme.fg("dim", cleanBlockText(log))}`;
610
660
  }
611
661
  }
612
- return out;
662
+ return { body: out, opCount: ops.length };
613
663
  }
614
664
 
615
665
  export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextArg) {
616
- const { result, expanded, isPartial, theme, context, options, host } = normalizeResultRenderArgs(
666
+ const { result, expanded, isPartial, theme, context, args, options, host } = normalizeResultRenderArgs(
617
667
  resultArg,
618
668
  optionsArg,
619
669
  themeArg,
@@ -639,52 +689,17 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
639
689
  }
640
690
 
641
691
  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
- }
692
+ const view = buildResultBody(theme, { payload, context, args, expanded, isPartial, isError: isErr });
693
+
694
+ if (shouldUseOmpFrame(host)) {
681
695
  return renderOmpResultCard(theme, {
682
- isErr: false,
696
+ isErr,
683
697
  payload,
684
698
  expanded,
685
- bodyText: out,
686
- isPartial: false,
699
+ bodyText: view.body,
700
+ isPartial: isPartial && !isErr,
687
701
  spinnerFrame: options?.spinnerFrame,
702
+ opCount: view.opCount,
688
703
  });
689
704
  }
690
705
 
@@ -692,32 +707,22 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
692
707
  if (options) options.lastComponent = comp;
693
708
  else if (context) context.lastComponent = comp;
694
709
 
695
- if (isPartial) {
696
- comp.setText("");
697
- return comp;
698
- }
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 ")}`;
699
715
 
700
716
  if (isErr) {
701
- let out = theme.fg("error", "✗ error");
702
- if (payload?.error) {
703
- out += `\n ${theme.fg("error", String(payload.error))}`;
704
- }
717
+ out += `\n ${theme.fg("error", payload?.error ? cleanBlockText(payload.error) : "error")}`;
705
718
  if (expanded && payload?.logs?.length) {
706
719
  out += `\n${theme.fg("dim", "── logs ──")}`;
707
- for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
720
+ for (const log of payload.logs.slice(0, 24)) out += `\n ${theme.fg("dim", cleanBlockText(log))}`;
708
721
  }
709
722
  comp.setTone("error");
710
- comp.setFraming(true);
711
- comp.setText(out);
712
- return comp;
713
- }
714
-
715
- const out = buildResultBody(theme, { payload, context, expanded });
716
- if (!out.trim()) {
717
- comp.setText("");
718
- return comp;
723
+ } else {
724
+ comp.setTone(isPartial ? "pending" : "success");
719
725
  }
720
- comp.setTone("success");
721
726
  comp.setFraming(true);
722
727
  comp.setText(out);
723
728
  return comp;
package/runtime.js CHANGED
@@ -59,23 +59,32 @@ export async function runGuestProgram(options) {
59
59
 
60
60
  let compiled = compiledCache.get(body);
61
61
  if (!compiled) {
62
- compiled = new AsyncFunction(
63
- "nova",
64
- "tools",
65
- "console",
66
- "parallel",
67
- "pipeline",
68
- "read",
69
- "write",
70
- "edit",
71
- "patch",
72
- "surface",
73
- "snap",
74
- "bash",
75
- "exec",
76
- "speculate",
77
- body,
78
- );
62
+ try {
63
+ compiled = new AsyncFunction(
64
+ "nova",
65
+ "tools",
66
+ "console",
67
+ "parallel",
68
+ "pipeline",
69
+ "read",
70
+ "write",
71
+ "edit",
72
+ "patch",
73
+ "surface",
74
+ "snap",
75
+ "bash",
76
+ "exec",
77
+ "speculate",
78
+ body,
79
+ );
80
+ } catch (err) {
81
+ return {
82
+ ok: false,
83
+ error: err instanceof Error ? err.message : String(err),
84
+ logs,
85
+ wallMs: Math.round(performance.now() - started),
86
+ };
87
+ }
79
88
  if (compiledCache.size >= COMPILED_CACHE_MAX) {
80
89
  const first = compiledCache.keys().next().value;
81
90
  if (first !== undefined) compiledCache.delete(first);
@@ -108,6 +117,16 @@ export async function runGuestProgram(options) {
108
117
  return res;
109
118
  };
110
119
 
120
+ const unwrapJsonValue = (res) => {
121
+ const value = unwrapValue(res);
122
+ if (!isString(value)) return value;
123
+ try {
124
+ return JSON.parse(value);
125
+ } catch {
126
+ return value;
127
+ }
128
+ };
129
+
111
130
  const guestRead = async (p, off, lim) => {
112
131
  if (Array.isArray(p)) {
113
132
  return await Promise.all(p.map((item) => guestRead(item, off, lim)));
@@ -123,11 +142,11 @@ export async function runGuestProgram(options) {
123
142
  const guestPatch = async (p, d) => unwrapValue(await nova.call("apply_patch", { path: p, patch: d }));
124
143
  const guestSurface = async (p) => {
125
144
  const res = await (isFunction(nova.surface) ? nova.surface(p) : nova.call("surface", { path: p }));
126
- return unwrapValue(res);
145
+ return unwrapJsonValue(res);
127
146
  };
128
147
  const guestSnap = async (q, p) => {
129
148
  const res = await (isFunction(nova.snap) ? nova.snap(q, p) : nova.call("snap", { query: q, path: p }));
130
- return unwrapValue(res);
149
+ return unwrapJsonValue(res);
131
150
  };
132
151
  const guestBash = async (cmd, opts) => {
133
152
  const res = await nova.call("bash", { command: cmd, ...opts });
package/snap.js CHANGED
@@ -94,21 +94,46 @@ function scoreContentDefinitions(content, tokens) {
94
94
  return { totalScore: score, bestLine, bestLineScore };
95
95
  }
96
96
 
97
- export async function executeSnap({ query, searchDir, vfs, runCommand }) {
97
+ function relativeHasSegment(relativePath, segmentName) {
98
+ return relativePath.split(path.sep).includes(segmentName);
99
+ }
100
+
101
+ function relativeHasHiddenSegment(relativePath) {
102
+ return relativePath.split(path.sep).some((segment) => segment.startsWith(".") && segment.length > 1);
103
+ }
104
+
105
+ export async function executeSnap({ query, searchDir, includeHidden = false, vfs, runCommand, pendingPaths = [] }) {
98
106
  const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
99
107
  if (tokens.length === 0) {
100
108
  throw new Error("snap requires at least one searchable concept keyword");
101
109
  }
102
110
 
103
111
  const dir = searchDir || process.cwd();
112
+ if (path.resolve(dir).split(path.sep).includes(".git")) {
113
+ throw new Error("snap cannot search Git metadata");
114
+ }
104
115
  let fileList = [];
105
116
  try {
106
- const res = await runCommand(["rg", "--files", dir], { timeoutMs: 15_000 });
117
+ const rgArgs = ["rg", "--files"];
118
+ if (includeHidden) rgArgs.push("--hidden");
119
+ rgArgs.push("-g", "!.git/**", "-g", "!**/.git/**", dir);
120
+ const res = await runCommand(rgArgs, { timeoutMs: 15_000 });
107
121
  fileList = res.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
108
122
  } catch {
109
123
  fileList = [];
110
124
  }
111
125
 
126
+ const seenPaths = new Set(fileList.map((filePath) => path.resolve(filePath)));
127
+ for (const pendingPath of pendingPaths) {
128
+ const absolutePath = path.resolve(pendingPath);
129
+ const relativePath = path.relative(path.resolve(dir), absolutePath);
130
+ const escapesDir = relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
131
+ const hiddenRelativePath = relativeHasHiddenSegment(relativePath);
132
+ if (escapesDir || relativeHasSegment(relativePath, ".git") || (!includeHidden && hiddenRelativePath) || seenPaths.has(absolutePath)) continue;
133
+ seenPaths.add(absolutePath);
134
+ fileList.push(absolutePath);
135
+ }
136
+
112
137
  if (fileList.length === 0) {
113
138
  throw new Error(`no files found to search in ${dir}`);
114
139
  }
@@ -125,7 +150,8 @@ export async function executeSnap({ query, searchDir, vfs, runCommand }) {
125
150
 
126
151
  if (candidates.length < 5) {
127
152
  try {
128
- const grepArgs = ["-l", "--max-count=1"];
153
+ const grepArgs = ["-l", "--max-count=1", "-g", "!.git/**", "-g", "!**/.git/**"];
154
+ if (includeHidden) grepArgs.push("--hidden");
129
155
  if (!wantsTest) {
130
156
  grepArgs.push("-g", "!test/**", "-g", "!tests/**", "-g", "!*.test.*", "-g", "!*.spec.*");
131
157
  }