@kgdiff/cli 0.0.0-stage → 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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -2
  3. package/dist/apply-command.d.ts +34 -0
  4. package/dist/apply-command.d.ts.map +1 -0
  5. package/dist/apply-command.js +135 -0
  6. package/dist/apply-command.js.map +1 -0
  7. package/dist/args.d.ts +96 -0
  8. package/dist/args.d.ts.map +1 -0
  9. package/dist/args.js +463 -0
  10. package/dist/args.js.map +1 -0
  11. package/dist/bin.d.ts +3 -0
  12. package/dist/bin.d.ts.map +1 -0
  13. package/dist/bin.js +17 -0
  14. package/dist/bin.js.map +1 -0
  15. package/dist/diff-command.d.ts +29 -0
  16. package/dist/diff-command.d.ts.map +1 -0
  17. package/dist/diff-command.js +236 -0
  18. package/dist/diff-command.js.map +1 -0
  19. package/dist/errors.d.ts +48 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +92 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/help.d.ts +11 -0
  24. package/dist/help.d.ts.map +1 -0
  25. package/dist/help.js +247 -0
  26. package/dist/help.js.map +1 -0
  27. package/dist/html.d.ts +53 -0
  28. package/dist/html.d.ts.map +1 -0
  29. package/dist/html.js +107 -0
  30. package/dist/html.js.map +1 -0
  31. package/dist/index.d.ts +25 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +24 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/inputs.d.ts +54 -0
  36. package/dist/inputs.d.ts.map +1 -0
  37. package/dist/inputs.js +204 -0
  38. package/dist/inputs.js.map +1 -0
  39. package/dist/io.d.ts +64 -0
  40. package/dist/io.d.ts.map +1 -0
  41. package/dist/io.js +2 -0
  42. package/dist/io.js.map +1 -0
  43. package/dist/locale.d.ts +58 -0
  44. package/dist/locale.d.ts.map +1 -0
  45. package/dist/locale.js +121 -0
  46. package/dist/locale.js.map +1 -0
  47. package/dist/main.d.ts +10 -0
  48. package/dist/main.d.ts.map +1 -0
  49. package/dist/main.js +51 -0
  50. package/dist/main.js.map +1 -0
  51. package/dist/markdown.d.ts +30 -0
  52. package/dist/markdown.d.ts.map +1 -0
  53. package/dist/markdown.js +93 -0
  54. package/dist/markdown.js.map +1 -0
  55. package/dist/model.d.ts +63 -0
  56. package/dist/model.d.ts.map +1 -0
  57. package/dist/model.js +151 -0
  58. package/dist/model.js.map +1 -0
  59. package/dist/node-io.d.ts +12 -0
  60. package/dist/node-io.d.ts.map +1 -0
  61. package/dist/node-io.js +135 -0
  62. package/dist/node-io.js.map +1 -0
  63. package/dist/output.d.ts +27 -0
  64. package/dist/output.d.ts.map +1 -0
  65. package/dist/output.js +50 -0
  66. package/dist/output.js.map +1 -0
  67. package/dist/overwrite.d.ts +14 -0
  68. package/dist/overwrite.d.ts.map +1 -0
  69. package/dist/overwrite.js +55 -0
  70. package/dist/overwrite.js.map +1 -0
  71. package/dist/report.d.ts +52 -0
  72. package/dist/report.d.ts.map +1 -0
  73. package/dist/report.js +64 -0
  74. package/dist/report.js.map +1 -0
  75. package/dist/style.d.ts +46 -0
  76. package/dist/style.d.ts.map +1 -0
  77. package/dist/style.js +140 -0
  78. package/dist/style.js.map +1 -0
  79. package/dist/summary.d.ts +40 -0
  80. package/dist/summary.d.ts.map +1 -0
  81. package/dist/summary.js +352 -0
  82. package/dist/summary.js.map +1 -0
  83. package/dist/version.d.ts +6 -0
  84. package/dist/version.d.ts.map +1 -0
  85. package/dist/version.js +19 -0
  86. package/dist/version.js.map +1 -0
  87. package/dist/width.d.ts +45 -0
  88. package/dist/width.d.ts.map +1 -0
  89. package/dist/width.js +232 -0
  90. package/dist/width.js.map +1 -0
  91. package/package.json +56 -3
  92. package/src/apply-command.ts +201 -0
  93. package/src/args.ts +605 -0
  94. package/src/bin.ts +22 -0
  95. package/src/diff-command.ts +299 -0
  96. package/src/errors.ts +131 -0
  97. package/src/help.ts +286 -0
  98. package/src/html.ts +145 -0
  99. package/src/index.ts +71 -0
  100. package/src/inputs.ts +264 -0
  101. package/src/io.ts +68 -0
  102. package/src/locale.ts +177 -0
  103. package/src/main.ts +56 -0
  104. package/src/markdown.ts +119 -0
  105. package/src/model.ts +181 -0
  106. package/src/node-io.ts +146 -0
  107. package/src/output.ts +81 -0
  108. package/src/overwrite.ts +75 -0
  109. package/src/report.ts +101 -0
  110. package/src/style.ts +164 -0
  111. package/src/summary.ts +440 -0
  112. package/src/version.ts +21 -0
  113. package/src/width.ts +222 -0
package/src/style.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Terminal colors: support detection (`--color`, TTY, `NO_COLOR`, `FORCE_COLOR`, `COLORTERM`,
3
+ * `TERM`) and a tiny painter for the meaning families of docs/visual-language.md.
4
+ *
5
+ * Colors are the visual-language family colors: the dark ("night") base colors on dark or
6
+ * unknown backgrounds, the light ("paper") strong colors when `COLORFGBG` reports a light
7
+ * background. Truecolor terminals get the exact values, 256-color terminals the nearest xterm
8
+ * palette entry, others the closest of the 16 basic colors.
9
+ */
10
+
11
+ export type ColorMode = 'auto' | 'always' | 'never';
12
+
13
+ /** 0 = no color, 1 = 16 colors, 2 = 256 colors, 3 = truecolor. */
14
+ export type ColorLevel = 0 | 1 | 2 | 3;
15
+
16
+ export type Env = Readonly<Record<string, string | undefined>>;
17
+
18
+ /** Color tones used by the CLI: the five meaning families plus UI accents. */
19
+ export type Tone = 'added' | 'removed' | 'content' | 'structure' | 'identity' | 'accent';
20
+
21
+ /**
22
+ * `FORCE_COLOR`: `0` / `false` → 0, `1` / `2` / `3` → exactly that level, `''` / `true` /
23
+ * anything else → `'depth'` (on, at the terminal's depth); unset → `undefined`.
24
+ */
25
+ function forceColorLevel(value: string | undefined): ColorLevel | 'depth' | undefined {
26
+ if (value === undefined) return undefined;
27
+ const v = value.trim().toLowerCase();
28
+ if (v === 'false' || v === '0') return 0;
29
+ if (v === '1') return 1;
30
+ if (v === '2') return 2;
31
+ if (v === '3') return 3;
32
+ return 'depth';
33
+ }
34
+
35
+ /** Color depth the terminal supports, assuming color is wanted at all. */
36
+ export function terminalDepth(env: Env): ColorLevel {
37
+ const colorterm = (env.COLORTERM ?? '').toLowerCase();
38
+ if (colorterm === 'truecolor' || colorterm === '24bit') return 3;
39
+ const term = (env.TERM ?? '').toLowerCase();
40
+ if (env.WT_SESSION || term === 'xterm-kitty' || term === 'xterm-ghostty' || term === 'wezterm') {
41
+ return 3;
42
+ }
43
+ if (env.GITHUB_ACTIONS === 'true') return 3;
44
+ if (/-256(colou?r)?$/.test(term) || env.TERM_PROGRAM === 'Apple_Terminal') return 2;
45
+ return 1;
46
+ }
47
+
48
+ /**
49
+ * Resolve the color level.
50
+ *
51
+ * - `never` → 0.
52
+ * - `always` → the level `FORCE_COLOR` names (1–3), else the terminal depth (at least 16
53
+ * colors), even when `NO_COLOR` is set.
54
+ * - `auto`: `FORCE_COLOR` wins (`0`/`false` disables, `1`–`3` force exactly that level, other
55
+ * values the terminal depth), then a non-empty `NO_COLOR` disables, then color only when the
56
+ * stream is a TTY and `TERM` is not `dumb`.
57
+ */
58
+ export function resolveColorLevel(mode: ColorMode, env: Env, isTTY: boolean): ColorLevel {
59
+ if (mode === 'never') return 0;
60
+ const forced = forceColorLevel(env.FORCE_COLOR);
61
+ const depth = (): ColorLevel => {
62
+ const d = terminalDepth(env);
63
+ return d === 0 ? 1 : d;
64
+ };
65
+ if (mode === 'always') return typeof forced === 'number' && forced > 0 ? forced : depth();
66
+ if (forced !== undefined) return forced === 'depth' ? depth() : forced;
67
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return 0;
68
+ if (!isTTY || env.TERM === 'dumb') return 0;
69
+ return terminalDepth(env);
70
+ }
71
+
72
+ /** True when `COLORFGBG` (rxvt, Konsole, iTerm2…) reports a light background. */
73
+ export function isLightBackground(env: Env): boolean {
74
+ const parts = (env.COLORFGBG ?? '').split(';');
75
+ const bg = Number(parts[parts.length - 1]);
76
+ return bg === 7 || bg === 15;
77
+ }
78
+
79
+ type Rgb = readonly [number, number, number];
80
+
81
+ interface ToneColors {
82
+ readonly dark: Rgb;
83
+ readonly light: Rgb;
84
+ /** SGR foreground code of the nearest basic color. */
85
+ readonly basic: number;
86
+ }
87
+
88
+ const hex = (value: string): Rgb => {
89
+ const n = Number.parseInt(value.slice(1), 16);
90
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
91
+ };
92
+
93
+ /** docs/visual-language.md: dark `family.*.base`, light `family.*.strong`. */
94
+ const TONES: Readonly<Record<Tone, ToneColors>> = {
95
+ added: { dark: hex('#34D399'), light: hex('#15803D'), basic: 32 },
96
+ removed: { dark: hex('#F87171'), light: hex('#B91C1C'), basic: 31 },
97
+ content: { dark: hex('#FBBF24'), light: hex('#B45309'), basic: 33 },
98
+ structure: { dark: hex('#38BDF8'), light: hex('#0369A1'), basic: 36 },
99
+ identity: { dark: hex('#A78BFA'), light: hex('#6D28D9'), basic: 35 },
100
+ accent: { dark: hex('#7C9CFF'), light: hex('#2F49B5'), basic: 34 },
101
+ };
102
+
103
+ const CUBE = [0, 95, 135, 175, 215, 255] as const;
104
+
105
+ function nearestCube(v: number): number {
106
+ let best = 0;
107
+ for (let i = 1; i < CUBE.length; i++) {
108
+ if (Math.abs((CUBE[i] as number) - v) < Math.abs((CUBE[best] as number) - v)) best = i;
109
+ }
110
+ return best;
111
+ }
112
+
113
+ /** Nearest xterm-256 palette index (6×6×6 cube or gray ramp) by squared RGB distance. */
114
+ export function ansi256([r, g, b]: Rgb): number {
115
+ const ri = nearestCube(r);
116
+ const gi = nearestCube(g);
117
+ const bi = nearestCube(b);
118
+ const cube: Rgb = [CUBE[ri] as number, CUBE[gi] as number, CUBE[bi] as number];
119
+ const grayLevel = Math.min(23, Math.max(0, Math.round(((r + g + b) / 3 - 8) / 10)));
120
+ const gray = 8 + grayLevel * 10;
121
+ const dist = (c: Rgb): number => (c[0] - r) ** 2 + (c[1] - g) ** 2 + (c[2] - b) ** 2;
122
+ return dist([gray, gray, gray]) < dist(cube) ? 232 + grayLevel : 16 + 36 * ri + 6 * gi + bi;
123
+ }
124
+
125
+ /** Applies colors and text attributes; every method is the identity at level 0. */
126
+ export interface Painter {
127
+ readonly level: ColorLevel;
128
+ tone(tone: Tone, text: string): string;
129
+ bold(text: string): string;
130
+ dim(text: string): string;
131
+ }
132
+
133
+ /** Create a painter for `level`. `light` picks colors that read on light backgrounds. */
134
+ export function createPainter(level: ColorLevel, light = false): Painter {
135
+ if (level === 0) {
136
+ const id = (text: string): string => text;
137
+ return { level, tone: (_tone, text) => text, bold: id, dim: id };
138
+ }
139
+ const open: Record<Tone, string> = {} as Record<Tone, string>;
140
+ for (const [name, colors] of Object.entries(TONES) as [Tone, ToneColors][]) {
141
+ const rgb = light ? colors.light : colors.dark;
142
+ open[name] =
143
+ level === 3
144
+ ? `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`
145
+ : level === 2
146
+ ? `\x1b[38;5;${ansi256(rgb)}m`
147
+ : `\x1b[${colors.basic}m`;
148
+ }
149
+ const wrap = (start: string, end: string) => (text: string) =>
150
+ text === '' ? '' : `${start}${text}${end}`;
151
+ return {
152
+ level,
153
+ tone: (tone, text) => (text === '' ? '' : `${open[tone]}${text}\x1b[39m`),
154
+ // SGR 22 ends both bold and dim; the CLI never nests the two.
155
+ bold: wrap('\x1b[1m', '\x1b[22m'),
156
+ dim: wrap('\x1b[2m', '\x1b[22m'),
157
+ };
158
+ }
159
+
160
+ /** Remove SGR escape sequences (for measuring or comparing painted text). */
161
+ export function stripAnsi(text: string): string {
162
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the point.
163
+ return text.replace(/\x1b\[[0-9;]*m/g, '');
164
+ }
package/src/summary.ts ADDED
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Terminal outputs: the default summary (header, counters, diffstat bar, hunk list), `--stat`
3
+ * and `--name-status` (docs/cli.md §Terminal summary).
4
+ *
5
+ * Every line is laid out on plain text first (measured with `stringWidth`, truncated with an
6
+ * ellipsis) and painted last, so alignment holds with and without color and with CJK labels.
7
+ */
8
+ import { type DiffResult, formatHunk, type Hunk, type Locale } from '@kgdiff/core';
9
+ import { formatInt, type Messages, messages } from './locale.js';
10
+ import {
11
+ apportion,
12
+ COUNTER_PHASES,
13
+ groupByPhase,
14
+ hunkFamily,
15
+ hunkGlyph,
16
+ nameStatus,
17
+ type Overview,
18
+ overview,
19
+ PHASE_FAMILY,
20
+ PHASE_GLYPH,
21
+ } from './model.js';
22
+ import type { Painter } from './style.js';
23
+ import { padEnd, padStart, sanitize, stringWidth, truncate, wrap } from './width.js';
24
+
25
+ export interface TextContext {
26
+ readonly paint: Painter;
27
+ readonly locale: Locale;
28
+ /** Maximum line width in columns. */
29
+ readonly width: number;
30
+ /** Maximum hunks listed in the summary; 0 lists all. */
31
+ readonly limit: number;
32
+ }
33
+
34
+ const INDENT = ' ';
35
+ const BRAND = 'kgdiff';
36
+ /** The layout never stretches beyond what it needs, nor below this width. */
37
+ const COMFORT_WIDTH = 72;
38
+ const MAX_BAR = 50;
39
+ const COLUMN_GAP = 3;
40
+ /** Narrowest title column worth keeping a family column for. */
41
+ const MIN_TITLE = 16;
42
+ /**
43
+ * Room kept for the summary after the family label before titles are truncated (at most a
44
+ * quarter of the line).
45
+ */
46
+ const MIN_SUMMARY = 22;
47
+
48
+ /** `179 → 184 nodes · 351 → 353 edges` (single numbers when a count did not change). */
49
+ export function totalsText(diff: DiffResult, m: Messages): string {
50
+ const { before, after } = diff.stats;
51
+ return m.totals([before.nodes, after.nodes], [before.edges, after.edges]);
52
+ }
53
+
54
+ /** Left and right text on one line, right-aligned to `width`; wraps the right part if needed. */
55
+ function spread(
56
+ left: { plain: string; painted: string },
57
+ right: { plain: string; painted: string },
58
+ width: number,
59
+ ): string[] {
60
+ const gap = width - stringWidth(left.plain) - stringWidth(right.plain);
61
+ if (!right.plain) return [left.painted];
62
+ if (gap >= COLUMN_GAP) return [left.painted + ' '.repeat(gap) + right.painted];
63
+ const room = width - INDENT.length;
64
+ return [
65
+ left.painted,
66
+ INDENT + (stringWidth(right.plain) <= room ? right.painted : truncate(right.plain, room)),
67
+ ];
68
+ }
69
+
70
+ function headerLines(diff: DiffResult, title: string, ctx: TextContext, width: number): string[] {
71
+ const m = messages(ctx.locale);
72
+ const { paint } = ctx;
73
+ const right = totalsText(diff, m);
74
+ const fixed = BRAND.length + 2;
75
+ const fitsBeside = width - fixed - COLUMN_GAP - stringWidth(right) >= 12;
76
+ const titleRoom = fitsBeside ? width - fixed - COLUMN_GAP - stringWidth(right) : width - fixed;
77
+ const shown = truncate(sanitize(title), titleRoom);
78
+ const left = {
79
+ plain: `${BRAND} ${shown}`,
80
+ painted: `${paint.bold(paint.tone('accent', BRAND))} ${paint.bold(shown)}`,
81
+ };
82
+ return spread(left, { plain: right, painted: paint.dim(right) }, width);
83
+ }
84
+
85
+ interface Painted {
86
+ readonly plain: string;
87
+ readonly painted: string;
88
+ }
89
+
90
+ function counterItems(ov: Overview, m: Messages, paint: Painter): Painted[] {
91
+ const items: Painted[] = [];
92
+ for (const phase of COUNTER_PHASES) {
93
+ const n = ov.phases[phase].hunks;
94
+ if (n === 0) continue;
95
+ const count = `${PHASE_GLYPH[phase]}${formatInt(n)}`;
96
+ items.push({
97
+ plain: `${count} ${m.counter[phase]}`,
98
+ painted: `${paint.bold(paint.tone(PHASE_FAMILY[phase], count))} ${m.counter[phase]}`,
99
+ });
100
+ }
101
+ return items;
102
+ }
103
+
104
+ const COUNTER_GAP = ' ';
105
+
106
+ /** Counter items joined on one line, or wrapped over several lines to fit `room`. */
107
+ function counterLines(items: readonly Painted[], room: number): Painted[] {
108
+ const lines: { plain: string[]; painted: string[] }[] = [];
109
+ for (const item of items) {
110
+ const line = lines[lines.length - 1];
111
+ const width = line ? stringWidth(line.plain.join(COUNTER_GAP)) + COUNTER_GAP.length : 0;
112
+ if (line && width + stringWidth(item.plain) <= room) {
113
+ line.plain.push(item.plain);
114
+ line.painted.push(item.painted);
115
+ } else lines.push({ plain: [item.plain], painted: [item.painted] });
116
+ }
117
+ return lines.map((l) => ({
118
+ plain: l.plain.join(COUNTER_GAP),
119
+ painted: l.painted.join(COUNTER_GAP),
120
+ }));
121
+ }
122
+
123
+ /** Proportional diffstat bar: one segment per phase, sized by changed elements. */
124
+ export function diffstatBar(ov: Overview, width: number, paint: Painter): string {
125
+ const cells = apportion(
126
+ COUNTER_PHASES.map((p) => ov.phases[p].changes),
127
+ Math.min(ov.changes, Math.max(10, Math.min(MAX_BAR, width - INDENT.length * 2))),
128
+ );
129
+ return COUNTER_PHASES.map((phase, i) => {
130
+ const n = cells[i] as number;
131
+ if (n === 0) return '';
132
+ return paint.level === 0
133
+ ? PHASE_GLYPH[phase].repeat(n)
134
+ : paint.tone(PHASE_FAMILY[phase], '█'.repeat(n));
135
+ }).join('');
136
+ }
137
+
138
+ function statLines(ov: Overview, ctx: TextContext, width: number): string[] {
139
+ const m = messages(ctx.locale);
140
+ const right = m.changes(ov.changes, ov.hunks);
141
+ const rows = counterLines(counterItems(ov, m, ctx.paint), width - INDENT.length);
142
+ const lines = rows.slice(0, -1).map((r) => INDENT + r.painted);
143
+ const last = rows[rows.length - 1] ?? { plain: '', painted: '' };
144
+ lines.push(
145
+ ...spread(
146
+ { plain: INDENT + last.plain, painted: INDENT + last.painted },
147
+ { plain: right, painted: ctx.paint.dim(right) },
148
+ width,
149
+ ),
150
+ );
151
+ lines.push(INDENT + diffstatBar(ov, width, ctx.paint));
152
+ return lines;
153
+ }
154
+
155
+ function identicalLines(diff: DiffResult, ctx: TextContext, width: number): string[] {
156
+ const m = messages(ctx.locale);
157
+ const { paint } = ctx;
158
+ const detail = m.identicalDetail(diff.stats.after.nodes, diff.stats.after.edges);
159
+ const head = `✓ ${m.identical}`;
160
+ const room = width - INDENT.length;
161
+ if (stringWidth(head) + 2 + stringWidth(detail) <= room) {
162
+ return [
163
+ `${INDENT}${paint.tone('added', '✓')} ${paint.bold(m.identical)} ${paint.dim(detail)}`,
164
+ ];
165
+ }
166
+ return [
167
+ `${INDENT}${paint.tone('added', '✓')} ${paint.bold(m.identical)}`,
168
+ ...wrap(detail, room).map((line) => INDENT + paint.dim(line)),
169
+ ];
170
+ }
171
+
172
+ interface Row {
173
+ readonly hunk: Hunk;
174
+ readonly glyph: string;
175
+ readonly title: string;
176
+ readonly family: string;
177
+ readonly subtitle: string;
178
+ }
179
+
180
+ function rowsOf(diff: DiffResult, hunks: readonly Hunk[], ctx: TextContext): Row[] {
181
+ const m = messages(ctx.locale);
182
+ return hunks.map((hunk) => {
183
+ const { title, subtitle } = formatHunk(hunk, diff, ctx.locale);
184
+ return {
185
+ hunk,
186
+ glyph: hunkGlyph(hunk),
187
+ title: sanitize(title),
188
+ family: m.family[hunkFamily(hunk)],
189
+ subtitle: sanitize(subtitle),
190
+ };
191
+ });
192
+ }
193
+
194
+ const rightText = (row: Row): string =>
195
+ row.subtitle ? `${row.family} · ${row.subtitle}` : row.family;
196
+
197
+ /** Natural width of the hunk list (before any truncation). */
198
+ function listWidth(rows: readonly Row[]): number {
199
+ let title = 0;
200
+ let right = 0;
201
+ for (const row of rows) {
202
+ title = Math.max(title, stringWidth(row.title));
203
+ right = Math.max(right, stringWidth(rightText(row)));
204
+ }
205
+ return INDENT.length + 2 + title + COLUMN_GAP + right;
206
+ }
207
+
208
+ /**
209
+ * Column widths for the hunk list. Titles come first: they get all the room they need as long
210
+ * as the family column keeps its family label plus a short summary; the summaries take the rest.
211
+ * Below ~40 columns the family column is dropped.
212
+ */
213
+ function listColumns(rows: readonly Row[], width: number): { title: number; right: number } {
214
+ const room = width - INDENT.length - 2 - COLUMN_GAP;
215
+ let maxTitle = 0;
216
+ let maxRight = 0;
217
+ let maxFamily = 0;
218
+ for (const row of rows) {
219
+ maxTitle = Math.max(maxTitle, stringWidth(row.title));
220
+ maxRight = Math.max(maxRight, stringWidth(rightText(row)));
221
+ maxFamily = Math.max(maxFamily, stringWidth(row.family));
222
+ }
223
+ if (maxTitle + maxRight <= room) return { title: maxTitle, right: maxRight };
224
+ if (room - maxFamily < MIN_TITLE) return { title: width - INDENT.length - 2, right: 0 };
225
+ const minRight = Math.min(maxRight, maxFamily + Math.min(MIN_SUMMARY, Math.floor(room / 4)));
226
+ const title = Math.max(MIN_TITLE, Math.min(maxTitle, room - minRight));
227
+ return { title, right: Math.max(maxFamily, Math.min(maxRight, room - title)) };
228
+ }
229
+
230
+ function renderRow(row: Row, cols: { title: number; right: number }, ctx: TextContext): string {
231
+ const { paint } = ctx;
232
+ const tone = hunkFamily(row.hunk);
233
+ const glyph = paint.tone(tone, row.glyph);
234
+ if (cols.right === 0) return `${INDENT}${glyph} ${truncate(row.title, cols.title)}`;
235
+ const title = padEnd(truncate(row.title, cols.title), cols.title);
236
+ const familyWidth = stringWidth(row.family);
237
+ const restRoom = cols.right - familyWidth;
238
+ const rest = row.subtitle && restRoom >= 6 ? truncate(` · ${row.subtitle}`, restRoom) : '';
239
+ return (
240
+ `${INDENT}${glyph} ${title}${' '.repeat(COLUMN_GAP)}` +
241
+ `${paint.tone(tone, row.family)}${paint.dim(rest)}`
242
+ );
243
+ }
244
+
245
+ /** Line width for a layout whose content naturally needs `natural` columns. */
246
+ function layoutWidth(natural: number, available: number): number {
247
+ return Math.min(available, Math.max(natural, COMFORT_WIDTH));
248
+ }
249
+
250
+ function headerNatural(diff: DiffResult, title: string, ctx: TextContext, ov: Overview): number {
251
+ const m = messages(ctx.locale);
252
+ const header = BRAND.length + 2 + stringWidth(sanitize(title)) + COLUMN_GAP;
253
+ const counters =
254
+ INDENT.length +
255
+ stringWidth(
256
+ counterItems(ov, m, ctx.paint)
257
+ .map((i) => i.plain)
258
+ .join(COUNTER_GAP),
259
+ ) +
260
+ COLUMN_GAP;
261
+ return Math.max(
262
+ header + stringWidth(totalsText(diff, m)),
263
+ counters + stringWidth(m.changes(ov.changes, ov.hunks)),
264
+ );
265
+ }
266
+
267
+ /**
268
+ * The default terminal output: header (title, node and edge totals), per-phase counters, a
269
+ * proportional diffstat bar, then the hunks grouped by phase with glyph, title and a family /
270
+ * summary column. At most `ctx.limit` hunks are listed (0 = all), then "… and N more".
271
+ */
272
+ export function renderSummary(diff: DiffResult, title: string, ctx: TextContext): string {
273
+ const ov = overview(diff);
274
+ if (ov.hunks === 0) {
275
+ const width = layoutWidth(headerNatural(diff, title, ctx, ov), ctx.width);
276
+ return `${[...headerLines(diff, title, ctx, width), '', ...identicalLines(diff, ctx, width)].join('\n')}\n`;
277
+ }
278
+ const m = messages(ctx.locale);
279
+ const groups = groupByPhase(diff.hunks);
280
+ let shown = 0;
281
+ const budget = ctx.limit > 0 ? ctx.limit : Number.POSITIVE_INFINITY;
282
+ const listed = groups.map((g) => {
283
+ const take = g.hunks.slice(0, Math.max(0, budget - shown));
284
+ shown += take.length;
285
+ return { phase: g.phase, total: g.hunks.length, rows: rowsOf(diff, take, ctx) };
286
+ });
287
+ const allRows = listed.flatMap((g) => g.rows);
288
+ const width = layoutWidth(
289
+ Math.max(headerNatural(diff, title, ctx, ov), listWidth(allRows)),
290
+ ctx.width,
291
+ );
292
+ const cols = listColumns(allRows, width);
293
+ const lines = [...headerLines(diff, title, ctx, width), '', ...statLines(ov, ctx, width), ''];
294
+ for (const group of listed) {
295
+ if (group.rows.length === 0) continue;
296
+ lines.push(
297
+ `${INDENT}${ctx.paint.bold(m.phase[group.phase])} ${ctx.paint.dim(formatInt(group.total))}`,
298
+ );
299
+ for (const row of group.rows) lines.push(renderRow(row, cols, ctx));
300
+ }
301
+ const hidden = ov.hunks - shown;
302
+ if (hidden > 0) {
303
+ const more = `${m.more(hidden)} · ${m.moreHint}`;
304
+ lines.push(INDENT + ctx.paint.dim(truncate(more, width - INDENT.length)));
305
+ }
306
+ return `${lines.join('\n')}\n`;
307
+ }
308
+
309
+ const MAX_TYPE_ROWS = 12;
310
+ const MAX_TYPE_NAME = 28;
311
+
312
+ /**
313
+ * `--stat`: header, counters and bar, a nodes / edges × added / removed / modified / unchanged
314
+ * table, and a git-style per-type breakdown (`+` added, `−` removed, `~` modified nodes).
315
+ */
316
+ export function renderStat(diff: DiffResult, title: string, ctx: TextContext): string {
317
+ const ov = overview(diff);
318
+ const width = layoutWidth(headerNatural(diff, title, ctx, ov), ctx.width);
319
+ const lines = [...headerLines(diff, title, ctx, width), ''];
320
+ if (ov.hunks === 0) return `${[...lines, ...identicalLines(diff, ctx, width)].join('\n')}\n`;
321
+ lines.push(...statLines(ov, ctx, width), '');
322
+ lines.push(...countsTable(diff, ctx, width), '');
323
+ lines.push(...typeLines(diff, ctx, width));
324
+ return `${lines.join('\n')}\n`;
325
+ }
326
+
327
+ /** Node / edge counts; columns are dropped from the right when the line is too narrow. */
328
+ function countsTable(diff: DiffResult, ctx: TextContext, width: number): string[] {
329
+ const m = messages(ctx.locale);
330
+ const { paint } = ctx;
331
+ const { nodes, edges } = diff.stats;
332
+ const all = [
333
+ { label: m.table.added, key: 'added', tone: 'added' },
334
+ { label: m.table.removed, key: 'removed', tone: 'removed' },
335
+ { label: m.table.modified, key: 'modified', tone: 'content' },
336
+ { label: m.table.unchanged, key: 'unchanged', tone: undefined },
337
+ ] as const;
338
+ const rows = [
339
+ { label: m.table.nodes, counts: nodes },
340
+ { label: m.table.edges, counts: edges },
341
+ ];
342
+ const labelWidth = Math.max(...rows.map((r) => stringWidth(r.label)));
343
+ const widthOf = (c: (typeof all)[number]): number =>
344
+ Math.max(stringWidth(c.label), ...rows.map((r) => formatInt(r.counts[c.key]).length));
345
+ const columns: (typeof all)[number][] = [...all];
346
+ while (
347
+ columns.length > 1 &&
348
+ INDENT.length + labelWidth + columns.reduce((sum, c) => sum + 2 + widthOf(c), 0) > width
349
+ ) {
350
+ columns.pop();
351
+ }
352
+ const widths = columns.map(widthOf);
353
+ const head =
354
+ INDENT +
355
+ ' '.repeat(labelWidth) +
356
+ columns.map((c, i) => ` ${padStart(c.label, widths[i] as number)}`).join('');
357
+ const body = rows.map(
358
+ (r) =>
359
+ INDENT +
360
+ padEnd(r.label, labelWidth) +
361
+ columns
362
+ .map((c, i) => {
363
+ const n = r.counts[c.key];
364
+ const cell = padStart(formatInt(n), widths[i] as number);
365
+ return ` ${c.tone && n > 0 ? paint.tone(c.tone, cell) : cell}`;
366
+ })
367
+ .join(''),
368
+ );
369
+ return [paint.dim(head), ...body];
370
+ }
371
+
372
+ function typeLines(diff: DiffResult, ctx: TextContext, width: number): string[] {
373
+ const m = messages(ctx.locale);
374
+ const { paint } = ctx;
375
+ const changed = diff.stats.byType.filter((t) => t.added + t.removed + t.modified > 0);
376
+ if (changed.length === 0) return [];
377
+ const shown = changed.slice(0, MAX_TYPE_ROWS);
378
+ const totals = shown.map((t) => t.added + t.removed + t.modified);
379
+ const countRoom = Math.max(...totals.map((n) => formatInt(n).length));
380
+ // Names give way first on narrow lines: indent, two spaces, count, space, ≥ 4 bar cells.
381
+ const nameRoom = Math.max(
382
+ 4,
383
+ Math.min(MAX_TYPE_NAME, width - INDENT.length - 2 - countRoom - 1 - 4),
384
+ );
385
+ const names = shown.map((t) => truncate(sanitize(t.type) || m.untyped, nameRoom));
386
+ const nameWidth = Math.max(...names.map(stringWidth));
387
+ const countWidth = Math.max(...totals.map((n) => formatInt(n).length));
388
+ const maxTotal = Math.max(...totals);
389
+ const barRoom = Math.max(1, Math.min(30, width - INDENT.length - nameWidth - countWidth - 3));
390
+ const lines = [INDENT + paint.bold(m.byType)];
391
+ shown.forEach((t, i) => {
392
+ const total = totals[i] as number;
393
+ const size =
394
+ maxTotal <= barRoom ? total : Math.max(1, Math.round((total / maxTotal) * barRoom));
395
+ const [a, r, mod] = apportion([t.added, t.removed, t.modified], size);
396
+ const bar =
397
+ paint.tone('added', '+'.repeat(a ?? 0)) +
398
+ paint.tone('removed', '−'.repeat(r ?? 0)) +
399
+ paint.tone('content', '~'.repeat(mod ?? 0));
400
+ lines.push(
401
+ `${INDENT}${padEnd(names[i] as string, nameWidth)} ${padStart(formatInt(total), countWidth)} ${bar}`,
402
+ );
403
+ });
404
+ if (changed.length > shown.length) {
405
+ lines.push(
406
+ INDENT +
407
+ paint.dim(truncate(m.moreTypes(changed.length - shown.length), width - INDENT.length)),
408
+ );
409
+ }
410
+ return lines;
411
+ }
412
+
413
+ /**
414
+ * `--name-status`: one line per hunk in review order: status letter, anchor id, title.
415
+ * Tab-separated (scriptable) unless `aligned`, which pads the columns for a terminal.
416
+ */
417
+ export function renderNameStatus(diff: DiffResult, ctx: TextContext, aligned: boolean): string {
418
+ if (diff.hunks.length === 0) return '';
419
+ const rows = diff.hunks.map((hunk) => ({
420
+ hunk,
421
+ letter: nameStatus(hunk, diff),
422
+ id: sanitize(hunk.anchor),
423
+ title: sanitize(formatHunk(hunk, diff, ctx.locale).title),
424
+ }));
425
+ const { paint } = ctx;
426
+ if (!aligned) {
427
+ return `${rows.map((r) => `${paint.tone(hunkFamily(r.hunk), r.letter)}\t${r.id}\t${r.title}`).join('\n')}\n`;
428
+ }
429
+ const idWidth = Math.min(
430
+ rows.reduce((max, r) => Math.max(max, stringWidth(r.id)), 0),
431
+ Math.max(8, Math.floor(ctx.width / 3)),
432
+ );
433
+ const titleRoom = Math.max(1, ctx.width - 1 - 2 - idWidth - 2);
434
+ return `${rows
435
+ .map((r) => {
436
+ const id = padEnd(truncate(r.id, idWidth), idWidth);
437
+ return `${paint.bold(paint.tone(hunkFamily(r.hunk), r.letter))} ${paint.dim(id)} ${truncate(r.title, titleRoom)}`;
438
+ })
439
+ .join('\n')}\n`;
440
+ }
package/src/version.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ let cached: string | undefined;
4
+
5
+ /**
6
+ * The CLI version from its package.json (one directory above both `src/` and `dist/`).
7
+ * Read lazily and once.
8
+ */
9
+ export function cliVersion(): string {
10
+ if (cached === undefined) {
11
+ try {
12
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
13
+ version?: unknown;
14
+ };
15
+ cached = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
16
+ } catch {
17
+ cached = '0.0.0';
18
+ }
19
+ }
20
+ return cached;
21
+ }