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
@@ -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
+ }
8
32
 
9
- function lane(): RefineLaneState {
10
- return { id: "lane-1", label: "reviewer-1", status: "queued", phase: "queued", detail: "" };
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
+ };
11
40
  }
12
41
 
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
+ };
49
+ }
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,275 @@ 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
+ });
64
129
 
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,
130
+ it("renders model-aware titles and footer hints", () => {
131
+ const lanes = [
132
+ readyLane("lane-1", "correctness", "correctness output"),
133
+ readyLane("lane-2", "ordering", "ordering output"),
134
+ readyLane("lane-3", "verification", "verification output"),
135
+ ];
136
+ const component = new RefineOverlayComponent(
137
+ fakeTheme,
138
+ "reviewer",
139
+ lanes,
140
+ () => {},
141
+ undefined,
142
+ "zai/glm-5.3-flash:high",
98
143
  );
99
- assert.ok(lines.length >= 3);
100
- assert.ok(lines.every((line) => line.length <= 40));
101
- assert.match(lines[0] ?? "", /Reading the file\./);
144
+ const lines = component.render(120);
145
+ assert.ok(lines.some((line) => line.includes("Reviewer (zai/glm-5.3-flash:high)")));
146
+ assert.ok(lines.some((line) => line.includes("Esc 关闭")));
147
+ assert.ok(lines.some((line) => line.includes("Tab & Shift + Tab")));
148
+ });
149
+
150
+ it("renders only the last three wrapped lines for streaming transcript previews", () => {
151
+ const linesText = ["line 1", "line 2", "line 3", "line 4", "line 5"].join("\n");
152
+ const component = new RefineOverlayComponent(
153
+ fakeTheme,
154
+ "criticizer",
155
+ [streamingToolLane("lane-1", "criticizer", linesText)],
156
+ () => {},
157
+ undefined,
158
+ "singularity-gpt/gpt-5.5:xhigh",
159
+ );
160
+ const lines = component.render(90);
161
+ assert.ok(lines.some((line) => line.includes("Criticizer (singularity-gpt/gpt-5.5:xhigh)")));
162
+ assert.ok(lines.some((line) => line.includes("…")));
163
+ assert.ok(lines.some((line) => line.includes("line 3")));
164
+ assert.ok(lines.some((line) => line.includes("line 4")));
165
+ assert.ok(lines.some((line) => line.includes("line 5")));
166
+ assert.equal(lines.some((line) => line.includes("line 1")), false);
167
+ assert.equal(lines.some((line) => line.includes("line 2")), false);
168
+ });
169
+
170
+ it("renders only the last three wrapped lines for streaming assistant previews", () => {
171
+ const linesText = ["line 1", "line 2", "line 3", "line 4", "line 5"].join("\n");
172
+ const component = new RefineOverlayComponent(
173
+ fakeTheme,
174
+ "reviewer",
175
+ [streamingAssistantLane("lane-1", "reviewer", linesText)],
176
+ () => {},
177
+ undefined,
178
+ "zai/glm-5.3-flash:high",
179
+ );
180
+ const lines = component.render(90);
181
+ assert.ok(lines.some((line) => line.includes("Reviewer (zai/glm-5.3-flash:high)")));
182
+ assert.ok(lines.some((line) => line.includes("…")));
183
+ assert.ok(lines.some((line) => line.includes("line 3")));
184
+ assert.ok(lines.some((line) => line.includes("line 4")));
185
+ assert.ok(lines.some((line) => line.includes("line 5")));
186
+ assert.equal(lines.some((line) => line.includes("line 1")), false);
187
+ assert.equal(lines.some((line) => line.includes("line 2")), false);
188
+ });
189
+
190
+ it("renders a single pane with pi-btw-style transcript badges", () => {
191
+ const component = new RefineOverlayComponent(
192
+ fakeTheme,
193
+ "criticizer",
194
+ [readyLane("lane-1", "criticizer", "assistant output")],
195
+ () => {},
196
+ );
197
+ const lines = component.render(90);
198
+ assert.equal(lines.filter((line) => line.startsWith("┌")).length, 2);
199
+ assert.equal(lines.filter((line) => line.startsWith("└")).length, 2);
200
+ assert.ok(lines.some((line) => line.includes("assistant")));
201
+ assert.ok(lines.some((line) => line.includes("assistant output")));
202
+ });
203
+
204
+ it("renders three vertical equal-height independent panes", () => {
205
+ const lanes = [
206
+ readyLane("lane-1", "correctness", "correctness output"),
207
+ readyLane("lane-2", "ordering", "ordering output"),
208
+ readyLane("lane-3", "verification", "verification output"),
209
+ ];
210
+ const component = new RefineOverlayComponent(fakeTheme, "reviewer", lanes, () => {});
211
+ const lines = component.render(120);
212
+ assert.equal(lines.filter((line) => line.startsWith("┌")).length, 4);
213
+ assert.equal(lines.filter((line) => line.startsWith("└")).length, 4);
214
+ assert.ok(lines.some((line) => line.includes("correctness")));
215
+ assert.ok(lines.some((line) => line.includes("ordering")));
216
+ assert.ok(lines.some((line) => line.includes("verification")));
217
+ const topIndexes = lines.flatMap((line, index) => line.startsWith("┌") ? [index] : []);
218
+ const bottomIndexes = lines.flatMap((line, index) => line.startsWith("└") ? [index] : []);
219
+ const paneHeights = topIndexes.slice(1).map((value, index) => bottomIndexes[index]! - value);
220
+ assert.ok(paneHeights.every((height) => height === paneHeights[0]));
102
221
  });
103
222
 
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);
223
+ it("follows the bottom, pauses on upward scroll, and switches independently with Tab", () => {
224
+ const long = Array.from({ length: 30 }, (_, index) => `line-${index + 1}`).join("\n");
225
+ const lanes = [readyLane("lane-1", "one", long), readyLane("lane-2", "two", long)];
226
+ const component = new RefineOverlayComponent(fakeTheme, "reviewer", lanes, () => {});
227
+ component.render(100);
228
+ const initialOffset = lanes[0]!.scrollOffset;
229
+ assert.ok(initialOffset > 0);
230
+ component.handleInput("\x1b[A");
231
+ component.render(100);
232
+ assert.equal(lanes[0]!.followTranscript, false);
233
+ assert.ok(lanes[0]!.scrollOffset < initialOffset);
234
+ const laneOneOffset = lanes[1]!.scrollOffset;
235
+ component.handleInput("\t");
236
+ component.handleInput("\x1b[A");
237
+ component.render(100);
238
+ assert.ok(lanes[1]!.scrollOffset < laneOneOffset);
239
+ assert.equal(lanes[0]!.scrollOffset, initialOffset - 1);
108
240
  });
109
241
  });
110
242
 
111
243
  describe("refine overlay wiring", () => {
112
- it("uses named public overlays, guards lifecycle edges, and does not depend on pi-btw", () => {
244
+ it("uses pi-btw geometry, no input box, and no pi-btw runtime dependency", () => {
113
245
  const source = fs.readFileSync(path.join(process.cwd(), "src", "refine-ui.ts"), "utf8");
114
246
  assert.match(source, /custom<void>/);
115
247
  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/);
248
+ assert.match(source, /width: "78%"/);
249
+ assert.match(source, /minWidth: OVERLAY_MIN_WIDTH/);
250
+ assert.match(source, /maxHeight: "78%"/);
251
+ assert.match(source, /anchor: "top-center"/);
252
+ assert.match(source, /margin: \{ top: 1, left: 2, right: 2 \}/);
253
+ assert.match(source, /previewTranscriptText/);
254
+ assert.match(source, /footerText/);
255
+ assert.match(source, /Tab|tab/);
256
+ assert.match(source, /scrollOffset|followTranscript/);
257
+ assert.doesNotMatch(source, /new Input|inputFrameLine|onSubmit/);
125
258
  assert.doesNotMatch(source, /pi-btw|createAgentSession|AgentSession/);
126
259
  });
260
+ });
261
+ describe("refine overlay lifecycle", () => {
262
+ it("treats Esc as close-only and never relays the abort hook", () => {
263
+ const calls: string[] = [];
264
+ const controller = new RefineOverlayController("reviewer", [{ id: "lane-1", label: "one" }], () => {
265
+ calls.push("abort");
266
+ });
267
+ controller.cancel();
268
+ assert.deepEqual(calls, [], "running Esc must not invoke the abort hook");
269
+ assert.equal(controller.isClosed(), true);
270
+
271
+ const another = new RefineOverlayController("reviewer", [{ id: "lane-1", label: "one" }], () => {
272
+ calls.push("abort");
273
+ });
274
+ another.cancel();
275
+ assert.deepEqual(calls, [], "repeated Esc must still not invoke the abort hook");
276
+ assert.equal(another.isClosed(), true);
277
+ });
278
+
279
+ it("keeps close idempotent across repeated calls", () => {
280
+ const controller = new RefineOverlayController("reviewer", [{ id: "lane-1", label: "one" }], () => undefined);
281
+ return controller.close().then(() => controller.close()).then(() => {
282
+ assert.equal(controller.isClosed(), true);
283
+ });
284
+ });
285
+
286
+ it("pairs mouse-mode enable with disable through the controller lifecycle", () => {
287
+ const writes: string[] = [];
288
+ const fakeTui = {
289
+ terminal: { write: (data: string) => { writes.push(data); } },
290
+ requestRender: () => undefined,
291
+ } as never;
292
+ const controller = new RefineOverlayController(
293
+ "reviewer",
294
+ [{ id: "lane-1", label: "one" }],
295
+ () => undefined,
296
+ );
297
+ // Simulate the factory hand-off path that open() runs.
298
+ (controller as unknown as { tui: unknown }).tui = fakeTui;
299
+ (controller as unknown as {
300
+ component: { dispose: () => void; tui?: unknown } | undefined;
301
+ }).component = new RefineOverlayComponent(
302
+ { fg: (_color: string, text: string) => text, bold: (text: string) => text } as never,
303
+ "reviewer",
304
+ controller.lanes,
305
+ () => undefined,
306
+ fakeTui,
307
+ );
308
+ assert.ok(writes.some((entry) => entry.includes("\x1b[?1000h")), "mouse-enable must be emitted");
309
+ return controller.close().then(() => {
310
+ assert.ok(writes.some((entry) => entry.includes("\x1b[?1000l")), "mouse-disable must be emitted");
311
+ assert.equal(controller.isClosed(), true);
312
+ });
313
+ });
314
+
315
+ it("closes refinement overlays on completion instead of retaining them", () => {
316
+ const source = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
317
+ assert.match(source, /await execution\.close\(\)/);
318
+ assert.doesNotMatch(source, /await execution\.retain\(\)|retainedRefineOverlay|closeRetainedRefineOverlay|markFinished|isFinished/);
319
+ });
320
+ });
321
+
322
+ describe("overlay frame colors and ANSI width", () => {
323
+ // Theme stub that emits REAL CSI sequences so frame-color and width behavior
324
+ // can be observed on the rendered output (fakeTheme strips colors entirely).
325
+ const ansiCodes: Record<string, number> = {
326
+ border: 31,
327
+ borderAccent: 32,
328
+ accent: 33,
329
+ dim: 90,
330
+ muted: 93,
331
+ success: 92,
332
+ error: 91,
333
+ warning: 93,
334
+ };
335
+ const ansiTheme = {
336
+ fg: (color: string, text: string) => `\x1b[${ansiCodes[color] ?? 39}m${text}\x1b[0m`,
337
+ bold: (text: string) => `\x1b[1m${text}\x1b[0m`,
338
+ } as never;
339
+
340
+ it("keeps the right wall intact on ANSI-heavy rows (every row exactly frame-wide)", () => {
341
+ const heavy = `\x1b[90m[${"payload".repeat(30)}]\x1b[0m styled \x1b[1mbold\x1b[0m tail`;
342
+ const component = new RefineOverlayComponent(ansiTheme, "reviewer", [readyLane("lane-1", "reviewer-1", heavy)], () => {});
343
+ const lines = component.render(120);
344
+ for (const line of lines) {
345
+ assert.equal(visibleWidth(line), 120, `row must be exactly frame-wide: ${JSON.stringify(line.slice(0, 80))}`);
346
+ }
347
+ const wallRows = lines.filter((line) => line.includes("│"));
348
+ assert.ok(wallRows.length >= 3, "title, body, and footer rows must carry side walls");
349
+ for (const row of wallRows) {
350
+ assert.match(row, /\x1b\[3[12]m│\x1b\[0m$/); // right wall survives fitLine
351
+ }
352
+ });
353
+
354
+ it("renders border for the outer frame and borderAccent only for the selected lane", () => {
355
+ const lanes = [
356
+ readyLane("lane-1", "correctness", "out-1"),
357
+ readyLane("lane-2", "ordering", "out-2"),
358
+ readyLane("lane-3", "verification", "out-3"),
359
+ ];
360
+ const component = new RefineOverlayComponent(ansiTheme, "reviewer", lanes, () => {});
361
+ const lines = component.render(120);
362
+ const outerTop = lines[0]!;
363
+ assert.match(outerTop, /^\x1b\[31m┌/);
364
+ const topBorders = lines.filter((line) => /\x1b\[31m┌/.test(line));
365
+ const selectedTopBorders = lines.filter((line) => /\x1b\[32m┌/.test(line));
366
+ assert.equal(topBorders.length, 3, "outer frame + two unselected panes use border color");
367
+ assert.equal(selectedTopBorders.length, 1, "exactly the selected pane uses borderAccent");
368
+ // Title row walls follow the outer frame color; content keeps accent.
369
+ assert.match(lines[1]!, /\x1b\[31m│/);
370
+ assert.match(lines[1]!, /\x1b\[33m/);
371
+ // Footer row walls also follow the outer frame color.
372
+ assert.match(lines[lines.length - 2]!, /\x1b\[31m│/);
373
+ });
374
+
375
+ it("keeps ANSI sequences atomic through truncateToWidth and wrapTextWithAnsi hard splits", async () => {
376
+ const { truncateToWidth, wrapTextWithAnsi } = await import("../src/refine-ui-helpers.ts");
377
+ const styled = `\x1b[90m${"ab".repeat(30)}\x1b[0m tail`;
378
+ const cut = truncateToWidth(styled, 10);
379
+ assert.equal(visibleWidth(cut), 10);
380
+ assert.doesNotMatch(cut, /\x1b\[[^\x40-\x7e]*$/); // no dangling CSI at the cut point
381
+
382
+ const longToken = `\x1b[90m${"w".repeat(80)}\x1b[0m`;
383
+ const wrapped = wrapTextWithAnsi(longToken, 20);
384
+ assert.ok(wrapped.length >= 4);
385
+ for (const line of wrapped) {
386
+ assert.ok(visibleWidth(line) <= 20);
387
+ assert.doesNotMatch(line, /\x1b\[[^\x40-\x7e]*$/); // every sequence stays whole
388
+ }
389
+ // Zero-width accounting: pure escape payload measures nothing.
390
+ assert.equal(visibleWidth("\x1b[38;5;244m\x1b[0m"), 0);
391
+ });
127
392
  });
@@ -55,7 +55,7 @@ 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
  });
@@ -80,35 +80,41 @@ describe("subagent runner lifecycle", () => {
80
80
 
81
81
  describe("subagent progress events", () => {
82
82
  it("normalizes turn and message lifecycle events", () => {
83
- assert.deepEqual(normalizeSubagentEvent({ type: "turn_start" }), { type: "turn", phase: "start" });
83
+ assert.deepEqual(normalizeSubagentEvent({ type: "turn_start", turnIndex: 2 }), [{ type: "turn", phase: "start", turnIndex: 2 }]);
84
84
  assert.deepEqual(
85
85
  normalizeSubagentEvent({
86
86
  type: "message_update",
87
- assistantMessageEvent: { type: "text_delta", delta: "checking the plan" },
87
+ assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: "checking the plan" },
88
88
  }),
89
- { type: "message", phase: "update", role: "assistant", text: "checking the plan" },
89
+ [{ type: "transcript", phase: "update", entryType: "assistant-text", key: "content:1", text: "checking the plan", update: "append", streaming: true }],
90
90
  );
91
91
  });
92
92
 
93
93
  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
- });
94
+ assert.deepEqual(
95
+ normalizeSubagentEvent({
96
+ type: "tool_execution_start",
97
+ toolCallId: "call-1",
98
+ toolName: "read",
99
+ args: { path: "/tmp/plan.md" },
100
+ }),
101
+ [{
102
+ type: "transcript",
103
+ phase: "start",
104
+ entryType: "tool-call",
105
+ key: "tool:call-1",
106
+ text: '{\n "path": "/tmp/plan.md"\n}',
107
+ update: "replace",
108
+ streaming: true,
109
+ toolCallId: "call-1",
110
+ toolName: "read",
111
+ }],
112
+ );
107
113
  });
108
114
 
109
115
  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);
116
+ assert.deepEqual(normalizeSubagentEvent(undefined), []);
117
+ assert.deepEqual(normalizeSubagentEvent({ type: "future_event" }), []);
118
+ assert.deepEqual(normalizeSubagentEvent({ type: "message_end" }), []);
113
119
  });
114
120
  });