pi-diff-review 0.1.17 → 0.1.19
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 +74 -4
- package/src/explanation/explainer.ts +12 -1
- package/src/index.ts +66 -0
- package/src/review/component.ts +229 -30
- package/src/review/types.ts +6 -0
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
|
|
|
@@ -59,12 +62,19 @@ export class ExplanationController {
|
|
|
59
62
|
private readonly onExplanationsChanged?: (
|
|
60
63
|
explanations: Map<string, string>,
|
|
61
64
|
) => void,
|
|
65
|
+
cachedAskText?: string,
|
|
66
|
+
private readonly onAskChanged?: (state?: ExplanationState) => void,
|
|
62
67
|
) {
|
|
63
68
|
for (const [key, text] of cachedExplanations) {
|
|
64
69
|
const trimmed = text.trim();
|
|
65
70
|
if (trimmed)
|
|
66
71
|
this.explanations.set(key, { status: "ready", text: trimmed });
|
|
67
72
|
}
|
|
73
|
+
|
|
74
|
+
const trimmedAskText = cachedAskText?.trim();
|
|
75
|
+
if (trimmedAskText) {
|
|
76
|
+
this.askState = { status: "ready", text: trimmedAskText };
|
|
77
|
+
}
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
get isAvailable(): boolean {
|
|
@@ -79,6 +89,65 @@ export class ExplanationController {
|
|
|
79
89
|
return LOADING_FRAMES[this.loadingFrame % LOADING_FRAMES.length] ?? "⠋";
|
|
80
90
|
}
|
|
81
91
|
|
|
92
|
+
ask(scope: ExplanationScope, question: string): void {
|
|
93
|
+
if (!this.explainer) return;
|
|
94
|
+
this.askAbortController?.abort();
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
this.askAbortController = controller;
|
|
97
|
+
const requestId = ++this.askRequestId;
|
|
98
|
+
let text = "";
|
|
99
|
+
this.askState = { status: "loading", text };
|
|
100
|
+
this.onAskChanged?.(this.askState);
|
|
101
|
+
this.startLoadingTimer();
|
|
102
|
+
|
|
103
|
+
void this.explainer
|
|
104
|
+
.explain(scope, question, {
|
|
105
|
+
signal: controller.signal,
|
|
106
|
+
onDelta: (delta) => {
|
|
107
|
+
if (requestId !== this.askRequestId) return;
|
|
108
|
+
text += delta;
|
|
109
|
+
this.askState = { status: "loading", text };
|
|
110
|
+
this.onAskChanged?.(this.askState);
|
|
111
|
+
this.tui.requestRender();
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
.then((finalText) => {
|
|
115
|
+
if (requestId !== this.askRequestId) return;
|
|
116
|
+
this.askState = {
|
|
117
|
+
status: "ready",
|
|
118
|
+
text: finalText.trim() || text.trim() || "No answer returned.",
|
|
119
|
+
};
|
|
120
|
+
this.onAskChanged?.(this.askState);
|
|
121
|
+
})
|
|
122
|
+
.catch((error) => {
|
|
123
|
+
if (requestId !== this.askRequestId) return;
|
|
124
|
+
if (controller.signal.aborted) return;
|
|
125
|
+
this.askState = {
|
|
126
|
+
status: "error",
|
|
127
|
+
message: error instanceof Error ? error.message : String(error),
|
|
128
|
+
};
|
|
129
|
+
this.onAskChanged?.(this.askState);
|
|
130
|
+
})
|
|
131
|
+
.finally(() => {
|
|
132
|
+
if (requestId !== this.askRequestId) return;
|
|
133
|
+
this.stopLoadingTimerIfIdle();
|
|
134
|
+
this.tui.requestRender();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
this.tui.requestRender();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getAskState(): ExplanationState | undefined {
|
|
141
|
+
return this.askState;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
clearAsk(): void {
|
|
145
|
+
this.askAbortController?.abort();
|
|
146
|
+
this.askAbortController = undefined;
|
|
147
|
+
this.askState = undefined;
|
|
148
|
+
this.onAskChanged?.(undefined);
|
|
149
|
+
}
|
|
150
|
+
|
|
82
151
|
ensure(scope: ExplanationScope | undefined): void {
|
|
83
152
|
if (!scope || !this.explainer) return;
|
|
84
153
|
if (this.explanations.has(scope.key)) return;
|
|
@@ -93,7 +162,7 @@ export class ExplanationController {
|
|
|
93
162
|
this.startLoadingTimer();
|
|
94
163
|
|
|
95
164
|
void this.explainer
|
|
96
|
-
.explain(scope, {
|
|
165
|
+
.explain(scope, undefined, {
|
|
97
166
|
signal: controller.signal,
|
|
98
167
|
onDelta: (delta) => {
|
|
99
168
|
if (requestId !== this.requestId) return;
|
|
@@ -129,6 +198,7 @@ export class ExplanationController {
|
|
|
129
198
|
|
|
130
199
|
dispose(): void {
|
|
131
200
|
this.abortController?.abort();
|
|
201
|
+
this.askAbortController?.abort();
|
|
132
202
|
this.stopLoadingTimer();
|
|
133
203
|
}
|
|
134
204
|
|
|
@@ -153,9 +223,9 @@ export class ExplanationController {
|
|
|
153
223
|
}
|
|
154
224
|
|
|
155
225
|
private stopLoadingTimerIfIdle(): void {
|
|
156
|
-
const hasLoading =
|
|
157
|
-
(
|
|
158
|
-
|
|
226
|
+
const hasLoading =
|
|
227
|
+
[...this.explanations.values()].some((e) => e.status === "loading") ||
|
|
228
|
+
this.askState?.status === "loading";
|
|
159
229
|
if (!hasLoading) this.stopLoadingTimer();
|
|
160
230
|
}
|
|
161
231
|
|
|
@@ -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/index.ts
CHANGED
|
@@ -10,12 +10,14 @@ import { buildReviewPrompt } from "./review/prompt.ts";
|
|
|
10
10
|
import { ReviewComponent } from "./review/component.ts";
|
|
11
11
|
import type {
|
|
12
12
|
DiffSource,
|
|
13
|
+
PersistedAsk,
|
|
13
14
|
ReviewComment,
|
|
14
15
|
ReviewResult,
|
|
15
16
|
} from "./review/types.ts";
|
|
16
17
|
|
|
17
18
|
const DIFF_REVIEW_CACHE_ENTRY = "pi-diff-review-cache";
|
|
18
19
|
const DIFF_REVIEW_EXPLANATION_CACHE_ENTRY = "pi-diff-review-explanation-cache";
|
|
20
|
+
const DIFF_REVIEW_ASK_CACHE_ENTRY = "pi-diff-review-ask-cache";
|
|
19
21
|
|
|
20
22
|
type DiffReviewCacheEntry = {
|
|
21
23
|
cacheKey: string;
|
|
@@ -29,6 +31,12 @@ type DiffExplanationCacheEntry = {
|
|
|
29
31
|
updatedAt: number;
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
type DiffAskCacheEntry = {
|
|
35
|
+
cacheKey: string;
|
|
36
|
+
ask?: PersistedAsk;
|
|
37
|
+
updatedAt: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
32
40
|
function getDiffCacheKey(
|
|
33
41
|
cwd: string,
|
|
34
42
|
source: DiffSource,
|
|
@@ -134,6 +142,56 @@ function persistCachedExplanations(
|
|
|
134
142
|
} satisfies DiffExplanationCacheEntry);
|
|
135
143
|
}
|
|
136
144
|
|
|
145
|
+
function getCachedAsk(
|
|
146
|
+
ctx: ExtensionCommandContext,
|
|
147
|
+
cacheKey: string,
|
|
148
|
+
): PersistedAsk | undefined {
|
|
149
|
+
let latest: DiffAskCacheEntry | undefined;
|
|
150
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
151
|
+
if (
|
|
152
|
+
entry.type !== "custom" ||
|
|
153
|
+
entry.customType !== DIFF_REVIEW_ASK_CACHE_ENTRY
|
|
154
|
+
) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const data = entry.data as Partial<DiffAskCacheEntry> | undefined;
|
|
159
|
+
if (data?.cacheKey !== cacheKey) continue;
|
|
160
|
+
|
|
161
|
+
const ask = data.ask;
|
|
162
|
+
const validAsk =
|
|
163
|
+
ask &&
|
|
164
|
+
typeof ask === "object" &&
|
|
165
|
+
typeof ask.scopeKey === "string" &&
|
|
166
|
+
typeof ask.anchorLineId === "string" &&
|
|
167
|
+
typeof ask.text === "string"
|
|
168
|
+
? ask
|
|
169
|
+
: undefined;
|
|
170
|
+
|
|
171
|
+
if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
|
|
172
|
+
latest = {
|
|
173
|
+
cacheKey: data.cacheKey,
|
|
174
|
+
ask: validAsk,
|
|
175
|
+
updatedAt: data.updatedAt ?? 0,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return latest?.ask;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function persistCachedAsk(
|
|
184
|
+
pi: ExtensionAPI,
|
|
185
|
+
cacheKey: string,
|
|
186
|
+
ask?: PersistedAsk,
|
|
187
|
+
): void {
|
|
188
|
+
pi.appendEntry(DIFF_REVIEW_ASK_CACHE_ENTRY, {
|
|
189
|
+
cacheKey,
|
|
190
|
+
ask,
|
|
191
|
+
updatedAt: Date.now(),
|
|
192
|
+
} satisfies DiffAskCacheEntry);
|
|
193
|
+
}
|
|
194
|
+
|
|
137
195
|
export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
138
196
|
pi.registerCommand("diff", {
|
|
139
197
|
description: "Review a git diff in a custom TUI (/diff [git diff args])",
|
|
@@ -158,6 +216,7 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
|
158
216
|
const cacheKey = getDiffCacheKey(ctx.cwd, source, diffText);
|
|
159
217
|
const comments = getCachedComments(ctx, cacheKey);
|
|
160
218
|
const explanations = getCachedExplanations(ctx, cacheKey);
|
|
219
|
+
const ask = getCachedAsk(ctx, cacheKey);
|
|
161
220
|
if (comments.size > 0) {
|
|
162
221
|
ctx.ui.notify(
|
|
163
222
|
`Restored ${comments.size} cached diff comment${comments.size === 1 ? "" : "s"}.`,
|
|
@@ -170,6 +229,9 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
|
170
229
|
"info",
|
|
171
230
|
);
|
|
172
231
|
}
|
|
232
|
+
if (ask) {
|
|
233
|
+
ctx.ui.notify("Restored cached ask answer.", "info");
|
|
234
|
+
}
|
|
173
235
|
|
|
174
236
|
const result = await ctx.ui.custom<ReviewResult>(
|
|
175
237
|
(tui, theme, _keybindings, done) => {
|
|
@@ -188,6 +250,10 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
|
188
250
|
(updatedExplanations) => {
|
|
189
251
|
persistCachedExplanations(pi, cacheKey, updatedExplanations);
|
|
190
252
|
},
|
|
253
|
+
ask,
|
|
254
|
+
(updatedAsk) => {
|
|
255
|
+
persistCachedAsk(pi, cacheKey, updatedAsk);
|
|
256
|
+
},
|
|
191
257
|
);
|
|
192
258
|
},
|
|
193
259
|
);
|
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,
|
|
@@ -28,6 +32,7 @@ import { padToWidth, lineNumberCell } from "../render/utils.ts";
|
|
|
28
32
|
import { buildSplitDiffRows } from "../diff/split.ts";
|
|
29
33
|
import type {
|
|
30
34
|
DiffRenderMode,
|
|
35
|
+
PersistedAsk,
|
|
31
36
|
ReviewComment,
|
|
32
37
|
ReviewLine,
|
|
33
38
|
ReviewResult,
|
|
@@ -54,6 +59,7 @@ type AnnotatedDiffRow =
|
|
|
54
59
|
const HELP_COMMANDS = [
|
|
55
60
|
["h", "show or hide this help"],
|
|
56
61
|
["j/k or arrows", "move selection"],
|
|
62
|
+
["PgUp / PgDown", "move up or down half a page"],
|
|
57
63
|
["ctrl-u / ctrl-d", "move up or down half a page"],
|
|
58
64
|
["g / G", "jump to top or bottom"],
|
|
59
65
|
["n / p", "jump to next or previous hunk"],
|
|
@@ -66,6 +72,7 @@ const HELP_COMMANDS = [
|
|
|
66
72
|
["t", "toggle inline comments and explanations"],
|
|
67
73
|
["v", "toggle unified or split rendering"],
|
|
68
74
|
["?", "toggle AI explanation for current hunk"],
|
|
75
|
+
["a", "ask a question about the current hunk"],
|
|
69
76
|
["Enter", "submit comments, save edits, or jump to search result"],
|
|
70
77
|
["Esc", "close help, cancel search/edit, clear selection, or exit"],
|
|
71
78
|
["q", "exit review"],
|
|
@@ -80,6 +87,10 @@ export class ReviewComponent {
|
|
|
80
87
|
|
|
81
88
|
private inlineAnnotationsVisible = true;
|
|
82
89
|
private visibleExplanationKeys = new Set<string>();
|
|
90
|
+
private explanationAnchorByScope = new Map<string, number>();
|
|
91
|
+
private askInputMode = false;
|
|
92
|
+
private askScope?: ExplanationScope;
|
|
93
|
+
private askAnchorIndex?: number;
|
|
83
94
|
private explanationController: ExplanationController;
|
|
84
95
|
private editor: Editor;
|
|
85
96
|
private splitRows?: SplitDiffRow[];
|
|
@@ -109,6 +120,8 @@ export class ReviewComponent {
|
|
|
109
120
|
private onCommentsChanged?: (comments: Map<string, ReviewComment>) => void,
|
|
110
121
|
cachedExplanations?: Map<string, string>,
|
|
111
122
|
private onExplanationsChanged?: (explanations: Map<string, string>) => void,
|
|
123
|
+
cachedAsk?: PersistedAsk,
|
|
124
|
+
private onAskChanged?: (ask?: PersistedAsk) => void,
|
|
112
125
|
) {
|
|
113
126
|
const firstCommentable = this.lines.findIndex((line) => line.commentable);
|
|
114
127
|
this.navigation = new ReviewNavigationState(
|
|
@@ -117,11 +130,36 @@ export class ReviewComponent {
|
|
|
117
130
|
);
|
|
118
131
|
this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
|
|
119
132
|
this.search = new ReviewSearchState(this.lines);
|
|
133
|
+
|
|
134
|
+
const restoredAsk = cachedAsk
|
|
135
|
+
? this.restorePersistedAsk(cachedAsk)
|
|
136
|
+
: undefined;
|
|
137
|
+
if (restoredAsk) {
|
|
138
|
+
this.askScope = restoredAsk.scope;
|
|
139
|
+
this.askAnchorIndex = restoredAsk.anchorIndex;
|
|
140
|
+
}
|
|
141
|
+
|
|
120
142
|
this.explanationController = new ExplanationController(
|
|
121
143
|
tui,
|
|
122
144
|
explainer,
|
|
123
145
|
cachedExplanations,
|
|
124
146
|
onExplanationsChanged,
|
|
147
|
+
restoredAsk ? cachedAsk?.text : undefined,
|
|
148
|
+
(state) => {
|
|
149
|
+
if (!state) {
|
|
150
|
+
this.onAskChanged?.(undefined);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (state.status !== "ready") return;
|
|
154
|
+
if (!this.askScope || this.askAnchorIndex == null) return;
|
|
155
|
+
const anchorLine = this.lines[this.askAnchorIndex];
|
|
156
|
+
if (!anchorLine) return;
|
|
157
|
+
this.onAskChanged?.({
|
|
158
|
+
scopeKey: this.askScope.key,
|
|
159
|
+
anchorLineId: anchorLine.id,
|
|
160
|
+
text: state.text,
|
|
161
|
+
});
|
|
162
|
+
},
|
|
125
163
|
);
|
|
126
164
|
|
|
127
165
|
this.editor = new Editor(tui as never, {
|
|
@@ -136,6 +174,20 @@ export class ReviewComponent {
|
|
|
136
174
|
});
|
|
137
175
|
|
|
138
176
|
this.editor.onSubmit = (value) => {
|
|
177
|
+
if (this.askInputMode) {
|
|
178
|
+
this.askInputMode = false;
|
|
179
|
+
this.editor.setText("");
|
|
180
|
+
const question = value.trim();
|
|
181
|
+
if (question && this.askScope) {
|
|
182
|
+
this.explanationController.ask(this.askScope, question);
|
|
183
|
+
} else {
|
|
184
|
+
this.askScope = undefined;
|
|
185
|
+
this.askAnchorIndex = undefined;
|
|
186
|
+
}
|
|
187
|
+
this.invalidateAnnotatedRows();
|
|
188
|
+
this.tui.requestRender(true);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
139
191
|
const trimmed = value.trim();
|
|
140
192
|
if (this.editingCommentKey === GLOBAL_COMMENT_KEY) {
|
|
141
193
|
if (!trimmed) {
|
|
@@ -210,13 +262,26 @@ export class ReviewComponent {
|
|
|
210
262
|
return;
|
|
211
263
|
}
|
|
212
264
|
|
|
265
|
+
if (this.askInputMode) {
|
|
266
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
267
|
+
this.exitAskInputMode();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
this.editor.handleInput(data);
|
|
271
|
+
this.invalidateAnnotatedRows();
|
|
272
|
+
this.tui.requestRender();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
213
276
|
if (this.search.mode) {
|
|
214
277
|
this.handleSearchInput(data);
|
|
215
278
|
return;
|
|
216
279
|
}
|
|
217
280
|
|
|
218
281
|
if (matchesKey(data, "escape")) {
|
|
219
|
-
if (this.
|
|
282
|
+
if (this.askScope) {
|
|
283
|
+
this.clearAsk();
|
|
284
|
+
} else if (this.search.query) {
|
|
220
285
|
this.clearSearch();
|
|
221
286
|
} else if (this.hasSelection()) {
|
|
222
287
|
this.clearSelection();
|
|
@@ -226,7 +291,11 @@ export class ReviewComponent {
|
|
|
226
291
|
return;
|
|
227
292
|
}
|
|
228
293
|
if (data === "q") {
|
|
229
|
-
this.
|
|
294
|
+
if (this.hasSelection()) {
|
|
295
|
+
this.clearSelection();
|
|
296
|
+
} else {
|
|
297
|
+
this.done({ action: "cancel" });
|
|
298
|
+
}
|
|
230
299
|
return;
|
|
231
300
|
}
|
|
232
301
|
if (data === "h") {
|
|
@@ -245,15 +314,19 @@ export class ReviewComponent {
|
|
|
245
314
|
this.toggleExplanationPane();
|
|
246
315
|
return;
|
|
247
316
|
}
|
|
317
|
+
if (data === "a") {
|
|
318
|
+
this.startAskMode();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
248
321
|
if (data === "/") {
|
|
249
322
|
this.startSearchMode();
|
|
250
323
|
return;
|
|
251
324
|
}
|
|
252
|
-
if (matchesKey(data, "ctrl+d")) {
|
|
325
|
+
if (matchesKey(data, "pageDown") || matchesKey(data, "ctrl+d")) {
|
|
253
326
|
this.move(this.getPageMoveAmount());
|
|
254
327
|
return;
|
|
255
328
|
}
|
|
256
|
-
if (matchesKey(data, "ctrl+u")) {
|
|
329
|
+
if (matchesKey(data, "pageUp") || matchesKey(data, "ctrl+u")) {
|
|
257
330
|
this.move(-this.getPageMoveAmount());
|
|
258
331
|
return;
|
|
259
332
|
}
|
|
@@ -515,7 +588,9 @@ export class ReviewComponent {
|
|
|
515
588
|
this.inlineAnnotationsVisible &&
|
|
516
589
|
this.annotatedRowsVisibleExplanationCount ===
|
|
517
590
|
this.visibleExplanationKeys.size &&
|
|
518
|
-
this.visibleExplanationKeys.size === 0
|
|
591
|
+
this.visibleExplanationKeys.size === 0 &&
|
|
592
|
+
!this.askInputMode &&
|
|
593
|
+
!this.askScope
|
|
519
594
|
) {
|
|
520
595
|
return this.annotatedRows;
|
|
521
596
|
}
|
|
@@ -659,30 +734,49 @@ export class ReviewComponent {
|
|
|
659
734
|
width: number,
|
|
660
735
|
): void {
|
|
661
736
|
const scope = getCurrentHunkScope(this.lines, lineIndex);
|
|
662
|
-
if (!scope
|
|
737
|
+
if (!scope) return;
|
|
663
738
|
|
|
664
|
-
|
|
665
|
-
|
|
739
|
+
if (
|
|
740
|
+
this.visibleExplanationKeys.has(scope.key) &&
|
|
741
|
+
this.getExplanationAnchorIndex(lineIndex, scope.key) === lineIndex
|
|
742
|
+
) {
|
|
743
|
+
const explanation = this.explanationController.getState(scope);
|
|
744
|
+
if (!this.explanationController.isAvailable) {
|
|
745
|
+
this.pushExplanationBlock(rows, "Explanation unavailable.", width);
|
|
746
|
+
} else if (!explanation) {
|
|
747
|
+
this.pushExplanationBlock(rows, "No explanation generated yet.", width);
|
|
748
|
+
} else if (explanation.status === "loading") {
|
|
749
|
+
this.pushExplanationBlock(
|
|
750
|
+
rows,
|
|
751
|
+
`${this.explanationController.getLoadingFrame()} ${explanation.text || "Generating explanation..."}`,
|
|
752
|
+
width,
|
|
753
|
+
);
|
|
754
|
+
} else if (explanation.status === "error") {
|
|
755
|
+
this.pushExplanationBlock(
|
|
756
|
+
rows,
|
|
757
|
+
`Explanation failed: ${explanation.message}`,
|
|
758
|
+
width,
|
|
759
|
+
);
|
|
760
|
+
} else {
|
|
761
|
+
this.pushExplanationBlock(rows, explanation.text, width);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
666
764
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
this.
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
width,
|
|
683
|
-
);
|
|
684
|
-
} else {
|
|
685
|
-
this.pushExplanationBlock(rows, explanation.text, width);
|
|
765
|
+
if (
|
|
766
|
+
this.askInputMode &&
|
|
767
|
+
this.askScope?.key === scope.key &&
|
|
768
|
+
this.askAnchorIndex === lineIndex
|
|
769
|
+
) {
|
|
770
|
+
this.pushInlineEditorBlock(rows, "Ask about this hunk", width);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (
|
|
774
|
+
!this.askInputMode &&
|
|
775
|
+
this.askScope?.key === scope.key &&
|
|
776
|
+
this.askAnchorIndex === lineIndex
|
|
777
|
+
) {
|
|
778
|
+
const askState = this.explanationController.getAskState();
|
|
779
|
+
if (askState) this.pushAskBlock(rows, askState, width);
|
|
686
780
|
}
|
|
687
781
|
}
|
|
688
782
|
|
|
@@ -717,14 +811,39 @@ export class ReviewComponent {
|
|
|
717
811
|
rows.push({ kind: "comment", text: "", part: "bottom" });
|
|
718
812
|
}
|
|
719
813
|
|
|
814
|
+
private pushAskBlock(
|
|
815
|
+
rows: AnnotatedDiffRow[],
|
|
816
|
+
state: ExplanationState,
|
|
817
|
+
width: number,
|
|
818
|
+
): void {
|
|
819
|
+
if (state.status === "loading") {
|
|
820
|
+
this.pushExplanationBlock(
|
|
821
|
+
rows,
|
|
822
|
+
`${this.explanationController.getLoadingFrame()} ${state.text || "Generating answer..."}`,
|
|
823
|
+
width,
|
|
824
|
+
" 💬 Answer ",
|
|
825
|
+
);
|
|
826
|
+
} else if (state.status === "error") {
|
|
827
|
+
this.pushExplanationBlock(
|
|
828
|
+
rows,
|
|
829
|
+
`Failed: ${state.message}`,
|
|
830
|
+
width,
|
|
831
|
+
" 💬 Answer ",
|
|
832
|
+
);
|
|
833
|
+
} else {
|
|
834
|
+
this.pushExplanationBlock(rows, state.text, width, " 💬 Answer ");
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
720
838
|
private pushExplanationBlock(
|
|
721
839
|
rows: AnnotatedDiffRow[],
|
|
722
840
|
text: string,
|
|
723
841
|
width: number,
|
|
842
|
+
title = " ✨ Explanation ",
|
|
724
843
|
): void {
|
|
725
844
|
rows.push({
|
|
726
845
|
kind: "explanation",
|
|
727
|
-
text: this.theme.fg("accent",
|
|
846
|
+
text: this.theme.fg("accent", title),
|
|
728
847
|
part: "top",
|
|
729
848
|
});
|
|
730
849
|
const wrapped = wrapTextWithAnsi(
|
|
@@ -833,6 +952,27 @@ export class ReviewComponent {
|
|
|
833
952
|
return comments.sort((a, b) => a.id.localeCompare(b.id));
|
|
834
953
|
}
|
|
835
954
|
|
|
955
|
+
private restorePersistedAsk(
|
|
956
|
+
cachedAsk: PersistedAsk,
|
|
957
|
+
): { scope: ExplanationScope; anchorIndex: number } | undefined {
|
|
958
|
+
const anchorIndex = this.lineIndexById.get(cachedAsk.anchorLineId);
|
|
959
|
+
if (anchorIndex != null) {
|
|
960
|
+
const scope = getCurrentHunkScope(this.lines, anchorIndex);
|
|
961
|
+
if (scope?.key === cachedAsk.scopeKey) {
|
|
962
|
+
return { scope, anchorIndex };
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
for (let index = 0; index < this.lines.length; index++) {
|
|
967
|
+
const scope = getCurrentHunkScope(this.lines, index);
|
|
968
|
+
if (scope?.key === cachedAsk.scopeKey) {
|
|
969
|
+
return { scope, anchorIndex: index };
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return undefined;
|
|
974
|
+
}
|
|
975
|
+
|
|
836
976
|
private getHunkEndIndex(selected: number): number | undefined {
|
|
837
977
|
const selectedLine = this.lines[selected];
|
|
838
978
|
if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
|
|
@@ -848,6 +988,16 @@ export class ReviewComponent {
|
|
|
848
988
|
return end;
|
|
849
989
|
}
|
|
850
990
|
|
|
991
|
+
private getExplanationAnchorIndex(
|
|
992
|
+
lineIndex: number,
|
|
993
|
+
scopeKey: string,
|
|
994
|
+
): number | undefined {
|
|
995
|
+
return (
|
|
996
|
+
this.explanationAnchorByScope.get(scopeKey) ??
|
|
997
|
+
this.getHunkEndIndex(lineIndex)
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
|
|
851
1001
|
private renderSplitDiffRowAt(splitRowIndex: number, width: number): string {
|
|
852
1002
|
const splitRow = this.getSplitDiffRows()[splitRowIndex];
|
|
853
1003
|
if (!splitRow) return " ".repeat(width);
|
|
@@ -891,6 +1041,41 @@ export class ReviewComponent {
|
|
|
891
1041
|
|
|
892
1042
|
dispose(): void {
|
|
893
1043
|
this.explanationController.dispose();
|
|
1044
|
+
this.clearAsk();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
private startAskMode(): void {
|
|
1048
|
+
const scope = this.getCurrentHunkScope();
|
|
1049
|
+
if (!scope) return;
|
|
1050
|
+
this.clearAsk();
|
|
1051
|
+
this.askScope = scope;
|
|
1052
|
+
this.askAnchorIndex = this.selected;
|
|
1053
|
+
this.askInputMode = true;
|
|
1054
|
+
this.inlineAnnotationsVisible = true;
|
|
1055
|
+
this.editor.setText("");
|
|
1056
|
+
this.invalidateAnnotatedRows();
|
|
1057
|
+
this.tui.requestRender(true);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
private exitAskInputMode(): void {
|
|
1061
|
+
this.askInputMode = false;
|
|
1062
|
+
this.editor.setText("");
|
|
1063
|
+
if (!this.explanationController.getAskState()) {
|
|
1064
|
+
this.askScope = undefined;
|
|
1065
|
+
this.askAnchorIndex = undefined;
|
|
1066
|
+
}
|
|
1067
|
+
this.invalidateAnnotatedRows();
|
|
1068
|
+
this.tui.requestRender(true);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private clearAsk(): void {
|
|
1072
|
+
this.askInputMode = false;
|
|
1073
|
+
this.askScope = undefined;
|
|
1074
|
+
this.askAnchorIndex = undefined;
|
|
1075
|
+
this.explanationController.clearAsk();
|
|
1076
|
+
this.editor.setText("");
|
|
1077
|
+
this.invalidateAnnotatedRows();
|
|
1078
|
+
this.tui.requestRender(true);
|
|
894
1079
|
}
|
|
895
1080
|
|
|
896
1081
|
private move(delta: number): void {
|
|
@@ -922,6 +1107,7 @@ export class ReviewComponent {
|
|
|
922
1107
|
|
|
923
1108
|
private hideInlineExplanation(): void {
|
|
924
1109
|
this.visibleExplanationKeys.clear();
|
|
1110
|
+
this.explanationAnchorByScope.clear();
|
|
925
1111
|
}
|
|
926
1112
|
|
|
927
1113
|
private toggleExplanationPane(): void {
|
|
@@ -930,8 +1116,10 @@ export class ReviewComponent {
|
|
|
930
1116
|
|
|
931
1117
|
if (this.visibleExplanationKeys.has(scope.key)) {
|
|
932
1118
|
this.visibleExplanationKeys.delete(scope.key);
|
|
1119
|
+
this.explanationAnchorByScope.delete(scope.key);
|
|
933
1120
|
} else {
|
|
934
1121
|
this.visibleExplanationKeys.add(scope.key);
|
|
1122
|
+
this.explanationAnchorByScope.set(scope.key, this.selected);
|
|
935
1123
|
this.ensureCurrentExplanation();
|
|
936
1124
|
}
|
|
937
1125
|
|
|
@@ -1167,11 +1355,22 @@ export class ReviewComponent {
|
|
|
1167
1355
|
private ensureScroll(viewportHeight: number, width: number): void {
|
|
1168
1356
|
this.navigation.ensureScroll(
|
|
1169
1357
|
viewportHeight,
|
|
1170
|
-
this.
|
|
1358
|
+
this.getScrollTargetDisplayRow(width),
|
|
1171
1359
|
this.getDisplayRowCount(width),
|
|
1172
1360
|
);
|
|
1173
1361
|
}
|
|
1174
1362
|
|
|
1363
|
+
private getScrollTargetDisplayRow(width: number): number {
|
|
1364
|
+
if (this.editMode || this.askInputMode) {
|
|
1365
|
+
const editorRow = this.getAnnotatedRows(width).findIndex(
|
|
1366
|
+
(row) => row.kind === "editor",
|
|
1367
|
+
);
|
|
1368
|
+
if (editorRow >= 0) return editorRow;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
return this.getSelectedDisplayRow(width);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1175
1374
|
private getDisplayText(line: ReviewLine): string {
|
|
1176
1375
|
const raw =
|
|
1177
1376
|
line.kind === "add" || line.kind === "remove" || line.kind === "context"
|
package/src/review/types.ts
CHANGED