pi-diff-review 0.1.17 → 0.1.18
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/package.json +1 -1
- package/src/explanation/controller.ts +62 -4
- package/src/explanation/explainer.ts +12 -1
- package/src/review/component.ts +141 -25
package/package.json
CHANGED
|
@@ -49,6 +49,9 @@ export class ExplanationController {
|
|
|
49
49
|
readonly explanations = new Map<string, ExplanationState>();
|
|
50
50
|
private abortController?: AbortController;
|
|
51
51
|
private requestId = 0;
|
|
52
|
+
private askState: ExplanationState | undefined;
|
|
53
|
+
private askAbortController?: AbortController;
|
|
54
|
+
private askRequestId = 0;
|
|
52
55
|
private loadingFrame = 0;
|
|
53
56
|
private loadingTimer?: ReturnType<typeof setInterval>;
|
|
54
57
|
|
|
@@ -79,6 +82,60 @@ export class ExplanationController {
|
|
|
79
82
|
return LOADING_FRAMES[this.loadingFrame % LOADING_FRAMES.length] ?? "⠋";
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
ask(scope: ExplanationScope, question: string): void {
|
|
86
|
+
if (!this.explainer) return;
|
|
87
|
+
this.askAbortController?.abort();
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
this.askAbortController = controller;
|
|
90
|
+
const requestId = ++this.askRequestId;
|
|
91
|
+
let text = "";
|
|
92
|
+
this.askState = { status: "loading", text };
|
|
93
|
+
this.startLoadingTimer();
|
|
94
|
+
|
|
95
|
+
void this.explainer
|
|
96
|
+
.explain(scope, question, {
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
onDelta: (delta) => {
|
|
99
|
+
if (requestId !== this.askRequestId) return;
|
|
100
|
+
text += delta;
|
|
101
|
+
this.askState = { status: "loading", text };
|
|
102
|
+
this.tui.requestRender();
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
.then((finalText) => {
|
|
106
|
+
if (requestId !== this.askRequestId) return;
|
|
107
|
+
this.askState = {
|
|
108
|
+
status: "ready",
|
|
109
|
+
text: finalText.trim() || text.trim() || "No answer returned.",
|
|
110
|
+
};
|
|
111
|
+
})
|
|
112
|
+
.catch((error) => {
|
|
113
|
+
if (requestId !== this.askRequestId) return;
|
|
114
|
+
if (controller.signal.aborted) return;
|
|
115
|
+
this.askState = {
|
|
116
|
+
status: "error",
|
|
117
|
+
message: error instanceof Error ? error.message : String(error),
|
|
118
|
+
};
|
|
119
|
+
})
|
|
120
|
+
.finally(() => {
|
|
121
|
+
if (requestId !== this.askRequestId) return;
|
|
122
|
+
this.stopLoadingTimerIfIdle();
|
|
123
|
+
this.tui.requestRender();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
this.tui.requestRender();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
getAskState(): ExplanationState | undefined {
|
|
130
|
+
return this.askState;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
clearAsk(): void {
|
|
134
|
+
this.askAbortController?.abort();
|
|
135
|
+
this.askAbortController = undefined;
|
|
136
|
+
this.askState = undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
82
139
|
ensure(scope: ExplanationScope | undefined): void {
|
|
83
140
|
if (!scope || !this.explainer) return;
|
|
84
141
|
if (this.explanations.has(scope.key)) return;
|
|
@@ -93,7 +150,7 @@ export class ExplanationController {
|
|
|
93
150
|
this.startLoadingTimer();
|
|
94
151
|
|
|
95
152
|
void this.explainer
|
|
96
|
-
.explain(scope, {
|
|
153
|
+
.explain(scope, undefined, {
|
|
97
154
|
signal: controller.signal,
|
|
98
155
|
onDelta: (delta) => {
|
|
99
156
|
if (requestId !== this.requestId) return;
|
|
@@ -129,6 +186,7 @@ export class ExplanationController {
|
|
|
129
186
|
|
|
130
187
|
dispose(): void {
|
|
131
188
|
this.abortController?.abort();
|
|
189
|
+
this.askAbortController?.abort();
|
|
132
190
|
this.stopLoadingTimer();
|
|
133
191
|
}
|
|
134
192
|
|
|
@@ -153,9 +211,9 @@ export class ExplanationController {
|
|
|
153
211
|
}
|
|
154
212
|
|
|
155
213
|
private stopLoadingTimerIfIdle(): void {
|
|
156
|
-
const hasLoading =
|
|
157
|
-
(
|
|
158
|
-
|
|
214
|
+
const hasLoading =
|
|
215
|
+
[...this.explanations.values()].some((e) => e.status === "loading") ||
|
|
216
|
+
this.askState?.status === "loading";
|
|
159
217
|
if (!hasLoading) this.stopLoadingTimer();
|
|
160
218
|
}
|
|
161
219
|
|
|
@@ -18,6 +18,7 @@ export type ExplanationState =
|
|
|
18
18
|
export type DiffExplainer = {
|
|
19
19
|
explain(
|
|
20
20
|
scope: ExplanationScope,
|
|
21
|
+
question?: string,
|
|
21
22
|
options?: {
|
|
22
23
|
signal?: AbortSignal;
|
|
23
24
|
onDelta?: (delta: string) => void;
|
|
@@ -25,6 +26,13 @@ export type DiffExplainer = {
|
|
|
25
26
|
): Promise<string>;
|
|
26
27
|
};
|
|
27
28
|
|
|
29
|
+
export function buildAskPrompt(
|
|
30
|
+
scope: ExplanationScope,
|
|
31
|
+
question: string,
|
|
32
|
+
): string {
|
|
33
|
+
return `Given this git diff hunk:\n\`\`\`diff\n${scope.diffText}\n\`\`\`\n\n${question}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
export function buildExplanationPrompt(scope: ExplanationScope): string {
|
|
29
37
|
return `Explain this git diff hunk for a code reviewer.
|
|
30
38
|
|
|
@@ -46,6 +54,7 @@ export class PiModelDiffExplainer implements DiffExplainer {
|
|
|
46
54
|
|
|
47
55
|
async explain(
|
|
48
56
|
scope: ExplanationScope,
|
|
57
|
+
question?: string,
|
|
49
58
|
options: {
|
|
50
59
|
signal?: AbortSignal;
|
|
51
60
|
onDelta?: (delta: string) => void;
|
|
@@ -67,7 +76,9 @@ export class PiModelDiffExplainer implements DiffExplainer {
|
|
|
67
76
|
messages: [
|
|
68
77
|
{
|
|
69
78
|
role: "user",
|
|
70
|
-
content:
|
|
79
|
+
content: question
|
|
80
|
+
? buildAskPrompt(scope, question)
|
|
81
|
+
: buildExplanationPrompt(scope),
|
|
71
82
|
timestamp: Date.now(),
|
|
72
83
|
},
|
|
73
84
|
],
|
package/src/review/component.ts
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
visibleWidth,
|
|
10
10
|
wrapTextWithAnsi,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
DiffExplainer,
|
|
14
|
+
ExplanationScope,
|
|
15
|
+
ExplanationState,
|
|
16
|
+
} from "../explanation/explainer.ts";
|
|
13
17
|
import {
|
|
14
18
|
GLOBAL_COMMENT_KEY,
|
|
15
19
|
buildCommentFromSelection,
|
|
@@ -66,6 +70,7 @@ const HELP_COMMANDS = [
|
|
|
66
70
|
["t", "toggle inline comments and explanations"],
|
|
67
71
|
["v", "toggle unified or split rendering"],
|
|
68
72
|
["?", "toggle AI explanation for current hunk"],
|
|
73
|
+
["a", "ask a question about the current hunk"],
|
|
69
74
|
["Enter", "submit comments, save edits, or jump to search result"],
|
|
70
75
|
["Esc", "close help, cancel search/edit, clear selection, or exit"],
|
|
71
76
|
["q", "exit review"],
|
|
@@ -80,6 +85,8 @@ export class ReviewComponent {
|
|
|
80
85
|
|
|
81
86
|
private inlineAnnotationsVisible = true;
|
|
82
87
|
private visibleExplanationKeys = new Set<string>();
|
|
88
|
+
private askInputMode = false;
|
|
89
|
+
private askScope?: ExplanationScope;
|
|
83
90
|
private explanationController: ExplanationController;
|
|
84
91
|
private editor: Editor;
|
|
85
92
|
private splitRows?: SplitDiffRow[];
|
|
@@ -136,6 +143,19 @@ export class ReviewComponent {
|
|
|
136
143
|
});
|
|
137
144
|
|
|
138
145
|
this.editor.onSubmit = (value) => {
|
|
146
|
+
if (this.askInputMode) {
|
|
147
|
+
this.askInputMode = false;
|
|
148
|
+
this.editor.setText("");
|
|
149
|
+
const question = value.trim();
|
|
150
|
+
if (question && this.askScope) {
|
|
151
|
+
this.explanationController.ask(this.askScope, question);
|
|
152
|
+
} else {
|
|
153
|
+
this.askScope = undefined;
|
|
154
|
+
}
|
|
155
|
+
this.invalidateAnnotatedRows();
|
|
156
|
+
this.tui.requestRender(true);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
139
159
|
const trimmed = value.trim();
|
|
140
160
|
if (this.editingCommentKey === GLOBAL_COMMENT_KEY) {
|
|
141
161
|
if (!trimmed) {
|
|
@@ -210,13 +230,26 @@ export class ReviewComponent {
|
|
|
210
230
|
return;
|
|
211
231
|
}
|
|
212
232
|
|
|
233
|
+
if (this.askInputMode) {
|
|
234
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
235
|
+
this.exitAskInputMode();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.editor.handleInput(data);
|
|
239
|
+
this.invalidateAnnotatedRows();
|
|
240
|
+
this.tui.requestRender();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
213
244
|
if (this.search.mode) {
|
|
214
245
|
this.handleSearchInput(data);
|
|
215
246
|
return;
|
|
216
247
|
}
|
|
217
248
|
|
|
218
249
|
if (matchesKey(data, "escape")) {
|
|
219
|
-
if (this.
|
|
250
|
+
if (this.askScope) {
|
|
251
|
+
this.clearAsk();
|
|
252
|
+
} else if (this.search.query) {
|
|
220
253
|
this.clearSearch();
|
|
221
254
|
} else if (this.hasSelection()) {
|
|
222
255
|
this.clearSelection();
|
|
@@ -245,6 +278,10 @@ export class ReviewComponent {
|
|
|
245
278
|
this.toggleExplanationPane();
|
|
246
279
|
return;
|
|
247
280
|
}
|
|
281
|
+
if (data === "a") {
|
|
282
|
+
this.startAskMode();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
248
285
|
if (data === "/") {
|
|
249
286
|
this.startSearchMode();
|
|
250
287
|
return;
|
|
@@ -515,7 +552,9 @@ export class ReviewComponent {
|
|
|
515
552
|
this.inlineAnnotationsVisible &&
|
|
516
553
|
this.annotatedRowsVisibleExplanationCount ===
|
|
517
554
|
this.visibleExplanationKeys.size &&
|
|
518
|
-
this.visibleExplanationKeys.size === 0
|
|
555
|
+
this.visibleExplanationKeys.size === 0 &&
|
|
556
|
+
!this.askInputMode &&
|
|
557
|
+
!this.askScope
|
|
519
558
|
) {
|
|
520
559
|
return this.annotatedRows;
|
|
521
560
|
}
|
|
@@ -659,30 +698,41 @@ export class ReviewComponent {
|
|
|
659
698
|
width: number,
|
|
660
699
|
): void {
|
|
661
700
|
const scope = getCurrentHunkScope(this.lines, lineIndex);
|
|
662
|
-
if (!scope
|
|
701
|
+
if (!scope) return;
|
|
663
702
|
|
|
664
703
|
const end = this.getHunkEndIndex(lineIndex);
|
|
665
704
|
if (end !== lineIndex) return;
|
|
666
705
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
this.
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
706
|
+
if (this.visibleExplanationKeys.has(scope.key)) {
|
|
707
|
+
const explanation = this.explanationController.getState(scope);
|
|
708
|
+
if (!this.explanationController.isAvailable) {
|
|
709
|
+
this.pushExplanationBlock(rows, "Explanation unavailable.", width);
|
|
710
|
+
} else if (!explanation) {
|
|
711
|
+
this.pushExplanationBlock(rows, "No explanation generated yet.", width);
|
|
712
|
+
} else if (explanation.status === "loading") {
|
|
713
|
+
this.pushExplanationBlock(
|
|
714
|
+
rows,
|
|
715
|
+
`${this.explanationController.getLoadingFrame()} ${explanation.text || "Generating explanation..."}`,
|
|
716
|
+
width,
|
|
717
|
+
);
|
|
718
|
+
} else if (explanation.status === "error") {
|
|
719
|
+
this.pushExplanationBlock(
|
|
720
|
+
rows,
|
|
721
|
+
`Explanation failed: ${explanation.message}`,
|
|
722
|
+
width,
|
|
723
|
+
);
|
|
724
|
+
} else {
|
|
725
|
+
this.pushExplanationBlock(rows, explanation.text, width);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (this.askInputMode && this.askScope?.key === scope.key) {
|
|
730
|
+
this.pushInlineEditorBlock(rows, "Ask about this hunk", width);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (!this.askInputMode && this.askScope?.key === scope.key) {
|
|
734
|
+
const askState = this.explanationController.getAskState();
|
|
735
|
+
if (askState) this.pushAskBlock(rows, askState, width);
|
|
686
736
|
}
|
|
687
737
|
}
|
|
688
738
|
|
|
@@ -717,14 +767,39 @@ export class ReviewComponent {
|
|
|
717
767
|
rows.push({ kind: "comment", text: "", part: "bottom" });
|
|
718
768
|
}
|
|
719
769
|
|
|
770
|
+
private pushAskBlock(
|
|
771
|
+
rows: AnnotatedDiffRow[],
|
|
772
|
+
state: ExplanationState,
|
|
773
|
+
width: number,
|
|
774
|
+
): void {
|
|
775
|
+
if (state.status === "loading") {
|
|
776
|
+
this.pushExplanationBlock(
|
|
777
|
+
rows,
|
|
778
|
+
`${this.explanationController.getLoadingFrame()} ${state.text || "Generating answer..."}`,
|
|
779
|
+
width,
|
|
780
|
+
" 💬 Answer ",
|
|
781
|
+
);
|
|
782
|
+
} else if (state.status === "error") {
|
|
783
|
+
this.pushExplanationBlock(
|
|
784
|
+
rows,
|
|
785
|
+
`Failed: ${state.message}`,
|
|
786
|
+
width,
|
|
787
|
+
" 💬 Answer ",
|
|
788
|
+
);
|
|
789
|
+
} else {
|
|
790
|
+
this.pushExplanationBlock(rows, state.text, width, " 💬 Answer ");
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
720
794
|
private pushExplanationBlock(
|
|
721
795
|
rows: AnnotatedDiffRow[],
|
|
722
796
|
text: string,
|
|
723
797
|
width: number,
|
|
798
|
+
title = " ✨ Explanation ",
|
|
724
799
|
): void {
|
|
725
800
|
rows.push({
|
|
726
801
|
kind: "explanation",
|
|
727
|
-
text: this.theme.fg("accent",
|
|
802
|
+
text: this.theme.fg("accent", title),
|
|
728
803
|
part: "top",
|
|
729
804
|
});
|
|
730
805
|
const wrapped = wrapTextWithAnsi(
|
|
@@ -891,6 +966,36 @@ export class ReviewComponent {
|
|
|
891
966
|
|
|
892
967
|
dispose(): void {
|
|
893
968
|
this.explanationController.dispose();
|
|
969
|
+
this.clearAsk();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
private startAskMode(): void {
|
|
973
|
+
const scope = this.getCurrentHunkScope();
|
|
974
|
+
if (!scope) return;
|
|
975
|
+
this.clearAsk();
|
|
976
|
+
this.askScope = scope;
|
|
977
|
+
this.askInputMode = true;
|
|
978
|
+
this.inlineAnnotationsVisible = true;
|
|
979
|
+
this.editor.setText("");
|
|
980
|
+
this.invalidateAnnotatedRows();
|
|
981
|
+
this.tui.requestRender(true);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
private exitAskInputMode(): void {
|
|
985
|
+
this.askInputMode = false;
|
|
986
|
+
this.editor.setText("");
|
|
987
|
+
if (!this.explanationController.getAskState()) this.askScope = undefined;
|
|
988
|
+
this.invalidateAnnotatedRows();
|
|
989
|
+
this.tui.requestRender(true);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private clearAsk(): void {
|
|
993
|
+
this.askInputMode = false;
|
|
994
|
+
this.askScope = undefined;
|
|
995
|
+
this.explanationController.clearAsk();
|
|
996
|
+
this.editor.setText("");
|
|
997
|
+
this.invalidateAnnotatedRows();
|
|
998
|
+
this.tui.requestRender(true);
|
|
894
999
|
}
|
|
895
1000
|
|
|
896
1001
|
private move(delta: number): void {
|
|
@@ -1167,11 +1272,22 @@ export class ReviewComponent {
|
|
|
1167
1272
|
private ensureScroll(viewportHeight: number, width: number): void {
|
|
1168
1273
|
this.navigation.ensureScroll(
|
|
1169
1274
|
viewportHeight,
|
|
1170
|
-
this.
|
|
1275
|
+
this.getScrollTargetDisplayRow(width),
|
|
1171
1276
|
this.getDisplayRowCount(width),
|
|
1172
1277
|
);
|
|
1173
1278
|
}
|
|
1174
1279
|
|
|
1280
|
+
private getScrollTargetDisplayRow(width: number): number {
|
|
1281
|
+
if (this.editMode || this.askInputMode) {
|
|
1282
|
+
const editorRow = this.getAnnotatedRows(width).findIndex(
|
|
1283
|
+
(row) => row.kind === "editor",
|
|
1284
|
+
);
|
|
1285
|
+
if (editorRow >= 0) return editorRow;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
return this.getSelectedDisplayRow(width);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1175
1291
|
private getDisplayText(line: ReviewLine): string {
|
|
1176
1292
|
const raw =
|
|
1177
1293
|
line.kind === "add" || line.kind === "remove" || line.kind === "context"
|