@mrclrchtr/supi-review 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +78 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  18. package/package.json +43 -0
  19. package/src/format-content.ts +71 -0
  20. package/src/git.ts +197 -0
  21. package/src/index.ts +1 -0
  22. package/src/progress-widget.ts +82 -0
  23. package/src/prompts.ts +116 -0
  24. package/src/renderer.ts +181 -0
  25. package/src/review.ts +351 -0
  26. package/src/runner-types.ts +32 -0
  27. package/src/runner.ts +424 -0
  28. package/src/settings.ts +246 -0
  29. package/src/target-resolution.ts +102 -0
  30. package/src/types.ts +49 -0
  31. package/src/ui.ts +116 -0
package/src/runner.ts ADDED
@@ -0,0 +1,424 @@
1
+ // biome-ignore lint/nursery/noExcessiveLinesPerFile: pre-existing, needs refactoring
2
+ import { clampThinkingLevel, type Model } from "@earendil-works/pi-ai";
3
+ import {
4
+ type AgentSession,
5
+ type AgentSessionEvent,
6
+ createAgentSession,
7
+ DefaultResourceLoader,
8
+ defineTool,
9
+ type ModelRegistry,
10
+ SessionManager,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { Type } from "typebox";
13
+ import type { ReviewerInvocation, ReviewProgress } from "./runner-types.ts";
14
+ import type { ReviewOutputEvent, ReviewResult, ReviewTarget } from "./types.ts";
15
+
16
+ export type { ReviewerInvocation } from "./runner-types.ts";
17
+
18
+ const DEFAULT_TIMEOUT_MS = 20 * 60 * 1_000;
19
+ const GRACE_TURNS = 3;
20
+ const STEER_MESSAGE = "Time limit reached. Wrap up and submit your review now.";
21
+ /** Maps tool names to human-readable activity descriptions. */
22
+ function toolNameToActivity(name: string, phase: "start" | "end"): string {
23
+ if (phase === "end") return "";
24
+ const map: Record<string, string> = {
25
+ read: "reading",
26
+ grep: "searching",
27
+ find: "finding files",
28
+ ls: "listing files",
29
+ submit_review: "submitting review",
30
+ };
31
+ return map[name] ?? name;
32
+ }
33
+ function createSubmitReviewTool(resultHolder: {
34
+ value: ReviewOutputEvent | undefined;
35
+ }): ReturnType<typeof defineTool> {
36
+ return defineTool({
37
+ name: "submit_review",
38
+ label: "Submit Review",
39
+ description: [
40
+ "Submit the final structured review result.",
41
+ "Call this tool when you have completed your review and are ready to submit the findings.",
42
+ ].join(" "),
43
+ parameters: Type.Object({
44
+ findings: Type.Array(
45
+ Type.Object({
46
+ title: Type.String(),
47
+ body: Type.String(),
48
+ confidence_score: Type.Number(),
49
+ priority: Type.Number(),
50
+ code_location: Type.Object({
51
+ absolute_file_path: Type.String(),
52
+ line_range: Type.Object({
53
+ start: Type.Number(),
54
+ end: Type.Number(),
55
+ }),
56
+ }),
57
+ }),
58
+ ),
59
+ overall_correctness: Type.String(),
60
+ overall_explanation: Type.String(),
61
+ overall_confidence_score: Type.Number(),
62
+ }),
63
+ execute: async (_toolCallId, args) => {
64
+ resultHolder.value = args as ReviewOutputEvent;
65
+ return {
66
+ content: [{ type: "text" as const, text: "Review submitted successfully." }],
67
+ details: args,
68
+ terminate: true,
69
+ };
70
+ },
71
+ });
72
+ }
73
+ export function buildReviewerSystemPrompt(): string {
74
+ return [
75
+ "You are a rigorous code reviewer. Review the provided code changes carefully and report any issues you find.",
76
+ "You have read-only tools only. Do NOT attempt to edit files or run commands that modify the working tree.",
77
+ "If the patch is fully correct, set overall_correctness to 'patch is correct' with high confidence.",
78
+ "",
79
+ "--- Review categories ---",
80
+ "Check each category that applies:",
81
+ "- Security: injection risks, auth bypasses, secrets exposure, data validation",
82
+ "- Performance: O(n²) algorithms, unnecessary allocations, N+1 queries, blocking operations",
83
+ "- Correctness: logic bugs, race conditions, type mismatches, off-by-one errors, missing null checks",
84
+ "- Maintainability: unclear naming, missing tests, dead code, duplication, overly complex logic",
85
+ "- API design: breaking changes, inconsistent patterns, missing validation, poor ergonomics",
86
+ "",
87
+ "--- Finding quality ---",
88
+ "Title: concise and specific (e.g. 'Missing null check in parseToken()', not 'Bug found')",
89
+ "Body: explain the problem, why it matters, and suggest a concrete fix",
90
+ "code_location: 1-based inclusive line range; verify against the diff before submitting",
91
+ "confidence_score: 0.0-1.0 based on how certain you are (obvious bug = 1.0, suspicion = 0.3)",
92
+ "priority: 0=info (notable observation), 1=minor (style/small issue), 2=major (bug/risk), 3=critical (security/data loss)",
93
+ "overall_correctness: 'patch is correct' | 'mostly correct' | 'patch is incorrect'",
94
+ "",
95
+ "--- Tool strategy ---",
96
+ "Use read to inspect full files when the diff lacks surrounding context.",
97
+ "Use grep to verify patterns across the codebase (e.g. consistent error handling, existing tests).",
98
+ "Use find to locate related files quickly.",
99
+ "If you need to understand the broader structure, use ls on relevant directories first.",
100
+ "",
101
+ "Do NOT output JSON directly — call submit_review with the structured result.",
102
+ ].join("\n");
103
+ }
104
+ async function createReviewerSession(
105
+ // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
106
+ model: Model<any>,
107
+ cwd: string,
108
+ submitReviewTool: ReturnType<typeof defineTool>,
109
+ modelRegistry?: ModelRegistry,
110
+ ): Promise<AgentSession> {
111
+ const resourceLoader = new DefaultResourceLoader({
112
+ cwd,
113
+ agentDir: process.env.PI_CODING_AGENT_DIR || "",
114
+ noExtensions: false,
115
+ noSkills: true,
116
+ noPromptTemplates: true,
117
+ noThemes: true,
118
+ noContextFiles: false,
119
+ appendSystemPrompt: [buildReviewerSystemPrompt()],
120
+ });
121
+ await resourceLoader.reload();
122
+ const { session } = await createAgentSession({
123
+ cwd,
124
+ model,
125
+ modelRegistry,
126
+ thinkingLevel: clampThinkingLevel(model, "xhigh"),
127
+ tools: ["read", "grep", "find", "ls", "submit_review"],
128
+ customTools: [submitReviewTool],
129
+ resourceLoader,
130
+ sessionManager: SessionManager.inMemory(cwd),
131
+ });
132
+ return session;
133
+ }
134
+ function extractLastAssistantText(session: AgentSession): string | undefined {
135
+ const messages = session.messages;
136
+ for (let i = messages.length - 1; i >= 0; i--) {
137
+ const msg = messages[i];
138
+ if (msg?.role !== "assistant") continue;
139
+ const text = extractAssistantTextContent(msg);
140
+ if (text) return text;
141
+ const errorMsg = extractAssistantErrorMessage(msg);
142
+ if (errorMsg) return errorMsg;
143
+ }
144
+ return undefined;
145
+ }
146
+ /** Extract non-empty text content from an assistant message (or undefined). */
147
+ function extractAssistantTextContent(msg: {
148
+ role: string;
149
+ content: string | Array<{ type: string; text?: string }>;
150
+ }): string | undefined {
151
+ if (typeof msg.content === "string") return msg.content || undefined;
152
+ if (!Array.isArray(msg.content)) return undefined;
153
+ const texts = msg.content
154
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
155
+ .map((c) => c.text);
156
+ if (texts.length > 0 && texts.some((t) => t.length > 0)) return texts.join("\n");
157
+ return undefined;
158
+ }
159
+ /** Extract errorMessage or stopReason description from a failed assistant message. */
160
+ function extractAssistantErrorMessage(msg: unknown): string | undefined {
161
+ const errMsg = (msg as Record<string, unknown>).errorMessage;
162
+ if (typeof errMsg === "string" && errMsg.length > 0) return errMsg;
163
+ const stopReason = (msg as Record<string, unknown>).stopReason;
164
+ if (stopReason === "error") return "Reviewer model returned an error";
165
+ if (stopReason === "aborted") return "Reviewer was aborted";
166
+ return undefined;
167
+ }
168
+ /** Format token count for display. */
169
+ export function formatTokens(count: number): string {
170
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
171
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
172
+ return String(count);
173
+ }
174
+ interface RunnerContext {
175
+ progress: ReviewProgress;
176
+ session: AgentSession;
177
+ target: ReviewTarget;
178
+ timeoutMs: number;
179
+ onToolActivity: ReviewerInvocation["onToolActivity"];
180
+ onProgress: ReviewerInvocation["onProgress"];
181
+ resolve: (result: ReviewResult) => void;
182
+ cleanup: (result: ReviewResult) => ReviewResult;
183
+ resultHolder: { value: ReviewOutputEvent | undefined };
184
+ signal?: AbortSignal;
185
+ /** Whether the review has already settled (resolved + disposed). */
186
+ state: { settled: boolean };
187
+ timeout: { steered: boolean; graceTurnsRemaining: number | undefined; aborting?: boolean };
188
+ }
189
+ function emitProgress(ctx: RunnerContext): void {
190
+ try {
191
+ const stats = ctx.session.getSessionStats();
192
+ ctx.progress.tokens = stats?.tokens
193
+ ? {
194
+ input: stats.tokens.input ?? 0,
195
+ output: stats.tokens.output ?? 0,
196
+ total: stats.tokens.total ?? 0,
197
+ }
198
+ : undefined;
199
+ } catch {
200
+ /* Session may not have stats yet */
201
+ }
202
+ ctx.onProgress?.({ ...ctx.progress, activities: [...ctx.progress.activities] });
203
+ }
204
+ function handleTurnEnd(ctx: RunnerContext): number | undefined {
205
+ ctx.progress.turns++;
206
+ ctx.progress.activities = [];
207
+ if (!ctx.state.settled && ctx.timeout.steered && ctx.timeout.graceTurnsRemaining !== undefined) {
208
+ ctx.timeout.graceTurnsRemaining--;
209
+ if (ctx.timeout.graceTurnsRemaining <= 0) {
210
+ ctx.timeout.aborting = true;
211
+ void ctx.session
212
+ .abort()
213
+ .catch(() => {})
214
+ .finally(() => {
215
+ const partialText = extractLastAssistantText(ctx.session);
216
+ ctx.resolve(
217
+ ctx.cleanup({
218
+ kind: "timeout",
219
+ target: ctx.target,
220
+ timeoutMs: ctx.timeoutMs,
221
+ partialOutput: partialText,
222
+ }),
223
+ );
224
+ });
225
+ }
226
+ }
227
+ emitProgress(ctx);
228
+ return ctx.timeout.graceTurnsRemaining;
229
+ }
230
+ function handleToolStart(
231
+ event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>,
232
+ ctx: RunnerContext,
233
+ ): void {
234
+ ctx.progress.toolUses++;
235
+ const activity = toolNameToActivity(event.toolName, "start");
236
+ if (activity) ctx.progress.activities.push(activity);
237
+ ctx.onToolActivity?.({ toolName: event.toolName, phase: "start" });
238
+ ctx.onProgress?.({ ...ctx.progress, activities: [...ctx.progress.activities] });
239
+ }
240
+ function handleToolEnd(
241
+ event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>,
242
+ ctx: RunnerContext,
243
+ ): void {
244
+ const startActivity = toolNameToActivity(event.toolName, "start");
245
+ if (startActivity) {
246
+ const idx = ctx.progress.activities.indexOf(startActivity);
247
+ if (idx !== -1) ctx.progress.activities.splice(idx, 1);
248
+ }
249
+ ctx.onToolActivity?.({ toolName: event.toolName, phase: "end" });
250
+ ctx.onProgress?.({ ...ctx.progress, activities: [...ctx.progress.activities] });
251
+ }
252
+ function handleAgentEnd(ctx: RunnerContext): void {
253
+ if (ctx.state.settled || ctx.signal?.aborted || ctx.timeout.aborting) return;
254
+ if (ctx.resultHolder.value) {
255
+ ctx.resolve(
256
+ ctx.cleanup({ kind: "success", output: ctx.resultHolder.value, target: ctx.target }),
257
+ );
258
+ } else {
259
+ const lastText = extractLastAssistantText(ctx.session);
260
+ ctx.resolve(
261
+ ctx.cleanup({
262
+ kind: "failed",
263
+ reason: lastText
264
+ ? `Reviewer did not call submit_review. Assistant said: ${truncateText(lastText, 400)}`
265
+ : "Reviewer did not produce any output.",
266
+ target: ctx.target,
267
+ }),
268
+ );
269
+ }
270
+ }
271
+ function truncateText(text: string, maxLen: number): string {
272
+ if (text.length <= maxLen) return text;
273
+ return `${text.slice(0, maxLen)}... (${text.length - maxLen} more chars)`;
274
+ }
275
+ function handleSessionEvent(event: AgentSessionEvent, ctx: RunnerContext): void {
276
+ switch (event.type) {
277
+ case "turn_end":
278
+ ctx.timeout.graceTurnsRemaining = handleTurnEnd(ctx);
279
+ break;
280
+ case "tool_execution_start":
281
+ handleToolStart(event, ctx);
282
+ break;
283
+ case "tool_execution_end":
284
+ handleToolEnd(event, ctx);
285
+ break;
286
+ case "agent_end":
287
+ handleAgentEnd(ctx);
288
+ break;
289
+ // Ignore other events (queue_update, compaction, auto_retry, etc.)
290
+ default:
291
+ break;
292
+ }
293
+ }
294
+ export async function runReviewer(inv: ReviewerInvocation): Promise<ReviewResult> {
295
+ const {
296
+ prompt,
297
+ model,
298
+ modelRegistry,
299
+ cwd,
300
+ signal,
301
+ target,
302
+ onToolActivity,
303
+ onProgress,
304
+ timeoutMs = DEFAULT_TIMEOUT_MS,
305
+ } = inv;
306
+ if (signal?.aborted) {
307
+ return { kind: "canceled", target };
308
+ }
309
+ // Holder for the submit_review tool result
310
+ const resultHolder: { value: ReviewOutputEvent | undefined } = { value: undefined };
311
+ const submitReviewTool = createSubmitReviewTool(resultHolder);
312
+ let session: AgentSession;
313
+ try {
314
+ session = await createReviewerSession(model, cwd, submitReviewTool, modelRegistry);
315
+ } catch (err) {
316
+ const reason = `Failed to create reviewer session: ${err instanceof Error ? err.message : String(err)}`;
317
+ return { kind: "failed" as const, reason, target };
318
+ }
319
+ const progress: ReviewProgress = { turns: 0, toolUses: 0, activities: [], tokens: undefined };
320
+ const state = { settled: false };
321
+ let cancelTeardown: (() => void) | undefined;
322
+ const cleanup = (result: ReviewResult): ReviewResult => {
323
+ if (state.settled) return result;
324
+ state.settled = true;
325
+ cancelTeardown?.();
326
+ session.dispose();
327
+ return result;
328
+ };
329
+ return new Promise<ReviewResult>((resolve) => {
330
+ const timeoutRef = {
331
+ steered: false,
332
+ graceTurnsRemaining: undefined as number | undefined,
333
+ hardAbortTimer: undefined as ReturnType<typeof setTimeout> | undefined,
334
+ };
335
+ const clearHardAbort = () => {
336
+ if (timeoutRef.hardAbortTimer) {
337
+ clearTimeout(timeoutRef.hardAbortTimer);
338
+ timeoutRef.hardAbortTimer = undefined;
339
+ }
340
+ };
341
+ const ctx: RunnerContext = {
342
+ progress,
343
+ session,
344
+ target,
345
+ timeoutMs,
346
+ onToolActivity,
347
+ onProgress,
348
+ resolve,
349
+ cleanup,
350
+ resultHolder,
351
+ signal,
352
+ state,
353
+ timeout: timeoutRef,
354
+ };
355
+ session.subscribe((event: AgentSessionEvent) => handleSessionEvent(event, ctx));
356
+ // --- abort ---
357
+ const onAbort = () => {
358
+ if (state.settled) return;
359
+ clearHardAbort();
360
+ void session
361
+ .abort()
362
+ .catch(() => {})
363
+ .finally(() => {
364
+ resolve(cleanup({ kind: "canceled", target }));
365
+ });
366
+ };
367
+ signal?.addEventListener("abort", onAbort, { once: true });
368
+ // --- timeout ---
369
+ const HARD_ABORT_GRACE_MS = 120_000;
370
+ const hardAbort = () => {
371
+ if (state.settled) return;
372
+ emitProgress(ctx);
373
+ void session
374
+ .abort()
375
+ .catch(() => {})
376
+ .finally(() => {
377
+ const partialText = extractLastAssistantText(session);
378
+ resolve(cleanup({ kind: "timeout", target, timeoutMs, partialOutput: partialText }));
379
+ });
380
+ };
381
+ const onTimeout = () => {
382
+ if (state.settled) return;
383
+ timeoutRef.steered = true;
384
+ timeoutRef.graceTurnsRemaining = GRACE_TURNS;
385
+ timeoutRef.hardAbortTimer = setTimeout(hardAbort, HARD_ABORT_GRACE_MS);
386
+ timeoutRef.hardAbortTimer.unref?.();
387
+ session
388
+ .steer(STEER_MESSAGE)
389
+ .then(() => {
390
+ // Steer succeeded; grace turns tracked via events
391
+ })
392
+ .catch(() => {
393
+ clearHardAbort();
394
+ hardAbort();
395
+ });
396
+ };
397
+ const timeoutId = setTimeout(onTimeout, timeoutMs);
398
+ timeoutId.unref?.();
399
+ cancelTeardown = () => {
400
+ signal?.removeEventListener("abort", onAbort);
401
+ clearTimeout(timeoutId);
402
+ clearHardAbort();
403
+ };
404
+ let cancelledDuringSetup = false;
405
+ if (signal?.aborted) {
406
+ signal.removeEventListener("abort", onAbort);
407
+ cancelledDuringSetup = true;
408
+ onAbort();
409
+ }
410
+ if (!cancelledDuringSetup) {
411
+ session.prompt(prompt).catch((err: unknown) => {
412
+ if (!state.settled) {
413
+ resolve(
414
+ cleanup({
415
+ kind: "failed",
416
+ reason: `Reviewer session error: ${err instanceof Error ? err.message : String(err)}`,
417
+ target,
418
+ }),
419
+ );
420
+ }
421
+ });
422
+ }
423
+ });
424
+ }
@@ -0,0 +1,246 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { Input, Key, matchesKey, type SettingItem } from "@earendil-works/pi-tui";
5
+ import {
6
+ type ConfigSettingsHelpers,
7
+ loadSupiConfig,
8
+ registerConfigSettings,
9
+ } from "@mrclrchtr/supi-core";
10
+ import type { ReviewSettings } from "./types.ts";
11
+
12
+ export const REVIEW_DEFAULTS: ReviewSettings = {
13
+ reviewModel: "",
14
+ maxDiffBytes: 100_000,
15
+ autoFix: false,
16
+ };
17
+
18
+ const INHERIT_MODEL_VALUE = "(inherit)";
19
+
20
+ let reviewModelChoices: string[] = [];
21
+
22
+ export function loadReviewSettings(cwd: string, homeDir?: string): ReviewSettings {
23
+ return loadSupiConfig("review", cwd, REVIEW_DEFAULTS, { homeDir });
24
+ }
25
+
26
+ /**
27
+ * Update the model ids offered by `/supi-settings` for review depth overrides.
28
+ *
29
+ * The list is session-local and mirrors the currently available authenticated models.
30
+ */
31
+ export function setReviewModelChoices(modelChoices: string[]): void {
32
+ reviewModelChoices = Array.from(
33
+ new Set(modelChoices.map((choice) => choice.trim()).filter((choice) => choice.length > 0)),
34
+ );
35
+ }
36
+
37
+ export function registerReviewSettings(): void {
38
+ registerConfigSettings({
39
+ id: "review",
40
+ label: "Review",
41
+ section: "review",
42
+ defaults: REVIEW_DEFAULTS,
43
+ buildItems: (settings) => buildReviewSettingItems(settings),
44
+ // biome-ignore lint/complexity/useMaxParams: ConfigSettingsOptions interface callback
45
+ persistChange: (_scope, _cwd, settingId, value, helpers) => {
46
+ switch (settingId) {
47
+ case "reviewModel":
48
+ persistModelOverride(value, helpers);
49
+ break;
50
+ case "maxDiffBytes":
51
+ persistMaxDiffBytes(value, helpers);
52
+ break;
53
+ case "autoFix":
54
+ persistAutoFix(value, helpers);
55
+ break;
56
+ }
57
+ },
58
+ });
59
+ }
60
+
61
+ function persistModelOverride(value: string, helpers: ConfigSettingsHelpers): void {
62
+ if (value.trim() && value !== INHERIT_MODEL_VALUE) {
63
+ helpers.set("reviewModel", value.trim());
64
+ } else {
65
+ helpers.unset("reviewModel");
66
+ }
67
+ }
68
+
69
+ function persistMaxDiffBytes(value: string, helpers: ConfigSettingsHelpers): void {
70
+ const num = Number.parseInt(value, 10);
71
+ if (Number.isFinite(num) && num > 0) {
72
+ helpers.set("maxDiffBytes", num);
73
+ } else {
74
+ helpers.unset("maxDiffBytes");
75
+ }
76
+ }
77
+
78
+ function persistAutoFix(value: string, helpers: ConfigSettingsHelpers): void {
79
+ if (value === "on") {
80
+ helpers.set("autoFix", true);
81
+ } else {
82
+ helpers.unset("autoFix");
83
+ }
84
+ }
85
+
86
+ // ─── Scoped model helpers (workaround for pi-mono#3535) ──────────────────────
87
+
88
+ /**
89
+ * Read PI’s `enabledModels` from settings.json (the user’s configured scoped model patterns).
90
+ * Returns undefined when no scope is configured (all models available).
91
+ */
92
+ export function readPiEnabledModels(): string[] | undefined {
93
+ try {
94
+ const settingsPath = join(getAgentDir(), "settings.json");
95
+ if (!existsSync(settingsPath)) return undefined;
96
+ const raw = readFileSync(settingsPath, "utf-8");
97
+ const parsed = JSON.parse(raw);
98
+ if (Array.isArray(parsed?.enabledModels)) {
99
+ const patterns = parsed.enabledModels.filter(
100
+ (p: unknown): p is string => typeof p === "string" && p.length > 0,
101
+ );
102
+ return patterns.length > 0 ? patterns : undefined;
103
+ }
104
+ } catch {
105
+ // settings.json may not exist or be invalid JSON
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /**
111
+ * Filter available models to only those matching enabled model patterns.
112
+ * Uses simple glob-style matching (* and ? wildcards) on both bare model IDs
113
+ * and canonical provider/modelId references.
114
+ */
115
+ export function filterByEnabledModels<T extends { provider: string; id: string }>(
116
+ patterns: string[],
117
+ available: T[],
118
+ ): T[] {
119
+ const seen = new Set<string>();
120
+ const result: T[] = [];
121
+
122
+ for (const model of available) {
123
+ for (const pattern of patterns) {
124
+ if (matchModelPattern(model, pattern)) {
125
+ const key = `${model.provider}/${model.id}`;
126
+ if (!seen.has(key)) {
127
+ seen.add(key);
128
+ result.push(model);
129
+ }
130
+ break;
131
+ }
132
+ }
133
+ }
134
+
135
+ return result;
136
+ }
137
+
138
+ /**
139
+ * Match a model against a single pattern.
140
+ * - If pattern contains "/", match against canonical “provider/modelId”
141
+ * - Otherwise, match against both bare modelId and canonical ref
142
+ * - Supports * (any chars) and ? (single char) wildcards
143
+ */
144
+ function matchModelPattern(model: { provider: string; id: string }, pattern: string): boolean {
145
+ const canonical = `${model.provider}/${model.id}`;
146
+
147
+ // Pattern with a slash is always canonical
148
+ if (pattern.includes("/")) {
149
+ return simpleGlobMatch(canonical, pattern);
150
+ }
151
+
152
+ // Bare pattern: try both canonical and bare model id
153
+ return simpleGlobMatch(canonical, pattern) || simpleGlobMatch(model.id, pattern);
154
+ }
155
+
156
+ /**
157
+ * Simple glob matching with * (any chars) and ? (single char) wildcards.
158
+ * Falls back to exact case-insensitive comparison when no wildcards are present.
159
+ */
160
+ function simpleGlobMatch(text: string, pattern: string): boolean {
161
+ if (!pattern.includes("*") && !pattern.includes("?")) {
162
+ return text.toLowerCase() === pattern.toLowerCase();
163
+ }
164
+
165
+ // Convert simple glob to regex
166
+ const regexStr = pattern
167
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
168
+ .replace(/\*/g, ".*")
169
+ .replace(/\?/g, ".");
170
+
171
+ return new RegExp(`^${regexStr}$`, "i").test(text);
172
+ }
173
+
174
+ function buildReviewSettingItems(settings: ReviewSettings): SettingItem[] {
175
+ return [
176
+ {
177
+ id: "reviewModel",
178
+ label: "Review Model",
179
+ description:
180
+ "Preselect the model used by /supi-review. Inherit uses the active session model.",
181
+ currentValue: settings.reviewModel || INHERIT_MODEL_VALUE,
182
+ values: buildModelCycleValues(settings.reviewModel),
183
+ },
184
+ {
185
+ id: "maxDiffBytes",
186
+ label: "Max Diff Size",
187
+ description: "Maximum diff bytes before truncation (default 100000)",
188
+ currentValue: String(settings.maxDiffBytes),
189
+ submenu: (currentValue, done) =>
190
+ createInputSubmenu(currentValue, "Max diff bytes before truncation:", done),
191
+ },
192
+ {
193
+ id: "autoFix",
194
+ label: "Auto-Fix After Review",
195
+ description: "Automatically trigger a fix turn after review completes with findings",
196
+ currentValue: settings.autoFix ? "on" : "off",
197
+ values: ["on", "off"],
198
+ },
199
+ ];
200
+ }
201
+
202
+ function buildModelCycleValues(currentValue: string): string[] {
203
+ const values = [INHERIT_MODEL_VALUE];
204
+ if (currentValue && !reviewModelChoices.includes(currentValue)) {
205
+ values.push(currentValue);
206
+ }
207
+ values.push(...reviewModelChoices);
208
+ return values;
209
+ }
210
+
211
+ function createInputSubmenu(
212
+ currentValue: string,
213
+ label: string,
214
+ done: (selectedValue?: string) => void,
215
+ ): {
216
+ render: (width: number) => string[];
217
+ invalidate: () => void;
218
+ handleInput: (data: string) => boolean;
219
+ } {
220
+ const input = new Input();
221
+ input.setValue(currentValue);
222
+
223
+ return {
224
+ render: (width: number) => {
225
+ const lines = [` ${label}`];
226
+ lines.push(...input.render(width));
227
+ lines.push(" enter confirm • esc cancel");
228
+ return lines;
229
+ },
230
+ invalidate: () => {
231
+ input.invalidate();
232
+ },
233
+ handleInput: (data: string) => {
234
+ if (matchesKey(data, Key.escape)) {
235
+ done();
236
+ return true;
237
+ }
238
+ if (matchesKey(data, Key.enter)) {
239
+ done(input.getValue());
240
+ return true;
241
+ }
242
+ input.handleInput(data);
243
+ return true;
244
+ },
245
+ };
246
+ }