@mrclrchtr/supi-review 1.3.1 → 1.5.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 (42) hide show
  1. package/README.md +86 -52
  2. package/package.json +2 -9
  3. package/src/git.ts +107 -1
  4. package/src/history/collect.ts +210 -0
  5. package/src/history/synthesize.ts +109 -0
  6. package/src/model.ts +100 -0
  7. package/src/review.ts +215 -287
  8. package/src/target/packet.ts +216 -0
  9. package/src/tool/brief-runner.ts +269 -0
  10. package/src/tool/review-runner.ts +434 -0
  11. package/src/tool/runner-types.ts +51 -0
  12. package/src/tool/schemas.ts +31 -0
  13. package/src/types.ts +96 -14
  14. package/src/ui/flow.ts +216 -0
  15. package/src/{format-content.ts → ui/format-content.ts} +29 -12
  16. package/src/ui/progress-widget.ts +86 -0
  17. package/src/{renderer.ts → ui/renderer.ts} +43 -14
  18. package/node_modules/@mrclrchtr/supi-core/README.md +0 -96
  19. package/node_modules/@mrclrchtr/supi-core/package.json +0 -44
  20. package/node_modules/@mrclrchtr/supi-core/src/api.ts +0 -83
  21. package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +0 -76
  22. package/node_modules/@mrclrchtr/supi-core/src/config.ts +0 -186
  23. package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +0 -119
  24. package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +0 -36
  25. package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +0 -31
  26. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -255
  27. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +0 -1
  28. package/node_modules/@mrclrchtr/supi-core/src/index.ts +0 -83
  29. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +0 -170
  30. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +0 -54
  31. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +0 -29
  32. package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +0 -15
  33. package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +0 -41
  34. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +0 -226
  35. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +0 -60
  36. package/src/progress-widget.ts +0 -82
  37. package/src/prompts.ts +0 -116
  38. package/src/runner-types.ts +0 -32
  39. package/src/runner.ts +0 -424
  40. package/src/settings.ts +0 -246
  41. package/src/target-resolution.ts +0 -102
  42. package/src/ui.ts +0 -116
package/src/model.ts ADDED
@@ -0,0 +1,100 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import { type ExtensionContext, SettingsManager } from "@earendil-works/pi-coding-agent";
3
+ import type { ReviewModelSelection } from "./types.ts";
4
+
5
+ /** Build the canonical `provider/modelId` string used throughout the review flow. */
6
+ export function toCanonicalModelId(
7
+ model: Pick<NonNullable<ExtensionContext["model"]>, "provider" | "id">,
8
+ ): string {
9
+ return `${model.provider}/${model.id}`;
10
+ }
11
+
12
+ /**
13
+ * List review models using Pi's scoped model configuration only.
14
+ *
15
+ * If no scoped model patterns are configured, the review picker is intentionally empty.
16
+ */
17
+ export function getSelectableReviewModels(
18
+ ctx: Pick<ExtensionContext, "cwd" | "modelRegistry" | "model">,
19
+ enabledModelPatterns = SettingsManager.create(ctx.cwd).getEnabledModels(),
20
+ ): ReviewModelSelection[] {
21
+ if (!enabledModelPatterns || enabledModelPatterns.length === 0) {
22
+ return [];
23
+ }
24
+
25
+ const byCanonicalId = new Map<string, ReviewModelSelection>();
26
+ const availableModels = filterByEnabledModels(
27
+ enabledModelPatterns,
28
+ ctx.modelRegistry.getAvailable(),
29
+ );
30
+
31
+ const addModel = (
32
+ // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
33
+ model: Model<any>,
34
+ isCurrent: boolean,
35
+ ) => {
36
+ const canonicalId = toCanonicalModelId(model);
37
+ const existing = byCanonicalId.get(canonicalId);
38
+ if (existing) {
39
+ if (isCurrent) existing.isCurrent = true;
40
+ return;
41
+ }
42
+
43
+ byCanonicalId.set(canonicalId, {
44
+ canonicalId,
45
+ provider: model.provider,
46
+ id: model.id,
47
+ model,
48
+ label: model.name ?? canonicalId,
49
+ description: canonicalId,
50
+ isCurrent,
51
+ });
52
+ };
53
+
54
+ if (ctx.model && matchModelPatterns(ctx.model, enabledModelPatterns)) {
55
+ addModel(ctx.model, true);
56
+ }
57
+
58
+ for (const model of availableModels) {
59
+ addModel(
60
+ model,
61
+ ctx.model ? toCanonicalModelId(model) === toCanonicalModelId(ctx.model) : false,
62
+ );
63
+ }
64
+
65
+ return Array.from(byCanonicalId.values()).sort((a, b) => {
66
+ if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
67
+ return a.canonicalId.localeCompare(b.canonicalId);
68
+ });
69
+ }
70
+
71
+ function filterByEnabledModels<T extends { provider: string; id: string }>(
72
+ patterns: string[],
73
+ models: T[],
74
+ ): T[] {
75
+ return models.filter((model) => matchModelPatterns(model, patterns));
76
+ }
77
+
78
+ function matchModelPatterns(model: { provider: string; id: string }, patterns: string[]): boolean {
79
+ return patterns.some((pattern) => matchModelPattern(model, pattern));
80
+ }
81
+
82
+ function matchModelPattern(model: { provider: string; id: string }, pattern: string): boolean {
83
+ const canonicalId = `${model.provider}/${model.id}`;
84
+ if (pattern.includes("/")) {
85
+ return simpleGlobMatch(canonicalId, pattern);
86
+ }
87
+ return simpleGlobMatch(model.id, pattern) || simpleGlobMatch(canonicalId, pattern);
88
+ }
89
+
90
+ function simpleGlobMatch(text: string, pattern: string): boolean {
91
+ if (!pattern.includes("*") && !pattern.includes("?")) {
92
+ return text.toLowerCase() === pattern.toLowerCase();
93
+ }
94
+
95
+ const regex = pattern
96
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
97
+ .replace(/\*/g, ".*")
98
+ .replace(/\?/g, ".");
99
+ return new RegExp(`^${regex}$`, "i").test(text);
100
+ }
package/src/review.ts CHANGED
@@ -1,168 +1,164 @@
1
- import type { Model } from "@earendil-works/pi-ai";
2
- import type { ExtensionAPI, ModelRegistry } from "@earendil-works/pi-coding-agent";
3
- import { formatReviewContent } from "./format-content.ts";
4
- import {
5
- getCommitFileNames,
6
- getCommitShow,
7
- getDiff,
8
- getDiffFileNames,
9
- getMergeBase,
10
- getUncommittedDiff,
11
- getUncommittedFileNames,
12
- } from "./git.ts";
13
- import { ReviewProgressWidget } from "./progress-widget.ts";
14
- import { buildReviewPrompt } from "./prompts.ts";
15
- import { registerReviewRenderer } from "./renderer.ts";
16
- import { runReviewer } from "./runner.ts";
17
- import type { ReviewerInvocation } from "./runner-types.ts";
18
- import {
19
- filterByEnabledModels,
20
- loadReviewSettings,
21
- readPiEnabledModels,
22
- registerReviewSettings,
23
- setReviewModelChoices,
24
- } from "./settings.ts";
25
- import { resolveGitTarget } from "./target-resolution.ts";
26
- import type { ReviewResult, ReviewTarget } from "./types.ts";
27
- import { selectAutoFix, selectBranch, selectCommit, selectPreset } from "./ui.ts";
1
+ import { buildSessionContext, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { resolveBranchSnapshot, resolveCommitSnapshot, resolveWorkingTreeSnapshot } from "./git.ts";
3
+ import { collectHistoryEvidence } from "./history/collect.ts";
4
+ import { synthesizeReviewBrief } from "./history/synthesize.ts";
5
+ import { buildReviewPacket } from "./target/packet.ts";
6
+ import { runReviewer } from "./tool/review-runner.ts";
7
+ import type { BriefSynthesisRunResult } from "./tool/runner-types.ts";
8
+ import type { ReviewPlan, ReviewResult, ReviewSnapshot, ReviewTargetSpec } from "./types.ts";
9
+ import { collectReviewNote, previewReviewPlan, selectModel, selectTarget } from "./ui/flow.ts";
10
+ import { formatReviewContent } from "./ui/format-content.ts";
11
+ import { ReviewProgressWidget } from "./ui/progress-widget.ts";
12
+ import { registerReviewRenderer } from "./ui/renderer.ts";
28
13
 
29
14
  type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
30
15
 
31
- interface ReviewExecutionOptions {
32
- target: ReviewTarget;
33
- maxDiffBytes: number;
34
- ctx: CommandContext;
35
- signal?: AbortSignal;
36
- onToolActivity?: ReviewerInvocation["onToolActivity"];
37
- onProgress?: ReviewerInvocation["onProgress"];
38
- }
39
-
40
16
  export default function reviewExtension(pi: ExtensionAPI) {
41
- registerReviewSettings();
42
17
  registerReviewRenderer(pi);
43
18
 
44
- const syncReviewModelChoices = (ctx: {
45
- modelRegistry: {
46
- getAvailable(): Array<{ provider: string; id: string }> | undefined;
47
- };
48
- }) => {
49
- const allModels = ctx.modelRegistry.getAvailable();
50
- if (!allModels) {
51
- setReviewModelChoices([]);
52
- return;
53
- }
54
-
55
- // Try to respect PI’s scoped models (enabledModels).
56
- // Workaround for pi-mono#3535 — swap to ctx.scopedModels when exposed by PI.
57
- const enabledPatterns = readPiEnabledModels();
58
- const models = enabledPatterns ? filterByEnabledModels(enabledPatterns, allModels) : allModels;
59
-
60
- setReviewModelChoices(toCanonicalModelIds(models));
61
- };
62
-
63
- pi.on("session_start", async (_event, ctx) => {
64
- syncReviewModelChoices(ctx);
65
- });
66
-
67
- pi.on("model_select", async (_event, ctx) => {
68
- syncReviewModelChoices(ctx);
69
- });
70
-
71
19
  pi.registerCommand("supi-review", {
72
- description: "Run a structured code review",
20
+ description: "Run a structured code review informed by the current session history",
73
21
  handler: async (_args, ctx) => {
74
- const settings = loadReviewSettings(ctx.cwd);
75
- await handleInteractive(settings.maxDiffBytes, settings.autoFix, ctx, pi);
22
+ await handleInteractive(ctx, pi);
76
23
  },
77
24
  });
78
25
  }
79
26
 
80
- async function handleInteractive(
81
- maxDiffBytes: number,
82
- autoFixDefault: boolean,
83
- ctx: CommandContext,
84
- pi: ExtensionAPI,
85
- ): Promise<void> {
86
- const preset = await selectPreset(ctx);
87
- if (!preset) return;
88
-
89
- const autoFix = await selectAutoFix(ctx, autoFixDefault);
90
- if (autoFix === undefined) return;
27
+ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise<void> {
28
+ if (!ctx.hasUI) {
29
+ return;
30
+ }
91
31
 
92
- const target = await resolvePresetTarget(preset, ctx);
32
+ const target = await selectTarget(ctx);
93
33
  if (!target) return;
94
34
 
95
- const result = await runReviewWithLoader(target, maxDiffBytes, ctx, pi);
96
- injectReviewMessage(pi, result, autoFix);
35
+ const model = await selectModel(ctx);
36
+ if (!model) return;
37
+
38
+ const note = await collectReviewNote(ctx);
39
+ if (note === undefined) return;
40
+ const normalizedNote = note.trim() || undefined;
41
+
42
+ const snapshot = await resolveReviewSnapshot(target, ctx);
43
+ if (!snapshot) return;
44
+
45
+ const sessionContext = buildSessionContext(
46
+ ctx.sessionManager.getEntries(),
47
+ ctx.sessionManager.getLeafId(),
48
+ );
49
+ const evidence = collectHistoryEvidence(sessionContext.messages, snapshot, normalizedNote);
50
+ const synthesis = await runBriefWithLoader({
51
+ snapshot,
52
+ modelId: model.canonicalId,
53
+ ctx,
54
+ pi,
55
+ run: (signal, onProgress) =>
56
+ synthesizeReviewBrief({
57
+ model,
58
+ modelRegistry: ctx.modelRegistry,
59
+ cwd: ctx.cwd,
60
+ snapshot,
61
+ evidence,
62
+ note: normalizedNote,
63
+ signal,
64
+ onProgress,
65
+ }),
66
+ });
67
+
68
+ if (synthesis.kind !== "success") {
69
+ notifySynthesisFailure(synthesis, ctx);
70
+ return;
71
+ }
72
+
73
+ const brief = {
74
+ ...synthesis.brief,
75
+ note: normalizedNote,
76
+ evidenceCount: evidence.length,
77
+ };
78
+
79
+ const packet = buildReviewPacket(snapshot, brief, model);
80
+ const plan: ReviewPlan = { model, snapshot, brief, packet };
81
+
82
+ const approved = await previewReviewPlan(ctx, plan);
83
+ if (!approved) return;
84
+
85
+ const result = await runReviewWithLoader(plan, ctx, pi);
86
+ injectReviewMessage(pi, result);
97
87
  }
98
88
 
99
- async function resolvePresetTarget(
100
- preset: import("./ui.ts").Preset,
89
+ async function resolveReviewSnapshot(
90
+ target: ReviewTargetSpec,
101
91
  ctx: CommandContext,
102
- ): Promise<ReviewTarget | undefined> {
103
- switch (preset) {
104
- case "base-branch": {
105
- const branch = await selectBranch(ctx);
106
- if (!branch) return undefined;
107
- const baseSha = await getMergeBase(ctx.cwd, branch);
108
- if (!baseSha) {
109
- ctx.ui.notify(`No merge base found for ${branch}`, "error");
110
- return undefined;
111
- }
112
- const [diff, changedFiles] = await Promise.all([
113
- getDiff(ctx.cwd, baseSha),
114
- getDiffFileNames(ctx.cwd, baseSha),
115
- ]);
116
- return { type: "base-branch", branch, diff, changedFiles };
117
- }
118
- case "uncommitted": {
119
- const [diff, changedFiles] = await Promise.all([
120
- getUncommittedDiff(ctx.cwd),
121
- getUncommittedFileNames(ctx.cwd),
122
- ]);
123
- if (!diff) {
124
- ctx.ui.notify("No uncommitted changes", "warning");
125
- return undefined;
126
- }
127
- return { type: "uncommitted", diff, changedFiles };
128
- }
129
- case "commit": {
130
- const sha = await selectCommit(ctx);
131
- if (!sha) return undefined;
132
- const [show, changedFiles] = await Promise.all([
133
- getCommitShow(ctx.cwd, sha),
134
- getCommitFileNames(ctx.cwd, sha),
135
- ]);
136
- return { type: "commit", sha, show, changedFiles };
137
- }
138
- case "custom": {
139
- const instructions = await ctx.ui.editor(
140
- "Review instructions",
141
- "Focus on security, performance, and correctness…",
142
- );
143
- if (!instructions?.trim()) {
144
- ctx.ui.notify("No instructions provided", "warning");
145
- return undefined;
146
- }
147
- const changedFiles = await getUncommittedFileNames(ctx.cwd);
148
- return { type: "custom", instructions: instructions.trim(), changedFiles };
149
- }
92
+ ): Promise<ReviewSnapshot | undefined> {
93
+ const snapshot =
94
+ target.kind === "working-tree"
95
+ ? await resolveWorkingTreeSnapshot(ctx.cwd)
96
+ : target.kind === "branch"
97
+ ? await resolveBranchSnapshot(ctx.cwd, target.base)
98
+ : await resolveCommitSnapshot(ctx.cwd, target.sha);
99
+
100
+ if (snapshot) {
101
+ return snapshot;
150
102
  }
151
- }
152
103
 
153
- async function executeReview(options: ReviewExecutionOptions): Promise<ReviewResult> {
154
- const { target, ctx, signal } = options;
155
- const resolved = await resolveGitTarget(target, ctx);
156
- if (resolved.kind !== "success") return resolved;
157
- if (signal?.aborted) {
158
- return { kind: "canceled", target: resolved.target };
104
+ switch (target.kind) {
105
+ case "working-tree":
106
+ ctx.ui.notify("No working tree changes found", "warning");
107
+ break;
108
+ case "branch":
109
+ ctx.ui.notify(`No reviewable changes found against ${target.base}`, "warning");
110
+ break;
111
+ case "commit":
112
+ ctx.ui.notify(`Unable to resolve commit ${target.sha}`, "error");
113
+ break;
159
114
  }
160
- return runReview({ ...options, target: resolved.target });
115
+
116
+ return undefined;
117
+ }
118
+
119
+ async function runBriefWithLoader(options: {
120
+ snapshot: ReviewSnapshot;
121
+ modelId: string;
122
+ ctx: CommandContext;
123
+ pi: ExtensionAPI;
124
+ run: (
125
+ signal: AbortSignal,
126
+ onProgress: (progress: Parameters<ReviewProgressWidget["updateProgress"]>[0]) => void,
127
+ ) => Promise<BriefSynthesisRunResult>;
128
+ }): Promise<BriefSynthesisRunResult> {
129
+ const { snapshot, modelId, ctx, pi, run } = options;
130
+ return ctx.ui.custom<BriefSynthesisRunResult>((tui, theme, _kb, done) => {
131
+ const widget = new ReviewProgressWidget(tui, theme, "Synthesizing review brief…");
132
+ let finished = false;
133
+
134
+ const finish = (result: BriefSynthesisRunResult) => {
135
+ if (finished) return;
136
+ finished = true;
137
+ pi.events.emit("supi:working:end", { source: "supi-review" });
138
+ widget.dispose();
139
+ done(result);
140
+ };
141
+
142
+ widget.onAbort = () => {
143
+ // The widget aborts its signal; the runner resolves with `canceled`.
144
+ };
145
+
146
+ pi.events.emit("supi:working:start", { source: "supi-review" });
147
+ run(widget.signal, (progress) => widget.updateProgress(progress))
148
+ .then((result) => finish(result))
149
+ .catch((error) => {
150
+ finish({
151
+ kind: "failed",
152
+ reason: `Brief synthesis failed for ${snapshot.title} on ${modelId}: ${error instanceof Error ? error.message : String(error)}`,
153
+ });
154
+ });
155
+
156
+ return widget;
157
+ });
161
158
  }
162
159
 
163
160
  async function runReviewWithLoader(
164
- target: ReviewTarget,
165
- maxDiffBytes: number,
161
+ plan: ReviewPlan,
166
162
  ctx: CommandContext,
167
163
  pi: ExtensionAPI,
168
164
  ): Promise<ReviewResult> {
@@ -174,30 +170,33 @@ async function runReviewWithLoader(
174
170
  if (finished) return;
175
171
  finished = true;
176
172
  pi.events.emit("supi:working:end", { source: "supi-review" });
173
+ widget.dispose();
177
174
  done(result);
178
175
  };
179
176
 
180
- widget.onAbort = () => finish({ kind: "canceled", target });
177
+ widget.onAbort = () => {
178
+ // The widget aborts its signal; the runner resolves with `canceled`.
179
+ };
181
180
 
182
181
  pi.events.emit("supi:working:start", { source: "supi-review" });
183
-
184
- executeReview({
185
- target,
186
- maxDiffBytes,
187
- ctx,
182
+ runReviewer({
183
+ prompt: plan.packet.prompt,
184
+ model: plan.model,
185
+ modelRegistry: ctx.modelRegistry,
186
+ cwd: ctx.cwd,
188
187
  signal: widget.signal,
188
+ snapshot: plan.snapshot,
189
+ brief: plan.brief,
189
190
  onProgress: (progress) => widget.updateProgress(progress),
190
191
  })
191
- .then((result) => {
192
- if (widget.signal.aborted) return;
193
- finish(result);
194
- })
195
- .catch((err) => {
196
- if (widget.signal.aborted) return;
192
+ .then((result) => finish(result))
193
+ .catch((error) => {
197
194
  finish({
198
195
  kind: "failed",
199
- reason: err instanceof Error ? err.message : String(err),
200
- target,
196
+ reason: error instanceof Error ? error.message : String(error),
197
+ snapshot: plan.snapshot,
198
+ brief: plan.brief,
199
+ modelId: plan.model.canonicalId,
201
200
  });
202
201
  });
203
202
 
@@ -205,139 +204,27 @@ async function runReviewWithLoader(
205
204
  });
206
205
  }
207
206
 
208
- function runReview(options: ReviewExecutionOptions): Promise<ReviewResult> {
209
- const { target, maxDiffBytes, ctx, signal, onToolActivity, onProgress } = options;
210
- const settings = loadReviewSettings(ctx.cwd);
211
- // ctx.modelRegistry is available because CommandContext extends ExtensionContext
212
- const model = resolveReviewerModel(settings, ctx.modelRegistry, ctx.model);
213
- if (!model) {
214
- return Promise.resolve({
215
- kind: "failed",
216
- reason:
217
- "No review model configured. Set a review model in settings or load a model in the session.",
218
- target,
219
- });
220
- }
221
-
222
- let diffOrBody = "";
223
- if (target.type === "base-branch" || target.type === "uncommitted") {
224
- diffOrBody = target.diff;
225
- } else if (target.type === "commit") {
226
- diffOrBody = target.show;
227
- }
228
-
229
- const truncated =
230
- target.type === "custom"
231
- ? { text: "", wasTruncated: false, truncatedBytes: 0 }
232
- : maybeTruncateDiff(diffOrBody, maxDiffBytes);
233
- const prompt = buildReviewPrompt(
234
- target,
235
- truncated.text,
236
- truncated.wasTruncated
237
- ? { truncated: true, truncatedBytes: truncated.truncatedBytes }
238
- : undefined,
239
- );
240
-
241
- const invocation: ReviewerInvocation = {
242
- prompt,
243
- model,
244
- modelRegistry: ctx.modelRegistry,
245
- cwd: ctx.cwd,
246
- target,
247
- signal,
248
- onToolActivity,
249
- onProgress,
250
- };
251
-
252
- return runReviewer(invocation);
253
- }
254
-
255
- function toCanonicalModelIds(
256
- models: Array<{ provider: string; id: string }> | undefined,
257
- ): string[] {
258
- if (!models) return [];
259
- return Array.from(new Set(models.map((model) => `${model.provider}/${model.id}`)));
260
- }
261
-
262
- function resolveReviewerModel(
263
- settings: ReturnType<typeof loadReviewSettings>,
264
- modelRegistry: ModelRegistry,
265
- // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
266
- sessionModel: Model<any> | undefined,
267
- // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
268
- ): Model<any> | undefined {
269
- const modelString = settings.reviewModel || resolveSessionModelId(sessionModel);
270
- if (!modelString) return undefined;
271
-
272
- // Parse "provider/model-id" format
273
- const slashIndex = modelString.indexOf("/");
274
- if (slashIndex === -1) {
275
- throw new Error(`Invalid review model format: "${modelString}". Expected "provider/model-id".`);
276
- }
277
-
278
- const provider = modelString.slice(0, slashIndex);
279
- const modelId = modelString.slice(slashIndex + 1);
280
- const found = modelRegistry.find(provider, modelId);
281
- if (!found) {
282
- throw new Error(`Review model "${modelString}" not found. Check your review model setting.`);
283
- }
284
- return found;
285
- }
286
-
287
- function resolveSessionModelId(
288
- model: Pick<NonNullable<CommandContext["model"]>, "id" | "provider"> | undefined,
289
- ): string | undefined {
290
- if (!model?.id) return undefined;
291
- return model.provider ? `${model.provider}/${model.id}` : model.id;
292
- }
293
-
294
- function maybeTruncateDiff(
295
- diff: string,
296
- maxBytes: number,
297
- ): { text: string; wasTruncated: boolean; truncatedBytes: number } {
298
- const encoder = new TextEncoder();
299
- const bytes = encoder.encode(diff);
300
- if (bytes.length <= maxBytes) {
301
- return { text: diff, wasTruncated: false, truncatedBytes: 0 };
302
- }
303
-
304
- const headBytes = Math.floor(maxBytes / 2);
305
- const tailBytes = maxBytes - headBytes;
306
-
307
- let headEnd = 0;
308
- let headCount = 0;
309
- for (let i = 0; i < diff.length; i++) {
310
- const b = encoder.encode(diff[i] ?? "");
311
- if (headCount + b.length > headBytes) break;
312
- headCount += b.length;
313
- headEnd = i + 1;
314
- }
315
-
316
- let tailStart = diff.length;
317
- let tailCount = 0;
318
- for (let i = diff.length - 1; i >= 0; i--) {
319
- const b = encoder.encode(diff[i] ?? "");
320
- if (tailCount + b.length > tailBytes) break;
321
- tailCount += b.length;
322
- tailStart = i;
323
- }
324
-
325
- if (tailStart <= headEnd) {
326
- const truncated = new TextDecoder().decode(bytes.slice(0, maxBytes));
327
- return { text: truncated, wasTruncated: true, truncatedBytes: bytes.length - maxBytes };
207
+ function notifySynthesisFailure(
208
+ result: Exclude<BriefSynthesisRunResult, { kind: "success" }>,
209
+ ctx: CommandContext,
210
+ ): void {
211
+ switch (result.kind) {
212
+ case "failed":
213
+ ctx.ui.notify(result.reason, "error");
214
+ break;
215
+ case "timeout":
216
+ ctx.ui.notify(
217
+ `Brief synthesis timed out after ${(result.timeoutMs / 1000).toFixed(0)}s`,
218
+ "warning",
219
+ );
220
+ break;
221
+ case "canceled":
222
+ ctx.ui.notify("Review canceled", "warning");
223
+ break;
328
224
  }
329
-
330
- const head = diff.slice(0, headEnd);
331
- const tail = diff.slice(tailStart);
332
- const omitted = bytes.length - headCount - tailCount;
333
- return {
334
- text: `${head}\n[... truncated ${omitted} bytes ...]\n${tail}`,
335
- wasTruncated: true,
336
- truncatedBytes: omitted,
337
- };
338
225
  }
339
226
 
340
- function injectReviewMessage(pi: ExtensionAPI, result: ReviewResult, autoFix: boolean): void {
227
+ function injectReviewMessage(pi: ExtensionAPI, result: ReviewResult): void {
341
228
  pi.sendMessage({
342
229
  customType: "supi-review",
343
230
  content: formatReviewContent(result),
@@ -345,7 +232,48 @@ function injectReviewMessage(pi: ExtensionAPI, result: ReviewResult, autoFix: bo
345
232
  details: { result },
346
233
  });
347
234
 
348
- if (autoFix && result.kind === "success" && result.output.findings.length > 0) {
349
- pi.sendUserMessage("Fix all findings from the review above.");
235
+ maybeQueueReviewFollowUp(pi, result);
236
+ }
237
+
238
+ function maybeQueueReviewFollowUp(pi: ExtensionAPI, result: ReviewResult): void {
239
+ if (result.kind !== "success" || result.output.findings.length === 0) {
240
+ return;
350
241
  }
242
+
243
+ pi.sendMessage(
244
+ {
245
+ customType: "supi-review-followup",
246
+ content: buildReviewFollowUpInstruction(result),
247
+ display: false,
248
+ details: {
249
+ findingCount: result.output.findings.length,
250
+ findings: result.output.findings.map((finding, index) => ({
251
+ number: index + 1,
252
+ title: finding.title,
253
+ })),
254
+ },
255
+ },
256
+ { triggerTurn: true },
257
+ );
258
+ }
259
+
260
+ function buildReviewFollowUpInstruction(
261
+ result: Extract<ReviewResult, { kind: "success" }>,
262
+ ): string {
263
+ const findings = result.output.findings.map(
264
+ (finding, index) => `- #${index + 1}: ${finding.title}`,
265
+ );
266
+
267
+ return [
268
+ "A code review just completed and the result is available in the preceding `supi-review` message.",
269
+ "Do not start fixing code immediately.",
270
+ "Your next task is to ask the user what to do with the review findings.",
271
+ "If the `ask_user` tool is available, use it for this decision.",
272
+ "Offer exactly these options: Done, Fix all, Fix selected, Verify findings.",
273
+ "If the user chooses Fix selected, ask a follow-up question listing the findings by number/title.",
274
+ "If the user chooses Verify findings, verify the findings first and then ask again whether to Fix all or Fix selected.",
275
+ "",
276
+ "Current findings:",
277
+ ...findings,
278
+ ].join("\n");
351
279
  }