pi-plans 0.2.0 → 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 +74 -21
- package/index.ts +115 -9
- package/package.json +7 -1
- package/references/pi-planning-workflow.md +18 -3
- package/references/state-and-config.md +34 -2
- package/scripts/validate.ts +4 -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 +1125 -371
- package/src/config-command.ts +326 -0
- package/src/exec.ts +356 -686
- package/src/refine-prompts.ts +50 -0
- package/src/refine-ui-helpers.ts +71 -18
- package/src/refine-ui-state.ts +87 -21
- package/src/refine-ui.ts +210 -102
- package/src/state.ts +19 -6
- package/src/subagent.ts +163 -61
- package/tests/ask-choice.test.ts +263 -0
- package/tests/autocomplete.test.ts +6 -1
- 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 +371 -57
- package/tests/config-command.test.ts +255 -0
- package/tests/exec.test.ts +665 -241
- 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/refine-prompts.test.ts +67 -2
- package/tests/refine-ui.test.ts +337 -72
- package/tests/subagent.test.ts +26 -20
- package/tools/ask-choice.ts +158 -11
- package/tools/code-graph.ts +254 -0
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +84 -1
- package/tools/refine.ts +61 -15
package/src/refine-ui.ts
CHANGED
|
@@ -7,6 +7,15 @@ import {
|
|
|
7
7
|
visibleWidth as localVisibleWidth,
|
|
8
8
|
wrapTextWithAnsi as localWrapTextWithAnsi,
|
|
9
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";
|
|
10
19
|
|
|
11
20
|
let _piTui: typeof import("@earendil-works/pi-tui") | undefined;
|
|
12
21
|
let _piTuiAttempted = false;
|
|
@@ -24,7 +33,7 @@ async function loadPiTui(): Promise<typeof import("@earendil-works/pi-tui") | un
|
|
|
24
33
|
|
|
25
34
|
void loadPiTui();
|
|
26
35
|
|
|
27
|
-
function truncateToWidth(text: string, width: number, ellipsis
|
|
36
|
+
function truncateToWidth(text: string, width: number, ellipsis = ""): string {
|
|
28
37
|
try {
|
|
29
38
|
return _piTui ? _piTui.truncateToWidth(text, width, ellipsis) : localTruncateToWidth(text, width, ellipsis);
|
|
30
39
|
} catch {
|
|
@@ -48,6 +57,29 @@ function wrapTextWithAnsi(text: string, width: number): string[] {
|
|
|
48
57
|
}
|
|
49
58
|
}
|
|
50
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
|
+
|
|
51
83
|
function handleEscape(data: string): boolean {
|
|
52
84
|
try {
|
|
53
85
|
return _piTui ? _piTui.matchesKey(data, "escape") : localMatchesEscape(data);
|
|
@@ -55,22 +87,15 @@ function handleEscape(data: string): boolean {
|
|
|
55
87
|
return localMatchesEscape(data);
|
|
56
88
|
}
|
|
57
89
|
}
|
|
58
|
-
import {
|
|
59
|
-
applyRefineProgress,
|
|
60
|
-
applyRefineResult,
|
|
61
|
-
statusLabel,
|
|
62
|
-
type RefineLaneState,
|
|
63
|
-
type RefineOverlayRole,
|
|
64
|
-
} from "./refine-ui-state.ts";
|
|
65
90
|
|
|
66
|
-
export type { RefineLaneState, RefineLaneStatus, RefineOverlayRole } from "./refine-ui-state.ts";
|
|
91
|
+
export type { RefineLaneState, RefineLaneStatus, RefineOverlayRole, RefineTranscriptEntry, RefineTranscriptEntryType } from "./refine-ui-state.ts";
|
|
67
92
|
|
|
68
|
-
const OVERLAY_MIN_WIDTH =
|
|
69
|
-
const OVERLAY_MAX_WIDTH = 96;
|
|
93
|
+
const OVERLAY_MIN_WIDTH = 72;
|
|
70
94
|
const OVERLAY_MIN_HEIGHT = 18;
|
|
71
95
|
const OVERLAY_MAX_HEIGHT = 32;
|
|
72
96
|
const OVERLAY_HEIGHT_RATIO = 0.78;
|
|
73
|
-
const
|
|
97
|
+
const STREAMING_PREVIEW_LINES = 3;
|
|
98
|
+
const OVERLAY_CHROME_LINES = 4; // top border + title row + footer row + bottom border
|
|
74
99
|
|
|
75
100
|
export function getTerminalRowCount(): number {
|
|
76
101
|
const raw = (process.stdout as { rows?: number }).rows;
|
|
@@ -83,55 +108,30 @@ export function pickOverlayHeight(): number {
|
|
|
83
108
|
return Math.min(OVERLAY_MAX_HEIGHT, target);
|
|
84
109
|
}
|
|
85
110
|
|
|
111
|
+
/** The overlay manager resolves the 78%/72-column size; render accepts its resolved width. */
|
|
86
112
|
export function pickOverlayWidth(width: number): number {
|
|
87
|
-
|
|
88
|
-
return Math.max(OVERLAY_MIN_WIDTH, Math.min(OVERLAY_MAX_WIDTH, target));
|
|
113
|
+
return Math.max(24, width);
|
|
89
114
|
}
|
|
90
115
|
|
|
91
|
-
|
|
92
|
-
|
|
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";
|
|
116
|
+
function borderColor(theme: Theme, selected = false): ThemeColor {
|
|
117
|
+
return selected ? "borderAccent" : "border";
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
function renderRow(theme: Theme, content: string, innerWidth: number): string {
|
|
117
|
-
const truncated = truncateToWidth(content, innerWidth, "
|
|
120
|
+
function renderRow(theme: Theme, content: string, innerWidth: number, selected = false): string {
|
|
121
|
+
const truncated = truncateToWidth(content, innerWidth, "");
|
|
118
122
|
const width = visibleWidth(truncated);
|
|
119
123
|
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)}┤`);
|
|
124
|
+
return `${theme.fg(borderColor(theme, selected), "│")}${truncated}${filler}${theme.fg(borderColor(theme, selected), "│")}`;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
function renderBorderLine(theme: Theme, innerWidth: number, edge: "top" | "bottom"): string {
|
|
128
|
-
const
|
|
127
|
+
function renderBorderLine(theme: Theme, innerWidth: number, edge: "top" | "bottom", selected = false): string {
|
|
128
|
+
const left = edge === "top" ? "┌" : "└";
|
|
129
129
|
const right = edge === "top" ? "┐" : "┘";
|
|
130
|
-
return theme.fg(borderColor(theme), `${
|
|
130
|
+
return theme.fg(borderColor(theme, selected), `${left}${"─".repeat(innerWidth)}${right}`);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
function fitLine(line: string, width: number): string {
|
|
134
|
-
return visibleWidth(line) > width ? truncateToWidth(line, width, "
|
|
134
|
+
return visibleWidth(line) > width ? truncateToWidth(line, width, "") : line;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
function laneColor(status: RefineLaneState["status"]): ThemeColor {
|
|
@@ -144,74 +144,177 @@ function laneColor(status: RefineLaneState["status"]): ThemeColor {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
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
|
+
|
|
147
212
|
export class RefineOverlayComponent implements Component {
|
|
148
213
|
private readonly theme: Theme;
|
|
149
214
|
private readonly role: RefineOverlayRole;
|
|
150
215
|
private readonly lanes: RefineLaneState[];
|
|
151
216
|
private readonly onCancel: () => void;
|
|
217
|
+
private readonly tui?: TUI;
|
|
218
|
+
private readonly modelLabel?: string;
|
|
219
|
+
private selectedLane = 0;
|
|
220
|
+
private disposed = false;
|
|
152
221
|
|
|
153
|
-
constructor(theme: Theme, role: RefineOverlayRole, lanes: RefineLaneState[], onCancel: () => void) {
|
|
222
|
+
constructor(theme: Theme, role: RefineOverlayRole, lanes: RefineLaneState[], onCancel: () => void, tui?: TUI, modelLabel?: string) {
|
|
154
223
|
this.theme = theme;
|
|
155
224
|
this.role = role;
|
|
156
225
|
this.lanes = lanes;
|
|
157
226
|
this.onCancel = onCancel;
|
|
227
|
+
this.tui = tui;
|
|
228
|
+
this.modelLabel = modelLabel;
|
|
229
|
+
this.tui?.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
|
|
158
230
|
}
|
|
159
231
|
|
|
160
232
|
handleInput(data: string): void {
|
|
161
|
-
if (
|
|
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");
|
|
162
268
|
}
|
|
163
269
|
|
|
164
270
|
render(width: number): string[] {
|
|
165
271
|
const dialogWidth = pickOverlayWidth(width);
|
|
166
|
-
const innerWidth = Math.max(
|
|
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
|
-
|
|
272
|
+
const innerWidth = Math.max(22, dialogWidth - 2);
|
|
195
273
|
const dialogHeight = pickOverlayHeight();
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
const summaryText = hiddenAbove > 0
|
|
200
|
-
? `${summary} · ↑${hiddenAbove}`
|
|
201
|
-
: summary;
|
|
202
|
-
|
|
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);
|
|
203
277
|
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"));
|
|
278
|
+
lines.push(renderRow(this.theme, this.theme.fg("accent", this.theme.bold(title)), innerWidth, false));
|
|
211
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"));
|
|
212
289
|
return lines.map((line) => fitLine(line, width));
|
|
213
290
|
}
|
|
214
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
|
+
|
|
215
318
|
invalidate(): void {}
|
|
216
319
|
}
|
|
217
320
|
|
|
@@ -220,10 +323,7 @@ export interface RefineOverlayContext {
|
|
|
220
323
|
hasUI: boolean;
|
|
221
324
|
}
|
|
222
325
|
|
|
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
|
-
*/
|
|
326
|
+
/** Owns the public overlay and exposes progress updates without owning child processes. */
|
|
227
327
|
export class RefineOverlayController {
|
|
228
328
|
readonly lanes: RefineLaneState[];
|
|
229
329
|
private readonly role: RefineOverlayRole;
|
|
@@ -244,10 +344,15 @@ export class RefineOverlayController {
|
|
|
244
344
|
status: "queued",
|
|
245
345
|
phase: "queued",
|
|
246
346
|
detail: "",
|
|
347
|
+
transcript: [],
|
|
348
|
+
currentTurnIndex: 0,
|
|
349
|
+
scrollOffset: 0,
|
|
350
|
+
followTranscript: true,
|
|
351
|
+
viewportHeight: 1,
|
|
247
352
|
}));
|
|
248
353
|
}
|
|
249
354
|
|
|
250
|
-
open(ctx: RefineOverlayContext): void {
|
|
355
|
+
open(ctx: RefineOverlayContext, modelLabel?: string): void {
|
|
251
356
|
if (!ctx.hasUI || this.overlayPromise) return;
|
|
252
357
|
|
|
253
358
|
this.overlayPromise = ctx.ui
|
|
@@ -255,7 +360,7 @@ export class RefineOverlayController {
|
|
|
255
360
|
(_tui, theme, _keybindings, done) => {
|
|
256
361
|
this.tui = _tui;
|
|
257
362
|
this.done = done;
|
|
258
|
-
this.component = new RefineOverlayComponent(theme, this.role, this.lanes, () => this.cancel());
|
|
363
|
+
this.component = new RefineOverlayComponent(theme, this.role, this.lanes, () => this.cancel(), _tui, modelLabel);
|
|
259
364
|
if (this.closed) done(undefined);
|
|
260
365
|
return this.component;
|
|
261
366
|
},
|
|
@@ -297,17 +402,19 @@ export class RefineOverlayController {
|
|
|
297
402
|
}
|
|
298
403
|
|
|
299
404
|
cancel(): void {
|
|
300
|
-
|
|
301
|
-
|
|
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.
|
|
302
407
|
void this.close();
|
|
303
408
|
}
|
|
304
409
|
|
|
305
410
|
async close(): Promise<void> {
|
|
306
411
|
if (!this.closed) {
|
|
307
412
|
this.closed = true;
|
|
413
|
+
this.component?.dispose();
|
|
308
414
|
if (this.done) this.done(undefined);
|
|
309
415
|
else this.handle?.hide();
|
|
310
416
|
this.component = undefined;
|
|
417
|
+
this.tui = undefined;
|
|
311
418
|
}
|
|
312
419
|
await this.overlayPromise;
|
|
313
420
|
}
|
|
@@ -315,8 +422,9 @@ export class RefineOverlayController {
|
|
|
315
422
|
isClosed(): boolean {
|
|
316
423
|
return this.closed;
|
|
317
424
|
}
|
|
425
|
+
|
|
318
426
|
}
|
|
319
427
|
|
|
320
428
|
export function refineOverlayContext(ctx: ExtensionContext | ExtensionCommandContext): RefineOverlayContext {
|
|
321
429
|
return { ui: ctx.ui, hasUI: ctx.hasUI };
|
|
322
|
-
}
|
|
430
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -43,6 +43,9 @@ export interface PlansConfig {
|
|
|
43
43
|
artifact_root: string;
|
|
44
44
|
artifact_root_source: SettingSource;
|
|
45
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;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
const DEFAULT_ARTIFACT_ROOT = "./docs/pi-plans";
|
|
@@ -80,6 +83,8 @@ export const DEFAULT_CONFIG: PlansConfig = {
|
|
|
80
83
|
artifact_root: DEFAULT_ARTIFACT_ROOT,
|
|
81
84
|
artifact_root_source: "unset",
|
|
82
85
|
artifact_root_updated_at: null,
|
|
86
|
+
graph_enabled: null,
|
|
87
|
+
graph_enabled_updated_at: null,
|
|
83
88
|
};
|
|
84
89
|
|
|
85
90
|
export const VALID_ROLE_MODES = new Set(["delegated-subagent", "current-session"]);
|
|
@@ -325,6 +330,13 @@ export function setArtifactRoot(workdir: string, artifactRoot: string, source: "
|
|
|
325
330
|
return { config, stateRoot, notices };
|
|
326
331
|
}
|
|
327
332
|
|
|
333
|
+
export function setGraphEnabled(workdir: string, enabled: boolean): EnsureResult {
|
|
334
|
+
const { config, stateRoot, notices } = ensureState(workdir);
|
|
335
|
+
config.graph_enabled = enabled;
|
|
336
|
+
config.graph_enabled_updated_at = utcNow();
|
|
337
|
+
atomicWriteJson(path.join(stateRoot, "config.json"), config);
|
|
338
|
+
return { config, stateRoot, notices };
|
|
339
|
+
}
|
|
328
340
|
|
|
329
341
|
export interface SetRoleOptions {
|
|
330
342
|
role: "reviewer" | "criticizer";
|
|
@@ -354,8 +366,13 @@ export function setRole(workdir: string, options: SetRoleOptions): EnsureResult
|
|
|
354
366
|
return { config, stateRoot, notices };
|
|
355
367
|
}
|
|
356
368
|
|
|
357
|
-
|
|
358
|
-
|
|
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
|
+
|
|
359
376
|
// ---------------------------------------------------------------------------
|
|
360
377
|
|
|
361
378
|
const SLUG_RE = /[^a-z0-9]+/g;
|
|
@@ -502,7 +519,3 @@ export function setRunStatus(workdir: string, runId: string, status: string): Ru
|
|
|
502
519
|
atomicWriteJson(runPath, run);
|
|
503
520
|
return run;
|
|
504
521
|
}
|
|
505
|
-
|
|
506
|
-
export function refsCacheDir(): string {
|
|
507
|
-
return path.join(os.homedir(), ".cache", "pi-plans", "refs");
|
|
508
|
-
}
|