@zephyrdeng/pi-review 0.7.1 → 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 +37 -8
- package/README.zh-CN.md +31 -5
- package/dist/args.d.ts +4 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +61 -4
- package/dist/args.js.map +1 -1
- package/dist/args.test.js +62 -1
- package/dist/args.test.js.map +1 -1
- package/dist/child-process.d.ts +3 -0
- package/dist/child-process.d.ts.map +1 -1
- package/dist/child-process.js +27 -5
- package/dist/child-process.js.map +1 -1
- package/dist/child-process.test.js +42 -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 +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -4
- 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 +6 -0
- package/dist/json-events.d.ts.map +1 -1
- package/dist/json-events.js +48 -6
- package/dist/json-events.js.map +1 -1
- package/dist/json-events.test.js +20 -1
- 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.js +33 -2
- package/dist/panel-cancellation.test.js.map +1 -1
- 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 +37 -39
- 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 +135 -2
- package/dist/panel-config.js.map +1 -1
- package/dist/panel-config.test.js +79 -2
- 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.js +1 -1
- package/dist/panel-tools.test.js.map +1 -1
- 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-view.d.ts +1 -0
- package/dist/panel-view.d.ts.map +1 -1
- package/dist/panel-view.js +40 -6
- package/dist/panel-view.js.map +1 -1
- package/dist/panel-view.test.js +68 -1
- package/dist/panel-view.test.js.map +1 -1
- package/dist/panel.d.ts +11 -1
- package/dist/panel.d.ts.map +1 -1
- package/dist/panel.js +67 -21
- 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.test.js +1 -1
- package/dist/review-events.test.js.map +1 -1
- 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 +23 -1
- package/dist/types.d.ts.map +1 -1
- 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 +366 -8
- package/extensions/panel-tool.ts +289 -68
- package/extensions/review.test.ts +79 -0
- package/extensions/review.ts +188 -44
- 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 +274 -2
- package/extensions/rv-prompts.ts +479 -33
- 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 +7 -4
- 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
package/extensions/panel-tool.ts
CHANGED
|
@@ -3,14 +3,32 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { Writable } from "node:stream";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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";
|
|
8
21
|
|
|
9
22
|
type Theme = { fg: (color: "toolTitle" | "muted" | "error" | "success" | "accent", text: string) => string; bold: (text: string) => string };
|
|
10
23
|
type PanelToolDetails = { state: PanelViewState; error?: string };
|
|
24
|
+
type PanelUi = { setStatus?: (key: string, text: string | undefined) => void };
|
|
25
|
+
type PanelToolContext = { ui?: PanelUi };
|
|
11
26
|
|
|
12
27
|
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
|
13
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;
|
|
14
32
|
|
|
15
33
|
function duration(ms: number | undefined): string {
|
|
16
34
|
if (!ms) return "0s";
|
|
@@ -18,81 +36,236 @@ function duration(ms: number | undefined): string {
|
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
function usageTokens(state: PanelViewState): string {
|
|
21
|
-
|
|
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}`;
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
function statusSymbol(status: string): string {
|
|
25
46
|
return status === "completed" ? "✓" : status === "failed" || status === "cancelled" ? "✗" : status === "running" ? "●" : "○";
|
|
26
47
|
}
|
|
27
48
|
|
|
28
|
-
function compactText(state: PanelViewState, theme: Theme): string {
|
|
29
|
-
const elapsed = state.startedAt ? duration((state.completedAt ??
|
|
49
|
+
function compactText(state: PanelViewState, theme: Theme, now = Date.now()): string {
|
|
50
|
+
const elapsed = state.startedAt ? duration((state.completedAt ?? now) - state.startedAt) : "0s";
|
|
30
51
|
const progress = `${state.aggregate.completed}/${state.aggregate.total} completed`;
|
|
31
|
-
const header = theme.fg("toolTitle", theme.bold(
|
|
52
|
+
const header = theme.fg("toolTitle", theme.bold(`${PANEL_TOOL_DISPLAY_NAME} ${progress}`)) + theme.fg("muted", ` · ${state.phase} · ${elapsed} · ${usageTokens(state)}`);
|
|
32
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}` : "";
|
|
33
57
|
const active = reviewer.activeTool ? ` · ${reviewer.activeTool}` : "";
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
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}`)}`;
|
|
37
64
|
});
|
|
38
65
|
return [header, ...rows].join("\n");
|
|
39
66
|
}
|
|
40
67
|
|
|
41
|
-
|
|
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 {
|
|
42
149
|
const meta = state.meta;
|
|
43
|
-
if (!meta) return "";
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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}`;
|
|
52
171
|
}
|
|
53
172
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return text;
|
|
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.
|
|
60
178
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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));
|
|
68
220
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
heading: theme.bold,
|
|
76
|
-
link: (text) => text,
|
|
77
|
-
linkUrl: (text) => text,
|
|
78
|
-
code: (text) => text,
|
|
79
|
-
codeBlock: (text) => text,
|
|
80
|
-
codeBlockBorder: (text) => text,
|
|
81
|
-
quote: (text) => text,
|
|
82
|
-
quoteBorder: (text) => text,
|
|
83
|
-
hr: (text) => text,
|
|
84
|
-
listBullet: (text) => text,
|
|
85
|
-
bold: theme.bold,
|
|
86
|
-
italic: (text) => text,
|
|
87
|
-
strikethrough: (text) => text,
|
|
88
|
-
underline: (text) => text,
|
|
89
|
-
}));
|
|
221
|
+
|
|
222
|
+
invalidate(): void {
|
|
223
|
+
this.compact.invalidate();
|
|
224
|
+
this.activity.invalidate();
|
|
225
|
+
this.conclusion.invalidate();
|
|
226
|
+
this.error.invalidate();
|
|
90
227
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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;
|
|
94
244
|
}
|
|
95
|
-
|
|
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;
|
|
96
269
|
}
|
|
97
270
|
|
|
98
271
|
export function registerPanelReviewTool(pi: ExtensionAPI): void {
|
|
@@ -102,17 +275,29 @@ export function registerPanelReviewTool(pi: ExtensionAPI): void {
|
|
|
102
275
|
description: "Run an isolated read-only pi-review panel and render each reviewer live.",
|
|
103
276
|
renderShell: "self",
|
|
104
277
|
parameters: Type.Object({
|
|
105
|
-
target: Type.String({ description: "Review target, such as @src or a
|
|
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." }),
|
|
106
279
|
mode: Type.Optional(Type.String({ description: "Review mode; defaults to code." })),
|
|
107
|
-
panel: Type.Optional(Type.String({ description: "Named panel preset; defaults to code-experts." })),
|
|
108
|
-
|
|
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." })),
|
|
109
288
|
thinking: Type.Optional(Type.String({ description: "Optional thinking level." })),
|
|
110
289
|
}),
|
|
111
|
-
async execute(_toolCallId, params, signal, onUpdate) {
|
|
290
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
112
291
|
let state = createPanelViewState();
|
|
113
292
|
let buffer = "";
|
|
114
293
|
let stderr = "";
|
|
115
|
-
const
|
|
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
|
+
};
|
|
116
301
|
const consume = (chunk: string) => {
|
|
117
302
|
buffer += chunk;
|
|
118
303
|
let newline: number;
|
|
@@ -135,11 +320,45 @@ export function registerPanelReviewTool(pi: ExtensionAPI): void {
|
|
|
135
320
|
callback();
|
|
136
321
|
},
|
|
137
322
|
});
|
|
138
|
-
|
|
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));
|
|
139
344
|
if (params.model) args.push("--model", params.model);
|
|
140
345
|
if (params.thinking) args.push("--thinking", params.thinking);
|
|
141
346
|
args.push("--", params.target);
|
|
142
|
-
|
|
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
|
+
}
|
|
143
362
|
if (buffer.trim()) {
|
|
144
363
|
const tail = buffer;
|
|
145
364
|
buffer = "";
|
|
@@ -152,18 +371,20 @@ export function registerPanelReviewTool(pi: ExtensionAPI): void {
|
|
|
152
371
|
: [`pi-review ended before a final event (${child.status ?? child.signal ?? "unknown"})`, stderr.trim()].filter(Boolean).join(": ");
|
|
153
372
|
const details: PanelToolDetails = { state, ...(error ? { error } : {}) };
|
|
154
373
|
return {
|
|
155
|
-
|
|
374
|
+
// Parent LLM must receive the full conclusion; details remain the TUI rendering source.
|
|
375
|
+
content: [{ type: "text", text: buildPanelResultContent(state, error) }],
|
|
156
376
|
details,
|
|
157
377
|
...(error ? { isError: true } : {}),
|
|
158
378
|
};
|
|
159
379
|
},
|
|
160
380
|
renderCall(args, theme, context) {
|
|
161
381
|
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
162
|
-
|
|
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}`));
|
|
163
384
|
return text;
|
|
164
385
|
},
|
|
165
|
-
renderResult(result, { expanded }, theme, context) {
|
|
166
|
-
return renderPanelResult(result.details as PanelToolDetails | undefined, expanded, theme, context.lastComponent);
|
|
386
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
387
|
+
return renderPanelResult(result.details as PanelToolDetails | undefined, expanded, theme, context.lastComponent, isPartial);
|
|
167
388
|
},
|
|
168
389
|
});
|
|
169
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
|
+
});
|