@heyhuynhgiabuu/pi-diff 0.2.2 → 0.3.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/README.md +117 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +55 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/diff.d.ts +14 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +72 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1738 -0
- package/dist/index.js.map +1 -0
- package/dist/review/command.d.ts +2 -0
- package/dist/review/command.d.ts.map +1 -0
- package/dist/review/command.js +365 -0
- package/dist/review/command.js.map +1 -0
- package/dist/review/export.d.ts +7 -0
- package/dist/review/export.d.ts.map +1 -0
- package/dist/review/export.js +77 -0
- package/dist/review/export.js.map +1 -0
- package/dist/review/file-preview.d.ts +9 -0
- package/dist/review/file-preview.d.ts.map +1 -0
- package/dist/review/file-preview.js +228 -0
- package/dist/review/file-preview.js.map +1 -0
- package/dist/review/git.d.ts +49 -0
- package/dist/review/git.d.ts.map +1 -0
- package/dist/review/git.js +200 -0
- package/dist/review/git.js.map +1 -0
- package/dist/review/hunk-preview.d.ts +25 -0
- package/dist/review/hunk-preview.d.ts.map +1 -0
- package/dist/review/hunk-preview.js +973 -0
- package/dist/review/hunk-preview.js.map +1 -0
- package/dist/review/interactive.d.ts +27 -0
- package/dist/review/interactive.d.ts.map +1 -0
- package/dist/review/interactive.js +146 -0
- package/dist/review/interactive.js.map +1 -0
- package/dist/review/model.d.ts +33 -0
- package/dist/review/model.d.ts.map +1 -0
- package/dist/review/model.js +208 -0
- package/dist/review/model.js.map +1 -0
- package/dist/review/prompt.d.ts +4 -0
- package/dist/review/prompt.d.ts.map +1 -0
- package/dist/review/prompt.js +43 -0
- package/dist/review/prompt.js.map +1 -0
- package/dist/review/session.d.ts +69 -0
- package/dist/review/session.d.ts.map +1 -0
- package/dist/review/session.js +190 -0
- package/dist/review/session.js.map +1 -0
- package/dist/review/tui.d.ts +57 -0
- package/dist/review/tui.d.ts.map +1 -0
- package/dist/review/tui.js +486 -0
- package/dist/review/tui.js.map +1 -0
- package/media/review-diff.png +0 -0
- package/package.json +69 -5
- package/prompts/review-diff-agent.md +19 -0
- package/biome.json +0 -29
- package/src/index.ts +0 -1635
- package/tsconfig.json +0 -19
package/src/index.ts
DELETED
|
@@ -1,1635 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pi-diff — Shiki-powered terminal diff renderer for pi.
|
|
3
|
-
*
|
|
4
|
-
* @module pi-diff
|
|
5
|
-
* @see https://github.com/buddingnewinsights/pi-diff
|
|
6
|
-
*
|
|
7
|
-
* Architecture (like OpenTUI / delta):
|
|
8
|
-
* 1. Syntax-highlight full code blocks via Shiki → ANSI (fg-only codes)
|
|
9
|
-
* 2. Layer diff background colors underneath (composites at cell level)
|
|
10
|
-
* 3. For word-level changes, inject brighter bg at changed char positions
|
|
11
|
-
* 4. Result: syntax fg + diff bg + word emphasis — all three visible together
|
|
12
|
-
*
|
|
13
|
-
* Views:
|
|
14
|
-
* • Split (side-by-side) — edit tool, auto-falls back to unified on narrow terminals
|
|
15
|
-
* • Unified (stacked) — write tool overwrites
|
|
16
|
-
*
|
|
17
|
-
* Performance:
|
|
18
|
-
* • Singleton Shiki highlighter (managed by @shikijs/cli)
|
|
19
|
-
* • LRU memo cache per highlighted block
|
|
20
|
-
* • Large-diff fallback (skip highlighting, still show diff)
|
|
21
|
-
* • Async rendering with invalidate() for non-blocking preview
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
-
import { extname, relative } from "node:path";
|
|
26
|
-
|
|
27
|
-
import { codeToANSI } from "@shikijs/cli";
|
|
28
|
-
import * as Diff from "diff";
|
|
29
|
-
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
30
|
-
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
// Diff Theme System — presets, auto-derive, and per-color overrides
|
|
33
|
-
//
|
|
34
|
-
// Resolution chain (per color, highest priority first):
|
|
35
|
-
// 1. Environment variable override (e.g. DIFF_BG_ADD="#1a3320")
|
|
36
|
-
// 2. diffColors.bgAdd from .pi/settings.json (explicit per-color hex)
|
|
37
|
-
// 3. diffTheme preset value (named preset like "midnight")
|
|
38
|
-
// 4. Auto-derived from pi theme fg colors (default behavior)
|
|
39
|
-
// 5. Hardcoded fallback
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
/** Hex color palette for a diff theme preset. All values "#RRGGBB". */
|
|
43
|
-
interface DiffPreset {
|
|
44
|
-
name: string;
|
|
45
|
-
description: string;
|
|
46
|
-
shikiTheme?: string;
|
|
47
|
-
bgAdd?: string;
|
|
48
|
-
bgDel?: string;
|
|
49
|
-
bgAddHighlight?: string;
|
|
50
|
-
bgDelHighlight?: string;
|
|
51
|
-
bgGutterAdd?: string;
|
|
52
|
-
bgGutterDel?: string;
|
|
53
|
-
bgEmpty?: string;
|
|
54
|
-
fgAdd?: string;
|
|
55
|
-
fgDel?: string;
|
|
56
|
-
fgDim?: string;
|
|
57
|
-
fgLnum?: string;
|
|
58
|
-
fgRule?: string;
|
|
59
|
-
fgStripe?: string;
|
|
60
|
-
fgSafeMuted?: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** User diff config read from .pi/settings.json */
|
|
64
|
-
interface DiffUserConfig {
|
|
65
|
-
diffTheme?: string;
|
|
66
|
-
diffColors?: Record<string, string>;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const DIFF_PRESETS: Record<string, DiffPreset> = {
|
|
70
|
-
default: {
|
|
71
|
-
name: "default",
|
|
72
|
-
description: "Original pi-diff colors — tuned for dark theme bases (~#1e1e2e)",
|
|
73
|
-
bgAdd: "#162620",
|
|
74
|
-
bgDel: "#2d1919",
|
|
75
|
-
bgAddHighlight: "#234b32",
|
|
76
|
-
bgDelHighlight: "#502323",
|
|
77
|
-
bgGutterAdd: "#12201a",
|
|
78
|
-
bgGutterDel: "#261616",
|
|
79
|
-
bgEmpty: "#121212",
|
|
80
|
-
fgDim: "#505050",
|
|
81
|
-
fgLnum: "#646464",
|
|
82
|
-
fgRule: "#323232",
|
|
83
|
-
fgStripe: "#282828",
|
|
84
|
-
fgSafeMuted: "#8b949e",
|
|
85
|
-
},
|
|
86
|
-
midnight: {
|
|
87
|
-
name: "midnight",
|
|
88
|
-
description: "Subtle tints for pure black (#000000) terminal backgrounds",
|
|
89
|
-
bgAdd: "#0d1a12",
|
|
90
|
-
bgDel: "#1a0d0d",
|
|
91
|
-
bgAddHighlight: "#1a3825",
|
|
92
|
-
bgDelHighlight: "#381a1a",
|
|
93
|
-
bgGutterAdd: "#091208",
|
|
94
|
-
bgGutterDel: "#120908",
|
|
95
|
-
bgEmpty: "#080808",
|
|
96
|
-
fgDim: "#404040",
|
|
97
|
-
fgLnum: "#505050",
|
|
98
|
-
fgRule: "#282828",
|
|
99
|
-
fgStripe: "#1e1e1e",
|
|
100
|
-
fgSafeMuted: "#8b949e",
|
|
101
|
-
},
|
|
102
|
-
subtle: {
|
|
103
|
-
name: "subtle",
|
|
104
|
-
description: "Minimal backgrounds — barely-there tints for a clean look",
|
|
105
|
-
bgAdd: "#081008",
|
|
106
|
-
bgDel: "#100808",
|
|
107
|
-
bgAddHighlight: "#122818",
|
|
108
|
-
bgDelHighlight: "#281212",
|
|
109
|
-
bgGutterAdd: "#060c06",
|
|
110
|
-
bgGutterDel: "#0c0606",
|
|
111
|
-
bgEmpty: "#060606",
|
|
112
|
-
fgDim: "#383838",
|
|
113
|
-
fgLnum: "#484848",
|
|
114
|
-
fgRule: "#242424",
|
|
115
|
-
fgStripe: "#181818",
|
|
116
|
-
fgSafeMuted: "#8b949e",
|
|
117
|
-
},
|
|
118
|
-
neon: {
|
|
119
|
-
name: "neon",
|
|
120
|
-
description: "Higher contrast backgrounds for better visibility",
|
|
121
|
-
bgAdd: "#1a3320",
|
|
122
|
-
bgDel: "#331a16",
|
|
123
|
-
bgAddHighlight: "#2d5c3a",
|
|
124
|
-
bgDelHighlight: "#5c2d2d",
|
|
125
|
-
bgGutterAdd: "#142818",
|
|
126
|
-
bgGutterDel: "#28120e",
|
|
127
|
-
bgEmpty: "#141414",
|
|
128
|
-
fgDim: "#606060",
|
|
129
|
-
fgLnum: "#787878",
|
|
130
|
-
fgRule: "#404040",
|
|
131
|
-
fgStripe: "#303030",
|
|
132
|
-
fgSafeMuted: "#9da5ae",
|
|
133
|
-
},
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
/** Parse 24-bit ANSI color code → RGB. Works for both fg and bg escapes. */
|
|
137
|
-
function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
|
|
138
|
-
const esc = "\u001b";
|
|
139
|
-
const m = ansi.match(new RegExp(`${esc}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
|
|
140
|
-
return m ? { r: +m[1], g: +m[2], b: +m[3] } : null;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Convert "#RRGGBB" hex → ANSI 24-bit background escape. */
|
|
144
|
-
function hexToBgAnsi(hex: string): string {
|
|
145
|
-
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return "";
|
|
146
|
-
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
147
|
-
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
148
|
-
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
149
|
-
return `\x1b[48;2;${r};${g};${b}m`;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Convert "#RRGGBB" hex → ANSI 24-bit foreground escape. */
|
|
153
|
-
function hexToFgAnsi(hex: string): string {
|
|
154
|
-
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return "";
|
|
155
|
-
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
156
|
-
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
157
|
-
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
158
|
-
return `\x1b[38;2;${r};${g};${b}m`;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Derive a muted background ANSI code from a foreground ANSI code.
|
|
162
|
-
* Scales the fg RGB by `intensity` (0.0–1.0) to produce a subtle tint. */
|
|
163
|
-
function deriveBgFromFg(fgAnsi: string, intensity: number): string {
|
|
164
|
-
const rgb = parseAnsiRgb(fgAnsi);
|
|
165
|
-
if (!rgb) return "";
|
|
166
|
-
const r = Math.round(rgb.r * intensity);
|
|
167
|
-
const g = Math.round(rgb.g * intensity);
|
|
168
|
-
const b = Math.round(rgb.b * intensity);
|
|
169
|
-
return `\x1b[48;2;${r};${g};${b}m`;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Mix an accent color into a base color at the given intensity (0.0–1.0).
|
|
173
|
-
* Returns an ANSI 24-bit background escape. Used to derive diff backgrounds
|
|
174
|
-
* that blend with the tool box background (toolSuccessBg). */
|
|
175
|
-
function mixBg(
|
|
176
|
-
base: { r: number; g: number; b: number },
|
|
177
|
-
accent: { r: number; g: number; b: number },
|
|
178
|
-
intensity: number,
|
|
179
|
-
): string {
|
|
180
|
-
const r = Math.round(base.r + (accent.r - base.r) * intensity);
|
|
181
|
-
const g = Math.round(base.g + (accent.g - base.g) * intensity);
|
|
182
|
-
const b = Math.round(base.b + (accent.b - base.b) * intensity);
|
|
183
|
-
return `\x1b[48;2;${r};${g};${b}m`;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Whether auto-derive from theme is still pending (runs lazily on first render). */
|
|
187
|
-
let _autoDerivePending = true;
|
|
188
|
-
|
|
189
|
-
/** Whether user set explicit bg config (via preset or per-color overrides). */
|
|
190
|
-
let _hasExplicitBgConfig = false;
|
|
191
|
-
|
|
192
|
-
/** Auto-derive all diff background colors from the pi theme's fg diff colors.
|
|
193
|
-
* Reads toolSuccessBg as the add/context base and toolErrorBg as the delete base,
|
|
194
|
-
* then mixes accent colors into each. Falls back to black (0,0,0) when a theme
|
|
195
|
-
* background is unavailable; toolErrorBg falls back to toolSuccessBg. */
|
|
196
|
-
function autoDeriveBgFromTheme(theme: any): void {
|
|
197
|
-
if (!theme?.getFgAnsi) return;
|
|
198
|
-
try {
|
|
199
|
-
const fgAdd = theme.getFgAnsi("toolDiffAdded");
|
|
200
|
-
const fgDel = theme.getFgAnsi("toolDiffRemoved");
|
|
201
|
-
const addRgb = parseAnsiRgb(fgAdd);
|
|
202
|
-
const delRgb = parseAnsiRgb(fgDel);
|
|
203
|
-
if (!addRgb || !delRgb) return;
|
|
204
|
-
|
|
205
|
-
let addBase = { r: 0, g: 0, b: 0 };
|
|
206
|
-
let delBase = addBase;
|
|
207
|
-
if (theme.getBgAnsi) {
|
|
208
|
-
try {
|
|
209
|
-
const successBgAnsi = theme.getBgAnsi("toolSuccessBg");
|
|
210
|
-
const successParsed = parseAnsiRgb(successBgAnsi);
|
|
211
|
-
if (successParsed) {
|
|
212
|
-
addBase = successParsed;
|
|
213
|
-
delBase = successParsed;
|
|
214
|
-
BG_BASE = successBgAnsi;
|
|
215
|
-
}
|
|
216
|
-
} catch {
|
|
217
|
-
/* no toolSuccessBg — use black */
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
try {
|
|
221
|
-
const errorParsed = parseAnsiRgb(theme.getBgAnsi("toolErrorBg"));
|
|
222
|
-
if (errorParsed) delBase = errorParsed;
|
|
223
|
-
} catch {
|
|
224
|
-
/* no toolErrorBg — use toolSuccessBg/black */
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Line backgrounds — subtle accent mixed into the matching tool-state base (8–10%)
|
|
229
|
-
BG_ADD = mixBg(addBase, addRgb, 0.08);
|
|
230
|
-
BG_DEL = mixBg(delBase, delRgb, 0.1);
|
|
231
|
-
|
|
232
|
-
// Word-level highlights — more visible (20–22%)
|
|
233
|
-
BG_ADD_W = mixBg(addBase, addRgb, 0.2);
|
|
234
|
-
BG_DEL_W = mixBg(delBase, delRgb, 0.22);
|
|
235
|
-
|
|
236
|
-
// Gutters — subtler than lines (5–6%)
|
|
237
|
-
BG_GUTTER_ADD = mixBg(addBase, addRgb, 0.05);
|
|
238
|
-
BG_GUTTER_DEL = mixBg(delBase, delRgb, 0.06);
|
|
239
|
-
|
|
240
|
-
// Empty filler and context — match the success/context base
|
|
241
|
-
BG_EMPTY = BG_BASE;
|
|
242
|
-
|
|
243
|
-
// Update RST to re-apply base bg after every reset — prevents black
|
|
244
|
-
// flashes between styled segments when toolSuccessBg is non-black
|
|
245
|
-
RST = `\x1b[0m${BG_BASE}`;
|
|
246
|
-
|
|
247
|
-
// Rebuild derived constants
|
|
248
|
-
DIVIDER = `${FG_RULE}│${RST}`;
|
|
249
|
-
} catch {
|
|
250
|
-
// Fall back to defaults silently
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** Load diff theme config from .pi/settings.json (project-level, then global). */
|
|
255
|
-
function loadDiffConfig(): DiffUserConfig {
|
|
256
|
-
const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
|
|
257
|
-
for (const p of paths) {
|
|
258
|
-
try {
|
|
259
|
-
if (existsSync(p)) {
|
|
260
|
-
const raw = JSON.parse(readFileSync(p, "utf-8"));
|
|
261
|
-
if (raw.diffTheme || raw.diffColors) {
|
|
262
|
-
return { diffTheme: raw.diffTheme, diffColors: raw.diffColors };
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
} catch {
|
|
266
|
-
// skip invalid files
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
return {};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** Apply diff palette from settings → preset → (auto-derive deferred) → defaults.
|
|
273
|
-
* Called once during extension initialization. */
|
|
274
|
-
function applyDiffPalette(): void {
|
|
275
|
-
const config = loadDiffConfig();
|
|
276
|
-
|
|
277
|
-
// Load preset if specified
|
|
278
|
-
const preset = config.diffTheme ? DIFF_PRESETS[config.diffTheme] : null;
|
|
279
|
-
if (preset) _hasExplicitBgConfig = true;
|
|
280
|
-
|
|
281
|
-
// Per-color overrides from settings
|
|
282
|
-
const ov = config.diffColors ?? {};
|
|
283
|
-
if (Object.keys(ov).length > 0) _hasExplicitBgConfig = true;
|
|
284
|
-
|
|
285
|
-
// Helper: apply a hex bg color if not env-overridden
|
|
286
|
-
const applyBg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
|
|
287
|
-
if (envName && process.env[envName]) return; // env override wins
|
|
288
|
-
const hex = ov[key] ?? presetVal;
|
|
289
|
-
if (hex) {
|
|
290
|
-
const a = hexToBgAnsi(hex);
|
|
291
|
-
if (a) set(a);
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
// Helper: apply a hex fg color if not env-overridden
|
|
295
|
-
const applyFg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
|
|
296
|
-
if (envName && process.env[envName]) return;
|
|
297
|
-
const hex = ov[key] ?? presetVal;
|
|
298
|
-
if (hex) {
|
|
299
|
-
const a = hexToFgAnsi(hex);
|
|
300
|
-
if (a) set(a);
|
|
301
|
-
}
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
// --- Apply backgrounds ---
|
|
305
|
-
applyBg("DIFF_BG_ADD", "bgAdd", preset?.bgAdd, (v) => {
|
|
306
|
-
BG_ADD = v;
|
|
307
|
-
});
|
|
308
|
-
applyBg("DIFF_BG_DEL", "bgDel", preset?.bgDel, (v) => {
|
|
309
|
-
BG_DEL = v;
|
|
310
|
-
});
|
|
311
|
-
applyBg("DIFF_BG_ADD_HL", "bgAddHighlight", preset?.bgAddHighlight, (v) => {
|
|
312
|
-
BG_ADD_W = v;
|
|
313
|
-
});
|
|
314
|
-
applyBg("DIFF_BG_DEL_HL", "bgDelHighlight", preset?.bgDelHighlight, (v) => {
|
|
315
|
-
BG_DEL_W = v;
|
|
316
|
-
});
|
|
317
|
-
applyBg("DIFF_BG_GUTTER_ADD", "bgGutterAdd", preset?.bgGutterAdd, (v) => {
|
|
318
|
-
BG_GUTTER_ADD = v;
|
|
319
|
-
});
|
|
320
|
-
applyBg("DIFF_BG_GUTTER_DEL", "bgGutterDel", preset?.bgGutterDel, (v) => {
|
|
321
|
-
BG_GUTTER_DEL = v;
|
|
322
|
-
});
|
|
323
|
-
applyBg(null, "bgEmpty", preset?.bgEmpty, (v) => {
|
|
324
|
-
BG_EMPTY = v;
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
// --- Apply foregrounds ---
|
|
328
|
-
applyFg("DIFF_FG_ADD", "fgAdd", preset?.fgAdd, (v) => {
|
|
329
|
-
FG_ADD = v;
|
|
330
|
-
});
|
|
331
|
-
applyFg("DIFF_FG_DEL", "fgDel", preset?.fgDel, (v) => {
|
|
332
|
-
FG_DEL = v;
|
|
333
|
-
});
|
|
334
|
-
applyFg(null, "fgDim", preset?.fgDim, (v) => {
|
|
335
|
-
FG_DIM = v;
|
|
336
|
-
});
|
|
337
|
-
applyFg(null, "fgLnum", preset?.fgLnum, (v) => {
|
|
338
|
-
FG_LNUM = v;
|
|
339
|
-
});
|
|
340
|
-
applyFg(null, "fgRule", preset?.fgRule, (v) => {
|
|
341
|
-
FG_RULE = v;
|
|
342
|
-
});
|
|
343
|
-
applyFg(null, "fgStripe", preset?.fgStripe, (v) => {
|
|
344
|
-
FG_STRIPE = v;
|
|
345
|
-
});
|
|
346
|
-
applyFg(null, "fgSafeMuted", preset?.fgSafeMuted, (v) => {
|
|
347
|
-
FG_SAFE_MUTED = v;
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
// --- Shiki syntax theme ---
|
|
351
|
-
const shiki = ov.shikiTheme ?? preset?.shikiTheme;
|
|
352
|
-
if (shiki) THEME = shiki as BundledTheme;
|
|
353
|
-
|
|
354
|
-
// --- Rebuild derived constants ---
|
|
355
|
-
DIVIDER = `${FG_RULE}│${RST}`;
|
|
356
|
-
DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
|
|
357
|
-
|
|
358
|
-
// If no explicit bg config, auto-derive will run on first render
|
|
359
|
-
_autoDerivePending = !_hasExplicitBgConfig;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// ---------------------------------------------------------------------------
|
|
363
|
-
// Config
|
|
364
|
-
// ---------------------------------------------------------------------------
|
|
365
|
-
|
|
366
|
-
let THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
|
|
367
|
-
|
|
368
|
-
function envInt(name: string, fallback: number): number {
|
|
369
|
-
const v = Number.parseInt(process.env[name] ?? "", 10);
|
|
370
|
-
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** Parse env hex color "#RRGGBB" → ANSI 24-bit fg/bg escape, or return fallback. */
|
|
374
|
-
function envFg(name: string, fallback: string): string {
|
|
375
|
-
const hex = process.env[name];
|
|
376
|
-
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback;
|
|
377
|
-
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
378
|
-
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
379
|
-
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
380
|
-
return `\x1b[38;2;${r};${g};${b}m`;
|
|
381
|
-
}
|
|
382
|
-
function envBg(name: string, fallback: string): string {
|
|
383
|
-
const hex = process.env[name];
|
|
384
|
-
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback;
|
|
385
|
-
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
386
|
-
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
387
|
-
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
388
|
-
return `\x1b[48;2;${r};${g};${b}m`;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// --- Split-view thresholds ---
|
|
392
|
-
// Split is preferred when there's real room. At narrow widths, a clean stacked
|
|
393
|
-
// (unified) view is better than a cramped split with wrapping.
|
|
394
|
-
const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", 150); // need ≥150 cols for split to breathe
|
|
395
|
-
const SPLIT_MIN_CODE_WIDTH = envInt("DIFF_SPLIT_MIN_CODE_WIDTH", 60); // ≥60 code cols per side
|
|
396
|
-
const SPLIT_MAX_WRAP_RATIO = 0.2; // if >20% lines wrap in split, fall back to stacked
|
|
397
|
-
const SPLIT_MAX_WRAP_LINES = 8; // absolute cap before unified fallback
|
|
398
|
-
|
|
399
|
-
// --- Terminal bounds ---
|
|
400
|
-
const MAX_TERM_WIDTH = 210; // max for 1728px wide display (~205 cols at typical font)
|
|
401
|
-
const DEFAULT_TERM_WIDTH = 200; // safe default for 1728x1117 resolution
|
|
402
|
-
|
|
403
|
-
// --- Rendering limits ---
|
|
404
|
-
const MAX_PREVIEW_LINES = 60; // was 50 — show slightly more context in edit preview
|
|
405
|
-
const MAX_RENDER_LINES = 150; // was 120 — show more of the diff in write tool
|
|
406
|
-
const MAX_HL_CHARS = 80_000; // was 50k — allow syntax hl for larger diffs
|
|
407
|
-
const CACHE_LIMIT = 192; // was 128 — bigger cache for multi-file sessions
|
|
408
|
-
|
|
409
|
-
// --- Word diff ---
|
|
410
|
-
const WORD_DIFF_MIN_SIM = 0.15; // was 0.2 — show word diffs for slightly less similar lines
|
|
411
|
-
|
|
412
|
-
// --- Wrapping ---
|
|
413
|
-
// Adaptive: narrow terminals truncate aggressively, wide terminals allow wrapping.
|
|
414
|
-
// Actual wrap rows are computed per-render via adaptiveWrapRows().
|
|
415
|
-
const MAX_WRAP_ROWS_WIDE = 3; // ≥180 cols
|
|
416
|
-
const MAX_WRAP_ROWS_MED = 2; // 120–179 cols
|
|
417
|
-
const MAX_WRAP_ROWS_NARROW = 1; // <120 cols (truncate, no wrap)
|
|
418
|
-
|
|
419
|
-
// ---------------------------------------------------------------------------
|
|
420
|
-
// ANSI
|
|
421
|
-
// ---------------------------------------------------------------------------
|
|
422
|
-
|
|
423
|
-
let RST = "\x1b[0m";
|
|
424
|
-
const BOLD = "\x1b[1m";
|
|
425
|
-
const DIM = "\x1b[2m";
|
|
426
|
-
|
|
427
|
-
// Subtle diff backgrounds — muted tones to let syntax fg shine through
|
|
428
|
-
// Override via env: DIFF_BG_ADD="#1a3320" etc. (hex "#RRGGBB" format)
|
|
429
|
-
let BG_ADD = envBg("DIFF_BG_ADD", "\x1b[48;2;22;38;32m"); // muted teal-green
|
|
430
|
-
let BG_DEL = envBg("DIFF_BG_DEL", "\x1b[48;2;45;25;25m"); // muted brown-red
|
|
431
|
-
let BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;35;75;50m"); // word-level emphasis
|
|
432
|
-
let BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;80;35;35m");
|
|
433
|
-
let BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;18;32;26m");
|
|
434
|
-
let BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", "\x1b[48;2;38;22;22m");
|
|
435
|
-
const BG_GUTTER_CTX = ""; // use terminal default bg for context gutters
|
|
436
|
-
let BG_EMPTY = "\x1b[48;2;18;18;18m"; // filler rows when one side is shorter
|
|
437
|
-
|
|
438
|
-
// Diff foregrounds — override via env: DIFF_FG_ADD="#50d264" etc.
|
|
439
|
-
let FG_ADD = envFg("DIFF_FG_ADD", "\x1b[38;2;100;180;120m"); // desaturated green
|
|
440
|
-
let FG_DEL = envFg("DIFF_FG_DEL", "\x1b[38;2;200;100;100m"); // desaturated red
|
|
441
|
-
let FG_DIM = "\x1b[38;2;80;80;80m";
|
|
442
|
-
let FG_LNUM = "\x1b[38;2;100;100;100m";
|
|
443
|
-
let FG_RULE = "\x1b[38;2;50;50;50m";
|
|
444
|
-
let FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
|
|
445
|
-
|
|
446
|
-
let FG_STRIPE = "\x1b[38;2;40;40;40m"; // gray diagonal stripes on terminal default bg
|
|
447
|
-
|
|
448
|
-
const BORDER_BAR = "▌";
|
|
449
|
-
|
|
450
|
-
/** Generate a dense diagonal stripe fill for empty filler cells.
|
|
451
|
-
* Solid ╱ characters — uniform direction like CSS diagonal hatching. */
|
|
452
|
-
function stripes(w: number, _rowOffset: number): string {
|
|
453
|
-
return BG_BASE + FG_STRIPE + "╱".repeat(w) + RST;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
let DIVIDER = `${FG_RULE}│${RST}`;
|
|
457
|
-
const ESC_RE = "\u001b";
|
|
458
|
-
const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, "g");
|
|
459
|
-
const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
|
|
460
|
-
const ANSI_PARAM_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
|
|
461
|
-
const BG_DEFAULT = "\x1b[49m"; // reset to terminal default background
|
|
462
|
-
let BG_BASE = BG_DEFAULT; // tool box base bg — updated from theme's toolSuccessBg
|
|
463
|
-
|
|
464
|
-
// ---------------------------------------------------------------------------
|
|
465
|
-
// Theme-aware diff colors
|
|
466
|
-
// ---------------------------------------------------------------------------
|
|
467
|
-
|
|
468
|
-
/** Resolved ANSI colors for diff rendering — theme overrides hardcoded defaults. */
|
|
469
|
-
interface DiffColors {
|
|
470
|
-
fgAdd: string;
|
|
471
|
-
fgDel: string;
|
|
472
|
-
fgCtx: string;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
let DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
|
|
476
|
-
|
|
477
|
-
/** Resolve diff fg colors from theme (if available), falling back to hardcoded ANSI.
|
|
478
|
-
* On first call with a valid theme, auto-derives bg colors if no explicit config was set.
|
|
479
|
-
* Always reads toolSuccessBg for BG_BASE (used for context/add line backgrounds). */
|
|
480
|
-
function resolveDiffColors(theme?: any): DiffColors {
|
|
481
|
-
// Always read toolSuccessBg for BG_BASE (even with explicit config)
|
|
482
|
-
if (theme?.getBgAnsi && BG_BASE === BG_DEFAULT) {
|
|
483
|
-
try {
|
|
484
|
-
const bgAnsi = theme.getBgAnsi("toolSuccessBg");
|
|
485
|
-
const parsed = parseAnsiRgb(bgAnsi);
|
|
486
|
-
if (parsed) {
|
|
487
|
-
BG_BASE = bgAnsi;
|
|
488
|
-
RST = `\x1b[0m${BG_BASE}`;
|
|
489
|
-
}
|
|
490
|
-
} catch {
|
|
491
|
-
/* ignore */
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Auto-derive bg colors from theme on first render (if no explicit preset/overrides)
|
|
496
|
-
if (_autoDerivePending && theme?.getFgAnsi) {
|
|
497
|
-
autoDeriveBgFromTheme(theme);
|
|
498
|
-
_autoDerivePending = false;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
|
|
502
|
-
try {
|
|
503
|
-
const fgAdd = theme.getFgAnsi("toolDiffAdded") || FG_ADD;
|
|
504
|
-
const fgDel = theme.getFgAnsi("toolDiffRemoved") || FG_DEL;
|
|
505
|
-
const fgCtx = theme.getFgAnsi("toolDiffContext") || FG_DIM;
|
|
506
|
-
return { fgAdd, fgDel, fgCtx };
|
|
507
|
-
} catch {
|
|
508
|
-
return DEFAULT_DIFF_COLORS;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// ---------------------------------------------------------------------------
|
|
513
|
-
// Adaptive helpers
|
|
514
|
-
// ---------------------------------------------------------------------------
|
|
515
|
-
|
|
516
|
-
/** Returns max wrap rows based on current terminal width. Narrow = truncate, wide = allow wrapping. */
|
|
517
|
-
function adaptiveWrapRows(tw?: number): number {
|
|
518
|
-
const w = tw ?? termW();
|
|
519
|
-
if (w >= 180) return MAX_WRAP_ROWS_WIDE;
|
|
520
|
-
if (w >= 120) return MAX_WRAP_ROWS_MED;
|
|
521
|
-
return MAX_WRAP_ROWS_NARROW;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// ---------------------------------------------------------------------------
|
|
525
|
-
// Types
|
|
526
|
-
// ---------------------------------------------------------------------------
|
|
527
|
-
|
|
528
|
-
interface DiffLine {
|
|
529
|
-
type: "add" | "del" | "ctx" | "sep";
|
|
530
|
-
oldNum: number | null;
|
|
531
|
-
newNum: number | null;
|
|
532
|
-
content: string;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
interface ParsedDiff {
|
|
536
|
-
lines: DiffLine[];
|
|
537
|
-
added: number;
|
|
538
|
-
removed: number;
|
|
539
|
-
chars: number;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// ---------------------------------------------------------------------------
|
|
543
|
-
// Utilities
|
|
544
|
-
// ---------------------------------------------------------------------------
|
|
545
|
-
|
|
546
|
-
function strip(s: string): string {
|
|
547
|
-
return s.replace(ANSI_RE, "");
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function tabs(s: string): string {
|
|
551
|
-
return s.replace(/\t/g, " ");
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function termW(): number {
|
|
555
|
-
// Try multiple sources — process.stdout.columns may be undefined in piped/subagent contexts
|
|
556
|
-
const raw =
|
|
557
|
-
process.stdout.columns ||
|
|
558
|
-
(process.stderr as any).columns ||
|
|
559
|
-
Number.parseInt(process.env.COLUMNS ?? "", 10) ||
|
|
560
|
-
DEFAULT_TERM_WIDTH;
|
|
561
|
-
return Math.max(80, Math.min(raw - 4, MAX_TERM_WIDTH)); // -4 safety margin for pi TUI padding
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/** Pad/truncate `s` to exactly `w` visible chars. ANSI-aware. */
|
|
565
|
-
function fit(s: string, w: number): string {
|
|
566
|
-
if (w <= 0) return "";
|
|
567
|
-
const plain = strip(s);
|
|
568
|
-
if (plain.length <= w) return s + " ".repeat(w - plain.length);
|
|
569
|
-
// Truncated — show content + dim › indicator
|
|
570
|
-
const showW = w > 2 ? w - 1 : w;
|
|
571
|
-
let vis = 0,
|
|
572
|
-
i = 0;
|
|
573
|
-
while (i < s.length && vis < showW) {
|
|
574
|
-
if (s[i] === "\x1b") {
|
|
575
|
-
const e = s.indexOf("m", i);
|
|
576
|
-
if (e !== -1) {
|
|
577
|
-
i = e + 1;
|
|
578
|
-
continue;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
vis++;
|
|
582
|
-
i++;
|
|
583
|
-
}
|
|
584
|
-
return w > 2 ? `${s.slice(0, i)}${RST}${FG_DIM}›${RST}` : `${s.slice(0, i)}${RST}`;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/** Extract last active fg + bg ANSI codes from a string. Used for wrapping continuations. */
|
|
588
|
-
function ansiState(s: string): string {
|
|
589
|
-
let fg = "",
|
|
590
|
-
bg = "";
|
|
591
|
-
for (const match of s.matchAll(ANSI_CAPTURE_RE)) {
|
|
592
|
-
const p = match[1] ?? "";
|
|
593
|
-
const seq = match[0] ?? "";
|
|
594
|
-
if (p === "0") {
|
|
595
|
-
fg = "";
|
|
596
|
-
bg = "";
|
|
597
|
-
} else if (p === "39") {
|
|
598
|
-
fg = "";
|
|
599
|
-
} else if (p.startsWith("38;")) {
|
|
600
|
-
fg = seq;
|
|
601
|
-
} else if (p.startsWith("48;")) {
|
|
602
|
-
bg = seq;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
return bg + fg;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function isLowContrastShikiFg(params: string): boolean {
|
|
609
|
-
if (params === "30" || params === "90") return true;
|
|
610
|
-
if (params === "38;5;0" || params === "38;5;8") return true;
|
|
611
|
-
if (!params.startsWith("38;2;")) return false;
|
|
612
|
-
const parts = params.split(";").map(Number);
|
|
613
|
-
if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
|
|
614
|
-
const [, , r, g, b] = parts;
|
|
615
|
-
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
616
|
-
return luminance < 72;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function normalizeShikiContrast(ansi: string): string {
|
|
620
|
-
return ansi.replace(ANSI_PARAM_CAPTURE_RE, (seq, params: string) =>
|
|
621
|
-
isLowContrastShikiFg(params) ? FG_SAFE_MUTED : seq,
|
|
622
|
-
);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/** Wrap ANSI-encoded string into rows of `w` visible chars. Max `maxRows` rows; last row truncates with ›. */
|
|
626
|
-
function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = ""): string[] {
|
|
627
|
-
if (w <= 0) return [""];
|
|
628
|
-
const plain = strip(s);
|
|
629
|
-
if (plain.length <= w) {
|
|
630
|
-
const pad = w - plain.length;
|
|
631
|
-
return pad > 0 ? [s + fillBg + " ".repeat(pad) + (fillBg ? RST : "")] : [s];
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
const rows: string[] = [];
|
|
635
|
-
let row = "",
|
|
636
|
-
vis = 0,
|
|
637
|
-
i = 0;
|
|
638
|
-
let onLastRow = false;
|
|
639
|
-
let effW = w;
|
|
640
|
-
|
|
641
|
-
while (i < s.length) {
|
|
642
|
-
// When we reach the last allowed row, reserve 1 char for › indicator
|
|
643
|
-
if (!onLastRow && rows.length >= maxRows - 1) {
|
|
644
|
-
onLastRow = true;
|
|
645
|
-
effW = w > 2 ? w - 1 : w;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// Pass through ANSI escapes
|
|
649
|
-
if (s[i] === "\x1b") {
|
|
650
|
-
const end = s.indexOf("m", i);
|
|
651
|
-
if (end !== -1) {
|
|
652
|
-
row += s.slice(i, end + 1);
|
|
653
|
-
i = end + 1;
|
|
654
|
-
continue;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// Row full
|
|
659
|
-
if (vis >= effW) {
|
|
660
|
-
if (onLastRow) {
|
|
661
|
-
// Check if remaining string has visible chars
|
|
662
|
-
let hasMore = false;
|
|
663
|
-
for (let j = i; j < s.length; j++) {
|
|
664
|
-
if (s[j] === "\x1b") {
|
|
665
|
-
const e2 = s.indexOf("m", j);
|
|
666
|
-
if (e2 !== -1) {
|
|
667
|
-
j = e2;
|
|
668
|
-
continue;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
hasMore = true;
|
|
672
|
-
break;
|
|
673
|
-
}
|
|
674
|
-
if (hasMore && w > 2) row += `${RST}${FG_DIM}›${RST}`;
|
|
675
|
-
else row += fillBg + " ".repeat(Math.max(0, w - vis)) + RST;
|
|
676
|
-
rows.push(row);
|
|
677
|
-
return rows;
|
|
678
|
-
}
|
|
679
|
-
// Normal wrap — carry ANSI state forward
|
|
680
|
-
const state = ansiState(row);
|
|
681
|
-
rows.push(row + RST);
|
|
682
|
-
row = state + fillBg;
|
|
683
|
-
vis = 0;
|
|
684
|
-
if (rows.length >= maxRows - 1) {
|
|
685
|
-
onLastRow = true;
|
|
686
|
-
effW = w > 2 ? w - 1 : w;
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
row += s[i];
|
|
691
|
-
vis++;
|
|
692
|
-
i++;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// Final row, padded
|
|
696
|
-
if (row.length > 0 || rows.length === 0) {
|
|
697
|
-
rows.push(row + fillBg + " ".repeat(Math.max(0, w - vis)) + RST);
|
|
698
|
-
}
|
|
699
|
-
return rows;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
function lnum(n: number | null, w: number, fg = FG_LNUM): string {
|
|
703
|
-
if (n === null) return " ".repeat(w);
|
|
704
|
-
const v = String(n);
|
|
705
|
-
return `${fg}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
function shortPath(cwd: string, home: string, p: string): string {
|
|
709
|
-
if (!p) return "";
|
|
710
|
-
const r = relative(cwd, p);
|
|
711
|
-
if (!r.startsWith("..") && !r.startsWith("/")) return r;
|
|
712
|
-
return p.replace(home, "~");
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
function summarize(a: number, d: number): string {
|
|
716
|
-
const p: string[] = [];
|
|
717
|
-
if (a > 0) p.push(`${FG_ADD}+${a}${RST}`);
|
|
718
|
-
if (d > 0) p.push(`${FG_DEL}-${d}${RST}`);
|
|
719
|
-
return p.length ? p.join(" ") : `${FG_DIM}no changes${RST}`;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
function rule(w: number): string {
|
|
723
|
-
return `${BG_BASE}${FG_RULE}${"─".repeat(w)}${RST}`;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
/**
|
|
727
|
-
* Decide whether split view is readable for the given terminal width.
|
|
728
|
-
* Prefers split view — side-by-side is always easier to scan.
|
|
729
|
-
* Falls back to unified only when code columns would be too cramped
|
|
730
|
-
* or too many lines would wrap even with adaptive truncation.
|
|
731
|
-
*/
|
|
732
|
-
function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES): boolean {
|
|
733
|
-
if (!diff.lines.length) return false;
|
|
734
|
-
if (tw < SPLIT_MIN_WIDTH) return false;
|
|
735
|
-
|
|
736
|
-
const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
737
|
-
const half = Math.floor((tw - 1) / 2); // -1 for center divider
|
|
738
|
-
const gw = nw + 5; // border + num + sign + sp + │ + sp
|
|
739
|
-
const cw = Math.max(12, half - gw);
|
|
740
|
-
if (cw < SPLIT_MIN_CODE_WIDTH) return false;
|
|
741
|
-
|
|
742
|
-
// Estimate how many lines would need wrapping at this code width
|
|
743
|
-
const vis = diff.lines.slice(0, maxRows);
|
|
744
|
-
let contentLines = 0;
|
|
745
|
-
let wrapCandidates = 0;
|
|
746
|
-
for (const l of vis) {
|
|
747
|
-
if (l.type === "sep") continue;
|
|
748
|
-
contentLines++;
|
|
749
|
-
if (tabs(l.content).length > cw) wrapCandidates++;
|
|
750
|
-
}
|
|
751
|
-
if (contentLines === 0) return true;
|
|
752
|
-
|
|
753
|
-
const wrapRatio = wrapCandidates / contentLines;
|
|
754
|
-
if (wrapCandidates >= SPLIT_MAX_WRAP_LINES) return false;
|
|
755
|
-
if (wrapRatio >= SPLIT_MAX_WRAP_RATIO) return false;
|
|
756
|
-
return true;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// ---------------------------------------------------------------------------
|
|
760
|
-
// Language detection
|
|
761
|
-
// ---------------------------------------------------------------------------
|
|
762
|
-
|
|
763
|
-
const EXT_LANG: Record<string, BundledLanguage> = {
|
|
764
|
-
ts: "typescript",
|
|
765
|
-
tsx: "tsx",
|
|
766
|
-
js: "javascript",
|
|
767
|
-
jsx: "jsx",
|
|
768
|
-
mjs: "javascript",
|
|
769
|
-
cjs: "javascript",
|
|
770
|
-
py: "python",
|
|
771
|
-
rb: "ruby",
|
|
772
|
-
rs: "rust",
|
|
773
|
-
go: "go",
|
|
774
|
-
java: "java",
|
|
775
|
-
c: "c",
|
|
776
|
-
cpp: "cpp",
|
|
777
|
-
h: "c",
|
|
778
|
-
hpp: "cpp",
|
|
779
|
-
cs: "csharp",
|
|
780
|
-
swift: "swift",
|
|
781
|
-
kt: "kotlin",
|
|
782
|
-
html: "html",
|
|
783
|
-
css: "css",
|
|
784
|
-
scss: "scss",
|
|
785
|
-
json: "json",
|
|
786
|
-
yaml: "yaml",
|
|
787
|
-
yml: "yaml",
|
|
788
|
-
toml: "toml",
|
|
789
|
-
md: "markdown",
|
|
790
|
-
sql: "sql",
|
|
791
|
-
sh: "bash",
|
|
792
|
-
bash: "bash",
|
|
793
|
-
zsh: "bash",
|
|
794
|
-
lua: "lua",
|
|
795
|
-
php: "php",
|
|
796
|
-
dart: "dart",
|
|
797
|
-
xml: "xml",
|
|
798
|
-
graphql: "graphql",
|
|
799
|
-
svelte: "svelte",
|
|
800
|
-
vue: "vue",
|
|
801
|
-
};
|
|
802
|
-
|
|
803
|
-
function lang(fp: string): BundledLanguage | undefined {
|
|
804
|
-
return EXT_LANG[extname(fp).slice(1).toLowerCase()];
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// ---------------------------------------------------------------------------
|
|
808
|
-
// Shiki ANSI cache + pre-warm
|
|
809
|
-
// ---------------------------------------------------------------------------
|
|
810
|
-
|
|
811
|
-
// Pre-warm the Shiki singleton (loads WASM grammars + theme) so the first
|
|
812
|
-
// diff render doesn't pay the ~200-500ms startup cost.
|
|
813
|
-
codeToANSI("", "typescript", THEME).catch(() => {});
|
|
814
|
-
|
|
815
|
-
const _cache = new Map<string, string[]>();
|
|
816
|
-
|
|
817
|
-
function _touch(k: string, v: string[]): string[] {
|
|
818
|
-
_cache.delete(k);
|
|
819
|
-
_cache.set(k, v);
|
|
820
|
-
while (_cache.size > CACHE_LIMIT) {
|
|
821
|
-
const first = _cache.keys().next().value;
|
|
822
|
-
if (first === undefined) break;
|
|
823
|
-
_cache.delete(first);
|
|
824
|
-
}
|
|
825
|
-
return v;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
async function hlBlock(code: string, language: BundledLanguage | undefined): Promise<string[]> {
|
|
829
|
-
if (!code) return [""];
|
|
830
|
-
if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
|
|
831
|
-
|
|
832
|
-
const k = `${THEME}\0${language}\0${code}`;
|
|
833
|
-
const hit = _cache.get(k);
|
|
834
|
-
if (hit) return _touch(k, hit);
|
|
835
|
-
|
|
836
|
-
try {
|
|
837
|
-
const ansi = normalizeShikiContrast(await codeToANSI(code, language, THEME));
|
|
838
|
-
const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
|
|
839
|
-
return _touch(k, out);
|
|
840
|
-
} catch {
|
|
841
|
-
return code.split("\n");
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// ---------------------------------------------------------------------------
|
|
846
|
-
// Diff parsing
|
|
847
|
-
// ---------------------------------------------------------------------------
|
|
848
|
-
|
|
849
|
-
function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff {
|
|
850
|
-
const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: ctx });
|
|
851
|
-
const lines: DiffLine[] = [];
|
|
852
|
-
let added = 0,
|
|
853
|
-
removed = 0;
|
|
854
|
-
|
|
855
|
-
for (let hi = 0; hi < patch.hunks.length; hi++) {
|
|
856
|
-
if (hi > 0) {
|
|
857
|
-
const prev = patch.hunks[hi - 1];
|
|
858
|
-
const gap = patch.hunks[hi].oldStart - (prev.oldStart + prev.oldLines);
|
|
859
|
-
lines.push({ type: "sep", oldNum: null, newNum: gap > 0 ? gap : null, content: "" });
|
|
860
|
-
}
|
|
861
|
-
const h = patch.hunks[hi];
|
|
862
|
-
let oL = h.oldStart,
|
|
863
|
-
nL = h.newStart;
|
|
864
|
-
for (const raw of h.lines) {
|
|
865
|
-
if (raw === "\") continue;
|
|
866
|
-
const ch = raw[0],
|
|
867
|
-
text = raw.slice(1);
|
|
868
|
-
if (ch === "+") {
|
|
869
|
-
lines.push({ type: "add", oldNum: null, newNum: nL++, content: text });
|
|
870
|
-
added++;
|
|
871
|
-
} else if (ch === "-") {
|
|
872
|
-
lines.push({ type: "del", oldNum: oL++, newNum: null, content: text });
|
|
873
|
-
removed++;
|
|
874
|
-
} else {
|
|
875
|
-
lines.push({ type: "ctx", oldNum: oL++, newNum: nL++, content: text });
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
return { lines, added, removed, chars: oldContent.length + newContent.length };
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
// ---------------------------------------------------------------------------
|
|
883
|
-
// Word diff + bg injection
|
|
884
|
-
//
|
|
885
|
-
// Key insight: Shiki's codeToANSI only emits fg codes (\x1b[38;...m and
|
|
886
|
-
// \x1b[39m). It never sets backgrounds. So we can layer a diff bg underneath
|
|
887
|
-
// and it persists through all fg switches. For word-level emphasis we swap
|
|
888
|
-
// the bg to a brighter shade at changed character positions.
|
|
889
|
-
// ---------------------------------------------------------------------------
|
|
890
|
-
|
|
891
|
-
/**
|
|
892
|
-
* Combined word diff analysis — single Diff.diffWords() call returns both
|
|
893
|
-
* similarity score and character ranges for emphasis highlighting.
|
|
894
|
-
* Replaces separate wordDiffRanges + wordDiffSimilarity (which called diffWords twice).
|
|
895
|
-
*/
|
|
896
|
-
function wordDiffAnalysis(
|
|
897
|
-
a: string,
|
|
898
|
-
b: string,
|
|
899
|
-
): {
|
|
900
|
-
similarity: number;
|
|
901
|
-
oldRanges: Array<[number, number]>;
|
|
902
|
-
newRanges: Array<[number, number]>;
|
|
903
|
-
} {
|
|
904
|
-
if (!a && !b) return { similarity: 1, oldRanges: [], newRanges: [] };
|
|
905
|
-
const parts = Diff.diffWords(a, b);
|
|
906
|
-
const oldRanges: Array<[number, number]> = [];
|
|
907
|
-
const newRanges: Array<[number, number]> = [];
|
|
908
|
-
let oPos = 0,
|
|
909
|
-
nPos = 0,
|
|
910
|
-
same = 0;
|
|
911
|
-
for (const p of parts) {
|
|
912
|
-
if (p.removed) {
|
|
913
|
-
oldRanges.push([oPos, oPos + p.value.length]);
|
|
914
|
-
oPos += p.value.length;
|
|
915
|
-
} else if (p.added) {
|
|
916
|
-
newRanges.push([nPos, nPos + p.value.length]);
|
|
917
|
-
nPos += p.value.length;
|
|
918
|
-
} else {
|
|
919
|
-
const len = p.value.length;
|
|
920
|
-
same += len;
|
|
921
|
-
oPos += len;
|
|
922
|
-
nPos += len;
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
const maxLen = Math.max(a.length, b.length);
|
|
926
|
-
return { similarity: maxLen > 0 ? same / maxLen : 1, oldRanges, newRanges };
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
/**
|
|
930
|
-
* Inject diff background into Shiki ANSI output.
|
|
931
|
-
* `baseBg` on unchanged spans, `hlBg` on changed character ranges.
|
|
932
|
-
* Re-injects bg after any full reset (\x1b[0m).
|
|
933
|
-
*
|
|
934
|
-
* Uses sorted-range pointer scan instead of Set (avoids O(totalChars) Set creation).
|
|
935
|
-
*/
|
|
936
|
-
function injectBg(ansiLine: string, ranges: Array<[number, number]>, baseBg: string, hlBg: string): string {
|
|
937
|
-
if (!ranges.length) return baseBg + ansiLine + RST;
|
|
938
|
-
|
|
939
|
-
let out = baseBg;
|
|
940
|
-
let vis = 0;
|
|
941
|
-
let inHL = false;
|
|
942
|
-
let ri = 0; // current range index
|
|
943
|
-
let i = 0;
|
|
944
|
-
|
|
945
|
-
while (i < ansiLine.length) {
|
|
946
|
-
if (ansiLine[i] === "\x1b") {
|
|
947
|
-
const m = ansiLine.indexOf("m", i);
|
|
948
|
-
if (m !== -1) {
|
|
949
|
-
const seq = ansiLine.slice(i, m + 1);
|
|
950
|
-
out += seq;
|
|
951
|
-
// Re-inject bg after full reset
|
|
952
|
-
if (seq === "\x1b[0m") out += inHL ? hlBg : baseBg;
|
|
953
|
-
i = m + 1;
|
|
954
|
-
continue;
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
// Advance past exhausted ranges
|
|
958
|
-
while (ri < ranges.length && vis >= ranges[ri][1]) ri++;
|
|
959
|
-
const want = ri < ranges.length && vis >= ranges[ri][0] && vis < ranges[ri][1];
|
|
960
|
-
if (want !== inHL) {
|
|
961
|
-
inHL = want;
|
|
962
|
-
out += inHL ? hlBg : baseBg;
|
|
963
|
-
}
|
|
964
|
-
out += ansiLine[i];
|
|
965
|
-
vis++;
|
|
966
|
-
i++;
|
|
967
|
-
}
|
|
968
|
-
return out + RST;
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
/** Simple word diff (no syntax hl) — fallback when Shiki isn't available. */
|
|
972
|
-
function plainWordDiff(oldText: string, newText: string): { old: string; new: string } {
|
|
973
|
-
const parts = Diff.diffWords(oldText, newText);
|
|
974
|
-
let o = "",
|
|
975
|
-
n = "";
|
|
976
|
-
for (const p of parts) {
|
|
977
|
-
if (p.removed) o += `${BG_DEL_W}${p.value}${RST}${BG_DEL}`;
|
|
978
|
-
else if (p.added) n += `${BG_ADD_W}${p.value}${RST}${BG_ADD}`;
|
|
979
|
-
else {
|
|
980
|
-
o += p.value;
|
|
981
|
-
n += p.value;
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
return { old: o, new: n };
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
// ---------------------------------------------------------------------------
|
|
988
|
-
// Stacked (unified) view — clean single-column layout
|
|
989
|
-
//
|
|
990
|
-
// Modelled after Shiki diff/GitHub stacked view:
|
|
991
|
-
// • Single line-number column (shows old num for del/ctx, new num for add)
|
|
992
|
-
// • Compact gutter: "NNN-│" or "NNN+│" or "NNN │"
|
|
993
|
-
// • Full-width code — no side-by-side cramming
|
|
994
|
-
// • Hunk separators as "··· N unmodified lines ···"
|
|
995
|
-
// • Paired del/add lines adjacent with word-level emphasis
|
|
996
|
-
// ---------------------------------------------------------------------------
|
|
997
|
-
|
|
998
|
-
async function renderUnified(
|
|
999
|
-
diff: ParsedDiff,
|
|
1000
|
-
language: BundledLanguage | undefined,
|
|
1001
|
-
max = MAX_RENDER_LINES,
|
|
1002
|
-
dc: DiffColors = DEFAULT_DIFF_COLORS,
|
|
1003
|
-
): Promise<string> {
|
|
1004
|
-
if (!diff.lines.length) return "";
|
|
1005
|
-
|
|
1006
|
-
const vis = diff.lines.slice(0, max);
|
|
1007
|
-
const tw = termW();
|
|
1008
|
-
const nw = Math.max(2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
1009
|
-
const gw = nw + 5; // border + num + sign + sp + │ + sp
|
|
1010
|
-
const cw = Math.max(20, tw - gw);
|
|
1011
|
-
const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES;
|
|
1012
|
-
|
|
1013
|
-
// Build separate old/new code blocks for highlighting
|
|
1014
|
-
const oldSrc: string[] = [],
|
|
1015
|
-
newSrc: string[] = [];
|
|
1016
|
-
for (const l of vis) {
|
|
1017
|
-
if (l.type === "ctx" || l.type === "del") oldSrc.push(l.content);
|
|
1018
|
-
if (l.type === "ctx" || l.type === "add") newSrc.push(l.content);
|
|
1019
|
-
}
|
|
1020
|
-
const [oldHL, newHL] = canHL
|
|
1021
|
-
? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
|
|
1022
|
-
: [oldSrc, newSrc];
|
|
1023
|
-
|
|
1024
|
-
let oI = 0,
|
|
1025
|
-
nI = 0,
|
|
1026
|
-
idx = 0;
|
|
1027
|
-
const out: string[] = [];
|
|
1028
|
-
out.push(rule(tw));
|
|
1029
|
-
|
|
1030
|
-
/** Emit a single stacked row with compact gutter + left border bar. */
|
|
1031
|
-
function emitRow(
|
|
1032
|
-
num: number | null,
|
|
1033
|
-
sign: string,
|
|
1034
|
-
gutterBg: string,
|
|
1035
|
-
signFg: string,
|
|
1036
|
-
body: string,
|
|
1037
|
-
bodyBg = "",
|
|
1038
|
-
): void {
|
|
1039
|
-
const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : "";
|
|
1040
|
-
const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : `${BG_BASE} `;
|
|
1041
|
-
const numFg = borderFg || FG_LNUM;
|
|
1042
|
-
const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${signFg}${sign}${RST} ${DIVIDER} `;
|
|
1043
|
-
const contGutter = `${border}${gutterBg}${" ".repeat(nw + 1)}${RST} ${DIVIDER} `;
|
|
1044
|
-
const rows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), bodyBg);
|
|
1045
|
-
out.push(`${gutter}${rows[0]}${RST}`);
|
|
1046
|
-
for (let r = 1; r < rows.length; r++) out.push(`${contGutter}${rows[r]}${RST}`);
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
while (idx < vis.length) {
|
|
1050
|
-
const l = vis[idx];
|
|
1051
|
-
|
|
1052
|
-
// Hunk separator — collapsed context
|
|
1053
|
-
if (l.type === "sep") {
|
|
1054
|
-
const gap = l.newNum;
|
|
1055
|
-
const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
|
|
1056
|
-
const totalW = Math.min(tw, 72);
|
|
1057
|
-
const pad = Math.max(0, totalW - label.length - 2);
|
|
1058
|
-
const half1 = Math.floor(pad / 2),
|
|
1059
|
-
half2 = pad - half1;
|
|
1060
|
-
out.push(`${BG_BASE}${FG_DIM}${"─".repeat(half1)}${label}${"─".repeat(half2)}${RST}`);
|
|
1061
|
-
idx++;
|
|
1062
|
-
continue;
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
// Context line — dimmed, single line number
|
|
1066
|
-
if (l.type === "ctx") {
|
|
1067
|
-
const hl = oldHL[oI] ?? l.content;
|
|
1068
|
-
emitRow(l.newNum, " ", BG_BASE, dc.fgCtx, `${BG_BASE}${DIM}${hl}`, BG_BASE);
|
|
1069
|
-
oI++;
|
|
1070
|
-
nI++;
|
|
1071
|
-
idx++;
|
|
1072
|
-
continue;
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// Collect del/add blocks
|
|
1076
|
-
const dels: Array<{ l: DiffLine; hl: string }> = [];
|
|
1077
|
-
while (idx < vis.length && vis[idx].type === "del") {
|
|
1078
|
-
dels.push({ l: vis[idx], hl: oldHL[oI] ?? vis[idx].content });
|
|
1079
|
-
oI++;
|
|
1080
|
-
idx++;
|
|
1081
|
-
}
|
|
1082
|
-
const adds: Array<{ l: DiffLine; hl: string }> = [];
|
|
1083
|
-
while (idx < vis.length && vis[idx].type === "add") {
|
|
1084
|
-
adds.push({ l: vis[idx], hl: newHL[nI] ?? vis[idx].content });
|
|
1085
|
-
nI++;
|
|
1086
|
-
idx++;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// 1:1 paired → word diff emphasis
|
|
1090
|
-
const isPaired = dels.length === 1 && adds.length === 1;
|
|
1091
|
-
const wd = isPaired ? wordDiffAnalysis(dels[0].l.content, adds[0].l.content) : null;
|
|
1092
|
-
|
|
1093
|
-
if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
|
|
1094
|
-
const delBody = injectBg(dels[0].hl, wd.oldRanges, BG_DEL, BG_DEL_W);
|
|
1095
|
-
const addBody = injectBg(adds[0].hl, wd.newRanges, BG_ADD, BG_ADD_W);
|
|
1096
|
-
emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, delBody, BG_DEL);
|
|
1097
|
-
emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, addBody, BG_ADD);
|
|
1098
|
-
continue;
|
|
1099
|
-
}
|
|
1100
|
-
if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
|
|
1101
|
-
const pwd = plainWordDiff(dels[0].l.content, adds[0].l.content);
|
|
1102
|
-
emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, `${BG_DEL}${pwd.old}`, BG_DEL);
|
|
1103
|
-
emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, `${BG_ADD}${pwd.new}`, BG_ADD);
|
|
1104
|
-
continue;
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
// Multi-line blocks — syntax highlighted with diff bg
|
|
1108
|
-
for (const d of dels) {
|
|
1109
|
-
const body = canHL ? `${BG_DEL}${d.hl}` : `${BG_DEL}${d.l.content}`;
|
|
1110
|
-
emitRow(d.l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, body, BG_DEL);
|
|
1111
|
-
}
|
|
1112
|
-
for (const a of adds) {
|
|
1113
|
-
const body = canHL ? `${BG_ADD}${a.hl}` : `${BG_ADD}${a.l.content}`;
|
|
1114
|
-
emitRow(a.l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, body, BG_ADD);
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
out.push(rule(tw));
|
|
1119
|
-
if (diff.lines.length > vis.length) {
|
|
1120
|
-
out.push(`${BG_BASE}${FG_DIM} … ${diff.lines.length - vis.length} more lines${RST}`);
|
|
1121
|
-
}
|
|
1122
|
-
return out.join("\n");
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
// ---------------------------------------------------------------------------
|
|
1126
|
-
// Split view (auto-fallback to unified when narrow)
|
|
1127
|
-
// ---------------------------------------------------------------------------
|
|
1128
|
-
|
|
1129
|
-
async function renderSplit(
|
|
1130
|
-
diff: ParsedDiff,
|
|
1131
|
-
language: BundledLanguage | undefined,
|
|
1132
|
-
max = MAX_PREVIEW_LINES,
|
|
1133
|
-
dc: DiffColors = DEFAULT_DIFF_COLORS,
|
|
1134
|
-
): Promise<string> {
|
|
1135
|
-
const tw = termW();
|
|
1136
|
-
if (!shouldUseSplit(diff, tw, max)) return renderUnified(diff, language, max, dc);
|
|
1137
|
-
if (!diff.lines.length) return "";
|
|
1138
|
-
|
|
1139
|
-
// Build rows
|
|
1140
|
-
type Row = { left: DiffLine | null; right: DiffLine | null };
|
|
1141
|
-
const rows: Row[] = [];
|
|
1142
|
-
let i = 0;
|
|
1143
|
-
while (i < diff.lines.length) {
|
|
1144
|
-
const l = diff.lines[i];
|
|
1145
|
-
if (l.type === "sep" || l.type === "ctx") {
|
|
1146
|
-
rows.push({ left: l, right: l });
|
|
1147
|
-
i++;
|
|
1148
|
-
continue;
|
|
1149
|
-
}
|
|
1150
|
-
const dels: DiffLine[] = [],
|
|
1151
|
-
adds: DiffLine[] = [];
|
|
1152
|
-
while (i < diff.lines.length && diff.lines[i].type === "del") {
|
|
1153
|
-
dels.push(diff.lines[i]);
|
|
1154
|
-
i++;
|
|
1155
|
-
}
|
|
1156
|
-
while (i < diff.lines.length && diff.lines[i].type === "add") {
|
|
1157
|
-
adds.push(diff.lines[i]);
|
|
1158
|
-
i++;
|
|
1159
|
-
}
|
|
1160
|
-
const n = Math.max(dels.length, adds.length);
|
|
1161
|
-
for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
const vis = rows.slice(0, max);
|
|
1165
|
-
const half = Math.floor((tw - 1) / 2); // -1 for center divider
|
|
1166
|
-
const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
1167
|
-
const gw = nw + 5; // border + num + sign + sp + │ + sp
|
|
1168
|
-
const cw = Math.max(12, half - gw);
|
|
1169
|
-
const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
|
|
1170
|
-
|
|
1171
|
-
// Build separate code blocks per side
|
|
1172
|
-
const leftSrc: string[] = [],
|
|
1173
|
-
rightSrc: string[] = [];
|
|
1174
|
-
for (const r of vis) {
|
|
1175
|
-
if (r.left && r.left.type !== "sep") leftSrc.push(r.left.content);
|
|
1176
|
-
if (r.right && r.right.type !== "sep") rightSrc.push(r.right.content);
|
|
1177
|
-
}
|
|
1178
|
-
const [leftHL, rightHL] = canHL
|
|
1179
|
-
? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
|
|
1180
|
-
: [leftSrc, rightSrc];
|
|
1181
|
-
|
|
1182
|
-
let lI = 0,
|
|
1183
|
-
rI = 0;
|
|
1184
|
-
let stripeRow = 0; // tracks row index for diagonal stripe offset
|
|
1185
|
-
|
|
1186
|
-
// Returns { gutter, contGutter, body } for wrapping composition
|
|
1187
|
-
type HalfResult = { gutter: string; contGutter: string; bodyRows: string[] };
|
|
1188
|
-
|
|
1189
|
-
function half_build(
|
|
1190
|
-
line: DiffLine | null,
|
|
1191
|
-
hl: string,
|
|
1192
|
-
ranges: Array<[number, number]> | null,
|
|
1193
|
-
side: "left" | "right",
|
|
1194
|
-
): HalfResult {
|
|
1195
|
-
// Empty filler — diagonal stripes
|
|
1196
|
-
if (!line) {
|
|
1197
|
-
const gw2 = nw + 2; // number + sign + space before │
|
|
1198
|
-
const gPat = FG_STRIPE + "╱".repeat(gw2) + RST;
|
|
1199
|
-
const g = ` ${gPat}${FG_RULE}│${RST} `;
|
|
1200
|
-
return { gutter: g, contGutter: g, bodyRows: [stripes(cw, stripeRow)] };
|
|
1201
|
-
}
|
|
1202
|
-
// Hunk separator
|
|
1203
|
-
if (line.type === "sep") {
|
|
1204
|
-
const gap = line.newNum;
|
|
1205
|
-
const label = gap && gap > 0 ? `··· ${gap} lines ···` : "···";
|
|
1206
|
-
const g = `${BG_BASE} ${FG_DIM}${fit("", nw + 2)}${RST}${FG_RULE}│${RST} `;
|
|
1207
|
-
return { gutter: g, contGutter: g, bodyRows: [`${BG_BASE}${FG_DIM}${fit(label, cw)}${RST}`] };
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
const isDel = line.type === "del",
|
|
1211
|
-
isAdd = line.type === "add";
|
|
1212
|
-
const gBg = isDel ? BG_GUTTER_DEL : isAdd ? BG_GUTTER_ADD : BG_BASE;
|
|
1213
|
-
const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_BASE;
|
|
1214
|
-
const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
|
|
1215
|
-
const sign = isDel ? "-" : isAdd ? "+" : " ";
|
|
1216
|
-
const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum;
|
|
1217
|
-
|
|
1218
|
-
// Border bar + colored line numbers for changed lines
|
|
1219
|
-
const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
|
|
1220
|
-
const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : ` ${BG_BASE}`;
|
|
1221
|
-
const numFg = borderFg || FG_LNUM;
|
|
1222
|
-
|
|
1223
|
-
let body: string;
|
|
1224
|
-
if (ranges && ranges.length > 0) {
|
|
1225
|
-
body = injectBg(hl, ranges, cBg, isDel ? BG_DEL_W : BG_ADD_W);
|
|
1226
|
-
} else if (isDel || isAdd) {
|
|
1227
|
-
body = `${cBg}${hl}`;
|
|
1228
|
-
} else {
|
|
1229
|
-
body = `${BG_BASE}${DIM}${hl}`;
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
const gutter = `${border}${gBg}${lnum(num, nw, numFg)}${sFg}${BOLD}${sign}${RST} ${FG_RULE}│${RST} `;
|
|
1233
|
-
const contGutter = `${border}${gBg}${" ".repeat(nw + 1)}${RST} ${FG_RULE}│${RST} `;
|
|
1234
|
-
const bodyRows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), cBg);
|
|
1235
|
-
return { gutter, contGutter, bodyRows };
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
const out: string[] = [];
|
|
1239
|
-
// Column headers — "old" / "new" positioned above line numbers
|
|
1240
|
-
const hdrOld = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgDel}${DIM}old${RST}`;
|
|
1241
|
-
const hdrNew = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgAdd}${DIM}new${RST}`;
|
|
1242
|
-
out.push(`${BG_BASE}${hdrOld}${" ".repeat(Math.max(0, half - nw - 1))}${FG_RULE}┊${RST}${hdrNew}`);
|
|
1243
|
-
out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
|
|
1244
|
-
|
|
1245
|
-
for (const r of vis) {
|
|
1246
|
-
const leftLine = r.left,
|
|
1247
|
-
rightLine = r.right;
|
|
1248
|
-
const paired = leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add";
|
|
1249
|
-
const wd = paired ? wordDiffAnalysis(leftLine.content, rightLine.content) : null;
|
|
1250
|
-
|
|
1251
|
-
let lResult: HalfResult, rResult: HalfResult;
|
|
1252
|
-
|
|
1253
|
-
if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
|
|
1254
|
-
const lhl = leftHL[lI++] ?? leftLine.content;
|
|
1255
|
-
const rhl = rightHL[rI++] ?? rightLine.content;
|
|
1256
|
-
lResult = half_build(leftLine, lhl, wd.oldRanges, "left");
|
|
1257
|
-
rResult = half_build(rightLine, rhl, wd.newRanges, "right");
|
|
1258
|
-
} else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
|
|
1259
|
-
const pwd = plainWordDiff(leftLine.content, rightLine.content);
|
|
1260
|
-
lI++;
|
|
1261
|
-
rI++;
|
|
1262
|
-
lResult = half_build(leftLine, pwd.old, null, "left");
|
|
1263
|
-
rResult = half_build(rightLine, pwd.new, null, "right");
|
|
1264
|
-
} else {
|
|
1265
|
-
const lhl = leftLine && leftLine.type !== "sep" ? (leftHL[lI++] ?? leftLine?.content ?? "") : "";
|
|
1266
|
-
const rhl = rightLine && rightLine.type !== "sep" ? (rightHL[rI++] ?? rightLine?.content ?? "") : "";
|
|
1267
|
-
lResult = half_build(leftLine, lhl, null, "left");
|
|
1268
|
-
rResult = half_build(rightLine, rhl, null, "right");
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
// Compose wrapped rows — pad shorter side with striped continuation rows
|
|
1272
|
-
const maxRows = Math.max(lResult.bodyRows.length, rResult.bodyRows.length);
|
|
1273
|
-
const leftIsEmpty = !r.left;
|
|
1274
|
-
const rightIsEmpty = !r.right;
|
|
1275
|
-
for (let row = 0; row < maxRows; row++) {
|
|
1276
|
-
const lg = row === 0 ? lResult.gutter : lResult.contGutter;
|
|
1277
|
-
const rg = row === 0 ? rResult.gutter : rResult.contGutter;
|
|
1278
|
-
const lb = lResult.bodyRows[row] ?? (leftIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
|
|
1279
|
-
const rb =
|
|
1280
|
-
rResult.bodyRows[row] ?? (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
|
|
1281
|
-
out.push(`${lg}${lb}${DIVIDER}${rg}${rb}`);
|
|
1282
|
-
stripeRow++;
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
|
|
1287
|
-
if (rows.length > vis.length) {
|
|
1288
|
-
out.push(`${BG_BASE}${FG_DIM} … ${rows.length - vis.length} more lines${RST}`);
|
|
1289
|
-
}
|
|
1290
|
-
return out.join("\n");
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
// ---------------------------------------------------------------------------
|
|
1294
|
-
// Extension
|
|
1295
|
-
// ---------------------------------------------------------------------------
|
|
1296
|
-
|
|
1297
|
-
export const __testing = {
|
|
1298
|
-
normalizeShikiContrast,
|
|
1299
|
-
parseDiff,
|
|
1300
|
-
renderSplit,
|
|
1301
|
-
renderUnified,
|
|
1302
|
-
};
|
|
1303
|
-
|
|
1304
|
-
export default function diffRendererExtension(pi: any): void {
|
|
1305
|
-
// Apply diff theme palette from settings/presets before rendering
|
|
1306
|
-
applyDiffPalette();
|
|
1307
|
-
|
|
1308
|
-
let createWriteTool: any, createEditTool: any, TextComponent: any;
|
|
1309
|
-
try {
|
|
1310
|
-
const sdk = require("@mariozechner/pi-coding-agent");
|
|
1311
|
-
createWriteTool = sdk.createWriteTool;
|
|
1312
|
-
createEditTool = sdk.createEditTool;
|
|
1313
|
-
TextComponent = require("@mariozechner/pi-tui").Text;
|
|
1314
|
-
} catch {
|
|
1315
|
-
return;
|
|
1316
|
-
}
|
|
1317
|
-
if (!createWriteTool || !createEditTool || !TextComponent) return;
|
|
1318
|
-
|
|
1319
|
-
const cwd = process.cwd();
|
|
1320
|
-
const home = process.env.HOME ?? "";
|
|
1321
|
-
const sp = (p: string) => shortPath(cwd, home, p);
|
|
1322
|
-
|
|
1323
|
-
// =======================================================================
|
|
1324
|
-
// write
|
|
1325
|
-
// =======================================================================
|
|
1326
|
-
|
|
1327
|
-
const origWrite = createWriteTool(cwd);
|
|
1328
|
-
|
|
1329
|
-
pi.registerTool({
|
|
1330
|
-
...origWrite,
|
|
1331
|
-
name: "write",
|
|
1332
|
-
|
|
1333
|
-
async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
|
|
1334
|
-
const fp = params.path ?? params.file_path ?? "";
|
|
1335
|
-
let old: string | null = null;
|
|
1336
|
-
try {
|
|
1337
|
-
if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
|
|
1338
|
-
} catch {
|
|
1339
|
-
old = null;
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
const result = await origWrite.execute(tid, params, sig, upd, ctx);
|
|
1343
|
-
const content = params.content ?? "";
|
|
1344
|
-
|
|
1345
|
-
// Store in details — the only custom field TUI preserves in renderResult
|
|
1346
|
-
if (old !== null && old !== content) {
|
|
1347
|
-
const diff = parseDiff(old, content);
|
|
1348
|
-
const lg = lang(fp);
|
|
1349
|
-
(result as any).details = { _type: "diff", summary: summarize(diff.added, diff.removed), diff, language: lg };
|
|
1350
|
-
} else if (old === null) {
|
|
1351
|
-
const lineCount = content ? content.split("\n").length : 0;
|
|
1352
|
-
(result as any).details = { _type: "new", lines: lineCount, content: content ?? "", filePath: fp };
|
|
1353
|
-
} else if (old === content) {
|
|
1354
|
-
(result as any).details = { _type: "noChange" };
|
|
1355
|
-
}
|
|
1356
|
-
return result;
|
|
1357
|
-
},
|
|
1358
|
-
|
|
1359
|
-
renderCall(args: any, theme: any, ctx: any) {
|
|
1360
|
-
const fp = args?.path ?? args?.file_path ?? "";
|
|
1361
|
-
const isNew = !fp || !existsSync(fp);
|
|
1362
|
-
const label = isNew ? "create" : "write";
|
|
1363
|
-
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1364
|
-
const hdr = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", sp(fp))}`;
|
|
1365
|
-
|
|
1366
|
-
// Streaming
|
|
1367
|
-
if (args?.content && !ctx.argsComplete) {
|
|
1368
|
-
const n = String(args.content).split("\n").length;
|
|
1369
|
-
text.setText(`${hdr} ${theme.fg("muted", `(${n} lines…)`)}`);
|
|
1370
|
-
return text;
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
// New file preview with Shiki
|
|
1374
|
-
if (args?.content && ctx.argsComplete && isNew) {
|
|
1375
|
-
const previewKey = `create:${fp}:${String(args.content).length}`;
|
|
1376
|
-
if (ctx.state._previewKey !== previewKey) {
|
|
1377
|
-
ctx.state._previewKey = previewKey;
|
|
1378
|
-
ctx.state._previewText = hdr;
|
|
1379
|
-
const lg = lang(fp);
|
|
1380
|
-
hlBlock(args.content, lg)
|
|
1381
|
-
.then((lines: string[]) => {
|
|
1382
|
-
if (ctx.state._previewKey !== previewKey) return;
|
|
1383
|
-
const maxShow = ctx.expanded ? lines.length : 16;
|
|
1384
|
-
const preview = lines.slice(0, maxShow).join("\n");
|
|
1385
|
-
const rem = lines.length - maxShow;
|
|
1386
|
-
let out = `${hdr}\n\n${preview}`;
|
|
1387
|
-
if (rem > 0) out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
|
|
1388
|
-
ctx.state._previewText = out;
|
|
1389
|
-
ctx.invalidate();
|
|
1390
|
-
})
|
|
1391
|
-
.catch(() => {});
|
|
1392
|
-
}
|
|
1393
|
-
text.setText(ctx.state._previewText ?? hdr);
|
|
1394
|
-
return text;
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
text.setText(hdr);
|
|
1398
|
-
return text;
|
|
1399
|
-
},
|
|
1400
|
-
|
|
1401
|
-
renderResult(result: any, _opt: any, theme: any, ctx: any) {
|
|
1402
|
-
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1403
|
-
if (ctx.isError) {
|
|
1404
|
-
const e =
|
|
1405
|
-
result.content
|
|
1406
|
-
?.filter((c: any) => c.type === "text")
|
|
1407
|
-
.map((c: any) => c.text || "")
|
|
1408
|
-
.join("\n") ?? "Error";
|
|
1409
|
-
text.setText(`\n${theme.fg("error", e)}`);
|
|
1410
|
-
return text;
|
|
1411
|
-
}
|
|
1412
|
-
const d = result.details;
|
|
1413
|
-
if (d?._type === "diff") {
|
|
1414
|
-
const w = termW();
|
|
1415
|
-
const key = `wd:${w}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`;
|
|
1416
|
-
if (ctx.state._wdk !== key) {
|
|
1417
|
-
ctx.state._wdk = key;
|
|
1418
|
-
ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
|
|
1419
|
-
const dc = resolveDiffColors(theme);
|
|
1420
|
-
renderSplit(d.diff, d.language, MAX_RENDER_LINES, dc)
|
|
1421
|
-
.then((rendered: string) => {
|
|
1422
|
-
if (ctx.state._wdk !== key) return;
|
|
1423
|
-
ctx.state._wdt = ` ${d.summary}\n${rendered}`;
|
|
1424
|
-
ctx.invalidate();
|
|
1425
|
-
})
|
|
1426
|
-
.catch(() => {
|
|
1427
|
-
if (ctx.state._wdk !== key) return;
|
|
1428
|
-
ctx.state._wdt = ` ${d.summary}`;
|
|
1429
|
-
ctx.invalidate();
|
|
1430
|
-
});
|
|
1431
|
-
}
|
|
1432
|
-
text.setText(ctx.state._wdt ?? ` ${d.summary}`);
|
|
1433
|
-
return text;
|
|
1434
|
-
}
|
|
1435
|
-
if (d?._type === "noChange") {
|
|
1436
|
-
text.setText(` ${theme.fg("muted", "✓ no changes")}`);
|
|
1437
|
-
return text;
|
|
1438
|
-
}
|
|
1439
|
-
if (d?._type === "new") {
|
|
1440
|
-
const { lines: lineCount, content: rawContent, filePath: fp } = d;
|
|
1441
|
-
const pk = `nf:${fp}:${lineCount}`;
|
|
1442
|
-
if (ctx.state._nfk !== pk) {
|
|
1443
|
-
ctx.state._nfk = pk;
|
|
1444
|
-
ctx.state._nft = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
|
|
1445
|
-
const lg = lang(fp);
|
|
1446
|
-
if (rawContent) {
|
|
1447
|
-
hlBlock(rawContent, lg)
|
|
1448
|
-
.then((hlLines: string[]) => {
|
|
1449
|
-
if (ctx.state._nfk !== pk) return;
|
|
1450
|
-
const maxShow = ctx.expanded ? hlLines.length : 12;
|
|
1451
|
-
const preview = hlLines.slice(0, maxShow).join("\n");
|
|
1452
|
-
const rem = hlLines.length - maxShow;
|
|
1453
|
-
let out = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}\n${preview}`;
|
|
1454
|
-
if (rem > 0) out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
|
|
1455
|
-
ctx.state._nft = out;
|
|
1456
|
-
ctx.invalidate();
|
|
1457
|
-
})
|
|
1458
|
-
.catch(() => {});
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
text.setText(ctx.state._nft ?? ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`);
|
|
1462
|
-
return text;
|
|
1463
|
-
}
|
|
1464
|
-
text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "written").slice(0, 120))}`);
|
|
1465
|
-
return text;
|
|
1466
|
-
},
|
|
1467
|
-
});
|
|
1468
|
-
|
|
1469
|
-
// =======================================================================
|
|
1470
|
-
// edit
|
|
1471
|
-
// =======================================================================
|
|
1472
|
-
|
|
1473
|
-
const origEdit = createEditTool(cwd);
|
|
1474
|
-
|
|
1475
|
-
function getEditOperations(input: any): Array<{ oldText: string; newText: string }> {
|
|
1476
|
-
if (Array.isArray(input?.edits)) {
|
|
1477
|
-
return input.edits
|
|
1478
|
-
.map((edit: any) => ({
|
|
1479
|
-
oldText:
|
|
1480
|
-
typeof edit?.oldText === "string" ? edit.oldText : typeof edit?.old_text === "string" ? edit.old_text : "",
|
|
1481
|
-
newText:
|
|
1482
|
-
typeof edit?.newText === "string" ? edit.newText : typeof edit?.new_text === "string" ? edit.new_text : "",
|
|
1483
|
-
}))
|
|
1484
|
-
.filter((edit: { oldText: string; newText: string }) => edit.oldText && edit.oldText !== edit.newText);
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
const oldText =
|
|
1488
|
-
typeof input?.oldText === "string" ? input.oldText : typeof input?.old_text === "string" ? input.old_text : "";
|
|
1489
|
-
const newText =
|
|
1490
|
-
typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
|
|
1491
|
-
return oldText && oldText !== newText ? [{ oldText, newText }] : [];
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
function summarizeEditOperations(operations: Array<{ oldText: string; newText: string }>) {
|
|
1495
|
-
const diffs = operations.map((edit) => parseDiff(edit.oldText, edit.newText));
|
|
1496
|
-
const totalAdded = diffs.reduce((sum, diff) => sum + diff.added, 0);
|
|
1497
|
-
const totalRemoved = diffs.reduce((sum, diff) => sum + diff.removed, 0);
|
|
1498
|
-
return {
|
|
1499
|
-
diffs,
|
|
1500
|
-
totalAdded,
|
|
1501
|
-
totalRemoved,
|
|
1502
|
-
summary: summarize(totalAdded, totalRemoved),
|
|
1503
|
-
};
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
pi.registerTool({
|
|
1507
|
-
...origEdit,
|
|
1508
|
-
name: "edit",
|
|
1509
|
-
|
|
1510
|
-
async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
|
|
1511
|
-
const fp = params.path ?? params.file_path ?? "";
|
|
1512
|
-
const operations = getEditOperations(params);
|
|
1513
|
-
const result = await origEdit.execute(tid, params, sig, upd, ctx);
|
|
1514
|
-
|
|
1515
|
-
if (operations.length === 0) return result;
|
|
1516
|
-
|
|
1517
|
-
const { diffs, summary } = summarizeEditOperations(operations);
|
|
1518
|
-
if (operations.length === 1) {
|
|
1519
|
-
let editLine = 0;
|
|
1520
|
-
try {
|
|
1521
|
-
if (fp && existsSync(fp)) {
|
|
1522
|
-
const f = readFileSync(fp, "utf-8");
|
|
1523
|
-
const idx = f.indexOf(operations[0].newText);
|
|
1524
|
-
if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
|
|
1525
|
-
}
|
|
1526
|
-
} catch {
|
|
1527
|
-
editLine = 0;
|
|
1528
|
-
}
|
|
1529
|
-
(result as any).details = { _type: "editInfo", summary, editLine };
|
|
1530
|
-
return result;
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
(result as any).details = {
|
|
1534
|
-
_type: "multiEditInfo",
|
|
1535
|
-
summary,
|
|
1536
|
-
editCount: operations.length,
|
|
1537
|
-
diffLineCount: diffs.reduce((sum, diff) => sum + diff.lines.length, 0),
|
|
1538
|
-
};
|
|
1539
|
-
return result;
|
|
1540
|
-
},
|
|
1541
|
-
|
|
1542
|
-
renderCall(args: any, theme: any, ctx: any) {
|
|
1543
|
-
const fp = args?.path ?? args?.file_path ?? "";
|
|
1544
|
-
const operations = getEditOperations(args);
|
|
1545
|
-
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1546
|
-
const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
|
|
1547
|
-
|
|
1548
|
-
if (!(ctx.argsComplete && operations.length > 0)) {
|
|
1549
|
-
text.setText(hdr);
|
|
1550
|
-
return text;
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
const pk = JSON.stringify({ fp, operations, w: termW() });
|
|
1554
|
-
if (ctx.state._pk !== pk) {
|
|
1555
|
-
ctx.state._pk = pk;
|
|
1556
|
-
ctx.state._pt = `${hdr} ${theme.fg("muted", "(rendering…)")}`;
|
|
1557
|
-
const lg = lang(fp);
|
|
1558
|
-
const dc = resolveDiffColors(theme);
|
|
1559
|
-
|
|
1560
|
-
if (operations.length === 1) {
|
|
1561
|
-
const diff = parseDiff(operations[0].oldText, operations[0].newText);
|
|
1562
|
-
renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
|
|
1563
|
-
.then((rendered) => {
|
|
1564
|
-
if (ctx.state._pk !== pk) return;
|
|
1565
|
-
ctx.state._pt = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`;
|
|
1566
|
-
ctx.invalidate();
|
|
1567
|
-
})
|
|
1568
|
-
.catch(() => {
|
|
1569
|
-
if (ctx.state._pk !== pk) return;
|
|
1570
|
-
ctx.state._pt = `${hdr} ${summarize(diff.added, diff.removed)}`;
|
|
1571
|
-
ctx.invalidate();
|
|
1572
|
-
});
|
|
1573
|
-
} else {
|
|
1574
|
-
const { diffs, summary } = summarizeEditOperations(operations);
|
|
1575
|
-
const maxShown = Math.min(operations.length, 3);
|
|
1576
|
-
const previewLines = Math.max(8, Math.floor(MAX_PREVIEW_LINES / maxShown));
|
|
1577
|
-
Promise.all(
|
|
1578
|
-
diffs.slice(0, maxShown).map((diff, index) =>
|
|
1579
|
-
renderSplit(diff, lg, previewLines, dc)
|
|
1580
|
-
.then((rendered) => `Edit ${index + 1}/${operations.length}\n${rendered}`)
|
|
1581
|
-
.catch(() => `Edit ${index + 1}/${operations.length} ${summarize(diff.added, diff.removed)}`),
|
|
1582
|
-
),
|
|
1583
|
-
)
|
|
1584
|
-
.then((sections) => {
|
|
1585
|
-
if (ctx.state._pk !== pk) return;
|
|
1586
|
-
const remainder = operations.length - maxShown;
|
|
1587
|
-
const suffix = remainder > 0 ? `\n${theme.fg("muted", `… ${remainder} more edit blocks`)}` : "";
|
|
1588
|
-
ctx.state._pt = `${hdr}\n${operations.length} edits ${summary}\n\n${sections.join("\n\n")}${suffix}`;
|
|
1589
|
-
ctx.invalidate();
|
|
1590
|
-
})
|
|
1591
|
-
.catch(() => {
|
|
1592
|
-
if (ctx.state._pk !== pk) return;
|
|
1593
|
-
ctx.state._pt = `${hdr} ${operations.length} edits ${summary}`;
|
|
1594
|
-
ctx.invalidate();
|
|
1595
|
-
});
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
text.setText(ctx.state._pt ?? hdr);
|
|
1600
|
-
return text;
|
|
1601
|
-
},
|
|
1602
|
-
|
|
1603
|
-
renderResult(result: any, _opt: any, theme: any, ctx: any) {
|
|
1604
|
-
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1605
|
-
if (ctx.isError) {
|
|
1606
|
-
const e =
|
|
1607
|
-
result.content
|
|
1608
|
-
?.filter((c: any) => c.type === "text")
|
|
1609
|
-
.map((c: any) => c.text || "")
|
|
1610
|
-
.join("\n") ?? "Error";
|
|
1611
|
-
text.setText(`\n${theme.fg("error", e)}`);
|
|
1612
|
-
return text;
|
|
1613
|
-
}
|
|
1614
|
-
if (result.details?._type === "editInfo") {
|
|
1615
|
-
const { summary: s, editLine } = result.details;
|
|
1616
|
-
const loc = editLine > 0 ? ` ${theme.fg("muted", `at line ${editLine}`)}` : "";
|
|
1617
|
-
const content = ` ${s}${loc}`;
|
|
1618
|
-
const vis = content.replace(ANSI_RE, "").length;
|
|
1619
|
-
const pad = Math.max(0, termW() - vis);
|
|
1620
|
-
text.setText(`${content}${" ".repeat(pad)}`);
|
|
1621
|
-
return text;
|
|
1622
|
-
}
|
|
1623
|
-
if (result.details?._type === "multiEditInfo") {
|
|
1624
|
-
const { summary: s, editCount, diffLineCount } = result.details;
|
|
1625
|
-
const content = ` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`;
|
|
1626
|
-
const vis = content.replace(ANSI_RE, "").length;
|
|
1627
|
-
const pad = Math.max(0, termW() - vis);
|
|
1628
|
-
text.setText(`${content}${" ".repeat(pad)}`);
|
|
1629
|
-
return text;
|
|
1630
|
-
}
|
|
1631
|
-
text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "edited").slice(0, 120))}`);
|
|
1632
|
-
return text;
|
|
1633
|
-
},
|
|
1634
|
-
});
|
|
1635
|
-
}
|