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.
Files changed (65) hide show
  1. package/README.md +74 -21
  2. package/index.ts +115 -9
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +18 -3
  5. package/references/state-and-config.md +34 -2
  6. package/scripts/validate.ts +4 -0
  7. package/src/code-graph/commands.ts +437 -0
  8. package/src/code-graph/discovery.ts +118 -0
  9. package/src/code-graph/git.ts +108 -0
  10. package/src/code-graph/identity.ts +59 -0
  11. package/src/code-graph/indexer.ts +281 -0
  12. package/src/code-graph/materialize.ts +166 -0
  13. package/src/code-graph/mode.ts +28 -0
  14. package/src/code-graph/mutations.ts +160 -0
  15. package/src/code-graph/parser.ts +51 -0
  16. package/src/code-graph/parsers/javascript.ts +35 -0
  17. package/src/code-graph/parsers/python.ts +160 -0
  18. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  19. package/src/code-graph/paths.ts +85 -0
  20. package/src/code-graph/prompts.ts +18 -0
  21. package/src/code-graph/resolver.ts +69 -0
  22. package/src/code-graph/runtime.ts +158 -0
  23. package/src/code-graph/schema.ts +135 -0
  24. package/src/code-graph/screening.ts +82 -0
  25. package/src/code-graph/store.ts +278 -0
  26. package/src/code-graph/summary.ts +435 -0
  27. package/src/code-graph/types.ts +163 -0
  28. package/src/compaction.ts +1125 -371
  29. package/src/config-command.ts +326 -0
  30. package/src/exec.ts +356 -686
  31. package/src/refine-prompts.ts +50 -0
  32. package/src/refine-ui-helpers.ts +71 -18
  33. package/src/refine-ui-state.ts +87 -21
  34. package/src/refine-ui.ts +210 -102
  35. package/src/state.ts +19 -6
  36. package/src/subagent.ts +163 -61
  37. package/tests/ask-choice.test.ts +263 -0
  38. package/tests/autocomplete.test.ts +6 -1
  39. package/tests/code-graph-apply.test.ts +185 -0
  40. package/tests/code-graph-commands.test.ts +211 -0
  41. package/tests/code-graph-db.test.ts +166 -0
  42. package/tests/code-graph-discovery.test.ts +38 -0
  43. package/tests/code-graph-git.test.ts +94 -0
  44. package/tests/code-graph-index.test.ts +175 -0
  45. package/tests/code-graph-loop.e2e.test.ts +159 -0
  46. package/tests/code-graph-mutations.test.ts +117 -0
  47. package/tests/code-graph-parser.test.ts +85 -0
  48. package/tests/code-graph-rollback.test.ts +100 -0
  49. package/tests/code-graph-summary-batching.test.ts +518 -0
  50. package/tests/code-graph-summary.test.ts +148 -0
  51. package/tests/compaction.test.ts +371 -57
  52. package/tests/config-command.test.ts +255 -0
  53. package/tests/exec.test.ts +665 -241
  54. package/tests/fixtures/code-graph/sample.js +36 -0
  55. package/tests/fixtures/code-graph/sample.py +20 -0
  56. package/tests/fixtures/code-graph/sample.ts +15 -0
  57. package/tests/graph-aware-file-tools.test.ts +411 -0
  58. package/tests/refine-prompts.test.ts +67 -2
  59. package/tests/refine-ui.test.ts +337 -72
  60. package/tests/subagent.test.ts +26 -20
  61. package/tools/ask-choice.ts +158 -11
  62. package/tools/code-graph.ts +254 -0
  63. package/tools/graph-aware-file-tools.ts +392 -0
  64. package/tools/plans.ts +84 -1
  65. 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?: string): string {
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 = 44;
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 CHROME_LINES = 5; // top + title + (header rule + footer rule + hints)
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
- 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));
113
+ return Math.max(24, width);
89
114
  }
90
115
 
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";
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 side = edge === "top" ? "┌" : "└";
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), `${side}${"─".repeat(innerWidth)}${right}`);
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, "...") : line;
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 (handleEscape(data)) this.onCancel();
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(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
-
272
+ const innerWidth = Math.max(22, dialogWidth - 2);
195
273
  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
-
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
- if (this.closed) return;
301
- this.onCancel();
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
- // Runs
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
- }