pi-plans 0.2.0 → 0.3.1

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 (75) hide show
  1. package/README.md +90 -26
  2. package/agents/ref-analyst.md +18 -0
  3. package/index.ts +121 -9
  4. package/package.json +16 -1
  5. package/references/pi-planning-workflow.md +21 -6
  6. package/references/state-and-config.md +52 -5
  7. package/scripts/validate.ts +5 -0
  8. package/skills/plan-with-refs/SKILL.md +3 -3
  9. package/src/code-graph/commands.ts +483 -0
  10. package/src/code-graph/discovery.ts +118 -0
  11. package/src/code-graph/git.ts +108 -0
  12. package/src/code-graph/identity.ts +59 -0
  13. package/src/code-graph/indexer.ts +281 -0
  14. package/src/code-graph/materialize.ts +166 -0
  15. package/src/code-graph/mode.ts +28 -0
  16. package/src/code-graph/mutations.ts +160 -0
  17. package/src/code-graph/parser.ts +51 -0
  18. package/src/code-graph/parsers/javascript.ts +35 -0
  19. package/src/code-graph/parsers/python.ts +160 -0
  20. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  21. package/src/code-graph/paths.ts +85 -0
  22. package/src/code-graph/prompts.ts +18 -0
  23. package/src/code-graph/resolver.ts +69 -0
  24. package/src/code-graph/runtime.ts +158 -0
  25. package/src/code-graph/schema.ts +135 -0
  26. package/src/code-graph/screening.ts +82 -0
  27. package/src/code-graph/store.ts +278 -0
  28. package/src/code-graph/summary.ts +435 -0
  29. package/src/code-graph/types.ts +163 -0
  30. package/src/compaction.ts +1125 -371
  31. package/src/config-command.ts +361 -0
  32. package/src/exec.ts +508 -693
  33. package/src/guard.ts +14 -1
  34. package/src/refine-prompts.ts +109 -0
  35. package/src/refine-ui-helpers.ts +71 -18
  36. package/src/refine-ui-state.ts +88 -22
  37. package/src/refine-ui.ts +210 -102
  38. package/src/state.ts +36 -7
  39. package/src/subagent.ts +164 -61
  40. package/src/termination-prompt.ts +22 -0
  41. package/tests/analyze-refs.test.ts +265 -0
  42. package/tests/ask-choice.test.ts +264 -0
  43. package/tests/autocomplete.test.ts +6 -1
  44. package/tests/code-graph-apply-action.test.ts +173 -0
  45. package/tests/code-graph-apply.test.ts +185 -0
  46. package/tests/code-graph-commands.test.ts +211 -0
  47. package/tests/code-graph-db.test.ts +166 -0
  48. package/tests/code-graph-discovery.test.ts +38 -0
  49. package/tests/code-graph-git.test.ts +94 -0
  50. package/tests/code-graph-index.test.ts +175 -0
  51. package/tests/code-graph-loop.e2e.test.ts +159 -0
  52. package/tests/code-graph-mutations.test.ts +117 -0
  53. package/tests/code-graph-parser.test.ts +85 -0
  54. package/tests/code-graph-rollback.test.ts +100 -0
  55. package/tests/code-graph-summary-batching.test.ts +518 -0
  56. package/tests/code-graph-summary.test.ts +148 -0
  57. package/tests/compaction.test.ts +371 -57
  58. package/tests/config-command.test.ts +263 -0
  59. package/tests/exec.test.ts +808 -241
  60. package/tests/fixtures/code-graph/sample.js +36 -0
  61. package/tests/fixtures/code-graph/sample.py +20 -0
  62. package/tests/fixtures/code-graph/sample.ts +15 -0
  63. package/tests/graph-aware-file-tools.test.ts +411 -0
  64. package/tests/guard.test.ts +27 -1
  65. package/tests/plans.test.ts +10 -0
  66. package/tests/refine-prompts.test.ts +101 -2
  67. package/tests/refine-ui.test.ts +371 -72
  68. package/tests/state.test.ts +32 -0
  69. package/tests/subagent.test.ts +48 -20
  70. package/tools/analyze-refs.ts +263 -0
  71. package/tools/ask-choice.ts +159 -11
  72. package/tools/code-graph.ts +277 -0
  73. package/tools/graph-aware-file-tools.ts +392 -0
  74. package/tools/plans.ts +97 -2
  75. package/tools/refine.ts +61 -15
@@ -2,29 +2,96 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
- import { RefineOverlayComponent, chunkDetailForOverlay } from "../src/refine-ui.ts";
5
+ import { RefineOverlayComponent, RefineOverlayController } from "../src/refine-ui.ts";
6
+ import { visibleWidth } from "../src/refine-ui-helpers.ts";
6
7
  import { applyRefineProgress, applyRefineResult, statusLabel, type RefineLaneState } from "../src/refine-ui-state.ts";
7
8
 
9
+ function lane(id = "lane-1", label = "reviewer-1", text = ""): RefineLaneState {
10
+ return {
11
+ id,
12
+ label,
13
+ status: "queued",
14
+ phase: "queued",
15
+ detail: "",
16
+ transcript: text ? [{ id: "0:content:0", type: "assistant-text", text, streaming: true }] : [],
17
+ currentTurnIndex: 0,
18
+ scrollOffset: 0,
19
+ followTranscript: true,
20
+ viewportHeight: 1,
21
+ };
22
+ }
23
+
24
+ function readyLane(id: string, label: string, text: string): RefineLaneState {
25
+ return {
26
+ ...lane(id, label),
27
+ status: "running",
28
+ phase: "responding",
29
+ transcript: [{ id: "0:content:0", type: "assistant-text", text, streaming: false }],
30
+ };
31
+ }
32
+
33
+ function streamingToolLane(id: string, label: string, text: string): RefineLaneState {
34
+ return {
35
+ ...lane(id, label),
36
+ status: "running",
37
+ phase: "tool result",
38
+ transcript: [{ id: "0:tool:0", type: "tool-result", text, streaming: true, toolName: "read", isError: false }],
39
+ };
40
+ }
8
41
 
9
- function lane(): RefineLaneState {
10
- return { id: "lane-1", label: "reviewer-1", status: "queued", phase: "queued", detail: "" };
42
+ function streamingAssistantLane(id: string, label: string, text: string): RefineLaneState {
43
+ return {
44
+ ...lane(id, label),
45
+ status: "running",
46
+ phase: "responding",
47
+ transcript: [{ id: "0:content:0", type: "assistant-text", text, streaming: true }],
48
+ };
11
49
  }
12
50
 
51
+ const fakeTheme = {
52
+ fg: (_color: string, text: string) => text,
53
+ bold: (text: string) => text,
54
+ } as never;
55
+
13
56
  describe("refine overlay state", () => {
14
- it("tracks tool progress and bounds the displayed detail", () => {
57
+ it("merges streaming deltas and keeps complete tool output without clipping it", () => {
15
58
  const state = lane();
59
+ applyRefineProgress(state, { type: "turn", phase: "start", turnIndex: 1 });
16
60
  applyRefineProgress(state, {
17
- type: "tool",
61
+ type: "transcript",
18
62
  phase: "update",
63
+ entryType: "assistant-text",
64
+ key: "content:0",
65
+ text: "first ",
66
+ update: "append",
67
+ streaming: true,
68
+ });
69
+ applyRefineProgress(state, {
70
+ type: "transcript",
71
+ phase: "update",
72
+ entryType: "assistant-text",
73
+ key: "content:0",
74
+ text: "second",
75
+ update: "append",
76
+ streaming: true,
77
+ });
78
+ const output = "x".repeat(500);
79
+ applyRefineProgress(state, {
80
+ type: "transcript",
81
+ phase: "end",
82
+ entryType: "tool-result",
83
+ key: "tool:call-1",
84
+ text: output,
85
+ update: "replace",
86
+ streaming: false,
19
87
  toolCallId: "call-1",
20
88
  toolName: "read",
21
- detail: "x".repeat(500),
89
+ isError: false,
22
90
  });
23
91
 
24
- assert.equal(state.status, "running");
25
- assert.equal(state.phase, "tool: read");
26
- assert.ok(state.detail.length <= 180);
27
- assert.match(state.detail, /\.\.\.$/);
92
+ assert.equal(state.transcript.find((entry) => entry.type === "assistant-text")?.text, "first second");
93
+ assert.equal(state.transcript.find((entry) => entry.type === "tool-result")?.text, output);
94
+ assert.equal(state.detail, output);
28
95
  });
29
96
 
30
97
  it("keeps terminal lane states stable after completion", () => {
@@ -32,13 +99,9 @@ describe("refine overlay state", () => {
32
99
  applyRefineResult(state, { ok: true, output: "final conclusion", stderr: "", turns: 1 });
33
100
  applyRefineProgress(state, { type: "turn", phase: "start" });
34
101
 
35
- assert.deepEqual(state, {
36
- id: "lane-1",
37
- label: "reviewer-1",
38
- status: "complete",
39
- phase: "complete",
40
- detail: "final conclusion",
41
- });
102
+ assert.equal(state.status, "complete");
103
+ assert.equal(state.phase, "complete");
104
+ assert.equal(state.transcript.at(-1)?.text, "final conclusion");
42
105
  });
43
106
 
44
107
  it("distinguishes cancelled and timed out child results", () => {
@@ -55,73 +118,309 @@ describe("refine overlay state", () => {
55
118
  });
56
119
  });
57
120
 
58
-
59
121
  describe("refine overlay viewport", () => {
60
- const fakeTheme = {
61
- fg: (_color: string, text: string) => text,
62
- bold: (text: string) => text,
63
- } as never;
122
+ it("renders the resolved overlay width without a legacy 96-column cap", () => {
123
+ const component = new RefineOverlayComponent(fakeTheme, "reviewer", [readyLane("lane-1", "reviewer-1", "output")], () => {});
124
+ const lines = component.render(120);
125
+ assert.ok(lines.some((line) => line.startsWith("┌")));
126
+ assert.ok(lines.every((line) => visibleWidth(line) <= 120));
127
+ assert.ok(lines.some((line) => line.includes("output")));
128
+ });
129
+
130
+ it("renders the Refs title for the refs role and keeps legacy titles distinct", () => {
131
+ const refs = new RefineOverlayComponent(
132
+ fakeTheme,
133
+ "refs",
134
+ [readyLane("ref-1", "ref-1", "analysis output")],
135
+ () => {},
136
+ undefined,
137
+ "zai/glm-5.3-flash:high",
138
+ );
139
+ const refLines = refs.render(120);
140
+ assert.ok(refLines.some((line) => line.includes("Refs (zai/glm-5.3-flash:high)")));
141
+ assert.ok(refLines.every((line) => !line.includes("Criticizer") && !line.includes("Reviewer")));
142
+
143
+ const reviewer = new RefineOverlayComponent(
144
+ fakeTheme,
145
+ "reviewer",
146
+ [readyLane("lane-1", "general", "ok")],
147
+ () => {},
148
+ undefined,
149
+ "m1",
150
+ );
151
+ assert.ok(reviewer.render(120).some((line) => line.includes("Reviewer (m1)")));
152
+
153
+ const criticizer = new RefineOverlayComponent(
154
+ fakeTheme,
155
+ "criticizer",
156
+ [readyLane("lane-1", "criticizer", "ok")],
157
+ () => {},
158
+ undefined,
159
+ "m2",
160
+ );
161
+ assert.ok(criticizer.render(120).some((line) => line.includes("Criticizer (m2)")));
162
+ });
163
+
164
+ it("renders model-aware titles and footer hints", () => {
165
+ const lanes = [
166
+ readyLane("lane-1", "correctness", "correctness output"),
167
+ readyLane("lane-2", "ordering", "ordering output"),
168
+ readyLane("lane-3", "verification", "verification output"),
169
+ ];
170
+ const component = new RefineOverlayComponent(
171
+ fakeTheme,
172
+ "reviewer",
173
+ lanes,
174
+ () => {},
175
+ undefined,
176
+ "zai/glm-5.3-flash:high",
177
+ );
178
+ const lines = component.render(120);
179
+ assert.ok(lines.some((line) => line.includes("Reviewer (zai/glm-5.3-flash:high)")));
180
+ assert.ok(lines.some((line) => line.includes("Esc 关闭")));
181
+ assert.ok(lines.some((line) => line.includes("Tab & Shift + Tab")));
182
+ });
183
+
184
+ it("renders only the last three wrapped lines for streaming transcript previews", () => {
185
+ const linesText = ["line 1", "line 2", "line 3", "line 4", "line 5"].join("\n");
186
+ const component = new RefineOverlayComponent(
187
+ fakeTheme,
188
+ "criticizer",
189
+ [streamingToolLane("lane-1", "criticizer", linesText)],
190
+ () => {},
191
+ undefined,
192
+ "singularity-gpt/gpt-5.5:xhigh",
193
+ );
194
+ const lines = component.render(90);
195
+ assert.ok(lines.some((line) => line.includes("Criticizer (singularity-gpt/gpt-5.5:xhigh)")));
196
+ assert.ok(lines.some((line) => line.includes("…")));
197
+ assert.ok(lines.some((line) => line.includes("line 3")));
198
+ assert.ok(lines.some((line) => line.includes("line 4")));
199
+ assert.ok(lines.some((line) => line.includes("line 5")));
200
+ assert.equal(lines.some((line) => line.includes("line 1")), false);
201
+ assert.equal(lines.some((line) => line.includes("line 2")), false);
202
+ });
203
+
204
+ it("renders only the last three wrapped lines for streaming assistant previews", () => {
205
+ const linesText = ["line 1", "line 2", "line 3", "line 4", "line 5"].join("\n");
206
+ const component = new RefineOverlayComponent(
207
+ fakeTheme,
208
+ "reviewer",
209
+ [streamingAssistantLane("lane-1", "reviewer", linesText)],
210
+ () => {},
211
+ undefined,
212
+ "zai/glm-5.3-flash:high",
213
+ );
214
+ const lines = component.render(90);
215
+ assert.ok(lines.some((line) => line.includes("Reviewer (zai/glm-5.3-flash:high)")));
216
+ assert.ok(lines.some((line) => line.includes("…")));
217
+ assert.ok(lines.some((line) => line.includes("line 3")));
218
+ assert.ok(lines.some((line) => line.includes("line 4")));
219
+ assert.ok(lines.some((line) => line.includes("line 5")));
220
+ assert.equal(lines.some((line) => line.includes("line 1")), false);
221
+ assert.equal(lines.some((line) => line.includes("line 2")), false);
222
+ });
64
223
 
65
- function makeLane(): RefineLaneState {
66
- return { id: "lane-1", label: "reviewer-1", status: "running", phase: "responding", detail: "" };
67
- }
68
-
69
- function makeLanes(detail: string): RefineLaneState[] {
70
- return [{ id: "lane-1", label: "reviewer-1", status: "running", phase: "responding", detail }];
71
- }
72
-
73
- it("clamps overlay width between min and max regardless of host width", () => {
74
- const component = new RefineOverlayComponent(fakeTheme, "reviewer", makeLanes(""), () => {});
75
- const narrow = component.render(20);
76
- const wide = component.render(240);
77
- assert.ok(narrow.some((line) => line.startsWith("┌")));
78
- assert.ok(wide.some((line) => line.startsWith("┌")));
79
- assert.ok(narrow.every((line) => line.length <= 20));
80
- });
81
-
82
- it("draws top and bottom borders with rounded corners and frame lines", () => {
83
- const component = new RefineOverlayComponent(fakeTheme, "criticizer", makeLanes(""), () => {});
84
- const lines = component.render(80);
85
- const first = lines[0] ?? "";
86
- const last = lines.at(-1) ?? "";
87
- assert.match(first, /^┌─+┐$/);
88
- assert.match(last, /^└─+┘$/);
89
- assert.ok(lines.some((line) => line.startsWith("├") && line.endsWith("┤")));
90
- assert.ok(lines.some((line) => line.startsWith("│") && line.endsWith("│")));
91
- });
92
-
93
- it("chunk sentence-sized detail lines so streaming fragments stay readable", () => {
94
- const lines = chunkDetailForOverlay(
95
- "Reading the file. Found three findings. The fourth line is unrelated.",
96
- 40,
97
- 6,
224
+ it("renders a single pane with pi-btw-style transcript badges", () => {
225
+ const component = new RefineOverlayComponent(
226
+ fakeTheme,
227
+ "criticizer",
228
+ [readyLane("lane-1", "criticizer", "assistant output")],
229
+ () => {},
98
230
  );
99
- assert.ok(lines.length >= 3);
100
- assert.ok(lines.every((line) => line.length <= 40));
101
- assert.match(lines[0] ?? "", /Reading the file\./);
231
+ const lines = component.render(90);
232
+ assert.equal(lines.filter((line) => line.startsWith("┌")).length, 2);
233
+ assert.equal(lines.filter((line) => line.startsWith("")).length, 2);
234
+ assert.ok(lines.some((line) => line.includes("assistant")));
235
+ assert.ok(lines.some((line) => line.includes("assistant output")));
236
+ });
237
+
238
+ it("renders three vertical equal-height independent panes", () => {
239
+ const lanes = [
240
+ readyLane("lane-1", "correctness", "correctness output"),
241
+ readyLane("lane-2", "ordering", "ordering output"),
242
+ readyLane("lane-3", "verification", "verification output"),
243
+ ];
244
+ const component = new RefineOverlayComponent(fakeTheme, "reviewer", lanes, () => {});
245
+ const lines = component.render(120);
246
+ assert.equal(lines.filter((line) => line.startsWith("┌")).length, 4);
247
+ assert.equal(lines.filter((line) => line.startsWith("└")).length, 4);
248
+ assert.ok(lines.some((line) => line.includes("correctness")));
249
+ assert.ok(lines.some((line) => line.includes("ordering")));
250
+ assert.ok(lines.some((line) => line.includes("verification")));
251
+ const topIndexes = lines.flatMap((line, index) => line.startsWith("┌") ? [index] : []);
252
+ const bottomIndexes = lines.flatMap((line, index) => line.startsWith("└") ? [index] : []);
253
+ const paneHeights = topIndexes.slice(1).map((value, index) => bottomIndexes[index]! - value);
254
+ assert.ok(paneHeights.every((height) => height === paneHeights[0]));
102
255
  });
103
256
 
104
- it("limits chunk length and refuses to overflow the maxLines budget", () => {
105
- const text = Array.from({ length: 30 }, (_, i) => `Sentence number ${i + 1}.`).join(" ");
106
- const lines = chunkDetailForOverlay(text, 60, 3);
107
- assert.equal(lines.length, 3);
257
+ it("follows the bottom, pauses on upward scroll, and switches independently with Tab", () => {
258
+ const long = Array.from({ length: 30 }, (_, index) => `line-${index + 1}`).join("\n");
259
+ const lanes = [readyLane("lane-1", "one", long), readyLane("lane-2", "two", long)];
260
+ const component = new RefineOverlayComponent(fakeTheme, "reviewer", lanes, () => {});
261
+ component.render(100);
262
+ const initialOffset = lanes[0]!.scrollOffset;
263
+ assert.ok(initialOffset > 0);
264
+ component.handleInput("\x1b[A");
265
+ component.render(100);
266
+ assert.equal(lanes[0]!.followTranscript, false);
267
+ assert.ok(lanes[0]!.scrollOffset < initialOffset);
268
+ const laneOneOffset = lanes[1]!.scrollOffset;
269
+ component.handleInput("\t");
270
+ component.handleInput("\x1b[A");
271
+ component.render(100);
272
+ assert.ok(lanes[1]!.scrollOffset < laneOneOffset);
273
+ assert.equal(lanes[0]!.scrollOffset, initialOffset - 1);
108
274
  });
109
275
  });
110
276
 
111
277
  describe("refine overlay wiring", () => {
112
- it("uses named public overlays, guards lifecycle edges, and does not depend on pi-btw", () => {
278
+ it("uses pi-btw geometry, no input box, and no pi-btw runtime dependency", () => {
113
279
  const source = fs.readFileSync(path.join(process.cwd(), "src", "refine-ui.ts"), "utf8");
114
280
  assert.match(source, /custom<void>/);
115
281
  assert.match(source, /overlay:\s*true/);
116
- assert.match(source, /"Reviewer"/);
117
- assert.match(source, /"Criticizer"/);
118
- assert.match(source, /truncateToWidth/);
119
- assert.match(source, /chunkDetailForOverlay/);
120
- assert.match(source, /pickOverlayHeight|pickOverlayWidth/);
121
- assert.match(source, /renderBorderLine|renderHorizontalRule|renderRow/);
122
- assert.match(source, /if \(this\.closed\) return;/);
123
- assert.match(source, /await this\.overlayPromise/);
124
- assert.match(source, /if \(!ctx\.hasUI \|\| this\.overlayPromise\) return/);
282
+ assert.match(source, /width: "78%"/);
283
+ assert.match(source, /minWidth: OVERLAY_MIN_WIDTH/);
284
+ assert.match(source, /maxHeight: "78%"/);
285
+ assert.match(source, /anchor: "top-center"/);
286
+ assert.match(source, /margin: \{ top: 1, left: 2, right: 2 \}/);
287
+ assert.match(source, /previewTranscriptText/);
288
+ assert.match(source, /footerText/);
289
+ assert.match(source, /Tab|tab/);
290
+ assert.match(source, /scrollOffset|followTranscript/);
291
+ assert.doesNotMatch(source, /new Input|inputFrameLine|onSubmit/);
125
292
  assert.doesNotMatch(source, /pi-btw|createAgentSession|AgentSession/);
126
293
  });
294
+ });
295
+ describe("refine overlay lifecycle", () => {
296
+ it("treats Esc as close-only and never relays the abort hook", () => {
297
+ const calls: string[] = [];
298
+ const controller = new RefineOverlayController("reviewer", [{ id: "lane-1", label: "one" }], () => {
299
+ calls.push("abort");
300
+ });
301
+ controller.cancel();
302
+ assert.deepEqual(calls, [], "running Esc must not invoke the abort hook");
303
+ assert.equal(controller.isClosed(), true);
304
+
305
+ const another = new RefineOverlayController("reviewer", [{ id: "lane-1", label: "one" }], () => {
306
+ calls.push("abort");
307
+ });
308
+ another.cancel();
309
+ assert.deepEqual(calls, [], "repeated Esc must still not invoke the abort hook");
310
+ assert.equal(another.isClosed(), true);
311
+ });
312
+
313
+ it("keeps close idempotent across repeated calls", () => {
314
+ const controller = new RefineOverlayController("reviewer", [{ id: "lane-1", label: "one" }], () => undefined);
315
+ return controller.close().then(() => controller.close()).then(() => {
316
+ assert.equal(controller.isClosed(), true);
317
+ });
318
+ });
319
+
320
+ it("pairs mouse-mode enable with disable through the controller lifecycle", () => {
321
+ const writes: string[] = [];
322
+ const fakeTui = {
323
+ terminal: { write: (data: string) => { writes.push(data); } },
324
+ requestRender: () => undefined,
325
+ } as never;
326
+ const controller = new RefineOverlayController(
327
+ "reviewer",
328
+ [{ id: "lane-1", label: "one" }],
329
+ () => undefined,
330
+ );
331
+ // Simulate the factory hand-off path that open() runs.
332
+ (controller as unknown as { tui: unknown }).tui = fakeTui;
333
+ (controller as unknown as {
334
+ component: { dispose: () => void; tui?: unknown } | undefined;
335
+ }).component = new RefineOverlayComponent(
336
+ { fg: (_color: string, text: string) => text, bold: (text: string) => text } as never,
337
+ "reviewer",
338
+ controller.lanes,
339
+ () => undefined,
340
+ fakeTui,
341
+ );
342
+ assert.ok(writes.some((entry) => entry.includes("\x1b[?1000h")), "mouse-enable must be emitted");
343
+ return controller.close().then(() => {
344
+ assert.ok(writes.some((entry) => entry.includes("\x1b[?1000l")), "mouse-disable must be emitted");
345
+ assert.equal(controller.isClosed(), true);
346
+ });
347
+ });
348
+
349
+ it("closes refinement overlays on completion instead of retaining them", () => {
350
+ const source = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
351
+ assert.match(source, /await execution\.close\(\)/);
352
+ assert.doesNotMatch(source, /await execution\.retain\(\)|retainedRefineOverlay|closeRetainedRefineOverlay|markFinished|isFinished/);
353
+ });
354
+ });
355
+
356
+ describe("overlay frame colors and ANSI width", () => {
357
+ // Theme stub that emits REAL CSI sequences so frame-color and width behavior
358
+ // can be observed on the rendered output (fakeTheme strips colors entirely).
359
+ const ansiCodes: Record<string, number> = {
360
+ border: 31,
361
+ borderAccent: 32,
362
+ accent: 33,
363
+ dim: 90,
364
+ muted: 93,
365
+ success: 92,
366
+ error: 91,
367
+ warning: 93,
368
+ };
369
+ const ansiTheme = {
370
+ fg: (color: string, text: string) => `\x1b[${ansiCodes[color] ?? 39}m${text}\x1b[0m`,
371
+ bold: (text: string) => `\x1b[1m${text}\x1b[0m`,
372
+ } as never;
373
+
374
+ it("keeps the right wall intact on ANSI-heavy rows (every row exactly frame-wide)", () => {
375
+ const heavy = `\x1b[90m[${"payload".repeat(30)}]\x1b[0m styled \x1b[1mbold\x1b[0m tail`;
376
+ const component = new RefineOverlayComponent(ansiTheme, "reviewer", [readyLane("lane-1", "reviewer-1", heavy)], () => {});
377
+ const lines = component.render(120);
378
+ for (const line of lines) {
379
+ assert.equal(visibleWidth(line), 120, `row must be exactly frame-wide: ${JSON.stringify(line.slice(0, 80))}`);
380
+ }
381
+ const wallRows = lines.filter((line) => line.includes("│"));
382
+ assert.ok(wallRows.length >= 3, "title, body, and footer rows must carry side walls");
383
+ for (const row of wallRows) {
384
+ assert.match(row, /\x1b\[3[12]m│\x1b\[0m$/); // right wall survives fitLine
385
+ }
386
+ });
387
+
388
+ it("renders border for the outer frame and borderAccent only for the selected lane", () => {
389
+ const lanes = [
390
+ readyLane("lane-1", "correctness", "out-1"),
391
+ readyLane("lane-2", "ordering", "out-2"),
392
+ readyLane("lane-3", "verification", "out-3"),
393
+ ];
394
+ const component = new RefineOverlayComponent(ansiTheme, "reviewer", lanes, () => {});
395
+ const lines = component.render(120);
396
+ const outerTop = lines[0]!;
397
+ assert.match(outerTop, /^\x1b\[31m┌/);
398
+ const topBorders = lines.filter((line) => /\x1b\[31m┌/.test(line));
399
+ const selectedTopBorders = lines.filter((line) => /\x1b\[32m┌/.test(line));
400
+ assert.equal(topBorders.length, 3, "outer frame + two unselected panes use border color");
401
+ assert.equal(selectedTopBorders.length, 1, "exactly the selected pane uses borderAccent");
402
+ // Title row walls follow the outer frame color; content keeps accent.
403
+ assert.match(lines[1]!, /\x1b\[31m│/);
404
+ assert.match(lines[1]!, /\x1b\[33m/);
405
+ // Footer row walls also follow the outer frame color.
406
+ assert.match(lines[lines.length - 2]!, /\x1b\[31m│/);
407
+ });
408
+
409
+ it("keeps ANSI sequences atomic through truncateToWidth and wrapTextWithAnsi hard splits", async () => {
410
+ const { truncateToWidth, wrapTextWithAnsi } = await import("../src/refine-ui-helpers.ts");
411
+ const styled = `\x1b[90m${"ab".repeat(30)}\x1b[0m tail`;
412
+ const cut = truncateToWidth(styled, 10);
413
+ assert.equal(visibleWidth(cut), 10);
414
+ assert.doesNotMatch(cut, /\x1b\[[^\x40-\x7e]*$/); // no dangling CSI at the cut point
415
+
416
+ const longToken = `\x1b[90m${"w".repeat(80)}\x1b[0m`;
417
+ const wrapped = wrapTextWithAnsi(longToken, 20);
418
+ assert.ok(wrapped.length >= 4);
419
+ for (const line of wrapped) {
420
+ assert.ok(visibleWidth(line) <= 20);
421
+ assert.doesNotMatch(line, /\x1b\[[^\x40-\x7e]*$/); // every sequence stays whole
422
+ }
423
+ // Zero-width accounting: pure escape payload measures nothing.
424
+ assert.equal(visibleWidth("\x1b[38;5;244m\x1b[0m"), 0);
425
+ });
127
426
  });
@@ -13,6 +13,7 @@ import {
13
13
  recordDecision,
14
14
  setLanguage,
15
15
  setArtifactRoot,
16
+ setRefsRoot,
16
17
  setRole,
17
18
  setRunStatus,
18
19
  showConfig,
@@ -72,6 +73,37 @@ describe("init", () => {
72
73
  assert.equal(config.artifact_root, "./docs/pi-plans");
73
74
  assert.equal(config.artifact_root_source, "unset");
74
75
  assert.equal(config.artifact_root_updated_at, null);
76
+ assert.equal(config.refs_root, null);
77
+ assert.equal(config.refs_root_source, "unset");
78
+ assert.equal(config.refs_root_updated_at, null);
79
+ });
80
+
81
+ it("normalizes old configs missing the refs_root trio", () => {
82
+ const workdir = mkWorkdir("refs-root-normalize");
83
+ initState(workdir);
84
+ const configPath = path.join(commonDir(workdir), "pi_plans", "config.json");
85
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
86
+ delete config.refs_root;
87
+ delete config.refs_root_source;
88
+ delete config.refs_root_updated_at;
89
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
90
+ const updated = initState(workdir);
91
+ assert.equal(updated.config.refs_root, null);
92
+ assert.equal(updated.config.refs_root_source, "unset");
93
+ assert.equal(updated.config.refs_root_updated_at, null);
94
+ assert.equal(readConfig(workdir).refs_root, null);
95
+ });
96
+
97
+ it("setRefsRoot persists the trio", () => {
98
+ const workdir = mkWorkdir("refs-root-set");
99
+ initState(workdir);
100
+ setRefsRoot(workdir, ".git/pi-plans/refs", "user");
101
+ assert.equal(readConfig(workdir).refs_root, ".git/pi-plans/refs");
102
+ assert.equal(readConfig(workdir).refs_root_source, "user");
103
+ assert.ok(typeof readConfig(workdir).refs_root_updated_at === "string");
104
+ const shown = showConfig(workdir);
105
+ assert.equal(shown.refs_root, ".git/pi-plans/refs");
106
+ assert.equal(shown.refs_root_source, "user");
75
107
  });
76
108
 
77
109
  it("migrates legacy artifact roots to ./docs/pi-plans", () => {
@@ -55,11 +55,33 @@ describe("subagent runner lifecycle", () => {
55
55
  assert.equal(result.output, "final conclusion");
56
56
  assert.equal(result.model, "fake/model");
57
57
  assert.ok(progress.includes("process:started"));
58
- assert.ok(progress.includes("tool:start"));
58
+ assert.ok(progress.includes("transcript:update"));
59
59
  assert.ok(progress.includes("process:exited"));
60
60
  assert.equal(result.turns, 1);
61
61
  });
62
62
 
63
+ it("marks refiner children with PI_PLANS_REFINER=1", async () => {
64
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-fake-pi-env-"));
65
+ const script = path.join(dir, "fake-pi-env.mjs");
66
+ fs.writeFileSync(
67
+ script,
68
+ [
69
+ 'const emit = (event) => process.stdout.write(JSON.stringify(event) + "\\n");',
70
+ 'emit({ type: "message_end", message: { role: "assistant", model: "fake/model", content: [{ type: "text", text: "marker=" + String(process.env.PI_PLANS_REFINER) }] } });',
71
+ ].join("\n"),
72
+ );
73
+ const previousScript = process.argv[1];
74
+ process.argv[1] = script;
75
+ try {
76
+ const result = await runPiSubagent({ systemPrompt: "p", task: "env", cwd: process.cwd(), timeoutMs: 2000 });
77
+ assert.equal(result.ok, true);
78
+ assert.equal(result.output, "marker=1");
79
+ } finally {
80
+ process.argv[1] = previousScript;
81
+ fs.rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ });
84
+
63
85
  it("returns a cancelled result after aborting the child", async () => {
64
86
  const abort = new AbortController();
65
87
  const promise = withFakePi("slow", { signal: abort.signal, timeoutMs: 2000 });
@@ -80,35 +102,41 @@ describe("subagent runner lifecycle", () => {
80
102
 
81
103
  describe("subagent progress events", () => {
82
104
  it("normalizes turn and message lifecycle events", () => {
83
- assert.deepEqual(normalizeSubagentEvent({ type: "turn_start" }), { type: "turn", phase: "start" });
105
+ assert.deepEqual(normalizeSubagentEvent({ type: "turn_start", turnIndex: 2 }), [{ type: "turn", phase: "start", turnIndex: 2 }]);
84
106
  assert.deepEqual(
85
107
  normalizeSubagentEvent({
86
108
  type: "message_update",
87
- assistantMessageEvent: { type: "text_delta", delta: "checking the plan" },
109
+ assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: "checking the plan" },
88
110
  }),
89
- { type: "message", phase: "update", role: "assistant", text: "checking the plan" },
111
+ [{ type: "transcript", phase: "update", entryType: "assistant-text", key: "content:1", text: "checking the plan", update: "append", streaming: true }],
90
112
  );
91
113
  });
92
114
 
93
115
  it("normalizes tool progress without exposing unbounded arguments", () => {
94
- const event = normalizeSubagentEvent({
95
- type: "tool_execution_start",
96
- toolCallId: "call-1",
97
- toolName: "read",
98
- args: { path: "/tmp/plan.md" },
99
- });
100
- assert.deepEqual(event, {
101
- type: "tool",
102
- phase: "start",
103
- toolCallId: "call-1",
104
- toolName: "read",
105
- detail: '{"path":"/tmp/plan.md"}',
106
- });
116
+ assert.deepEqual(
117
+ normalizeSubagentEvent({
118
+ type: "tool_execution_start",
119
+ toolCallId: "call-1",
120
+ toolName: "read",
121
+ args: { path: "/tmp/plan.md" },
122
+ }),
123
+ [{
124
+ type: "transcript",
125
+ phase: "start",
126
+ entryType: "tool-call",
127
+ key: "tool:call-1",
128
+ text: '{\n "path": "/tmp/plan.md"\n}',
129
+ update: "replace",
130
+ streaming: true,
131
+ toolCallId: "call-1",
132
+ toolName: "read",
133
+ }],
134
+ );
107
135
  });
108
136
 
109
137
  it("ignores unknown or malformed events", () => {
110
- assert.equal(normalizeSubagentEvent(undefined), undefined);
111
- assert.equal(normalizeSubagentEvent({ type: "future_event" }), undefined);
112
- assert.equal(normalizeSubagentEvent({ type: "message_end" }), undefined);
138
+ assert.deepEqual(normalizeSubagentEvent(undefined), []);
139
+ assert.deepEqual(normalizeSubagentEvent({ type: "future_event" }), []);
140
+ assert.deepEqual(normalizeSubagentEvent({ type: "message_end" }), []);
113
141
  });
114
142
  });