flake-update-20260505
1/**
2 * Ask User Tool
3 *
4 * Allows the AI to ask the user a question and wait for their response.
5 * Supports free-form text input, single-choice suggestions, and
6 * multi-select checkboxes.
7 *
8 * Usage by the AI:
9 * ask_user({ question: "Which database should I use?" })
10 * ask_user({ question: "How should I handle errors?", suggestions: ["retry", "fail fast", "log and continue"] })
11 * ask_user({ question: "Which features?", suggestions: ["auth", "logging", "caching"], multiSelect: true })
12 */
13
14import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent";
16import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui";
17import { Type } from "@sinclair/typebox";
18
19export default function (pi: ExtensionAPI) {
20 pi.registerTool({
21 name: "AskUserQuestion",
22 label: "Ask User",
23 description:
24 "Ask the user a question and wait for their response. Use when you need clarification, a decision, or user input to proceed. Supports optional suggestions the user can pick from or ignore.",
25 promptGuidelines: [
26 "Use ask_user when you need user input, clarification, or a decision before proceeding.",
27 "Prefer ask_user over guessing when the choice significantly affects the outcome.",
28 "Keep questions concise and specific. Provide suggestions when there are obvious options.",
29 "Use multiSelect: true when the user may want to pick more than one option from the suggestions.",
30 ],
31 parameters: Type.Object({
32 question: Type.String({ description: "The question to ask the user" }),
33 suggestions: Type.Optional(
34 Type.Array(Type.String(), {
35 description:
36 "Optional list of suggested answers. User can pick one or type their own response.",
37 }),
38 ),
39 multiSelect: Type.Optional(
40 Type.Boolean({
41 description:
42 "If true, user can select multiple suggestions (checkbox-style). Requires suggestions to be provided.",
43 }),
44 ),
45 }),
46
47 async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
48 if (!ctx.hasUI) {
49 return {
50 content: [
51 {
52 type: "text",
53 text: "Error: Cannot ask user — running in non-interactive mode",
54 },
55 ],
56 details: { question: params.question, answer: null },
57 };
58 }
59
60 const { question, suggestions, multiSelect } = params;
61 const hasSuggestions = suggestions && suggestions.length > 0;
62
63 let answer: string | undefined;
64
65 if (multiSelect && hasSuggestions) {
66 // Multi-select: checkbox-style toggles using SettingsList
67 const selected = new Set<string>();
68
69 const result = await ctx.ui.custom<string[] | null>((tui, theme, _kb, done) => {
70 const container = new Container();
71
72 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
73 container.addChild(new Text(theme.fg("accent", theme.bold(question)), 1, 0));
74 container.addChild(new Text(theme.fg("dim", "Space/Enter toggle • Tab confirm • Esc cancel"), 1, 0));
75
76 const items: SettingItem[] = suggestions.map((s) => ({
77 id: s,
78 label: s,
79 currentValue: "☐",
80 values: ["☐", "☑"],
81 }));
82
83 const settingsList = new SettingsList(
84 items,
85 Math.min(items.length + 2, 15),
86 getSettingsListTheme(),
87 (id, newValue) => {
88 if (newValue === "☑") {
89 selected.add(id);
90 } else {
91 selected.delete(id);
92 }
93 },
94 () => done(null), // Esc → cancel
95 );
96 container.addChild(settingsList);
97
98 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
99
100 return {
101 render: (w) => container.render(w),
102 invalidate: () => container.invalidate(),
103 handleInput: (data) => {
104 // Tab = confirm selections
105 if (data === "\t") {
106 done(Array.from(selected));
107 return;
108 }
109 settingsList.handleInput(data);
110 tui.requestRender();
111 },
112 };
113 });
114
115 if (result === null || result.length === 0) {
116 return {
117 content: [{ type: "text", text: "User cancelled — did not select any options." }],
118 details: { question, suggestions, multiSelect: true, answer: null, selected: [] },
119 };
120 }
121
122 answer = result.join(", ");
123 return {
124 content: [{ type: "text", text: `User selected: ${answer}` }],
125 details: { question, suggestions, multiSelect: true, answer, selected: result },
126 };
127 } else if (hasSuggestions) {
128 // Single-select: show suggestions as selectable options + free-form input
129 const freeFormOption = "✎ Type a different answer…";
130 const options = [...suggestions, freeFormOption];
131 const choice = await ctx.ui.select(question, options);
132
133 if (choice === undefined) {
134 return {
135 content: [{ type: "text", text: "User cancelled — did not answer the question." }],
136 details: { question, suggestions, answer: null },
137 };
138 }
139
140 if (choice === freeFormOption) {
141 answer = await ctx.ui.input(question);
142 } else {
143 answer = choice;
144 }
145 } else {
146 // Free-form text input
147 answer = await ctx.ui.input(question);
148 }
149
150 if (answer === undefined || answer.trim() === "") {
151 return {
152 content: [{ type: "text", text: "User cancelled — did not answer the question." }],
153 details: { question, suggestions, answer: null },
154 };
155 }
156
157 return {
158 content: [{ type: "text", text: `User answered: ${answer}` }],
159 details: { question, suggestions, answer },
160 };
161 },
162 });
163}