pi-supernova 0.0.4 → 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,19 @@
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.
8
18
 
9
19
  ## [0.0.4] - 2026-09-03
10
20
 
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
@@ -38,8 +38,12 @@ Inside the program you get:
38
38
  nova.describe(name) — full parameter summary on demand
39
39
  nova.call(name, args) — invoke a host tool (or native adapter)
40
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
41
44
  parallel(thunks) / pipeline(items, ...stages)
42
45
 
46
+ Shorthand globals: read, write, edit, patch, exec, snap, surface.
43
47
  Prefer search→describe→call. Keep intermediates in the program; return a shaped value.
44
48
  Schemas are NOT dumped into the system prompt — discover them inside the runtime.`;
45
49
 
@@ -164,6 +168,7 @@ export default function piSupernova(pi) {
164
168
  timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs,
165
169
  };
166
170
 
171
+ const runStartedAt = performance.now();
167
172
  let outcome;
168
173
  try {
169
174
  outcome = await runGuestProgram({
@@ -173,7 +178,15 @@ export default function piSupernova(pi) {
173
178
  signal: runController.signal,
174
179
  onTimeout: abortRun,
175
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
+ };
176
188
  } finally {
189
+ bridge.setCallListener(null);
177
190
  signal?.removeEventListener("abort", abortRun);
178
191
  }
179
192
 
package/omp-frame.js CHANGED
@@ -103,7 +103,15 @@ function bgFnForState(theme, state) {
103
103
  }
104
104
 
105
105
  export function renderPortableFrame(theme, { header, sections = [], state = "pending", borderColor, width }) {
106
- 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
+ }
107
115
  const box = boxOf(theme);
108
116
  const border = borderPaint(theme, state, borderColor);
109
117
  const bgFn = bgFnForState(theme, state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.0.4",
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
@@ -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.
@@ -372,6 +384,7 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
372
384
  isPartial: !!opts.isPartial,
373
385
  theme: themeOrCtx,
374
386
  context,
387
+ args: ctxOrArgs?.code ? ctxOrArgs : context.args,
375
388
  host: detectResultHost(options, ctxOrArgs),
376
389
  options: opts,
377
390
  };
@@ -386,6 +399,7 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
386
399
  isPartial: !!context.isPartial,
387
400
  theme: options,
388
401
  context,
402
+ args: context.args,
389
403
  host: "pi",
390
404
  options: {},
391
405
  };
@@ -393,20 +407,33 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
393
407
  throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
394
408
  }
395
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
+
396
434
  function collectCallOps(args, context) {
397
- const stateTrace = context?.state?.trace;
398
- if (Array.isArray(stateTrace) && stateTrace.length > 0) {
399
- return stateTrace
400
- .map((item) => {
401
- const tool = item?.name || "tool";
402
- let target = "";
403
- if (item?.args?.path) target = String(item.args.path);
404
- else if (item?.args?.command) target = String(item.args.command);
405
- else if (item?.args?.pattern) target = String(item.args.pattern);
406
- return displayOperation(tool, target);
407
- })
408
- .filter(Boolean);
409
- }
435
+ const traced = operationsFromTrace(context?.state?.trace);
436
+ if (traced.length > 0) return traced;
410
437
  return extractOperationsFromCode(args?.code)
411
438
  .map((op) => displayOperation(op.tool, op.target))
412
439
  .filter(Boolean);
@@ -437,7 +464,7 @@ function tickCallTimer(context) {
437
464
  function formatOpBodyLine(theme, op) {
438
465
  const icon = ACTION_ICONS[op.tool] || "✦ ";
439
466
  const bullet = theme.fg("accent", icon);
440
- const toolName = theme.fg("syntaxFunction", op.tool.padEnd(4, " "));
467
+ const toolName = theme.fg("syntaxFunction", op.tool.padEnd(7, " "));
441
468
  const rawTarget = formatOpTarget(op.target, op.tool);
442
469
  const target = rawTarget ? " " + theme.fg("muted", rawTarget) : "";
443
470
  return `${bullet}${toolName}${target}`;
@@ -452,10 +479,7 @@ function shouldUseOmpFrame(host) {
452
479
  * Pending call has no hourglass on the head row (same as native Write/Edit).
453
480
  */
454
481
  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} ` : " · ");
482
+ const opSummary = ops.length === 0 ? "composing" : `${ops.length} call${ops.length === 1 ? "" : "s"}`;
459
483
  const description = timeStr ? `${opSummary} · ${timeStr}` : opSummary;
460
484
  // No pending icon on the framed head row — matches native Write/Edit.
461
485
  const header = novaStatusLine(theme, {
@@ -466,9 +490,9 @@ function renderOmpCallCard(theme, { ops, timeStr, expanded, code }) {
466
490
  const bodyLines = ops.map((op) => formatOpBodyLine(theme, op));
467
491
  if (expanded && code) {
468
492
  bodyLines.push(theme.fg("dim", "── source ──"));
469
- for (const line of String(code).trim().split("\n")) {
470
- bodyLines.push(theme.fg("toolOutput", line));
471
- }
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`));
472
496
  }
473
497
  return {
474
498
  header,
@@ -480,18 +504,20 @@ function renderOmpCallCard(theme, { ops, timeStr, expanded, code }) {
480
504
  });
481
505
  }
482
506
 
483
- function renderOmpResultCard(theme, { isErr, payload, expanded, bodyText, isPartial, spinnerFrame }) {
507
+ function renderOmpResultCard(theme, { isErr, payload, expanded, bodyText, isPartial, spinnerFrame, opCount = 0 }) {
484
508
  if (isErr) {
485
- const errLines = [];
486
- 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)));
487
511
  if (expanded && payload?.logs?.length) {
488
512
  errLines.push(theme.fg("dim", "── logs ──"));
489
- 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)));
490
514
  }
515
+ const wall = payload?.wallMs != null ? `${payload.wallMs}ms` : "";
516
+ const calls = opCount > 0 ? `${opCount} call${opCount === 1 ? "" : "s"}` : "";
491
517
  const header = novaStatusLine(theme, {
492
518
  icon: "error",
493
519
  title: "nova",
494
- description: payload?.error ? String(payload.error).split("\n")[0] : "error",
520
+ description: [calls, "failed", wall].filter(Boolean).join(" · "),
495
521
  });
496
522
  return novaFramedBlock(theme, (width) => ({
497
523
  header,
@@ -503,11 +529,13 @@ function renderOmpResultCard(theme, { isErr, payload, expanded, bodyText, isPart
503
529
  }
504
530
 
505
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(" · ");
506
534
  const header = novaStatusLine(theme, {
507
535
  icon: isPartial ? "running" : undefined,
508
536
  spinnerFrame,
509
537
  title: "nova",
510
- description: wall || undefined,
538
+ description: description || undefined,
511
539
  });
512
540
  const bodyLines = String(bodyText || "").split("\n");
513
541
  while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
@@ -556,7 +584,7 @@ export function renderSupernovaCall(a, b, c) {
556
584
 
557
585
  if (context?.expanded && args?.code) {
558
586
  out += "\n" + theme.fg("dim", "── source ──");
559
- out += "\n" + theme.fg("toolOutput", String(args.code).trim());
587
+ out += "\n" + theme.fg("toolOutput", cleanBlockText(args.code).trim());
560
588
  }
561
589
 
562
590
  if (context?.isError) comp.setTone("error");
@@ -567,51 +595,75 @@ export function renderSupernovaCall(a, b, c) {
567
595
  return comp;
568
596
  }
569
597
 
570
- 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 }) {
571
631
  let out = "";
572
632
  const trace = payload?.trace || context?.state?.trace || [];
573
- 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`)}`;
574
642
 
575
- if (diffs.length > 0) {
576
- const maxDiffsShown = expanded ? diffs.length : 2;
577
- const shownDiffs = diffs.slice(0, maxDiffsShown);
578
- for (const diff of shownDiffs) {
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)) {
579
648
  const box = renderDiffBox(diff, theme, 120);
580
- if (box) out += (out ? "\n\n" : "") + box;
581
- }
582
- if (!expanded && diffs.length > maxDiffsShown) {
583
- const remaining = diffs.length - maxDiffsShown;
584
- out +=
585
- "\n\n" +
586
- theme.fg(
587
- "dim",
588
- `… ${remaining} more file edit${remaining === 1 ? "" : "s"} (press Enter to expand)`,
589
- );
649
+ if (box) out += "\n" + box;
590
650
  }
591
- }
651
+ if (diffs.length > maxDiffs) out += `\n${theme.fg("dim", `… ${diffs.length - maxDiffs} more changed files`)}`;
592
652
 
593
- if (expanded) {
594
- const resVal = payload?.result;
595
- if (resVal !== undefined) {
596
- let formatted;
597
- try {
598
- formatted = isString(resVal) ? resVal : JSON.stringify(resVal, null, 2);
599
- } catch {
600
- formatted = String(resVal);
601
- }
653
+ if (payload?.result !== undefined) {
602
654
  out += (out ? "\n" : "") + theme.fg("dim", "── result ──");
603
- out += "\n" + theme.fg("toolOutput", formatted);
655
+ out += "\n" + theme.fg("toolOutput", boundedResult(payload.result));
604
656
  }
605
- if (payload?.logs?.length) {
657
+ if (!isError && payload?.logs?.length) {
606
658
  out += (out ? "\n" : "") + theme.fg("dim", "── logs ──");
607
- 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))}`;
608
660
  }
609
661
  }
610
- return out;
662
+ return { body: out, opCount: ops.length };
611
663
  }
612
664
 
613
665
  export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextArg) {
614
- const { result, expanded, isPartial, theme, context, options, host } = normalizeResultRenderArgs(
666
+ const { result, expanded, isPartial, theme, context, args, options, host } = normalizeResultRenderArgs(
615
667
  resultArg,
616
668
  optionsArg,
617
669
  themeArg,
@@ -637,51 +689,17 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
637
689
  }
638
690
 
639
691
  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
- }
692
+ const view = buildResultBody(theme, { payload, context, args, expanded, isPartial, isError: isErr });
693
+
694
+ if (shouldUseOmpFrame(host)) {
678
695
  return renderOmpResultCard(theme, {
679
- isErr: false,
696
+ isErr,
680
697
  payload,
681
698
  expanded,
682
- bodyText: out,
683
- isPartial: false,
699
+ bodyText: view.body,
700
+ isPartial: isPartial && !isErr,
684
701
  spinnerFrame: options?.spinnerFrame,
702
+ opCount: view.opCount,
685
703
  });
686
704
  }
687
705
 
@@ -689,32 +707,22 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
689
707
  if (options) options.lastComponent = comp;
690
708
  else if (context) context.lastComponent = comp;
691
709
 
692
- if (isPartial) {
693
- comp.setText("");
694
- return comp;
695
- }
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 ")}`;
696
715
 
697
716
  if (isErr) {
698
- let out = theme.fg("error", "✗ error");
699
- if (payload?.error) {
700
- out += `\n ${theme.fg("error", String(payload.error))}`;
701
- }
717
+ out += `\n ${theme.fg("error", payload?.error ? cleanBlockText(payload.error) : "error")}`;
702
718
  if (expanded && payload?.logs?.length) {
703
719
  out += `\n${theme.fg("dim", "── logs ──")}`;
704
- 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))}`;
705
721
  }
706
722
  comp.setTone("error");
707
- comp.setFraming(true);
708
- comp.setText(out);
709
- return comp;
710
- }
711
-
712
- const out = buildResultBody(theme, { payload, context, expanded });
713
- if (!out.trim()) {
714
- comp.setText("");
715
- return comp;
723
+ } else {
724
+ comp.setTone(isPartial ? "pending" : "success");
716
725
  }
717
- comp.setTone("success");
718
726
  comp.setFraming(true);
719
727
  comp.setText(out);
720
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
  }