pi-diff-review 0.1.19 → 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.
package/README.md CHANGED
@@ -49,6 +49,14 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
49
49
 
50
50
  `/diff <git-diff-args>` is passed through to `git diff`, so these examples are equivalent to running `git diff`, `git diff --cached`, and `git diff main...HEAD` locally before opening the review UI.
51
51
 
52
+ Open one or more files or folders with `/view`:
53
+
54
+ ```text
55
+ /view src/index.ts src/review
56
+ ```
57
+
58
+ `/view` expands folders into text files, renders them in the same review UI, and lets you annotate actual code lines instead of diff hunks.
59
+
52
60
  ### Staged vs. unstaged changes
53
61
 
54
62
  - `/diff` shows unstaged working-tree changes only.
@@ -59,6 +67,7 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
59
67
  ## Features
60
68
 
61
69
  - `/diff` reviews the current unstaged `git diff`
70
+ - `/view <files-or-folders>` reviews source files directly
62
71
  - `/diff --cached` reviews staged changes
63
72
  - `/diff main...HEAD` reviews changes on the current branch relative to `main`
64
73
  - `/diff <git-diff-args>` passes arguments through to `git diff`
@@ -77,7 +86,9 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
77
86
  - `C` to add or edit an overall diff comment
78
87
  - `x` to delete a comment for the current line or selected range
79
88
  - `Enter` to submit comments back to pi
80
- - Comments are cached per session and restored when reopening the same diff
89
+ - Comments are cached per session and restored when reopening the same diff or view
90
+ - File comments are also persisted in a repo-local workspace store and shown again in `/view` or on matching lines in `/diff`
91
+ - The UI indicates persisted comments that are hidden in the current files, elsewhere in the workspace, stale, or orphaned
81
92
  - `q` to exit
82
93
 
83
94
  ## Contributing
@@ -1,6 +1,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { registerDiffReviewCommand } from "../src/index.ts";
2
+ import {
3
+ registerDiffReviewCommand,
4
+ registerViewCommand,
5
+ } from "../src/index.ts";
3
6
 
4
7
  export default function (pi: ExtensionAPI) {
5
8
  registerDiffReviewCommand(pi);
9
+ registerViewCommand(pi);
6
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import type { DiffSource } from "../review/types.ts";
3
+ import { tokenizeShellArgs } from "../shared/args.ts";
3
4
 
4
5
  export function parseDiffSource(args: string): DiffSource {
5
6
  const trimmed = args.trim();
@@ -20,48 +21,12 @@ export function parseDiffSource(args: string): DiffSource {
20
21
  }
21
22
 
22
23
  function tokenizeDiffArgs(input: string): string[] {
23
- const args: string[] = [];
24
- let current = "";
25
- let quote: '"' | "'" | undefined;
26
- let escaping = false;
27
-
28
- for (const char of input) {
29
- if (escaping) {
30
- current += char;
31
- escaping = false;
32
- continue;
33
- }
34
-
35
- if (char === "\\" && quote !== "'") {
36
- escaping = true;
37
- continue;
38
- }
39
-
40
- if ((char === '"' || char === "'") && !quote) {
41
- quote = char;
42
- continue;
43
- }
44
-
45
- if (char === quote) {
46
- quote = undefined;
47
- continue;
48
- }
49
-
50
- if (/\s/.test(char) && !quote) {
51
- if (current) {
52
- args.push(current);
53
- current = "";
54
- }
55
- continue;
56
- }
57
-
58
- current += char;
24
+ try {
25
+ return tokenizeShellArgs(input);
26
+ } catch (error) {
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ throw new Error(message.replace(/in arguments$/, "in git diff args"));
59
29
  }
60
-
61
- if (escaping) current += "\\";
62
- if (quote) throw new Error(`Unterminated ${quote} quote in git diff args`);
63
- if (current) args.push(current);
64
- return args;
65
30
  }
66
31
 
67
32
  export const DIFF_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
@@ -12,7 +12,40 @@ export function getCurrentHunkScope(
12
12
  selected: number,
13
13
  ): ExplanationScope | undefined {
14
14
  const selectedLine = lines[selected];
15
- if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
15
+ if (!selectedLine?.filePath) return undefined;
16
+ if (!selectedLine.commentable && !selectedLine.hunkLabel) return undefined;
17
+
18
+ if (!selectedLine.hunkLabel) {
19
+ let start = selected;
20
+ while (
21
+ start > 0 &&
22
+ lines[start - 1]?.filePath === selectedLine.filePath &&
23
+ lines[start - 1]?.commentable
24
+ ) {
25
+ start--;
26
+ }
27
+
28
+ let end = selected;
29
+ while (
30
+ end + 1 < lines.length &&
31
+ lines[end + 1]?.filePath === selectedLine.filePath &&
32
+ lines[end + 1]?.commentable
33
+ ) {
34
+ end++;
35
+ }
36
+
37
+ const diffText = lines
38
+ .slice(start, end + 1)
39
+ .map((line) => line.text.replace(/^ /, ""))
40
+ .join("\n");
41
+ return {
42
+ key: `file:${selectedLine.filePath}:${start}:${end}`,
43
+ kind: "file",
44
+ title: selectedLine.filePath,
45
+ filePath: selectedLine.filePath,
46
+ diffText,
47
+ };
48
+ }
16
49
 
17
50
  let start = selected;
18
51
  while (
@@ -4,7 +4,7 @@ import type { AssistantMessage, Context } from "@earendil-works/pi-ai";
4
4
 
5
5
  export type ExplanationScope = {
6
6
  key: string;
7
- kind: "hunk";
7
+ kind: "hunk" | "file";
8
8
  title: string;
9
9
  filePath?: string;
10
10
  diffText: string;
@@ -30,10 +30,30 @@ export function buildAskPrompt(
30
30
  scope: ExplanationScope,
31
31
  question: string,
32
32
  ): string {
33
+ if (scope.kind === "file") {
34
+ return `Given this code excerpt:\n\`\`\`${scope.filePath ? ` ${scope.filePath}` : ""}\n${scope.diffText}\n\`\`\`\n\n${question}`;
35
+ }
36
+
33
37
  return `Given this git diff hunk:\n\`\`\`diff\n${scope.diffText}\n\`\`\`\n\n${question}`;
34
38
  }
35
39
 
36
40
  export function buildExplanationPrompt(scope: ExplanationScope): string {
41
+ if (scope.kind === "file") {
42
+ return `Explain this code excerpt for a reviewer.
43
+
44
+ Focus on:
45
+ - what this code is doing
46
+ - why it matters in context
47
+ - behavioral, API, or test implications
48
+ - notable risks or edge cases
49
+
50
+ Keep it concise and practical.
51
+
52
+ \`\`\`${scope.filePath ? ` ${scope.filePath}` : ""}
53
+ ${scope.diffText}
54
+ \`\`\``;
55
+ }
56
+
37
57
  return `Explain this git diff hunk for a code reviewer.
38
58
 
39
59
  Focus on:
@@ -72,7 +92,7 @@ export class PiModelDiffExplainer implements DiffExplainer {
72
92
 
73
93
  const context: Context = {
74
94
  systemPrompt:
75
- "You explain code diffs clearly and concisely for code review. Focus on intent, behavior, and risk. Avoid restating every changed line.",
95
+ "You explain code clearly and concisely for review. Focus on intent, behavior, and risk. Avoid restating every line.",
76
96
  messages: [
77
97
  {
78
98
  role: "user",
package/src/index.ts CHANGED
@@ -6,197 +6,35 @@ import type {
6
6
  import { getDiff, parseDiffSource } from "./diff/source.ts";
7
7
  import { parseDiff } from "./diff/parser.ts";
8
8
  import { PiModelDiffExplainer } from "./explanation/explainer.ts";
9
- import { buildReviewPrompt } from "./review/prompt.ts";
9
+ import {
10
+ getCachedAsk,
11
+ getCachedComments,
12
+ getCachedExplanations,
13
+ persistCachedAsk,
14
+ persistCachedComments,
15
+ persistCachedExplanations,
16
+ } from "./review/cache.ts";
10
17
  import { ReviewComponent } from "./review/component.ts";
18
+ import { buildReviewPrompt, buildViewReviewPrompt } from "./review/prompt.ts";
11
19
  import type {
12
- DiffSource,
13
- PersistedAsk,
14
20
  ReviewComment,
21
+ ReviewLine,
15
22
  ReviewResult,
16
23
  } from "./review/types.ts";
24
+ import { WorkspaceCommentStore } from "./review/workspace-comments.ts";
25
+ import { parseViewFiles } from "./view/parser.ts";
26
+ import { parseViewSource, resolveViewFiles } from "./view/source.ts";
17
27
 
18
- const DIFF_REVIEW_CACHE_ENTRY = "pi-diff-review-cache";
19
- const DIFF_REVIEW_EXPLANATION_CACHE_ENTRY = "pi-diff-review-explanation-cache";
20
- const DIFF_REVIEW_ASK_CACHE_ENTRY = "pi-diff-review-ask-cache";
21
-
22
- type DiffReviewCacheEntry = {
23
- cacheKey: string;
24
- comments: ReviewComment[];
25
- updatedAt: number;
26
- };
27
-
28
- type DiffExplanationCacheEntry = {
29
- cacheKey: string;
30
- explanations: Record<string, string>;
31
- updatedAt: number;
32
- };
33
-
34
- type DiffAskCacheEntry = {
35
- cacheKey: string;
36
- ask?: PersistedAsk;
37
- updatedAt: number;
38
- };
39
-
40
- function getDiffCacheKey(
41
- cwd: string,
42
- source: DiffSource,
43
- diffText: string,
44
- ): string {
45
- const hash = createHash("sha256").update(diffText).digest("hex");
46
- return `${cwd}\0${source.label}\0${hash}`;
47
- }
48
-
49
- function getCachedComments(
50
- ctx: ExtensionCommandContext,
51
- cacheKey: string,
52
- ): Map<string, ReviewComment> {
53
- let latest: DiffReviewCacheEntry | undefined;
54
- for (const entry of ctx.sessionManager.getEntries()) {
55
- if (
56
- entry.type !== "custom" ||
57
- entry.customType !== DIFF_REVIEW_CACHE_ENTRY
58
- ) {
59
- continue;
60
- }
61
-
62
- const data = entry.data as Partial<DiffReviewCacheEntry> | undefined;
63
- if (data?.cacheKey !== cacheKey || !Array.isArray(data.comments)) {
64
- continue;
65
- }
66
-
67
- if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
68
- latest = {
69
- cacheKey: data.cacheKey,
70
- comments: data.comments,
71
- updatedAt: data.updatedAt ?? 0,
72
- };
73
- }
74
- }
75
-
76
- return new Map(
77
- (latest?.comments ?? []).map((comment) => [comment.id, comment]),
78
- );
79
- }
80
-
81
- function persistCachedComments(
82
- pi: ExtensionAPI,
83
- cacheKey: string,
84
- comments: Iterable<ReviewComment>,
85
- ): void {
86
- pi.appendEntry(DIFF_REVIEW_CACHE_ENTRY, {
87
- cacheKey,
88
- comments: [...comments],
89
- updatedAt: Date.now(),
90
- } satisfies DiffReviewCacheEntry);
91
- }
92
-
93
- function getCachedExplanations(
94
- ctx: ExtensionCommandContext,
95
- cacheKey: string,
96
- ): Map<string, string> {
97
- let latest: DiffExplanationCacheEntry | undefined;
98
- for (const entry of ctx.sessionManager.getEntries()) {
99
- if (
100
- entry.type !== "custom" ||
101
- entry.customType !== DIFF_REVIEW_EXPLANATION_CACHE_ENTRY
102
- ) {
103
- continue;
104
- }
105
-
106
- const data = entry.data as Partial<DiffExplanationCacheEntry> | undefined;
107
- if (
108
- data?.cacheKey !== cacheKey ||
109
- !data.explanations ||
110
- typeof data.explanations !== "object" ||
111
- Array.isArray(data.explanations)
112
- ) {
113
- continue;
114
- }
115
-
116
- if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
117
- latest = {
118
- cacheKey: data.cacheKey,
119
- explanations: Object.fromEntries(
120
- Object.entries(data.explanations).filter(
121
- (entry): entry is [string, string] =>
122
- typeof entry[0] === "string" && typeof entry[1] === "string",
123
- ),
124
- ),
125
- updatedAt: data.updatedAt ?? 0,
126
- };
127
- }
128
- }
129
-
130
- return new Map(Object.entries(latest?.explanations ?? {}));
131
- }
132
-
133
- function persistCachedExplanations(
134
- pi: ExtensionAPI,
135
- cacheKey: string,
136
- explanations: Map<string, string>,
137
- ): void {
138
- pi.appendEntry(DIFF_REVIEW_EXPLANATION_CACHE_ENTRY, {
139
- cacheKey,
140
- explanations: Object.fromEntries(explanations),
141
- updatedAt: Date.now(),
142
- } satisfies DiffExplanationCacheEntry);
143
- }
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);
28
+ function getReviewCacheKey(cwd: string, label: string, text: string): string {
29
+ const hash = createHash("sha256").update(text).digest("hex");
30
+ return `${cwd}\0${label}\0${hash}`;
193
31
  }
194
32
 
195
33
  export function registerDiffReviewCommand(pi: ExtensionAPI): void {
196
34
  pi.registerCommand("diff", {
197
35
  description: "Review a git diff in a custom TUI (/diff [git diff args])",
198
36
  handler: async (args: string, ctx: ExtensionCommandContext) => {
199
- let source: DiffSource;
37
+ let source;
200
38
  let diffText: string;
201
39
  try {
202
40
  source = parseDiffSource(args);
@@ -213,62 +51,168 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
213
51
  }
214
52
 
215
53
  const reviewLines = parseDiff(diffText);
216
- const cacheKey = getDiffCacheKey(ctx.cwd, source, diffText);
217
- const comments = getCachedComments(ctx, cacheKey);
218
- const explanations = getCachedExplanations(ctx, cacheKey);
219
- const ask = getCachedAsk(ctx, cacheKey);
220
- if (comments.size > 0) {
221
- ctx.ui.notify(
222
- `Restored ${comments.size} cached diff comment${comments.size === 1 ? "" : "s"}.`,
223
- "info",
224
- );
54
+ await openReview(pi, ctx, {
55
+ title: source.label,
56
+ promptLabel: source.promptLabel,
57
+ cacheKey: getReviewCacheKey(ctx.cwd, source.label, diffText),
58
+ reviewLines,
59
+ buildPrompt: (comments) =>
60
+ buildReviewPrompt(comments, source.promptLabel),
61
+ });
62
+ },
63
+ });
64
+ }
65
+
66
+ export function registerViewCommand(pi: ExtensionAPI): void {
67
+ pi.registerCommand("view", {
68
+ description:
69
+ "Review one or more files or folders in a custom TUI (/view <paths...>)",
70
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
71
+ let source;
72
+ let files: string[];
73
+ try {
74
+ source = parseViewSource(args);
75
+ files = resolveViewFiles(ctx.cwd, source);
76
+ } catch (error) {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ ctx.ui.notify(`Unable to open view: ${message}`, "error");
79
+ return;
225
80
  }
226
- if (explanations.size > 0) {
81
+
82
+ if (files.length === 0) {
227
83
  ctx.ui.notify(
228
- `Restored ${explanations.size} cached hunk explanation${explanations.size === 1 ? "" : "s"}.`,
84
+ "No viewable text files matched the requested paths.",
229
85
  "info",
230
86
  );
87
+ return;
231
88
  }
232
- if (ask) {
233
- ctx.ui.notify("Restored cached ask answer.", "info");
234
- }
235
89
 
236
- const result = await ctx.ui.custom<ReviewResult>(
237
- (tui, theme, _keybindings, done) => {
238
- return new ReviewComponent(
239
- tui,
240
- theme,
241
- source.label,
242
- reviewLines,
243
- comments,
244
- done,
245
- new PiModelDiffExplainer(ctx),
246
- (updatedComments) => {
247
- persistCachedComments(pi, cacheKey, updatedComments.values());
248
- },
249
- explanations,
250
- (updatedExplanations) => {
251
- persistCachedExplanations(pi, cacheKey, updatedExplanations);
252
- },
253
- ask,
254
- (updatedAsk) => {
255
- persistCachedAsk(pi, cacheKey, updatedAsk);
256
- },
90
+ const workspaceStore = new WorkspaceCommentStore(ctx.cwd);
91
+ const reviewLines = parseViewFiles(workspaceStore.rootPath, files);
92
+ const contentKey = reviewLines.map((line) => line.text).join("\n");
93
+
94
+ await openReview(pi, ctx, {
95
+ title: source.label,
96
+ promptLabel: source.promptLabel,
97
+ cacheKey: getReviewCacheKey(ctx.cwd, source.label, contentKey),
98
+ reviewLines,
99
+ workspaceStore,
100
+ buildPrompt: (comments) =>
101
+ buildViewReviewPrompt(comments, source.promptLabel),
102
+ });
103
+ },
104
+ });
105
+ }
106
+
107
+ async function openReview(
108
+ pi: ExtensionAPI,
109
+ ctx: ExtensionCommandContext,
110
+ options: {
111
+ title: string;
112
+ promptLabel: string;
113
+ cacheKey: string;
114
+ reviewLines: ReviewLine[];
115
+ buildPrompt: (comments: ReviewComment[]) => string;
116
+ workspaceStore?: WorkspaceCommentStore;
117
+ },
118
+ ): Promise<void> {
119
+ const workspaceStore =
120
+ options.workspaceStore ?? new WorkspaceCommentStore(ctx.cwd);
121
+ const cachedComments = getCachedComments(ctx, options.cacheKey);
122
+ const comments = workspaceStore.getVisibleComments(options.reviewLines);
123
+ for (const [id, comment] of cachedComments) {
124
+ comments.set(id, comment);
125
+ }
126
+
127
+ const explanations = getCachedExplanations(ctx, options.cacheKey);
128
+ const ask = getCachedAsk(ctx, options.cacheKey);
129
+ const summary = () => workspaceStore.summarize(options.reviewLines);
130
+
131
+ if (cachedComments.size > 0) {
132
+ ctx.ui.notify(
133
+ `Restored ${cachedComments.size} cached review comment${cachedComments.size === 1 ? "" : "s"}.`,
134
+ "info",
135
+ );
136
+ }
137
+ if (explanations.size > 0) {
138
+ ctx.ui.notify(
139
+ `Restored ${explanations.size} cached explanation${explanations.size === 1 ? "" : "s"}.`,
140
+ "info",
141
+ );
142
+ }
143
+ if (ask) {
144
+ ctx.ui.notify("Restored cached ask answer.", "info");
145
+ }
146
+
147
+ const initialSummary = summary();
148
+ if (initialSummary.hiddenInCurrentFiles > 0 || initialSummary.elsewhere > 0) {
149
+ ctx.ui.notify(
150
+ [
151
+ initialSummary.hiddenInCurrentFiles > 0
152
+ ? `${initialSummary.hiddenInCurrentFiles} comments hidden in current files`
153
+ : undefined,
154
+ initialSummary.elsewhere > 0
155
+ ? `${initialSummary.elsewhere} comments elsewhere in the workspace`
156
+ : undefined,
157
+ ]
158
+ .filter(Boolean)
159
+ .join(" • "),
160
+ "info",
161
+ );
162
+ }
163
+ if (initialSummary.stale > 0 || initialSummary.orphaned > 0) {
164
+ ctx.ui.notify(
165
+ [
166
+ initialSummary.stale > 0
167
+ ? `${initialSummary.stale} stale comment${initialSummary.stale === 1 ? "" : "s"}`
168
+ : undefined,
169
+ initialSummary.orphaned > 0
170
+ ? `${initialSummary.orphaned} orphaned comment${initialSummary.orphaned === 1 ? "" : "s"}`
171
+ : undefined,
172
+ ]
173
+ .filter(Boolean)
174
+ .join(" • "),
175
+ "warning",
176
+ );
177
+ }
178
+
179
+ const result = await ctx.ui.custom<ReviewResult>(
180
+ (tui, theme, _keybindings, done) => {
181
+ return new ReviewComponent(
182
+ tui,
183
+ theme,
184
+ options.title,
185
+ options.reviewLines,
186
+ comments,
187
+ done,
188
+ new PiModelDiffExplainer(ctx),
189
+ (updatedComments) => {
190
+ persistCachedComments(pi, options.cacheKey, updatedComments.values());
191
+ workspaceStore.syncFromComments(
192
+ options.reviewLines,
193
+ updatedComments.values(),
257
194
  );
258
195
  },
196
+ explanations,
197
+ (updatedExplanations) => {
198
+ persistCachedExplanations(pi, options.cacheKey, updatedExplanations);
199
+ },
200
+ ask,
201
+ (updatedAsk) => {
202
+ persistCachedAsk(pi, options.cacheKey, updatedAsk);
203
+ },
204
+ () => summary(),
259
205
  );
206
+ },
207
+ );
260
208
 
261
- if (!result || result.action !== "submit") return;
262
- if (result.comments.length === 0) {
263
- ctx.ui.notify("No review comments to send.", "info");
264
- persistCachedComments(pi, cacheKey, []);
265
- return;
266
- }
209
+ if (!result || result.action !== "submit") return;
210
+ if (result.comments.length === 0) {
211
+ ctx.ui.notify("No review comments to send.", "info");
212
+ persistCachedComments(pi, options.cacheKey, []);
213
+ return;
214
+ }
267
215
 
268
- persistCachedComments(pi, cacheKey, []);
269
- pi.sendUserMessage(
270
- buildReviewPrompt(result.comments, source.promptLabel),
271
- );
272
- },
273
- });
216
+ persistCachedComments(pi, options.cacheKey, []);
217
+ pi.sendUserMessage(options.buildPrompt(result.comments));
274
218
  }