pi-diff-review 0.1.12 → 0.1.13

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.
@@ -2,23 +2,30 @@ import {
2
2
  getLanguageFromPath,
3
3
  highlightCode,
4
4
  } from "@earendil-works/pi-coding-agent";
5
+ import { Editor, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
6
+ import type { DiffExplainer } from "./explain.ts";
5
7
  import {
6
- Editor,
7
- matchesKey,
8
- truncateToWidth,
9
- visibleWidth,
10
- wrapTextWithAnsi,
11
- } from "@earendil-works/pi-tui";
12
- import type {
13
- DiffExplainer,
14
- ExplanationScope,
15
- ExplanationState,
16
- } from "./explain.ts";
8
+ GLOBAL_COMMENT_KEY,
9
+ buildCommentFromSelection,
10
+ buildCommentLineKeys,
11
+ buildGlobalComment,
12
+ getSelectionKey,
13
+ } from "./comment-manager.ts";
14
+ import {
15
+ ExplanationController,
16
+ getCurrentHunkScope,
17
+ } from "./explanation-controller.ts";
17
18
  import { formatLocation } from "./prompt.ts";
19
+ import { ReviewNavigationState } from "./review-navigation.ts";
20
+ import {
21
+ renderCommentsPane as renderCommentsPaneContent,
22
+ renderExplanationPane as renderExplanationPaneContent,
23
+ } from "./review-panes.ts";
24
+ import { padToWidth, lineNumberCell } from "./render-utils.ts";
25
+ import { buildSplitDiffRows } from "./split-diff.ts";
18
26
  import type {
19
27
  DiffRenderMode,
20
28
  ReviewComment,
21
- ReviewLayout,
22
29
  ReviewLine,
23
30
  ReviewResult,
24
31
  ReviewTheme,
@@ -29,32 +36,14 @@ import type {
29
36
  SplitDiffRow,
30
37
  } from "./types.ts";
31
38
 
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
- }
37
-
38
- function lineNumberCell(value?: number): string {
39
- return value == null ? " " : String(value).padStart(4, " ");
40
- }
41
-
42
- const GLOBAL_COMMENT_KEY = "__global_diff_comment__";
43
-
44
39
  export class ReviewComponent {
45
- private selected = 0;
46
- private scrollTop = 0;
40
+ private navigation: ReviewNavigationState;
47
41
  private editMode = false;
48
42
  private editingCommentKey?: string;
49
- private selectionAnchor?: number;
50
- private layout: ReviewLayout = "side-by-side";
51
- private diffRenderMode: DiffRenderMode = "unified";
43
+
44
+ private showRightPane = true;
52
45
  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>;
46
+ private explanationController: ExplanationController;
58
47
  private editor: Editor;
59
48
  private splitRows?: SplitDiffRow[];
60
49
  private splitRowByLineIndex?: number[];
@@ -73,10 +62,21 @@ export class ReviewComponent {
73
62
  private done: (result: ReviewResult) => void,
74
63
  private explainer?: DiffExplainer,
75
64
  private onCommentsChanged?: (comments: Map<string, ReviewComment>) => void,
65
+ cachedExplanations?: Map<string, string>,
66
+ private onExplanationsChanged?: (explanations: Map<string, string>) => void,
76
67
  ) {
77
68
  const firstCommentable = this.lines.findIndex((line) => line.commentable);
78
- this.selected = firstCommentable >= 0 ? firstCommentable : 0;
69
+ this.navigation = new ReviewNavigationState(
70
+ this.lines.length,
71
+ firstCommentable >= 0 ? firstCommentable : 0,
72
+ );
79
73
  this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
74
+ this.explanationController = new ExplanationController(
75
+ tui,
76
+ explainer,
77
+ cachedExplanations,
78
+ onExplanationsChanged,
79
+ );
80
80
 
81
81
  this.editor = new Editor(tui as never, {
82
82
  borderColor: (s) => theme.fg("accent", s),
@@ -96,10 +96,7 @@ export class ReviewComponent {
96
96
  if (this.comments.delete(GLOBAL_COMMENT_KEY))
97
97
  this.markCommentsChanged();
98
98
  } else {
99
- this.comments.set(
100
- GLOBAL_COMMENT_KEY,
101
- this.buildGlobalComment(trimmed),
102
- );
99
+ this.comments.set(GLOBAL_COMMENT_KEY, buildGlobalComment(trimmed));
103
100
  this.markCommentsChanged();
104
101
  }
105
102
  this.exitEditMode();
@@ -118,7 +115,7 @@ export class ReviewComponent {
118
115
  } else {
119
116
  this.comments.set(
120
117
  key,
121
- this.buildCommentFromSelection(selection, trimmed),
118
+ buildCommentFromSelection(this.lines, selection, trimmed),
122
119
  );
123
120
  this.markCommentsChanged();
124
121
  }
@@ -127,6 +124,26 @@ export class ReviewComponent {
127
124
  };
128
125
  }
129
126
 
127
+ private get selected(): number {
128
+ return this.navigation.selected;
129
+ }
130
+
131
+ private set selected(value: number) {
132
+ this.navigation.selected = value;
133
+ }
134
+
135
+ private get scrollTop(): number {
136
+ return this.navigation.scrollTop;
137
+ }
138
+
139
+ private set scrollTop(value: number) {
140
+ this.navigation.scrollTop = value;
141
+ }
142
+
143
+ private get diffRenderMode(): DiffRenderMode {
144
+ return this.navigation.diffRenderMode;
145
+ }
146
+
130
147
  handleInput(data: string): void {
131
148
  if (this.editMode) {
132
149
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
@@ -151,6 +168,10 @@ export class ReviewComponent {
151
168
  return;
152
169
  }
153
170
  if (data === "t") {
171
+ this.toggleRightPane();
172
+ return;
173
+ }
174
+ if (data === "v") {
154
175
  this.toggleDiffRenderMode();
155
176
  return;
156
177
  }
@@ -232,22 +253,18 @@ export class ReviewComponent {
232
253
  ? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
233
254
  : this.hasSelection()
234
255
  ? `${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`,
256
+ : `${this.lines.length} lines • ${this.comments.size} comments • ${this.getPositionText(selectedLine)} • ${this.showRightPane ? "sidebar shown" : "sidebar hidden"} • j/k move • g/G top/bottom • ctrl-u/d page • t sidebar • v unified/split • ? explain • J/K extend • c comment • C overall • x delete • n/p hunk • Enter submit • q quit`,
236
257
  ),
237
258
  width,
238
259
  ),
239
260
  );
240
- if (this.layout === "side-by-side") {
261
+ if (!this.showRightPane) {
241
262
  this.ensureScroll(viewportHeight);
242
- output.push(
243
- ...this.renderSideBySide(width, viewportHeight, selectedLine),
244
- );
263
+ output.push(...this.renderFullWidthDiffRows(width, viewportHeight));
245
264
  } else {
246
- const { diffHeight, commentsHeight } =
247
- this.getStackedHeights(viewportHeight);
248
- this.ensureScroll(diffHeight);
265
+ this.ensureScroll(viewportHeight);
249
266
  output.push(
250
- ...this.renderStacked(width, diffHeight, commentsHeight, selectedLine),
267
+ ...this.renderSideBySide(width, viewportHeight, selectedLine),
251
268
  );
252
269
  }
253
270
 
@@ -282,28 +299,18 @@ export class ReviewComponent {
282
299
  return output;
283
300
  }
284
301
 
285
- private renderStacked(
286
- 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
- ];
299
- }
300
-
301
302
  private renderDiffRows(width: number, height: number): string[] {
302
303
  return this.diffRenderMode === "split"
303
304
  ? this.renderSplitDiffRows(width, height)
304
305
  : this.renderUnifiedDiffRows(width, height);
305
306
  }
306
307
 
308
+ private renderFullWidthDiffRows(width: number, height: number): string[] {
309
+ return this.renderDiffRows(width, height).map((row) =>
310
+ padToWidth(truncateToWidth(row, width), width),
311
+ );
312
+ }
313
+
307
314
  private renderUnifiedDiffRows(width: number, height: number): string[] {
308
315
  const output: string[] = [];
309
316
  const selection = this.getSelectionBounds();
@@ -378,66 +385,42 @@ export class ReviewComponent {
378
385
  return Math.max(6, terminalRows - headerHeight - footerHeight);
379
386
  }
380
387
 
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;
388
-
389
- if (commentsHeight < 3 && availableForPanes >= 4) {
390
- commentsHeight = 3;
391
- diffHeight = availableForPanes - commentsHeight;
392
- }
393
-
394
- return { diffHeight, commentsHeight };
395
- }
396
-
397
388
  invalidate(): void {
398
389
  this.highlightedLineCache.clear();
399
390
  }
400
391
 
401
392
  dispose(): void {
402
- this.explanationAbort?.abort();
403
- this.stopLoadingTimer();
393
+ this.explanationController.dispose();
404
394
  }
405
395
 
406
396
  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;
397
+ if (!this.navigation.move(delta)) return;
413
398
  this.tui.requestRender();
414
399
  }
415
400
 
416
401
  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
- }
402
+ const result = this.navigation.jumpToBoundary(boundary);
403
+ if (!result.changed) return;
404
+ this.tui.requestRender();
426
405
  }
427
406
 
428
- private toggleLayout(): void {
429
- this.layout = this.layout === "side-by-side" ? "stacked" : "side-by-side";
407
+ private toggleDiffRenderMode(): void {
408
+ this.navigation.toggleDiffRenderMode();
430
409
  this.tui.requestRender(true);
431
410
  }
432
411
 
433
- private toggleDiffRenderMode(): void {
434
- this.diffRenderMode =
435
- this.diffRenderMode === "unified" ? "split" : "unified";
436
- this.scrollTop = 0;
412
+ private toggleRightPane(): void {
413
+ this.showRightPane = !this.showRightPane;
437
414
  this.tui.requestRender(true);
438
415
  }
439
416
 
417
+ private showCommentsPane(): void {
418
+ this.showRightPane = true;
419
+ this.rightPaneMode = "comments";
420
+ }
421
+
440
422
  private toggleExplanationPane(): void {
423
+ this.showRightPane = true;
441
424
  this.rightPaneMode =
442
425
  this.rightPaneMode === "comments" ? "explanation" : "comments";
443
426
  if (this.rightPaneMode === "explanation") {
@@ -448,44 +431,25 @@ export class ReviewComponent {
448
431
 
449
432
  private getPageMoveAmount(): number {
450
433
  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));
434
+ return Math.max(1, Math.floor(contentHeight / 2));
456
435
  }
457
436
 
458
437
  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;
438
+ if (!this.navigation.extendSelection(delta)) return;
468
439
  this.tui.requestRender();
469
440
  }
470
441
 
471
442
  private clearSelection(): void {
472
- if (this.selectionAnchor == null) return;
473
- this.selectionAnchor = undefined;
443
+ if (!this.navigation.clearSelection()) return;
474
444
  this.tui.requestRender();
475
445
  }
476
446
 
477
447
  private hasSelection(): boolean {
478
- return (
479
- this.selectionAnchor != null && this.selectionAnchor !== this.selected
480
- );
448
+ return this.navigation.hasSelection();
481
449
  }
482
450
 
483
451
  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
- };
452
+ return this.navigation.getSelectionBounds();
489
453
  }
490
454
 
491
455
  private getActiveCommentSelection(): SelectionBounds | undefined {
@@ -497,7 +461,7 @@ export class ReviewComponent {
497
461
  }
498
462
 
499
463
  private getSelectionKey(start: number, end: number): string {
500
- return `${this.lines[start]?.id ?? start}:${this.lines[end]?.id ?? end}`;
464
+ return getSelectionKey(this.lines, start, end);
501
465
  }
502
466
 
503
467
  private getCommentForSelection(
@@ -522,62 +486,13 @@ export class ReviewComponent {
522
486
  private ensureCommentLineKeys(): void {
523
487
  if (this.commentLineKeysRevision === this.commentsRevision) return;
524
488
 
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
-
489
+ this.commentLineKeys = buildCommentLineKeys(
490
+ this.comments,
491
+ this.lineIndexById,
492
+ );
542
493
  this.commentLineKeysRevision = this.commentsRevision;
543
494
  }
544
495
 
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
496
  private getPositionText(selectedLine?: ReviewLine): string {
582
497
  const position = `${Math.min(this.selected + 1, this.lines.length)}/${this.lines.length}`;
583
498
  return selectedLine?.filePath
@@ -621,7 +536,7 @@ export class ReviewComponent {
621
536
 
622
537
  private startGlobalEditMode(): void {
623
538
  const existing = this.comments.get(GLOBAL_COMMENT_KEY);
624
- this.rightPaneMode = "comments";
539
+ this.showCommentsPane();
625
540
  this.editMode = true;
626
541
  this.editingCommentKey = GLOBAL_COMMENT_KEY;
627
542
  this.editor.setText(existing?.text ?? "");
@@ -637,7 +552,7 @@ export class ReviewComponent {
637
552
  if (startLine.filePath !== endLine.filePath) return;
638
553
 
639
554
  const existing = this.getCommentForSelection(selection);
640
- this.rightPaneMode = "comments";
555
+ this.showCommentsPane();
641
556
  this.editMode = true;
642
557
  this.editingCommentKey = this.getSelectionKey(
643
558
  selection.start,
@@ -657,57 +572,7 @@ export class ReviewComponent {
657
572
  private getSplitDiffRows(): SplitDiffRow[] {
658
573
  if (this.splitRows) return this.splitRows;
659
574
 
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
-
575
+ const { rows, rowByLineIndex } = buildSplitDiffRows(this.lines);
711
576
  this.splitRows = rows;
712
577
  this.splitRowByLineIndex = rowByLineIndex;
713
578
  return rows;
@@ -749,18 +614,10 @@ export class ReviewComponent {
749
614
  }
750
615
 
751
616
  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)),
617
+ this.navigation.ensureScroll(
618
+ viewportHeight,
619
+ this.getSelectedDisplayRow(),
620
+ this.getDisplayRowCount(),
764
621
  );
765
622
  }
766
623
 
@@ -858,169 +715,22 @@ export class ReviewComponent {
858
715
  height: number,
859
716
  selectedLine?: ReviewLine,
860
717
  ): string[] {
861
- const lines: string[] = [];
862
- const title = this.theme.fg("accent", this.theme.bold("Comments"));
863
718
  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);
719
+ return renderCommentsPaneContent({
720
+ width,
721
+ height,
722
+ selectedLine,
723
+ theme: this.theme,
724
+ lines: this.lines,
725
+ comments: this.comments,
726
+ editor: this.editor,
727
+ editMode: this.editMode,
728
+ editingCommentKey: this.editingCommentKey,
729
+ selection,
730
+ currentComment: this.getCommentForSelection(selection),
731
+ footerText: this.getFooterText(selectedLine),
732
+ hasSelection: this.hasSelection(),
733
+ });
1024
734
  }
1025
735
 
1026
736
  private renderExplanationPane(
@@ -1028,198 +738,21 @@ export class ReviewComponent {
1028
738
  height: number,
1029
739
  selectedLine?: ReviewLine,
1030
740
  ): 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);
741
+ return renderExplanationPaneContent({
742
+ width,
743
+ height,
744
+ selectedLine,
745
+ theme: this.theme,
746
+ scope: this.getCurrentHunkScope(),
747
+ controller: this.explanationController,
748
+ });
1115
749
  }
1116
750
 
1117
751
  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();
752
+ this.explanationController.ensure(this.getCurrentHunkScope());
1218
753
  }
1219
754
 
1220
- private stopLoadingTimer(): void {
1221
- if (!this.loadingTimer) return;
1222
- clearInterval(this.loadingTimer);
1223
- this.loadingTimer = undefined;
755
+ private getCurrentHunkScope() {
756
+ return getCurrentHunkScope(this.lines, this.selected);
1224
757
  }
1225
758
  }