pi-supernova 0.5.0 → 0.7.0

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.
Files changed (50) hide show
  1. package/README.md +97 -11
  2. package/docs/CHANGELOG.md +150 -0
  3. package/docs/TOKEN_COSTS.md +71 -29
  4. package/index.js +126 -82
  5. package/package.json +2 -2
  6. package/src/adapters/bash.js +73 -0
  7. package/src/adapters/edit.js +249 -0
  8. package/src/adapters/errors.js +31 -0
  9. package/src/adapters/index.js +31 -0
  10. package/src/adapters/list.js +102 -0
  11. package/src/adapters/read.js +805 -0
  12. package/src/adapters/refs.js +41 -0
  13. package/src/adapters/write.js +96 -0
  14. package/src/bridge/catalog.js +30 -220
  15. package/src/bridge/host-bridge.js +142 -1032
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -188
  18. package/src/context/evidence.js +142 -70
  19. package/src/context/fuzzy.js +61 -22
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +26 -12
  22. package/src/context/repo-index.js +242 -71
  23. package/src/context/search.js +189 -56
  24. package/src/context/snap.js +306 -150
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +29 -14
  27. package/src/contract/bash.js +31 -0
  28. package/src/contract/edit.js +95 -0
  29. package/src/contract/read.js +220 -0
  30. package/src/fs/check.js +19 -7
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +97 -51
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +289 -162
  37. package/src/fs/workspace.js +122 -105
  38. package/src/output/bottleneck.js +211 -107
  39. package/src/output/format.js +112 -63
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +306 -213
  42. package/src/runtime/parallel.js +99 -63
  43. package/src/runtime/program-batch.js +189 -69
  44. package/src/runtime/program-file.js +6 -3
  45. package/src/runtime/reference.js +13 -12
  46. package/src/runtime/runtime.js +327 -176
  47. package/src/shared/decode.js +61 -27
  48. package/src/ui/omp-frame.js +70 -46
  49. package/src/ui/render-measure.js +51 -29
  50. package/src/ui/render.js +242 -146
@@ -60,48 +60,82 @@ function plainFromCollection(value, seen, depth) {
60
60
  return out;
61
61
  }
62
62
 
63
- /** Convert any guest value to structured-clone-safe, JSON-shaped data. */
64
- export function toPlain(value, seen = new Set(), depth = 0) {
65
- if (value === null || value === undefined) return value;
66
- const tag = toStr.call(value);
63
+ function withSeen(value, seen, fn) {
64
+ seen.add(value);
65
+
66
+ try { return fn(); }
67
+ finally { seen.delete(value); }
68
+ }
67
69
 
68
- if (tag === "[object String]" || tag === "[object Number]" || tag === "[object Boolean]") return value.valueOf();
70
+ function functionLabel(value) {
71
+ return "[Function" + (value.name ? " " + value.name : "") + "]";
72
+ }
69
73
 
70
- if (tag === "[object BigInt]") return value.toString() + "n";
74
+ function plainByTag(value, tag) {
75
+ if (tag === "[object String]") return { hit: true, out: value.valueOf() };
71
76
 
72
- if (isFunction(value)) return "[Function" + (value.name ? " " + value.name : "") + "]";
77
+ if (tag === "[object Number]" || tag === "[object Boolean]") return { hit: true, out: value.valueOf() };
73
78
 
74
- if (tag === "[object Symbol]") return value.toString();
79
+ if (tag === "[object BigInt]") return { hit: true, out: value.toString() + "n" };
75
80
 
76
- if (depth > MAX_DEPTH) return "[Depth]";
81
+ if (tag === "[object Symbol]") return { hit: true, out: value.toString() };
77
82
 
78
- if (seen.has(value)) return "[Circular]";
83
+ return { hit: false };
84
+ }
79
85
 
80
- if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
86
+ function plainAtom(value) {
87
+ if (value === null || value === undefined) return { hit: true, out: value };
88
+ const tagged = plainByTag(value, toStr.call(value));
81
89
 
82
- if (value instanceof RegExp) return value.toString();
90
+ if (tagged.hit) return tagged;
83
91
 
84
- if (value instanceof Error) {
85
- const out = { name: value.name, message: value.message };
92
+ if (isFunction(value)) return { hit: true, out: functionLabel(value) };
86
93
 
87
- if (value.cause !== undefined) out.cause = toPlain(value.cause, seen, depth + 1);
94
+ return { hit: false };
95
+ }
88
96
 
89
- return out;
90
- }
97
+ function plainDate(value) {
98
+ return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
99
+ }
91
100
 
92
- if (value instanceof Promise) return "[Promise]";
101
+ function plainHosted(value) {
102
+ if (value instanceof Date) return { hit: true, out: plainDate(value) };
93
103
 
94
- if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return plainFromBinary(value);
104
+ if (value instanceof RegExp) return { hit: true, out: value.toString() };
95
105
 
96
- if (isFunction(value.toJSON)) return toPlain(value.toJSON(), seen, depth + 1);
97
- seen.add(value);
106
+ if (value instanceof Promise) return { hit: true, out: "[Promise]" };
98
107
 
99
- try {
100
- return plainFromCollection(value, seen, depth);
101
- } finally {
102
- seen.delete(value);
103
- }
108
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return { hit: true, out: plainFromBinary(value) };
109
+
110
+ return { hit: false };
104
111
  }
105
112
 
106
- // ---- RPC to the host thread ----
113
+ function plainError(value, seen, depth) {
114
+ const out = { name: value.name, message: value.message };
115
+
116
+ if (value.cause !== undefined) out.cause = withSeen(value, seen, () => toPlain(value.cause, seen, depth + 1));
117
+
118
+ return out;
119
+ }
120
+
121
+ /** Convert any guest value to structured-clone-safe, JSON-shaped data. */
122
+ export function toPlain(value, seen = new Set(), depth = 0) {
123
+ const atom = plainAtom(value);
124
+
125
+ if (atom.hit) return atom.out;
126
+
127
+ if (depth > MAX_DEPTH) return "[Depth]";
107
128
 
129
+ if (seen.has(value)) return "[Circular]";
130
+
131
+ if (value instanceof Error) return plainError(value, seen, depth);
132
+ const hosted = plainHosted(value);
133
+
134
+ if (hosted.hit) return hosted.out;
135
+
136
+ if (isFunction(value.toJSON)) return withSeen(value, seen, () => toPlain(value.toJSON(), seen, depth + 1));
137
+
138
+ return withSeen(value, seen, () => plainFromCollection(value, seen, depth));
139
+ }
140
+
141
+ // ---- RPC to the host thread ----
@@ -38,7 +38,10 @@ function borderPaint(theme, state, borderColor) {
38
38
 
39
39
  if (theme && isFunction(theme.fg)) {
40
40
  try {
41
- return (text) => theme.fg(key, text);
41
+ return (text) => {
42
+ try { return theme.fg(key, text); }
43
+ catch { return text; }
44
+ };
42
45
  } catch {
43
46
  /* fall through */
44
47
  }
@@ -51,10 +54,14 @@ const STATUS_PREFIX = { error: ["error", "✗ "], running: ["dim", "… "] };
51
54
 
52
55
  function statusHeader(theme, { title, description, state, icon }) {
53
56
  const resolved = icon ?? (state === "error" ? "error" : undefined);
57
+ const fg = (key, text) => {
58
+ try { return isFunction(theme?.fg) ? theme.fg(key, text) : text; }
59
+ catch { return text; }
60
+ };
54
61
  const prefixSpec = STATUS_PREFIX[resolved];
55
- const prefix = prefixSpec ? (theme?.fg ? theme.fg(prefixSpec[0], prefixSpec[1]) : prefixSpec[1]) : "";
56
- const titleText = theme?.fg ? theme.fg("accent", title) : title;
57
- const descText = description ? (theme?.fg ? theme.fg("muted", description) : description) : "";
62
+ const prefix = prefixSpec ? fg(prefixSpec[0], prefixSpec[1]) : "";
63
+ const titleText = fg("accent", title);
64
+ const descText = description ? fg("muted", description) : "";
58
65
 
59
66
  return descText ? `${prefix}${titleText}: ${descText}` : `${prefix}${titleText}`;
60
67
  }
@@ -82,20 +89,17 @@ function wrapBg(paint) {
82
89
  };
83
90
  }
84
91
 
85
- function bgFnForState(theme, state) {
86
- if (!state || !theme) return undefined;
87
- const key = bgKeyFor(state);
88
-
89
- if (isFunction(theme.bg)) {
90
- try {
91
- if (!isString(theme.bg(key, "x"))) return undefined;
92
- } catch {
93
- return undefined;
94
- }
95
-
96
- return wrapBg((text) => theme.bg(key, text));
92
+ function probeThemeBg(theme, key) {
93
+ try {
94
+ if (!isString(theme.bg(key, "x"))) return undefined;
95
+ } catch {
96
+ return undefined;
97
97
  }
98
98
 
99
+ return wrapBg((text) => theme.bg(key, text));
100
+ }
101
+
102
+ function themeAnsiBgFn(theme, key) {
99
103
  if (!isFunction(theme.getBgAnsi)) return undefined;
100
104
 
101
105
  try {
@@ -109,47 +113,56 @@ function bgFnForState(theme, state) {
109
113
  }
110
114
  }
111
115
 
112
- function frameBodyLines(sections, contentWidth, box, border, bgFn, w, paintBar) {
116
+ function bgFnForState(theme, state) {
117
+ if (!state || !theme) return undefined;
118
+ const key = bgKeyFor(state);
119
+
120
+ if (isFunction(theme.bg)) return probeThemeBg(theme, key);
121
+
122
+ return themeAnsiBgFn(theme, key);
123
+ }
124
+
125
+ function frameSectionLines(section, contentWidth, box, border, bgFn, w, paintBar) {
113
126
  const lines = [];
114
- const normalized = sections.length > 0 ? sections : [{ lines: [] }];
115
- const v = box.vertical;
116
127
 
117
- for (const section of normalized) {
118
- if (section.label) lines.push(paintBar(box.teeRight || "├", box.teeLeft || "┤", section.label));
128
+ if (section.label) lines.push(paintBar(box.teeRight || "├", box.teeLeft || "┤", section.label));
119
129
 
120
- for (const raw of section.lines || []) {
121
- for (const piece of String(raw).split("\n")) {
122
- const body = clampLine(piece, contentWidth);
123
- const pad = Math.max(0, contentWidth - measureWidth(body));
124
- lines.push(padLine(`${border(v)} ${body}${" ".repeat(pad)} ${border(v)}`, w, bgFn));
125
- }
130
+ for (const raw of section.lines || []) {
131
+ for (const piece of String(raw).split("\n")) {
132
+ const body = clampLine(piece, contentWidth);
133
+ const pad = Math.max(0, contentWidth - measureWidth(body));
134
+ lines.push(padLine(`${border(box.vertical)} ${body}${" ".repeat(pad)} ${border(box.vertical)}`, w, bgFn));
126
135
  }
127
136
  }
128
137
 
129
138
  return lines;
130
139
  }
131
140
 
132
- function renderPortableFrame(theme, { header, sections = [], state = "pending", borderColor, width }) {
133
- const w = Math.max(1, width | 0);
141
+ function frameBodyLines(sections, contentWidth, box, border, bgFn, w, paintBar) {
142
+ const normalized = sections.length > 0 ? sections : [{ lines: [] }];
143
+ const lines = [];
134
144
 
135
- if (w < 8) {
136
- const rawLines = [header];
145
+ for (const section of normalized) lines.push(...frameSectionLines(section, contentWidth, box, border, bgFn, w, paintBar));
137
146
 
138
- for (const section of sections) {
139
- if (section.label) rawLines.push(section.label);
140
- rawLines.push(...(section.lines || []));
141
- }
147
+ return lines;
148
+ }
149
+
150
+ function collapseNarrowFrame(header, sections, w) {
151
+ const rawLines = [header];
142
152
 
143
- return rawLines.flatMap((line) => line ? [clampLine(line, w)] : []);
153
+ for (const section of sections) {
154
+ if (section.label) rawLines.push(section.label);
155
+ rawLines.push(...(section.lines || []));
144
156
  }
145
157
 
146
- const box = boxOf(theme);
147
- const border = borderPaint(theme, state, borderColor);
148
- const bgFn = bgFnForState(theme, state);
158
+ return rawLines.flatMap((line) => line ? [clampLine(line, w)] : []);
159
+ }
160
+
161
+ function paintBarFor(box, border, bgFn, w) {
149
162
  const h = box.horizontal;
150
163
  const cap = h.repeat(3);
151
164
 
152
- const paintBar = (leftChar, rightChar, label) => {
165
+ return (leftChar, rightChar, label) => {
153
166
  const left = `${leftChar}${cap}`;
154
167
  const right = rightChar;
155
168
 
@@ -166,14 +179,25 @@ function renderPortableFrame(theme, { header, sections = [], state = "pending",
166
179
 
167
180
  return padLine(`${border(left)}${trimmed}${border(h.repeat(fill))}${border(right)}`, w, bgFn);
168
181
  };
182
+ }
169
183
 
170
- const contentWidth = Math.max(1, w - 2 - 2);
171
- const lines = [];
172
- lines.push(paintBar(box.topLeft, box.topRight, header));
173
- lines.push(...frameBodyLines(sections, contentWidth, box, border, bgFn, w, paintBar));
174
- lines.push(paintBar(box.bottomLeft, box.bottomRight, null));
184
+ function renderPortableFrame(theme, { header, sections = [], state = "pending", borderColor, width, paintBg = true }) {
185
+ const w = Math.max(1, width | 0);
175
186
 
176
- return lines;
187
+ if (w < 8) return collapseNarrowFrame(header, sections, w);
188
+ const box = boxOf(theme);
189
+ const border = borderPaint(theme, state, borderColor);
190
+ // Hosts that tint the whole tool block themselves (OMP's contentBox) must
191
+ // get unpainted rows: a second bg wrap here would clear the outer bg with
192
+ // \x1b[49m and strand the host's right pad as an unpainted bar.
193
+ const bgFn = paintBg ? bgFnForState(theme, state) : undefined;
194
+ const paintBar = paintBarFor(box, border, bgFn, w);
195
+
196
+ return [
197
+ paintBar(box.topLeft, box.topRight, header),
198
+ ...frameBodyLines(sections, Math.max(1, w - 2 - 2), box, border, bgFn, w, paintBar),
199
+ paintBar(box.bottomLeft, box.bottomRight, null),
200
+ ];
177
201
  }
178
202
 
179
203
  function createPortableFramedComponent(theme, build) {
@@ -11,6 +11,27 @@ let cachedWidthChars = 0;
11
11
 
12
12
  const MAX_WIDTH_CACHE_CHARS = 512_000;
13
13
 
14
+ function asciiWidth(plain, normalized) {
15
+ return /^[\x20-\x7e\u2500-\u257f\u00b7\u00d7\u2026\u2713\u2717]*$/.test(plain)
16
+ ? plain.length
17
+ : stringWidth(normalized);
18
+ }
19
+
20
+ function rememberWidth(raw, width) {
21
+ if (raw.length > 4096) return width;
22
+
23
+ while (widthCache.size >= 4096 || cachedWidthChars + raw.length > MAX_WIDTH_CACHE_CHARS) {
24
+ const oldest = widthCache.keys().next().value;
25
+ widthCache.delete(oldest);
26
+ cachedWidthChars -= oldest.length;
27
+ }
28
+
29
+ widthCache.set(raw, width);
30
+ cachedWidthChars += raw.length;
31
+
32
+ return width;
33
+ }
34
+
14
35
  export function measureWidth(text) {
15
36
  const raw = String(text ?? "");
16
37
  const cached = widthCache.get(raw);
@@ -22,24 +43,9 @@ export function measureWidth(text) {
22
43
 
23
44
  // ASCII and these single-column chrome glyphs need no Unicode segmentation.
24
45
  // Any other character/control/escape sequence uses the full oracle.
25
- const width = /^[\x20-\x7e\u2500-\u257f\u00b7\u00d7\u2026\u2713\u2717]*$/.test(plain)
26
- ? plain.length
27
- : stringWidth(normalized);
28
-
29
46
  // Cache immutable text only, never host/theme/result objects. Bound both
30
47
  // bookkeeping and retained text; unusually long lines bypass retention.
31
- if (raw.length <= 4096) {
32
- while (widthCache.size >= 4096 || cachedWidthChars + raw.length > MAX_WIDTH_CACHE_CHARS) {
33
- const oldest = widthCache.keys().next().value;
34
- widthCache.delete(oldest);
35
- cachedWidthChars -= oldest.length;
36
- }
37
-
38
- widthCache.set(raw, width);
39
- cachedWidthChars += raw.length;
40
- }
41
-
42
- return width;
48
+ return rememberWidth(raw, asciiWidth(plain, normalized));
43
49
  }
44
50
 
45
51
  function takePrefix(text, width) {
@@ -76,6 +82,24 @@ export function clampLine(line, width) {
76
82
  return hardTruncate(line, width);
77
83
  }
78
84
 
85
+ function pushWrapSegment(out, current, columns, segment, size, width) {
86
+ if (columns + size > width && current.text) {
87
+ out.push(current.text);
88
+ current.text = "";
89
+ columns = 0;
90
+ }
91
+
92
+ if (size > width) {
93
+ out.push(ELLIPSIS);
94
+
95
+ return columns;
96
+ }
97
+
98
+ current.text += segment;
99
+
100
+ return columns + size;
101
+ }
102
+
79
103
  /** Wrap complete, already-sanitized result text without splitting graphemes. */
80
104
  export function wrapLine(line, width) {
81
105
  if (width <= 0) return [];
@@ -83,33 +107,31 @@ export function wrapLine(line, width) {
83
107
 
84
108
  if (measureWidth(text) <= width) return [text];
85
109
  const out = [];
86
- let current = "";
110
+ const current = { text: "" };
87
111
  let columns = 0;
88
112
 
89
113
  for (const { segment } of segmenter.segment(text)) {
90
- const size = measureWidth(segment);
91
-
92
- if (columns + size > width && current) { out.push(current); current = ""; columns = 0; }
93
-
94
- if (size > width) { out.push(ELLIPSIS); continue; }
95
-
96
- current += segment;
97
- columns += size;
114
+ columns = pushWrapSegment(out, current, columns, segment, measureWidth(segment), width);
98
115
  }
99
116
 
100
- if (current) out.push(current);
117
+ if (current.text) out.push(current.text);
101
118
 
102
119
  return out;
103
120
  }
104
121
 
122
+ function shortenedPath(text) {
123
+ const parts = text.split("/").filter(Boolean);
124
+ const base = parts.at(-1) ?? text;
125
+
126
+ return { base, suffix: parts.length > 1 ? "…/" + base : base };
127
+ }
128
+
105
129
  export function fitPath(pathText, budget) {
106
130
  const width = Math.max(0, Math.floor(budget));
107
131
  const text = String(pathText ?? "").replace(/\\/g, "/");
108
132
 
109
133
  if (measureWidth(text) <= width) return text;
110
- const parts = text.split("/").filter(Boolean);
111
- const base = parts.at(-1) ?? text;
112
- const suffix = parts.length > 1 ? "…/" + base : base;
134
+ const { base, suffix } = shortenedPath(text);
113
135
 
114
136
  return measureWidth(suffix) <= width ? suffix : hardTruncate(base, width);
115
137
  }