@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.
- package/README.md +86 -52
- package/package.json +2 -9
- package/src/git.ts +107 -1
- package/src/history/collect.ts +210 -0
- package/src/history/synthesize.ts +109 -0
- package/src/model.ts +100 -0
- package/src/review.ts +215 -287
- package/src/target/packet.ts +216 -0
- package/src/tool/brief-runner.ts +269 -0
- package/src/tool/review-runner.ts +434 -0
- package/src/tool/runner-types.ts +51 -0
- package/src/tool/schemas.ts +31 -0
- package/src/types.ts +96 -14
- package/src/ui/flow.ts +216 -0
- package/src/{format-content.ts → ui/format-content.ts} +29 -12
- package/src/ui/progress-widget.ts +86 -0
- package/src/{renderer.ts → ui/renderer.ts} +43 -14
- package/node_modules/@mrclrchtr/supi-core/README.md +0 -96
- package/node_modules/@mrclrchtr/supi-core/package.json +0 -44
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +0 -83
- package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +0 -76
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +0 -186
- package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +0 -119
- package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +0 -36
- package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +0 -31
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -255
- package/node_modules/@mrclrchtr/supi-core/src/extension.ts +0 -1
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +0 -83
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +0 -170
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +0 -54
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +0 -29
- package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +0 -15
- package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +0 -41
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +0 -226
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +0 -60
- package/src/progress-widget.ts +0 -82
- package/src/prompts.ts +0 -116
- package/src/runner-types.ts +0 -32
- package/src/runner.ts +0 -424
- package/src/settings.ts +0 -246
- package/src/target-resolution.ts +0 -102
- 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
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "./
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
32
|
+
const target = await selectTarget(ctx);
|
|
93
33
|
if (!target) return;
|
|
94
34
|
|
|
95
|
-
const
|
|
96
|
-
|
|
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
|
|
100
|
-
|
|
89
|
+
async function resolveReviewSnapshot(
|
|
90
|
+
target: ReviewTargetSpec,
|
|
101
91
|
ctx: CommandContext,
|
|
102
|
-
): Promise<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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:
|
|
200
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
-
|
|
349
|
-
|
|
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
|
}
|