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/tests/refine-ui.test.ts
CHANGED
|
@@ -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,
|
|
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
|
|
10
|
-
return {
|
|
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("
|
|
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: "
|
|
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
|
-
|
|
89
|
+
isError: false,
|
|
22
90
|
});
|
|
23
91
|
|
|
24
|
-
assert.equal(state.
|
|
25
|
-
assert.equal(state.
|
|
26
|
-
assert.
|
|
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.
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
100
|
-
assert.ok(lines.
|
|
101
|
-
assert.
|
|
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("
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
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
|
|
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, /"
|
|
117
|
-
assert.match(source, /
|
|
118
|
-
assert.match(source, /
|
|
119
|
-
assert.match(source, /
|
|
120
|
-
assert.match(source, /
|
|
121
|
-
assert.match(source, /
|
|
122
|
-
assert.match(source, /
|
|
123
|
-
assert.match(source, /
|
|
124
|
-
assert.match(source, /
|
|
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
|
});
|
package/tests/subagent.test.ts
CHANGED
|
@@ -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("
|
|
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: "
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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.
|
|
111
|
-
assert.
|
|
112
|
-
assert.
|
|
116
|
+
assert.deepEqual(normalizeSubagentEvent(undefined), []);
|
|
117
|
+
assert.deepEqual(normalizeSubagentEvent({ type: "future_event" }), []);
|
|
118
|
+
assert.deepEqual(normalizeSubagentEvent({ type: "message_end" }), []);
|
|
113
119
|
});
|
|
114
120
|
});
|