@fresh-editor/fresh-editor 0.1.65 → 0.1.67

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.
@@ -0,0 +1,1045 @@
1
+ // Review Diff Plugin
2
+ // Provides a unified workflow for reviewing code changes (diffs, conflicts, AI outputs).
3
+
4
+ /// <reference path="./lib/fresh.d.ts" />
5
+ /// <reference path="./lib/types.ts" />
6
+ /// <reference path="./lib/virtual-buffer-factory.ts" />
7
+
8
+ import { VirtualBufferFactory } from "./lib/virtual-buffer-factory.ts";
9
+
10
+ /**
11
+ * Hunk status for staging
12
+ */
13
+ type HunkStatus = 'pending' | 'staged' | 'discarded';
14
+
15
+ /**
16
+ * Review status for a hunk
17
+ */
18
+ type ReviewStatus = 'pending' | 'approved' | 'needs_changes' | 'rejected' | 'question';
19
+
20
+ /**
21
+ * A review comment attached to a specific line in a file
22
+ * Uses file line numbers (not hunk-relative) so comments survive rebases
23
+ */
24
+ interface ReviewComment {
25
+ id: string;
26
+ hunk_id: string; // For grouping, but line numbers are primary
27
+ file: string; // File path
28
+ text: string;
29
+ timestamp: string;
30
+ // Line positioning using actual file line numbers
31
+ old_line?: number; // Line number in old file version (for - lines)
32
+ new_line?: number; // Line number in new file version (for + lines)
33
+ line_content?: string; // The actual line content for context/matching
34
+ line_type?: 'add' | 'remove' | 'context'; // Type of line
35
+ // Selection range (for multi-line comments)
36
+ selection?: {
37
+ start_line: number; // Start line in file
38
+ end_line: number; // End line in file
39
+ version: 'old' | 'new'; // Which file version
40
+ };
41
+ }
42
+
43
+ /**
44
+ * A diff hunk (block of changes)
45
+ */
46
+ interface Hunk {
47
+ id: string;
48
+ file: string;
49
+ range: { start: number; end: number }; // new file line range
50
+ oldRange: { start: number; end: number }; // old file line range
51
+ type: 'add' | 'remove' | 'modify';
52
+ lines: string[];
53
+ status: HunkStatus;
54
+ reviewStatus: ReviewStatus;
55
+ contextHeader: string;
56
+ byteOffset: number; // Position in the virtual buffer
57
+ }
58
+
59
+ /**
60
+ * Review Session State
61
+ */
62
+ interface ReviewState {
63
+ hunks: Hunk[];
64
+ hunkStatus: Record<string, HunkStatus>;
65
+ comments: ReviewComment[];
66
+ originalRequest?: string;
67
+ overallFeedback?: string;
68
+ reviewBufferId: number | null;
69
+ }
70
+
71
+ const state: ReviewState = {
72
+ hunks: [],
73
+ hunkStatus: {},
74
+ comments: [],
75
+ reviewBufferId: null,
76
+ };
77
+
78
+ // --- Refresh State ---
79
+ let isUpdating = false;
80
+
81
+ // --- Colors & Styles ---
82
+ const STYLE_BORDER: [number, number, number] = [70, 70, 70];
83
+ const STYLE_HEADER: [number, number, number] = [120, 120, 255];
84
+ const STYLE_FILE_NAME: [number, number, number] = [220, 220, 100];
85
+ const STYLE_ADD_BG: [number, number, number] = [40, 100, 40]; // Brighter Green BG
86
+ const STYLE_REMOVE_BG: [number, number, number] = [100, 40, 40]; // Brighter Red BG
87
+ const STYLE_ADD_TEXT: [number, number, number] = [150, 255, 150]; // Very Bright Green
88
+ const STYLE_REMOVE_TEXT: [number, number, number] = [255, 150, 150]; // Very Bright Red
89
+ const STYLE_STAGED: [number, number, number] = [100, 100, 100];
90
+ const STYLE_DISCARDED: [number, number, number] = [120, 60, 60];
91
+ const STYLE_COMMENT: [number, number, number] = [180, 180, 100]; // Yellow for comments
92
+ const STYLE_COMMENT_BORDER: [number, number, number] = [100, 100, 60];
93
+ const STYLE_APPROVED: [number, number, number] = [100, 200, 100]; // Green checkmark
94
+ const STYLE_REJECTED: [number, number, number] = [200, 100, 100]; // Red X
95
+ const STYLE_QUESTION: [number, number, number] = [200, 200, 100]; // Yellow ?
96
+
97
+ /**
98
+ * Calculate UTF-8 byte length of a string manually since TextEncoder is not available
99
+ */
100
+ function getByteLength(str: string): number {
101
+ let s = 0;
102
+ for (let i = 0; i < str.length; i++) {
103
+ const code = str.charCodeAt(i);
104
+ if (code <= 0x7f) s += 1;
105
+ else if (code <= 0x7ff) s += 2;
106
+ else if (code >= 0xd800 && code <= 0xdfff) {
107
+ s += 4; i++;
108
+ } else s += 3;
109
+ }
110
+ return s;
111
+ }
112
+
113
+ // --- Diff Logic ---
114
+
115
+ interface DiffPart {
116
+ text: string;
117
+ type: 'added' | 'removed' | 'unchanged';
118
+ }
119
+
120
+ function diffStrings(oldStr: string, newStr: string): DiffPart[] {
121
+ const n = oldStr.length;
122
+ const m = newStr.length;
123
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
124
+
125
+ for (let i = 1; i <= n; i++) {
126
+ for (let j = 1; j <= m; j++) {
127
+ if (oldStr[i - 1] === newStr[j - 1]) {
128
+ dp[i][j] = dp[i - 1][j - 1] + 1;
129
+ } else {
130
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
131
+ }
132
+ }
133
+ }
134
+
135
+ const result: DiffPart[] = [];
136
+ let i = n, j = m;
137
+ while (i > 0 || j > 0) {
138
+ if (i > 0 && j > 0 && oldStr[i - 1] === newStr[j - 1]) {
139
+ result.unshift({ text: oldStr[i - 1], type: 'unchanged' });
140
+ i--; j--;
141
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
142
+ result.unshift({ text: newStr[j - 1], type: 'added' });
143
+ j--;
144
+ } else {
145
+ result.unshift({ text: oldStr[i - 1], type: 'removed' });
146
+ i--;
147
+ }
148
+ }
149
+
150
+ const coalesced: DiffPart[] = [];
151
+ for (const part of result) {
152
+ const last = coalesced[coalesced.length - 1];
153
+ if (last && last.type === part.type) {
154
+ last.text += part.text;
155
+ } else {
156
+ coalesced.push(part);
157
+ }
158
+ }
159
+ return coalesced;
160
+ }
161
+
162
+ async function getGitDiff(): Promise<Hunk[]> {
163
+ const result = await editor.spawnProcess("git", ["diff", "HEAD", "--unified=3"]);
164
+ if (result.exit_code !== 0) return [];
165
+
166
+ const lines = result.stdout.split('\n');
167
+ const hunks: Hunk[] = [];
168
+ let currentFile = "";
169
+ let currentHunk: Hunk | null = null;
170
+
171
+ for (let i = 0; i < lines.length; i++) {
172
+ const line = lines[i];
173
+ if (line.startsWith('diff --git')) {
174
+ const match = line.match(/diff --git a\/(.+) b\/(.+)/);
175
+ if (match) {
176
+ currentFile = match[2];
177
+ currentHunk = null;
178
+ }
179
+ } else if (line.startsWith('@@')) {
180
+ const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@(.*)/);
181
+ if (match && currentFile) {
182
+ const oldStart = parseInt(match[1]);
183
+ const newStart = parseInt(match[2]);
184
+ currentHunk = {
185
+ id: `${currentFile}:${newStart}`,
186
+ file: currentFile,
187
+ range: { start: newStart, end: newStart },
188
+ oldRange: { start: oldStart, end: oldStart },
189
+ type: 'modify',
190
+ lines: [],
191
+ status: 'pending',
192
+ reviewStatus: 'pending',
193
+ contextHeader: match[3]?.trim() || "",
194
+ byteOffset: 0
195
+ };
196
+ hunks.push(currentHunk);
197
+ }
198
+ } else if (currentHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
199
+ if (!line.startsWith('---') && !line.startsWith('+++')) {
200
+ currentHunk.lines.push(line);
201
+ }
202
+ }
203
+ }
204
+ return hunks;
205
+ }
206
+
207
+ interface HighlightTask {
208
+ range: [number, number];
209
+ fg: [number, number, number];
210
+ bg?: [number, number, number];
211
+ bold?: boolean;
212
+ italic?: boolean;
213
+ }
214
+
215
+ /**
216
+ * Render the Review Stream buffer content and return highlight tasks
217
+ */
218
+ async function renderReviewStream(): Promise<{ entries: TextPropertyEntry[], highlights: HighlightTask[] }> {
219
+ const entries: TextPropertyEntry[] = [];
220
+ const highlights: HighlightTask[] = [];
221
+ let currentFile = "";
222
+ let currentByte = 0;
223
+
224
+ // Add help header with keybindings at the TOP
225
+ const helpHeader = "╔══════════════════════════════════════════════════════════════════════════╗\n";
226
+ const helpLen0 = getByteLength(helpHeader);
227
+ entries.push({ text: helpHeader, properties: { type: "help" } });
228
+ highlights.push({ range: [currentByte, currentByte + helpLen0], fg: STYLE_COMMENT_BORDER });
229
+ currentByte += helpLen0;
230
+
231
+ const helpLine1 = "║ REVIEW: [c]omment [a]pprove [x]reject [!]changes [?]question [u]ndo ║\n";
232
+ const helpLen1 = getByteLength(helpLine1);
233
+ entries.push({ text: helpLine1, properties: { type: "help" } });
234
+ highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_COMMENT });
235
+ currentByte += helpLen1;
236
+
237
+ const helpLine2 = "║ STAGE: [s]tage [d]iscard | NAV: [n]ext [p]rev [Enter]drill [q]uit ║\n";
238
+ const helpLen2 = getByteLength(helpLine2);
239
+ entries.push({ text: helpLine2, properties: { type: "help" } });
240
+ highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
241
+ currentByte += helpLen2;
242
+
243
+ const helpLine3 = "║ EXPORT: [E] .review/session.md | [O]verall feedback | [r]efresh ║\n";
244
+ const helpLen3 = getByteLength(helpLine3);
245
+ entries.push({ text: helpLine3, properties: { type: "help" } });
246
+ highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
247
+ currentByte += helpLen3;
248
+
249
+ const helpFooter = "╚══════════════════════════════════════════════════════════════════════════╝\n\n";
250
+ const helpLen4 = getByteLength(helpFooter);
251
+ entries.push({ text: helpFooter, properties: { type: "help" } });
252
+ highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT_BORDER });
253
+ currentByte += helpLen4;
254
+
255
+ for (let hunkIndex = 0; hunkIndex < state.hunks.length; hunkIndex++) {
256
+ const hunk = state.hunks[hunkIndex];
257
+ if (hunk.file !== currentFile) {
258
+ // Header & Border
259
+ const titlePrefix = "┌─ ";
260
+ const titleLine = `${titlePrefix}${hunk.file} ${"─".repeat(Math.max(0, 60 - hunk.file.length))}\n`;
261
+ const titleLen = getByteLength(titleLine);
262
+ entries.push({ text: titleLine, properties: { type: "banner", file: hunk.file } });
263
+ highlights.push({ range: [currentByte, currentByte + titleLen], fg: STYLE_BORDER });
264
+ const prefixLen = getByteLength(titlePrefix);
265
+ highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + getByteLength(hunk.file)], fg: STYLE_FILE_NAME, bold: true });
266
+ currentByte += titleLen;
267
+ currentFile = hunk.file;
268
+ }
269
+
270
+ hunk.byteOffset = currentByte;
271
+
272
+ // Status icons: staging (left) and review (right)
273
+ const stagingIcon = hunk.status === 'staged' ? '✓' : (hunk.status === 'discarded' ? '✗' : ' ');
274
+ const reviewIcon = hunk.reviewStatus === 'approved' ? '✓' :
275
+ hunk.reviewStatus === 'rejected' ? '✗' :
276
+ hunk.reviewStatus === 'needs_changes' ? '!' :
277
+ hunk.reviewStatus === 'question' ? '?' : ' ';
278
+ const reviewLabel = hunk.reviewStatus !== 'pending' ? ` ← ${hunk.reviewStatus.toUpperCase()}` : '';
279
+
280
+ const headerPrefix = "│ ";
281
+ const headerText = `${headerPrefix}${stagingIcon} ${reviewIcon} [ ${hunk.contextHeader} ]${reviewLabel}\n`;
282
+ const headerLen = getByteLength(headerText);
283
+
284
+ let hunkColor = STYLE_HEADER;
285
+ if (hunk.status === 'staged') hunkColor = STYLE_STAGED;
286
+ else if (hunk.status === 'discarded') hunkColor = STYLE_DISCARDED;
287
+
288
+ let reviewColor = STYLE_HEADER;
289
+ if (hunk.reviewStatus === 'approved') reviewColor = STYLE_APPROVED;
290
+ else if (hunk.reviewStatus === 'rejected') reviewColor = STYLE_REJECTED;
291
+ else if (hunk.reviewStatus === 'needs_changes') reviewColor = STYLE_QUESTION;
292
+ else if (hunk.reviewStatus === 'question') reviewColor = STYLE_QUESTION;
293
+
294
+ entries.push({ text: headerText, properties: { type: "header", hunkId: hunk.id, index: hunkIndex } });
295
+ highlights.push({ range: [currentByte, currentByte + headerLen], fg: STYLE_BORDER });
296
+ const headerPrefixLen = getByteLength(headerPrefix);
297
+ // Staging icon
298
+ highlights.push({ range: [currentByte + headerPrefixLen, currentByte + headerPrefixLen + getByteLength(stagingIcon)], fg: hunkColor, bold: true });
299
+ // Review icon
300
+ highlights.push({ range: [currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1, currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1 + getByteLength(reviewIcon)], fg: reviewColor, bold: true });
301
+ // Context header
302
+ const contextStart = currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1 + getByteLength(reviewIcon) + 3;
303
+ highlights.push({ range: [contextStart, currentByte + headerLen - getByteLength(reviewLabel) - 2], fg: hunkColor });
304
+ // Review label
305
+ if (reviewLabel) {
306
+ highlights.push({ range: [currentByte + headerLen - getByteLength(reviewLabel) - 1, currentByte + headerLen - 1], fg: reviewColor, bold: true });
307
+ }
308
+ currentByte += headerLen;
309
+
310
+ // Track actual file line numbers as we iterate
311
+ let oldLineNum = hunk.oldRange.start;
312
+ let newLineNum = hunk.range.start;
313
+
314
+ for (let i = 0; i < hunk.lines.length; i++) {
315
+ const line = hunk.lines[i];
316
+ const nextLine = hunk.lines[i + 1];
317
+ const marker = line[0];
318
+ const content = line.substring(1);
319
+ const linePrefix = "│ ";
320
+ const lineText = `${linePrefix}${marker} ${content}\n`;
321
+ const lineLen = getByteLength(lineText);
322
+ const prefixLen = getByteLength(linePrefix);
323
+
324
+ // Determine line type and which line numbers apply
325
+ const lineType: 'add' | 'remove' | 'context' =
326
+ marker === '+' ? 'add' : marker === '-' ? 'remove' : 'context';
327
+ const curOldLine = lineType !== 'add' ? oldLineNum : undefined;
328
+ const curNewLine = lineType !== 'remove' ? newLineNum : undefined;
329
+
330
+ if (line.startsWith('-') && nextLine && nextLine.startsWith('+') && hunk.status === 'pending') {
331
+ const oldContent = line.substring(1);
332
+ const newContent = nextLine.substring(1);
333
+ const diffParts = diffStrings(oldContent, newContent);
334
+
335
+ // Removed
336
+ entries.push({ text: lineText, properties: {
337
+ type: "content", hunkId: hunk.id, file: hunk.file,
338
+ lineType: 'remove', oldLine: curOldLine, lineContent: line
339
+ } });
340
+ highlights.push({ range: [currentByte, currentByte + lineLen], fg: STYLE_BORDER });
341
+ highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_REMOVE_TEXT, bold: true });
342
+
343
+ let cbOffset = currentByte + prefixLen + 2;
344
+ diffParts.forEach(p => {
345
+ const pLen = getByteLength(p.text);
346
+ if (p.type === 'removed') {
347
+ highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_REMOVE_TEXT, bg: STYLE_REMOVE_BG, bold: true });
348
+ cbOffset += pLen;
349
+ } else if (p.type === 'unchanged') {
350
+ highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_REMOVE_TEXT });
351
+ cbOffset += pLen;
352
+ }
353
+ });
354
+ currentByte += lineLen;
355
+
356
+ // Added (increment old line for the removed line we just processed)
357
+ oldLineNum++;
358
+ const nextLineText = `${linePrefix}+ ${nextLine.substring(1)}\n`;
359
+ const nextLineLen = getByteLength(nextLineText);
360
+ entries.push({ text: nextLineText, properties: {
361
+ type: "content", hunkId: hunk.id, file: hunk.file,
362
+ lineType: 'add', newLine: newLineNum, lineContent: nextLine
363
+ } });
364
+ newLineNum++;
365
+ highlights.push({ range: [currentByte, currentByte + nextLineLen], fg: STYLE_BORDER });
366
+ highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_ADD_TEXT, bold: true });
367
+
368
+ cbOffset = currentByte + prefixLen + 2;
369
+ diffParts.forEach(p => {
370
+ const pLen = getByteLength(p.text);
371
+ if (p.type === 'added') {
372
+ highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_ADD_TEXT, bg: STYLE_ADD_BG, bold: true });
373
+ cbOffset += pLen;
374
+ } else if (p.type === 'unchanged') {
375
+ highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_ADD_TEXT });
376
+ cbOffset += pLen;
377
+ }
378
+ });
379
+ currentByte += nextLineLen;
380
+
381
+ // Render comments for the removed line (curOldLine before increment)
382
+ const removedLineComments = state.comments.filter(c =>
383
+ c.hunk_id === hunk.id && c.line_type === 'remove' && c.old_line === curOldLine
384
+ );
385
+ for (const comment of removedLineComments) {
386
+ const commentPrefix = `│ » [-${comment.old_line}] `;
387
+ const commentLines = comment.text.split('\n');
388
+ for (let ci = 0; ci < commentLines.length; ci++) {
389
+ const prefix = ci === 0 ? commentPrefix : "│ ";
390
+ const commentLine = `${prefix}${commentLines[ci]}\n`;
391
+ const commentLineLen = getByteLength(commentLine);
392
+ entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
393
+ highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
394
+ highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
395
+ currentByte += commentLineLen;
396
+ }
397
+ }
398
+
399
+ // Render comments for the added line (newLineNum - 1, since we already incremented)
400
+ const addedLineComments = state.comments.filter(c =>
401
+ c.hunk_id === hunk.id && c.line_type === 'add' && c.new_line === (newLineNum - 1)
402
+ );
403
+ for (const comment of addedLineComments) {
404
+ const commentPrefix = `│ » [+${comment.new_line}] `;
405
+ const commentLines = comment.text.split('\n');
406
+ for (let ci = 0; ci < commentLines.length; ci++) {
407
+ const prefix = ci === 0 ? commentPrefix : "│ ";
408
+ const commentLine = `${prefix}${commentLines[ci]}\n`;
409
+ const commentLineLen = getByteLength(commentLine);
410
+ entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
411
+ highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
412
+ highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
413
+ currentByte += commentLineLen;
414
+ }
415
+ }
416
+
417
+ i++;
418
+ } else {
419
+ entries.push({ text: lineText, properties: {
420
+ type: "content", hunkId: hunk.id, file: hunk.file,
421
+ lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line
422
+ } });
423
+ highlights.push({ range: [currentByte, currentByte + lineLen], fg: STYLE_BORDER });
424
+ if (hunk.status === 'pending') {
425
+ if (line.startsWith('+')) {
426
+ highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_ADD_TEXT, bold: true });
427
+ highlights.push({ range: [currentByte + prefixLen + 2, currentByte + lineLen], fg: STYLE_ADD_TEXT });
428
+ } else if (line.startsWith('-')) {
429
+ highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_REMOVE_TEXT, bold: true });
430
+ highlights.push({ range: [currentByte + prefixLen + 2, currentByte + lineLen], fg: STYLE_REMOVE_TEXT });
431
+ }
432
+ } else {
433
+ highlights.push({ range: [currentByte + prefixLen, currentByte + lineLen], fg: hunkColor });
434
+ }
435
+ currentByte += lineLen;
436
+
437
+ // Increment line counters based on line type
438
+ if (lineType === 'remove') oldLineNum++;
439
+ else if (lineType === 'add') newLineNum++;
440
+ else { oldLineNum++; newLineNum++; } // context
441
+
442
+ // Render any comments attached to this specific line
443
+ const lineComments = state.comments.filter(c =>
444
+ c.hunk_id === hunk.id && (
445
+ (lineType === 'remove' && c.old_line === curOldLine) ||
446
+ (lineType === 'add' && c.new_line === curNewLine) ||
447
+ (lineType === 'context' && (c.old_line === curOldLine || c.new_line === curNewLine))
448
+ )
449
+ );
450
+ for (const comment of lineComments) {
451
+ const lineRef = comment.line_type === 'add'
452
+ ? `+${comment.new_line}`
453
+ : comment.line_type === 'remove'
454
+ ? `-${comment.old_line}`
455
+ : `${comment.new_line}`;
456
+ const commentPrefix = `│ » [${lineRef}] `;
457
+ const commentLines = comment.text.split('\n');
458
+ for (let ci = 0; ci < commentLines.length; ci++) {
459
+ const prefix = ci === 0 ? commentPrefix : "│ ";
460
+ const commentLine = `${prefix}${commentLines[ci]}\n`;
461
+ const commentLineLen = getByteLength(commentLine);
462
+ entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
463
+ highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
464
+ highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
465
+ currentByte += commentLineLen;
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ // Render any comments without specific line info at the end of hunk
472
+ const orphanComments = state.comments.filter(c =>
473
+ c.hunk_id === hunk.id && !c.old_line && !c.new_line
474
+ );
475
+ if (orphanComments.length > 0) {
476
+ const commentBorder = "│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄\n";
477
+ const borderLen = getByteLength(commentBorder);
478
+ entries.push({ text: commentBorder, properties: { type: "comment-border" } });
479
+ highlights.push({ range: [currentByte, currentByte + borderLen], fg: STYLE_COMMENT_BORDER });
480
+ currentByte += borderLen;
481
+
482
+ for (const comment of orphanComments) {
483
+ const commentPrefix = "│ » ";
484
+ const commentLines = comment.text.split('\n');
485
+ for (let ci = 0; ci < commentLines.length; ci++) {
486
+ const prefix = ci === 0 ? commentPrefix : "│ ";
487
+ const commentLine = `${prefix}${commentLines[ci]}\n`;
488
+ const commentLineLen = getByteLength(commentLine);
489
+ entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
490
+ highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
491
+ highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
492
+ currentByte += commentLineLen;
493
+ }
494
+ }
495
+
496
+ entries.push({ text: commentBorder, properties: { type: "comment-border" } });
497
+ highlights.push({ range: [currentByte, currentByte + borderLen], fg: STYLE_COMMENT_BORDER });
498
+ currentByte += borderLen;
499
+ }
500
+
501
+ const isLastOfFile = hunkIndex === state.hunks.length - 1 || state.hunks[hunkIndex + 1].file !== hunk.file;
502
+ if (isLastOfFile) {
503
+ const bottomLine = `└${"─".repeat(64)}\n`;
504
+ const bottomLen = getByteLength(bottomLine);
505
+ entries.push({ text: bottomLine, properties: { type: "border" } });
506
+ highlights.push({ range: [currentByte, currentByte + bottomLen], fg: STYLE_BORDER });
507
+ currentByte += bottomLen;
508
+ }
509
+ }
510
+
511
+ if (entries.length === 0) {
512
+ entries.push({ text: "No changes to review.\n", properties: {} });
513
+ } else {
514
+ // Add help footer with keybindings
515
+ const helpSeparator = "\n" + "─".repeat(70) + "\n";
516
+ const helpLen1 = getByteLength(helpSeparator);
517
+ entries.push({ text: helpSeparator, properties: { type: "help" } });
518
+ highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_BORDER });
519
+ currentByte += helpLen1;
520
+
521
+ const helpLine1 = "REVIEW: [c]omment [a]pprove [x]reject [!]needs-changes [?]question [u]ndo\n";
522
+ const helpLen2 = getByteLength(helpLine1);
523
+ entries.push({ text: helpLine1, properties: { type: "help" } });
524
+ highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
525
+ currentByte += helpLen2;
526
+
527
+ const helpLine2 = "STAGE: [s]tage [d]iscard | NAV: [n]ext [p]rev [Enter]drill-down [q]uit\n";
528
+ const helpLen3 = getByteLength(helpLine2);
529
+ entries.push({ text: helpLine2, properties: { type: "help" } });
530
+ highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
531
+ currentByte += helpLen3;
532
+
533
+ const helpLine3 = "EXPORT: [E]xport to .review/session.md | [O]verall feedback [r]efresh\n";
534
+ const helpLen4 = getByteLength(helpLine3);
535
+ entries.push({ text: helpLine3, properties: { type: "help" } });
536
+ highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT });
537
+ currentByte += helpLen4;
538
+ }
539
+ return { entries, highlights };
540
+ }
541
+
542
+ /**
543
+ * Updates the buffer UI (text and highlights) based on current state.hunks
544
+ */
545
+ async function updateReviewUI() {
546
+ if (state.reviewBufferId !== null) {
547
+ const { entries, highlights } = await renderReviewStream();
548
+ editor.setVirtualBufferContent(state.reviewBufferId, entries);
549
+
550
+ editor.clearNamespace(state.reviewBufferId, "review-diff");
551
+ highlights.forEach((h) => {
552
+ const bg = h.bg || [-1, -1, -1];
553
+ // addOverlay signature: bufferId, namespace, start, end, r, g, b, underline, bold, italic, bg_r, bg_g, bg_b
554
+ editor.addOverlay(
555
+ state.reviewBufferId!,
556
+ "review-diff",
557
+ h.range[0],
558
+ h.range[1],
559
+ h.fg[0], h.fg[1], h.fg[2], // foreground color
560
+ false, // underline
561
+ h.bold || false, // bold
562
+ h.italic || false, // italic
563
+ bg[0], bg[1], bg[2] // background color
564
+ );
565
+ });
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Fetches latest diff data and refreshes the UI
571
+ */
572
+ async function refreshReviewData() {
573
+ if (isUpdating) return;
574
+ isUpdating = true;
575
+ editor.setStatus("Refreshing review diff...");
576
+ try {
577
+ const newHunks = await getGitDiff();
578
+ newHunks.forEach(h => h.status = state.hunkStatus[h.id] || 'pending');
579
+ state.hunks = newHunks;
580
+ await updateReviewUI();
581
+ editor.setStatus(`Review diff updated. Found ${state.hunks.length} hunks.`);
582
+ } catch (e) {
583
+ editor.debug(`ReviewDiff Error: ${e}`);
584
+ } finally {
585
+ isUpdating = false;
586
+ }
587
+ }
588
+
589
+ // --- Actions ---
590
+
591
+ globalThis.review_stage_hunk = async () => {
592
+ const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
593
+ if (props.length > 0 && props[0].hunkId) {
594
+ const id = props[0].hunkId as string;
595
+ state.hunkStatus[id] = 'staged';
596
+ const h = state.hunks.find(x => x.id === id);
597
+ if (h) h.status = 'staged';
598
+ await updateReviewUI();
599
+ }
600
+ };
601
+
602
+ globalThis.review_discard_hunk = async () => {
603
+ const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
604
+ if (props.length > 0 && props[0].hunkId) {
605
+ const id = props[0].hunkId as string;
606
+ state.hunkStatus[id] = 'discarded';
607
+ const h = state.hunks.find(x => x.id === id);
608
+ if (h) h.status = 'discarded';
609
+ await updateReviewUI();
610
+ }
611
+ };
612
+
613
+ globalThis.review_undo_action = async () => {
614
+ const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
615
+ if (props.length > 0 && props[0].hunkId) {
616
+ const id = props[0].hunkId as string;
617
+ state.hunkStatus[id] = 'pending';
618
+ const h = state.hunks.find(x => x.id === id);
619
+ if (h) h.status = 'pending';
620
+ await updateReviewUI();
621
+ }
622
+ };
623
+
624
+ globalThis.review_next_hunk = () => {
625
+ const bid = editor.getActiveBufferId();
626
+ const props = editor.getTextPropertiesAtCursor(bid);
627
+ let cur = -1;
628
+ if (props.length > 0 && props[0].index !== undefined) cur = props[0].index as number;
629
+ if (cur + 1 < state.hunks.length) editor.setBufferCursor(bid, state.hunks[cur + 1].byteOffset);
630
+ };
631
+
632
+ globalThis.review_prev_hunk = () => {
633
+ const bid = editor.getActiveBufferId();
634
+ const props = editor.getTextPropertiesAtCursor(bid);
635
+ let cur = state.hunks.length;
636
+ if (props.length > 0 && props[0].index !== undefined) cur = props[0].index as number;
637
+ if (cur - 1 >= 0) editor.setBufferCursor(bid, state.hunks[cur - 1].byteOffset);
638
+ };
639
+
640
+ globalThis.review_refresh = () => { refreshReviewData(); };
641
+
642
+ let activeDiffViewState: { lSplit: number, rSplit: number } | null = null;
643
+
644
+ globalThis.on_viewport_changed = (data: any) => {
645
+ if (!activeDiffViewState) return;
646
+ if (data.split_id === activeDiffViewState.lSplit) (editor as any).setSplitScroll(activeDiffViewState.rSplit, data.top_byte);
647
+ else if (data.split_id === activeDiffViewState.rSplit) (editor as any).setSplitScroll(activeDiffViewState.lSplit, data.top_byte);
648
+ };
649
+
650
+ globalThis.review_drill_down = async () => {
651
+ const bid = editor.getActiveBufferId();
652
+ const props = editor.getTextPropertiesAtCursor(bid);
653
+ if (props.length > 0 && props[0].hunkId) {
654
+ const id = props[0].hunkId as string;
655
+ const h = state.hunks.find(x => x.id === id);
656
+ if (!h) return;
657
+ const gitShow = await editor.spawnProcess("git", ["show", `HEAD:${h.file}`]);
658
+ if (gitShow.exit_code !== 0) return;
659
+
660
+ // Side-by-side layout: NEW (editable, left) | OLD (read-only, right)
661
+ // Note: Ideally OLD should be on left per convention, but API creates splits to the right
662
+
663
+ // Step 1: Open NEW file in current split (becomes LEFT pane)
664
+ editor.openFile(h.file, h.range.start, 0);
665
+ const newSplitId = (editor as any).getActiveSplitId();
666
+
667
+ // Step 2: Create OLD (HEAD) version in new split (becomes RIGHT pane)
668
+ // editing_disabled: true prevents text input in read-only buffer
669
+ const oldRes = await editor.createVirtualBufferInSplit({
670
+ name: `[OLD ◀] ${h.file}`, // Arrow indicates this is the old/reference version
671
+ mode: "special",
672
+ read_only: true,
673
+ editing_disabled: true,
674
+ entries: [{ text: gitShow.stdout, properties: {} }],
675
+ ratio: 0.5,
676
+ direction: "vertical",
677
+ show_line_numbers: true
678
+ });
679
+ const oldSplitId = oldRes.split_id!;
680
+
681
+ // Focus on NEW (left) pane - this is the editable working version
682
+ (editor as any).focusSplit(newSplitId);
683
+
684
+ // Track splits for synchronized scrolling
685
+ activeDiffViewState = { lSplit: newSplitId, rSplit: oldSplitId };
686
+ editor.on("viewport_changed", "on_viewport_changed");
687
+ }
688
+ };
689
+
690
+ // --- Review Comment Actions ---
691
+
692
+ function getCurrentHunkId(): string | null {
693
+ const bid = editor.getActiveBufferId();
694
+ const props = editor.getTextPropertiesAtCursor(bid);
695
+ if (props.length > 0 && props[0].hunkId) return props[0].hunkId as string;
696
+ return null;
697
+ }
698
+
699
+ interface PendingCommentInfo {
700
+ hunkId: string;
701
+ file: string;
702
+ lineType?: 'add' | 'remove' | 'context';
703
+ oldLine?: number;
704
+ newLine?: number;
705
+ lineContent?: string;
706
+ }
707
+
708
+ function getCurrentLineInfo(): PendingCommentInfo | null {
709
+ const bid = editor.getActiveBufferId();
710
+ const props = editor.getTextPropertiesAtCursor(bid);
711
+ if (props.length > 0 && props[0].hunkId) {
712
+ const hunk = state.hunks.find(h => h.id === props[0].hunkId);
713
+ return {
714
+ hunkId: props[0].hunkId as string,
715
+ file: (props[0].file as string) || hunk?.file || '',
716
+ lineType: props[0].lineType as 'add' | 'remove' | 'context' | undefined,
717
+ oldLine: props[0].oldLine as number | undefined,
718
+ newLine: props[0].newLine as number | undefined,
719
+ lineContent: props[0].lineContent as string | undefined
720
+ };
721
+ }
722
+ return null;
723
+ }
724
+
725
+ // Pending prompt state for event-based prompt handling
726
+ let pendingCommentInfo: PendingCommentInfo | null = null;
727
+
728
+ globalThis.review_add_comment = async () => {
729
+ const info = getCurrentLineInfo();
730
+ if (!info) {
731
+ editor.setStatus("No hunk selected for comment");
732
+ return;
733
+ }
734
+ pendingCommentInfo = info;
735
+
736
+ // Show line context in prompt (if on a specific line)
737
+ let lineRef = 'hunk';
738
+ if (info.lineType === 'add' && info.newLine) {
739
+ lineRef = `+${info.newLine}`;
740
+ } else if (info.lineType === 'remove' && info.oldLine) {
741
+ lineRef = `-${info.oldLine}`;
742
+ } else if (info.newLine) {
743
+ lineRef = `L${info.newLine}`;
744
+ } else if (info.oldLine) {
745
+ lineRef = `L${info.oldLine}`;
746
+ }
747
+ editor.startPrompt(`Comment on ${lineRef}: `, "review-comment");
748
+ };
749
+
750
+ // Prompt event handlers
751
+ globalThis.on_review_prompt_confirm = (args: { prompt_type: string; input: string }): boolean => {
752
+ if (args.prompt_type !== "review-comment") {
753
+ return true; // Not our prompt
754
+ }
755
+ if (pendingCommentInfo && args.input && args.input.trim()) {
756
+ const comment: ReviewComment = {
757
+ id: `comment-${Date.now()}`,
758
+ hunk_id: pendingCommentInfo.hunkId,
759
+ file: pendingCommentInfo.file,
760
+ text: args.input.trim(),
761
+ timestamp: new Date().toISOString(),
762
+ old_line: pendingCommentInfo.oldLine,
763
+ new_line: pendingCommentInfo.newLine,
764
+ line_content: pendingCommentInfo.lineContent,
765
+ line_type: pendingCommentInfo.lineType
766
+ };
767
+ state.comments.push(comment);
768
+ updateReviewUI();
769
+ let lineRef = 'hunk';
770
+ if (comment.line_type === 'add' && comment.new_line) {
771
+ lineRef = `line +${comment.new_line}`;
772
+ } else if (comment.line_type === 'remove' && comment.old_line) {
773
+ lineRef = `line -${comment.old_line}`;
774
+ } else if (comment.new_line) {
775
+ lineRef = `line ${comment.new_line}`;
776
+ } else if (comment.old_line) {
777
+ lineRef = `line ${comment.old_line}`;
778
+ }
779
+ editor.setStatus(`Comment added to ${lineRef}`);
780
+ }
781
+ pendingCommentInfo = null;
782
+ return true;
783
+ };
784
+
785
+ globalThis.on_review_prompt_cancel = (args: { prompt_type: string }): boolean => {
786
+ if (args.prompt_type === "review-comment") {
787
+ pendingCommentInfo = null;
788
+ editor.setStatus("Comment cancelled");
789
+ }
790
+ return true;
791
+ };
792
+
793
+ // Register prompt event handlers
794
+ editor.on("prompt_confirmed", "on_review_prompt_confirm");
795
+ editor.on("prompt_cancelled", "on_review_prompt_cancel");
796
+
797
+ globalThis.review_approve_hunk = async () => {
798
+ const hunkId = getCurrentHunkId();
799
+ if (!hunkId) return;
800
+ const h = state.hunks.find(x => x.id === hunkId);
801
+ if (h) {
802
+ h.reviewStatus = 'approved';
803
+ await updateReviewUI();
804
+ editor.setStatus(`Hunk approved`);
805
+ }
806
+ };
807
+
808
+ globalThis.review_reject_hunk = async () => {
809
+ const hunkId = getCurrentHunkId();
810
+ if (!hunkId) return;
811
+ const h = state.hunks.find(x => x.id === hunkId);
812
+ if (h) {
813
+ h.reviewStatus = 'rejected';
814
+ await updateReviewUI();
815
+ editor.setStatus(`Hunk rejected`);
816
+ }
817
+ };
818
+
819
+ globalThis.review_needs_changes = async () => {
820
+ const hunkId = getCurrentHunkId();
821
+ if (!hunkId) return;
822
+ const h = state.hunks.find(x => x.id === hunkId);
823
+ if (h) {
824
+ h.reviewStatus = 'needs_changes';
825
+ await updateReviewUI();
826
+ editor.setStatus(`Hunk marked as needs changes`);
827
+ }
828
+ };
829
+
830
+ globalThis.review_question_hunk = async () => {
831
+ const hunkId = getCurrentHunkId();
832
+ if (!hunkId) return;
833
+ const h = state.hunks.find(x => x.id === hunkId);
834
+ if (h) {
835
+ h.reviewStatus = 'question';
836
+ await updateReviewUI();
837
+ editor.setStatus(`Hunk marked with question`);
838
+ }
839
+ };
840
+
841
+ globalThis.review_clear_status = async () => {
842
+ const hunkId = getCurrentHunkId();
843
+ if (!hunkId) return;
844
+ const h = state.hunks.find(x => x.id === hunkId);
845
+ if (h) {
846
+ h.reviewStatus = 'pending';
847
+ await updateReviewUI();
848
+ editor.setStatus(`Hunk review status cleared`);
849
+ }
850
+ };
851
+
852
+ globalThis.review_set_overall_feedback = async () => {
853
+ const text = await editor.prompt("Overall feedback: ", state.overallFeedback || "");
854
+ if (text !== null) {
855
+ state.overallFeedback = text.trim();
856
+ editor.setStatus(`Overall feedback ${text.trim() ? 'set' : 'cleared'}`);
857
+ }
858
+ };
859
+
860
+ globalThis.review_export_session = async () => {
861
+ const cwd = editor.getCwd();
862
+ const reviewDir = editor.pathJoin(cwd, ".review");
863
+
864
+ // Create .review directory if needed
865
+ await editor.spawnProcess("mkdir", ["-p", reviewDir]);
866
+
867
+ // Generate markdown content
868
+ let md = `# Code Review Session\n`;
869
+ md += `Date: ${new Date().toISOString()}\n\n`;
870
+
871
+ if (state.originalRequest) {
872
+ md += `## Original Request\n${state.originalRequest}\n\n`;
873
+ }
874
+
875
+ if (state.overallFeedback) {
876
+ md += `## Overall Feedback\n${state.overallFeedback}\n\n`;
877
+ }
878
+
879
+ // Stats
880
+ const approved = state.hunks.filter(h => h.reviewStatus === 'approved').length;
881
+ const rejected = state.hunks.filter(h => h.reviewStatus === 'rejected').length;
882
+ const needsChanges = state.hunks.filter(h => h.reviewStatus === 'needs_changes').length;
883
+ const questions = state.hunks.filter(h => h.reviewStatus === 'question').length;
884
+ md += `## Summary\n`;
885
+ md += `- Total hunks: ${state.hunks.length}\n`;
886
+ md += `- Approved: ${approved}\n`;
887
+ md += `- Rejected: ${rejected}\n`;
888
+ md += `- Needs changes: ${needsChanges}\n`;
889
+ md += `- Questions: ${questions}\n\n`;
890
+
891
+ // Group by file
892
+ const fileGroups: Record<string, Hunk[]> = {};
893
+ for (const hunk of state.hunks) {
894
+ if (!fileGroups[hunk.file]) fileGroups[hunk.file] = [];
895
+ fileGroups[hunk.file].push(hunk);
896
+ }
897
+
898
+ for (const [file, hunks] of Object.entries(fileGroups)) {
899
+ md += `## File: ${file}\n\n`;
900
+ for (const hunk of hunks) {
901
+ const statusStr = hunk.reviewStatus.toUpperCase();
902
+ md += `### ${hunk.contextHeader || 'Hunk'} (line ${hunk.range.start})\n`;
903
+ md += `**Status**: ${statusStr}\n\n`;
904
+
905
+ const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
906
+ if (hunkComments.length > 0) {
907
+ md += `**Comments:**\n`;
908
+ for (const c of hunkComments) {
909
+ // Format line reference
910
+ let lineRef = '';
911
+ if (c.line_type === 'add' && c.new_line) {
912
+ lineRef = `[+${c.new_line}]`;
913
+ } else if (c.line_type === 'remove' && c.old_line) {
914
+ lineRef = `[-${c.old_line}]`;
915
+ } else if (c.new_line) {
916
+ lineRef = `[L${c.new_line}]`;
917
+ } else if (c.old_line) {
918
+ lineRef = `[L${c.old_line}]`;
919
+ }
920
+ md += `> 💬 ${lineRef} ${c.text}\n`;
921
+ if (c.line_content) {
922
+ md += `> \`${c.line_content.trim()}\`\n`;
923
+ }
924
+ md += `\n`;
925
+ }
926
+ }
927
+ }
928
+ }
929
+
930
+ // Write file
931
+ const filePath = editor.pathJoin(reviewDir, "session.md");
932
+ await editor.writeFile(filePath, md);
933
+ editor.setStatus(`Review exported to ${filePath}`);
934
+ };
935
+
936
+ globalThis.review_export_json = async () => {
937
+ const cwd = editor.getCwd();
938
+ const reviewDir = editor.pathJoin(cwd, ".review");
939
+ await editor.spawnProcess("mkdir", ["-p", reviewDir]);
940
+
941
+ const session = {
942
+ version: "1.0",
943
+ timestamp: new Date().toISOString(),
944
+ original_request: state.originalRequest || null,
945
+ overall_feedback: state.overallFeedback || null,
946
+ files: {} as Record<string, any>
947
+ };
948
+
949
+ for (const hunk of state.hunks) {
950
+ if (!session.files[hunk.file]) session.files[hunk.file] = { hunks: [] };
951
+ const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
952
+ session.files[hunk.file].hunks.push({
953
+ context: hunk.contextHeader,
954
+ old_lines: [hunk.oldRange.start, hunk.oldRange.end],
955
+ new_lines: [hunk.range.start, hunk.range.end],
956
+ status: hunk.reviewStatus,
957
+ comments: hunkComments.map(c => ({
958
+ text: c.text,
959
+ line_type: c.line_type || null,
960
+ old_line: c.old_line || null,
961
+ new_line: c.new_line || null,
962
+ line_content: c.line_content || null
963
+ }))
964
+ });
965
+ }
966
+
967
+ const filePath = editor.pathJoin(reviewDir, "session.json");
968
+ await editor.writeFile(filePath, JSON.stringify(session, null, 2));
969
+ editor.setStatus(`Review exported to ${filePath}`);
970
+ };
971
+
972
+ globalThis.start_review_diff = async () => {
973
+ editor.setStatus("Generating Review Diff Stream...");
974
+ editor.setContext("review-mode", true);
975
+
976
+ // Initial data fetch
977
+ const newHunks = await getGitDiff();
978
+ state.hunks = newHunks;
979
+ state.comments = []; // Reset comments for new session
980
+
981
+ const bufferId = await VirtualBufferFactory.create({
982
+ name: "*Review Diff*", mode: "review-mode", read_only: true,
983
+ entries: (await renderReviewStream()).entries, showLineNumbers: false
984
+ });
985
+ state.reviewBufferId = bufferId;
986
+ await updateReviewUI(); // Apply initial highlights
987
+
988
+ editor.setStatus(`Review Diff: ${state.hunks.length} hunks | [c]omment [a]pprove [x]reject [!]changes [?]question [E]xport`);
989
+ editor.on("buffer_activated", "on_review_buffer_activated");
990
+ editor.on("buffer_closed", "on_review_buffer_closed");
991
+ };
992
+
993
+ globalThis.stop_review_diff = () => {
994
+ state.reviewBufferId = null;
995
+ editor.setContext("review-mode", false);
996
+ editor.off("buffer_activated", "on_review_buffer_activated");
997
+ editor.off("buffer_closed", "on_review_buffer_closed");
998
+ editor.setStatus("Review Diff Mode stopped.");
999
+ };
1000
+
1001
+ globalThis.on_review_buffer_activated = (data: any) => {
1002
+ if (data.buffer_id === state.reviewBufferId) refreshReviewData();
1003
+ };
1004
+
1005
+ globalThis.on_review_buffer_closed = (data: any) => {
1006
+ if (data.buffer_id === state.reviewBufferId) stop_review_diff();
1007
+ };
1008
+
1009
+ // Register Modes and Commands
1010
+ editor.registerCommand("Review Diff", "Start code review session", "start_review_diff", "global");
1011
+ editor.registerCommand("Stop Review Diff", "Stop the review session", "stop_review_diff", "review-mode");
1012
+ editor.registerCommand("Refresh Review Diff", "Refresh the list of changes", "review_refresh", "review-mode");
1013
+
1014
+ // Review Comment Commands
1015
+ editor.registerCommand("Review: Add Comment", "Add a review comment to the current hunk", "review_add_comment", "review-mode");
1016
+ editor.registerCommand("Review: Approve Hunk", "Mark hunk as approved", "review_approve_hunk", "review-mode");
1017
+ editor.registerCommand("Review: Reject Hunk", "Mark hunk as rejected", "review_reject_hunk", "review-mode");
1018
+ editor.registerCommand("Review: Needs Changes", "Mark hunk as needing changes", "review_needs_changes", "review-mode");
1019
+ editor.registerCommand("Review: Question", "Mark hunk with a question", "review_question_hunk", "review-mode");
1020
+ editor.registerCommand("Review: Clear Status", "Clear hunk review status", "review_clear_status", "review-mode");
1021
+ editor.registerCommand("Review: Overall Feedback", "Set overall review feedback", "review_set_overall_feedback", "review-mode");
1022
+ editor.registerCommand("Review: Export to Markdown", "Export review to .review/session.md", "review_export_session", "review-mode");
1023
+ editor.registerCommand("Review: Export to JSON", "Export review to .review/session.json", "review_export_json", "review-mode");
1024
+
1025
+ editor.on("buffer_closed", "on_buffer_closed");
1026
+
1027
+ editor.defineMode("review-mode", "normal", [
1028
+ // Staging actions
1029
+ ["s", "review_stage_hunk"], ["d", "review_discard_hunk"],
1030
+ // Navigation
1031
+ ["n", "review_next_hunk"], ["p", "review_prev_hunk"], ["r", "review_refresh"],
1032
+ ["Enter", "review_drill_down"], ["q", "close_buffer"],
1033
+ // Review actions
1034
+ ["c", "review_add_comment"],
1035
+ ["a", "review_approve_hunk"],
1036
+ ["x", "review_reject_hunk"],
1037
+ ["!", "review_needs_changes"],
1038
+ ["?", "review_question_hunk"],
1039
+ ["u", "review_clear_status"],
1040
+ ["O", "review_set_overall_feedback"],
1041
+ // Export
1042
+ ["E", "review_export_session"],
1043
+ ], true);
1044
+
1045
+ editor.debug("Review Diff plugin loaded with review comments support");