@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.
Files changed (176) hide show
  1. package/README.md +37 -8
  2. package/README.zh-CN.md +31 -5
  3. package/dist/args.d.ts +4 -0
  4. package/dist/args.d.ts.map +1 -1
  5. package/dist/args.js +61 -4
  6. package/dist/args.js.map +1 -1
  7. package/dist/args.test.js +62 -1
  8. package/dist/args.test.js.map +1 -1
  9. package/dist/child-process.d.ts +3 -0
  10. package/dist/child-process.d.ts.map +1 -1
  11. package/dist/child-process.js +27 -5
  12. package/dist/child-process.js.map +1 -1
  13. package/dist/child-process.test.js +42 -1
  14. package/dist/child-process.test.js.map +1 -1
  15. package/dist/cli-exit.test.js +14 -5
  16. package/dist/cli-exit.test.js.map +1 -1
  17. package/dist/cli.js +8 -3
  18. package/dist/cli.js.map +1 -1
  19. package/dist/index.d.ts +7 -5
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -4
  22. package/dist/index.js.map +1 -1
  23. package/dist/install-args.test.js +1 -1
  24. package/dist/install-args.test.js.map +1 -1
  25. package/dist/install.d.ts.map +1 -1
  26. package/dist/install.js +2 -3
  27. package/dist/install.js.map +1 -1
  28. package/dist/json-events.d.ts +6 -0
  29. package/dist/json-events.d.ts.map +1 -1
  30. package/dist/json-events.js +48 -6
  31. package/dist/json-events.js.map +1 -1
  32. package/dist/json-events.test.js +20 -1
  33. package/dist/json-events.test.js.map +1 -1
  34. package/dist/loop.d.ts +28 -2
  35. package/dist/loop.d.ts.map +1 -1
  36. package/dist/loop.js +45 -3
  37. package/dist/loop.js.map +1 -1
  38. package/dist/loop.test.js +24 -1
  39. package/dist/loop.test.js.map +1 -1
  40. package/dist/matcher.test.js +1 -1
  41. package/dist/matcher.test.js.map +1 -1
  42. package/dist/meta-footer.d.ts +4 -8
  43. package/dist/meta-footer.d.ts.map +1 -1
  44. package/dist/meta-footer.js +19 -5
  45. package/dist/meta-footer.js.map +1 -1
  46. package/dist/meta-footer.test.js +11 -4
  47. package/dist/meta-footer.test.js.map +1 -1
  48. package/dist/panel-aggregate.test.js +1 -1
  49. package/dist/panel-aggregate.test.js.map +1 -1
  50. package/dist/panel-cancellation.test.js +33 -2
  51. package/dist/panel-cancellation.test.js.map +1 -1
  52. package/dist/panel-cli-ui.test.d.ts +2 -0
  53. package/dist/panel-cli-ui.test.d.ts.map +1 -0
  54. package/dist/panel-cli-ui.test.js +91 -0
  55. package/dist/panel-cli-ui.test.js.map +1 -0
  56. package/dist/panel-cli.test.js +37 -39
  57. package/dist/panel-cli.test.js.map +1 -1
  58. package/dist/panel-config.d.ts +38 -0
  59. package/dist/panel-config.d.ts.map +1 -1
  60. package/dist/panel-config.js +135 -2
  61. package/dist/panel-config.js.map +1 -1
  62. package/dist/panel-config.test.js +79 -2
  63. package/dist/panel-config.test.js.map +1 -1
  64. package/dist/panel-loop.test.js +1 -1
  65. package/dist/panel-loop.test.js.map +1 -1
  66. package/dist/panel-tools.test.js +1 -1
  67. package/dist/panel-tools.test.js.map +1 -1
  68. package/dist/panel-ui.d.ts +20 -0
  69. package/dist/panel-ui.d.ts.map +1 -0
  70. package/dist/panel-ui.js +84 -0
  71. package/dist/panel-ui.js.map +1 -0
  72. package/dist/panel-ui.test.d.ts +2 -0
  73. package/dist/panel-ui.test.d.ts.map +1 -0
  74. package/dist/panel-ui.test.js +122 -0
  75. package/dist/panel-ui.test.js.map +1 -0
  76. package/dist/panel-view.d.ts +1 -0
  77. package/dist/panel-view.d.ts.map +1 -1
  78. package/dist/panel-view.js +40 -6
  79. package/dist/panel-view.js.map +1 -1
  80. package/dist/panel-view.test.js +68 -1
  81. package/dist/panel-view.test.js.map +1 -1
  82. package/dist/panel.d.ts +11 -1
  83. package/dist/panel.d.ts.map +1 -1
  84. package/dist/panel.js +67 -21
  85. package/dist/panel.js.map +1 -1
  86. package/dist/prompt.d.ts +2 -0
  87. package/dist/prompt.d.ts.map +1 -1
  88. package/dist/prompt.js +34 -3
  89. package/dist/prompt.js.map +1 -1
  90. package/dist/prompt.test.js +21 -2
  91. package/dist/prompt.test.js.map +1 -1
  92. package/dist/review-events.test.js +1 -1
  93. package/dist/review-events.test.js.map +1 -1
  94. package/dist/review-result.test.js +1 -1
  95. package/dist/review-result.test.js.map +1 -1
  96. package/dist/review.js +3 -3
  97. package/dist/review.js.map +1 -1
  98. package/dist/review.meta.test.js +1 -1
  99. package/dist/review.meta.test.js.map +1 -1
  100. package/dist/skill-protocol.test.js +1 -1
  101. package/dist/skill-protocol.test.js.map +1 -1
  102. package/dist/skill-update.test.d.ts +2 -0
  103. package/dist/skill-update.test.d.ts.map +1 -0
  104. package/dist/skill-update.test.js +23 -0
  105. package/dist/skill-update.test.js.map +1 -0
  106. package/dist/skill.d.ts +22 -0
  107. package/dist/skill.d.ts.map +1 -1
  108. package/dist/skill.js +109 -22
  109. package/dist/skill.js.map +1 -1
  110. package/dist/types.d.ts +23 -1
  111. package/dist/types.d.ts.map +1 -1
  112. package/dist/types.js.map +1 -1
  113. package/dist/ui-client.d.ts +13 -0
  114. package/dist/ui-client.d.ts.map +1 -0
  115. package/dist/ui-client.js +161 -0
  116. package/dist/ui-client.js.map +1 -0
  117. package/dist/ui-client.test.d.ts +2 -0
  118. package/dist/ui-client.test.d.ts.map +1 -0
  119. package/dist/ui-client.test.js +48 -0
  120. package/dist/ui-client.test.js.map +1 -0
  121. package/dist/ui-html.d.ts +7 -0
  122. package/dist/ui-html.d.ts.map +1 -0
  123. package/dist/ui-html.js +52 -0
  124. package/dist/ui-html.js.map +1 -0
  125. package/dist/ui-html.test.d.ts +2 -0
  126. package/dist/ui-html.test.d.ts.map +1 -0
  127. package/dist/ui-html.test.js +26 -0
  128. package/dist/ui-html.test.js.map +1 -0
  129. package/dist/ui-server-main.d.ts +3 -0
  130. package/dist/ui-server-main.d.ts.map +1 -0
  131. package/dist/ui-server-main.js +128 -0
  132. package/dist/ui-server-main.js.map +1 -0
  133. package/dist/ui-server-main.test.d.ts +2 -0
  134. package/dist/ui-server-main.test.d.ts.map +1 -0
  135. package/dist/ui-server-main.test.js +123 -0
  136. package/dist/ui-server-main.test.js.map +1 -0
  137. package/dist/ui-server.d.ts +42 -0
  138. package/dist/ui-server.d.ts.map +1 -0
  139. package/dist/ui-server.js +233 -0
  140. package/dist/ui-server.js.map +1 -0
  141. package/dist/ui-server.test.d.ts +2 -0
  142. package/dist/ui-server.test.d.ts.map +1 -0
  143. package/dist/ui-server.test.js +236 -0
  144. package/dist/ui-server.test.js.map +1 -0
  145. package/dist/update.d.ts.map +1 -1
  146. package/dist/update.js +48 -19
  147. package/dist/update.js.map +1 -1
  148. package/extensions/panel-tool.test.ts +366 -8
  149. package/extensions/panel-tool.ts +289 -68
  150. package/extensions/review.test.ts +79 -0
  151. package/extensions/review.ts +188 -44
  152. package/extensions/rv-completions.test.ts +177 -18
  153. package/extensions/rv-completions.ts +490 -60
  154. package/extensions/rv-interactive.test.ts +562 -0
  155. package/extensions/rv-interactive.ts +616 -0
  156. package/extensions/rv-locale.test.ts +12 -4
  157. package/extensions/rv-locale.ts +44 -11
  158. package/extensions/rv-model-picker.test.ts +172 -0
  159. package/extensions/rv-model-picker.ts +313 -0
  160. package/extensions/rv-model-priorities.test.ts +29 -9
  161. package/extensions/rv-model-priorities.ts +23 -8
  162. package/extensions/rv-prompts.test.ts +274 -2
  163. package/extensions/rv-prompts.ts +479 -33
  164. package/extensions/rv-resolve.test.ts +189 -0
  165. package/extensions/rv-resolve.ts +313 -0
  166. package/extensions/rv-semantic.test.ts +1 -1
  167. package/extensions/rv-semantic.ts +38 -4
  168. package/package.json +7 -4
  169. package/resources/rv-model-priorities.json +18 -9
  170. package/skills/pi-review/SKILL.md +84 -60
  171. package/skills/pi-review/references/codex-tools.md +18 -11
  172. package/skills/pi-review/references/model-selection.md +49 -28
  173. package/dist/extensions/review.d.ts +0 -2
  174. package/dist/extensions/review.js +0 -22
  175. package/dist/extensions/rv-prompts.d.ts +0 -19
  176. package/dist/extensions/rv-prompts.js +0 -132
@@ -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 { Container, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
7
- import { createPanelViewState, reducePanelEvent, spawnStreamingChild, type PanelViewState, type ReviewEvent } from "@zephyrdeng/pi-review";
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
- return state.aggregate.usage ? `${state.aggregate.usage.totalTokens.toLocaleString()} tok` : "—";
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 ?? Date.now()) - state.startedAt) : "0s";
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(`pi-review panel ${progress}`)) + theme.fg("muted", ` · ${state.phase} · ${elapsed} · ${usageTokens(state)}`);
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 elapsed = reviewer.startedAt ? ` · ${duration((reviewer.completedAt ?? Date.now()) - reviewer.startedAt)}` : "";
35
- const tokens = reviewer.usage ? ` · ${reviewer.usage.totalTokens.toLocaleString()} tok` : "";
36
- 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}${active}${elapsed}${tokens}`)}`;
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
- function finalMarkdown(state: PanelViewState): string {
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
- const confirmed = meta.confirmedClusters.map((finding) => `- ${finding.summary} (${finding.supportingReviewerIds.join(", ")})`);
45
- const advisories = meta.advisories.map((finding) => `- ${finding.summary} (${finding.supportingReviewerIds.join(", ")})`);
46
- return [
47
- "### Panel result",
48
- `Health: ${meta.panelHealth}; status: ${meta.status}.`,
49
- confirmed.length ? `### Confirmed findings\n${confirmed.join("\n")}` : "### Confirmed findings\nNone.",
50
- advisories.length ? `### Advisories\n${advisories.join("\n")}` : "",
51
- ].filter(Boolean).join("\n\n");
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
- export function renderPanelResult(details: PanelToolDetails | undefined, expanded: boolean, theme: Theme, previous?: Component): Component {
55
- if (!details) return new Text(theme.fg("muted", "Panel review has no progress details."), 0, 0);
56
- if (!expanded) {
57
- const text = previous instanceof Text ? previous : new Text("", 0, 0);
58
- text.setText(compactText(details.state, theme));
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
- const container = new Container();
62
- container.addChild(new Text(compactText(details.state, theme), 0, 0));
63
- for (const reviewer of Object.values(details.state.reviewers)) {
64
- if (reviewer.recentActivity.length === 0) continue;
65
- container.addChild(new Spacer(1));
66
- container.addChild(new Text(theme.fg("muted", `${reviewer.reviewerId} activity`), 0, 0));
67
- container.addChild(new Text(reviewer.recentActivity.join("\n"), 0, 0));
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
- const markdown = finalMarkdown(details.state);
70
- if (markdown) {
71
- container.addChild(new Spacer(1));
72
- // Pi supplies a Markdown theme to built-in renderers; plain text keeps this
73
- // extension portable across public API versions while still using Markdown.
74
- container.addChild(new Markdown(markdown, 0, 0, {
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
- if (details.error) {
92
- container.addChild(new Spacer(1));
93
- container.addChild(new Text(theme.fg("error", details.error), 0, 0));
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
- return container;
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 code-review brief." }),
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
- model: Type.Optional(Type.String({ description: "Optional reviewer model." })),
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 publish = () => onUpdate?.({ content: [{ type: "text", text: `Panel: ${state.aggregate.completed}/${state.aggregate.total} completed` }], details: { state } });
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
- const args = [cliPath, "--panel", params.panel ?? "code-experts", "--mode", params.mode ?? "code", "--output-format", "events-jsonl"];
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
- const child = await spawnStreamingChild(process.execPath, args, { cwd: process.cwd(), env: process.env, stdoutSink, stderrSink, signal, processGroup: true });
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
- content: [{ type: "text", text: error ?? `Panel completed: ${state.meta?.status ?? "unknown"}` }],
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
- text.setText(theme.fg("toolTitle", theme.bold("pi_review ")) + theme.fg("muted", `${args.panel ?? "code-experts"} · ${args.mode ?? "code"} · ${args.target}`));
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
+ });