pi-supernova 0.0.11 → 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/CHANGELOG.md +74 -3
- package/README.md +72 -21
- package/bottleneck.js +98 -97
- package/catalog.js +12 -36
- package/check.js +166 -0
- package/config.default.json +1 -0
- package/config.js +1 -2
- package/decode.js +57 -6
- package/evidence.js +27 -35
- package/format.js +16 -17
- package/fuzzy.js +182 -0
- package/guest-worker.js +38 -157
- package/host-bridge.js +246 -124
- package/index.js +95 -123
- package/ledger.js +150 -0
- package/omp-frame.js +7 -23
- package/outline.js +80 -0
- package/package.json +11 -2
- package/parallel.js +42 -34
- package/patch.js +62 -72
- package/render-measure.js +46 -144
- package/render.js +43 -158
- package/repo-index.js +106 -11
- package/runtime.js +204 -227
- package/search.js +141 -0
- package/snap.js +1 -6
- package/surface.js +22 -13
- package/vfs.js +114 -75
- package/workspace.js +51 -33
package/parallel.js
CHANGED
|
@@ -1,47 +1,55 @@
|
|
|
1
|
+
import { isFunction } from "./decode.js";
|
|
1
2
|
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "snap", "evidence", "surface", "asgrep_search", "asgrep_status", "ast_grep", "web_search"]);
|
|
4
|
+
const READ_ONLY_LSP = new Set(["definition", "references", "hover", "symbols", "diagnostics", "implementation", "type_definition", "incoming_calls", "outgoing_calls"]);
|
|
5
|
+
|
|
6
|
+
export function isMutatingTool(name, config = {}, args = {}, definition) {
|
|
7
|
+
if ((config.mutatingTools ?? []).includes(name)) return true;
|
|
8
|
+
if ((config.mutatingPrefixes ?? []).some(prefix => prefix && name.startsWith(prefix))) return true;
|
|
9
|
+
if (definition?.annotations?.readOnlyHint === true) return false;
|
|
10
|
+
if (name === "lsp") {
|
|
11
|
+
const action = args.action ?? args.operation;
|
|
12
|
+
if (READ_ONLY_LSP.has(action)) return false;
|
|
13
|
+
if (["rename", "rename_file"].includes(action)) return args.apply !== false;
|
|
14
|
+
if (action === "code_actions") return args.apply === true;
|
|
15
|
+
return true;
|
|
9
16
|
}
|
|
10
|
-
return
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const out = [];
|
|
14
|
-
for (const thunk of list) out.push(await thunk());
|
|
15
|
-
return out;
|
|
17
|
+
if (name === "todo") return args.op !== "view";
|
|
18
|
+
if (name === "hub") return !["list", "ps", "logs", "describe"].includes(args.op);
|
|
19
|
+
return !READ_ONLY_TOOLS.has(name);
|
|
16
20
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
return count > 1;
|
|
21
|
+
|
|
22
|
+
function requireArray(value, name) {
|
|
23
|
+
if (!Array.isArray(value)) throw new TypeError(name + " requires an array");
|
|
24
|
+
return value;
|
|
22
25
|
}
|
|
26
|
+
|
|
23
27
|
export async function runParallelWave(thunks, meta, options = {}) {
|
|
24
|
-
const list =
|
|
25
|
-
if (list.
|
|
28
|
+
const list = requireArray(thunks, "parallel wave");
|
|
29
|
+
if (list.some(item => !isFunction(item))) throw new TypeError("parallel wave requires functions");
|
|
30
|
+
if (!list.length) return { results: [], mode: "serial", reason: "empty" };
|
|
26
31
|
const { mode = "auto", config = {} } = options;
|
|
27
|
-
const names =
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const names = meta?.names ?? [];
|
|
33
|
+
const mutating = names.length !== list.length || names.some((name, i) => isMutatingTool(name, config, meta?.calls?.[i]?.args, meta?.definitions?.[i]));
|
|
34
|
+
if (!mutating && (mode === "parallel" || (mode === "auto" && list.length > 1))) {
|
|
35
|
+
// Do not finish a wave while its already-started host calls are still running.
|
|
36
|
+
const settled = await Promise.allSettled(list.map(thunk => Promise.resolve().then(thunk)));
|
|
37
|
+
const failure = settled.find(item => item.status === "rejected");
|
|
38
|
+
if (failure) throw failure.reason;
|
|
39
|
+
return { results: settled.map(item => item.value), mode: "parallel", reason: "independent-reads" };
|
|
32
40
|
}
|
|
33
|
-
const results =
|
|
34
|
-
|
|
35
|
-
return { results, mode: "serial", reason: "single-or-forced" };
|
|
41
|
+
const results = [];
|
|
42
|
+
for (const thunk of list) results.push(await thunk());
|
|
43
|
+
return { results, mode: "serial", reason: mutating ? "mutating" : "single-or-forced" };
|
|
36
44
|
}
|
|
45
|
+
|
|
37
46
|
export async function parallel(items) {
|
|
38
|
-
|
|
39
|
-
return Promise.all(list.map((item) => (item instanceof Function ? item() : item)));
|
|
47
|
+
return Promise.all(requireArray(items, "parallel").map(item => isFunction(item) ? item() : item));
|
|
40
48
|
}
|
|
49
|
+
|
|
41
50
|
export async function pipeline(items, ...stages) {
|
|
42
|
-
let current =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
51
|
+
let current = requireArray(items, "pipeline");
|
|
52
|
+
if (stages.some(stage => !isFunction(stage))) throw new TypeError("pipeline stages must be functions");
|
|
53
|
+
for (const stage of stages) current = await Promise.all(current.map(item => stage(item)));
|
|
46
54
|
return current;
|
|
47
55
|
}
|
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
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
for (let delta = 1; 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
|
-
|
|
82
|
-
const
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
offsetShift +=
|
|
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,163 +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 — undercount ⇒ 92>91 crash).
|
|
15
|
-
*/
|
|
16
7
|
export function measureWidth(text) {
|
|
17
|
-
|
|
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;
|
|
8
|
+
return stringWidth(String(text ?? "").replace(/\t/g, " "));
|
|
26
9
|
}
|
|
27
10
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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;
|
|
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);
|
|
59
21
|
}
|
|
60
22
|
|
|
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;
|
|
68
|
-
}
|
|
69
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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;
|
|
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;
|
|
147
55
|
}
|
|
148
56
|
|
|
149
|
-
/**
|
|
150
|
-
* Fit a filesystem path into `budget` columns, keeping the basename visible.
|
|
151
|
-
*/
|
|
152
57
|
export function fitPath(pathText, budget) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return hardTruncate(base, w);
|
|
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);
|
|
161
65
|
}
|
|
162
|
-
|
|
163
|
-
export { ELLIPSIS };
|