pi-redline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/extensions/session-diff.ts +1298 -0
- package/package.json +48 -0
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Diff Extension
|
|
3
|
+
*
|
|
4
|
+
* /session-diff opens an overlay showing every file edited/written during the
|
|
5
|
+
* current session, grouped by repo, with a syntax-highlighted git diff viewer.
|
|
6
|
+
*
|
|
7
|
+
* In the diff pane you can:
|
|
8
|
+
* - select line ranges (`v` to enter select mode, ↑/↓ to extend),
|
|
9
|
+
* - annotate the selection with a short note (`a`),
|
|
10
|
+
* - review all annotations across all files (`A`),
|
|
11
|
+
* - submit them as a single prompt back to the session (`S`).
|
|
12
|
+
*
|
|
13
|
+
* Inspired by Plannotator's code-review flow, but native and in-TUI.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
getLanguageFromPath,
|
|
18
|
+
highlightCode,
|
|
19
|
+
type ExtensionAPI,
|
|
20
|
+
type ExtensionContext,
|
|
21
|
+
} from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import {
|
|
23
|
+
Key,
|
|
24
|
+
matchesKey,
|
|
25
|
+
truncateToWidth,
|
|
26
|
+
visibleWidth,
|
|
27
|
+
wrapTextWithAnsi,
|
|
28
|
+
} from "@earendil-works/pi-tui";
|
|
29
|
+
import { spawnSync } from "node:child_process";
|
|
30
|
+
import { existsSync, statSync } from "node:fs";
|
|
31
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
32
|
+
|
|
33
|
+
// ─── Catppuccin Mocha palette (rgb triples for ANSI true-color) ──────────────
|
|
34
|
+
const C = {
|
|
35
|
+
mantle: "24;24;37",
|
|
36
|
+
crust: "17;17;27",
|
|
37
|
+
text: "205;214;244",
|
|
38
|
+
subtext1: "186;194;222",
|
|
39
|
+
subtext0: "166;173;200",
|
|
40
|
+
overlay1: "127;132;156",
|
|
41
|
+
overlay0: "108;112;134",
|
|
42
|
+
surface2: "88;91;112",
|
|
43
|
+
surface1: "69;71;90",
|
|
44
|
+
surface0: "49;50;68",
|
|
45
|
+
blue: "137;180;250",
|
|
46
|
+
lavender: "180;190;254",
|
|
47
|
+
sapphire: "116;199;236",
|
|
48
|
+
mauve: "203;166;247",
|
|
49
|
+
green: "166;227;161",
|
|
50
|
+
red: "243;139;168",
|
|
51
|
+
yellow: "249;226;175",
|
|
52
|
+
peach: "250;179;135",
|
|
53
|
+
teal: "148;226;213",
|
|
54
|
+
};
|
|
55
|
+
const fg = (rgb: string, s: string) => `\x1b[38;2;${rgb}m${s}\x1b[39m`;
|
|
56
|
+
const bg = (rgb: string, s: string) => `\x1b[48;2;${rgb}m${s}\x1b[49m`;
|
|
57
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
|
58
|
+
|
|
59
|
+
const BG_ADD = "27;48;36"; // dark green tint
|
|
60
|
+
const BG_DEL = "61;26;34"; // dark red tint
|
|
61
|
+
const BG_SEL = "62;70;100"; // selection (slightly brighter than surface0)
|
|
62
|
+
|
|
63
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
64
|
+
interface TouchedFile {
|
|
65
|
+
abs: string;
|
|
66
|
+
repo: string | null;
|
|
67
|
+
firstSeen: number;
|
|
68
|
+
lastSeen: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type DiffLineKind = "context" | "add" | "del" | "hunk" | "fileHeader" | "rawHeader";
|
|
72
|
+
interface DiffLine {
|
|
73
|
+
kind: DiffLineKind;
|
|
74
|
+
oldNum?: number;
|
|
75
|
+
newNum?: number;
|
|
76
|
+
/** Code content with the leading +/-/space marker removed. */
|
|
77
|
+
text: string;
|
|
78
|
+
/** The original raw diff line. */
|
|
79
|
+
raw: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type AnnotKind = "fix" | "explain";
|
|
83
|
+
interface Annotation {
|
|
84
|
+
id: string;
|
|
85
|
+
kind: AnnotKind;
|
|
86
|
+
file: string; // absolute path
|
|
87
|
+
repo: string | null;
|
|
88
|
+
/** Inclusive parsed-line indices into the cached DiffLine[] for the file. */
|
|
89
|
+
parsedStart: number;
|
|
90
|
+
parsedEnd: number;
|
|
91
|
+
/** Human-readable line range, e.g. "45-48" using new-side numbers when available. */
|
|
92
|
+
lineRangeLabel: string;
|
|
93
|
+
/** Snippet of the selected code (lines joined with \n). */
|
|
94
|
+
snippet: string;
|
|
95
|
+
note: string;
|
|
96
|
+
createdAt: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ENTRY_TYPE = "session-diff:touched";
|
|
100
|
+
|
|
101
|
+
type Mode = "scroll" | "select" | "annotate" | "review";
|
|
102
|
+
|
|
103
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
104
|
+
function findRepoRoot(filePath: string): string | null {
|
|
105
|
+
let dir = existsSync(filePath) && !isDir(filePath) ? dirname(filePath) : filePath;
|
|
106
|
+
if (!existsSync(dir)) dir = dirname(dir);
|
|
107
|
+
while (true) {
|
|
108
|
+
if (existsSync(`${dir}/.git`)) return dir;
|
|
109
|
+
const parent = dirname(dir);
|
|
110
|
+
if (parent === dir) return null;
|
|
111
|
+
dir = parent;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isDir(p: string): boolean {
|
|
116
|
+
try {
|
|
117
|
+
return statSync(p).isDirectory();
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function absolutize(p: string, cwd: string): string {
|
|
124
|
+
if (!p) return p;
|
|
125
|
+
const cleaned = p.startsWith("@") ? p.slice(1) : p;
|
|
126
|
+
return isAbsolute(cleaned) ? cleaned : resolve(cwd, cleaned);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function repoLabel(repoRoot: string | null): string {
|
|
130
|
+
if (!repoRoot) return "(no repo)";
|
|
131
|
+
const home = process.env.HOME;
|
|
132
|
+
if (home && repoRoot === home) return "~";
|
|
133
|
+
if (home && repoRoot.startsWith(`${home}/`)) return `~/${repoRoot.slice(home.length + 1)}`;
|
|
134
|
+
return repoRoot;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Look up the canonical repo name + current branch for a repo/worktree root.
|
|
139
|
+
* For worktrees, `git rev-parse --git-common-dir` points at the original
|
|
140
|
+
* repo's `.git`, so its parent directory gives the project's real name.
|
|
141
|
+
* Cached per root since spawning git for every render would be wasteful.
|
|
142
|
+
*/
|
|
143
|
+
const repoInfoCache = new Map<string, { name: string; branch: string }>();
|
|
144
|
+
function getRepoInfo(repoRoot: string | null): { name: string; branch: string } {
|
|
145
|
+
if (!repoRoot) return { name: "(no repo)", branch: "" };
|
|
146
|
+
const hit = repoInfoCache.get(repoRoot);
|
|
147
|
+
if (hit) return hit;
|
|
148
|
+
let name = repoRoot.split("/").pop() || repoRoot;
|
|
149
|
+
try {
|
|
150
|
+
const r = spawnSync(
|
|
151
|
+
"git",
|
|
152
|
+
["-C", repoRoot, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
153
|
+
{ encoding: "utf8" },
|
|
154
|
+
);
|
|
155
|
+
if (r.status === 0) {
|
|
156
|
+
const commonDir = (r.stdout || "").trim();
|
|
157
|
+
if (commonDir) {
|
|
158
|
+
const mainRoot = commonDir.replace(/\/\.git\/?$/, "");
|
|
159
|
+
const base = mainRoot.split("/").pop();
|
|
160
|
+
if (base) name = base;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
let branch = "";
|
|
165
|
+
try {
|
|
166
|
+
const r = spawnSync("git", ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
167
|
+
encoding: "utf8",
|
|
168
|
+
});
|
|
169
|
+
if (r.status === 0) branch = (r.stdout || "").trim();
|
|
170
|
+
} catch {}
|
|
171
|
+
const info = { name, branch };
|
|
172
|
+
repoInfoCache.set(repoRoot, info);
|
|
173
|
+
return info;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function leftTruncate(s: string, width: number): string {
|
|
177
|
+
const w = visibleWidth(s);
|
|
178
|
+
if (w <= width) return s;
|
|
179
|
+
const overflow = w - width + 1;
|
|
180
|
+
return `…${s.slice(overflow)}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function relativeFileLabel(repo: string | null, abs: string): string {
|
|
184
|
+
if (!repo) return abs;
|
|
185
|
+
const rel = relative(repo, abs);
|
|
186
|
+
return rel || abs;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function padTo(width: number, s: string): string {
|
|
190
|
+
const w = visibleWidth(s);
|
|
191
|
+
if (w >= width) return truncateToWidth(s, width, "");
|
|
192
|
+
return s + " ".repeat(width - w);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Git ─────────────────────────────────────────────────────────────────────
|
|
196
|
+
function gitDiff(repoRoot: string | null, abs: string): string {
|
|
197
|
+
const UFLAG = "--unified=999999";
|
|
198
|
+
if (!repoRoot) {
|
|
199
|
+
if (!existsSync(abs)) return "";
|
|
200
|
+
const r = spawnSync(
|
|
201
|
+
"git",
|
|
202
|
+
["diff", "--no-index", "--no-color", UFLAG, "/dev/null", abs],
|
|
203
|
+
{ encoding: "utf8" },
|
|
204
|
+
);
|
|
205
|
+
return r.stdout ?? "";
|
|
206
|
+
}
|
|
207
|
+
const rel = relative(repoRoot, abs);
|
|
208
|
+
const tracked =
|
|
209
|
+
spawnSync("git", ["-C", repoRoot, "ls-files", "--error-unmatch", "--", rel]).status === 0;
|
|
210
|
+
if (tracked) {
|
|
211
|
+
const r = spawnSync(
|
|
212
|
+
"git",
|
|
213
|
+
["-C", repoRoot, "diff", "HEAD", "--no-color", UFLAG, "--", rel],
|
|
214
|
+
{ encoding: "utf8" },
|
|
215
|
+
);
|
|
216
|
+
return r.stdout ?? "";
|
|
217
|
+
}
|
|
218
|
+
if (!existsSync(abs)) return "";
|
|
219
|
+
const r = spawnSync(
|
|
220
|
+
"git",
|
|
221
|
+
["-C", repoRoot, "diff", "--no-index", "--no-color", UFLAG, "/dev/null", abs],
|
|
222
|
+
{ encoding: "utf8" },
|
|
223
|
+
);
|
|
224
|
+
return r.stdout ?? "";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Diff parser ─────────────────────────────────────────────────────────────
|
|
228
|
+
function parseDiff(raw: string): DiffLine[] {
|
|
229
|
+
const out: DiffLine[] = [];
|
|
230
|
+
let oldN = 0;
|
|
231
|
+
let newN = 0;
|
|
232
|
+
for (const ln of raw.split("\n")) {
|
|
233
|
+
if (ln.startsWith("@@")) {
|
|
234
|
+
const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(ln);
|
|
235
|
+
if (m) {
|
|
236
|
+
oldN = parseInt(m[1], 10);
|
|
237
|
+
newN = parseInt(m[2], 10);
|
|
238
|
+
}
|
|
239
|
+
out.push({ kind: "hunk", text: ln, raw: ln });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (ln.startsWith("---") || ln.startsWith("+++")) {
|
|
243
|
+
out.push({ kind: "fileHeader", text: ln, raw: ln });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (
|
|
247
|
+
ln.startsWith("diff ") ||
|
|
248
|
+
ln.startsWith("index ") ||
|
|
249
|
+
ln.startsWith("new file") ||
|
|
250
|
+
ln.startsWith("deleted file") ||
|
|
251
|
+
ln.startsWith("similarity ") ||
|
|
252
|
+
ln.startsWith("rename ") ||
|
|
253
|
+
ln.startsWith("Binary ")
|
|
254
|
+
) {
|
|
255
|
+
out.push({ kind: "rawHeader", text: ln, raw: ln });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (ln.startsWith("+")) {
|
|
259
|
+
out.push({ kind: "add", newNum: newN, text: ln.slice(1), raw: ln });
|
|
260
|
+
newN++;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (ln.startsWith("-")) {
|
|
264
|
+
out.push({ kind: "del", oldNum: oldN, text: ln.slice(1), raw: ln });
|
|
265
|
+
oldN++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (ln.startsWith(" ")) {
|
|
269
|
+
out.push({ kind: "context", oldNum: oldN, newNum: newN, text: ln.slice(1), raw: ln });
|
|
270
|
+
oldN++;
|
|
271
|
+
newN++;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
// Empty line or unrecognized — keep flow.
|
|
275
|
+
if (ln === "") {
|
|
276
|
+
out.push({ kind: "context", oldNum: oldN, newNum: newN, text: "", raw: " " });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
out.push({ kind: "rawHeader", text: ln, raw: ln });
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─── Syntax highlight + diff colorizer ───────────────────────────────────────
|
|
285
|
+
function highlightOne(content: string, lang: string | undefined): string {
|
|
286
|
+
if (!content) return content;
|
|
287
|
+
try {
|
|
288
|
+
return highlightCode(content, lang).join("");
|
|
289
|
+
} catch {
|
|
290
|
+
return content;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function colorDiffContent(line: DiffLine, lang: string | undefined): string {
|
|
295
|
+
switch (line.kind) {
|
|
296
|
+
case "fileHeader":
|
|
297
|
+
return fg(C.overlay1, line.raw);
|
|
298
|
+
case "rawHeader":
|
|
299
|
+
return fg(C.overlay0, line.raw);
|
|
300
|
+
case "hunk":
|
|
301
|
+
return fg(C.mauve, line.raw);
|
|
302
|
+
case "add":
|
|
303
|
+
return bg(BG_ADD, `${fg(C.green, "+")}${highlightOne(line.text, lang)}`);
|
|
304
|
+
case "del":
|
|
305
|
+
return bg(BG_DEL, `${fg(C.red, "-")}${highlightOne(line.text, lang)}`);
|
|
306
|
+
case "context":
|
|
307
|
+
default:
|
|
308
|
+
return ` ${highlightOne(line.text, lang)}`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── View ────────────────────────────────────────────────────────────────────
|
|
313
|
+
type Row =
|
|
314
|
+
| { kind: "repo"; repo: string | null; collapsed: boolean; count: number }
|
|
315
|
+
| { kind: "repoMeta"; repo: string | null; tone: "branch" | "path"; text: string }
|
|
316
|
+
| { kind: "file"; repo: string | null; abs: string };
|
|
317
|
+
|
|
318
|
+
interface RenderedDiff {
|
|
319
|
+
rows: string[]; // colored, gutter-prefixed, wrapped lines ready to slice
|
|
320
|
+
rowToParsed: number[]; // rendered row index → parsed DiffLine index
|
|
321
|
+
parsedToFirstRow: number[]; // parsed index → first rendered row
|
|
322
|
+
gutterW: number;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
class SessionDiffView {
|
|
326
|
+
private rows: Row[] = [];
|
|
327
|
+
private leftSelected = 0;
|
|
328
|
+
private leftScroll = 0;
|
|
329
|
+
private collapsed = new Set<string>();
|
|
330
|
+
private focus: "left" | "right" = "left";
|
|
331
|
+
|
|
332
|
+
// Right-pane mode state
|
|
333
|
+
private mode: Mode = "scroll";
|
|
334
|
+
private cursor = 0; // parsed-line index for current file
|
|
335
|
+
private selAnchor: number | null = null;
|
|
336
|
+
private diffScrollByFile = new Map<string, number>(); // abs → first visible rendered row
|
|
337
|
+
private annotInput = "";
|
|
338
|
+
private pendingAnnotKind: AnnotKind = "fix";
|
|
339
|
+
|
|
340
|
+
// Review-pane state
|
|
341
|
+
private reviewSel = 0;
|
|
342
|
+
|
|
343
|
+
// Caches
|
|
344
|
+
private parsedCache = new Map<string, DiffLine[]>(); // abs → parsed
|
|
345
|
+
private rendered = new Map<string, RenderedDiff>(); // key=abs@@width@@selSig → rendered
|
|
346
|
+
private cachedWidth?: number;
|
|
347
|
+
private cachedHeight?: number;
|
|
348
|
+
private cachedLines?: string[];
|
|
349
|
+
|
|
350
|
+
public onClose?: () => void;
|
|
351
|
+
public onSubmit?: (prompt: string) => void;
|
|
352
|
+
|
|
353
|
+
constructor(
|
|
354
|
+
private files: TouchedFile[],
|
|
355
|
+
/** Annotations live in the extension closure so they survive overlay close. */
|
|
356
|
+
private annotations: Annotation[],
|
|
357
|
+
) {
|
|
358
|
+
this.rebuildRows();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Left tree ────────────────────────────────────────────────────────────
|
|
362
|
+
private repoKey(r: string | null): string {
|
|
363
|
+
return r ?? "__none__";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private rebuildRows() {
|
|
367
|
+
const groups = new Map<string, { repo: string | null; files: TouchedFile[] }>();
|
|
368
|
+
for (const f of this.files) {
|
|
369
|
+
const k = this.repoKey(f.repo);
|
|
370
|
+
if (!groups.has(k)) groups.set(k, { repo: f.repo, files: [] });
|
|
371
|
+
groups.get(k)!.files.push(f);
|
|
372
|
+
}
|
|
373
|
+
for (const g of groups.values()) g.files.sort((a, b) => b.lastSeen - a.lastSeen);
|
|
374
|
+
this.rows = [];
|
|
375
|
+
for (const [, g] of groups) {
|
|
376
|
+
const collapsed = this.collapsed.has(this.repoKey(g.repo));
|
|
377
|
+
this.rows.push({ kind: "repo", repo: g.repo, collapsed, count: g.files.length });
|
|
378
|
+
if (!collapsed) {
|
|
379
|
+
const info = getRepoInfo(g.repo);
|
|
380
|
+
if (info.branch) {
|
|
381
|
+
this.rows.push({ kind: "repoMeta", repo: g.repo, tone: "branch", text: info.branch });
|
|
382
|
+
}
|
|
383
|
+
if (g.repo) {
|
|
384
|
+
this.rows.push({ kind: "repoMeta", repo: g.repo, tone: "path", text: repoLabel(g.repo) });
|
|
385
|
+
}
|
|
386
|
+
for (const f of g.files) this.rows.push({ kind: "file", repo: g.repo, abs: f.abs });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (this.leftSelected >= this.rows.length)
|
|
390
|
+
this.leftSelected = Math.max(0, this.rows.length - 1);
|
|
391
|
+
while (this.rows[this.leftSelected]?.kind === "repoMeta") this.leftSelected++;
|
|
392
|
+
if (this.leftSelected >= this.rows.length)
|
|
393
|
+
this.leftSelected = Math.max(0, this.rows.length - 1);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private currentFile(): { abs: string; repo: string | null } | null {
|
|
397
|
+
const r = this.rows[this.leftSelected];
|
|
398
|
+
if (!r || r.kind !== "file") return null;
|
|
399
|
+
return { abs: r.abs, repo: r.repo };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private getParsed(abs: string, repo: string | null): DiffLine[] {
|
|
403
|
+
const hit = this.parsedCache.get(abs);
|
|
404
|
+
if (hit) return hit;
|
|
405
|
+
const parsed = parseDiff(gitDiff(repo, abs));
|
|
406
|
+
this.parsedCache.set(abs, parsed);
|
|
407
|
+
return parsed;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private firstCodeLine(parsed: DiffLine[]): number {
|
|
411
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
412
|
+
if (
|
|
413
|
+
parsed[i].kind === "context" ||
|
|
414
|
+
parsed[i].kind === "add" ||
|
|
415
|
+
parsed[i].kind === "del"
|
|
416
|
+
)
|
|
417
|
+
return i;
|
|
418
|
+
}
|
|
419
|
+
return 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private isCodeLine(p: DiffLine): boolean {
|
|
423
|
+
return p.kind === "context" || p.kind === "add" || p.kind === "del";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── Diff rendering with gutter / cursor / selection ──────────────────────
|
|
427
|
+
private selRange(): [number, number] | null {
|
|
428
|
+
if (this.mode !== "select" && this.mode !== "annotate") return null;
|
|
429
|
+
if (this.selAnchor == null) return [this.cursor, this.cursor];
|
|
430
|
+
return [Math.min(this.selAnchor, this.cursor), Math.max(this.selAnchor, this.cursor)];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private buildRendered(abs: string, repo: string | null, width: number): RenderedDiff {
|
|
434
|
+
const parsed = this.getParsed(abs, repo);
|
|
435
|
+
const lang = getLanguageFromPath(abs);
|
|
436
|
+
|
|
437
|
+
// Determine gutter width based on max line number.
|
|
438
|
+
let maxNum = 1;
|
|
439
|
+
for (const p of parsed) {
|
|
440
|
+
if (p.oldNum != null && p.oldNum > maxNum) maxNum = p.oldNum;
|
|
441
|
+
if (p.newNum != null && p.newNum > maxNum) maxNum = p.newNum;
|
|
442
|
+
}
|
|
443
|
+
const numW = Math.max(2, String(maxNum).length);
|
|
444
|
+
const gutterW = 1 /* cursor col */ + numW + 1 /* sp */ + numW + 1 /* sp */ + 1 /* │ */ + 1 /* sp */;
|
|
445
|
+
const codeW = Math.max(10, width - gutterW);
|
|
446
|
+
|
|
447
|
+
const sel = this.selRange();
|
|
448
|
+
const cursor = this.cursor;
|
|
449
|
+
|
|
450
|
+
const rows: string[] = [];
|
|
451
|
+
const rowToParsed: number[] = [];
|
|
452
|
+
const parsedToFirstRow: number[] = new Array(parsed.length).fill(-1);
|
|
453
|
+
|
|
454
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
455
|
+
const p = parsed[i];
|
|
456
|
+
const inSel = !!sel && i >= sel[0] && i <= sel[1];
|
|
457
|
+
const isCursor = this.focus === "right" && (this.mode === "select" || this.mode === "annotate") && i === cursor;
|
|
458
|
+
const isAnnotated = this.annotations.some(
|
|
459
|
+
(a) => a.file === abs && i >= a.parsedStart && i <= a.parsedEnd,
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
// Gutter: cursor col, old, new, separator
|
|
463
|
+
let cursorCol: string;
|
|
464
|
+
if (isCursor) cursorCol = fg(C.peach, "▌");
|
|
465
|
+
else if (isAnnotated) cursorCol = fg(C.yellow, "│");
|
|
466
|
+
else cursorCol = " ";
|
|
467
|
+
|
|
468
|
+
let oldStr: string;
|
|
469
|
+
let newStr: string;
|
|
470
|
+
if (p.kind === "hunk" || p.kind === "fileHeader" || p.kind === "rawHeader") {
|
|
471
|
+
oldStr = " ".repeat(numW);
|
|
472
|
+
newStr = " ".repeat(numW);
|
|
473
|
+
} else {
|
|
474
|
+
oldStr =
|
|
475
|
+
p.oldNum != null
|
|
476
|
+
? (p.kind === "del" ? fg(C.red, String(p.oldNum).padStart(numW)) : fg(C.overlay0, String(p.oldNum).padStart(numW)))
|
|
477
|
+
: " ".repeat(numW);
|
|
478
|
+
newStr =
|
|
479
|
+
p.newNum != null
|
|
480
|
+
? (p.kind === "add" ? fg(C.green, String(p.newNum).padStart(numW)) : fg(C.overlay0, String(p.newNum).padStart(numW)))
|
|
481
|
+
: " ".repeat(numW);
|
|
482
|
+
}
|
|
483
|
+
const sepCol = fg(isCursor ? C.peach : C.surface1, "│");
|
|
484
|
+
const gutter = `${cursorCol}${oldStr} ${newStr} ${sepCol} `;
|
|
485
|
+
const blankGutter = ` ${" ".repeat(numW)} ${" ".repeat(numW)} ${fg(C.surface1, "│")} `;
|
|
486
|
+
|
|
487
|
+
const colored = colorDiffContent(p, lang);
|
|
488
|
+
const wrapped = wrapTextWithAnsi(colored, codeW);
|
|
489
|
+
if (wrapped.length === 0) wrapped.push("");
|
|
490
|
+
|
|
491
|
+
parsedToFirstRow[i] = rows.length;
|
|
492
|
+
for (let w = 0; w < wrapped.length; w++) {
|
|
493
|
+
let body = wrapped[w];
|
|
494
|
+
if (inSel) body = bg(BG_SEL, padTo(codeW, body));
|
|
495
|
+
const g = w === 0 ? gutter : blankGutter;
|
|
496
|
+
rows.push(`${g}${body}`);
|
|
497
|
+
rowToParsed.push(i);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return { rows, rowToParsed, parsedToFirstRow, gutterW };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Build a stable signature for the cache key.
|
|
505
|
+
private renderSig(abs: string, width: number): string {
|
|
506
|
+
const sel = this.selRange();
|
|
507
|
+
const selKey = sel ? `${sel[0]}-${sel[1]}` : "none";
|
|
508
|
+
const annotKey = this.annotations
|
|
509
|
+
.filter((a) => a.file === abs)
|
|
510
|
+
.map((a) => `${a.parsedStart}-${a.parsedEnd}`)
|
|
511
|
+
.join(",");
|
|
512
|
+
return `${abs}@@${width}@@${this.focus}@@${this.mode}@@${this.cursor}@@${selKey}@@${annotKey}`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private getRendered(abs: string, repo: string | null, width: number): RenderedDiff {
|
|
516
|
+
const key = this.renderSig(abs, width);
|
|
517
|
+
const hit = this.rendered.get(key);
|
|
518
|
+
if (hit) return hit;
|
|
519
|
+
const r = this.buildRendered(abs, repo, width);
|
|
520
|
+
// Keep the cache small.
|
|
521
|
+
if (this.rendered.size > 16) this.rendered.clear();
|
|
522
|
+
this.rendered.set(key, r);
|
|
523
|
+
return r;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private getDiffScroll(abs: string): number {
|
|
527
|
+
return this.diffScrollByFile.get(abs) ?? 0;
|
|
528
|
+
}
|
|
529
|
+
private setDiffScroll(abs: string, v: number) {
|
|
530
|
+
this.diffScrollByFile.set(abs, Math.max(0, v));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ── Input ────────────────────────────────────────────────────────────────
|
|
534
|
+
handleInput(data: string): void {
|
|
535
|
+
// Annotate mode: capture text input.
|
|
536
|
+
if (this.mode === "annotate") {
|
|
537
|
+
if (matchesKey(data, Key.escape)) {
|
|
538
|
+
this.mode = "select";
|
|
539
|
+
this.annotInput = "";
|
|
540
|
+
this.invalidate();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (matchesKey(data, Key.enter)) {
|
|
544
|
+
this.commitAnnotation();
|
|
545
|
+
this.invalidate();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (matchesKey(data, Key.backspace)) {
|
|
549
|
+
this.annotInput = this.annotInput.slice(0, -1);
|
|
550
|
+
this.invalidate();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
554
|
+
this.annotInput += data;
|
|
555
|
+
this.invalidate();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Review mode
|
|
562
|
+
if (this.mode === "review") {
|
|
563
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
564
|
+
this.mode = "scroll";
|
|
565
|
+
this.invalidate();
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
569
|
+
if (this.reviewSel > 0) this.reviewSel--;
|
|
570
|
+
this.invalidate();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
574
|
+
if (this.reviewSel < this.annotations.length - 1) this.reviewSel++;
|
|
575
|
+
this.invalidate();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (data === "d" || matchesKey(data, Key.delete)) {
|
|
579
|
+
this.annotations.splice(this.reviewSel, 1);
|
|
580
|
+
if (this.reviewSel >= this.annotations.length) this.reviewSel = Math.max(0, this.annotations.length - 1);
|
|
581
|
+
this.invalidate();
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (data === "S") {
|
|
585
|
+
this.submit();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (matchesKey(data, Key.enter)) {
|
|
589
|
+
// Jump to the annotation's file + line
|
|
590
|
+
const a = this.annotations[this.reviewSel];
|
|
591
|
+
if (a) this.focusAnnotation(a);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Global keys
|
|
598
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
599
|
+
this.onClose?.();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (data === "A") {
|
|
603
|
+
if (this.annotations.length === 0) return;
|
|
604
|
+
this.mode = "review";
|
|
605
|
+
this.reviewSel = 0;
|
|
606
|
+
this.invalidate();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (data === "S") {
|
|
610
|
+
this.submit();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (this.focus === "left") {
|
|
615
|
+
this.handleLeft(data);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Right pane
|
|
620
|
+
const cf = this.currentFile();
|
|
621
|
+
if (!cf) return;
|
|
622
|
+
const parsed = this.getParsed(cf.abs, cf.repo);
|
|
623
|
+
|
|
624
|
+
if (this.mode === "scroll") {
|
|
625
|
+
if (matchesKey(data, Key.up) || data === "k") this.setDiffScroll(cf.abs, this.getDiffScroll(cf.abs) - 1);
|
|
626
|
+
else if (matchesKey(data, Key.down) || data === "j") this.setDiffScroll(cf.abs, this.getDiffScroll(cf.abs) + 1);
|
|
627
|
+
else if (data === " " || data === "\x06") this.setDiffScroll(cf.abs, this.getDiffScroll(cf.abs) + 10);
|
|
628
|
+
else if (data === "\x02") this.setDiffScroll(cf.abs, this.getDiffScroll(cf.abs) - 10);
|
|
629
|
+
else if (matchesKey(data, Key.left) || data === "h") this.focus = "left";
|
|
630
|
+
else if (matchesKey(data, Key.home) || data === "g") this.setDiffScroll(cf.abs, 0);
|
|
631
|
+
else if (data === "v") {
|
|
632
|
+
this.mode = "select";
|
|
633
|
+
// Place cursor at the first visible code line; no anchor yet.
|
|
634
|
+
const r = this.getRendered(cf.abs, cf.repo, this.lastRightW);
|
|
635
|
+
const firstVisibleRow = this.getDiffScroll(cf.abs);
|
|
636
|
+
const firstVisibleParsed = r.rowToParsed[firstVisibleRow] ?? 0;
|
|
637
|
+
let c = firstVisibleParsed;
|
|
638
|
+
while (c < parsed.length && !this.isCodeLine(parsed[c])) c++;
|
|
639
|
+
if (c >= parsed.length) c = this.firstCodeLine(parsed);
|
|
640
|
+
this.cursor = c;
|
|
641
|
+
this.selAnchor = null;
|
|
642
|
+
}
|
|
643
|
+
this.invalidate();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (this.mode === "select") {
|
|
648
|
+
if (matchesKey(data, Key.escape)) {
|
|
649
|
+
// If a range is in progress, just drop it; another esc exits select.
|
|
650
|
+
if (this.selAnchor != null) {
|
|
651
|
+
this.selAnchor = null;
|
|
652
|
+
} else {
|
|
653
|
+
this.mode = "scroll";
|
|
654
|
+
}
|
|
655
|
+
this.invalidate();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
// Arrows: move cursor; extend range if an anchor was dropped.
|
|
659
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
660
|
+
this.moveCursor(parsed, -1, this.selAnchor != null);
|
|
661
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
662
|
+
this.moveCursor(parsed, +1, this.selAnchor != null);
|
|
663
|
+
} else if (data === " ") {
|
|
664
|
+
// First space -> drop anchor at cursor (range begins).
|
|
665
|
+
// Second space -> confirm range and open the annotation input.
|
|
666
|
+
if (this.selAnchor == null) {
|
|
667
|
+
this.selAnchor = this.cursor;
|
|
668
|
+
} else {
|
|
669
|
+
this.mode = "annotate";
|
|
670
|
+
this.annotInput = "";
|
|
671
|
+
this.pendingAnnotKind = "fix";
|
|
672
|
+
}
|
|
673
|
+
} else if (data === "o") {
|
|
674
|
+
if (this.selAnchor != null) {
|
|
675
|
+
const t = this.cursor;
|
|
676
|
+
this.cursor = this.selAnchor;
|
|
677
|
+
this.selAnchor = t;
|
|
678
|
+
}
|
|
679
|
+
} else if (data === "a" || matchesKey(data, Key.enter)) {
|
|
680
|
+
if (this.selAnchor == null) this.selAnchor = this.cursor;
|
|
681
|
+
this.mode = "annotate";
|
|
682
|
+
this.annotInput = "";
|
|
683
|
+
this.pendingAnnotKind = "fix";
|
|
684
|
+
} else if (data === "x" || data === "X") {
|
|
685
|
+
if (this.selAnchor == null) this.selAnchor = this.cursor;
|
|
686
|
+
this.mode = "annotate";
|
|
687
|
+
this.annotInput = "";
|
|
688
|
+
this.pendingAnnotKind = "explain";
|
|
689
|
+
} else if (matchesKey(data, Key.left) || data === "h") {
|
|
690
|
+
this.mode = "scroll";
|
|
691
|
+
this.selAnchor = null;
|
|
692
|
+
}
|
|
693
|
+
this.ensureCursorVisible(cf.abs, cf.repo);
|
|
694
|
+
this.invalidate();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private lastRightW = 80;
|
|
700
|
+
|
|
701
|
+
private handleLeft(data: string): void {
|
|
702
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
703
|
+
if (this.leftSelected > 0) {
|
|
704
|
+
this.leftSelected--;
|
|
705
|
+
while (this.leftSelected > 0 && this.rows[this.leftSelected].kind === "repoMeta")
|
|
706
|
+
this.leftSelected--;
|
|
707
|
+
}
|
|
708
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
709
|
+
if (this.leftSelected < this.rows.length - 1) {
|
|
710
|
+
this.leftSelected++;
|
|
711
|
+
while (
|
|
712
|
+
this.leftSelected < this.rows.length - 1 &&
|
|
713
|
+
this.rows[this.leftSelected].kind === "repoMeta"
|
|
714
|
+
)
|
|
715
|
+
this.leftSelected++;
|
|
716
|
+
}
|
|
717
|
+
} else if (matchesKey(data, Key.left) || data === "h") {
|
|
718
|
+
const r = this.rows[this.leftSelected];
|
|
719
|
+
if (r?.kind === "repo" && !r.collapsed) {
|
|
720
|
+
this.collapsed.add(this.repoKey(r.repo));
|
|
721
|
+
this.rebuildRows();
|
|
722
|
+
} else if (r?.kind === "file") {
|
|
723
|
+
for (let i = this.leftSelected - 1; i >= 0; i--) {
|
|
724
|
+
if (this.rows[i].kind === "repo") {
|
|
725
|
+
this.leftSelected = i;
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
} else if (matchesKey(data, Key.right) || matchesKey(data, Key.enter) || data === "l") {
|
|
731
|
+
const r = this.rows[this.leftSelected];
|
|
732
|
+
if (r?.kind === "repo") {
|
|
733
|
+
if (r.collapsed) {
|
|
734
|
+
this.collapsed.delete(this.repoKey(r.repo));
|
|
735
|
+
this.rebuildRows();
|
|
736
|
+
}
|
|
737
|
+
} else if (r?.kind === "file") {
|
|
738
|
+
this.focus = "right";
|
|
739
|
+
this.mode = "scroll";
|
|
740
|
+
this.selAnchor = null;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
this.invalidate();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private moveCursor(parsed: DiffLine[], delta: number, shift: boolean) {
|
|
747
|
+
if (shift && this.selAnchor == null) this.selAnchor = this.cursor;
|
|
748
|
+
else if (!shift) this.selAnchor = null;
|
|
749
|
+
let c = this.cursor;
|
|
750
|
+
while (true) {
|
|
751
|
+
c += delta;
|
|
752
|
+
if (c < 0 || c >= parsed.length) {
|
|
753
|
+
c = Math.max(0, Math.min(parsed.length - 1, c));
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
if (this.isCodeLine(parsed[c])) break;
|
|
757
|
+
}
|
|
758
|
+
this.cursor = c;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private ensureCursorVisible(abs: string, repo: string | null) {
|
|
762
|
+
const r = this.getRendered(abs, repo, this.lastRightW);
|
|
763
|
+
const row = r.parsedToFirstRow[this.cursor] ?? 0;
|
|
764
|
+
const top = this.getDiffScroll(abs);
|
|
765
|
+
const innerH = this.lastInnerH;
|
|
766
|
+
if (row < top) this.setDiffScroll(abs, row);
|
|
767
|
+
else if (row >= top + innerH) this.setDiffScroll(abs, row - innerH + 1);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private lastInnerH = 10;
|
|
771
|
+
|
|
772
|
+
// ── Annotation lifecycle ────────────────────────────────────────────────
|
|
773
|
+
private commitAnnotation() {
|
|
774
|
+
const cf = this.currentFile();
|
|
775
|
+
if (!cf) {
|
|
776
|
+
this.mode = "scroll";
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const note = this.annotInput.trim();
|
|
780
|
+
this.annotInput = "";
|
|
781
|
+
// For "explain" annotations an empty note is fine — the LLM is asked to
|
|
782
|
+
// explain the snippet on its own. Fix-mode requires a note.
|
|
783
|
+
if (!note && this.pendingAnnotKind !== "explain") {
|
|
784
|
+
this.mode = "select";
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const sel = this.selRange();
|
|
788
|
+
if (!sel) {
|
|
789
|
+
this.mode = "select";
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const parsed = this.getParsed(cf.abs, cf.repo);
|
|
793
|
+
const [s, e] = sel;
|
|
794
|
+
// Build a human range label using new-side line numbers if available, else old.
|
|
795
|
+
const linesNew: number[] = [];
|
|
796
|
+
const linesOld: number[] = [];
|
|
797
|
+
const snippetLines: string[] = [];
|
|
798
|
+
for (let i = s; i <= e; i++) {
|
|
799
|
+
const p = parsed[i];
|
|
800
|
+
if (p.newNum != null) linesNew.push(p.newNum);
|
|
801
|
+
if (p.oldNum != null) linesOld.push(p.oldNum);
|
|
802
|
+
if (this.isCodeLine(p)) {
|
|
803
|
+
const prefix = p.kind === "add" ? "+ " : p.kind === "del" ? "- " : " ";
|
|
804
|
+
snippetLines.push(`${prefix}${p.text}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
let rangeLabel: string;
|
|
808
|
+
if (linesNew.length > 0) {
|
|
809
|
+
rangeLabel = linesNew.length === 1 ? `L${linesNew[0]}` : `L${linesNew[0]}-${linesNew[linesNew.length - 1]}`;
|
|
810
|
+
} else if (linesOld.length > 0) {
|
|
811
|
+
rangeLabel = linesOld.length === 1 ? `L${linesOld[0]} (old)` : `L${linesOld[0]}-${linesOld[linesOld.length - 1]} (old)`;
|
|
812
|
+
} else {
|
|
813
|
+
rangeLabel = "(no line range)";
|
|
814
|
+
}
|
|
815
|
+
this.annotations.push({
|
|
816
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
817
|
+
kind: this.pendingAnnotKind,
|
|
818
|
+
file: cf.abs,
|
|
819
|
+
repo: cf.repo,
|
|
820
|
+
parsedStart: s,
|
|
821
|
+
parsedEnd: e,
|
|
822
|
+
lineRangeLabel: rangeLabel,
|
|
823
|
+
snippet: snippetLines.join("\n"),
|
|
824
|
+
note,
|
|
825
|
+
createdAt: Date.now(),
|
|
826
|
+
});
|
|
827
|
+
this.mode = "select";
|
|
828
|
+
this.selAnchor = null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private focusAnnotation(a: Annotation) {
|
|
832
|
+
// Switch left selection to the file
|
|
833
|
+
const idx = this.rows.findIndex((r) => r.kind === "file" && r.abs === a.file);
|
|
834
|
+
if (idx >= 0) this.leftSelected = idx;
|
|
835
|
+
this.focus = "right";
|
|
836
|
+
this.mode = "select";
|
|
837
|
+
this.cursor = a.parsedStart;
|
|
838
|
+
this.selAnchor = a.parsedEnd;
|
|
839
|
+
this.ensureCursorVisible(a.file, a.repo);
|
|
840
|
+
this.invalidate();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private submit() {
|
|
844
|
+
if (this.annotations.length === 0) return;
|
|
845
|
+
const fixes = this.annotations.filter((a) => a.kind === "fix");
|
|
846
|
+
const explains = this.annotations.filter((a) => a.kind === "explain");
|
|
847
|
+
const sections: string[] = [];
|
|
848
|
+
|
|
849
|
+
const renderGroup = (items: Annotation[]) => {
|
|
850
|
+
const byFile = new Map<string, Annotation[]>();
|
|
851
|
+
for (const a of items) {
|
|
852
|
+
if (!byFile.has(a.file)) byFile.set(a.file, []);
|
|
853
|
+
byFile.get(a.file)!.push(a);
|
|
854
|
+
}
|
|
855
|
+
for (const [file, arr] of byFile) {
|
|
856
|
+
const repo = arr[0].repo;
|
|
857
|
+
const rel = relativeFileLabel(repo, file);
|
|
858
|
+
sections.push(`\n### \`${rel}\`${repo ? ` _(repo: ${repoLabel(repo)})_` : ""}`);
|
|
859
|
+
for (const a of arr) {
|
|
860
|
+
const label = a.note ? ` — ${a.note}` : "";
|
|
861
|
+
sections.push(`\n**${a.lineRangeLabel}**${label}`);
|
|
862
|
+
if (a.snippet) {
|
|
863
|
+
sections.push("```diff");
|
|
864
|
+
sections.push(a.snippet);
|
|
865
|
+
sections.push("```");
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
if (fixes.length > 0 && explains.length > 0) {
|
|
872
|
+
sections.push(
|
|
873
|
+
"I'm reviewing the changes you made in this session. Some annotations request fixes, others ask for an explanation — please respond to each:",
|
|
874
|
+
);
|
|
875
|
+
sections.push("\n## Fixes requested\nPlease apply each of the following changes:");
|
|
876
|
+
renderGroup(fixes);
|
|
877
|
+
sections.push(
|
|
878
|
+
"\n## Explanations requested\nFor each of the following, explain what the code does, why it was written this way, and any non-obvious implications. Do not modify the code.",
|
|
879
|
+
);
|
|
880
|
+
renderGroup(explains);
|
|
881
|
+
} else if (explains.length > 0) {
|
|
882
|
+
sections.push(
|
|
883
|
+
"For each of the following code regions from this session, please explain what the code does, why it was written this way, and any non-obvious implications. Do not modify the code unless I explicitly ask.",
|
|
884
|
+
);
|
|
885
|
+
renderGroup(explains);
|
|
886
|
+
} else {
|
|
887
|
+
sections.push(
|
|
888
|
+
"I'm reviewing the changes you made in this session. Here are my inline annotations — please address each:",
|
|
889
|
+
);
|
|
890
|
+
renderGroup(fixes);
|
|
891
|
+
}
|
|
892
|
+
const prompt = sections.join("\n");
|
|
893
|
+
this.onSubmit?.(prompt);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ── Render ──────────────────────────────────────────────────────────────
|
|
897
|
+
invalidate(): void {
|
|
898
|
+
this.cachedWidth = undefined;
|
|
899
|
+
this.cachedLines = undefined;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
render(width: number): string[] {
|
|
903
|
+
const termRows = process.stdout.rows ?? 30;
|
|
904
|
+
const totalHeight = Math.max(14, Math.min(termRows - 4, Math.floor(termRows * 0.82)));
|
|
905
|
+
|
|
906
|
+
if (
|
|
907
|
+
this.cachedLines &&
|
|
908
|
+
this.cachedWidth === width &&
|
|
909
|
+
this.cachedHeight === totalHeight
|
|
910
|
+
) {
|
|
911
|
+
return this.cachedLines;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const innerW = width - 2;
|
|
915
|
+
const leftWidth = Math.max(26, Math.min(48, Math.floor(innerW * 0.32)));
|
|
916
|
+
const rightWidth = innerW - leftWidth - 1;
|
|
917
|
+
const headerH = 1;
|
|
918
|
+
const footerH = this.mode === "annotate" ? 2 : 1;
|
|
919
|
+
const dividerH = 2;
|
|
920
|
+
const bodyH = Math.max(3, totalHeight - 2 - headerH - footerH - dividerH);
|
|
921
|
+
|
|
922
|
+
this.lastRightW = rightWidth - 1; // 1 for inner left padding
|
|
923
|
+
this.lastInnerH = bodyH - 2; // 2 = file header + rule inside right pane
|
|
924
|
+
|
|
925
|
+
const border = (s: string) => fg(C.mauve, s);
|
|
926
|
+
const top = border(`╭${"─".repeat(innerW)}╮`);
|
|
927
|
+
const bot = border(`╰${"─".repeat(innerW)}╯`);
|
|
928
|
+
const rule = border("├") + fg(C.surface1, "─".repeat(innerW)) + border("┤");
|
|
929
|
+
const V = border("│");
|
|
930
|
+
|
|
931
|
+
const header = `${V}${padTo(innerW, this.renderHeader(innerW))}${V}`;
|
|
932
|
+
|
|
933
|
+
const left = this.renderLeft(leftWidth, bodyH);
|
|
934
|
+
const right = this.mode === "review"
|
|
935
|
+
? this.renderReview(rightWidth, bodyH)
|
|
936
|
+
: this.renderRight(rightWidth, bodyH);
|
|
937
|
+
const sep = fg(C.surface1, "│");
|
|
938
|
+
|
|
939
|
+
const body: string[] = [];
|
|
940
|
+
for (let i = 0; i < bodyH; i++) {
|
|
941
|
+
const l = left[i] ?? padTo(leftWidth, "");
|
|
942
|
+
const r = right[i] ?? padTo(rightWidth, "");
|
|
943
|
+
body.push(`${V}${l}${sep}${r}${V}`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const footerLines = this.renderFooter(innerW);
|
|
947
|
+
const footerWrapped = footerLines.map((ln) => `${V}${padTo(innerW, ln)}${V}`);
|
|
948
|
+
|
|
949
|
+
const lines = [top, header, rule, ...body, rule, ...footerWrapped, bot];
|
|
950
|
+
const tinted = lines.map((ln) => bg(C.mantle, padTo(width, ln)));
|
|
951
|
+
this.cachedLines = tinted;
|
|
952
|
+
this.cachedWidth = width;
|
|
953
|
+
this.cachedHeight = totalHeight;
|
|
954
|
+
return tinted;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
private renderHeader(width: number): string {
|
|
958
|
+
const title = bold(fg(C.lavender, " Session Diff "));
|
|
959
|
+
const hint = fg(
|
|
960
|
+
C.overlay1,
|
|
961
|
+
` ${this.files.length} file${this.files.length === 1 ? "" : "s"} touched`,
|
|
962
|
+
);
|
|
963
|
+
const annot = this.annotations.length > 0
|
|
964
|
+
? ` ${fg(C.yellow, `${this.annotations.length} annotation${this.annotations.length === 1 ? "" : "s"}`)}`
|
|
965
|
+
: "";
|
|
966
|
+
const modeTag = this.mode !== "scroll"
|
|
967
|
+
? ` ${bg(C.surface1, fg(C.peach, ` ${this.mode.toUpperCase()} `))}`
|
|
968
|
+
: "";
|
|
969
|
+
return padTo(width, title + hint + annot + modeTag);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
private renderFooter(width: number): string[] {
|
|
973
|
+
if (this.mode === "annotate") {
|
|
974
|
+
const sel = this.selRange();
|
|
975
|
+
const cf = this.currentFile();
|
|
976
|
+
let rangeLabel = "?";
|
|
977
|
+
if (sel && cf) {
|
|
978
|
+
const parsed = this.getParsed(cf.abs, cf.repo);
|
|
979
|
+
const [s, e] = sel;
|
|
980
|
+
const ns = parsed[s]?.newNum ?? parsed[s]?.oldNum;
|
|
981
|
+
const ne = parsed[e]?.newNum ?? parsed[e]?.oldNum;
|
|
982
|
+
rangeLabel = ns != null && ne != null ? (ns === ne ? `L${ns}` : `L${ns}-${ne}`) : "?";
|
|
983
|
+
}
|
|
984
|
+
const kindTag =
|
|
985
|
+
this.pendingAnnotKind === "explain"
|
|
986
|
+
? bg(C.surface1, fg(C.sapphire, " explain "))
|
|
987
|
+
: bg(C.surface1, fg(C.yellow, " fix "));
|
|
988
|
+
const icon = this.pendingAnnotKind === "explain" ? fg(C.sapphire, "?") : fg(C.yellow, "✎");
|
|
989
|
+
const placeholder =
|
|
990
|
+
this.pendingAnnotKind === "explain"
|
|
991
|
+
? "what to explain (optional):"
|
|
992
|
+
: "note:";
|
|
993
|
+
const prompt = ` ${kindTag} ${icon} ${fg(C.subtext0, `${placeholder} (${rangeLabel})`)} ${fg(C.text, this.annotInput)}${fg(C.peach, "▏")}`;
|
|
994
|
+
const help = ` ${fg(C.peach, "⏎")} ${fg(C.subtext0, "save")} · ${fg(C.peach, "esc")} ${fg(C.subtext0, "cancel")}`;
|
|
995
|
+
return [padTo(width, prompt), padTo(width, help)];
|
|
996
|
+
}
|
|
997
|
+
if (this.mode === "review") {
|
|
998
|
+
const segs: [string, string][] = [
|
|
999
|
+
["↑↓", "navigate"],
|
|
1000
|
+
["⏎", "jump to file"],
|
|
1001
|
+
["d", "delete"],
|
|
1002
|
+
["S", `submit (${this.annotations.length})`],
|
|
1003
|
+
["esc", "back"],
|
|
1004
|
+
];
|
|
1005
|
+
return [padTo(width, " " + segs.map(([k, v]) => `${fg(C.peach, k)} ${fg(C.subtext0, v)}`).join(fg(C.overlay0, " · ")))];
|
|
1006
|
+
}
|
|
1007
|
+
let segs: [string, string][];
|
|
1008
|
+
if (this.focus === "left") {
|
|
1009
|
+
segs = [
|
|
1010
|
+
["↑↓", "navigate"],
|
|
1011
|
+
["←", "collapse"],
|
|
1012
|
+
["→/⏎", "open diff"],
|
|
1013
|
+
["A", `review (${this.annotations.length})`],
|
|
1014
|
+
["q", "close"],
|
|
1015
|
+
];
|
|
1016
|
+
} else if (this.mode === "select") {
|
|
1017
|
+
const annotN = this.annotations.length;
|
|
1018
|
+
segs = this.selAnchor == null
|
|
1019
|
+
? [
|
|
1020
|
+
["↑↓", "move"],
|
|
1021
|
+
["space", "start range"],
|
|
1022
|
+
["a/⏎", "annotate (fix)"],
|
|
1023
|
+
["x", "annotate (explain)"],
|
|
1024
|
+
["A", `review (${annotN})`],
|
|
1025
|
+
["S", "submit"],
|
|
1026
|
+
["esc", "exit"],
|
|
1027
|
+
]
|
|
1028
|
+
: [
|
|
1029
|
+
["↑↓", "extend"],
|
|
1030
|
+
["a/space", "end + fix"],
|
|
1031
|
+
["x", "end + explain"],
|
|
1032
|
+
["o", "swap ends"],
|
|
1033
|
+
["esc", "drop range"],
|
|
1034
|
+
];
|
|
1035
|
+
} else {
|
|
1036
|
+
segs = [
|
|
1037
|
+
["↑↓", "scroll"],
|
|
1038
|
+
["v", "select lines"],
|
|
1039
|
+
["←", "back"],
|
|
1040
|
+
["A", `review (${this.annotations.length})`],
|
|
1041
|
+
["S", "submit"],
|
|
1042
|
+
["q", "close"],
|
|
1043
|
+
];
|
|
1044
|
+
}
|
|
1045
|
+
return [padTo(width, " " + segs.map(([k, v]) => `${fg(C.peach, k)} ${fg(C.subtext0, v)}`).join(fg(C.overlay0, " · ")))];
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
private renderLeft(width: number, height: number): string[] {
|
|
1049
|
+
if (this.leftSelected < this.leftScroll) this.leftScroll = this.leftSelected;
|
|
1050
|
+
if (this.leftSelected >= this.leftScroll + height)
|
|
1051
|
+
this.leftScroll = this.leftSelected - height + 1;
|
|
1052
|
+
|
|
1053
|
+
const visible = this.rows.slice(this.leftScroll, this.leftScroll + height);
|
|
1054
|
+
const out: string[] = [];
|
|
1055
|
+
for (let i = 0; i < visible.length; i++) {
|
|
1056
|
+
const idx = this.leftScroll + i;
|
|
1057
|
+
const r = visible[i];
|
|
1058
|
+
const isSel = idx === this.leftSelected;
|
|
1059
|
+
out.push(this.renderRow(r, isSel, width));
|
|
1060
|
+
}
|
|
1061
|
+
while (out.length < height) out.push(padTo(width, ""));
|
|
1062
|
+
return out;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private renderRow(r: Row, selected: boolean, width: number): string {
|
|
1066
|
+
const prefix = selected && this.focus === "left" ? fg(C.peach, "▌") : " ";
|
|
1067
|
+
let body: string;
|
|
1068
|
+
if (r.kind === "repo") {
|
|
1069
|
+
const arrow = r.collapsed ? fg(C.overlay1, "▸") : fg(C.overlay1, "▾");
|
|
1070
|
+
const count = fg(C.overlay0, ` (${r.count})`);
|
|
1071
|
+
const countW = visibleWidth(` (${r.count})`);
|
|
1072
|
+
const nameMax = Math.max(8, width - 4 - countW);
|
|
1073
|
+
const { name: repoName } = getRepoInfo(r.repo);
|
|
1074
|
+
const label = leftTruncate(repoName, nameMax);
|
|
1075
|
+
const name = fg(C.blue, bold(label));
|
|
1076
|
+
body = `${arrow} ${name}${count}`;
|
|
1077
|
+
} else if (r.kind === "repoMeta") {
|
|
1078
|
+
if (r.tone === "branch") {
|
|
1079
|
+
body = ` ${fg(C.teal, "")} ${fg(C.subtext0, r.text)}`;
|
|
1080
|
+
} else {
|
|
1081
|
+
const max = Math.max(8, width - 5);
|
|
1082
|
+
body = ` ${fg(C.overlay0, leftTruncate(r.text, max))}`;
|
|
1083
|
+
}
|
|
1084
|
+
} else {
|
|
1085
|
+
const fileAnnots = this.annotations.filter((a) => a.file === r.abs).length;
|
|
1086
|
+
const badge = fileAnnots > 0 ? fg(C.yellow, ` ✎${fileAnnots}`) : "";
|
|
1087
|
+
const label = relativeFileLabel(r.repo, r.abs);
|
|
1088
|
+
body = ` ${fg(selected ? C.text : C.subtext1, label)}${badge}`;
|
|
1089
|
+
}
|
|
1090
|
+
const full = `${prefix}${body}`;
|
|
1091
|
+
const truncated = truncateToWidth(full, width, "…");
|
|
1092
|
+
if (selected) return bg(C.surface0, padTo(width, truncated));
|
|
1093
|
+
return padTo(width, truncated);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
private renderRight(width: number, height: number): string[] {
|
|
1097
|
+
const cf = this.currentFile();
|
|
1098
|
+
const out: string[] = [];
|
|
1099
|
+
if (!cf) {
|
|
1100
|
+
out.push(padTo(width, ` ${fg(C.overlay1, "Select a file on the left to view its diff.")}`));
|
|
1101
|
+
while (out.length < height) out.push(padTo(width, ""));
|
|
1102
|
+
return out;
|
|
1103
|
+
}
|
|
1104
|
+
const head = ` ${fg(C.sapphire, bold(relativeFileLabel(cf.repo, cf.abs)))}${fg(
|
|
1105
|
+
C.overlay0,
|
|
1106
|
+
cf.repo ? ` — ${repoLabel(cf.repo)}` : "",
|
|
1107
|
+
)}`;
|
|
1108
|
+
out.push(padTo(width, truncateToWidth(head, width, "…")));
|
|
1109
|
+
out.push(padTo(width, fg(C.surface1, "─".repeat(width))));
|
|
1110
|
+
|
|
1111
|
+
const innerH = height - 2;
|
|
1112
|
+
const r = this.getRendered(cf.abs, cf.repo, width - 1);
|
|
1113
|
+
const totalRows = r.rows.length;
|
|
1114
|
+
let scroll = this.getDiffScroll(cf.abs);
|
|
1115
|
+
if (scroll > Math.max(0, totalRows - innerH)) {
|
|
1116
|
+
scroll = Math.max(0, totalRows - innerH);
|
|
1117
|
+
this.setDiffScroll(cf.abs, scroll);
|
|
1118
|
+
}
|
|
1119
|
+
const slice = r.rows.slice(scroll, scroll + innerH);
|
|
1120
|
+
for (const ln of slice) out.push(padTo(width, ` ${ln}`));
|
|
1121
|
+
while (out.length < height) out.push(padTo(width, ""));
|
|
1122
|
+
|
|
1123
|
+
if (totalRows > innerH) {
|
|
1124
|
+
const pct = Math.min(100, Math.round(((scroll + innerH) / totalRows) * 100));
|
|
1125
|
+
const tag = fg(C.overlay1, ` ${pct}% `);
|
|
1126
|
+
const lastIdx = out.length - 1;
|
|
1127
|
+
out[lastIdx] = padTo(width, truncateToWidth(out[lastIdx], width - visibleWidth(tag), "")) + tag;
|
|
1128
|
+
}
|
|
1129
|
+
return out;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
private renderReview(width: number, height: number): string[] {
|
|
1133
|
+
const out: string[] = [];
|
|
1134
|
+
const head = ` ${fg(C.lavender, bold(`Annotations (${this.annotations.length})`))}`;
|
|
1135
|
+
out.push(padTo(width, head));
|
|
1136
|
+
out.push(padTo(width, fg(C.surface1, "─".repeat(width))));
|
|
1137
|
+
const innerH = height - 2;
|
|
1138
|
+
if (this.annotations.length === 0) {
|
|
1139
|
+
out.push(padTo(width, ` ${fg(C.overlay1, "No annotations yet.")}`));
|
|
1140
|
+
while (out.length < height) out.push(padTo(width, ""));
|
|
1141
|
+
return out;
|
|
1142
|
+
}
|
|
1143
|
+
// Render up to innerH lines, paginate naively. Each annotation = 2 lines.
|
|
1144
|
+
const linesPer = 3;
|
|
1145
|
+
const startAnnot = Math.max(0, this.reviewSel - Math.floor(innerH / linesPer / 2));
|
|
1146
|
+
let used = 0;
|
|
1147
|
+
for (let i = startAnnot; i < this.annotations.length && used + linesPer <= innerH; i++) {
|
|
1148
|
+
const a = this.annotations[i];
|
|
1149
|
+
const isSel = i === this.reviewSel;
|
|
1150
|
+
const marker = isSel ? fg(C.peach, "▌") : " ";
|
|
1151
|
+
const rel = relativeFileLabel(a.repo, a.file);
|
|
1152
|
+
const kindBadge =
|
|
1153
|
+
a.kind === "explain"
|
|
1154
|
+
? bg(C.surface1, fg(C.sapphire, " explain "))
|
|
1155
|
+
: bg(C.surface1, fg(C.yellow, " fix "));
|
|
1156
|
+
const head1 = `${marker} ${kindBadge} ${fg(C.sapphire, rel)} ${fg(C.overlay0, a.lineRangeLabel)}`;
|
|
1157
|
+
const icon = a.kind === "explain" ? fg(C.sapphire, "?") : fg(C.yellow, "✎");
|
|
1158
|
+
const head2 = `${marker} ${icon} ${fg(C.text, a.note || (a.kind === "explain" ? "(no question — just explain it)" : ""))}`;
|
|
1159
|
+
const head3 = `${marker} ${fg(C.overlay1, "─".repeat(Math.max(0, width - 3)))}`;
|
|
1160
|
+
const wrap = (s: string) => (isSel ? bg(C.surface0, padTo(width, truncateToWidth(s, width, "…"))) : padTo(width, truncateToWidth(s, width, "…")));
|
|
1161
|
+
out.push(wrap(head1));
|
|
1162
|
+
out.push(wrap(head2));
|
|
1163
|
+
out.push(wrap(head3));
|
|
1164
|
+
used += linesPer;
|
|
1165
|
+
}
|
|
1166
|
+
while (out.length < height) out.push(padTo(width, ""));
|
|
1167
|
+
return out;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// ─── Extension entry point ───────────────────────────────────────────────────
|
|
1172
|
+
export default function (pi: ExtensionAPI) {
|
|
1173
|
+
const touched = new Map<string, TouchedFile>();
|
|
1174
|
+
// Annotations persist across overlay close so an accidental `esc` doesn't lose work.
|
|
1175
|
+
const annotations: Annotation[] = [];
|
|
1176
|
+
|
|
1177
|
+
function record(abs: string, ts: number) {
|
|
1178
|
+
const existing = touched.get(abs);
|
|
1179
|
+
if (existing) {
|
|
1180
|
+
existing.lastSeen = ts;
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
touched.set(abs, { abs, repo: findRepoRoot(abs), firstSeen: ts, lastSeen: ts });
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1187
|
+
touched.clear();
|
|
1188
|
+
const callIndex = new Map<string, { name: string; args: any; ts: number }>();
|
|
1189
|
+
const entries = ctx.sessionManager.getEntries();
|
|
1190
|
+
for (const entry of entries) {
|
|
1191
|
+
if (entry.type !== "message") continue;
|
|
1192
|
+
const msg: any = (entry as any).message;
|
|
1193
|
+
if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
1194
|
+
for (const c of msg.content) {
|
|
1195
|
+
if (c && c.type === "toolCall" && typeof c.id === "string") {
|
|
1196
|
+
callIndex.set(c.id, {
|
|
1197
|
+
name: String(c.name ?? ""),
|
|
1198
|
+
args: c.arguments ?? {},
|
|
1199
|
+
ts: msg.timestamp ?? Date.now(),
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const alreadyPersisted = new Set<string>();
|
|
1205
|
+
for (const entry of entries) {
|
|
1206
|
+
if (entry.type === "custom" && (entry as any).customType === ENTRY_TYPE) {
|
|
1207
|
+
const data = (entry as any).data;
|
|
1208
|
+
if (data?.path) {
|
|
1209
|
+
record(data.path, (entry as any).timestamp ?? Date.now());
|
|
1210
|
+
alreadyPersisted.add(data.path);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
for (const entry of entries) {
|
|
1215
|
+
if (entry.type !== "message") continue;
|
|
1216
|
+
const msg: any = (entry as any).message;
|
|
1217
|
+
if (msg?.role !== "toolResult" || msg.isError) continue;
|
|
1218
|
+
const name = String(msg.toolName ?? "").toLowerCase();
|
|
1219
|
+
if (name !== "edit" && name !== "write" && name !== "multiedit") continue;
|
|
1220
|
+
const call = callIndex.get(msg.toolCallId);
|
|
1221
|
+
const args = call?.args ?? {};
|
|
1222
|
+
const rawPath: string | undefined =
|
|
1223
|
+
args.path ?? args.file_path ?? args.filePath ?? args.target;
|
|
1224
|
+
if (!rawPath) continue;
|
|
1225
|
+
const abs = absolutize(rawPath, ctx.cwd);
|
|
1226
|
+
const ts = msg.timestamp ?? call?.ts ?? Date.now();
|
|
1227
|
+
const isNew = !touched.has(abs);
|
|
1228
|
+
record(abs, ts);
|
|
1229
|
+
if (isNew && !alreadyPersisted.has(abs)) {
|
|
1230
|
+
pi.appendEntry(ENTRY_TYPE, { path: abs });
|
|
1231
|
+
alreadyPersisted.add(abs);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
1237
|
+
if (event.isError) return;
|
|
1238
|
+
const name = (event.toolName || "").toLowerCase();
|
|
1239
|
+
if (name !== "edit" && name !== "write" && name !== "multiedit") return;
|
|
1240
|
+
const input: any = (event as any).input ?? {};
|
|
1241
|
+
const path: string | undefined =
|
|
1242
|
+
input.path ?? input.file_path ?? input.filePath ?? input.target;
|
|
1243
|
+
if (!path) return;
|
|
1244
|
+
const abs = absolutize(path, ctx.cwd);
|
|
1245
|
+
const isNew = !touched.has(abs);
|
|
1246
|
+
record(abs, Date.now());
|
|
1247
|
+
if (isNew) pi.appendEntry(ENTRY_TYPE, { path: abs });
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
pi.registerCommand("session-diff", {
|
|
1251
|
+
description: "Show files changed in this session with a diff + annotation viewer",
|
|
1252
|
+
handler: async (_args, ctx: ExtensionContext) => {
|
|
1253
|
+
if (!ctx.hasUI) {
|
|
1254
|
+
ctx.ui.notify("session-diff requires an interactive UI", "error");
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const files = [...touched.values()];
|
|
1258
|
+
if (files.length === 0) {
|
|
1259
|
+
ctx.ui.notify("No files changed in this session yet.", "info");
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
let pendingPrompt: string | null = null;
|
|
1263
|
+
await ctx.ui.custom<void>(
|
|
1264
|
+
(tui, _theme, _kb, done) => {
|
|
1265
|
+
const view = new SessionDiffView(files, annotations);
|
|
1266
|
+
view.onClose = () => done(undefined);
|
|
1267
|
+
view.onSubmit = (prompt) => {
|
|
1268
|
+
pendingPrompt = prompt;
|
|
1269
|
+
// Submission consumes the annotations.
|
|
1270
|
+
annotations.length = 0;
|
|
1271
|
+
done(undefined);
|
|
1272
|
+
};
|
|
1273
|
+
return {
|
|
1274
|
+
render: (w) => view.render(w),
|
|
1275
|
+
invalidate: () => view.invalidate(),
|
|
1276
|
+
handleInput: (data) => {
|
|
1277
|
+
view.handleInput(data);
|
|
1278
|
+
tui.requestRender();
|
|
1279
|
+
},
|
|
1280
|
+
};
|
|
1281
|
+
},
|
|
1282
|
+
{
|
|
1283
|
+
overlay: true,
|
|
1284
|
+
overlayOptions: {
|
|
1285
|
+
width: "90%",
|
|
1286
|
+
minWidth: 80,
|
|
1287
|
+
maxHeight: "85%",
|
|
1288
|
+
anchor: "center",
|
|
1289
|
+
},
|
|
1290
|
+
},
|
|
1291
|
+
);
|
|
1292
|
+
if (pendingPrompt) {
|
|
1293
|
+
pi.sendUserMessage(pendingPrompt);
|
|
1294
|
+
ctx.ui.notify("Submitted annotations to the session.", "info");
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
}
|