pi-plans 0.1.1 → 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.
@@ -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,6 +34,7 @@ export interface RoleConfig {
34
34
  confirmed_at: string | null;
35
35
  }
36
36
 
37
+
37
38
  export interface PlansConfig {
38
39
  schema: number;
39
40
  language: LanguageConfig;
@@ -54,6 +55,13 @@ function normalizeArtifactRoot(config: PlansConfig): PlansConfig {
54
55
  return config;
55
56
  }
56
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
+
57
65
  export const DEFAULT_CONFIG: PlansConfig = {
58
66
  schema: 1,
59
67
  language: { tag: null, source: "unset", updated_at: null },
@@ -256,7 +264,8 @@ export function loadConfig(stateRoot: string): PlansConfig {
256
264
  } catch (error) {
257
265
  throw new StateError(`invalid config.json: ${(error as Error).message}`);
258
266
  }
259
- 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));
260
269
  }
261
270
 
262
271
  function noticeIfSubdir(workdir: string, notices: string[]): void {
@@ -316,6 +325,7 @@ export function setArtifactRoot(workdir: string, artifactRoot: string, source: "
316
325
  return { config, stateRoot, notices };
317
326
  }
318
327
 
328
+
319
329
  export interface SetRoleOptions {
320
330
  role: "reviewer" | "criticizer";
321
331
  mode?: string;
@@ -359,6 +369,7 @@ export interface StartRunOptions {
359
369
  topic: string;
360
370
  skill: string;
361
371
  requestText: string;
372
+ onStart?: (run: RunInfo) => void;
362
373
  }
363
374
 
364
375
  export interface StartRunResult {
@@ -408,6 +419,13 @@ export function startRun(workdir: string, options: StartRunOptions): StartRunRes
408
419
  run_dir: runDir,
409
420
  artifact_dir: artifactDir,
410
421
  } satisfies ActiveInfo);
422
+ if (options.onStart) {
423
+ try {
424
+ options.onStart(run);
425
+ } catch {
426
+ /* marker failure is non-fatal: planning start should not abort when the entry appender rejects */
427
+ }
428
+ }
411
429
  return { run, notices };
412
430
  }
413
431