@zephyrdeng/pi-review 0.7.0 → 0.8.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 +51 -7
- package/README.zh-CN.md +45 -5
- package/dist/args.d.ts +4 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +82 -5
- package/dist/args.js.map +1 -1
- package/dist/args.test.js +72 -1
- package/dist/args.test.js.map +1 -1
- package/dist/child-process.d.ts +6 -0
- package/dist/child-process.d.ts.map +1 -1
- package/dist/child-process.js +48 -0
- package/dist/child-process.js.map +1 -1
- package/dist/child-process.test.js +55 -1
- package/dist/child-process.test.js.map +1 -1
- package/dist/cli-exit.test.js +14 -5
- package/dist/cli-exit.test.js.map +1 -1
- package/dist/cli.js +8 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +13 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -5
- package/dist/index.js.map +1 -1
- package/dist/install-args.test.js +1 -1
- package/dist/install-args.test.js.map +1 -1
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +2 -3
- package/dist/install.js.map +1 -1
- package/dist/json-events.d.ts +34 -2
- package/dist/json-events.d.ts.map +1 -1
- package/dist/json-events.js +156 -49
- package/dist/json-events.js.map +1 -1
- package/dist/json-events.test.js +176 -4
- package/dist/json-events.test.js.map +1 -1
- package/dist/loop.d.ts +28 -2
- package/dist/loop.d.ts.map +1 -1
- package/dist/loop.js +45 -3
- package/dist/loop.js.map +1 -1
- package/dist/loop.test.js +24 -1
- package/dist/loop.test.js.map +1 -1
- package/dist/matcher.test.js +1 -1
- package/dist/matcher.test.js.map +1 -1
- package/dist/meta-footer.d.ts +4 -8
- package/dist/meta-footer.d.ts.map +1 -1
- package/dist/meta-footer.js +19 -5
- package/dist/meta-footer.js.map +1 -1
- package/dist/meta-footer.test.js +11 -4
- package/dist/meta-footer.test.js.map +1 -1
- package/dist/panel-aggregate.test.js +1 -1
- package/dist/panel-aggregate.test.js.map +1 -1
- package/dist/panel-cancellation.test.d.ts +2 -0
- package/dist/panel-cancellation.test.d.ts.map +1 -0
- package/dist/panel-cancellation.test.js +55 -0
- package/dist/panel-cancellation.test.js.map +1 -0
- package/dist/panel-cli-ui.test.d.ts +2 -0
- package/dist/panel-cli-ui.test.d.ts.map +1 -0
- package/dist/panel-cli-ui.test.js +91 -0
- package/dist/panel-cli-ui.test.js.map +1 -0
- package/dist/panel-cli.test.js +76 -27
- package/dist/panel-cli.test.js.map +1 -1
- package/dist/panel-config.d.ts +38 -0
- package/dist/panel-config.d.ts.map +1 -1
- package/dist/panel-config.js +137 -4
- package/dist/panel-config.js.map +1 -1
- package/dist/panel-config.test.js +84 -4
- package/dist/panel-config.test.js.map +1 -1
- package/dist/panel-loop.test.js +1 -1
- package/dist/panel-loop.test.js.map +1 -1
- package/dist/panel-tools.test.d.ts +2 -0
- package/dist/panel-tools.test.d.ts.map +1 -0
- package/dist/panel-tools.test.js +12 -0
- package/dist/panel-tools.test.js.map +1 -0
- package/dist/panel-ui.d.ts +20 -0
- package/dist/panel-ui.d.ts.map +1 -0
- package/dist/panel-ui.js +84 -0
- package/dist/panel-ui.js.map +1 -0
- package/dist/panel-ui.test.d.ts +2 -0
- package/dist/panel-ui.test.d.ts.map +1 -0
- package/dist/panel-ui.test.js +122 -0
- package/dist/panel-ui.test.js.map +1 -0
- package/dist/panel-usage.d.ts +6 -0
- package/dist/panel-usage.d.ts.map +1 -0
- package/dist/panel-usage.js +21 -0
- package/dist/panel-usage.js.map +1 -0
- package/dist/panel-view.d.ts +42 -0
- package/dist/panel-view.d.ts.map +1 -0
- package/dist/panel-view.js +124 -0
- package/dist/panel-view.js.map +1 -0
- package/dist/panel-view.test.d.ts +2 -0
- package/dist/panel-view.test.d.ts.map +1 -0
- package/dist/panel-view.test.js +112 -0
- package/dist/panel-view.test.js.map +1 -0
- package/dist/panel.d.ts +22 -2
- package/dist/panel.d.ts.map +1 -1
- package/dist/panel.js +184 -60
- package/dist/panel.js.map +1 -1
- package/dist/prompt.d.ts +2 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +34 -3
- package/dist/prompt.js.map +1 -1
- package/dist/prompt.test.js +21 -2
- package/dist/prompt.test.js.map +1 -1
- package/dist/review-events.d.ts +70 -0
- package/dist/review-events.d.ts.map +1 -0
- package/dist/review-events.js +52 -0
- package/dist/review-events.js.map +1 -0
- package/dist/review-events.test.d.ts +2 -0
- package/dist/review-events.test.d.ts.map +1 -0
- package/dist/review-events.test.js +47 -0
- package/dist/review-events.test.js.map +1 -0
- package/dist/review-result.test.js +1 -1
- package/dist/review-result.test.js.map +1 -1
- package/dist/review.js +3 -3
- package/dist/review.js.map +1 -1
- package/dist/review.meta.test.js +1 -1
- package/dist/review.meta.test.js.map +1 -1
- package/dist/skill-protocol.test.js +1 -1
- package/dist/skill-protocol.test.js.map +1 -1
- package/dist/skill-update.test.d.ts +2 -0
- package/dist/skill-update.test.d.ts.map +1 -0
- package/dist/skill-update.test.js +23 -0
- package/dist/skill-update.test.js.map +1 -0
- package/dist/skill.d.ts +22 -0
- package/dist/skill.d.ts.map +1 -1
- package/dist/skill.js +109 -22
- package/dist/skill.js.map +1 -1
- package/dist/types.d.ts +28 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/dist/ui-client.d.ts +13 -0
- package/dist/ui-client.d.ts.map +1 -0
- package/dist/ui-client.js +161 -0
- package/dist/ui-client.js.map +1 -0
- package/dist/ui-client.test.d.ts +2 -0
- package/dist/ui-client.test.d.ts.map +1 -0
- package/dist/ui-client.test.js +48 -0
- package/dist/ui-client.test.js.map +1 -0
- package/dist/ui-html.d.ts +7 -0
- package/dist/ui-html.d.ts.map +1 -0
- package/dist/ui-html.js +52 -0
- package/dist/ui-html.js.map +1 -0
- package/dist/ui-html.test.d.ts +2 -0
- package/dist/ui-html.test.d.ts.map +1 -0
- package/dist/ui-html.test.js +26 -0
- package/dist/ui-html.test.js.map +1 -0
- package/dist/ui-server-main.d.ts +3 -0
- package/dist/ui-server-main.d.ts.map +1 -0
- package/dist/ui-server-main.js +128 -0
- package/dist/ui-server-main.js.map +1 -0
- package/dist/ui-server-main.test.d.ts +2 -0
- package/dist/ui-server-main.test.d.ts.map +1 -0
- package/dist/ui-server-main.test.js +123 -0
- package/dist/ui-server-main.test.js.map +1 -0
- package/dist/ui-server.d.ts +42 -0
- package/dist/ui-server.d.ts.map +1 -0
- package/dist/ui-server.js +233 -0
- package/dist/ui-server.js.map +1 -0
- package/dist/ui-server.test.d.ts +2 -0
- package/dist/ui-server.test.d.ts.map +1 -0
- package/dist/ui-server.test.js +236 -0
- package/dist/ui-server.test.js.map +1 -0
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +48 -19
- package/dist/update.js.map +1 -1
- package/extensions/panel-tool.test.ts +388 -0
- package/extensions/panel-tool.ts +390 -0
- package/extensions/review.test.ts +79 -0
- package/extensions/review.ts +191 -45
- package/extensions/rv-completions.test.ts +177 -18
- package/extensions/rv-completions.ts +490 -60
- package/extensions/rv-interactive.test.ts +562 -0
- package/extensions/rv-interactive.ts +616 -0
- package/extensions/rv-locale.test.ts +12 -4
- package/extensions/rv-locale.ts +44 -11
- package/extensions/rv-model-picker.test.ts +172 -0
- package/extensions/rv-model-picker.ts +313 -0
- package/extensions/rv-model-priorities.test.ts +29 -9
- package/extensions/rv-model-priorities.ts +23 -8
- package/extensions/rv-prompts.test.ts +281 -2
- package/extensions/rv-prompts.ts +480 -32
- package/extensions/rv-resolve.test.ts +189 -0
- package/extensions/rv-resolve.ts +313 -0
- package/extensions/rv-semantic.test.ts +1 -1
- package/extensions/rv-semantic.ts +38 -4
- package/package.json +18 -6
- package/resources/review-presets.json +2 -2
- package/resources/rv-model-priorities.json +18 -9
- package/skills/pi-review/SKILL.md +84 -60
- package/skills/pi-review/references/codex-tools.md +18 -11
- package/skills/pi-review/references/model-selection.md +49 -28
- package/dist/extensions/review.d.ts +0 -2
- package/dist/extensions/review.js +0 -22
- package/dist/extensions/rv-prompts.d.ts +0 -19
- package/dist/extensions/rv-prompts.js +0 -132
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { Writable } from "node:stream";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
8
|
+
import {
|
|
9
|
+
createPanelViewState,
|
|
10
|
+
formatCost,
|
|
11
|
+
formatDurationMs,
|
|
12
|
+
formatPanelMetaAscii,
|
|
13
|
+
formatTokens,
|
|
14
|
+
formatUsage,
|
|
15
|
+
reducePanelEvent,
|
|
16
|
+
spawnStreamingChild,
|
|
17
|
+
type PanelReviewMeta,
|
|
18
|
+
type PanelViewState,
|
|
19
|
+
type ReviewEvent,
|
|
20
|
+
} from "@zephyrdeng/pi-review";
|
|
21
|
+
|
|
22
|
+
type Theme = { fg: (color: "toolTitle" | "muted" | "error" | "success" | "accent", text: string) => string; bold: (text: string) => string };
|
|
23
|
+
type PanelToolDetails = { state: PanelViewState; error?: string };
|
|
24
|
+
type PanelUi = { setStatus?: (key: string, text: string | undefined) => void };
|
|
25
|
+
type PanelToolContext = { ui?: PanelUi };
|
|
26
|
+
|
|
27
|
+
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const cliPath = path.resolve(extensionDir, "../bin/pi-review.js");
|
|
29
|
+
const PANEL_TOOL_DISPLAY_NAME = "Pi Review Panel";
|
|
30
|
+
const PANEL_STATUS_KEY = "pi-review";
|
|
31
|
+
const LIVE_TICK_MS = 2000;
|
|
32
|
+
|
|
33
|
+
function duration(ms: number | undefined): string {
|
|
34
|
+
if (!ms) return "0s";
|
|
35
|
+
return `${Math.floor(ms / 1000)}s`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function usageTokens(state: PanelViewState): string {
|
|
39
|
+
const usage = state.aggregate.usage;
|
|
40
|
+
if (!usage) return "—";
|
|
41
|
+
const cost = typeof usage.costTotal === "number" ? formatCost(usage.costTotal) : "n/a";
|
|
42
|
+
return `${formatTokens(usage.totalTokens)} tok · cost ${cost}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function statusSymbol(status: string): string {
|
|
46
|
+
return status === "completed" ? "✓" : status === "failed" || status === "cancelled" ? "✗" : status === "running" ? "●" : "○";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compactText(state: PanelViewState, theme: Theme, now = Date.now()): string {
|
|
50
|
+
const elapsed = state.startedAt ? duration((state.completedAt ?? now) - state.startedAt) : "0s";
|
|
51
|
+
const progress = `${state.aggregate.completed}/${state.aggregate.total} completed`;
|
|
52
|
+
const header = theme.fg("toolTitle", theme.bold(`${PANEL_TOOL_DISPLAY_NAME} ${progress}`)) + theme.fg("muted", ` · ${state.phase} · ${elapsed} · ${usageTokens(state)}`);
|
|
53
|
+
const rows = Object.values(state.reviewers).map((reviewer) => {
|
|
54
|
+
const lifecycle = ` · ${reviewer.status}`;
|
|
55
|
+
const model = reviewer.model ? ` · ${reviewer.model}` : "";
|
|
56
|
+
const thinking = reviewer.thinking ? ` · ${reviewer.thinking}` : "";
|
|
57
|
+
const active = reviewer.activeTool ? ` · ${reviewer.activeTool}` : "";
|
|
58
|
+
const activeSummary = reviewer.activeToolSummary ? ` · ${reviewer.activeToolSummary}` : "";
|
|
59
|
+
const reviewerElapsed = reviewer.startedAt ? ` · ${duration((reviewer.completedAt ?? now) - reviewer.startedAt)}` : "";
|
|
60
|
+
const idle = reviewer.status === "running" && reviewer.activityAt ? ` · idle ${duration(now - reviewer.activityAt)}` : "";
|
|
61
|
+
const tokens = reviewer.usage ? ` · ${formatTokens(reviewer.usage.totalTokens)} tok` : "";
|
|
62
|
+
const cost = reviewer.usage ? ` · cost ${typeof reviewer.usage.costTotal === "number" ? formatCost(reviewer.usage.costTotal) : "n/a"}` : "";
|
|
63
|
+
return `${theme.fg(reviewer.status === "failed" ? "error" : reviewer.status === "completed" ? "success" : "accent", statusSymbol(reviewer.status))} ${theme.fg("toolTitle", reviewer.reviewerId)}${theme.fg("muted", ` ${reviewer.role}${lifecycle}${model}${thinking}${active}${activeSummary}${reviewerElapsed}${idle}${tokens}${cost}`)}`;
|
|
64
|
+
});
|
|
65
|
+
return [header, ...rows].join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Expand hint that respects configured keybindings when available. */
|
|
69
|
+
export function expandHint(): string {
|
|
70
|
+
try {
|
|
71
|
+
return keyHint("app.tools.expand", "to expand");
|
|
72
|
+
} catch {
|
|
73
|
+
return "ctrl+o to expand";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Plain-text findings list (no markdown). Pi TUI does not render markdown. */
|
|
78
|
+
export function formatFindingsPlain(meta: PanelReviewMeta): string {
|
|
79
|
+
const lines: string[] = [];
|
|
80
|
+
if (meta.confirmedClusters.length > 0) {
|
|
81
|
+
lines.push("Confirmed findings:");
|
|
82
|
+
for (const c of meta.confirmedClusters) {
|
|
83
|
+
const bits = [
|
|
84
|
+
c.summary,
|
|
85
|
+
c.severity,
|
|
86
|
+
c.path,
|
|
87
|
+
`${c.supportCount}/${meta.configuredReviewers} reviewers`,
|
|
88
|
+
c.supportingReviewerIds.join(", "),
|
|
89
|
+
].filter(Boolean);
|
|
90
|
+
lines.push(` - ${bits.join(" · ")}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (meta.advisories.length > 0) {
|
|
94
|
+
lines.push("Advisories (non-blocking):");
|
|
95
|
+
for (const c of meta.advisories) {
|
|
96
|
+
const bits = [
|
|
97
|
+
c.summary,
|
|
98
|
+
c.severity,
|
|
99
|
+
c.path,
|
|
100
|
+
`${c.supportCount}/${meta.configuredReviewers} reviewers`,
|
|
101
|
+
c.supportingReviewerIds.join(", "),
|
|
102
|
+
].filter(Boolean);
|
|
103
|
+
lines.push(` - ${bits.join(" · ")}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function reviewerSummaryLines(state: PanelViewState): string[] {
|
|
110
|
+
const fromMeta = state.meta?.reviewers ?? [];
|
|
111
|
+
if (fromMeta.length > 0) {
|
|
112
|
+
return fromMeta.map((reviewer) => {
|
|
113
|
+
const live = state.reviewers[reviewer.reviewerId];
|
|
114
|
+
const role = reviewer.role ? ` · ${reviewer.role}` : "";
|
|
115
|
+
const model = reviewer.model ? ` · ${reviewer.model}` : "";
|
|
116
|
+
const thinking = reviewer.thinking ? ` · ${reviewer.thinking}` : "";
|
|
117
|
+
const usage = reviewer.usage ? ` · ${formatTokens(reviewer.usage.totalTokens)} tok · ${formatUsage(reviewer.usage)}` : "";
|
|
118
|
+
const cost = reviewer.usage ? ` · cost ${typeof reviewer.usage.costTotal === "number" ? formatCost(reviewer.usage.costTotal) : "n/a"}` : "";
|
|
119
|
+
const head = `- ${reviewer.reviewerId}${role}${model}${thinking} · ${reviewer.status} · ${reviewer.verdict} · ${formatDurationMs(reviewer.durationMs)}${usage}${cost}`;
|
|
120
|
+
const findings = live?.submission?.result.findings?.map((finding) => finding.summary).filter(Boolean).slice(0, 5) ?? [];
|
|
121
|
+
if (findings.length > 0) return `${head}\n ${findings.map((summary) => `• ${summary}`).join("\n ")}`;
|
|
122
|
+
if (reviewer.runtimeError || reviewer.parseError) return `${head}\n • ${reviewer.runtimeError ?? reviewer.parseError}`;
|
|
123
|
+
if (live?.error) return `${head}\n • ${live.error}`;
|
|
124
|
+
return head;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return Object.values(state.reviewers).map((reviewer) => {
|
|
128
|
+
const role = reviewer.role ? ` · ${reviewer.role}` : "";
|
|
129
|
+
const model = reviewer.model ? ` · ${reviewer.model}` : "";
|
|
130
|
+
const thinking = reviewer.thinking ? ` · ${reviewer.thinking}` : "";
|
|
131
|
+
const status = reviewer.submission?.result.status ?? reviewer.status;
|
|
132
|
+
const verdict = reviewer.submission?.result.verdict ? ` · ${reviewer.submission.result.verdict}` : "";
|
|
133
|
+
const durationMs = reviewer.startedAt ? (reviewer.completedAt ?? Date.now()) - reviewer.startedAt : undefined;
|
|
134
|
+
const usage = reviewer.usage ? ` · ${formatTokens(reviewer.usage.totalTokens)} tok · ${formatUsage(reviewer.usage)}` : "";
|
|
135
|
+
const cost = reviewer.usage ? ` · cost ${typeof reviewer.usage.costTotal === "number" ? formatCost(reviewer.usage.costTotal) : "n/a"}` : "";
|
|
136
|
+
const head = `- ${reviewer.reviewerId}${role}${model}${thinking} · ${status}${verdict}${durationMs !== undefined ? ` · ${formatDurationMs(durationMs)}` : ""}${usage}${cost}`;
|
|
137
|
+
const findings = reviewer.submission?.result.findings?.map((finding) => finding.summary).filter(Boolean).slice(0, 5) ?? [];
|
|
138
|
+
if (findings.length > 0) return `${head}\n ${findings.map((summary) => `• ${summary}`).join("\n ")}`;
|
|
139
|
+
if (reviewer.error) return `${head}\n • ${reviewer.error}`;
|
|
140
|
+
return head;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Full panel conclusion for the parent LLM and expanded TUI.
|
|
146
|
+
* Pure plain text / ASCII only — Pi does not render markdown in tool results.
|
|
147
|
+
*/
|
|
148
|
+
export function buildPanelResultContent(state: PanelViewState, error?: string): string {
|
|
149
|
+
const meta = state.meta;
|
|
150
|
+
if (!meta && !error) return "Panel completed: unknown";
|
|
151
|
+
if (!meta) return error ?? "Panel completed: unknown";
|
|
152
|
+
|
|
153
|
+
const findings = formatFindingsPlain(meta);
|
|
154
|
+
const ascii = formatPanelMetaAscii(meta);
|
|
155
|
+
const summaries = reviewerSummaryLines(state);
|
|
156
|
+
const parts = [
|
|
157
|
+
findings,
|
|
158
|
+
ascii,
|
|
159
|
+
summaries.length ? `Reviewer summaries:\n${summaries.join("\n")}` : "",
|
|
160
|
+
error ? `Error:\n${error}` : "",
|
|
161
|
+
].filter(Boolean);
|
|
162
|
+
return parts.join("\n\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ambientStatus(state: PanelViewState, now = Date.now()): string {
|
|
166
|
+
const elapsed = state.startedAt ? duration((state.completedAt ?? now) - state.startedAt) : "0s";
|
|
167
|
+
const running = state.aggregate.running;
|
|
168
|
+
const phase = state.phase === "aggregating" ? "aggregating" : state.phase;
|
|
169
|
+
const runningBit = running > 0 ? ` · ${running} running` : "";
|
|
170
|
+
return `● panel ${state.aggregate.completed}/${state.aggregate.total}${runningBit} · ${phase} · ${elapsed}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function setAmbientStatus(ctx: PanelToolContext | undefined, text: string | undefined): void {
|
|
174
|
+
try {
|
|
175
|
+
ctx?.ui?.setStatus?.(PANEL_STATUS_KEY, text);
|
|
176
|
+
} catch {
|
|
177
|
+
// Non-interactive / print hosts may not implement status chrome.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Stable tool-result component reused across onUpdate renders.
|
|
183
|
+
* Collapsed and expanded paths mutate the same child instances instead of rebuilding trees.
|
|
184
|
+
*/
|
|
185
|
+
export class PanelResultView implements Component {
|
|
186
|
+
private readonly compact = new Text("", 0, 0);
|
|
187
|
+
private readonly activity = new Text("", 0, 0);
|
|
188
|
+
private readonly conclusion = new Text("", 0, 0);
|
|
189
|
+
private readonly error = new Text("", 0, 0);
|
|
190
|
+
private showActivity = false;
|
|
191
|
+
private showConclusion = false;
|
|
192
|
+
private showError = false;
|
|
193
|
+
|
|
194
|
+
update(details: PanelToolDetails, expanded: boolean, theme: Theme, isPartial = false, now = Date.now()): void {
|
|
195
|
+
const body = compactText(details.state, theme, now);
|
|
196
|
+
if (!expanded) {
|
|
197
|
+
// Discoverability: host expand binding (default ctrl+o).
|
|
198
|
+
this.compact.setText(`${body}\n${expandHint()}`);
|
|
199
|
+
this.showActivity = false;
|
|
200
|
+
this.showConclusion = false;
|
|
201
|
+
this.showError = Boolean(details.error);
|
|
202
|
+
if (details.error) this.error.setText(theme.fg("error", details.error));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.compact.setText(body);
|
|
207
|
+
const activityBlocks = Object.values(details.state.reviewers)
|
|
208
|
+
.filter((reviewer) => reviewer.recentActivity.length > 0)
|
|
209
|
+
.map((reviewer) => `${theme.fg("muted", `${reviewer.reviewerId} activity`)}\n${reviewer.recentActivity.join("\n")}`);
|
|
210
|
+
this.showActivity = activityBlocks.length > 0;
|
|
211
|
+
if (this.showActivity) this.activity.setText(activityBlocks.join("\n\n"));
|
|
212
|
+
|
|
213
|
+
// Plain Text only — Pi does not render markdown in tool result components.
|
|
214
|
+
const conclusion = !isPartial && details.state.meta ? buildPanelResultContent(details.state) : "";
|
|
215
|
+
this.showConclusion = Boolean(conclusion);
|
|
216
|
+
if (conclusion) this.conclusion.setText(conclusion);
|
|
217
|
+
|
|
218
|
+
this.showError = Boolean(details.error);
|
|
219
|
+
if (details.error) this.error.setText(theme.fg("error", details.error));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
invalidate(): void {
|
|
223
|
+
this.compact.invalidate();
|
|
224
|
+
this.activity.invalidate();
|
|
225
|
+
this.conclusion.invalidate();
|
|
226
|
+
this.error.invalidate();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
render(width: number): string[] {
|
|
230
|
+
const lines = [...this.compact.render(width)];
|
|
231
|
+
if (this.showActivity) {
|
|
232
|
+
lines.push("");
|
|
233
|
+
lines.push(...this.activity.render(width));
|
|
234
|
+
}
|
|
235
|
+
if (this.showConclusion) {
|
|
236
|
+
lines.push("");
|
|
237
|
+
lines.push(...this.conclusion.render(width));
|
|
238
|
+
}
|
|
239
|
+
if (this.showError) {
|
|
240
|
+
lines.push("");
|
|
241
|
+
lines.push(...this.error.render(width));
|
|
242
|
+
}
|
|
243
|
+
return lines;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function renderPanelResult(details: PanelToolDetails | undefined, expanded: boolean, theme: Theme, previous?: Component, isPartial = false, now = Date.now()): Component {
|
|
248
|
+
if (!details) return new Text(theme.fg("muted", isPartial ? `${PANEL_TOOL_DISPLAY_NAME} · starting…` : "Panel review has no progress details."), 0, 0);
|
|
249
|
+
const view = previous instanceof PanelResultView ? previous : new PanelResultView();
|
|
250
|
+
view.update(details, expanded, theme, isPartial, now);
|
|
251
|
+
return view;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export type PanelToolParams = {
|
|
255
|
+
target: string;
|
|
256
|
+
panel?: string;
|
|
257
|
+
reviewers?: number;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** Runtime guard for direct tool calls; schema validation is not the only boundary. */
|
|
261
|
+
export function panelToolParamError(params: PanelToolParams): string | undefined {
|
|
262
|
+
if (params.reviewers !== undefined) {
|
|
263
|
+
if (!Number.isSafeInteger(params.reviewers) || params.reviewers < 2 || params.reviewers > 8) {
|
|
264
|
+
return "reviewers must be an integer between 2 and 8; use the shell CLI for a single reviewer";
|
|
265
|
+
}
|
|
266
|
+
if (params.panel) return "panel cannot be combined with reviewers";
|
|
267
|
+
}
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function registerPanelReviewTool(pi: ExtensionAPI): void {
|
|
272
|
+
pi.registerTool({
|
|
273
|
+
name: "pi_review",
|
|
274
|
+
label: "Pi Review Panel",
|
|
275
|
+
description: "Run an isolated read-only pi-review panel and render each reviewer live.",
|
|
276
|
+
renderShell: "self",
|
|
277
|
+
parameters: Type.Object({
|
|
278
|
+
target: Type.String({ description: "Review target as given by the user, such as @src, @src/foo.ts, or a free-text brief. Directories stay tool path targets; only real files are attached." }),
|
|
279
|
+
mode: Type.Optional(Type.String({ description: "Review mode; defaults to code." })),
|
|
280
|
+
panel: Type.Optional(Type.String({ description: "Named panel preset; defaults to code-experts when reviewers is omitted." })),
|
|
281
|
+
reviewers: Type.Optional(Type.Number({ minimum: 2, maximum: 8, description: "Independent reviewer count (2-8). Cannot combine with panel; single review uses the shell CLI." })),
|
|
282
|
+
reviewerModels: Type.Optional(Type.Array(Type.String(), { description: "Per-reviewer models as id=provider/model[:thinking] (e.g. r1=openai/gpt-5.6-sol:low). Trailing :thinking wins over shared thinking." })),
|
|
283
|
+
consensus: Type.Optional(Type.String({ description: "any | quorum | majority | unanimous" })),
|
|
284
|
+
minAgree: Type.Optional(Type.Number({ description: "Quorum threshold when consensus=quorum" })),
|
|
285
|
+
consensusModel: Type.Optional(Type.String({ description: "Model for semantic adjudication only" })),
|
|
286
|
+
concurrency: Type.Optional(Type.Number({ description: "Bound parallel reviewers (≤ reviewer count)" })),
|
|
287
|
+
model: Type.Optional(Type.String({ description: "Optional shared reviewer model." })),
|
|
288
|
+
thinking: Type.Optional(Type.String({ description: "Optional thinking level." })),
|
|
289
|
+
}),
|
|
290
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
291
|
+
let state = createPanelViewState();
|
|
292
|
+
let buffer = "";
|
|
293
|
+
let stderr = "";
|
|
294
|
+
const toolCtx = ctx as PanelToolContext | undefined;
|
|
295
|
+
const publish = () => {
|
|
296
|
+
onUpdate?.({ content: [{ type: "text", text: `${PANEL_TOOL_DISPLAY_NAME}: ${state.aggregate.completed}/${state.aggregate.total} completed` }], details: { state } });
|
|
297
|
+
if (state.phase === "running" || state.phase === "aggregating") {
|
|
298
|
+
setAmbientStatus(toolCtx, ambientStatus(state));
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const consume = (chunk: string) => {
|
|
302
|
+
buffer += chunk;
|
|
303
|
+
let newline: number;
|
|
304
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
305
|
+
const line = buffer.slice(0, newline);
|
|
306
|
+
buffer = buffer.slice(newline + 1);
|
|
307
|
+
try {
|
|
308
|
+
state = reducePanelEvent(state, JSON.parse(line) as ReviewEvent);
|
|
309
|
+
publish();
|
|
310
|
+
} catch {
|
|
311
|
+
// Only the stable pi-review event protocol reaches this stream.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const stdoutSink = new Writable({ write(chunk, _encoding, callback) { consume(String(chunk)); callback(); } });
|
|
316
|
+
const stderrSink = new Writable({
|
|
317
|
+
write(chunk, _encoding, callback) {
|
|
318
|
+
const next = stderr + String(chunk);
|
|
319
|
+
stderr = next.length > 2000 ? next.slice(-2000) : next;
|
|
320
|
+
callback();
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
// Preserve the full panel configuration and enforce the panel-only tool boundary.
|
|
324
|
+
const paramError = panelToolParamError(params);
|
|
325
|
+
if (paramError) {
|
|
326
|
+
setAmbientStatus(toolCtx, undefined);
|
|
327
|
+
return {
|
|
328
|
+
content: [{ type: "text", text: `${PANEL_TOOL_DISPLAY_NAME}: ${paramError}` }],
|
|
329
|
+
details: { state, error: paramError },
|
|
330
|
+
isError: true,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const args = [cliPath, "--mode", params.mode ?? "code", "--output-format", "events-jsonl"];
|
|
334
|
+
if (params.reviewers !== undefined) {
|
|
335
|
+
args.push("--reviewers", String(params.reviewers));
|
|
336
|
+
} else {
|
|
337
|
+
args.push("--panel", params.panel ?? "code-experts");
|
|
338
|
+
}
|
|
339
|
+
for (const mapping of params.reviewerModels ?? []) args.push("--reviewer-model", mapping);
|
|
340
|
+
if (params.consensus) args.push("--consensus", params.consensus);
|
|
341
|
+
if (params.minAgree !== undefined) args.push("--min-agree", String(params.minAgree));
|
|
342
|
+
if (params.consensusModel) args.push("--consensus-model", params.consensusModel);
|
|
343
|
+
if (params.concurrency !== undefined) args.push("--concurrency", String(params.concurrency));
|
|
344
|
+
if (params.model) args.push("--model", params.model);
|
|
345
|
+
if (params.thinking) args.push("--thinking", params.thinking);
|
|
346
|
+
args.push("--", params.target);
|
|
347
|
+
|
|
348
|
+
// Keep elapsed/idle labels fresh even when the child is quiet between events.
|
|
349
|
+
const tick = setInterval(() => {
|
|
350
|
+
if (state.phase === "running" || state.phase === "aggregating") publish();
|
|
351
|
+
}, LIVE_TICK_MS);
|
|
352
|
+
tick.unref?.();
|
|
353
|
+
|
|
354
|
+
let child: Awaited<ReturnType<typeof spawnStreamingChild>>;
|
|
355
|
+
try {
|
|
356
|
+
setAmbientStatus(toolCtx, ambientStatus(state));
|
|
357
|
+
child = await spawnStreamingChild(process.execPath, args, { cwd: process.cwd(), env: process.env, stdoutSink, stderrSink, signal, processGroup: true });
|
|
358
|
+
} finally {
|
|
359
|
+
clearInterval(tick);
|
|
360
|
+
setAmbientStatus(toolCtx, undefined);
|
|
361
|
+
}
|
|
362
|
+
if (buffer.trim()) {
|
|
363
|
+
const tail = buffer;
|
|
364
|
+
buffer = "";
|
|
365
|
+
consume(`${tail}\n`);
|
|
366
|
+
}
|
|
367
|
+
const error = child.error
|
|
368
|
+
? child.error.message
|
|
369
|
+
: state.meta
|
|
370
|
+
? undefined
|
|
371
|
+
: [`pi-review ended before a final event (${child.status ?? child.signal ?? "unknown"})`, stderr.trim()].filter(Boolean).join(": ");
|
|
372
|
+
const details: PanelToolDetails = { state, ...(error ? { error } : {}) };
|
|
373
|
+
return {
|
|
374
|
+
// Parent LLM must receive the full conclusion; details remain the TUI rendering source.
|
|
375
|
+
content: [{ type: "text", text: buildPanelResultContent(state, error) }],
|
|
376
|
+
details,
|
|
377
|
+
...(error ? { isError: true } : {}),
|
|
378
|
+
};
|
|
379
|
+
},
|
|
380
|
+
renderCall(args, theme, context) {
|
|
381
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
382
|
+
const width = args.reviewers !== undefined ? `reviewers=${args.reviewers}` : (args.panel ?? "code-experts");
|
|
383
|
+
text.setText(theme.fg("toolTitle", theme.bold(`${PANEL_TOOL_DISPLAY_NAME} `)) + theme.fg("muted", `${width} · ${args.mode ?? "code"} · ${args.target}`));
|
|
384
|
+
return text;
|
|
385
|
+
},
|
|
386
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
387
|
+
return renderPanelResult(result.details as PanelToolDetails | undefined, expanded, theme, context.lastComponent, isPartial);
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "vitest";
|
|
3
|
+
import piReviewExtension from "./review.js";
|
|
4
|
+
|
|
5
|
+
type CommandConfig = {
|
|
6
|
+
getArgumentCompletions?: (prefix: string) => Array<{ value: string; label: string }> | null;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type SessionStart = (_event: unknown, ctx: {
|
|
10
|
+
modelRegistry: { getAvailable: () => unknown[] };
|
|
11
|
+
model?: { provider?: string };
|
|
12
|
+
sessionManager: { getEntries: () => unknown[] };
|
|
13
|
+
}) => void;
|
|
14
|
+
|
|
15
|
+
function registeredExtension(): { commands: Map<string, CommandConfig>; sessionStart: SessionStart } {
|
|
16
|
+
const commands = new Map<string, CommandConfig>();
|
|
17
|
+
let sessionStart: SessionStart | undefined;
|
|
18
|
+
const pi = {
|
|
19
|
+
registerTool() {},
|
|
20
|
+
on(event: string, handler: SessionStart) {
|
|
21
|
+
if (event === "session_start") sessionStart = handler;
|
|
22
|
+
},
|
|
23
|
+
registerCommand(name: string, config: CommandConfig) {
|
|
24
|
+
commands.set(name, config);
|
|
25
|
+
},
|
|
26
|
+
sendUserMessage() {},
|
|
27
|
+
};
|
|
28
|
+
piReviewExtension(pi as never);
|
|
29
|
+
assert.ok(sessionStart);
|
|
30
|
+
return { commands, sessionStart };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const emptySession = () => ({
|
|
34
|
+
modelRegistry: { getAvailable: () => [] },
|
|
35
|
+
sessionManager: { getEntries: () => [] },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("extension completion fallback never replaces an entered target when the catalog is empty", () => {
|
|
39
|
+
const { commands, sessionStart } = registeredExtension();
|
|
40
|
+
sessionStart({}, emptySession());
|
|
41
|
+
const complete = commands.get("rv")?.getArgumentCompletions;
|
|
42
|
+
assert.ok(complete);
|
|
43
|
+
|
|
44
|
+
const modelItems = complete!("@src --model ");
|
|
45
|
+
assert.ok(modelItems);
|
|
46
|
+
assert.ok(modelItems!.every((item) => item.value.startsWith("@src --model ")));
|
|
47
|
+
|
|
48
|
+
// Natural-language targets have no static candidate; returning null is safer than
|
|
49
|
+
// replacing the whole argument with a bare --model/--mode suggestion.
|
|
50
|
+
assert.equal(complete!("review auth behavior"), null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a new session with an empty catalog does not reuse models captured by a previous session", () => {
|
|
54
|
+
const { commands, sessionStart } = registeredExtension();
|
|
55
|
+
const complete = commands.get("rv")?.getArgumentCompletions;
|
|
56
|
+
assert.ok(complete);
|
|
57
|
+
|
|
58
|
+
sessionStart({}, {
|
|
59
|
+
modelRegistry: {
|
|
60
|
+
getAvailable: () => [{
|
|
61
|
+
provider: "old-provider",
|
|
62
|
+
id: "old-model",
|
|
63
|
+
name: "old-model",
|
|
64
|
+
reasoning: true,
|
|
65
|
+
contextWindow: 100_000,
|
|
66
|
+
thinkingLevelMap: { high: "high" },
|
|
67
|
+
}],
|
|
68
|
+
},
|
|
69
|
+
model: { provider: "old-provider" },
|
|
70
|
+
sessionManager: { getEntries: () => [] },
|
|
71
|
+
});
|
|
72
|
+
assert.ok(complete!("--model ")?.some((item) => item.label === "old-provider/old-model"));
|
|
73
|
+
|
|
74
|
+
sessionStart({}, emptySession());
|
|
75
|
+
const next = complete!("--model ");
|
|
76
|
+
assert.ok(next);
|
|
77
|
+
assert.ok(!next!.some((item) => item.label === "old-provider/old-model"));
|
|
78
|
+
assert.ok(next!.some((item) => item.label === "<provider/model>"));
|
|
79
|
+
});
|