pi-plans 0.1.2 → 0.3.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 +98 -19
- package/index.ts +147 -66
- package/package.json +7 -1
- package/references/pi-planning-workflow.md +21 -3
- package/references/state-and-config.md +35 -3
- package/scripts/validate.ts +4 -0
- package/src/autocomplete.ts +163 -0
- package/src/code-graph/commands.ts +437 -0
- package/src/code-graph/discovery.ts +118 -0
- package/src/code-graph/git.ts +108 -0
- package/src/code-graph/identity.ts +59 -0
- package/src/code-graph/indexer.ts +281 -0
- package/src/code-graph/materialize.ts +166 -0
- package/src/code-graph/mode.ts +28 -0
- package/src/code-graph/mutations.ts +160 -0
- package/src/code-graph/parser.ts +51 -0
- package/src/code-graph/parsers/javascript.ts +35 -0
- package/src/code-graph/parsers/python.ts +160 -0
- package/src/code-graph/parsers/tree-sitter.ts +316 -0
- package/src/code-graph/paths.ts +85 -0
- package/src/code-graph/prompts.ts +18 -0
- package/src/code-graph/resolver.ts +69 -0
- package/src/code-graph/runtime.ts +158 -0
- package/src/code-graph/schema.ts +135 -0
- package/src/code-graph/screening.ts +82 -0
- package/src/code-graph/store.ts +278 -0
- package/src/code-graph/summary.ts +435 -0
- package/src/code-graph/types.ts +163 -0
- package/src/compaction.ts +1256 -0
- package/src/config-command.ts +326 -0
- package/src/exec.ts +519 -625
- package/src/plan.ts +37 -0
- package/src/query-hook.ts +82 -0
- package/src/refine-prompts.ts +50 -0
- package/src/refine-ui-helpers.ts +142 -0
- package/src/refine-ui-state.ts +144 -0
- package/src/refine-ui.ts +430 -0
- package/src/state.ts +24 -29
- package/src/subagent.ts +299 -70
- package/tests/ask-choice.test.ts +263 -0
- package/tests/autocomplete.test.ts +147 -0
- package/tests/code-graph-apply.test.ts +185 -0
- package/tests/code-graph-commands.test.ts +211 -0
- package/tests/code-graph-db.test.ts +166 -0
- package/tests/code-graph-discovery.test.ts +38 -0
- package/tests/code-graph-git.test.ts +94 -0
- package/tests/code-graph-index.test.ts +175 -0
- package/tests/code-graph-loop.e2e.test.ts +159 -0
- package/tests/code-graph-mutations.test.ts +117 -0
- package/tests/code-graph-parser.test.ts +85 -0
- package/tests/code-graph-rollback.test.ts +100 -0
- package/tests/code-graph-summary-batching.test.ts +518 -0
- package/tests/code-graph-summary.test.ts +148 -0
- package/tests/compaction.test.ts +388 -0
- package/tests/config-command.test.ts +255 -0
- package/tests/exec.test.ts +751 -422
- package/tests/execute-plan.test.ts +65 -0
- package/tests/fixtures/code-graph/sample.js +36 -0
- package/tests/fixtures/code-graph/sample.py +20 -0
- package/tests/fixtures/code-graph/sample.ts +15 -0
- package/tests/graph-aware-file-tools.test.ts +411 -0
- package/tests/plan.test.ts +11 -1
- package/tests/plans.test.ts +6 -5
- package/tests/query-hook.test.ts +82 -0
- package/tests/refine-prompts.test.ts +67 -2
- package/tests/refine-ui.test.ts +392 -0
- package/tests/state.test.ts +12 -15
- package/tests/subagent.test.ts +120 -0
- package/tools/ask-choice.ts +180 -11
- package/tools/code-graph.ts +254 -0
- package/tools/execute-plan.ts +7 -39
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +84 -18
- package/tools/refine.ts +180 -80
- package/src/execution-panel.ts +0 -633
- package/tests/execution-panel.test.ts +0 -234
package/src/refine-ui.ts
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
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
|
+
import {
|
|
11
|
+
applyRefineProgress,
|
|
12
|
+
applyRefineResult,
|
|
13
|
+
statusLabel,
|
|
14
|
+
type RefineLaneState,
|
|
15
|
+
type RefineTranscriptEntry,
|
|
16
|
+
type RefineTranscriptEntryType,
|
|
17
|
+
type RefineOverlayRole,
|
|
18
|
+
} from "./refine-ui-state.ts";
|
|
19
|
+
|
|
20
|
+
let _piTui: typeof import("@earendil-works/pi-tui") | undefined;
|
|
21
|
+
let _piTuiAttempted = false;
|
|
22
|
+
|
|
23
|
+
async function loadPiTui(): Promise<typeof import("@earendil-works/pi-tui") | undefined> {
|
|
24
|
+
if (_piTuiAttempted) return _piTui;
|
|
25
|
+
_piTuiAttempted = true;
|
|
26
|
+
try {
|
|
27
|
+
_piTui = await import("@earendil-works/pi-tui");
|
|
28
|
+
} catch {
|
|
29
|
+
_piTui = undefined;
|
|
30
|
+
}
|
|
31
|
+
return _piTui;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
void loadPiTui();
|
|
35
|
+
|
|
36
|
+
function truncateToWidth(text: string, width: number, ellipsis = ""): string {
|
|
37
|
+
try {
|
|
38
|
+
return _piTui ? _piTui.truncateToWidth(text, width, ellipsis) : localTruncateToWidth(text, width, ellipsis);
|
|
39
|
+
} catch {
|
|
40
|
+
return localTruncateToWidth(text, width, ellipsis);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function visibleWidth(text: string): number {
|
|
45
|
+
try {
|
|
46
|
+
return _piTui ? _piTui.visibleWidth(text) : localVisibleWidth(text);
|
|
47
|
+
} catch {
|
|
48
|
+
return localVisibleWidth(text);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function wrapTextWithAnsi(text: string, width: number): string[] {
|
|
53
|
+
try {
|
|
54
|
+
return _piTui ? _piTui.wrapTextWithAnsi(text, width) : localWrapTextWithAnsi(text, width);
|
|
55
|
+
} catch {
|
|
56
|
+
return localWrapTextWithAnsi(text, width);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type RefineKey = "escape" | "tab" | "shift+tab" | "up" | "down" | "pageUp" | "pageDown";
|
|
61
|
+
|
|
62
|
+
function fallbackKey(data: string, key: RefineKey): boolean {
|
|
63
|
+
const sequences: Record<RefineKey, string[]> = {
|
|
64
|
+
escape: ["\x1b", "\x1b\x1b"],
|
|
65
|
+
tab: ["\t"],
|
|
66
|
+
"shift+tab": ["\x1b[Z"],
|
|
67
|
+
up: ["\x1b[A", "\x1bOA"],
|
|
68
|
+
down: ["\x1b[B", "\x1bOB"],
|
|
69
|
+
pageUp: ["\x1b[5~"],
|
|
70
|
+
pageDown: ["\x1b[6~"],
|
|
71
|
+
};
|
|
72
|
+
return sequences[key].includes(data);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function matchesKey(data: string, key: RefineKey): boolean {
|
|
76
|
+
try {
|
|
77
|
+
return _piTui ? _piTui.matchesKey(data, key) : fallbackKey(data, key);
|
|
78
|
+
} catch {
|
|
79
|
+
return fallbackKey(data, key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleEscape(data: string): boolean {
|
|
84
|
+
try {
|
|
85
|
+
return _piTui ? _piTui.matchesKey(data, "escape") : localMatchesEscape(data);
|
|
86
|
+
} catch {
|
|
87
|
+
return localMatchesEscape(data);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type { RefineLaneState, RefineLaneStatus, RefineOverlayRole, RefineTranscriptEntry, RefineTranscriptEntryType } from "./refine-ui-state.ts";
|
|
92
|
+
|
|
93
|
+
const OVERLAY_MIN_WIDTH = 72;
|
|
94
|
+
const OVERLAY_MIN_HEIGHT = 18;
|
|
95
|
+
const OVERLAY_MAX_HEIGHT = 32;
|
|
96
|
+
const OVERLAY_HEIGHT_RATIO = 0.78;
|
|
97
|
+
const STREAMING_PREVIEW_LINES = 3;
|
|
98
|
+
const OVERLAY_CHROME_LINES = 4; // top border + title row + footer row + bottom border
|
|
99
|
+
|
|
100
|
+
export function getTerminalRowCount(): number {
|
|
101
|
+
const raw = (process.stdout as { rows?: number }).rows;
|
|
102
|
+
return typeof raw === "number" && raw > 0 ? raw : 30;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function pickOverlayHeight(): number {
|
|
106
|
+
const rows = getTerminalRowCount();
|
|
107
|
+
const target = Math.max(OVERLAY_MIN_HEIGHT, Math.floor(rows * OVERLAY_HEIGHT_RATIO));
|
|
108
|
+
return Math.min(OVERLAY_MAX_HEIGHT, target);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The overlay manager resolves the 78%/72-column size; render accepts its resolved width. */
|
|
112
|
+
export function pickOverlayWidth(width: number): number {
|
|
113
|
+
return Math.max(24, width);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function borderColor(theme: Theme, selected = false): ThemeColor {
|
|
117
|
+
return selected ? "borderAccent" : "border";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderRow(theme: Theme, content: string, innerWidth: number, selected = false): string {
|
|
121
|
+
const truncated = truncateToWidth(content, innerWidth, "");
|
|
122
|
+
const width = visibleWidth(truncated);
|
|
123
|
+
const filler = innerWidth > width ? " ".repeat(innerWidth - width) : "";
|
|
124
|
+
return `${theme.fg(borderColor(theme, selected), "│")}${truncated}${filler}${theme.fg(borderColor(theme, selected), "│")}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderBorderLine(theme: Theme, innerWidth: number, edge: "top" | "bottom", selected = false): string {
|
|
128
|
+
const left = edge === "top" ? "┌" : "└";
|
|
129
|
+
const right = edge === "top" ? "┐" : "┘";
|
|
130
|
+
return theme.fg(borderColor(theme, selected), `${left}${"─".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
|
+
function entryBadge(entry: RefineTranscriptEntry, theme: Theme): string {
|
|
148
|
+
const streaming = entry.streaming ? theme.fg("warning", " ▍") : "";
|
|
149
|
+
switch (entry.type) {
|
|
150
|
+
case "assistant-text":
|
|
151
|
+
return theme.fg("accent", theme.bold(" assistant ")) + streaming;
|
|
152
|
+
case "thinking":
|
|
153
|
+
return theme.fg("warning", theme.bold(" thinking ")) + streaming;
|
|
154
|
+
case "tool-call":
|
|
155
|
+
return theme.fg("accent", theme.bold(` tool ${entry.toolName ?? "call"} `)) + streaming;
|
|
156
|
+
case "tool-result":
|
|
157
|
+
return theme.fg(entry.isError ? "error" : "success", theme.bold(` ${entry.isError ? "error" : "result"} ${entry.toolName ?? ""} `)) + streaming;
|
|
158
|
+
case "diagnostic":
|
|
159
|
+
return theme.fg("error", theme.bold(" diagnostic "));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function wrapTranscriptText(text: string, width: number): string[] {
|
|
164
|
+
const sourceLines = text.replace(/\r\n/g, "\n").split("\n");
|
|
165
|
+
return sourceLines.flatMap((line) => line ? wrapTextWithAnsi(line, Math.max(1, width)) : [""]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function previewTranscriptText(entry: RefineTranscriptEntry, width: number): { lines: string[]; truncated: boolean } {
|
|
169
|
+
const lines = wrapTranscriptText(entry.text, Math.max(1, width));
|
|
170
|
+
if (!entry.streaming || entry.type === "thinking" || lines.length <= STREAMING_PREVIEW_LINES) {
|
|
171
|
+
return { lines, truncated: false };
|
|
172
|
+
}
|
|
173
|
+
return { lines: lines.slice(-STREAMING_PREVIEW_LINES), truncated: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function footerText(laneCount: number): string {
|
|
177
|
+
const parts = ["Esc 关闭", "↑/↓ 滚动", "PgUp/PgDn 翻页"];
|
|
178
|
+
if (laneCount > 1) parts.push("Tab & Shift + Tab 切换 lane");
|
|
179
|
+
return parts.join(" · ");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function buildTranscriptLines(entries: RefineTranscriptEntry[], theme: Theme, width: number): string[] {
|
|
183
|
+
const lines: string[] = [];
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
lines.push(entryBadge(entry, theme));
|
|
186
|
+
const preview = previewTranscriptText(entry, Math.max(1, width - 2));
|
|
187
|
+
if (preview.truncated) lines.push(` ${theme.fg("dim", "…")}`);
|
|
188
|
+
for (const line of preview.lines) {
|
|
189
|
+
const styled = entry.type === "thinking"
|
|
190
|
+
? theme.fg("warning", line)
|
|
191
|
+
: entry.type === "tool-result" && entry.isError
|
|
192
|
+
? theme.fg("error", line)
|
|
193
|
+
: entry.type === "diagnostic"
|
|
194
|
+
? theme.fg("error", line)
|
|
195
|
+
: theme.fg("dim", line);
|
|
196
|
+
lines.push(` ${styled}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return lines;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function summaryFor(role: RefineOverlayRole, lanes: RefineLaneState[], modelLabel?: string): string {
|
|
203
|
+
const complete = lanes.filter((lane) => lane.status === "complete").length;
|
|
204
|
+
const terminal = lanes.filter((lane) => ["complete", "failed", "cancelled"].includes(lane.status)).length;
|
|
205
|
+
const running = lanes.filter((lane) => lane.status === "running").length;
|
|
206
|
+
const title = role === "reviewer" ? "Reviewer" : "Criticizer";
|
|
207
|
+
const visibleTitle = modelLabel ? `${title} (${modelLabel})` : title;
|
|
208
|
+
const state = terminal === lanes.length ? "done" : running > 0 ? `${running} running` : "queued";
|
|
209
|
+
return `${visibleTitle} · ${complete}/${lanes.length} done · ${state}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export class RefineOverlayComponent implements Component {
|
|
213
|
+
private readonly theme: Theme;
|
|
214
|
+
private readonly role: RefineOverlayRole;
|
|
215
|
+
private readonly lanes: RefineLaneState[];
|
|
216
|
+
private readonly onCancel: () => void;
|
|
217
|
+
private readonly tui?: TUI;
|
|
218
|
+
private readonly modelLabel?: string;
|
|
219
|
+
private selectedLane = 0;
|
|
220
|
+
private disposed = false;
|
|
221
|
+
|
|
222
|
+
constructor(theme: Theme, role: RefineOverlayRole, lanes: RefineLaneState[], onCancel: () => void, tui?: TUI, modelLabel?: string) {
|
|
223
|
+
this.theme = theme;
|
|
224
|
+
this.role = role;
|
|
225
|
+
this.lanes = lanes;
|
|
226
|
+
this.onCancel = onCancel;
|
|
227
|
+
this.tui = tui;
|
|
228
|
+
this.modelLabel = modelLabel;
|
|
229
|
+
this.tui?.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
handleInput(data: string): void {
|
|
233
|
+
if (this.disposed) return;
|
|
234
|
+
if (handleEscape(data)) {
|
|
235
|
+
this.onCancel();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (this.lanes.length > 1 && matchesKey(data, "tab")) {
|
|
239
|
+
this.selectedLane = (this.selectedLane + 1) % this.lanes.length;
|
|
240
|
+
this.tui?.requestRender();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (this.lanes.length > 1 && matchesKey(data, "shift+tab")) {
|
|
244
|
+
this.selectedLane = (this.selectedLane - 1 + this.lanes.length) % this.lanes.length;
|
|
245
|
+
this.tui?.requestRender();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const lane = this.lanes[this.selectedLane];
|
|
249
|
+
if (!lane) return;
|
|
250
|
+
const viewport = Math.max(1, lane.viewportHeight ?? 1);
|
|
251
|
+
if (matchesKey(data, "up")) lane.scrollOffset -= 1;
|
|
252
|
+
else if (matchesKey(data, "down")) lane.scrollOffset += 1;
|
|
253
|
+
else if (matchesKey(data, "pageUp")) lane.scrollOffset -= Math.max(1, viewport - 1);
|
|
254
|
+
else if (matchesKey(data, "pageDown")) lane.scrollOffset += Math.max(1, viewport - 1);
|
|
255
|
+
else {
|
|
256
|
+
const mouse = data.match(/^\x1b\[<(\d+);\d+;\d+[Mm]$/);
|
|
257
|
+
if (!mouse || (Number(mouse[1]) & 64) !== 64) return;
|
|
258
|
+
lane.scrollOffset += (Number(mouse[1]) & 1) === 0 ? -3 : 3;
|
|
259
|
+
}
|
|
260
|
+
lane.followTranscript = false;
|
|
261
|
+
this.tui?.requestRender();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
dispose(): void {
|
|
265
|
+
if (this.disposed) return;
|
|
266
|
+
this.disposed = true;
|
|
267
|
+
this.tui?.terminal?.write?.("\x1b[?1000l\x1b[?1006l");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
render(width: number): string[] {
|
|
271
|
+
const dialogWidth = pickOverlayWidth(width);
|
|
272
|
+
const innerWidth = Math.max(22, dialogWidth - 2);
|
|
273
|
+
const dialogHeight = pickOverlayHeight();
|
|
274
|
+
const laneCount = Math.max(1, this.lanes.length);
|
|
275
|
+
const paneHeight = Math.max(3, Math.floor((dialogHeight - OVERLAY_CHROME_LINES) / laneCount));
|
|
276
|
+
const title = summaryFor(this.role, this.lanes, this.modelLabel);
|
|
277
|
+
const lines: string[] = [renderBorderLine(this.theme, innerWidth, "top")];
|
|
278
|
+
lines.push(renderRow(this.theme, this.theme.fg("accent", this.theme.bold(title)), innerWidth, false));
|
|
279
|
+
|
|
280
|
+
if (this.lanes.length === 0) {
|
|
281
|
+
lines.push(renderRow(this.theme, this.theme.fg("dim", "No active lanes"), innerWidth));
|
|
282
|
+
} else {
|
|
283
|
+
for (let index = 0; index < this.lanes.length; index++) {
|
|
284
|
+
lines.push(...this.renderPane(this.lanes[index]!, innerWidth, paneHeight, index === this.selectedLane));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
lines.push(renderRow(this.theme, this.theme.fg("dim", footerText(this.lanes.length)), innerWidth));
|
|
288
|
+
lines.push(renderBorderLine(this.theme, innerWidth, "bottom"));
|
|
289
|
+
return lines.map((line) => fitLine(line, width));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private renderPane(lane: RefineLaneState, innerWidth: number, paneHeight: number, selected: boolean): string[] {
|
|
293
|
+
const transcriptWidth = Math.max(1, innerWidth - 2);
|
|
294
|
+
const transcriptLines = buildTranscriptLines(lane.transcript, this.theme, transcriptWidth);
|
|
295
|
+
const viewportHeight = Math.max(1, paneHeight - 3);
|
|
296
|
+
lane.viewportHeight = viewportHeight;
|
|
297
|
+
const maxScroll = Math.max(0, transcriptLines.length - viewportHeight);
|
|
298
|
+
if (lane.followTranscript) lane.scrollOffset = maxScroll;
|
|
299
|
+
else {
|
|
300
|
+
lane.scrollOffset = Math.max(0, Math.min(lane.scrollOffset, maxScroll));
|
|
301
|
+
if (lane.scrollOffset >= maxScroll) lane.followTranscript = true;
|
|
302
|
+
}
|
|
303
|
+
const hiddenAbove = lane.scrollOffset;
|
|
304
|
+
const hiddenBelow = Math.max(0, maxScroll - lane.scrollOffset);
|
|
305
|
+
const scrollCount = hiddenAbove || hiddenBelow ? ` ↑${hiddenAbove} ↓${hiddenBelow}` : "";
|
|
306
|
+
const status = this.theme.fg(laneColor(lane.status), statusLabel(lane.status));
|
|
307
|
+
const phase = lane.phase ? this.theme.fg("muted", ` · ${lane.phase}`) : "";
|
|
308
|
+
const marker = selected ? "▸ " : " ";
|
|
309
|
+
const header = `${marker}${this.theme.fg("accent", this.theme.bold(lane.label))} ${status}${phase}${this.theme.fg("dim", scrollCount)}`;
|
|
310
|
+
const visible = transcriptLines.slice(lane.scrollOffset, lane.scrollOffset + viewportHeight);
|
|
311
|
+
const lines = [renderBorderLine(this.theme, innerWidth, "top", selected), renderRow(this.theme, header, innerWidth, selected)];
|
|
312
|
+
for (const line of visible) lines.push(renderRow(this.theme, line, innerWidth, selected));
|
|
313
|
+
for (let i = visible.length; i < viewportHeight; i++) lines.push(renderRow(this.theme, "", innerWidth, selected));
|
|
314
|
+
lines.push(renderBorderLine(this.theme, innerWidth, "bottom", selected));
|
|
315
|
+
return lines;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
invalidate(): void {}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface RefineOverlayContext {
|
|
322
|
+
ui: ExtensionContext["ui"];
|
|
323
|
+
hasUI: boolean;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Owns the public overlay and exposes progress updates without owning child processes. */
|
|
327
|
+
export class RefineOverlayController {
|
|
328
|
+
readonly lanes: RefineLaneState[];
|
|
329
|
+
private readonly role: RefineOverlayRole;
|
|
330
|
+
private readonly onCancel: () => void;
|
|
331
|
+
private handle: OverlayHandle | undefined;
|
|
332
|
+
private done: ((result: undefined) => void) | undefined;
|
|
333
|
+
private component: RefineOverlayComponent | undefined;
|
|
334
|
+
private overlayPromise: Promise<void> | undefined;
|
|
335
|
+
private tui: TUI | undefined;
|
|
336
|
+
private closed = false;
|
|
337
|
+
|
|
338
|
+
constructor(role: RefineOverlayRole, laneIds: Array<{ id: string; label?: string }>, onCancel: () => void) {
|
|
339
|
+
this.role = role;
|
|
340
|
+
this.onCancel = onCancel;
|
|
341
|
+
this.lanes = laneIds.map((lane) => ({
|
|
342
|
+
id: lane.id,
|
|
343
|
+
label: lane.label ?? lane.id,
|
|
344
|
+
status: "queued",
|
|
345
|
+
phase: "queued",
|
|
346
|
+
detail: "",
|
|
347
|
+
transcript: [],
|
|
348
|
+
currentTurnIndex: 0,
|
|
349
|
+
scrollOffset: 0,
|
|
350
|
+
followTranscript: true,
|
|
351
|
+
viewportHeight: 1,
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
open(ctx: RefineOverlayContext, modelLabel?: string): void {
|
|
356
|
+
if (!ctx.hasUI || this.overlayPromise) return;
|
|
357
|
+
|
|
358
|
+
this.overlayPromise = ctx.ui
|
|
359
|
+
.custom<void>(
|
|
360
|
+
(_tui, theme, _keybindings, done) => {
|
|
361
|
+
this.tui = _tui;
|
|
362
|
+
this.done = done;
|
|
363
|
+
this.component = new RefineOverlayComponent(theme, this.role, this.lanes, () => this.cancel(), _tui, modelLabel);
|
|
364
|
+
if (this.closed) done(undefined);
|
|
365
|
+
return this.component;
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
overlay: true,
|
|
369
|
+
overlayOptions: {
|
|
370
|
+
width: "78%",
|
|
371
|
+
minWidth: OVERLAY_MIN_WIDTH,
|
|
372
|
+
maxHeight: "78%",
|
|
373
|
+
anchor: "top-center",
|
|
374
|
+
margin: { top: 1, left: 2, right: 2 },
|
|
375
|
+
},
|
|
376
|
+
onHandle: (handle) => {
|
|
377
|
+
this.handle = handle;
|
|
378
|
+
handle.focus();
|
|
379
|
+
if (this.closed) handle.hide();
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
)
|
|
383
|
+
.then(() => undefined)
|
|
384
|
+
.catch(() => undefined);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
update(laneId: string, event: SubagentProgressEvent): void {
|
|
388
|
+
if (this.closed) return;
|
|
389
|
+
const lane = this.lanes.find((candidate) => candidate.id === laneId);
|
|
390
|
+
if (!lane) return;
|
|
391
|
+
applyRefineProgress(lane, event);
|
|
392
|
+
this.tui?.requestRender();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
complete(laneId: string, result: SubagentResult): void {
|
|
396
|
+
if (this.closed) return;
|
|
397
|
+
const lane = this.lanes.find((candidate) => candidate.id === laneId);
|
|
398
|
+
if (lane) {
|
|
399
|
+
applyRefineResult(lane, result);
|
|
400
|
+
this.tui?.requestRender();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
cancel(): void {
|
|
405
|
+
// Esc only closes the overlay; the refiner child keeps running to natural
|
|
406
|
+
// completion and its result still flows through the tool result path.
|
|
407
|
+
void this.close();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async close(): Promise<void> {
|
|
411
|
+
if (!this.closed) {
|
|
412
|
+
this.closed = true;
|
|
413
|
+
this.component?.dispose();
|
|
414
|
+
if (this.done) this.done(undefined);
|
|
415
|
+
else this.handle?.hide();
|
|
416
|
+
this.component = undefined;
|
|
417
|
+
this.tui = undefined;
|
|
418
|
+
}
|
|
419
|
+
await this.overlayPromise;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
isClosed(): boolean {
|
|
423
|
+
return this.closed;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function refineOverlayContext(ctx: ExtensionContext | ExtensionCommandContext): RefineOverlayContext {
|
|
429
|
+
return { ui: ctx.ui, hasUI: ctx.hasUI };
|
|
430
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -34,21 +34,18 @@ 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;
|
|
46
|
+
/** null = never asked; the plans tool surfaces a hint so the agent asks once. */
|
|
47
|
+
graph_enabled: boolean | null;
|
|
48
|
+
graph_enabled_updated_at: string | null;
|
|
52
49
|
}
|
|
53
50
|
|
|
54
51
|
const DEFAULT_ARTIFACT_ROOT = "./docs/pi-plans";
|
|
@@ -61,6 +58,13 @@ function normalizeArtifactRoot(config: PlansConfig): PlansConfig {
|
|
|
61
58
|
return config;
|
|
62
59
|
}
|
|
63
60
|
|
|
61
|
+
type LegacyPlansConfig = PlansConfig & { execution?: unknown };
|
|
62
|
+
|
|
63
|
+
function normalizeLegacyExecutionConfig(config: LegacyPlansConfig): PlansConfig {
|
|
64
|
+
const { execution: _execution, ...rest } = config;
|
|
65
|
+
return rest as PlansConfig;
|
|
66
|
+
}
|
|
67
|
+
|
|
64
68
|
export const DEFAULT_CONFIG: PlansConfig = {
|
|
65
69
|
schema: 1,
|
|
66
70
|
language: { tag: null, source: "unset", updated_at: null },
|
|
@@ -76,14 +80,11 @@ export const DEFAULT_CONFIG: PlansConfig = {
|
|
|
76
80
|
name_prefix: "pi-plans-criticizer",
|
|
77
81
|
confirmed_at: null,
|
|
78
82
|
},
|
|
79
|
-
execution: {
|
|
80
|
-
model_selector: null,
|
|
81
|
-
source: "unset",
|
|
82
|
-
updated_at: null,
|
|
83
|
-
},
|
|
84
83
|
artifact_root: DEFAULT_ARTIFACT_ROOT,
|
|
85
84
|
artifact_root_source: "unset",
|
|
86
85
|
artifact_root_updated_at: null,
|
|
86
|
+
graph_enabled: null,
|
|
87
|
+
graph_enabled_updated_at: null,
|
|
87
88
|
};
|
|
88
89
|
|
|
89
90
|
export const VALID_ROLE_MODES = new Set(["delegated-subagent", "current-session"]);
|
|
@@ -268,7 +269,8 @@ export function loadConfig(stateRoot: string): PlansConfig {
|
|
|
268
269
|
} catch (error) {
|
|
269
270
|
throw new StateError(`invalid config.json: ${(error as Error).message}`);
|
|
270
271
|
}
|
|
271
|
-
|
|
272
|
+
const merged = deepMergeDefaults(data as LegacyPlansConfig, DEFAULT_CONFIG as LegacyPlansConfig);
|
|
273
|
+
return normalizeLegacyExecutionConfig(normalizeArtifactRoot(merged as PlansConfig));
|
|
272
274
|
}
|
|
273
275
|
|
|
274
276
|
function noticeIfSubdir(workdir: string, notices: string[]): void {
|
|
@@ -328,18 +330,10 @@ export function setArtifactRoot(workdir: string, artifactRoot: string, source: "
|
|
|
328
330
|
return { config, stateRoot, notices };
|
|
329
331
|
}
|
|
330
332
|
|
|
331
|
-
export
|
|
332
|
-
modelSelector?: string;
|
|
333
|
-
source: "user" | "auto";
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
export function setExecutionModel(workdir: string, options: SetExecutionModelOptions): EnsureResult {
|
|
333
|
+
export function setGraphEnabled(workdir: string, enabled: boolean): EnsureResult {
|
|
337
334
|
const { config, stateRoot, notices } = ensureState(workdir);
|
|
338
|
-
config.
|
|
339
|
-
|
|
340
|
-
source: options.source,
|
|
341
|
-
updated_at: utcNow(),
|
|
342
|
-
};
|
|
335
|
+
config.graph_enabled = enabled;
|
|
336
|
+
config.graph_enabled_updated_at = utcNow();
|
|
343
337
|
atomicWriteJson(path.join(stateRoot, "config.json"), config);
|
|
344
338
|
return { config, stateRoot, notices };
|
|
345
339
|
}
|
|
@@ -372,8 +366,13 @@ export function setRole(workdir: string, options: SetRoleOptions): EnsureResult
|
|
|
372
366
|
return { config, stateRoot, notices };
|
|
373
367
|
}
|
|
374
368
|
|
|
375
|
-
|
|
376
|
-
|
|
369
|
+
export function updateConfig(workdir: string, updater: (config: PlansConfig) => PlansConfig): EnsureResult {
|
|
370
|
+
const { config, stateRoot, notices } = ensureState(workdir);
|
|
371
|
+
const next = updater(structuredClone(config));
|
|
372
|
+
atomicWriteJson(path.join(stateRoot, "config.json"), next);
|
|
373
|
+
return { config: next, stateRoot, notices };
|
|
374
|
+
}
|
|
375
|
+
|
|
377
376
|
// ---------------------------------------------------------------------------
|
|
378
377
|
|
|
379
378
|
const SLUG_RE = /[^a-z0-9]+/g;
|
|
@@ -520,7 +519,3 @@ export function setRunStatus(workdir: string, runId: string, status: string): Ru
|
|
|
520
519
|
atomicWriteJson(runPath, run);
|
|
521
520
|
return run;
|
|
522
521
|
}
|
|
523
|
-
|
|
524
|
-
export function refsCacheDir(): string {
|
|
525
|
-
return path.join(os.homedir(), ".cache", "pi-plans", "refs");
|
|
526
|
-
}
|