@diegopetrucci/pi-extensions 0.1.73 → 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 +71 -21
  54. package/extensions/fast/index.ts +513 -313
  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
@@ -2,54 +2,30 @@
2
2
  * Adapted from:
3
3
  * https://github.com/mitsuhiko/agent-stuff/blob/main/extensions/review.ts
4
4
  *
5
- * Upstream source is licensed under Apache-2.0. This vendored copy preserves
6
- * the original behavior for tlh/pi compatibility as a standalone package.
7
- */
8
-
9
- /**
10
- * Code Review Extension (inspired by Codex's review feature)
11
- *
12
- * Provides a `/review` command that prompts the agent to review code changes.
13
- * Supports multiple review modes:
14
- * - Review a GitHub pull request (checks out the PR locally)
15
- * - Review against a base branch (PR style)
16
- * - Review uncommitted changes
17
- * - Review a specific commit
18
- * - Shared custom review instructions (applied to all review modes when configured)
19
- *
20
- * Usage:
21
- * - `/review` - show interactive selector
22
- * - `/review pr 123` - review PR #123 (checks out locally)
23
- * - `/review pr https://github.com/owner/repo/pull/123` - review PR from URL
24
- * - `/review uncommitted` - review uncommitted changes directly
25
- * - `/review branch main` - review against main branch
26
- * - `/review commit abc123` - review specific commit
27
- * - `/review folder src docs` - review specific folders/files (snapshot, not diff)
28
- * - `/review` selector includes Add/Remove custom review instructions (applies to all modes)
29
- * - `/review --extra "focus on performance regressions"` - add extra review instruction (works with any mode)
5
+ * Upstream source is licensed under Apache-2.0.
30
6
  *
31
- * Project-specific review guidelines:
32
- * - If the project is trusted and a REVIEW_GUIDELINES.md file exists next to
33
- * the project config directory, its contents are appended to the review prompt.
34
- *
35
- * Note: PR review requires a clean working tree (no uncommitted changes to tracked files).
7
+ * This fork adds loop fixing, persisted custom review instructions, a
8
+ * blocking-findings parser, and /end-review handoff modes.
36
9
  */
37
10
 
38
- import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
+ import type {
12
+ ExtensionAPI,
13
+ ExtensionContext,
14
+ ExtensionCommandContext,
15
+ } from "@earendil-works/pi-coding-agent";
39
16
  import { CONFIG_DIR_NAME, DynamicBorder, BorderedLoader } from "@earendil-works/pi-coding-agent";
40
17
  import {
41
- Container,
42
- fuzzyFilter,
43
- Input,
44
- type SelectItem,
45
- SelectList,
46
- Spacer,
47
- Text,
18
+ Container,
19
+ fuzzyFilter,
20
+ Input,
21
+ type SelectItem,
22
+ SelectList,
23
+ Spacer,
24
+ Text,
48
25
  } from "@earendil-works/pi-tui";
49
26
  import path from "node:path";
50
27
  import { promises as fs } from "node:fs";
51
28
 
52
-
53
29
  // State to track fresh session review (where we branched from).
54
30
  // Module-level state means only one review can be active at a time.
55
31
  // This is intentional - the UI and /end-review command assume a single active review.
@@ -60,21 +36,21 @@ let reviewCustomInstructions: string | undefined = undefined;
60
36
  let reviewLoopInProgress = false;
61
37
 
62
38
  function getReviewRuntimeState() {
63
- return {
64
- reviewOriginId,
65
- endReviewInProgress,
66
- reviewLoopFixingEnabled,
67
- reviewCustomInstructions,
68
- reviewLoopInProgress,
69
- };
39
+ return {
40
+ reviewOriginId,
41
+ endReviewInProgress,
42
+ reviewLoopFixingEnabled,
43
+ reviewCustomInstructions,
44
+ reviewLoopInProgress,
45
+ };
70
46
  }
71
47
 
72
48
  function resetReviewRuntimeState() {
73
- reviewOriginId = undefined;
74
- endReviewInProgress = false;
75
- reviewLoopFixingEnabled = false;
76
- reviewCustomInstructions = undefined;
77
- reviewLoopInProgress = false;
49
+ reviewOriginId = undefined;
50
+ endReviewInProgress = false;
51
+ reviewLoopFixingEnabled = false;
52
+ reviewCustomInstructions = undefined;
53
+ reviewLoopInProgress = false;
78
54
  }
79
55
 
80
56
  const REVIEW_STATE_TYPE = "review-session";
@@ -85,341 +61,374 @@ const REVIEW_LOOP_START_TIMEOUT_MS = 15000;
85
61
  const REVIEW_LOOP_START_POLL_MS = 50;
86
62
 
87
63
  type ReviewSessionState = {
88
- active: boolean;
89
- originId?: string;
64
+ active: boolean;
65
+ originId?: string;
90
66
  };
91
67
 
92
68
  type ReviewSettingsState = {
93
- loopFixingEnabled?: boolean;
94
- customInstructions?: string;
69
+ loopFixingEnabled?: boolean;
70
+ customInstructions?: string;
95
71
  };
96
72
 
97
73
  type ProjectConfigContext = {
98
- cwd: string;
99
- isProjectTrusted?: () => boolean;
74
+ cwd: string;
75
+ isProjectTrusted?: () => boolean;
100
76
  };
101
77
 
102
78
  function canReadProjectConfig(ctx: ProjectConfigContext): boolean {
103
- return typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted();
79
+ return typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted();
104
80
  }
105
81
 
106
82
  function setReviewWidget(ctx: ExtensionContext, active: boolean) {
107
- if (!ctx.hasUI) return;
108
- if (!active || ctx.mode !== "tui") {
109
- ctx.ui.setWidget("review", undefined);
110
- return;
111
- }
112
-
113
- ctx.ui.setWidget("review", (_tui, theme) => {
114
- const message = reviewLoopInProgress
115
- ? "Review session active (loop fixing running)"
116
- : reviewLoopFixingEnabled
117
- ? "Review session active (loop fixing enabled), return with /end-review"
118
- : "Review session active, return with /end-review";
119
- const text = new Text(theme.fg("warning", message), 0, 0);
120
- return {
121
- render(width: number) {
122
- return text.render(width);
123
- },
124
- invalidate() {
125
- text.invalidate();
126
- },
127
- };
128
- });
83
+ if (!ctx.hasUI) return;
84
+ if (!active || ctx.mode !== "tui") {
85
+ ctx.ui.setWidget("review", undefined);
86
+ return;
87
+ }
88
+
89
+ ctx.ui.setWidget("review", (_tui, theme) => {
90
+ const message = reviewLoopInProgress
91
+ ? "Review session active (loop fixing running)"
92
+ : reviewLoopFixingEnabled
93
+ ? "Review session active (loop fixing enabled), return with /end-review"
94
+ : "Review session active, return with /end-review";
95
+ const text = new Text(theme.fg("warning", message), 0, 0);
96
+ return {
97
+ render(width: number) {
98
+ return text.render(width);
99
+ },
100
+ invalidate() {
101
+ text.invalidate();
102
+ },
103
+ };
104
+ });
129
105
  }
130
106
 
131
107
  function getReviewState(ctx: ExtensionContext): ReviewSessionState | undefined {
132
- let state: ReviewSessionState | undefined;
133
- for (const entry of ctx.sessionManager.getBranch()) {
134
- if (entry.type === "custom" && entry.customType === REVIEW_STATE_TYPE) {
135
- state = entry.data as ReviewSessionState | undefined;
136
- }
137
- }
138
-
139
- return state;
108
+ let state: ReviewSessionState | undefined;
109
+ for (const entry of ctx.sessionManager.getBranch()) {
110
+ if (entry.type === "custom" && entry.customType === REVIEW_STATE_TYPE) {
111
+ state = entry.data as ReviewSessionState | undefined;
112
+ }
113
+ }
114
+
115
+ return state;
140
116
  }
141
117
 
142
118
  function applyReviewState(ctx: ExtensionContext) {
143
- const state = getReviewState(ctx);
119
+ const state = getReviewState(ctx);
144
120
 
145
- if (state?.active && state.originId) {
146
- reviewOriginId = state.originId;
147
- setReviewWidget(ctx, true);
148
- return;
149
- }
121
+ if (state?.active && state.originId) {
122
+ reviewOriginId = state.originId;
123
+ setReviewWidget(ctx, true);
124
+ return;
125
+ }
150
126
 
151
- reviewOriginId = undefined;
152
- setReviewWidget(ctx, false);
127
+ reviewOriginId = undefined;
128
+ setReviewWidget(ctx, false);
153
129
  }
154
130
 
155
131
  function getReviewSettings(ctx: ExtensionContext): ReviewSettingsState {
156
- let state: ReviewSettingsState | undefined;
157
- for (const entry of ctx.sessionManager.getEntries()) {
158
- if (entry.type === "custom" && entry.customType === REVIEW_SETTINGS_TYPE) {
159
- state = entry.data as ReviewSettingsState | undefined;
160
- }
161
- }
162
-
163
- return {
164
- loopFixingEnabled: state?.loopFixingEnabled === true,
165
- customInstructions: state?.customInstructions?.trim() || undefined,
166
- };
132
+ let state: ReviewSettingsState | undefined;
133
+ for (const entry of ctx.sessionManager.getEntries()) {
134
+ if (entry.type === "custom" && entry.customType === REVIEW_SETTINGS_TYPE) {
135
+ state = entry.data as ReviewSettingsState | undefined;
136
+ }
137
+ }
138
+
139
+ return {
140
+ loopFixingEnabled: state?.loopFixingEnabled === true,
141
+ customInstructions: state?.customInstructions?.trim() || undefined,
142
+ };
167
143
  }
168
144
 
169
145
  function applyReviewSettings(ctx: ExtensionContext) {
170
- const state = getReviewSettings(ctx);
171
- reviewLoopFixingEnabled = state.loopFixingEnabled === true;
172
- reviewCustomInstructions = state.customInstructions?.trim() || undefined;
146
+ const state = getReviewSettings(ctx);
147
+ reviewLoopFixingEnabled = state.loopFixingEnabled === true;
148
+ reviewCustomInstructions = state.customInstructions?.trim() || undefined;
173
149
  }
174
150
 
175
151
  function parseMarkdownHeading(line: string): { level: number; title: string } | null {
176
- const headingMatch = line.match(/^\s*(#{1,6})\s+(.+?)\s*$/);
177
- if (!headingMatch) {
178
- return null;
179
- }
180
-
181
- const rawTitle = headingMatch[2].replace(/\s+#+\s*$/, "").trim();
182
- return {
183
- level: headingMatch[1].length,
184
- title: rawTitle,
185
- };
152
+ const headingMatch = line.match(/^\s*(#{1,6})\s+(.+?)\s*$/);
153
+ if (!headingMatch) {
154
+ return null;
155
+ }
156
+
157
+ const rawTitle = headingMatch[2].replace(/\s+#+\s*$/, "").trim();
158
+ return {
159
+ level: headingMatch[1].length,
160
+ title: rawTitle,
161
+ };
186
162
  }
187
163
 
188
164
  function getFindingsSectionBounds(lines: string[]): { start: number; end: number } | null {
189
- let start = -1;
190
- let findingsHeadingLevel: number | null = null;
191
-
192
- for (let i = 0; i < lines.length; i++) {
193
- const line = lines[i];
194
- const heading = parseMarkdownHeading(line);
195
- if (heading && /^findings\b/i.test(heading.title)) {
196
- start = i + 1;
197
- findingsHeadingLevel = heading.level;
198
- break;
199
- }
200
- if (/^\s*findings\s*:?\s*$/i.test(line)) {
201
- start = i + 1;
202
- break;
203
- }
204
- }
205
-
206
- if (start < 0) {
207
- return null;
208
- }
209
-
210
- let end = lines.length;
211
- for (let i = start; i < lines.length; i++) {
212
- const line = lines[i];
213
- const heading = parseMarkdownHeading(line);
214
- if (heading) {
215
- const normalizedTitle = heading.title.replace(/[*_`]/g, "").trim();
216
- if (/^(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(normalizedTitle)) {
217
- end = i;
218
- break;
219
- }
220
-
221
- if (/\[P[0-3]\]/i.test(heading.title)) {
222
- continue;
223
- }
224
-
225
- if (findingsHeadingLevel !== null && heading.level <= findingsHeadingLevel) {
226
- end = i;
227
- break;
228
- }
229
- }
230
-
231
- if (/^\s*(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(line)) {
232
- end = i;
233
- break;
234
- }
235
- }
236
-
237
- return { start, end };
165
+ let start = -1;
166
+ let findingsHeadingLevel: number | null = null;
167
+
168
+ for (let i = 0; i < lines.length; i++) {
169
+ const line = lines[i];
170
+ const heading = parseMarkdownHeading(line);
171
+ if (heading && /^findings\b/i.test(heading.title)) {
172
+ start = i + 1;
173
+ findingsHeadingLevel = heading.level;
174
+ break;
175
+ }
176
+ if (/^\s*findings\s*:?\s*$/i.test(line)) {
177
+ start = i + 1;
178
+ break;
179
+ }
180
+ }
181
+
182
+ if (start < 0) {
183
+ return null;
184
+ }
185
+
186
+ let end = lines.length;
187
+ for (let i = start; i < lines.length; i++) {
188
+ const line = lines[i];
189
+ const heading = parseMarkdownHeading(line);
190
+ if (heading) {
191
+ const normalizedTitle = heading.title.replace(/[*_`]/g, "").trim();
192
+ if (
193
+ /^(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(
194
+ normalizedTitle,
195
+ )
196
+ ) {
197
+ end = i;
198
+ break;
199
+ }
200
+
201
+ if (/\[P[0-3]\]/i.test(heading.title)) {
202
+ continue;
203
+ }
204
+
205
+ if (findingsHeadingLevel !== null && heading.level <= findingsHeadingLevel) {
206
+ end = i;
207
+ break;
208
+ }
209
+ }
210
+
211
+ if (
212
+ /^\s*(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(
213
+ line,
214
+ )
215
+ ) {
216
+ end = i;
217
+ break;
218
+ }
219
+ }
220
+
221
+ return { start, end };
238
222
  }
239
223
 
240
224
  function isLikelyFindingLine(line: string): boolean {
241
- if (!/\[P[0-3]\]/i.test(line)) {
242
- return false;
243
- }
244
-
245
- if (/^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+priority\s+tag\b/i.test(line)) {
246
- return false;
247
- }
248
-
249
- if (/^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+\[P[0-3]\]\s*-\s*(?:drop everything|urgent|normal|low|nice to have)\b/i.test(line)) {
250
- return false;
251
- }
252
-
253
- const allPriorityTags = line.match(/\[P[0-3]\]/gi) ?? [];
254
- if (allPriorityTags.length > 1) {
255
- return false;
256
- }
257
-
258
- if (/^\s*(?:[-*+]|(?:\d+)[.)])\s+/.test(line)) {
259
- return true;
260
- }
261
-
262
- if (/^\s*#{1,6}\s+/.test(line)) {
263
- return true;
264
- }
265
-
266
- if (/^\s*(?:\*\*|__)?\[P[0-3]\](?:\*\*|__)?(?=\s|:|-)/i.test(line)) {
267
- return true;
268
- }
269
-
270
- return false;
225
+ if (!/\[P[0-3]\]/i.test(line)) {
226
+ return false;
227
+ }
228
+
229
+ if (/^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+priority\s+tag\b/i.test(line)) {
230
+ return false;
231
+ }
232
+
233
+ if (
234
+ /^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+\[P[0-3]\]\s*-\s*(?:drop everything|urgent|normal|low|nice to have)\b/i.test(
235
+ line,
236
+ )
237
+ ) {
238
+ return false;
239
+ }
240
+
241
+ const allPriorityTags = line.match(/\[P[0-3]\]/gi) ?? [];
242
+ if (allPriorityTags.length > 1) {
243
+ return false;
244
+ }
245
+
246
+ if (/^\s*(?:[-*+]|(?:\d+)[.)])\s+/.test(line)) {
247
+ return true;
248
+ }
249
+
250
+ if (/^\s*#{1,6}\s+/.test(line)) {
251
+ return true;
252
+ }
253
+
254
+ if (/^\s*(?:\*\*|__)?\[P[0-3]\](?:\*\*|__)?(?=\s|:|-)/i.test(line)) {
255
+ return true;
256
+ }
257
+
258
+ return false;
271
259
  }
272
260
 
273
261
  function normalizeVerdictValue(value: string): string {
274
- return value
275
- .trim()
276
- .replace(/^[-*+]\s*/, "")
277
- .replace(/^['"`]+|['"`]+$/g, "")
278
- .toLowerCase();
262
+ return value
263
+ .trim()
264
+ .replace(/^[-*+]\s*/, "")
265
+ .replace(/^['"`]+|['"`]+$/g, "")
266
+ .toLowerCase();
279
267
  }
280
268
 
281
269
  function isNeedsAttentionVerdictValue(value: string): boolean {
282
- const normalized = normalizeVerdictValue(value);
283
- if (!normalized.includes("needs attention")) {
284
- return false;
285
- }
286
-
287
- if (/\bnot\s+needs\s+attention\b/.test(normalized)) {
288
- return false;
289
- }
290
-
291
- // Reject rubric/choice phrasing like "correct or needs attention", but
292
- // keep legitimate verdict text that may contain unrelated "or".
293
- if (/\bcorrect\b/.test(normalized) && /\bor\b/.test(normalized)) {
294
- return false;
295
- }
296
-
297
- return true;
270
+ const normalized = normalizeVerdictValue(value);
271
+ if (!normalized.includes("needs attention")) {
272
+ return false;
273
+ }
274
+
275
+ if (/\bnot\s+needs\s+attention\b/.test(normalized)) {
276
+ return false;
277
+ }
278
+
279
+ // Reject rubric/choice phrasing like "correct or needs attention", but
280
+ // keep legitimate verdict text that may contain unrelated "or".
281
+ if (/\bcorrect\b/.test(normalized) && /\bor\b/.test(normalized)) {
282
+ return false;
283
+ }
284
+
285
+ return true;
298
286
  }
299
287
 
300
288
  function hasNeedsAttentionVerdict(messageText: string): boolean {
301
- const lines = messageText.split(/\r?\n/);
302
-
303
- for (const line of lines) {
304
- const inlineMatch = line.match(/^\s*(?:[*-+]\s*)?(?:overall\s+)?verdict\s*:\s*(.+)$/i);
305
- if (inlineMatch && isNeedsAttentionVerdictValue(inlineMatch[1])) {
306
- return true;
307
- }
308
- }
309
-
310
- for (let i = 0; i < lines.length; i++) {
311
- const line = lines[i];
312
- const heading = parseMarkdownHeading(line);
313
-
314
- let verdictLevel: number | null = null;
315
- if (heading) {
316
- const normalizedHeading = heading.title.replace(/[*_`]/g, "").trim();
317
- if (!/^(?:overall\s+)?verdict\b/i.test(normalizedHeading)) {
318
- continue;
319
- }
320
- verdictLevel = heading.level;
321
- } else if (!/^\s*(?:overall\s+)?verdict\s*:?\s*$/i.test(line)) {
322
- continue;
323
- }
324
-
325
- for (let j = i + 1; j < lines.length; j++) {
326
- const verdictLine = lines[j];
327
- const nextHeading = parseMarkdownHeading(verdictLine);
328
- if (nextHeading) {
329
- const normalizedNextHeading = nextHeading.title.replace(/[*_`]/g, "").trim();
330
- if (verdictLevel === null || nextHeading.level <= verdictLevel) {
331
- break;
332
- }
333
- if (/^(review scope|findings|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(normalizedNextHeading)) {
334
- break;
335
- }
336
- }
337
-
338
- const trimmed = verdictLine.trim();
339
- if (!trimmed) {
340
- continue;
341
- }
342
-
343
- if (isNeedsAttentionVerdictValue(trimmed)) {
344
- return true;
345
- }
346
-
347
- if (/\bcorrect\b/i.test(normalizeVerdictValue(trimmed))) {
348
- break;
349
- }
350
- }
351
- }
352
-
353
- return false;
289
+ const lines = messageText.split(/\r?\n/);
290
+
291
+ for (const line of lines) {
292
+ const inlineMatch = line.match(/^\s*(?:[*-+]\s*)?(?:overall\s+)?verdict\s*:\s*(.+)$/i);
293
+ if (inlineMatch && isNeedsAttentionVerdictValue(inlineMatch[1])) {
294
+ return true;
295
+ }
296
+ }
297
+
298
+ for (let i = 0; i < lines.length; i++) {
299
+ const line = lines[i];
300
+ const heading = parseMarkdownHeading(line);
301
+
302
+ let verdictLevel: number | null = null;
303
+ if (heading) {
304
+ const normalizedHeading = heading.title.replace(/[*_`]/g, "").trim();
305
+ if (!/^(?:overall\s+)?verdict\b/i.test(normalizedHeading)) {
306
+ continue;
307
+ }
308
+ verdictLevel = heading.level;
309
+ } else if (!/^\s*(?:overall\s+)?verdict\s*:?\s*$/i.test(line)) {
310
+ continue;
311
+ }
312
+
313
+ for (let j = i + 1; j < lines.length; j++) {
314
+ const verdictLine = lines[j];
315
+ const nextHeading = parseMarkdownHeading(verdictLine);
316
+ if (nextHeading) {
317
+ const normalizedNextHeading = nextHeading.title.replace(/[*_`]/g, "").trim();
318
+ if (verdictLevel === null || nextHeading.level <= verdictLevel) {
319
+ break;
320
+ }
321
+ if (
322
+ /^(review scope|findings|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(
323
+ normalizedNextHeading,
324
+ )
325
+ ) {
326
+ break;
327
+ }
328
+ }
329
+
330
+ const trimmed = verdictLine.trim();
331
+ if (!trimmed) {
332
+ continue;
333
+ }
334
+
335
+ if (isNeedsAttentionVerdictValue(trimmed)) {
336
+ return true;
337
+ }
338
+
339
+ if (/\bcorrect\b/i.test(normalizeVerdictValue(trimmed))) {
340
+ break;
341
+ }
342
+ }
343
+ }
344
+
345
+ return false;
354
346
  }
355
347
 
356
348
  function hasBlockingReviewFindings(messageText: string): boolean {
357
- const lines = messageText.split(/\r?\n/);
358
- const bounds = getFindingsSectionBounds(lines);
359
- const candidateLines = bounds ? lines.slice(bounds.start, bounds.end) : lines;
360
-
361
- let inCodeFence = false;
362
- let foundTaggedFinding = false;
363
- for (const line of candidateLines) {
364
- if (/^\s*```/.test(line)) {
365
- inCodeFence = !inCodeFence;
366
- continue;
367
- }
368
- if (inCodeFence) {
369
- continue;
370
- }
371
-
372
- if (!isLikelyFindingLine(line)) {
373
- continue;
374
- }
375
-
376
- foundTaggedFinding = true;
377
- if (/\[(P0|P1|P2)\]/i.test(line)) {
378
- return true;
379
- }
380
- }
381
-
382
- if (foundTaggedFinding) {
383
- return false;
384
- }
385
-
386
- return hasNeedsAttentionVerdict(messageText);
349
+ const lines = messageText.split(/\r?\n/);
350
+ const bounds = getFindingsSectionBounds(lines);
351
+ const candidateLines = bounds ? lines.slice(bounds.start, bounds.end) : lines;
352
+
353
+ let inCodeFence = false;
354
+ let foundTaggedFinding = false;
355
+ for (const line of candidateLines) {
356
+ if (/^\s*```/.test(line)) {
357
+ inCodeFence = !inCodeFence;
358
+ continue;
359
+ }
360
+ if (inCodeFence) {
361
+ continue;
362
+ }
363
+
364
+ if (!isLikelyFindingLine(line)) {
365
+ continue;
366
+ }
367
+
368
+ foundTaggedFinding = true;
369
+ if (/\[(P0|P1|P2)\]/i.test(line)) {
370
+ return true;
371
+ }
372
+ }
373
+
374
+ if (foundTaggedFinding) {
375
+ return false;
376
+ }
377
+
378
+ return hasNeedsAttentionVerdict(messageText);
387
379
  }
388
380
 
389
381
  // Review target types (matching Codex's approach)
390
382
  type ReviewTarget =
391
- | { type: "uncommitted" }
392
- | { type: "baseBranch"; branch: string }
393
- | { type: "commit"; sha: string; title?: string }
394
- | { type: "pullRequest"; prNumber: number; baseBranch: string; title: string }
395
- | { type: "folder"; paths: string[] };
383
+ | { type: "uncommitted" }
384
+ | { type: "baseBranch"; branch: string }
385
+ | { type: "commit"; sha: string; title?: string }
386
+ | { type: "pullRequest"; prNumber: number; baseBranch: string; title: string }
387
+ | { type: "folder"; paths: string[] };
396
388
 
397
389
  // Prompts (adapted from Codex)
398
390
  const UNCOMMITTED_PROMPT =
399
- "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.";
391
+ "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.";
400
392
 
401
393
  const LOCAL_CHANGES_REVIEW_INSTRUCTIONS =
402
- "Also include local working-tree changes (staged, unstaged, and untracked files) from this branch. Use `git status --porcelain`, `git diff`, `git diff --staged`, and `git ls-files --others --exclude-standard` so local fixes are part of this review cycle.";
394
+ "Also include local working-tree changes (staged, unstaged, and untracked files) from this branch. Use `git status --porcelain`, `git diff`, `git diff --staged`, and `git ls-files --others --exclude-standard` so local fixes are part of this review cycle.";
403
395
 
404
396
  const BASE_BRANCH_PROMPT_WITH_MERGE_BASE =
405
- "Review the code changes against the base branch '{baseBranch}'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes relative to {baseBranch}. Provide prioritized, actionable findings.";
397
+ "Review the code changes against the base branch '{baseBranch}'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes relative to {baseBranch}. Provide prioritized, actionable findings.";
406
398
 
407
399
  const BASE_BRANCH_PROMPT_FALLBACK =
408
- "Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{upstream}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings.";
400
+ 'Review the code changes against the base branch \'{branch}\'. Start by finding the merge diff between the current branch and {branch}\'s upstream e.g. (`git merge-base HEAD "$(git rev-parse --abbrev-ref "{branch}@{upstream}")"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings.';
409
401
 
410
402
  const COMMIT_PROMPT_WITH_TITLE =
411
- 'Review the code changes introduced by commit {sha} ("{title}"). Provide prioritized, actionable findings.';
403
+ 'Review the code changes introduced by commit {sha} ("{title}"). Provide prioritized, actionable findings.';
412
404
 
413
- const COMMIT_PROMPT = "Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.";
405
+ const COMMIT_PROMPT =
406
+ "Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.";
414
407
 
415
408
  const PULL_REQUEST_PROMPT =
416
- 'Review pull request #{prNumber} ("{title}") against the base branch \'{baseBranch}\'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes that would be merged. Provide prioritized, actionable findings.';
409
+ "Review pull request #{prNumber} (\"{title}\") against the base branch '{baseBranch}'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes that would be merged. Provide prioritized, actionable findings.";
417
410
 
418
411
  const PULL_REQUEST_PROMPT_FALLBACK =
419
- 'Review pull request #{prNumber} ("{title}") against the base branch \'{baseBranch}\'. Start by finding the merge base between the current branch and {baseBranch} (e.g., `git merge-base HEAD {baseBranch}`), then run `git diff` against that SHA to see the changes that would be merged. Provide prioritized, actionable findings.';
412
+ "Review pull request #{prNumber} (\"{title}\") against the base branch '{baseBranch}'. Start by finding the merge base between the current branch and {baseBranch} (e.g., `git merge-base HEAD {baseBranch}`), then run `git diff` against that SHA to see the changes that would be merged. Provide prioritized, actionable findings.";
420
413
 
421
414
  const FOLDER_REVIEW_PROMPT =
422
- "Review the code in the following paths: {paths}. This is a snapshot review (not a diff). Read the files directly in these paths and provide prioritized, actionable findings.";
415
+ "Review the code in the following paths: {paths}. This is a snapshot review (not a diff). Read the files directly in these paths and provide prioritized, actionable findings.";
416
+
417
+ // Shared by the review rubric and the /end-review summary so the callout list
418
+ // and the "informational, not a fix item" rule cannot drift.
419
+ const HUMAN_REVIEWER_CALLOUTS = `## Human Reviewer Callouts (Non-Blocking)
420
+
421
+ Include only applicable callouts (no yes/no lines):
422
+
423
+ - **This change adds a database migration:** <files/details>
424
+ - **This change introduces a new dependency:** <package(s)/details>
425
+ - **This change changes a dependency (or the lockfile):** <files/package(s)/details>
426
+ - **This change modifies auth/permission behavior:** <what changed and where>
427
+ - **This change introduces backwards-incompatible public schema/API/contract changes:** <what changed and where>
428
+ - **This change includes irreversible or destructive operations:** <operation and scope>
429
+
430
+ These are informational callouts for the human reviewer, not fix items.
431
+ If none apply, write "- (none)".`;
423
432
 
424
433
  // The detailed review rubric (adapted from Codex's review_prompt.md)
425
434
  const REVIEW_RUBRIC = `# Review Guidelines
@@ -485,24 +494,12 @@ When reviewing added or modified error handling, default to fail-fast behavior.
485
494
 
486
495
  After findings/verdict, you MUST append this final section:
487
496
 
488
- ## Human Reviewer Callouts (Non-Blocking)
489
-
490
- Include only applicable callouts (no yes/no lines):
491
-
492
- - **This change adds a database migration:** <files/details>
493
- - **This change introduces a new dependency:** <package(s)/details>
494
- - **This change changes a dependency (or the lockfile):** <files/package(s)/details>
495
- - **This change modifies auth/permission behavior:** <what changed and where>
496
- - **This change introduces backwards-incompatible public schema/API/contract changes:** <what changed and where>
497
- - **This change includes irreversible or destructive operations:** <operation and scope>
497
+ ${HUMAN_REVIEWER_CALLOUTS}
498
498
 
499
499
  Rules for this section:
500
- 1. These are informational callouts for the human reviewer, not fix items.
501
- 2. Do not include them in Findings unless there is an independent defect.
502
- 3. These callouts alone must not change the verdict.
503
- 4. Only include callouts that apply to the reviewed change.
504
- 5. Keep each emitted callout bold exactly as written.
505
- 6. If none apply, write "- (none)".
500
+ 1. Do not include them in Findings unless there is an independent defect.
501
+ 2. These callouts alone must not change the verdict.
502
+ 3. Keep each emitted callout bold exactly as written.
506
503
 
507
504
  ## Priority levels
508
505
 
@@ -526,509 +523,545 @@ Provide your findings in a clear, structured format:
526
523
  Output all findings the author would fix if they knew about them. If there are no qualifying findings, explicitly state the code looks good. Don't stop at the first finding - list every qualifying issue. Then append the required non-blocking callouts section.`;
527
524
 
528
525
  async function loadProjectReviewGuidelines(cwd: string): Promise<string | null> {
529
- let currentDir = path.resolve(cwd);
530
-
531
- while (true) {
532
- const piDir = path.join(currentDir, CONFIG_DIR_NAME);
533
- const guidelinesPath = path.join(currentDir, "REVIEW_GUIDELINES.md");
534
-
535
- const piStats = await fs.stat(piDir).catch(() => null);
536
- if (piStats?.isDirectory()) {
537
- const guidelineStats = await fs.stat(guidelinesPath).catch(() => null);
538
- if (guidelineStats?.isFile()) {
539
- try {
540
- const content = await fs.readFile(guidelinesPath, "utf8");
541
- const trimmed = content.trim();
542
- return trimmed ? trimmed : null;
543
- } catch {
544
- return null;
545
- }
546
- }
547
- return null;
548
- }
549
-
550
- const parentDir = path.dirname(currentDir);
551
- if (parentDir === currentDir) {
552
- return null;
553
- }
554
- currentDir = parentDir;
555
- }
526
+ let currentDir = path.resolve(cwd);
527
+
528
+ while (true) {
529
+ const piDir = path.join(currentDir, CONFIG_DIR_NAME);
530
+ const guidelinesPath = path.join(currentDir, "REVIEW_GUIDELINES.md");
531
+
532
+ const piStats = await fs.stat(piDir).catch(() => null);
533
+ if (piStats?.isDirectory()) {
534
+ const guidelineStats = await fs.stat(guidelinesPath).catch(() => null);
535
+ if (guidelineStats?.isFile()) {
536
+ try {
537
+ const content = await fs.readFile(guidelinesPath, "utf8");
538
+ const trimmed = content.trim();
539
+ return trimmed ? trimmed : null;
540
+ } catch {
541
+ return null;
542
+ }
543
+ }
544
+ return null;
545
+ }
546
+
547
+ const parentDir = path.dirname(currentDir);
548
+ if (parentDir === currentDir) {
549
+ return null;
550
+ }
551
+ currentDir = parentDir;
552
+ }
556
553
  }
557
554
 
558
- /**
559
- * Get the merge base between HEAD and a branch
560
- */
561
- async function getMergeBase(
562
- pi: ExtensionAPI,
563
- branch: string,
564
- ): Promise<string | null> {
565
- try {
566
- // First try to get the upstream tracking branch
567
- const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [
568
- "rev-parse",
569
- "--abbrev-ref",
570
- `${branch}@{upstream}`,
571
- ]);
572
-
573
- if (upstreamCode === 0 && upstream.trim()) {
574
- const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", upstream.trim()]);
575
- if (code === 0 && mergeBase.trim()) {
576
- return mergeBase.trim();
577
- }
578
- }
579
-
580
- // Fall back to using the branch directly
581
- const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", branch]);
582
- if (code === 0 && mergeBase.trim()) {
583
- return mergeBase.trim();
584
- }
585
-
586
- return null;
587
- } catch {
588
- return null;
589
- }
555
+ async function getMergeBase(pi: ExtensionAPI, branch: string): Promise<string | null> {
556
+ try {
557
+ const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [
558
+ "rev-parse",
559
+ "--abbrev-ref",
560
+ `${branch}@{upstream}`,
561
+ ]);
562
+
563
+ if (upstreamCode === 0 && upstream.trim()) {
564
+ const { stdout: mergeBase, code } = await pi.exec("git", [
565
+ "merge-base",
566
+ "HEAD",
567
+ upstream.trim(),
568
+ ]);
569
+ if (code === 0 && mergeBase.trim()) {
570
+ return mergeBase.trim();
571
+ }
572
+ }
573
+
574
+ const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", branch]);
575
+ if (code === 0 && mergeBase.trim()) {
576
+ return mergeBase.trim();
577
+ }
578
+
579
+ return null;
580
+ } catch {
581
+ return null;
582
+ }
590
583
  }
591
584
 
592
- /**
593
- * Get list of local branches
594
- */
595
585
  async function getLocalBranches(pi: ExtensionAPI): Promise<string[]> {
596
- const { stdout, code } = await pi.exec("git", ["branch", "--format=%(refname:short)"]);
597
- if (code !== 0) return [];
598
- return stdout
599
- .trim()
600
- .split("\n")
601
- .filter((b) => b.trim());
586
+ const { stdout, code } = await pi.exec("git", ["branch", "--format=%(refname:short)"]);
587
+ if (code !== 0) return [];
588
+ return stdout
589
+ .trim()
590
+ .split("\n")
591
+ .filter((b) => b.trim());
602
592
  }
603
593
 
604
- /**
605
- * Get list of recent commits
606
- */
607
- async function getRecentCommits(pi: ExtensionAPI, limit: number = 10): Promise<Array<{ sha: string; title: string }>> {
608
- const { stdout, code } = await pi.exec("git", ["log", `--oneline`, `-n`, `${limit}`]);
609
- if (code !== 0) return [];
610
-
611
- return stdout
612
- .trim()
613
- .split("\n")
614
- .filter((line) => line.trim())
615
- .map((line) => {
616
- const [sha, ...rest] = line.trim().split(" ");
617
- return { sha, title: rest.join(" ") };
618
- });
594
+ async function getRecentCommits(
595
+ pi: ExtensionAPI,
596
+ limit: number = 10,
597
+ ): Promise<Array<{ sha: string; title: string }>> {
598
+ const { stdout, code } = await pi.exec("git", ["log", `--oneline`, `-n`, `${limit}`]);
599
+ if (code !== 0) return [];
600
+
601
+ return stdout
602
+ .trim()
603
+ .split("\n")
604
+ .filter((line) => line.trim())
605
+ .map((line) => {
606
+ const [sha, ...rest] = line.trim().split(" ");
607
+ return { sha, title: rest.join(" ") };
608
+ });
619
609
  }
620
610
 
621
- /**
622
- * Check if there are uncommitted changes (staged, unstaged, or untracked)
623
- */
624
611
  async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> {
625
- const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
626
- return code === 0 && stdout.trim().length > 0;
612
+ const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
613
+ return code === 0 && stdout.trim().length > 0;
627
614
  }
628
615
 
629
- /**
630
- * Check if there are changes that would prevent switching branches
631
- * (staged or unstaged changes to tracked files - untracked files are fine)
632
- */
616
+ // Tracked staged/unstaged changes block branch switches. Untracked files (??) do not.
633
617
  async function hasPendingChanges(pi: ExtensionAPI): Promise<boolean> {
634
- // Check for staged or unstaged changes to tracked files
635
- const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
636
- if (code !== 0) return false;
637
-
638
- // Filter out untracked files (lines starting with ??)
639
- const lines = stdout.trim().split("\n").filter((line) => line.trim());
640
- const trackedChanges = lines.filter((line) => !line.startsWith("??"));
641
- return trackedChanges.length > 0;
618
+ const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
619
+ if (code !== 0) return false;
620
+
621
+ const lines = stdout
622
+ .trim()
623
+ .split("\n")
624
+ .filter((line) => line.trim());
625
+ const trackedChanges = lines.filter((line) => !line.startsWith("??"));
626
+ return trackedChanges.length > 0;
642
627
  }
643
628
 
644
- /**
645
- * Parse a PR reference (URL or number) and return the PR number
646
- */
647
629
  function parsePrReference(ref: string): number | null {
648
- const trimmed = ref.trim();
649
-
650
- if (/^\d+$/.test(trimmed)) {
651
- const num = Number(trimmed);
652
- if (Number.isSafeInteger(num) && num > 0) {
653
- return num;
654
- }
655
- }
656
-
657
- // Try to extract from GitHub URL
658
- // Formats: https://github.com/owner/repo/pull/123
659
- // github.com/owner/repo/pull/123
660
- const urlMatch = trimmed.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/[^/]+\/[^/]+\/pull\/(\d+)(?:[/?#]|$)/i);
661
- if (urlMatch) {
662
- const num = Number(urlMatch[1]);
663
- return Number.isSafeInteger(num) && num > 0 ? num : null;
664
- }
665
-
666
- return null;
630
+ const trimmed = ref.trim();
631
+
632
+ if (/^\d+$/.test(trimmed)) {
633
+ const num = Number(trimmed);
634
+ if (Number.isSafeInteger(num) && num > 0) {
635
+ return num;
636
+ }
637
+ }
638
+
639
+ // Try to extract from GitHub URL
640
+ // Formats: https://github.com/owner/repo/pull/123
641
+ // github.com/owner/repo/pull/123
642
+ const urlMatch = trimmed.match(
643
+ /^(?:https?:\/\/)?(?:www\.)?github\.com\/[^/]+\/[^/]+\/pull\/(\d+)(?:[/?#]|$)/i,
644
+ );
645
+ if (urlMatch) {
646
+ const num = Number(urlMatch[1]);
647
+ return Number.isSafeInteger(num) && num > 0 ? num : null;
648
+ }
649
+
650
+ return null;
667
651
  }
668
652
 
669
- /**
670
- * Get PR information from GitHub CLI
671
- */
672
- async function getPrInfo(pi: ExtensionAPI, prNumber: number): Promise<{ baseBranch: string; title: string; headBranch: string } | null> {
673
- const { stdout, code } = await pi.exec("gh", [
674
- "pr", "view", String(prNumber),
675
- "--json", "baseRefName,title,headRefName",
676
- ]);
677
-
678
- if (code !== 0) return null;
679
-
680
- try {
681
- const data = JSON.parse(stdout);
682
- return {
683
- baseBranch: data.baseRefName,
684
- title: data.title,
685
- headBranch: data.headRefName,
686
- };
687
- } catch {
688
- return null;
689
- }
653
+ async function getPrInfo(
654
+ pi: ExtensionAPI,
655
+ prNumber: number,
656
+ ): Promise<{ baseBranch: string; title: string; headBranch: string } | null> {
657
+ const { stdout, code } = await pi.exec("gh", [
658
+ "pr",
659
+ "view",
660
+ String(prNumber),
661
+ "--json",
662
+ "baseRefName,title,headRefName",
663
+ ]);
664
+
665
+ if (code !== 0) return null;
666
+
667
+ try {
668
+ const data = JSON.parse(stdout);
669
+ return {
670
+ baseBranch: data.baseRefName,
671
+ title: data.title,
672
+ headBranch: data.headRefName,
673
+ };
674
+ } catch {
675
+ return null;
676
+ }
690
677
  }
691
678
 
692
- /**
693
- * Checkout a PR using GitHub CLI
694
- */
695
- async function checkoutPr(pi: ExtensionAPI, prNumber: number): Promise<{ success: boolean; error?: string }> {
696
- const { stdout, stderr, code } = await pi.exec("gh", ["pr", "checkout", String(prNumber)]);
697
-
698
- if (code !== 0) {
699
- return { success: false, error: stderr || stdout || "Failed to checkout PR" };
700
- }
701
-
702
- return { success: true };
679
+ async function checkoutPullRequest(
680
+ pi: ExtensionAPI,
681
+ ctx: ExtensionContext,
682
+ ref: string,
683
+ ): Promise<ReviewTarget | null> {
684
+ const prNumber = parsePrReference(ref);
685
+ if (!prNumber) {
686
+ ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error");
687
+ return null;
688
+ }
689
+
690
+ ctx.ui.notify(`Fetching PR #${prNumber} info...`, "info");
691
+ const prInfo = await getPrInfo(pi, prNumber);
692
+ if (!prInfo) {
693
+ ctx.ui.notify(
694
+ `Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`,
695
+ "error",
696
+ );
697
+ return null;
698
+ }
699
+
700
+ if (await hasPendingChanges(pi)) {
701
+ ctx.ui.notify(
702
+ "Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.",
703
+ "error",
704
+ );
705
+ return null;
706
+ }
707
+
708
+ ctx.ui.notify(`Checking out PR #${prNumber}...`, "info");
709
+ const { stdout, stderr, code } = await pi.exec("gh", ["pr", "checkout", String(prNumber)]);
710
+ if (code !== 0) {
711
+ ctx.ui.notify(`Failed to checkout PR: ${stderr || stdout || "Failed to checkout PR"}`, "error");
712
+ return null;
713
+ }
714
+
715
+ ctx.ui.notify(`Checked out PR #${prNumber} (${prInfo.headBranch})`, "info");
716
+ return {
717
+ type: "pullRequest",
718
+ prNumber,
719
+ baseBranch: prInfo.baseBranch,
720
+ title: prInfo.title,
721
+ };
703
722
  }
704
723
 
705
- /**
706
- * Get the current branch name
707
- */
708
724
  async function getCurrentBranch(pi: ExtensionAPI): Promise<string | null> {
709
- const { stdout, code } = await pi.exec("git", ["branch", "--show-current"]);
710
- if (code === 0 && stdout.trim()) {
711
- return stdout.trim();
712
- }
713
- return null;
725
+ const { stdout, code } = await pi.exec("git", ["branch", "--show-current"]);
726
+ if (code === 0 && stdout.trim()) {
727
+ return stdout.trim();
728
+ }
729
+ return null;
714
730
  }
715
731
 
716
- /**
717
- * Get the default branch (main or master)
718
- */
719
732
  async function getDefaultBranch(pi: ExtensionAPI): Promise<string> {
720
- // Try to get from remote HEAD
721
- const { stdout, code } = await pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
722
- if (code === 0 && stdout.trim()) {
723
- return stdout.trim().replace("origin/", "");
724
- }
725
-
726
- // Fall back to checking if main or master exists
727
- const branches = await getLocalBranches(pi);
728
- if (branches.includes("main")) return "main";
729
- if (branches.includes("master")) return "master";
730
-
731
- return "main"; // Default fallback
733
+ const { stdout, code } = await pi.exec("git", [
734
+ "symbolic-ref",
735
+ "refs/remotes/origin/HEAD",
736
+ "--short",
737
+ ]);
738
+ if (code === 0 && stdout.trim()) {
739
+ return stdout.trim().replace("origin/", "");
740
+ }
741
+
742
+ const branches = await getLocalBranches(pi);
743
+ if (branches.includes("main")) return "main";
744
+ if (branches.includes("master")) return "master";
745
+
746
+ return "main";
732
747
  }
733
748
 
734
- /**
735
- * Build the review prompt based on target
736
- */
737
749
  async function buildReviewPrompt(
738
- pi: ExtensionAPI,
739
- target: ReviewTarget,
740
- options?: { includeLocalChanges?: boolean },
750
+ pi: ExtensionAPI,
751
+ target: ReviewTarget,
752
+ options?: { includeLocalChanges?: boolean },
741
753
  ): Promise<string> {
742
- const includeLocalChanges = options?.includeLocalChanges === true;
743
-
744
- switch (target.type) {
745
- case "uncommitted":
746
- return UNCOMMITTED_PROMPT;
747
-
748
- case "baseBranch": {
749
- const mergeBase = await getMergeBase(pi, target.branch);
750
- const basePrompt = mergeBase
751
- ? BASE_BRANCH_PROMPT_WITH_MERGE_BASE.replace(/{baseBranch}/g, target.branch).replace(/{mergeBaseSha}/g, mergeBase)
752
- : BASE_BRANCH_PROMPT_FALLBACK.replace(/{branch}/g, target.branch);
753
- return includeLocalChanges ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}` : basePrompt;
754
- }
755
-
756
- case "commit":
757
- if (target.title) {
758
- return COMMIT_PROMPT_WITH_TITLE.replace("{sha}", target.sha).replace("{title}", target.title);
759
- }
760
- return COMMIT_PROMPT.replace("{sha}", target.sha);
761
-
762
- case "pullRequest": {
763
- const mergeBase = await getMergeBase(pi, target.baseBranch);
764
- const basePrompt = mergeBase
765
- ? PULL_REQUEST_PROMPT
766
- .replace(/{prNumber}/g, String(target.prNumber))
767
- .replace(/{title}/g, target.title)
768
- .replace(/{baseBranch}/g, target.baseBranch)
769
- .replace(/{mergeBaseSha}/g, mergeBase)
770
- : PULL_REQUEST_PROMPT_FALLBACK
771
- .replace(/{prNumber}/g, String(target.prNumber))
772
- .replace(/{title}/g, target.title)
773
- .replace(/{baseBranch}/g, target.baseBranch);
774
- return includeLocalChanges ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}` : basePrompt;
775
- }
776
-
777
- case "folder":
778
- return FOLDER_REVIEW_PROMPT.replace("{paths}", target.paths.join(", "));
779
- }
754
+ const includeLocalChanges = options?.includeLocalChanges === true;
755
+
756
+ switch (target.type) {
757
+ case "uncommitted":
758
+ return UNCOMMITTED_PROMPT;
759
+
760
+ case "baseBranch": {
761
+ const mergeBase = await getMergeBase(pi, target.branch);
762
+ const basePrompt = mergeBase
763
+ ? BASE_BRANCH_PROMPT_WITH_MERGE_BASE.replace(/{baseBranch}/g, target.branch).replace(
764
+ /{mergeBaseSha}/g,
765
+ mergeBase,
766
+ )
767
+ : BASE_BRANCH_PROMPT_FALLBACK.replace(/{branch}/g, target.branch);
768
+ return includeLocalChanges
769
+ ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}`
770
+ : basePrompt;
771
+ }
772
+
773
+ case "commit":
774
+ if (target.title) {
775
+ return COMMIT_PROMPT_WITH_TITLE.replace("{sha}", target.sha).replace(
776
+ "{title}",
777
+ target.title,
778
+ );
779
+ }
780
+ return COMMIT_PROMPT.replace("{sha}", target.sha);
781
+
782
+ case "pullRequest": {
783
+ const mergeBase = await getMergeBase(pi, target.baseBranch);
784
+ const basePrompt = mergeBase
785
+ ? PULL_REQUEST_PROMPT.replace(/{prNumber}/g, String(target.prNumber))
786
+ .replace(/{title}/g, target.title)
787
+ .replace(/{baseBranch}/g, target.baseBranch)
788
+ .replace(/{mergeBaseSha}/g, mergeBase)
789
+ : PULL_REQUEST_PROMPT_FALLBACK.replace(/{prNumber}/g, String(target.prNumber))
790
+ .replace(/{title}/g, target.title)
791
+ .replace(/{baseBranch}/g, target.baseBranch);
792
+ return includeLocalChanges
793
+ ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}`
794
+ : basePrompt;
795
+ }
796
+
797
+ case "folder":
798
+ return FOLDER_REVIEW_PROMPT.replace("{paths}", target.paths.join(", "));
799
+ }
780
800
  }
781
801
 
782
- /**
783
- * Get user-facing hint for the review target
784
- */
785
802
  type ComposeReviewPromptOptions = {
786
- customInstructions?: string;
787
- extraInstruction?: string;
788
- projectGuidelines?: string | null;
803
+ customInstructions?: string;
804
+ extraInstruction?: string;
805
+ projectGuidelines?: string | null;
789
806
  };
790
807
 
791
808
  function composeReviewPrompt(prompt: string, options: ComposeReviewPromptOptions = {}): string {
792
- let fullPrompt = `${REVIEW_RUBRIC}\n\n---\n\nPlease perform a code review with the following focus:\n\n${prompt}`;
809
+ let fullPrompt = `${REVIEW_RUBRIC}\n\n---\n\nPlease perform a code review with the following focus:\n\n${prompt}`;
793
810
 
794
- if (options.customInstructions?.trim()) {
795
- fullPrompt += `\n\nShared custom review instructions (applies to all reviews):\n\n${options.customInstructions.trim()}`;
796
- }
811
+ if (options.customInstructions?.trim()) {
812
+ fullPrompt += `\n\nShared custom review instructions (applies to all reviews):\n\n${options.customInstructions.trim()}`;
813
+ }
797
814
 
798
- if (options.extraInstruction?.trim()) {
799
- fullPrompt += `\n\nAdditional user-provided review instruction:\n\n${options.extraInstruction.trim()}`;
800
- }
815
+ if (options.extraInstruction?.trim()) {
816
+ fullPrompt += `\n\nAdditional user-provided review instruction:\n\n${options.extraInstruction.trim()}`;
817
+ }
801
818
 
802
- if (options.projectGuidelines?.trim()) {
803
- fullPrompt += `\n\nThis project has additional instructions for code reviews:\n\n${options.projectGuidelines.trim()}`;
804
- }
819
+ if (options.projectGuidelines?.trim()) {
820
+ fullPrompt += `\n\nThis project has additional instructions for code reviews:\n\n${options.projectGuidelines.trim()}`;
821
+ }
805
822
 
806
- return fullPrompt;
823
+ return fullPrompt;
807
824
  }
808
825
 
809
826
  function getUserFacingHint(target: ReviewTarget): string {
810
- switch (target.type) {
811
- case "uncommitted":
812
- return "current changes";
813
- case "baseBranch":
814
- return `changes against '${target.branch}'`;
815
- case "commit": {
816
- const shortSha = target.sha.slice(0, 7);
817
- return target.title ? `commit ${shortSha}: ${target.title}` : `commit ${shortSha}`;
818
- }
819
-
820
- case "pullRequest": {
821
- const shortTitle = target.title.length > 30 ? target.title.slice(0, 27) + "..." : target.title;
822
- return `PR #${target.prNumber}: ${shortTitle}`;
823
- }
824
-
825
- case "folder": {
826
- const joined = target.paths.join(", ");
827
- return joined.length > 40 ? `folders: ${joined.slice(0, 37)}...` : `folders: ${joined}`;
828
- }
829
- }
827
+ switch (target.type) {
828
+ case "uncommitted":
829
+ return "current changes";
830
+ case "baseBranch":
831
+ return `changes against '${target.branch}'`;
832
+ case "commit": {
833
+ const shortSha = target.sha.slice(0, 7);
834
+ return target.title ? `commit ${shortSha}: ${target.title}` : `commit ${shortSha}`;
835
+ }
836
+
837
+ case "pullRequest": {
838
+ const shortTitle =
839
+ target.title.length > 30 ? target.title.slice(0, 27) + "..." : target.title;
840
+ return `PR #${target.prNumber}: ${shortTitle}`;
841
+ }
842
+
843
+ case "folder": {
844
+ const joined = target.paths.join(", ");
845
+ return joined.length > 40 ? `folders: ${joined.slice(0, 37)}...` : `folders: ${joined}`;
846
+ }
847
+ }
830
848
  }
831
849
 
832
850
  type AssistantSnapshot = {
833
- id: string;
834
- text: string;
835
- stopReason?: string;
851
+ id: string;
852
+ text: string;
853
+ stopReason?: string;
836
854
  };
837
855
 
838
856
  function extractAssistantTextContent(content: unknown): string {
839
- if (typeof content === "string") {
840
- return content.trim();
841
- }
842
-
843
- if (!Array.isArray(content)) {
844
- return "";
845
- }
846
-
847
- const textParts = content
848
- .filter(
849
- (part): part is { type: "text"; text: string } =>
850
- Boolean(part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part),
851
- )
852
- .map((part) => part.text);
853
- return textParts.join("\n").trim();
857
+ if (typeof content === "string") {
858
+ return content.trim();
859
+ }
860
+
861
+ if (!Array.isArray(content)) {
862
+ return "";
863
+ }
864
+
865
+ const textParts = content
866
+ .filter((part): part is { type: "text"; text: string } =>
867
+ Boolean(
868
+ part &&
869
+ typeof part === "object" &&
870
+ "type" in part &&
871
+ part.type === "text" &&
872
+ "text" in part,
873
+ ),
874
+ )
875
+ .map((part) => part.text);
876
+ return textParts.join("\n").trim();
854
877
  }
855
878
 
856
879
  function getLastAssistantSnapshot(ctx: ExtensionContext): AssistantSnapshot | null {
857
- const entries = ctx.sessionManager.getBranch();
858
- for (let i = entries.length - 1; i >= 0; i--) {
859
- const entry = entries[i];
860
- if (entry.type !== "message" || entry.message.role !== "assistant") {
861
- continue;
862
- }
863
-
864
- const assistantMessage = entry.message as { content?: unknown; stopReason?: string };
865
- return {
866
- id: entry.id,
867
- text: extractAssistantTextContent(assistantMessage.content),
868
- stopReason: assistantMessage.stopReason,
869
- };
870
- }
871
-
872
- return null;
880
+ const entries = ctx.sessionManager.getBranch();
881
+ for (let i = entries.length - 1; i >= 0; i--) {
882
+ const entry = entries[i];
883
+ if (entry.type !== "message" || entry.message.role !== "assistant") {
884
+ continue;
885
+ }
886
+
887
+ const assistantMessage = entry.message as { content?: unknown; stopReason?: string };
888
+ return {
889
+ id: entry.id,
890
+ text: extractAssistantTextContent(assistantMessage.content),
891
+ stopReason: assistantMessage.stopReason,
892
+ };
893
+ }
894
+
895
+ return null;
873
896
  }
874
897
 
875
898
  function sleep(ms: number): Promise<void> {
876
- return new Promise((resolve) => setTimeout(resolve, ms));
899
+ return new Promise((resolve) => setTimeout(resolve, ms));
877
900
  }
878
901
 
879
902
  function tokenizeArgs(value: string): string[] {
880
- const tokens: string[] = [];
881
- let current = "";
882
- let quote: '"' | "'" | null = null;
883
-
884
- for (let i = 0; i < value.length; i++) {
885
- const char = value[i];
886
-
887
- if (quote) {
888
- if (char === "\\" && i + 1 < value.length) {
889
- current += value[i + 1];
890
- i += 1;
891
- continue;
892
- }
893
- if (char === quote) {
894
- quote = null;
895
- continue;
896
- }
897
- current += char;
898
- continue;
899
- }
900
-
901
- if (char === '"' || char === "'") {
902
- quote = char;
903
- continue;
904
- }
905
-
906
- if (/\s/.test(char)) {
907
- if (current.length > 0) {
908
- tokens.push(current);
909
- current = "";
910
- }
911
- continue;
912
- }
913
-
914
- current += char;
915
- }
916
-
917
- if (current.length > 0) {
918
- tokens.push(current);
919
- }
920
-
921
- return tokens;
903
+ const tokens: string[] = [];
904
+ let current = "";
905
+ let quote: '"' | "'" | null = null;
906
+
907
+ for (let i = 0; i < value.length; i++) {
908
+ const char = value[i];
909
+
910
+ if (quote) {
911
+ if (char === "\\" && i + 1 < value.length) {
912
+ current += value[i + 1];
913
+ i += 1;
914
+ continue;
915
+ }
916
+ if (char === quote) {
917
+ quote = null;
918
+ continue;
919
+ }
920
+ current += char;
921
+ continue;
922
+ }
923
+
924
+ if (char === '"' || char === "'") {
925
+ quote = char;
926
+ continue;
927
+ }
928
+
929
+ if (/\s/.test(char)) {
930
+ if (current.length > 0) {
931
+ tokens.push(current);
932
+ current = "";
933
+ }
934
+ continue;
935
+ }
936
+
937
+ current += char;
938
+ }
939
+
940
+ if (current.length > 0) {
941
+ tokens.push(current);
942
+ }
943
+
944
+ return tokens;
922
945
  }
923
946
 
924
947
  function parseReviewPaths(value: string): string[] {
925
- return tokenizeArgs(value)
926
- .map((item) => item.trim())
927
- .filter((item) => item.length > 0);
948
+ return tokenizeArgs(value)
949
+ .map((item) => item.trim())
950
+ .filter((item) => item.length > 0);
928
951
  }
929
952
 
930
953
  type ParsedReviewArgs = {
931
- target: ReviewTarget | { type: "pr"; ref: string } | null;
932
- extraInstruction?: string;
933
- error?: string;
954
+ target: ReviewTarget | { type: "pr"; ref: string } | null;
955
+ extraInstruction?: string;
956
+ error?: string;
934
957
  };
935
958
 
936
959
  function parseArgs(args: string | undefined): ParsedReviewArgs {
937
- if (!args?.trim()) return { target: null };
938
-
939
- const rawParts = tokenizeArgs(args.trim());
940
- const parts: string[] = [];
941
- let extraInstruction: string | undefined;
942
-
943
- for (let i = 0; i < rawParts.length; i++) {
944
- const part = rawParts[i];
945
- if (part === "--extra") {
946
- const next = rawParts[i + 1];
947
- if (!next) {
948
- return { target: null, error: "Missing value for --extra" };
949
- }
950
- extraInstruction = next;
951
- i += 1;
952
- continue;
953
- }
954
-
955
- if (part.startsWith("--extra=")) {
956
- extraInstruction = part.slice("--extra=".length);
957
- continue;
958
- }
959
-
960
- parts.push(part);
961
- }
962
-
963
- if (parts.length === 0) {
964
- return { target: null, extraInstruction };
965
- }
966
-
967
- const subcommand = parts[0]?.toLowerCase();
968
-
969
- switch (subcommand) {
970
- case "uncommitted":
971
- return { target: { type: "uncommitted" }, extraInstruction };
972
-
973
- case "branch": {
974
- const branch = parts[1];
975
- if (!branch) return { target: null, extraInstruction };
976
- return { target: { type: "baseBranch", branch }, extraInstruction };
977
- }
978
-
979
- case "commit": {
980
- const sha = parts[1];
981
- if (!sha) return { target: null, extraInstruction };
982
- const title = parts.slice(2).join(" ") || undefined;
983
- return { target: { type: "commit", sha, title }, extraInstruction };
984
- }
985
-
986
- case "folder": {
987
- const paths = parts.slice(1).map((item) => item.trim()).filter((item) => item.length > 0);
988
- if (paths.length === 0) return { target: null, extraInstruction };
989
- return { target: { type: "folder", paths }, extraInstruction };
990
- }
991
-
992
- case "pr": {
993
- const ref = parts[1];
994
- if (!ref) return { target: null, extraInstruction };
995
- return { target: { type: "pr", ref }, extraInstruction };
996
- }
997
-
998
- default:
999
- return { target: null, extraInstruction };
1000
- }
960
+ if (!args?.trim()) return { target: null };
961
+
962
+ const rawParts = tokenizeArgs(args.trim());
963
+ const parts: string[] = [];
964
+ let extraInstruction: string | undefined;
965
+
966
+ for (let i = 0; i < rawParts.length; i++) {
967
+ const part = rawParts[i];
968
+ if (part === "--extra") {
969
+ const next = rawParts[i + 1];
970
+ if (!next) {
971
+ return { target: null, error: "Missing value for --extra" };
972
+ }
973
+ extraInstruction = next;
974
+ i += 1;
975
+ continue;
976
+ }
977
+
978
+ if (part.startsWith("--extra=")) {
979
+ extraInstruction = part.slice("--extra=".length);
980
+ continue;
981
+ }
982
+
983
+ parts.push(part);
984
+ }
985
+
986
+ if (parts.length === 0) {
987
+ return { target: null, extraInstruction };
988
+ }
989
+
990
+ const subcommand = parts[0]?.toLowerCase();
991
+
992
+ switch (subcommand) {
993
+ case "uncommitted":
994
+ return { target: { type: "uncommitted" }, extraInstruction };
995
+
996
+ case "branch": {
997
+ const branch = parts[1];
998
+ if (!branch) return { target: null, extraInstruction };
999
+ return { target: { type: "baseBranch", branch }, extraInstruction };
1000
+ }
1001
+
1002
+ case "commit": {
1003
+ const sha = parts[1];
1004
+ if (!sha) return { target: null, extraInstruction };
1005
+ const title = parts.slice(2).join(" ") || undefined;
1006
+ return { target: { type: "commit", sha, title }, extraInstruction };
1007
+ }
1008
+
1009
+ case "folder": {
1010
+ const paths = parts
1011
+ .slice(1)
1012
+ .map((item) => item.trim())
1013
+ .filter((item) => item.length > 0);
1014
+ if (paths.length === 0) return { target: null, extraInstruction };
1015
+ return { target: { type: "folder", paths }, extraInstruction };
1016
+ }
1017
+
1018
+ case "pr": {
1019
+ const ref = parts[1];
1020
+ if (!ref) return { target: null, extraInstruction };
1021
+ return { target: { type: "pr", ref }, extraInstruction };
1022
+ }
1023
+
1024
+ default:
1025
+ return { target: null, extraInstruction };
1026
+ }
1001
1027
  }
1002
1028
 
1003
- async function waitForLoopTurnToStart(ctx: ExtensionContext, previousAssistantId?: string): Promise<boolean> {
1004
- const deadline = Date.now() + REVIEW_LOOP_START_TIMEOUT_MS;
1005
-
1006
- while (Date.now() < deadline) {
1007
- const lastAssistantId = getLastAssistantSnapshot(ctx)?.id;
1008
- if (!ctx.isIdle() || ctx.hasPendingMessages() || (lastAssistantId && lastAssistantId !== previousAssistantId)) {
1009
- return true;
1010
- }
1011
- await sleep(REVIEW_LOOP_START_POLL_MS);
1012
- }
1013
-
1014
- return false;
1029
+ async function waitForLoopTurnToStart(
1030
+ ctx: ExtensionContext,
1031
+ previousAssistantId?: string,
1032
+ ): Promise<boolean> {
1033
+ const deadline = Date.now() + REVIEW_LOOP_START_TIMEOUT_MS;
1034
+
1035
+ while (Date.now() < deadline) {
1036
+ const lastAssistantId = getLastAssistantSnapshot(ctx)?.id;
1037
+ if (
1038
+ !ctx.isIdle() ||
1039
+ ctx.hasPendingMessages() ||
1040
+ (lastAssistantId && lastAssistantId !== previousAssistantId)
1041
+ ) {
1042
+ return true;
1043
+ }
1044
+ await sleep(REVIEW_LOOP_START_POLL_MS);
1045
+ }
1046
+
1047
+ return false;
1015
1048
  }
1016
1049
 
1017
1050
  // Review preset options for the selector (keep this order stable)
1018
1051
  const REVIEW_PRESETS = [
1019
- { value: "uncommitted", label: "Review uncommitted changes", description: "" },
1020
- { value: "baseBranch", label: "Review against a base branch", description: "(local)" },
1021
- { value: "commit", label: "Review a commit", description: "" },
1022
- { value: "pullRequest", label: "Review a pull request", description: "(GitHub PR)" },
1023
- { value: "folder", label: "Review a folder (or more)", description: "(snapshot, not diff)" },
1052
+ { value: "uncommitted", label: "Review uncommitted changes", description: "" },
1053
+ { value: "baseBranch", label: "Review against a base branch", description: "(local)" },
1054
+ { value: "commit", label: "Review a commit", description: "" },
1055
+ { value: "pullRequest", label: "Review a pull request", description: "(GitHub PR)" },
1056
+ { value: "folder", label: "Review a folder (or more)", description: "(snapshot, not diff)" },
1024
1057
  ] as const;
1025
1058
 
1026
1059
  const TOGGLE_LOOP_FIXING_VALUE = "toggleLoopFixing" as const;
1027
1060
  const TOGGLE_CUSTOM_INSTRUCTIONS_VALUE = "toggleCustomInstructions" as const;
1028
1061
  type ReviewPresetValue =
1029
- | (typeof REVIEW_PRESETS)[number]["value"]
1030
- | typeof TOGGLE_LOOP_FIXING_VALUE
1031
- | typeof TOGGLE_CUSTOM_INSTRUCTIONS_VALUE;
1062
+ | (typeof REVIEW_PRESETS)[number]["value"]
1063
+ | typeof TOGGLE_LOOP_FIXING_VALUE
1064
+ | typeof TOGGLE_CUSTOM_INSTRUCTIONS_VALUE;
1032
1065
 
1033
1066
  const REVIEW_SUMMARY_PROMPT = `We are leaving a code-review branch and returning to the main coding branch.
1034
1067
  Create a structured handoff that can be used immediately to implement fixes.
@@ -1058,899 +1091,724 @@ For EACH finding, include:
1058
1091
  - Any constraints or preferences mentioned during review
1059
1092
  - Or "(none)"
1060
1093
 
1061
- ## Human Reviewer Callouts (Non-Blocking)
1062
- Include only applicable callouts (no yes/no lines):
1063
- - **This change adds a database migration:** <files/details>
1064
- - **This change introduces a new dependency:** <package(s)/details>
1065
- - **This change changes a dependency (or the lockfile):** <files/package(s)/details>
1066
- - **This change modifies auth/permission behavior:** <what changed and where>
1067
- - **This change introduces backwards-incompatible public schema/API/contract changes:** <what changed and where>
1068
- - **This change includes irreversible or destructive operations:** <operation and scope>
1069
-
1070
- If none apply, write "- (none)".
1071
-
1072
- These are informational callouts for humans and are not fix items by themselves.
1094
+ ${HUMAN_REVIEWER_CALLOUTS}
1073
1095
 
1074
1096
  Preserve exact file paths, function names, and error messages where available.`;
1075
1097
 
1076
1098
  export const __test__ = {
1077
- applyReviewSettings,
1078
- applyReviewState,
1079
- buildReviewPrompt,
1080
- composeReviewPrompt,
1081
- getReviewRuntimeState,
1082
- getReviewSettings,
1083
- getReviewState,
1084
- hasBlockingReviewFindings,
1085
- hasNeedsAttentionVerdict,
1086
- getUserFacingHint,
1087
- parseArgs,
1088
- parsePrReference,
1089
- parseReviewPaths,
1090
- REVIEW_SUMMARY_PROMPT,
1091
- loadProjectReviewGuidelines,
1092
- resetReviewRuntimeState,
1093
- tokenizeArgs,
1099
+ applyReviewSettings,
1100
+ applyReviewState,
1101
+ buildReviewPrompt,
1102
+ composeReviewPrompt,
1103
+ getReviewRuntimeState,
1104
+ getReviewSettings,
1105
+ getReviewState,
1106
+ hasBlockingReviewFindings,
1107
+ hasNeedsAttentionVerdict,
1108
+ getUserFacingHint,
1109
+ parseArgs,
1110
+ parsePrReference,
1111
+ parseReviewPaths,
1112
+ HUMAN_REVIEWER_CALLOUTS,
1113
+ REVIEW_RUBRIC,
1114
+ REVIEW_SUMMARY_PROMPT,
1115
+ loadProjectReviewGuidelines,
1116
+ resetReviewRuntimeState,
1117
+ tokenizeArgs,
1094
1118
  };
1095
1119
 
1096
1120
  export default function reviewExtension(pi: ExtensionAPI) {
1097
- function persistReviewSettings() {
1098
- pi.appendEntry(REVIEW_SETTINGS_TYPE, {
1099
- loopFixingEnabled: reviewLoopFixingEnabled,
1100
- customInstructions: reviewCustomInstructions,
1101
- });
1102
- }
1103
-
1104
- function setReviewLoopFixingEnabled(enabled: boolean) {
1105
- reviewLoopFixingEnabled = enabled;
1106
- persistReviewSettings();
1107
- }
1108
-
1109
- function setReviewCustomInstructions(instructions: string | undefined) {
1110
- reviewCustomInstructions = instructions?.trim() || undefined;
1111
- persistReviewSettings();
1112
- }
1113
-
1114
- function applyAllReviewState(ctx: ExtensionContext) {
1115
- applyReviewSettings(ctx);
1116
- applyReviewState(ctx);
1117
- }
1118
-
1119
- pi.on("session_start", (_event, ctx) => {
1120
- applyAllReviewState(ctx);
1121
- });
1122
-
1123
- pi.on("session_tree", (_event, ctx) => {
1124
- applyAllReviewState(ctx);
1125
- });
1126
-
1127
- /**
1128
- * Determine the smart default review type based on git state
1129
- */
1130
- async function getSmartDefault(): Promise<"uncommitted" | "baseBranch" | "commit"> {
1131
- // Priority 1: If there are uncommitted changes, default to reviewing them
1132
- if (await hasUncommittedChanges(pi)) {
1133
- return "uncommitted";
1134
- }
1135
-
1136
- // Priority 2: If on a feature branch (not the default branch), default to PR-style review
1137
- const currentBranch = await getCurrentBranch(pi);
1138
- const defaultBranch = await getDefaultBranch(pi);
1139
- if (currentBranch && currentBranch !== defaultBranch) {
1140
- return "baseBranch";
1141
- }
1142
-
1143
- // Priority 3: Default to reviewing a specific commit
1144
- return "commit";
1145
- }
1146
-
1147
- /**
1148
- * Show the review preset selector
1149
- */
1150
- async function showReviewSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1151
- // Determine smart default (but keep the list order stable)
1152
- const smartDefault = await getSmartDefault();
1153
- const presetItems: SelectItem[] = REVIEW_PRESETS.map((preset) => ({
1154
- value: preset.value,
1155
- label: preset.label,
1156
- description: preset.description,
1157
- }));
1158
- const smartDefaultIndex = presetItems.findIndex((item) => item.value === smartDefault);
1159
-
1160
- while (true) {
1161
- const customInstructionsLabel = reviewCustomInstructions
1162
- ? "Remove custom review instructions"
1163
- : "Add custom review instructions";
1164
- const customInstructionsDescription = reviewCustomInstructions
1165
- ? "(currently set)"
1166
- : "(applies to all review modes)";
1167
- const loopToggleLabel = reviewLoopFixingEnabled ? "Disable Loop Fixing" : "Enable Loop Fixing";
1168
- const loopToggleDescription = reviewLoopFixingEnabled ? "(currently on)" : "(currently off)";
1169
- const items: SelectItem[] = [
1170
- ...presetItems,
1171
- {
1172
- value: TOGGLE_CUSTOM_INSTRUCTIONS_VALUE,
1173
- label: customInstructionsLabel,
1174
- description: customInstructionsDescription,
1175
- },
1176
- { value: TOGGLE_LOOP_FIXING_VALUE, label: loopToggleLabel, description: loopToggleDescription },
1177
- ];
1178
-
1179
- const result = await ctx.ui.custom<ReviewPresetValue | null>((tui, theme, _kb, done) => {
1180
- const container = new Container();
1181
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1182
- container.addChild(new Text(theme.fg("accent", theme.bold("Select a review preset"))));
1183
-
1184
- const selectList = new SelectList(items, Math.min(items.length, 10), {
1185
- selectedPrefix: (text) => theme.fg("accent", text),
1186
- selectedText: (text) => theme.fg("accent", text),
1187
- description: (text) => theme.fg("muted", text),
1188
- scrollInfo: (text) => theme.fg("dim", text),
1189
- noMatch: (text) => theme.fg("warning", text),
1190
- });
1191
-
1192
- // Preselect the smart default without reordering the list
1193
- if (smartDefaultIndex >= 0) {
1194
- selectList.setSelectedIndex(smartDefaultIndex);
1195
- }
1196
-
1197
- selectList.onSelect = (item) => done(item.value as ReviewPresetValue);
1198
- selectList.onCancel = () => done(null);
1199
-
1200
- container.addChild(selectList);
1201
- container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to go back")));
1202
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1203
-
1204
- return {
1205
- render(width: number) {
1206
- return container.render(width);
1207
- },
1208
- invalidate() {
1209
- container.invalidate();
1210
- },
1211
- handleInput(data: string) {
1212
- selectList.handleInput(data);
1213
- tui.requestRender();
1214
- },
1215
- };
1216
- });
1217
-
1218
- if (!result) return null;
1219
-
1220
- if (result === TOGGLE_LOOP_FIXING_VALUE) {
1221
- const nextEnabled = !reviewLoopFixingEnabled;
1222
- setReviewLoopFixingEnabled(nextEnabled);
1223
- ctx.ui.notify(nextEnabled ? "Loop fixing enabled" : "Loop fixing disabled", "info");
1224
- continue;
1225
- }
1226
-
1227
- if (result === TOGGLE_CUSTOM_INSTRUCTIONS_VALUE) {
1228
- if (reviewCustomInstructions) {
1229
- setReviewCustomInstructions(undefined);
1230
- ctx.ui.notify("Custom review instructions removed", "info");
1231
- continue;
1232
- }
1233
-
1234
- const customInstructions = await ctx.ui.editor(
1235
- "Enter custom review instructions (applies to all review modes):",
1236
- "",
1237
- );
1238
-
1239
- if (!customInstructions?.trim()) {
1240
- ctx.ui.notify("Custom review instructions not changed", "info");
1241
- continue;
1242
- }
1243
-
1244
- setReviewCustomInstructions(customInstructions);
1245
- ctx.ui.notify("Custom review instructions saved", "info");
1246
- continue;
1247
- }
1248
-
1249
- // Handle each preset type
1250
- switch (result) {
1251
- case "uncommitted":
1252
- return { type: "uncommitted" };
1253
-
1254
- case "baseBranch": {
1255
- const target = await showBranchSelector(ctx);
1256
- if (target) return target;
1257
- break;
1258
- }
1259
-
1260
- case "commit": {
1261
- if (reviewLoopFixingEnabled) {
1262
- ctx.ui.notify("Loop mode does not work with commit review.", "error");
1263
- break;
1264
- }
1265
- const target = await showCommitSelector(ctx);
1266
- if (target) return target;
1267
- break;
1268
- }
1269
-
1270
- case "folder": {
1271
- const target = await showFolderInput(ctx);
1272
- if (target) return target;
1273
- break;
1274
- }
1275
-
1276
- case "pullRequest": {
1277
- const target = await showPrInput(ctx);
1278
- if (target) return target;
1279
- break;
1280
- }
1281
-
1282
- default:
1283
- return null;
1284
- }
1285
- }
1286
- }
1287
-
1288
- /**
1289
- * Show branch selector for base branch review
1290
- */
1291
- async function showBranchSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1292
- const branches = await getLocalBranches(pi);
1293
- const currentBranch = await getCurrentBranch(pi);
1294
- const defaultBranch = await getDefaultBranch(pi);
1295
-
1296
- // Never offer the current branch as a base branch (reviewing against itself is meaningless).
1297
- const candidateBranches = currentBranch ? branches.filter((b) => b !== currentBranch) : branches;
1298
-
1299
- if (candidateBranches.length === 0) {
1300
- ctx.ui.notify(
1301
- currentBranch ? `No other branches found (current branch: ${currentBranch})` : "No branches found",
1302
- "error",
1303
- );
1304
- return null;
1305
- }
1306
-
1307
- // Sort branches with default branch first
1308
- const sortedBranches = candidateBranches.sort((a, b) => {
1309
- if (a === defaultBranch) return -1;
1310
- if (b === defaultBranch) return 1;
1311
- return a.localeCompare(b);
1312
- });
1313
-
1314
- const items: SelectItem[] = sortedBranches.map((branch) => ({
1315
- value: branch,
1316
- label: branch,
1317
- description: branch === defaultBranch ? "(default)" : "",
1318
- }));
1319
-
1320
- const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) => {
1321
- const container = new Container();
1322
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1323
- container.addChild(new Text(theme.fg("accent", theme.bold("Select base branch"))));
1324
-
1325
- const searchInput = new Input();
1326
- container.addChild(searchInput);
1327
- container.addChild(new Spacer(1));
1328
-
1329
- const listContainer = new Container();
1330
- container.addChild(listContainer);
1331
- container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
1332
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1333
-
1334
- let filteredItems = items;
1335
- let selectList: SelectList | null = null;
1336
-
1337
- const updateList = () => {
1338
- listContainer.clear();
1339
- if (filteredItems.length === 0) {
1340
- listContainer.addChild(new Text(theme.fg("warning", " No matching branches")));
1341
- selectList = null;
1342
- return;
1343
- }
1344
-
1345
- selectList = new SelectList(filteredItems, Math.min(filteredItems.length, 10), {
1346
- selectedPrefix: (text) => theme.fg("accent", text),
1347
- selectedText: (text) => theme.fg("accent", text),
1348
- description: (text) => theme.fg("muted", text),
1349
- scrollInfo: (text) => theme.fg("dim", text),
1350
- noMatch: (text) => theme.fg("warning", text),
1351
- });
1352
-
1353
- selectList.onSelect = (item) => done(item.value);
1354
- selectList.onCancel = () => done(null);
1355
- listContainer.addChild(selectList);
1356
- };
1357
-
1358
- const applyFilter = () => {
1359
- const query = searchInput.getValue();
1360
- filteredItems = query
1361
- ? fuzzyFilter(items, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`)
1362
- : items;
1363
- updateList();
1364
- };
1365
-
1366
- applyFilter();
1367
-
1368
- return {
1369
- render(width: number) {
1370
- return container.render(width);
1371
- },
1372
- invalidate() {
1373
- container.invalidate();
1374
- },
1375
- handleInput(data: string) {
1376
- if (
1377
- keybindings.matches(data, "tui.select.up") ||
1378
- keybindings.matches(data, "tui.select.down") ||
1379
- keybindings.matches(data, "tui.select.confirm") ||
1380
- keybindings.matches(data, "tui.select.cancel")
1381
- ) {
1382
- if (selectList) {
1383
- selectList.handleInput(data);
1384
- } else if (keybindings.matches(data, "tui.select.cancel")) {
1385
- done(null);
1386
- }
1387
- tui.requestRender();
1388
- return;
1389
- }
1390
-
1391
- searchInput.handleInput(data);
1392
- applyFilter();
1393
- tui.requestRender();
1394
- },
1395
- };
1396
- });
1397
-
1398
- if (!result) return null;
1399
- return { type: "baseBranch", branch: result };
1400
- }
1401
-
1402
- /**
1403
- * Show commit selector
1404
- */
1405
- async function showCommitSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1406
- const commits = await getRecentCommits(pi, 20);
1407
-
1408
- if (commits.length === 0) {
1409
- ctx.ui.notify("No commits found", "error");
1410
- return null;
1411
- }
1412
-
1413
- const items: SelectItem[] = commits.map((commit) => ({
1414
- value: commit.sha,
1415
- label: `${commit.sha.slice(0, 7)} ${commit.title}`,
1416
- description: "",
1417
- }));
1418
-
1419
- const result = await ctx.ui.custom<{ sha: string; title: string } | null>((tui, theme, keybindings, done) => {
1420
- const container = new Container();
1421
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1422
- container.addChild(new Text(theme.fg("accent", theme.bold("Select commit to review"))));
1423
-
1424
- const searchInput = new Input();
1425
- container.addChild(searchInput);
1426
- container.addChild(new Spacer(1));
1427
-
1428
- const listContainer = new Container();
1429
- container.addChild(listContainer);
1430
- container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
1431
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1432
-
1433
- let filteredItems = items;
1434
- let selectList: SelectList | null = null;
1435
-
1436
- const updateList = () => {
1437
- listContainer.clear();
1438
- if (filteredItems.length === 0) {
1439
- listContainer.addChild(new Text(theme.fg("warning", " No matching commits")));
1440
- selectList = null;
1441
- return;
1442
- }
1443
-
1444
- selectList = new SelectList(filteredItems, Math.min(filteredItems.length, 10), {
1445
- selectedPrefix: (text) => theme.fg("accent", text),
1446
- selectedText: (text) => theme.fg("accent", text),
1447
- description: (text) => theme.fg("muted", text),
1448
- scrollInfo: (text) => theme.fg("dim", text),
1449
- noMatch: (text) => theme.fg("warning", text),
1450
- });
1451
-
1452
- selectList.onSelect = (item) => {
1453
- const commit = commits.find((c) => c.sha === item.value);
1454
- if (commit) {
1455
- done(commit);
1456
- } else {
1457
- done(null);
1458
- }
1459
- };
1460
- selectList.onCancel = () => done(null);
1461
- listContainer.addChild(selectList);
1462
- };
1463
-
1464
- const applyFilter = () => {
1465
- const query = searchInput.getValue();
1466
- filteredItems = query
1467
- ? fuzzyFilter(items, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`)
1468
- : items;
1469
- updateList();
1470
- };
1471
-
1472
- applyFilter();
1473
-
1474
- return {
1475
- render(width: number) {
1476
- return container.render(width);
1477
- },
1478
- invalidate() {
1479
- container.invalidate();
1480
- },
1481
- handleInput(data: string) {
1482
- if (
1483
- keybindings.matches(data, "tui.select.up") ||
1484
- keybindings.matches(data, "tui.select.down") ||
1485
- keybindings.matches(data, "tui.select.confirm") ||
1486
- keybindings.matches(data, "tui.select.cancel")
1487
- ) {
1488
- if (selectList) {
1489
- selectList.handleInput(data);
1490
- } else if (keybindings.matches(data, "tui.select.cancel")) {
1491
- done(null);
1492
- }
1493
- tui.requestRender();
1494
- return;
1495
- }
1496
-
1497
- searchInput.handleInput(data);
1498
- applyFilter();
1499
- tui.requestRender();
1500
- },
1501
- };
1502
- });
1503
-
1504
- if (!result) return null;
1505
- return { type: "commit", sha: result.sha, title: result.title };
1506
- }
1507
-
1508
-
1509
- /**
1510
- * Show folder input
1511
- */
1512
- async function showFolderInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1513
- const result = await ctx.ui.editor(
1514
- "Enter folders/files to review (space-separated or one per line):",
1515
- ".",
1516
- );
1517
-
1518
- if (!result?.trim()) return null;
1519
- const paths = parseReviewPaths(result);
1520
- if (paths.length === 0) return null;
1521
-
1522
- return { type: "folder", paths };
1523
- }
1524
-
1525
- /**
1526
- * Show PR input and handle checkout
1527
- */
1528
- async function showPrInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1529
- // First check for pending changes that would prevent branch switching
1530
- if (await hasPendingChanges(pi)) {
1531
- ctx.ui.notify("Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", "error");
1532
- return null;
1533
- }
1534
-
1535
- // Get PR reference from user
1536
- const prRef = await ctx.ui.editor(
1537
- "Enter PR number or URL (e.g. 123 or https://github.com/owner/repo/pull/123):",
1538
- "",
1539
- );
1540
-
1541
- if (!prRef?.trim()) return null;
1542
-
1543
- const prNumber = parsePrReference(prRef);
1544
- if (!prNumber) {
1545
- ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error");
1546
- return null;
1547
- }
1548
-
1549
- // Get PR info from GitHub
1550
- ctx.ui.notify(`Fetching PR #${prNumber} info...`, "info");
1551
- const prInfo = await getPrInfo(pi, prNumber);
1552
-
1553
- if (!prInfo) {
1554
- ctx.ui.notify(`Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`, "error");
1555
- return null;
1556
- }
1557
-
1558
- // Check again for pending changes (in case something changed)
1559
- if (await hasPendingChanges(pi)) {
1560
- ctx.ui.notify("Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", "error");
1561
- return null;
1562
- }
1563
-
1564
- // Checkout the PR
1565
- ctx.ui.notify(`Checking out PR #${prNumber}...`, "info");
1566
- const checkoutResult = await checkoutPr(pi, prNumber);
1567
-
1568
- if (!checkoutResult.success) {
1569
- ctx.ui.notify(`Failed to checkout PR: ${checkoutResult.error}`, "error");
1570
- return null;
1571
- }
1572
-
1573
- ctx.ui.notify(`Checked out PR #${prNumber} (${prInfo.headBranch})`, "info");
1574
-
1575
- return {
1576
- type: "pullRequest",
1577
- prNumber,
1578
- baseBranch: prInfo.baseBranch,
1579
- title: prInfo.title,
1580
- };
1581
- }
1582
-
1583
- /**
1584
- * Execute the review
1585
- */
1586
- async function executeReview(
1587
- ctx: ExtensionCommandContext,
1588
- target: ReviewTarget,
1589
- useFreshSession: boolean,
1590
- options?: { includeLocalChanges?: boolean; extraInstruction?: string },
1591
- ): Promise<boolean> {
1592
- // Check if we're already in a review
1593
- if (reviewOriginId) {
1594
- ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
1595
- return false;
1596
- }
1597
-
1598
- // Handle fresh session mode
1599
- if (useFreshSession) {
1600
- // Store current position (where we'll return to).
1601
- // In an empty session there is no leaf yet, so create a lightweight anchor first.
1602
- let originId = ctx.sessionManager.getLeafId() ?? undefined;
1603
- if (!originId) {
1604
- pi.appendEntry(REVIEW_ANCHOR_TYPE, { createdAt: new Date().toISOString() });
1605
- originId = ctx.sessionManager.getLeafId() ?? undefined;
1606
- }
1607
- if (!originId) {
1608
- ctx.ui.notify("Failed to determine review origin.", "error");
1609
- return false;
1610
- }
1611
- reviewOriginId = originId;
1612
-
1613
- // Keep a local copy so session_tree events during navigation don't wipe it
1614
- const lockedOriginId = originId;
1615
-
1616
- // Find the first user message in the session.
1617
- // If none exists (e.g. brand-new session), we'll stay on the current leaf.
1618
- const entries = ctx.sessionManager.getEntries();
1619
- const firstUserMessage = entries.find(
1620
- (e) => e.type === "message" && e.message.role === "user",
1621
- );
1622
-
1623
- if (firstUserMessage) {
1624
- // Navigate to first user message to create a new branch from that point
1625
- // Label it as "code-review" so it's visible in the tree
1626
- try {
1627
- const result = await ctx.navigateTree(firstUserMessage.id, { summarize: false, label: "code-review" });
1628
- if (result.cancelled) {
1629
- reviewOriginId = undefined;
1630
- return false;
1631
- }
1632
- } catch (error) {
1633
- // Clean up state if navigation fails
1634
- reviewOriginId = undefined;
1635
- ctx.ui.notify(`Failed to start review: ${error instanceof Error ? error.message : String(error)}`, "error");
1636
- return false;
1637
- }
1638
-
1639
- // Clear the editor (navigating to user message fills it with the message text)
1640
- ctx.ui.setEditorText("");
1641
- }
1642
-
1643
- // Restore origin after navigation events (session_tree can reset it)
1644
- reviewOriginId = lockedOriginId;
1645
-
1646
- // Show widget indicating review is active
1647
- setReviewWidget(ctx, true);
1648
-
1649
- // Persist review state so tree navigation can restore/reset it
1650
- pi.appendEntry(REVIEW_STATE_TYPE, { active: true, originId: lockedOriginId });
1651
- }
1652
-
1653
- const prompt = await buildReviewPrompt(pi, target, {
1654
- includeLocalChanges: options?.includeLocalChanges === true,
1655
- });
1656
- const hint = getUserFacingHint(target);
1657
- const projectGuidelines = canReadProjectConfig(ctx) ? await loadProjectReviewGuidelines(ctx.cwd) : null;
1658
-
1659
- const fullPrompt = composeReviewPrompt(prompt, {
1660
- customInstructions: reviewCustomInstructions,
1661
- extraInstruction: options?.extraInstruction,
1662
- projectGuidelines,
1663
- });
1664
-
1665
- const modeHint = useFreshSession ? " (fresh session)" : "";
1666
- ctx.ui.notify(`Starting review: ${hint}${modeHint}`, "info");
1667
-
1668
- // Send as a user message that triggers a turn
1669
- pi.sendUserMessage(fullPrompt);
1670
- return true;
1671
- }
1672
-
1673
- /**
1674
- * Handle PR checkout and return a ReviewTarget (or null on failure)
1675
- */
1676
- async function handlePrCheckout(ctx: ExtensionContext, ref: string): Promise<ReviewTarget | null> {
1677
- // First check for pending changes
1678
- if (await hasPendingChanges(pi)) {
1679
- ctx.ui.notify("Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", "error");
1680
- return null;
1681
- }
1682
-
1683
- const prNumber = parsePrReference(ref);
1684
- if (!prNumber) {
1685
- ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error");
1686
- return null;
1687
- }
1688
-
1689
- // Get PR info
1690
- ctx.ui.notify(`Fetching PR #${prNumber} info...`, "info");
1691
- const prInfo = await getPrInfo(pi, prNumber);
1692
-
1693
- if (!prInfo) {
1694
- ctx.ui.notify(`Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`, "error");
1695
- return null;
1696
- }
1697
-
1698
- // Checkout the PR
1699
- ctx.ui.notify(`Checking out PR #${prNumber}...`, "info");
1700
- const checkoutResult = await checkoutPr(pi, prNumber);
1701
-
1702
- if (!checkoutResult.success) {
1703
- ctx.ui.notify(`Failed to checkout PR: ${checkoutResult.error}`, "error");
1704
- return null;
1705
- }
1706
-
1707
- ctx.ui.notify(`Checked out PR #${prNumber} (${prInfo.headBranch})`, "info");
1708
-
1709
- return {
1710
- type: "pullRequest",
1711
- prNumber,
1712
- baseBranch: prInfo.baseBranch,
1713
- title: prInfo.title,
1714
- };
1715
- }
1716
-
1717
- function isLoopCompatibleTarget(target: ReviewTarget): boolean {
1718
- if (target.type !== "commit") {
1719
- return true;
1720
- }
1721
-
1722
- return false;
1723
- }
1724
-
1725
- async function runLoopFixingReview(
1726
- ctx: ExtensionCommandContext,
1727
- target: ReviewTarget,
1728
- extraInstruction?: string,
1729
- ): Promise<void> {
1730
- if (reviewLoopInProgress) {
1731
- ctx.ui.notify("Loop fixing review is already running.", "warning");
1732
- return;
1733
- }
1734
-
1735
- reviewLoopInProgress = true;
1736
- setReviewWidget(ctx, Boolean(reviewOriginId));
1737
- try {
1738
- ctx.ui.notify(
1739
- "Loop fixing enabled: using Empty branch mode and cycling until no blocking findings remain.",
1740
- "info",
1741
- );
1742
-
1743
- for (let pass = 1; pass <= REVIEW_LOOP_MAX_ITERATIONS; pass++) {
1744
- const reviewBaselineAssistantId = getLastAssistantSnapshot(ctx)?.id;
1745
- const started = await executeReview(ctx, target, true, {
1746
- includeLocalChanges: true,
1747
- extraInstruction,
1748
- });
1749
- if (!started) {
1750
- ctx.ui.notify("Loop fixing stopped before starting the review pass.", "warning");
1751
- return;
1752
- }
1753
-
1754
- const reviewTurnStarted = await waitForLoopTurnToStart(ctx, reviewBaselineAssistantId);
1755
- if (!reviewTurnStarted) {
1756
- ctx.ui.notify("Loop fixing stopped: review pass did not start in time.", "error");
1757
- return;
1758
- }
1759
-
1760
- await ctx.waitForIdle();
1761
-
1762
- const reviewSnapshot = getLastAssistantSnapshot(ctx);
1763
- if (!reviewSnapshot || reviewSnapshot.id === reviewBaselineAssistantId) {
1764
- ctx.ui.notify("Loop fixing stopped: could not read the review result.", "warning");
1765
- return;
1766
- }
1767
-
1768
- if (reviewSnapshot.stopReason === "aborted") {
1769
- ctx.ui.notify("Loop fixing stopped: review was aborted.", "warning");
1770
- return;
1771
- }
1772
-
1773
- if (reviewSnapshot.stopReason === "error") {
1774
- ctx.ui.notify("Loop fixing stopped: review failed with an error.", "error");
1775
- return;
1776
- }
1777
-
1778
- if (reviewSnapshot.stopReason === "length") {
1779
- ctx.ui.notify("Loop fixing stopped: review output was truncated (stopReason=length).", "warning");
1780
- return;
1781
- }
1782
-
1783
- if (!hasBlockingReviewFindings(reviewSnapshot.text)) {
1784
- const finalized = await executeEndReviewAction(ctx, "returnAndSummarize", {
1785
- showSummaryLoader: true,
1786
- notifySuccess: false,
1787
- });
1788
- if (finalized !== "ok") {
1789
- return;
1790
- }
1791
-
1792
- ctx.ui.notify("Loop fixing complete: no blocking findings remain.", "info");
1793
- return;
1794
- }
1795
-
1796
- ctx.ui.notify(`Loop fixing pass ${pass}: found blocking findings, returning to fix them...`, "info");
1797
-
1798
- const fixBaselineAssistantId = getLastAssistantSnapshot(ctx)?.id;
1799
- const sentFixPrompt = await executeEndReviewAction(ctx, "returnAndFix", {
1800
- showSummaryLoader: true,
1801
- notifySuccess: false,
1802
- });
1803
- if (sentFixPrompt !== "ok") {
1804
- return;
1805
- }
1806
-
1807
- const fixTurnStarted = await waitForLoopTurnToStart(ctx, fixBaselineAssistantId);
1808
- if (!fixTurnStarted) {
1809
- ctx.ui.notify("Loop fixing stopped: fix pass did not start in time.", "error");
1810
- return;
1811
- }
1812
-
1813
- await ctx.waitForIdle();
1814
-
1815
- const fixSnapshot = getLastAssistantSnapshot(ctx);
1816
- if (!fixSnapshot || fixSnapshot.id === fixBaselineAssistantId) {
1817
- ctx.ui.notify("Loop fixing stopped: could not read the fix pass result.", "warning");
1818
- return;
1819
- }
1820
- if (fixSnapshot.stopReason === "aborted") {
1821
- ctx.ui.notify("Loop fixing stopped: fix pass was aborted.", "warning");
1822
- return;
1823
- }
1824
- if (fixSnapshot.stopReason === "error") {
1825
- ctx.ui.notify("Loop fixing stopped: fix pass failed with an error.", "error");
1826
- return;
1827
- }
1828
- if (fixSnapshot.stopReason === "length") {
1829
- ctx.ui.notify("Loop fixing stopped: fix pass output was truncated (stopReason=length).", "warning");
1830
- return;
1831
- }
1832
- }
1833
-
1834
- ctx.ui.notify(
1835
- `Loop fixing stopped after ${REVIEW_LOOP_MAX_ITERATIONS} passes (safety limit reached).`,
1836
- "warning",
1837
- );
1838
- } finally {
1839
- reviewLoopInProgress = false;
1840
- setReviewWidget(ctx, Boolean(reviewOriginId));
1841
- }
1842
- }
1843
-
1844
- // Register the /review command
1845
- pi.registerCommand("review", {
1846
- description: "Review code changes (PR, uncommitted, branch, commit, or folder)",
1847
- handler: async (args, ctx) => {
1848
- if (ctx.mode !== "tui") {
1849
- ctx.ui.notify("Review requires interactive mode", "error");
1850
- return;
1851
- }
1852
-
1853
- if (reviewLoopInProgress) {
1854
- ctx.ui.notify("Loop fixing review is already running.", "warning");
1855
- return;
1856
- }
1857
-
1858
- // Check if we're already in a review
1859
- if (reviewOriginId) {
1860
- ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
1861
- return;
1862
- }
1863
-
1864
- // Check if we're in a git repository
1865
- const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
1866
- if (code !== 0) {
1867
- ctx.ui.notify("Not a git repository", "error");
1868
- return;
1869
- }
1870
-
1871
- // Try to parse direct arguments
1872
- let target: ReviewTarget | null = null;
1873
- let fromSelector = false;
1874
- let extraInstruction: string | undefined;
1875
- const parsed = parseArgs(args);
1876
- if (parsed.error) {
1877
- ctx.ui.notify(parsed.error, "error");
1878
- return;
1879
- }
1880
- extraInstruction = parsed.extraInstruction?.trim() || undefined;
1881
-
1882
- if (parsed.target) {
1883
- if (parsed.target.type === "pr") {
1884
- // Handle PR checkout (async operation)
1885
- target = await handlePrCheckout(ctx, parsed.target.ref);
1886
- if (!target) {
1887
- ctx.ui.notify("PR review failed. Returning to review menu.", "warning");
1888
- }
1889
- } else {
1890
- target = parsed.target;
1891
- }
1892
- }
1893
-
1894
- // If no args or invalid args, show selector
1895
- if (!target) {
1896
- fromSelector = true;
1897
- }
1898
-
1899
- while (true) {
1900
- if (!target && fromSelector) {
1901
- target = await showReviewSelector(ctx);
1902
- }
1903
-
1904
- if (!target) {
1905
- ctx.ui.notify("Review cancelled", "info");
1906
- return;
1907
- }
1908
-
1909
- if (reviewLoopFixingEnabled && !isLoopCompatibleTarget(target)) {
1910
- ctx.ui.notify("Loop mode does not work with commit review.", "error");
1911
- if (fromSelector) {
1912
- target = null;
1913
- continue;
1914
- }
1915
- return;
1916
- }
1917
-
1918
- if (reviewLoopFixingEnabled) {
1919
- await runLoopFixingReview(ctx, target, extraInstruction);
1920
- return;
1921
- }
1922
-
1923
- // Determine if we should use fresh session mode
1924
- // Check if this is a new session (no messages yet)
1925
- const entries = ctx.sessionManager.getEntries();
1926
- const messageCount = entries.filter((e) => e.type === "message").length;
1927
-
1928
- // In an empty session, default to fresh review mode so /end-review works consistently.
1929
- let useFreshSession = messageCount === 0;
1930
-
1931
- if (messageCount > 0) {
1932
- // Existing session - ask user which mode they want
1933
- const choice = await ctx.ui.select("Start review in:", ["Empty branch", "Current session"]);
1934
-
1935
- if (choice === undefined) {
1936
- if (fromSelector) {
1937
- target = null;
1938
- continue;
1939
- }
1940
- ctx.ui.notify("Review cancelled", "info");
1941
- return;
1942
- }
1943
-
1944
- useFreshSession = choice === "Empty branch";
1945
- }
1946
-
1947
- await executeReview(ctx, target, useFreshSession, { extraInstruction });
1948
- return;
1949
- }
1950
- },
1951
- });
1952
-
1953
- const REVIEW_FIX_FINDINGS_PROMPT = `Use the latest review summary in this session and implement the review findings now.
1121
+ function persistReviewSettings() {
1122
+ pi.appendEntry(REVIEW_SETTINGS_TYPE, {
1123
+ loopFixingEnabled: reviewLoopFixingEnabled,
1124
+ customInstructions: reviewCustomInstructions,
1125
+ });
1126
+ }
1127
+
1128
+ function setReviewLoopFixingEnabled(enabled: boolean) {
1129
+ reviewLoopFixingEnabled = enabled;
1130
+ persistReviewSettings();
1131
+ }
1132
+
1133
+ function setReviewCustomInstructions(instructions: string | undefined) {
1134
+ reviewCustomInstructions = instructions?.trim() || undefined;
1135
+ persistReviewSettings();
1136
+ }
1137
+
1138
+ function applyAllReviewState(ctx: ExtensionContext) {
1139
+ applyReviewSettings(ctx);
1140
+ applyReviewState(ctx);
1141
+ }
1142
+
1143
+ pi.on("session_start", (_event, ctx) => {
1144
+ applyAllReviewState(ctx);
1145
+ });
1146
+
1147
+ pi.on("session_tree", (_event, ctx) => {
1148
+ applyAllReviewState(ctx);
1149
+ });
1150
+
1151
+ async function getSmartDefault(): Promise<"uncommitted" | "baseBranch" | "commit"> {
1152
+ if (await hasUncommittedChanges(pi)) {
1153
+ return "uncommitted";
1154
+ }
1155
+
1156
+ const currentBranch = await getCurrentBranch(pi);
1157
+ const defaultBranch = await getDefaultBranch(pi);
1158
+ if (currentBranch && currentBranch !== defaultBranch) {
1159
+ return "baseBranch";
1160
+ }
1161
+
1162
+ return "commit";
1163
+ }
1164
+
1165
+ async function showReviewSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1166
+ // Determine smart default (but keep the list order stable)
1167
+ const smartDefault = await getSmartDefault();
1168
+ const presetItems: SelectItem[] = REVIEW_PRESETS.map((preset) => ({
1169
+ value: preset.value,
1170
+ label: preset.label,
1171
+ description: preset.description,
1172
+ }));
1173
+ const smartDefaultIndex = presetItems.findIndex((item) => item.value === smartDefault);
1174
+
1175
+ while (true) {
1176
+ const customInstructionsLabel = reviewCustomInstructions
1177
+ ? "Remove custom review instructions"
1178
+ : "Add custom review instructions";
1179
+ const customInstructionsDescription = reviewCustomInstructions
1180
+ ? "(currently set)"
1181
+ : "(applies to all review modes)";
1182
+ const loopToggleLabel = reviewLoopFixingEnabled
1183
+ ? "Disable Loop Fixing"
1184
+ : "Enable Loop Fixing";
1185
+ const loopToggleDescription = reviewLoopFixingEnabled ? "(currently on)" : "(currently off)";
1186
+ const items: SelectItem[] = [
1187
+ ...presetItems,
1188
+ {
1189
+ value: TOGGLE_CUSTOM_INSTRUCTIONS_VALUE,
1190
+ label: customInstructionsLabel,
1191
+ description: customInstructionsDescription,
1192
+ },
1193
+ {
1194
+ value: TOGGLE_LOOP_FIXING_VALUE,
1195
+ label: loopToggleLabel,
1196
+ description: loopToggleDescription,
1197
+ },
1198
+ ];
1199
+
1200
+ const result = await ctx.ui.custom<ReviewPresetValue | null>((tui, theme, _kb, done) => {
1201
+ const container = new Container();
1202
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1203
+ container.addChild(new Text(theme.fg("accent", theme.bold("Select a review preset"))));
1204
+
1205
+ const selectList = new SelectList(items, Math.min(items.length, 10), {
1206
+ selectedPrefix: (text) => theme.fg("accent", text),
1207
+ selectedText: (text) => theme.fg("accent", text),
1208
+ description: (text) => theme.fg("muted", text),
1209
+ scrollInfo: (text) => theme.fg("dim", text),
1210
+ noMatch: (text) => theme.fg("warning", text),
1211
+ });
1212
+
1213
+ // Preselect the smart default without reordering the list
1214
+ if (smartDefaultIndex >= 0) {
1215
+ selectList.setSelectedIndex(smartDefaultIndex);
1216
+ }
1217
+
1218
+ selectList.onSelect = (item) => done(item.value as ReviewPresetValue);
1219
+ selectList.onCancel = () => done(null);
1220
+
1221
+ container.addChild(selectList);
1222
+ container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to go back")));
1223
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1224
+
1225
+ return {
1226
+ render(width: number) {
1227
+ return container.render(width);
1228
+ },
1229
+ invalidate() {
1230
+ container.invalidate();
1231
+ },
1232
+ handleInput(data: string) {
1233
+ selectList.handleInput(data);
1234
+ tui.requestRender();
1235
+ },
1236
+ };
1237
+ });
1238
+
1239
+ if (!result) return null;
1240
+
1241
+ if (result === TOGGLE_LOOP_FIXING_VALUE) {
1242
+ const nextEnabled = !reviewLoopFixingEnabled;
1243
+ setReviewLoopFixingEnabled(nextEnabled);
1244
+ ctx.ui.notify(nextEnabled ? "Loop fixing enabled" : "Loop fixing disabled", "info");
1245
+ continue;
1246
+ }
1247
+
1248
+ if (result === TOGGLE_CUSTOM_INSTRUCTIONS_VALUE) {
1249
+ if (reviewCustomInstructions) {
1250
+ setReviewCustomInstructions(undefined);
1251
+ ctx.ui.notify("Custom review instructions removed", "info");
1252
+ continue;
1253
+ }
1254
+
1255
+ const customInstructions = await ctx.ui.editor(
1256
+ "Enter custom review instructions (applies to all review modes):",
1257
+ "",
1258
+ );
1259
+
1260
+ if (!customInstructions?.trim()) {
1261
+ ctx.ui.notify("Custom review instructions not changed", "info");
1262
+ continue;
1263
+ }
1264
+
1265
+ setReviewCustomInstructions(customInstructions);
1266
+ ctx.ui.notify("Custom review instructions saved", "info");
1267
+ continue;
1268
+ }
1269
+
1270
+ switch (result) {
1271
+ case "uncommitted":
1272
+ return { type: "uncommitted" };
1273
+
1274
+ case "baseBranch": {
1275
+ const target = await showBranchSelector(ctx);
1276
+ if (target) return target;
1277
+ break;
1278
+ }
1279
+
1280
+ case "commit": {
1281
+ if (reviewLoopFixingEnabled) {
1282
+ ctx.ui.notify("Loop mode does not work with commit review.", "error");
1283
+ break;
1284
+ }
1285
+ const target = await showCommitSelector(ctx);
1286
+ if (target) return target;
1287
+ break;
1288
+ }
1289
+
1290
+ case "folder": {
1291
+ const target = await showFolderInput(ctx);
1292
+ if (target) return target;
1293
+ break;
1294
+ }
1295
+
1296
+ case "pullRequest": {
1297
+ const target = await showPrInput(ctx);
1298
+ if (target) return target;
1299
+ break;
1300
+ }
1301
+
1302
+ default:
1303
+ return null;
1304
+ }
1305
+ }
1306
+ }
1307
+
1308
+ async function showSearchableSelect<T>(
1309
+ ctx: ExtensionContext,
1310
+ options: {
1311
+ title: string;
1312
+ items: SelectItem[];
1313
+ emptyCopy: string;
1314
+ mapResult: (item: SelectItem) => T | null;
1315
+ },
1316
+ ): Promise<T | null> {
1317
+ const { title, items, emptyCopy, mapResult } = options;
1318
+ return ctx.ui.custom<T | null>((tui, theme, keybindings, done) => {
1319
+ const container = new Container();
1320
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1321
+ container.addChild(new Text(theme.fg("accent", theme.bold(title))));
1322
+
1323
+ const searchInput = new Input();
1324
+ container.addChild(searchInput);
1325
+ container.addChild(new Spacer(1));
1326
+
1327
+ const listContainer = new Container();
1328
+ container.addChild(listContainer);
1329
+ container.addChild(
1330
+ new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")),
1331
+ );
1332
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1333
+
1334
+ let filteredItems = items;
1335
+ let selectList: SelectList | null = null;
1336
+
1337
+ const updateList = () => {
1338
+ listContainer.clear();
1339
+ if (filteredItems.length === 0) {
1340
+ listContainer.addChild(new Text(theme.fg("warning", emptyCopy)));
1341
+ selectList = null;
1342
+ return;
1343
+ }
1344
+
1345
+ selectList = new SelectList(filteredItems, Math.min(filteredItems.length, 10), {
1346
+ selectedPrefix: (text) => theme.fg("accent", text),
1347
+ selectedText: (text) => theme.fg("accent", text),
1348
+ description: (text) => theme.fg("muted", text),
1349
+ scrollInfo: (text) => theme.fg("dim", text),
1350
+ noMatch: (text) => theme.fg("warning", text),
1351
+ });
1352
+
1353
+ selectList.onSelect = (item) => done(mapResult(item));
1354
+ selectList.onCancel = () => done(null);
1355
+ listContainer.addChild(selectList);
1356
+ };
1357
+
1358
+ const applyFilter = () => {
1359
+ const query = searchInput.getValue();
1360
+ filteredItems = query
1361
+ ? fuzzyFilter(
1362
+ items,
1363
+ query,
1364
+ (item) => `${item.label} ${item.value} ${item.description ?? ""}`,
1365
+ )
1366
+ : items;
1367
+ updateList();
1368
+ };
1369
+
1370
+ applyFilter();
1371
+
1372
+ return {
1373
+ render(width: number) {
1374
+ return container.render(width);
1375
+ },
1376
+ invalidate() {
1377
+ container.invalidate();
1378
+ },
1379
+ handleInput(data: string) {
1380
+ if (
1381
+ keybindings.matches(data, "tui.select.up") ||
1382
+ keybindings.matches(data, "tui.select.down") ||
1383
+ keybindings.matches(data, "tui.select.confirm") ||
1384
+ keybindings.matches(data, "tui.select.cancel")
1385
+ ) {
1386
+ if (selectList) {
1387
+ selectList.handleInput(data);
1388
+ } else if (keybindings.matches(data, "tui.select.cancel")) {
1389
+ done(null);
1390
+ }
1391
+ tui.requestRender();
1392
+ return;
1393
+ }
1394
+
1395
+ searchInput.handleInput(data);
1396
+ applyFilter();
1397
+ tui.requestRender();
1398
+ },
1399
+ };
1400
+ });
1401
+ }
1402
+
1403
+ async function showBranchSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1404
+ const branches = await getLocalBranches(pi);
1405
+ const currentBranch = await getCurrentBranch(pi);
1406
+ const defaultBranch = await getDefaultBranch(pi);
1407
+
1408
+ // Never offer the current branch as a base branch (reviewing against itself is meaningless).
1409
+ const candidateBranches = currentBranch
1410
+ ? branches.filter((b) => b !== currentBranch)
1411
+ : branches;
1412
+
1413
+ if (candidateBranches.length === 0) {
1414
+ ctx.ui.notify(
1415
+ currentBranch
1416
+ ? `No other branches found (current branch: ${currentBranch})`
1417
+ : "No branches found",
1418
+ "error",
1419
+ );
1420
+ return null;
1421
+ }
1422
+
1423
+ // Sort branches with default branch first
1424
+ const sortedBranches = candidateBranches.sort((a, b) => {
1425
+ if (a === defaultBranch) return -1;
1426
+ if (b === defaultBranch) return 1;
1427
+ return a.localeCompare(b);
1428
+ });
1429
+
1430
+ const items: SelectItem[] = sortedBranches.map((branch) => ({
1431
+ value: branch,
1432
+ label: branch,
1433
+ description: branch === defaultBranch ? "(default)" : "",
1434
+ }));
1435
+
1436
+ const branch = await showSearchableSelect(ctx, {
1437
+ title: "Select base branch",
1438
+ items,
1439
+ emptyCopy: " No matching branches",
1440
+ mapResult: (item) => item.value,
1441
+ });
1442
+ if (!branch) return null;
1443
+ return { type: "baseBranch", branch };
1444
+ }
1445
+
1446
+ async function showCommitSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1447
+ const commits = await getRecentCommits(pi, 20);
1448
+
1449
+ if (commits.length === 0) {
1450
+ ctx.ui.notify("No commits found", "error");
1451
+ return null;
1452
+ }
1453
+
1454
+ const items: SelectItem[] = commits.map((commit) => ({
1455
+ value: commit.sha,
1456
+ label: `${commit.sha.slice(0, 7)} ${commit.title}`,
1457
+ description: "",
1458
+ }));
1459
+
1460
+ const commit = await showSearchableSelect(ctx, {
1461
+ title: "Select commit to review",
1462
+ items,
1463
+ emptyCopy: " No matching commits",
1464
+ mapResult: (item) => commits.find((candidate) => candidate.sha === item.value) ?? null,
1465
+ });
1466
+ if (!commit) return null;
1467
+ return { type: "commit", sha: commit.sha, title: commit.title };
1468
+ }
1469
+
1470
+ async function showFolderInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1471
+ const result = await ctx.ui.editor(
1472
+ "Enter folders/files to review (space-separated or one per line):",
1473
+ ".",
1474
+ );
1475
+
1476
+ if (!result?.trim()) return null;
1477
+ const paths = parseReviewPaths(result);
1478
+ if (paths.length === 0) return null;
1479
+
1480
+ return { type: "folder", paths };
1481
+ }
1482
+
1483
+ async function showPrInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1484
+ const prRef = await ctx.ui.editor(
1485
+ "Enter PR number or URL (e.g. 123 or https://github.com/owner/repo/pull/123):",
1486
+ "",
1487
+ );
1488
+
1489
+ if (!prRef?.trim()) return null;
1490
+ return checkoutPullRequest(pi, ctx, prRef);
1491
+ }
1492
+
1493
+ async function executeReview(
1494
+ ctx: ExtensionCommandContext,
1495
+ target: ReviewTarget,
1496
+ useFreshSession: boolean,
1497
+ options?: { includeLocalChanges?: boolean; extraInstruction?: string },
1498
+ ): Promise<boolean> {
1499
+ if (reviewOriginId) {
1500
+ ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
1501
+ return false;
1502
+ }
1503
+
1504
+ if (useFreshSession) {
1505
+ // In an empty session there is no leaf yet, so create a lightweight anchor first.
1506
+ let originId = ctx.sessionManager.getLeafId() ?? undefined;
1507
+ if (!originId) {
1508
+ pi.appendEntry(REVIEW_ANCHOR_TYPE, { createdAt: new Date().toISOString() });
1509
+ originId = ctx.sessionManager.getLeafId() ?? undefined;
1510
+ }
1511
+ if (!originId) {
1512
+ ctx.ui.notify("Failed to determine review origin.", "error");
1513
+ return false;
1514
+ }
1515
+ reviewOriginId = originId;
1516
+
1517
+ // Keep a local copy so session_tree events during navigation don't wipe it
1518
+ const lockedOriginId = originId;
1519
+
1520
+ // No user message (brand-new session): stay on the current leaf.
1521
+ const entries = ctx.sessionManager.getEntries();
1522
+ const firstUserMessage = entries.find(
1523
+ (e) => e.type === "message" && e.message.role === "user",
1524
+ );
1525
+
1526
+ if (firstUserMessage) {
1527
+ // Label the new branch so it stays visible in the session tree.
1528
+ try {
1529
+ const result = await ctx.navigateTree(firstUserMessage.id, {
1530
+ summarize: false,
1531
+ label: "code-review",
1532
+ });
1533
+ if (result.cancelled) {
1534
+ reviewOriginId = undefined;
1535
+ return false;
1536
+ }
1537
+ } catch (error) {
1538
+ reviewOriginId = undefined;
1539
+ ctx.ui.notify(
1540
+ `Failed to start review: ${error instanceof Error ? error.message : String(error)}`,
1541
+ "error",
1542
+ );
1543
+ return false;
1544
+ }
1545
+
1546
+ // Clear the editor (navigating to user message fills it with the message text)
1547
+ ctx.ui.setEditorText("");
1548
+ }
1549
+
1550
+ // Restore origin after navigation events (session_tree can reset it)
1551
+ reviewOriginId = lockedOriginId;
1552
+
1553
+ setReviewWidget(ctx, true);
1554
+
1555
+ // Persist review state so tree navigation can restore/reset it
1556
+ pi.appendEntry(REVIEW_STATE_TYPE, { active: true, originId: lockedOriginId });
1557
+ }
1558
+
1559
+ const prompt = await buildReviewPrompt(pi, target, {
1560
+ includeLocalChanges: options?.includeLocalChanges === true,
1561
+ });
1562
+ const hint = getUserFacingHint(target);
1563
+ const projectGuidelines = canReadProjectConfig(ctx)
1564
+ ? await loadProjectReviewGuidelines(ctx.cwd)
1565
+ : null;
1566
+
1567
+ const fullPrompt = composeReviewPrompt(prompt, {
1568
+ customInstructions: reviewCustomInstructions,
1569
+ extraInstruction: options?.extraInstruction,
1570
+ projectGuidelines,
1571
+ });
1572
+
1573
+ const modeHint = useFreshSession ? " (fresh session)" : "";
1574
+ ctx.ui.notify(`Starting review: ${hint}${modeHint}`, "info");
1575
+
1576
+ pi.sendUserMessage(fullPrompt);
1577
+ return true;
1578
+ }
1579
+
1580
+ async function runLoopFixingReview(
1581
+ ctx: ExtensionCommandContext,
1582
+ target: ReviewTarget,
1583
+ extraInstruction?: string,
1584
+ ): Promise<void> {
1585
+ if (reviewLoopInProgress) {
1586
+ ctx.ui.notify("Loop fixing review is already running.", "warning");
1587
+ return;
1588
+ }
1589
+
1590
+ reviewLoopInProgress = true;
1591
+ setReviewWidget(ctx, Boolean(reviewOriginId));
1592
+ try {
1593
+ ctx.ui.notify(
1594
+ "Loop fixing enabled: using Empty branch mode and cycling until no blocking findings remain.",
1595
+ "info",
1596
+ );
1597
+
1598
+ for (let pass = 1; pass <= REVIEW_LOOP_MAX_ITERATIONS; pass++) {
1599
+ const reviewBaselineAssistantId = getLastAssistantSnapshot(ctx)?.id;
1600
+ const started = await executeReview(ctx, target, true, {
1601
+ includeLocalChanges: true,
1602
+ extraInstruction,
1603
+ });
1604
+ if (!started) {
1605
+ ctx.ui.notify("Loop fixing stopped before starting the review pass.", "warning");
1606
+ return;
1607
+ }
1608
+
1609
+ const reviewTurnStarted = await waitForLoopTurnToStart(ctx, reviewBaselineAssistantId);
1610
+ if (!reviewTurnStarted) {
1611
+ ctx.ui.notify("Loop fixing stopped: review pass did not start in time.", "error");
1612
+ return;
1613
+ }
1614
+
1615
+ await ctx.waitForIdle();
1616
+
1617
+ const reviewSnapshot = getLastAssistantSnapshot(ctx);
1618
+ if (!reviewSnapshot || reviewSnapshot.id === reviewBaselineAssistantId) {
1619
+ ctx.ui.notify("Loop fixing stopped: could not read the review result.", "warning");
1620
+ return;
1621
+ }
1622
+
1623
+ if (reviewSnapshot.stopReason === "aborted") {
1624
+ ctx.ui.notify("Loop fixing stopped: review was aborted.", "warning");
1625
+ return;
1626
+ }
1627
+
1628
+ if (reviewSnapshot.stopReason === "error") {
1629
+ ctx.ui.notify("Loop fixing stopped: review failed with an error.", "error");
1630
+ return;
1631
+ }
1632
+
1633
+ if (reviewSnapshot.stopReason === "length") {
1634
+ ctx.ui.notify(
1635
+ "Loop fixing stopped: review output was truncated (stopReason=length).",
1636
+ "warning",
1637
+ );
1638
+ return;
1639
+ }
1640
+
1641
+ if (!hasBlockingReviewFindings(reviewSnapshot.text)) {
1642
+ const finalized = await executeEndReviewAction(ctx, "returnAndSummarize", {
1643
+ showSummaryLoader: true,
1644
+ notifySuccess: false,
1645
+ });
1646
+ if (finalized !== "ok") {
1647
+ return;
1648
+ }
1649
+
1650
+ ctx.ui.notify("Loop fixing complete: no blocking findings remain.", "info");
1651
+ return;
1652
+ }
1653
+
1654
+ ctx.ui.notify(
1655
+ `Loop fixing pass ${pass}: found blocking findings, returning to fix them...`,
1656
+ "info",
1657
+ );
1658
+
1659
+ const fixBaselineAssistantId = getLastAssistantSnapshot(ctx)?.id;
1660
+ const sentFixPrompt = await executeEndReviewAction(ctx, "returnAndFix", {
1661
+ showSummaryLoader: true,
1662
+ notifySuccess: false,
1663
+ });
1664
+ if (sentFixPrompt !== "ok") {
1665
+ return;
1666
+ }
1667
+
1668
+ const fixTurnStarted = await waitForLoopTurnToStart(ctx, fixBaselineAssistantId);
1669
+ if (!fixTurnStarted) {
1670
+ ctx.ui.notify("Loop fixing stopped: fix pass did not start in time.", "error");
1671
+ return;
1672
+ }
1673
+
1674
+ await ctx.waitForIdle();
1675
+
1676
+ const fixSnapshot = getLastAssistantSnapshot(ctx);
1677
+ if (!fixSnapshot || fixSnapshot.id === fixBaselineAssistantId) {
1678
+ ctx.ui.notify("Loop fixing stopped: could not read the fix pass result.", "warning");
1679
+ return;
1680
+ }
1681
+ if (fixSnapshot.stopReason === "aborted") {
1682
+ ctx.ui.notify("Loop fixing stopped: fix pass was aborted.", "warning");
1683
+ return;
1684
+ }
1685
+ if (fixSnapshot.stopReason === "error") {
1686
+ ctx.ui.notify("Loop fixing stopped: fix pass failed with an error.", "error");
1687
+ return;
1688
+ }
1689
+ if (fixSnapshot.stopReason === "length") {
1690
+ ctx.ui.notify(
1691
+ "Loop fixing stopped: fix pass output was truncated (stopReason=length).",
1692
+ "warning",
1693
+ );
1694
+ return;
1695
+ }
1696
+ }
1697
+
1698
+ ctx.ui.notify(
1699
+ `Loop fixing stopped after ${REVIEW_LOOP_MAX_ITERATIONS} passes (safety limit reached).`,
1700
+ "warning",
1701
+ );
1702
+ } finally {
1703
+ reviewLoopInProgress = false;
1704
+ setReviewWidget(ctx, Boolean(reviewOriginId));
1705
+ }
1706
+ }
1707
+
1708
+ pi.registerCommand("review", {
1709
+ description: "Review code changes (PR, uncommitted, branch, commit, or folder)",
1710
+ handler: async (args, ctx) => {
1711
+ if (ctx.mode !== "tui") {
1712
+ ctx.ui.notify("Review requires interactive mode", "error");
1713
+ return;
1714
+ }
1715
+
1716
+ if (reviewLoopInProgress) {
1717
+ ctx.ui.notify("Loop fixing review is already running.", "warning");
1718
+ return;
1719
+ }
1720
+
1721
+ if (reviewOriginId) {
1722
+ ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
1723
+ return;
1724
+ }
1725
+
1726
+ const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
1727
+ if (code !== 0) {
1728
+ ctx.ui.notify("Not a git repository", "error");
1729
+ return;
1730
+ }
1731
+
1732
+ let target: ReviewTarget | null = null;
1733
+ let fromSelector = false;
1734
+ let extraInstruction: string | undefined;
1735
+ const parsed = parseArgs(args);
1736
+ if (parsed.error) {
1737
+ ctx.ui.notify(parsed.error, "error");
1738
+ return;
1739
+ }
1740
+ extraInstruction = parsed.extraInstruction?.trim() || undefined;
1741
+
1742
+ if (parsed.target) {
1743
+ if (parsed.target.type === "pr") {
1744
+ target = await checkoutPullRequest(pi, ctx, parsed.target.ref);
1745
+ if (!target) {
1746
+ ctx.ui.notify("PR review failed. Returning to review menu.", "warning");
1747
+ }
1748
+ } else {
1749
+ target = parsed.target;
1750
+ }
1751
+ }
1752
+
1753
+ if (!target) {
1754
+ fromSelector = true;
1755
+ }
1756
+
1757
+ while (true) {
1758
+ if (!target && fromSelector) {
1759
+ target = await showReviewSelector(ctx);
1760
+ }
1761
+
1762
+ if (!target) {
1763
+ ctx.ui.notify("Review cancelled", "info");
1764
+ return;
1765
+ }
1766
+
1767
+ if (reviewLoopFixingEnabled && target.type === "commit") {
1768
+ ctx.ui.notify("Loop mode does not work with commit review.", "error");
1769
+ if (fromSelector) {
1770
+ target = null;
1771
+ continue;
1772
+ }
1773
+ return;
1774
+ }
1775
+
1776
+ if (reviewLoopFixingEnabled) {
1777
+ await runLoopFixingReview(ctx, target, extraInstruction);
1778
+ return;
1779
+ }
1780
+
1781
+ const entries = ctx.sessionManager.getEntries();
1782
+ const messageCount = entries.filter((e) => e.type === "message").length;
1783
+
1784
+ // In an empty session, default to fresh review mode so /end-review works consistently.
1785
+ let useFreshSession = messageCount === 0;
1786
+
1787
+ if (messageCount > 0) {
1788
+ const choice = await ctx.ui.select("Start review in:", [
1789
+ "Empty branch",
1790
+ "Current session",
1791
+ ]);
1792
+
1793
+ if (choice === undefined) {
1794
+ if (fromSelector) {
1795
+ target = null;
1796
+ continue;
1797
+ }
1798
+ ctx.ui.notify("Review cancelled", "info");
1799
+ return;
1800
+ }
1801
+
1802
+ useFreshSession = choice === "Empty branch";
1803
+ }
1804
+
1805
+ await executeReview(ctx, target, useFreshSession, { extraInstruction });
1806
+ return;
1807
+ }
1808
+ },
1809
+ });
1810
+
1811
+ const REVIEW_FIX_FINDINGS_PROMPT = `Use the latest review summary in this session and implement the review findings now.
1954
1812
 
1955
1813
  Instructions:
1956
1814
  1. Treat the summary's Findings/Fix Queue as a checklist.
@@ -1963,232 +1821,247 @@ Instructions:
1963
1821
  8. Run relevant tests/checks for touched code where practical.
1964
1822
  9. End with: fixed items, deferred/skipped items (with reasons), and verification results.`;
1965
1823
 
1966
- type EndReviewAction = "returnOnly" | "returnAndFix" | "returnAndSummarize";
1967
- type EndReviewActionResult = "ok" | "cancelled" | "error";
1968
- type EndReviewActionOptions = {
1969
- showSummaryLoader?: boolean;
1970
- notifySuccess?: boolean;
1971
- };
1972
-
1973
- function getActiveReviewOrigin(ctx: ExtensionContext): string | undefined {
1974
- if (reviewOriginId) {
1975
- return reviewOriginId;
1976
- }
1977
-
1978
- const state = getReviewState(ctx);
1979
- if (state?.active && state.originId) {
1980
- reviewOriginId = state.originId;
1981
- return reviewOriginId;
1982
- }
1983
-
1984
- if (state?.active) {
1985
- setReviewWidget(ctx, false);
1986
- pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
1987
- ctx.ui.notify("Review state was missing origin info; cleared review status.", "warning");
1988
- }
1989
-
1990
- return undefined;
1991
- }
1992
-
1993
- function clearReviewState(ctx: ExtensionContext) {
1994
- setReviewWidget(ctx, false);
1995
- reviewOriginId = undefined;
1996
- pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
1997
- }
1998
-
1999
- type SummaryNavigationResult =
2000
- | { status: "success" }
2001
- | { status: "cancelled" }
2002
- | { status: "late-success" }
2003
- | { status: "error"; error: string };
2004
-
2005
- async function navigateWithSummary(
2006
- ctx: ExtensionCommandContext,
2007
- originId: string,
2008
- showLoader: boolean,
2009
- ): Promise<SummaryNavigationResult> {
2010
- if (showLoader && ctx.mode === "tui") {
2011
- return ctx.ui.custom<SummaryNavigationResult>((tui, theme, _kb, done) => {
2012
- const loader = new BorderedLoader(tui, theme, "Returning and summarizing review branch...");
2013
- let finished = false;
2014
- let abortRequested = false;
2015
-
2016
- const finish = (result: SummaryNavigationResult) => {
2017
- if (finished) return;
2018
- finished = true;
2019
- done(result);
2020
- };
2021
-
2022
- const navigationPromise = ctx
2023
- .navigateTree(originId, {
2024
- summarize: true,
2025
- customInstructions: REVIEW_SUMMARY_PROMPT,
2026
- replaceInstructions: true,
2027
- })
2028
- .then((result) => {
2029
- finish(
2030
- result.cancelled
2031
- ? { status: "cancelled" }
2032
- : abortRequested
2033
- ? { status: "late-success" }
2034
- : { status: "success" },
2035
- );
2036
- })
2037
- .catch((err) => {
2038
- finish({ status: "error", error: err instanceof Error ? err.message : String(err) });
2039
- });
2040
-
2041
- loader.onAbort = () => {
2042
- if (abortRequested) return;
2043
- abortRequested = true;
2044
- void Promise.resolve()
2045
- .then(() => ctx.abort())
2046
- .catch(() => undefined)
2047
- .then(() => navigationPromise)
2048
- .catch(() => undefined);
2049
- };
2050
-
2051
- return loader;
2052
- });
2053
- }
2054
-
2055
- try {
2056
- const result = await ctx.navigateTree(originId, {
2057
- summarize: true,
2058
- customInstructions: REVIEW_SUMMARY_PROMPT,
2059
- replaceInstructions: true,
2060
- });
2061
- return result.cancelled ? { status: "cancelled" } : { status: "success" };
2062
- } catch (error) {
2063
- return { status: "error", error: error instanceof Error ? error.message : String(error) };
2064
- }
2065
- }
2066
-
2067
- async function executeEndReviewAction(
2068
- ctx: ExtensionCommandContext,
2069
- action: EndReviewAction,
2070
- options: EndReviewActionOptions = {},
2071
- ): Promise<EndReviewActionResult> {
2072
- const originId = getActiveReviewOrigin(ctx);
2073
- if (!originId) {
2074
- if (!getReviewState(ctx)?.active) {
2075
- ctx.ui.notify("Not in a review branch (use /review first, or review was started in current session mode)", "info");
2076
- }
2077
- return "error";
2078
- }
2079
-
2080
- const notifySuccess = options.notifySuccess ?? true;
2081
-
2082
- if (action === "returnOnly") {
2083
- try {
2084
- const result = await ctx.navigateTree(originId, { summarize: false });
2085
- if (result.cancelled) {
2086
- ctx.ui.notify("Navigation cancelled. Use /end-review to try again.", "info");
2087
- return "cancelled";
2088
- }
2089
- } catch (error) {
2090
- ctx.ui.notify(`Failed to return: ${error instanceof Error ? error.message : String(error)}`, "error");
2091
- return "error";
2092
- }
2093
-
2094
- clearReviewState(ctx);
2095
- if (notifySuccess) {
2096
- ctx.ui.notify("Review complete! Returned to original position.", "info");
2097
- }
2098
- return "ok";
2099
- }
2100
-
2101
- const summaryResult = await navigateWithSummary(ctx, originId, options.showSummaryLoader ?? false);
2102
- if (summaryResult.status === "error") {
2103
- ctx.ui.notify(`Summarization failed: ${summaryResult.error}`, "error");
2104
- return "error";
2105
- }
2106
-
2107
- if (summaryResult.status === "cancelled") {
2108
- ctx.ui.notify("Navigation cancelled. Use /end-review to try again.", "info");
2109
- return "cancelled";
2110
- }
2111
-
2112
- clearReviewState(ctx);
2113
- if (summaryResult.status === "late-success") {
2114
- ctx.ui.notify("Cancellation arrived too late; review already completed, and no follow-up was queued.", "warning");
2115
- return "ok";
2116
- }
2117
-
2118
- if (action === "returnAndSummarize") {
2119
- if (!ctx.ui.getEditorText().trim()) {
2120
- ctx.ui.setEditorText("Act on the review findings");
2121
- }
2122
- if (notifySuccess) {
2123
- ctx.ui.notify("Review complete! Returned and summarized.", "info");
2124
- }
2125
- return "ok";
2126
- }
2127
-
2128
- pi.sendUserMessage(REVIEW_FIX_FINDINGS_PROMPT, { deliverAs: "followUp" });
2129
- if (notifySuccess) {
2130
- ctx.ui.notify("Review complete! Returned and queued a follow-up to fix findings.", "info");
2131
- }
2132
- return "ok";
2133
- }
2134
-
2135
- async function runEndReview(ctx: ExtensionCommandContext): Promise<void> {
2136
- if (!ctx.hasUI) {
2137
- ctx.ui.notify("End-review requires interactive mode", "error");
2138
- return;
2139
- }
2140
-
2141
- if (reviewLoopInProgress) {
2142
- ctx.ui.notify("Loop fixing review is running. Wait for it to finish.", "info");
2143
- return;
2144
- }
2145
-
2146
- if (!getActiveReviewOrigin(ctx)) {
2147
- if (!getReviewState(ctx)?.active) {
2148
- ctx.ui.notify("Not in a review branch (use /review first, or review was started in current session mode)", "info");
2149
- }
2150
- return;
2151
- }
2152
-
2153
- if (endReviewInProgress) {
2154
- ctx.ui.notify("/end-review is already running", "info");
2155
- return;
2156
- }
2157
-
2158
- endReviewInProgress = true;
2159
- try {
2160
- const choice = await ctx.ui.select("Finish review:", [
2161
- "Return only",
2162
- "Return and fix findings",
2163
- "Return and summarize",
2164
- ]);
2165
-
2166
- if (choice === undefined) {
2167
- ctx.ui.notify("Cancelled. Use /end-review to try again.", "info");
2168
- return;
2169
- }
2170
-
2171
- const action: EndReviewAction =
2172
- choice === "Return and fix findings"
2173
- ? "returnAndFix"
2174
- : choice === "Return and summarize"
2175
- ? "returnAndSummarize"
2176
- : "returnOnly";
2177
-
2178
- await executeEndReviewAction(ctx, action, {
2179
- showSummaryLoader: true,
2180
- notifySuccess: true,
2181
- });
2182
- } finally {
2183
- endReviewInProgress = false;
2184
- }
2185
- }
2186
-
2187
- // Register the /end-review command
2188
- pi.registerCommand("end-review", {
2189
- description: "Complete review and return to original position",
2190
- handler: async (_args, ctx) => {
2191
- await runEndReview(ctx);
2192
- },
2193
- });
1824
+ type EndReviewAction = "returnOnly" | "returnAndFix" | "returnAndSummarize";
1825
+ type EndReviewActionResult = "ok" | "cancelled" | "error";
1826
+ type EndReviewActionOptions = {
1827
+ showSummaryLoader?: boolean;
1828
+ notifySuccess?: boolean;
1829
+ };
1830
+
1831
+ function getActiveReviewOrigin(ctx: ExtensionContext): string | undefined {
1832
+ if (reviewOriginId) {
1833
+ return reviewOriginId;
1834
+ }
1835
+
1836
+ const state = getReviewState(ctx);
1837
+ if (state?.active && state.originId) {
1838
+ reviewOriginId = state.originId;
1839
+ return reviewOriginId;
1840
+ }
1841
+
1842
+ if (state?.active) {
1843
+ setReviewWidget(ctx, false);
1844
+ pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
1845
+ ctx.ui.notify("Review state was missing origin info; cleared review status.", "warning");
1846
+ }
1847
+
1848
+ return undefined;
1849
+ }
1850
+
1851
+ function clearReviewState(ctx: ExtensionContext) {
1852
+ setReviewWidget(ctx, false);
1853
+ reviewOriginId = undefined;
1854
+ pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
1855
+ }
1856
+
1857
+ type SummaryNavigationResult =
1858
+ | { status: "success" }
1859
+ | { status: "cancelled" }
1860
+ | { status: "late-success" }
1861
+ | { status: "error"; error: string };
1862
+
1863
+ async function navigateWithSummary(
1864
+ ctx: ExtensionCommandContext,
1865
+ originId: string,
1866
+ showLoader: boolean,
1867
+ ): Promise<SummaryNavigationResult> {
1868
+ if (showLoader && ctx.mode === "tui") {
1869
+ return ctx.ui.custom<SummaryNavigationResult>((tui, theme, _kb, done) => {
1870
+ const loader = new BorderedLoader(tui, theme, "Returning and summarizing review branch...");
1871
+ let finished = false;
1872
+ let abortRequested = false;
1873
+
1874
+ const finish = (result: SummaryNavigationResult) => {
1875
+ if (finished) return;
1876
+ finished = true;
1877
+ done(result);
1878
+ };
1879
+
1880
+ const navigationPromise = ctx
1881
+ .navigateTree(originId, {
1882
+ summarize: true,
1883
+ customInstructions: REVIEW_SUMMARY_PROMPT,
1884
+ replaceInstructions: true,
1885
+ })
1886
+ .then((result) => {
1887
+ finish(
1888
+ result.cancelled
1889
+ ? { status: "cancelled" }
1890
+ : abortRequested
1891
+ ? { status: "late-success" }
1892
+ : { status: "success" },
1893
+ );
1894
+ })
1895
+ .catch((err) => {
1896
+ finish({ status: "error", error: err instanceof Error ? err.message : String(err) });
1897
+ });
1898
+
1899
+ loader.onAbort = () => {
1900
+ if (abortRequested) return;
1901
+ abortRequested = true;
1902
+ void Promise.resolve()
1903
+ .then(() => ctx.abort())
1904
+ .catch(() => undefined)
1905
+ .then(() => navigationPromise)
1906
+ .catch(() => undefined);
1907
+ };
1908
+
1909
+ return loader;
1910
+ });
1911
+ }
1912
+
1913
+ try {
1914
+ const result = await ctx.navigateTree(originId, {
1915
+ summarize: true,
1916
+ customInstructions: REVIEW_SUMMARY_PROMPT,
1917
+ replaceInstructions: true,
1918
+ });
1919
+ return result.cancelled ? { status: "cancelled" } : { status: "success" };
1920
+ } catch (error) {
1921
+ return { status: "error", error: error instanceof Error ? error.message : String(error) };
1922
+ }
1923
+ }
1924
+
1925
+ async function executeEndReviewAction(
1926
+ ctx: ExtensionCommandContext,
1927
+ action: EndReviewAction,
1928
+ options: EndReviewActionOptions = {},
1929
+ ): Promise<EndReviewActionResult> {
1930
+ const originId = getActiveReviewOrigin(ctx);
1931
+ if (!originId) {
1932
+ if (!getReviewState(ctx)?.active) {
1933
+ ctx.ui.notify(
1934
+ "Not in a review branch (use /review first, or review was started in current session mode)",
1935
+ "info",
1936
+ );
1937
+ }
1938
+ return "error";
1939
+ }
1940
+
1941
+ const notifySuccess = options.notifySuccess ?? true;
1942
+
1943
+ if (action === "returnOnly") {
1944
+ try {
1945
+ const result = await ctx.navigateTree(originId, { summarize: false });
1946
+ if (result.cancelled) {
1947
+ ctx.ui.notify("Navigation cancelled. Use /end-review to try again.", "info");
1948
+ return "cancelled";
1949
+ }
1950
+ } catch (error) {
1951
+ ctx.ui.notify(
1952
+ `Failed to return: ${error instanceof Error ? error.message : String(error)}`,
1953
+ "error",
1954
+ );
1955
+ return "error";
1956
+ }
1957
+
1958
+ clearReviewState(ctx);
1959
+ if (notifySuccess) {
1960
+ ctx.ui.notify("Review complete! Returned to original position.", "info");
1961
+ }
1962
+ return "ok";
1963
+ }
1964
+
1965
+ const summaryResult = await navigateWithSummary(
1966
+ ctx,
1967
+ originId,
1968
+ options.showSummaryLoader ?? false,
1969
+ );
1970
+ if (summaryResult.status === "error") {
1971
+ ctx.ui.notify(`Summarization failed: ${summaryResult.error}`, "error");
1972
+ return "error";
1973
+ }
1974
+
1975
+ if (summaryResult.status === "cancelled") {
1976
+ ctx.ui.notify("Navigation cancelled. Use /end-review to try again.", "info");
1977
+ return "cancelled";
1978
+ }
1979
+
1980
+ clearReviewState(ctx);
1981
+ if (summaryResult.status === "late-success") {
1982
+ ctx.ui.notify(
1983
+ "Cancellation arrived too late; review already completed, and no follow-up was queued.",
1984
+ "warning",
1985
+ );
1986
+ return "ok";
1987
+ }
1988
+
1989
+ if (action === "returnAndSummarize") {
1990
+ if (!ctx.ui.getEditorText().trim()) {
1991
+ ctx.ui.setEditorText("Act on the review findings");
1992
+ }
1993
+ if (notifySuccess) {
1994
+ ctx.ui.notify("Review complete! Returned and summarized.", "info");
1995
+ }
1996
+ return "ok";
1997
+ }
1998
+
1999
+ pi.sendUserMessage(REVIEW_FIX_FINDINGS_PROMPT, { deliverAs: "followUp" });
2000
+ if (notifySuccess) {
2001
+ ctx.ui.notify("Review complete! Returned and queued a follow-up to fix findings.", "info");
2002
+ }
2003
+ return "ok";
2004
+ }
2005
+
2006
+ async function runEndReview(ctx: ExtensionCommandContext): Promise<void> {
2007
+ if (!ctx.hasUI) {
2008
+ ctx.ui.notify("End-review requires interactive mode", "error");
2009
+ return;
2010
+ }
2011
+
2012
+ if (reviewLoopInProgress) {
2013
+ ctx.ui.notify("Loop fixing review is running. Wait for it to finish.", "info");
2014
+ return;
2015
+ }
2016
+
2017
+ if (!getActiveReviewOrigin(ctx)) {
2018
+ if (!getReviewState(ctx)?.active) {
2019
+ ctx.ui.notify(
2020
+ "Not in a review branch (use /review first, or review was started in current session mode)",
2021
+ "info",
2022
+ );
2023
+ }
2024
+ return;
2025
+ }
2026
+
2027
+ if (endReviewInProgress) {
2028
+ ctx.ui.notify("/end-review is already running", "info");
2029
+ return;
2030
+ }
2031
+
2032
+ endReviewInProgress = true;
2033
+ try {
2034
+ const choice = await ctx.ui.select("Finish review:", [
2035
+ "Return only",
2036
+ "Return and fix findings",
2037
+ "Return and summarize",
2038
+ ]);
2039
+
2040
+ if (choice === undefined) {
2041
+ ctx.ui.notify("Cancelled. Use /end-review to try again.", "info");
2042
+ return;
2043
+ }
2044
+
2045
+ const action: EndReviewAction =
2046
+ choice === "Return and fix findings"
2047
+ ? "returnAndFix"
2048
+ : choice === "Return and summarize"
2049
+ ? "returnAndSummarize"
2050
+ : "returnOnly";
2051
+
2052
+ await executeEndReviewAction(ctx, action, {
2053
+ showSummaryLoader: true,
2054
+ notifySuccess: true,
2055
+ });
2056
+ } finally {
2057
+ endReviewInProgress = false;
2058
+ }
2059
+ }
2060
+
2061
+ pi.registerCommand("end-review", {
2062
+ description: "Complete review and return to original position",
2063
+ handler: async (_args, ctx) => {
2064
+ await runEndReview(ctx);
2065
+ },
2066
+ });
2194
2067
  }