@diegopetrucci/pi-extensions 0.1.74 → 0.1.78

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 (108) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +17 -12
  3. package/extensions/agent-workflow-audit/README.md +1 -1
  4. package/extensions/agent-workflow-audit/index.ts +1631 -1009
  5. package/extensions/agent-workflow-audit/package.json +1 -1
  6. package/extensions/annotate-git-diff/README.md +3 -1
  7. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  8. package/extensions/annotate-git-diff/git.ts +846 -768
  9. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  10. package/extensions/annotate-git-diff/index.ts +712 -671
  11. package/extensions/annotate-git-diff/package.json +1 -1
  12. package/extensions/annotate-git-diff/prompt.ts +45 -45
  13. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  14. package/extensions/annotate-git-diff/review-server.ts +337 -311
  15. package/extensions/annotate-git-diff/types.ts +132 -132
  16. package/extensions/annotate-git-diff/ui.ts +27 -27
  17. package/extensions/annotate-git-diff/watch.ts +74 -74
  18. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  19. package/extensions/annotate-git-diff/web/index.html +5 -6
  20. package/extensions/annotate-last-message/README.md +1 -1
  21. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  22. package/extensions/annotate-last-message/index.ts +210 -206
  23. package/extensions/annotate-last-message/package.json +1 -1
  24. package/extensions/annotate-last-message/prompt.ts +87 -66
  25. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  26. package/extensions/annotate-last-message/session.ts +94 -89
  27. package/extensions/annotate-last-message/theme.ts +199 -193
  28. package/extensions/annotate-last-message/types.ts +23 -23
  29. package/extensions/annotate-last-message/ui.ts +27 -25
  30. package/extensions/annotate-last-message/web/app.js +4 -7
  31. package/extensions/annotate-last-message/web/index.html +3 -3
  32. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  33. package/extensions/brrr/README.md +1 -1
  34. package/extensions/brrr/index.ts +213 -191
  35. package/extensions/brrr/package.json +1 -1
  36. package/extensions/claude-fast/README.md +5 -79
  37. package/extensions/claude-fast/index.ts +31 -289
  38. package/extensions/claude-fast/package.json +2 -3
  39. package/extensions/code-reviewer/README.md +6 -6
  40. package/extensions/code-reviewer/index.ts +1825 -1373
  41. package/extensions/code-reviewer/package.json +1 -1
  42. package/extensions/confirm-destructive/README.md +1 -1
  43. package/extensions/confirm-destructive/index.ts +50 -52
  44. package/extensions/confirm-destructive/package.json +1 -1
  45. package/extensions/context-cap/README.md +9 -9
  46. package/extensions/context-cap/index.ts +313 -305
  47. package/extensions/context-cap/package.json +2 -2
  48. package/extensions/context-inspector/index.ts +1211 -1064
  49. package/extensions/context-inspector/package.json +1 -1
  50. package/extensions/contrarian/README.md +4 -3
  51. package/extensions/contrarian/index.ts +1863 -1737
  52. package/extensions/contrarian/package.json +1 -1
  53. package/extensions/dirty-repo-guard/index.ts +37 -41
  54. package/extensions/dirty-repo-guard/package.json +1 -1
  55. package/extensions/dynamic-context-pruning/README.md +11 -43
  56. package/extensions/dynamic-context-pruning/index.ts +2131 -2105
  57. package/extensions/dynamic-context-pruning/package.json +1 -1
  58. package/extensions/fast/README.md +52 -14
  59. package/extensions/fast/index.ts +513 -317
  60. package/extensions/fast/package.json +1 -1
  61. package/extensions/git-footer/README.md +1 -1
  62. package/extensions/git-footer/index.ts +458 -411
  63. package/extensions/git-footer/package.json +2 -3
  64. package/extensions/gnosis/index.ts +180 -130
  65. package/extensions/gnosis/package.json +1 -1
  66. package/extensions/inline-bash/index.ts +87 -103
  67. package/extensions/inline-bash/package.json +1 -1
  68. package/extensions/librarian/README.md +1 -1
  69. package/extensions/librarian/index.ts +1877 -1419
  70. package/extensions/librarian/package.json +1 -1
  71. package/extensions/minimal-footer/README.md +5 -5
  72. package/extensions/minimal-footer/index.ts +987 -1012
  73. package/extensions/minimal-footer/minimal-footer.example.json +1 -1
  74. package/extensions/minimal-footer/openai-usage.ts +195 -190
  75. package/extensions/minimal-footer/package.json +1 -1
  76. package/extensions/notify/index.ts +222 -230
  77. package/extensions/notify/package.json +1 -1
  78. package/extensions/openai-fast/README.md +5 -73
  79. package/extensions/openai-fast/index.ts +32 -272
  80. package/extensions/openai-fast/package.json +2 -3
  81. package/extensions/oracle/README.md +4 -3
  82. package/extensions/oracle/index.ts +1740 -1627
  83. package/extensions/oracle/package.json +1 -1
  84. package/extensions/permission-gate/README.md +3 -0
  85. package/extensions/permission-gate/index.ts +785 -612
  86. package/extensions/permission-gate/package.json +1 -1
  87. package/extensions/permission-gate/powershell-safety.ts +840 -793
  88. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  89. package/extensions/project-mcp-json/README.md +199 -0
  90. package/extensions/project-mcp-json/config.ts +564 -0
  91. package/extensions/project-mcp-json/index.ts +119 -0
  92. package/extensions/project-mcp-json/package.json +38 -0
  93. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  94. package/extensions/quiet-tools/README.md +30 -19
  95. package/extensions/quiet-tools/index.ts +376 -291
  96. package/extensions/quiet-tools/package.json +2 -2
  97. package/extensions/review/README.md +2 -2
  98. package/extensions/review/index.ts +1745 -1872
  99. package/extensions/review/package.json +1 -1
  100. package/extensions/todo/index.ts +280 -280
  101. package/extensions/todo/package.json +1 -1
  102. package/extensions/triage-comments/README.md +6 -4
  103. package/extensions/triage-comments/index.ts +2620 -1888
  104. package/extensions/triage-comments/package.json +1 -1
  105. package/package.json +9 -6
  106. package/assets/social-preview.png +0 -0
  107. package/extensions/claude-fast/claude-fast.example.json +0 -4
  108. 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
  }