pi-diff-review 0.1.18 → 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.18",
3
+ "version": "0.1.19",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -62,12 +62,19 @@ export class ExplanationController {
62
62
  private readonly onExplanationsChanged?: (
63
63
  explanations: Map<string, string>,
64
64
  ) => void,
65
+ cachedAskText?: string,
66
+ private readonly onAskChanged?: (state?: ExplanationState) => void,
65
67
  ) {
66
68
  for (const [key, text] of cachedExplanations) {
67
69
  const trimmed = text.trim();
68
70
  if (trimmed)
69
71
  this.explanations.set(key, { status: "ready", text: trimmed });
70
72
  }
73
+
74
+ const trimmedAskText = cachedAskText?.trim();
75
+ if (trimmedAskText) {
76
+ this.askState = { status: "ready", text: trimmedAskText };
77
+ }
71
78
  }
72
79
 
73
80
  get isAvailable(): boolean {
@@ -90,6 +97,7 @@ export class ExplanationController {
90
97
  const requestId = ++this.askRequestId;
91
98
  let text = "";
92
99
  this.askState = { status: "loading", text };
100
+ this.onAskChanged?.(this.askState);
93
101
  this.startLoadingTimer();
94
102
 
95
103
  void this.explainer
@@ -99,6 +107,7 @@ export class ExplanationController {
99
107
  if (requestId !== this.askRequestId) return;
100
108
  text += delta;
101
109
  this.askState = { status: "loading", text };
110
+ this.onAskChanged?.(this.askState);
102
111
  this.tui.requestRender();
103
112
  },
104
113
  })
@@ -108,6 +117,7 @@ export class ExplanationController {
108
117
  status: "ready",
109
118
  text: finalText.trim() || text.trim() || "No answer returned.",
110
119
  };
120
+ this.onAskChanged?.(this.askState);
111
121
  })
112
122
  .catch((error) => {
113
123
  if (requestId !== this.askRequestId) return;
@@ -116,6 +126,7 @@ export class ExplanationController {
116
126
  status: "error",
117
127
  message: error instanceof Error ? error.message : String(error),
118
128
  };
129
+ this.onAskChanged?.(this.askState);
119
130
  })
120
131
  .finally(() => {
121
132
  if (requestId !== this.askRequestId) return;
@@ -134,6 +145,7 @@ export class ExplanationController {
134
145
  this.askAbortController?.abort();
135
146
  this.askAbortController = undefined;
136
147
  this.askState = undefined;
148
+ this.onAskChanged?.(undefined);
137
149
  }
138
150
 
139
151
  ensure(scope: ExplanationScope | undefined): void {
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
  );
@@ -32,6 +32,7 @@ import { padToWidth, lineNumberCell } from "../render/utils.ts";
32
32
  import { buildSplitDiffRows } from "../diff/split.ts";
33
33
  import type {
34
34
  DiffRenderMode,
35
+ PersistedAsk,
35
36
  ReviewComment,
36
37
  ReviewLine,
37
38
  ReviewResult,
@@ -58,6 +59,7 @@ type AnnotatedDiffRow =
58
59
  const HELP_COMMANDS = [
59
60
  ["h", "show or hide this help"],
60
61
  ["j/k or arrows", "move selection"],
62
+ ["PgUp / PgDown", "move up or down half a page"],
61
63
  ["ctrl-u / ctrl-d", "move up or down half a page"],
62
64
  ["g / G", "jump to top or bottom"],
63
65
  ["n / p", "jump to next or previous hunk"],
@@ -85,8 +87,10 @@ export class ReviewComponent {
85
87
 
86
88
  private inlineAnnotationsVisible = true;
87
89
  private visibleExplanationKeys = new Set<string>();
90
+ private explanationAnchorByScope = new Map<string, number>();
88
91
  private askInputMode = false;
89
92
  private askScope?: ExplanationScope;
93
+ private askAnchorIndex?: number;
90
94
  private explanationController: ExplanationController;
91
95
  private editor: Editor;
92
96
  private splitRows?: SplitDiffRow[];
@@ -116,6 +120,8 @@ export class ReviewComponent {
116
120
  private onCommentsChanged?: (comments: Map<string, ReviewComment>) => void,
117
121
  cachedExplanations?: Map<string, string>,
118
122
  private onExplanationsChanged?: (explanations: Map<string, string>) => void,
123
+ cachedAsk?: PersistedAsk,
124
+ private onAskChanged?: (ask?: PersistedAsk) => void,
119
125
  ) {
120
126
  const firstCommentable = this.lines.findIndex((line) => line.commentable);
121
127
  this.navigation = new ReviewNavigationState(
@@ -124,11 +130,36 @@ export class ReviewComponent {
124
130
  );
125
131
  this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
126
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
+
127
142
  this.explanationController = new ExplanationController(
128
143
  tui,
129
144
  explainer,
130
145
  cachedExplanations,
131
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
+ },
132
163
  );
133
164
 
134
165
  this.editor = new Editor(tui as never, {
@@ -151,6 +182,7 @@ export class ReviewComponent {
151
182
  this.explanationController.ask(this.askScope, question);
152
183
  } else {
153
184
  this.askScope = undefined;
185
+ this.askAnchorIndex = undefined;
154
186
  }
155
187
  this.invalidateAnnotatedRows();
156
188
  this.tui.requestRender(true);
@@ -259,7 +291,11 @@ export class ReviewComponent {
259
291
  return;
260
292
  }
261
293
  if (data === "q") {
262
- this.done({ action: "cancel" });
294
+ if (this.hasSelection()) {
295
+ this.clearSelection();
296
+ } else {
297
+ this.done({ action: "cancel" });
298
+ }
263
299
  return;
264
300
  }
265
301
  if (data === "h") {
@@ -286,11 +322,11 @@ export class ReviewComponent {
286
322
  this.startSearchMode();
287
323
  return;
288
324
  }
289
- if (matchesKey(data, "ctrl+d")) {
325
+ if (matchesKey(data, "pageDown") || matchesKey(data, "ctrl+d")) {
290
326
  this.move(this.getPageMoveAmount());
291
327
  return;
292
328
  }
293
- if (matchesKey(data, "ctrl+u")) {
329
+ if (matchesKey(data, "pageUp") || matchesKey(data, "ctrl+u")) {
294
330
  this.move(-this.getPageMoveAmount());
295
331
  return;
296
332
  }
@@ -700,10 +736,10 @@ export class ReviewComponent {
700
736
  const scope = getCurrentHunkScope(this.lines, lineIndex);
701
737
  if (!scope) return;
702
738
 
703
- const end = this.getHunkEndIndex(lineIndex);
704
- if (end !== lineIndex) return;
705
-
706
- if (this.visibleExplanationKeys.has(scope.key)) {
739
+ if (
740
+ this.visibleExplanationKeys.has(scope.key) &&
741
+ this.getExplanationAnchorIndex(lineIndex, scope.key) === lineIndex
742
+ ) {
707
743
  const explanation = this.explanationController.getState(scope);
708
744
  if (!this.explanationController.isAvailable) {
709
745
  this.pushExplanationBlock(rows, "Explanation unavailable.", width);
@@ -726,11 +762,19 @@ export class ReviewComponent {
726
762
  }
727
763
  }
728
764
 
729
- if (this.askInputMode && this.askScope?.key === scope.key) {
765
+ if (
766
+ this.askInputMode &&
767
+ this.askScope?.key === scope.key &&
768
+ this.askAnchorIndex === lineIndex
769
+ ) {
730
770
  this.pushInlineEditorBlock(rows, "Ask about this hunk", width);
731
771
  }
732
772
 
733
- if (!this.askInputMode && this.askScope?.key === scope.key) {
773
+ if (
774
+ !this.askInputMode &&
775
+ this.askScope?.key === scope.key &&
776
+ this.askAnchorIndex === lineIndex
777
+ ) {
734
778
  const askState = this.explanationController.getAskState();
735
779
  if (askState) this.pushAskBlock(rows, askState, width);
736
780
  }
@@ -908,6 +952,27 @@ export class ReviewComponent {
908
952
  return comments.sort((a, b) => a.id.localeCompare(b.id));
909
953
  }
910
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
+
911
976
  private getHunkEndIndex(selected: number): number | undefined {
912
977
  const selectedLine = this.lines[selected];
913
978
  if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
@@ -923,6 +988,16 @@ export class ReviewComponent {
923
988
  return end;
924
989
  }
925
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
+
926
1001
  private renderSplitDiffRowAt(splitRowIndex: number, width: number): string {
927
1002
  const splitRow = this.getSplitDiffRows()[splitRowIndex];
928
1003
  if (!splitRow) return " ".repeat(width);
@@ -974,6 +1049,7 @@ export class ReviewComponent {
974
1049
  if (!scope) return;
975
1050
  this.clearAsk();
976
1051
  this.askScope = scope;
1052
+ this.askAnchorIndex = this.selected;
977
1053
  this.askInputMode = true;
978
1054
  this.inlineAnnotationsVisible = true;
979
1055
  this.editor.setText("");
@@ -984,7 +1060,10 @@ export class ReviewComponent {
984
1060
  private exitAskInputMode(): void {
985
1061
  this.askInputMode = false;
986
1062
  this.editor.setText("");
987
- if (!this.explanationController.getAskState()) this.askScope = undefined;
1063
+ if (!this.explanationController.getAskState()) {
1064
+ this.askScope = undefined;
1065
+ this.askAnchorIndex = undefined;
1066
+ }
988
1067
  this.invalidateAnnotatedRows();
989
1068
  this.tui.requestRender(true);
990
1069
  }
@@ -992,6 +1071,7 @@ export class ReviewComponent {
992
1071
  private clearAsk(): void {
993
1072
  this.askInputMode = false;
994
1073
  this.askScope = undefined;
1074
+ this.askAnchorIndex = undefined;
995
1075
  this.explanationController.clearAsk();
996
1076
  this.editor.setText("");
997
1077
  this.invalidateAnnotatedRows();
@@ -1027,6 +1107,7 @@ export class ReviewComponent {
1027
1107
 
1028
1108
  private hideInlineExplanation(): void {
1029
1109
  this.visibleExplanationKeys.clear();
1110
+ this.explanationAnchorByScope.clear();
1030
1111
  }
1031
1112
 
1032
1113
  private toggleExplanationPane(): void {
@@ -1035,8 +1116,10 @@ export class ReviewComponent {
1035
1116
 
1036
1117
  if (this.visibleExplanationKeys.has(scope.key)) {
1037
1118
  this.visibleExplanationKeys.delete(scope.key);
1119
+ this.explanationAnchorByScope.delete(scope.key);
1038
1120
  } else {
1039
1121
  this.visibleExplanationKeys.add(scope.key);
1122
+ this.explanationAnchorByScope.set(scope.key, this.selected);
1040
1123
  this.ensureCurrentExplanation();
1041
1124
  }
1042
1125
 
@@ -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 = {