pi-plans 0.5.7 → 0.7.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/CONTRIBUTING.md +126 -0
- package/README.md +49 -39
- package/agents/ref-analyst.md +7 -4
- package/agents/reviewer.md +12 -3
- package/index.ts +74 -40
- package/package.json +2 -1
- package/references/pi-planning-workflow.md +45 -58
- package/references/plan-artifact-template.md +71 -60
- package/references/state-and-config.md +63 -47
- package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
- package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
- package/scripts/run-tests.ts +12 -1
- package/scripts/validate.ts +22 -10
- package/skills/debug-and-plan/SKILL.md +4 -4
- package/skills/plan-big/SKILL.md +5 -5
- package/skills/plan-normal/SKILL.md +5 -5
- package/skills/plan-small/SKILL.md +5 -5
- package/skills/plan-with-refs/SKILL.md +8 -8
- package/skills/planning/SKILL.md +1 -1
- package/src/ask-form.ts +4 -4
- package/src/auditor.ts +126 -0
- package/src/auto-approve.ts +1 -1
- package/src/autocomplete.ts +19 -17
- package/src/code-graph/commands.ts +2 -2
- package/src/code-graph/community.ts +1 -1
- package/src/code-graph/paths.ts +1 -1
- package/src/code-graph/watch.ts +2 -2
- package/src/compaction.ts +3 -3
- package/src/config-command.ts +154 -76
- package/src/dashboard.ts +257 -0
- package/src/exec.ts +709 -705
- package/src/global-state.ts +304 -0
- package/src/guard.ts +16 -3
- package/src/messaging.ts +44 -0
- package/src/plan.ts +421 -112
- package/src/query-hook.ts +4 -4
- package/src/refine-prompts.ts +14 -72
- package/src/refine-ui-helpers.ts +24 -5
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +1 -1
- package/src/resume-command.ts +40 -130
- package/src/resume.ts +15 -17
- package/src/role-panels.ts +542 -0
- package/src/run-context.ts +5 -4
- package/src/run-picker.ts +98 -0
- package/src/state.ts +380 -77
- package/src/subagent.ts +32 -1
- package/src/task-tool.ts +100 -0
- package/src/tasks.ts +189 -0
- package/src/thinking-levels.ts +67 -0
- package/src/ui-language.ts +3 -54
- package/src/workflow-state.ts +78 -57
- package/tests/analyze-refs.test.ts +35 -18
- package/tests/ask-choice-pros-cons.test.ts +147 -0
- package/tests/ask-choice-schema.test.ts +0 -12
- package/tests/ask-choice.test.ts +2 -49
- package/tests/ask-form-tool.test.ts +4 -5
- package/tests/ask-form.test.ts +2 -2
- package/tests/auditor.test.ts +111 -0
- package/tests/auto-approve.test.ts +7 -10
- package/tests/autocomplete.test.ts +8 -11
- package/tests/code-graph-apply-action.test.ts +2 -2
- package/tests/code-graph-commands.test.ts +2 -2
- package/tests/code-graph-index.test.ts +2 -2
- package/tests/code-graph-loop.e2e.test.ts +1 -1
- package/tests/code-graph-mutations.test.ts +1 -1
- package/tests/code-graph-rollback.test.ts +1 -1
- package/tests/code-graph-v05.test.ts +2 -2
- package/tests/compaction.test.ts +1 -1
- package/tests/config-command.test.ts +103 -100
- package/tests/dashboard.test.ts +268 -0
- package/tests/exec-lifecycle.test.ts +181 -115
- package/tests/exec-panel-lifecycle.test.ts +106 -251
- package/tests/exec.test.ts +617 -1706
- package/tests/execute-plan.test.ts +44 -19
- package/tests/extension-load.test.ts +48 -0
- package/tests/global-state.test.ts +371 -0
- package/tests/graph-aware-file-tools.test.ts +5 -5
- package/tests/guard.test.ts +1 -1
- package/tests/multi-run.test.ts +184 -0
- package/tests/plan.test.ts +139 -62
- package/tests/plans.test.ts +7 -79
- package/tests/refine-prompts.test.ts +20 -71
- package/tests/refine-resume.test.ts +27 -22
- package/tests/refine-ui.test.ts +6 -15
- package/tests/resume-lifecycle.test.ts +37 -22
- package/tests/resume.test.ts +43 -88
- package/tests/role-panels.test.ts +391 -0
- package/tests/run-context.test.ts +1 -1
- package/tests/run-ownership.test.ts +1 -1
- package/tests/stale-ctx.test.ts +218 -0
- package/tests/state.test.ts +151 -32
- package/tests/subagent-thinking.test.ts +65 -0
- package/tests/subagent-usage.test.ts +1 -1
- package/tests/task-tool.test.ts +61 -0
- package/tests/thinking-levels.test.ts +77 -0
- package/tests/ui-language.test.ts +2 -17
- package/tests/workflow-state.test.ts +17 -99
- package/tools/analyze-refs.ts +67 -32
- package/tools/ask-choice.ts +19 -49
- package/tools/code-graph.ts +2 -2
- package/tools/execute-plan.ts +63 -33
- package/tools/graph-aware-file-tools.ts +6 -4
- package/tools/plans.ts +40 -66
- package/tools/refine.ts +101 -164
- package/agents/criticizer.md +0 -18
- package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
- package/src/panel.ts +0 -473
- package/src/termination-prompt.ts +0 -73
- package/tests/goal-wait.test.ts +0 -269
- package/tests/panel-i-zero.test.ts +0 -420
- package/tests/panel.test.ts +0 -355
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-use reviewer model/effort panels (v0.7.0).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the old ask_choice-mediated model confirmation: in TUI the gate
|
|
5
|
+
* pops a /model-style searchable panel (pi's exported
|
|
6
|
+
* ModelSelectorComponent) followed by a /thinking-style effort panel (a thin
|
|
7
|
+
* variant of pi's ThinkingSelectorComponent — the exported one cannot carry
|
|
8
|
+
* the extra "Default" sentinel row). Sessions with a UI but no TUI (RPC,
|
|
9
|
+
* ACP) get native ui.select menus over the same data; UI-less sessions keep
|
|
10
|
+
* the agent-mediated text flow in the calling tools.
|
|
11
|
+
*
|
|
12
|
+
* The result is persisted to the GLOBAL reviewer config
|
|
13
|
+
* (`~/.pi/pi-plans/config.json`, PI_PLANS_GLOBAL_DIR override) exactly once,
|
|
14
|
+
* after BOTH panels complete — Esc on either panel cancels the whole gate
|
|
15
|
+
* without writing anything (F-005).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
ModelSelectorComponent,
|
|
20
|
+
getSelectListTheme,
|
|
21
|
+
type ModelRuntime,
|
|
22
|
+
} from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { Container, Input, SelectList, Spacer, Text, fuzzyFilter, getKeybindings, matchesKey } from "@earendil-works/pi-tui";
|
|
24
|
+
import type { Component, TuiMouseDispatchResult, TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
25
|
+
import type { Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
26
|
+
import { DEFAULT_LEVEL_DESCRIPTION, DEFAULT_LEVEL_SENTINEL, levelsForModel } from "./thinking-levels.ts";
|
|
27
|
+
import { setGlobalRole, type GlobalRoleConfig } from "./global-state.ts";
|
|
28
|
+
import { truncateToWidth, visibleWidth } from "./refine-ui-helpers.ts";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Host abstraction (structural — tools pass their ExtensionContext)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** Narrow structural view of ExtensionContext the panels need. */
|
|
35
|
+
export interface RolePanelHost {
|
|
36
|
+
mode: string | undefined;
|
|
37
|
+
hasUI: boolean;
|
|
38
|
+
model?: { provider: string; id: string } | null;
|
|
39
|
+
scopedModels?: ReadonlyArray<{ model: unknown; thinkingLevel?: string | undefined }> | null;
|
|
40
|
+
modelRegistry?: unknown;
|
|
41
|
+
ui: {
|
|
42
|
+
custom?: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => unknown, options?: Record<string, unknown>) => Promise<unknown>;
|
|
43
|
+
select?: (title: string, options: string[], opts?: unknown) => Promise<string | undefined>;
|
|
44
|
+
notify?: (message: string, type?: "info" | "warning" | "error") => void;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ModelRuntime adapter (F-010, Q-5=A): PRIMARY path maps the public
|
|
50
|
+
* ModelRegistry facade (getAvailable/find/getError/refresh — documented as
|
|
51
|
+
* "facade exposed to extensions") onto the four runtime methods
|
|
52
|
+
* ModelSelectorComponent uses. The private `(modelRegistry as any).runtime`
|
|
53
|
+
* field is only a SECONDARY attempt; both failing yields null and callers
|
|
54
|
+
* fall back to menus.
|
|
55
|
+
*/
|
|
56
|
+
export function adapterModelRuntime(host: RolePanelHost): ModelRuntime | null {
|
|
57
|
+
const registry = host.modelRegistry as
|
|
58
|
+
| {
|
|
59
|
+
getAvailable?: unknown;
|
|
60
|
+
find?: unknown;
|
|
61
|
+
getError?: unknown;
|
|
62
|
+
refresh?: unknown;
|
|
63
|
+
runtime?: unknown;
|
|
64
|
+
}
|
|
65
|
+
| undefined;
|
|
66
|
+
if (
|
|
67
|
+
registry &&
|
|
68
|
+
typeof registry.getAvailable === "function" &&
|
|
69
|
+
typeof registry.find === "function" &&
|
|
70
|
+
typeof registry.getError === "function" &&
|
|
71
|
+
typeof registry.refresh === "function"
|
|
72
|
+
) {
|
|
73
|
+
return {
|
|
74
|
+
getAvailableSnapshot: () => (registry.getAvailable as () => Model[])(),
|
|
75
|
+
getModel: (providerId: string, modelId: string) =>
|
|
76
|
+
(registry.find as (provider: string, id: string) => Model | undefined)(providerId, modelId),
|
|
77
|
+
getError: () => (registry.getError as () => string | undefined)(),
|
|
78
|
+
refresh: (options?: unknown) => (registry.refresh as (options?: unknown) => Promise<unknown>)(options),
|
|
79
|
+
} as unknown as ModelRuntime;
|
|
80
|
+
}
|
|
81
|
+
const runtime = registry?.runtime as ModelRuntime | undefined;
|
|
82
|
+
if (runtime && typeof (runtime as { getAvailableSnapshot?: unknown }).getAvailableSnapshot === "function") {
|
|
83
|
+
return runtime;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Models from the registry snapshot (menus + availability checks). */
|
|
89
|
+
export function availableModels(host: RolePanelHost): Model[] {
|
|
90
|
+
const registry = host.modelRegistry as { getAvailable?: () => Model[] } | undefined;
|
|
91
|
+
if (!registry || typeof registry.getAvailable !== "function") return [];
|
|
92
|
+
try {
|
|
93
|
+
return registry.getAvailable() ?? [];
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Find a configured model by exact selector (F-008 pre-spawn check). */
|
|
100
|
+
export function findModel(host: RolePanelHost, selector: string): Model | null {
|
|
101
|
+
const at = selector.indexOf("/");
|
|
102
|
+
if (at <= 0) return null;
|
|
103
|
+
const registry = host.modelRegistry as { find?: (provider: string, id: string) => Model | undefined } | undefined;
|
|
104
|
+
if (!registry || typeof registry.find !== "function") return null;
|
|
105
|
+
try {
|
|
106
|
+
return registry.find(selector.slice(0, at), selector.slice(at + 1)) ?? null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Effort panel (thin /thinking variant with a Default sentinel row)
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const LEVEL_DESCRIPTIONS: Record<string, string> = {
|
|
117
|
+
off: "No reasoning",
|
|
118
|
+
minimal: "Very brief reasoning (~1k tokens)",
|
|
119
|
+
low: "Light reasoning (~2k tokens)",
|
|
120
|
+
medium: "Moderate reasoning (~8k tokens)",
|
|
121
|
+
high: "Deep reasoning (~16k tokens)",
|
|
122
|
+
xhigh: "Extra-high reasoning (~32k tokens)",
|
|
123
|
+
max: "Maximum reasoning",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const EFFORT_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 32 };
|
|
127
|
+
|
|
128
|
+
/** Minimal shape of pi's theme needed to paint a border. */
|
|
129
|
+
export type BorderColor = (text: string) => string;
|
|
130
|
+
|
|
131
|
+
const identityColor: BorderColor = (text) => text;
|
|
132
|
+
|
|
133
|
+
/** Build a border painter from the `theme` the ui.custom factory hands us.
|
|
134
|
+
* Falls back to plain text when the host passes no (or a partial) theme. */
|
|
135
|
+
export function borderColorFrom(theme: unknown): BorderColor {
|
|
136
|
+
const fg = (theme as { fg?: (color: string, text: string) => string } | null | undefined)?.fg;
|
|
137
|
+
if (typeof fg !== "function") return identityColor;
|
|
138
|
+
return (text) => {
|
|
139
|
+
try {
|
|
140
|
+
return fg("border", text);
|
|
141
|
+
} catch {
|
|
142
|
+
return identityColor(text);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Strip ANSI, then report whether the line is nothing but a horizontal rule
|
|
148
|
+
* (the shape pi's DynamicBorder emits: `─` repeated across the width). */
|
|
149
|
+
function isRuleLine(line: string): boolean {
|
|
150
|
+
// eslint-disable-next-line no-control-regex
|
|
151
|
+
const plain = line.replace(/\x1b\[[0-9;]*m/g, "").trim();
|
|
152
|
+
return plain.length > 0 && /^─+$/.test(plain);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* v0.7.1: wrap a panel in a FULL box (left/right rules included).
|
|
157
|
+
*
|
|
158
|
+
* pi's `DynamicBorder` — used by ModelSelectorComponent, ThinkingSelector and
|
|
159
|
+
* our EffortPanelComponent — renders only a horizontal line, so the first-use
|
|
160
|
+
* gate panels had top/bottom borders but no sides, while the reviewer panel
|
|
161
|
+
* (refine-ui.ts) already drew a complete box. This wrapper makes the gate
|
|
162
|
+
* panels consistent with the rest of pi-plans.
|
|
163
|
+
*
|
|
164
|
+
* The inner component's own rule lines are stripped, so the total line count is
|
|
165
|
+
* unchanged and the overlay's `maxHeight` budget is unaffected.
|
|
166
|
+
*
|
|
167
|
+
* Focus, keyboard and mouse are forwarded to the inner component: the TUI only
|
|
168
|
+
* sets `focused` on the component it mounts, so the wrapper must relay it for
|
|
169
|
+
* the inner search Input to keep the hardware cursor (CURSOR_MARKER).
|
|
170
|
+
*/
|
|
171
|
+
export class BorderedPanel implements Component {
|
|
172
|
+
private readonly inner: Component;
|
|
173
|
+
private readonly color: BorderColor;
|
|
174
|
+
|
|
175
|
+
constructor(inner: Component, color?: BorderColor) {
|
|
176
|
+
this.inner = inner;
|
|
177
|
+
this.color = color ?? identityColor;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The wrapped component (exposed for tests / unwrapping). */
|
|
181
|
+
get content(): Component {
|
|
182
|
+
return this.inner;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get focused(): boolean {
|
|
186
|
+
return (this.inner as { focused?: boolean }).focused === true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
set focused(value: boolean) {
|
|
190
|
+
(this.inner as { focused?: boolean }).focused = value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
render(width: number): string[] {
|
|
194
|
+
const innerWidth = Math.max(1, width - 2);
|
|
195
|
+
let lines = this.inner.render(innerWidth);
|
|
196
|
+
// Drop the inner component's own top/bottom rules so we don't draw two.
|
|
197
|
+
while (lines.length > 0 && isRuleLine(lines[0]!)) lines = lines.slice(1);
|
|
198
|
+
while (lines.length > 0 && isRuleLine(lines[lines.length - 1]!)) lines = lines.slice(0, -1);
|
|
199
|
+
const boxed = lines.map((line) => {
|
|
200
|
+
const body = truncateToWidth(line, innerWidth, "");
|
|
201
|
+
const filler = innerWidth > visibleWidth(body) ? " ".repeat(innerWidth - visibleWidth(body)) : "";
|
|
202
|
+
return `${this.color("│")}${body}${filler}${this.color("│")}`;
|
|
203
|
+
});
|
|
204
|
+
return [
|
|
205
|
+
this.color(`┌${"─".repeat(innerWidth)}┐`),
|
|
206
|
+
...boxed,
|
|
207
|
+
this.color(`└${"─".repeat(innerWidth)}┘`),
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
handleInput(data: string): void {
|
|
212
|
+
this.inner.handleInput?.(data);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Translate the box-local mouse event into the inner component's
|
|
216
|
+
* coordinate space (one rule column on each side, one rule row on top). */
|
|
217
|
+
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
|
|
218
|
+
const inner = this.inner as { handleMouse?: (e: TuiMouseEvent) => TuiMouseDispatchResult | undefined };
|
|
219
|
+
if (!inner.handleMouse) return undefined;
|
|
220
|
+
const innerY = event.y - 1;
|
|
221
|
+
const innerHeight = Math.max(0, event.height - 2);
|
|
222
|
+
if (innerY < 0 || innerY >= innerHeight) return undefined;
|
|
223
|
+
return inner.handleMouse({
|
|
224
|
+
...event,
|
|
225
|
+
x: event.x - 1,
|
|
226
|
+
y: innerY,
|
|
227
|
+
width: Math.max(1, event.width - 2),
|
|
228
|
+
height: innerHeight,
|
|
229
|
+
} as TuiMouseEvent);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
invalidate(): void {
|
|
233
|
+
this.inner.invalidate?.();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
dispose(): void {
|
|
237
|
+
(this.inner as { dispose?: () => void }).dispose?.();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface EffortItem {
|
|
242
|
+
value: string; // "default" or a concrete level
|
|
243
|
+
label: string;
|
|
244
|
+
description: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Panel items for a model: the Default sentinel first, then supported levels. */
|
|
248
|
+
export function effortItems(model: Pick<Model, "reasoning" | "thinkingLevelMap">, currentLevel: string | null): EffortItem[] {
|
|
249
|
+
const levels = levelsForModel(model);
|
|
250
|
+
const items: EffortItem[] = [
|
|
251
|
+
{
|
|
252
|
+
value: DEFAULT_LEVEL_SENTINEL,
|
|
253
|
+
label: DEFAULT_LEVEL_SENTINEL,
|
|
254
|
+
description: DEFAULT_LEVEL_DESCRIPTION,
|
|
255
|
+
},
|
|
256
|
+
...levels.map((level: ModelThinkingLevel) => ({
|
|
257
|
+
value: level,
|
|
258
|
+
label: level,
|
|
259
|
+
description: LEVEL_DESCRIPTIONS[level] ?? "",
|
|
260
|
+
})),
|
|
261
|
+
];
|
|
262
|
+
if (currentLevel && !items.some((item) => item.value === currentLevel)) {
|
|
263
|
+
// Stored level not supported by the newly chosen model: still show it
|
|
264
|
+
// (marked) so the user sees what was there and can change it.
|
|
265
|
+
items.push({ value: currentLevel, label: currentLevel, description: `${currentLevel} (not supported by this model)` });
|
|
266
|
+
}
|
|
267
|
+
return items;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* /thinking-style panel with an extra leading Default row. Mirrors pi's
|
|
272
|
+
* ThinkingSelectorComponent structure (filter Input + SelectList) minus the
|
|
273
|
+
* Ctrl+S set-as-default affordance, which has no meaning for the reviewer role.
|
|
274
|
+
*
|
|
275
|
+
* v0.7.1: the surrounding box comes from BorderedPanel, so this component no
|
|
276
|
+
* longer adds DynamicBorder children. That also removes a latent crash — a
|
|
277
|
+
* no-arg DynamicBorder reads pi's global `theme`, which is undefined when the
|
|
278
|
+
* extension is loaded through jiti (its own source warns about exactly this).
|
|
279
|
+
* It must therefore always be mounted inside a BorderedPanel.
|
|
280
|
+
*/
|
|
281
|
+
export class EffortPanelComponent extends Container {
|
|
282
|
+
private searchInput: Input;
|
|
283
|
+
private selectList: SelectList;
|
|
284
|
+
private selectListChildIndex: number;
|
|
285
|
+
private allItems: EffortItem[];
|
|
286
|
+
private onSelect: (value: string) => void;
|
|
287
|
+
private onCancel: () => void;
|
|
288
|
+
private _focused = false;
|
|
289
|
+
|
|
290
|
+
get focused(): boolean {
|
|
291
|
+
return this._focused;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
set focused(value: boolean) {
|
|
295
|
+
this._focused = value;
|
|
296
|
+
this.searchInput.focused = value;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
constructor(items: EffortItem[], preselect: string, onSelect: (value: string) => void, onCancel: () => void) {
|
|
300
|
+
super();
|
|
301
|
+
this.allItems = items;
|
|
302
|
+
this.onSelect = onSelect;
|
|
303
|
+
this.onCancel = onCancel;
|
|
304
|
+
this.addChild(new Spacer(1));
|
|
305
|
+
this.addChild(new Text("Reviewer Thinking Level", 0, 0));
|
|
306
|
+
this.addChild(new Spacer(1));
|
|
307
|
+
this.addChild(new Text("Applies to spawned reviewer subagents only", 0, 0));
|
|
308
|
+
this.addChild(new Spacer(1));
|
|
309
|
+
this.searchInput = new Input();
|
|
310
|
+
this.searchInput.onSubmit = () => this.selectList.handleInput("\r");
|
|
311
|
+
this.addChild(this.searchInput);
|
|
312
|
+
this.addChild(new Spacer(1));
|
|
313
|
+
this.selectList = this.buildSelectList(items, preselect);
|
|
314
|
+
this.selectListChildIndex = this.children.length;
|
|
315
|
+
this.addChild(this.selectList);
|
|
316
|
+
this.addChild(new Spacer(1));
|
|
317
|
+
this.addChild(new Text(" Enter to select · Esc to cancel (cancels the whole gate)", 0, 0));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private buildSelectList(items: EffortItem[], preselect: string): SelectList {
|
|
321
|
+
const list = new SelectList(items, Math.max(1, items.length), getSelectListTheme(), EFFORT_LAYOUT);
|
|
322
|
+
const index = items.findIndex((item) => item.value === preselect);
|
|
323
|
+
if (index !== -1) list.setSelectedIndex(index);
|
|
324
|
+
list.onSelect = (item: EffortItem) => this.onSelect(item.value);
|
|
325
|
+
list.onCancel = () => this.onCancel();
|
|
326
|
+
return list;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private applyFilter(query: string): void {
|
|
330
|
+
const filtered = query
|
|
331
|
+
? fuzzyFilter(this.allItems, query, (item) => `${item.label} ${item.description ?? ""}`)
|
|
332
|
+
: this.allItems;
|
|
333
|
+
const selected = (this.selectList.getSelectedItem() as EffortItem | undefined)?.value;
|
|
334
|
+
const newList = this.buildSelectList(filtered, selected);
|
|
335
|
+
this.children[this.selectListChildIndex] = newList;
|
|
336
|
+
this.selectList = newList;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
handleInput(keyData: string): void {
|
|
340
|
+
const kb = getKeybindings();
|
|
341
|
+
const isNav =
|
|
342
|
+
kb.matches(keyData, "tui.select.up") ||
|
|
343
|
+
kb.matches(keyData, "tui.select.down") ||
|
|
344
|
+
kb.matches(keyData, "tui.select.confirm") ||
|
|
345
|
+
kb.matches(keyData, "tui.select.cancel");
|
|
346
|
+
if (isNav) {
|
|
347
|
+
this.selectList.handleInput(keyData);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
this.searchInput.handleInput(keyData);
|
|
351
|
+
this.applyFilter(this.searchInput.getValue());
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Panel + menu flows
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
export type FirstUseOutcome =
|
|
360
|
+
| {
|
|
361
|
+
status: "confirmed";
|
|
362
|
+
modelSelector: string;
|
|
363
|
+
model: Model | null;
|
|
364
|
+
thinkingLevel: string | null;
|
|
365
|
+
role: GlobalRoleConfig;
|
|
366
|
+
notices: string[];
|
|
367
|
+
via: "panel" | "menu";
|
|
368
|
+
}
|
|
369
|
+
| { status: "cancelled"; via: "panel" | "menu" }
|
|
370
|
+
| { status: "unavailable"; reason: "no-custom" | "no-runtime" | "no-models" | "no-select" };
|
|
371
|
+
|
|
372
|
+
const OVERLAY_OPTIONS = {
|
|
373
|
+
overlay: true,
|
|
374
|
+
overlayOptions: {
|
|
375
|
+
width: "78%",
|
|
376
|
+
minWidth: 60,
|
|
377
|
+
maxHeight: "78%",
|
|
378
|
+
anchor: "top-center",
|
|
379
|
+
margin: { top: 1, left: 2, right: 2 },
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/** /model-style searchable panel. Returns null when the host cannot show
|
|
384
|
+
* overlays (RPC custom() returns undefined) — callers fall back to menus. */
|
|
385
|
+
async function pickModelViaPanel(host: RolePanelHost): Promise<Model | null | undefined> {
|
|
386
|
+
if (typeof host.ui.custom !== "function") return undefined;
|
|
387
|
+
const runtime = adapterModelRuntime(host);
|
|
388
|
+
if (runtime === null) return undefined;
|
|
389
|
+
const scoped = (host.scopedModels ?? []) as never;
|
|
390
|
+
const currentModel = host.model ? ({ ...host.model } as never) : undefined;
|
|
391
|
+
return (await host.ui.custom<Model | null>((tui, theme, _kb, done) => {
|
|
392
|
+
let settled = false;
|
|
393
|
+
const finish = (value: Model | null) => {
|
|
394
|
+
if (settled) return;
|
|
395
|
+
settled = true;
|
|
396
|
+
done(value);
|
|
397
|
+
};
|
|
398
|
+
const selector = new ModelSelectorComponent(
|
|
399
|
+
tui as never,
|
|
400
|
+
currentModel,
|
|
401
|
+
runtime,
|
|
402
|
+
scoped,
|
|
403
|
+
(model) => finish(model as Model),
|
|
404
|
+
() => {
|
|
405
|
+
finish(null);
|
|
406
|
+
(tui as { requestRender?: () => void }).requestRender?.();
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
queueMicrotask(() => (tui as { requestRender?: () => void }).requestRender?.());
|
|
410
|
+
// v0.7.1: pi's ModelSelectorComponent draws only a horizontal rule; wrap
|
|
411
|
+
// it so the gate panel has a full box like the reviewer panel.
|
|
412
|
+
return new BorderedPanel(selector, borderColorFrom(theme));
|
|
413
|
+
}, OVERLAY_OPTIONS as never)) as Model | null | undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** /thinking-style effort panel. Assumes a real TUI (called after the model
|
|
417
|
+
* panel succeeded). Returns "default" | level | null (Esc). */
|
|
418
|
+
async function pickLevelViaPanel(
|
|
419
|
+
host: RolePanelHost,
|
|
420
|
+
model: Model,
|
|
421
|
+
currentLevel: string | null,
|
|
422
|
+
): Promise<string | null | undefined> {
|
|
423
|
+
const items = effortItems(model, currentLevel);
|
|
424
|
+
return (await host.ui.custom<string | null>((_tui, theme, _kb, done) => {
|
|
425
|
+
let settled = false;
|
|
426
|
+
const finish = (value: string | null) => {
|
|
427
|
+
if (settled) return;
|
|
428
|
+
settled = true;
|
|
429
|
+
done(value);
|
|
430
|
+
};
|
|
431
|
+
const panel = new EffortPanelComponent(items, currentLevel ?? DEFAULT_LEVEL_SENTINEL, (value) => finish(value), () => finish(null));
|
|
432
|
+
return new BorderedPanel(panel, borderColorFrom(theme));
|
|
433
|
+
}, OVERLAY_OPTIONS as never)) as string | null | undefined;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Native menus for hasUI-but-not-TUI sessions (Q-2=A) — shared with the
|
|
437
|
+
* TUI panel-construction fallback. */
|
|
438
|
+
async function pickModelViaMenu(host: RolePanelHost): Promise<string | undefined> {
|
|
439
|
+
if (typeof host.ui.select !== "function") return undefined;
|
|
440
|
+
const models = availableModels(host);
|
|
441
|
+
if (models.length === 0) return undefined;
|
|
442
|
+
const options = models.map((model) => `${model.provider}/${model.id}`);
|
|
443
|
+
const picked = await host.ui.select("Reviewer model? (type Other… to enter an exact provider/model string)", options);
|
|
444
|
+
return typeof picked === "string" ? picked : undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function selectorOf(model: Model): string {
|
|
448
|
+
return `${model.provider}/${model.id}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Resolve a selector to a Model for the effort step; menus may pick models
|
|
452
|
+
* the registry no longer lists, so fall back to a bare selector object. */
|
|
453
|
+
function modelForEffort(host: RolePanelHost, selector: string): Model | null {
|
|
454
|
+
return findModel(host, selector);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Run the first-use flow and, on full completion, persist the confirmed
|
|
459
|
+
* role to the global config and return it. Esc anywhere cancels the whole
|
|
460
|
+
* gate without persisting (F-005). The returned role is what the caller
|
|
461
|
+
* must use for THIS invocation (never a stale pre-gate snapshot).
|
|
462
|
+
*/
|
|
463
|
+
export async function runFirstUseFlow(host: RolePanelHost, storedLevel: string | null): Promise<FirstUseOutcome> {
|
|
464
|
+
const tui = host.mode === "tui" && typeof host.ui.custom === "function";
|
|
465
|
+
if (tui) {
|
|
466
|
+
let picked: Model | null | undefined;
|
|
467
|
+
try {
|
|
468
|
+
picked = await pickModelViaPanel(host);
|
|
469
|
+
} catch {
|
|
470
|
+
// Construction/refresh failure (pi upgrade drift, F-010): fall back
|
|
471
|
+
// to menus instead of failing the gate.
|
|
472
|
+
picked = undefined;
|
|
473
|
+
}
|
|
474
|
+
if (picked === undefined) {
|
|
475
|
+
// custom() unavailable (RPC) or runtime adapter failed — menu fallback.
|
|
476
|
+
} else if (picked === null) {
|
|
477
|
+
return { status: "cancelled", via: "panel" };
|
|
478
|
+
} else {
|
|
479
|
+
const level = await pickLevelViaPanel(host, picked, storedLevel);
|
|
480
|
+
if (level === undefined || level === null) return { status: "cancelled", via: "panel" };
|
|
481
|
+
return confirmRole(selectorOf(picked), level === DEFAULT_LEVEL_SENTINEL ? null : level, picked, "panel");
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// Menus (hasUI non-TUI, or TUI panel fallback).
|
|
485
|
+
if (!host.hasUI || typeof host.ui.select !== "function") {
|
|
486
|
+
return { status: "unavailable", reason: tui ? "no-runtime" : "no-select" };
|
|
487
|
+
}
|
|
488
|
+
const models = availableModels(host);
|
|
489
|
+
if (models.length === 0 && tui) return { status: "unavailable", reason: "no-models" };
|
|
490
|
+
const selector = await pickModelViaMenu(host);
|
|
491
|
+
if (selector === undefined) return { status: "cancelled", via: "menu" };
|
|
492
|
+
const model = modelForEffort(host, selector);
|
|
493
|
+
if (model) {
|
|
494
|
+
const items = effortItems(model, storedLevel);
|
|
495
|
+
const labels = items.map((item) => `${item.label} — ${item.description}`);
|
|
496
|
+
const picked = await host.ui.select("Reviewer thinking level? (first row = default: no --thinking flag)", labels);
|
|
497
|
+
if (picked === undefined) return { status: "cancelled", via: "menu" };
|
|
498
|
+
const index = labels.indexOf(picked);
|
|
499
|
+
const value = index >= 0 ? items[index]!.value : DEFAULT_LEVEL_SENTINEL;
|
|
500
|
+
return confirmRole(selector, value === DEFAULT_LEVEL_SENTINEL ? null : value, model, "menu");
|
|
501
|
+
}
|
|
502
|
+
// Selector not in the registry (manually entered): confirm as-is with the
|
|
503
|
+
// default level — spawn-side validation catches typos with a precise error.
|
|
504
|
+
return confirmRole(selector, null, null, "menu");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function confirmRole(modelSelector: string, thinkingLevel: string | null, model: Model | null, via: "panel" | "menu"): FirstUseOutcome {
|
|
508
|
+
const applied = setGlobalRole({ modelSelector, thinkingLevel: thinkingLevel ?? "default", confirmed: true });
|
|
509
|
+
return {
|
|
510
|
+
status: "confirmed",
|
|
511
|
+
modelSelector,
|
|
512
|
+
model,
|
|
513
|
+
thinkingLevel,
|
|
514
|
+
role: applied.global.reviewer,
|
|
515
|
+
notices: applied.notices,
|
|
516
|
+
via,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Esc-cancelled gate error (F-005): a dedicated message + details marker so
|
|
521
|
+
* the agent does NOT re-ask via ask_choice or blindly retry. */
|
|
522
|
+
export function firstUseCancelledError(toolName: string): Error {
|
|
523
|
+
const error = new Error(
|
|
524
|
+
`The user closed the ${toolName} first-use reviewer model panel without completing it. Do NOT re-ask the model question with ask_choice and do NOT retry ${toolName} unless the user asks. The user can configure the reviewer at any time with /config-pi-plans.`,
|
|
525
|
+
);
|
|
526
|
+
(error as Error & { cancelled?: boolean }).cancelled = true;
|
|
527
|
+
return error;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** UI-less gate guidance (F-004): embedded model list + exact set-role call. */
|
|
531
|
+
export function firstUseTextGuidance(models: Model[], globalConfigPath: string): string {
|
|
532
|
+
const sample = models
|
|
533
|
+
.slice(0, 30)
|
|
534
|
+
.map((model) => `${model.provider}/${model.id}`)
|
|
535
|
+
.join(", ");
|
|
536
|
+
return [
|
|
537
|
+
"The reviewer model was never confirmed. This session has no interactive UI, so ask the model-confirmation question with ask_choice:",
|
|
538
|
+
"1. Choose a model — available selectors include: " + (sample || "(none found via the model registry; ask the user for an exact provider/model string)"),
|
|
539
|
+
"2. Other / 3. Auto-complete — choose a concrete provider/model selector (inherit is no longer supported).",
|
|
540
|
+
`Then persist with the plans tool: set-role, role=reviewer, modelSelector=<provider/model>, confirmed: true (plus thinkingLevel: high|medium|…|default when the user wants a specific level). The reviewer role lives in the global config (${globalConfigPath}), shared across all workspaces; automation may also pre-write that file directly.`,
|
|
541
|
+
].join("\n");
|
|
542
|
+
}
|
package/src/run-context.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
-
import { getRun, readActive, runDirPath, type ActiveInfo } from "./state.ts";
|
|
13
|
+
import { getRun, newestNonTerminalRun, readActive, runDirPath, type ActiveInfo } from "./state.ts";
|
|
14
14
|
|
|
15
15
|
interface RunBinding {
|
|
16
16
|
runId: string;
|
|
@@ -54,9 +54,10 @@ export function activeInfoById(workdir: string, runId: string): ActiveInfo | nul
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* Resolve the run this session should attribute work to.
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* Resolve the run this session should attribute work to. Resolution order:
|
|
58
|
+
* 1. the session binding (a session-bound run always wins);
|
|
59
|
+
* 2. registry fallback: the newest non-terminal run (`readActive` shim), or
|
|
60
|
+
* null when every run is terminal.
|
|
60
61
|
*/
|
|
61
62
|
export function resolveActiveRun(session: unknown, workdir: string): ActiveInfo | null {
|
|
62
63
|
if (session !== undefined && session !== null) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared run picker (v0.6.0 multi-run support): a descriptive selector form
|
|
3
|
+
* used by /plans-abandon, /plans-execute, and /resume-plans whenever more than
|
|
4
|
+
* one candidate run exists in the workdir. Labels show topic · status · skill ·
|
|
5
|
+
* updated_at, width-fitted; the recommended run (session binding or shared
|
|
6
|
+
* active) is listed first.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { readdirSync } from "node:fs";
|
|
11
|
+
import { boundRunId } from "./run-context.ts";
|
|
12
|
+
import { listRuns, TERMINAL_RUN_STATUSES, type RunSummary } from "./state.ts";
|
|
13
|
+
import { truncateToWidth, visibleWidth } from "./refine-ui-helpers.ts";
|
|
14
|
+
|
|
15
|
+
export interface RunPickerContext {
|
|
16
|
+
cwd: string;
|
|
17
|
+
sessionManager: unknown;
|
|
18
|
+
ui: {
|
|
19
|
+
select: (title: string, options: string[]) => Promise<string | undefined>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Pick-window budget: keep labels to one selector row in normal terminals. */
|
|
24
|
+
const LABEL_WIDTH_BUDGET = 88;
|
|
25
|
+
|
|
26
|
+
/** True when the run's artifact dir contains at least one PLAN_vN.md. */
|
|
27
|
+
function hasPlanFile(run: RunSummary): boolean {
|
|
28
|
+
try {
|
|
29
|
+
return readdirSync(run.artifact_dir).some((name) => /^PLAN_v\d+\.md$/i.test(name));
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Non-terminal runs (planning/accepted/executing/stopped) with ≥1 plan file. */
|
|
36
|
+
export function executionCandidates(workdir: string): RunSummary[] {
|
|
37
|
+
return listRuns(workdir).filter((run) => !TERMINAL_RUN_STATUSES.has(run.status) && hasPlanFile(run));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Non-terminal runs (anything that can still be abandoned). */
|
|
41
|
+
export function abandonCandidates(workdir: string): RunSummary[] {
|
|
42
|
+
return listRuns(workdir).filter((run) => !TERMINAL_RUN_STATUSES.has(run.status));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One-line descriptive label: topic · status · skill · updated_at. */
|
|
46
|
+
export function runPickerLabel(run: RunSummary, recommended: boolean): string {
|
|
47
|
+
const label = `${recommended ? "★ " : ""}${run.topic} · ${run.status} · ${run.skill} · ${run.updated_at} · ${run.run_id}`;
|
|
48
|
+
if (visibleWidth(label) <= LABEL_WIDTH_BUDGET) return label;
|
|
49
|
+
return truncateToWidth(label, LABEL_WIDTH_BUDGET, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Show the descriptive run picker. Returns the chosen RunSummary, or null when
|
|
54
|
+
* cancelled / no UI. `candidates` must be pre-sorted newest-first (listRuns
|
|
55
|
+
* order); the recommended run is moved to the front.
|
|
56
|
+
*/
|
|
57
|
+
export async function pickRun(
|
|
58
|
+
ctx: RunPickerContext,
|
|
59
|
+
options: { candidates: RunSummary[]; recommendedId?: string | null; title: string },
|
|
60
|
+
): Promise<RunSummary | null> {
|
|
61
|
+
const candidates = [...options.candidates];
|
|
62
|
+
if (options.recommendedId) {
|
|
63
|
+
const index = candidates.findIndex((run) => run.run_id === options.recommendedId);
|
|
64
|
+
if (index > 0) {
|
|
65
|
+
const [recommended] = candidates.splice(index, 1);
|
|
66
|
+
candidates.unshift(recommended);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const labels = candidates.map((run) => runPickerLabel(run, run.run_id === options.recommendedId));
|
|
70
|
+
const selected = await ctx.ui.select(options.title, labels);
|
|
71
|
+
if (selected === undefined) return null;
|
|
72
|
+
const index = labels.indexOf(selected);
|
|
73
|
+
return candidates[index] ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the run a command should operate on, binding-first:
|
|
78
|
+
* 1. the session-bound run when it is among the candidates;
|
|
79
|
+
* 2. exactly one candidate → direct (no form — 0.5.7 parity);
|
|
80
|
+
* 3. zero candidates → null;
|
|
81
|
+
* 4. multiple candidates → the descriptive picker (recommended = bound run).
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveCommandRun(
|
|
84
|
+
ctx: RunPickerContext,
|
|
85
|
+
options: { candidates: RunSummary[]; title: string },
|
|
86
|
+
): Promise<RunSummary | null> {
|
|
87
|
+
const bound = boundRunId(ctx.sessionManager, ctx.cwd);
|
|
88
|
+
const boundCandidate = bound ? options.candidates.find((run) => run.run_id === bound) ?? null : null;
|
|
89
|
+
if (boundCandidate !== null) return boundCandidate;
|
|
90
|
+
if (options.candidates.length === 0) return null;
|
|
91
|
+
if (options.candidates.length === 1) return options.candidates[0];
|
|
92
|
+
return pickRun(ctx, { ...options, recommendedId: bound });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Convert a RunSummary into the ActiveInfo shape used downstream. */
|
|
96
|
+
export function activeInfoOf(run: RunSummary, stateRoot: string): { run_id: string; run_dir: string; artifact_dir: string } {
|
|
97
|
+
return { run_id: run.run_id, run_dir: path.join(stateRoot, "runs", run.run_id), artifact_dir: run.artifact_dir };
|
|
98
|
+
}
|