pi-diff-review 0.1.12 → 0.1.14

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.
@@ -9,60 +9,71 @@ import {
9
9
  visibleWidth,
10
10
  wrapTextWithAnsi,
11
11
  } from "@earendil-works/pi-tui";
12
- import type {
13
- DiffExplainer,
14
- ExplanationScope,
15
- ExplanationState,
16
- } from "./explain.ts";
17
- import { formatLocation } from "./prompt.ts";
12
+ import type { DiffExplainer } from "./explain.ts";
13
+ import {
14
+ GLOBAL_COMMENT_KEY,
15
+ buildCommentFromSelection,
16
+ buildCommentLineKeys,
17
+ buildGlobalComment,
18
+ getSelectionKey,
19
+ } from "./comment-manager.ts";
20
+ import {
21
+ ExplanationController,
22
+ getCurrentHunkScope,
23
+ } from "./explanation-controller.ts";
24
+ import { formatCommentLocation, formatLocation } from "./prompt.ts";
25
+ import { ReviewNavigationState } from "./review-navigation.ts";
26
+ import { padToWidth, lineNumberCell } from "./render-utils.ts";
27
+ import { buildSplitDiffRows } from "./split-diff.ts";
18
28
  import type {
19
29
  DiffRenderMode,
20
30
  ReviewComment,
21
- ReviewLayout,
22
31
  ReviewLine,
23
32
  ReviewResult,
24
33
  ReviewTheme,
25
34
  ReviewTui,
26
- RightPaneMode,
27
35
  SelectionBounds,
28
36
  SplitDiffCell,
29
37
  SplitDiffRow,
30
38
  } from "./types.ts";
31
39
 
32
- function padToWidth(text: string, width: number): string {
33
- const visible = visibleWidth(text);
34
- if (visible >= width) return truncateToWidth(text, width);
35
- return text + " ".repeat(width - visible);
36
- }
40
+ type InlineBoxPart = "top" | "body" | "bottom";
41
+ type InlineBoxRowKind = "comment" | "editor" | "explanation";
42
+ type InlineBoxRow = {
43
+ kind: InlineBoxRowKind;
44
+ text: string;
45
+ part: InlineBoxPart;
46
+ };
37
47
 
38
- function lineNumberCell(value?: number): string {
39
- return value == null ? " " : String(value).padStart(4, " ");
40
- }
41
-
42
- const GLOBAL_COMMENT_KEY = "__global_diff_comment__";
48
+ type AnnotatedDiffRow =
49
+ | { kind: "diff"; lineIndex: number }
50
+ | { kind: "split"; splitRowIndex: number }
51
+ | InlineBoxRow;
43
52
 
44
53
  export class ReviewComponent {
45
- private selected = 0;
46
- private scrollTop = 0;
54
+ private navigation: ReviewNavigationState;
47
55
  private editMode = false;
48
56
  private editingCommentKey?: string;
49
- private selectionAnchor?: number;
50
- private layout: ReviewLayout = "side-by-side";
51
- private diffRenderMode: DiffRenderMode = "unified";
52
- private rightPaneMode: RightPaneMode = "comments";
53
- private explanations = new Map<string, ExplanationState>();
54
- private explanationAbort?: AbortController;
55
- private explanationRequestId = 0;
56
- private loadingFrame = 0;
57
- private loadingTimer?: ReturnType<typeof setInterval>;
57
+
58
+ private inlineAnnotationsVisible = true;
59
+ private visibleExplanationKeys = new Set<string>();
60
+ private explanationController: ExplanationController;
58
61
  private editor: Editor;
59
62
  private splitRows?: SplitDiffRow[];
60
- private splitRowByLineIndex?: number[];
61
63
  private lineIndexById = new Map<string, number>();
62
64
  private commentLineKeys = new Map<number, string[]>();
63
65
  private commentsRevision = 0;
64
66
  private commentLineKeysRevision = -1;
65
67
  private highlightedLineCache = new Map<string, string>();
68
+ private annotatedRows?: AnnotatedDiffRow[];
69
+ private annotatedRowsWidth = 0;
70
+ private annotatedRowsRevision = -1;
71
+ private annotatedRowsEditMode = false;
72
+ private annotatedRowsEditingCommentKey?: string;
73
+ private annotatedRowsMode?: DiffRenderMode;
74
+ private annotatedRowsInlineAnnotationsVisible = true;
75
+ private annotatedRowsVisibleExplanationCount = 0;
76
+ private annotatedRowByLineIndex?: number[];
66
77
 
67
78
  constructor(
68
79
  private tui: ReviewTui,
@@ -73,10 +84,21 @@ export class ReviewComponent {
73
84
  private done: (result: ReviewResult) => void,
74
85
  private explainer?: DiffExplainer,
75
86
  private onCommentsChanged?: (comments: Map<string, ReviewComment>) => void,
87
+ cachedExplanations?: Map<string, string>,
88
+ private onExplanationsChanged?: (explanations: Map<string, string>) => void,
76
89
  ) {
77
90
  const firstCommentable = this.lines.findIndex((line) => line.commentable);
78
- this.selected = firstCommentable >= 0 ? firstCommentable : 0;
91
+ this.navigation = new ReviewNavigationState(
92
+ this.lines.length,
93
+ firstCommentable >= 0 ? firstCommentable : 0,
94
+ );
79
95
  this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
96
+ this.explanationController = new ExplanationController(
97
+ tui,
98
+ explainer,
99
+ cachedExplanations,
100
+ onExplanationsChanged,
101
+ );
80
102
 
81
103
  this.editor = new Editor(tui as never, {
82
104
  borderColor: (s) => theme.fg("accent", s),
@@ -96,10 +118,7 @@ export class ReviewComponent {
96
118
  if (this.comments.delete(GLOBAL_COMMENT_KEY))
97
119
  this.markCommentsChanged();
98
120
  } else {
99
- this.comments.set(
100
- GLOBAL_COMMENT_KEY,
101
- this.buildGlobalComment(trimmed),
102
- );
121
+ this.comments.set(GLOBAL_COMMENT_KEY, buildGlobalComment(trimmed));
103
122
  this.markCommentsChanged();
104
123
  }
105
124
  this.exitEditMode();
@@ -118,15 +137,36 @@ export class ReviewComponent {
118
137
  } else {
119
138
  this.comments.set(
120
139
  key,
121
- this.buildCommentFromSelection(selection, trimmed),
140
+ buildCommentFromSelection(this.lines, selection, trimmed),
122
141
  );
123
142
  this.markCommentsChanged();
124
143
  }
125
144
 
145
+ this.navigation.clearSelection();
126
146
  this.exitEditMode();
127
147
  };
128
148
  }
129
149
 
150
+ private get selected(): number {
151
+ return this.navigation.selected;
152
+ }
153
+
154
+ private set selected(value: number) {
155
+ this.navigation.selected = value;
156
+ }
157
+
158
+ private get scrollTop(): number {
159
+ return this.navigation.scrollTop;
160
+ }
161
+
162
+ private set scrollTop(value: number) {
163
+ this.navigation.scrollTop = value;
164
+ }
165
+
166
+ private get diffRenderMode(): DiffRenderMode {
167
+ return this.navigation.diffRenderMode;
168
+ }
169
+
130
170
  handleInput(data: string): void {
131
171
  if (this.editMode) {
132
172
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
@@ -134,6 +174,7 @@ export class ReviewComponent {
134
174
  return;
135
175
  }
136
176
  this.editor.handleInput(data);
177
+ this.invalidateAnnotatedRows();
137
178
  this.tui.requestRender();
138
179
  return;
139
180
  }
@@ -151,6 +192,10 @@ export class ReviewComponent {
151
192
  return;
152
193
  }
153
194
  if (data === "t") {
195
+ this.toggleInlineAnnotations();
196
+ return;
197
+ }
198
+ if (data === "v") {
154
199
  this.toggleDiffRenderMode();
155
200
  return;
156
201
  }
@@ -229,27 +274,17 @@ export class ReviewComponent {
229
274
  this.theme.fg(
230
275
  "dim",
231
276
  this.editMode
232
- ? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
277
+ ? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • editing inline comment • Enter save • Esc/Ctrl+C cancel`
233
278
  : this.hasSelection()
234
- ? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • C overall comment • Enter submit`
235
- : `${this.lines.length} lines • ${this.comments.size} comments • ${this.getPositionText(selectedLine)} • j/k move • g/G top/bottom • ctrl-u/d page • t unified/split • ? explain • J/K extend • c comment • C overall • x delete • n/p hunk • Enter submit • q quit`,
279
+ ? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • C overall comment • Enter submit`
280
+ : `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • ${this.getPositionText(selectedLine)} • inline ${this.inlineAnnotationsVisible ? "shown" : "hidden"} • j/k move • g/G top/bottom • ctrl-u/d page • t annotations • v unified/split • ? explain • J/K extend • c comment • C overall • x delete • n/p hunk • Enter submit • q quit`,
236
281
  ),
237
282
  width,
238
283
  ),
239
284
  );
240
- if (this.layout === "side-by-side") {
241
- this.ensureScroll(viewportHeight);
242
- output.push(
243
- ...this.renderSideBySide(width, viewportHeight, selectedLine),
244
- );
245
- } else {
246
- const { diffHeight, commentsHeight } =
247
- this.getStackedHeights(viewportHeight);
248
- this.ensureScroll(diffHeight);
249
- output.push(
250
- ...this.renderStacked(width, diffHeight, commentsHeight, selectedLine),
251
- );
252
- }
285
+
286
+ this.ensureScroll(viewportHeight, width);
287
+ output.push(...this.renderAnnotatedDiffRows(width, viewportHeight));
253
288
 
254
289
  output.push(
255
290
  truncateToWidth(
@@ -260,232 +295,495 @@ export class ReviewComponent {
260
295
  return output;
261
296
  }
262
297
 
263
- private renderSideBySide(
264
- width: number,
265
- height: number,
266
- selectedLine?: ReviewLine,
267
- ): string[] {
268
- const rightWidth = Math.max(28, Math.floor(width * 0.34));
269
- const separatorWidth = 3;
270
- const leftWidth = Math.max(30, width - rightWidth - separatorWidth);
271
- const rightPane = this.renderRightPane(rightWidth, height, selectedLine);
298
+ private renderAnnotatedDiffRows(width: number, height: number): string[] {
299
+ const rows = this.getAnnotatedRows(width);
272
300
  const output: string[] = [];
273
- const diffPane = this.renderDiffRows(leftWidth, height);
274
301
 
275
302
  for (let row = 0; row < height; row++) {
276
- const left = diffPane[row] ?? " ".repeat(leftWidth);
277
- const right = rightPane[row] ?? " ".repeat(rightWidth);
278
- const combined = `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`;
279
- output.push(truncateToWidth(combined, width));
303
+ const annotated = rows[this.scrollTop + row];
304
+ if (!annotated) {
305
+ output.push(" ".repeat(width));
306
+ continue;
307
+ }
308
+
309
+ if (annotated.kind === "diff") {
310
+ const index = annotated.lineIndex;
311
+ const line = this.lines[index]!;
312
+ output.push(
313
+ this.renderDiffLine(
314
+ line,
315
+ index,
316
+ width,
317
+ index === this.selected,
318
+ this.getSelectionBounds(),
319
+ ),
320
+ );
321
+ continue;
322
+ }
323
+
324
+ if (annotated.kind === "split") {
325
+ output.push(this.renderSplitDiffRowAt(annotated.splitRowIndex, width));
326
+ continue;
327
+ }
328
+
329
+ output.push(this.renderInlineAnnotationRow(annotated, width));
280
330
  }
281
331
 
282
332
  return output;
283
333
  }
284
334
 
285
- private renderStacked(
335
+ private getAnnotatedRows(width: number): AnnotatedDiffRow[] {
336
+ if (
337
+ this.annotatedRows &&
338
+ this.annotatedRowsWidth === width &&
339
+ this.annotatedRowsRevision === this.commentsRevision &&
340
+ this.annotatedRowsEditMode === this.editMode &&
341
+ this.annotatedRowsEditingCommentKey === this.editingCommentKey &&
342
+ this.annotatedRowsMode === this.diffRenderMode &&
343
+ this.annotatedRowsInlineAnnotationsVisible ===
344
+ this.inlineAnnotationsVisible &&
345
+ this.annotatedRowsVisibleExplanationCount ===
346
+ this.visibleExplanationKeys.size &&
347
+ this.visibleExplanationKeys.size === 0
348
+ ) {
349
+ return this.annotatedRows;
350
+ }
351
+
352
+ const rows: AnnotatedDiffRow[] = [];
353
+ const rowByLineIndex: number[] = [];
354
+
355
+ this.pushGlobalAnnotationRows(rows, width);
356
+
357
+ if (this.diffRenderMode === "split") {
358
+ this.pushAnnotatedSplitRows(rows, rowByLineIndex, width);
359
+ } else {
360
+ this.pushAnnotatedUnifiedRows(rows, rowByLineIndex, width);
361
+ }
362
+
363
+ this.annotatedRows = rows;
364
+ this.annotatedRowsWidth = width;
365
+ this.annotatedRowsRevision = this.commentsRevision;
366
+ this.annotatedRowsEditMode = this.editMode;
367
+ this.annotatedRowsEditingCommentKey = this.editingCommentKey;
368
+ this.annotatedRowsMode = this.diffRenderMode;
369
+ this.annotatedRowsInlineAnnotationsVisible = this.inlineAnnotationsVisible;
370
+ this.annotatedRowsVisibleExplanationCount =
371
+ this.visibleExplanationKeys.size;
372
+ this.annotatedRowByLineIndex = rowByLineIndex;
373
+ return rows;
374
+ }
375
+
376
+ private pushAnnotatedUnifiedRows(
377
+ rows: AnnotatedDiffRow[],
378
+ rowByLineIndex: number[],
286
379
  width: number,
287
- diffHeight: number,
288
- commentsHeight: number,
289
- selectedLine?: ReviewLine,
290
- ): string[] {
291
- const comments = this.renderRightPane(width, commentsHeight, selectedLine);
292
- return [
293
- ...this.renderDiffRows(width, diffHeight),
294
- this.theme.fg("borderMuted", "─".repeat(width)),
295
- ...Array.from({ length: commentsHeight }, (_, index) =>
296
- padToWidth(truncateToWidth(comments[index] ?? "", width), width),
297
- ),
298
- ];
380
+ ): void {
381
+ for (let index = 0; index < this.lines.length; index++) {
382
+ rowByLineIndex[index] = rows.length;
383
+ rows.push({ kind: "diff", lineIndex: index });
384
+
385
+ if (!this.inlineAnnotationsVisible) continue;
386
+
387
+ this.pushInlineCommentRows(rows, index, width);
388
+ this.pushInlineEditorRows(rows, index, width);
389
+ this.pushInlineExplanationRows(rows, index, width);
390
+ }
299
391
  }
300
392
 
301
- private renderDiffRows(width: number, height: number): string[] {
302
- return this.diffRenderMode === "split"
303
- ? this.renderSplitDiffRows(width, height)
304
- : this.renderUnifiedDiffRows(width, height);
393
+ private pushAnnotatedSplitRows(
394
+ rows: AnnotatedDiffRow[],
395
+ rowByLineIndex: number[],
396
+ width: number,
397
+ ): void {
398
+ const splitRows = this.getSplitDiffRows();
399
+ for (
400
+ let splitRowIndex = 0;
401
+ splitRowIndex < splitRows.length;
402
+ splitRowIndex++
403
+ ) {
404
+ const splitRow = splitRows[splitRowIndex]!;
405
+ const lineIndexes = this.getLineIndexesForSplitRow(splitRow);
406
+ for (const lineIndex of lineIndexes) {
407
+ rowByLineIndex[lineIndex] = rows.length;
408
+ }
409
+
410
+ rows.push({ kind: "split", splitRowIndex });
411
+ if (!this.inlineAnnotationsVisible) continue;
412
+
413
+ for (const lineIndex of lineIndexes) {
414
+ this.pushInlineCommentRows(rows, lineIndex, width);
415
+ this.pushInlineEditorRows(rows, lineIndex, width);
416
+ this.pushInlineExplanationRows(rows, lineIndex, width);
417
+ }
418
+ }
305
419
  }
306
420
 
307
- private renderUnifiedDiffRows(width: number, height: number): string[] {
308
- const output: string[] = [];
309
- const selection = this.getSelectionBounds();
421
+ private getLineIndexesForSplitRow(row: SplitDiffRow): number[] {
422
+ if (row.kind === "full") return [row.cell.index];
423
+ const indexes: number[] = [];
424
+ if (row.left) indexes.push(row.left.index);
425
+ if (row.right && row.right.index !== row.left?.index) {
426
+ indexes.push(row.right.index);
427
+ }
428
+ return indexes;
429
+ }
310
430
 
311
- for (let row = 0; row < height; row++) {
312
- const index = this.scrollTop + row;
313
- const line = this.lines[index];
314
- output.push(
315
- line
316
- ? this.renderDiffLine(
317
- line,
318
- index,
319
- width,
320
- index === this.selected,
321
- selection,
322
- )
323
- : " ".repeat(width),
431
+ private pushGlobalAnnotationRows(
432
+ rows: AnnotatedDiffRow[],
433
+ width: number,
434
+ ): void {
435
+ if (!this.inlineAnnotationsVisible) return;
436
+
437
+ const globalComment = this.comments.get(GLOBAL_COMMENT_KEY);
438
+ if (globalComment) {
439
+ this.pushAnnotationBlock(
440
+ rows,
441
+ "comment",
442
+ globalComment.text,
443
+ width,
444
+ "Overall diff comment",
324
445
  );
325
446
  }
326
447
 
327
- return output;
448
+ if (this.editMode && this.editingCommentKey === GLOBAL_COMMENT_KEY) {
449
+ this.pushInlineEditorBlock(rows, "Draft overall diff note", width);
450
+ }
328
451
  }
329
452
 
330
- private renderSplitDiffRows(width: number, height: number): string[] {
331
- const rows = this.getSplitDiffRows();
332
- const output: string[] = [];
333
- const separatorWidth = 3;
334
- const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
335
- const rightWidth = Math.max(10, width - leftWidth - separatorWidth);
453
+ private pushInlineCommentRows(
454
+ rows: AnnotatedDiffRow[],
455
+ lineIndex: number,
456
+ width: number,
457
+ ): void {
458
+ for (const comment of this.getCommentsEndingAtLine(lineIndex)) {
459
+ if (this.editMode && comment.id === this.editingCommentKey) continue;
460
+ this.pushAnnotationBlock(
461
+ rows,
462
+ "comment",
463
+ comment.text,
464
+ width,
465
+ formatCommentLocation(comment),
466
+ );
467
+ }
468
+ }
336
469
 
337
- for (let row = 0; row < height; row++) {
338
- const splitRow = rows[this.scrollTop + row];
339
- if (!splitRow) {
340
- output.push(" ".repeat(width));
341
- continue;
342
- }
470
+ private pushInlineEditorRows(
471
+ rows: AnnotatedDiffRow[],
472
+ lineIndex: number,
473
+ width: number,
474
+ ): void {
475
+ if (!this.editMode || this.editingCommentKey === GLOBAL_COMMENT_KEY) return;
476
+ const selection = this.getActiveCommentSelection();
477
+ if (!selection || selection.end !== lineIndex) return;
478
+ this.pushInlineEditorBlock(
479
+ rows,
480
+ `Draft note - ${this.formatSelectionLocation(selection)}`,
481
+ width,
482
+ );
483
+ }
343
484
 
344
- if (splitRow.kind === "full") {
345
- output.push(
346
- this.renderDiffLine(
347
- splitRow.cell.line,
348
- splitRow.cell.index,
349
- width,
350
- splitRow.cell.index === this.selected,
351
- this.getSelectionBounds(),
352
- ),
353
- );
354
- continue;
355
- }
485
+ private pushInlineExplanationRows(
486
+ rows: AnnotatedDiffRow[],
487
+ lineIndex: number,
488
+ width: number,
489
+ ): void {
490
+ const scope = getCurrentHunkScope(this.lines, lineIndex);
491
+ if (!scope || !this.visibleExplanationKeys.has(scope.key)) return;
492
+
493
+ const end = this.getHunkEndIndex(lineIndex);
494
+ if (end !== lineIndex) return;
495
+
496
+ const explanation = this.explanationController.getState(scope);
497
+ if (!this.explanationController.isAvailable) {
498
+ this.pushExplanationBlock(rows, "Explanation unavailable.", width);
499
+ } else if (!explanation) {
500
+ this.pushExplanationBlock(rows, "No explanation generated yet.", width);
501
+ } else if (explanation.status === "loading") {
502
+ this.pushExplanationBlock(
503
+ rows,
504
+ `${this.explanationController.getLoadingFrame()} ${explanation.text || "Generating explanation..."}`,
505
+ width,
506
+ );
507
+ } else if (explanation.status === "error") {
508
+ this.pushExplanationBlock(
509
+ rows,
510
+ `Explanation failed: ${explanation.message}`,
511
+ width,
512
+ );
513
+ } else {
514
+ this.pushExplanationBlock(rows, explanation.text, width);
515
+ }
516
+ }
356
517
 
357
- const left = splitRow.left
358
- ? this.renderSplitDiffCell(splitRow.left, leftWidth, "left")
359
- : " ".repeat(leftWidth);
360
- const right = splitRow.right
361
- ? this.renderSplitDiffCell(splitRow.right, rightWidth, "right")
362
- : " ".repeat(rightWidth);
363
- output.push(
364
- truncateToWidth(
365
- `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`,
366
- width,
367
- ),
518
+ private pushAnnotationBlock(
519
+ rows: AnnotatedDiffRow[],
520
+ kind: "comment",
521
+ text: string,
522
+ width: number,
523
+ title?: string,
524
+ ): void {
525
+ this.pushCommentBlock(rows, text, width, title);
526
+ }
527
+
528
+ private pushCommentBlock(
529
+ rows: AnnotatedDiffRow[],
530
+ text: string,
531
+ width: number,
532
+ title?: string,
533
+ ): void {
534
+ rows.push({
535
+ kind: "comment",
536
+ text: title ? this.theme.fg("accent", ` ${title} `) : "",
537
+ part: "top",
538
+ });
539
+ const wrapped = wrapTextWithAnsi(
540
+ this.theme.fg("text", text),
541
+ this.getInlineContentWidth(width),
542
+ );
543
+ for (const line of wrapped.length > 0 ? wrapped : [""]) {
544
+ rows.push({ kind: "comment", text: line, part: "body" });
545
+ }
546
+ rows.push({ kind: "comment", text: "", part: "bottom" });
547
+ }
548
+
549
+ private pushExplanationBlock(
550
+ rows: AnnotatedDiffRow[],
551
+ text: string,
552
+ width: number,
553
+ ): void {
554
+ rows.push({
555
+ kind: "explanation",
556
+ text: this.theme.fg("accent", " ✨ Explanation "),
557
+ part: "top",
558
+ });
559
+ const wrapped = wrapTextWithAnsi(
560
+ this.theme.fg("muted", text),
561
+ this.getInlineContentWidth(width),
562
+ );
563
+ for (const line of wrapped.length > 0 ? wrapped : [""]) {
564
+ rows.push({ kind: "explanation", text: line, part: "body" });
565
+ }
566
+ rows.push({ kind: "explanation", text: "", part: "bottom" });
567
+ }
568
+
569
+ private pushInlineEditorBlock(
570
+ rows: AnnotatedDiffRow[],
571
+ title: string,
572
+ width: number,
573
+ ): void {
574
+ rows.push({
575
+ kind: "editor",
576
+ text: this.theme.fg("accent", ` ${title} `),
577
+ part: "top",
578
+ });
579
+ const editorLines = this.editor.render(this.getInlineContentWidth(width));
580
+ const bodyLines = editorLines.slice(1, -1);
581
+ for (const line of bodyLines.length > 0 ? bodyLines : [""]) {
582
+ rows.push({ kind: "editor", text: line, part: "body" });
583
+ }
584
+ rows.push({ kind: "editor", text: "", part: "bottom" });
585
+ }
586
+
587
+ private renderInlineAnnotationRow(
588
+ row: Exclude<AnnotatedDiffRow, { kind: "diff" } | { kind: "split" }>,
589
+ width: number,
590
+ ): string {
591
+ if (row.kind === "editor") {
592
+ return this.renderInlineBoxRow(
593
+ { text: row.text, part: row.part },
594
+ width,
595
+ "accent",
368
596
  );
369
597
  }
370
598
 
371
- return output;
599
+ if (row.kind === "comment" || row.kind === "explanation") {
600
+ return this.renderInlineBoxRow(
601
+ { text: row.text, part: row.part },
602
+ width,
603
+ "borderMuted",
604
+ );
605
+ }
606
+
607
+ return " ".repeat(width);
372
608
  }
373
609
 
374
- private getContentHeight(): number {
375
- const terminalRows = this.tui.terminal?.rows ?? 24;
376
- const headerHeight = 3;
377
- const footerHeight = 2;
378
- return Math.max(6, terminalRows - headerHeight - footerHeight);
610
+ private renderInlineBoxRow(
611
+ row: { text: string; part: "top" | "body" | "bottom" },
612
+ width: number,
613
+ borderColor: "accent" | "borderMuted",
614
+ ): string {
615
+ const indent = " ";
616
+ const contentWidth = this.getInlineContentWidth(width);
617
+ const body =
618
+ row.part === "top" || row.part === "bottom"
619
+ ? this.renderInlineBoxHorizontal(row.text, contentWidth, borderColor)
620
+ : padToWidth(truncateToWidth(row.text, contentWidth), contentWidth);
621
+ const left = row.part === "top" ? "╭" : row.part === "bottom" ? "╰" : "│";
622
+ const right = row.part === "top" ? "╮" : row.part === "bottom" ? "╯" : "│";
623
+ return padToWidth(
624
+ truncateToWidth(
625
+ `${indent}${this.theme.fg(borderColor, left)}${body}${this.theme.fg(borderColor, right)}`,
626
+ width,
627
+ ),
628
+ width,
629
+ );
379
630
  }
380
631
 
381
- private getStackedHeights(viewportHeight: number): {
382
- diffHeight: number;
383
- commentsHeight: number;
384
- } {
385
- const availableForPanes = Math.max(2, viewportHeight - 1);
386
- let diffHeight = Math.max(1, Math.floor(availableForPanes * 0.6));
387
- let commentsHeight = availableForPanes - diffHeight;
632
+ private renderInlineBoxHorizontal(
633
+ title: string,
634
+ width: number,
635
+ borderColor: "accent" | "borderMuted",
636
+ ): string {
637
+ if (!title) return this.theme.fg(borderColor, "─".repeat(width));
388
638
 
389
- if (commentsHeight < 3 && availableForPanes >= 4) {
390
- commentsHeight = 3;
391
- diffHeight = availableForPanes - commentsHeight;
639
+ const visibleTitle = truncateToWidth(title, Math.max(0, width));
640
+ const remaining = Math.max(0, width - visibleWidth(visibleTitle));
641
+ return `${visibleTitle}${this.theme.fg(borderColor, "─".repeat(remaining))}`;
642
+ }
643
+
644
+ private getInlineContentWidth(width: number): number {
645
+ return Math.max(10, width - 8);
646
+ }
647
+
648
+ private invalidateAnnotatedRows(): void {
649
+ this.annotatedRows = undefined;
650
+ this.annotatedRowByLineIndex = undefined;
651
+ }
652
+
653
+ private getCommentsEndingAtLine(lineIndex: number): ReviewComment[] {
654
+ const comments: ReviewComment[] = [];
655
+ for (const comment of this.comments.values()) {
656
+ if (comment.global) continue;
657
+ const start = this.lineIndexById.get(comment.startLineId);
658
+ const end = this.lineIndexById.get(comment.endLineId);
659
+ if (start == null || end == null) continue;
660
+ if (Math.max(start, end) === lineIndex) comments.push(comment);
392
661
  }
662
+ return comments.sort((a, b) => a.id.localeCompare(b.id));
663
+ }
664
+
665
+ private getHunkEndIndex(selected: number): number | undefined {
666
+ const selectedLine = this.lines[selected];
667
+ if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
393
668
 
394
- return { diffHeight, commentsHeight };
669
+ let end = selected;
670
+ while (
671
+ end + 1 < this.lines.length &&
672
+ this.lines[end + 1]?.filePath === selectedLine.filePath &&
673
+ this.lines[end + 1]?.hunkLabel === selectedLine.hunkLabel
674
+ ) {
675
+ end++;
676
+ }
677
+ return end;
678
+ }
679
+
680
+ private renderSplitDiffRowAt(splitRowIndex: number, width: number): string {
681
+ const splitRow = this.getSplitDiffRows()[splitRowIndex];
682
+ if (!splitRow) return " ".repeat(width);
683
+
684
+ if (splitRow.kind === "full") {
685
+ return this.renderDiffLine(
686
+ splitRow.cell.line,
687
+ splitRow.cell.index,
688
+ width,
689
+ splitRow.cell.index === this.selected,
690
+ this.getSelectionBounds(),
691
+ );
692
+ }
693
+
694
+ const separatorWidth = 3;
695
+ const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
696
+ const rightWidth = Math.max(10, width - leftWidth - separatorWidth);
697
+ const left = splitRow.left
698
+ ? this.renderSplitDiffCell(splitRow.left, leftWidth, "left")
699
+ : " ".repeat(leftWidth);
700
+ const right = splitRow.right
701
+ ? this.renderSplitDiffCell(splitRow.right, rightWidth, "right")
702
+ : " ".repeat(rightWidth);
703
+ return truncateToWidth(
704
+ `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`,
705
+ width,
706
+ );
707
+ }
708
+
709
+ private getContentHeight(): number {
710
+ const terminalRows = this.tui.terminal?.rows ?? 24;
711
+ const headerHeight = 3;
712
+ const footerHeight = 2;
713
+ return Math.max(6, terminalRows - headerHeight - footerHeight);
395
714
  }
396
715
 
397
716
  invalidate(): void {
398
717
  this.highlightedLineCache.clear();
718
+ this.invalidateAnnotatedRows();
399
719
  }
400
720
 
401
721
  dispose(): void {
402
- this.explanationAbort?.abort();
403
- this.stopLoadingTimer();
722
+ this.explanationController.dispose();
404
723
  }
405
724
 
406
725
  private move(delta: number): void {
407
- const next = Math.max(
408
- 0,
409
- Math.min(this.lines.length - 1, this.selected + delta),
410
- );
411
- if (next === this.selected) return;
412
- this.selected = next;
726
+ if (!this.navigation.move(delta)) return;
413
727
  this.tui.requestRender();
414
728
  }
415
729
 
416
730
  private jumpToBoundary(boundary: "start" | "end"): void {
417
- const next = boundary === "start" ? 0 : Math.max(0, this.lines.length - 1);
418
- const hadSelection = this.selectionAnchor != null;
419
- if (next === this.selected && !hadSelection) return;
420
- this.selected = next;
421
- if (hadSelection) {
422
- this.clearSelection();
423
- } else {
424
- this.tui.requestRender();
425
- }
731
+ const result = this.navigation.jumpToBoundary(boundary);
732
+ if (!result.changed) return;
733
+ this.tui.requestRender();
426
734
  }
427
735
 
428
- private toggleLayout(): void {
429
- this.layout = this.layout === "side-by-side" ? "stacked" : "side-by-side";
736
+ private toggleDiffRenderMode(): void {
737
+ this.navigation.toggleDiffRenderMode();
430
738
  this.tui.requestRender(true);
431
739
  }
432
740
 
433
- private toggleDiffRenderMode(): void {
434
- this.diffRenderMode =
435
- this.diffRenderMode === "unified" ? "split" : "unified";
436
- this.scrollTop = 0;
741
+ private toggleInlineAnnotations(): void {
742
+ this.inlineAnnotationsVisible = !this.inlineAnnotationsVisible;
743
+ this.invalidateAnnotatedRows();
437
744
  this.tui.requestRender(true);
438
745
  }
439
746
 
747
+ private hideInlineExplanation(): void {
748
+ this.visibleExplanationKeys.clear();
749
+ }
750
+
440
751
  private toggleExplanationPane(): void {
441
- this.rightPaneMode =
442
- this.rightPaneMode === "comments" ? "explanation" : "comments";
443
- if (this.rightPaneMode === "explanation") {
752
+ const scope = this.getCurrentHunkScope();
753
+ if (!scope) return;
754
+
755
+ if (this.visibleExplanationKeys.has(scope.key)) {
756
+ this.visibleExplanationKeys.delete(scope.key);
757
+ } else {
758
+ this.visibleExplanationKeys.add(scope.key);
444
759
  this.ensureCurrentExplanation();
445
760
  }
761
+
762
+ this.invalidateAnnotatedRows();
446
763
  this.tui.requestRender(true);
447
764
  }
448
765
 
449
766
  private getPageMoveAmount(): number {
450
767
  const contentHeight = this.getContentHeight();
451
- const diffHeight =
452
- this.layout === "stacked"
453
- ? this.getStackedHeights(contentHeight).diffHeight
454
- : contentHeight;
455
- return Math.max(1, Math.floor(diffHeight / 2));
768
+ return Math.max(1, Math.floor(contentHeight / 2));
456
769
  }
457
770
 
458
771
  private extendSelection(delta: number): void {
459
- if (this.selectionAnchor == null) {
460
- this.selectionAnchor = this.selected;
461
- }
462
- const next = Math.max(
463
- 0,
464
- Math.min(this.lines.length - 1, this.selected + delta),
465
- );
466
- if (next === this.selected) return;
467
- this.selected = next;
772
+ if (!this.navigation.extendSelection(delta)) return;
468
773
  this.tui.requestRender();
469
774
  }
470
775
 
471
776
  private clearSelection(): void {
472
- if (this.selectionAnchor == null) return;
473
- this.selectionAnchor = undefined;
777
+ if (!this.navigation.clearSelection()) return;
474
778
  this.tui.requestRender();
475
779
  }
476
780
 
477
781
  private hasSelection(): boolean {
478
- return (
479
- this.selectionAnchor != null && this.selectionAnchor !== this.selected
480
- );
782
+ return this.navigation.hasSelection();
481
783
  }
482
784
 
483
785
  private getSelectionBounds(): SelectionBounds | undefined {
484
- if (this.selectionAnchor == null) return undefined;
485
- return {
486
- start: Math.min(this.selectionAnchor, this.selected),
487
- end: Math.max(this.selectionAnchor, this.selected),
488
- };
786
+ return this.navigation.getSelectionBounds();
489
787
  }
490
788
 
491
789
  private getActiveCommentSelection(): SelectionBounds | undefined {
@@ -497,7 +795,17 @@ export class ReviewComponent {
497
795
  }
498
796
 
499
797
  private getSelectionKey(start: number, end: number): string {
500
- return `${this.lines[start]?.id ?? start}:${this.lines[end]?.id ?? end}`;
798
+ return getSelectionKey(this.lines, start, end);
799
+ }
800
+
801
+ private formatSelectionLocation(selection: SelectionBounds): string {
802
+ const startLine = this.lines[selection.start];
803
+ const endLine = this.lines[selection.end];
804
+ if (!startLine || !endLine) return "diff";
805
+
806
+ const start = formatLocation(startLine);
807
+ const end = formatLocation(endLine);
808
+ return start === end ? start : `${start} -> ${end}`;
501
809
  }
502
810
 
503
811
  private getCommentForSelection(
@@ -516,68 +824,20 @@ export class ReviewComponent {
516
824
 
517
825
  private markCommentsChanged(): void {
518
826
  this.commentsRevision++;
827
+ this.invalidateAnnotatedRows();
519
828
  this.onCommentsChanged?.(this.comments);
520
829
  }
521
830
 
522
831
  private ensureCommentLineKeys(): void {
523
832
  if (this.commentLineKeysRevision === this.commentsRevision) return;
524
833
 
525
- this.commentLineKeys = new Map<number, string[]>();
526
- for (const [key, comment] of this.comments) {
527
- const start = this.lineIndexById.get(comment.startLineId);
528
- const end = this.lineIndexById.get(comment.endLineId);
529
- if (start == null || end == null) continue;
530
- const from = Math.min(start, end);
531
- const to = Math.max(start, end);
532
- for (let index = from; index <= to; index++) {
533
- const keys = this.commentLineKeys.get(index);
534
- if (keys) {
535
- keys.push(key);
536
- } else {
537
- this.commentLineKeys.set(index, [key]);
538
- }
539
- }
540
- }
541
-
834
+ this.commentLineKeys = buildCommentLineKeys(
835
+ this.comments,
836
+ this.lineIndexById,
837
+ );
542
838
  this.commentLineKeysRevision = this.commentsRevision;
543
839
  }
544
840
 
545
- private buildGlobalComment(text: string): ReviewComment {
546
- return {
547
- id: GLOBAL_COMMENT_KEY,
548
- filePath: "Overall diff",
549
- text,
550
- global: true,
551
- startLineId: GLOBAL_COMMENT_KEY,
552
- endLineId: GLOBAL_COMMENT_KEY,
553
- lineText: "",
554
- };
555
- }
556
-
557
- private buildCommentFromSelection(
558
- selection: SelectionBounds,
559
- text: string,
560
- ): ReviewComment {
561
- const startLine = this.lines[selection.start]!;
562
- const endLine = this.lines[selection.end]!;
563
- const excerpt = this.lines
564
- .slice(selection.start, selection.end + 1)
565
- .map((line) => line.text)
566
- .join("\n");
567
- return {
568
- id: this.getSelectionKey(selection.start, selection.end),
569
- filePath: startLine.filePath ?? endLine.filePath ?? "(unknown file)",
570
- text,
571
- startLineId: startLine.id,
572
- endLineId: endLine.id,
573
- startOldLineNumber: startLine.oldLineNumber,
574
- startNewLineNumber: startLine.newLineNumber,
575
- endOldLineNumber: endLine.oldLineNumber,
576
- endNewLineNumber: endLine.newLineNumber,
577
- lineText: excerpt,
578
- };
579
- }
580
-
581
841
  private getPositionText(selectedLine?: ReviewLine): string {
582
842
  const position = `${Math.min(this.selected + 1, this.lines.length)}/${this.lines.length}`;
583
843
  return selectedLine?.filePath
@@ -621,10 +881,12 @@ export class ReviewComponent {
621
881
 
622
882
  private startGlobalEditMode(): void {
623
883
  const existing = this.comments.get(GLOBAL_COMMENT_KEY);
624
- this.rightPaneMode = "comments";
884
+ this.inlineAnnotationsVisible = true;
885
+ this.hideInlineExplanation();
625
886
  this.editMode = true;
626
887
  this.editingCommentKey = GLOBAL_COMMENT_KEY;
627
888
  this.editor.setText(existing?.text ?? "");
889
+ this.invalidateAnnotatedRows();
628
890
  this.tui.requestRender(true);
629
891
  }
630
892
 
@@ -637,13 +899,15 @@ export class ReviewComponent {
637
899
  if (startLine.filePath !== endLine.filePath) return;
638
900
 
639
901
  const existing = this.getCommentForSelection(selection);
640
- this.rightPaneMode = "comments";
902
+ this.inlineAnnotationsVisible = true;
903
+ this.hideInlineExplanation();
641
904
  this.editMode = true;
642
905
  this.editingCommentKey = this.getSelectionKey(
643
906
  selection.start,
644
907
  selection.end,
645
908
  );
646
909
  this.editor.setText(existing?.text ?? "");
910
+ this.invalidateAnnotatedRows();
647
911
  this.tui.requestRender(true);
648
912
  }
649
913
 
@@ -651,78 +915,25 @@ export class ReviewComponent {
651
915
  this.editMode = false;
652
916
  this.editingCommentKey = undefined;
653
917
  this.editor.setText("");
918
+ this.invalidateAnnotatedRows();
654
919
  this.tui.requestRender(true);
655
920
  }
656
921
 
657
922
  private getSplitDiffRows(): SplitDiffRow[] {
658
923
  if (this.splitRows) return this.splitRows;
659
924
 
660
- const rows: SplitDiffRow[] = [];
661
- const rowByLineIndex: number[] = [];
662
- let index = 0;
663
-
664
- const pushRow = (row: SplitDiffRow) => {
665
- const displayRow = rows.length;
666
- rows.push(row);
667
- if (row.kind === "full") {
668
- rowByLineIndex[row.cell.index] = displayRow;
669
- } else {
670
- if (row.left) rowByLineIndex[row.left.index] = displayRow;
671
- if (row.right) rowByLineIndex[row.right.index] = displayRow;
672
- }
673
- };
674
-
675
- while (index < this.lines.length) {
676
- const line = this.lines[index]!;
677
-
678
- if (line.kind === "remove" || line.kind === "add") {
679
- const removals: SplitDiffCell[] = [];
680
- const additions: SplitDiffCell[] = [];
681
-
682
- while (this.lines[index]?.kind === "remove") {
683
- removals.push({ line: this.lines[index]!, index });
684
- index++;
685
- }
686
- while (this.lines[index]?.kind === "add") {
687
- additions.push({ line: this.lines[index]!, index });
688
- index++;
689
- }
690
-
691
- const count = Math.max(removals.length, additions.length);
692
- for (let offset = 0; offset < count; offset++) {
693
- pushRow({
694
- kind: "split",
695
- left: removals[offset],
696
- right: additions[offset],
697
- });
698
- }
699
- continue;
700
- }
701
-
702
- if (line.kind === "context") {
703
- const cell = { line, index };
704
- pushRow({ kind: "split", left: cell, right: cell });
705
- } else {
706
- pushRow({ kind: "full", cell: { line, index } });
707
- }
708
- index++;
709
- }
710
-
925
+ const { rows } = buildSplitDiffRows(this.lines);
711
926
  this.splitRows = rows;
712
- this.splitRowByLineIndex = rowByLineIndex;
713
927
  return rows;
714
928
  }
715
929
 
716
- private getSelectedDisplayRow(): number {
717
- if (this.diffRenderMode === "unified") return this.selected;
718
- this.getSplitDiffRows();
719
- return this.splitRowByLineIndex?.[this.selected] ?? 0;
930
+ private getSelectedDisplayRow(width: number): number {
931
+ this.getAnnotatedRows(width);
932
+ return this.annotatedRowByLineIndex?.[this.selected] ?? 0;
720
933
  }
721
934
 
722
- private getDisplayRowCount(): number {
723
- return this.diffRenderMode === "unified"
724
- ? this.lines.length
725
- : this.getSplitDiffRows().length;
935
+ private getDisplayRowCount(width: number): number {
936
+ return this.getAnnotatedRows(width).length;
726
937
  }
727
938
 
728
939
  private renderSplitDiffCell(
@@ -732,7 +943,7 @@ export class ReviewComponent {
732
943
  ): string {
733
944
  const { line, index } = cell;
734
945
  const hasComment = this.getCommentKeysForLine(index).length > 0;
735
- const commentMark = hasComment ? this.theme.fg("warning", "") : " ";
946
+ const commentMark = hasComment ? this.theme.fg("borderAccent", "") : " ";
736
947
  const lineNumber =
737
948
  side === "left" ? line.oldLineNumber : line.newLineNumber;
738
949
  const prefix = `${commentMark} ${lineNumberCell(lineNumber)} `;
@@ -748,19 +959,11 @@ export class ReviewComponent {
748
959
  return this.applyDiffBackground(line, styled, width);
749
960
  }
750
961
 
751
- private ensureScroll(viewportHeight: number): void {
752
- const selectedRow = this.getSelectedDisplayRow();
753
- const rowCount = this.getDisplayRowCount();
754
-
755
- if (selectedRow < this.scrollTop) {
756
- this.scrollTop = selectedRow;
757
- }
758
- if (selectedRow >= this.scrollTop + viewportHeight) {
759
- this.scrollTop = selectedRow - viewportHeight + 1;
760
- }
761
- this.scrollTop = Math.max(
762
- 0,
763
- Math.min(this.scrollTop, Math.max(0, rowCount - viewportHeight)),
962
+ private ensureScroll(viewportHeight: number, width: number): void {
963
+ this.navigation.ensureScroll(
964
+ viewportHeight,
965
+ this.getSelectedDisplayRow(width),
966
+ this.getDisplayRowCount(width),
764
967
  );
765
968
  }
766
969
 
@@ -829,7 +1032,7 @@ export class ReviewComponent {
829
1032
  selection?: SelectionBounds,
830
1033
  ): string {
831
1034
  const hasComment = this.getCommentKeysForLine(index).length > 0;
832
- const commentMark = hasComment ? this.theme.fg("warning", "") : " ";
1035
+ const commentMark = hasComment ? this.theme.fg("borderAccent", "") : " ";
833
1036
  const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
834
1037
  const prefix = `${commentMark} ${numbers} `;
835
1038
  let styled = this.renderDiffRowContent(line, prefix);
@@ -843,383 +1046,11 @@ export class ReviewComponent {
843
1046
  return this.applyDiffBackground(line, styled, width);
844
1047
  }
845
1048
 
846
- private renderRightPane(
847
- width: number,
848
- height: number,
849
- selectedLine?: ReviewLine,
850
- ): string[] {
851
- return this.rightPaneMode === "explanation"
852
- ? this.renderExplanationPane(width, height, selectedLine)
853
- : this.renderCommentsPane(width, height, selectedLine);
854
- }
855
-
856
- private renderCommentsPane(
857
- width: number,
858
- height: number,
859
- selectedLine?: ReviewLine,
860
- ): string[] {
861
- const lines: string[] = [];
862
- const title = this.theme.fg("accent", this.theme.bold("Comments"));
863
- const selection = this.getActiveCommentSelection();
864
- const currentComment = this.getCommentForSelection(selection);
865
-
866
- lines.push(truncateToWidth(title, width));
867
- lines.push(
868
- truncateToWidth(
869
- this.theme.fg(
870
- "dim",
871
- selection
872
- ? this.getFooterText(selectedLine)
873
- : selectedLine
874
- ? formatLocation(selectedLine)
875
- : "No selection",
876
- ),
877
- width,
878
- ),
879
- );
880
- lines.push("");
881
-
882
- if (this.editMode && this.editingCommentKey === GLOBAL_COMMENT_KEY) {
883
- lines[1] = truncateToWidth(
884
- this.theme.fg("dim", "Overall diff comment"),
885
- width,
886
- );
887
- lines.push(
888
- ...wrapTextWithAnsi(
889
- this.theme.fg(
890
- "dim",
891
- "Editing overall diff comment. Enter saves. Esc or Ctrl+C cancels.",
892
- ),
893
- width,
894
- ),
895
- );
896
- lines.push("");
897
- for (const line of this.editor.render(Math.max(10, width))) {
898
- lines.push(truncateToWidth(line, width));
899
- }
900
- return lines.slice(0, height);
901
- }
902
-
903
- const globalComment = this.comments.get(GLOBAL_COMMENT_KEY);
904
- if (globalComment) {
905
- lines.push(
906
- truncateToWidth(
907
- this.theme.fg("accent", this.theme.bold("Overall diff comment")),
908
- width,
909
- ),
910
- );
911
- lines.push(
912
- ...wrapTextWithAnsi(this.theme.fg("text", globalComment.text), width),
913
- );
914
- lines.push(...wrapTextWithAnsi(this.theme.fg("dim", "C edits"), width));
915
- lines.push("");
916
- }
917
-
918
- if (!selectedLine) {
919
- lines.push(
920
- ...wrapTextWithAnsi(
921
- this.theme.fg("muted", "No diff lines available."),
922
- width,
923
- ),
924
- );
925
- return lines.slice(0, height);
926
- }
927
-
928
- if (!selection) {
929
- lines.push(
930
- ...wrapTextWithAnsi(
931
- this.theme.fg(
932
- "muted",
933
- "Move to a diff line and press c to add a comment, or press C for an overall diff comment.",
934
- ),
935
- width,
936
- ),
937
- );
938
- return lines.slice(0, height);
939
- }
940
-
941
- if (this.editMode && currentComment?.id === this.editingCommentKey) {
942
- lines.push(
943
- ...wrapTextWithAnsi(
944
- this.theme.fg(
945
- "dim",
946
- "Editing comment. Enter saves. Esc or Ctrl+C cancels.",
947
- ),
948
- width,
949
- ),
950
- );
951
- lines.push("");
952
- for (const line of this.editor.render(Math.max(10, width))) {
953
- lines.push(truncateToWidth(line, width));
954
- }
955
- } else if (this.editMode && this.editingCommentKey) {
956
- lines.push(
957
- ...wrapTextWithAnsi(
958
- this.theme.fg(
959
- "dim",
960
- "Editing comment. Enter saves. Esc or Ctrl+C cancels.",
961
- ),
962
- width,
963
- ),
964
- );
965
- lines.push("");
966
- for (const line of this.editor.render(Math.max(10, width))) {
967
- lines.push(truncateToWidth(line, width));
968
- }
969
- } else if (currentComment) {
970
- lines.push(
971
- ...wrapTextWithAnsi(this.theme.fg("text", currentComment.text), width),
972
- );
973
- lines.push("");
974
- lines.push(
975
- ...wrapTextWithAnsi(
976
- this.theme.fg("dim", "x deletes this comment • c edits"),
977
- width,
978
- ),
979
- );
980
- } else {
981
- lines.push(
982
- ...wrapTextWithAnsi(
983
- this.theme.fg(
984
- "muted",
985
- this.hasSelection()
986
- ? "No comment on this range."
987
- : "No comment on this line.",
988
- ),
989
- width,
990
- ),
991
- );
992
- lines.push("");
993
- lines.push(
994
- ...wrapTextWithAnsi(
995
- this.theme.fg(
996
- "dim",
997
- this.hasSelection()
998
- ? "Press c to add a range comment, or C for an overall diff comment."
999
- : "Press c to add one. Use J/K to extend a range. Press C for an overall diff comment.",
1000
- ),
1001
- width,
1002
- ),
1003
- );
1004
- }
1005
-
1006
- lines.push("");
1007
- lines.push(
1008
- truncateToWidth(
1009
- this.theme.fg("accent", this.theme.bold("Excerpt")),
1010
- width,
1011
- ),
1012
- );
1013
- const excerpt = this.lines
1014
- .slice(selection.start, selection.end + 1)
1015
- .map((line) => line.text)
1016
- .join("\n");
1017
- lines.push(
1018
- ...wrapTextWithAnsi(
1019
- this.theme.fg("toolDiffContext", excerpt || "(blank line)"),
1020
- width,
1021
- ),
1022
- );
1023
- return lines.slice(0, height);
1024
- }
1025
-
1026
- private renderExplanationPane(
1027
- width: number,
1028
- height: number,
1029
- selectedLine?: ReviewLine,
1030
- ): string[] {
1031
- const lines: string[] = [];
1032
- const title = this.theme.fg("accent", this.theme.bold("Explanation"));
1033
- const scope = this.getCurrentHunkScope();
1034
-
1035
- lines.push(truncateToWidth(title, width));
1036
- lines.push(
1037
- truncateToWidth(
1038
- this.theme.fg(
1039
- "dim",
1040
- scope?.title ??
1041
- (selectedLine ? formatLocation(selectedLine) : "No selection"),
1042
- ),
1043
- width,
1044
- ),
1045
- );
1046
- lines.push("");
1047
-
1048
- if (!this.explainer) {
1049
- lines.push(
1050
- ...wrapTextWithAnsi(
1051
- this.theme.fg("warning", "Diff explanations are unavailable."),
1052
- width,
1053
- ),
1054
- );
1055
- return lines.slice(0, height);
1056
- }
1057
-
1058
- if (!scope) {
1059
- lines.push(
1060
- ...wrapTextWithAnsi(
1061
- this.theme.fg(
1062
- "muted",
1063
- "Move to a changed hunk and press ? to generate an explanation.",
1064
- ),
1065
- width,
1066
- ),
1067
- );
1068
- return lines.slice(0, height);
1069
- }
1070
-
1071
- const explanation = this.explanations.get(scope.key);
1072
- if (!explanation) {
1073
- lines.push(
1074
- ...wrapTextWithAnsi(
1075
- this.theme.fg(
1076
- "muted",
1077
- "No explanation generated yet. Press ? again after returning to comments to generate this hunk.",
1078
- ),
1079
- width,
1080
- ),
1081
- );
1082
- } else if (explanation.status === "loading") {
1083
- const spinner = this.getLoadingFrame();
1084
- lines.push(
1085
- truncateToWidth(
1086
- this.theme.fg("accent", `${spinner} Generating explanation...`),
1087
- width,
1088
- ),
1089
- );
1090
- if (explanation.text.trim()) {
1091
- lines.push("");
1092
- lines.push(
1093
- ...wrapTextWithAnsi(this.theme.fg("text", explanation.text), width),
1094
- );
1095
- }
1096
- } else if (explanation.status === "error") {
1097
- lines.push(
1098
- ...wrapTextWithAnsi(
1099
- this.theme.fg(
1100
- "warning",
1101
- `Unable to explain diff: ${explanation.message}`,
1102
- ),
1103
- width,
1104
- ),
1105
- );
1106
- } else {
1107
- lines.push(
1108
- ...wrapTextWithAnsi(this.theme.fg("text", explanation.text), width),
1109
- );
1110
- }
1111
-
1112
- lines.push("");
1113
- lines.push(...wrapTextWithAnsi(this.theme.fg("dim", "? comments"), width));
1114
- return lines.slice(0, height);
1115
- }
1116
-
1117
1049
  private ensureCurrentExplanation(): void {
1118
- const scope = this.getCurrentHunkScope();
1119
- if (!scope || !this.explainer) return;
1120
- if (this.explanations.has(scope.key)) return;
1121
-
1122
- this.explanationAbort?.abort();
1123
- const controller = new AbortController();
1124
- this.explanationAbort = controller;
1125
- const requestId = ++this.explanationRequestId;
1126
- let text = "";
1127
-
1128
- this.explanations.set(scope.key, { status: "loading", text });
1129
- this.startLoadingTimer();
1130
-
1131
- void this.explainer
1132
- .explain(scope, {
1133
- signal: controller.signal,
1134
- onDelta: (delta) => {
1135
- if (requestId !== this.explanationRequestId) return;
1136
- text += delta;
1137
- this.explanations.set(scope.key, { status: "loading", text });
1138
- this.tui.requestRender();
1139
- },
1140
- })
1141
- .then((finalText) => {
1142
- if (requestId !== this.explanationRequestId) return;
1143
- this.explanations.set(scope.key, {
1144
- status: "ready",
1145
- text: finalText.trim() || text.trim() || "No explanation returned.",
1146
- });
1147
- })
1148
- .catch((error) => {
1149
- if (requestId !== this.explanationRequestId) return;
1150
- if (controller.signal.aborted) return;
1151
- this.explanations.set(scope.key, {
1152
- status: "error",
1153
- message: error instanceof Error ? error.message : String(error),
1154
- });
1155
- })
1156
- .finally(() => {
1157
- if (requestId !== this.explanationRequestId) return;
1158
- this.stopLoadingTimerIfIdle();
1159
- this.tui.requestRender();
1160
- });
1161
-
1162
- this.tui.requestRender();
1163
- }
1164
-
1165
- private getCurrentHunkScope(): ExplanationScope | undefined {
1166
- const selectedLine = this.lines[this.selected];
1167
- if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
1168
-
1169
- let start = this.selected;
1170
- while (
1171
- start > 0 &&
1172
- this.lines[start - 1]?.filePath === selectedLine.filePath &&
1173
- this.lines[start - 1]?.hunkLabel === selectedLine.hunkLabel
1174
- ) {
1175
- start--;
1176
- }
1177
-
1178
- let end = this.selected;
1179
- while (
1180
- end + 1 < this.lines.length &&
1181
- this.lines[end + 1]?.filePath === selectedLine.filePath &&
1182
- this.lines[end + 1]?.hunkLabel === selectedLine.hunkLabel
1183
- ) {
1184
- end++;
1185
- }
1186
-
1187
- const diffText = this.lines
1188
- .slice(start, end + 1)
1189
- .map((line) => line.text)
1190
- .join("\n");
1191
- return {
1192
- key: `hunk:${selectedLine.filePath}:${selectedLine.hunkLabel}:${start}:${end}`,
1193
- kind: "hunk",
1194
- title: `${selectedLine.filePath} ${selectedLine.hunkLabel}`,
1195
- filePath: selectedLine.filePath,
1196
- diffText,
1197
- };
1198
- }
1199
-
1200
- private getLoadingFrame(): string {
1201
- const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1202
- return frames[this.loadingFrame % frames.length] ?? "⠋";
1203
- }
1204
-
1205
- private startLoadingTimer(): void {
1206
- if (this.loadingTimer) return;
1207
- this.loadingTimer = setInterval(() => {
1208
- this.loadingFrame++;
1209
- this.tui.requestRender();
1210
- }, 120);
1211
- }
1212
-
1213
- private stopLoadingTimerIfIdle(): void {
1214
- const hasLoading = [...this.explanations.values()].some(
1215
- (explanation) => explanation.status === "loading",
1216
- );
1217
- if (!hasLoading) this.stopLoadingTimer();
1050
+ this.explanationController.ensure(this.getCurrentHunkScope());
1218
1051
  }
1219
1052
 
1220
- private stopLoadingTimer(): void {
1221
- if (!this.loadingTimer) return;
1222
- clearInterval(this.loadingTimer);
1223
- this.loadingTimer = undefined;
1053
+ private getCurrentHunkScope() {
1054
+ return getCurrentHunkScope(this.lines, this.selected);
1224
1055
  }
1225
1056
  }