@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/diff",
3
- "version": "1.4.1",
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 chars. ANSI-aware. */
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 plain = strip(s);
24
- if (plain.length <= w) return s + " ".repeat(w - plain.length);
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
- i = 0;
28
- while (i < s.length && vis < showW) {
29
- if (s[i] === "\x1b") {
30
- const e = s.indexOf("m", i);
31
- if (e !== -1) {
32
- i = e + 1;
33
- continue;
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 ? `${s.slice(0, i)}${C.RST}${C.FG_DIM}›${C.RST}` : `${s.slice(0, i)}${C.RST}`;
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
+ }
@@ -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 chars. */
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 plain = Ansi.strip(s);
49
- if (plain.length <= w) {
50
- const pad = w - plain.length;
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, i = 0;
57
+ let row = "", vis = 0, ti = 0;
56
58
  let onLastRow = false;
57
59
  let effW = w;
58
60
 
59
- while (i < s.length) {
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
- if (s[i] === "\x1b") {
65
- const end = s.indexOf("m", i);
66
- if (end !== -1) {
67
- row += s.slice(i, end + 1);
68
- i = end + 1;
69
- continue;
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 >= effW) {
74
+ if (vis + tok.width > effW) {
73
75
  if (onLastRow) {
74
- let hasMore = false;
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 += s[i];
98
- vis++;
99
- i++;
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
- const cw = Math.max(12, half - gw);
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);
@@ -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
- const cw = Math.max(12, half - gw);
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[] = [];
@@ -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
- const cw = Math.max(20, width - gw);
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;