pi-diff-review 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  # pi-diff-review
4
4
 
5
- Easily provide code reviews directly within [pi](https://pi.dev/).
5
+ Embedded code reviews directly directly within [pi](https://pi.dev/).
6
6
 
7
- <img width="1095" height="853" alt="pi-diff-review-screenshot" src="https://github.com/user-attachments/assets/f1a39117-f2a6-4ee7-bf72-a299990ab1dd" />
7
+ <img width="1986" height="1556" alt="pi-diff-review-screenshot(1)" src="https://github.com/user-attachments/assets/3fd00163-5d19-489b-94ed-3d4816c6cad3" />
8
8
 
9
9
  ## Install
10
10
 
@@ -14,8 +14,6 @@ Install from npm:
14
14
  pi install npm:pi-diff-review
15
15
  ```
16
16
 
17
- Package: https://www.npmjs.com/package/pi-diff-review
18
-
19
17
  Or install directly from GitHub:
20
18
 
21
19
  ```bash
@@ -30,6 +28,7 @@ pi install https://github.com/cmpadden/pi-diff-review
30
28
  - `g/G` to jump to the top or bottom of the diff
31
29
  - `ctrl-u` / `ctrl-d` to move up/down by half a page
32
30
  - `t` toggles the diff between unified and side-by-side split rendering
31
+ - `?` toggles an AI-generated explanation for the current hunk
33
32
  - `J/K` to extend a highlighted selection into a comment range
34
33
  - `esc` clears the active selection, or exits review when no selection is active
35
34
  - `n/p` to jump hunks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,6 +49,7 @@
49
49
  ]
50
50
  },
51
51
  "dependencies": {
52
+ "@earendil-works/pi-ai": "^0.74.0",
52
53
  "@pierre/diffs": "^1.1.21"
53
54
  }
54
55
  }
package/src/explain.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { streamSimple } from "@earendil-works/pi-ai";
2
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import type { AssistantMessage, Context } from "@earendil-works/pi-ai";
4
+
5
+ export type ExplanationScope = {
6
+ key: string;
7
+ kind: "hunk";
8
+ title: string;
9
+ filePath?: string;
10
+ diffText: string;
11
+ };
12
+
13
+ export type ExplanationState =
14
+ | { status: "loading"; text: string }
15
+ | { status: "ready"; text: string }
16
+ | { status: "error"; message: string };
17
+
18
+ export type DiffExplainer = {
19
+ explain(
20
+ scope: ExplanationScope,
21
+ options?: {
22
+ signal?: AbortSignal;
23
+ onDelta?: (delta: string) => void;
24
+ },
25
+ ): Promise<string>;
26
+ };
27
+
28
+ export function buildExplanationPrompt(scope: ExplanationScope): string {
29
+ return `Explain this git diff hunk for a code reviewer.
30
+
31
+ Focus on:
32
+ - what changed
33
+ - why it matters
34
+ - behavioral, API, or test implications
35
+ - notable risks or edge cases
36
+
37
+ Keep it concise and practical. Do not suggest code changes unless they are directly relevant to understanding the diff.
38
+
39
+ \`\`\`diff
40
+ ${scope.diffText}
41
+ \`\`\``;
42
+ }
43
+
44
+ export class PiModelDiffExplainer implements DiffExplainer {
45
+ constructor(private ctx: ExtensionCommandContext) {}
46
+
47
+ async explain(
48
+ scope: ExplanationScope,
49
+ options: {
50
+ signal?: AbortSignal;
51
+ onDelta?: (delta: string) => void;
52
+ } = {},
53
+ ): Promise<string> {
54
+ const model = this.ctx.model;
55
+ if (!model) {
56
+ throw new Error("No model is selected.");
57
+ }
58
+
59
+ const auth = await this.ctx.modelRegistry.getApiKeyAndHeaders(model);
60
+ if (!auth.ok) {
61
+ throw new Error(auth.error);
62
+ }
63
+
64
+ const context: Context = {
65
+ systemPrompt:
66
+ "You explain code diffs clearly and concisely for code review. Focus on intent, behavior, and risk. Avoid restating every changed line.",
67
+ messages: [
68
+ {
69
+ role: "user",
70
+ content: buildExplanationPrompt(scope),
71
+ timestamp: Date.now(),
72
+ },
73
+ ],
74
+ };
75
+
76
+ let streamedText = "";
77
+ const stream = streamSimple(model, context, {
78
+ apiKey: auth.apiKey,
79
+ headers: auth.headers,
80
+ signal: options.signal,
81
+ maxTokens: 800,
82
+ reasoning: "minimal",
83
+ });
84
+
85
+ for await (const event of stream) {
86
+ if (event.type === "text_delta") {
87
+ streamedText += event.delta;
88
+ options.onDelta?.(event.delta);
89
+ } else if (event.type === "done") {
90
+ return extractText(event.message) || streamedText.trim();
91
+ } else if (event.type === "error") {
92
+ throw new Error(event.error.errorMessage ?? "Explanation failed.");
93
+ }
94
+ }
95
+
96
+ return streamedText.trim();
97
+ }
98
+ }
99
+
100
+ function extractText(message: AssistantMessage): string {
101
+ return message.content
102
+ .filter((content) => content.type === "text")
103
+ .map((content) => content.text)
104
+ .join("")
105
+ .trim();
106
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import { getDiff, parseDiffSource } from "./diff-source.ts";
6
6
  import { parseDiff } from "./diff-parser.ts";
7
+ import { PiModelDiffExplainer } from "./explain.ts";
7
8
  import { buildReviewPrompt } from "./prompt.ts";
8
9
  import { ReviewComponent } from "./review-component.ts";
9
10
  import type { DiffSource, ReviewComment, ReviewResult } from "./types.ts";
@@ -39,6 +40,7 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
39
40
  reviewLines,
40
41
  comments,
41
42
  done,
43
+ new PiModelDiffExplainer(ctx),
42
44
  );
43
45
  },
44
46
  );
@@ -5,6 +5,11 @@ import {
5
5
  visibleWidth,
6
6
  wrapTextWithAnsi,
7
7
  } from "@earendil-works/pi-tui";
8
+ import type {
9
+ DiffExplainer,
10
+ ExplanationScope,
11
+ ExplanationState,
12
+ } from "./explain.ts";
8
13
  import { formatLocation } from "./prompt.ts";
9
14
  import type {
10
15
  DiffRenderMode,
@@ -14,6 +19,7 @@ import type {
14
19
  ReviewResult,
15
20
  ReviewTheme,
16
21
  ReviewTui,
22
+ RightPaneMode,
17
23
  SelectionBounds,
18
24
  SplitDiffCell,
19
25
  SplitDiffRow,
@@ -37,7 +43,19 @@ export class ReviewComponent {
37
43
  private selectionAnchor?: number;
38
44
  private layout: ReviewLayout = "side-by-side";
39
45
  private diffRenderMode: DiffRenderMode = "unified";
46
+ private rightPaneMode: RightPaneMode = "comments";
47
+ private explanations = new Map<string, ExplanationState>();
48
+ private explanationAbort?: AbortController;
49
+ private explanationRequestId = 0;
50
+ private loadingFrame = 0;
51
+ private loadingTimer?: ReturnType<typeof setInterval>;
40
52
  private editor: Editor;
53
+ private splitRows?: SplitDiffRow[];
54
+ private splitRowByLineIndex?: number[];
55
+ private lineIndexById = new Map<string, number>();
56
+ private commentLineKeys = new Map<number, string[]>();
57
+ private commentsRevision = 0;
58
+ private commentLineKeysRevision = -1;
41
59
 
42
60
  constructor(
43
61
  private tui: ReviewTui,
@@ -46,9 +64,11 @@ export class ReviewComponent {
46
64
  private lines: ReviewLine[],
47
65
  private comments: Map<string, ReviewComment>,
48
66
  private done: (result: ReviewResult) => void,
67
+ private explainer?: DiffExplainer,
49
68
  ) {
50
69
  const firstCommentable = this.lines.findIndex((line) => line.commentable);
51
70
  this.selected = firstCommentable >= 0 ? firstCommentable : 0;
71
+ this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
52
72
 
53
73
  this.editor = new Editor(tui as never, {
54
74
  borderColor: (s) => theme.fg("accent", s),
@@ -71,12 +91,13 @@ export class ReviewComponent {
71
91
  const trimmed = value.trim();
72
92
  const key = this.getSelectionKey(selection.start, selection.end);
73
93
  if (!trimmed) {
74
- this.comments.delete(key);
94
+ if (this.comments.delete(key)) this.markCommentsChanged();
75
95
  } else {
76
96
  this.comments.set(
77
97
  key,
78
98
  this.buildCommentFromSelection(selection, trimmed),
79
99
  );
100
+ this.markCommentsChanged();
80
101
  }
81
102
 
82
103
  this.exitEditMode();
@@ -110,6 +131,10 @@ export class ReviewComponent {
110
131
  this.toggleDiffRenderMode();
111
132
  return;
112
133
  }
134
+ if (data === "?") {
135
+ this.toggleExplanationPane();
136
+ return;
137
+ }
113
138
  if (matchesKey(data, "ctrl+d")) {
114
139
  this.move(this.getPageMoveAmount());
115
140
  return;
@@ -180,7 +205,7 @@ export class ReviewComponent {
180
205
  ? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
181
206
  : this.hasSelection()
182
207
  ? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • Enter submit`
183
- : `${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 • J/K extend • c comment • x delete • n/p hunk • Enter submit • q quit`,
208
+ : `${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 • x delete • n/p hunk • Enter submit • q quit`,
184
209
  ),
185
210
  width,
186
211
  ),
@@ -276,7 +301,7 @@ export class ReviewComponent {
276
301
  }
277
302
 
278
303
  private renderSplitDiffRows(width: number, height: number): string[] {
279
- const rows = this.buildSplitDiffRows();
304
+ const rows = this.getSplitDiffRows();
280
305
  const output: string[] = [];
281
306
  const separatorWidth = 3;
282
307
  const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
@@ -344,18 +369,31 @@ export class ReviewComponent {
344
369
 
345
370
  invalidate(): void {}
346
371
 
372
+ dispose(): void {
373
+ this.explanationAbort?.abort();
374
+ this.stopLoadingTimer();
375
+ }
376
+
347
377
  private move(delta: number): void {
348
- this.selected = Math.max(
378
+ const next = Math.max(
349
379
  0,
350
380
  Math.min(this.lines.length - 1, this.selected + delta),
351
381
  );
382
+ if (next === this.selected) return;
383
+ this.selected = next;
352
384
  this.tui.requestRender();
353
385
  }
354
386
 
355
387
  private jumpToBoundary(boundary: "start" | "end"): void {
356
- this.selected =
357
- boundary === "start" ? 0 : Math.max(0, this.lines.length - 1);
358
- this.clearSelection();
388
+ const next = boundary === "start" ? 0 : Math.max(0, this.lines.length - 1);
389
+ const hadSelection = this.selectionAnchor != null;
390
+ if (next === this.selected && !hadSelection) return;
391
+ this.selected = next;
392
+ if (hadSelection) {
393
+ this.clearSelection();
394
+ } else {
395
+ this.tui.requestRender();
396
+ }
359
397
  }
360
398
 
361
399
  private toggleLayout(): void {
@@ -370,6 +408,15 @@ export class ReviewComponent {
370
408
  this.tui.requestRender(true);
371
409
  }
372
410
 
411
+ private toggleExplanationPane(): void {
412
+ this.rightPaneMode =
413
+ this.rightPaneMode === "comments" ? "explanation" : "comments";
414
+ if (this.rightPaneMode === "explanation") {
415
+ this.ensureCurrentExplanation();
416
+ }
417
+ this.tui.requestRender(true);
418
+ }
419
+
373
420
  private getPageMoveAmount(): number {
374
421
  const contentHeight = this.getContentHeight();
375
422
  const diffHeight =
@@ -383,14 +430,17 @@ export class ReviewComponent {
383
430
  if (this.selectionAnchor == null) {
384
431
  this.selectionAnchor = this.selected;
385
432
  }
386
- this.selected = Math.max(
433
+ const next = Math.max(
387
434
  0,
388
435
  Math.min(this.lines.length - 1, this.selected + delta),
389
436
  );
437
+ if (next === this.selected) return;
438
+ this.selected = next;
390
439
  this.tui.requestRender();
391
440
  }
392
441
 
393
442
  private clearSelection(): void {
443
+ if (this.selectionAnchor == null) return;
394
444
  this.selectionAnchor = undefined;
395
445
  this.tui.requestRender();
396
446
  }
@@ -431,19 +481,35 @@ export class ReviewComponent {
431
481
  }
432
482
 
433
483
  private getCommentKeysForLine(index: number): string[] {
434
- const line = this.lines[index];
435
- if (!line) return [];
436
- return [...this.comments.entries()]
437
- .filter(([, comment]) => {
438
- const start = this.lines.findIndex(
439
- (item) => item.id === comment.startLineId,
440
- );
441
- const end = this.lines.findIndex(
442
- (item) => item.id === comment.endLineId,
443
- );
444
- return start !== -1 && end !== -1 && index >= start && index <= end;
445
- })
446
- .map(([key]) => key);
484
+ this.ensureCommentLineKeys();
485
+ return this.commentLineKeys.get(index) ?? [];
486
+ }
487
+
488
+ private markCommentsChanged(): void {
489
+ this.commentsRevision++;
490
+ }
491
+
492
+ private ensureCommentLineKeys(): void {
493
+ if (this.commentLineKeysRevision === this.commentsRevision) return;
494
+
495
+ this.commentLineKeys = new Map<number, string[]>();
496
+ for (const [key, comment] of this.comments) {
497
+ const start = this.lineIndexById.get(comment.startLineId);
498
+ const end = this.lineIndexById.get(comment.endLineId);
499
+ if (start == null || end == null) continue;
500
+ const from = Math.min(start, end);
501
+ const to = Math.max(start, end);
502
+ for (let index = from; index <= to; index++) {
503
+ const keys = this.commentLineKeys.get(index);
504
+ if (keys) {
505
+ keys.push(key);
506
+ } else {
507
+ this.commentLineKeys.set(index, [key]);
508
+ }
509
+ }
510
+ }
511
+
512
+ this.commentLineKeysRevision = this.commentsRevision;
447
513
  }
448
514
 
449
515
  private buildCommentFromSelection(
@@ -503,7 +569,11 @@ export class ReviewComponent {
503
569
  private deleteComment(): void {
504
570
  const selection = this.getActiveCommentSelection();
505
571
  if (!selection) return;
506
- this.comments.delete(this.getSelectionKey(selection.start, selection.end));
572
+ if (
573
+ this.comments.delete(this.getSelectionKey(selection.start, selection.end))
574
+ ) {
575
+ this.markCommentsChanged();
576
+ }
507
577
  this.tui.requestRender();
508
578
  }
509
579
 
@@ -532,10 +602,24 @@ export class ReviewComponent {
532
602
  this.tui.requestRender(true);
533
603
  }
534
604
 
535
- private buildSplitDiffRows(): SplitDiffRow[] {
605
+ private getSplitDiffRows(): SplitDiffRow[] {
606
+ if (this.splitRows) return this.splitRows;
607
+
536
608
  const rows: SplitDiffRow[] = [];
609
+ const rowByLineIndex: number[] = [];
537
610
  let index = 0;
538
611
 
612
+ const pushRow = (row: SplitDiffRow) => {
613
+ const displayRow = rows.length;
614
+ rows.push(row);
615
+ if (row.kind === "full") {
616
+ rowByLineIndex[row.cell.index] = displayRow;
617
+ } else {
618
+ if (row.left) rowByLineIndex[row.left.index] = displayRow;
619
+ if (row.right) rowByLineIndex[row.right.index] = displayRow;
620
+ }
621
+ };
622
+
539
623
  while (index < this.lines.length) {
540
624
  const line = this.lines[index]!;
541
625
 
@@ -554,7 +638,7 @@ export class ReviewComponent {
554
638
 
555
639
  const count = Math.max(removals.length, additions.length);
556
640
  for (let offset = 0; offset < count; offset++) {
557
- rows.push({
641
+ pushRow({
558
642
  kind: "split",
559
643
  left: removals[offset],
560
644
  right: additions[offset],
@@ -565,32 +649,28 @@ export class ReviewComponent {
565
649
 
566
650
  if (line.kind === "context") {
567
651
  const cell = { line, index };
568
- rows.push({ kind: "split", left: cell, right: cell });
652
+ pushRow({ kind: "split", left: cell, right: cell });
569
653
  } else {
570
- rows.push({ kind: "full", cell: { line, index } });
654
+ pushRow({ kind: "full", cell: { line, index } });
571
655
  }
572
656
  index++;
573
657
  }
574
658
 
659
+ this.splitRows = rows;
660
+ this.splitRowByLineIndex = rowByLineIndex;
575
661
  return rows;
576
662
  }
577
663
 
578
664
  private getSelectedDisplayRow(): number {
579
665
  if (this.diffRenderMode === "unified") return this.selected;
580
- const rows = this.buildSplitDiffRows();
581
- const row = rows.findIndex((item) =>
582
- item.kind === "full"
583
- ? item.cell.index === this.selected
584
- : item.left?.index === this.selected ||
585
- item.right?.index === this.selected,
586
- );
587
- return row === -1 ? 0 : row;
666
+ this.getSplitDiffRows();
667
+ return this.splitRowByLineIndex?.[this.selected] ?? 0;
588
668
  }
589
669
 
590
670
  private getDisplayRowCount(): number {
591
671
  return this.diffRenderMode === "unified"
592
672
  ? this.lines.length
593
- : this.buildSplitDiffRows().length;
673
+ : this.getSplitDiffRows().length;
594
674
  }
595
675
 
596
676
  private renderSplitDiffCell(
@@ -697,6 +777,16 @@ export class ReviewComponent {
697
777
  width: number,
698
778
  height: number,
699
779
  selectedLine?: ReviewLine,
780
+ ): string[] {
781
+ return this.rightPaneMode === "explanation"
782
+ ? this.renderExplanationPane(width, height, selectedLine)
783
+ : this.renderCommentsPane(width, height, selectedLine);
784
+ }
785
+
786
+ private renderCommentsPane(
787
+ width: number,
788
+ height: number,
789
+ selectedLine?: ReviewLine,
700
790
  ): string[] {
701
791
  const lines: string[] = [];
702
792
  const title = this.theme.fg("accent", this.theme.bold("Comments"));
@@ -826,4 +916,204 @@ export class ReviewComponent {
826
916
  );
827
917
  return lines.slice(0, height);
828
918
  }
919
+
920
+ private renderExplanationPane(
921
+ width: number,
922
+ height: number,
923
+ selectedLine?: ReviewLine,
924
+ ): string[] {
925
+ const lines: string[] = [];
926
+ const title = this.theme.fg("accent", this.theme.bold("Explanation"));
927
+ const scope = this.getCurrentHunkScope();
928
+
929
+ lines.push(truncateToWidth(title, width));
930
+ lines.push(
931
+ truncateToWidth(
932
+ this.theme.fg(
933
+ "dim",
934
+ scope?.title ??
935
+ (selectedLine ? formatLocation(selectedLine) : "No selection"),
936
+ ),
937
+ width,
938
+ ),
939
+ );
940
+ lines.push("");
941
+
942
+ if (!this.explainer) {
943
+ lines.push(
944
+ ...wrapTextWithAnsi(
945
+ this.theme.fg("warning", "Diff explanations are unavailable."),
946
+ width,
947
+ ),
948
+ );
949
+ return lines.slice(0, height);
950
+ }
951
+
952
+ if (!scope) {
953
+ lines.push(
954
+ ...wrapTextWithAnsi(
955
+ this.theme.fg(
956
+ "muted",
957
+ "Move to a changed hunk and press ? to generate an explanation.",
958
+ ),
959
+ width,
960
+ ),
961
+ );
962
+ return lines.slice(0, height);
963
+ }
964
+
965
+ const explanation = this.explanations.get(scope.key);
966
+ if (!explanation) {
967
+ lines.push(
968
+ ...wrapTextWithAnsi(
969
+ this.theme.fg(
970
+ "muted",
971
+ "No explanation generated yet. Press ? again after returning to comments to generate this hunk.",
972
+ ),
973
+ width,
974
+ ),
975
+ );
976
+ } else if (explanation.status === "loading") {
977
+ const spinner = this.getLoadingFrame();
978
+ lines.push(
979
+ truncateToWidth(
980
+ this.theme.fg("accent", `${spinner} Generating explanation...`),
981
+ width,
982
+ ),
983
+ );
984
+ if (explanation.text.trim()) {
985
+ lines.push("");
986
+ lines.push(
987
+ ...wrapTextWithAnsi(this.theme.fg("text", explanation.text), width),
988
+ );
989
+ }
990
+ } else if (explanation.status === "error") {
991
+ lines.push(
992
+ ...wrapTextWithAnsi(
993
+ this.theme.fg(
994
+ "warning",
995
+ `Unable to explain diff: ${explanation.message}`,
996
+ ),
997
+ width,
998
+ ),
999
+ );
1000
+ } else {
1001
+ lines.push(
1002
+ ...wrapTextWithAnsi(this.theme.fg("text", explanation.text), width),
1003
+ );
1004
+ }
1005
+
1006
+ lines.push("");
1007
+ lines.push(...wrapTextWithAnsi(this.theme.fg("dim", "? comments"), width));
1008
+ return lines.slice(0, height);
1009
+ }
1010
+
1011
+ private ensureCurrentExplanation(): void {
1012
+ const scope = this.getCurrentHunkScope();
1013
+ if (!scope || !this.explainer) return;
1014
+ if (this.explanations.has(scope.key)) return;
1015
+
1016
+ this.explanationAbort?.abort();
1017
+ const controller = new AbortController();
1018
+ this.explanationAbort = controller;
1019
+ const requestId = ++this.explanationRequestId;
1020
+ let text = "";
1021
+
1022
+ this.explanations.set(scope.key, { status: "loading", text });
1023
+ this.startLoadingTimer();
1024
+
1025
+ void this.explainer
1026
+ .explain(scope, {
1027
+ signal: controller.signal,
1028
+ onDelta: (delta) => {
1029
+ if (requestId !== this.explanationRequestId) return;
1030
+ text += delta;
1031
+ this.explanations.set(scope.key, { status: "loading", text });
1032
+ this.tui.requestRender();
1033
+ },
1034
+ })
1035
+ .then((finalText) => {
1036
+ if (requestId !== this.explanationRequestId) return;
1037
+ this.explanations.set(scope.key, {
1038
+ status: "ready",
1039
+ text: finalText.trim() || text.trim() || "No explanation returned.",
1040
+ });
1041
+ })
1042
+ .catch((error) => {
1043
+ if (requestId !== this.explanationRequestId) return;
1044
+ if (controller.signal.aborted) return;
1045
+ this.explanations.set(scope.key, {
1046
+ status: "error",
1047
+ message: error instanceof Error ? error.message : String(error),
1048
+ });
1049
+ })
1050
+ .finally(() => {
1051
+ if (requestId !== this.explanationRequestId) return;
1052
+ this.stopLoadingTimerIfIdle();
1053
+ this.tui.requestRender();
1054
+ });
1055
+
1056
+ this.tui.requestRender();
1057
+ }
1058
+
1059
+ private getCurrentHunkScope(): ExplanationScope | undefined {
1060
+ const selectedLine = this.lines[this.selected];
1061
+ if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
1062
+
1063
+ let start = this.selected;
1064
+ while (
1065
+ start > 0 &&
1066
+ this.lines[start - 1]?.filePath === selectedLine.filePath &&
1067
+ this.lines[start - 1]?.hunkLabel === selectedLine.hunkLabel
1068
+ ) {
1069
+ start--;
1070
+ }
1071
+
1072
+ let end = this.selected;
1073
+ while (
1074
+ end + 1 < this.lines.length &&
1075
+ this.lines[end + 1]?.filePath === selectedLine.filePath &&
1076
+ this.lines[end + 1]?.hunkLabel === selectedLine.hunkLabel
1077
+ ) {
1078
+ end++;
1079
+ }
1080
+
1081
+ const diffText = this.lines
1082
+ .slice(start, end + 1)
1083
+ .map((line) => line.text)
1084
+ .join("\n");
1085
+ return {
1086
+ key: `hunk:${selectedLine.filePath}:${selectedLine.hunkLabel}:${start}:${end}`,
1087
+ kind: "hunk",
1088
+ title: `${selectedLine.filePath} ${selectedLine.hunkLabel}`,
1089
+ filePath: selectedLine.filePath,
1090
+ diffText,
1091
+ };
1092
+ }
1093
+
1094
+ private getLoadingFrame(): string {
1095
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1096
+ return frames[this.loadingFrame % frames.length] ?? "⠋";
1097
+ }
1098
+
1099
+ private startLoadingTimer(): void {
1100
+ if (this.loadingTimer) return;
1101
+ this.loadingTimer = setInterval(() => {
1102
+ this.loadingFrame++;
1103
+ this.tui.requestRender();
1104
+ }, 120);
1105
+ }
1106
+
1107
+ private stopLoadingTimerIfIdle(): void {
1108
+ const hasLoading = [...this.explanations.values()].some(
1109
+ (explanation) => explanation.status === "loading",
1110
+ );
1111
+ if (!hasLoading) this.stopLoadingTimer();
1112
+ }
1113
+
1114
+ private stopLoadingTimer(): void {
1115
+ if (!this.loadingTimer) return;
1116
+ clearInterval(this.loadingTimer);
1117
+ this.loadingTimer = undefined;
1118
+ }
829
1119
  }
package/src/types.ts CHANGED
@@ -59,3 +59,5 @@ export type ReviewTui = {
59
59
  };
60
60
 
61
61
  export type ReviewTheme = Theme;
62
+
63
+ export type RightPaneMode = "comments" | "explanation";