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.
- package/README.md +47 -15
- package/index.ts +143 -95
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +6 -3
- package/references/state-and-config.md +1 -1
- package/src/autocomplete.ts +163 -0
- package/src/compaction.ts +502 -0
- package/src/exec.ts +1098 -95
- package/src/plan.ts +143 -0
- package/src/query-hook.ts +82 -0
- package/src/refine-ui-helpers.ts +89 -0
- package/src/refine-ui-state.ts +78 -0
- package/src/refine-ui.ts +322 -0
- package/src/state.ts +19 -1
- package/src/subagent.ts +196 -69
- package/tests/autocomplete.test.ts +142 -0
- package/tests/compaction.test.ts +74 -0
- package/tests/exec.test.ts +803 -101
- package/tests/execute-plan.test.ts +65 -0
- package/tests/plan.test.ts +94 -1
- package/tests/plans.test.ts +37 -0
- package/tests/query-hook.test.ts +82 -0
- package/tests/refine-ui.test.ts +127 -0
- package/tests/state.test.ts +57 -0
- package/tests/subagent.test.ts +114 -0
- package/tools/ask-choice.ts +22 -0
- package/tools/execute-plan.ts +13 -5
- package/tools/plans.ts +16 -0
- package/tools/refine.ts +125 -71
- package/src/execution-panel.ts +0 -499
- package/tests/execution-panel.test.ts +0 -205
package/src/refine-ui.ts
ADDED
|
@@ -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
|
-
|
|
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
|
|