pi-diff-review 0.1.6 → 0.1.8
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 +4 -4
- package/package.json +2 -1
- package/src/explain.ts +106 -0
- package/src/index.ts +2 -0
- package/src/prompt.ts +2 -0
- package/src/review-component.ts +326 -6
- package/src/types.ts +3 -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,10 +28,12 @@ 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
|
|
36
35
|
- `c` to add or edit a comment for the current line or selected range
|
|
36
|
+
- `C` to add or edit an overall diff comment
|
|
37
37
|
- `x` to delete a comment for the current line or selected range
|
|
38
38
|
- `Enter` to submit comments back to pi
|
|
39
39
|
- `q` to exit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-diff-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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/prompt.ts
CHANGED
|
@@ -18,6 +18,8 @@ export function formatLocation(line: {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function formatCommentLocation(comment: ReviewComment): string {
|
|
21
|
+
if (comment.global) return "Overall diff";
|
|
22
|
+
|
|
21
23
|
const start = formatLocation({
|
|
22
24
|
filePath: comment.filePath,
|
|
23
25
|
oldLineNumber: comment.startOldLineNumber,
|
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,
|
|
@@ -29,6 +35,8 @@ function lineNumberCell(value?: number): string {
|
|
|
29
35
|
return value == null ? " " : String(value).padStart(4, " ");
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
const GLOBAL_COMMENT_KEY = "__global_diff_comment__";
|
|
39
|
+
|
|
32
40
|
export class ReviewComponent {
|
|
33
41
|
private selected = 0;
|
|
34
42
|
private scrollTop = 0;
|
|
@@ -37,6 +45,12 @@ export class ReviewComponent {
|
|
|
37
45
|
private selectionAnchor?: number;
|
|
38
46
|
private layout: ReviewLayout = "side-by-side";
|
|
39
47
|
private diffRenderMode: DiffRenderMode = "unified";
|
|
48
|
+
private rightPaneMode: RightPaneMode = "comments";
|
|
49
|
+
private explanations = new Map<string, ExplanationState>();
|
|
50
|
+
private explanationAbort?: AbortController;
|
|
51
|
+
private explanationRequestId = 0;
|
|
52
|
+
private loadingFrame = 0;
|
|
53
|
+
private loadingTimer?: ReturnType<typeof setInterval>;
|
|
40
54
|
private editor: Editor;
|
|
41
55
|
private splitRows?: SplitDiffRow[];
|
|
42
56
|
private splitRowByLineIndex?: number[];
|
|
@@ -52,6 +66,7 @@ export class ReviewComponent {
|
|
|
52
66
|
private lines: ReviewLine[],
|
|
53
67
|
private comments: Map<string, ReviewComment>,
|
|
54
68
|
private done: (result: ReviewResult) => void,
|
|
69
|
+
private explainer?: DiffExplainer,
|
|
55
70
|
) {
|
|
56
71
|
const firstCommentable = this.lines.findIndex((line) => line.commentable);
|
|
57
72
|
this.selected = firstCommentable >= 0 ? firstCommentable : 0;
|
|
@@ -69,13 +84,28 @@ export class ReviewComponent {
|
|
|
69
84
|
});
|
|
70
85
|
|
|
71
86
|
this.editor.onSubmit = (value) => {
|
|
87
|
+
const trimmed = value.trim();
|
|
88
|
+
if (this.editingCommentKey === GLOBAL_COMMENT_KEY) {
|
|
89
|
+
if (!trimmed) {
|
|
90
|
+
if (this.comments.delete(GLOBAL_COMMENT_KEY))
|
|
91
|
+
this.markCommentsChanged();
|
|
92
|
+
} else {
|
|
93
|
+
this.comments.set(
|
|
94
|
+
GLOBAL_COMMENT_KEY,
|
|
95
|
+
this.buildGlobalComment(trimmed),
|
|
96
|
+
);
|
|
97
|
+
this.markCommentsChanged();
|
|
98
|
+
}
|
|
99
|
+
this.exitEditMode();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
72
103
|
const selection = this.getActiveCommentSelection();
|
|
73
104
|
if (!selection) {
|
|
74
105
|
this.exitEditMode();
|
|
75
106
|
return;
|
|
76
107
|
}
|
|
77
108
|
|
|
78
|
-
const trimmed = value.trim();
|
|
79
109
|
const key = this.getSelectionKey(selection.start, selection.end);
|
|
80
110
|
if (!trimmed) {
|
|
81
111
|
if (this.comments.delete(key)) this.markCommentsChanged();
|
|
@@ -118,6 +148,10 @@ export class ReviewComponent {
|
|
|
118
148
|
this.toggleDiffRenderMode();
|
|
119
149
|
return;
|
|
120
150
|
}
|
|
151
|
+
if (data === "?") {
|
|
152
|
+
this.toggleExplanationPane();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
121
155
|
if (matchesKey(data, "ctrl+d")) {
|
|
122
156
|
this.move(this.getPageMoveAmount());
|
|
123
157
|
return;
|
|
@@ -166,6 +200,10 @@ export class ReviewComponent {
|
|
|
166
200
|
this.startEditMode();
|
|
167
201
|
return;
|
|
168
202
|
}
|
|
203
|
+
if (data === "C") {
|
|
204
|
+
this.startGlobalEditMode();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
169
207
|
if (matchesKey(data, "enter")) {
|
|
170
208
|
const comments = [...this.comments.values()].sort((a, b) =>
|
|
171
209
|
a.id.localeCompare(b.id),
|
|
@@ -187,8 +225,8 @@ export class ReviewComponent {
|
|
|
187
225
|
this.editMode
|
|
188
226
|
? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
|
|
189
227
|
: this.hasSelection()
|
|
190
|
-
? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • Enter submit`
|
|
191
|
-
: `${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`,
|
|
228
|
+
? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • C overall comment • Enter submit`
|
|
229
|
+
: `${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`,
|
|
192
230
|
),
|
|
193
231
|
width,
|
|
194
232
|
),
|
|
@@ -352,6 +390,11 @@ export class ReviewComponent {
|
|
|
352
390
|
|
|
353
391
|
invalidate(): void {}
|
|
354
392
|
|
|
393
|
+
dispose(): void {
|
|
394
|
+
this.explanationAbort?.abort();
|
|
395
|
+
this.stopLoadingTimer();
|
|
396
|
+
}
|
|
397
|
+
|
|
355
398
|
private move(delta: number): void {
|
|
356
399
|
const next = Math.max(
|
|
357
400
|
0,
|
|
@@ -386,6 +429,15 @@ export class ReviewComponent {
|
|
|
386
429
|
this.tui.requestRender(true);
|
|
387
430
|
}
|
|
388
431
|
|
|
432
|
+
private toggleExplanationPane(): void {
|
|
433
|
+
this.rightPaneMode =
|
|
434
|
+
this.rightPaneMode === "comments" ? "explanation" : "comments";
|
|
435
|
+
if (this.rightPaneMode === "explanation") {
|
|
436
|
+
this.ensureCurrentExplanation();
|
|
437
|
+
}
|
|
438
|
+
this.tui.requestRender(true);
|
|
439
|
+
}
|
|
440
|
+
|
|
389
441
|
private getPageMoveAmount(): number {
|
|
390
442
|
const contentHeight = this.getContentHeight();
|
|
391
443
|
const diffHeight =
|
|
@@ -481,6 +533,18 @@ export class ReviewComponent {
|
|
|
481
533
|
this.commentLineKeysRevision = this.commentsRevision;
|
|
482
534
|
}
|
|
483
535
|
|
|
536
|
+
private buildGlobalComment(text: string): ReviewComment {
|
|
537
|
+
return {
|
|
538
|
+
id: GLOBAL_COMMENT_KEY,
|
|
539
|
+
filePath: "Overall diff",
|
|
540
|
+
text,
|
|
541
|
+
global: true,
|
|
542
|
+
startLineId: GLOBAL_COMMENT_KEY,
|
|
543
|
+
endLineId: GLOBAL_COMMENT_KEY,
|
|
544
|
+
lineText: "",
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
484
548
|
private buildCommentFromSelection(
|
|
485
549
|
selection: SelectionBounds,
|
|
486
550
|
text: string,
|
|
@@ -546,6 +610,15 @@ export class ReviewComponent {
|
|
|
546
610
|
this.tui.requestRender();
|
|
547
611
|
}
|
|
548
612
|
|
|
613
|
+
private startGlobalEditMode(): void {
|
|
614
|
+
const existing = this.comments.get(GLOBAL_COMMENT_KEY);
|
|
615
|
+
this.rightPaneMode = "comments";
|
|
616
|
+
this.editMode = true;
|
|
617
|
+
this.editingCommentKey = GLOBAL_COMMENT_KEY;
|
|
618
|
+
this.editor.setText(existing?.text ?? "");
|
|
619
|
+
this.tui.requestRender(true);
|
|
620
|
+
}
|
|
621
|
+
|
|
549
622
|
private startEditMode(): void {
|
|
550
623
|
const selection = this.getActiveCommentSelection();
|
|
551
624
|
if (!selection) return;
|
|
@@ -555,6 +628,7 @@ export class ReviewComponent {
|
|
|
555
628
|
if (startLine.filePath !== endLine.filePath) return;
|
|
556
629
|
|
|
557
630
|
const existing = this.getCommentForSelection(selection);
|
|
631
|
+
this.rightPaneMode = "comments";
|
|
558
632
|
this.editMode = true;
|
|
559
633
|
this.editingCommentKey = this.getSelectionKey(
|
|
560
634
|
selection.start,
|
|
@@ -746,6 +820,16 @@ export class ReviewComponent {
|
|
|
746
820
|
width: number,
|
|
747
821
|
height: number,
|
|
748
822
|
selectedLine?: ReviewLine,
|
|
823
|
+
): string[] {
|
|
824
|
+
return this.rightPaneMode === "explanation"
|
|
825
|
+
? this.renderExplanationPane(width, height, selectedLine)
|
|
826
|
+
: this.renderCommentsPane(width, height, selectedLine);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private renderCommentsPane(
|
|
830
|
+
width: number,
|
|
831
|
+
height: number,
|
|
832
|
+
selectedLine?: ReviewLine,
|
|
749
833
|
): string[] {
|
|
750
834
|
const lines: string[] = [];
|
|
751
835
|
const title = this.theme.fg("accent", this.theme.bold("Comments"));
|
|
@@ -768,6 +852,42 @@ export class ReviewComponent {
|
|
|
768
852
|
);
|
|
769
853
|
lines.push("");
|
|
770
854
|
|
|
855
|
+
if (this.editMode && this.editingCommentKey === GLOBAL_COMMENT_KEY) {
|
|
856
|
+
lines[1] = truncateToWidth(
|
|
857
|
+
this.theme.fg("dim", "Overall diff comment"),
|
|
858
|
+
width,
|
|
859
|
+
);
|
|
860
|
+
lines.push(
|
|
861
|
+
...wrapTextWithAnsi(
|
|
862
|
+
this.theme.fg(
|
|
863
|
+
"dim",
|
|
864
|
+
"Editing overall diff comment. Enter saves. Esc or Ctrl+C cancels.",
|
|
865
|
+
),
|
|
866
|
+
width,
|
|
867
|
+
),
|
|
868
|
+
);
|
|
869
|
+
lines.push("");
|
|
870
|
+
for (const line of this.editor.render(Math.max(10, width))) {
|
|
871
|
+
lines.push(truncateToWidth(line, width));
|
|
872
|
+
}
|
|
873
|
+
return lines.slice(0, height);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const globalComment = this.comments.get(GLOBAL_COMMENT_KEY);
|
|
877
|
+
if (globalComment) {
|
|
878
|
+
lines.push(
|
|
879
|
+
truncateToWidth(
|
|
880
|
+
this.theme.fg("accent", this.theme.bold("Overall diff comment")),
|
|
881
|
+
width,
|
|
882
|
+
),
|
|
883
|
+
);
|
|
884
|
+
lines.push(
|
|
885
|
+
...wrapTextWithAnsi(this.theme.fg("text", globalComment.text), width),
|
|
886
|
+
);
|
|
887
|
+
lines.push(...wrapTextWithAnsi(this.theme.fg("dim", "C edits"), width));
|
|
888
|
+
lines.push("");
|
|
889
|
+
}
|
|
890
|
+
|
|
771
891
|
if (!selectedLine) {
|
|
772
892
|
lines.push(
|
|
773
893
|
...wrapTextWithAnsi(
|
|
@@ -783,7 +903,7 @@ export class ReviewComponent {
|
|
|
783
903
|
...wrapTextWithAnsi(
|
|
784
904
|
this.theme.fg(
|
|
785
905
|
"muted",
|
|
786
|
-
"Move to a diff line and press c to add a comment.",
|
|
906
|
+
"Move to a diff line and press c to add a comment, or press C for an overall diff comment.",
|
|
787
907
|
),
|
|
788
908
|
width,
|
|
789
909
|
),
|
|
@@ -848,8 +968,8 @@ export class ReviewComponent {
|
|
|
848
968
|
this.theme.fg(
|
|
849
969
|
"dim",
|
|
850
970
|
this.hasSelection()
|
|
851
|
-
? "Press c to add a range comment."
|
|
852
|
-
: "Press c to add one. Use J/K to extend a range.",
|
|
971
|
+
? "Press c to add a range comment, or C for an overall diff comment."
|
|
972
|
+
: "Press c to add one. Use J/K to extend a range. Press C for an overall diff comment.",
|
|
853
973
|
),
|
|
854
974
|
width,
|
|
855
975
|
),
|
|
@@ -875,4 +995,204 @@ export class ReviewComponent {
|
|
|
875
995
|
);
|
|
876
996
|
return lines.slice(0, height);
|
|
877
997
|
}
|
|
998
|
+
|
|
999
|
+
private renderExplanationPane(
|
|
1000
|
+
width: number,
|
|
1001
|
+
height: number,
|
|
1002
|
+
selectedLine?: ReviewLine,
|
|
1003
|
+
): string[] {
|
|
1004
|
+
const lines: string[] = [];
|
|
1005
|
+
const title = this.theme.fg("accent", this.theme.bold("Explanation"));
|
|
1006
|
+
const scope = this.getCurrentHunkScope();
|
|
1007
|
+
|
|
1008
|
+
lines.push(truncateToWidth(title, width));
|
|
1009
|
+
lines.push(
|
|
1010
|
+
truncateToWidth(
|
|
1011
|
+
this.theme.fg(
|
|
1012
|
+
"dim",
|
|
1013
|
+
scope?.title ??
|
|
1014
|
+
(selectedLine ? formatLocation(selectedLine) : "No selection"),
|
|
1015
|
+
),
|
|
1016
|
+
width,
|
|
1017
|
+
),
|
|
1018
|
+
);
|
|
1019
|
+
lines.push("");
|
|
1020
|
+
|
|
1021
|
+
if (!this.explainer) {
|
|
1022
|
+
lines.push(
|
|
1023
|
+
...wrapTextWithAnsi(
|
|
1024
|
+
this.theme.fg("warning", "Diff explanations are unavailable."),
|
|
1025
|
+
width,
|
|
1026
|
+
),
|
|
1027
|
+
);
|
|
1028
|
+
return lines.slice(0, height);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (!scope) {
|
|
1032
|
+
lines.push(
|
|
1033
|
+
...wrapTextWithAnsi(
|
|
1034
|
+
this.theme.fg(
|
|
1035
|
+
"muted",
|
|
1036
|
+
"Move to a changed hunk and press ? to generate an explanation.",
|
|
1037
|
+
),
|
|
1038
|
+
width,
|
|
1039
|
+
),
|
|
1040
|
+
);
|
|
1041
|
+
return lines.slice(0, height);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const explanation = this.explanations.get(scope.key);
|
|
1045
|
+
if (!explanation) {
|
|
1046
|
+
lines.push(
|
|
1047
|
+
...wrapTextWithAnsi(
|
|
1048
|
+
this.theme.fg(
|
|
1049
|
+
"muted",
|
|
1050
|
+
"No explanation generated yet. Press ? again after returning to comments to generate this hunk.",
|
|
1051
|
+
),
|
|
1052
|
+
width,
|
|
1053
|
+
),
|
|
1054
|
+
);
|
|
1055
|
+
} else if (explanation.status === "loading") {
|
|
1056
|
+
const spinner = this.getLoadingFrame();
|
|
1057
|
+
lines.push(
|
|
1058
|
+
truncateToWidth(
|
|
1059
|
+
this.theme.fg("accent", `${spinner} Generating explanation...`),
|
|
1060
|
+
width,
|
|
1061
|
+
),
|
|
1062
|
+
);
|
|
1063
|
+
if (explanation.text.trim()) {
|
|
1064
|
+
lines.push("");
|
|
1065
|
+
lines.push(
|
|
1066
|
+
...wrapTextWithAnsi(this.theme.fg("text", explanation.text), width),
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
} else if (explanation.status === "error") {
|
|
1070
|
+
lines.push(
|
|
1071
|
+
...wrapTextWithAnsi(
|
|
1072
|
+
this.theme.fg(
|
|
1073
|
+
"warning",
|
|
1074
|
+
`Unable to explain diff: ${explanation.message}`,
|
|
1075
|
+
),
|
|
1076
|
+
width,
|
|
1077
|
+
),
|
|
1078
|
+
);
|
|
1079
|
+
} else {
|
|
1080
|
+
lines.push(
|
|
1081
|
+
...wrapTextWithAnsi(this.theme.fg("text", explanation.text), width),
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
lines.push("");
|
|
1086
|
+
lines.push(...wrapTextWithAnsi(this.theme.fg("dim", "? comments"), width));
|
|
1087
|
+
return lines.slice(0, height);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private ensureCurrentExplanation(): void {
|
|
1091
|
+
const scope = this.getCurrentHunkScope();
|
|
1092
|
+
if (!scope || !this.explainer) return;
|
|
1093
|
+
if (this.explanations.has(scope.key)) return;
|
|
1094
|
+
|
|
1095
|
+
this.explanationAbort?.abort();
|
|
1096
|
+
const controller = new AbortController();
|
|
1097
|
+
this.explanationAbort = controller;
|
|
1098
|
+
const requestId = ++this.explanationRequestId;
|
|
1099
|
+
let text = "";
|
|
1100
|
+
|
|
1101
|
+
this.explanations.set(scope.key, { status: "loading", text });
|
|
1102
|
+
this.startLoadingTimer();
|
|
1103
|
+
|
|
1104
|
+
void this.explainer
|
|
1105
|
+
.explain(scope, {
|
|
1106
|
+
signal: controller.signal,
|
|
1107
|
+
onDelta: (delta) => {
|
|
1108
|
+
if (requestId !== this.explanationRequestId) return;
|
|
1109
|
+
text += delta;
|
|
1110
|
+
this.explanations.set(scope.key, { status: "loading", text });
|
|
1111
|
+
this.tui.requestRender();
|
|
1112
|
+
},
|
|
1113
|
+
})
|
|
1114
|
+
.then((finalText) => {
|
|
1115
|
+
if (requestId !== this.explanationRequestId) return;
|
|
1116
|
+
this.explanations.set(scope.key, {
|
|
1117
|
+
status: "ready",
|
|
1118
|
+
text: finalText.trim() || text.trim() || "No explanation returned.",
|
|
1119
|
+
});
|
|
1120
|
+
})
|
|
1121
|
+
.catch((error) => {
|
|
1122
|
+
if (requestId !== this.explanationRequestId) return;
|
|
1123
|
+
if (controller.signal.aborted) return;
|
|
1124
|
+
this.explanations.set(scope.key, {
|
|
1125
|
+
status: "error",
|
|
1126
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1127
|
+
});
|
|
1128
|
+
})
|
|
1129
|
+
.finally(() => {
|
|
1130
|
+
if (requestId !== this.explanationRequestId) return;
|
|
1131
|
+
this.stopLoadingTimerIfIdle();
|
|
1132
|
+
this.tui.requestRender();
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
this.tui.requestRender();
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
private getCurrentHunkScope(): ExplanationScope | undefined {
|
|
1139
|
+
const selectedLine = this.lines[this.selected];
|
|
1140
|
+
if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
|
|
1141
|
+
|
|
1142
|
+
let start = this.selected;
|
|
1143
|
+
while (
|
|
1144
|
+
start > 0 &&
|
|
1145
|
+
this.lines[start - 1]?.filePath === selectedLine.filePath &&
|
|
1146
|
+
this.lines[start - 1]?.hunkLabel === selectedLine.hunkLabel
|
|
1147
|
+
) {
|
|
1148
|
+
start--;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
let end = this.selected;
|
|
1152
|
+
while (
|
|
1153
|
+
end + 1 < this.lines.length &&
|
|
1154
|
+
this.lines[end + 1]?.filePath === selectedLine.filePath &&
|
|
1155
|
+
this.lines[end + 1]?.hunkLabel === selectedLine.hunkLabel
|
|
1156
|
+
) {
|
|
1157
|
+
end++;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const diffText = this.lines
|
|
1161
|
+
.slice(start, end + 1)
|
|
1162
|
+
.map((line) => line.text)
|
|
1163
|
+
.join("\n");
|
|
1164
|
+
return {
|
|
1165
|
+
key: `hunk:${selectedLine.filePath}:${selectedLine.hunkLabel}:${start}:${end}`,
|
|
1166
|
+
kind: "hunk",
|
|
1167
|
+
title: `${selectedLine.filePath} ${selectedLine.hunkLabel}`,
|
|
1168
|
+
filePath: selectedLine.filePath,
|
|
1169
|
+
diffText,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
private getLoadingFrame(): string {
|
|
1174
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1175
|
+
return frames[this.loadingFrame % frames.length] ?? "⠋";
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
private startLoadingTimer(): void {
|
|
1179
|
+
if (this.loadingTimer) return;
|
|
1180
|
+
this.loadingTimer = setInterval(() => {
|
|
1181
|
+
this.loadingFrame++;
|
|
1182
|
+
this.tui.requestRender();
|
|
1183
|
+
}, 120);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
private stopLoadingTimerIfIdle(): void {
|
|
1187
|
+
const hasLoading = [...this.explanations.values()].some(
|
|
1188
|
+
(explanation) => explanation.status === "loading",
|
|
1189
|
+
);
|
|
1190
|
+
if (!hasLoading) this.stopLoadingTimer();
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
private stopLoadingTimer(): void {
|
|
1194
|
+
if (!this.loadingTimer) return;
|
|
1195
|
+
clearInterval(this.loadingTimer);
|
|
1196
|
+
this.loadingTimer = undefined;
|
|
1197
|
+
}
|
|
878
1198
|
}
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type ReviewComment = {
|
|
|
6
6
|
id: string;
|
|
7
7
|
filePath: string;
|
|
8
8
|
text: string;
|
|
9
|
+
global?: boolean;
|
|
9
10
|
startLineId: string;
|
|
10
11
|
endLineId: string;
|
|
11
12
|
startOldLineNumber?: number;
|
|
@@ -59,3 +60,5 @@ export type ReviewTui = {
|
|
|
59
60
|
};
|
|
60
61
|
|
|
61
62
|
export type ReviewTheme = Theme;
|
|
63
|
+
|
|
64
|
+
export type RightPaneMode = "comments" | "explanation";
|