pi-diff-review 0.1.18 → 0.1.20

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.
@@ -0,0 +1,171 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type { PersistedAsk, ReviewComment } from "./types.ts";
6
+
7
+ const REVIEW_COMMENT_CACHE_ENTRY = "pi-diff-review-cache";
8
+ const REVIEW_EXPLANATION_CACHE_ENTRY = "pi-diff-review-explanation-cache";
9
+ const REVIEW_ASK_CACHE_ENTRY = "pi-diff-review-ask-cache";
10
+
11
+ type ReviewCommentCacheEntry = {
12
+ cacheKey: string;
13
+ comments: ReviewComment[];
14
+ updatedAt: number;
15
+ };
16
+
17
+ type ReviewExplanationCacheEntry = {
18
+ cacheKey: string;
19
+ explanations: Record<string, string>;
20
+ updatedAt: number;
21
+ };
22
+
23
+ type ReviewAskCacheEntry = {
24
+ cacheKey: string;
25
+ ask?: PersistedAsk;
26
+ updatedAt: number;
27
+ };
28
+
29
+ export function getCachedComments(
30
+ ctx: ExtensionCommandContext,
31
+ cacheKey: string,
32
+ ): Map<string, ReviewComment> {
33
+ let latest: ReviewCommentCacheEntry | undefined;
34
+ for (const entry of ctx.sessionManager.getEntries()) {
35
+ if (
36
+ entry.type !== "custom" ||
37
+ entry.customType !== REVIEW_COMMENT_CACHE_ENTRY
38
+ ) {
39
+ continue;
40
+ }
41
+
42
+ const data = entry.data as Partial<ReviewCommentCacheEntry> | undefined;
43
+ if (data?.cacheKey !== cacheKey || !Array.isArray(data.comments)) continue;
44
+
45
+ if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
46
+ latest = {
47
+ cacheKey: data.cacheKey,
48
+ comments: data.comments,
49
+ updatedAt: data.updatedAt ?? 0,
50
+ };
51
+ }
52
+ }
53
+
54
+ return new Map(
55
+ (latest?.comments ?? []).map((comment) => [comment.id, comment]),
56
+ );
57
+ }
58
+
59
+ export function persistCachedComments(
60
+ pi: ExtensionAPI,
61
+ cacheKey: string,
62
+ comments: Iterable<ReviewComment>,
63
+ ): void {
64
+ pi.appendEntry(REVIEW_COMMENT_CACHE_ENTRY, {
65
+ cacheKey,
66
+ comments: [...comments],
67
+ updatedAt: Date.now(),
68
+ } satisfies ReviewCommentCacheEntry);
69
+ }
70
+
71
+ export function getCachedExplanations(
72
+ ctx: ExtensionCommandContext,
73
+ cacheKey: string,
74
+ ): Map<string, string> {
75
+ let latest: ReviewExplanationCacheEntry | undefined;
76
+ for (const entry of ctx.sessionManager.getEntries()) {
77
+ if (
78
+ entry.type !== "custom" ||
79
+ entry.customType !== REVIEW_EXPLANATION_CACHE_ENTRY
80
+ ) {
81
+ continue;
82
+ }
83
+
84
+ const data = entry.data as Partial<ReviewExplanationCacheEntry> | undefined;
85
+ if (
86
+ data?.cacheKey !== cacheKey ||
87
+ !data.explanations ||
88
+ typeof data.explanations !== "object" ||
89
+ Array.isArray(data.explanations)
90
+ ) {
91
+ continue;
92
+ }
93
+
94
+ if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
95
+ latest = {
96
+ cacheKey: data.cacheKey,
97
+ explanations: Object.fromEntries(
98
+ Object.entries(data.explanations).filter(
99
+ (entry): entry is [string, string] =>
100
+ typeof entry[0] === "string" && typeof entry[1] === "string",
101
+ ),
102
+ ),
103
+ updatedAt: data.updatedAt ?? 0,
104
+ };
105
+ }
106
+ }
107
+
108
+ return new Map(Object.entries(latest?.explanations ?? {}));
109
+ }
110
+
111
+ export function persistCachedExplanations(
112
+ pi: ExtensionAPI,
113
+ cacheKey: string,
114
+ explanations: Map<string, string>,
115
+ ): void {
116
+ pi.appendEntry(REVIEW_EXPLANATION_CACHE_ENTRY, {
117
+ cacheKey,
118
+ explanations: Object.fromEntries(explanations),
119
+ updatedAt: Date.now(),
120
+ } satisfies ReviewExplanationCacheEntry);
121
+ }
122
+
123
+ export function getCachedAsk(
124
+ ctx: ExtensionCommandContext,
125
+ cacheKey: string,
126
+ ): PersistedAsk | undefined {
127
+ let latest: ReviewAskCacheEntry | undefined;
128
+ for (const entry of ctx.sessionManager.getEntries()) {
129
+ if (
130
+ entry.type !== "custom" ||
131
+ entry.customType !== REVIEW_ASK_CACHE_ENTRY
132
+ ) {
133
+ continue;
134
+ }
135
+
136
+ const data = entry.data as Partial<ReviewAskCacheEntry> | undefined;
137
+ if (data?.cacheKey !== cacheKey) continue;
138
+
139
+ const ask = data.ask;
140
+ const validAsk =
141
+ ask &&
142
+ typeof ask === "object" &&
143
+ typeof ask.scopeKey === "string" &&
144
+ typeof ask.anchorLineId === "string" &&
145
+ typeof ask.text === "string"
146
+ ? ask
147
+ : undefined;
148
+
149
+ if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
150
+ latest = {
151
+ cacheKey: data.cacheKey,
152
+ ask: validAsk,
153
+ updatedAt: data.updatedAt ?? 0,
154
+ };
155
+ }
156
+ }
157
+
158
+ return latest?.ask;
159
+ }
160
+
161
+ export function persistCachedAsk(
162
+ pi: ExtensionAPI,
163
+ cacheKey: string,
164
+ ask?: PersistedAsk,
165
+ ): void {
166
+ pi.appendEntry(REVIEW_ASK_CACHE_ENTRY, {
167
+ cacheKey,
168
+ ask,
169
+ updatedAt: Date.now(),
170
+ } satisfies ReviewAskCacheEntry);
171
+ }
@@ -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,
@@ -40,6 +41,7 @@ import type {
40
41
  SelectionBounds,
41
42
  SplitDiffCell,
42
43
  SplitDiffRow,
44
+ WorkspaceCommentSummary,
43
45
  } from "./types.ts";
44
46
 
45
47
  type InlineBoxPart = "top" | "body" | "bottom";
@@ -58,6 +60,7 @@ type AnnotatedDiffRow =
58
60
  const HELP_COMMANDS = [
59
61
  ["h", "show or hide this help"],
60
62
  ["j/k or arrows", "move selection"],
63
+ ["PgUp / PgDown", "move up or down half a page"],
61
64
  ["ctrl-u / ctrl-d", "move up or down half a page"],
62
65
  ["g / G", "jump to top or bottom"],
63
66
  ["n / p", "jump to next or previous hunk"],
@@ -85,8 +88,10 @@ export class ReviewComponent {
85
88
 
86
89
  private inlineAnnotationsVisible = true;
87
90
  private visibleExplanationKeys = new Set<string>();
91
+ private explanationAnchorByScope = new Map<string, number>();
88
92
  private askInputMode = false;
89
93
  private askScope?: ExplanationScope;
94
+ private askAnchorIndex?: number;
90
95
  private explanationController: ExplanationController;
91
96
  private editor: Editor;
92
97
  private splitRows?: SplitDiffRow[];
@@ -116,6 +121,11 @@ export class ReviewComponent {
116
121
  private onCommentsChanged?: (comments: Map<string, ReviewComment>) => void,
117
122
  cachedExplanations?: Map<string, string>,
118
123
  private onExplanationsChanged?: (explanations: Map<string, string>) => void,
124
+ cachedAsk?: PersistedAsk,
125
+ private onAskChanged?: (ask?: PersistedAsk) => void,
126
+ private getWorkspaceCommentSummary?: (
127
+ comments: Map<string, ReviewComment>,
128
+ ) => WorkspaceCommentSummary | undefined,
119
129
  ) {
120
130
  const firstCommentable = this.lines.findIndex((line) => line.commentable);
121
131
  this.navigation = new ReviewNavigationState(
@@ -124,11 +134,36 @@ export class ReviewComponent {
124
134
  );
125
135
  this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
126
136
  this.search = new ReviewSearchState(this.lines);
137
+
138
+ const restoredAsk = cachedAsk
139
+ ? this.restorePersistedAsk(cachedAsk)
140
+ : undefined;
141
+ if (restoredAsk) {
142
+ this.askScope = restoredAsk.scope;
143
+ this.askAnchorIndex = restoredAsk.anchorIndex;
144
+ }
145
+
127
146
  this.explanationController = new ExplanationController(
128
147
  tui,
129
148
  explainer,
130
149
  cachedExplanations,
131
150
  onExplanationsChanged,
151
+ restoredAsk ? cachedAsk?.text : undefined,
152
+ (state) => {
153
+ if (!state) {
154
+ this.onAskChanged?.(undefined);
155
+ return;
156
+ }
157
+ if (state.status !== "ready") return;
158
+ if (!this.askScope || this.askAnchorIndex == null) return;
159
+ const anchorLine = this.lines[this.askAnchorIndex];
160
+ if (!anchorLine) return;
161
+ this.onAskChanged?.({
162
+ scopeKey: this.askScope.key,
163
+ anchorLineId: anchorLine.id,
164
+ text: state.text,
165
+ });
166
+ },
132
167
  );
133
168
 
134
169
  this.editor = new Editor(tui as never, {
@@ -151,6 +186,7 @@ export class ReviewComponent {
151
186
  this.explanationController.ask(this.askScope, question);
152
187
  } else {
153
188
  this.askScope = undefined;
189
+ this.askAnchorIndex = undefined;
154
190
  }
155
191
  this.invalidateAnnotatedRows();
156
192
  this.tui.requestRender(true);
@@ -259,7 +295,11 @@ export class ReviewComponent {
259
295
  return;
260
296
  }
261
297
  if (data === "q") {
262
- this.done({ action: "cancel" });
298
+ if (this.hasSelection()) {
299
+ this.clearSelection();
300
+ } else {
301
+ this.done({ action: "cancel" });
302
+ }
263
303
  return;
264
304
  }
265
305
  if (data === "h") {
@@ -286,11 +326,11 @@ export class ReviewComponent {
286
326
  this.startSearchMode();
287
327
  return;
288
328
  }
289
- if (matchesKey(data, "ctrl+d")) {
329
+ if (matchesKey(data, "pageDown") || matchesKey(data, "ctrl+d")) {
290
330
  this.move(this.getPageMoveAmount());
291
331
  return;
292
332
  }
293
- if (matchesKey(data, "ctrl+u")) {
333
+ if (matchesKey(data, "pageUp") || matchesKey(data, "ctrl+u")) {
294
334
  this.move(-this.getPageMoveAmount());
295
335
  return;
296
336
  }
@@ -358,11 +398,12 @@ export class ReviewComponent {
358
398
  render(width: number): string[] {
359
399
  const viewportHeight = this.getContentHeight();
360
400
  const selectedLine = this.lines[this.selected];
401
+ const workspaceSummary = this.getWorkspaceCommentSummary?.(this.comments);
361
402
  const output: string[] = [];
362
403
 
363
404
  output.push(
364
405
  this.renderStatusLine(
365
- this.getHeaderText(selectedLine),
406
+ this.getHeaderText(selectedLine, workspaceSummary),
366
407
  "Press h for help",
367
408
  width,
368
409
  ),
@@ -373,15 +414,21 @@ export class ReviewComponent {
373
414
 
374
415
  output.push(
375
416
  truncateToWidth(
376
- this.theme.fg("muted", this.getFooterText(selectedLine)),
417
+ this.theme.fg(
418
+ "muted",
419
+ this.getFooterText(selectedLine, workspaceSummary),
420
+ ),
377
421
  width,
378
422
  ),
379
423
  );
380
424
  return this.helpVisible ? this.renderHelpModal(output, width) : output;
381
425
  }
382
426
 
383
- private getHeaderText(selectedLine?: ReviewLine): string {
384
- const base = `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments`;
427
+ private getHeaderText(
428
+ selectedLine?: ReviewLine,
429
+ workspaceSummary?: WorkspaceCommentSummary,
430
+ ): string {
431
+ const base = `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments${this.formatWorkspaceSummary(workspaceSummary)}`;
385
432
 
386
433
  if (this.editMode) {
387
434
  return `${base} • editing ${this.editingCommentKey === GLOBAL_COMMENT_KEY ? "overall comment" : "inline comment"}`;
@@ -700,10 +747,10 @@ export class ReviewComponent {
700
747
  const scope = getCurrentHunkScope(this.lines, lineIndex);
701
748
  if (!scope) return;
702
749
 
703
- const end = this.getHunkEndIndex(lineIndex);
704
- if (end !== lineIndex) return;
705
-
706
- if (this.visibleExplanationKeys.has(scope.key)) {
750
+ if (
751
+ this.visibleExplanationKeys.has(scope.key) &&
752
+ this.getExplanationAnchorIndex(lineIndex, scope.key) === lineIndex
753
+ ) {
707
754
  const explanation = this.explanationController.getState(scope);
708
755
  if (!this.explanationController.isAvailable) {
709
756
  this.pushExplanationBlock(rows, "Explanation unavailable.", width);
@@ -726,11 +773,19 @@ export class ReviewComponent {
726
773
  }
727
774
  }
728
775
 
729
- if (this.askInputMode && this.askScope?.key === scope.key) {
776
+ if (
777
+ this.askInputMode &&
778
+ this.askScope?.key === scope.key &&
779
+ this.askAnchorIndex === lineIndex
780
+ ) {
730
781
  this.pushInlineEditorBlock(rows, "Ask about this hunk", width);
731
782
  }
732
783
 
733
- if (!this.askInputMode && this.askScope?.key === scope.key) {
784
+ if (
785
+ !this.askInputMode &&
786
+ this.askScope?.key === scope.key &&
787
+ this.askAnchorIndex === lineIndex
788
+ ) {
734
789
  const askState = this.explanationController.getAskState();
735
790
  if (askState) this.pushAskBlock(rows, askState, width);
736
791
  }
@@ -908,6 +963,27 @@ export class ReviewComponent {
908
963
  return comments.sort((a, b) => a.id.localeCompare(b.id));
909
964
  }
910
965
 
966
+ private restorePersistedAsk(
967
+ cachedAsk: PersistedAsk,
968
+ ): { scope: ExplanationScope; anchorIndex: number } | undefined {
969
+ const anchorIndex = this.lineIndexById.get(cachedAsk.anchorLineId);
970
+ if (anchorIndex != null) {
971
+ const scope = getCurrentHunkScope(this.lines, anchorIndex);
972
+ if (scope?.key === cachedAsk.scopeKey) {
973
+ return { scope, anchorIndex };
974
+ }
975
+ }
976
+
977
+ for (let index = 0; index < this.lines.length; index++) {
978
+ const scope = getCurrentHunkScope(this.lines, index);
979
+ if (scope?.key === cachedAsk.scopeKey) {
980
+ return { scope, anchorIndex: index };
981
+ }
982
+ }
983
+
984
+ return undefined;
985
+ }
986
+
911
987
  private getHunkEndIndex(selected: number): number | undefined {
912
988
  const selectedLine = this.lines[selected];
913
989
  if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
@@ -923,6 +999,16 @@ export class ReviewComponent {
923
999
  return end;
924
1000
  }
925
1001
 
1002
+ private getExplanationAnchorIndex(
1003
+ lineIndex: number,
1004
+ scopeKey: string,
1005
+ ): number | undefined {
1006
+ return (
1007
+ this.explanationAnchorByScope.get(scopeKey) ??
1008
+ this.getHunkEndIndex(lineIndex)
1009
+ );
1010
+ }
1011
+
926
1012
  private renderSplitDiffRowAt(splitRowIndex: number, width: number): string {
927
1013
  const splitRow = this.getSplitDiffRows()[splitRowIndex];
928
1014
  if (!splitRow) return " ".repeat(width);
@@ -974,6 +1060,7 @@ export class ReviewComponent {
974
1060
  if (!scope) return;
975
1061
  this.clearAsk();
976
1062
  this.askScope = scope;
1063
+ this.askAnchorIndex = this.selected;
977
1064
  this.askInputMode = true;
978
1065
  this.inlineAnnotationsVisible = true;
979
1066
  this.editor.setText("");
@@ -984,7 +1071,10 @@ export class ReviewComponent {
984
1071
  private exitAskInputMode(): void {
985
1072
  this.askInputMode = false;
986
1073
  this.editor.setText("");
987
- if (!this.explanationController.getAskState()) this.askScope = undefined;
1074
+ if (!this.explanationController.getAskState()) {
1075
+ this.askScope = undefined;
1076
+ this.askAnchorIndex = undefined;
1077
+ }
988
1078
  this.invalidateAnnotatedRows();
989
1079
  this.tui.requestRender(true);
990
1080
  }
@@ -992,6 +1082,7 @@ export class ReviewComponent {
992
1082
  private clearAsk(): void {
993
1083
  this.askInputMode = false;
994
1084
  this.askScope = undefined;
1085
+ this.askAnchorIndex = undefined;
995
1086
  this.explanationController.clearAsk();
996
1087
  this.editor.setText("");
997
1088
  this.invalidateAnnotatedRows();
@@ -1027,6 +1118,7 @@ export class ReviewComponent {
1027
1118
 
1028
1119
  private hideInlineExplanation(): void {
1029
1120
  this.visibleExplanationKeys.clear();
1121
+ this.explanationAnchorByScope.clear();
1030
1122
  }
1031
1123
 
1032
1124
  private toggleExplanationPane(): void {
@@ -1035,8 +1127,10 @@ export class ReviewComponent {
1035
1127
 
1036
1128
  if (this.visibleExplanationKeys.has(scope.key)) {
1037
1129
  this.visibleExplanationKeys.delete(scope.key);
1130
+ this.explanationAnchorByScope.delete(scope.key);
1038
1131
  } else {
1039
1132
  this.visibleExplanationKeys.add(scope.key);
1133
+ this.explanationAnchorByScope.set(scope.key, this.selected);
1040
1134
  this.ensureCurrentExplanation();
1041
1135
  }
1042
1136
 
@@ -1126,7 +1220,10 @@ export class ReviewComponent {
1126
1220
  : position;
1127
1221
  }
1128
1222
 
1129
- private getFooterText(selectedLine?: ReviewLine): string {
1223
+ private getFooterText(
1224
+ selectedLine?: ReviewLine,
1225
+ workspaceSummary?: WorkspaceCommentSummary,
1226
+ ): string {
1130
1227
  if (this.search.mode) {
1131
1228
  return `Search: /${this.search.draftQuery} • Enter jump • Esc cancel`;
1132
1229
  }
@@ -1141,7 +1238,28 @@ export class ReviewComponent {
1141
1238
  const endLine = this.lines[selection.end]!;
1142
1239
  return `Selected ${count} lines: ${formatLocation(startLine)} -> ${formatLocation(endLine)}`;
1143
1240
  }
1144
- return `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
1241
+
1242
+ const selectedText = `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
1243
+ const workspaceText = this.formatWorkspaceSummary(workspaceSummary, false);
1244
+ return workspaceText ? `${selectedText} • ${workspaceText}` : selectedText;
1245
+ }
1246
+
1247
+ private formatWorkspaceSummary(
1248
+ summary?: WorkspaceCommentSummary,
1249
+ includeVisible = true,
1250
+ ): string {
1251
+ if (!summary) return "";
1252
+
1253
+ const parts: string[] = [];
1254
+ if (includeVisible && summary.visible > 0)
1255
+ parts.push(`${summary.visible} visible persisted`);
1256
+ if (summary.hiddenInCurrentFiles > 0) {
1257
+ parts.push(`${summary.hiddenInCurrentFiles} hidden in current files`);
1258
+ }
1259
+ if (summary.elsewhere > 0) parts.push(`${summary.elsewhere} elsewhere`);
1260
+ if (summary.stale > 0) parts.push(`${summary.stale} stale`);
1261
+ if (summary.orphaned > 0) parts.push(`${summary.orphaned} orphaned`);
1262
+ return parts.length > 0 ? ` • ${parts.join(" • ")}` : "";
1145
1263
  }
1146
1264
 
1147
1265
  private jumpHunk(direction: 1 | -1): void {
@@ -36,16 +36,39 @@ export function formatCommentLocation(comment: ReviewComment): string {
36
36
  export function buildReviewPrompt(
37
37
  comments: ReviewComment[],
38
38
  promptLabel: string,
39
+ ): string {
40
+ return buildCommentPrompt(
41
+ comments,
42
+ `Address this local code review feedback for ${promptLabel}.`,
43
+ "diff",
44
+ );
45
+ }
46
+
47
+ export function buildViewReviewPrompt(
48
+ comments: ReviewComment[],
49
+ promptLabel: string,
50
+ ): string {
51
+ return buildCommentPrompt(
52
+ comments,
53
+ `Address this code review feedback for ${promptLabel}.`,
54
+ "ts",
55
+ );
56
+ }
57
+
58
+ function buildCommentPrompt(
59
+ comments: ReviewComment[],
60
+ intro: string,
61
+ codeFenceLanguage: string,
39
62
  ): string {
40
63
  const body = comments
41
64
  .map((comment) => {
42
65
  const location = formatCommentLocation(comment);
43
66
  const excerpt = comment.lineText.trim()
44
- ? `\n Excerpt:\n\n\`\`\`diff\n${comment.lineText}\n\`\`\``
67
+ ? `\n Excerpt:\n\n\`\`\`${codeFenceLanguage}\n${comment.lineText}\n\`\`\``
45
68
  : "";
46
69
  return `- \`${location}\` — ${comment.text}${excerpt}`;
47
70
  })
48
71
  .join("\n");
49
72
 
50
- return `Address this local code review feedback for ${promptLabel}.\n\n## Review comments\n${body}\n\nPlease apply the feedback and summarize what changed.`;
73
+ return `${intro}\n\n## Review comments\n${body}\n\nPlease apply the feedback and summarize what changed.`;
51
74
  }
@@ -42,6 +42,20 @@ 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
+
51
+ export type WorkspaceCommentSummary = {
52
+ visible: number;
53
+ hiddenInCurrentFiles: number;
54
+ elsewhere: number;
55
+ stale: number;
56
+ orphaned: number;
57
+ };
58
+
45
59
  export type DiffRenderMode = "unified" | "split";
46
60
 
47
61
  export type SplitDiffCell = {