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 +16 -0
- package/README.md +14 -5
- package/catalog.js +4 -1
- package/diff.js +21 -0
- package/host-bridge.js +48 -27
- package/index.js +28 -13
- package/omp-frame.js +22 -58
- package/package.json +1 -1
- package/render-measure.js +14 -8
- package/render.js +146 -141
- package/runtime.js +38 -19
- package/snap.js +29 -3
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
-
|
|
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: {
|
|
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(
|
|
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(
|
|
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
|
-
:
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
if (
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
774
|
-
|
|
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-
|
|
2
|
+
* OMP-style rounded tool chrome for supernova.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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(
|
|
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);
|
|
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
|
-
|
|
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
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 >=
|
|
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
|
|
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
|
-
|
|
115
|
-
end
|
|
118
|
+
end += ch.length;
|
|
119
|
+
if (ch === "/" || ch === " ") lastBreak = end;
|
|
116
120
|
}
|
|
117
121
|
if (end === i) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 {
|
|
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: "
|
|
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: "
|
|
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: "
|
|
197
|
-
{ regex: /(?:nova\.)?search\s*\(\s*["'`]([^"'`]+)["'`]/
|
|
198
|
-
{ regex: /(
|
|
199
|
-
{ regex: /(
|
|
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 =
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 =
|
|
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
|
|
399
|
-
if (
|
|
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(
|
|
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"
|
|
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
|
-
|
|
471
|
-
|
|
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",
|
|
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",
|
|
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:
|
|
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:
|
|
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",
|
|
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
|
|
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
|
|
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 (
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
|
|
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 +=
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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
|
|
643
|
-
|
|
644
|
-
if (
|
|
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
|
|
696
|
+
isErr,
|
|
683
697
|
payload,
|
|
684
698
|
expanded,
|
|
685
|
-
bodyText:
|
|
686
|
-
isPartial:
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
711
|
-
comp.
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|