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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
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
 
@@ -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 = [...this.explanations.values()].some(
157
- (explanation) => explanation.status === "loading",
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: buildExplanationPrompt(scope),
79
+ content: question
80
+ ? buildAskPrompt(scope, question)
81
+ : buildExplanationPrompt(scope),
71
82
  timestamp: Date.now(),
72
83
  },
73
84
  ],
@@ -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,
@@ -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.search.query) {
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 || !this.visibleExplanationKeys.has(scope.key)) return;
701
+ if (!scope) return;
663
702
 
664
703
  const end = this.getHunkEndIndex(lineIndex);
665
704
  if (end !== lineIndex) return;
666
705
 
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);
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", " ✨ Explanation "),
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.getSelectedDisplayRow(width),
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"