pi-supernova 0.0.8 → 0.0.15
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 +85 -2
- package/README.md +36 -12
- package/bottleneck.js +1 -1
- package/catalog.js +20 -15
- package/check.js +166 -0
- package/config.default.json +1 -0
- package/config.js +1 -1
- package/decode.js +0 -10
- package/evidence.js +429 -0
- package/fuzzy.js +182 -0
- package/guest-worker.js +14 -11
- package/host-bridge.js +184 -74
- package/index.js +65 -42
- package/ledger.js +178 -0
- package/omp-frame.js +7 -22
- package/outline.js +80 -0
- package/package.json +8 -1
- package/render-measure.js +2 -30
- package/render.js +46 -150
- package/repo-index.js +273 -0
- package/runtime.js +3 -20
- package/search.js +141 -0
- package/snap.js +86 -108
- package/surface.js +24 -12
- package/vfs.js +7 -6
- package/workspace.js +33 -2
package/ledger.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Seen-ledger: the model's context window is a memory. Nothing that already reached the
|
|
2
|
+
// model in this session is sent again verbatim. A run of identical lines (≥ MIN_RUN, with
|
|
3
|
+
// enough substantive lines) collapses to one marker that cites the earlier program and,
|
|
4
|
+
// when the lines came from a file, path:a–b, so one read(path, a, n) recovers them.
|
|
5
|
+
//
|
|
6
|
+
// This is not compression: every collapsed line already exists, verbatim, in the model's
|
|
7
|
+
// context. Changed lines are never collapsed, so a re-read after an edit shows exactly the
|
|
8
|
+
// delta. Lines the current program read with an explicit offset/limit are pinned and always
|
|
9
|
+
// shown: that is the model asking for a specific window on purpose.
|
|
10
|
+
|
|
11
|
+
const MIN_RUN = 6;
|
|
12
|
+
const MIN_SUBSTANTIVE = 4;
|
|
13
|
+
const MAX_CANDIDATES = 8;
|
|
14
|
+
const DEFAULT_WINDOW = 40;
|
|
15
|
+
const MAX_STORED_LINES = 200_000;
|
|
16
|
+
|
|
17
|
+
function hashLine(line) {
|
|
18
|
+
let h = 0x811c9dc5;
|
|
19
|
+
for (let i = 0; i < line.length; i++) {
|
|
20
|
+
h ^= line.charCodeAt(i);
|
|
21
|
+
h = Math.imul(h, 0x01000193);
|
|
22
|
+
}
|
|
23
|
+
return h >>> 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function substantive(line) {
|
|
27
|
+
return line.trim().length >= 8;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class SeenLedger {
|
|
31
|
+
constructor({ window = DEFAULT_WINDOW } = {}) {
|
|
32
|
+
this.window = window;
|
|
33
|
+
this.results = new Map(); // call → { hashes: Uint32Array, lines: string[] }
|
|
34
|
+
this.occurrences = new Map(); // hash → [{ call, index }]
|
|
35
|
+
this.origins = new Map(); // hash → { path, line } (provenance recorded by the bridge)
|
|
36
|
+
this.pinned = new Set(); // "path:line" pinned by the current program
|
|
37
|
+
this.storedLines = 0;
|
|
38
|
+
this.stats = { programs: 0, returnedChars: 0, collapsedChars: 0, collapsedRuns: 0 };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
reset() {
|
|
42
|
+
this.results.clear();
|
|
43
|
+
this.occurrences.clear();
|
|
44
|
+
this.origins.clear();
|
|
45
|
+
this.pinned.clear();
|
|
46
|
+
this.storedLines = 0;
|
|
47
|
+
this.stats = { programs: 0, returnedChars: 0, collapsedChars: 0, collapsedRuns: 0 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beginProgram(call) {
|
|
51
|
+
this.pinned.clear();
|
|
52
|
+
this.stats.programs++;
|
|
53
|
+
for (const old of [...this.results.keys()]) {
|
|
54
|
+
if (old <= call - this.window) this.forget(old);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
forget(call) {
|
|
59
|
+
const entry = this.results.get(call);
|
|
60
|
+
if (!entry) return;
|
|
61
|
+
for (let i = 0; i < entry.hashes.length; i++) {
|
|
62
|
+
const list = this.occurrences.get(entry.hashes[i]);
|
|
63
|
+
if (!list) continue;
|
|
64
|
+
const kept = list.filter((o) => o.call !== call);
|
|
65
|
+
if (kept.length) this.occurrences.set(entry.hashes[i], kept);
|
|
66
|
+
else this.occurrences.delete(entry.hashes[i]);
|
|
67
|
+
}
|
|
68
|
+
this.storedLines -= entry.hashes.length;
|
|
69
|
+
this.results.delete(call);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Provenance for lines the bridge is about to hand to the program: file text, outlines, evidence spans. */
|
|
73
|
+
recordOrigin(path, firstLine, lines, pin = false) {
|
|
74
|
+
for (let i = 0; i < lines.length; i++) {
|
|
75
|
+
if (!substantive(lines[i])) continue;
|
|
76
|
+
this.origins.set(hashLine(lines[i]), { path, line: firstLine + i });
|
|
77
|
+
if (pin) this.pinned.add(path + ":" + (firstLine + i));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
isPinned(hash) {
|
|
82
|
+
const o = this.origins.get(hash);
|
|
83
|
+
return o !== undefined && this.pinned.has(o.path + ":" + o.line);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Length of the identical run between lines[i…] and earlier result `cand`, stopping at pinned lines. */
|
|
87
|
+
runLength(hashes, cand, i) {
|
|
88
|
+
const earlier = this.results.get(cand.call);
|
|
89
|
+
if (!earlier) return 0;
|
|
90
|
+
let k = 0;
|
|
91
|
+
while (i + k < hashes.length && cand.index + k < earlier.hashes.length && earlier.hashes[cand.index + k] === hashes[i + k] && !this.isPinned(hashes[i + k])) k++;
|
|
92
|
+
return k;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Longest earlier run starting at lines[i]; null when shorter than MIN_RUN or not substantive enough. */
|
|
96
|
+
longestRun(hashes, lines, i) {
|
|
97
|
+
const candidates = this.occurrences.get(hashes[i]);
|
|
98
|
+
if (!candidates) return null;
|
|
99
|
+
let best = null;
|
|
100
|
+
for (const cand of candidates.slice(-MAX_CANDIDATES)) {
|
|
101
|
+
const length = this.runLength(hashes, cand, i);
|
|
102
|
+
if (length >= MIN_RUN && (!best || length > best.length)) best = { call: cand.call, index: cand.index, length };
|
|
103
|
+
}
|
|
104
|
+
if (!best) return null;
|
|
105
|
+
const substantiveCount = lines.slice(i, i + best.length).filter(substantive).length;
|
|
106
|
+
return substantiveCount >= MIN_SUBSTANTIVE ? best : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** "path:a–b" when every line of the run has consecutive provenance in one file, else "". */
|
|
110
|
+
citation(hashes, i, length) {
|
|
111
|
+
const first = this.origins.get(hashes[i]);
|
|
112
|
+
if (!first) return "";
|
|
113
|
+
let expectLine = first.line;
|
|
114
|
+
for (let k = 0; k < length; k++) {
|
|
115
|
+
const o = this.origins.get(hashes[i + k]);
|
|
116
|
+
if (o) {
|
|
117
|
+
if (o.path !== first.path || o.line < expectLine) return "";
|
|
118
|
+
expectLine = o.line + 1;
|
|
119
|
+
} else {
|
|
120
|
+
expectLine++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return first.path + ":" + first.line + "–" + (expectLine - 1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Collapse runs already shown; returns the text to send and remembers exactly that text as call N.
|
|
128
|
+
*/
|
|
129
|
+
dedupe(text, call) {
|
|
130
|
+
const lines = text.split("\n");
|
|
131
|
+
if (lines.length < MIN_RUN) {
|
|
132
|
+
this.stats.returnedChars += text.length;
|
|
133
|
+
this.remember(call, lines);
|
|
134
|
+
return text;
|
|
135
|
+
}
|
|
136
|
+
const hashes = new Uint32Array(lines.length);
|
|
137
|
+
for (let i = 0; i < lines.length; i++) hashes[i] = hashLine(lines[i]);
|
|
138
|
+
const out = [];
|
|
139
|
+
let collapsedChars = 0;
|
|
140
|
+
for (let i = 0; i < lines.length; ) {
|
|
141
|
+
const run = substantive(lines[i]) ? this.longestRun(hashes, lines, i) : null;
|
|
142
|
+
if (!run) {
|
|
143
|
+
out.push(lines[i]);
|
|
144
|
+
i++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const cite = this.citation(hashes, i, run.length);
|
|
148
|
+
out.push("⋯ " + run.length + " lines same as #" + run.call + (cite ? " · " + cite : "") + " ⋯");
|
|
149
|
+
for (let k = 0; k < run.length; k++) collapsedChars += lines[i + k].length + 1;
|
|
150
|
+
this.stats.collapsedRuns++;
|
|
151
|
+
i += run.length;
|
|
152
|
+
}
|
|
153
|
+
const sent = out.join("\n");
|
|
154
|
+
this.stats.returnedChars += sent.length;
|
|
155
|
+
this.stats.collapsedChars += collapsedChars;
|
|
156
|
+
this.remember(call, out);
|
|
157
|
+
return sent;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
remember(call, lines) {
|
|
161
|
+
if (this.storedLines + lines.length > MAX_STORED_LINES) {
|
|
162
|
+
for (const old of [...this.results.keys()].sort((a, b) => a - b)) {
|
|
163
|
+
this.forget(old);
|
|
164
|
+
if (this.storedLines + lines.length <= MAX_STORED_LINES) break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const hashes = new Uint32Array(lines.length);
|
|
168
|
+
for (let i = 0; i < lines.length; i++) {
|
|
169
|
+
hashes[i] = hashLine(lines[i]);
|
|
170
|
+
if (!substantive(lines[i])) continue;
|
|
171
|
+
let list = this.occurrences.get(hashes[i]);
|
|
172
|
+
if (!list) this.occurrences.set(hashes[i], (list = []));
|
|
173
|
+
list.push({ call, index: i });
|
|
174
|
+
}
|
|
175
|
+
this.results.set(call, { hashes });
|
|
176
|
+
this.storedLines += lines.length;
|
|
177
|
+
}
|
|
178
|
+
}
|
package/omp-frame.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared Pi/OMP rounded tool chrome for supernova.
|
|
3
3
|
*
|
|
4
|
-
* Intentionally self-contained
|
|
4
|
+
* Intentionally self-contained: never dynamic-imports `@oh-my-pi/pi-coding-agent`
|
|
5
5
|
* (that hung OMP plugin load). Portable geometry matches native edit/write cards.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -43,30 +43,15 @@ function borderPaint(theme, state, borderColor) {
|
|
|
43
43
|
return (text) => text;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
if (iconOverride !== undefined) return undefined;
|
|
48
|
-
if (icon !== undefined) return icon;
|
|
49
|
-
return state === "error" ? "error" : undefined;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const STATUS_GLYPH = { error: "✗ ", running: "… " };
|
|
53
|
-
const STATUS_COLOR = { error: "error", running: "dim" };
|
|
54
|
-
|
|
55
|
-
function statusPrefix(theme, resolvedIcon, iconOverride, spinnerFrame) {
|
|
56
|
-
if (iconOverride) return `${iconOverride} `;
|
|
57
|
-
const key = resolvedIcon === "error" ? "error" : resolvedIcon === "running" || spinnerFrame ? "running" : undefined;
|
|
58
|
-
const glyph = STATUS_GLYPH[key];
|
|
59
|
-
if (!glyph) return "";
|
|
60
|
-
return theme?.fg ? theme.fg(STATUS_COLOR[key], glyph) : glyph;
|
|
61
|
-
}
|
|
46
|
+
const STATUS_PREFIX = { error: ["error", "✗ "], running: ["dim", "… "] };
|
|
62
47
|
|
|
63
|
-
function statusHeader(theme, { title, description, state,
|
|
64
|
-
const
|
|
48
|
+
function statusHeader(theme, { title, description, state, icon }) {
|
|
49
|
+
const resolved = icon ?? (state === "error" ? "error" : undefined);
|
|
50
|
+
const prefixSpec = STATUS_PREFIX[resolved];
|
|
51
|
+
const prefix = prefixSpec ? (theme?.fg ? theme.fg(prefixSpec[0], prefixSpec[1]) : prefixSpec[1]) : "";
|
|
65
52
|
const titleText = theme?.fg ? theme.fg("accent", title) : title;
|
|
66
53
|
const descText = description ? (theme?.fg ? theme.fg("muted", description) : description) : "";
|
|
67
|
-
|
|
68
|
-
if (!descText) return `${prefix}${titleText}`;
|
|
69
|
-
return `${prefix}${titleText}: ${descText}`;
|
|
54
|
+
return descText ? `${prefix}${titleText}: ${descText}` : `${prefix}${titleText}`;
|
|
70
55
|
}
|
|
71
56
|
|
|
72
57
|
function padLine(line, width, bgFn) {
|
package/outline.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { WorkspaceIndex } from "./repo-index.js";
|
|
2
|
+
import { tokenizeQuery } from "./snap.js";
|
|
3
|
+
import { stem } from "./evidence.js";
|
|
4
|
+
|
|
5
|
+
// read(path, { about }): one call, whole-file structure, only the relevant bodies expanded.
|
|
6
|
+
// L0 (names) and L1 (signatures + line ranges) for every declaration; L2 (full text) for the
|
|
7
|
+
// spans that match the question, within a character budget. The model reads a 600-line file
|
|
8
|
+
// in ~15% of its tokens and knows the exact read(path, offset, limit) to issue for anything folded.
|
|
9
|
+
|
|
10
|
+
const OUTLINE_DEFAULTS = { maxChars: 8000, maxExpanded: 6, headerLines: 30, maxRefs: 5, references: null };
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
function relevance(span, lower, stems) {
|
|
14
|
+
if (stems.length === 0) return 0;
|
|
15
|
+
const nameLower = span.name.toLowerCase();
|
|
16
|
+
let score = 0;
|
|
17
|
+
for (const s of stems) if (nameLower.includes(s)) score += 40;
|
|
18
|
+
for (let i = span.start - 1; i < span.end; i++) {
|
|
19
|
+
let hits = 0;
|
|
20
|
+
for (const s of stems) if (lower[i].includes(s)) hits++;
|
|
21
|
+
score += hits * hits * 5;
|
|
22
|
+
}
|
|
23
|
+
return score;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function chooseExpanded(spans, lower, stems, raw, opts) {
|
|
27
|
+
const scored = spans.map((s, i) => ({ i, r: relevance(s, lower, stems), chars: raw.slice(s.start - 1, s.end).join("\n").length }));
|
|
28
|
+
scored.sort((a, b) => b.r - a.r || a.i - b.i);
|
|
29
|
+
const expanded = new Set();
|
|
30
|
+
let budget = opts.maxChars;
|
|
31
|
+
// Weak-match cutoff (as fff's weak-match detector): stop once relevance falls below 40% of the best span.
|
|
32
|
+
const floor = stems.length > 0 ? Math.max(1, scored[0].r * 0.4) : 0;
|
|
33
|
+
for (const { i, r, chars } of scored) {
|
|
34
|
+
if (expanded.size >= opts.maxExpanded || r < floor) break;
|
|
35
|
+
if (chars > budget) continue;
|
|
36
|
+
expanded.add(i);
|
|
37
|
+
budget -= chars;
|
|
38
|
+
}
|
|
39
|
+
return expanded;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function foldedLine(span) {
|
|
43
|
+
const body = span.end - span.start;
|
|
44
|
+
const sig = span.signature.replace(/\s*\{\s*$/, "");
|
|
45
|
+
return String(span.start).padStart(5) + " " + sig + (body > 0 ? " … " + body + " lines" : "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function expandedBlock(span, raw, opts) {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (let l = span.start; l <= span.end; l++) out.push(String(l).padStart(5) + " " + raw[l - 1]);
|
|
51
|
+
const refs = opts.references ? opts.references(span.name, span.start) : [];
|
|
52
|
+
// Who uses this declaration: the relation a reader would otherwise grep for next.
|
|
53
|
+
if (refs.length) out.push(" // used by: " + refs.slice(0, opts.maxRefs).join(", ") + (refs.length > opts.maxRefs ? " (+" + (refs.length - opts.maxRefs) + ")" : ""));
|
|
54
|
+
return out.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param entry index entry (text + cached lines/surface)
|
|
59
|
+
* @param about question or symbol; empty ⇒ pure skeleton (every body folded)
|
|
60
|
+
*/
|
|
61
|
+
export function outlineFile(entry, relPath, about, options = {}) {
|
|
62
|
+
const opts = { ...OUTLINE_DEFAULTS, ...options };
|
|
63
|
+
const { raw, lower } = WorkspaceIndex.linesOf(entry);
|
|
64
|
+
const lineCount = raw.length;
|
|
65
|
+
const spans = WorkspaceIndex.spansOf(entry).map((s) => ({ ...s, signature: raw[s.start - 1].trim() }));
|
|
66
|
+
if (spans.length === 0) return null; // no structure: caller falls back to plain text
|
|
67
|
+
const stems = [...new Set(tokenizeQuery(about || "").tokens.map(stem))];
|
|
68
|
+
const expanded = chooseExpanded(spans, lower, stems, raw, opts);
|
|
69
|
+
|
|
70
|
+
const parts = [];
|
|
71
|
+
const headerEnd = Math.min(spans[0].start - 1, opts.headerLines);
|
|
72
|
+
if (headerEnd > 0) {
|
|
73
|
+
const header = raw.slice(0, headerEnd).filter((l) => l.trim());
|
|
74
|
+
if (header.length) parts.push(header.map((l, i) => String(i + 1).padStart(5) + " " + l).join("\n"));
|
|
75
|
+
if (spans[0].start - 1 > opts.headerLines) parts.push(" … " + (spans[0].start - 1 - opts.headerLines) + " more header lines");
|
|
76
|
+
}
|
|
77
|
+
for (let i = 0; i < spans.length; i++) parts.push(expanded.has(i) ? expandedBlock(spans[i], raw, opts) : foldedLine(spans[i]));
|
|
78
|
+
const title = "// " + relPath + " · " + lineCount + " lines · " + spans.length + " declarations · " + expanded.size + " expanded" + (about ? " for \"" + about + "\"" : "") + " · read(path, line, count) for a folded body";
|
|
79
|
+
return { text: title + "\n" + parts.join("\n"), expanded: expanded.size, declarations: spans.length };
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-supernova",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15",
|
|
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",
|
|
@@ -30,6 +30,13 @@
|
|
|
30
30
|
"parallel.js",
|
|
31
31
|
"runtime.js",
|
|
32
32
|
"guest-worker.js",
|
|
33
|
+
"repo-index.js",
|
|
34
|
+
"evidence.js",
|
|
35
|
+
"fuzzy.js",
|
|
36
|
+
"outline.js",
|
|
37
|
+
"ledger.js",
|
|
38
|
+
"check.js",
|
|
39
|
+
"search.js",
|
|
33
40
|
"format.js",
|
|
34
41
|
"patch.js",
|
|
35
42
|
"vfs.js",
|
package/render-measure.js
CHANGED
|
@@ -9,9 +9,9 @@ import { stripVTControlCharacters } from "node:util";
|
|
|
9
9
|
const ELLIPSIS = "…";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Visible columns
|
|
12
|
+
* Visible columns: ANSI/OSC stripped, tabs → 3 spaces.
|
|
13
13
|
* ASCII-fast; non-ASCII uses a wide-char heuristic aligned with typical terminal
|
|
14
|
-
* / pi-tui behavior (emoji & symbols like ⚡ are 2 cols
|
|
14
|
+
* / pi-tui behavior (emoji & symbols like ⚡ are 2 cols; an undercount is the 92>91 crash).
|
|
15
15
|
*/
|
|
16
16
|
export function measureWidth(text) {
|
|
17
17
|
const raw = String(text ?? "").replace(/\t/g, " ");
|
|
@@ -119,33 +119,6 @@ export function clampLine(line, width) {
|
|
|
119
119
|
return plain.slice(0, Math.max(0, w - 1)) + ELLIPSIS;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/**
|
|
123
|
-
* Wrap plain text to width, preferring breaks after `/` or space so paths stay readable.
|
|
124
|
-
* Every returned chunk is ≤ width (no ellipsis — caller clamps if needed).
|
|
125
|
-
*/
|
|
126
|
-
export function wrapPlainToWidth(plain, width) {
|
|
127
|
-
const w = Math.max(1, width | 0);
|
|
128
|
-
const text = String(plain ?? "");
|
|
129
|
-
if (text.length === 0) return [""];
|
|
130
|
-
if (measureWidth(text) <= w) return [text];
|
|
131
|
-
const lines = [];
|
|
132
|
-
let i = 0;
|
|
133
|
-
while (i < text.length) {
|
|
134
|
-
const { end, lastBreak } = takeChunk(text, i, w);
|
|
135
|
-
if (end === i) {
|
|
136
|
-
const ch = text.codePointAt(i) > 0xffff ? text.slice(i, i + 2) : text[i];
|
|
137
|
-
lines.push(hardTruncate(ch, w));
|
|
138
|
-
i += ch.length;
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
let cut = end;
|
|
142
|
-
if (end < text.length && lastBreak > i + Math.floor(w * 0.35)) cut = lastBreak;
|
|
143
|
-
lines.push(text.slice(i, cut));
|
|
144
|
-
i = cut;
|
|
145
|
-
}
|
|
146
|
-
return lines;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
122
|
/**
|
|
150
123
|
* Fit a filesystem path into `budget` columns, keeping the basename visible.
|
|
151
124
|
*/
|
|
@@ -160,4 +133,3 @@ export function fitPath(pathText, budget) {
|
|
|
160
133
|
return hardTruncate(base, w);
|
|
161
134
|
}
|
|
162
135
|
|
|
163
|
-
export { ELLIPSIS };
|
package/render.js
CHANGED
|
@@ -12,124 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
import { stripVTControlCharacters } from "node:util";
|
|
14
14
|
import { isString, isObject, isFunction } from "./decode.js";
|
|
15
|
-
import {
|
|
16
|
-
measureWidth,
|
|
17
|
-
hardTruncate,
|
|
18
|
-
clampLine,
|
|
19
|
-
wrapPlainToWidth,
|
|
20
|
-
fitPath,
|
|
21
|
-
} from "./render-measure.js";
|
|
15
|
+
import { measureWidth, hardTruncate, clampLine, fitPath } from "./render-measure.js";
|
|
22
16
|
import { novaFramedBlock, novaStatusLine } from "./omp-frame.js";
|
|
23
17
|
import { formatValue } from "./format.js";
|
|
24
18
|
|
|
25
|
-
export { measureWidth, hardTruncate, clampLine
|
|
26
|
-
|
|
27
|
-
function fitOutputLines(text, width) {
|
|
28
|
-
const w = Math.max(1, width | 0);
|
|
29
|
-
const out = [];
|
|
30
|
-
for (const line of String(text ?? "")
|
|
31
|
-
.replace(/\t/g, " ")
|
|
32
|
-
.split("\n")) {
|
|
33
|
-
if (measureWidth(line) <= w) {
|
|
34
|
-
out.push(line);
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
// Wrap on plain text so long paths continue on the next line instead of
|
|
38
|
-
// dying as `packages/pi-supern…`. ANSI is dropped on wrap (crash-safety).
|
|
39
|
-
const plain = stripVTControlCharacters(line);
|
|
40
|
-
for (const chunk of wrapPlainToWidth(plain, w)) {
|
|
41
|
-
out.push(clampLine(chunk, w));
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return out.length > 0 ? out : [""];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Compact bounded text; the host supplies the card background and borders. */
|
|
48
|
-
export class SafeText {
|
|
49
|
-
constructor(text = "") {
|
|
50
|
-
this.text = text;
|
|
51
|
-
}
|
|
52
|
-
setText(text) {
|
|
53
|
-
this.text = text;
|
|
54
|
-
}
|
|
55
|
-
invalidate() {}
|
|
56
|
-
render(width = 80) {
|
|
57
|
-
const raw = String(this.text ?? "");
|
|
58
|
-
return raw.trim() ? fitOutputLines(raw, Math.max(1, width | 0)) : [];
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Keep names some tests / older call sites may import.
|
|
63
|
-
export const visibleWidth = measureWidth;
|
|
64
|
-
export const truncateToWidth = hardTruncate;
|
|
65
|
-
|
|
66
|
-
export function extractOperationsFromCode(code) {
|
|
67
|
-
const trimmed = String(code || "").trim();
|
|
68
|
-
if (!trimmed) return [];
|
|
69
|
-
|
|
70
|
-
const ops = [];
|
|
71
|
-
const seen = new Set();
|
|
72
|
-
|
|
73
|
-
const addOp = (tool, target) => {
|
|
74
|
-
const key = `${tool}:${target}`;
|
|
75
|
-
if (!seen.has(key)) {
|
|
76
|
-
seen.add(key);
|
|
77
|
-
ops.push({ tool, target });
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const callRegex = /nova\.call\s*\(\s*["'`]([a-zA-Z0-9_-]+)["'`](?:\s*,\s*(\{[\s\S]*?\}))?/g;
|
|
82
|
-
let match;
|
|
83
|
-
while ((match = callRegex.exec(trimmed)) !== null) {
|
|
84
|
-
const tool = match[1];
|
|
85
|
-
let target = "";
|
|
86
|
-
if (match[2]) {
|
|
87
|
-
const pathMatch = /path\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
|
|
88
|
-
const cmdMatch = /command\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
|
|
89
|
-
const patMatch = /pattern\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
|
|
90
|
-
if (pathMatch) target = pathMatch[1];
|
|
91
|
-
else if (cmdMatch) target = cmdMatch[1].length > 30 ? cmdMatch[1].slice(0, 27) + "…" : cmdMatch[1];
|
|
92
|
-
else if (patMatch) target = patMatch[1];
|
|
93
|
-
}
|
|
94
|
-
addOp(tool, target);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const callManyRegex = /nova\.callMany\s*\(\s*\[([\s\S]*?)\]\s*\)/g;
|
|
98
|
-
while ((match = callManyRegex.exec(trimmed)) !== null) {
|
|
99
|
-
const inner = match[1];
|
|
100
|
-
const subCalls = inner.matchAll(/name\s*:\s*["'`]([a-zA-Z0-9_-]+)["'`]/g);
|
|
101
|
-
for (const sub of subCalls) addOp(sub[1], "");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const namedCalls = [
|
|
105
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?read\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "read", wrap: (p) => p },
|
|
106
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?write\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "write", wrap: (p) => p },
|
|
107
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?edit\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
|
|
108
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?patch\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "patch", wrap: (p) => p },
|
|
109
|
-
{
|
|
110
|
-
regex: /(?:^|[^\w$.])(?:nova\.)?bash\s*\(\s*["'`]([^"'`]+)["'`]/gm,
|
|
111
|
-
tool: "bash",
|
|
112
|
-
wrap: (c) => (c.length > 32 ? c.slice(0, 29) + "…" : c),
|
|
113
|
-
},
|
|
114
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?exec\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "exec", wrap: (c) => c },
|
|
115
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?search\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "search", wrap: (q) => `"${q}"` },
|
|
116
|
-
{ regex: /(?:^|[^\w$.])nova\.describe\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "describe", wrap: (name) => name },
|
|
117
|
-
{ regex: /(?:^|[^\w$.])nova\.has\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "has", wrap: (name) => name },
|
|
118
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?surface\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "surface", wrap: (p) => p },
|
|
119
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?snap\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "snap", wrap: (q) => `"${q}"` },
|
|
120
|
-
];
|
|
121
|
-
for (const item of namedCalls) {
|
|
122
|
-
while ((match = item.regex.exec(trimmed)) !== null) {
|
|
123
|
-
addOp(item.tool, item.wrap(match[1]));
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (/nova\.speculate\s*\(/.test(trimmed)) {
|
|
128
|
-
addOp("speculate", "(branch foam)");
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return ops;
|
|
132
|
-
}
|
|
19
|
+
export { measureWidth, hardTruncate, clampLine };
|
|
133
20
|
|
|
134
21
|
function formatDiffRows(diff, theme, maxShown = 6) {
|
|
135
22
|
if (!diff || !Array.isArray(diff.lines) || diff.lines.length === 0) return [];
|
|
@@ -178,7 +65,7 @@ function displayOperation(tool, target, diff, ok, item) {
|
|
|
178
65
|
const rawName = cleanInlineText(tool);
|
|
179
66
|
if (!rawName) return null;
|
|
180
67
|
const normalized = rawName === "apply_patch" ? "patch" : rawName;
|
|
181
|
-
return { tool: normalized, target, diff, ok, ms: item?.ms, exitCode: item?.exitCode, time: item?.time };
|
|
68
|
+
return { tool: normalized, target, diff, ok, ms: item?.ms, exitCode: item?.exitCode, time: item?.time, error: item?.error };
|
|
182
69
|
}
|
|
183
70
|
|
|
184
71
|
function isTheme(value) {
|
|
@@ -217,7 +104,7 @@ export function normalizeCallRenderArgs(a, b, c) {
|
|
|
217
104
|
/**
|
|
218
105
|
* Dual-host renderResult args:
|
|
219
106
|
* Pi: (result, {expanded,isPartial}, theme, context)
|
|
220
|
-
* OMP: (result, {expanded,isPartial}, theme, args)
|
|
107
|
+
* OMP: (result, {expanded,isPartial}, theme, args) (4th is args, not context)
|
|
221
108
|
*
|
|
222
109
|
* Call shapes share the first three positions, so host is inferred from the
|
|
223
110
|
* fourth argument's context-versus-args shape.
|
|
@@ -237,7 +124,7 @@ function detectResultHost(options, ctxOrArgs) {
|
|
|
237
124
|
return "pi";
|
|
238
125
|
}
|
|
239
126
|
|
|
240
|
-
|
|
127
|
+
function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
|
|
241
128
|
if (isTheme(themeOrCtx)) {
|
|
242
129
|
const opts = isObject(options) ? options : {};
|
|
243
130
|
const context = contextFrom(opts, ctxOrArgs);
|
|
@@ -271,6 +158,11 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
|
|
|
271
158
|
throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
|
|
272
159
|
}
|
|
273
160
|
|
|
161
|
+
function batchTarget(paths) {
|
|
162
|
+
const names = paths.map((p) => String(p).replace(/\\/g, "/").split("/").pop());
|
|
163
|
+
return `${paths.length} files: ${names.join(", ")}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
274
166
|
const OPERATION_TARGETS = [
|
|
275
167
|
[(item) => item?.name === "snap", (item, args) => {
|
|
276
168
|
const query = args.query ? `"${args.query}"` : "";
|
|
@@ -278,6 +170,7 @@ const OPERATION_TARGETS = [
|
|
|
278
170
|
return `${query} → ${args.path}`;
|
|
279
171
|
}],
|
|
280
172
|
[(item) => item?.name === "search", (item, args) => (args.query ? `"${args.query}"` : "")],
|
|
173
|
+
[(item, args) => Array.isArray(args.path), (item, args) => batchTarget(args.path)],
|
|
281
174
|
[(item, args) => args.path, (item, args) => String(args.path)],
|
|
282
175
|
[(item) => item?.diff?.path, (item) => String(item.diff.path)],
|
|
283
176
|
[(item, args) => args.target && isString(args.target), (item, args) => args.target],
|
|
@@ -327,20 +220,21 @@ function operationsFromTrace(trace) {
|
|
|
327
220
|
.filter(Boolean);
|
|
328
221
|
}
|
|
329
222
|
|
|
223
|
+
// The call slot is always empty: the result card owns the whole lifecycle in both hosts.
|
|
224
|
+
const EMPTY_CALL = { render: () => [], invalidate() {} };
|
|
225
|
+
|
|
330
226
|
export function renderSupernovaCall(a, b, c) {
|
|
331
227
|
const { context, options } = normalizeCallRenderArgs(a, b, c);
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
comp.setText("");
|
|
336
|
-
return comp;
|
|
228
|
+
if (options) options.lastComponent = EMPTY_CALL;
|
|
229
|
+
else if (context) context.lastComponent = EMPTY_CALL;
|
|
230
|
+
return EMPTY_CALL;
|
|
337
231
|
}
|
|
338
232
|
|
|
339
233
|
const TOOL_COL = 7;
|
|
340
234
|
const DURATION_COL = 6;
|
|
341
235
|
const PREVIEW_LINES = 24;
|
|
342
236
|
|
|
343
|
-
|
|
237
|
+
function formatDuration(ms) {
|
|
344
238
|
if (!Number.isFinite(ms) || ms < 0) return "";
|
|
345
239
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
346
240
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
@@ -405,15 +299,15 @@ function formatOpRow(theme, op, width, isPartial, isError) {
|
|
|
405
299
|
prefix += theme.fg("toolDiffAdded", added) + theme.fg("dim", "/") + theme.fg("toolDiffRemoved", removed) + " ";
|
|
406
300
|
used += added.length + 1 + removed.length + 1;
|
|
407
301
|
}
|
|
408
|
-
const
|
|
409
|
-
|
|
302
|
+
const budget = Math.max(1, width - used);
|
|
303
|
+
const target = formatTarget(op, budget);
|
|
304
|
+
if (target) return prefix + theme.fg("muted", target);
|
|
305
|
+
if (op.ok === false && op.error) return prefix + theme.fg("error", clampLine(cleanInlineText(op.error), budget));
|
|
306
|
+
return prefix.trimEnd();
|
|
410
307
|
}
|
|
411
308
|
|
|
412
|
-
function operationsFor(payload, context
|
|
413
|
-
|
|
414
|
-
const traced = operationsFromTrace(trace);
|
|
415
|
-
if (traced.length > 0) return traced;
|
|
416
|
-
return extractOperationsFromCode(args?.code).map((op) => displayOperation(op.tool, op.target)).filter(Boolean);
|
|
309
|
+
function operationsFor(payload, context) {
|
|
310
|
+
return operationsFromTrace(payload?.trace || context?.state?.trace || []);
|
|
417
311
|
}
|
|
418
312
|
|
|
419
313
|
function resultLines(value, maxLines) {
|
|
@@ -466,32 +360,34 @@ class UnifiedResultCard {
|
|
|
466
360
|
set(theme, model) {
|
|
467
361
|
this.theme = theme;
|
|
468
362
|
this.model = model;
|
|
469
|
-
this.
|
|
363
|
+
this.cache = undefined;
|
|
470
364
|
}
|
|
471
365
|
invalidate() {
|
|
472
|
-
this.
|
|
366
|
+
this.cache = undefined;
|
|
473
367
|
}
|
|
474
368
|
render(width = 80) {
|
|
475
369
|
const { theme, model } = this;
|
|
476
370
|
if (!theme || !model) return [];
|
|
477
|
-
if (
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
371
|
+
if (this.cache?.width === width) return this.cache.lines;
|
|
372
|
+
const view = buildBodyLines(theme, Math.max(1, width - 4), model);
|
|
373
|
+
const header = novaStatusLine(theme, {
|
|
374
|
+
icon: model.isError ? "error" : model.isPartial ? "running" : undefined,
|
|
375
|
+
title: "nova",
|
|
376
|
+
description: describeCard(model, view.opCount),
|
|
377
|
+
});
|
|
378
|
+
// A program with no host calls has nothing to frame: one status line, no empty box.
|
|
379
|
+
const lines = view.lines.length === 0
|
|
380
|
+
? [clampLine(header, width)]
|
|
381
|
+
: novaFramedBlock(theme, () => ({
|
|
382
|
+
header,
|
|
383
|
+
sections: [{ lines: view.lines }],
|
|
488
384
|
state: model.isError ? "error" : model.isPartial ? "pending" : "success",
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
return
|
|
385
|
+
// borderMuted is invisible on OMP's card background; dim matches the duration column.
|
|
386
|
+
borderColor: model.isError ? "error" : "dim",
|
|
387
|
+
width,
|
|
388
|
+
})).render(width);
|
|
389
|
+
this.cache = { width, lines };
|
|
390
|
+
return lines;
|
|
495
391
|
}
|
|
496
392
|
}
|
|
497
393
|
|