pi-supernova 0.0.15 → 0.1.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.
package/patch.js CHANGED
@@ -3,104 +3,94 @@ import { isString } from "./decode.js";
3
3
  function parseHunkHeader(line) {
4
4
  const match = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
5
5
  if (!match) return null;
6
- return {
7
- oldStart: parseInt(match[1], 10),
8
- oldLength: match[2] !== undefined ? parseInt(match[2], 10) : 1,
9
- newStart: parseInt(match[3], 10),
10
- newLength: match[4] !== undefined ? parseInt(match[4], 10) : 1,
11
- lines: [],
12
- };
13
- }
14
-
15
- function isHunkLine(line) {
16
- return line.startsWith("+") || line.startsWith("-") || line.startsWith(" ");
6
+ return { oldStart: Number(match[1]), oldLength: match[2] === undefined ? 1 : Number(match[2]),
7
+ newStart: Number(match[3]), newLength: match[4] === undefined ? 1 : Number(match[4]), lines: [], noNewline: [] };
17
8
  }
18
9
 
19
10
  export function parsePatchHunks(patchText) {
20
- const patchLines = patchText.replace(/\r\n/g, "\n").split("\n");
21
11
  const hunks = [];
22
- let current = null;
23
-
24
- for (const line of patchLines) {
12
+ let current;
13
+ let oldCount = 0;
14
+ let newCount = 0;
15
+ for (const line of patchText.split("\n")) {
25
16
  const header = parseHunkHeader(line);
26
17
  if (header) {
27
- if (current) hunks.push(current);
28
18
  current = header;
29
- } else if (current && isHunkLine(line)) {
19
+ hunks.push(current);
20
+ oldCount = 0;
21
+ newCount = 0;
22
+ } else if (current && line.startsWith("\")) {
23
+ if (!current.lines.length) throw new Error("newline marker requires a preceding hunk line");
24
+ current.noNewline.push(current.lines.length - 1);
25
+ } else if (current && /^[+ -]/.test(line)) {
26
+ if (oldCount === current.oldLength && newCount === current.newLength && /^--- |^\+\+\+ /.test(line)) {
27
+ throw new Error("apply_patch accepts one file at a time");
28
+ }
30
29
  current.lines.push(line);
30
+ if (line[0] !== "+") oldCount++;
31
+ if (line[0] !== "-") newCount++;
31
32
  }
32
33
  }
33
- if (current) hunks.push(current);
34
- if (hunks.length === 0) {
35
- throw new Error("no valid patch hunks found (expected @@ -old,len +new,len @@)");
36
- }
34
+ if (!hunks.length) throw new Error("no valid patch hunks found (expected @@ -old,len +new,len @@)");
37
35
  return hunks;
38
36
  }
39
37
 
40
- function findHunkMatch(fileLines, expectedOld, nominal) {
41
- const matchAt = (idx) => {
42
- if (idx < 0 || idx + expectedOld.length > fileLines.length) return false;
43
- for (let j = 0; j < expectedOld.length; j++) {
44
- if (fileLines[idx + j] !== expectedOld[j]) return false;
45
- }
46
- return true;
47
- };
38
+ function splitFile(text) {
39
+ if (!text) return [];
40
+ const chunks = text.split("\n");
41
+ const trailing = chunks.at(-1) === "";
42
+ if (trailing) chunks.pop();
43
+ return chunks.map((chunk, index) => {
44
+ const newline = index < chunks.length - 1 || trailing;
45
+ const crlf = newline && chunk.endsWith("\r");
46
+ return { text: crlf ? chunk.slice(0, -1) : chunk, ending: newline ? (crlf ? "\r\n" : "\n") : "" };
47
+ });
48
+ }
48
49
 
50
+ function findHunkMatch(fileLines, expectedOld, nominal) {
51
+ const matchAt = index => index >= 0 && index + expectedOld.length <= fileLines.length
52
+ && expectedOld.every((line, i) => fileLines[index + i].text === line);
49
53
  if (matchAt(nominal)) return nominal;
50
- const maxDelta = Math.max(fileLines.length, 100);
51
- for (let delta = 1; delta <= maxDelta; delta++) {
54
+ if (!expectedOld.length) return -1;
55
+ for (let delta = 1; delta <= Math.max(fileLines.length, 100); delta++) {
52
56
  if (matchAt(nominal + delta)) return nominal + delta;
53
57
  if (matchAt(nominal - delta)) return nominal - delta;
54
58
  }
55
59
  return -1;
56
60
  }
57
61
 
58
- function splitHunkLines(hunk) {
59
- const expectedOld = [];
60
- const newLines = [];
61
- for (const hLine of hunk.lines) {
62
- if (hLine.startsWith("-")) {
63
- expectedOld.push(hLine.slice(1));
64
- } else if (hLine.startsWith("+")) {
65
- newLines.push(hLine.slice(1));
66
- } else {
67
- const val = hLine.startsWith(" ") ? hLine.slice(1) : "";
68
- expectedOld.push(val);
69
- newLines.push(val);
70
- }
71
- }
72
- return { expectedOld, newLines };
73
- }
74
-
75
62
  export function applyPatchToText(originalText, patchText) {
76
- if (!isString(patchText) || !patchText.trim()) {
77
- throw new Error("apply_patch requires non-empty patch");
78
- }
79
-
63
+ if (!isString(patchText) || !patchText.trim()) throw new Error("apply_patch requires non-empty patch");
80
64
  const hunks = parsePatchHunks(patchText);
81
- let fileLines = originalText.replace(/\r\n/g, "\n").split("\n");
82
- const hasTrailingNewline = originalText.endsWith("\n");
65
+ const fileLines = splitFile(originalText);
66
+ const ending = fileLines.find(line => line.ending)?.ending ?? "\n";
83
67
  let offsetShift = 0;
84
-
68
+ let relocationShift = 0;
85
69
  for (let h = 0; h < hunks.length; h++) {
86
70
  const hunk = hunks[h];
87
- const { expectedOld, newLines } = splitHunkLines(hunk);
88
-
89
- if (expectedOld.length !== hunk.oldLength || newLines.length !== hunk.newLength) {
90
- throw new Error(`patch hunk ${h + 1} length does not match its header`);
91
- }
92
-
93
- const nominal = Math.max(0, hunk.oldStart - 1 + offsetShift);
94
- const matchIdx = findHunkMatch(fileLines, expectedOld, nominal);
95
- if (matchIdx === -1) {
96
- throw new Error(`patch hunk ${h + 1} rejected at line ${hunk.oldStart}: context did not match`);
71
+ const textOf = line => line.slice(1).replace(/\r$/, "");
72
+ const expectedOld = hunk.lines.filter(line => line[0] !== "+").map(textOf);
73
+ const newCount = hunk.lines.filter(line => line[0] !== "-").length;
74
+ if (expectedOld.length !== hunk.oldLength || newCount !== hunk.newLength) throw new Error("patch hunk " + (h + 1) + " length does not match its header");
75
+ // The new coordinate also handles BSD diff's -1,0 header at file start.
76
+ const nominal = hunk.oldLength === 0 ? hunk.newStart - 1 + relocationShift : hunk.oldStart - 1 + offsetShift;
77
+ const matchIndex = findHunkMatch(fileLines, expectedOld, nominal);
78
+ if (matchIndex < 0) throw new Error("patch hunk " + (h + 1) + " rejected at line " + hunk.oldStart + ": context did not match");
79
+ const replacement = [];
80
+ let oldIndex = matchIndex;
81
+ for (let i = 0; i < hunk.lines.length; i++) {
82
+ const line = hunk.lines[i];
83
+ if (line[0] === "+") {
84
+ replacement.push({ text: textOf(line), ending: hunk.noNewline.includes(i) ? "" : line.endsWith("\r") ? "\r\n" : ending });
85
+ } else {
86
+ const original = fileLines[oldIndex++];
87
+ if (hunk.noNewline.includes(i) && original.ending) throw new Error("patch newline marker does not match the file");
88
+ if (line[0] === " ") replacement.push(original);
89
+ }
97
90
  }
98
-
99
- fileLines.splice(matchIdx, expectedOld.length, ...newLines);
100
- offsetShift += (matchIdx - nominal) + (newLines.length - expectedOld.length);
91
+ fileLines.splice(matchIndex, expectedOld.length, ...replacement);
92
+ relocationShift += matchIndex - nominal;
93
+ offsetShift += matchIndex - nominal + replacement.length - expectedOld.length;
101
94
  }
102
-
103
- let resultText = fileLines.join("\n");
104
- if (hasTrailingNewline && !resultText.endsWith("\n")) resultText += "\n";
105
- return { resultText, hunkCount: hunks.length };
95
+ return { resultText: fileLines.map(line => line.text + line.ending).join(""), hunkCount: hunks.length };
106
96
  }
package/render-measure.js CHANGED
@@ -1,135 +1,65 @@
1
- /**
2
- * Width / truncate primitives used by the compact renderer.
3
- * Self-contained so path-install never depends on a host truncate that can
4
- * append ellipsis after cutting to maxWidth (Pi 92>91 crash class).
5
- */
6
-
7
1
  import { stripVTControlCharacters } from "node:util";
2
+ import stringWidth from "string-width";
8
3
 
9
4
  const ELLIPSIS = "…";
5
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
10
6
 
11
- /**
12
- * Visible columns: ANSI/OSC stripped, tabs → 3 spaces.
13
- * ASCII-fast; non-ASCII uses a wide-char heuristic aligned with typical terminal
14
- * / pi-tui behavior (emoji & symbols like ⚡ are 2 cols; an undercount is the 92>91 crash).
15
- */
16
7
  export function measureWidth(text) {
17
- const raw = String(text ?? "").replace(/\t/g, " ");
18
- if (raw.length === 0) return 0;
19
- const plain = raw.includes("\x1b") ? stripVTControlCharacters(raw) : raw;
20
- if (/^[\x20-\x7e]*$/.test(plain)) return plain.length;
21
- let width = 0;
22
- for (const ch of plain) {
23
- width += codePointWidth(ch.codePointAt(0));
24
- }
25
- return width;
26
- }
27
-
28
- const ZERO_RANGES = [
29
- [0x00, 0x1f],
30
- [0x7f, 0x9f],
31
- [0x0300, 0x036f],
32
- [0x1ab0, 0x1aff],
33
- [0x1dc0, 0x1dff],
34
- [0x20d0, 0x20ff],
35
- [0xfe00, 0xfe0e],
36
- [0xfe20, 0xfe2f],
37
- ];
38
-
39
- const WIDE_RANGES = [
40
- [0x1100, 0x115f],
41
- [0x2e80, 0xa4cf],
42
- [0xac00, 0xd7a3],
43
- [0xf900, 0xfaff],
44
- [0xfe10, 0xfe19],
45
- [0xfe30, 0xfe6f],
46
- [0xff00, 0xff60],
47
- [0xffe0, 0xffe6],
48
- [0x1f000, 0x1faff],
49
- [0x20000, 0x3fffd],
50
- ];
51
-
52
- const WIDE_SINGLES = [0x2329, 0x232a, 0x26a1, 0x2b50, 0x2728];
53
-
54
- function inRanges(cp, ranges) {
55
- for (const [lo, hi] of ranges) {
56
- if (cp >= lo && cp <= hi) return true;
57
- }
58
- return false;
8
+ return stringWidth(String(text ?? "").replace(/\t/g, " "));
59
9
  }
60
10
 
61
- function codePointWidth(cp) {
62
- if (cp === 0xfe0f) return 1;
63
- if (cp === 0x200d) return 0;
64
- if (inRanges(cp, ZERO_RANGES)) return 0;
65
- if (WIDE_SINGLES.includes(cp)) return 2;
66
- if (inRanges(cp, WIDE_RANGES)) return 2;
67
- return 1;
11
+ function takePrefix(text, width) {
12
+ let end = 0;
13
+ let columns = 0;
14
+ for (const { segment, index } of segmenter.segment(text)) {
15
+ const next = measureWidth(segment);
16
+ if (columns + next > width) break;
17
+ columns += next;
18
+ end = index + segment.length;
19
+ }
20
+ return text.slice(0, end);
68
21
  }
69
22
 
70
- function takeChunk(text, start, width) {
71
- let end = start;
72
- let visible = 0;
73
- let lastBreak = -1;
74
- while (end < text.length) {
75
- const cp = text.codePointAt(end);
76
- const ch = cp > 0xffff ? text.slice(end, end + 2) : text[end];
77
- const cw = measureWidth(ch);
78
- if (visible + cw > width) break;
79
- visible += cw;
80
- end += ch.length;
81
- if (ch === "/" || ch === " ") lastBreak = end;
82
- }
83
- return { end, lastBreak };
84
- }
85
-
86
- /**
87
- * Truncate so the result's visible width is ALWAYS ≤ maxWidth, ellipsis included.
88
- * Strips ANSI in the truncated region (crash-safety > color fidelity on overflow).
89
- */
90
23
  export function hardTruncate(text, maxWidth, ellipsis = ELLIPSIS) {
91
- const w = Math.max(0, maxWidth | 0);
92
- if (w === 0) return "";
93
- const raw = String(text ?? "").replace(/\t/g, " ");
94
- if (measureWidth(raw) <= w) return raw;
95
- const ell = String(ellipsis);
96
- const ellW = measureWidth(ell);
97
- if (ellW >= w) {
98
- if (ellW === 0) return "";
99
- return ell.slice(0, w);
100
- }
101
- const budget = w - ellW;
102
- const plain = stripVTControlCharacters(raw);
103
- const { end } = takeChunk(plain, 0, budget);
104
- return plain.slice(0, end) + ell;
24
+ const width = Math.max(0, Math.floor(maxWidth));
25
+ if (!width) return "";
26
+ const raw = String(text ?? "").replace(/\t/g, " ");
27
+ if (measureWidth(raw) <= width) return raw;
28
+ const suffix = stripVTControlCharacters(String(ellipsis));
29
+ const suffixWidth = measureWidth(suffix);
30
+ if (suffixWidth >= width) return takePrefix(suffix, width);
31
+ return takePrefix(stripVTControlCharacters(raw), width - suffixWidth) + suffix;
105
32
  }
106
33
 
107
- /**
108
- * Absolute clamp used by every renderer. Loops + hard truncate; never returns > width.
109
- */
110
34
  export function clampLine(line, width) {
111
- const w = Math.max(1, width | 0);
112
- let out = String(line ?? "").replace(/\t/g, " ");
113
- if (measureWidth(out) <= w) return out;
114
- out = hardTruncate(out, w, ELLIPSIS);
115
- if (measureWidth(out) <= w) return out;
116
- const plain = stripVTControlCharacters(out);
117
- if (plain.length <= w) return plain;
118
- if (w === 1) return ELLIPSIS;
119
- return plain.slice(0, Math.max(0, w - 1)) + ELLIPSIS;
35
+ return hardTruncate(line, width);
120
36
  }
121
37
 
122
- /**
123
- * Fit a filesystem path into `budget` columns, keeping the basename visible.
124
- */
125
- export function fitPath(pathText, budget) {
126
- const w = Math.max(1, budget | 0);
127
- let p = String(pathText ?? "").replace(/\\/g, "/");
128
- if (measureWidth(p) <= w) return p;
129
- const parts = p.split("/").filter(Boolean);
130
- const base = parts.length > 0 ? parts[parts.length - 1] : p;
131
- const suffix = parts.length > 1 ? `…/${base}` : base;
132
- if (measureWidth(suffix) <= w) return suffix;
133
- return hardTruncate(base, w);
38
+ /** Wrap complete, already-sanitized result text without splitting graphemes. */
39
+ export function wrapLine(line, width) {
40
+ if (width <= 0) return [];
41
+ const text = String(line).replace(/\t/g, " ");
42
+ if (measureWidth(text) <= width) return [text];
43
+ const out = [];
44
+ let current = "";
45
+ let columns = 0;
46
+ for (const { segment } of segmenter.segment(text)) {
47
+ const size = measureWidth(segment);
48
+ if (columns + size > width && current) { out.push(current); current = ""; columns = 0; }
49
+ if (size > width) { out.push(ELLIPSIS); continue; }
50
+ current += segment;
51
+ columns += size;
52
+ }
53
+ if (current) out.push(current);
54
+ return out;
134
55
  }
135
56
 
57
+ export function fitPath(pathText, budget) {
58
+ const width = Math.max(0, Math.floor(budget));
59
+ const text = String(pathText ?? "").replace(/\\/g, "/");
60
+ if (measureWidth(text) <= width) return text;
61
+ const parts = text.split("/").filter(Boolean);
62
+ const base = parts.at(-1) ?? text;
63
+ const suffix = parts.length > 1 ? "…/" + base : base;
64
+ return measureWidth(suffix) <= width ? suffix : hardTruncate(base, width);
65
+ }
package/render.js CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { stripVTControlCharacters } from "node:util";
14
14
  import { isString, isObject, isFunction } from "./decode.js";
15
- import { measureWidth, hardTruncate, clampLine, fitPath } from "./render-measure.js";
15
+ import { measureWidth, hardTruncate, clampLine, fitPath, wrapLine } from "./render-measure.js";
16
16
  import { novaFramedBlock, novaStatusLine } from "./omp-frame.js";
17
17
  import { formatValue } from "./format.js";
18
18
 
@@ -232,7 +232,6 @@ export function renderSupernovaCall(a, b, c) {
232
232
 
233
233
  const TOOL_COL = 7;
234
234
  const DURATION_COL = 6;
235
- const PREVIEW_LINES = 24;
236
235
 
237
236
  function formatDuration(ms) {
238
237
  if (!Number.isFinite(ms) || ms < 0) return "";
@@ -310,12 +309,9 @@ function operationsFor(payload, context) {
310
309
  return operationsFromTrace(payload?.trace || context?.state?.trace || []);
311
310
  }
312
311
 
313
- function resultLines(value, maxLines) {
312
+ function resultLines(value, width) {
314
313
  const text = isString(value) ? value : formatValue(value);
315
- const lines = cleanBlockText(text).split("\n");
316
- const shown = lines.slice(0, maxLines);
317
- if (lines.length > maxLines) shown.push(`… ${lines.length - maxLines} more lines`);
318
- return shown;
314
+ return cleanBlockText(text).split("\n").flatMap(line => wrapLine(line, width));
319
315
  }
320
316
 
321
317
  function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError) {
@@ -327,15 +323,18 @@ function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, is
327
323
  if (ops.length > maxOps) lines.push(theme.fg("dim", ` … ${ops.length - maxOps} more calls`));
328
324
  }
329
325
 
330
- function appendTail(lines, theme, payload, expanded, isError) {
331
- if (isError) lines.push(theme.fg("error", "✗ " + (payload?.error ? cleanBlockText(payload.error) : "error")));
326
+ function appendTail(lines, theme, payload, expanded, isError, width) {
327
+ if (isError) {
328
+ const error = "✗ " + (payload?.error ? cleanBlockText(payload.error) : "error");
329
+ for (const line of expanded ? resultLines(error, width) : error.split("\n")) lines.push(theme.fg("error", line));
330
+ }
332
331
  else if (expanded && payload?.result !== undefined) {
333
332
  lines.push(theme.fg("dim", "── result ──"));
334
- for (const line of resultLines(payload.result, PREVIEW_LINES)) lines.push(theme.fg("toolOutput", line));
333
+ for (const line of resultLines(payload.result, width)) lines.push(theme.fg("toolOutput", line));
335
334
  }
336
335
  if (expanded && payload?.logs?.length) {
337
336
  lines.push(theme.fg("dim", "── logs ──"));
338
- for (const log of payload.logs.slice(0, PREVIEW_LINES)) lines.push(theme.fg("dim", cleanBlockText(log)));
337
+ for (const log of payload.logs) for (const line of resultLines(log, width)) lines.push(theme.fg("dim", line));
339
338
  }
340
339
  }
341
340
 
@@ -345,7 +344,7 @@ function buildBodyLines(theme, width, { payload, context, args, expanded, isPart
345
344
  const maxDiffLines = expanded ? 24 : 8;
346
345
  const lines = [];
347
346
  appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError);
348
- appendTail(lines, theme, payload, expanded, isError);
347
+ appendTail(lines, theme, payload, expanded, isError, width);
349
348
  return { lines, opCount: ops.length };
350
349
  }
351
350
 
@@ -367,7 +366,7 @@ class UnifiedResultCard {
367
366
  }
368
367
  render(width = 80) {
369
368
  const { theme, model } = this;
370
- if (!theme || !model) return [];
369
+ if (!theme || !model || width <= 0) return [];
371
370
  if (this.cache?.width === width) return this.cache.lines;
372
371
  const view = buildBodyLines(theme, Math.max(1, width - 4), model);
373
372
  const header = novaStatusLine(theme, {
package/repo-index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { extractStructuralSurface } from "./surface.js";
4
+ import { isFunction } from "./decode.js";
4
5
  import { Frecency } from "./fuzzy.js";
5
6
  import { relativeSlash } from "./workspace.js";
6
7
 
@@ -112,7 +113,7 @@ export class WorkspaceIndex {
112
113
  this.watchers.set(root, false);
113
114
  this.lists.clear();
114
115
  });
115
- if (typeof watcher.unref === "function") watcher.unref();
116
+ if (isFunction(watcher.unref)) watcher.unref();
116
117
  ok = true;
117
118
  } catch {
118
119
  ok = false;