pi-plans 0.1.2 → 0.2.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/src/plan.ts CHANGED
@@ -35,6 +35,43 @@ export function scanDoneMarkers(text: string): string[] {
35
35
  return [...text.matchAll(/\[DONE:(VC-\d+)\]/g)].map((match) => match[1]);
36
36
  }
37
37
 
38
+ export interface CurrentIMarker {
39
+ id: string;
40
+ }
41
+
42
+ /** Extract current implementation-item anchors from an assistant message. */
43
+ export function scanCurrentIMarkers(text: string): CurrentIMarker[] {
44
+ return [...text.matchAll(/\[(I-\d+):current\]/g)].map((match) => ({ id: match[1] }));
45
+ }
46
+
47
+ export const scanCurrentMarkers = scanCurrentIMarkers;
48
+
49
+ /** Resolve the last known current-I marker, ignoring unknown ids. */
50
+ export function resolveCurrentI(
51
+ implItems: ImplItem[],
52
+ markers: CurrentIMarker[] | string[],
53
+ fallback?: string,
54
+ ): string | undefined {
55
+ const known = new Set(implItems.map((item) => item.id));
56
+ let current: string | undefined;
57
+ for (const marker of markers) {
58
+ const id = typeof marker === "string" ? marker : marker.id;
59
+ if (known.has(id)) current = id;
60
+ }
61
+ return current ?? fallback;
62
+ }
63
+
64
+ /** Deterministic frontier used by snapshots written before currentI existed. */
65
+ export function inferCurrentI(
66
+ implItems: ImplItem[] | undefined,
67
+ items: CheckItem[],
68
+ implStatus: Record<string, ImplMarkerState> | undefined,
69
+ ): string | undefined {
70
+ if (!implItems?.length) return undefined;
71
+ const statuses = resolveImplStatuses(implItems, items, implStatus);
72
+ return implItems.find((item) => statuses[item.id] !== "vc-passed")?.id ?? implItems.at(-1)?.id;
73
+ }
74
+
38
75
  export type ImplMarkerState = "implemented" | "validating";
39
76
 
40
77
  export interface ImplMarker {
@@ -0,0 +1,82 @@
1
+ import type { ExtensionAPI, ExtensionContext, InputEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const QUERY_INTERVIEW_MESSAGE_CUSTOM_TYPE = "pi-plans-query-interview";
4
+ export const QUERY_INTERVIEW_MESSAGE =
5
+ "Before implementing a new user request, if any requirement, constraint, expected behavior, or acceptance criterion is unclear, interview the user with focused clarification questions and confirm the answers before making changes. If the request is clear, proceed directly with the implementation.";
6
+
7
+ interface QueryHookState {
8
+ pendingQueries: number;
9
+ }
10
+
11
+ type QueryHookSession = { __piPlansQueryHook?: QueryHookState };
12
+
13
+ function sessionCarrier(ctx: ExtensionContext): QueryHookSession | undefined {
14
+ return ctx.sessionManager as unknown as QueryHookSession | undefined;
15
+ }
16
+
17
+ function ensureState(ctx: ExtensionContext): QueryHookState | undefined {
18
+ const session = sessionCarrier(ctx);
19
+ if (!session) return undefined;
20
+ return (session.__piPlansQueryHook ??= { pendingQueries: 0 });
21
+ }
22
+
23
+ export function isOrdinaryExternalQuery(
24
+ input: Pick<InputEvent, "text" | "source" | "streamingBehavior">,
25
+ ): boolean {
26
+ const text = input.text.trim();
27
+ return (
28
+ (input.source === "interactive" || input.source === "rpc")
29
+ && input.streamingBehavior === undefined
30
+ && text.length > 0
31
+ && !text.startsWith("/")
32
+ );
33
+ }
34
+
35
+ export function recordOrdinaryQuery(
36
+ ctx: ExtensionContext,
37
+ input: Pick<InputEvent, "text" | "source" | "streamingBehavior">,
38
+ ): boolean {
39
+ if (!isOrdinaryExternalQuery(input)) return false;
40
+ const state = ensureState(ctx);
41
+ if (!state) return false;
42
+ state.pendingQueries += 1;
43
+ return true;
44
+ }
45
+
46
+ export function consumeOrdinaryQuery(ctx: ExtensionContext): boolean {
47
+ const session = sessionCarrier(ctx);
48
+ const state = session?.__piPlansQueryHook;
49
+ if (!state || state.pendingQueries <= 0) return false;
50
+ state.pendingQueries -= 1;
51
+ if (state.pendingQueries === 0) delete session.__piPlansQueryHook;
52
+ return true;
53
+ }
54
+
55
+ export function resetOrdinaryQueryState(ctx: ExtensionContext): void {
56
+ const session = sessionCarrier(ctx);
57
+ if (session) delete session.__piPlansQueryHook;
58
+ }
59
+
60
+ export function registerQueryInterviewHooks(
61
+ pi: ExtensionAPI,
62
+ suppressForWorkflow: (ctx: ExtensionContext) => boolean,
63
+ ): void {
64
+ pi.on("input", async (event, ctx) => {
65
+ recordOrdinaryQuery(ctx, event);
66
+ });
67
+
68
+ pi.on("before_agent_start", async (_event, ctx) => {
69
+ if (!consumeOrdinaryQuery(ctx) || suppressForWorkflow(ctx)) return;
70
+ return {
71
+ message: {
72
+ customType: QUERY_INTERVIEW_MESSAGE_CUSTOM_TYPE,
73
+ content: QUERY_INTERVIEW_MESSAGE,
74
+ display: false,
75
+ },
76
+ };
77
+ });
78
+
79
+ pi.on("session_start", async (_event, ctx) => {
80
+ resetOrdinaryQueryState(ctx);
81
+ });
82
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Lightweight, dependency-free replacements for the pi-tui text utilities used
3
+ * by the refine overlay. Keeping these local lets the overlay render correctly
4
+ * in contexts where the pi-tui module is not on the resolver path (notably the
5
+ * standalone `node:test` runs in this repository, since `@earendil-works/pi-tui`
6
+ * is a peer of `@earendil-works/pi-coding-agent` rather than a direct
7
+ * dependency of pi-plans).
8
+ */
9
+
10
+ const ELLIPSIS = "…";
11
+
12
+ export function visibleWidth(text: string): number {
13
+ if (!text) return 0;
14
+ let width = 0;
15
+ for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
16
+ const cp = segment.segment.codePointAt(0) ?? 0;
17
+ if (cp < 0x20) continue;
18
+ if (cp === 0x09) { width += 3; continue; }
19
+ if (cp >= 0x1100 && cp <= 0x115f) { width += 2; continue; }
20
+ if (cp >= 0x2e80 && cp <= 0x9fff) { width += 2; continue; }
21
+ if (cp >= 0xac00 && cp <= 0xd7a3) { width += 2; continue; }
22
+ if (cp >= 0xff00 && cp <= 0xff60) { width += 2; continue; }
23
+ if (cp >= 0x1f300 && cp <= 0x1faff) { width += 2; continue; }
24
+ width += 1;
25
+ }
26
+ return width;
27
+ }
28
+
29
+ export function truncateToWidth(text: string, maxWidth: number, ellipsis = ELLIPSIS): string {
30
+ const measured = visibleWidth(text);
31
+ if (measured <= maxWidth) return text;
32
+ const ellipsisWidth = ellipsis ? visibleWidth(ellipsis) : 0;
33
+ const target = Math.max(0, maxWidth - ellipsisWidth);
34
+ let result = "";
35
+ let used = 0;
36
+ for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
37
+ const w = visibleWidth(segment.segment);
38
+ if (used + w > target) break;
39
+ result += segment.segment;
40
+ used += w;
41
+ }
42
+ return ellipsis ? `${result}${ellipsis}` : result;
43
+ }
44
+
45
+ export function wrapTextWithAnsi(text: string, maxWidth: number): string[] {
46
+ if (maxWidth <= 0) return [text];
47
+ const normalized = text.replace(/\r\n/g, "\n");
48
+ const words = normalized.split(/(\s+)/);
49
+ const lines: string[] = [];
50
+ let current = "";
51
+ let currentWidth = 0;
52
+ for (const word of words) {
53
+ if (word === "") continue;
54
+ const wordWidth = visibleWidth(word);
55
+ if (wordWidth > maxWidth) {
56
+ // Hard split a single overlong word.
57
+ if (current) { lines.push(current); current = ""; currentWidth = 0; }
58
+ let buffer = "";
59
+ let bufferWidth = 0;
60
+ for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(word)) {
61
+ const w = visibleWidth(segment.segment);
62
+ if (bufferWidth + w > maxWidth) {
63
+ lines.push(buffer);
64
+ buffer = segment.segment;
65
+ bufferWidth = w;
66
+ } else {
67
+ buffer += segment.segment;
68
+ bufferWidth += w;
69
+ }
70
+ }
71
+ if (buffer) { lines.push(buffer); buffer = ""; bufferWidth = 0; }
72
+ continue;
73
+ }
74
+ if (currentWidth + wordWidth > maxWidth) {
75
+ lines.push(current.trimEnd());
76
+ current = word.trimStart();
77
+ currentWidth = visibleWidth(current);
78
+ } else {
79
+ current += word;
80
+ currentWidth += wordWidth;
81
+ }
82
+ }
83
+ if (current.trim().length > 0) lines.push(current.trimEnd());
84
+ return lines;
85
+ }
86
+
87
+ export function matchesEscape(data: string): boolean {
88
+ return data === "\x1b" || data === "\x1b\x1b" || /^(\x1b\[\??\d*[A-Za-z])|(\x1bO[A-Za-z])$/.test(data);
89
+ }
@@ -0,0 +1,78 @@
1
+ import type { SubagentProgressEvent, SubagentResult } from "./subagent.ts";
2
+
3
+ export type RefineOverlayRole = "reviewer" | "criticizer";
4
+ export type RefineLaneStatus = "queued" | "running" | "complete" | "failed" | "cancelled";
5
+
6
+ export interface RefineLaneState {
7
+ id: string;
8
+ label: string;
9
+ status: RefineLaneStatus;
10
+ phase: string;
11
+ detail: string;
12
+ }
13
+
14
+ function shorten(text: string, maxLength: number): string {
15
+ const normalized = text.replace(/\s+/g, " ").trim();
16
+ return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
17
+ }
18
+
19
+ export function statusLabel(status: RefineLaneStatus): string {
20
+ switch (status) {
21
+ case "queued":
22
+ return "queued";
23
+ case "running":
24
+ return "running";
25
+ case "complete":
26
+ return "done";
27
+ case "failed":
28
+ return "failed";
29
+ case "cancelled":
30
+ return "cancelled";
31
+ }
32
+ }
33
+
34
+ export function applyRefineProgress(lane: RefineLaneState, event: SubagentProgressEvent): void {
35
+ if (lane.status === "complete" || lane.status === "failed" || lane.status === "cancelled") return;
36
+
37
+ switch (event.type) {
38
+ case "process":
39
+ lane.phase = event.phase === "started" ? "starting" : "exiting";
40
+ return;
41
+ case "turn":
42
+ lane.status = "running";
43
+ lane.phase = event.phase === "start" ? "thinking" : "waiting";
44
+ return;
45
+ case "message":
46
+ lane.status = "running";
47
+ lane.phase = event.role === "assistant" ? "responding" : event.role;
48
+ if (event.text) lane.detail = shorten(event.text, 180);
49
+ return;
50
+ case "tool":
51
+ lane.status = "running";
52
+ lane.phase = `tool: ${event.toolName}`;
53
+ if (event.detail) lane.detail = shorten(event.detail, 180);
54
+ return;
55
+ case "stderr":
56
+ lane.phase = "diagnostic";
57
+ if (event.text) lane.detail = shorten(event.text, 180);
58
+ return;
59
+ }
60
+ }
61
+
62
+ export function applyRefineResult(lane: RefineLaneState, result: SubagentResult): void {
63
+ if (result.ok) {
64
+ lane.status = "complete";
65
+ lane.phase = "complete";
66
+ lane.detail = shorten(result.output, 180);
67
+ return;
68
+ }
69
+ if (result.cancelled) {
70
+ lane.status = "cancelled";
71
+ lane.phase = "cancelled";
72
+ lane.detail = shorten(result.errorMessage ?? "Subagent was aborted", 180);
73
+ return;
74
+ }
75
+ lane.status = "failed";
76
+ lane.phase = result.timedOut ? "timed out" : "failed";
77
+ lane.detail = shorten((result.errorMessage ?? result.stderr) || "Subagent failed", 180);
78
+ }
@@ -0,0 +1,322 @@
1
+ import type { ExtensionCommandContext, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, OverlayHandle, TUI } from "@earendil-works/pi-tui";
3
+ import type { SubagentProgressEvent, SubagentResult } from "./subagent.ts";
4
+ import {
5
+ matchesEscape as localMatchesEscape,
6
+ truncateToWidth as localTruncateToWidth,
7
+ visibleWidth as localVisibleWidth,
8
+ wrapTextWithAnsi as localWrapTextWithAnsi,
9
+ } from "./refine-ui-helpers.ts";
10
+
11
+ let _piTui: typeof import("@earendil-works/pi-tui") | undefined;
12
+ let _piTuiAttempted = false;
13
+
14
+ async function loadPiTui(): Promise<typeof import("@earendil-works/pi-tui") | undefined> {
15
+ if (_piTuiAttempted) return _piTui;
16
+ _piTuiAttempted = true;
17
+ try {
18
+ _piTui = await import("@earendil-works/pi-tui");
19
+ } catch {
20
+ _piTui = undefined;
21
+ }
22
+ return _piTui;
23
+ }
24
+
25
+ void loadPiTui();
26
+
27
+ function truncateToWidth(text: string, width: number, ellipsis?: string): string {
28
+ try {
29
+ return _piTui ? _piTui.truncateToWidth(text, width, ellipsis) : localTruncateToWidth(text, width, ellipsis);
30
+ } catch {
31
+ return localTruncateToWidth(text, width, ellipsis);
32
+ }
33
+ }
34
+
35
+ function visibleWidth(text: string): number {
36
+ try {
37
+ return _piTui ? _piTui.visibleWidth(text) : localVisibleWidth(text);
38
+ } catch {
39
+ return localVisibleWidth(text);
40
+ }
41
+ }
42
+
43
+ function wrapTextWithAnsi(text: string, width: number): string[] {
44
+ try {
45
+ return _piTui ? _piTui.wrapTextWithAnsi(text, width) : localWrapTextWithAnsi(text, width);
46
+ } catch {
47
+ return localWrapTextWithAnsi(text, width);
48
+ }
49
+ }
50
+
51
+ function handleEscape(data: string): boolean {
52
+ try {
53
+ return _piTui ? _piTui.matchesKey(data, "escape") : localMatchesEscape(data);
54
+ } catch {
55
+ return localMatchesEscape(data);
56
+ }
57
+ }
58
+ import {
59
+ applyRefineProgress,
60
+ applyRefineResult,
61
+ statusLabel,
62
+ type RefineLaneState,
63
+ type RefineOverlayRole,
64
+ } from "./refine-ui-state.ts";
65
+
66
+ export type { RefineLaneState, RefineLaneStatus, RefineOverlayRole } from "./refine-ui-state.ts";
67
+
68
+ const OVERLAY_MIN_WIDTH = 44;
69
+ const OVERLAY_MAX_WIDTH = 96;
70
+ const OVERLAY_MIN_HEIGHT = 18;
71
+ const OVERLAY_MAX_HEIGHT = 32;
72
+ const OVERLAY_HEIGHT_RATIO = 0.78;
73
+ const CHROME_LINES = 5; // top + title + (header rule + footer rule + hints)
74
+
75
+ export function getTerminalRowCount(): number {
76
+ const raw = (process.stdout as { rows?: number }).rows;
77
+ return typeof raw === "number" && raw > 0 ? raw : 30;
78
+ }
79
+
80
+ export function pickOverlayHeight(): number {
81
+ const rows = getTerminalRowCount();
82
+ const target = Math.max(OVERLAY_MIN_HEIGHT, Math.floor(rows * OVERLAY_HEIGHT_RATIO));
83
+ return Math.min(OVERLAY_MAX_HEIGHT, target);
84
+ }
85
+
86
+ export function pickOverlayWidth(width: number): number {
87
+ const target = Math.max(OVERLAY_MIN_WIDTH, Math.min(OVERLAY_MAX_WIDTH, Math.floor(width * 0.78)));
88
+ return Math.max(OVERLAY_MIN_WIDTH, Math.min(OVERLAY_MAX_WIDTH, target));
89
+ }
90
+
91
+ /**
92
+ * Break a free-form assistant/tool detail blob into sentence-sized lines so the
93
+ * overlay stays readable even when the model streams raw fragments.
94
+ */
95
+ export function chunkDetailForOverlay(text: string, innerWidth: number, maxLines = 6): string[] {
96
+ const normalized = text.replace(/\s+/g, " ").trim();
97
+ if (!normalized) return [];
98
+ const boundary = /(?<=[.!?])\s+/;
99
+ const sentences = normalized.split(boundary).map((sentence) => sentence.trim()).filter(Boolean);
100
+ if (sentences.length === 0) return [normalized];
101
+ const lines: string[] = [];
102
+ const usable = Math.max(8, innerWidth);
103
+ for (const sentence of sentences) {
104
+ for (const wrapped of wrapTextWithAnsi(sentence, usable)) {
105
+ lines.push(wrapped);
106
+ if (lines.length >= maxLines) return lines;
107
+ }
108
+ }
109
+ return lines;
110
+ }
111
+
112
+ function borderColor(theme: Theme): ThemeColor {
113
+ return "borderAccent";
114
+ }
115
+
116
+ function renderRow(theme: Theme, content: string, innerWidth: number): string {
117
+ const truncated = truncateToWidth(content, innerWidth, "...");
118
+ const width = visibleWidth(truncated);
119
+ const filler = innerWidth > width ? " ".repeat(innerWidth - width) : "";
120
+ return `${theme.fg(borderColor(theme), "│")}${truncated}${filler}${theme.fg(borderColor(theme), "│")}`;
121
+ }
122
+
123
+ function renderHorizontalRule(theme: Theme, innerWidth: number): string {
124
+ return theme.fg(borderColor(theme), `├${"─".repeat(innerWidth)}┤`);
125
+ }
126
+
127
+ function renderBorderLine(theme: Theme, innerWidth: number, edge: "top" | "bottom"): string {
128
+ const side = edge === "top" ? "┌" : "└";
129
+ const right = edge === "top" ? "┐" : "┘";
130
+ return theme.fg(borderColor(theme), `${side}${"─".repeat(innerWidth)}${right}`);
131
+ }
132
+
133
+ function fitLine(line: string, width: number): string {
134
+ return visibleWidth(line) > width ? truncateToWidth(line, width, "...") : line;
135
+ }
136
+
137
+ function laneColor(status: RefineLaneState["status"]): ThemeColor {
138
+ switch (status) {
139
+ case "complete": return "success";
140
+ case "failed": return "error";
141
+ case "cancelled": return "warning";
142
+ case "running": return "accent";
143
+ case "queued": return "muted";
144
+ }
145
+ }
146
+
147
+ export class RefineOverlayComponent implements Component {
148
+ private readonly theme: Theme;
149
+ private readonly role: RefineOverlayRole;
150
+ private readonly lanes: RefineLaneState[];
151
+ private readonly onCancel: () => void;
152
+
153
+ constructor(theme: Theme, role: RefineOverlayRole, lanes: RefineLaneState[], onCancel: () => void) {
154
+ this.theme = theme;
155
+ this.role = role;
156
+ this.lanes = lanes;
157
+ this.onCancel = onCancel;
158
+ }
159
+
160
+ handleInput(data: string): void {
161
+ if (handleEscape(data)) this.onCancel();
162
+ }
163
+
164
+ render(width: number): string[] {
165
+ const dialogWidth = pickOverlayWidth(width);
166
+ const innerWidth = Math.max(8, dialogWidth - 2);
167
+ const title = this.role === "reviewer" ? "Reviewer" : "Criticizer";
168
+
169
+ // Build lane rows (label + status + detail sentence chunk)
170
+ const laneRows: string[] = [];
171
+ for (const lane of this.lanes) {
172
+ const status = this.theme.fg(laneColor(lane.status), statusLabel(lane.status));
173
+ const phase = lane.phase ? this.theme.fg("muted", ` · ${lane.phase}`) : "";
174
+ laneRows.push(this.theme.fg("accent", `▸ ${lane.label}:`) + ` ${status}${phase}`);
175
+ for (const chunk of chunkDetailForOverlay(lane.detail, innerWidth - 4, 4)) {
176
+ laneRows.push(this.theme.fg("dim", ` ${chunk}`));
177
+ }
178
+ }
179
+
180
+ const runningCount = this.lanes.filter((lane) => lane.status === "running").length;
181
+ const summary = `${this.lanes.length} lane${this.lanes.length === 1 ? "" : "s"}` +
182
+ (runningCount > 0 ? ` · ${runningCount} running` : "") +
183
+ (this.lanes.every((lane) => lane.status === "complete") ? " · all done" : "");
184
+
185
+ const hint = "Esc cancel · ↑/↓ lanes scroll when more lines than viewport";
186
+
187
+ // Compose chrome + body, then enforce viewport height.
188
+ const body = [
189
+ this.theme.fg("accent", this.theme.bold(title)),
190
+ this.theme.fg("dim", "Independent read-only refinement in progress"),
191
+ ...laneRows,
192
+ ];
193
+ if (body.length === 0) body.push(this.theme.fg("dim", "(no active lanes)"));
194
+
195
+ const dialogHeight = pickOverlayHeight();
196
+ const available = Math.max(8, dialogHeight - CHROME_LINES);
197
+ const visibleBody = body.slice(Math.max(0, body.length - available));
198
+ const hiddenAbove = Math.max(0, body.length - visibleBody.length);
199
+ const summaryText = hiddenAbove > 0
200
+ ? `${summary} · ↑${hiddenAbove}`
201
+ : summary;
202
+
203
+ const lines: string[] = [renderBorderLine(this.theme, innerWidth, "top")];
204
+ lines.push(renderRow(this.theme, this.theme.fg("accent", this.theme.bold(title)), innerWidth));
205
+ lines.push(renderRow(this.theme, this.theme.fg("dim", summaryText), innerWidth));
206
+ lines.push(renderHorizontalRule(this.theme, innerWidth));
207
+ for (const row of visibleBody) lines.push(renderRow(this.theme, row, innerWidth));
208
+ lines.push(renderHorizontalRule(this.theme, innerWidth));
209
+ lines.push(renderRow(this.theme, this.theme.fg("dim", hint), innerWidth));
210
+ lines.push(renderBorderLine(this.theme, innerWidth, "bottom"));
211
+
212
+ return lines.map((line) => fitLine(line, width));
213
+ }
214
+
215
+ invalidate(): void {}
216
+ }
217
+
218
+ export interface RefineOverlayContext {
219
+ ui: ExtensionContext["ui"];
220
+ hasUI: boolean;
221
+ }
222
+
223
+ /**
224
+ * Owns a one-shot overlay. Child processes remain owned by the caller; the
225
+ * controller only receives progress and supplies a single cancellation hook.
226
+ */
227
+ export class RefineOverlayController {
228
+ readonly lanes: RefineLaneState[];
229
+ private readonly role: RefineOverlayRole;
230
+ private readonly onCancel: () => void;
231
+ private handle: OverlayHandle | undefined;
232
+ private done: ((result: undefined) => void) | undefined;
233
+ private component: RefineOverlayComponent | undefined;
234
+ private overlayPromise: Promise<void> | undefined;
235
+ private tui: TUI | undefined;
236
+ private closed = false;
237
+
238
+ constructor(role: RefineOverlayRole, laneIds: Array<{ id: string; label?: string }>, onCancel: () => void) {
239
+ this.role = role;
240
+ this.onCancel = onCancel;
241
+ this.lanes = laneIds.map((lane) => ({
242
+ id: lane.id,
243
+ label: lane.label ?? lane.id,
244
+ status: "queued",
245
+ phase: "queued",
246
+ detail: "",
247
+ }));
248
+ }
249
+
250
+ open(ctx: RefineOverlayContext): void {
251
+ if (!ctx.hasUI || this.overlayPromise) return;
252
+
253
+ this.overlayPromise = ctx.ui
254
+ .custom<void>(
255
+ (_tui, theme, _keybindings, done) => {
256
+ this.tui = _tui;
257
+ this.done = done;
258
+ this.component = new RefineOverlayComponent(theme, this.role, this.lanes, () => this.cancel());
259
+ if (this.closed) done(undefined);
260
+ return this.component;
261
+ },
262
+ {
263
+ overlay: true,
264
+ overlayOptions: {
265
+ width: "78%",
266
+ minWidth: OVERLAY_MIN_WIDTH,
267
+ maxHeight: "78%",
268
+ anchor: "top-center",
269
+ margin: { top: 1, left: 2, right: 2 },
270
+ },
271
+ onHandle: (handle) => {
272
+ this.handle = handle;
273
+ handle.focus();
274
+ if (this.closed) handle.hide();
275
+ },
276
+ },
277
+ )
278
+ .then(() => undefined)
279
+ .catch(() => undefined);
280
+ }
281
+
282
+ update(laneId: string, event: SubagentProgressEvent): void {
283
+ if (this.closed) return;
284
+ const lane = this.lanes.find((candidate) => candidate.id === laneId);
285
+ if (!lane) return;
286
+ applyRefineProgress(lane, event);
287
+ this.tui?.requestRender();
288
+ }
289
+
290
+ complete(laneId: string, result: SubagentResult): void {
291
+ if (this.closed) return;
292
+ const lane = this.lanes.find((candidate) => candidate.id === laneId);
293
+ if (lane) {
294
+ applyRefineResult(lane, result);
295
+ this.tui?.requestRender();
296
+ }
297
+ }
298
+
299
+ cancel(): void {
300
+ if (this.closed) return;
301
+ this.onCancel();
302
+ void this.close();
303
+ }
304
+
305
+ async close(): Promise<void> {
306
+ if (!this.closed) {
307
+ this.closed = true;
308
+ if (this.done) this.done(undefined);
309
+ else this.handle?.hide();
310
+ this.component = undefined;
311
+ }
312
+ await this.overlayPromise;
313
+ }
314
+
315
+ isClosed(): boolean {
316
+ return this.closed;
317
+ }
318
+ }
319
+
320
+ export function refineOverlayContext(ctx: ExtensionContext | ExtensionCommandContext): RefineOverlayContext {
321
+ return { ui: ctx.ui, hasUI: ctx.hasUI };
322
+ }
package/src/state.ts CHANGED
@@ -34,18 +34,12 @@ export interface RoleConfig {
34
34
  confirmed_at: string | null;
35
35
  }
36
36
 
37
- export interface ExecutionConfig {
38
- model_selector: string | null;
39
- source: SettingSource;
40
- updated_at: string | null;
41
- }
42
37
 
43
38
  export interface PlansConfig {
44
39
  schema: number;
45
40
  language: LanguageConfig;
46
41
  reviewer: RoleConfig;
47
42
  criticizer: RoleConfig;
48
- execution: ExecutionConfig;
49
43
  artifact_root: string;
50
44
  artifact_root_source: SettingSource;
51
45
  artifact_root_updated_at: string | null;
@@ -61,6 +55,13 @@ function normalizeArtifactRoot(config: PlansConfig): PlansConfig {
61
55
  return config;
62
56
  }
63
57
 
58
+ type LegacyPlansConfig = PlansConfig & { execution?: unknown };
59
+
60
+ function normalizeLegacyExecutionConfig(config: LegacyPlansConfig): PlansConfig {
61
+ const { execution: _execution, ...rest } = config;
62
+ return rest as PlansConfig;
63
+ }
64
+
64
65
  export const DEFAULT_CONFIG: PlansConfig = {
65
66
  schema: 1,
66
67
  language: { tag: null, source: "unset", updated_at: null },
@@ -76,11 +77,6 @@ export const DEFAULT_CONFIG: PlansConfig = {
76
77
  name_prefix: "pi-plans-criticizer",
77
78
  confirmed_at: null,
78
79
  },
79
- execution: {
80
- model_selector: null,
81
- source: "unset",
82
- updated_at: null,
83
- },
84
80
  artifact_root: DEFAULT_ARTIFACT_ROOT,
85
81
  artifact_root_source: "unset",
86
82
  artifact_root_updated_at: null,
@@ -268,7 +264,8 @@ export function loadConfig(stateRoot: string): PlansConfig {
268
264
  } catch (error) {
269
265
  throw new StateError(`invalid config.json: ${(error as Error).message}`);
270
266
  }
271
- return normalizeArtifactRoot(deepMergeDefaults(data as PlansConfig, DEFAULT_CONFIG));
267
+ const merged = deepMergeDefaults(data as LegacyPlansConfig, DEFAULT_CONFIG as LegacyPlansConfig);
268
+ return normalizeLegacyExecutionConfig(normalizeArtifactRoot(merged as PlansConfig));
272
269
  }
273
270
 
274
271
  function noticeIfSubdir(workdir: string, notices: string[]): void {
@@ -328,21 +325,6 @@ export function setArtifactRoot(workdir: string, artifactRoot: string, source: "
328
325
  return { config, stateRoot, notices };
329
326
  }
330
327
 
331
- export interface SetExecutionModelOptions {
332
- modelSelector?: string;
333
- source: "user" | "auto";
334
- }
335
-
336
- export function setExecutionModel(workdir: string, options: SetExecutionModelOptions): EnsureResult {
337
- const { config, stateRoot, notices } = ensureState(workdir);
338
- config.execution = {
339
- model_selector: options.modelSelector === undefined || options.modelSelector === "inherit" ? null : options.modelSelector,
340
- source: options.source,
341
- updated_at: utcNow(),
342
- };
343
- atomicWriteJson(path.join(stateRoot, "config.json"), config);
344
- return { config, stateRoot, notices };
345
- }
346
328
 
347
329
  export interface SetRoleOptions {
348
330
  role: "reviewer" | "criticizer";