@mrclrchtr/supi-review 2.7.0 → 3.0.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 +75 -163
- package/node_modules/@mrclrchtr/supi-core/README.md +1 -1
- package/node_modules/@mrclrchtr/supi-core/package.json +2 -1
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +40 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/scoped-settings-list.ts +1 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +14 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-submenus.ts +31 -14
- package/node_modules/@mrclrchtr/supi-core/src/settings.ts +1 -0
- package/package.json +3 -3
- package/src/config.ts +64 -0
- package/src/git-command.ts +78 -0
- package/src/git.ts +306 -505
- package/src/history/collect.ts +43 -120
- package/src/model.ts +35 -0
- package/src/review-path.ts +85 -0
- package/src/review-result.ts +43 -92
- package/src/review.ts +154 -288
- package/src/session/review-plan-store.ts +20 -51
- package/src/target/packet.ts +46 -369
- package/src/tool/agent-review-schemas.ts +101 -104
- package/src/tool/agent-review-tools.ts +269 -301
- package/src/tool/child-failure-diagnostics.ts +1 -1
- package/src/tool/child-resource-loader.ts +37 -0
- package/src/tool/child-session-runner.ts +83 -0
- package/src/tool/output-page.ts +47 -0
- package/src/tool/planner-runner.ts +69 -0
- package/src/tool/review-runner.ts +42 -257
- package/src/tool/review-system-prompt.ts +12 -110
- package/src/tool/review-tools.ts +119 -0
- package/src/tool/review-workflow.ts +309 -0
- package/src/tool/runner-helpers.ts +3 -8
- package/src/tool/schemas.ts +75 -62
- package/src/tui/common.ts +175 -0
- package/src/tui/prepare.ts +132 -0
- package/src/tui/run.ts +160 -0
- package/src/types.ts +153 -275
- package/src/history/synthesize.ts +0 -121
- package/src/tool/agent-review-workflow.ts +0 -398
- package/src/tool/brief-runner.ts +0 -180
- package/src/tool/guidance.ts +0 -21
- package/src/tool/review-handlers.ts +0 -314
- package/src/tool/snapshot-tools.ts +0 -117
- package/src/ui/flow.ts +0 -189
- package/src/ui/format-content.ts +0 -143
- package/src/ui/renderer.ts +0 -274
- package/src/ui/review-plan-inspector.ts +0 -391
- package/src/ui/review-tool-format.ts +0 -138
- package/src/ui/review-tool-renderer.ts +0 -234
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type AgentSession,
|
|
3
|
-
type AgentSessionEvent,
|
|
4
|
-
defineTool,
|
|
5
|
-
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import type {
|
|
7
|
-
ChildFailureDiagnostics,
|
|
8
|
-
RawReviewResult,
|
|
9
|
-
ReviewInvocation,
|
|
10
|
-
ReviewOutputEvent,
|
|
11
|
-
ReviewProgress,
|
|
12
|
-
} from "../types.ts";
|
|
13
|
-
import type { ChildLifecycleHostMarker } from "./child-lifecycle-trace.ts";
|
|
14
|
-
import { buildProgressTokens } from "./runner-helpers.ts";
|
|
15
|
-
import { reviewOutputSchema } from "./schemas.ts";
|
|
16
|
-
|
|
17
|
-
const STEER_SUBMIT_MESSAGE =
|
|
18
|
-
"You stopped without calling submit_review. Call submit_review now with your findings.";
|
|
19
|
-
const DEFAULT_TIMEOUT_MS = 60 * 60 * 1_000;
|
|
20
|
-
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// Runner-specific context combining lifecycle-managed fields with
|
|
23
|
-
// runner-private mutable state.
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
export interface RunnerContext {
|
|
26
|
-
progress: ReviewProgress;
|
|
27
|
-
session: AgentSession;
|
|
28
|
-
resolve: (result: RawReviewResult) => void;
|
|
29
|
-
cleanup: (result: RawReviewResult) => RawReviewResult;
|
|
30
|
-
state: { settled: boolean; aborting: boolean };
|
|
31
|
-
resultHolder: { value: ReviewOutputEvent | undefined };
|
|
32
|
-
invocation: ReviewInvocation;
|
|
33
|
-
submitSteered: boolean;
|
|
34
|
-
timeoutSteered: boolean;
|
|
35
|
-
graceTurnsRemaining: number | undefined;
|
|
36
|
-
getFailureDiagnostics: () => ChildFailureDiagnostics;
|
|
37
|
-
recordHostMarker: (marker: ChildLifecycleHostMarker) => void;
|
|
38
|
-
/** Accumulated per-tool-label execution counts. */
|
|
39
|
-
toolCounts: Record<string, number>;
|
|
40
|
-
/** Set of distinct file paths inspected via read_snapshot_diff / read_snapshot_file. */
|
|
41
|
-
inspectedFiles: Set<string>;
|
|
42
|
-
/** Timestamp (ms) when the runner started, for elapsed-time display. */
|
|
43
|
-
startTime: number;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
// Tool and progress helpers
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
|
|
50
|
-
/** Maps tool names to compact display labels for the progress line. */
|
|
51
|
-
function toolNameToLabel(name: string): string {
|
|
52
|
-
const map: Record<string, string> = {
|
|
53
|
-
read: "reads",
|
|
54
|
-
grep: "greps",
|
|
55
|
-
find: "finds",
|
|
56
|
-
ls: "ls",
|
|
57
|
-
submit_review: "submits",
|
|
58
|
-
read_snapshot_diff: "diffs",
|
|
59
|
-
read_snapshot_file: "file-reads",
|
|
60
|
-
};
|
|
61
|
-
return map[name] ?? name;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Maps tool names to focus labels for the progress narrative line. */
|
|
65
|
-
function toolNameToFocusLabel(name: string): string {
|
|
66
|
-
const map: Record<string, string> = {
|
|
67
|
-
read: "Reading",
|
|
68
|
-
grep: "Searching",
|
|
69
|
-
find: "Finding",
|
|
70
|
-
ls: "Listing",
|
|
71
|
-
submit_review: "Submitting review",
|
|
72
|
-
read_snapshot_diff: "Reading",
|
|
73
|
-
read_snapshot_file: "Reading",
|
|
74
|
-
};
|
|
75
|
-
return map[name] ?? name;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Extract the basename from a file path, or return the path unchanged. */
|
|
79
|
-
function extractBasename(file: string): string {
|
|
80
|
-
const lastSlash = file.lastIndexOf("/");
|
|
81
|
-
return lastSlash >= 0 ? file.slice(lastSlash + 1) : file;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Truncate a focus detail string to a display-friendly length. */
|
|
85
|
-
function truncateFocusDetail(value: string): string {
|
|
86
|
-
return value.length > 40 ? `${value.slice(0, 40)}…` : value;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Extract focus detail from a read-type tool (read, read_snapshot_diff, read_snapshot_file). */
|
|
90
|
-
function tryExtractReadDetail(toolName: string, a: Record<string, unknown>): string | undefined {
|
|
91
|
-
const file = (a.file ?? a.path) as string | undefined;
|
|
92
|
-
if (!file) return undefined;
|
|
93
|
-
const name = extractBasename(file);
|
|
94
|
-
if (toolName === "read_snapshot_diff") return `${name} (diff)`;
|
|
95
|
-
if (toolName === "read_snapshot_file") return `${name} (full)`;
|
|
96
|
-
return name;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Extract a human-readable detail string from tool args for the focus display. */
|
|
100
|
-
function tryExtractFocusDetail(toolName: string, args: unknown): string | undefined {
|
|
101
|
-
if (typeof args !== "object" || args === null) return undefined;
|
|
102
|
-
const a = args as Record<string, unknown>;
|
|
103
|
-
|
|
104
|
-
// submit_review is self-explanatory — no detail needed
|
|
105
|
-
if (toolName === "submit_review") return undefined;
|
|
106
|
-
|
|
107
|
-
// Read-type tools: extract file path, show basename only
|
|
108
|
-
if (
|
|
109
|
-
toolName === "read" ||
|
|
110
|
-
toolName === "read_snapshot_diff" ||
|
|
111
|
-
toolName === "read_snapshot_file"
|
|
112
|
-
) {
|
|
113
|
-
return tryExtractReadDetail(toolName, a);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// grep: show the search pattern
|
|
117
|
-
if (toolName === "grep") {
|
|
118
|
-
const pattern = a.pattern as string | undefined;
|
|
119
|
-
return pattern ? truncateFocusDetail(pattern) : undefined;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// find: show the glob / pattern
|
|
123
|
-
if (toolName === "find") {
|
|
124
|
-
const pattern = (a.pattern ?? a.glob) as string | undefined;
|
|
125
|
-
return pattern ? truncateFocusDetail(pattern) : undefined;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ls: show the listing path
|
|
129
|
-
if (toolName === "ls") {
|
|
130
|
-
const path = a.path as string | undefined;
|
|
131
|
-
return path ? truncateFocusDetail(path) : undefined;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return undefined;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Extract a file path from tool args when the tool is file-inspecting (for inspectedFiles tracking). */
|
|
138
|
-
function tryExtractFileArg(toolName: string, args: unknown): string | undefined {
|
|
139
|
-
if (toolName !== "read_snapshot_diff" && toolName !== "read_snapshot_file") return undefined;
|
|
140
|
-
if (typeof args !== "object" || args === null) return undefined;
|
|
141
|
-
const file = (args as Record<string, unknown>).file;
|
|
142
|
-
return typeof file === "string" ? file : undefined;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function createSubmitReviewTool(resultHolder: {
|
|
146
|
-
value: ReviewOutputEvent | undefined;
|
|
147
|
-
}): ReturnType<typeof defineTool> {
|
|
148
|
-
return defineTool({
|
|
149
|
-
name: "submit_review",
|
|
150
|
-
label: "Submit Review",
|
|
151
|
-
description:
|
|
152
|
-
"Submit the final structured review result after you finish investigating the changes.",
|
|
153
|
-
parameters: reviewOutputSchema,
|
|
154
|
-
execute: async (_toolCallId, args) => {
|
|
155
|
-
resultHolder.value = args as ReviewOutputEvent;
|
|
156
|
-
return {
|
|
157
|
-
content: [{ type: "text" as const, text: "Review submitted successfully." }],
|
|
158
|
-
details: args,
|
|
159
|
-
terminate: true,
|
|
160
|
-
};
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export function emitProgress(ctx: RunnerContext): void {
|
|
166
|
-
ctx.progress.tokens = buildProgressTokens(() => ctx.session.getSessionStats());
|
|
167
|
-
ctx.progress.toolCounts = { ...ctx.toolCounts };
|
|
168
|
-
ctx.progress.filesInspected = ctx.inspectedFiles.size;
|
|
169
|
-
ctx.progress.filesTotal = ctx.invocation.snapshot.stats.files;
|
|
170
|
-
ctx.progress.elapsedMs = Date.now() - ctx.startTime;
|
|
171
|
-
ctx.invocation.onProgress?.(ctx.progress);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ---------------------------------------------------------------------------
|
|
175
|
-
// Event handlers
|
|
176
|
-
// ---------------------------------------------------------------------------
|
|
177
|
-
|
|
178
|
-
export function handleTurnEnd(ctx: RunnerContext): void {
|
|
179
|
-
ctx.progress.turns++;
|
|
180
|
-
|
|
181
|
-
if (!ctx.state.settled && ctx.timeoutSteered && ctx.graceTurnsRemaining !== undefined) {
|
|
182
|
-
ctx.graceTurnsRemaining--;
|
|
183
|
-
if (ctx.graceTurnsRemaining <= 0) {
|
|
184
|
-
ctx.state.aborting = true;
|
|
185
|
-
doFinalAbort(ctx);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
emitProgress(ctx);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function handleToolStart(
|
|
193
|
-
event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>,
|
|
194
|
-
ctx: RunnerContext,
|
|
195
|
-
): void {
|
|
196
|
-
ctx.progress.toolUses++;
|
|
197
|
-
|
|
198
|
-
const label = toolNameToLabel(event.toolName);
|
|
199
|
-
ctx.toolCounts[label] = (ctx.toolCounts[label] ?? 0) + 1;
|
|
200
|
-
|
|
201
|
-
const file = tryExtractFileArg(event.toolName, event.args);
|
|
202
|
-
if (file) ctx.inspectedFiles.add(file);
|
|
203
|
-
|
|
204
|
-
const focusDetail = tryExtractFocusDetail(event.toolName, event.args);
|
|
205
|
-
ctx.progress.currentFocus = {
|
|
206
|
-
label: toolNameToFocusLabel(event.toolName),
|
|
207
|
-
detail: focusDetail ?? "",
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
ctx.invocation.onToolActivity?.({ toolName: event.toolName, phase: "start" });
|
|
211
|
-
emitProgress(ctx);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export function handleToolEnd(
|
|
215
|
-
event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>,
|
|
216
|
-
ctx: RunnerContext,
|
|
217
|
-
): void {
|
|
218
|
-
ctx.progress.currentFocus = undefined;
|
|
219
|
-
ctx.invocation.onToolActivity?.({ toolName: event.toolName, phase: "end" });
|
|
220
|
-
emitProgress(ctx);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export function handleMessageEnd(
|
|
224
|
-
event: Extract<AgentSessionEvent, { type: "message_end" }>,
|
|
225
|
-
ctx: RunnerContext,
|
|
226
|
-
): void {
|
|
227
|
-
if (ctx.state.settled || ctx.submitSteered || ctx.resultHolder.value) return;
|
|
228
|
-
|
|
229
|
-
const msg = event.message as { role?: string; stopReason?: string };
|
|
230
|
-
if (msg.role !== "assistant" || msg.stopReason !== "stop") return;
|
|
231
|
-
|
|
232
|
-
ctx.submitSteered = true;
|
|
233
|
-
ctx.recordHostMarker({ type: "steer_requested", reason: "submit" });
|
|
234
|
-
ctx.session.steer(STEER_SUBMIT_MESSAGE).catch(() => {});
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Finalize only after Pi has no retry, compaction recovery, or queued continuation left.
|
|
239
|
-
* `agent_end` is a low-level boundary and is not terminal for a managed session.
|
|
240
|
-
*/
|
|
241
|
-
export function handleAgentSettled(ctx: RunnerContext): void {
|
|
242
|
-
if (ctx.state.settled || ctx.state.aborting) return;
|
|
243
|
-
|
|
244
|
-
if (ctx.resultHolder.value) {
|
|
245
|
-
ctx.resolve(
|
|
246
|
-
ctx.cleanup({
|
|
247
|
-
kind: "success",
|
|
248
|
-
output: ctx.resultHolder.value,
|
|
249
|
-
snapshot: ctx.invocation.snapshot,
|
|
250
|
-
brief: ctx.invocation.brief,
|
|
251
|
-
modelId: ctx.invocation.model.canonicalId,
|
|
252
|
-
}),
|
|
253
|
-
);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
ctx.resolve(
|
|
258
|
-
ctx.cleanup({
|
|
259
|
-
kind: "failed",
|
|
260
|
-
failureCode: "missing-structured-output",
|
|
261
|
-
snapshot: ctx.invocation.snapshot,
|
|
262
|
-
brief: ctx.invocation.brief,
|
|
263
|
-
modelId: ctx.invocation.model.canonicalId,
|
|
264
|
-
diagnostics: ctx.getFailureDiagnostics(),
|
|
265
|
-
}),
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
export function handleSessionEvent(event: AgentSessionEvent, ctx: RunnerContext): void {
|
|
270
|
-
switch (event.type) {
|
|
271
|
-
case "turn_end":
|
|
272
|
-
handleTurnEnd(ctx);
|
|
273
|
-
break;
|
|
274
|
-
case "tool_execution_start":
|
|
275
|
-
handleToolStart(event, ctx);
|
|
276
|
-
break;
|
|
277
|
-
case "tool_execution_end":
|
|
278
|
-
handleToolEnd(event, ctx);
|
|
279
|
-
break;
|
|
280
|
-
case "message_end": {
|
|
281
|
-
handleMessageEnd(event, ctx);
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
284
|
-
case "agent_settled":
|
|
285
|
-
handleAgentSettled(ctx);
|
|
286
|
-
break;
|
|
287
|
-
default:
|
|
288
|
-
break;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ---------------------------------------------------------------------------
|
|
293
|
-
// Hard-abort helper (called from handleTurnEnd when grace turns expire)
|
|
294
|
-
// ---------------------------------------------------------------------------
|
|
295
|
-
|
|
296
|
-
function doFinalAbort(ctx: RunnerContext): void {
|
|
297
|
-
emitProgress(ctx);
|
|
298
|
-
ctx.recordHostMarker({ type: "abort_requested", reason: "timeout" });
|
|
299
|
-
void ctx.session
|
|
300
|
-
.abort()
|
|
301
|
-
.catch(() => {})
|
|
302
|
-
.finally(() => {
|
|
303
|
-
ctx.resolve(
|
|
304
|
-
ctx.cleanup({
|
|
305
|
-
kind: "timeout" as const,
|
|
306
|
-
snapshot: ctx.invocation.snapshot,
|
|
307
|
-
timeoutMs: ctx.invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
308
|
-
brief: ctx.invocation.brief,
|
|
309
|
-
modelId: ctx.invocation.model.canonicalId,
|
|
310
|
-
diagnostics: ctx.getFailureDiagnostics(),
|
|
311
|
-
}),
|
|
312
|
-
);
|
|
313
|
-
});
|
|
314
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Type } from "typebox";
|
|
4
|
-
import { getSnapshotFileContent, getSnapshotFileDiff } from "../git.ts";
|
|
5
|
-
import type { ReviewSnapshot } from "../types.ts";
|
|
6
|
-
|
|
7
|
-
/** Create the read_snapshot_diff custom tool for the reviewer child session. */
|
|
8
|
-
export function createSnapshotDiffTool(
|
|
9
|
-
cwd: string,
|
|
10
|
-
snapshot: ReviewSnapshot,
|
|
11
|
-
): ReturnType<typeof defineTool> {
|
|
12
|
-
return defineTool({
|
|
13
|
-
name: "read_snapshot_diff",
|
|
14
|
-
label: "Read Snapshot Diff",
|
|
15
|
-
description:
|
|
16
|
-
"Read the exact diff for a single changed file in the selected review snapshot. " +
|
|
17
|
-
"The file must be in the snapshot's changed-files list.",
|
|
18
|
-
parameters: Type.Object({
|
|
19
|
-
file: Type.String(),
|
|
20
|
-
}),
|
|
21
|
-
execute: async (_toolCallId, args) => {
|
|
22
|
-
const file = (args as { file: string }).file;
|
|
23
|
-
if (!snapshot.changedFiles.includes(file)) {
|
|
24
|
-
return {
|
|
25
|
-
content: [
|
|
26
|
-
{
|
|
27
|
-
type: "text" as const,
|
|
28
|
-
text:
|
|
29
|
-
`Error: "${file}" is not in the snapshot's changed-files list. ` +
|
|
30
|
-
"Use the changed-file manifest from the review task to pick a valid file.",
|
|
31
|
-
},
|
|
32
|
-
],
|
|
33
|
-
details: null,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const diff = await getSnapshotFileDiff(cwd, snapshot, file);
|
|
38
|
-
if (!diff.trim()) {
|
|
39
|
-
return {
|
|
40
|
-
content: [
|
|
41
|
-
{
|
|
42
|
-
type: "text" as const,
|
|
43
|
-
text:
|
|
44
|
-
`No diff available for "${file}" in this snapshot. ` +
|
|
45
|
-
"The file may be untracked; use read_snapshot_file to inspect its current content.",
|
|
46
|
-
},
|
|
47
|
-
],
|
|
48
|
-
details: null,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
content: [{ type: "text" as const, text: diff }],
|
|
54
|
-
details: null,
|
|
55
|
-
};
|
|
56
|
-
},
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Create the read_snapshot_file custom tool for the reviewer child session. */
|
|
61
|
-
export function createSnapshotFileTool(
|
|
62
|
-
cwd: string,
|
|
63
|
-
snapshot: ReviewSnapshot,
|
|
64
|
-
): ReturnType<typeof defineTool> {
|
|
65
|
-
return defineTool({
|
|
66
|
-
name: "read_snapshot_file",
|
|
67
|
-
label: "Read Snapshot File",
|
|
68
|
-
description:
|
|
69
|
-
"Read the before or after content of a single changed file in the selected review snapshot. " +
|
|
70
|
-
'"before" shows the file before the change (HEAD for working tree, base for branch, parent for commit). ' +
|
|
71
|
-
'"after" shows the file after the change. The file must be in the snapshot\'s changed-files list.',
|
|
72
|
-
parameters: Type.Object({
|
|
73
|
-
file: Type.String(),
|
|
74
|
-
side: StringEnum(["before", "after"] as const),
|
|
75
|
-
}),
|
|
76
|
-
execute: async (_toolCallId, args) => {
|
|
77
|
-
const { file, side } = args as { file: string; side: "before" | "after" };
|
|
78
|
-
|
|
79
|
-
if (!snapshot.changedFiles.includes(file)) {
|
|
80
|
-
return {
|
|
81
|
-
content: [
|
|
82
|
-
{
|
|
83
|
-
type: "text" as const,
|
|
84
|
-
text:
|
|
85
|
-
`Error: "${file}" is not in the snapshot's changed-files list. ` +
|
|
86
|
-
"Use the changed-file manifest from the review task to pick a valid file.",
|
|
87
|
-
},
|
|
88
|
-
],
|
|
89
|
-
details: null,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const content = await getSnapshotFileContent(cwd, snapshot, file, side);
|
|
94
|
-
|
|
95
|
-
if (content === undefined) {
|
|
96
|
-
const hint =
|
|
97
|
-
side === "before"
|
|
98
|
-
? "The file may be newly added or renamed (use read_snapshot_diff to check for renames)."
|
|
99
|
-
: "The file may have been deleted.";
|
|
100
|
-
return {
|
|
101
|
-
content: [
|
|
102
|
-
{
|
|
103
|
-
type: "text" as const,
|
|
104
|
-
text: `Content for "${file}" (${side}) is not available. ${hint}`,
|
|
105
|
-
},
|
|
106
|
-
],
|
|
107
|
-
details: null,
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
content: [{ type: "text" as const, text: content }],
|
|
113
|
-
details: null,
|
|
114
|
-
};
|
|
115
|
-
},
|
|
116
|
-
});
|
|
117
|
-
}
|
package/src/ui/flow.ts
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import { DynamicBorder, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Container, type SelectItem, SelectList, Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import { getLocalBranches, getRecentCommits } from "../git.ts";
|
|
4
|
-
import { getSelectableReviewModels } from "../model.ts";
|
|
5
|
-
import type { ReviewModelSelection, ReviewPlan, ReviewTargetSpec } from "../types.ts";
|
|
6
|
-
import { ReviewPlanPreviewComponent } from "./review-plan-inspector.ts";
|
|
7
|
-
|
|
8
|
-
interface SelectFromListOptions<T> {
|
|
9
|
-
items: SelectItem[];
|
|
10
|
-
title: string;
|
|
11
|
-
maxHeight: number;
|
|
12
|
-
onSelect: (item: SelectItem) => T | undefined;
|
|
13
|
-
initialIndex?: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Shared TUI helper for bordered select lists used throughout the review flow. */
|
|
17
|
-
function selectFromList<T>(
|
|
18
|
-
ctx: ExtensionContext,
|
|
19
|
-
options: SelectFromListOptions<T>,
|
|
20
|
-
): Promise<T | undefined> {
|
|
21
|
-
const { items, title, maxHeight, onSelect, initialIndex } = options;
|
|
22
|
-
const widestLabel = items.reduce((w, item) => Math.max(w, visibleWidth(item.label)), 0);
|
|
23
|
-
const maxPrimaryColumnWidth = Math.max(widestLabel + 4, 32);
|
|
24
|
-
|
|
25
|
-
return ctx.ui.custom<T | undefined>((tui, theme, _kb, done) => {
|
|
26
|
-
const container = new Container();
|
|
27
|
-
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
28
|
-
container.addChild(new Text(theme.fg("accent", title), 1, 0));
|
|
29
|
-
|
|
30
|
-
const selectList = new SelectList(
|
|
31
|
-
items,
|
|
32
|
-
Math.min(items.length, maxHeight),
|
|
33
|
-
{
|
|
34
|
-
selectedPrefix: (text) => theme.fg("accent", text),
|
|
35
|
-
selectedText: (text) => theme.fg("accent", text),
|
|
36
|
-
description: (text) => theme.fg("muted", text),
|
|
37
|
-
scrollInfo: (text) => theme.fg("dim", text),
|
|
38
|
-
noMatch: (text) => theme.fg("warning", text),
|
|
39
|
-
},
|
|
40
|
-
{ maxPrimaryColumnWidth },
|
|
41
|
-
);
|
|
42
|
-
if (initialIndex !== undefined) {
|
|
43
|
-
selectList.setSelectedIndex(initialIndex);
|
|
44
|
-
}
|
|
45
|
-
selectList.onSelect = (item) => done(onSelect(item));
|
|
46
|
-
selectList.onCancel = () => done(undefined);
|
|
47
|
-
|
|
48
|
-
container.addChild(selectList);
|
|
49
|
-
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
|
|
50
|
-
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
render: (width) => container.render(width),
|
|
54
|
-
invalidate: () => container.invalidate(),
|
|
55
|
-
handleInput: (data) => {
|
|
56
|
-
selectList.handleInput(data);
|
|
57
|
-
tui.requestRender();
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Prompt for the concrete git target to review. */
|
|
64
|
-
export async function selectTarget(ctx: ExtensionContext): Promise<ReviewTargetSpec | undefined> {
|
|
65
|
-
const preset = await selectFromList(ctx, {
|
|
66
|
-
items: [
|
|
67
|
-
{
|
|
68
|
-
value: "working-tree",
|
|
69
|
-
label: "Review working tree",
|
|
70
|
-
description: "Staged, unstaged, and untracked changes in the current checkout",
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
value: "branch",
|
|
74
|
-
label: "Review changes vs base branch",
|
|
75
|
-
description: "Compare the current branch against another local branch",
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
value: "commit",
|
|
79
|
-
label: "Review a commit",
|
|
80
|
-
description: "Review a single recent commit",
|
|
81
|
-
},
|
|
82
|
-
],
|
|
83
|
-
title: "Select review target",
|
|
84
|
-
maxHeight: 8,
|
|
85
|
-
onSelect: (item) => item.value,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
if (!preset) return undefined;
|
|
89
|
-
|
|
90
|
-
switch (preset) {
|
|
91
|
-
case "working-tree":
|
|
92
|
-
return { kind: "working-tree" };
|
|
93
|
-
case "branch": {
|
|
94
|
-
const base = await selectBranch(ctx);
|
|
95
|
-
return base ? { kind: "branch", base } : undefined;
|
|
96
|
-
}
|
|
97
|
-
case "commit": {
|
|
98
|
-
const sha = await selectCommit(ctx);
|
|
99
|
-
return sha ? { kind: "commit", sha } : undefined;
|
|
100
|
-
}
|
|
101
|
-
default:
|
|
102
|
-
return undefined;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Prompt for the explicit reviewer model used by both synthesis and review. */
|
|
107
|
-
export async function selectModel(
|
|
108
|
-
ctx: Pick<ExtensionContext, "cwd" | "modelRegistry" | "model" | "ui">,
|
|
109
|
-
): Promise<ReviewModelSelection | undefined> {
|
|
110
|
-
const models = getSelectableReviewModels(ctx);
|
|
111
|
-
if (models.length === 0) {
|
|
112
|
-
ctx.ui.notify(
|
|
113
|
-
"No scoped models are enabled for /supi-review. Configure enabledModels or use /scoped-models.",
|
|
114
|
-
"error",
|
|
115
|
-
);
|
|
116
|
-
return undefined;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const currentIndex = models.findIndex((model) => model.isCurrent);
|
|
120
|
-
|
|
121
|
-
return selectFromList(ctx as ExtensionContext, {
|
|
122
|
-
items: models.map((model) => ({
|
|
123
|
-
value: model.canonicalId,
|
|
124
|
-
label: model.isCurrent ? `${model.canonicalId} [current]` : model.canonicalId,
|
|
125
|
-
description: model.label !== model.canonicalId ? model.label : undefined,
|
|
126
|
-
})),
|
|
127
|
-
title: "Select scoped reviewer model",
|
|
128
|
-
maxHeight: 12,
|
|
129
|
-
initialIndex: currentIndex >= 0 ? currentIndex : 0,
|
|
130
|
-
onSelect: (item) => models.find((model) => model.canonicalId === item.value),
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Collect an optional freeform note for the generated review brief. */
|
|
135
|
-
export async function collectReviewNote(ctx: ExtensionContext): Promise<string | undefined> {
|
|
136
|
-
const value = await ctx.ui.input(
|
|
137
|
-
"Optional review note",
|
|
138
|
-
"Anything specific to watch for? Leave empty to skip.",
|
|
139
|
-
);
|
|
140
|
-
if (value === undefined) return undefined;
|
|
141
|
-
return value.trim();
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Show the synthesized brief, the actual reviewer prompt preview, and ask for approval. */
|
|
145
|
-
export function previewReviewPlan(ctx: ExtensionContext, plan: ReviewPlan): Promise<boolean> {
|
|
146
|
-
return ctx.ui.custom<boolean>(
|
|
147
|
-
(tui, theme, _kb, done) =>
|
|
148
|
-
new ReviewPlanPreviewComponent({
|
|
149
|
-
plan,
|
|
150
|
-
theme,
|
|
151
|
-
onDone: done,
|
|
152
|
-
requestRender: () => tui.requestRender(),
|
|
153
|
-
}),
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export async function selectBranch(ctx: ExtensionContext): Promise<string | undefined> {
|
|
158
|
-
const branches = await getLocalBranches(ctx.cwd);
|
|
159
|
-
if (branches.length === 0) {
|
|
160
|
-
ctx.ui.notify("No local branches found", "warning");
|
|
161
|
-
return undefined;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return selectFromList(ctx, {
|
|
165
|
-
items: branches.map((branch) => ({ value: branch, label: branch })),
|
|
166
|
-
title: "Select base branch",
|
|
167
|
-
maxHeight: 15,
|
|
168
|
-
onSelect: (item) => item.value,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export async function selectCommit(ctx: ExtensionContext): Promise<string | undefined> {
|
|
173
|
-
const commits = await getRecentCommits(ctx.cwd, 30);
|
|
174
|
-
if (commits.length === 0) {
|
|
175
|
-
ctx.ui.notify("No recent commits found", "warning");
|
|
176
|
-
return undefined;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return selectFromList(ctx, {
|
|
180
|
-
items: commits.map((commit) => ({
|
|
181
|
-
value: commit.sha,
|
|
182
|
-
label: `${commit.sha.slice(0, 7)} ${commit.subject}`,
|
|
183
|
-
description: commit.sha,
|
|
184
|
-
})),
|
|
185
|
-
title: "Select commit to review",
|
|
186
|
-
maxHeight: 15,
|
|
187
|
-
onSelect: (item) => item.value,
|
|
188
|
-
});
|
|
189
|
-
}
|