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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 = [...this.explanations.values()].some(
157
- (explanation) => explanation.status === "loading",
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: buildExplanationPrompt(scope),
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
  );
@@ -9,7 +9,11 @@ import {
9
9
  visibleWidth,
10
10
  wrapTextWithAnsi,
11
11
  } from "@earendil-works/pi-tui";
12
- import type { DiffExplainer } from "../explanation/explainer.ts";
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.search.query) {
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.done({ action: "cancel" });
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 || !this.visibleExplanationKeys.has(scope.key)) return;
737
+ if (!scope) return;
663
738
 
664
- const end = this.getHunkEndIndex(lineIndex);
665
- if (end !== lineIndex) return;
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
- const explanation = this.explanationController.getState(scope);
668
- if (!this.explanationController.isAvailable) {
669
- this.pushExplanationBlock(rows, "Explanation unavailable.", width);
670
- } else if (!explanation) {
671
- this.pushExplanationBlock(rows, "No explanation generated yet.", width);
672
- } else if (explanation.status === "loading") {
673
- this.pushExplanationBlock(
674
- rows,
675
- `${this.explanationController.getLoadingFrame()} ${explanation.text || "Generating explanation..."}`,
676
- width,
677
- );
678
- } else if (explanation.status === "error") {
679
- this.pushExplanationBlock(
680
- rows,
681
- `Explanation failed: ${explanation.message}`,
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", " ✨ Explanation "),
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.getSelectedDisplayRow(width),
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"
@@ -42,6 +42,12 @@ export type DiffSource = {
42
42
  args: string[];
43
43
  };
44
44
 
45
+ export type PersistedAsk = {
46
+ scopeKey: string;
47
+ anchorLineId: string;
48
+ text: string;
49
+ };
50
+
45
51
  export type DiffRenderMode = "unified" | "split";
46
52
 
47
53
  export type SplitDiffCell = {