pi-plans 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +74 -21
  2. package/index.ts +115 -9
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +18 -3
  5. package/references/state-and-config.md +34 -2
  6. package/scripts/validate.ts +4 -0
  7. package/src/code-graph/commands.ts +437 -0
  8. package/src/code-graph/discovery.ts +118 -0
  9. package/src/code-graph/git.ts +108 -0
  10. package/src/code-graph/identity.ts +59 -0
  11. package/src/code-graph/indexer.ts +281 -0
  12. package/src/code-graph/materialize.ts +166 -0
  13. package/src/code-graph/mode.ts +28 -0
  14. package/src/code-graph/mutations.ts +160 -0
  15. package/src/code-graph/parser.ts +51 -0
  16. package/src/code-graph/parsers/javascript.ts +35 -0
  17. package/src/code-graph/parsers/python.ts +160 -0
  18. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  19. package/src/code-graph/paths.ts +85 -0
  20. package/src/code-graph/prompts.ts +18 -0
  21. package/src/code-graph/resolver.ts +69 -0
  22. package/src/code-graph/runtime.ts +158 -0
  23. package/src/code-graph/schema.ts +135 -0
  24. package/src/code-graph/screening.ts +82 -0
  25. package/src/code-graph/store.ts +278 -0
  26. package/src/code-graph/summary.ts +435 -0
  27. package/src/code-graph/types.ts +163 -0
  28. package/src/compaction.ts +1125 -371
  29. package/src/config-command.ts +326 -0
  30. package/src/exec.ts +356 -686
  31. package/src/refine-prompts.ts +50 -0
  32. package/src/refine-ui-helpers.ts +71 -18
  33. package/src/refine-ui-state.ts +87 -21
  34. package/src/refine-ui.ts +210 -102
  35. package/src/state.ts +19 -6
  36. package/src/subagent.ts +163 -61
  37. package/tests/ask-choice.test.ts +263 -0
  38. package/tests/autocomplete.test.ts +6 -1
  39. package/tests/code-graph-apply.test.ts +185 -0
  40. package/tests/code-graph-commands.test.ts +211 -0
  41. package/tests/code-graph-db.test.ts +166 -0
  42. package/tests/code-graph-discovery.test.ts +38 -0
  43. package/tests/code-graph-git.test.ts +94 -0
  44. package/tests/code-graph-index.test.ts +175 -0
  45. package/tests/code-graph-loop.e2e.test.ts +159 -0
  46. package/tests/code-graph-mutations.test.ts +117 -0
  47. package/tests/code-graph-parser.test.ts +85 -0
  48. package/tests/code-graph-rollback.test.ts +100 -0
  49. package/tests/code-graph-summary-batching.test.ts +518 -0
  50. package/tests/code-graph-summary.test.ts +148 -0
  51. package/tests/compaction.test.ts +371 -57
  52. package/tests/config-command.test.ts +255 -0
  53. package/tests/exec.test.ts +665 -241
  54. package/tests/fixtures/code-graph/sample.js +36 -0
  55. package/tests/fixtures/code-graph/sample.py +20 -0
  56. package/tests/fixtures/code-graph/sample.ts +15 -0
  57. package/tests/graph-aware-file-tools.test.ts +411 -0
  58. package/tests/refine-prompts.test.ts +67 -2
  59. package/tests/refine-ui.test.ts +337 -72
  60. package/tests/subagent.test.ts +26 -20
  61. package/tools/ask-choice.ts +158 -11
  62. package/tools/code-graph.ts +254 -0
  63. package/tools/graph-aware-file-tools.ts +392 -0
  64. package/tools/plans.ts +84 -1
  65. package/tools/refine.ts +61 -15
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: "tool";
18
+ type: "transcript";
18
19
  phase: "start" | "update" | "end";
19
- toolCallId: string;
20
- toolName: string;
21
- detail: string;
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?: Array<{ type: string; text?: string; thinking?: string }> | string;
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
- function messageText(message: MessageLike | undefined, kind: "text" | "thinking" = "text"): string {
103
- if (!message?.content) return "";
104
- if (typeof message.content === "string") return kind === "text" ? message.content : "";
105
- return message.content
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 messageEventText(value: unknown): string {
112
- if (!value || typeof value !== "object") return "";
113
- const event = value as { delta?: unknown; content?: unknown };
114
- return typeof event.delta === "string" ? event.delta : typeof event.content === "string" ? event.content : "";
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 preview(value: unknown, maxLength = 240): string {
118
- if (typeof value === "string") return value.slice(0, maxLength);
119
- if (value === undefined) return "";
120
- try {
121
- const text = JSON.stringify(value);
122
- return text ? text.slice(0, maxLength) : "";
123
- } catch {
124
- return String(value).slice(0, maxLength);
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 | undefined {
134
- if (!value || typeof value !== "object") return undefined;
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 undefined;
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 === "message_start" || event.type === "message_update" || event.type === "message_end") {
142
- if (!event.message && !event.assistantMessageEvent) return undefined;
143
- const role = event.message?.role ?? "assistant";
144
- return {
145
- type: "message",
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
- const toolEvent =
153
- event.type === "tool_execution_start"
154
- ? "start"
155
- : event.type === "tool_execution_update"
156
- ? "update"
157
- : event.type === "tool_execution_end" || event.type === "tool_result_end"
158
- ? "end"
159
- : null;
160
- if (toolEvent) {
161
- return {
162
- type: "tool",
163
- phase: toolEvent,
164
- toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : "unknown",
165
- toolName: typeof event.toolName === "string" ? event.toolName : "tool",
166
- detail: preview(event.args ?? event.partialResult ?? event.result),
167
- ...(typeof event.isError === "boolean" ? { isError: event.isError } : {}),
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 undefined;
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 = 15 * 60 * 1000;
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
- if (progress) emitProgress(options, progress);
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.match(execSource, /customType === AUTOCOMPLETE_ENTRY/);
144
+ assert.doesNotMatch(execSource, /customType === AUTOCOMPLETE_ENTRY/);
145
+ assert.match(execSource, /shouldTriggerPlanningCompaction\(_ctx: ExtensionContext\): boolean \{\n\s+return false;/);
141
146
  });
142
147
  });