@milanglacier/pi-plan-mode 0.5.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 +99 -0
- package/flow.ts +665 -0
- package/index.ts +240 -0
- package/install.mjs +107 -0
- package/package.json +54 -0
- package/plan-files.ts +154 -0
- package/prompts/PLAN.prompt.md +13 -0
- package/prompts.ts +45 -0
- package/qna/index.ts +3 -0
- package/qna/pi-tui-loader.ts +92 -0
- package/qna/qna-tui.ts +877 -0
- package/qna/scroll-select.ts +353 -0
- package/request-user-input.ts +259 -0
- package/schemas.ts +53 -0
- package/state.ts +164 -0
- package/types.ts +36 -0
- package/utils.ts +64 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { requirePiTuiModule } from "./pi-tui-loader.js";
|
|
2
|
+
|
|
3
|
+
let cachedPiTui:
|
|
4
|
+
| {
|
|
5
|
+
Key: {
|
|
6
|
+
enter: string;
|
|
7
|
+
escape: string;
|
|
8
|
+
up: string;
|
|
9
|
+
down: string;
|
|
10
|
+
ctrl: (key: string) => string;
|
|
11
|
+
};
|
|
12
|
+
matchesKey: (input: string, key: string) => boolean;
|
|
13
|
+
truncateToWidth: (text: string, width: number) => string;
|
|
14
|
+
visibleWidth: (text: string) => number;
|
|
15
|
+
wrapTextWithAnsi: (text: string, width: number) => string[];
|
|
16
|
+
}
|
|
17
|
+
| undefined;
|
|
18
|
+
|
|
19
|
+
function getPiTui() {
|
|
20
|
+
if (cachedPiTui) {
|
|
21
|
+
return cachedPiTui;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
cachedPiTui = requirePiTuiModule() as {
|
|
25
|
+
Key: {
|
|
26
|
+
enter: string;
|
|
27
|
+
escape: string;
|
|
28
|
+
up: string;
|
|
29
|
+
down: string;
|
|
30
|
+
ctrl: (key: string) => string;
|
|
31
|
+
};
|
|
32
|
+
matchesKey: (input: string, key: string) => boolean;
|
|
33
|
+
truncateToWidth: (text: string, width: number) => string;
|
|
34
|
+
visibleWidth: (text: string) => number;
|
|
35
|
+
wrapTextWithAnsi: (text: string, width: number) => string[];
|
|
36
|
+
};
|
|
37
|
+
return cachedPiTui;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ScrollSelectOption<T> {
|
|
41
|
+
value: T;
|
|
42
|
+
label: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ScrollSelectSearchConfig<T> {
|
|
46
|
+
title: string;
|
|
47
|
+
placeholder?: string;
|
|
48
|
+
getOptions: (query: string) => Promise<ScrollSelectOption<T>[]> | ScrollSelectOption<T>[];
|
|
49
|
+
emptyMessage?: (query: string) => string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ScrollSelectConfig<T> {
|
|
53
|
+
title: string;
|
|
54
|
+
options: ScrollSelectOption<T>[];
|
|
55
|
+
footerHint?: string;
|
|
56
|
+
emptyMessage?: string;
|
|
57
|
+
initialValue?: T;
|
|
58
|
+
maxVisibleOptions?: number;
|
|
59
|
+
overlayWidth?: number | string;
|
|
60
|
+
overlayMaxHeight?: number | string;
|
|
61
|
+
search?: ScrollSelectSearchConfig<T>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type ScrollSelectUi = {
|
|
65
|
+
custom?: <T>(
|
|
66
|
+
factory: (
|
|
67
|
+
tui: { requestRender: () => void },
|
|
68
|
+
theme: ScrollSelectTheme,
|
|
69
|
+
keybindings: unknown,
|
|
70
|
+
done: (value: T) => void,
|
|
71
|
+
) => {
|
|
72
|
+
render: (width: number) => string[];
|
|
73
|
+
handleInput: (data: string) => void;
|
|
74
|
+
dispose?: () => void;
|
|
75
|
+
},
|
|
76
|
+
options?: unknown,
|
|
77
|
+
) => Promise<T>;
|
|
78
|
+
select?: (title: string, options: string[]) => Promise<string | null | undefined>;
|
|
79
|
+
input?: (title: string, placeholder?: string) => Promise<string | null | undefined>;
|
|
80
|
+
notify?: (message: string, type?: "error" | "info" | "warning") => void;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type ScrollSelectTheme = {
|
|
84
|
+
fg: (color: string, text: string) => string;
|
|
85
|
+
bg?: (color: string, text: string) => string;
|
|
86
|
+
bold: (text: string) => string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
class ScrollSelectComponent<T> {
|
|
90
|
+
focused = false;
|
|
91
|
+
|
|
92
|
+
private readonly tui: { requestRender: () => void };
|
|
93
|
+
private readonly theme: ScrollSelectTheme;
|
|
94
|
+
private readonly done: (value: T | null) => void;
|
|
95
|
+
private readonly input: ScrollSelectUi["input"];
|
|
96
|
+
private readonly notify: ScrollSelectUi["notify"];
|
|
97
|
+
private readonly baseOptions: ScrollSelectOption<T>[];
|
|
98
|
+
private readonly title: string;
|
|
99
|
+
private readonly footerHint: string;
|
|
100
|
+
private readonly emptyMessage: string;
|
|
101
|
+
private readonly maxVisibleOptions: number;
|
|
102
|
+
private readonly search?: ScrollSelectSearchConfig<T>;
|
|
103
|
+
|
|
104
|
+
private options: ScrollSelectOption<T>[];
|
|
105
|
+
private cursorIndex: number;
|
|
106
|
+
private searchQuery = "";
|
|
107
|
+
private searching = false;
|
|
108
|
+
|
|
109
|
+
constructor(
|
|
110
|
+
config: ScrollSelectConfig<T>,
|
|
111
|
+
dependencies: {
|
|
112
|
+
tui: { requestRender: () => void };
|
|
113
|
+
theme: ScrollSelectTheme;
|
|
114
|
+
done: (value: T | null) => void;
|
|
115
|
+
input: ScrollSelectUi["input"];
|
|
116
|
+
notify: ScrollSelectUi["notify"];
|
|
117
|
+
},
|
|
118
|
+
) {
|
|
119
|
+
this.tui = dependencies.tui;
|
|
120
|
+
this.theme = dependencies.theme;
|
|
121
|
+
this.done = dependencies.done;
|
|
122
|
+
this.input = dependencies.input;
|
|
123
|
+
this.notify = dependencies.notify;
|
|
124
|
+
this.baseOptions = [...config.options];
|
|
125
|
+
this.options = [...config.options];
|
|
126
|
+
this.title = config.title;
|
|
127
|
+
this.footerHint = config.footerHint ?? "";
|
|
128
|
+
this.emptyMessage = config.emptyMessage ?? "No options available.";
|
|
129
|
+
this.maxVisibleOptions = Math.max(4, config.maxVisibleOptions ?? 10);
|
|
130
|
+
this.search = config.search;
|
|
131
|
+
this.cursorIndex = this.getInitialCursorIndex(config.initialValue);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
render(width: number): string[] {
|
|
135
|
+
const { truncateToWidth, visibleWidth, wrapTextWithAnsi } = getPiTui();
|
|
136
|
+
const safeWidth = Math.max(20, width);
|
|
137
|
+
const contentWidth = Math.max(12, safeWidth - 2);
|
|
138
|
+
const lines: string[] = [];
|
|
139
|
+
const selectedLineIndexes = new Set<number>();
|
|
140
|
+
|
|
141
|
+
for (const line of this.title.split("\n")) {
|
|
142
|
+
for (const wrapped of wrapTextWithAnsi(line, contentWidth)) {
|
|
143
|
+
lines.push(truncateToWidth(wrapped, contentWidth));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
lines.push("");
|
|
148
|
+
|
|
149
|
+
if (this.search) {
|
|
150
|
+
const filterText = this.searchQuery.trim().length > 0 ? `Filter: ${this.searchQuery}` : "Filter: all";
|
|
151
|
+
lines.push(truncateToWidth(this.theme.fg("dim", filterText), contentWidth));
|
|
152
|
+
lines.push("");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (this.options.length === 0) {
|
|
156
|
+
lines.push(truncateToWidth(this.theme.fg("dim", this.emptyMessage), contentWidth));
|
|
157
|
+
} else {
|
|
158
|
+
const { start, end } = this.getVisibleRange();
|
|
159
|
+
|
|
160
|
+
if (start > 0) {
|
|
161
|
+
lines.push(truncateToWidth(this.theme.fg("dim", `↑ ${start} more`), contentWidth));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (let index = start; index < end; index++) {
|
|
165
|
+
const option = this.options[index];
|
|
166
|
+
if (!option) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const prefix = index === this.cursorIndex ? this.theme.fg("accent", "→ ") : " ";
|
|
171
|
+
const availableWidth = Math.max(4, contentWidth - visibleWidth(prefix));
|
|
172
|
+
const label = truncateToWidth(option.label, availableWidth);
|
|
173
|
+
const line = `${prefix}${index === this.cursorIndex ? this.theme.fg("accent", label) : label}`;
|
|
174
|
+
lines.push(truncateToWidth(line, contentWidth));
|
|
175
|
+
if (index === this.cursorIndex) {
|
|
176
|
+
selectedLineIndexes.add(lines.length - 1);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const hiddenBelow = this.options.length - end;
|
|
181
|
+
if (hiddenBelow > 0) {
|
|
182
|
+
lines.push(truncateToWidth(this.theme.fg("dim", `↓ ${hiddenBelow} more`), contentWidth));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
lines.push("");
|
|
187
|
+
lines.push(truncateToWidth(this.footerText(), contentWidth));
|
|
188
|
+
return lines.map((line, index) =>
|
|
189
|
+
this.renderSurfaceLine(line, contentWidth, {
|
|
190
|
+
selected: selectedLineIndexes.has(index),
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
handleInput(data: string): void {
|
|
196
|
+
const { Key, matchesKey } = getPiTui();
|
|
197
|
+
|
|
198
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
199
|
+
this.done(null);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (matchesKey(data, Key.enter) || data === "\r") {
|
|
204
|
+
this.done(this.options[this.cursorIndex]?.value ?? null);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (matchesKey(data, Key.up) || data === "k" || matchesKey(data, Key.ctrl("p"))) {
|
|
209
|
+
this.moveCursor(-1);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (matchesKey(data, Key.down) || data === "j" || matchesKey(data, Key.ctrl("n"))) {
|
|
214
|
+
this.moveCursor(1);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (this.search && (data === "/" || data === "s")) {
|
|
219
|
+
void this.promptSearch();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
invalidate(): void {}
|
|
224
|
+
|
|
225
|
+
dispose(): void {}
|
|
226
|
+
|
|
227
|
+
private renderSurfaceLine(line: string, contentWidth: number, options: { selected?: boolean } = {}): string {
|
|
228
|
+
const { visibleWidth } = getPiTui();
|
|
229
|
+
const paddedLine = `${line}${" ".repeat(Math.max(0, contentWidth - visibleWidth(line)))}`;
|
|
230
|
+
const boxedLine = ` ${paddedLine} `;
|
|
231
|
+
if (typeof this.theme.bg !== "function") {
|
|
232
|
+
return boxedLine;
|
|
233
|
+
}
|
|
234
|
+
return this.theme.bg(options.selected ? "selectedBg" : "customMessageBg", boxedLine);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private footerText(): string {
|
|
238
|
+
const parts = ["[↑↓/j/k] scroll", "[enter] select", "[esc] cancel"];
|
|
239
|
+
if (this.search) {
|
|
240
|
+
parts.push("[/] search");
|
|
241
|
+
}
|
|
242
|
+
if (this.footerHint.trim().length > 0) {
|
|
243
|
+
parts.push(this.footerHint.trim());
|
|
244
|
+
}
|
|
245
|
+
return this.theme.fg("dim", parts.join(" • "));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private getInitialCursorIndex(initialValue: T | undefined): number {
|
|
249
|
+
if (initialValue === undefined) {
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const index = this.baseOptions.findIndex((option) => Object.is(option.value, initialValue));
|
|
254
|
+
return index >= 0 ? index : 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private getVisibleRange(): { start: number; end: number } {
|
|
258
|
+
const count = this.options.length;
|
|
259
|
+
const visible = Math.min(this.maxVisibleOptions, count);
|
|
260
|
+
if (count <= visible) {
|
|
261
|
+
return { start: 0, end: count };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let start = Math.max(0, this.cursorIndex - Math.floor(visible / 2));
|
|
265
|
+
start = Math.min(start, count - visible);
|
|
266
|
+
return { start, end: Math.min(count, start + visible) };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private moveCursor(delta: number): void {
|
|
270
|
+
if (this.options.length === 0) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
this.cursorIndex = Math.max(0, Math.min(this.options.length - 1, this.cursorIndex + delta));
|
|
275
|
+
this.tui.requestRender();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async promptSearch(): Promise<void> {
|
|
279
|
+
if (!this.search || typeof this.input !== "function" || this.searching) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
this.searching = true;
|
|
284
|
+
try {
|
|
285
|
+
const raw = await this.input(this.search.title, this.searchQuery || this.search.placeholder);
|
|
286
|
+
if (raw === null || raw === undefined) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const query = raw.trim();
|
|
291
|
+
if (query === this.searchQuery) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const nextOptions = await this.search.getOptions(query);
|
|
296
|
+
if (nextOptions.length === 0) {
|
|
297
|
+
this.notify?.(
|
|
298
|
+
this.search.emptyMessage?.(query) ?? `No option matched ${query ? `"${query}"` : "the current filter"}.`,
|
|
299
|
+
"warning",
|
|
300
|
+
);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
this.searchQuery = query;
|
|
305
|
+
this.options = [...nextOptions];
|
|
306
|
+
this.cursorIndex = 0;
|
|
307
|
+
this.tui.requestRender();
|
|
308
|
+
} finally {
|
|
309
|
+
this.searching = false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function openScrollableSelect<T>(ui: ScrollSelectUi, config: ScrollSelectConfig<T>): Promise<T | null> {
|
|
315
|
+
if (config.options.length === 0) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (config.options.length === 1) {
|
|
320
|
+
return config.options[0]?.value ?? null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (typeof ui.custom !== "function") {
|
|
324
|
+
if (typeof ui.select !== "function") {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const selected = await ui.select(
|
|
329
|
+
config.title,
|
|
330
|
+
config.options.map((option) => option.label),
|
|
331
|
+
);
|
|
332
|
+
return config.options.find((option) => option.label === selected)?.value ?? null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return await ui.custom<T | null>(
|
|
336
|
+
(tui, theme, _keybindings, done) =>
|
|
337
|
+
new ScrollSelectComponent(config, {
|
|
338
|
+
tui,
|
|
339
|
+
theme,
|
|
340
|
+
done,
|
|
341
|
+
input: ui.input,
|
|
342
|
+
notify: ui.notify,
|
|
343
|
+
}),
|
|
344
|
+
{
|
|
345
|
+
overlay: true,
|
|
346
|
+
overlayOptions: {
|
|
347
|
+
anchor: "center",
|
|
348
|
+
width: config.overlayWidth ?? 84,
|
|
349
|
+
maxHeight: config.overlayMaxHeight ?? "75%",
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type { QnAResponse, QnAResult } from "./qna";
|
|
2
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { QnATuiComponent, requirePiTuiModule } from "./qna";
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
NormalizedRequestUserInputQuestion,
|
|
9
|
+
PlanModeState,
|
|
10
|
+
RequestUserInputAnswer,
|
|
11
|
+
RequestUserInputDetails,
|
|
12
|
+
RequestUserInputQuestion,
|
|
13
|
+
RequestUserInputResponse,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
import { findDuplicateId } from "./utils";
|
|
17
|
+
|
|
18
|
+
function createText(text: string) {
|
|
19
|
+
const { Text } = requirePiTuiModule() as {
|
|
20
|
+
Text: new (text: string, x: number, y: number) => unknown;
|
|
21
|
+
};
|
|
22
|
+
return new Text(text, 0, 0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeRequestUserInputQuestions(
|
|
26
|
+
rawQuestions: RequestUserInputQuestion[],
|
|
27
|
+
): { questions: NormalizedRequestUserInputQuestion[] } | { error: string } {
|
|
28
|
+
const questions: NormalizedRequestUserInputQuestion[] = rawQuestions.map((question) => ({
|
|
29
|
+
...question,
|
|
30
|
+
id: question.id.trim(),
|
|
31
|
+
options: question.options ?? [],
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
for (const question of questions) {
|
|
35
|
+
if (!question.id) {
|
|
36
|
+
return { error: "request_user_input question ids must be non-empty." };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const duplicateQuestionId = findDuplicateId(questions.map((question) => question.id));
|
|
41
|
+
if (duplicateQuestionId) {
|
|
42
|
+
return {
|
|
43
|
+
error: `request_user_input question ids must be unique. Duplicate id: ${duplicateQuestionId}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { questions };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildRequestUserInputAnswer(
|
|
51
|
+
question: NormalizedRequestUserInputQuestion,
|
|
52
|
+
response: QnAResponse,
|
|
53
|
+
): RequestUserInputAnswer {
|
|
54
|
+
const hasOptions = question.options.length > 0;
|
|
55
|
+
const otherIndex = question.options.length;
|
|
56
|
+
const trimmed = response.customText.trim();
|
|
57
|
+
|
|
58
|
+
if (!hasOptions) {
|
|
59
|
+
if (trimmed.length === 0) {
|
|
60
|
+
return { answers: [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { answers: [`user_note: ${trimmed}`] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (response.selectedOptionIndex === otherIndex) {
|
|
67
|
+
if (trimmed.length === 0) {
|
|
68
|
+
return { answers: [] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { answers: ["Other", `user_note: ${trimmed}`] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const label = question.options[response.selectedOptionIndex]?.label;
|
|
75
|
+
if (!label) {
|
|
76
|
+
return { answers: [] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { answers: [label] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function buildRequestUserInputResponse(
|
|
83
|
+
questions: NormalizedRequestUserInputQuestion[],
|
|
84
|
+
responses: QnAResponse[],
|
|
85
|
+
): RequestUserInputResponse {
|
|
86
|
+
const answers: Record<string, RequestUserInputAnswer> = {};
|
|
87
|
+
for (let i = 0; i < questions.length; i++) {
|
|
88
|
+
answers[questions[i].id] = buildRequestUserInputAnswer(questions[i], responses[i]);
|
|
89
|
+
}
|
|
90
|
+
return { answers };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function summarizeRequestUserInputAnswer(answer: RequestUserInputAnswer | undefined): string {
|
|
94
|
+
const entries = answer?.answers ?? [];
|
|
95
|
+
if (entries.length === 0) {
|
|
96
|
+
return "(no answer)";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const notes = entries
|
|
100
|
+
.filter((entry) => entry.startsWith("user_note: "))
|
|
101
|
+
.map((entry) => entry.slice("user_note: ".length).trim())
|
|
102
|
+
.filter((entry) => entry.length > 0);
|
|
103
|
+
const selected = entries
|
|
104
|
+
.filter((entry) => !entry.startsWith("user_note: "))
|
|
105
|
+
.map((entry) => entry.trim())
|
|
106
|
+
.filter((entry) => entry.length > 0);
|
|
107
|
+
|
|
108
|
+
if (selected.length === 0 && notes.length > 0) {
|
|
109
|
+
return notes.join(" · ");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (selected.length > 0 && notes.length > 0) {
|
|
113
|
+
return `${selected.join(", ")} (${notes.join(" · ")})`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return selected.join(", ") || "(no answer)";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildRequestUserInputSummary(details: RequestUserInputDetails): string {
|
|
120
|
+
const lines: string[] = [];
|
|
121
|
+
for (let i = 0; i < details.questions.length; i++) {
|
|
122
|
+
const question = details.questions[i];
|
|
123
|
+
const answer = details.response.answers[question.id];
|
|
124
|
+
lines.push(`${i + 1}. ${question.question}`);
|
|
125
|
+
lines.push(` Answer: ${summarizeRequestUserInputAnswer(answer)}`);
|
|
126
|
+
}
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function collectRequestUserInputAnswers(
|
|
131
|
+
ctx: ExtensionContext,
|
|
132
|
+
questions: NormalizedRequestUserInputQuestion[],
|
|
133
|
+
): Promise<RequestUserInputResponse | null> {
|
|
134
|
+
const result = await ctx.ui.custom<QnAResult | null>(
|
|
135
|
+
(tui, theme, _kb, done) =>
|
|
136
|
+
new QnATuiComponent(questions, tui, done, {
|
|
137
|
+
title: "Questions",
|
|
138
|
+
questionSummaryLabel: (question) => question.header?.trim() || question.question,
|
|
139
|
+
accentColor: (text) => theme.fg("accent", text),
|
|
140
|
+
successColor: (text) => theme.fg("success", text),
|
|
141
|
+
warningColor: (text) => theme.fg("warning", text),
|
|
142
|
+
mutedColor: (text) => theme.fg("muted", text),
|
|
143
|
+
dimColor: (text) => theme.fg("dim", text),
|
|
144
|
+
boldText: (text) => theme.bold(text),
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (!result) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return buildRequestUserInputResponse(questions, result.responses);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function registerRequestUserInputTool(
|
|
156
|
+
pi: ExtensionAPI,
|
|
157
|
+
dependencies: {
|
|
158
|
+
getState: () => PlanModeState;
|
|
159
|
+
requestUserInputSchema: unknown;
|
|
160
|
+
},
|
|
161
|
+
) {
|
|
162
|
+
pi.registerTool({
|
|
163
|
+
description:
|
|
164
|
+
"Request user input for one to three short questions and wait for the response. This tool is only available in Plan mode.",
|
|
165
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<RequestUserInputDetails>> {
|
|
166
|
+
if (!dependencies.getState().active) {
|
|
167
|
+
return {
|
|
168
|
+
isError: true,
|
|
169
|
+
content: [
|
|
170
|
+
{
|
|
171
|
+
type: "text",
|
|
172
|
+
text: "request_user_input is unavailable when plan mode is inactive",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!ctx.hasUI) {
|
|
179
|
+
return {
|
|
180
|
+
isError: true,
|
|
181
|
+
content: [
|
|
182
|
+
{
|
|
183
|
+
type: "text",
|
|
184
|
+
text: "request_user_input requires interactive mode",
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const normalized = normalizeRequestUserInputQuestions(params.questions as RequestUserInputQuestion[]);
|
|
191
|
+
if ("error" in normalized) {
|
|
192
|
+
return {
|
|
193
|
+
isError: true,
|
|
194
|
+
content: [{ type: "text", text: normalized.error }],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const response = await collectRequestUserInputAnswers(ctx, normalized.questions);
|
|
199
|
+
if (!response) {
|
|
200
|
+
return {
|
|
201
|
+
isError: true,
|
|
202
|
+
content: [
|
|
203
|
+
{
|
|
204
|
+
type: "text",
|
|
205
|
+
text: "request_user_input was cancelled before receiving a response",
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const details: RequestUserInputDetails = {
|
|
212
|
+
questions: normalized.questions,
|
|
213
|
+
response,
|
|
214
|
+
};
|
|
215
|
+
return {
|
|
216
|
+
content: [
|
|
217
|
+
{
|
|
218
|
+
type: "text",
|
|
219
|
+
text: buildRequestUserInputSummary(details),
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
details,
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
label: "request_user_input",
|
|
226
|
+
name: "request_user_input",
|
|
227
|
+
parameters: dependencies.requestUserInputSchema,
|
|
228
|
+
renderCall(args, theme) {
|
|
229
|
+
const questions = ((args.questions as RequestUserInputQuestion[] | undefined) ?? []).length;
|
|
230
|
+
const label = `${questions} question${questions === 1 ? "" : "s"}`;
|
|
231
|
+
return createText(`${theme.fg("toolTitle", theme.bold("request_user_input "))}${theme.fg("muted", label)}`);
|
|
232
|
+
},
|
|
233
|
+
renderResult(result, { isPartial }, theme) {
|
|
234
|
+
if (isPartial) {
|
|
235
|
+
return createText(theme.fg("muted", "Waiting for user input..."));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const details = result.details as RequestUserInputDetails | undefined;
|
|
239
|
+
if (!details) {
|
|
240
|
+
const text = result.content.find((item) => item.type === "text");
|
|
241
|
+
return createText(text?.type === "text" ? text.text : "(no output)");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const lines: string[] = [];
|
|
245
|
+
for (let i = 0; i < details.questions.length; i++) {
|
|
246
|
+
const question = details.questions[i];
|
|
247
|
+
const answer = summarizeRequestUserInputAnswer(details.response.answers[question.id]);
|
|
248
|
+
lines.push(`${theme.fg("accent", `${i + 1}.`)} ${question.question}`);
|
|
249
|
+
if (answer === "(no answer)") {
|
|
250
|
+
lines.push(` ${theme.fg("muted", "Answer:")} ${theme.fg("warning", answer)}`);
|
|
251
|
+
} else {
|
|
252
|
+
lines.push(` ${theme.fg("muted", "Answer:")} ${answer}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return createText(lines.join("\n"));
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
}
|
package/schemas.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
export const SetPlanSchema = Type.Object(
|
|
4
|
+
{
|
|
5
|
+
plan: Type.String({
|
|
6
|
+
description:
|
|
7
|
+
"Full plan document text. This overwrites the current plan file and should include the complete latest plan.",
|
|
8
|
+
}),
|
|
9
|
+
},
|
|
10
|
+
{ additionalProperties: false },
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export const RequestUserInputOptionSchema = Type.Object(
|
|
14
|
+
{
|
|
15
|
+
description: Type.String({
|
|
16
|
+
description: "One short sentence explaining impact/tradeoff if selected.",
|
|
17
|
+
}),
|
|
18
|
+
label: Type.String({ description: "User-facing label (1-5 words)." }),
|
|
19
|
+
},
|
|
20
|
+
{ additionalProperties: false },
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const RequestUserInputQuestionSchema = Type.Object(
|
|
24
|
+
{
|
|
25
|
+
header: Type.String({
|
|
26
|
+
description: "Short header label shown in the UI (12 or fewer chars).",
|
|
27
|
+
}),
|
|
28
|
+
id: Type.String({
|
|
29
|
+
description: "Stable identifier for mapping answers (snake_case).",
|
|
30
|
+
}),
|
|
31
|
+
options: Type.Optional(
|
|
32
|
+
Type.Array(RequestUserInputOptionSchema, {
|
|
33
|
+
description:
|
|
34
|
+
"Optional multiple-choice options. When omitted or empty, the question is treated as open-ended and accepts freeform input.",
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
question: Type.String({
|
|
38
|
+
description: "Single-sentence prompt shown to the user.",
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
{ additionalProperties: false },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
export const RequestUserInputSchema = Type.Object(
|
|
45
|
+
{
|
|
46
|
+
questions: Type.Array(RequestUserInputQuestionSchema, {
|
|
47
|
+
description: "Questions to show the user. Prefer 1 and do not exceed 3.",
|
|
48
|
+
maxItems: 3,
|
|
49
|
+
minItems: 1,
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
{ additionalProperties: false },
|
|
53
|
+
);
|