@heyhuynhgiabuu/pi-diff 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -3
- package/biome.json +20 -15
- package/media/split.png +0 -0
- package/media/unified.png +0 -0
- package/package.json +50 -50
- package/src/index.ts +608 -169
package/src/index.ts
CHANGED
|
@@ -28,11 +28,275 @@ import { codeToANSI } from "@shikijs/cli";
|
|
|
28
28
|
import * as Diff from "diff";
|
|
29
29
|
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
30
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
|
+
/** Whether auto-derive from theme is still pending (runs lazily on first render). */
|
|
173
|
+
let _autoDerivePending = true;
|
|
174
|
+
|
|
175
|
+
/** Whether user set explicit bg config (via preset or per-color overrides). */
|
|
176
|
+
let _hasExplicitBgConfig = false;
|
|
177
|
+
|
|
178
|
+
/** Auto-derive all diff background colors from the pi theme's fg diff colors.
|
|
179
|
+
* Uses different intensity levels for line bg, word highlights, and gutters. */
|
|
180
|
+
function autoDeriveBgFromTheme(theme: any): void {
|
|
181
|
+
if (!theme?.getFgAnsi) return;
|
|
182
|
+
try {
|
|
183
|
+
const fgAdd = theme.getFgAnsi("toolDiffAdded");
|
|
184
|
+
const fgDel = theme.getFgAnsi("toolDiffRemoved");
|
|
185
|
+
|
|
186
|
+
// Line backgrounds — subtle tint (8–10% of fg color)
|
|
187
|
+
const bgAdd = deriveBgFromFg(fgAdd, 0.08);
|
|
188
|
+
if (bgAdd) BG_ADD = bgAdd;
|
|
189
|
+
const bgDel = deriveBgFromFg(fgDel, 0.10);
|
|
190
|
+
if (bgDel) BG_DEL = bgDel;
|
|
191
|
+
|
|
192
|
+
// Word-level highlights — more visible (20–22%)
|
|
193
|
+
const bgAddW = deriveBgFromFg(fgAdd, 0.20);
|
|
194
|
+
if (bgAddW) BG_ADD_W = bgAddW;
|
|
195
|
+
const bgDelW = deriveBgFromFg(fgDel, 0.22);
|
|
196
|
+
if (bgDelW) BG_DEL_W = bgDelW;
|
|
197
|
+
|
|
198
|
+
// Gutters — subtler than lines (5–6%)
|
|
199
|
+
const bgGA = deriveBgFromFg(fgAdd, 0.05);
|
|
200
|
+
if (bgGA) BG_GUTTER_ADD = bgGA;
|
|
201
|
+
const bgGD = deriveBgFromFg(fgDel, 0.06);
|
|
202
|
+
if (bgGD) BG_GUTTER_DEL = bgGD;
|
|
203
|
+
|
|
204
|
+
// Empty filler — neutral dark from add color luminance
|
|
205
|
+
const addRgb = parseAnsiRgb(fgAdd);
|
|
206
|
+
if (addRgb) {
|
|
207
|
+
const lum = Math.round((addRgb.r * 0.3 + addRgb.g * 0.6 + addRgb.b * 0.1) * 0.04);
|
|
208
|
+
BG_EMPTY = `\x1b[48;2;${lum};${lum};${lum}m`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Rebuild derived constants
|
|
212
|
+
DIVIDER = `${FG_RULE}│${RST}`;
|
|
213
|
+
} catch {
|
|
214
|
+
// Fall back to defaults silently
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Load diff theme config from .pi/settings.json (project-level, then global). */
|
|
219
|
+
function loadDiffConfig(): DiffUserConfig {
|
|
220
|
+
const paths = [
|
|
221
|
+
`${process.cwd()}/.pi/settings.json`,
|
|
222
|
+
`${process.env.HOME ?? ""}/.pi/settings.json`,
|
|
223
|
+
];
|
|
224
|
+
for (const p of paths) {
|
|
225
|
+
try {
|
|
226
|
+
if (existsSync(p)) {
|
|
227
|
+
const raw = JSON.parse(readFileSync(p, "utf-8"));
|
|
228
|
+
if (raw.diffTheme || raw.diffColors) {
|
|
229
|
+
return { diffTheme: raw.diffTheme, diffColors: raw.diffColors };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
// skip invalid files
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return {};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Apply diff palette from settings → preset → (auto-derive deferred) → defaults.
|
|
240
|
+
* Called once during extension initialization. */
|
|
241
|
+
function applyDiffPalette(): void {
|
|
242
|
+
const config = loadDiffConfig();
|
|
243
|
+
|
|
244
|
+
// Load preset if specified
|
|
245
|
+
const preset = config.diffTheme ? DIFF_PRESETS[config.diffTheme] : null;
|
|
246
|
+
if (preset) _hasExplicitBgConfig = true;
|
|
247
|
+
|
|
248
|
+
// Per-color overrides from settings
|
|
249
|
+
const ov = config.diffColors ?? {};
|
|
250
|
+
if (Object.keys(ov).length > 0) _hasExplicitBgConfig = true;
|
|
251
|
+
|
|
252
|
+
// Helper: apply a hex bg color if not env-overridden
|
|
253
|
+
const applyBg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
|
|
254
|
+
if (envName && process.env[envName]) return; // env override wins
|
|
255
|
+
const hex = ov[key] ?? presetVal;
|
|
256
|
+
if (hex) { const a = hexToBgAnsi(hex); if (a) set(a); }
|
|
257
|
+
};
|
|
258
|
+
// Helper: apply a hex fg color if not env-overridden
|
|
259
|
+
const applyFg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
|
|
260
|
+
if (envName && process.env[envName]) return;
|
|
261
|
+
const hex = ov[key] ?? presetVal;
|
|
262
|
+
if (hex) { const a = hexToFgAnsi(hex); if (a) set(a); }
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// --- Apply backgrounds ---
|
|
266
|
+
applyBg("DIFF_BG_ADD", "bgAdd", preset?.bgAdd, (v) => { BG_ADD = v; });
|
|
267
|
+
applyBg("DIFF_BG_DEL", "bgDel", preset?.bgDel, (v) => { BG_DEL = v; });
|
|
268
|
+
applyBg("DIFF_BG_ADD_HL", "bgAddHighlight", preset?.bgAddHighlight, (v) => { BG_ADD_W = v; });
|
|
269
|
+
applyBg("DIFF_BG_DEL_HL", "bgDelHighlight", preset?.bgDelHighlight, (v) => { BG_DEL_W = v; });
|
|
270
|
+
applyBg("DIFF_BG_GUTTER_ADD", "bgGutterAdd", preset?.bgGutterAdd, (v) => { BG_GUTTER_ADD = v; });
|
|
271
|
+
applyBg("DIFF_BG_GUTTER_DEL", "bgGutterDel", preset?.bgGutterDel, (v) => { BG_GUTTER_DEL = v; });
|
|
272
|
+
applyBg(null, "bgEmpty", preset?.bgEmpty, (v) => { BG_EMPTY = v; });
|
|
273
|
+
|
|
274
|
+
// --- Apply foregrounds ---
|
|
275
|
+
applyFg("DIFF_FG_ADD", "fgAdd", preset?.fgAdd, (v) => { FG_ADD = v; });
|
|
276
|
+
applyFg("DIFF_FG_DEL", "fgDel", preset?.fgDel, (v) => { FG_DEL = v; });
|
|
277
|
+
applyFg(null, "fgDim", preset?.fgDim, (v) => { FG_DIM = v; });
|
|
278
|
+
applyFg(null, "fgLnum", preset?.fgLnum, (v) => { FG_LNUM = v; });
|
|
279
|
+
applyFg(null, "fgRule", preset?.fgRule, (v) => { FG_RULE = v; });
|
|
280
|
+
applyFg(null, "fgStripe", preset?.fgStripe, (v) => { FG_STRIPE = v; });
|
|
281
|
+
applyFg(null, "fgSafeMuted", preset?.fgSafeMuted, (v) => { FG_SAFE_MUTED = v; });
|
|
282
|
+
|
|
283
|
+
// --- Shiki syntax theme ---
|
|
284
|
+
const shiki = ov.shikiTheme ?? preset?.shikiTheme;
|
|
285
|
+
if (shiki) THEME = shiki as BundledTheme;
|
|
286
|
+
|
|
287
|
+
// --- Rebuild derived constants ---
|
|
288
|
+
DIVIDER = `${FG_RULE}│${RST}`;
|
|
289
|
+
DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
|
|
290
|
+
|
|
291
|
+
// If no explicit bg config, auto-derive will run on first render
|
|
292
|
+
_autoDerivePending = !_hasExplicitBgConfig;
|
|
293
|
+
}
|
|
294
|
+
|
|
31
295
|
// ---------------------------------------------------------------------------
|
|
32
296
|
// Config
|
|
33
297
|
// ---------------------------------------------------------------------------
|
|
34
298
|
|
|
35
|
-
|
|
299
|
+
let THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
|
|
36
300
|
|
|
37
301
|
function envInt(name: string, fallback: number): number {
|
|
38
302
|
const v = Number.parseInt(process.env[name] ?? "", 10);
|
|
@@ -60,30 +324,30 @@ function envBg(name: string, fallback: string): string {
|
|
|
60
324
|
// --- Split-view thresholds ---
|
|
61
325
|
// Split is preferred when there's real room. At narrow widths, a clean stacked
|
|
62
326
|
// (unified) view is better than a cramped split with wrapping.
|
|
63
|
-
const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", 150);
|
|
327
|
+
const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", 150); // need ≥150 cols for split to breathe
|
|
64
328
|
const SPLIT_MIN_CODE_WIDTH = envInt("DIFF_SPLIT_MIN_CODE_WIDTH", 60); // ≥60 code cols per side
|
|
65
|
-
const SPLIT_MAX_WRAP_RATIO = 0.
|
|
66
|
-
const SPLIT_MAX_WRAP_LINES = 8;
|
|
329
|
+
const SPLIT_MAX_WRAP_RATIO = 0.2; // if >20% lines wrap in split, fall back to stacked
|
|
330
|
+
const SPLIT_MAX_WRAP_LINES = 8; // absolute cap before unified fallback
|
|
67
331
|
|
|
68
332
|
// --- Terminal bounds ---
|
|
69
|
-
const MAX_TERM_WIDTH = 210;
|
|
70
|
-
const DEFAULT_TERM_WIDTH = 200;
|
|
333
|
+
const MAX_TERM_WIDTH = 210; // max for 1728px wide display (~205 cols at typical font)
|
|
334
|
+
const DEFAULT_TERM_WIDTH = 200; // safe default for 1728x1117 resolution
|
|
71
335
|
|
|
72
336
|
// --- Rendering limits ---
|
|
73
|
-
const MAX_PREVIEW_LINES = 60;
|
|
74
|
-
const MAX_RENDER_LINES = 150;
|
|
75
|
-
const MAX_HL_CHARS = 80_000;
|
|
76
|
-
const CACHE_LIMIT = 192;
|
|
337
|
+
const MAX_PREVIEW_LINES = 60; // was 50 — show slightly more context in edit preview
|
|
338
|
+
const MAX_RENDER_LINES = 150; // was 120 — show more of the diff in write tool
|
|
339
|
+
const MAX_HL_CHARS = 80_000; // was 50k — allow syntax hl for larger diffs
|
|
340
|
+
const CACHE_LIMIT = 192; // was 128 — bigger cache for multi-file sessions
|
|
77
341
|
|
|
78
342
|
// --- Word diff ---
|
|
79
|
-
const WORD_DIFF_MIN_SIM = 0.15;
|
|
343
|
+
const WORD_DIFF_MIN_SIM = 0.15; // was 0.2 — show word diffs for slightly less similar lines
|
|
80
344
|
|
|
81
345
|
// --- Wrapping ---
|
|
82
346
|
// Adaptive: narrow terminals truncate aggressively, wide terminals allow wrapping.
|
|
83
347
|
// Actual wrap rows are computed per-render via adaptiveWrapRows().
|
|
84
|
-
const MAX_WRAP_ROWS_WIDE = 3;
|
|
85
|
-
const MAX_WRAP_ROWS_MED
|
|
86
|
-
const MAX_WRAP_ROWS_NARROW = 1;
|
|
348
|
+
const MAX_WRAP_ROWS_WIDE = 3; // ≥180 cols
|
|
349
|
+
const MAX_WRAP_ROWS_MED = 2; // 120–179 cols
|
|
350
|
+
const MAX_WRAP_ROWS_NARROW = 1; // <120 cols (truncate, no wrap)
|
|
87
351
|
|
|
88
352
|
// ---------------------------------------------------------------------------
|
|
89
353
|
// ANSI
|
|
@@ -95,24 +359,24 @@ const DIM = "\x1b[2m";
|
|
|
95
359
|
|
|
96
360
|
// Subtle diff backgrounds — muted tones to let syntax fg shine through
|
|
97
361
|
// Override via env: DIFF_BG_ADD="#1a3320" etc. (hex "#RRGGBB" format)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const BG_GUTTER_CTX = "";
|
|
105
|
-
|
|
362
|
+
let BG_ADD = envBg("DIFF_BG_ADD", "\x1b[48;2;22;38;32m"); // muted teal-green
|
|
363
|
+
let BG_DEL = envBg("DIFF_BG_DEL", "\x1b[48;2;45;25;25m"); // muted brown-red
|
|
364
|
+
let BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;35;75;50m"); // word-level emphasis
|
|
365
|
+
let BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;80;35;35m");
|
|
366
|
+
let BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;18;32;26m");
|
|
367
|
+
let BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", "\x1b[48;2;38;22;22m");
|
|
368
|
+
const BG_GUTTER_CTX = ""; // use terminal default bg for context gutters
|
|
369
|
+
let BG_EMPTY = "\x1b[48;2;18;18;18m"; // filler rows when one side is shorter
|
|
106
370
|
|
|
107
371
|
// Diff foregrounds — override via env: DIFF_FG_ADD="#50d264" etc.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
372
|
+
let FG_ADD = envFg("DIFF_FG_ADD", "\x1b[38;2;100;180;120m"); // desaturated green
|
|
373
|
+
let FG_DEL = envFg("DIFF_FG_DEL", "\x1b[38;2;200;100;100m"); // desaturated red
|
|
374
|
+
let FG_DIM = "\x1b[38;2;80;80;80m";
|
|
375
|
+
let FG_LNUM = "\x1b[38;2;100;100;100m";
|
|
376
|
+
let FG_RULE = "\x1b[38;2;50;50;50m";
|
|
377
|
+
let FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
|
|
114
378
|
|
|
115
|
-
|
|
379
|
+
let FG_STRIPE = "\x1b[38;2;40;40;40m"; // gray diagonal stripes on terminal default bg
|
|
116
380
|
|
|
117
381
|
const BORDER_BAR = "▌";
|
|
118
382
|
|
|
@@ -122,8 +386,11 @@ function stripes(w: number, _rowOffset: number): string {
|
|
|
122
386
|
return FG_STRIPE + "╱".repeat(w) + RST;
|
|
123
387
|
}
|
|
124
388
|
|
|
125
|
-
|
|
126
|
-
const
|
|
389
|
+
let DIVIDER = `${FG_RULE}│${RST}`;
|
|
390
|
+
const ESC_RE = "\u001b";
|
|
391
|
+
const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, "g");
|
|
392
|
+
const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
|
|
393
|
+
const ANSI_PARAM_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
|
|
127
394
|
const BG_DEFAULT = "\x1b[49m"; // reset to terminal default background
|
|
128
395
|
|
|
129
396
|
// ---------------------------------------------------------------------------
|
|
@@ -137,10 +404,17 @@ interface DiffColors {
|
|
|
137
404
|
fgCtx: string;
|
|
138
405
|
}
|
|
139
406
|
|
|
140
|
-
|
|
407
|
+
let DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
|
|
141
408
|
|
|
142
|
-
/** Resolve diff fg colors from theme (if available), falling back to hardcoded ANSI.
|
|
409
|
+
/** Resolve diff fg colors from theme (if available), falling back to hardcoded ANSI.
|
|
410
|
+
* On first call with a valid theme, auto-derives bg colors if no explicit config was set. */
|
|
143
411
|
function resolveDiffColors(theme?: any): DiffColors {
|
|
412
|
+
// Auto-derive bg colors from theme on first render (if no explicit preset/overrides)
|
|
413
|
+
if (_autoDerivePending && theme?.getFgAnsi) {
|
|
414
|
+
autoDeriveBgFromTheme(theme);
|
|
415
|
+
_autoDerivePending = false;
|
|
416
|
+
}
|
|
417
|
+
|
|
144
418
|
if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
|
|
145
419
|
try {
|
|
146
420
|
const fgAdd = theme.getFgAnsi("toolDiffAdded") || FG_ADD;
|
|
@@ -186,16 +460,21 @@ interface ParsedDiff {
|
|
|
186
460
|
// Utilities
|
|
187
461
|
// ---------------------------------------------------------------------------
|
|
188
462
|
|
|
189
|
-
function strip(s: string): string {
|
|
463
|
+
function strip(s: string): string {
|
|
464
|
+
return s.replace(ANSI_RE, "");
|
|
465
|
+
}
|
|
190
466
|
|
|
191
|
-
function tabs(s: string): string {
|
|
467
|
+
function tabs(s: string): string {
|
|
468
|
+
return s.replace(/\t/g, " ");
|
|
469
|
+
}
|
|
192
470
|
|
|
193
471
|
function termW(): number {
|
|
194
472
|
// Try multiple sources — process.stdout.columns may be undefined in piped/subagent contexts
|
|
195
|
-
const raw =
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
||
|
|
473
|
+
const raw =
|
|
474
|
+
process.stdout.columns ||
|
|
475
|
+
(process.stderr as any).columns ||
|
|
476
|
+
Number.parseInt(process.env.COLUMNS ?? "", 10) ||
|
|
477
|
+
DEFAULT_TERM_WIDTH;
|
|
199
478
|
return Math.max(80, Math.min(raw - 4, MAX_TERM_WIDTH)); // -4 safety margin for pi TUI padding
|
|
200
479
|
}
|
|
201
480
|
|
|
@@ -206,27 +485,39 @@ function fit(s: string, w: number): string {
|
|
|
206
485
|
if (plain.length <= w) return s + " ".repeat(w - plain.length);
|
|
207
486
|
// Truncated — show content + dim › indicator
|
|
208
487
|
const showW = w > 2 ? w - 1 : w;
|
|
209
|
-
let vis = 0,
|
|
488
|
+
let vis = 0,
|
|
489
|
+
i = 0;
|
|
210
490
|
while (i < s.length && vis < showW) {
|
|
211
|
-
if (s[i] === "\x1b") {
|
|
212
|
-
|
|
491
|
+
if (s[i] === "\x1b") {
|
|
492
|
+
const e = s.indexOf("m", i);
|
|
493
|
+
if (e !== -1) {
|
|
494
|
+
i = e + 1;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
vis++;
|
|
499
|
+
i++;
|
|
213
500
|
}
|
|
214
|
-
return w > 2
|
|
215
|
-
? s.slice(0, i) + RST + FG_DIM + "›" + RST
|
|
216
|
-
: s.slice(0, i) + RST;
|
|
501
|
+
return w > 2 ? `${s.slice(0, i)}${RST}${FG_DIM}›${RST}` : `${s.slice(0, i)}${RST}`;
|
|
217
502
|
}
|
|
218
503
|
|
|
219
504
|
/** Extract last active fg + bg ANSI codes from a string. Used for wrapping continuations. */
|
|
220
505
|
function ansiState(s: string): string {
|
|
221
|
-
let fg = "",
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
if (p === "0") {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
else if (p
|
|
506
|
+
let fg = "",
|
|
507
|
+
bg = "";
|
|
508
|
+
for (const match of s.matchAll(ANSI_CAPTURE_RE)) {
|
|
509
|
+
const p = match[1] ?? "";
|
|
510
|
+
const seq = match[0] ?? "";
|
|
511
|
+
if (p === "0") {
|
|
512
|
+
fg = "";
|
|
513
|
+
bg = "";
|
|
514
|
+
} else if (p === "39") {
|
|
515
|
+
fg = "";
|
|
516
|
+
} else if (p.startsWith("38;")) {
|
|
517
|
+
fg = seq;
|
|
518
|
+
} else if (p.startsWith("48;")) {
|
|
519
|
+
bg = seq;
|
|
520
|
+
}
|
|
230
521
|
}
|
|
231
522
|
return bg + fg;
|
|
232
523
|
}
|
|
@@ -238,14 +529,14 @@ function isLowContrastShikiFg(params: string): boolean {
|
|
|
238
529
|
const parts = params.split(";").map(Number);
|
|
239
530
|
if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
|
|
240
531
|
const [, , r, g, b] = parts;
|
|
241
|
-
const luminance =
|
|
532
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
242
533
|
return luminance < 72;
|
|
243
534
|
}
|
|
244
535
|
|
|
245
536
|
function normalizeShikiContrast(ansi: string): string {
|
|
246
|
-
return ansi.replace(
|
|
247
|
-
isLowContrastShikiFg(params) ? FG_SAFE_MUTED : seq
|
|
248
|
-
)
|
|
537
|
+
return ansi.replace(ANSI_PARAM_CAPTURE_RE, (seq, params: string) =>
|
|
538
|
+
isLowContrastShikiFg(params) ? FG_SAFE_MUTED : seq,
|
|
539
|
+
);
|
|
249
540
|
}
|
|
250
541
|
|
|
251
542
|
/** Wrap ANSI-encoded string into rows of `w` visible chars. Max `maxRows` rows; last row truncates with ›. */
|
|
@@ -258,7 +549,9 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
|
|
|
258
549
|
}
|
|
259
550
|
|
|
260
551
|
const rows: string[] = [];
|
|
261
|
-
let row = "",
|
|
552
|
+
let row = "",
|
|
553
|
+
vis = 0,
|
|
554
|
+
i = 0;
|
|
262
555
|
let onLastRow = false;
|
|
263
556
|
let effW = w;
|
|
264
557
|
|
|
@@ -272,7 +565,11 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
|
|
|
272
565
|
// Pass through ANSI escapes
|
|
273
566
|
if (s[i] === "\x1b") {
|
|
274
567
|
const end = s.indexOf("m", i);
|
|
275
|
-
if (end !== -1) {
|
|
568
|
+
if (end !== -1) {
|
|
569
|
+
row += s.slice(i, end + 1);
|
|
570
|
+
i = end + 1;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
276
573
|
}
|
|
277
574
|
|
|
278
575
|
// Row full
|
|
@@ -281,10 +578,17 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
|
|
|
281
578
|
// Check if remaining string has visible chars
|
|
282
579
|
let hasMore = false;
|
|
283
580
|
for (let j = i; j < s.length; j++) {
|
|
284
|
-
if (s[j] === "\x1b") {
|
|
285
|
-
|
|
581
|
+
if (s[j] === "\x1b") {
|
|
582
|
+
const e2 = s.indexOf("m", j);
|
|
583
|
+
if (e2 !== -1) {
|
|
584
|
+
j = e2;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
hasMore = true;
|
|
589
|
+
break;
|
|
286
590
|
}
|
|
287
|
-
if (hasMore && w > 2) row += RST
|
|
591
|
+
if (hasMore && w > 2) row += `${RST}${FG_DIM}›${RST}`;
|
|
288
592
|
else row += fillBg + " ".repeat(Math.max(0, w - vis)) + RST;
|
|
289
593
|
rows.push(row);
|
|
290
594
|
return rows;
|
|
@@ -294,10 +598,15 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
|
|
|
294
598
|
rows.push(row + RST);
|
|
295
599
|
row = state + fillBg;
|
|
296
600
|
vis = 0;
|
|
297
|
-
if (rows.length >= maxRows - 1) {
|
|
601
|
+
if (rows.length >= maxRows - 1) {
|
|
602
|
+
onLastRow = true;
|
|
603
|
+
effW = w > 2 ? w - 1 : w;
|
|
604
|
+
}
|
|
298
605
|
}
|
|
299
606
|
|
|
300
|
-
row += s[i];
|
|
607
|
+
row += s[i];
|
|
608
|
+
vis++;
|
|
609
|
+
i++;
|
|
301
610
|
}
|
|
302
611
|
|
|
303
612
|
// Final row, padded
|
|
@@ -341,7 +650,7 @@ function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINE
|
|
|
341
650
|
if (!diff.lines.length) return false;
|
|
342
651
|
if (tw < SPLIT_MIN_WIDTH) return false;
|
|
343
652
|
|
|
344
|
-
const nw = Math.max(2, String(Math.max(...diff.lines.map(l => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
653
|
+
const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
345
654
|
const half = Math.floor((tw - 1) / 2); // -1 for center divider
|
|
346
655
|
const gw = nw + 5; // border + num + sign + sp + │ + sp
|
|
347
656
|
const cw = Math.max(12, half - gw);
|
|
@@ -369,16 +678,43 @@ function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINE
|
|
|
369
678
|
// ---------------------------------------------------------------------------
|
|
370
679
|
|
|
371
680
|
const EXT_LANG: Record<string, BundledLanguage> = {
|
|
372
|
-
ts: "typescript",
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
681
|
+
ts: "typescript",
|
|
682
|
+
tsx: "tsx",
|
|
683
|
+
js: "javascript",
|
|
684
|
+
jsx: "jsx",
|
|
685
|
+
mjs: "javascript",
|
|
686
|
+
cjs: "javascript",
|
|
687
|
+
py: "python",
|
|
688
|
+
rb: "ruby",
|
|
689
|
+
rs: "rust",
|
|
690
|
+
go: "go",
|
|
691
|
+
java: "java",
|
|
692
|
+
c: "c",
|
|
693
|
+
cpp: "cpp",
|
|
694
|
+
h: "c",
|
|
695
|
+
hpp: "cpp",
|
|
696
|
+
cs: "csharp",
|
|
697
|
+
swift: "swift",
|
|
698
|
+
kt: "kotlin",
|
|
699
|
+
html: "html",
|
|
700
|
+
css: "css",
|
|
701
|
+
scss: "scss",
|
|
702
|
+
json: "json",
|
|
703
|
+
yaml: "yaml",
|
|
704
|
+
yml: "yaml",
|
|
705
|
+
toml: "toml",
|
|
706
|
+
md: "markdown",
|
|
707
|
+
sql: "sql",
|
|
708
|
+
sh: "bash",
|
|
709
|
+
bash: "bash",
|
|
710
|
+
zsh: "bash",
|
|
711
|
+
lua: "lua",
|
|
712
|
+
php: "php",
|
|
713
|
+
dart: "dart",
|
|
714
|
+
xml: "xml",
|
|
715
|
+
graphql: "graphql",
|
|
716
|
+
svelte: "svelte",
|
|
717
|
+
vue: "vue",
|
|
382
718
|
};
|
|
383
719
|
|
|
384
720
|
function lang(fp: string): BundledLanguage | undefined {
|
|
@@ -396,7 +732,8 @@ codeToANSI("", "typescript", THEME).catch(() => {});
|
|
|
396
732
|
const _cache = new Map<string, string[]>();
|
|
397
733
|
|
|
398
734
|
function _touch(k: string, v: string[]): string[] {
|
|
399
|
-
_cache.delete(k);
|
|
735
|
+
_cache.delete(k);
|
|
736
|
+
_cache.set(k, v);
|
|
400
737
|
while (_cache.size > CACHE_LIMIT) {
|
|
401
738
|
const first = _cache.keys().next().value;
|
|
402
739
|
if (first === undefined) break;
|
|
@@ -429,7 +766,8 @@ async function hlBlock(code: string, language: BundledLanguage | undefined): Pro
|
|
|
429
766
|
function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff {
|
|
430
767
|
const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: ctx });
|
|
431
768
|
const lines: DiffLine[] = [];
|
|
432
|
-
let added = 0,
|
|
769
|
+
let added = 0,
|
|
770
|
+
removed = 0;
|
|
433
771
|
|
|
434
772
|
for (let hi = 0; hi < patch.hunks.length; hi++) {
|
|
435
773
|
if (hi > 0) {
|
|
@@ -438,13 +776,21 @@ function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff
|
|
|
438
776
|
lines.push({ type: "sep", oldNum: null, newNum: gap > 0 ? gap : null, content: "" });
|
|
439
777
|
}
|
|
440
778
|
const h = patch.hunks[hi];
|
|
441
|
-
let oL = h.oldStart,
|
|
779
|
+
let oL = h.oldStart,
|
|
780
|
+
nL = h.newStart;
|
|
442
781
|
for (const raw of h.lines) {
|
|
443
782
|
if (raw === "\") continue;
|
|
444
|
-
const ch = raw[0],
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
783
|
+
const ch = raw[0],
|
|
784
|
+
text = raw.slice(1);
|
|
785
|
+
if (ch === "+") {
|
|
786
|
+
lines.push({ type: "add", oldNum: null, newNum: nL++, content: text });
|
|
787
|
+
added++;
|
|
788
|
+
} else if (ch === "-") {
|
|
789
|
+
lines.push({ type: "del", oldNum: oL++, newNum: null, content: text });
|
|
790
|
+
removed++;
|
|
791
|
+
} else {
|
|
792
|
+
lines.push({ type: "ctx", oldNum: oL++, newNum: nL++, content: text });
|
|
793
|
+
}
|
|
448
794
|
}
|
|
449
795
|
}
|
|
450
796
|
return { lines, added, removed, chars: oldContent.length + newContent.length };
|
|
@@ -464,7 +810,10 @@ function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff
|
|
|
464
810
|
* similarity score and character ranges for emphasis highlighting.
|
|
465
811
|
* Replaces separate wordDiffRanges + wordDiffSimilarity (which called diffWords twice).
|
|
466
812
|
*/
|
|
467
|
-
function wordDiffAnalysis(
|
|
813
|
+
function wordDiffAnalysis(
|
|
814
|
+
a: string,
|
|
815
|
+
b: string,
|
|
816
|
+
): {
|
|
468
817
|
similarity: number;
|
|
469
818
|
oldRanges: Array<[number, number]>;
|
|
470
819
|
newRanges: Array<[number, number]>;
|
|
@@ -473,11 +822,22 @@ function wordDiffAnalysis(a: string, b: string): {
|
|
|
473
822
|
const parts = Diff.diffWords(a, b);
|
|
474
823
|
const oldRanges: Array<[number, number]> = [];
|
|
475
824
|
const newRanges: Array<[number, number]> = [];
|
|
476
|
-
let oPos = 0,
|
|
825
|
+
let oPos = 0,
|
|
826
|
+
nPos = 0,
|
|
827
|
+
same = 0;
|
|
477
828
|
for (const p of parts) {
|
|
478
|
-
if (p.removed) {
|
|
479
|
-
|
|
480
|
-
|
|
829
|
+
if (p.removed) {
|
|
830
|
+
oldRanges.push([oPos, oPos + p.value.length]);
|
|
831
|
+
oPos += p.value.length;
|
|
832
|
+
} else if (p.added) {
|
|
833
|
+
newRanges.push([nPos, nPos + p.value.length]);
|
|
834
|
+
nPos += p.value.length;
|
|
835
|
+
} else {
|
|
836
|
+
const len = p.value.length;
|
|
837
|
+
same += len;
|
|
838
|
+
oPos += len;
|
|
839
|
+
nPos += len;
|
|
840
|
+
}
|
|
481
841
|
}
|
|
482
842
|
const maxLen = Math.max(a.length, b.length);
|
|
483
843
|
return { similarity: maxLen > 0 ? same / maxLen : 1, oldRanges, newRanges };
|
|
@@ -490,12 +850,7 @@ function wordDiffAnalysis(a: string, b: string): {
|
|
|
490
850
|
*
|
|
491
851
|
* Uses sorted-range pointer scan instead of Set (avoids O(totalChars) Set creation).
|
|
492
852
|
*/
|
|
493
|
-
function injectBg(
|
|
494
|
-
ansiLine: string,
|
|
495
|
-
ranges: Array<[number, number]>,
|
|
496
|
-
baseBg: string,
|
|
497
|
-
hlBg: string,
|
|
498
|
-
): string {
|
|
853
|
+
function injectBg(ansiLine: string, ranges: Array<[number, number]>, baseBg: string, hlBg: string): string {
|
|
499
854
|
if (!ranges.length) return baseBg + ansiLine + RST;
|
|
500
855
|
|
|
501
856
|
let out = baseBg;
|
|
@@ -519,9 +874,13 @@ function injectBg(
|
|
|
519
874
|
// Advance past exhausted ranges
|
|
520
875
|
while (ri < ranges.length && vis >= ranges[ri][1]) ri++;
|
|
521
876
|
const want = ri < ranges.length && vis >= ranges[ri][0] && vis < ranges[ri][1];
|
|
522
|
-
if (want !== inHL) {
|
|
877
|
+
if (want !== inHL) {
|
|
878
|
+
inHL = want;
|
|
879
|
+
out += inHL ? hlBg : baseBg;
|
|
880
|
+
}
|
|
523
881
|
out += ansiLine[i];
|
|
524
|
-
vis++;
|
|
882
|
+
vis++;
|
|
883
|
+
i++;
|
|
525
884
|
}
|
|
526
885
|
return out + RST;
|
|
527
886
|
}
|
|
@@ -529,11 +888,15 @@ function injectBg(
|
|
|
529
888
|
/** Simple word diff (no syntax hl) — fallback when Shiki isn't available. */
|
|
530
889
|
function plainWordDiff(oldText: string, newText: string): { old: string; new: string } {
|
|
531
890
|
const parts = Diff.diffWords(oldText, newText);
|
|
532
|
-
let o = "",
|
|
891
|
+
let o = "",
|
|
892
|
+
n = "";
|
|
533
893
|
for (const p of parts) {
|
|
534
894
|
if (p.removed) o += `${BG_DEL_W}${p.value}${RST}${BG_DEL}`;
|
|
535
895
|
else if (p.added) n += `${BG_ADD_W}${p.value}${RST}${BG_ADD}`;
|
|
536
|
-
else {
|
|
896
|
+
else {
|
|
897
|
+
o += p.value;
|
|
898
|
+
n += p.value;
|
|
899
|
+
}
|
|
537
900
|
}
|
|
538
901
|
return { old: o, new: n };
|
|
539
902
|
}
|
|
@@ -549,18 +912,24 @@ function plainWordDiff(oldText: string, newText: string): { old: string; new: st
|
|
|
549
912
|
// • Paired del/add lines adjacent with word-level emphasis
|
|
550
913
|
// ---------------------------------------------------------------------------
|
|
551
914
|
|
|
552
|
-
async function renderUnified(
|
|
915
|
+
async function renderUnified(
|
|
916
|
+
diff: ParsedDiff,
|
|
917
|
+
language: BundledLanguage | undefined,
|
|
918
|
+
max = MAX_RENDER_LINES,
|
|
919
|
+
dc: DiffColors = DEFAULT_DIFF_COLORS,
|
|
920
|
+
): Promise<string> {
|
|
553
921
|
if (!diff.lines.length) return "";
|
|
554
922
|
|
|
555
923
|
const vis = diff.lines.slice(0, max);
|
|
556
924
|
const tw = termW();
|
|
557
|
-
const nw = Math.max(2, String(Math.max(...vis.map(l => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
925
|
+
const nw = Math.max(2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
558
926
|
const gw = nw + 5; // border + num + sign + sp + │ + sp
|
|
559
927
|
const cw = Math.max(20, tw - gw);
|
|
560
928
|
const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES;
|
|
561
929
|
|
|
562
930
|
// Build separate old/new code blocks for highlighting
|
|
563
|
-
const oldSrc: string[] = [],
|
|
931
|
+
const oldSrc: string[] = [],
|
|
932
|
+
newSrc: string[] = [];
|
|
564
933
|
for (const l of vis) {
|
|
565
934
|
if (l.type === "ctx" || l.type === "del") oldSrc.push(l.content);
|
|
566
935
|
if (l.type === "ctx" || l.type === "add") newSrc.push(l.content);
|
|
@@ -569,12 +938,21 @@ async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undef
|
|
|
569
938
|
? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
|
|
570
939
|
: [oldSrc, newSrc];
|
|
571
940
|
|
|
572
|
-
let oI = 0,
|
|
941
|
+
let oI = 0,
|
|
942
|
+
nI = 0,
|
|
943
|
+
idx = 0;
|
|
573
944
|
const out: string[] = [];
|
|
574
945
|
out.push(rule(tw));
|
|
575
946
|
|
|
576
947
|
/** Emit a single stacked row with compact gutter + left border bar. */
|
|
577
|
-
function emitRow(
|
|
948
|
+
function emitRow(
|
|
949
|
+
num: number | null,
|
|
950
|
+
sign: string,
|
|
951
|
+
gutterBg: string,
|
|
952
|
+
signFg: string,
|
|
953
|
+
body: string,
|
|
954
|
+
bodyBg = "",
|
|
955
|
+
): void {
|
|
578
956
|
const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : "";
|
|
579
957
|
const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : `${BG_DEFAULT} `;
|
|
580
958
|
const numFg = borderFg || FG_LNUM;
|
|
@@ -594,28 +972,35 @@ async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undef
|
|
|
594
972
|
const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
|
|
595
973
|
const totalW = Math.min(tw, 72);
|
|
596
974
|
const pad = Math.max(0, totalW - label.length - 2);
|
|
597
|
-
const half1 = Math.floor(pad / 2),
|
|
975
|
+
const half1 = Math.floor(pad / 2),
|
|
976
|
+
half2 = pad - half1;
|
|
598
977
|
out.push(`${FG_DIM}${"─".repeat(half1)}${label}${"─".repeat(half2)}${RST}`);
|
|
599
|
-
idx++;
|
|
978
|
+
idx++;
|
|
979
|
+
continue;
|
|
600
980
|
}
|
|
601
981
|
|
|
602
982
|
// Context line — dimmed, single line number
|
|
603
983
|
if (l.type === "ctx") {
|
|
604
984
|
const hl = oldHL[oI] ?? l.content;
|
|
605
985
|
emitRow(l.newNum, " ", BG_DEFAULT, dc.fgCtx, `${BG_DEFAULT}${DIM}${hl}`, BG_DEFAULT);
|
|
606
|
-
oI++;
|
|
986
|
+
oI++;
|
|
987
|
+
nI++;
|
|
988
|
+
idx++;
|
|
989
|
+
continue;
|
|
607
990
|
}
|
|
608
991
|
|
|
609
992
|
// Collect del/add blocks
|
|
610
993
|
const dels: Array<{ l: DiffLine; hl: string }> = [];
|
|
611
994
|
while (idx < vis.length && vis[idx].type === "del") {
|
|
612
995
|
dels.push({ l: vis[idx], hl: oldHL[oI] ?? vis[idx].content });
|
|
613
|
-
oI++;
|
|
996
|
+
oI++;
|
|
997
|
+
idx++;
|
|
614
998
|
}
|
|
615
999
|
const adds: Array<{ l: DiffLine; hl: string }> = [];
|
|
616
1000
|
while (idx < vis.length && vis[idx].type === "add") {
|
|
617
1001
|
adds.push({ l: vis[idx], hl: newHL[nI] ?? vis[idx].content });
|
|
618
|
-
nI++;
|
|
1002
|
+
nI++;
|
|
1003
|
+
idx++;
|
|
619
1004
|
}
|
|
620
1005
|
|
|
621
1006
|
// 1:1 paired → word diff emphasis
|
|
@@ -658,7 +1043,12 @@ async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undef
|
|
|
658
1043
|
// Split view (auto-fallback to unified when narrow)
|
|
659
1044
|
// ---------------------------------------------------------------------------
|
|
660
1045
|
|
|
661
|
-
async function renderSplit(
|
|
1046
|
+
async function renderSplit(
|
|
1047
|
+
diff: ParsedDiff,
|
|
1048
|
+
language: BundledLanguage | undefined,
|
|
1049
|
+
max = MAX_PREVIEW_LINES,
|
|
1050
|
+
dc: DiffColors = DEFAULT_DIFF_COLORS,
|
|
1051
|
+
): Promise<string> {
|
|
662
1052
|
const tw = termW();
|
|
663
1053
|
if (!shouldUseSplit(diff, tw, max)) return renderUnified(diff, language, max, dc);
|
|
664
1054
|
if (!diff.lines.length) return "";
|
|
@@ -669,23 +1059,35 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
|
|
|
669
1059
|
let i = 0;
|
|
670
1060
|
while (i < diff.lines.length) {
|
|
671
1061
|
const l = diff.lines[i];
|
|
672
|
-
if (l.type === "sep" || l.type === "ctx") {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1062
|
+
if (l.type === "sep" || l.type === "ctx") {
|
|
1063
|
+
rows.push({ left: l, right: l });
|
|
1064
|
+
i++;
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
const dels: DiffLine[] = [],
|
|
1068
|
+
adds: DiffLine[] = [];
|
|
1069
|
+
while (i < diff.lines.length && diff.lines[i].type === "del") {
|
|
1070
|
+
dels.push(diff.lines[i]);
|
|
1071
|
+
i++;
|
|
1072
|
+
}
|
|
1073
|
+
while (i < diff.lines.length && diff.lines[i].type === "add") {
|
|
1074
|
+
adds.push(diff.lines[i]);
|
|
1075
|
+
i++;
|
|
1076
|
+
}
|
|
676
1077
|
const n = Math.max(dels.length, adds.length);
|
|
677
1078
|
for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
|
|
678
1079
|
}
|
|
679
1080
|
|
|
680
1081
|
const vis = rows.slice(0, max);
|
|
681
1082
|
const half = Math.floor((tw - 1) / 2); // -1 for center divider
|
|
682
|
-
const nw = Math.max(2, String(Math.max(...diff.lines.map(l => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
1083
|
+
const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
683
1084
|
const gw = nw + 5; // border + num + sign + sp + │ + sp
|
|
684
1085
|
const cw = Math.max(12, half - gw);
|
|
685
1086
|
const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
|
|
686
1087
|
|
|
687
1088
|
// Build separate code blocks per side
|
|
688
|
-
const leftSrc: string[] = [],
|
|
1089
|
+
const leftSrc: string[] = [],
|
|
1090
|
+
rightSrc: string[] = [];
|
|
689
1091
|
for (const r of vis) {
|
|
690
1092
|
if (r.left && r.left.type !== "sep") leftSrc.push(r.left.content);
|
|
691
1093
|
if (r.right && r.right.type !== "sep") rightSrc.push(r.right.content);
|
|
@@ -694,13 +1096,19 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
|
|
|
694
1096
|
? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
|
|
695
1097
|
: [leftSrc, rightSrc];
|
|
696
1098
|
|
|
697
|
-
let lI = 0,
|
|
1099
|
+
let lI = 0,
|
|
1100
|
+
rI = 0;
|
|
698
1101
|
let stripeRow = 0; // tracks row index for diagonal stripe offset
|
|
699
1102
|
|
|
700
1103
|
// Returns { gutter, contGutter, body } for wrapping composition
|
|
701
1104
|
type HalfResult = { gutter: string; contGutter: string; bodyRows: string[] };
|
|
702
1105
|
|
|
703
|
-
function half_build(
|
|
1106
|
+
function half_build(
|
|
1107
|
+
line: DiffLine | null,
|
|
1108
|
+
hl: string,
|
|
1109
|
+
ranges: Array<[number, number]> | null,
|
|
1110
|
+
side: "left" | "right",
|
|
1111
|
+
): HalfResult {
|
|
704
1112
|
// Empty filler — diagonal stripes
|
|
705
1113
|
if (!line) {
|
|
706
1114
|
const gw2 = nw + 2; // number + sign + space before │
|
|
@@ -716,12 +1124,13 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
|
|
|
716
1124
|
return { gutter: g, contGutter: g, bodyRows: [`${FG_DIM}${fit(label, cw)}${RST}`] };
|
|
717
1125
|
}
|
|
718
1126
|
|
|
719
|
-
const isDel = line.type === "del",
|
|
1127
|
+
const isDel = line.type === "del",
|
|
1128
|
+
isAdd = line.type === "add";
|
|
720
1129
|
const gBg = isDel ? BG_GUTTER_DEL : isAdd ? BG_GUTTER_ADD : BG_DEFAULT;
|
|
721
1130
|
const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_DEFAULT;
|
|
722
1131
|
const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
|
|
723
1132
|
const sign = isDel ? "-" : isAdd ? "+" : " ";
|
|
724
|
-
const num = isDel ? line.oldNum : isAdd ? line.newNum :
|
|
1133
|
+
const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum;
|
|
725
1134
|
|
|
726
1135
|
// Border bar + colored line numbers for changed lines
|
|
727
1136
|
const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
|
|
@@ -751,7 +1160,8 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
|
|
|
751
1160
|
out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
|
|
752
1161
|
|
|
753
1162
|
for (const r of vis) {
|
|
754
|
-
const leftLine = r.left,
|
|
1163
|
+
const leftLine = r.left,
|
|
1164
|
+
rightLine = r.right;
|
|
755
1165
|
const paired = leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add";
|
|
756
1166
|
const wd = paired ? wordDiffAnalysis(leftLine.content, rightLine.content) : null;
|
|
757
1167
|
|
|
@@ -764,12 +1174,13 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
|
|
|
764
1174
|
rResult = half_build(rightLine, rhl, wd.newRanges, "right");
|
|
765
1175
|
} else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
|
|
766
1176
|
const pwd = plainWordDiff(leftLine.content, rightLine.content);
|
|
767
|
-
lI++;
|
|
1177
|
+
lI++;
|
|
1178
|
+
rI++;
|
|
768
1179
|
lResult = half_build(leftLine, pwd.old, null, "left");
|
|
769
1180
|
rResult = half_build(rightLine, pwd.new, null, "right");
|
|
770
1181
|
} else {
|
|
771
|
-
const lhl =
|
|
772
|
-
const rhl =
|
|
1182
|
+
const lhl = leftLine && leftLine.type !== "sep" ? (leftHL[lI++] ?? leftLine?.content ?? "") : "";
|
|
1183
|
+
const rhl = rightLine && rightLine.type !== "sep" ? (rightHL[rI++] ?? rightLine?.content ?? "") : "";
|
|
773
1184
|
lResult = half_build(leftLine, lhl, null, "left");
|
|
774
1185
|
rResult = half_build(rightLine, rhl, null, "right");
|
|
775
1186
|
}
|
|
@@ -782,7 +1193,8 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
|
|
|
782
1193
|
const lg = row === 0 ? lResult.gutter : lResult.contGutter;
|
|
783
1194
|
const rg = row === 0 ? rResult.gutter : rResult.contGutter;
|
|
784
1195
|
const lb = lResult.bodyRows[row] ?? (leftIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
|
|
785
|
-
const rb =
|
|
1196
|
+
const rb =
|
|
1197
|
+
rResult.bodyRows[row] ?? (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
|
|
786
1198
|
out.push(`${lg}${lb}${DIVIDER}${rg}${rb}`);
|
|
787
1199
|
stripeRow++;
|
|
788
1200
|
}
|
|
@@ -807,13 +1219,18 @@ export const __testing = {
|
|
|
807
1219
|
};
|
|
808
1220
|
|
|
809
1221
|
export default function diffRendererExtension(pi: any): void {
|
|
1222
|
+
// Apply diff theme palette from settings/presets before rendering
|
|
1223
|
+
applyDiffPalette();
|
|
1224
|
+
|
|
810
1225
|
let createWriteTool: any, createEditTool: any, TextComponent: any;
|
|
811
1226
|
try {
|
|
812
1227
|
const sdk = require("@mariozechner/pi-coding-agent");
|
|
813
1228
|
createWriteTool = sdk.createWriteTool;
|
|
814
1229
|
createEditTool = sdk.createEditTool;
|
|
815
1230
|
TextComponent = require("@mariozechner/pi-tui").Text;
|
|
816
|
-
} catch {
|
|
1231
|
+
} catch {
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
817
1234
|
if (!createWriteTool || !createEditTool || !TextComponent) return;
|
|
818
1235
|
|
|
819
1236
|
const cwd = process.cwd();
|
|
@@ -833,7 +1250,11 @@ export default function diffRendererExtension(pi: any): void {
|
|
|
833
1250
|
async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
|
|
834
1251
|
const fp = params.path ?? params.file_path ?? "";
|
|
835
1252
|
let old: string | null = null;
|
|
836
|
-
try {
|
|
1253
|
+
try {
|
|
1254
|
+
if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
|
|
1255
|
+
} catch {
|
|
1256
|
+
old = null;
|
|
1257
|
+
}
|
|
837
1258
|
|
|
838
1259
|
const result = await origWrite.execute(tid, params, sig, upd, ctx);
|
|
839
1260
|
const content = params.content ?? "";
|
|
@@ -873,16 +1294,18 @@ export default function diffRendererExtension(pi: any): void {
|
|
|
873
1294
|
ctx.state._previewKey = previewKey;
|
|
874
1295
|
ctx.state._previewText = hdr;
|
|
875
1296
|
const lg = lang(fp);
|
|
876
|
-
hlBlock(args.content, lg)
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1297
|
+
hlBlock(args.content, lg)
|
|
1298
|
+
.then((lines: string[]) => {
|
|
1299
|
+
if (ctx.state._previewKey !== previewKey) return;
|
|
1300
|
+
const maxShow = ctx.expanded ? lines.length : 16;
|
|
1301
|
+
const preview = lines.slice(0, maxShow).join("\n");
|
|
1302
|
+
const rem = lines.length - maxShow;
|
|
1303
|
+
let out = `${hdr}\n\n${preview}`;
|
|
1304
|
+
if (rem > 0) out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
|
|
1305
|
+
ctx.state._previewText = out;
|
|
1306
|
+
ctx.invalidate();
|
|
1307
|
+
})
|
|
1308
|
+
.catch(() => {});
|
|
886
1309
|
}
|
|
887
1310
|
text.setText(ctx.state._previewText ?? hdr);
|
|
888
1311
|
return text;
|
|
@@ -895,7 +1318,11 @@ export default function diffRendererExtension(pi: any): void {
|
|
|
895
1318
|
renderResult(result: any, _opt: any, theme: any, ctx: any) {
|
|
896
1319
|
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
897
1320
|
if (ctx.isError) {
|
|
898
|
-
const e =
|
|
1321
|
+
const e =
|
|
1322
|
+
result.content
|
|
1323
|
+
?.filter((c: any) => c.type === "text")
|
|
1324
|
+
.map((c: any) => c.text || "")
|
|
1325
|
+
.join("\n") ?? "Error";
|
|
899
1326
|
text.setText(`\n${theme.fg("error", e)}`);
|
|
900
1327
|
return text;
|
|
901
1328
|
}
|
|
@@ -907,15 +1334,17 @@ export default function diffRendererExtension(pi: any): void {
|
|
|
907
1334
|
ctx.state._wdk = key;
|
|
908
1335
|
ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
|
|
909
1336
|
const dc = resolveDiffColors(theme);
|
|
910
|
-
renderSplit(d.diff, d.language, MAX_RENDER_LINES, dc)
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1337
|
+
renderSplit(d.diff, d.language, MAX_RENDER_LINES, dc)
|
|
1338
|
+
.then((rendered: string) => {
|
|
1339
|
+
if (ctx.state._wdk !== key) return;
|
|
1340
|
+
ctx.state._wdt = ` ${d.summary}\n${rendered}`;
|
|
1341
|
+
ctx.invalidate();
|
|
1342
|
+
})
|
|
1343
|
+
.catch(() => {
|
|
1344
|
+
if (ctx.state._wdk !== key) return;
|
|
1345
|
+
ctx.state._wdt = ` ${d.summary}`;
|
|
1346
|
+
ctx.invalidate();
|
|
1347
|
+
});
|
|
919
1348
|
}
|
|
920
1349
|
text.setText(ctx.state._wdt ?? ` ${d.summary}`);
|
|
921
1350
|
return text;
|
|
@@ -932,16 +1361,18 @@ export default function diffRendererExtension(pi: any): void {
|
|
|
932
1361
|
ctx.state._nft = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
|
|
933
1362
|
const lg = lang(fp);
|
|
934
1363
|
if (rawContent) {
|
|
935
|
-
hlBlock(rawContent, lg)
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1364
|
+
hlBlock(rawContent, lg)
|
|
1365
|
+
.then((hlLines: string[]) => {
|
|
1366
|
+
if (ctx.state._nfk !== pk) return;
|
|
1367
|
+
const maxShow = ctx.expanded ? hlLines.length : 12;
|
|
1368
|
+
const preview = hlLines.slice(0, maxShow).join("\n");
|
|
1369
|
+
const rem = hlLines.length - maxShow;
|
|
1370
|
+
let out = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}\n${preview}`;
|
|
1371
|
+
if (rem > 0) out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
|
|
1372
|
+
ctx.state._nft = out;
|
|
1373
|
+
ctx.invalidate();
|
|
1374
|
+
})
|
|
1375
|
+
.catch(() => {});
|
|
945
1376
|
}
|
|
946
1377
|
}
|
|
947
1378
|
text.setText(ctx.state._nft ?? ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`);
|
|
@@ -977,7 +1408,9 @@ export default function diffRendererExtension(pi: any): void {
|
|
|
977
1408
|
const idx = f.indexOf(newText);
|
|
978
1409
|
if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
|
|
979
1410
|
}
|
|
980
|
-
} catch {
|
|
1411
|
+
} catch {
|
|
1412
|
+
editLine = 0;
|
|
1413
|
+
}
|
|
981
1414
|
const diff = parseDiff(oldText, newText);
|
|
982
1415
|
(result as any).details = { _type: "editInfo", summary: summarize(diff.added, diff.removed), editLine };
|
|
983
1416
|
}
|
|
@@ -1003,17 +1436,19 @@ export default function diffRendererExtension(pi: any): void {
|
|
|
1003
1436
|
const lg = lang(fp);
|
|
1004
1437
|
const diff = parseDiff(oldText, newText);
|
|
1005
1438
|
const dc = resolveDiffColors(theme);
|
|
1006
|
-
renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1439
|
+
renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
|
|
1440
|
+
.then((rendered) => {
|
|
1441
|
+
if (ctx.state._pk !== pk) return;
|
|
1442
|
+
ctx.state._pt = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`;
|
|
1443
|
+
ctx.invalidate();
|
|
1444
|
+
})
|
|
1445
|
+
.catch(() => {
|
|
1446
|
+
if (ctx.state._pk !== pk) return;
|
|
1447
|
+
// Fallback: plain word diff
|
|
1448
|
+
const diff2 = parseDiff(oldText, newText);
|
|
1449
|
+
ctx.state._pt = `${hdr} ${summarize(diff2.added, diff2.removed)}`;
|
|
1450
|
+
ctx.invalidate();
|
|
1451
|
+
});
|
|
1017
1452
|
}
|
|
1018
1453
|
|
|
1019
1454
|
text.setText(ctx.state._pt ?? hdr);
|
|
@@ -1023,7 +1458,11 @@ export default function diffRendererExtension(pi: any): void {
|
|
|
1023
1458
|
renderResult(result: any, _opt: any, theme: any, ctx: any) {
|
|
1024
1459
|
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1025
1460
|
if (ctx.isError) {
|
|
1026
|
-
const e =
|
|
1461
|
+
const e =
|
|
1462
|
+
result.content
|
|
1463
|
+
?.filter((c: any) => c.type === "text")
|
|
1464
|
+
.map((c: any) => c.text || "")
|
|
1465
|
+
.join("\n") ?? "Error";
|
|
1027
1466
|
text.setText(`\n${theme.fg("error", e)}`);
|
|
1028
1467
|
return text;
|
|
1029
1468
|
}
|