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