pi-diff-review 0.1.1 → 0.1.3

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.1",
3
+ "version": "0.1.3",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,6 +21,7 @@
21
21
  ],
22
22
  "files": [
23
23
  "extensions",
24
+ "src",
24
25
  "README.md",
25
26
  "LICENSE"
26
27
  ],
@@ -0,0 +1,410 @@
1
+ import { parsePatchFiles } from "@pierre/diffs";
2
+ import type {
3
+ ChangeContent,
4
+ ContextContent,
5
+ FileDiffMetadata,
6
+ Hunk,
7
+ } from "@pierre/diffs";
8
+ import type { ReviewLine } from "./types.ts";
9
+
10
+ export function parseDiff(diffText: string): ReviewLine[] {
11
+ try {
12
+ const reviewLines = parseDiffWithPierre(diffText);
13
+ if (reviewLines.length > 0) return reviewLines;
14
+ } catch {
15
+ // Fall back to the local parser for any patch formats @pierre/diffs does
16
+ // not recognize. The review UI should remain available even if the richer
17
+ // parser fails on unusual diff output.
18
+ }
19
+
20
+ return parseDiffManual(diffText);
21
+ }
22
+
23
+ function parseDiffWithPierre(diffText: string): ReviewLine[] {
24
+ const patches = parsePatchFiles(diffText);
25
+ const parsed: ReviewLine[] = [];
26
+ let lineIndex = 0;
27
+
28
+ const pushLine = (line: Omit<ReviewLine, "id">) => {
29
+ parsed.push({ id: `line-${lineIndex++}`, ...line });
30
+ };
31
+
32
+ for (const patch of patches) {
33
+ if (patch.patchMetadata?.trim()) {
34
+ for (const line of patch.patchMetadata.trimEnd().split("\n")) {
35
+ pushLine({ kind: "meta", text: line, commentable: false });
36
+ }
37
+ }
38
+
39
+ for (const file of patch.files) {
40
+ appendPierreFileDiff(file, pushLine);
41
+ }
42
+ }
43
+
44
+ return parsed;
45
+ }
46
+
47
+ function appendPierreFileDiff(
48
+ file: FileDiffMetadata,
49
+ pushLine: (line: Omit<ReviewLine, "id">) => void,
50
+ ): void {
51
+ const previousFile =
52
+ file.prevName ?? (file.type === "new" ? undefined : file.name);
53
+ const nextFile = file.type === "deleted" ? undefined : file.name;
54
+ const displayPreviousFile = previousFile ?? file.name;
55
+ const displayNextFile = nextFile ?? file.name;
56
+ const currentFile = nextFile ?? previousFile ?? file.name;
57
+
58
+ pushLine({
59
+ kind: "meta",
60
+ text: `diff --git a/${displayPreviousFile} b/${displayNextFile}`,
61
+ filePath: currentFile,
62
+ commentable: false,
63
+ });
64
+
65
+ if (file.type === "new" && file.mode) {
66
+ pushLine({
67
+ kind: "meta",
68
+ text: `new file mode ${file.mode}`,
69
+ filePath: currentFile,
70
+ commentable: false,
71
+ });
72
+ } else if (file.type === "deleted" && file.mode) {
73
+ pushLine({
74
+ kind: "meta",
75
+ text: `deleted file mode ${file.mode}`,
76
+ filePath: currentFile,
77
+ commentable: false,
78
+ });
79
+ } else if (file.prevMode && file.mode && file.prevMode !== file.mode) {
80
+ pushLine({
81
+ kind: "meta",
82
+ text: `old mode ${file.prevMode}`,
83
+ filePath: currentFile,
84
+ commentable: false,
85
+ });
86
+ pushLine({
87
+ kind: "meta",
88
+ text: `new mode ${file.mode}`,
89
+ filePath: currentFile,
90
+ commentable: false,
91
+ });
92
+ }
93
+
94
+ if (file.prevObjectId && file.newObjectId) {
95
+ pushLine({
96
+ kind: "meta",
97
+ text: `index ${file.prevObjectId}..${file.newObjectId}${file.mode ? ` ${file.mode}` : ""}`,
98
+ filePath: currentFile,
99
+ commentable: false,
100
+ });
101
+ }
102
+
103
+ if (file.type === "rename-pure" || file.type === "rename-changed") {
104
+ if (file.prevName) {
105
+ pushLine({
106
+ kind: "meta",
107
+ text: `rename from ${file.prevName}`,
108
+ filePath: currentFile,
109
+ commentable: false,
110
+ });
111
+ }
112
+ pushLine({
113
+ kind: "meta",
114
+ text: `rename to ${file.name}`,
115
+ filePath: currentFile,
116
+ commentable: false,
117
+ });
118
+ }
119
+
120
+ if (file.hunks.length === 0) return;
121
+
122
+ pushLine({
123
+ kind: "meta",
124
+ text: previousFile ? `--- a/${previousFile}` : "--- /dev/null",
125
+ filePath: currentFile,
126
+ commentable: false,
127
+ });
128
+ pushLine({
129
+ kind: "meta",
130
+ text: nextFile ? `+++ b/${nextFile}` : "+++ /dev/null",
131
+ filePath: currentFile,
132
+ commentable: false,
133
+ });
134
+
135
+ for (const hunk of file.hunks) {
136
+ appendPierreHunk(file, hunk, currentFile, pushLine);
137
+ }
138
+ }
139
+
140
+ function appendPierreHunk(
141
+ file: FileDiffMetadata,
142
+ hunk: Hunk,
143
+ currentFile: string,
144
+ pushLine: (line: Omit<ReviewLine, "id">) => void,
145
+ ): void {
146
+ const hunkLabel = hunk.hunkSpecs?.trimEnd() ?? "@@";
147
+ let oldLine = hunk.deletionStart;
148
+ let newLine = hunk.additionStart;
149
+ let deletionIndex = hunk.deletionLineIndex;
150
+ let additionIndex = hunk.additionLineIndex;
151
+
152
+ pushLine({
153
+ kind: "hunk",
154
+ text: hunkLabel,
155
+ filePath: currentFile,
156
+ commentable: false,
157
+ hunkLabel,
158
+ });
159
+
160
+ for (const content of hunk.hunkContent) {
161
+ if (content.type === "context") {
162
+ ({ oldLine, newLine, deletionIndex, additionIndex } = appendPierreContext(
163
+ file,
164
+ content,
165
+ currentFile,
166
+ hunkLabel,
167
+ oldLine,
168
+ newLine,
169
+ deletionIndex,
170
+ additionIndex,
171
+ pushLine,
172
+ ));
173
+ } else {
174
+ ({ oldLine, newLine, deletionIndex, additionIndex } = appendPierreChange(
175
+ file,
176
+ content,
177
+ currentFile,
178
+ hunkLabel,
179
+ oldLine,
180
+ newLine,
181
+ deletionIndex,
182
+ additionIndex,
183
+ pushLine,
184
+ ));
185
+ }
186
+ }
187
+ }
188
+
189
+ type PierreLineState = {
190
+ oldLine: number;
191
+ newLine: number;
192
+ deletionIndex: number;
193
+ additionIndex: number;
194
+ };
195
+
196
+ function appendPierreContext(
197
+ file: FileDiffMetadata,
198
+ content: ContextContent,
199
+ currentFile: string,
200
+ hunkLabel: string,
201
+ oldLine: number,
202
+ newLine: number,
203
+ deletionIndex: number,
204
+ additionIndex: number,
205
+ pushLine: (line: Omit<ReviewLine, "id">) => void,
206
+ ): PierreLineState {
207
+ for (let i = 0; i < content.lines; i++) {
208
+ const lineText =
209
+ file.deletionLines[deletionIndex] ??
210
+ file.additionLines[additionIndex] ??
211
+ "";
212
+ pushLine({
213
+ kind: "context",
214
+ text: ` ${stripLineEnding(lineText)}`,
215
+ filePath: currentFile,
216
+ oldLineNumber: oldLine,
217
+ newLineNumber: newLine,
218
+ commentable: true,
219
+ hunkLabel,
220
+ });
221
+ oldLine++;
222
+ newLine++;
223
+ deletionIndex++;
224
+ additionIndex++;
225
+ }
226
+
227
+ return { oldLine, newLine, deletionIndex, additionIndex };
228
+ }
229
+
230
+ function appendPierreChange(
231
+ file: FileDiffMetadata,
232
+ content: ChangeContent,
233
+ currentFile: string,
234
+ hunkLabel: string,
235
+ oldLine: number,
236
+ newLine: number,
237
+ deletionIndex: number,
238
+ additionIndex: number,
239
+ pushLine: (line: Omit<ReviewLine, "id">) => void,
240
+ ): PierreLineState {
241
+ for (let i = 0; i < content.deletions; i++) {
242
+ const lineText = file.deletionLines[deletionIndex] ?? "";
243
+ pushLine({
244
+ kind: "remove",
245
+ text: `-${stripLineEnding(lineText)}`,
246
+ filePath: currentFile,
247
+ oldLineNumber: oldLine,
248
+ commentable: true,
249
+ hunkLabel,
250
+ });
251
+ oldLine++;
252
+ deletionIndex++;
253
+ }
254
+
255
+ for (let i = 0; i < content.additions; i++) {
256
+ const lineText = file.additionLines[additionIndex] ?? "";
257
+ pushLine({
258
+ kind: "add",
259
+ text: `+${stripLineEnding(lineText)}`,
260
+ filePath: currentFile,
261
+ newLineNumber: newLine,
262
+ commentable: true,
263
+ hunkLabel,
264
+ });
265
+ newLine++;
266
+ additionIndex++;
267
+ }
268
+
269
+ return { oldLine, newLine, deletionIndex, additionIndex };
270
+ }
271
+
272
+ function stripLineEnding(text: string): string {
273
+ return text.replace(/\r?\n$/, "");
274
+ }
275
+
276
+ function parseDiffManual(diffText: string): ReviewLine[] {
277
+ const lines = diffText.split("\n");
278
+ const parsed: ReviewLine[] = [];
279
+
280
+ let currentFile: string | undefined;
281
+ let previousFile: string | undefined;
282
+ let nextFile: string | undefined;
283
+ let currentHunk: string | undefined;
284
+ let oldLine = 0;
285
+ let newLine = 0;
286
+ let lineIndex = 0;
287
+
288
+ for (const raw of lines) {
289
+ if (raw.startsWith("diff --git ")) {
290
+ const match = raw.match(/^diff --git a\/(.+?) b\/(.+)$/);
291
+ previousFile = match?.[1];
292
+ nextFile = match?.[2];
293
+ currentFile = nextFile ?? previousFile;
294
+ currentHunk = undefined;
295
+ parsed.push({
296
+ id: `line-${lineIndex++}`,
297
+ kind: "meta",
298
+ text: raw,
299
+ filePath: currentFile,
300
+ commentable: false,
301
+ });
302
+ continue;
303
+ }
304
+
305
+ if (raw.startsWith("--- ")) {
306
+ previousFile =
307
+ raw === "--- /dev/null"
308
+ ? undefined
309
+ : raw.replace(/^--- a\//, "").replace(/^--- /, "");
310
+ parsed.push({
311
+ id: `line-${lineIndex++}`,
312
+ kind: "meta",
313
+ text: raw,
314
+ filePath: currentFile,
315
+ commentable: false,
316
+ });
317
+ continue;
318
+ }
319
+
320
+ if (raw.startsWith("+++ ")) {
321
+ nextFile =
322
+ raw === "+++ /dev/null"
323
+ ? undefined
324
+ : raw.replace(/^\+\+\+ b\//, "").replace(/^\+\+\+ /, "");
325
+ currentFile = nextFile ?? previousFile;
326
+ parsed.push({
327
+ id: `line-${lineIndex++}`,
328
+ kind: "meta",
329
+ text: raw,
330
+ filePath: currentFile,
331
+ commentable: false,
332
+ });
333
+ continue;
334
+ }
335
+
336
+ if (raw.startsWith("@@")) {
337
+ const match = raw.match(
338
+ /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/,
339
+ );
340
+ if (match) {
341
+ oldLine = Number(match[1]);
342
+ newLine = Number(match[3]);
343
+ }
344
+ currentHunk = raw;
345
+ parsed.push({
346
+ id: `line-${lineIndex++}`,
347
+ kind: "hunk",
348
+ text: raw,
349
+ filePath: currentFile,
350
+ commentable: false,
351
+ hunkLabel: currentHunk,
352
+ });
353
+ continue;
354
+ }
355
+
356
+ if (raw.startsWith("+") && !raw.startsWith("+++")) {
357
+ parsed.push({
358
+ id: `line-${lineIndex++}`,
359
+ kind: "add",
360
+ text: raw,
361
+ filePath: currentFile,
362
+ newLineNumber: newLine,
363
+ commentable: Boolean(currentFile),
364
+ hunkLabel: currentHunk,
365
+ });
366
+ newLine++;
367
+ continue;
368
+ }
369
+
370
+ if (raw.startsWith("-") && !raw.startsWith("---")) {
371
+ parsed.push({
372
+ id: `line-${lineIndex++}`,
373
+ kind: "remove",
374
+ text: raw,
375
+ filePath: currentFile,
376
+ oldLineNumber: oldLine,
377
+ commentable: Boolean(currentFile),
378
+ hunkLabel: currentHunk,
379
+ });
380
+ oldLine++;
381
+ continue;
382
+ }
383
+
384
+ if (raw.startsWith(" ")) {
385
+ parsed.push({
386
+ id: `line-${lineIndex++}`,
387
+ kind: "context",
388
+ text: raw,
389
+ filePath: currentFile,
390
+ oldLineNumber: oldLine,
391
+ newLineNumber: newLine,
392
+ commentable: Boolean(currentFile),
393
+ hunkLabel: currentHunk,
394
+ });
395
+ oldLine++;
396
+ newLine++;
397
+ continue;
398
+ }
399
+
400
+ parsed.push({
401
+ id: `line-${lineIndex++}`,
402
+ kind: "meta",
403
+ text: raw,
404
+ filePath: currentFile,
405
+ commentable: false,
406
+ });
407
+ }
408
+
409
+ return parsed;
410
+ }
@@ -0,0 +1,32 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import type { DiffSource } from "./types.ts";
3
+
4
+ export function parseDiffSource(args: string): DiffSource {
5
+ const trimmed = args.trim();
6
+ if (!trimmed) {
7
+ return {
8
+ label: "unstaged git diff",
9
+ promptLabel: "the current unstaged git diff",
10
+ args: [],
11
+ };
12
+ }
13
+
14
+ const gitArgs = trimmed.split(/\s+/).filter(Boolean);
15
+ return {
16
+ label: `git diff ${trimmed}`,
17
+ promptLabel: `\`git diff ${trimmed}\``,
18
+ args: gitArgs,
19
+ };
20
+ }
21
+
22
+ export function getDiff(cwd: string, source: DiffSource): string {
23
+ return execFileSync(
24
+ "git",
25
+ ["diff", "--no-color", "--unified=3", ...source.args],
26
+ {
27
+ cwd,
28
+ encoding: "utf8",
29
+ stdio: ["ignore", "pipe", "pipe"],
30
+ },
31
+ );
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@mariozechner/pi-coding-agent";
5
+ import { getDiff, parseDiffSource } from "./diff-source.ts";
6
+ import { parseDiff } from "./diff-parser.ts";
7
+ import { buildReviewPrompt } from "./prompt.ts";
8
+ import { ReviewComponent } from "./review-component.ts";
9
+ import type { ReviewComment, ReviewResult } from "./types.ts";
10
+
11
+ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
12
+ pi.registerCommand("diff", {
13
+ description: "Review a git diff in a custom TUI (/diff [git diff args])",
14
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
15
+ const source = parseDiffSource(args);
16
+ let diffText: string;
17
+ try {
18
+ diffText = getDiff(ctx.cwd, source);
19
+ } catch (error) {
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ ctx.ui.notify(`Unable to read ${source.label}: ${message}`, "error");
22
+ return;
23
+ }
24
+
25
+ if (!diffText.trim()) {
26
+ ctx.ui.notify(`No changes to review for ${source.label}.`, "info");
27
+ return;
28
+ }
29
+
30
+ const reviewLines = parseDiff(diffText);
31
+ const result = await ctx.ui.custom<ReviewResult>(
32
+ (tui, theme, _keybindings, done) => {
33
+ const comments = new Map<string, ReviewComment>();
34
+ return new ReviewComponent(
35
+ tui,
36
+ theme,
37
+ source.label,
38
+ reviewLines,
39
+ comments,
40
+ done,
41
+ );
42
+ },
43
+ );
44
+
45
+ if (!result || result.action !== "submit") return;
46
+ if (result.comments.length === 0) {
47
+ ctx.ui.notify("No review comments to send.", "info");
48
+ return;
49
+ }
50
+
51
+ pi.sendUserMessage(
52
+ buildReviewPrompt(result.comments, source.promptLabel),
53
+ );
54
+ },
55
+ });
56
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { ReviewComment } from "./types.ts";
2
+
3
+ export function formatLocation(line: {
4
+ filePath?: string;
5
+ oldLineNumber?: number;
6
+ newLineNumber?: number;
7
+ }): string {
8
+ const file = line.filePath ?? "(unknown file)";
9
+ if (line.oldLineNumber != null && line.newLineNumber != null) {
10
+ if (line.oldLineNumber === line.newLineNumber) {
11
+ return `${file}:${line.newLineNumber}`;
12
+ }
13
+ return `${file}:old:${line.oldLineNumber}/new:${line.newLineNumber}`;
14
+ }
15
+ if (line.newLineNumber != null) return `${file}:new:${line.newLineNumber}`;
16
+ if (line.oldLineNumber != null) return `${file}:old:${line.oldLineNumber}`;
17
+ return file;
18
+ }
19
+
20
+ function formatCommentLocation(comment: ReviewComment): string {
21
+ const start = formatLocation({
22
+ filePath: comment.filePath,
23
+ oldLineNumber: comment.startOldLineNumber,
24
+ newLineNumber: comment.startNewLineNumber,
25
+ });
26
+ const end = formatLocation({
27
+ filePath: comment.filePath,
28
+ oldLineNumber: comment.endOldLineNumber,
29
+ newLineNumber: comment.endNewLineNumber,
30
+ });
31
+ return start === end ? start : `${start} -> ${end}`;
32
+ }
33
+
34
+ export function buildReviewPrompt(
35
+ comments: ReviewComment[],
36
+ promptLabel: string,
37
+ ): string {
38
+ const body = comments
39
+ .map((comment) => {
40
+ const location = formatCommentLocation(comment);
41
+ const excerpt = comment.lineText.trim()
42
+ ? `\n Excerpt:\n\n\`\`\`diff\n${comment.lineText}\n\`\`\``
43
+ : "";
44
+ return `- \`${location}\` — ${comment.text}${excerpt}`;
45
+ })
46
+ .join("\n");
47
+
48
+ return `Address this local code review feedback for ${promptLabel}.\n\n## Review comments\n${body}\n\nPlease apply the feedback and summarize what changed.`;
49
+ }