@diegopetrucci/pi-annotate-git-diff 0.1.0
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/.pi-fleet-tested-version +1 -0
- package/README.md +43 -0
- package/clipboard.ts +143 -0
- package/git.ts +943 -0
- package/glimpseui.d.ts +89 -0
- package/index.ts +412 -0
- package/package.json +50 -0
- package/prompt.ts +65 -0
- package/quiet-glimpse.ts +156 -0
- package/types.ts +202 -0
- package/ui.ts +71 -0
- package/watch.ts +104 -0
- package/web/app.js +2381 -0
- package/web/index.html +913 -0
package/glimpseui.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
declare module "glimpseui" {
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
|
|
4
|
+
export type FollowMode = "snap" | "spring";
|
|
5
|
+
export type CursorAnchor = "top-left" | "top-right" | "right" | "bottom-right" | "bottom-left" | "left";
|
|
6
|
+
|
|
7
|
+
export interface GlimpseOpenOptions {
|
|
8
|
+
width?: number;
|
|
9
|
+
height?: number;
|
|
10
|
+
title?: string;
|
|
11
|
+
x?: number;
|
|
12
|
+
y?: number;
|
|
13
|
+
frameless?: boolean;
|
|
14
|
+
floating?: boolean;
|
|
15
|
+
transparent?: boolean;
|
|
16
|
+
clickThrough?: boolean;
|
|
17
|
+
followCursor?: boolean;
|
|
18
|
+
followMode?: FollowMode;
|
|
19
|
+
cursorAnchor?: CursorAnchor;
|
|
20
|
+
cursorOffset?: {
|
|
21
|
+
x?: number;
|
|
22
|
+
y?: number;
|
|
23
|
+
};
|
|
24
|
+
hidden?: boolean;
|
|
25
|
+
autoClose?: boolean;
|
|
26
|
+
timeout?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GlimpseScreenInfo {
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
scaleFactor: number;
|
|
33
|
+
visibleX?: number;
|
|
34
|
+
visibleY?: number;
|
|
35
|
+
visibleWidth?: number;
|
|
36
|
+
visibleHeight?: number;
|
|
37
|
+
x?: number;
|
|
38
|
+
y?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GlimpseAppearanceInfo {
|
|
42
|
+
darkMode: boolean;
|
|
43
|
+
accentColor: string;
|
|
44
|
+
reduceMotion: boolean;
|
|
45
|
+
increaseContrast: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GlimpseCursorInfo {
|
|
49
|
+
x: number;
|
|
50
|
+
y: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GlimpseCursorTip {
|
|
54
|
+
x: number;
|
|
55
|
+
y: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GlimpseInfo {
|
|
59
|
+
screen: GlimpseScreenInfo;
|
|
60
|
+
screens: GlimpseScreenInfo[];
|
|
61
|
+
appearance: GlimpseAppearanceInfo;
|
|
62
|
+
cursor: GlimpseCursorInfo;
|
|
63
|
+
cursorTip: GlimpseCursorTip | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class GlimpseWindow extends EventEmitter {
|
|
67
|
+
on(event: "ready", listener: (info: GlimpseInfo) => void): this;
|
|
68
|
+
on(event: "message", listener: (data: unknown) => void): this;
|
|
69
|
+
on(event: "info", listener: (info: GlimpseInfo) => void): this;
|
|
70
|
+
on(event: "closed", listener: () => void): this;
|
|
71
|
+
on(event: "error", listener: (error: Error) => void): this;
|
|
72
|
+
once(event: "ready", listener: (info: GlimpseInfo) => void): this;
|
|
73
|
+
once(event: "message", listener: (data: unknown) => void): this;
|
|
74
|
+
once(event: "info", listener: (info: GlimpseInfo) => void): this;
|
|
75
|
+
once(event: "closed", listener: () => void): this;
|
|
76
|
+
once(event: "error", listener: (error: Error) => void): this;
|
|
77
|
+
send(js: string): void;
|
|
78
|
+
setHTML(html: string): void;
|
|
79
|
+
show(options?: { title?: string }): void;
|
|
80
|
+
close(): void;
|
|
81
|
+
loadFile(path: string): void;
|
|
82
|
+
get info(): GlimpseInfo | null;
|
|
83
|
+
getInfo(): void;
|
|
84
|
+
followCursor(enabled: boolean, anchor?: CursorAnchor, mode?: FollowMode): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function open(html: string, options?: GlimpseOpenOptions): GlimpseWindow;
|
|
88
|
+
export function prompt<T = unknown>(html: string, options?: GlimpseOpenOptions): Promise<T | null>;
|
|
89
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// Standalone /annotate-git-diff extension.
|
|
2
|
+
// Adapted from @ryan_nookpi/pi-extension-diff-review (MIT), itself inspired by
|
|
3
|
+
// badlogic/pi-diff-review. See ./README.md for attribution details.
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { readSystemClipboard, writeSystemClipboard } from "./clipboard.js";
|
|
6
|
+
import { getCommitFiles, getReviewWindowData, isWorkingTreeCommitSha, loadReviewFileContents } from "./git.js";
|
|
7
|
+
import { composeReviewPrompt } from "./prompt.js";
|
|
8
|
+
import { openQuietGlimpse, type QuietGlimpseWindow } from "./quiet-glimpse.js";
|
|
9
|
+
import type {
|
|
10
|
+
ReviewCancelPayload,
|
|
11
|
+
ReviewClipboardReadPayload,
|
|
12
|
+
ReviewClipboardWritePayload,
|
|
13
|
+
ReviewFile,
|
|
14
|
+
ReviewFileContents,
|
|
15
|
+
ReviewHostMessage,
|
|
16
|
+
ReviewRequestCommitPayload,
|
|
17
|
+
ReviewRequestFilePayload,
|
|
18
|
+
ReviewRequestReviewDataPayload,
|
|
19
|
+
ReviewSubmitPayload,
|
|
20
|
+
ReviewWindowMessage,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
import { buildReviewHtml } from "./ui.js";
|
|
23
|
+
import { createRepoChangeWatcher, type RepoChangeWatcher } from "./watch.js";
|
|
24
|
+
|
|
25
|
+
function hasMessageType(value: unknown, type: ReviewWindowMessage["type"]): boolean {
|
|
26
|
+
return typeof value === "object" && value != null && "type" in value && value.type === type;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isSubmitPayload(value: unknown): value is ReviewSubmitPayload {
|
|
30
|
+
return hasMessageType(value, "submit");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isCancelPayload(value: unknown): value is ReviewCancelPayload {
|
|
34
|
+
return hasMessageType(value, "cancel");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isRequestFilePayload(value: unknown): value is ReviewRequestFilePayload {
|
|
38
|
+
return hasMessageType(value, "request-file");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRequestCommitPayload(value: unknown): value is ReviewRequestCommitPayload {
|
|
42
|
+
return hasMessageType(value, "request-commit");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRequestReviewDataPayload(value: unknown): value is ReviewRequestReviewDataPayload {
|
|
46
|
+
return hasMessageType(value, "request-review-data");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isClipboardReadPayload(value: unknown): value is ReviewClipboardReadPayload {
|
|
50
|
+
return hasMessageType(value, "clipboard-read");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isClipboardWritePayload(value: unknown): value is ReviewClipboardWritePayload {
|
|
54
|
+
return hasMessageType(value, "clipboard-write");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function escapeForInlineScript(value: string): string {
|
|
58
|
+
return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hasReviewFeedback(payload: ReviewSubmitPayload): boolean {
|
|
62
|
+
return payload.overallComment.trim().length > 0 || payload.comments.some((comment) => comment.body.trim().length > 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function appendReviewPrompt(ctx: ExtensionCommandContext, prompt: string): void {
|
|
66
|
+
const prefix = ctx.ui.getEditorText().trim().length > 0 ? "\n\n" : "";
|
|
67
|
+
ctx.ui.pasteToEditor(`${prefix}${prompt}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default function (pi: ExtensionAPI) {
|
|
71
|
+
let activeWindow: QuietGlimpseWindow | null = null;
|
|
72
|
+
let activeWatcher: RepoChangeWatcher | null = null;
|
|
73
|
+
const suppressedWindows = new WeakSet<QuietGlimpseWindow>();
|
|
74
|
+
|
|
75
|
+
function stopActiveWatcher(): void {
|
|
76
|
+
if (activeWatcher == null) return;
|
|
77
|
+
activeWatcher.dispose();
|
|
78
|
+
activeWatcher = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function closeActiveWindow(options: { suppressResults?: boolean } = {}): void {
|
|
82
|
+
if (activeWindow == null) return;
|
|
83
|
+
const windowToClose = activeWindow;
|
|
84
|
+
activeWindow = null;
|
|
85
|
+
stopActiveWatcher();
|
|
86
|
+
if (options.suppressResults) {
|
|
87
|
+
suppressedWindows.add(windowToClose);
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
windowToClose.close();
|
|
91
|
+
} catch {
|
|
92
|
+
// Window is already closing; ignore shutdown races.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function reviewRepository(ctx: ExtensionCommandContext): Promise<void> {
|
|
97
|
+
if (!ctx.hasUI) {
|
|
98
|
+
ctx.ui.notify("annotate-git-diff requires interactive mode.", "error");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (activeWindow != null) {
|
|
103
|
+
ctx.ui.notify("A review window is already open.", "warning");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
let reviewData = await getReviewWindowData(pi, ctx.cwd);
|
|
109
|
+
const { repoRoot } = reviewData;
|
|
110
|
+
if (reviewData.files.length === 0 && reviewData.commits.length === 0) {
|
|
111
|
+
ctx.ui.notify("No reviewable files found.", "info");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const html = buildReviewHtml(reviewData);
|
|
116
|
+
const window = await openQuietGlimpse(html, {
|
|
117
|
+
width: 1680,
|
|
118
|
+
height: 1020,
|
|
119
|
+
title: "annotate-git-diff",
|
|
120
|
+
});
|
|
121
|
+
activeWindow = window;
|
|
122
|
+
|
|
123
|
+
const fileMap = new Map(reviewData.files.map((file) => [file.id, file]));
|
|
124
|
+
const commitFileCache = new Map<string, Promise<ReviewFile[]>>();
|
|
125
|
+
const contentCache = new Map<string, Promise<ReviewFileContents>>();
|
|
126
|
+
|
|
127
|
+
const clearRefreshableCaches = (): void => {
|
|
128
|
+
contentCache.clear();
|
|
129
|
+
for (const sha of commitFileCache.keys()) {
|
|
130
|
+
if (isWorkingTreeCommitSha(sha)) {
|
|
131
|
+
commitFileCache.delete(sha);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const sendWindowMessage = (message: ReviewHostMessage): void => {
|
|
137
|
+
if (activeWindow !== window) return;
|
|
138
|
+
const payload = escapeForInlineScript(JSON.stringify(message));
|
|
139
|
+
window.send(`window.__reviewReceive(${payload});`);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
let watcherWarningShown = false;
|
|
143
|
+
activeWatcher = createRepoChangeWatcher(
|
|
144
|
+
repoRoot,
|
|
145
|
+
() => {
|
|
146
|
+
sendWindowMessage({ type: "working-tree-changed", changedAt: Date.now() });
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
onError: (error) => {
|
|
150
|
+
if (watcherWarningShown || activeWindow !== window) return;
|
|
151
|
+
watcherWarningShown = true;
|
|
152
|
+
ctx.ui.notify(`Review change watcher failed: ${error.message}`, "warning");
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const loadCommitFiles = (sha: string): Promise<ReviewFile[]> => {
|
|
158
|
+
const cached = commitFileCache.get(sha);
|
|
159
|
+
if (cached != null) return cached;
|
|
160
|
+
const pending = getCommitFiles(pi, repoRoot, sha);
|
|
161
|
+
commitFileCache.set(sha, pending);
|
|
162
|
+
pending
|
|
163
|
+
.then((commitFiles) => {
|
|
164
|
+
for (const cf of commitFiles) fileMap.set(cf.id, cf);
|
|
165
|
+
})
|
|
166
|
+
.catch(() => {});
|
|
167
|
+
return pending;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const loadContents = (
|
|
171
|
+
file: ReviewFile,
|
|
172
|
+
scope: ReviewRequestFilePayload["scope"],
|
|
173
|
+
commitSha: string | null,
|
|
174
|
+
): Promise<ReviewFileContents> => {
|
|
175
|
+
const cacheKey = `${scope}:${commitSha ?? ""}:${file.id}`;
|
|
176
|
+
const cached = contentCache.get(cacheKey);
|
|
177
|
+
if (cached != null) return cached;
|
|
178
|
+
|
|
179
|
+
const pending = loadReviewFileContents(pi, repoRoot, file, scope, commitSha, reviewData.branchMergeBaseSha);
|
|
180
|
+
contentCache.set(cacheKey, pending);
|
|
181
|
+
return pending;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const terminalMessagePromise = new Promise<ReviewSubmitPayload | ReviewCancelPayload | null>(
|
|
185
|
+
(resolve, reject) => {
|
|
186
|
+
let settled = false;
|
|
187
|
+
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
188
|
+
|
|
189
|
+
const cleanup = (): void => {
|
|
190
|
+
if (closeTimer != null) {
|
|
191
|
+
clearTimeout(closeTimer);
|
|
192
|
+
closeTimer = null;
|
|
193
|
+
}
|
|
194
|
+
window.removeListener("message", onMessage);
|
|
195
|
+
window.removeListener("closed", onClosed);
|
|
196
|
+
window.removeListener("error", onError);
|
|
197
|
+
if (activeWindow === window) {
|
|
198
|
+
activeWindow = null;
|
|
199
|
+
stopActiveWatcher();
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const settle = (value: ReviewSubmitPayload | ReviewCancelPayload | null): void => {
|
|
204
|
+
if (settled) return;
|
|
205
|
+
settled = true;
|
|
206
|
+
cleanup();
|
|
207
|
+
resolve(value);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const handleRequestFile = async (message: ReviewRequestFilePayload): Promise<void> => {
|
|
211
|
+
const file = fileMap.get(message.fileId);
|
|
212
|
+
if (file == null) {
|
|
213
|
+
sendWindowMessage({
|
|
214
|
+
type: "file-error",
|
|
215
|
+
requestId: message.requestId,
|
|
216
|
+
fileId: message.fileId,
|
|
217
|
+
scope: message.scope,
|
|
218
|
+
commitSha: message.commitSha ?? null,
|
|
219
|
+
message: "Unknown file requested.",
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const contents = await loadContents(file, message.scope, message.commitSha ?? null);
|
|
226
|
+
sendWindowMessage({
|
|
227
|
+
type: "file-data",
|
|
228
|
+
requestId: message.requestId,
|
|
229
|
+
fileId: message.fileId,
|
|
230
|
+
scope: message.scope,
|
|
231
|
+
commitSha: message.commitSha ?? null,
|
|
232
|
+
originalContent: contents.originalContent,
|
|
233
|
+
modifiedContent: contents.modifiedContent,
|
|
234
|
+
kind: contents.kind,
|
|
235
|
+
mimeType: contents.mimeType,
|
|
236
|
+
originalExists: contents.originalExists,
|
|
237
|
+
modifiedExists: contents.modifiedExists,
|
|
238
|
+
originalPreviewUrl: contents.originalPreviewUrl,
|
|
239
|
+
modifiedPreviewUrl: contents.modifiedPreviewUrl,
|
|
240
|
+
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
const messageText = error instanceof Error ? error.message : String(error);
|
|
243
|
+
sendWindowMessage({
|
|
244
|
+
type: "file-error",
|
|
245
|
+
requestId: message.requestId,
|
|
246
|
+
fileId: message.fileId,
|
|
247
|
+
scope: message.scope,
|
|
248
|
+
commitSha: message.commitSha ?? null,
|
|
249
|
+
message: messageText,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const handleRequestCommit = async (message: ReviewRequestCommitPayload): Promise<void> => {
|
|
255
|
+
try {
|
|
256
|
+
const commitFiles = await loadCommitFiles(message.sha);
|
|
257
|
+
sendWindowMessage({
|
|
258
|
+
type: "commit-data",
|
|
259
|
+
requestId: message.requestId,
|
|
260
|
+
sha: message.sha,
|
|
261
|
+
files: commitFiles,
|
|
262
|
+
});
|
|
263
|
+
} catch (error) {
|
|
264
|
+
const messageText = error instanceof Error ? error.message : String(error);
|
|
265
|
+
sendWindowMessage({
|
|
266
|
+
type: "commit-error",
|
|
267
|
+
requestId: message.requestId,
|
|
268
|
+
sha: message.sha,
|
|
269
|
+
message: messageText,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const handleRequestReviewData = async (message: ReviewRequestReviewDataPayload): Promise<void> => {
|
|
275
|
+
try {
|
|
276
|
+
const nextReviewData = await getReviewWindowData(pi, repoRoot);
|
|
277
|
+
clearRefreshableCaches();
|
|
278
|
+
reviewData = nextReviewData;
|
|
279
|
+
for (const file of reviewData.files) fileMap.set(file.id, file);
|
|
280
|
+
sendWindowMessage({
|
|
281
|
+
type: "review-data",
|
|
282
|
+
requestId: message.requestId,
|
|
283
|
+
files: reviewData.files,
|
|
284
|
+
commits: reviewData.commits,
|
|
285
|
+
branchBaseRef: reviewData.branchBaseRef,
|
|
286
|
+
branchMergeBaseSha: reviewData.branchMergeBaseSha,
|
|
287
|
+
repositoryHasHead: reviewData.repositoryHasHead,
|
|
288
|
+
});
|
|
289
|
+
} catch (error) {
|
|
290
|
+
const messageText = error instanceof Error ? error.message : String(error);
|
|
291
|
+
sendWindowMessage({
|
|
292
|
+
type: "review-data-error",
|
|
293
|
+
requestId: message.requestId,
|
|
294
|
+
message: messageText,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const handleClipboardRead = (message: ReviewClipboardReadPayload): void => {
|
|
300
|
+
try {
|
|
301
|
+
sendWindowMessage({
|
|
302
|
+
type: "clipboard-data",
|
|
303
|
+
requestId: message.requestId,
|
|
304
|
+
text: readSystemClipboard(),
|
|
305
|
+
});
|
|
306
|
+
} catch (error) {
|
|
307
|
+
const messageText = error instanceof Error ? error.message : String(error);
|
|
308
|
+
sendWindowMessage({
|
|
309
|
+
type: "clipboard-data",
|
|
310
|
+
requestId: message.requestId,
|
|
311
|
+
text: "",
|
|
312
|
+
message: messageText,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const handleClipboardWrite = (message: ReviewClipboardWritePayload): void => {
|
|
318
|
+
try {
|
|
319
|
+
writeSystemClipboard(message.text);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
const messageText = error instanceof Error ? error.message : String(error);
|
|
322
|
+
ctx.ui.notify(`Failed to copy from review window: ${messageText}`, "warning");
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const onMessage = (message: unknown): void => {
|
|
327
|
+
if (isRequestFilePayload(message)) {
|
|
328
|
+
void handleRequestFile(message);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (isRequestCommitPayload(message)) {
|
|
332
|
+
void handleRequestCommit(message);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (isRequestReviewDataPayload(message)) {
|
|
336
|
+
void handleRequestReviewData(message);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (isClipboardReadPayload(message)) {
|
|
340
|
+
handleClipboardRead(message);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (isClipboardWritePayload(message)) {
|
|
344
|
+
handleClipboardWrite(message);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (isSubmitPayload(message) || isCancelPayload(message)) {
|
|
348
|
+
settle(message);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const onClosed = (): void => {
|
|
353
|
+
if (settled || closeTimer != null) return;
|
|
354
|
+
closeTimer = setTimeout(() => {
|
|
355
|
+
closeTimer = null;
|
|
356
|
+
settle(null);
|
|
357
|
+
}, 250);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const onError = (error: Error): void => {
|
|
361
|
+
if (settled) return;
|
|
362
|
+
settled = true;
|
|
363
|
+
cleanup();
|
|
364
|
+
reject(error);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
window.on("message", onMessage);
|
|
368
|
+
window.on("closed", onClosed);
|
|
369
|
+
window.on("error", onError);
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
void (async () => {
|
|
374
|
+
try {
|
|
375
|
+
const message = await terminalMessagePromise;
|
|
376
|
+
if (suppressedWindows.has(window)) return;
|
|
377
|
+
if (message == null) return;
|
|
378
|
+
if (message.type === "cancel") {
|
|
379
|
+
ctx.ui.notify("Review cancelled.", "info");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (!hasReviewFeedback(message)) return;
|
|
383
|
+
|
|
384
|
+
const prompt = composeReviewPrompt([...fileMap.values()], message);
|
|
385
|
+
appendReviewPrompt(ctx, prompt);
|
|
386
|
+
ctx.ui.notify("Appended review feedback to the editor.", "info");
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (suppressedWindows.has(window)) return;
|
|
389
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
390
|
+
ctx.ui.notify(`Review failed: ${message}`, "error");
|
|
391
|
+
}
|
|
392
|
+
})();
|
|
393
|
+
|
|
394
|
+
ctx.ui.notify("Opened native review window.", "info");
|
|
395
|
+
} catch (error) {
|
|
396
|
+
closeActiveWindow({ suppressResults: true });
|
|
397
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
398
|
+
ctx.ui.notify(`Review failed: ${message}`, "error");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
pi.registerCommand("annotate-git-diff", {
|
|
403
|
+
description: "Open a native review window with branch, per-commit, and all-files scopes",
|
|
404
|
+
handler: async (_args, ctx) => {
|
|
405
|
+
await reviewRepository(ctx);
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
pi.on("session_shutdown", async () => {
|
|
410
|
+
closeActiveWindow({ suppressResults: true });
|
|
411
|
+
});
|
|
412
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@diegopetrucci/pi-annotate-git-diff",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A standalone pi extension that adds /annotate-git-diff, a native Glimpse UI for annotating git diffs and sending feedback to the editor.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"annotate",
|
|
9
|
+
"git",
|
|
10
|
+
"diff",
|
|
11
|
+
"review"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/diegopetrucci/pi-extensions.git",
|
|
17
|
+
"directory": "extensions/annotate-git-diff"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"index.ts",
|
|
21
|
+
"clipboard.ts",
|
|
22
|
+
"git.ts",
|
|
23
|
+
"prompt.ts",
|
|
24
|
+
"quiet-glimpse.ts",
|
|
25
|
+
"types.ts",
|
|
26
|
+
"ui.ts",
|
|
27
|
+
"watch.ts",
|
|
28
|
+
"web",
|
|
29
|
+
"glimpseui.d.ts",
|
|
30
|
+
"README.md",
|
|
31
|
+
".pi-fleet-tested-version"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"pi": {
|
|
37
|
+
"extensions": [
|
|
38
|
+
"index.ts"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@tailwindcss/browser": "^4.3.0",
|
|
46
|
+
"glimpseui": "^0.8.1",
|
|
47
|
+
"monaco-editor": "0.52.2"
|
|
48
|
+
},
|
|
49
|
+
"type": "module"
|
|
50
|
+
}
|
package/prompt.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DiffReviewComment, ReviewFile, ReviewSubmitPayload } from "./types.js";
|
|
2
|
+
|
|
3
|
+
function formatScopeLabel(comment: DiffReviewComment): string {
|
|
4
|
+
switch (comment.scope) {
|
|
5
|
+
case "branch":
|
|
6
|
+
return "branch diff";
|
|
7
|
+
case "commits":
|
|
8
|
+
return comment.commitKind === "working-tree"
|
|
9
|
+
? "working tree changes"
|
|
10
|
+
: comment.commitShort
|
|
11
|
+
? `commit ${comment.commitShort}`
|
|
12
|
+
: "commit";
|
|
13
|
+
default:
|
|
14
|
+
return "all files";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getCommentFilePath(file: ReviewFile | undefined): string {
|
|
19
|
+
if (file == null) return "(unknown file)";
|
|
20
|
+
return file.gitDiff?.displayPath ?? file.path;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatLocation(comment: DiffReviewComment, file: ReviewFile | undefined): string {
|
|
24
|
+
const filePath = getCommentFilePath(file);
|
|
25
|
+
const scopePrefix = `[${formatScopeLabel(comment)}] `;
|
|
26
|
+
|
|
27
|
+
if (comment.side === "file" || comment.startLine == null) {
|
|
28
|
+
return `${scopePrefix}${filePath}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const range =
|
|
32
|
+
comment.endLine != null && comment.endLine !== comment.startLine
|
|
33
|
+
? `${comment.startLine}-${comment.endLine}`
|
|
34
|
+
: `${comment.startLine}`;
|
|
35
|
+
|
|
36
|
+
if (comment.scope === "all") {
|
|
37
|
+
return `${scopePrefix}${filePath}:${range}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const suffix = comment.side === "original" ? " (old)" : " (new)";
|
|
41
|
+
return `${scopePrefix}${filePath}:${range}${suffix}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function composeReviewPrompt(files: ReviewFile[], payload: ReviewSubmitPayload): string {
|
|
45
|
+
const fileMap = new Map(files.map((file) => [file.id, file]));
|
|
46
|
+
const lines: string[] = [];
|
|
47
|
+
|
|
48
|
+
lines.push("Please address the following feedback");
|
|
49
|
+
lines.push("");
|
|
50
|
+
|
|
51
|
+
const overallComment = payload.overallComment.trim();
|
|
52
|
+
if (overallComment.length > 0) {
|
|
53
|
+
lines.push(overallComment);
|
|
54
|
+
lines.push("");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
payload.comments.forEach((comment, index) => {
|
|
58
|
+
const file = fileMap.get(comment.fileId);
|
|
59
|
+
lines.push(`${index + 1}. ${formatLocation(comment, file)}`);
|
|
60
|
+
lines.push(` ${comment.body.trim()}`);
|
|
61
|
+
lines.push("");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return lines.join("\n").trim();
|
|
65
|
+
}
|