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.
- package/README.md +90 -26
- package/agents/ref-analyst.md +18 -0
- package/index.ts +121 -9
- package/package.json +16 -1
- package/references/pi-planning-workflow.md +21 -6
- package/references/state-and-config.md +52 -5
- package/scripts/validate.ts +5 -0
- package/skills/plan-with-refs/SKILL.md +3 -3
- package/src/code-graph/commands.ts +483 -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 +361 -0
- package/src/exec.ts +508 -693
- package/src/guard.ts +14 -1
- package/src/refine-prompts.ts +109 -0
- package/src/refine-ui-helpers.ts +71 -18
- package/src/refine-ui-state.ts +88 -22
- package/src/refine-ui.ts +210 -102
- package/src/state.ts +36 -7
- package/src/subagent.ts +164 -61
- package/src/termination-prompt.ts +22 -0
- package/tests/analyze-refs.test.ts +265 -0
- package/tests/ask-choice.test.ts +264 -0
- package/tests/autocomplete.test.ts +6 -1
- package/tests/code-graph-apply-action.test.ts +173 -0
- 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 +263 -0
- package/tests/exec.test.ts +808 -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/guard.test.ts +27 -1
- package/tests/plans.test.ts +10 -0
- package/tests/refine-prompts.test.ts +101 -2
- package/tests/refine-ui.test.ts +371 -72
- package/tests/state.test.ts +32 -0
- package/tests/subagent.test.ts +48 -20
- package/tools/analyze-refs.ts +263 -0
- package/tools/ask-choice.ts +159 -11
- package/tools/code-graph.ts +277 -0
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +97 -2
- 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
|
+
}
|
|
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
|
|
10
|
-
return {
|
|
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("
|
|
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,309 @@ 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
|
+
});
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
100
|
-
assert.
|
|
101
|
-
assert.
|
|
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("
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
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
|
|
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, /"
|
|
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, /
|
|
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
|
});
|
package/tests/state.test.ts
CHANGED
|
@@ -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", () => {
|
package/tests/subagent.test.ts
CHANGED
|
@@ -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("
|
|
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: "
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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.
|
|
111
|
-
assert.
|
|
112
|
-
assert.
|
|
138
|
+
assert.deepEqual(normalizeSubagentEvent(undefined), []);
|
|
139
|
+
assert.deepEqual(normalizeSubagentEvent({ type: "future_event" }), []);
|
|
140
|
+
assert.deepEqual(normalizeSubagentEvent({ type: "message_end" }), []);
|
|
113
141
|
});
|
|
114
142
|
});
|