pi-pr-review 1.1.2
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 +192 -0
- package/extensions/pr-review-subagent.ts +971 -0
- package/extensions/review-table.ts +280 -0
- package/package.json +31 -0
- package/prompts/pr-review.md +215 -0
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pr-review-subagent
|
|
3
|
+
*
|
|
4
|
+
* Adds configurable, tiered review subagents to the /pr-review workflow.
|
|
5
|
+
*
|
|
6
|
+
* - Config surface: `pr-review.json` (user: ~/.pi/agent, project: <repo>/.pi) maps
|
|
7
|
+
* the labels `light` / `medium` / `heavy` to whatever models you choose.
|
|
8
|
+
* No model names are hardcoded here — you configure them.
|
|
9
|
+
* - Tool: `review_subagent` spawns one isolated `pi` subprocess on the model
|
|
10
|
+
* bound to the requested tier and returns its review report.
|
|
11
|
+
* - Tool: `review_subagents` accepts multiple pass assignments with shared PR
|
|
12
|
+
* context and runs those isolated subprocesses concurrently with bounded
|
|
13
|
+
* parallelism, returning deterministic per-pass results.
|
|
14
|
+
* - Command: `/pr-review-config` shows or edits the tier→model mapping.
|
|
15
|
+
*
|
|
16
|
+
* The orchestrating /pr-review prompt dispatches passes by tier label:
|
|
17
|
+
* light -> overview / strengths / high-level risk scan
|
|
18
|
+
* medium -> convention compliance + readability / maintainability
|
|
19
|
+
* heavy -> bug + security/logic review
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as os from "node:os";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
27
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
28
|
+
import {
|
|
29
|
+
CONFIG_DIR_NAME,
|
|
30
|
+
type ExtensionAPI,
|
|
31
|
+
type ExtensionContext,
|
|
32
|
+
getAgentDir,
|
|
33
|
+
getSelectListTheme,
|
|
34
|
+
getSettingsListTheme,
|
|
35
|
+
} from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import {
|
|
37
|
+
Container,
|
|
38
|
+
fuzzyFilter,
|
|
39
|
+
getKeybindings,
|
|
40
|
+
Input,
|
|
41
|
+
type SelectItem,
|
|
42
|
+
SelectList,
|
|
43
|
+
type SettingItem,
|
|
44
|
+
SettingsList,
|
|
45
|
+
Text,
|
|
46
|
+
} from "@earendil-works/pi-tui";
|
|
47
|
+
import { Type } from "typebox";
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Config
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
type Tier = "light" | "medium" | "heavy";
|
|
54
|
+
const TIERS: Tier[] = ["light", "medium", "heavy"];
|
|
55
|
+
|
|
56
|
+
const UNSET = "(unset — pi default)";
|
|
57
|
+
const TOOLS_PRESETS = ["read,bash,grep,find,ls", "read,grep,find,ls", "read"];
|
|
58
|
+
const DEFAULT_BATCH_PARALLEL = 4;
|
|
59
|
+
const MAX_BATCH_PARALLEL = 6;
|
|
60
|
+
const TIER_PURPOSE: Record<Tier, string> = {
|
|
61
|
+
light: "overview / strengths / high-level risk scan",
|
|
62
|
+
medium: "convention compliance + readability / maintainability",
|
|
63
|
+
heavy: "bug + security/logic review",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
interface PrReviewConfig {
|
|
67
|
+
/** Tier label -> model spec (e.g. "anthropic/model", "openai/model:high"). */
|
|
68
|
+
tiers: Partial<Record<Tier, string>>;
|
|
69
|
+
/** Tools granted to each review subagent process. */
|
|
70
|
+
tools: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DEFAULT_TOOLS = ["read", "bash", "grep", "find", "ls"];
|
|
74
|
+
const CONFIG_FILENAME = "pr-review.json";
|
|
75
|
+
|
|
76
|
+
function userConfigPath(): string {
|
|
77
|
+
return path.join(getAgentDir(), CONFIG_FILENAME);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function projectConfigPath(cwd: string): string {
|
|
81
|
+
return path.join(cwd, CONFIG_DIR_NAME, CONFIG_FILENAME);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readConfigFile(filePath: string): Partial<PrReviewConfig> {
|
|
85
|
+
try {
|
|
86
|
+
if (!fs.existsSync(filePath)) return {};
|
|
87
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
88
|
+
return typeof parsed === "object" && parsed ? (parsed as Partial<PrReviewConfig>) : {};
|
|
89
|
+
} catch {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** User config, overlaid by project config when the project is trusted. */
|
|
95
|
+
function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): PrReviewConfig {
|
|
96
|
+
const user = readConfigFile(userConfigPath());
|
|
97
|
+
let project: Partial<PrReviewConfig> = {};
|
|
98
|
+
try {
|
|
99
|
+
if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd));
|
|
100
|
+
} catch {
|
|
101
|
+
/* trust check unavailable -> user config only */
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
tiers: { ...(user.tiers ?? {}), ...(project.tiers ?? {}) },
|
|
105
|
+
tools: project.tools ?? user.tools ?? DEFAULT_TOOLS,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** User-level config only (the scope the config command edits), with defaults. */
|
|
110
|
+
function readUserConfig(): PrReviewConfig {
|
|
111
|
+
const raw = readConfigFile(userConfigPath());
|
|
112
|
+
return { tiers: { ...(raw.tiers ?? {}) }, tools: raw.tools ?? [...DEFAULT_TOOLS] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeUserConfig(next: PrReviewConfig): string {
|
|
116
|
+
const filePath = userConfigPath();
|
|
117
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
118
|
+
fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
119
|
+
return filePath;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Resolve the model spec for a tier, falling back to the nearest configured tier. */
|
|
123
|
+
function resolveModelSpec(config: PrReviewConfig, tier: Tier): { spec?: string; usedTier?: Tier } {
|
|
124
|
+
if (config.tiers[tier]) return { spec: config.tiers[tier], usedTier: tier };
|
|
125
|
+
// Preference order: search outward from the requested tier.
|
|
126
|
+
const order: Record<Tier, Tier[]> = {
|
|
127
|
+
light: ["light", "medium", "heavy"],
|
|
128
|
+
medium: ["medium", "heavy", "light"],
|
|
129
|
+
heavy: ["heavy", "medium", "light"],
|
|
130
|
+
};
|
|
131
|
+
for (const candidate of order[tier]) {
|
|
132
|
+
if (config.tiers[candidate]) return { spec: config.tiers[candidate], usedTier: candidate };
|
|
133
|
+
}
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Subprocess plumbing (mirrors the official subagent example)
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
142
|
+
const currentScript = process.argv[1];
|
|
143
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
144
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
145
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
146
|
+
}
|
|
147
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
148
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
149
|
+
if (!isGenericRuntime) return { command: process.execPath, args };
|
|
150
|
+
return { command: "pi", args };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function finalAssistantText(messages: Message[]): string {
|
|
154
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
155
|
+
const msg = messages[i];
|
|
156
|
+
if (msg.role === "assistant") {
|
|
157
|
+
for (const part of msg.content) {
|
|
158
|
+
if (part.type === "text" && part.text.trim()) return part.text;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const TIER_GUIDANCE: Record<Tier, string> = {
|
|
166
|
+
light:
|
|
167
|
+
"You are a fast overview reviewer. Produce a concise overview of what the change does and how, list genuine strengths, and note high-level risk areas worth closer specialist review. Do not deep-dive into defects.",
|
|
168
|
+
medium:
|
|
169
|
+
"You are a balanced convention, readability, and maintainability reviewer. Follow the assigned objective exactly: apply only in-scope repository convention files and capture clear maintainability/readability issues without duplicating deep correctness or security analysis.",
|
|
170
|
+
heavy:
|
|
171
|
+
"You are a rigorous specialist reviewer for correctness, security, performance, and logic. Follow the assigned objective exactly, validate each candidate before reporting, and drop anything that is actually correct or that you cannot substantiate.",
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
function buildSubagentSystemPrompt(tier: Tier): string {
|
|
175
|
+
const lines = [
|
|
176
|
+
"You are an isolated code-review subagent invoked by the /pr-review orchestrator.",
|
|
177
|
+
TIER_GUIDANCE[tier],
|
|
178
|
+
"",
|
|
179
|
+
"Stay inside the assigned objective. Within that objective, surface EVERY issue the author would want to know about — from trivial nits up to blocking defects. Do not discard minor issues; classify them by severity instead. Only leave out non-issues: things that are actually correct, unsubstantiated speculation, or subjective preferences with no concrete benefit.",
|
|
180
|
+
"Stay strictly in PR scope: only report issues caused by or directly relevant to this PR's diff (the changed lines and the code they provably affect). Do NOT flag pre-existing issues in untouched code or audit the wider codebase; if a problem existed before this change, leave it out. Reading surrounding files/callers is for context and confirmation only.",
|
|
181
|
+
];
|
|
182
|
+
if (tier === "light") {
|
|
183
|
+
lines.push(
|
|
184
|
+
"Return concise Markdown with two sections:",
|
|
185
|
+
"- 'Overview:' 1-3 short paragraphs on what the PR does and author intent.",
|
|
186
|
+
"- 'Strengths:' a bullet list of genuine strengths (or 'none').",
|
|
187
|
+
);
|
|
188
|
+
} else {
|
|
189
|
+
lines.push(
|
|
190
|
+
"Return your findings as a concise Markdown list. For each finding include, on its own lines:",
|
|
191
|
+
"- title: an imperative summary prefixed with a severity tag [P0]|[P1]|[P2]|[P3]|[nit]",
|
|
192
|
+
"- severity: one of P0, P1, P2, P3, nit (P0/P1 are blocking)",
|
|
193
|
+
"- why: the impact and the exact input/environment needed for it to bite",
|
|
194
|
+
"- location: <repo-relative file path>:<start-end lines exactly as they appear in the diff> (or 'repo-wide')",
|
|
195
|
+
"- side: RIGHT for added/context lines, LEFT for removed lines",
|
|
196
|
+
"- in_diff: yes if those lines are inside the PR diff (so an inline comment can be posted), otherwise no",
|
|
197
|
+
"- pr_related: yes only if this PR introduces or provably affects the issue (drop pre-existing/unrelated issues)",
|
|
198
|
+
"- confidence: a float 0.0-1.0",
|
|
199
|
+
"If there are genuinely no findings at any severity, reply exactly with: NO FINDINGS.",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
lines.push("Do not attempt to post GitHub comments or modify files. Reviewing only.");
|
|
203
|
+
return lines.join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function writeTempPrompt(tier: Tier, body: string): Promise<{ dir: string; filePath: string }> {
|
|
207
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-pr-review-"));
|
|
208
|
+
const filePath = path.join(dir, `system-${tier}.md`);
|
|
209
|
+
await fs.promises.writeFile(filePath, body, { encoding: "utf-8", mode: 0o600 });
|
|
210
|
+
return { dir, filePath };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface RunResult {
|
|
214
|
+
text: string;
|
|
215
|
+
exitCode: number;
|
|
216
|
+
stderr: string;
|
|
217
|
+
stopReason?: string;
|
|
218
|
+
errorMessage?: string;
|
|
219
|
+
model?: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function runReviewSubprocess(
|
|
223
|
+
command: string,
|
|
224
|
+
args: string[],
|
|
225
|
+
cwd: string,
|
|
226
|
+
signal: AbortSignal | undefined,
|
|
227
|
+
onText: (text: string) => void,
|
|
228
|
+
): Promise<RunResult> {
|
|
229
|
+
return new Promise<RunResult>((resolve) => {
|
|
230
|
+
const messages: Message[] = [];
|
|
231
|
+
const result: RunResult = { text: "", exitCode: 0, stderr: "" };
|
|
232
|
+
let buffer = "";
|
|
233
|
+
let aborted = false;
|
|
234
|
+
|
|
235
|
+
const proc = spawn(command, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
236
|
+
|
|
237
|
+
const processLine = (line: string) => {
|
|
238
|
+
if (!line.trim()) return;
|
|
239
|
+
let event: { type?: string; message?: Message };
|
|
240
|
+
try {
|
|
241
|
+
event = JSON.parse(line);
|
|
242
|
+
} catch {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (event.type === "message_end" && event.message) {
|
|
246
|
+
messages.push(event.message);
|
|
247
|
+
if (event.message.role === "assistant") {
|
|
248
|
+
if (event.message.model) result.model = event.message.model;
|
|
249
|
+
if (event.message.stopReason) result.stopReason = event.message.stopReason;
|
|
250
|
+
if (event.message.errorMessage) result.errorMessage = event.message.errorMessage;
|
|
251
|
+
const t = finalAssistantText(messages);
|
|
252
|
+
if (t) onText(t);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
proc.stdout.on("data", (data) => {
|
|
258
|
+
buffer += data.toString();
|
|
259
|
+
const lines = buffer.split("\n");
|
|
260
|
+
buffer = lines.pop() ?? "";
|
|
261
|
+
for (const line of lines) processLine(line);
|
|
262
|
+
});
|
|
263
|
+
proc.stderr.on("data", (data) => {
|
|
264
|
+
result.stderr += data.toString();
|
|
265
|
+
});
|
|
266
|
+
proc.on("close", (code) => {
|
|
267
|
+
if (buffer.trim()) processLine(buffer);
|
|
268
|
+
result.text = finalAssistantText(messages);
|
|
269
|
+
result.exitCode = code ?? 0;
|
|
270
|
+
if (aborted) result.stopReason = "aborted";
|
|
271
|
+
resolve(result);
|
|
272
|
+
});
|
|
273
|
+
proc.on("error", (err) => {
|
|
274
|
+
result.exitCode = 1;
|
|
275
|
+
result.errorMessage = err.message;
|
|
276
|
+
resolve(result);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (signal) {
|
|
280
|
+
const kill = () => {
|
|
281
|
+
aborted = true;
|
|
282
|
+
proc.kill("SIGTERM");
|
|
283
|
+
setTimeout(() => {
|
|
284
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
285
|
+
}, 5000);
|
|
286
|
+
};
|
|
287
|
+
if (signal.aborted) kill();
|
|
288
|
+
else signal.addEventListener("abort", kill, { once: true });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
interface SubagentPassRequest {
|
|
294
|
+
id?: string;
|
|
295
|
+
tier: Tier;
|
|
296
|
+
objective: string;
|
|
297
|
+
context?: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface SubagentPassResult {
|
|
301
|
+
id: string;
|
|
302
|
+
tier: Tier;
|
|
303
|
+
usedTier?: Tier;
|
|
304
|
+
model?: string;
|
|
305
|
+
exitCode: number;
|
|
306
|
+
status: "completed" | "failed";
|
|
307
|
+
notice: string;
|
|
308
|
+
text: string;
|
|
309
|
+
stderr?: string;
|
|
310
|
+
stopReason?: string;
|
|
311
|
+
errorMessage?: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function noticeForTier(tier: Tier, spec: string | undefined, usedTier: Tier | undefined): string {
|
|
315
|
+
if (spec) {
|
|
316
|
+
return usedTier === tier
|
|
317
|
+
? `tier=${tier} model=${spec}`
|
|
318
|
+
: `tier=${tier} (not configured; using ${usedTier} model=${spec})`;
|
|
319
|
+
}
|
|
320
|
+
return `tier=${tier} (no tier configured; using pi default model — run /pr-review-config to set tiers)`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function buildPassTask(objective: string, context: string | undefined): string {
|
|
324
|
+
return context ? `Objective: ${objective}\n\n--- PR context / diff ---\n${context}` : `Objective: ${objective}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function runSubagentPass(
|
|
328
|
+
config: PrReviewConfig,
|
|
329
|
+
ctx: Pick<ExtensionContext, "cwd">,
|
|
330
|
+
pass: SubagentPassRequest,
|
|
331
|
+
signal: AbortSignal | undefined,
|
|
332
|
+
onText?: (text: string) => void,
|
|
333
|
+
): Promise<SubagentPassResult> {
|
|
334
|
+
const tier = pass.tier;
|
|
335
|
+
const { spec, usedTier } = resolveModelSpec(config, tier);
|
|
336
|
+
const notice = noticeForTier(tier, spec, usedTier);
|
|
337
|
+
let tmp: { dir: string; filePath: string } | undefined;
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const args = ["--mode", "json", "-p", "--no-session"];
|
|
341
|
+
if (spec) args.push("--model", spec);
|
|
342
|
+
if (config.tools.length > 0) args.push("--tools", config.tools.join(","));
|
|
343
|
+
|
|
344
|
+
tmp = await writeTempPrompt(tier, buildSubagentSystemPrompt(tier));
|
|
345
|
+
args.push("--append-system-prompt", tmp.filePath);
|
|
346
|
+
args.push(buildPassTask(pass.objective, pass.context));
|
|
347
|
+
|
|
348
|
+
const invocation = getPiInvocation(args);
|
|
349
|
+
const result = await runReviewSubprocess(invocation.command, invocation.args, ctx.cwd, signal, (text) => {
|
|
350
|
+
onText?.(text);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
354
|
+
return {
|
|
355
|
+
id: pass.id ?? tier,
|
|
356
|
+
tier,
|
|
357
|
+
usedTier,
|
|
358
|
+
model: result.model ?? spec,
|
|
359
|
+
exitCode: result.exitCode,
|
|
360
|
+
status: failed ? "failed" : "completed",
|
|
361
|
+
notice,
|
|
362
|
+
text: result.text || (failed ? "" : "NO FINDINGS."),
|
|
363
|
+
stderr: result.stderr || undefined,
|
|
364
|
+
stopReason: result.stopReason,
|
|
365
|
+
errorMessage: result.errorMessage,
|
|
366
|
+
};
|
|
367
|
+
} catch (e) {
|
|
368
|
+
return {
|
|
369
|
+
id: pass.id ?? tier,
|
|
370
|
+
tier,
|
|
371
|
+
usedTier,
|
|
372
|
+
model: spec,
|
|
373
|
+
exitCode: 1,
|
|
374
|
+
status: "failed",
|
|
375
|
+
notice,
|
|
376
|
+
text: "",
|
|
377
|
+
errorMessage: errMessage(e),
|
|
378
|
+
};
|
|
379
|
+
} finally {
|
|
380
|
+
if (tmp) {
|
|
381
|
+
try {
|
|
382
|
+
fs.rmSync(tmp.dir, { recursive: true, force: true });
|
|
383
|
+
} catch {
|
|
384
|
+
/* ignore */
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function normalizeMaxParallel(raw: unknown, count: number): number {
|
|
391
|
+
if (count <= 0) return 0;
|
|
392
|
+
const requested = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : DEFAULT_BATCH_PARALLEL;
|
|
393
|
+
return Math.max(1, Math.min(count, MAX_BATCH_PARALLEL, requested > 0 ? requested : DEFAULT_BATCH_PARALLEL));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function runWithConcurrency<T, R>(
|
|
397
|
+
items: T[],
|
|
398
|
+
limit: number,
|
|
399
|
+
worker: (item: T, index: number) => Promise<R>,
|
|
400
|
+
): Promise<R[]> {
|
|
401
|
+
const results = new Array<R>(items.length);
|
|
402
|
+
let next = 0;
|
|
403
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
404
|
+
while (next < items.length) {
|
|
405
|
+
const index = next++;
|
|
406
|
+
results[index] = await worker(items[index]!, index);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
await Promise.all(workers);
|
|
410
|
+
return results;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function formatBatchResults(results: SubagentPassResult[], maxParallel: number): string {
|
|
414
|
+
const failed = results.filter((r) => r.status === "failed");
|
|
415
|
+
const lines = [
|
|
416
|
+
`Review subagents completed: ${results.length - failed.length}/${results.length} succeeded (max_parallel=${maxParallel}).`,
|
|
417
|
+
];
|
|
418
|
+
if (failed.length) {
|
|
419
|
+
lines.push(
|
|
420
|
+
`WARNING: ${failed.length} pass(es) failed. Treat this as incomplete review evidence unless you rerun or cover the failed pass inline.`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
for (const result of results) {
|
|
424
|
+
lines.push("", `## Pass: ${result.id}`, `status: ${result.status}`, result.notice);
|
|
425
|
+
if (result.status === "failed") {
|
|
426
|
+
const detail = result.errorMessage || result.stderr || result.text || "(no output)";
|
|
427
|
+
lines.push(`error: ${detail}`);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
lines.push(result.text || "NO FINDINGS.");
|
|
431
|
+
}
|
|
432
|
+
return lines.join("\n");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function combineContexts(shared: string | undefined, specific: string | undefined): string | undefined {
|
|
436
|
+
const parts: string[] = [];
|
|
437
|
+
if (shared?.trim()) parts.push(shared.trim());
|
|
438
|
+
if (specific?.trim()) parts.push(`--- Pass-specific context ---\n${specific.trim()}`);
|
|
439
|
+
return parts.length ? parts.join("\n\n") : undefined;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// Extension
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
const ReviewSubagentParams = Type.Object({
|
|
447
|
+
tier: StringEnum(["light", "medium", "heavy"] as const, {
|
|
448
|
+
description:
|
|
449
|
+
"Model tier / subagent label. light = overview & risk scan; medium = conventions/readability; heavy = correctness/security/performance.",
|
|
450
|
+
}),
|
|
451
|
+
objective: Type.String({
|
|
452
|
+
description: "Precise instruction for this subagent — what to review and what to return.",
|
|
453
|
+
}),
|
|
454
|
+
context: Type.Optional(
|
|
455
|
+
Type.String({
|
|
456
|
+
description:
|
|
457
|
+
"Shared context for the subagent, typically the PR title/description plus the unified diff to review.",
|
|
458
|
+
}),
|
|
459
|
+
),
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const ReviewSubagentsParams = Type.Object({
|
|
463
|
+
context: Type.Optional(
|
|
464
|
+
Type.String({
|
|
465
|
+
description:
|
|
466
|
+
"Shared PR context for every pass, typically PR title/body/metadata, in-scope convention-file summaries, and the unified diff.",
|
|
467
|
+
}),
|
|
468
|
+
),
|
|
469
|
+
max_parallel: Type.Optional(
|
|
470
|
+
Type.Number({
|
|
471
|
+
description: `Maximum pass subprocesses to run concurrently. Defaults to ${DEFAULT_BATCH_PARALLEL}; capped at ${MAX_BATCH_PARALLEL}.`,
|
|
472
|
+
}),
|
|
473
|
+
),
|
|
474
|
+
passes: Type.Array(
|
|
475
|
+
Type.Object({
|
|
476
|
+
id: Type.Optional(
|
|
477
|
+
Type.String({
|
|
478
|
+
description: "Stable pass label used in the ordered batch result, e.g. overview, conventions, correctness.",
|
|
479
|
+
}),
|
|
480
|
+
),
|
|
481
|
+
tier: StringEnum(["light", "medium", "heavy"] as const, {
|
|
482
|
+
description:
|
|
483
|
+
"Model tier / subagent label. light = overview & risk scan; medium = conventions/readability; heavy = correctness/security/performance.",
|
|
484
|
+
}),
|
|
485
|
+
objective: Type.String({
|
|
486
|
+
description: "Precise instruction for this pass — what to review and what to return.",
|
|
487
|
+
}),
|
|
488
|
+
context: Type.Optional(
|
|
489
|
+
Type.String({
|
|
490
|
+
description: "Optional pass-specific context appended after the shared context.",
|
|
491
|
+
}),
|
|
492
|
+
),
|
|
493
|
+
}),
|
|
494
|
+
{
|
|
495
|
+
description: "Independent review passes to run concurrently. Results are returned in this same order.",
|
|
496
|
+
},
|
|
497
|
+
),
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
export default function (pi: ExtensionAPI) {
|
|
501
|
+
pi.registerTool({
|
|
502
|
+
name: "review_subagent",
|
|
503
|
+
label: "Review Subagent",
|
|
504
|
+
description: [
|
|
505
|
+
"Delegate one PR-review pass to an isolated subagent running on a configured model tier.",
|
|
506
|
+
"Pass tier (light|medium|heavy) plus an objective and the diff as context.",
|
|
507
|
+
"Model per tier is configured via /pr-review-config (stored in pr-review.json).",
|
|
508
|
+
"Returns the subagent's candidate findings; the orchestrator validates, filters, and emits the final JSON.",
|
|
509
|
+
].join(" "),
|
|
510
|
+
promptSnippet:
|
|
511
|
+
"Run a tiered PR-review pass (light/medium/heavy) in an isolated subagent on the configured model",
|
|
512
|
+
promptGuidelines: [
|
|
513
|
+
"Use review_subagent for a single /pr-review pass when review_subagents is unavailable or when rerunning one failed batch pass.",
|
|
514
|
+
"When calling review_subagent, pass the unified diff and PR title/description in `context` so the subagent does not refetch it.",
|
|
515
|
+
],
|
|
516
|
+
parameters: ReviewSubagentParams,
|
|
517
|
+
|
|
518
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
519
|
+
const tier = params.tier as Tier;
|
|
520
|
+
const result = await runSubagentPass(
|
|
521
|
+
loadConfig(ctx),
|
|
522
|
+
ctx,
|
|
523
|
+
{ tier, objective: params.objective, context: params.context },
|
|
524
|
+
signal,
|
|
525
|
+
(text) => onUpdate?.({ content: [{ type: "text", text }] }),
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
if (result.status === "failed") {
|
|
529
|
+
const detail = result.errorMessage || result.stderr || result.text || "(no output)";
|
|
530
|
+
return {
|
|
531
|
+
content: [{ type: "text", text: `Review subagent failed [${result.notice}]: ${detail}` }],
|
|
532
|
+
isError: true,
|
|
533
|
+
details: {
|
|
534
|
+
tier: result.tier,
|
|
535
|
+
usedTier: result.usedTier,
|
|
536
|
+
model: result.model,
|
|
537
|
+
exitCode: result.exitCode,
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
content: [{ type: "text", text: `[${result.notice}]\n\n${result.text || "NO FINDINGS."}` }],
|
|
544
|
+
details: {
|
|
545
|
+
tier: result.tier,
|
|
546
|
+
usedTier: result.usedTier,
|
|
547
|
+
model: result.model,
|
|
548
|
+
exitCode: result.exitCode,
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
pi.registerTool({
|
|
555
|
+
name: "review_subagents",
|
|
556
|
+
label: "Review Subagents Batch",
|
|
557
|
+
description: [
|
|
558
|
+
"Delegate multiple independent PR-review passes to isolated subagents and run them concurrently.",
|
|
559
|
+
"Pass shared PR context once plus ordered pass assignments with tier, objective, and optional pass-specific context.",
|
|
560
|
+
"Each pass uses the configured light/medium/heavy model tier from /pr-review-config.",
|
|
561
|
+
"Returns deterministic ordered per-pass outputs; partial failures are explicit so the orchestrator can rerun or cover them inline.",
|
|
562
|
+
].join(" "),
|
|
563
|
+
promptSnippet:
|
|
564
|
+
"Run independent light/medium/heavy PR-review passes concurrently in isolated subagents with shared PR context",
|
|
565
|
+
promptGuidelines: [
|
|
566
|
+
"Prefer review_subagents over separate review_subagent calls for independent /pr-review passes; it guarantees bounded parallel execution instead of relying on the orchestrator to emit concurrent tool calls.",
|
|
567
|
+
"Fetch PR metadata and the unified diff once, then pass that shared context in `context`; use per-pass `context` only for extra instructions or scoped convention-file excerpts.",
|
|
568
|
+
"If any pass reports status=failed, treat the review evidence as incomplete: rerun that pass or perform it inline before finalizing the JSON.",
|
|
569
|
+
],
|
|
570
|
+
parameters: ReviewSubagentsParams,
|
|
571
|
+
|
|
572
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
573
|
+
const rawPasses = Array.isArray(params.passes) ? params.passes : [];
|
|
574
|
+
if (rawPasses.length === 0) {
|
|
575
|
+
return {
|
|
576
|
+
content: [{ type: "text", text: "review_subagents requires at least one pass assignment." }],
|
|
577
|
+
isError: true,
|
|
578
|
+
details: { passCount: 0 },
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const sharedContext = typeof params.context === "string" ? params.context : undefined;
|
|
583
|
+
const passes: SubagentPassRequest[] = rawPasses.map((pass, index) => {
|
|
584
|
+
const tier = pass.tier as Tier;
|
|
585
|
+
return {
|
|
586
|
+
id: typeof pass.id === "string" && pass.id.trim() ? pass.id.trim() : `${index + 1}-${tier}`,
|
|
587
|
+
tier,
|
|
588
|
+
objective: pass.objective,
|
|
589
|
+
context: combineContexts(sharedContext, typeof pass.context === "string" ? pass.context : undefined),
|
|
590
|
+
};
|
|
591
|
+
});
|
|
592
|
+
const maxParallel = normalizeMaxParallel(params.max_parallel, passes.length);
|
|
593
|
+
const config = loadConfig(ctx);
|
|
594
|
+
|
|
595
|
+
const results = await runWithConcurrency(passes, maxParallel, async (pass) => {
|
|
596
|
+
const result = await runSubagentPass(config, ctx, pass, signal);
|
|
597
|
+
onUpdate?.({
|
|
598
|
+
content: [
|
|
599
|
+
{
|
|
600
|
+
type: "text",
|
|
601
|
+
text: `review_subagents: pass ${result.id} ${result.status} (${result.notice})`,
|
|
602
|
+
},
|
|
603
|
+
],
|
|
604
|
+
});
|
|
605
|
+
return result;
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const failed = results.filter((r) => r.status === "failed");
|
|
609
|
+
return {
|
|
610
|
+
content: [{ type: "text", text: formatBatchResults(results, maxParallel) }],
|
|
611
|
+
...(failed.length > 0 ? { isError: true } : {}),
|
|
612
|
+
details: {
|
|
613
|
+
maxParallel,
|
|
614
|
+
passCount: results.length,
|
|
615
|
+
failedCount: failed.length,
|
|
616
|
+
results: results.map((r) => ({
|
|
617
|
+
id: r.id,
|
|
618
|
+
tier: r.tier,
|
|
619
|
+
usedTier: r.usedTier,
|
|
620
|
+
model: r.model,
|
|
621
|
+
exitCode: r.exitCode,
|
|
622
|
+
status: r.status,
|
|
623
|
+
stopReason: r.stopReason,
|
|
624
|
+
errorMessage: r.errorMessage,
|
|
625
|
+
outputChars: r.text.length,
|
|
626
|
+
})),
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
pi.registerCommand("pr-review-config", {
|
|
633
|
+
description: "Open the review-tier settings menu, or show/set light/medium/heavy models for /pr-review",
|
|
634
|
+
handler: async (args, ctx) => {
|
|
635
|
+
const raw = (args ?? "").trim();
|
|
636
|
+
const parsed = parseConfigArgs(raw);
|
|
637
|
+
if (parsed.errors.length) {
|
|
638
|
+
ctx.ui.notify(`Invalid pr-review config: ${parsed.errors.join("; ")}`, "error");
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
// Direct set: `/pr-review-config light=... heavy=...`
|
|
644
|
+
if (parsed.hasChanges) {
|
|
645
|
+
const next = applyConfigPatch(readUserConfig(), parsed.patch);
|
|
646
|
+
writeUserConfig(next);
|
|
647
|
+
post(ctx, pi, summarizeConfig(next, loadConfig(ctx), ctx, true), { config: next });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Interactive menu: `/pr-review-config` in the TUI.
|
|
652
|
+
if (shouldOpenConfigMenu(raw, ctx)) {
|
|
653
|
+
let available: string[] = [];
|
|
654
|
+
try {
|
|
655
|
+
available = (await ctx.modelRegistry.getAvailable()).map((m) => `${m.provider}/${m.id}`).sort();
|
|
656
|
+
} catch {
|
|
657
|
+
/* fall back to text if models can't be listed */
|
|
658
|
+
}
|
|
659
|
+
if (available.length > 0) {
|
|
660
|
+
const result = await showConfigMenu(pi, ctx, available);
|
|
661
|
+
if (result === "closed") return;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Text summary: `/pr-review-config show`, non-TUI, or menu fallback.
|
|
666
|
+
post(ctx, pi, summarizeConfig(readUserConfig(), loadConfig(ctx), ctx, false), {});
|
|
667
|
+
} catch (e) {
|
|
668
|
+
ctx.ui.notify(`pr-review-config failed: ${errMessage(e)}`, "error");
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
getArgumentCompletions(prefix: string) {
|
|
672
|
+
const normalized = prefix.toLowerCase();
|
|
673
|
+
const filtered = CONFIG_COMPLETIONS.filter((item) => item.value.toLowerCase().startsWith(normalized));
|
|
674
|
+
return filtered.length ? filtered : null;
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/* ----------------------- config command helpers ------------------------- */
|
|
680
|
+
|
|
681
|
+
function errMessage(e: unknown): string {
|
|
682
|
+
return e instanceof Error ? e.message : String(e);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function post(
|
|
686
|
+
ctx: ExtensionContext,
|
|
687
|
+
pi: ExtensionAPI,
|
|
688
|
+
markdown: string,
|
|
689
|
+
details: Record<string, unknown>,
|
|
690
|
+
): void {
|
|
691
|
+
if (ctx.hasUI) {
|
|
692
|
+
pi.sendMessage({ customType: "pr-review", content: markdown, display: true, details }, { triggerTurn: false });
|
|
693
|
+
} else {
|
|
694
|
+
ctx.ui.notify(markdown, "info");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function shouldOpenConfigMenu(args: string, ctx: ExtensionContext): boolean {
|
|
699
|
+
return args.length === 0 && ctx.mode === "tui" && typeof ctx.ui.custom === "function";
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const CONFIG_COMPLETIONS: Array<{ value: string; label: string }> = [
|
|
703
|
+
...TIERS.map((t) => ({ value: `${t}=`, label: `${t}=<model> — ${TIER_PURPOSE[t]}` })),
|
|
704
|
+
{ value: "tools=", label: "tools=read,bash,grep,find,ls — tools granted to review subagents" },
|
|
705
|
+
{ value: "show", label: "show — print the current tier config" },
|
|
706
|
+
];
|
|
707
|
+
|
|
708
|
+
/** Split respecting single/double quotes so `evidence`-style values with spaces survive. */
|
|
709
|
+
function splitConfigArgs(input: string): string[] {
|
|
710
|
+
const tokens: string[] = [];
|
|
711
|
+
let token = "";
|
|
712
|
+
let quote: '"' | "'" | undefined;
|
|
713
|
+
for (let i = 0; i < input.length; i++) {
|
|
714
|
+
const ch = input[i]!;
|
|
715
|
+
if (quote) {
|
|
716
|
+
if (ch === quote) quote = undefined;
|
|
717
|
+
else token += ch;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (ch === '"' || ch === "'") {
|
|
721
|
+
quote = ch;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (/\s/.test(ch)) {
|
|
725
|
+
if (token) {
|
|
726
|
+
tokens.push(token);
|
|
727
|
+
token = "";
|
|
728
|
+
}
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
token += ch;
|
|
732
|
+
}
|
|
733
|
+
if (token) tokens.push(token);
|
|
734
|
+
return tokens;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
interface ConfigPatch {
|
|
738
|
+
tiers: Partial<Record<Tier, string | null>>;
|
|
739
|
+
tools?: string[];
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolean; errors: string[] } {
|
|
743
|
+
const patch: ConfigPatch = { tiers: {} };
|
|
744
|
+
const errors: string[] = [];
|
|
745
|
+
const tokens = splitConfigArgs(args).filter((t) => !["show", "get", "current"].includes(t.toLowerCase()));
|
|
746
|
+
for (const token of tokens) {
|
|
747
|
+
const eq = token.indexOf("=");
|
|
748
|
+
if (eq < 0) {
|
|
749
|
+
errors.push(`unrecognized argument "${token}" (use key=value, e.g. heavy=provider/model)`);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
const key = token.slice(0, eq).replace(/^--?/, "").toLowerCase();
|
|
753
|
+
const value = token.slice(eq + 1);
|
|
754
|
+
if ((TIERS as string[]).includes(key)) {
|
|
755
|
+
patch.tiers[key as Tier] = value === "" || value === "unset" ? null : value;
|
|
756
|
+
} else if (key === "tools") {
|
|
757
|
+
patch.tools = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
758
|
+
} else {
|
|
759
|
+
errors.push(`unknown key "${key}" (expected light|medium|heavy|tools)`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const hasChanges = Object.keys(patch.tiers).length > 0 || patch.tools !== undefined;
|
|
763
|
+
return { patch, hasChanges, errors };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewConfig {
|
|
767
|
+
const next: PrReviewConfig = { tiers: { ...base.tiers }, tools: [...base.tools] };
|
|
768
|
+
for (const tier of TIERS) {
|
|
769
|
+
if (!(tier in patch.tiers)) continue;
|
|
770
|
+
const value = patch.tiers[tier];
|
|
771
|
+
if (value === null || value === undefined) delete next.tiers[tier];
|
|
772
|
+
else next.tiers[tier] = value;
|
|
773
|
+
}
|
|
774
|
+
if (patch.tools) next.tools = patch.tools;
|
|
775
|
+
return next;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function summarizeConfig(
|
|
779
|
+
user: PrReviewConfig,
|
|
780
|
+
effective: PrReviewConfig,
|
|
781
|
+
ctx: ExtensionContext,
|
|
782
|
+
changed: boolean,
|
|
783
|
+
): string {
|
|
784
|
+
let projectPath: string | undefined;
|
|
785
|
+
try {
|
|
786
|
+
if (ctx.isProjectTrusted()) {
|
|
787
|
+
const p = projectConfigPath(ctx.cwd);
|
|
788
|
+
if (fs.existsSync(p)) projectPath = p;
|
|
789
|
+
}
|
|
790
|
+
} catch {
|
|
791
|
+
/* ignore */
|
|
792
|
+
}
|
|
793
|
+
const lines = [
|
|
794
|
+
`# PR review config${changed ? " updated" : ""}`,
|
|
795
|
+
"",
|
|
796
|
+
"| Tier | Your setting | Effective | Used for |",
|
|
797
|
+
"|---|---|---|---|",
|
|
798
|
+
...TIERS.map(
|
|
799
|
+
(t) =>
|
|
800
|
+
`| \`${t}\` | ${user.tiers[t] ? `\`${user.tiers[t]}\`` : "_unset_"} | ${effective.tiers[t] ? `\`${effective.tiers[t]}\`` : "_pi default_"} | ${TIER_PURPOSE[t]} |`,
|
|
801
|
+
),
|
|
802
|
+
`| \`tools\` | \`${user.tools.join(",")}\` | \`${effective.tools.join(",")}\` | tools granted to each review subagent |`,
|
|
803
|
+
"",
|
|
804
|
+
`User config: \`${userConfigPath()}\``,
|
|
805
|
+
];
|
|
806
|
+
if (projectPath) lines.push(`Project overlay (trusted): \`${projectPath}\``);
|
|
807
|
+
lines.push(
|
|
808
|
+
"",
|
|
809
|
+
"## Usage",
|
|
810
|
+
"- Open the settings menu: `/pr-review-config`",
|
|
811
|
+
"- Print this summary: `/pr-review-config show`",
|
|
812
|
+
"- Set directly: `/pr-review-config light=provider/model heavy=provider/model:high`",
|
|
813
|
+
"- Clear a tier: `/pr-review-config medium=unset`",
|
|
814
|
+
"- A `<model>` is any pi model pattern (`provider/model` or `provider/model:thinking`).",
|
|
815
|
+
);
|
|
816
|
+
return lines.join("\n");
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/* --------------------------- interactive menu --------------------------- */
|
|
820
|
+
|
|
821
|
+
const MODEL_LIST_ROWS = 10;
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Model picker submenu with type-to-filter search.
|
|
825
|
+
*
|
|
826
|
+
* A raw SelectList only handles arrows/enter/esc, so we wrap it: an Input captures
|
|
827
|
+
* keystrokes and we fuzzy-filter the options (matching the built-in SettingsList
|
|
828
|
+
* search UX and fuzzyFilter's slash-token matching, so typing a model name substring
|
|
829
|
+
* finds `provider/model`). The SelectList is rebuilt as the query changes.
|
|
830
|
+
*/
|
|
831
|
+
function buildModelSubmenu(available: string[], currentSpec: string | undefined) {
|
|
832
|
+
return (_currentValue: string, done: (selectedValue?: string) => void) => {
|
|
833
|
+
const allItems: SelectItem[] = [
|
|
834
|
+
{ value: "__unset__", label: UNSET, description: "Fall back to the nearest configured tier, then the pi default." },
|
|
835
|
+
...available.map((spec) => ({ value: spec, label: spec })),
|
|
836
|
+
];
|
|
837
|
+
const theme = getSelectListTheme();
|
|
838
|
+
const search = new Input();
|
|
839
|
+
|
|
840
|
+
const makeList = (items: SelectItem[], selectValue?: string): SelectList => {
|
|
841
|
+
const list = new SelectList(items, MODEL_LIST_ROWS, theme);
|
|
842
|
+
if (selectValue) {
|
|
843
|
+
const idx = items.findIndex((i) => i.value === selectValue);
|
|
844
|
+
if (idx >= 0) list.setSelectedIndex(idx);
|
|
845
|
+
}
|
|
846
|
+
list.onSelect = (item) => done(item.value);
|
|
847
|
+
list.onCancel = () => done();
|
|
848
|
+
return list;
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
let list = makeList(allItems, currentSpec ?? "__unset__");
|
|
852
|
+
|
|
853
|
+
const applyQuery = (query: string) => {
|
|
854
|
+
const filtered = query.trim() ? fuzzyFilter(allItems, query, (i) => i.label) : allItems;
|
|
855
|
+
list = makeList(filtered);
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const navKeys = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"];
|
|
859
|
+
return {
|
|
860
|
+
render(width: number): string[] {
|
|
861
|
+
return [...search.render(width), ...list.render(width)];
|
|
862
|
+
},
|
|
863
|
+
invalidate(): void {
|
|
864
|
+
search.invalidate?.();
|
|
865
|
+
list.invalidate();
|
|
866
|
+
},
|
|
867
|
+
handleInput(data: string): void {
|
|
868
|
+
const kb = getKeybindings();
|
|
869
|
+
if (navKeys.some((k) => kb.matches(data, k))) {
|
|
870
|
+
list.handleInput(data);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
// Ignore other escape sequences (arrows/fn/etc.) so they never pollute the search box.
|
|
874
|
+
if (data.startsWith("\x1b")) return;
|
|
875
|
+
// Everything else is search typing (Input handles printable chars + backspace + editing keys).
|
|
876
|
+
const sanitized = data.replace(/ /g, "");
|
|
877
|
+
if (!sanitized) return;
|
|
878
|
+
search.handleInput(sanitized);
|
|
879
|
+
applyQuery(search.getValue());
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function configMenuItems(cfg: PrReviewConfig, available: string[]): SettingItem[] {
|
|
886
|
+
const tierItems: SettingItem[] = TIERS.map((tier) => ({
|
|
887
|
+
id: tier,
|
|
888
|
+
label: `${tier} model`,
|
|
889
|
+
description: `Used for: ${TIER_PURPOSE[tier]}. Press Enter to pick a model.`,
|
|
890
|
+
currentValue: cfg.tiers[tier] ?? UNSET,
|
|
891
|
+
submenu: buildModelSubmenu(available, cfg.tiers[tier]),
|
|
892
|
+
}));
|
|
893
|
+
const current = cfg.tools.join(",");
|
|
894
|
+
const toolValues = [current, ...TOOLS_PRESETS.filter((p) => p !== current)];
|
|
895
|
+
return [
|
|
896
|
+
...tierItems,
|
|
897
|
+
{
|
|
898
|
+
id: "tools",
|
|
899
|
+
label: "subagent tools",
|
|
900
|
+
description: "Tools granted to each review subagent. Enter/Space cycles presets.",
|
|
901
|
+
currentValue: current,
|
|
902
|
+
values: toolValues,
|
|
903
|
+
},
|
|
904
|
+
];
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function showConfigMenu(
|
|
908
|
+
pi: ExtensionAPI,
|
|
909
|
+
ctx: ExtensionContext,
|
|
910
|
+
available: string[],
|
|
911
|
+
): Promise<"closed" | "fallback"> {
|
|
912
|
+
const draft = readUserConfig();
|
|
913
|
+
try {
|
|
914
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
915
|
+
const container = new Container();
|
|
916
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("PR Review Config")), 1, 1));
|
|
917
|
+
container.addChild(
|
|
918
|
+
new Text(theme.fg("dim", "Select a value to apply it immediately. Esc closes the menu."), 1, 0),
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
let settingsList: SettingsList;
|
|
922
|
+
const refresh = () => {
|
|
923
|
+
for (const tier of TIERS) settingsList.updateValue(tier, draft.tiers[tier] ?? UNSET);
|
|
924
|
+
settingsList.updateValue("tools", draft.tools.join(","));
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
const persist = (id: string, newValue: string) => {
|
|
928
|
+
if ((TIERS as string[]).includes(id)) {
|
|
929
|
+
if (newValue === "__unset__") delete draft.tiers[id as Tier];
|
|
930
|
+
else draft.tiers[id as Tier] = newValue;
|
|
931
|
+
} else if (id === "tools") {
|
|
932
|
+
draft.tools = newValue.split(",").map((s) => s.trim()).filter(Boolean);
|
|
933
|
+
} else {
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
try {
|
|
937
|
+
writeUserConfig(draft);
|
|
938
|
+
refresh();
|
|
939
|
+
const shown = id === "tools" ? draft.tools.join(",") : (draft.tiers[id as Tier] ?? UNSET);
|
|
940
|
+
ctx.ui.notify(`PR review config: ${id} = ${shown}`, "info");
|
|
941
|
+
} catch (e) {
|
|
942
|
+
ctx.ui.notify(`pr-review-config failed: ${errMessage(e)}`, "error");
|
|
943
|
+
}
|
|
944
|
+
tui.requestRender();
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
settingsList = new SettingsList(
|
|
948
|
+
configMenuItems(draft, available),
|
|
949
|
+
6,
|
|
950
|
+
getSettingsListTheme(),
|
|
951
|
+
(id, newValue) => persist(id, newValue),
|
|
952
|
+
() => done(undefined),
|
|
953
|
+
{ enableSearch: true },
|
|
954
|
+
);
|
|
955
|
+
container.addChild(settingsList);
|
|
956
|
+
|
|
957
|
+
return {
|
|
958
|
+
render: (width: number) => container.render(width),
|
|
959
|
+
invalidate: () => container.invalidate(),
|
|
960
|
+
handleInput: (data: string) => {
|
|
961
|
+
settingsList.handleInput(data);
|
|
962
|
+
tui.requestRender();
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
});
|
|
966
|
+
} catch (e) {
|
|
967
|
+
ctx.ui.notify(`PR review config menu unavailable; showing text config instead. ${errMessage(e)}`, "warning");
|
|
968
|
+
return "fallback";
|
|
969
|
+
}
|
|
970
|
+
return "closed";
|
|
971
|
+
}
|