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/src/subagent.ts
CHANGED
|
@@ -9,16 +9,21 @@ import * as fs from "node:fs";
|
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
|
|
12
|
+
export type SubagentTranscriptEntryType = "assistant-text" | "thinking" | "tool-call" | "tool-result";
|
|
13
|
+
|
|
12
14
|
export type SubagentProgressEvent =
|
|
13
15
|
| { type: "process"; phase: "started" | "exited"; code?: number }
|
|
14
|
-
| { type: "turn"; phase: "start" | "end" }
|
|
15
|
-
| { type: "message"; phase: "start" | "update" | "end"; role: string; text: string }
|
|
16
|
+
| { type: "turn"; phase: "start" | "end"; turnIndex?: number }
|
|
16
17
|
| {
|
|
17
|
-
type: "
|
|
18
|
+
type: "transcript";
|
|
18
19
|
phase: "start" | "update" | "end";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
entryType: SubagentTranscriptEntryType;
|
|
21
|
+
key: string;
|
|
22
|
+
text: string;
|
|
23
|
+
update: "append" | "replace";
|
|
24
|
+
streaming: boolean;
|
|
25
|
+
toolCallId?: string;
|
|
26
|
+
toolName?: string;
|
|
22
27
|
isError?: boolean;
|
|
23
28
|
}
|
|
24
29
|
| { type: "stderr"; text: string };
|
|
@@ -72,12 +77,13 @@ export function getPiInvocation(args: string[]): { command: string; args: string
|
|
|
72
77
|
|
|
73
78
|
interface MessageLike {
|
|
74
79
|
role: string;
|
|
75
|
-
content?:
|
|
80
|
+
content?: unknown;
|
|
76
81
|
model?: string;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
interface RawSubagentEvent {
|
|
80
85
|
type?: unknown;
|
|
86
|
+
turnIndex?: unknown;
|
|
81
87
|
message?: MessageLike;
|
|
82
88
|
toolCallId?: unknown;
|
|
83
89
|
toolName?: unknown;
|
|
@@ -88,6 +94,42 @@ interface RawSubagentEvent {
|
|
|
88
94
|
assistantMessageEvent?: unknown;
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
function formatValue(value: unknown): string {
|
|
98
|
+
if (typeof value === "string") return value;
|
|
99
|
+
if (value === undefined) return "";
|
|
100
|
+
if (value && typeof value === "object") {
|
|
101
|
+
const content = (value as { content?: unknown }).content;
|
|
102
|
+
if (Array.isArray(content)) {
|
|
103
|
+
const text = content
|
|
104
|
+
.map((part) => {
|
|
105
|
+
if (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string") {
|
|
106
|
+
return (part as { text: string }).text;
|
|
107
|
+
}
|
|
108
|
+
return formatValue(part);
|
|
109
|
+
})
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.join("\n");
|
|
112
|
+
if (text) return text;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
117
|
+
} catch {
|
|
118
|
+
return String(value);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function messageText(message: MessageLike | undefined, kind: "text" | "thinking" = "text"): string {
|
|
123
|
+
if (!Array.isArray(message?.content)) return typeof message?.content === "string" && kind === "text" ? message.content : "";
|
|
124
|
+
return message.content
|
|
125
|
+
.filter((part) => part && typeof part === "object" && (part as { type?: unknown }).type === kind)
|
|
126
|
+
.map((part) => {
|
|
127
|
+
const value = part as { text?: unknown; thinking?: unknown };
|
|
128
|
+
return typeof value.text === "string" ? value.text : typeof value.thinking === "string" ? value.thinking : "";
|
|
129
|
+
})
|
|
130
|
+
.join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
91
133
|
function finalOutput(messages: MessageLike[]): string {
|
|
92
134
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
93
135
|
const message = messages[i];
|
|
@@ -99,29 +141,91 @@ function finalOutput(messages: MessageLike[]): string {
|
|
|
99
141
|
return "";
|
|
100
142
|
}
|
|
101
143
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.filter((part) => part.type === kind)
|
|
107
|
-
.map((part) => (kind === "thinking" ? part.thinking ?? part.text ?? "" : part.text ?? ""))
|
|
108
|
-
.join("\n");
|
|
144
|
+
interface TranscriptEventOptions {
|
|
145
|
+
toolCallId?: string;
|
|
146
|
+
toolName?: string;
|
|
147
|
+
isError?: boolean;
|
|
109
148
|
}
|
|
110
149
|
|
|
111
|
-
function
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
150
|
+
function transcriptEvent(
|
|
151
|
+
phase: "start" | "update" | "end",
|
|
152
|
+
entryType: SubagentTranscriptEntryType,
|
|
153
|
+
key: string,
|
|
154
|
+
text: string,
|
|
155
|
+
update: "append" | "replace",
|
|
156
|
+
streaming: boolean,
|
|
157
|
+
options: TranscriptEventOptions = {},
|
|
158
|
+
): SubagentProgressEvent {
|
|
159
|
+
return { type: "transcript", phase, entryType, key, text, update, streaming, ...options };
|
|
115
160
|
}
|
|
116
161
|
|
|
117
|
-
function
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
162
|
+
function messageContentEvents(message: MessageLike | undefined, phase: "start" | "end"): SubagentProgressEvent[] {
|
|
163
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) return [];
|
|
164
|
+
return message.content.flatMap((part, index) => {
|
|
165
|
+
if (!part || typeof part !== "object") return [];
|
|
166
|
+
const value = part as { type?: unknown; text?: unknown; thinking?: unknown; id?: unknown; name?: unknown; arguments?: unknown };
|
|
167
|
+
if (value.type === "text" && typeof value.text === "string") {
|
|
168
|
+
return [transcriptEvent(phase, "assistant-text", `content:${index}`, value.text, "replace", phase !== "end")];
|
|
169
|
+
}
|
|
170
|
+
if (value.type === "thinking") {
|
|
171
|
+
const text = typeof value.thinking === "string" ? value.thinking : typeof value.text === "string" ? value.text : "";
|
|
172
|
+
return [transcriptEvent(phase, "thinking", `content:${index}`, text, "replace", phase !== "end")];
|
|
173
|
+
}
|
|
174
|
+
if (value.type === "toolCall") {
|
|
175
|
+
return [transcriptEvent(phase, "tool-call", `content:${index}`, formatValue(value.arguments), "replace", phase !== "end", {
|
|
176
|
+
toolCallId: typeof value.id === "string" ? value.id : undefined,
|
|
177
|
+
toolName: typeof value.name === "string" ? value.name : undefined,
|
|
178
|
+
})];
|
|
179
|
+
}
|
|
180
|
+
return [];
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function assistantUpdateEvents(event: RawSubagentEvent): SubagentProgressEvent[] {
|
|
185
|
+
const update = event.assistantMessageEvent;
|
|
186
|
+
if (!update || typeof update !== "object") return messageContentEvents(event.message, "end");
|
|
187
|
+
const value = update as {
|
|
188
|
+
type?: unknown;
|
|
189
|
+
contentIndex?: unknown;
|
|
190
|
+
delta?: unknown;
|
|
191
|
+
content?: unknown;
|
|
192
|
+
id?: unknown;
|
|
193
|
+
toolName?: unknown;
|
|
194
|
+
toolCall?: { id?: unknown; name?: unknown; arguments?: unknown };
|
|
195
|
+
};
|
|
196
|
+
const type = typeof value.type === "string" ? value.type : "";
|
|
197
|
+
const index = typeof value.contentIndex === "number" ? value.contentIndex : 0;
|
|
198
|
+
const key = `content:${index}`;
|
|
199
|
+
switch (type) {
|
|
200
|
+
case "text_start":
|
|
201
|
+
return [transcriptEvent("start", "assistant-text", key, "", "replace", true)];
|
|
202
|
+
case "text_delta":
|
|
203
|
+
return [transcriptEvent("update", "assistant-text", key, typeof value.delta === "string" ? value.delta : "", "append", true)];
|
|
204
|
+
case "text_end":
|
|
205
|
+
return [transcriptEvent("end", "assistant-text", key, typeof value.content === "string" ? value.content : "", "replace", false)];
|
|
206
|
+
case "thinking_start":
|
|
207
|
+
return [transcriptEvent("start", "thinking", key, "", "replace", true)];
|
|
208
|
+
case "thinking_delta":
|
|
209
|
+
return [transcriptEvent("update", "thinking", key, typeof value.delta === "string" ? value.delta : "", "append", true)];
|
|
210
|
+
case "thinking_end":
|
|
211
|
+
return [transcriptEvent("end", "thinking", key, typeof value.content === "string" ? value.content : "", "replace", false)];
|
|
212
|
+
case "toolcall_start":
|
|
213
|
+
return [transcriptEvent("start", "tool-call", key, "", "replace", true, {
|
|
214
|
+
toolCallId: typeof value.id === "string" ? value.id : undefined,
|
|
215
|
+
toolName: typeof value.toolName === "string" ? value.toolName : undefined,
|
|
216
|
+
})];
|
|
217
|
+
case "toolcall_delta":
|
|
218
|
+
return [transcriptEvent("update", "tool-call", key, typeof value.delta === "string" ? value.delta : "", "append", true, {
|
|
219
|
+
toolCallId: typeof value.id === "string" ? value.id : undefined,
|
|
220
|
+
toolName: typeof value.toolName === "string" ? value.toolName : undefined,
|
|
221
|
+
})];
|
|
222
|
+
case "toolcall_end":
|
|
223
|
+
return [transcriptEvent("end", "tool-call", key, formatValue(value.toolCall?.arguments), "replace", false, {
|
|
224
|
+
toolCallId: typeof value.toolCall?.id === "string" ? value.toolCall.id : typeof value.id === "string" ? value.id : undefined,
|
|
225
|
+
toolName: typeof value.toolCall?.name === "string" ? value.toolCall.name : typeof value.toolName === "string" ? value.toolName : undefined,
|
|
226
|
+
})];
|
|
227
|
+
default:
|
|
228
|
+
return [];
|
|
125
229
|
}
|
|
126
230
|
}
|
|
127
231
|
|
|
@@ -130,45 +234,43 @@ function preview(value: unknown, maxLength = 240): string {
|
|
|
130
234
|
* protocol consumed by the refinement overlay. Unknown events are ignored so
|
|
131
235
|
* adding progress support cannot make result parsing version-fragile.
|
|
132
236
|
*/
|
|
133
|
-
export function normalizeSubagentEvent(value: unknown): SubagentProgressEvent
|
|
134
|
-
if (!value || typeof value !== "object") return
|
|
237
|
+
export function normalizeSubagentEvent(value: unknown): SubagentProgressEvent[] {
|
|
238
|
+
if (!value || typeof value !== "object") return [];
|
|
135
239
|
const event = value as RawSubagentEvent;
|
|
136
|
-
if (typeof event.type !== "string") return
|
|
137
|
-
|
|
138
|
-
if (event.type === "turn_start") return { type: "turn", phase: "start" };
|
|
139
|
-
if (event.type === "turn_end") return { type: "turn", phase: "end" };
|
|
240
|
+
if (typeof event.type !== "string") return [];
|
|
140
241
|
|
|
141
|
-
if (event.type === "
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
phase: event.type.slice("message_".length) as "start" | "update" | "end",
|
|
147
|
-
role,
|
|
148
|
-
text: messageText(event.message) || messageEventText(event.assistantMessageEvent),
|
|
149
|
-
};
|
|
242
|
+
if (event.type === "turn_start") {
|
|
243
|
+
return [{ type: "turn", phase: "start", ...(typeof event.turnIndex === "number" ? { turnIndex: event.turnIndex } : {}) }];
|
|
244
|
+
}
|
|
245
|
+
if (event.type === "turn_end") {
|
|
246
|
+
return [{ type: "turn", phase: "end", ...(typeof event.turnIndex === "number" ? { turnIndex: event.turnIndex } : {}) }];
|
|
150
247
|
}
|
|
151
248
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
toolCallId: typeof event.toolCallId === "string" ? event.toolCallId :
|
|
165
|
-
toolName: typeof event.toolName === "string" ? event.toolName :
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
249
|
+
if (event.type === "message_start") return messageContentEvents(event.message, "start");
|
|
250
|
+
if (event.type === "message_update") return assistantUpdateEvents(event);
|
|
251
|
+
if (event.type === "message_end") return messageContentEvents(event.message, "end");
|
|
252
|
+
|
|
253
|
+
if (event.type === "tool_execution_start") {
|
|
254
|
+
return [transcriptEvent("start", "tool-call", `tool:${String(event.toolCallId ?? "unknown")}`, formatValue(event.args), "replace", true, {
|
|
255
|
+
toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : undefined,
|
|
256
|
+
toolName: typeof event.toolName === "string" ? event.toolName : undefined,
|
|
257
|
+
})];
|
|
258
|
+
}
|
|
259
|
+
if (event.type === "tool_execution_update") {
|
|
260
|
+
return [transcriptEvent("update", "tool-result", `tool:${String(event.toolCallId ?? "unknown")}`, formatValue(event.partialResult), "replace", true, {
|
|
261
|
+
toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : undefined,
|
|
262
|
+
toolName: typeof event.toolName === "string" ? event.toolName : undefined,
|
|
263
|
+
})];
|
|
264
|
+
}
|
|
265
|
+
if (event.type === "tool_execution_end") {
|
|
266
|
+
return [transcriptEvent("end", "tool-result", `tool:${String(event.toolCallId ?? "unknown")}`, formatValue(event.result), "replace", false, {
|
|
267
|
+
toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : undefined,
|
|
268
|
+
toolName: typeof event.toolName === "string" ? event.toolName : undefined,
|
|
269
|
+
isError: typeof event.isError === "boolean" ? event.isError : undefined,
|
|
270
|
+
})];
|
|
169
271
|
}
|
|
170
272
|
|
|
171
|
-
return
|
|
273
|
+
return [];
|
|
172
274
|
}
|
|
173
275
|
|
|
174
276
|
function emitProgress(options: SubagentOptions, event: SubagentProgressEvent): void {
|
|
@@ -179,7 +281,7 @@ function emitProgress(options: SubagentOptions, event: SubagentProgressEvent): v
|
|
|
179
281
|
}
|
|
180
282
|
}
|
|
181
283
|
|
|
182
|
-
const DEFAULT_TIMEOUT_MS =
|
|
284
|
+
const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
183
285
|
|
|
184
286
|
export async function runPiSubagent(options: SubagentOptions): Promise<SubagentResult> {
|
|
185
287
|
const tools = options.tools ?? ["read", "grep", "find", "ls"];
|
|
@@ -240,7 +342,7 @@ export async function runPiSubagent(options: SubagentOptions): Promise<SubagentR
|
|
|
240
342
|
return;
|
|
241
343
|
}
|
|
242
344
|
const progress = normalizeSubagentEvent(event);
|
|
243
|
-
|
|
345
|
+
for (const progressEvent of progress) emitProgress(options, progressEvent);
|
|
244
346
|
if ((event.type === "message_end" || event.type === "tool_result_end") && event.message) {
|
|
245
347
|
messages.push(event.message);
|
|
246
348
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/** Behavior tests for the ask_choice trailing option (post-execution
|
|
2
|
+
* amelioration prompt): Auto-refine loop replaces Auto-complete, the
|
|
3
|
+
* auto-answer mode is suppressed, and headless sessions stop instead of
|
|
4
|
+
* auto-answering. */
|
|
5
|
+
|
|
6
|
+
import * as assert from "node:assert/strict";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { after, before, describe, it } from "node:test";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { fitAskChoicePanel, OPTION_MAX_LINES, PANEL_CHROME_LINES, PANEL_SAFETY_MARGIN, SELECTOR_WIDTH_OVERHEAD, STATUS_BAR_HEIGHT, registerAskChoiceTool } from "../tools/ask-choice.ts";
|
|
13
|
+
import { visibleWidth } from "../src/refine-ui-helpers.ts";
|
|
14
|
+
|
|
15
|
+
type ToolDef = {
|
|
16
|
+
execute: (id: string, params: any, signal: undefined, update: undefined, ctx: any) => Promise<any>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let root: string;
|
|
20
|
+
|
|
21
|
+
before(() => {
|
|
22
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-choice-"));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
after(() => {
|
|
26
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function loadTool(): ToolDef {
|
|
30
|
+
let tool: ToolDef | undefined;
|
|
31
|
+
const pi = {
|
|
32
|
+
registerTool: (definition: ToolDef) => {
|
|
33
|
+
tool = definition;
|
|
34
|
+
},
|
|
35
|
+
} as unknown as ExtensionAPI;
|
|
36
|
+
registerAskChoiceTool(pi);
|
|
37
|
+
if (!tool) throw new Error("ask_choice tool not registered");
|
|
38
|
+
return tool;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let notifyCalls: Array<{ message: string; severity?: string }> = [];
|
|
42
|
+
|
|
43
|
+
function makeCtx(opts: {
|
|
44
|
+
hasUI?: boolean;
|
|
45
|
+
select: (question: string, labels: string[]) => Promise<string | undefined>;
|
|
46
|
+
}) {
|
|
47
|
+
notifyCalls = [];
|
|
48
|
+
return {
|
|
49
|
+
cwd: root,
|
|
50
|
+
hasUI: opts.hasUI ?? true,
|
|
51
|
+
sessionManager: {},
|
|
52
|
+
ui: {
|
|
53
|
+
select: opts.select,
|
|
54
|
+
input: async () => "typed",
|
|
55
|
+
notify: (message: string, severity?: string) => {
|
|
56
|
+
notifyCalls.push({ message, severity });
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const OPTIONS = [
|
|
63
|
+
{ label: "Run one Reviewer round", recommended: true },
|
|
64
|
+
{ label: "Run a Criticizer round" },
|
|
65
|
+
{ label: "Finish here" },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
describe("ask_choice trailing option", () => {
|
|
69
|
+
it("renders Auto-refine loop as the trailing option and suppresses Auto-complete", async () => {
|
|
70
|
+
const tool = loadTool();
|
|
71
|
+
let seenLabels: string[] = [];
|
|
72
|
+
const ctx = makeCtx({
|
|
73
|
+
select: async (_question, labels) => {
|
|
74
|
+
seenLabels = labels;
|
|
75
|
+
return labels.find((label) => label.startsWith("Auto-refine loop"));
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const result = await tool.execute("t1", {
|
|
79
|
+
question: "Ameliorate?",
|
|
80
|
+
options: OPTIONS,
|
|
81
|
+
autoComplete: true, // deliberately erroneous: trailing must suppress it
|
|
82
|
+
trailing: "auto-refine-loop",
|
|
83
|
+
}, undefined, undefined, ctx);
|
|
84
|
+
|
|
85
|
+
assert.equal(seenLabels.at(-1)?.startsWith("Auto-refine loop"), true, "Auto-refine loop must be last");
|
|
86
|
+
assert.equal(seenLabels.some((label) => label.startsWith("Auto-complete")), false, "Auto-complete must be absent");
|
|
87
|
+
const text = result.content[0].text as string;
|
|
88
|
+
assert.match(text, /User selected Auto-refine loop/);
|
|
89
|
+
assert.match(text, /until no high-severity finding \(hard cap 5 rounds\)/);
|
|
90
|
+
assert.match(text, /refine \(role: "reviewer", target: "implementation"\)/);
|
|
91
|
+
assert.equal(result.details.source, "user");
|
|
92
|
+
assert.equal(result.details.answer, "Auto-refine loop");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps Auto-complete as the trailing option without the trailing param", async () => {
|
|
96
|
+
const tool = loadTool();
|
|
97
|
+
let seenLabels: string[] = [];
|
|
98
|
+
const ctx = makeCtx({
|
|
99
|
+
select: async (_question, labels) => {
|
|
100
|
+
seenLabels = labels;
|
|
101
|
+
return labels[0];
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
await tool.execute("t2", {
|
|
105
|
+
question: "Planning question?",
|
|
106
|
+
options: OPTIONS,
|
|
107
|
+
autoComplete: true,
|
|
108
|
+
}, undefined, undefined, ctx);
|
|
109
|
+
assert.equal(seenLabels.at(-1)?.startsWith("Auto-complete"), true);
|
|
110
|
+
assert.equal(seenLabels.some((label) => label.startsWith("Auto-refine loop")), false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("never auto-answers a trailing question in headless sessions", async () => {
|
|
114
|
+
const tool = loadTool();
|
|
115
|
+
const ctx = makeCtx({ hasUI: false, select: async () => undefined });
|
|
116
|
+
await assert.rejects(
|
|
117
|
+
tool.execute("t3", {
|
|
118
|
+
question: "Ameliorate?",
|
|
119
|
+
options: OPTIONS,
|
|
120
|
+
autoComplete: true,
|
|
121
|
+
trailing: "auto-refine-loop",
|
|
122
|
+
}, undefined, undefined, ctx),
|
|
123
|
+
/No UI available/,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("ask_choice panel fitting", () => {
|
|
129
|
+
it("sanitizes newlines in the question and labels", () => {
|
|
130
|
+
const fitted = fitAskChoicePanel("Q line1\nQ line2", [{ core: "1. a\nb", display: "1. a\nb — desc" }], 100, 30);
|
|
131
|
+
assert.doesNotMatch(fitted.question, /\r?\n/);
|
|
132
|
+
for (const label of fitted.labels) assert.doesNotMatch(label, /\r?\n/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("caps each label at the 3-line budget with a .. marker (CJK included)", () => {
|
|
136
|
+
const columns = 100;
|
|
137
|
+
const budget = OPTION_MAX_LINES * Math.max(20, columns - SELECTOR_WIDTH_OVERHEAD);
|
|
138
|
+
const cjkLabel = `1. ${"选项".repeat(200)}`; // 800 visible columns, far over the budget
|
|
139
|
+
const fitted = fitAskChoicePanel("q", [{ core: cjkLabel, display: cjkLabel }], columns, 60);
|
|
140
|
+
assert.equal(fitted.labels.length, 1);
|
|
141
|
+
assert.ok(visibleWidth(fitted.labels[0]) <= budget, `label exceeds 3-line budget: ${visibleWidth(fitted.labels[0])}`);
|
|
142
|
+
assert.match(fitted.labels[0], /\.\.$/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("degrades: stage 1 strips descriptions before touching labels", () => {
|
|
146
|
+
const columns = 100;
|
|
147
|
+
const rows = 30; // rowBudget = 27 → content budget 17 lines after chrome
|
|
148
|
+
const items = Array.from({ length: 12 }, (_, i) => ({
|
|
149
|
+
core: `${i + 1}. option-${i}`,
|
|
150
|
+
display: `${i + 1}. option-${i} — ${"detail ".repeat(40)}`,
|
|
151
|
+
}));
|
|
152
|
+
const fitted = fitAskChoicePanel("q", items, columns, rows);
|
|
153
|
+
for (const label of fitted.labels) assert.doesNotMatch(label, /detail/);
|
|
154
|
+
assert.match(fitted.labels[0] ?? "", /^1\. option-0/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("degrades: stage 2 collapses labels to one line before truncating the question", () => {
|
|
158
|
+
const columns = 100;
|
|
159
|
+
const rows = 30;
|
|
160
|
+
const items = Array.from({ length: 15 }, (_, i) => ({
|
|
161
|
+
core: `${i + 1}. ${"x".repeat(170)}`,
|
|
162
|
+
display: `${i + 1}. ${"x".repeat(170)} — description`,
|
|
163
|
+
}));
|
|
164
|
+
const fitted = fitAskChoicePanel("the question", items, columns, rows);
|
|
165
|
+
for (const label of fitted.labels) assert.ok(visibleWidth(label) <= Math.max(20, columns - SELECTOR_WIDTH_OVERHEAD));
|
|
166
|
+
assert.equal(fitted.question, "the question"); // question untouched at stage 2
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("warns once when even the minimal form exceeds a tiny terminal", () => {
|
|
170
|
+
const columns = 60;
|
|
171
|
+
const rows = 12; // rowBudget = 9; 30 options can never fit
|
|
172
|
+
const items = Array.from({ length: 30 }, (_, i) => ({ core: `${i + 1}. opt-${i}`, display: `${i + 1}. opt-${i}` }));
|
|
173
|
+
const fitted = fitAskChoicePanel("q", items, columns, rows);
|
|
174
|
+
assert.equal(fitted.overflowWarned, true);
|
|
175
|
+
for (const label of fitted.labels) assert.ok(visibleWidth(label) <= 20);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("degrades: stage 3 truncates the question only after labels are single-lined", () => {
|
|
179
|
+
const columns = 100;
|
|
180
|
+
const rows = 32; // rowBudget = 29; chrome 9 → content budget 20 lines
|
|
181
|
+
const longQuestion = "q".repeat(300);
|
|
182
|
+
const items = Array.from({ length: 18 }, (_, i) => ({ core: `${i + 1}. opt-${i}`, display: `${i + 1}. opt-${i}` }));
|
|
183
|
+
const fitted = fitAskChoicePanel(longQuestion, items, columns, rows);
|
|
184
|
+
assert.equal(fitted.overflowWarned, false); // stops at stage 3, not the minimal form
|
|
185
|
+
assert.ok(visibleWidth(fitted.question) <= Math.max(20, columns - SELECTOR_WIDTH_OVERHEAD));
|
|
186
|
+
assert.match(fitted.question, /\.\.$/);
|
|
187
|
+
assert.match(fitted.labels[0] ?? "", /^1\. opt-0$/); // labels stay single-line and untruncated
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("keeps Other/Auto-complete/Auto-refine prefixes alive even on tiny terminals", () => {
|
|
191
|
+
const columns = 26;
|
|
192
|
+
const rows = 12; // minimal form: 20-column floor
|
|
193
|
+
const items = [
|
|
194
|
+
{ core: "1. keep-current", display: "1. keep-current" },
|
|
195
|
+
{ core: "Other… (type your own answer)", display: "Other… (type your own answer)", fixed: true },
|
|
196
|
+
{ core: "Auto-complete (take the recommended option)", display: "Auto-complete (take the recommended option)", fixed: true },
|
|
197
|
+
{ core: "Auto-refine loop (run refinement rounds until no high-severity finding or the 5-round cap)", display: "Auto-refine loop (run refinement rounds until no high-severity finding or the 5-round cap)", fixed: true },
|
|
198
|
+
];
|
|
199
|
+
const fitted = fitAskChoicePanel("q", items, columns, rows);
|
|
200
|
+
assert.match(fitted.labels[1] ?? "", /^Other…/);
|
|
201
|
+
assert.match(fitted.labels[2] ?? "", /^Auto-complete/);
|
|
202
|
+
assert.match(fitted.labels[3] ?? "", /^Auto-refine loop/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("execute notifies once when the minimal form still overflows a tiny terminal", async () => {
|
|
206
|
+
const tool = loadTool();
|
|
207
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-choice-overflow-"));
|
|
208
|
+
const rowsDesc = Object.getOwnPropertyDescriptor(process.stdout, "rows");
|
|
209
|
+
const colsDesc = Object.getOwnPropertyDescriptor(process.stdout, "columns");
|
|
210
|
+
Object.defineProperty(process.stdout, "rows", { value: 12, configurable: true });
|
|
211
|
+
Object.defineProperty(process.stdout, "columns", { value: 60, configurable: true });
|
|
212
|
+
try {
|
|
213
|
+
const ctx = makeCtx({
|
|
214
|
+
select: async (_question, labels) => labels[0],
|
|
215
|
+
});
|
|
216
|
+
// makeCtx binds cwd at construction; point it at the temp dir via workdir param instead.
|
|
217
|
+
await tool.execute(
|
|
218
|
+
"id",
|
|
219
|
+
{
|
|
220
|
+
question: "Pick one",
|
|
221
|
+
options: Array.from({ length: 30 }, (_, i) => ({ label: `opt-${i}`, description: `${"detail ".repeat(20)}` })),
|
|
222
|
+
autoComplete: false,
|
|
223
|
+
allowOther: false,
|
|
224
|
+
workdir: dir,
|
|
225
|
+
},
|
|
226
|
+
undefined,
|
|
227
|
+
undefined,
|
|
228
|
+
ctx,
|
|
229
|
+
);
|
|
230
|
+
assert.equal(notifyCalls.length, 1);
|
|
231
|
+
assert.match(notifyCalls[0]!.message, /Terminal too small/);
|
|
232
|
+
} finally {
|
|
233
|
+
if (rowsDesc) Object.defineProperty(process.stdout, "rows", rowsDesc);
|
|
234
|
+
if (colsDesc) Object.defineProperty(process.stdout, "columns", colsDesc);
|
|
235
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("pi-tui Text renders every fitted label within 3 lines at real panel width", async () => {
|
|
240
|
+
let Text: any;
|
|
241
|
+
try {
|
|
242
|
+
({ Text } = await import("@earendil-works/pi-tui"));
|
|
243
|
+
} catch {
|
|
244
|
+
return; // D-007 sanctioned fallback: pi-tui not resolvable in this layout
|
|
245
|
+
}
|
|
246
|
+
const columns = 100;
|
|
247
|
+
const rows = 60;
|
|
248
|
+
const realWidth = columns - 4; // border + padding + marker, per extension-selector.js
|
|
249
|
+
const items = Array.from({ length: 8 }, (_, i) => ({
|
|
250
|
+
core: `${i + 1}. option-${i}`,
|
|
251
|
+
display: `${i + 1}. option-${i} — ${"中文描述 " .repeat(30)}mixed ascii tail ${"x".repeat(60)}`,
|
|
252
|
+
}));
|
|
253
|
+
const fitted = fitAskChoicePanel("q", items, columns, rows);
|
|
254
|
+
let totalLines = PANEL_CHROME_LINES + 1; // chrome + question line
|
|
255
|
+
for (const label of fitted.labels) {
|
|
256
|
+
const component = new Text(label, 1, 0);
|
|
257
|
+
const rendered = component.render(realWidth);
|
|
258
|
+
assert.ok(rendered.length <= OPTION_MAX_LINES, `label renders ${rendered.length} lines: ${JSON.stringify(label.slice(0, 60))}`);
|
|
259
|
+
totalLines += rendered.length;
|
|
260
|
+
}
|
|
261
|
+
assert.ok(totalLines < rows - STATUS_BAR_HEIGHT - PANEL_SAFETY_MARGIN);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -132,11 +132,16 @@ describe("ask_choice Auto-complete wiring", () => {
|
|
|
132
132
|
assert.match(source, /isAutoCompleteEnabled/);
|
|
133
133
|
assert.match(source, /recordAskChoice\(ctx, true\)/);
|
|
134
134
|
assert.match(source, /autoComplete && selected\.startsWith\("Auto-complete"\)/);
|
|
135
|
+
// Trailing option: Auto-refine loop replaces Auto-complete and is suppressed in headless sessions.
|
|
136
|
+
assert.match(source, /trailing === undefined/);
|
|
137
|
+
assert.match(source, /AUTO_REFINE_LOOP_LABEL/);
|
|
138
|
+
assert.match(source, /trailing && selected\.startsWith\("Auto-refine loop"\)/);
|
|
135
139
|
const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
|
|
136
140
|
assert.match(indexSource, /plans-autocomplete-stop/);
|
|
137
141
|
assert.match(indexSource, /autoCompleteStatus\(ctx\)/);
|
|
138
142
|
assert.match(indexSource, /restoreAutoCompleteFromSession/);
|
|
139
143
|
const execSource = fs.readFileSync(path.join(process.cwd(), "src", "exec.ts"), "utf8");
|
|
140
|
-
assert.
|
|
144
|
+
assert.doesNotMatch(execSource, /customType === AUTOCOMPLETE_ENTRY/);
|
|
145
|
+
assert.match(execSource, /shouldTriggerPlanningCompaction\(_ctx: ExtensionContext\): boolean \{\n\s+return false;/);
|
|
141
146
|
});
|
|
142
147
|
});
|