@diegopetrucci/pi-extensions 0.1.74 → 0.1.76

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.
Files changed (93) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +9 -4
  3. package/extensions/agent-workflow-audit/index.ts +1398 -1009
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/README.md +3 -1
  6. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  7. package/extensions/annotate-git-diff/git.ts +853 -767
  8. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  9. package/extensions/annotate-git-diff/index.ts +712 -671
  10. package/extensions/annotate-git-diff/package.json +1 -1
  11. package/extensions/annotate-git-diff/prompt.ts +45 -45
  12. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  13. package/extensions/annotate-git-diff/review-server.ts +337 -311
  14. package/extensions/annotate-git-diff/types.ts +132 -132
  15. package/extensions/annotate-git-diff/ui.ts +27 -27
  16. package/extensions/annotate-git-diff/watch.ts +74 -74
  17. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  18. package/extensions/annotate-git-diff/web/index.html +5 -6
  19. package/extensions/annotate-last-message/README.md +1 -1
  20. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  21. package/extensions/annotate-last-message/index.ts +210 -206
  22. package/extensions/annotate-last-message/package.json +1 -1
  23. package/extensions/annotate-last-message/prompt.ts +87 -66
  24. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  25. package/extensions/annotate-last-message/session.ts +94 -89
  26. package/extensions/annotate-last-message/theme.ts +199 -193
  27. package/extensions/annotate-last-message/types.ts +23 -23
  28. package/extensions/annotate-last-message/ui.ts +27 -25
  29. package/extensions/annotate-last-message/web/app.js +4 -7
  30. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  31. package/extensions/brrr/README.md +1 -1
  32. package/extensions/brrr/index.ts +213 -191
  33. package/extensions/brrr/package.json +1 -1
  34. package/extensions/claude-fast/README.md +5 -79
  35. package/extensions/claude-fast/index.ts +31 -289
  36. package/extensions/claude-fast/package.json +2 -3
  37. package/extensions/code-reviewer/README.md +1 -1
  38. package/extensions/code-reviewer/index.ts +1598 -1350
  39. package/extensions/code-reviewer/package.json +1 -1
  40. package/extensions/confirm-destructive/README.md +1 -1
  41. package/extensions/confirm-destructive/index.ts +55 -51
  42. package/extensions/confirm-destructive/package.json +1 -1
  43. package/extensions/context-cap/index.ts +312 -304
  44. package/extensions/context-cap/package.json +1 -1
  45. package/extensions/context-inspector/index.ts +1211 -1064
  46. package/extensions/context-inspector/package.json +1 -1
  47. package/extensions/contrarian/index.ts +1854 -1735
  48. package/extensions/contrarian/package.json +1 -1
  49. package/extensions/dirty-repo-guard/index.ts +41 -41
  50. package/extensions/dynamic-context-pruning/README.md +6 -38
  51. package/extensions/dynamic-context-pruning/index.ts +2124 -1980
  52. package/extensions/dynamic-context-pruning/package.json +1 -1
  53. package/extensions/fast/README.md +50 -12
  54. package/extensions/fast/index.ts +513 -317
  55. package/extensions/fast/package.json +1 -1
  56. package/extensions/git-footer/README.md +1 -1
  57. package/extensions/git-footer/index.ts +458 -411
  58. package/extensions/git-footer/package.json +2 -3
  59. package/extensions/gnosis/index.ts +180 -130
  60. package/extensions/gnosis/package.json +1 -1
  61. package/extensions/inline-bash/index.ts +88 -88
  62. package/extensions/inline-bash/package.json +1 -1
  63. package/extensions/librarian/index.ts +1669 -1419
  64. package/extensions/librarian/package.json +1 -1
  65. package/extensions/minimal-footer/index.ts +994 -1012
  66. package/extensions/minimal-footer/openai-usage.ts +195 -190
  67. package/extensions/notify/index.ts +222 -230
  68. package/extensions/notify/package.json +1 -1
  69. package/extensions/openai-fast/README.md +5 -73
  70. package/extensions/openai-fast/index.ts +32 -272
  71. package/extensions/openai-fast/package.json +2 -3
  72. package/extensions/oracle/index.ts +1731 -1625
  73. package/extensions/oracle/package.json +1 -1
  74. package/extensions/permission-gate/index.ts +658 -608
  75. package/extensions/permission-gate/package.json +1 -1
  76. package/extensions/permission-gate/powershell-safety.ts +840 -793
  77. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  78. package/extensions/project-mcp-json/README.md +199 -0
  79. package/extensions/project-mcp-json/config.ts +564 -0
  80. package/extensions/project-mcp-json/index.ts +119 -0
  81. package/extensions/project-mcp-json/package.json +38 -0
  82. package/extensions/quiet-tools/index.ts +282 -264
  83. package/extensions/review/README.md +2 -2
  84. package/extensions/review/index.ts +1745 -1872
  85. package/extensions/review/package.json +1 -1
  86. package/extensions/todo/index.ts +284 -265
  87. package/extensions/triage-comments/README.md +2 -2
  88. package/extensions/triage-comments/index.ts +2294 -1882
  89. package/extensions/triage-comments/package.json +1 -1
  90. package/package.json +6 -3
  91. package/assets/social-preview.png +0 -0
  92. package/extensions/claude-fast/claude-fast.example.json +0 -4
  93. package/extensions/openai-fast/openai-fast.example.json +0 -4
@@ -3,739 +3,780 @@
3
3
  // badlogic/pi-diff-review. See ./README.md for attribution details.
4
4
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
5
  import { readSystemClipboard, writeSystemClipboard } from "./clipboard.js";
6
- import { getCommitFiles, getReviewWindowData, isWorkingTreeCommitSha, loadReviewFileContents } from "./git.js";
6
+ import {
7
+ getCommitFiles,
8
+ getReviewWindowData,
9
+ isWorkingTreeCommitSha,
10
+ loadReviewFileContents,
11
+ } from "./git.js";
7
12
  import { composeReviewPrompt } from "./prompt.js";
8
13
  import { openQuietGlimpse, type QuietGlimpseWindow } from "./quiet-glimpse.js";
9
14
  import { startReviewUiServer, type ReviewUiServer } from "./review-server.js";
10
15
  import type {
11
- CommentSide,
12
- DiffReviewComment,
13
- ReviewCancelPayload,
14
- ReviewClipboardReadPayload,
15
- ReviewClipboardWritePayload,
16
- ReviewCommitKind,
17
- ReviewFile,
18
- ReviewFileContents,
19
- ReviewHostMessage,
20
- ReviewRequestCommitPayload,
21
- ReviewRequestFilePayload,
22
- ReviewRequestReviewDataPayload,
23
- ReviewScope,
24
- ReviewSubmitPayload,
25
- ReviewWindowMessage,
16
+ CommentSide,
17
+ DiffReviewComment,
18
+ ReviewCancelPayload,
19
+ ReviewClipboardReadPayload,
20
+ ReviewClipboardWritePayload,
21
+ ReviewCommitKind,
22
+ ReviewFile,
23
+ ReviewFileContents,
24
+ ReviewHostMessage,
25
+ ReviewRequestCommitPayload,
26
+ ReviewRequestFilePayload,
27
+ ReviewRequestReviewDataPayload,
28
+ ReviewScope,
29
+ ReviewSubmitPayload,
30
+ ReviewWindowMessage,
26
31
  } from "./types.js";
27
32
  import { createRepoChangeWatcher, type RepoChangeWatcher } from "./watch.js";
28
33
 
29
34
  function isRecord(value: unknown): value is Record<string, unknown> {
30
- return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
36
  }
32
37
 
33
38
  function isString(value: unknown): value is string {
34
- return typeof value === "string";
39
+ return typeof value === "string";
35
40
  }
36
41
 
37
42
  function isNullableString(value: unknown): value is string | null | undefined {
38
- return value == null || typeof value === "string";
43
+ return value == null || typeof value === "string";
39
44
  }
40
45
 
41
46
  function isCommentSide(value: unknown): value is CommentSide {
42
- return value === "original" || value === "modified" || value === "file";
47
+ return value === "original" || value === "modified" || value === "file";
43
48
  }
44
49
 
45
50
  function isReviewScope(value: unknown): value is ReviewScope {
46
- return value === "branch" || value === "commits" || value === "all";
51
+ return value === "branch" || value === "commits" || value === "all";
47
52
  }
48
53
 
49
54
  function isReviewCommitKind(value: unknown): value is ReviewCommitKind | null | undefined {
50
- return value == null || value === "commit" || value === "working-tree";
55
+ return value == null || value === "commit" || value === "working-tree";
51
56
  }
52
57
 
53
58
  function hasNullableInteger(value: Record<string, unknown>, key: string): boolean {
54
- if (!Object.prototype.hasOwnProperty.call(value, key)) return false;
55
- const field = value[key];
56
- return field === null || (typeof field === "number" && Number.isInteger(field));
59
+ if (!Object.prototype.hasOwnProperty.call(value, key)) return false;
60
+ const field = value[key];
61
+ return field === null || (typeof field === "number" && Number.isInteger(field));
57
62
  }
58
63
 
59
64
  function isDiffReviewComment(value: unknown): value is DiffReviewComment {
60
- if (!isRecord(value)) return false;
61
- return (
62
- isString(value.id) &&
63
- isString(value.fileId) &&
64
- isReviewScope(value.scope) &&
65
- isNullableString(value.commitSha) &&
66
- isNullableString(value.commitShort) &&
67
- isReviewCommitKind(value.commitKind) &&
68
- isCommentSide(value.side) &&
69
- hasNullableInteger(value, "startLine") &&
70
- hasNullableInteger(value, "endLine") &&
71
- isString(value.body)
72
- );
65
+ if (!isRecord(value)) return false;
66
+ return (
67
+ isString(value.id) &&
68
+ isString(value.fileId) &&
69
+ isReviewScope(value.scope) &&
70
+ isNullableString(value.commitSha) &&
71
+ isNullableString(value.commitShort) &&
72
+ isReviewCommitKind(value.commitKind) &&
73
+ isCommentSide(value.side) &&
74
+ hasNullableInteger(value, "startLine") &&
75
+ hasNullableInteger(value, "endLine") &&
76
+ isString(value.body)
77
+ );
73
78
  }
74
79
 
75
80
  function parseSubmitPayload(value: unknown): ReviewSubmitPayload | null {
76
- if (!isRecord(value) || value.type !== "submit") return null;
77
- if (!isString(value.overallComment) || !Array.isArray(value.comments) || !value.comments.every(isDiffReviewComment)) {
78
- return null;
79
- }
80
-
81
- // Fail-safe: only a literal false means the user explicitly clicked Submit.
82
- // Missing, true, and malformed discriminator values remain editor-only drafts.
83
- return {
84
- type: "submit",
85
- overallComment: value.overallComment,
86
- comments: value.comments,
87
- draft: value.draft !== false,
88
- };
81
+ if (!isRecord(value) || value.type !== "submit") return null;
82
+ if (
83
+ !isString(value.overallComment) ||
84
+ !Array.isArray(value.comments) ||
85
+ !value.comments.every(isDiffReviewComment)
86
+ ) {
87
+ return null;
88
+ }
89
+
90
+ // Fail-safe: only a literal false means the user explicitly clicked Submit.
91
+ // Missing, true, and malformed discriminator values remain editor-only drafts.
92
+ return {
93
+ type: "submit",
94
+ overallComment: value.overallComment,
95
+ comments: value.comments,
96
+ draft: value.draft !== false,
97
+ };
89
98
  }
90
99
 
91
100
  function isCancelPayload(value: unknown): value is ReviewCancelPayload {
92
- return isRecord(value) && value.type === "cancel";
101
+ return isRecord(value) && value.type === "cancel";
93
102
  }
94
103
 
95
- function hasMessageType(value: unknown, type: ReviewWindowMessage["type"]): value is Record<string, unknown> {
96
- return isRecord(value) && value.type === type;
104
+ function hasMessageType(
105
+ value: unknown,
106
+ type: ReviewWindowMessage["type"],
107
+ ): value is Record<string, unknown> {
108
+ return isRecord(value) && value.type === type;
97
109
  }
98
110
 
99
111
  function isRequestFilePayload(value: unknown): value is ReviewRequestFilePayload {
100
- return (
101
- hasMessageType(value, "request-file") &&
102
- isString(value.requestId) &&
103
- isString(value.fileId) &&
104
- isReviewScope(value.scope) &&
105
- isNullableString(value.commitSha)
106
- );
112
+ return (
113
+ hasMessageType(value, "request-file") &&
114
+ isString(value.requestId) &&
115
+ isString(value.fileId) &&
116
+ isReviewScope(value.scope) &&
117
+ isNullableString(value.commitSha)
118
+ );
107
119
  }
108
120
 
109
121
  function isRequestCommitPayload(value: unknown): value is ReviewRequestCommitPayload {
110
- return hasMessageType(value, "request-commit") && isString(value.requestId) && isString(value.sha);
122
+ return (
123
+ hasMessageType(value, "request-commit") && isString(value.requestId) && isString(value.sha)
124
+ );
111
125
  }
112
126
 
113
127
  function isRequestReviewDataPayload(value: unknown): value is ReviewRequestReviewDataPayload {
114
- return hasMessageType(value, "request-review-data") && isString(value.requestId);
128
+ return hasMessageType(value, "request-review-data") && isString(value.requestId);
115
129
  }
116
130
 
117
131
  function isClipboardReadPayload(value: unknown): value is ReviewClipboardReadPayload {
118
- return hasMessageType(value, "clipboard-read") && isString(value.requestId);
132
+ return hasMessageType(value, "clipboard-read") && isString(value.requestId);
119
133
  }
120
134
 
121
135
  function isClipboardWritePayload(value: unknown): value is ReviewClipboardWritePayload {
122
- return hasMessageType(value, "clipboard-write") && isString(value.text);
136
+ return hasMessageType(value, "clipboard-write") && isString(value.text);
123
137
  }
124
138
 
125
139
  function escapeForInlineScript(value: string): string {
126
- return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
140
+ return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
127
141
  }
128
142
 
129
143
  function hasReviewFeedback(payload: ReviewSubmitPayload): boolean {
130
- return payload.overallComment.trim().length > 0 || payload.comments.some((comment) => comment.body.trim().length > 0);
144
+ return (
145
+ payload.overallComment.trim().length > 0 ||
146
+ payload.comments.some((comment) => comment.body.trim().length > 0)
147
+ );
131
148
  }
132
149
 
133
150
  function appendReviewPrompt(ctx: ExtensionCommandContext, prompt: string): void {
134
- const prefix = ctx.ui.getEditorText().trim().length > 0 ? "\n\n" : "";
135
- ctx.ui.pasteToEditor(`${prefix}${prompt}`);
151
+ const prefix = ctx.ui.getEditorText().trim().length > 0 ? "\n\n" : "";
152
+ ctx.ui.pasteToEditor(`${prefix}${prompt}`);
136
153
  }
137
154
 
138
155
  export default function (pi: ExtensionAPI) {
139
- let activeWindow: QuietGlimpseWindow | null = null;
140
- let activeUiServer: ReviewUiServer | null = null;
141
- let activeWatcher: RepoChangeWatcher | null = null;
142
- let reviewOpening = false;
143
- let reviewAttempt = 0;
144
- const suppressedWindows = new WeakSet<QuietGlimpseWindow>();
145
-
146
- function stopActiveWatcher(): void {
147
- if (activeWatcher == null) return;
148
- activeWatcher.dispose();
149
- activeWatcher = null;
150
- }
151
-
152
- function closeActiveWindow(options: { suppressResults?: boolean } = {}): void {
153
- reviewAttempt += 1;
154
- const windowToClose = activeWindow;
155
- activeWindow = null;
156
- activeUiServer?.dispose();
157
- activeUiServer = null;
158
- stopActiveWatcher();
159
- if (windowToClose == null) return;
160
- if (options.suppressResults) {
161
- suppressedWindows.add(windowToClose);
162
- }
163
- try {
164
- windowToClose.close();
165
- } catch {
166
- // Window is already closing; ignore shutdown races.
167
- }
168
- }
169
-
170
- async function reviewRepository(ctx: ExtensionCommandContext): Promise<void> {
171
- if (ctx.mode !== "tui") {
172
- ctx.ui.notify("annotate-git-diff requires interactive mode.", "error");
173
- return;
174
- }
175
-
176
- if (reviewOpening || activeWindow != null || activeUiServer != null) {
177
- ctx.ui.notify("A review window is already open.", "warning");
178
- return;
179
- }
180
-
181
- reviewOpening = true;
182
- const attempt = ++reviewAttempt;
183
- try {
184
- let reviewData = await getReviewWindowData(pi, ctx.cwd);
185
- if (attempt !== reviewAttempt) return;
186
- const { repoRoot } = reviewData;
187
- if (reviewData.files.length === 0 && reviewData.commits.length === 0) {
188
- ctx.ui.notify("No reviewable files found.", "info");
189
- return;
190
- }
191
-
192
- const uiServer = await startReviewUiServer(reviewData);
193
- if (attempt !== reviewAttempt) {
194
- uiServer.dispose();
195
- return;
196
- }
197
- activeUiServer = uiServer;
198
- const window = await openQuietGlimpse(uiServer.html, {
199
- width: 1680,
200
- height: 1020,
201
- title: "annotate-git-diff",
202
- });
203
- if (attempt !== reviewAttempt) {
204
- suppressedWindows.add(window);
205
- window.close();
206
- uiServer.dispose();
207
- return;
208
- }
209
- activeWindow = window;
210
- if (uiServer.failure != null) throw uiServer.failure;
211
- if (window.failure != null) throw window.failure;
212
- if (window.closed) throw new Error("Glimpse closed while the review window was starting.");
213
-
214
- const historicalFiles = new Map<string, ReviewFile>();
215
- const rememberFiles = (files: ReviewFile[]): void => {
216
- for (const file of files) {
217
- // A submitted comment may outlive a refresh. Keep the first metadata
218
- // advertised for an id so refreshed authorization cannot rewrite its
219
- // historical path in the composed prompt.
220
- if (!historicalFiles.has(file.id)) historicalFiles.set(file.id, file);
221
- }
222
- };
223
- rememberFiles(reviewData.files);
224
-
225
- type ReviewReadSnapshot = {
226
- generation: number;
227
- data: typeof reviewData;
228
- branchFiles: Map<string, ReviewFile>;
229
- allFiles: Map<string, ReviewFile>;
230
- commitFiles: Map<string, Map<string, ReviewFile>>;
231
- };
232
-
233
- const createReviewReadSnapshot = (
234
- data: typeof reviewData,
235
- generation: number,
236
- previous: ReviewReadSnapshot | null,
237
- ): ReviewReadSnapshot => {
238
- const branchFiles = new Map<string, ReviewFile>();
239
- const allFiles = new Map<string, ReviewFile>();
240
- for (const file of data.files) {
241
- allFiles.set(file.id, file);
242
- if (file.inGitDiff) branchFiles.set(file.id, file);
243
- }
244
-
245
- const commitFiles = new Map<string, Map<string, ReviewFile>>();
246
- for (const commit of data.commits) {
247
- if (commit.kind !== "commit") continue;
248
- const retainedFiles = previous?.commitFiles.get(commit.sha);
249
- if (retainedFiles != null) commitFiles.set(commit.sha, retainedFiles);
250
- }
251
-
252
- return { generation, data, branchFiles, allFiles, commitFiles };
253
- };
254
-
255
- const commitFileCache = new Map<string, Promise<ReviewFile[]>>();
256
- const contentCache = new Map<string, Promise<ReviewFileContents>>();
257
- let currentSnapshot = createReviewReadSnapshot(reviewData, 0, null);
258
- const isLiveWindow = (): boolean => activeWindow === window && attempt === reviewAttempt && !window.closed;
259
-
260
- const cacheCommitSha = (cacheKey: string): string | null => {
261
- if (!cacheKey.startsWith("commits:")) return null;
262
- const rest = cacheKey.slice("commits:".length);
263
- const separator = rest.indexOf(":");
264
- return separator < 0 ? null : rest.slice(0, separator);
265
- };
266
-
267
- const pruneCachesForReviewData = (nextReviewData: typeof reviewData): void => {
268
- const retainedImmutableCommits = new Set(
269
- nextReviewData.commits.filter((commit) => commit.kind === "commit").map((commit) => commit.sha),
270
- );
271
- for (const sha of commitFileCache.keys()) {
272
- if (!retainedImmutableCommits.has(sha)) commitFileCache.delete(sha);
273
- }
274
- for (const cacheKey of contentCache.keys()) {
275
- const commitSha = cacheCommitSha(cacheKey);
276
- if (commitSha == null || !retainedImmutableCommits.has(commitSha)) {
277
- contentCache.delete(cacheKey);
278
- }
279
- }
280
- };
281
-
282
- const replaceReviewData = (nextReviewData: typeof reviewData): void => {
283
- pruneCachesForReviewData(nextReviewData);
284
- const previousSnapshot = currentSnapshot;
285
- reviewData = nextReviewData;
286
- rememberFiles(nextReviewData.files);
287
- currentSnapshot = createReviewReadSnapshot(nextReviewData, previousSnapshot.generation + 1, previousSnapshot);
288
- };
289
-
290
- const sendWindowMessage = (message: ReviewHostMessage, guard?: () => boolean): void => {
291
- if (!isLiveWindow()) return;
292
- if (guard != null && !guard()) return;
293
- const payload = escapeForInlineScript(JSON.stringify(message));
294
- window.send(`window.__reviewReceive(${payload});`);
295
- };
296
-
297
- let watcherWarningShown = false;
298
- activeWatcher = createRepoChangeWatcher(
299
- repoRoot,
300
- () => {
301
- sendWindowMessage({ type: "working-tree-changed", changedAt: Date.now() });
302
- },
303
- {
304
- onError: (error) => {
305
- if (watcherWarningShown || activeWindow !== window) return;
306
- watcherWarningShown = true;
307
- ctx.ui.notify(`Review change watcher failed: ${error.message}`, "warning");
308
- },
309
- },
310
- );
311
-
312
- const loadCommitFiles = (sha: string): Promise<ReviewFile[]> => {
313
- const cached = commitFileCache.get(sha);
314
- if (cached != null) return cached;
315
- const pending = getCommitFiles(pi, repoRoot, sha);
316
- commitFileCache.set(sha, pending);
317
- pending
318
- .then((commitFiles) => {
319
- if (commitFileCache.get(sha) !== pending || !isLiveWindow()) return;
320
- if (!currentSnapshot.data.commits.some((commit) => commit.sha === sha)) return;
321
- rememberFiles(commitFiles);
322
- currentSnapshot.commitFiles.set(sha, new Map(commitFiles.map((file) => [file.id, file])));
323
- })
324
- .catch(() => {
325
- // A rejected promise is retryable, but an old rejection must not
326
- // evict a replacement request for the same key.
327
- if (isLiveWindow() && commitFileCache.get(sha) === pending) commitFileCache.delete(sha);
328
- });
329
- return pending;
330
- };
331
-
332
- const loadContents = (
333
- file: ReviewFile,
334
- scope: ReviewRequestFilePayload["scope"],
335
- commitSha: string | null,
336
- snapshot: ReviewReadSnapshot,
337
- ): Promise<ReviewFileContents> => {
338
- const cacheKey = `${scope}:${commitSha ?? ""}:${file.id}`;
339
- const cached = contentCache.get(cacheKey);
340
- if (cached != null) return cached;
341
-
342
- const pending = loadReviewFileContents(pi, repoRoot, file, scope, commitSha, snapshot.data.branchMergeBaseSha);
343
- contentCache.set(cacheKey, pending);
344
- pending.catch(() => {
345
- // Do not let an older rejection remove a newer retry's promise.
346
- if (isLiveWindow() && contentCache.get(cacheKey) === pending) contentCache.delete(cacheKey);
347
- });
348
- return pending;
349
- };
350
-
351
- const isAdvertisedCommitSha = (snapshot: ReviewReadSnapshot, sha: string): boolean =>
352
- snapshot.data.commits.some((commit) => commit.sha === sha);
353
-
354
- const isCurrentCommitRequest = (
355
- snapshot: ReviewReadSnapshot,
356
- sha: string,
357
- pending: Promise<ReviewFile[]> | null = null,
358
- ): boolean => {
359
- if (!isLiveWindow()) return false;
360
- const commit = currentSnapshot.data.commits.find((item) => item.sha === sha);
361
- if (commit == null) return false;
362
- if (currentSnapshot === snapshot) return true;
363
- return commit.kind === "commit" && pending != null && commitFileCache.get(sha) === pending;
364
- };
365
-
366
- const isCurrentFileRequest = (
367
- snapshot: ReviewReadSnapshot,
368
- scope: ReviewRequestFilePayload["scope"],
369
- commitSha: string | null,
370
- file: ReviewFile,
371
- ): boolean => {
372
- if (!isLiveWindow()) return false;
373
- if (scope === "commits") {
374
- if (commitSha == null || !isAdvertisedCommitSha(currentSnapshot, commitSha)) return false;
375
- if (isWorkingTreeCommitSha(commitSha) && currentSnapshot !== snapshot) return false;
376
- return currentSnapshot.commitFiles.get(commitSha)?.get(file.id) === file;
377
- }
378
- if (currentSnapshot !== snapshot) return false;
379
- const files = scope === "branch" ? currentSnapshot.branchFiles : currentSnapshot.allFiles;
380
- return files.get(file.id) === file;
381
- };
382
-
383
- const terminalMessagePromise = new Promise<ReviewSubmitPayload | ReviewCancelPayload | null>(
384
- (resolve, reject) => {
385
- let settled = false;
386
- let closeTimer: ReturnType<typeof setTimeout> | null = null;
387
- let removeUiServerErrorListener = (): void => {};
388
-
389
- const requestWindowClose = (): void => {
390
- try {
391
- window.close();
392
- } catch {
393
- // Ignore races when the native process has already exited.
394
- }
395
- };
396
-
397
- const cleanup = (): void => {
398
- if (closeTimer != null) {
399
- clearTimeout(closeTimer);
400
- closeTimer = null;
401
- }
402
- window.removeListener("message", onMessage);
403
- window.removeListener("closed", onClosed);
404
- window.removeListener("error", onError);
405
- removeUiServerErrorListener();
406
- removeUiServerErrorListener = (): void => {};
407
- if (activeWindow === window) {
408
- activeWindow = null;
409
- stopActiveWatcher();
410
- }
411
- if (activeUiServer === uiServer) activeUiServer = null;
412
- uiServer.dispose();
413
- };
414
-
415
- const settle = (value: ReviewSubmitPayload | ReviewCancelPayload | null): void => {
416
- if (settled) return;
417
- settled = true;
418
- cleanup();
419
- resolve(value);
420
- };
421
-
422
- const handleRequestFile = async (message: ReviewRequestFilePayload): Promise<void> => {
423
- const snapshot = currentSnapshot;
424
- const currentGuard = (): boolean => isLiveWindow() && currentSnapshot === snapshot;
425
- const commitSha = message.commitSha ?? null;
426
- if (message.scope === "commits") {
427
- if (commitSha == null) {
428
- sendWindowMessage(
429
- {
430
- type: "file-error",
431
- requestId: message.requestId,
432
- fileId: message.fileId,
433
- scope: message.scope,
434
- commitSha: null,
435
- message: "A commit SHA is required for commit-scoped file requests.",
436
- },
437
- currentGuard,
438
- );
439
- return;
440
- }
441
- if (!isAdvertisedCommitSha(snapshot, commitSha)) {
442
- sendWindowMessage(
443
- {
444
- type: "file-error",
445
- requestId: message.requestId,
446
- fileId: message.fileId,
447
- scope: message.scope,
448
- commitSha,
449
- message: "Unknown commit requested.",
450
- },
451
- currentGuard,
452
- );
453
- return;
454
- }
455
- } else if (commitSha != null) {
456
- sendWindowMessage(
457
- {
458
- type: "file-error",
459
- requestId: message.requestId,
460
- fileId: message.fileId,
461
- scope: message.scope,
462
- commitSha,
463
- message: "A commit SHA is only valid for commit-scoped file requests.",
464
- },
465
- currentGuard,
466
- );
467
- return;
468
- }
469
-
470
- const fileMap =
471
- message.scope === "branch"
472
- ? snapshot.branchFiles
473
- : message.scope === "all"
474
- ? snapshot.allFiles
475
- : snapshot.commitFiles.get(commitSha as string) ?? new Map<string, ReviewFile>();
476
- const file = fileMap.get(message.fileId);
477
- if (file == null) {
478
- sendWindowMessage(
479
- {
480
- type: "file-error",
481
- requestId: message.requestId,
482
- fileId: message.fileId,
483
- scope: message.scope,
484
- commitSha,
485
- message: "Unknown file requested.",
486
- },
487
- currentGuard,
488
- );
489
- return;
490
- }
491
-
492
- try {
493
- const contents = await loadContents(file, message.scope, commitSha, snapshot);
494
- if (!isCurrentFileRequest(snapshot, message.scope, commitSha, file)) return;
495
- sendWindowMessage(
496
- {
497
- type: "file-data",
498
- requestId: message.requestId,
499
- fileId: message.fileId,
500
- scope: message.scope,
501
- commitSha,
502
- originalContent: contents.originalContent,
503
- modifiedContent: contents.modifiedContent,
504
- kind: contents.kind,
505
- mimeType: contents.mimeType,
506
- originalExists: contents.originalExists,
507
- modifiedExists: contents.modifiedExists,
508
- originalPreviewUrl: contents.originalPreviewUrl,
509
- modifiedPreviewUrl: contents.modifiedPreviewUrl,
510
- },
511
- () => isCurrentFileRequest(snapshot, message.scope, commitSha, file),
512
- );
513
- } catch (error) {
514
- if (!isCurrentFileRequest(snapshot, message.scope, commitSha, file)) return;
515
- const messageText = error instanceof Error ? error.message : String(error);
516
- sendWindowMessage(
517
- {
518
- type: "file-error",
519
- requestId: message.requestId,
520
- fileId: message.fileId,
521
- scope: message.scope,
522
- commitSha,
523
- message: messageText,
524
- },
525
- () => isCurrentFileRequest(snapshot, message.scope, commitSha, file),
526
- );
527
- }
528
- };
529
-
530
- const handleRequestCommit = async (message: ReviewRequestCommitPayload): Promise<void> => {
531
- const snapshot = currentSnapshot;
532
- const currentGuard = (): boolean => isLiveWindow() && currentSnapshot === snapshot;
533
- if (!isAdvertisedCommitSha(snapshot, message.sha)) {
534
- sendWindowMessage(
535
- {
536
- type: "commit-error",
537
- requestId: message.requestId,
538
- sha: message.sha,
539
- message: "Unknown commit requested.",
540
- },
541
- currentGuard,
542
- );
543
- return;
544
- }
545
-
546
- const pending = loadCommitFiles(message.sha);
547
- try {
548
- const commitFiles = await pending;
549
- if (!isCurrentCommitRequest(snapshot, message.sha, pending)) return;
550
- if (currentSnapshot.commitFiles.get(message.sha) == null) return;
551
- sendWindowMessage(
552
- {
553
- type: "commit-data",
554
- requestId: message.requestId,
555
- sha: message.sha,
556
- files: commitFiles,
557
- },
558
- () =>
559
- isCurrentCommitRequest(snapshot, message.sha, pending) && currentSnapshot.commitFiles.get(message.sha) != null,
560
- );
561
- } catch (error) {
562
- if (!isCurrentCommitRequest(snapshot, message.sha, pending)) return;
563
- const messageText = error instanceof Error ? error.message : String(error);
564
- sendWindowMessage({
565
- type: "commit-error",
566
- requestId: message.requestId,
567
- sha: message.sha,
568
- message: messageText,
569
- });
570
- }
571
- };
572
-
573
- let refreshRequestSequence = 0;
574
- let newestRefreshRequest = 0;
575
- const handleRequestReviewData = async (message: ReviewRequestReviewDataPayload): Promise<void> => {
576
- const refreshRequest = ++refreshRequestSequence;
577
- newestRefreshRequest = refreshRequest;
578
- try {
579
- const nextReviewData = await getReviewWindowData(pi, repoRoot);
580
- if (!isLiveWindow() || refreshRequest !== newestRefreshRequest) return;
581
- replaceReviewData(nextReviewData);
582
- sendWindowMessage(
583
- {
584
- type: "review-data",
585
- requestId: message.requestId,
586
- files: nextReviewData.files,
587
- commits: nextReviewData.commits,
588
- branchBaseRef: nextReviewData.branchBaseRef,
589
- branchMergeBaseSha: nextReviewData.branchMergeBaseSha,
590
- repositoryHasHead: nextReviewData.repositoryHasHead,
591
- },
592
- () => isLiveWindow() && refreshRequest === newestRefreshRequest,
593
- );
594
- } catch (error) {
595
- if (!isLiveWindow() || refreshRequest !== newestRefreshRequest) return;
596
- const messageText = error instanceof Error ? error.message : String(error);
597
- sendWindowMessage({
598
- type: "review-data-error",
599
- requestId: message.requestId,
600
- message: messageText,
601
- });
602
- }
603
- };
604
-
605
- const handleClipboardRead = (message: ReviewClipboardReadPayload): void => {
606
- try {
607
- sendWindowMessage({
608
- type: "clipboard-data",
609
- requestId: message.requestId,
610
- text: readSystemClipboard(),
611
- });
612
- } catch (error) {
613
- const messageText = error instanceof Error ? error.message : String(error);
614
- sendWindowMessage({
615
- type: "clipboard-data",
616
- requestId: message.requestId,
617
- text: "",
618
- message: messageText,
619
- });
620
- }
621
- };
622
-
623
- const handleClipboardWrite = (message: ReviewClipboardWritePayload): void => {
624
- try {
625
- writeSystemClipboard(message.text);
626
- } catch (error) {
627
- const messageText = error instanceof Error ? error.message : String(error);
628
- ctx.ui.notify(`Failed to copy from review window: ${messageText}`, "warning");
629
- }
630
- };
631
-
632
- const onMessage = (message: unknown): void => {
633
- // Terminal messages remain recoverable during the native close grace
634
- // period; all other work must stop once the window is closed or retired.
635
- const submit = parseSubmitPayload(message);
636
- if (submit != null) {
637
- settle(submit);
638
- requestWindowClose();
639
- return;
640
- }
641
- if (isCancelPayload(message)) {
642
- settle(message);
643
- requestWindowClose();
644
- return;
645
- }
646
- if (!isLiveWindow()) return;
647
- if (isRequestFilePayload(message)) {
648
- void handleRequestFile(message);
649
- return;
650
- }
651
- if (isRequestCommitPayload(message)) {
652
- void handleRequestCommit(message);
653
- return;
654
- }
655
- if (isRequestReviewDataPayload(message)) {
656
- void handleRequestReviewData(message);
657
- return;
658
- }
659
- if (isClipboardReadPayload(message)) {
660
- handleClipboardRead(message);
661
- return;
662
- }
663
- if (isClipboardWritePayload(message)) {
664
- handleClipboardWrite(message);
665
- }
666
- };
667
-
668
- const onClosed = (): void => {
669
- if (settled || closeTimer != null) return;
670
- closeTimer = setTimeout(() => {
671
- closeTimer = null;
672
- settle(null);
673
- }, 250);
674
- };
675
-
676
- const onError = (error: Error): void => {
677
- if (settled) return;
678
- settled = true;
679
- requestWindowClose();
680
- cleanup();
681
- reject(error);
682
- };
683
-
684
- window.on("message", onMessage);
685
- window.on("closed", onClosed);
686
- window.on("error", onError);
687
- removeUiServerErrorListener = uiServer.onError(onError);
688
- if (uiServer.failure != null) onError(uiServer.failure);
689
- else if (window.failure != null) onError(window.failure);
690
- else if (window.closed) onClosed();
691
- },
692
- );
693
-
694
- void (async () => {
695
- try {
696
- const message = await terminalMessagePromise;
697
- if (suppressedWindows.has(window) || attempt !== reviewAttempt) return;
698
- if (message == null) return;
699
- if (message.type === "cancel") {
700
- ctx.ui.notify("Review cancelled.", "info");
701
- return;
702
- }
703
- if (!hasReviewFeedback(message)) return;
704
-
705
- const prompt = composeReviewPrompt([...historicalFiles.values()], message);
706
- if (message.draft === true) {
707
- appendReviewPrompt(ctx, prompt);
708
- ctx.ui.notify("Appended review feedback to the editor.", "info");
709
- } else {
710
- pi.sendUserMessage(prompt, { deliverAs: "followUp" });
711
- ctx.ui.notify("Review feedback sent to the agent.", "info");
712
- }
713
- } catch (error) {
714
- if (suppressedWindows.has(window) || attempt !== reviewAttempt) return;
715
- const message = error instanceof Error ? error.message : String(error);
716
- ctx.ui.notify(`Review failed: ${message}`, "error");
717
- }
718
- })();
719
-
720
- ctx.ui.notify("Opened native review window.", "info");
721
- } catch (error) {
722
- if (attempt !== reviewAttempt) return;
723
- closeActiveWindow({ suppressResults: true });
724
- const message = error instanceof Error ? error.message : String(error);
725
- ctx.ui.notify(`Review failed: ${message}`, "error");
726
- } finally {
727
- reviewOpening = false;
728
- }
729
- }
730
-
731
- pi.registerCommand("annotate-git-diff", {
732
- description: "Open a native review window with branch, per-commit, and all-files scopes",
733
- handler: async (_args, ctx) => {
734
- await reviewRepository(ctx);
735
- },
736
- });
737
-
738
- pi.on("session_shutdown", async () => {
739
- closeActiveWindow({ suppressResults: true });
740
- });
156
+ let activeWindow: QuietGlimpseWindow | null = null;
157
+ let activeUiServer: ReviewUiServer | null = null;
158
+ let activeWatcher: RepoChangeWatcher | null = null;
159
+ let reviewOpening = false;
160
+ let reviewAttempt = 0;
161
+ const suppressedWindows = new WeakSet<QuietGlimpseWindow>();
162
+
163
+ function stopActiveWatcher(): void {
164
+ if (activeWatcher == null) return;
165
+ activeWatcher.dispose();
166
+ activeWatcher = null;
167
+ }
168
+
169
+ function closeActiveWindow(options: { suppressResults?: boolean } = {}): void {
170
+ reviewAttempt += 1;
171
+ const windowToClose = activeWindow;
172
+ activeWindow = null;
173
+ activeUiServer?.dispose();
174
+ activeUiServer = null;
175
+ stopActiveWatcher();
176
+ if (windowToClose == null) return;
177
+ if (options.suppressResults) {
178
+ suppressedWindows.add(windowToClose);
179
+ }
180
+ try {
181
+ windowToClose.close();
182
+ } catch {
183
+ // Window is already closing; ignore shutdown races.
184
+ }
185
+ }
186
+
187
+ async function reviewRepository(ctx: ExtensionCommandContext): Promise<void> {
188
+ if (ctx.mode !== "tui") {
189
+ ctx.ui.notify("annotate-git-diff requires interactive mode.", "error");
190
+ return;
191
+ }
192
+
193
+ if (reviewOpening || activeWindow != null || activeUiServer != null) {
194
+ ctx.ui.notify("A review window is already open.", "warning");
195
+ return;
196
+ }
197
+
198
+ reviewOpening = true;
199
+ const attempt = ++reviewAttempt;
200
+ try {
201
+ let reviewData = await getReviewWindowData(pi, ctx.cwd);
202
+ if (attempt !== reviewAttempt) return;
203
+ const { repoRoot } = reviewData;
204
+ if (reviewData.files.length === 0 && reviewData.commits.length === 0) {
205
+ ctx.ui.notify("No reviewable files found.", "info");
206
+ return;
207
+ }
208
+
209
+ const uiServer = await startReviewUiServer(reviewData);
210
+ if (attempt !== reviewAttempt) {
211
+ uiServer.dispose();
212
+ return;
213
+ }
214
+ activeUiServer = uiServer;
215
+ const window = await openQuietGlimpse(uiServer.html, {
216
+ width: 1680,
217
+ height: 1020,
218
+ title: "annotate-git-diff",
219
+ });
220
+ if (attempt !== reviewAttempt) {
221
+ suppressedWindows.add(window);
222
+ window.close();
223
+ uiServer.dispose();
224
+ return;
225
+ }
226
+ activeWindow = window;
227
+ if (uiServer.failure != null) throw uiServer.failure;
228
+ if (window.failure != null) throw window.failure;
229
+ if (window.closed) throw new Error("Glimpse closed while the review window was starting.");
230
+
231
+ const historicalFiles = new Map<string, ReviewFile>();
232
+ const rememberFiles = (files: ReviewFile[]): void => {
233
+ for (const file of files) {
234
+ // A submitted comment may outlive a refresh. Keep the first metadata
235
+ // advertised for an id so refreshed authorization cannot rewrite its
236
+ // historical path in the composed prompt.
237
+ if (!historicalFiles.has(file.id)) historicalFiles.set(file.id, file);
238
+ }
239
+ };
240
+ rememberFiles(reviewData.files);
241
+
242
+ type ReviewReadSnapshot = {
243
+ generation: number;
244
+ data: typeof reviewData;
245
+ branchFiles: Map<string, ReviewFile>;
246
+ allFiles: Map<string, ReviewFile>;
247
+ commitFiles: Map<string, Map<string, ReviewFile>>;
248
+ };
249
+
250
+ const createReviewReadSnapshot = (
251
+ data: typeof reviewData,
252
+ generation: number,
253
+ previous: ReviewReadSnapshot | null,
254
+ ): ReviewReadSnapshot => {
255
+ const branchFiles = new Map<string, ReviewFile>();
256
+ const allFiles = new Map<string, ReviewFile>();
257
+ for (const file of data.files) {
258
+ allFiles.set(file.id, file);
259
+ if (file.inGitDiff) branchFiles.set(file.id, file);
260
+ }
261
+
262
+ const commitFiles = new Map<string, Map<string, ReviewFile>>();
263
+ for (const commit of data.commits) {
264
+ if (commit.kind !== "commit") continue;
265
+ const retainedFiles = previous?.commitFiles.get(commit.sha);
266
+ if (retainedFiles != null) commitFiles.set(commit.sha, retainedFiles);
267
+ }
268
+
269
+ return { generation, data, branchFiles, allFiles, commitFiles };
270
+ };
271
+
272
+ const commitFileCache = new Map<string, Promise<ReviewFile[]>>();
273
+ const contentCache = new Map<string, Promise<ReviewFileContents>>();
274
+ let currentSnapshot = createReviewReadSnapshot(reviewData, 0, null);
275
+ const isLiveWindow = (): boolean =>
276
+ activeWindow === window && attempt === reviewAttempt && !window.closed;
277
+
278
+ const cacheCommitSha = (cacheKey: string): string | null => {
279
+ if (!cacheKey.startsWith("commits:")) return null;
280
+ const rest = cacheKey.slice("commits:".length);
281
+ const separator = rest.indexOf(":");
282
+ return separator < 0 ? null : rest.slice(0, separator);
283
+ };
284
+
285
+ const pruneCachesForReviewData = (nextReviewData: typeof reviewData): void => {
286
+ const retainedImmutableCommits = new Set(
287
+ nextReviewData.commits
288
+ .filter((commit) => commit.kind === "commit")
289
+ .map((commit) => commit.sha),
290
+ );
291
+ for (const sha of commitFileCache.keys()) {
292
+ if (!retainedImmutableCommits.has(sha)) commitFileCache.delete(sha);
293
+ }
294
+ for (const cacheKey of contentCache.keys()) {
295
+ const commitSha = cacheCommitSha(cacheKey);
296
+ if (commitSha == null || !retainedImmutableCommits.has(commitSha)) {
297
+ contentCache.delete(cacheKey);
298
+ }
299
+ }
300
+ };
301
+
302
+ const replaceReviewData = (nextReviewData: typeof reviewData): void => {
303
+ pruneCachesForReviewData(nextReviewData);
304
+ const previousSnapshot = currentSnapshot;
305
+ reviewData = nextReviewData;
306
+ rememberFiles(nextReviewData.files);
307
+ currentSnapshot = createReviewReadSnapshot(
308
+ nextReviewData,
309
+ previousSnapshot.generation + 1,
310
+ previousSnapshot,
311
+ );
312
+ };
313
+
314
+ const sendWindowMessage = (message: ReviewHostMessage, guard?: () => boolean): void => {
315
+ if (!isLiveWindow()) return;
316
+ if (guard != null && !guard()) return;
317
+ const payload = escapeForInlineScript(JSON.stringify(message));
318
+ window.send(`window.__reviewReceive(${payload});`);
319
+ };
320
+
321
+ let watcherWarningShown = false;
322
+ activeWatcher = createRepoChangeWatcher(
323
+ repoRoot,
324
+ () => {
325
+ sendWindowMessage({ type: "working-tree-changed", changedAt: Date.now() });
326
+ },
327
+ {
328
+ onError: (error) => {
329
+ if (watcherWarningShown || activeWindow !== window) return;
330
+ watcherWarningShown = true;
331
+ ctx.ui.notify(`Review change watcher failed: ${error.message}`, "warning");
332
+ },
333
+ },
334
+ );
335
+
336
+ const loadCommitFiles = (sha: string): Promise<ReviewFile[]> => {
337
+ const cached = commitFileCache.get(sha);
338
+ if (cached != null) return cached;
339
+ const pending = getCommitFiles(pi, repoRoot, sha);
340
+ commitFileCache.set(sha, pending);
341
+ pending
342
+ .then((commitFiles) => {
343
+ if (commitFileCache.get(sha) !== pending || !isLiveWindow()) return;
344
+ if (!currentSnapshot.data.commits.some((commit) => commit.sha === sha)) return;
345
+ rememberFiles(commitFiles);
346
+ currentSnapshot.commitFiles.set(
347
+ sha,
348
+ new Map(commitFiles.map((file) => [file.id, file])),
349
+ );
350
+ })
351
+ .catch(() => {
352
+ // A rejected promise is retryable, but an old rejection must not
353
+ // evict a replacement request for the same key.
354
+ if (isLiveWindow() && commitFileCache.get(sha) === pending) commitFileCache.delete(sha);
355
+ });
356
+ return pending;
357
+ };
358
+
359
+ const loadContents = (
360
+ file: ReviewFile,
361
+ scope: ReviewRequestFilePayload["scope"],
362
+ commitSha: string | null,
363
+ snapshot: ReviewReadSnapshot,
364
+ ): Promise<ReviewFileContents> => {
365
+ const cacheKey = `${scope}:${commitSha ?? ""}:${file.id}`;
366
+ const cached = contentCache.get(cacheKey);
367
+ if (cached != null) return cached;
368
+
369
+ const pending = loadReviewFileContents(
370
+ pi,
371
+ repoRoot,
372
+ file,
373
+ scope,
374
+ commitSha,
375
+ snapshot.data.branchMergeBaseSha,
376
+ );
377
+ contentCache.set(cacheKey, pending);
378
+ pending.catch(() => {
379
+ // Do not let an older rejection remove a newer retry's promise.
380
+ if (isLiveWindow() && contentCache.get(cacheKey) === pending)
381
+ contentCache.delete(cacheKey);
382
+ });
383
+ return pending;
384
+ };
385
+
386
+ const isAdvertisedCommitSha = (snapshot: ReviewReadSnapshot, sha: string): boolean =>
387
+ snapshot.data.commits.some((commit) => commit.sha === sha);
388
+
389
+ const isCurrentCommitRequest = (
390
+ snapshot: ReviewReadSnapshot,
391
+ sha: string,
392
+ pending: Promise<ReviewFile[]> | null = null,
393
+ ): boolean => {
394
+ if (!isLiveWindow()) return false;
395
+ const commit = currentSnapshot.data.commits.find((item) => item.sha === sha);
396
+ if (commit == null) return false;
397
+ if (currentSnapshot === snapshot) return true;
398
+ return commit.kind === "commit" && pending != null && commitFileCache.get(sha) === pending;
399
+ };
400
+
401
+ const isCurrentFileRequest = (
402
+ snapshot: ReviewReadSnapshot,
403
+ scope: ReviewRequestFilePayload["scope"],
404
+ commitSha: string | null,
405
+ file: ReviewFile,
406
+ ): boolean => {
407
+ if (!isLiveWindow()) return false;
408
+ if (scope === "commits") {
409
+ if (commitSha == null || !isAdvertisedCommitSha(currentSnapshot, commitSha)) return false;
410
+ if (isWorkingTreeCommitSha(commitSha) && currentSnapshot !== snapshot) return false;
411
+ return currentSnapshot.commitFiles.get(commitSha)?.get(file.id) === file;
412
+ }
413
+ if (currentSnapshot !== snapshot) return false;
414
+ const files = scope === "branch" ? currentSnapshot.branchFiles : currentSnapshot.allFiles;
415
+ return files.get(file.id) === file;
416
+ };
417
+
418
+ const terminalMessagePromise = new Promise<ReviewSubmitPayload | ReviewCancelPayload | null>(
419
+ (resolve, reject) => {
420
+ let settled = false;
421
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
422
+ let removeUiServerErrorListener = (): void => {};
423
+
424
+ const requestWindowClose = (): void => {
425
+ try {
426
+ window.close();
427
+ } catch {
428
+ // Ignore races when the native process has already exited.
429
+ }
430
+ };
431
+
432
+ const cleanup = (): void => {
433
+ if (closeTimer != null) {
434
+ clearTimeout(closeTimer);
435
+ closeTimer = null;
436
+ }
437
+ window.removeListener("message", onMessage);
438
+ window.removeListener("closed", onClosed);
439
+ window.removeListener("error", onError);
440
+ removeUiServerErrorListener();
441
+ removeUiServerErrorListener = (): void => {};
442
+ if (activeWindow === window) {
443
+ activeWindow = null;
444
+ stopActiveWatcher();
445
+ }
446
+ if (activeUiServer === uiServer) activeUiServer = null;
447
+ uiServer.dispose();
448
+ };
449
+
450
+ const settle = (value: ReviewSubmitPayload | ReviewCancelPayload | null): void => {
451
+ if (settled) return;
452
+ settled = true;
453
+ cleanup();
454
+ resolve(value);
455
+ };
456
+
457
+ const handleRequestFile = async (message: ReviewRequestFilePayload): Promise<void> => {
458
+ const snapshot = currentSnapshot;
459
+ const currentGuard = (): boolean => isLiveWindow() && currentSnapshot === snapshot;
460
+ const commitSha = message.commitSha ?? null;
461
+ if (message.scope === "commits") {
462
+ if (commitSha == null) {
463
+ sendWindowMessage(
464
+ {
465
+ type: "file-error",
466
+ requestId: message.requestId,
467
+ fileId: message.fileId,
468
+ scope: message.scope,
469
+ commitSha: null,
470
+ message: "A commit SHA is required for commit-scoped file requests.",
471
+ },
472
+ currentGuard,
473
+ );
474
+ return;
475
+ }
476
+ if (!isAdvertisedCommitSha(snapshot, commitSha)) {
477
+ sendWindowMessage(
478
+ {
479
+ type: "file-error",
480
+ requestId: message.requestId,
481
+ fileId: message.fileId,
482
+ scope: message.scope,
483
+ commitSha,
484
+ message: "Unknown commit requested.",
485
+ },
486
+ currentGuard,
487
+ );
488
+ return;
489
+ }
490
+ } else if (commitSha != null) {
491
+ sendWindowMessage(
492
+ {
493
+ type: "file-error",
494
+ requestId: message.requestId,
495
+ fileId: message.fileId,
496
+ scope: message.scope,
497
+ commitSha,
498
+ message: "A commit SHA is only valid for commit-scoped file requests.",
499
+ },
500
+ currentGuard,
501
+ );
502
+ return;
503
+ }
504
+
505
+ const fileMap =
506
+ message.scope === "branch"
507
+ ? snapshot.branchFiles
508
+ : message.scope === "all"
509
+ ? snapshot.allFiles
510
+ : (snapshot.commitFiles.get(commitSha as string) ??
511
+ new Map<string, ReviewFile>());
512
+ const file = fileMap.get(message.fileId);
513
+ if (file == null) {
514
+ sendWindowMessage(
515
+ {
516
+ type: "file-error",
517
+ requestId: message.requestId,
518
+ fileId: message.fileId,
519
+ scope: message.scope,
520
+ commitSha,
521
+ message: "Unknown file requested.",
522
+ },
523
+ currentGuard,
524
+ );
525
+ return;
526
+ }
527
+
528
+ try {
529
+ const contents = await loadContents(file, message.scope, commitSha, snapshot);
530
+ if (!isCurrentFileRequest(snapshot, message.scope, commitSha, file)) return;
531
+ sendWindowMessage(
532
+ {
533
+ type: "file-data",
534
+ requestId: message.requestId,
535
+ fileId: message.fileId,
536
+ scope: message.scope,
537
+ commitSha,
538
+ originalContent: contents.originalContent,
539
+ modifiedContent: contents.modifiedContent,
540
+ kind: contents.kind,
541
+ mimeType: contents.mimeType,
542
+ originalExists: contents.originalExists,
543
+ modifiedExists: contents.modifiedExists,
544
+ originalPreviewUrl: contents.originalPreviewUrl,
545
+ modifiedPreviewUrl: contents.modifiedPreviewUrl,
546
+ },
547
+ () => isCurrentFileRequest(snapshot, message.scope, commitSha, file),
548
+ );
549
+ } catch (error) {
550
+ if (!isCurrentFileRequest(snapshot, message.scope, commitSha, file)) return;
551
+ const messageText = error instanceof Error ? error.message : String(error);
552
+ sendWindowMessage(
553
+ {
554
+ type: "file-error",
555
+ requestId: message.requestId,
556
+ fileId: message.fileId,
557
+ scope: message.scope,
558
+ commitSha,
559
+ message: messageText,
560
+ },
561
+ () => isCurrentFileRequest(snapshot, message.scope, commitSha, file),
562
+ );
563
+ }
564
+ };
565
+
566
+ const handleRequestCommit = async (
567
+ message: ReviewRequestCommitPayload,
568
+ ): Promise<void> => {
569
+ const snapshot = currentSnapshot;
570
+ const currentGuard = (): boolean => isLiveWindow() && currentSnapshot === snapshot;
571
+ if (!isAdvertisedCommitSha(snapshot, message.sha)) {
572
+ sendWindowMessage(
573
+ {
574
+ type: "commit-error",
575
+ requestId: message.requestId,
576
+ sha: message.sha,
577
+ message: "Unknown commit requested.",
578
+ },
579
+ currentGuard,
580
+ );
581
+ return;
582
+ }
583
+
584
+ const pending = loadCommitFiles(message.sha);
585
+ try {
586
+ const commitFiles = await pending;
587
+ if (!isCurrentCommitRequest(snapshot, message.sha, pending)) return;
588
+ if (currentSnapshot.commitFiles.get(message.sha) == null) return;
589
+ sendWindowMessage(
590
+ {
591
+ type: "commit-data",
592
+ requestId: message.requestId,
593
+ sha: message.sha,
594
+ files: commitFiles,
595
+ },
596
+ () =>
597
+ isCurrentCommitRequest(snapshot, message.sha, pending) &&
598
+ currentSnapshot.commitFiles.get(message.sha) != null,
599
+ );
600
+ } catch (error) {
601
+ if (!isCurrentCommitRequest(snapshot, message.sha, pending)) return;
602
+ const messageText = error instanceof Error ? error.message : String(error);
603
+ sendWindowMessage({
604
+ type: "commit-error",
605
+ requestId: message.requestId,
606
+ sha: message.sha,
607
+ message: messageText,
608
+ });
609
+ }
610
+ };
611
+
612
+ let refreshRequestSequence = 0;
613
+ let newestRefreshRequest = 0;
614
+ const handleRequestReviewData = async (
615
+ message: ReviewRequestReviewDataPayload,
616
+ ): Promise<void> => {
617
+ const refreshRequest = ++refreshRequestSequence;
618
+ newestRefreshRequest = refreshRequest;
619
+ try {
620
+ const nextReviewData = await getReviewWindowData(pi, repoRoot);
621
+ if (!isLiveWindow() || refreshRequest !== newestRefreshRequest) return;
622
+ replaceReviewData(nextReviewData);
623
+ sendWindowMessage(
624
+ {
625
+ type: "review-data",
626
+ requestId: message.requestId,
627
+ files: nextReviewData.files,
628
+ commits: nextReviewData.commits,
629
+ branchBaseRef: nextReviewData.branchBaseRef,
630
+ branchMergeBaseSha: nextReviewData.branchMergeBaseSha,
631
+ repositoryHasHead: nextReviewData.repositoryHasHead,
632
+ },
633
+ () => isLiveWindow() && refreshRequest === newestRefreshRequest,
634
+ );
635
+ } catch (error) {
636
+ if (!isLiveWindow() || refreshRequest !== newestRefreshRequest) return;
637
+ const messageText = error instanceof Error ? error.message : String(error);
638
+ sendWindowMessage({
639
+ type: "review-data-error",
640
+ requestId: message.requestId,
641
+ message: messageText,
642
+ });
643
+ }
644
+ };
645
+
646
+ const handleClipboardRead = (message: ReviewClipboardReadPayload): void => {
647
+ try {
648
+ sendWindowMessage({
649
+ type: "clipboard-data",
650
+ requestId: message.requestId,
651
+ text: readSystemClipboard(),
652
+ });
653
+ } catch (error) {
654
+ const messageText = error instanceof Error ? error.message : String(error);
655
+ sendWindowMessage({
656
+ type: "clipboard-data",
657
+ requestId: message.requestId,
658
+ text: "",
659
+ message: messageText,
660
+ });
661
+ }
662
+ };
663
+
664
+ const handleClipboardWrite = (message: ReviewClipboardWritePayload): void => {
665
+ try {
666
+ writeSystemClipboard(message.text);
667
+ } catch (error) {
668
+ const messageText = error instanceof Error ? error.message : String(error);
669
+ ctx.ui.notify(`Failed to copy from review window: ${messageText}`, "warning");
670
+ }
671
+ };
672
+
673
+ const onMessage = (message: unknown): void => {
674
+ // Terminal messages remain recoverable during the native close grace
675
+ // period; all other work must stop once the window is closed or retired.
676
+ const submit = parseSubmitPayload(message);
677
+ if (submit != null) {
678
+ settle(submit);
679
+ requestWindowClose();
680
+ return;
681
+ }
682
+ if (isCancelPayload(message)) {
683
+ settle(message);
684
+ requestWindowClose();
685
+ return;
686
+ }
687
+ if (!isLiveWindow()) return;
688
+ if (isRequestFilePayload(message)) {
689
+ void handleRequestFile(message);
690
+ return;
691
+ }
692
+ if (isRequestCommitPayload(message)) {
693
+ void handleRequestCommit(message);
694
+ return;
695
+ }
696
+ if (isRequestReviewDataPayload(message)) {
697
+ void handleRequestReviewData(message);
698
+ return;
699
+ }
700
+ if (isClipboardReadPayload(message)) {
701
+ handleClipboardRead(message);
702
+ return;
703
+ }
704
+ if (isClipboardWritePayload(message)) {
705
+ handleClipboardWrite(message);
706
+ }
707
+ };
708
+
709
+ const onClosed = (): void => {
710
+ if (settled || closeTimer != null) return;
711
+ closeTimer = setTimeout(() => {
712
+ closeTimer = null;
713
+ settle(null);
714
+ }, 250);
715
+ };
716
+
717
+ const onError = (error: Error): void => {
718
+ if (settled) return;
719
+ settled = true;
720
+ requestWindowClose();
721
+ cleanup();
722
+ reject(error);
723
+ };
724
+
725
+ window.on("message", onMessage);
726
+ window.on("closed", onClosed);
727
+ window.on("error", onError);
728
+ removeUiServerErrorListener = uiServer.onError(onError);
729
+ if (uiServer.failure != null) onError(uiServer.failure);
730
+ else if (window.failure != null) onError(window.failure);
731
+ else if (window.closed) onClosed();
732
+ },
733
+ );
734
+
735
+ void (async () => {
736
+ try {
737
+ const message = await terminalMessagePromise;
738
+ if (suppressedWindows.has(window) || attempt !== reviewAttempt) return;
739
+ if (message == null) return;
740
+ if (message.type === "cancel") {
741
+ ctx.ui.notify("Review cancelled.", "info");
742
+ return;
743
+ }
744
+ if (!hasReviewFeedback(message)) return;
745
+
746
+ const prompt = composeReviewPrompt([...historicalFiles.values()], message);
747
+ if (message.draft === true) {
748
+ appendReviewPrompt(ctx, prompt);
749
+ ctx.ui.notify("Appended review feedback to the editor.", "info");
750
+ } else {
751
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
752
+ ctx.ui.notify("Review feedback sent to the agent.", "info");
753
+ }
754
+ } catch (error) {
755
+ if (suppressedWindows.has(window) || attempt !== reviewAttempt) return;
756
+ const message = error instanceof Error ? error.message : String(error);
757
+ ctx.ui.notify(`Review failed: ${message}`, "error");
758
+ }
759
+ })();
760
+
761
+ ctx.ui.notify("Opened native review window.", "info");
762
+ } catch (error) {
763
+ if (attempt !== reviewAttempt) return;
764
+ closeActiveWindow({ suppressResults: true });
765
+ const message = error instanceof Error ? error.message : String(error);
766
+ ctx.ui.notify(`Review failed: ${message}`, "error");
767
+ } finally {
768
+ reviewOpening = false;
769
+ }
770
+ }
771
+
772
+ pi.registerCommand("annotate-git-diff", {
773
+ description: "Open a native review window with branch, per-commit, and all-files scopes",
774
+ handler: async (_args, ctx) => {
775
+ await reviewRepository(ctx);
776
+ },
777
+ });
778
+
779
+ pi.on("session_shutdown", async () => {
780
+ closeActiveWindow({ suppressResults: true });
781
+ });
741
782
  }