@quandev104/pi-style 0.1.1
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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/dist/extensions/pi-style.js +7897 -0
- package/dist/extensions/pi-style.js.map +1 -0
- package/extension-src/pi-style/app/command-service.ts +244 -0
- package/extension-src/pi-style/app/commands.ts +12 -0
- package/extension-src/pi-style/app/config-storage.ts +98 -0
- package/extension-src/pi-style/app/doctor.ts +53 -0
- package/extension-src/pi-style/app/index.ts +322 -0
- package/extension-src/pi-style/app/providers.ts +268 -0
- package/extension-src/pi-style/app/render-scheduler.ts +48 -0
- package/extension-src/pi-style/app/runtime.ts +404 -0
- package/extension-src/pi-style/app/snapshot.ts +15 -0
- package/extension-src/pi-style/domain/capabilities.ts +34 -0
- package/extension-src/pi-style/domain/config-authorization.ts +20 -0
- package/extension-src/pi-style/domain/config-diagnostics.ts +20 -0
- package/extension-src/pi-style/domain/config-diff.ts +30 -0
- package/extension-src/pi-style/domain/config-migrations.ts +52 -0
- package/extension-src/pi-style/domain/config-normalization.ts +567 -0
- package/extension-src/pi-style/domain/config-presets.ts +51 -0
- package/extension-src/pi-style/domain/config-types.ts +115 -0
- package/extension-src/pi-style/domain/providers.ts +37 -0
- package/extension-src/pi-style/domain/status-presets.ts +60 -0
- package/extension-src/pi-style/domain/status-renderer.ts +142 -0
- package/extension-src/pi-style/domain/status.ts +430 -0
- package/extension-src/pi-style/domain/theme.ts +232 -0
- package/extension-src/pi-style/features/.gitkeep +0 -0
- package/extension-src/pi-style/features/editor/index.ts +304 -0
- package/extension-src/pi-style/features/messages/boxed-block.ts +74 -0
- package/extension-src/pi-style/features/messages/index.ts +145 -0
- package/extension-src/pi-style/features/messages/special-blocks.ts +281 -0
- package/extension-src/pi-style/features/startup/index.ts +564 -0
- package/extension-src/pi-style/features/startup/logo.ts +151 -0
- package/extension-src/pi-style/features/status-line/index.ts +319 -0
- package/extension-src/pi-style/features/tools/boxed/bash.ts +347 -0
- package/extension-src/pi-style/features/tools/boxed/edit.ts +113 -0
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +67 -0
- package/extension-src/pi-style/features/tools/boxed/find.ts +65 -0
- package/extension-src/pi-style/features/tools/boxed/grep.ts +108 -0
- package/extension-src/pi-style/features/tools/boxed/index.ts +64 -0
- package/extension-src/pi-style/features/tools/boxed/ls.ts +65 -0
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +190 -0
- package/extension-src/pi-style/features/tools/boxed/read.ts +204 -0
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -0
- package/extension-src/pi-style/features/tools/boxed/shared.ts +157 -0
- package/extension-src/pi-style/features/tools/boxed/write.ts +89 -0
- package/extension-src/pi-style/features/tools/index.ts +480 -0
- package/extension-src/pi-style/pi/commands.ts +17 -0
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +195 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +645 -0
- package/extension-src/pi-style/pi/compatibility-registry.ts +329 -0
- package/extension-src/pi-style/pi/config-host.ts +52 -0
- package/extension-src/pi-style/pi/config-session.ts +105 -0
- package/extension-src/pi-style/pi/index.ts +77 -0
- package/extension-src/pi-style/pi/operational-state.ts +29 -0
- package/extension-src/pi-style/pi/session-coordinator.ts +187 -0
- package/extension-src/pi-style/pi/session-usage.ts +62 -0
- package/extension-src/pi-style/pi/startup-resources.ts +70 -0
- package/extension-src/pi-style/shared/.gitkeep +0 -0
- package/extension-src/pi-style/shared/ansi.ts +386 -0
- package/extension-src/pi-style/shared/box.ts +805 -0
- package/extension-src/pi-style/shared/disposable-store.ts +28 -0
- package/extension-src/pi-style/shared/elapsed.ts +88 -0
- package/extension-src/pi-style/shared/render-budget.ts +367 -0
- package/extension-src/pi-style/shared/split-diff.ts +692 -0
- package/extension-src/pi-style/shared/theme-extras.ts +292 -0
- package/package.json +68 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
// Split-diff renderer — side-by-side diff view with syntax highlighting and
|
|
2
|
+
// inline emphasis.
|
|
3
|
+
|
|
4
|
+
import { highlightCode } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
import { stripAnsi } from "./ansi.js";
|
|
8
|
+
import { safeTruncateToWidth, safeVisibleWidth } from "./render-budget.js";
|
|
9
|
+
|
|
10
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
type DiffLine = {
|
|
13
|
+
prefix: "+" | "-" | " ";
|
|
14
|
+
line: string;
|
|
15
|
+
lineNumber: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type SplitDiffRow = {
|
|
19
|
+
kind: "context" | "changed" | "added" | "removed";
|
|
20
|
+
left?: DiffLine;
|
|
21
|
+
right?: DiffLine;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type CellLineKind = "add" | "remove" | "context";
|
|
25
|
+
|
|
26
|
+
type DiffSpan = { start: number; end: number };
|
|
27
|
+
|
|
28
|
+
type RgbColor = { r: number; g: number; b: number };
|
|
29
|
+
|
|
30
|
+
/** Structural view of the theme as used by the split-diff renderer. */
|
|
31
|
+
export interface SplitDiffTheme {
|
|
32
|
+
fg(color: string, text: string): string;
|
|
33
|
+
getBgAnsi?(color: string): string;
|
|
34
|
+
getFgAnsi?(color: string): string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type DiffPalette = {
|
|
38
|
+
addRowBgAnsi: string;
|
|
39
|
+
removeRowBgAnsi: string;
|
|
40
|
+
addEmphasisBgAnsi: string;
|
|
41
|
+
removeEmphasisBgAnsi: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ── Constants ──────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const ESC = "\x1b";
|
|
47
|
+
const BG_ANSI_PATTERN = new RegExp(`${ESC}\\[(?:4\\d|10\\d|48;5;\\d{1,3}|48;2;\\d{1,3};\\d{1,3};\\d{1,3}|49)m`, "g");
|
|
48
|
+
const CONTROL_CHARS = "\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F";
|
|
49
|
+
|
|
50
|
+
const ADD_ROW_BACKGROUND_MIX_RATIO = 0.24;
|
|
51
|
+
const REMOVE_ROW_BACKGROUND_MIX_RATIO = 0.12;
|
|
52
|
+
const ADD_INLINE_EMPHASIS_MIX_RATIO = 0.44;
|
|
53
|
+
const REMOVE_INLINE_EMPHASIS_MIX_RATIO = 0.26;
|
|
54
|
+
|
|
55
|
+
// ── ANSI color utilities (diff-specific) ───────────────────────────
|
|
56
|
+
|
|
57
|
+
function ansi256ToRgb(code: number): RgbColor {
|
|
58
|
+
if (code <= 15) {
|
|
59
|
+
const base16: RgbColor[] = [
|
|
60
|
+
{ r: 0, g: 0, b: 0 },
|
|
61
|
+
{ r: 128, g: 0, b: 0 },
|
|
62
|
+
{ r: 0, g: 128, b: 0 },
|
|
63
|
+
{ r: 128, g: 128, b: 0 },
|
|
64
|
+
{ r: 0, g: 0, b: 128 },
|
|
65
|
+
{ r: 128, g: 0, b: 128 },
|
|
66
|
+
{ r: 0, g: 128, b: 128 },
|
|
67
|
+
{ r: 192, g: 192, b: 192 },
|
|
68
|
+
{ r: 128, g: 128, b: 128 },
|
|
69
|
+
{ r: 255, g: 0, b: 0 },
|
|
70
|
+
{ r: 0, g: 255, b: 0 },
|
|
71
|
+
{ r: 255, g: 255, b: 0 },
|
|
72
|
+
{ r: 0, g: 0, b: 255 },
|
|
73
|
+
{ r: 255, g: 0, b: 255 },
|
|
74
|
+
{ r: 0, g: 255, b: 255 },
|
|
75
|
+
{ r: 255, g: 255, b: 255 },
|
|
76
|
+
];
|
|
77
|
+
return base16[code] ?? { r: 255, g: 255, b: 255 };
|
|
78
|
+
}
|
|
79
|
+
if (code >= 232) {
|
|
80
|
+
const value = Math.max(0, Math.min(255, 8 + (code - 232) * 10));
|
|
81
|
+
return { r: value, g: value, b: value };
|
|
82
|
+
}
|
|
83
|
+
const cube = code - 16;
|
|
84
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
85
|
+
const blue = cube % 6;
|
|
86
|
+
const green = Math.floor(cube / 6) % 6;
|
|
87
|
+
const red = Math.floor(cube / 36) % 6;
|
|
88
|
+
return {
|
|
89
|
+
r: levels[red] ?? 0,
|
|
90
|
+
g: levels[green] ?? 0,
|
|
91
|
+
b: levels[blue] ?? 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseAnsiColorCode(ansi: string | undefined): RgbColor | null {
|
|
96
|
+
if (!ansi) return null;
|
|
97
|
+
const rgbMatch = new RegExp(`${ESC}\\[(?:3|4)8;2;(\\d{1,3});(\\d{1,3});(\\d{1,3})m`).exec(ansi);
|
|
98
|
+
if (rgbMatch) {
|
|
99
|
+
const r = Number.parseInt(rgbMatch[1] ?? "0", 10);
|
|
100
|
+
const g = Number.parseInt(rgbMatch[2] ?? "0", 10);
|
|
101
|
+
const b = Number.parseInt(rgbMatch[3] ?? "0", 10);
|
|
102
|
+
return { r, g, b };
|
|
103
|
+
}
|
|
104
|
+
const bitMatch = new RegExp(`${ESC}\\[(?:3|4)8;5;(\\d{1,3})m`).exec(ansi);
|
|
105
|
+
if (bitMatch) {
|
|
106
|
+
const code = Number.parseInt(bitMatch[1] ?? "0", 10);
|
|
107
|
+
return ansi256ToRgb(code);
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rgbToBgAnsi(color: RgbColor): string {
|
|
113
|
+
const r = Math.max(0, Math.min(255, Math.round(color.r)));
|
|
114
|
+
const g = Math.max(0, Math.min(255, Math.round(color.g)));
|
|
115
|
+
const b = Math.max(0, Math.min(255, Math.round(color.b)));
|
|
116
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function mixRgb(base: RgbColor, tint: RgbColor, ratio: number): RgbColor {
|
|
120
|
+
const clamped = Math.max(0, Math.min(1, ratio));
|
|
121
|
+
return {
|
|
122
|
+
r: base.r * (1 - clamped) + tint.r * clamped,
|
|
123
|
+
g: base.g * (1 - clamped) + tint.g * clamped,
|
|
124
|
+
b: base.b * (1 - clamped) + tint.b * clamped,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveDiffPalette(theme: SplitDiffTheme): DiffPalette {
|
|
129
|
+
const baseBg = parseAnsiColorCode(theme.getBgAnsi?.("toolSuccessBg")) ??
|
|
130
|
+
parseAnsiColorCode(theme.getBgAnsi?.("toolPendingBg")) ?? { r: 32, g: 35, b: 42 };
|
|
131
|
+
const addFg = parseAnsiColorCode(theme.getFgAnsi?.("toolDiffAdded")) ?? { r: 88, g: 173, b: 88 };
|
|
132
|
+
const removeFg = parseAnsiColorCode(theme.getFgAnsi?.("toolDiffRemoved")) ?? { r: 196, g: 98, b: 98 };
|
|
133
|
+
|
|
134
|
+
const addRowBg = mixRgb(baseBg, addFg, ADD_ROW_BACKGROUND_MIX_RATIO);
|
|
135
|
+
const removeRowBg = mixRgb(baseBg, removeFg, REMOVE_ROW_BACKGROUND_MIX_RATIO);
|
|
136
|
+
const addEmphasisBg = mixRgb(baseBg, addFg, ADD_INLINE_EMPHASIS_MIX_RATIO);
|
|
137
|
+
const removeEmphasisBg = mixRgb(baseBg, removeFg, REMOVE_INLINE_EMPHASIS_MIX_RATIO);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
addRowBgAnsi: rgbToBgAnsi(addRowBg),
|
|
141
|
+
removeRowBgAnsi: rgbToBgAnsi(removeRowBg),
|
|
142
|
+
addEmphasisBgAnsi: rgbToBgAnsi(addEmphasisBg),
|
|
143
|
+
removeEmphasisBgAnsi: rgbToBgAnsi(removeEmphasisBg),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── ANSI background helpers ────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function keepBackgroundAcrossResets(text: string, rowBgAnsi: string): string {
|
|
150
|
+
if (!text) return text;
|
|
151
|
+
|
|
152
|
+
return text.replace(new RegExp(`${ESC}\\[([0-9;]*)m`, "g"), (sequence, rawCodes) => {
|
|
153
|
+
const split = String(rawCodes ?? "")
|
|
154
|
+
.split(";")
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
const codes = split.length > 0 ? split : ["0"]; // ESC[m == reset
|
|
157
|
+
const hasGlobalReset = codes.includes("0");
|
|
158
|
+
const hasBgReset = codes.includes("49");
|
|
159
|
+
if (!hasGlobalReset && !hasBgReset) return sequence;
|
|
160
|
+
|
|
161
|
+
const rebuiltCodes = codes.filter((code: string) => code !== "49");
|
|
162
|
+
const rebuilt = rebuiltCodes.length > 0 ? `\x1b[${rebuiltCodes.join(";")}m` : "";
|
|
163
|
+
return `${rebuilt}${rowBgAnsi}`;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function applyBackgroundToVisibleRange(
|
|
168
|
+
ansiText: string,
|
|
169
|
+
start: number,
|
|
170
|
+
end: number,
|
|
171
|
+
backgroundAnsi: string,
|
|
172
|
+
restoreBackgroundAnsi: string,
|
|
173
|
+
): string {
|
|
174
|
+
if (!ansiText || start >= end || end <= 0) return ansiText;
|
|
175
|
+
|
|
176
|
+
let output = "";
|
|
177
|
+
let visibleIndex = 0;
|
|
178
|
+
let index = 0;
|
|
179
|
+
let inRange = false;
|
|
180
|
+
|
|
181
|
+
while (index < ansiText.length) {
|
|
182
|
+
if (ansiText[index] === "\x1b") {
|
|
183
|
+
const sequenceEnd = ansiText.indexOf("m", index);
|
|
184
|
+
if (sequenceEnd !== -1) {
|
|
185
|
+
output += ansiText.slice(index, sequenceEnd + 1);
|
|
186
|
+
index = sequenceEnd + 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (visibleIndex === start && !inRange) {
|
|
192
|
+
output += backgroundAnsi;
|
|
193
|
+
inRange = true;
|
|
194
|
+
}
|
|
195
|
+
if (visibleIndex === end && inRange) {
|
|
196
|
+
output += restoreBackgroundAnsi;
|
|
197
|
+
inRange = false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
output += ansiText[index] ?? "";
|
|
201
|
+
visibleIndex++;
|
|
202
|
+
index++;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (inRange) output += restoreBackgroundAnsi;
|
|
206
|
+
return output;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Text utilities ─────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
function sanitizeSingleLineText(value: string): string {
|
|
212
|
+
return value
|
|
213
|
+
.replace(/\r/g, "")
|
|
214
|
+
.replace(/\n/g, "")
|
|
215
|
+
.replace(new RegExp(`[${CONTROL_CHARS}]`, "g"), "");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function stripInlineBreaksPreserveAnsi(value: string): string {
|
|
219
|
+
return value.replace(/\r/g, "").replace(/\n/g, "");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function padRight(value: string, width: number): string {
|
|
223
|
+
const visual = safeVisibleWidth(stripAnsi(value));
|
|
224
|
+
if (visual >= width) return value;
|
|
225
|
+
return value + " ".repeat(width - visual);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function fitToWidth(value: string, width: number): string {
|
|
229
|
+
return padRight(safeTruncateToWidth(value, width), width);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function padRenderedLineWidth(line: string, width: number): string {
|
|
233
|
+
const safeWidth = Math.max(1, width);
|
|
234
|
+
const current = safeVisibleWidth(stripAnsi(line));
|
|
235
|
+
if (current >= safeWidth) return line;
|
|
236
|
+
return line + " ".repeat(safeWidth - current);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function wrapPlainText(text: string, width: number): string[] {
|
|
240
|
+
const safeWidth = Math.max(1, width);
|
|
241
|
+
const safeText = sanitizeSingleLineText(text);
|
|
242
|
+
if (!safeText) return [""];
|
|
243
|
+
|
|
244
|
+
const lines: string[] = [];
|
|
245
|
+
let cursor = 0;
|
|
246
|
+
|
|
247
|
+
while (cursor < safeText.length) {
|
|
248
|
+
const remaining = safeText.length - cursor;
|
|
249
|
+
if (remaining <= safeWidth) {
|
|
250
|
+
lines.push(safeText.slice(cursor));
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const window = safeText.slice(cursor, cursor + safeWidth);
|
|
255
|
+
const breakOnSpace = window.lastIndexOf(" ");
|
|
256
|
+
|
|
257
|
+
if (breakOnSpace > 0) {
|
|
258
|
+
const next = breakOnSpace + 1; // keep the space so offsets stay stable for inline diff spans
|
|
259
|
+
lines.push(safeText.slice(cursor, cursor + next));
|
|
260
|
+
cursor += next;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Fallback for long uninterrupted tokens (paths, hashes, etc.)
|
|
265
|
+
lines.push(window);
|
|
266
|
+
cursor += safeWidth;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return lines.length > 0 ? lines : [""];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Diff parsing ───────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
function parseLineNumber(value: string): number | undefined {
|
|
275
|
+
const trimmed = value.trim();
|
|
276
|
+
if (!/^\d+$/.test(trimmed)) return undefined;
|
|
277
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
278
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function makeDiffLine(prefix: "+" | "-" | " ", lineNumber: string | number | undefined, line: string): DiffLine {
|
|
282
|
+
return {
|
|
283
|
+
prefix,
|
|
284
|
+
lineNumber: lineNumber === undefined ? "" : String(lineNumber),
|
|
285
|
+
line,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function parseDiffLine(rawLine: string): DiffLine | undefined {
|
|
290
|
+
const match = rawLine.match(/^([+\- ])\s?(.*)$/);
|
|
291
|
+
if (!match) return undefined;
|
|
292
|
+
const [, prefix, rest = ""] = match;
|
|
293
|
+
if (prefix !== "+" && prefix !== "-" && prefix !== " ") return undefined;
|
|
294
|
+
|
|
295
|
+
const gutterMatch = rest.match(/^(\d+)\s(.*)$/);
|
|
296
|
+
const lineNumber = gutterMatch?.[1] ?? "";
|
|
297
|
+
const line = gutterMatch?.[2] ?? rest;
|
|
298
|
+
const cleanLineNumber = sanitizeSingleLineText(lineNumber);
|
|
299
|
+
const cleanLine = sanitizeSingleLineText(line).replace(/\t/g, " ");
|
|
300
|
+
return { prefix, lineNumber: cleanLineNumber, line: cleanLine };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function computeInlineDiffSpans(leftLine: string, rightLine: string): { left: DiffSpan[]; right: DiffSpan[] } {
|
|
304
|
+
if (leftLine === rightLine) return { left: [], right: [] };
|
|
305
|
+
let start = 0;
|
|
306
|
+
const minLen = Math.min(leftLine.length, rightLine.length);
|
|
307
|
+
while (start < minLen && leftLine[start] === rightLine[start]) start++;
|
|
308
|
+
|
|
309
|
+
let leftEnd = leftLine.length;
|
|
310
|
+
let rightEnd = rightLine.length;
|
|
311
|
+
while (leftEnd > start && rightEnd > start && leftLine[leftEnd - 1] === rightLine[rightEnd - 1]) {
|
|
312
|
+
leftEnd--;
|
|
313
|
+
rightEnd--;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const leftSpan = leftEnd > start ? [{ start, end: leftEnd }] : [];
|
|
317
|
+
const rightSpan = rightEnd > start ? [{ start, end: rightEnd }] : [];
|
|
318
|
+
return { left: leftSpan, right: rightSpan };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Exported helpers ───────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
export function buildSplitRows(diff: string): SplitDiffRow[] {
|
|
324
|
+
const rows: SplitDiffRow[] = [];
|
|
325
|
+
const pendingLeft: DiffLine[] = [];
|
|
326
|
+
const pendingRight: DiffLine[] = [];
|
|
327
|
+
let oldCursor: number | undefined;
|
|
328
|
+
let newCursor: number | undefined;
|
|
329
|
+
|
|
330
|
+
const flushPending = () => {
|
|
331
|
+
while (pendingLeft.length > 0 || pendingRight.length > 0) {
|
|
332
|
+
const left = pendingLeft.shift();
|
|
333
|
+
const right = pendingRight.shift();
|
|
334
|
+
if (left && right) rows.push({ kind: "changed", left, right });
|
|
335
|
+
else if (left) rows.push({ kind: "removed", left });
|
|
336
|
+
else if (right) rows.push({ kind: "added", right });
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
for (const rawLine of diff.split("\n")) {
|
|
341
|
+
const parsed = parseDiffLine(rawLine);
|
|
342
|
+
if (!parsed) continue;
|
|
343
|
+
|
|
344
|
+
const parsedNum = parseLineNumber(parsed.lineNumber);
|
|
345
|
+
if (parsed.prefix === "-") {
|
|
346
|
+
const oldNum = parsedNum ?? oldCursor;
|
|
347
|
+
if (oldNum !== undefined) oldCursor = oldNum + 1;
|
|
348
|
+
pendingLeft.push(makeDiffLine("-", oldNum, parsed.line));
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (parsed.prefix === "+") {
|
|
352
|
+
const newNum = parsedNum ?? newCursor;
|
|
353
|
+
if (newNum !== undefined) newCursor = newNum + 1;
|
|
354
|
+
pendingRight.push(makeDiffLine("+", newNum, parsed.line));
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
flushPending();
|
|
359
|
+
|
|
360
|
+
const oldNum = parsedNum ?? oldCursor;
|
|
361
|
+
const newNum = newCursor ?? oldNum;
|
|
362
|
+
if (oldNum !== undefined) oldCursor = oldNum + 1;
|
|
363
|
+
if (newNum !== undefined) newCursor = newNum + 1;
|
|
364
|
+
|
|
365
|
+
rows.push({
|
|
366
|
+
kind: "context",
|
|
367
|
+
left: makeDiffLine(" ", oldNum, parsed.line),
|
|
368
|
+
right: makeDiffLine(" ", newNum, parsed.line),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
flushPending();
|
|
373
|
+
return rows;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function countDiffStats(diff: string): { additions: number; removals: number } {
|
|
377
|
+
let additions = 0;
|
|
378
|
+
let removals = 0;
|
|
379
|
+
for (const line of diff.split("\n")) {
|
|
380
|
+
if (line.startsWith("+++") || line.startsWith("---")) continue;
|
|
381
|
+
if (line.startsWith("+")) additions += 1;
|
|
382
|
+
if (line.startsWith("-")) removals += 1;
|
|
383
|
+
}
|
|
384
|
+
return { additions, removals };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function renderDiffMeter(theme: SplitDiffTheme, additions: number, removals: number, width = 20): string {
|
|
388
|
+
const total = additions + removals;
|
|
389
|
+
if (total <= 0) return "";
|
|
390
|
+
|
|
391
|
+
const addBlocks = Math.round((additions / total) * width);
|
|
392
|
+
const removeBlocks = Math.max(0, width - addBlocks);
|
|
393
|
+
const addBar = addBlocks > 0 ? theme.fg("toolDiffAdded", "━".repeat(addBlocks)) : "";
|
|
394
|
+
const removeBar = removeBlocks > 0 ? theme.fg("toolDiffRemoved", "━".repeat(removeBlocks)) : "";
|
|
395
|
+
return `${theme.fg("dim", "[")}${addBar}${removeBar}${theme.fg("dim", "]")}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function extractEditedPath(message: string): string | undefined {
|
|
399
|
+
const m = message.match(/Successfully replaced (?:text|\d+ block\(s\)|lines L\d+-\d+) in (.+)\.$/);
|
|
400
|
+
return m?.[1];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function firstText(content: Array<{ type: string; text?: string }>): string {
|
|
404
|
+
for (const part of content) {
|
|
405
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
406
|
+
return part.text;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return "";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── SplitDiffComponent ─────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
export class SplitDiffComponent implements Component {
|
|
415
|
+
private cacheWidth: number | undefined;
|
|
416
|
+
private cacheLines: string[] | undefined;
|
|
417
|
+
private readonly lineNumberWidth: number;
|
|
418
|
+
private readonly highlightCache = new Map<string, string>();
|
|
419
|
+
private readonly inlineHighlights = new WeakMap<DiffLine, DiffSpan[]>();
|
|
420
|
+
private readonly palette: DiffPalette;
|
|
421
|
+
private readonly containerBgAnsi: string;
|
|
422
|
+
|
|
423
|
+
constructor(
|
|
424
|
+
private readonly theme: SplitDiffTheme,
|
|
425
|
+
private readonly rows: SplitDiffRow[],
|
|
426
|
+
private readonly maxRows: number,
|
|
427
|
+
private readonly language?: string,
|
|
428
|
+
) {
|
|
429
|
+
let maxDigits = 3;
|
|
430
|
+
for (const row of rows) {
|
|
431
|
+
const leftDigits = row.left?.lineNumber.trim().length ?? 0;
|
|
432
|
+
const rightDigits = row.right?.lineNumber.trim().length ?? 0;
|
|
433
|
+
maxDigits = Math.max(maxDigits, leftDigits, rightDigits);
|
|
434
|
+
|
|
435
|
+
if (row.kind === "changed" && row.left && row.right) {
|
|
436
|
+
const spans = computeInlineDiffSpans(row.left.line, row.right.line);
|
|
437
|
+
if (spans.left.length > 0) this.inlineHighlights.set(row.left, spans.left);
|
|
438
|
+
if (spans.right.length > 0) this.inlineHighlights.set(row.right, spans.right);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
this.lineNumberWidth = maxDigits;
|
|
442
|
+
this.palette = resolveDiffPalette(theme);
|
|
443
|
+
this.containerBgAnsi = theme.getBgAnsi?.("toolSuccessBg") ?? "";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private getCellLineKind(kind: SplitDiffRow["kind"], side: "left" | "right"): CellLineKind {
|
|
447
|
+
if (kind === "changed") return side === "left" ? "remove" : "add";
|
|
448
|
+
if (kind === "removed" && side === "left") return "remove";
|
|
449
|
+
if (kind === "added" && side === "right") return "add";
|
|
450
|
+
return "context";
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private getVisualLineKind(kind: SplitDiffRow["kind"], side: "left" | "right", line?: DiffLine): CellLineKind {
|
|
454
|
+
const base = this.getCellLineKind(kind, side);
|
|
455
|
+
if ((kind === "added" || kind === "removed") && (line?.line ?? "") === "") {
|
|
456
|
+
return "context";
|
|
457
|
+
}
|
|
458
|
+
return base;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private getNumberColor(lineKind: CellLineKind): "toolDiffRemoved" | "toolDiffAdded" | "dim" {
|
|
462
|
+
if (lineKind === "remove") return "toolDiffRemoved";
|
|
463
|
+
if (lineKind === "add") return "toolDiffAdded";
|
|
464
|
+
return "dim";
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private getRowBackground(lineKind: CellLineKind): string | undefined {
|
|
468
|
+
if (lineKind === "add") return this.palette.addRowBgAnsi;
|
|
469
|
+
if (lineKind === "remove") return this.palette.removeRowBgAnsi;
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private getEmphasisBackground(lineKind: CellLineKind): string | undefined {
|
|
474
|
+
if (lineKind === "add") return this.palette.addEmphasisBgAnsi;
|
|
475
|
+
if (lineKind === "remove") return this.palette.removeEmphasisBgAnsi;
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private getCellFillBackground(kind: SplitDiffRow["kind"], side: "left" | "right"): string | undefined {
|
|
480
|
+
switch (kind) {
|
|
481
|
+
case "changed":
|
|
482
|
+
return side === "left" ? this.palette.removeRowBgAnsi : this.palette.addRowBgAnsi;
|
|
483
|
+
case "removed":
|
|
484
|
+
return side === "left" ? this.palette.removeRowBgAnsi : undefined;
|
|
485
|
+
case "added":
|
|
486
|
+
return side === "right" ? this.palette.addRowBgAnsi : undefined;
|
|
487
|
+
default:
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private blankCell(kind: SplitDiffRow["kind"], side: "left" | "right", columnWidth: number): string {
|
|
493
|
+
const lineKind = this.getCellLineKind(kind, side);
|
|
494
|
+
const markerChar = lineKind === "add" || lineKind === "remove" ? "▌" : " ";
|
|
495
|
+
const markerColor =
|
|
496
|
+
lineKind === "add" ? "toolDiffAdded" : lineKind === "remove" ? "toolDiffRemoved" : "borderMuted";
|
|
497
|
+
const marker = this.theme.fg(markerColor, markerChar);
|
|
498
|
+
const lineNumber = this.theme.fg("dim", " ".repeat(this.lineNumberWidth));
|
|
499
|
+
const divider = this.theme.fg("borderMuted", " │ ");
|
|
500
|
+
const prefix = `${marker} ${lineNumber}${divider}`;
|
|
501
|
+
const prefixPlain = `${markerChar} ${" ".repeat(this.lineNumberWidth)} │ `;
|
|
502
|
+
const tailWidth = Math.max(0, columnWidth - safeVisibleWidth(prefixPlain));
|
|
503
|
+
let rendered = prefix + " ".repeat(tailWidth);
|
|
504
|
+
|
|
505
|
+
const bg = this.getCellFillBackground(kind, side);
|
|
506
|
+
if (!bg) return padRenderedLineWidth(rendered, columnWidth);
|
|
507
|
+
rendered = `${bg}${keepBackgroundAcrossResets(rendered, bg)}${this.containerBgAnsi}`;
|
|
508
|
+
return padRenderedLineWidth(rendered, columnWidth);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private syntaxHighlight(line: string): string {
|
|
512
|
+
if (!this.language) return stripInlineBreaksPreserveAnsi(line);
|
|
513
|
+
const safeLine = sanitizeSingleLineText(line);
|
|
514
|
+
const key = `${this.language}\n${safeLine}`;
|
|
515
|
+
const cached = this.highlightCache.get(key);
|
|
516
|
+
if (cached) return cached;
|
|
517
|
+
|
|
518
|
+
let highlighted = safeLine;
|
|
519
|
+
try {
|
|
520
|
+
highlighted = highlightCode(safeLine, this.language)[0] ?? safeLine;
|
|
521
|
+
highlighted = stripInlineBreaksPreserveAnsi(highlighted).replace(BG_ANSI_PATTERN, "");
|
|
522
|
+
} catch {
|
|
523
|
+
highlighted = safeLine;
|
|
524
|
+
}
|
|
525
|
+
this.highlightCache.set(key, highlighted);
|
|
526
|
+
return highlighted;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private formatCellLines(
|
|
530
|
+
kind: SplitDiffRow["kind"],
|
|
531
|
+
side: "left" | "right",
|
|
532
|
+
line: DiffLine | undefined,
|
|
533
|
+
columnWidth: number,
|
|
534
|
+
): string[] {
|
|
535
|
+
if (!line) return [this.blankCell(kind, side, columnWidth)];
|
|
536
|
+
|
|
537
|
+
const lineKind = this.getVisualLineKind(kind, side, line);
|
|
538
|
+
const markerChar = lineKind === "add" || lineKind === "remove" ? "▌" : " ";
|
|
539
|
+
const markerColor =
|
|
540
|
+
lineKind === "add" ? "toolDiffAdded" : lineKind === "remove" ? "toolDiffRemoved" : "borderMuted";
|
|
541
|
+
const lineNumber = line.lineNumber.trim().padStart(this.lineNumberWidth, " ");
|
|
542
|
+
|
|
543
|
+
const firstPrefixAnsi =
|
|
544
|
+
this.theme.fg(markerColor, markerChar) +
|
|
545
|
+
" " +
|
|
546
|
+
this.theme.fg(this.getNumberColor(lineKind), lineNumber) +
|
|
547
|
+
this.theme.fg("borderMuted", " │ ");
|
|
548
|
+
const firstPrefixPlain = `${markerChar} ${lineNumber} │ `;
|
|
549
|
+
|
|
550
|
+
const contPrefixAnsi =
|
|
551
|
+
this.theme.fg(markerColor, markerChar) +
|
|
552
|
+
" " +
|
|
553
|
+
this.theme.fg("dim", " ".repeat(this.lineNumberWidth)) +
|
|
554
|
+
this.theme.fg("borderMuted", " │ ");
|
|
555
|
+
const contPrefixPlain = `${markerChar} ${" ".repeat(this.lineNumberWidth)} │ `;
|
|
556
|
+
|
|
557
|
+
const codeWidth = Math.max(1, columnWidth - safeVisibleWidth(firstPrefixPlain));
|
|
558
|
+
const rowBg = this.getRowBackground(lineKind);
|
|
559
|
+
const emphasisBg = this.getEmphasisBackground(lineKind);
|
|
560
|
+
|
|
561
|
+
const plainSegments = wrapPlainText(line.line, codeWidth);
|
|
562
|
+
const lines: string[] = [];
|
|
563
|
+
const spans = this.inlineHighlights.get(line) ?? [];
|
|
564
|
+
|
|
565
|
+
let consumed = 0;
|
|
566
|
+
for (let i = 0; i < plainSegments.length; i++) {
|
|
567
|
+
const prefixAnsi = i === 0 ? firstPrefixAnsi : contPrefixAnsi;
|
|
568
|
+
const prefixPlain = i === 0 ? firstPrefixPlain : contPrefixPlain;
|
|
569
|
+
const plainSegment = plainSegments[i] ?? "";
|
|
570
|
+
let segment = this.syntaxHighlight(plainSegment);
|
|
571
|
+
|
|
572
|
+
if (spans.length > 0 && emphasisBg) {
|
|
573
|
+
const segmentStart = consumed;
|
|
574
|
+
for (let si = spans.length - 1; si >= 0; si--) {
|
|
575
|
+
const span = spans[si];
|
|
576
|
+
if (!span) continue;
|
|
577
|
+
const localStart = Math.max(0, span.start - segmentStart);
|
|
578
|
+
const localEnd = Math.min(plainSegment.length, span.end - segmentStart);
|
|
579
|
+
if (localEnd > localStart) {
|
|
580
|
+
segment = applyBackgroundToVisibleRange(
|
|
581
|
+
segment,
|
|
582
|
+
localStart,
|
|
583
|
+
localEnd,
|
|
584
|
+
emphasisBg,
|
|
585
|
+
rowBg ?? this.containerBgAnsi,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
segment = fitToWidth(segment, codeWidth);
|
|
592
|
+
let rendered = prefixAnsi + segment;
|
|
593
|
+
|
|
594
|
+
// Defensive pad if prefix widths diverge because of unicode widths
|
|
595
|
+
const expectedWidth = safeVisibleWidth(prefixPlain) + codeWidth;
|
|
596
|
+
const currentWidth = safeVisibleWidth(stripAnsi(rendered));
|
|
597
|
+
if (currentWidth < expectedWidth) {
|
|
598
|
+
rendered += " ".repeat(expectedWidth - currentWidth);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (rowBg) {
|
|
602
|
+
rendered = `${rowBg}${keepBackgroundAcrossResets(rendered, rowBg)}${this.containerBgAnsi}`;
|
|
603
|
+
}
|
|
604
|
+
lines.push(padRenderedLineWidth(rendered, columnWidth));
|
|
605
|
+
consumed += plainSegment.length;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return lines;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
render(width: number): string[] {
|
|
612
|
+
if (this.cacheWidth === width && this.cacheLines) return this.cacheLines;
|
|
613
|
+
|
|
614
|
+
const safeWidth = Math.max(20, width);
|
|
615
|
+
const columnSeparator = this.theme.fg("borderMuted", " │ ");
|
|
616
|
+
const separatorWidth = safeVisibleWidth(stripAnsi(columnSeparator));
|
|
617
|
+
const leftWidth = Math.max(20, Math.floor((safeWidth - separatorWidth) / 2));
|
|
618
|
+
const rightWidth = Math.max(20, safeWidth - separatorWidth - leftWidth);
|
|
619
|
+
|
|
620
|
+
const formatBorderCell = (columnWidth: number, junction: string): string => {
|
|
621
|
+
const safeColumnWidth = Math.max(1, columnWidth);
|
|
622
|
+
const chars = "─".repeat(safeColumnWidth).split("");
|
|
623
|
+
const dividerIndex = this.lineNumberWidth + 3;
|
|
624
|
+
if (dividerIndex >= 0 && dividerIndex < chars.length) {
|
|
625
|
+
chars[dividerIndex] = junction;
|
|
626
|
+
}
|
|
627
|
+
return this.theme.fg("borderMuted", chars.join(""));
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const formatHeaderCell = (label: string, columnWidth: number): string => {
|
|
631
|
+
// Keep marker+space columns, then place label inside the line-number column.
|
|
632
|
+
const markerPad = " ";
|
|
633
|
+
const lineNumberLabel = fitToWidth(label, this.lineNumberWidth);
|
|
634
|
+
const prefixAnsi =
|
|
635
|
+
this.theme.fg("borderMuted", markerPad) +
|
|
636
|
+
this.theme.fg("dim", lineNumberLabel) +
|
|
637
|
+
this.theme.fg("borderMuted", " │ ");
|
|
638
|
+
const prefixPlain = `${markerPad}${stripAnsi(lineNumberLabel)} │ `;
|
|
639
|
+
const codeWidth = Math.max(0, columnWidth - safeVisibleWidth(prefixPlain));
|
|
640
|
+
return padRenderedLineWidth(prefixAnsi + " ".repeat(codeWidth), columnWidth);
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const lines: string[] = [];
|
|
644
|
+
lines.push(
|
|
645
|
+
padRenderedLineWidth(
|
|
646
|
+
formatBorderCell(leftWidth, "┬") + this.theme.fg("borderMuted", "─┬─") + formatBorderCell(rightWidth, "┬"),
|
|
647
|
+
safeWidth,
|
|
648
|
+
),
|
|
649
|
+
);
|
|
650
|
+
lines.push(
|
|
651
|
+
padRenderedLineWidth(
|
|
652
|
+
formatHeaderCell("old", leftWidth) + columnSeparator + formatHeaderCell("new", rightWidth),
|
|
653
|
+
safeWidth,
|
|
654
|
+
),
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
for (const row of this.rows.slice(0, this.maxRows)) {
|
|
658
|
+
const leftCellLines = this.formatCellLines(row.kind, "left", row.left, leftWidth);
|
|
659
|
+
const rightCellLines = this.formatCellLines(row.kind, "right", row.right, rightWidth);
|
|
660
|
+
const rowHeight = Math.max(leftCellLines.length, rightCellLines.length);
|
|
661
|
+
|
|
662
|
+
for (let i = 0; i < rowHeight; i++) {
|
|
663
|
+
const leftFallbackKind: SplitDiffRow["kind"] = row.kind === "changed" ? "context" : row.kind;
|
|
664
|
+
const rightFallbackKind: SplitDiffRow["kind"] = row.kind === "changed" ? "context" : row.kind;
|
|
665
|
+
const leftCell = leftCellLines[i] ?? this.blankCell(leftFallbackKind, "left", leftWidth);
|
|
666
|
+
const rightCell = rightCellLines[i] ?? this.blankCell(rightFallbackKind, "right", rightWidth);
|
|
667
|
+
const joined = padRenderedLineWidth(leftCell + columnSeparator + rightCell, safeWidth);
|
|
668
|
+
lines.push(joined);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (this.rows.length > this.maxRows) {
|
|
673
|
+
lines.push(this.theme.fg("muted", `... ${this.rows.length - this.maxRows} more rows`));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
lines.push(
|
|
677
|
+
padRenderedLineWidth(
|
|
678
|
+
formatBorderCell(leftWidth, "┴") + this.theme.fg("borderMuted", "─┴─") + formatBorderCell(rightWidth, "┴"),
|
|
679
|
+
safeWidth,
|
|
680
|
+
),
|
|
681
|
+
);
|
|
682
|
+
this.cacheWidth = width;
|
|
683
|
+
this.cacheLines = lines;
|
|
684
|
+
return lines;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
invalidate(): void {
|
|
688
|
+
this.cacheWidth = undefined;
|
|
689
|
+
this.cacheLines = undefined;
|
|
690
|
+
this.highlightCache.clear();
|
|
691
|
+
}
|
|
692
|
+
}
|