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 +3 -4
- package/package.json +2 -1
- package/src/explain.ts +106 -0
- package/src/index.ts +2 -0
- package/src/review-component.ts +325 -35
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
# pi-diff-review
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Embedded code reviews directly directly within [pi](https://pi.dev/).
|
|
6
6
|
|
|
7
|
-
<img width="
|
|
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.
|
|
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
|
);
|
package/src/review-component.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
357
|
-
|
|
358
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
652
|
+
pushRow({ kind: "split", left: cell, right: cell });
|
|
569
653
|
} else {
|
|
570
|
-
|
|
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
|
-
|
|
581
|
-
|
|
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.
|
|
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
|
}
|