pi-diff-review 0.1.12 → 0.1.14

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
@@ -2,9 +2,9 @@
2
2
 
3
3
  # pi-diff-review
4
4
 
5
- Embedded code reviews directly directly within [pi](https://pi.dev/).
5
+ Embedded code reviews and AI summaries directly within [pi](https://pi.dev/).
6
6
 
7
- <img width="1986" height="1556" alt="pi-diff-review-screenshot(1)" src="https://github.com/user-attachments/assets/3fd00163-5d19-489b-94ed-3d4816c6cad3" />
7
+ <img width="1986" height="1556" alt="pi-diff-review-screenshot" src="https://github.com/user-attachments/assets/5ddd7226-28d4-4617-8c5c-35b4b6af68dc" />
8
8
 
9
9
  ## Install
10
10
 
@@ -27,14 +27,46 @@ git clone https://github.com/cmpadden/pi-diff-review
27
27
  pi install ./pi-diff-review
28
28
  ```
29
29
 
30
+ ## Usage
31
+
32
+ Start a review with `/diff`. By default, this reviews your current unstaged changes:
33
+
34
+ ```text
35
+ /diff
36
+ ```
37
+
38
+ Review staged changes with `--cached`:
39
+
40
+ ```text
41
+ /diff --cached
42
+ ```
43
+
44
+ Review a branch or commit range by passing any `git diff` arguments after `/diff`:
45
+
46
+ ```text
47
+ /diff main...HEAD
48
+ ```
49
+
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
+
52
+ ### Staged vs. unstaged changes
53
+
54
+ - `/diff` shows unstaged working-tree changes only.
55
+ - `/diff --cached` shows staged changes only.
56
+ - If you have both staged and unstaged edits, run both commands separately to review each set.
57
+ - To review everything relative to a base branch, use a range such as `/diff main...HEAD`.
58
+
30
59
  ## Features
31
60
 
32
61
  - `/diff` reviews the current unstaged `git diff`
33
- - `/diff <git-diff-args>` passes arguments through to `git diff` (for example `/diff main...HEAD`)
62
+ - `/diff --cached` reviews staged changes
63
+ - `/diff main...HEAD` reviews changes on the current branch relative to `main`
64
+ - `/diff <git-diff-args>` passes arguments through to `git diff`
34
65
  - `j/k` or arrow keys to move
35
66
  - `g/G` to jump to the top or bottom of the diff
36
67
  - `ctrl-u` / `ctrl-d` to move up/down by half a page
37
- - `t` toggles the diff between unified and side-by-side split rendering
68
+ - `t` toggles inline comments/explanations
69
+ - `v` toggles the diff between unified and side-by-side split rendering
38
70
  - `?` toggles an AI-generated explanation for the current hunk
39
71
  - `J/K` to extend a highlighted selection into a comment range
40
72
  - `esc` clears the active selection, or exits review when no selection is active
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,73 @@
1
+ import type { ReviewComment, ReviewLine, SelectionBounds } from "./types.ts";
2
+
3
+ export const GLOBAL_COMMENT_KEY = "__global_diff_comment__";
4
+
5
+ export function getSelectionKey(
6
+ lines: ReviewLine[],
7
+ start: number,
8
+ end: number,
9
+ ): string {
10
+ return `${lines[start]?.id ?? start}:${lines[end]?.id ?? end}`;
11
+ }
12
+
13
+ export function buildGlobalComment(text: string): ReviewComment {
14
+ return {
15
+ id: GLOBAL_COMMENT_KEY,
16
+ filePath: "Overall diff",
17
+ text,
18
+ global: true,
19
+ startLineId: GLOBAL_COMMENT_KEY,
20
+ endLineId: GLOBAL_COMMENT_KEY,
21
+ lineText: "",
22
+ };
23
+ }
24
+
25
+ export function buildCommentFromSelection(
26
+ lines: ReviewLine[],
27
+ selection: SelectionBounds,
28
+ text: string,
29
+ ): ReviewComment {
30
+ const startLine = lines[selection.start]!;
31
+ const endLine = lines[selection.end]!;
32
+ const excerpt = lines
33
+ .slice(selection.start, selection.end + 1)
34
+ .map((line) => line.text)
35
+ .join("\n");
36
+ return {
37
+ id: getSelectionKey(lines, selection.start, selection.end),
38
+ filePath: startLine.filePath ?? endLine.filePath ?? "(unknown file)",
39
+ text,
40
+ startLineId: startLine.id,
41
+ endLineId: endLine.id,
42
+ startOldLineNumber: startLine.oldLineNumber,
43
+ startNewLineNumber: startLine.newLineNumber,
44
+ endOldLineNumber: endLine.oldLineNumber,
45
+ endNewLineNumber: endLine.newLineNumber,
46
+ lineText: excerpt,
47
+ };
48
+ }
49
+
50
+ export function buildCommentLineKeys(
51
+ comments: Map<string, ReviewComment>,
52
+ lineIndexById: Map<string, number>,
53
+ ): Map<number, string[]> {
54
+ const commentLineKeys = new Map<number, string[]>();
55
+
56
+ for (const [key, comment] of comments) {
57
+ const start = lineIndexById.get(comment.startLineId);
58
+ const end = lineIndexById.get(comment.endLineId);
59
+ if (start == null || end == null) continue;
60
+ const from = Math.min(start, end);
61
+ const to = Math.max(start, end);
62
+ for (let index = from; index <= to; index++) {
63
+ const keys = commentLineKeys.get(index);
64
+ if (keys) {
65
+ keys.push(key);
66
+ } else {
67
+ commentLineKeys.set(index, [key]);
68
+ }
69
+ }
70
+ }
71
+
72
+ return commentLineKeys;
73
+ }
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { spawnSync } from "node:child_process";
2
2
  import type { DiffSource } from "./types.ts";
3
3
 
4
4
  export function parseDiffSource(args: string): DiffSource {
@@ -64,14 +64,40 @@ function tokenizeDiffArgs(input: string): string[] {
64
64
  return args;
65
65
  }
66
66
 
67
+ export const DIFF_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
68
+
67
69
  export function getDiff(cwd: string, source: DiffSource): string {
68
- return execFileSync(
69
- "git",
70
- ["diff", "--no-color", "--unified=3", ...source.args],
71
- {
72
- cwd,
73
- encoding: "utf8",
74
- stdio: ["ignore", "pipe", "pipe"],
75
- },
76
- );
70
+ const args = ["diff", "--no-color", "--unified=3", ...source.args];
71
+ const result = spawnSync("git", args, {
72
+ cwd,
73
+ encoding: "utf8",
74
+ maxBuffer: DIFF_MAX_BUFFER_BYTES,
75
+ stdio: ["ignore", "pipe", "pipe"],
76
+ });
77
+
78
+ if (result.error) {
79
+ throw new Error(formatSpawnError(result.error));
80
+ }
81
+
82
+ if (result.status !== 0) {
83
+ const stderr = result.stderr.trim();
84
+ throw new Error(
85
+ stderr || `git ${args.join(" ")} exited with status ${result.status}.`,
86
+ );
87
+ }
88
+
89
+ return result.stdout;
90
+ }
91
+
92
+ function formatSpawnError(error: Error & { code?: string }): string {
93
+ if (error.code === "ENOBUFS") {
94
+ return `Diff output exceeded the ${formatBytes(DIFF_MAX_BUFFER_BYTES)} safety limit. Try reviewing a smaller diff or narrowing with git diff pathspecs.`;
95
+ }
96
+
97
+ return error.message;
98
+ }
99
+
100
+ function formatBytes(bytes: number): string {
101
+ const mib = bytes / 1024 / 1024;
102
+ return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`;
77
103
  }
@@ -0,0 +1,167 @@
1
+ import type {
2
+ DiffExplainer,
3
+ ExplanationScope,
4
+ ExplanationState,
5
+ } from "./explain.ts";
6
+ import type { ReviewLine, ReviewTui } from "./types.ts";
7
+
8
+ const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+
10
+ export function getCurrentHunkScope(
11
+ lines: ReviewLine[],
12
+ selected: number,
13
+ ): ExplanationScope | undefined {
14
+ const selectedLine = lines[selected];
15
+ if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
16
+
17
+ let start = selected;
18
+ while (
19
+ start > 0 &&
20
+ lines[start - 1]?.filePath === selectedLine.filePath &&
21
+ lines[start - 1]?.hunkLabel === selectedLine.hunkLabel
22
+ ) {
23
+ start--;
24
+ }
25
+
26
+ let end = selected;
27
+ while (
28
+ end + 1 < lines.length &&
29
+ lines[end + 1]?.filePath === selectedLine.filePath &&
30
+ lines[end + 1]?.hunkLabel === selectedLine.hunkLabel
31
+ ) {
32
+ end++;
33
+ }
34
+
35
+ const diffText = lines
36
+ .slice(start, end + 1)
37
+ .map((line) => line.text)
38
+ .join("\n");
39
+ return {
40
+ key: `hunk:${selectedLine.filePath}:${selectedLine.hunkLabel}:${start}:${end}`,
41
+ kind: "hunk",
42
+ title: `${selectedLine.filePath} ${selectedLine.hunkLabel}`,
43
+ filePath: selectedLine.filePath,
44
+ diffText,
45
+ };
46
+ }
47
+
48
+ export class ExplanationController {
49
+ readonly explanations = new Map<string, ExplanationState>();
50
+ private abortController?: AbortController;
51
+ private requestId = 0;
52
+ private loadingFrame = 0;
53
+ private loadingTimer?: ReturnType<typeof setInterval>;
54
+
55
+ constructor(
56
+ private readonly tui: ReviewTui,
57
+ private readonly explainer?: DiffExplainer,
58
+ cachedExplanations: Map<string, string> = new Map(),
59
+ private readonly onExplanationsChanged?: (
60
+ explanations: Map<string, string>,
61
+ ) => void,
62
+ ) {
63
+ for (const [key, text] of cachedExplanations) {
64
+ const trimmed = text.trim();
65
+ if (trimmed)
66
+ this.explanations.set(key, { status: "ready", text: trimmed });
67
+ }
68
+ }
69
+
70
+ get isAvailable(): boolean {
71
+ return this.explainer != null;
72
+ }
73
+
74
+ getState(scope: ExplanationScope | undefined): ExplanationState | undefined {
75
+ return scope ? this.explanations.get(scope.key) : undefined;
76
+ }
77
+
78
+ getLoadingFrame(): string {
79
+ return LOADING_FRAMES[this.loadingFrame % LOADING_FRAMES.length] ?? "⠋";
80
+ }
81
+
82
+ ensure(scope: ExplanationScope | undefined): void {
83
+ if (!scope || !this.explainer) return;
84
+ if (this.explanations.has(scope.key)) return;
85
+
86
+ this.abortController?.abort();
87
+ const controller = new AbortController();
88
+ this.abortController = controller;
89
+ const requestId = ++this.requestId;
90
+ let text = "";
91
+
92
+ this.explanations.set(scope.key, { status: "loading", text });
93
+ this.startLoadingTimer();
94
+
95
+ void this.explainer
96
+ .explain(scope, {
97
+ signal: controller.signal,
98
+ onDelta: (delta) => {
99
+ if (requestId !== this.requestId) return;
100
+ text += delta;
101
+ this.explanations.set(scope.key, { status: "loading", text });
102
+ this.tui.requestRender();
103
+ },
104
+ })
105
+ .then((finalText) => {
106
+ if (requestId !== this.requestId) return;
107
+ this.explanations.set(scope.key, {
108
+ status: "ready",
109
+ text: finalText.trim() || text.trim() || "No explanation returned.",
110
+ });
111
+ this.emitExplanationsChanged();
112
+ })
113
+ .catch((error) => {
114
+ if (requestId !== this.requestId) return;
115
+ if (controller.signal.aborted) return;
116
+ this.explanations.set(scope.key, {
117
+ status: "error",
118
+ message: error instanceof Error ? error.message : String(error),
119
+ });
120
+ })
121
+ .finally(() => {
122
+ if (requestId !== this.requestId) return;
123
+ this.stopLoadingTimerIfIdle();
124
+ this.tui.requestRender();
125
+ });
126
+
127
+ this.tui.requestRender();
128
+ }
129
+
130
+ dispose(): void {
131
+ this.abortController?.abort();
132
+ this.stopLoadingTimer();
133
+ }
134
+
135
+ private emitExplanationsChanged(): void {
136
+ if (!this.onExplanationsChanged) return;
137
+
138
+ const readyExplanations = new Map<string, string>();
139
+ for (const [key, explanation] of this.explanations) {
140
+ if (explanation.status === "ready") {
141
+ readyExplanations.set(key, explanation.text);
142
+ }
143
+ }
144
+ this.onExplanationsChanged(readyExplanations);
145
+ }
146
+
147
+ private startLoadingTimer(): void {
148
+ if (this.loadingTimer) return;
149
+ this.loadingTimer = setInterval(() => {
150
+ this.loadingFrame++;
151
+ this.tui.requestRender();
152
+ }, 120);
153
+ }
154
+
155
+ private stopLoadingTimerIfIdle(): void {
156
+ const hasLoading = [...this.explanations.values()].some(
157
+ (explanation) => explanation.status === "loading",
158
+ );
159
+ if (!hasLoading) this.stopLoadingTimer();
160
+ }
161
+
162
+ private stopLoadingTimer(): void {
163
+ if (!this.loadingTimer) return;
164
+ clearInterval(this.loadingTimer);
165
+ this.loadingTimer = undefined;
166
+ }
167
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { ReviewComponent } from "./review-component.ts";
11
11
  import type { DiffSource, ReviewComment, ReviewResult } from "./types.ts";
12
12
 
13
13
  const DIFF_REVIEW_CACHE_ENTRY = "pi-diff-review-cache";
14
+ const DIFF_REVIEW_EXPLANATION_CACHE_ENTRY = "pi-diff-review-explanation-cache";
14
15
 
15
16
  type DiffReviewCacheEntry = {
16
17
  cacheKey: string;
@@ -18,6 +19,12 @@ type DiffReviewCacheEntry = {
18
19
  updatedAt: number;
19
20
  };
20
21
 
22
+ type DiffExplanationCacheEntry = {
23
+ cacheKey: string;
24
+ explanations: Record<string, string>;
25
+ updatedAt: number;
26
+ };
27
+
21
28
  function getDiffCacheKey(
22
29
  cwd: string,
23
30
  source: DiffSource,
@@ -71,6 +78,58 @@ function persistCachedComments(
71
78
  } satisfies DiffReviewCacheEntry);
72
79
  }
73
80
 
81
+ function getCachedExplanations(
82
+ ctx: ExtensionCommandContext,
83
+ cacheKey: string,
84
+ ): Map<string, string> {
85
+ let latest: DiffExplanationCacheEntry | undefined;
86
+ for (const entry of ctx.sessionManager.getEntries()) {
87
+ if (
88
+ entry.type !== "custom" ||
89
+ entry.customType !== DIFF_REVIEW_EXPLANATION_CACHE_ENTRY
90
+ ) {
91
+ continue;
92
+ }
93
+
94
+ const data = entry.data as Partial<DiffExplanationCacheEntry> | undefined;
95
+ if (
96
+ data?.cacheKey !== cacheKey ||
97
+ !data.explanations ||
98
+ typeof data.explanations !== "object" ||
99
+ Array.isArray(data.explanations)
100
+ ) {
101
+ continue;
102
+ }
103
+
104
+ if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
105
+ latest = {
106
+ cacheKey: data.cacheKey,
107
+ explanations: Object.fromEntries(
108
+ Object.entries(data.explanations).filter(
109
+ (entry): entry is [string, string] =>
110
+ typeof entry[0] === "string" && typeof entry[1] === "string",
111
+ ),
112
+ ),
113
+ updatedAt: data.updatedAt ?? 0,
114
+ };
115
+ }
116
+ }
117
+
118
+ return new Map(Object.entries(latest?.explanations ?? {}));
119
+ }
120
+
121
+ function persistCachedExplanations(
122
+ pi: ExtensionAPI,
123
+ cacheKey: string,
124
+ explanations: Map<string, string>,
125
+ ): void {
126
+ pi.appendEntry(DIFF_REVIEW_EXPLANATION_CACHE_ENTRY, {
127
+ cacheKey,
128
+ explanations: Object.fromEntries(explanations),
129
+ updatedAt: Date.now(),
130
+ } satisfies DiffExplanationCacheEntry);
131
+ }
132
+
74
133
  export function registerDiffReviewCommand(pi: ExtensionAPI): void {
75
134
  pi.registerCommand("diff", {
76
135
  description: "Review a git diff in a custom TUI (/diff [git diff args])",
@@ -94,12 +153,19 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
94
153
  const reviewLines = parseDiff(diffText);
95
154
  const cacheKey = getDiffCacheKey(ctx.cwd, source, diffText);
96
155
  const comments = getCachedComments(ctx, cacheKey);
156
+ const explanations = getCachedExplanations(ctx, cacheKey);
97
157
  if (comments.size > 0) {
98
158
  ctx.ui.notify(
99
159
  `Restored ${comments.size} cached diff comment${comments.size === 1 ? "" : "s"}.`,
100
160
  "info",
101
161
  );
102
162
  }
163
+ if (explanations.size > 0) {
164
+ ctx.ui.notify(
165
+ `Restored ${explanations.size} cached hunk explanation${explanations.size === 1 ? "" : "s"}.`,
166
+ "info",
167
+ );
168
+ }
103
169
 
104
170
  const result = await ctx.ui.custom<ReviewResult>(
105
171
  (tui, theme, _keybindings, done) => {
@@ -114,6 +180,10 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
114
180
  (updatedComments) => {
115
181
  persistCachedComments(pi, cacheKey, updatedComments.values());
116
182
  },
183
+ explanations,
184
+ (updatedExplanations) => {
185
+ persistCachedExplanations(pi, cacheKey, updatedExplanations);
186
+ },
117
187
  );
118
188
  },
119
189
  );
package/src/prompt.ts CHANGED
@@ -17,7 +17,7 @@ export function formatLocation(line: {
17
17
  return file;
18
18
  }
19
19
 
20
- function formatCommentLocation(comment: ReviewComment): string {
20
+ export function formatCommentLocation(comment: ReviewComment): string {
21
21
  if (comment.global) return "Overall diff";
22
22
 
23
23
  const start = formatLocation({
@@ -0,0 +1,11 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ export function padToWidth(text: string, width: number): string {
4
+ const visible = visibleWidth(text);
5
+ if (visible >= width) return truncateToWidth(text, width);
6
+ return text + " ".repeat(width - visible);
7
+ }
8
+
9
+ export function lineNumberCell(value?: number): string {
10
+ return value == null ? " " : String(value).padStart(4, " ");
11
+ }