@pi-archimedes/diff 1.4.1 → 1.4.2
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/package.json +3 -2
- package/src/ansi/manip.ts +17 -15
- package/src/ansi/width.ts +107 -0
- package/src/render/shared.ts +25 -28
- package/src/render/split.ts +3 -1
- package/src/render/unified.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/diff",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"diff": "^8.0.0",
|
|
18
18
|
"shiki": "^4.0.0",
|
|
19
|
-
"@shikijs/cli": "^4.0.2"
|
|
19
|
+
"@shikijs/cli": "^4.0.2",
|
|
20
|
+
"get-east-asian-width": "^1.6.0"
|
|
20
21
|
},
|
|
21
22
|
"peerDependencies": {
|
|
22
23
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
package/src/ansi/manip.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { relative } from "node:path";
|
|
4
4
|
import * as C from "./codes.js";
|
|
5
|
+
import { tokenize } from "./width.js";
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// ANSI manipulation
|
|
@@ -17,26 +18,27 @@ export function tabs(s: string): string {
|
|
|
17
18
|
return s.replace(/\t/g, " ");
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
/** Pad/truncate `s` to exactly `w` visible
|
|
21
|
+
/** Pad/truncate `s` to exactly `w` visible columns. ANSI + grapheme-aware. */
|
|
21
22
|
export function fit(s: string, w: number): string {
|
|
22
23
|
if (w <= 0) return "";
|
|
23
|
-
const
|
|
24
|
-
|
|
24
|
+
const tokens = tokenize(s);
|
|
25
|
+
const total = tokens.reduce((sum, t) => sum + t.width, 0);
|
|
26
|
+
if (total <= w) return s + " ".repeat(w - total);
|
|
25
27
|
const showW = w > 2 ? w - 1 : w;
|
|
26
28
|
let vis = 0,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
vis++;
|
|
37
|
-
i++;
|
|
29
|
+
out = "";
|
|
30
|
+
for (const tok of tokens) {
|
|
31
|
+
// A grapheme cluster is atomic — never split it mid-render. A cluster
|
|
32
|
+
// wider than the entire budget (only possible when showW is 1 and the
|
|
33
|
+
// grapheme is wide) is dropped rather than overflowing the target width.
|
|
34
|
+
if (tok.width > showW) continue;
|
|
35
|
+
if (vis + tok.width > showW) break;
|
|
36
|
+
out += tok.ansi + tok.text;
|
|
37
|
+
vis += tok.width;
|
|
38
38
|
}
|
|
39
|
-
return w > 2
|
|
39
|
+
return w > 2
|
|
40
|
+
? `${out}${C.RST}${" ".repeat(Math.max(0, showW - vis))}${C.FG_DIM}›${C.RST}`
|
|
41
|
+
: `${out}${C.RST}${" ".repeat(Math.max(0, w - vis))}`;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** Extract last active fg + bg ANSI codes from a string. Used for wrapping continuations. */
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI + grapheme-aware width utilities.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors pi-tui's own `visibleWidth()` semantics (east-asian-width + emoji
|
|
5
|
+
* heuristics) so diff rendering never emits rows that violate the TUI's
|
|
6
|
+
* "line must not exceed terminal width" invariant. Plain `.length` on a JS
|
|
7
|
+
* string undercounts wide/emoji graphemes (e.g. "✅" is 1 UTF-16 code unit
|
|
8
|
+
* but occupies 2 terminal columns), which silently produces over-wide lines.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { eastAsianWidth } from "get-east-asian-width";
|
|
12
|
+
|
|
13
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
14
|
+
|
|
15
|
+
const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/u;
|
|
16
|
+
const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/u;
|
|
17
|
+
// RGI_Emoji requires the `v` flag (set notation); fall back to a broad
|
|
18
|
+
// Extended_Pictographic + presentation-selector heuristic under `u`, which
|
|
19
|
+
// is good enough here since couldBeEmoji() already pre-filters candidates.
|
|
20
|
+
const rgiEmojiRegex = /^[\p{Extended_Pictographic}\u{1F1E6}-\u{1F1FF}]/u;
|
|
21
|
+
|
|
22
|
+
function couldBeEmoji(segment: string): boolean {
|
|
23
|
+
const cp = segment.codePointAt(0);
|
|
24
|
+
if (cp === undefined) return false;
|
|
25
|
+
return (
|
|
26
|
+
(cp >= 0x1f000 && cp <= 0x1fbff) ||
|
|
27
|
+
(cp >= 0x2300 && cp <= 0x23ff) ||
|
|
28
|
+
(cp >= 0x2600 && cp <= 0x27bf) ||
|
|
29
|
+
(cp >= 0x2b50 && cp <= 0x2b55) ||
|
|
30
|
+
segment.includes("\uFE0F") ||
|
|
31
|
+
segment.length > 2
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Terminal column width of a single grapheme cluster. */
|
|
36
|
+
export function graphemeWidth(segment: string): number {
|
|
37
|
+
// Tabs are normally expanded to 2 spaces upstream via Ansi.tabs(), but
|
|
38
|
+
// guard here too so these utilities are robust to raw tab input.
|
|
39
|
+
if (segment === "\t") return 2;
|
|
40
|
+
if (zeroWidthRegex.test(segment)) return 0;
|
|
41
|
+
if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) return 2;
|
|
42
|
+
const base = segment.replace(leadingNonPrintingRegex, "");
|
|
43
|
+
const cp = base.codePointAt(0);
|
|
44
|
+
if (cp === undefined) return 0;
|
|
45
|
+
if (cp >= 0x1f1e6 && cp <= 0x1f1ff) return 2;
|
|
46
|
+
let width = eastAsianWidth(cp);
|
|
47
|
+
if (segment.length > 1) {
|
|
48
|
+
for (const char of segment.slice(1)) {
|
|
49
|
+
const c = char.codePointAt(0);
|
|
50
|
+
if (c === undefined) continue;
|
|
51
|
+
if (c >= 0xff00 && c <= 0xffef) width += eastAsianWidth(c);
|
|
52
|
+
else if (c === 0x0e33 || c === 0x0eb3) width += 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return width;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One grapheme cluster, plus any ANSI SGR codes immediately preceding it. */
|
|
59
|
+
export interface WidthToken {
|
|
60
|
+
ansi: string;
|
|
61
|
+
text: string;
|
|
62
|
+
width: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Tokenize an ANSI-encoded string into (ansi-prefix, grapheme, width) triples.
|
|
67
|
+
* Only SGR (`ESC[...m`) sequences are recognized as ANSI — matches the rest
|
|
68
|
+
* of this package's ANSI handling, which never emits other CSI/OSC codes.
|
|
69
|
+
*/
|
|
70
|
+
export function tokenize(s: string): WidthToken[] {
|
|
71
|
+
const tokens: WidthToken[] = [];
|
|
72
|
+
let pendingAnsi = "";
|
|
73
|
+
let i = 0;
|
|
74
|
+
while (i < s.length) {
|
|
75
|
+
if (s[i] === "\x1b") {
|
|
76
|
+
const end = s.indexOf("m", i);
|
|
77
|
+
if (end !== -1) {
|
|
78
|
+
pendingAnsi += s.slice(i, end + 1);
|
|
79
|
+
i = end + 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Bare ESC with no "m" terminator anywhere after it — not a valid
|
|
83
|
+
// SGR sequence. Treat it as a zero-width literal char so `i` always
|
|
84
|
+
// advances (otherwise this would loop forever).
|
|
85
|
+
tokens.push({ ansi: pendingAnsi, text: "", width: 0 });
|
|
86
|
+
pendingAnsi = "";
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
let end = i;
|
|
91
|
+
while (end < s.length && s[end] !== "\x1b") end++;
|
|
92
|
+
for (const { segment } of graphemeSegmenter.segment(s.slice(i, end))) {
|
|
93
|
+
tokens.push({ ansi: pendingAnsi, text: segment, width: graphemeWidth(segment) });
|
|
94
|
+
pendingAnsi = "";
|
|
95
|
+
}
|
|
96
|
+
i = end;
|
|
97
|
+
}
|
|
98
|
+
if (pendingAnsi) tokens.push({ ansi: pendingAnsi, text: "", width: 0 });
|
|
99
|
+
return tokens;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Visible terminal-column width of an ANSI-encoded string. */
|
|
103
|
+
export function visibleWidth(s: string): number {
|
|
104
|
+
let total = 0;
|
|
105
|
+
for (const tok of tokenize(s)) total += tok.width;
|
|
106
|
+
return total;
|
|
107
|
+
}
|
package/src/render/shared.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Shared constants, config, and helpers for diff rendering. */
|
|
2
2
|
|
|
3
3
|
import * as Ansi from "../ansi/index.js";
|
|
4
|
+
import { tokenize } from "../ansi/width.js";
|
|
4
5
|
import type { ParsedDiff } from "../core/diff.js";
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -42,61 +43,55 @@ export function adaptiveWrapRows(w: number): number {
|
|
|
42
43
|
return MAX_WRAP_ROWS_NARROW;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/** Wrap ANSI-encoded string into rows of `w` visible
|
|
46
|
+
/** Wrap ANSI-encoded string into rows of `w` visible columns. Grapheme + east-asian-width aware. */
|
|
46
47
|
export function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(w), fillBg = "", rst = Ansi.RST): string[] {
|
|
47
48
|
if (w <= 0) return [""];
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
const tokens = tokenize(s);
|
|
50
|
+
const total = tokens.reduce((sum, t) => sum + t.width, 0);
|
|
51
|
+
if (total <= w) {
|
|
52
|
+
const pad = w - total;
|
|
51
53
|
return pad > 0 ? [s + fillBg + " ".repeat(pad) + (fillBg ? rst : "")] : [s];
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
const rows: string[] = [];
|
|
55
|
-
let row = "", vis = 0,
|
|
57
|
+
let row = "", vis = 0, ti = 0;
|
|
56
58
|
let onLastRow = false;
|
|
57
59
|
let effW = w;
|
|
58
60
|
|
|
59
|
-
while (
|
|
61
|
+
while (ti < tokens.length) {
|
|
60
62
|
if (!onLastRow && rows.length >= maxRows - 1) {
|
|
61
63
|
onLastRow = true;
|
|
62
64
|
effW = w > 2 ? w - 1 : w;
|
|
63
65
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
const tok = tokens[ti]!;
|
|
67
|
+
// A grapheme cluster is atomic — never split it mid-render. If it's wider
|
|
68
|
+
// than the row even on its own (only possible when effW < its width,
|
|
69
|
+
// e.g. a wide emoji in a 1-column row), drop it rather than overflow.
|
|
70
|
+
if (tok.width > effW) {
|
|
71
|
+
ti++;
|
|
72
|
+
continue;
|
|
71
73
|
}
|
|
72
|
-
if (vis
|
|
74
|
+
if (vis + tok.width > effW) {
|
|
73
75
|
if (onLastRow) {
|
|
74
|
-
|
|
75
|
-
for (let j = i; j < s.length; j++) {
|
|
76
|
-
if (s[j] === "\x1b") {
|
|
77
|
-
const e2 = s.indexOf("m", j);
|
|
78
|
-
if (e2 !== -1) { j = e2; continue; }
|
|
79
|
-
}
|
|
80
|
-
hasMore = true;
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
76
|
+
const hasMore = ti < tokens.length;
|
|
83
77
|
if (hasMore && w > 2) row += `${rst}${Ansi.FG_DIM}›${rst}`;
|
|
84
78
|
else row += fillBg + " ".repeat(Math.max(0, w - vis)) + rst;
|
|
85
79
|
rows.push(row);
|
|
86
80
|
return rows;
|
|
87
81
|
}
|
|
88
82
|
const state = Ansi.ansiState(row);
|
|
89
|
-
rows.push(row + rst);
|
|
83
|
+
rows.push(row + fillBg + " ".repeat(Math.max(0, w - vis)) + rst);
|
|
90
84
|
row = state + fillBg;
|
|
91
85
|
vis = 0;
|
|
92
86
|
if (rows.length >= maxRows - 1) {
|
|
93
87
|
onLastRow = true;
|
|
94
88
|
effW = w > 2 ? w - 1 : w;
|
|
95
89
|
}
|
|
90
|
+
continue;
|
|
96
91
|
}
|
|
97
|
-
row +=
|
|
98
|
-
vis
|
|
99
|
-
|
|
92
|
+
row += tok.ansi + tok.text;
|
|
93
|
+
vis += tok.width;
|
|
94
|
+
ti++;
|
|
100
95
|
}
|
|
101
96
|
if (row.length > 0 || rows.length === 0) {
|
|
102
97
|
rows.push(row + fillBg + " ".repeat(Math.max(0, w - vis)) + rst);
|
|
@@ -112,7 +107,9 @@ export function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVI
|
|
|
112
107
|
const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
113
108
|
const half = Math.floor((tw - 1) / 2);
|
|
114
109
|
const gw = nw + 5;
|
|
115
|
-
|
|
110
|
+
// Keep in sync with renderSplitLines' cw formula so this heuristic agrees
|
|
111
|
+
// with what the renderer will actually use.
|
|
112
|
+
const cw = Math.max(1, half - gw);
|
|
116
113
|
if (cw < cfg.diffSplitMinCodeWidth) return false;
|
|
117
114
|
|
|
118
115
|
const vis = diff.lines.slice(0, maxRows);
|
package/src/render/split.ts
CHANGED
|
@@ -69,7 +69,9 @@ export async function renderSplitLines(
|
|
|
69
69
|
const half = Math.floor((width - 1) / 2);
|
|
70
70
|
const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
71
71
|
const gw = nw + 5;
|
|
72
|
-
|
|
72
|
+
// Never let the content column exceed what's actually available — on very
|
|
73
|
+
// narrow terminals the gutter alone can approach `half`.
|
|
74
|
+
const cw = Math.max(1, half - gw);
|
|
73
75
|
const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
|
|
74
76
|
|
|
75
77
|
const leftSrc: string[] = [], rightSrc: string[] = [];
|
package/src/render/unified.ts
CHANGED
|
@@ -48,7 +48,9 @@ export async function renderUnifiedLines(
|
|
|
48
48
|
const vis = diff.lines.slice(0, max);
|
|
49
49
|
const nw = Math.max(2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
50
50
|
const gw = nw + 5;
|
|
51
|
-
|
|
51
|
+
// Never let the content column exceed what's actually available — on very
|
|
52
|
+
// narrow terminals the gutter alone can approach `width`.
|
|
53
|
+
const cw = Math.max(1, width - gw);
|
|
52
54
|
const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES;
|
|
53
55
|
const rst = dbg.rst;
|
|
54
56
|
const bgBase = dbg.bgBase;
|