@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/prompts.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { ReviewTarget } from "./types.ts";
2
+
3
+ export interface DiffStats {
4
+ files: number;
5
+ additions: number;
6
+ deletions: number;
7
+ }
8
+
9
+ export interface BuildPromptOptions {
10
+ truncated?: boolean;
11
+ truncatedBytes?: number;
12
+ }
13
+
14
+ export function buildReviewPrompt(
15
+ target: ReviewTarget,
16
+ diff: string = "",
17
+ options: BuildPromptOptions = {},
18
+ ): string {
19
+ const parts: string[] = [];
20
+
21
+ // Preamble with target metadata
22
+ parts.push(buildPreamble(target));
23
+ parts.push("");
24
+
25
+ if (options.truncated && options.truncatedBytes && options.truncatedBytes > 0) {
26
+ parts.push(
27
+ `> Note: the diff was truncated (${options.truncatedBytes} bytes omitted from the middle).`,
28
+ );
29
+ parts.push("");
30
+ }
31
+
32
+ // Diff or custom instructions
33
+ parts.push("## Changes to review");
34
+ parts.push("");
35
+
36
+ if (target.type === "custom") {
37
+ parts.push(target.instructions);
38
+ } else {
39
+ parts.push("```diff");
40
+ parts.push(diff);
41
+ parts.push("```");
42
+ }
43
+
44
+ return parts.join("\n");
45
+ }
46
+
47
+ export function parseDiffStats(text: string): DiffStats {
48
+ let files = 0;
49
+ let additions = 0;
50
+ let deletions = 0;
51
+ let inDiff = false;
52
+
53
+ for (const line of text.split("\n")) {
54
+ if (line.startsWith("diff --git ")) {
55
+ files++;
56
+ inDiff = true;
57
+ } else if (inDiff) {
58
+ if (line.startsWith("+") && !line.startsWith("+++")) {
59
+ additions++;
60
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
61
+ deletions++;
62
+ }
63
+ }
64
+ }
65
+
66
+ return { files, additions, deletions };
67
+ }
68
+
69
+ function buildPreamble(target: ReviewTarget): string {
70
+ const changedFilesLine =
71
+ target.changedFiles && target.changedFiles.length > 0
72
+ ? `**Changed files:** ${target.changedFiles.join(", ")}`
73
+ : undefined;
74
+
75
+ switch (target.type) {
76
+ case "base-branch": {
77
+ const stats = parseDiffStats(target.diff);
78
+ const lines = [
79
+ `# Review: changes on current branch vs ${target.branch}`,
80
+ `**Target:** base branch \`${target.branch}\``,
81
+ `**Files changed:** ${stats.files}`,
82
+ `**Changes:** +${stats.additions} / -${stats.deletions} lines`,
83
+ ];
84
+ if (changedFilesLine) lines.push(changedFilesLine);
85
+ return lines.join("\n");
86
+ }
87
+ case "uncommitted": {
88
+ const stats = parseDiffStats(target.diff);
89
+ const lines = [
90
+ "# Review: uncommitted changes",
91
+ "**Target:** working tree (staged + unstaged + untracked)",
92
+ `**Files changed:** ${stats.files}`,
93
+ `**Changes:** +${stats.additions} / -${stats.deletions} lines`,
94
+ ];
95
+ if (changedFilesLine) lines.push(changedFilesLine);
96
+ return lines.join("\n");
97
+ }
98
+ case "commit": {
99
+ const stats = parseDiffStats(target.show);
100
+ const lines = [
101
+ `# Review: commit ${target.sha}`,
102
+ `**Target:** commit \`${target.sha}\``,
103
+ `**Files changed:** ${stats.files}`,
104
+ `**Changes:** +${stats.additions} / -${stats.deletions} lines`,
105
+ ];
106
+ if (changedFilesLine) lines.push(changedFilesLine);
107
+ return lines.join("\n");
108
+ }
109
+ case "custom": {
110
+ const lines = ["# Review: custom instructions", "**Target:** user-provided review task"];
111
+ if (changedFilesLine) lines.push(changedFilesLine);
112
+ lines.push("No diff provided — follow the instructions below.");
113
+ return lines.join("\n");
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,181 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
3
+ import type { ReviewFinding, ReviewResult } from "./types.ts";
4
+
5
+ export function registerReviewRenderer(pi: ExtensionAPI): void {
6
+ pi.registerMessageRenderer("supi-review", (message, { expanded }, theme) => {
7
+ const result = (message.details as { result?: ReviewResult } | undefined)?.result;
8
+ if (!result) {
9
+ return new Text(theme.fg("dim", "No review data"), 1, 0);
10
+ }
11
+
12
+ switch (result.kind) {
13
+ case "success":
14
+ return renderSuccess(result, theme, expanded);
15
+ case "failed":
16
+ return renderFailed(result, theme);
17
+ case "canceled":
18
+ return renderCanceled(result, theme);
19
+ case "timeout":
20
+ return renderTimeout(result, theme);
21
+ default:
22
+ return new Text(theme.fg("dim", "Unknown review state"), 1, 0);
23
+ }
24
+ });
25
+ }
26
+
27
+ function renderSuccess(
28
+ result: Extract<ReviewResult, { kind: "success" }>,
29
+ theme: Parameters<Parameters<ExtensionAPI["registerMessageRenderer"]>[1]>[2],
30
+ expanded: boolean,
31
+ ): Container {
32
+ const container = new Container();
33
+ const output = result.output;
34
+
35
+ container.addChild(new Text(theme.fg("accent", "◆ Code Review Results"), 1, 0));
36
+ container.addChild(new Spacer(1));
37
+
38
+ const normalizedVerdict = output.overall_correctness.toLowerCase();
39
+ const verdictColor = normalizedVerdict.includes("incorrect")
40
+ ? "warning"
41
+ : normalizedVerdict.includes("correct")
42
+ ? "success"
43
+ : "warning";
44
+ container.addChild(
45
+ new Text(
46
+ `${theme.fg(verdictColor, "●")} ${theme.fg(verdictColor, output.overall_correctness)}` +
47
+ theme.fg("dim", ` (confidence: ${(output.overall_confidence_score * 100).toFixed(0)}%)`),
48
+ 1,
49
+ 0,
50
+ ),
51
+ );
52
+
53
+ if (output.overall_explanation) {
54
+ container.addChild(new Spacer(1));
55
+ container.addChild(new Text(theme.fg("dim", output.overall_explanation), 1, 0));
56
+ }
57
+
58
+ if (expanded) {
59
+ container.addChild(new Spacer(1));
60
+
61
+ if (output.findings.length === 0) {
62
+ container.addChild(new Text(theme.fg("success", "✓ No issues found"), 1, 0));
63
+ } else {
64
+ container.addChild(
65
+ new Text(theme.fg("accent", `Findings (${output.findings.length})`), 1, 0),
66
+ );
67
+ for (const finding of output.findings) {
68
+ container.addChild(renderFinding(finding, theme));
69
+ }
70
+ }
71
+ }
72
+
73
+ return container;
74
+ }
75
+
76
+ function renderFinding(
77
+ finding: ReviewFinding,
78
+ theme: Parameters<Parameters<ExtensionAPI["registerMessageRenderer"]>[1]>[2],
79
+ ): Container {
80
+ const container = new Container();
81
+ const priorityColor = priorityColorName(finding.priority);
82
+ const priorityLabel = priorityText(finding.priority);
83
+
84
+ const loc = finding.code_location;
85
+ const locText =
86
+ loc.absolute_file_path +
87
+ (loc.line_range.start === loc.line_range.end
88
+ ? `:${loc.line_range.start}`
89
+ : `:${loc.line_range.start}-${loc.line_range.end}`);
90
+
91
+ container.addChild(new Spacer(1));
92
+ container.addChild(
93
+ new Text(
94
+ `${theme.fg(priorityColor, "●")} ${theme.fg("text", finding.title)} ${theme.fg("dim", priorityLabel)}`,
95
+ 1,
96
+ 0,
97
+ ),
98
+ );
99
+ container.addChild(new Text(theme.fg("dim", locText), 2, 0));
100
+
101
+ if (finding.body) {
102
+ const box = new Box(1, 0);
103
+ box.addChild(new Text(theme.fg("text", finding.body), 0, 0));
104
+ container.addChild(box);
105
+ }
106
+
107
+ return container;
108
+ }
109
+
110
+ function renderFailed(
111
+ result: Extract<ReviewResult, { kind: "failed" }>,
112
+ theme: Parameters<Parameters<ExtensionAPI["registerMessageRenderer"]>[1]>[2],
113
+ ): Container {
114
+ const container = new Container();
115
+ container.addChild(new Text(theme.fg("error", "◆ Review Failed"), 1, 0));
116
+ container.addChild(new Spacer(1));
117
+ container.addChild(new Text(theme.fg("error", result.reason), 1, 0));
118
+ return container;
119
+ }
120
+
121
+ function renderTimeout(
122
+ result: Extract<ReviewResult, { kind: "timeout" }>,
123
+ theme: Parameters<Parameters<ExtensionAPI["registerMessageRenderer"]>[1]>[2],
124
+ ): Container {
125
+ const container = new Container();
126
+ container.addChild(new Text(theme.fg("warning", "◆ Review Timed Out"), 1, 0));
127
+ container.addChild(new Spacer(1));
128
+ container.addChild(
129
+ new Text(
130
+ theme.fg("warning", `Reviewer exceeded the ${(result.timeoutMs / 1000).toFixed(0)}s timeout`),
131
+ 1,
132
+ 0,
133
+ ),
134
+ );
135
+ if (result.partialOutput) {
136
+ container.addChild(new Spacer(1));
137
+ container.addChild(new Text(theme.fg("dim", "Partial output:"), 1, 0));
138
+ const excerpt = result.partialOutput.slice(0, 500);
139
+ container.addChild(new Text(theme.fg("dim", excerpt), 1, 0));
140
+ }
141
+ return container;
142
+ }
143
+
144
+ function renderCanceled(
145
+ _result: Extract<ReviewResult, { kind: "canceled" }>,
146
+ theme: Parameters<Parameters<ExtensionAPI["registerMessageRenderer"]>[1]>[2],
147
+ ): Container {
148
+ const container = new Container();
149
+ container.addChild(new Text(theme.fg("warning", "◆ Review Canceled"), 1, 0));
150
+ return container;
151
+ }
152
+
153
+ function priorityColorName(priority: number): "success" | "warning" | "error" {
154
+ switch (priority) {
155
+ case 0:
156
+ return "success";
157
+ case 1:
158
+ return "success";
159
+ case 2:
160
+ return "warning";
161
+ case 3:
162
+ return "error";
163
+ default:
164
+ return "success";
165
+ }
166
+ }
167
+
168
+ function priorityText(priority: number): string {
169
+ switch (priority) {
170
+ case 0:
171
+ return "info";
172
+ case 1:
173
+ return "minor";
174
+ case 2:
175
+ return "major";
176
+ case 3:
177
+ return "critical";
178
+ default:
179
+ return "info";
180
+ }
181
+ }
package/src/review.ts ADDED
@@ -0,0 +1,351 @@
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";
28
+
29
+ type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
30
+
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
+ export default function reviewExtension(pi: ExtensionAPI) {
41
+ registerReviewSettings();
42
+ registerReviewRenderer(pi);
43
+
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
+ pi.registerCommand("supi-review", {
72
+ description: "Run a structured code review",
73
+ handler: async (_args, ctx) => {
74
+ const settings = loadReviewSettings(ctx.cwd);
75
+ await handleInteractive(settings.maxDiffBytes, settings.autoFix, ctx, pi);
76
+ },
77
+ });
78
+ }
79
+
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;
91
+
92
+ const target = await resolvePresetTarget(preset, ctx);
93
+ if (!target) return;
94
+
95
+ const result = await runReviewWithLoader(target, maxDiffBytes, ctx, pi);
96
+ injectReviewMessage(pi, result, autoFix);
97
+ }
98
+
99
+ async function resolvePresetTarget(
100
+ preset: import("./ui.ts").Preset,
101
+ 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
+ }
150
+ }
151
+ }
152
+
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 };
159
+ }
160
+ return runReview({ ...options, target: resolved.target });
161
+ }
162
+
163
+ async function runReviewWithLoader(
164
+ target: ReviewTarget,
165
+ maxDiffBytes: number,
166
+ ctx: CommandContext,
167
+ pi: ExtensionAPI,
168
+ ): Promise<ReviewResult> {
169
+ return ctx.ui.custom<ReviewResult>((tui, theme, _kb, done) => {
170
+ const widget = new ReviewProgressWidget(tui, theme, "Running code review…");
171
+ let finished = false;
172
+
173
+ const finish = (result: ReviewResult) => {
174
+ if (finished) return;
175
+ finished = true;
176
+ pi.events.emit("supi:working:end", { source: "supi-review" });
177
+ done(result);
178
+ };
179
+
180
+ widget.onAbort = () => finish({ kind: "canceled", target });
181
+
182
+ pi.events.emit("supi:working:start", { source: "supi-review" });
183
+
184
+ executeReview({
185
+ target,
186
+ maxDiffBytes,
187
+ ctx,
188
+ signal: widget.signal,
189
+ onProgress: (progress) => widget.updateProgress(progress),
190
+ })
191
+ .then((result) => {
192
+ if (widget.signal.aborted) return;
193
+ finish(result);
194
+ })
195
+ .catch((err) => {
196
+ if (widget.signal.aborted) return;
197
+ finish({
198
+ kind: "failed",
199
+ reason: err instanceof Error ? err.message : String(err),
200
+ target,
201
+ });
202
+ });
203
+
204
+ return widget;
205
+ });
206
+ }
207
+
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 };
328
+ }
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
+ }
339
+
340
+ function injectReviewMessage(pi: ExtensionAPI, result: ReviewResult, autoFix: boolean): void {
341
+ pi.sendMessage({
342
+ customType: "supi-review",
343
+ content: formatReviewContent(result),
344
+ display: true,
345
+ details: { result },
346
+ });
347
+
348
+ if (autoFix && result.kind === "success" && result.output.findings.length > 0) {
349
+ pi.sendUserMessage("Fix all findings from the review above.");
350
+ }
351
+ }
@@ -0,0 +1,32 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+ import type { ReviewTarget } from "./types.ts";
4
+
5
+ /** Progress state exposed by the runner for widget integration. */
6
+ export interface ReviewProgress {
7
+ /** Number of agentic turns completed. */
8
+ turns: number;
9
+ /** Number of tool executions started. */
10
+ toolUses: number;
11
+ /** Human-readable activity descriptions for active tools. */
12
+ activities: string[];
13
+ /** Token usage stats, if available. */
14
+ tokens?: { input: number; output: number; total: number };
15
+ }
16
+
17
+ export interface ReviewerInvocation {
18
+ prompt: string;
19
+ // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
20
+ model: Model<any>;
21
+ /** Model registry from the parent session — passed to createAgentSession
22
+ * so that provider registrations, auth, and streaming work correctly. */
23
+ modelRegistry?: ModelRegistry;
24
+ cwd: string;
25
+ signal?: AbortSignal;
26
+ target: ReviewTarget;
27
+ timeoutMs?: number;
28
+ /** Callback for tool activity events (starts/ends) for widget integration. */
29
+ onToolActivity?: (event: { toolName: string; phase: "start" | "end" }) => void;
30
+ /** Callback for progress state updates (turn count, tool uses, tokens). */
31
+ onProgress?: (progress: ReviewProgress) => void;
32
+ }