main
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 "@earendil-works/pi-coding-agent";
15import { DynamicBorder, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
17import { Type } from "@sinclair/typebox";
18
19/**
20 * Serializes TUI access across concurrent tool calls.
21 *
22 * The harness may execute several tool calls from a single assistant turn
23 * concurrently. Since AskUserQuestion grabs the single TUI input focus
24 * (ctx.ui.custom/select/input), running more than one at once deadlocks:
25 * the overlays fight for focus and none ever resolves, so no tool results
26 * are produced and the turn hangs forever.
27 *
28 * This mutex chains executions so each question is presented (and answered)
29 * before the next one starts.
30 */
31let uiLock: Promise<void> = Promise.resolve();
32
33function acquireUILock(): Promise<() => void> {
34 let release!: () => void;
35 const next = new Promise<void>((resolve) => {
36 release = resolve;
37 });
38 const prev = uiLock;
39 uiLock = uiLock.then(() => next);
40 return prev.then(() => release);
41}
42
43export default function (pi: ExtensionAPI) {
44 pi.registerTool({
45 name: "AskUserQuestion",
46 label: "Ask User",
47 description:
48 "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.",
49 promptGuidelines: [
50 "Use ask_user when you need user input, clarification, or a decision before proceeding.",
51 "Prefer ask_user over guessing when the choice significantly affects the outcome.",
52 "Keep questions concise and specific. Provide suggestions when there are obvious options.",
53 "Use multiSelect: true when the user may want to pick more than one option from the suggestions.",
54 ],
55 parameters: Type.Object({
56 question: Type.String({ description: "The question to ask the user" }),
57 suggestions: Type.Optional(
58 Type.Array(Type.String(), {
59 description:
60 "Optional list of suggested answers. User can pick one or type their own response.",
61 }),
62 ),
63 multiSelect: Type.Optional(
64 Type.Boolean({
65 description:
66 "If true, user can select multiple suggestions (checkbox-style). Requires suggestions to be provided.",
67 }),
68 ),
69 }),
70
71 async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
72 if (!ctx.hasUI) {
73 return {
74 content: [
75 {
76 type: "text",
77 text: "Error: Cannot ask user — running in non-interactive mode",
78 },
79 ],
80 details: { question: params.question, answer: null },
81 };
82 }
83
84 // Serialize: if the model asked several questions in one turn, present
85 // them sequentially instead of letting the overlays deadlock.
86 const release = await acquireUILock();
87 try {
88 const { question, suggestions, multiSelect } = params;
89 const hasSuggestions = suggestions && suggestions.length > 0;
90
91 let answer: string | undefined;
92
93 if (multiSelect && hasSuggestions) {
94 // Multi-select: checkbox-style toggles using SettingsList
95 const selected = new Set<string>();
96
97 const result = await ctx.ui.custom<string[] | null>((tui, theme, _kb, done) => {
98 const container = new Container();
99
100 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
101 container.addChild(new Text(theme.fg("accent", theme.bold(question)), 1, 0));
102 container.addChild(new Text(theme.fg("dim", "Space/Enter toggle • Tab confirm • Esc cancel"), 1, 0));
103
104 const items: SettingItem[] = suggestions.map((s) => ({
105 id: s,
106 label: s,
107 currentValue: "☐",
108 values: ["☐", "☑"],
109 }));
110
111 const settingsList = new SettingsList(
112 items,
113 Math.min(items.length + 2, 15),
114 getSettingsListTheme(),
115 (id, newValue) => {
116 if (newValue === "☑") {
117 selected.add(id);
118 } else {
119 selected.delete(id);
120 }
121 },
122 () => done(null), // Esc → cancel
123 );
124 container.addChild(settingsList);
125
126 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
127
128 return {
129 render: (w) => container.render(w),
130 invalidate: () => container.invalidate(),
131 handleInput: (data) => {
132 // Tab = confirm selections
133 if (data === "\t") {
134 done(Array.from(selected));
135 return;
136 }
137 settingsList.handleInput(data);
138 tui.requestRender();
139 },
140 };
141 });
142
143 if (result === null || result.length === 0) {
144 return {
145 content: [{ type: "text", text: "User cancelled — did not select any options." }],
146 details: { question, suggestions, multiSelect: true, answer: null, selected: [] },
147 };
148 }
149
150 answer = result.join(", ");
151 return {
152 content: [{ type: "text", text: `User selected: ${answer}` }],
153 details: { question, suggestions, multiSelect: true, answer, selected: result },
154 };
155 } else if (hasSuggestions) {
156 // Single-select: show suggestions as selectable options + free-form input
157 const freeFormOption = "✎ Type a different answer…";
158 const options = [...suggestions, freeFormOption];
159 const choice = await ctx.ui.select(question, options);
160
161 if (choice === undefined) {
162 return {
163 content: [{ type: "text", text: "User cancelled — did not answer the question." }],
164 details: { question, suggestions, answer: null },
165 };
166 }
167
168 if (choice === freeFormOption) {
169 answer = await ctx.ui.input(question);
170 } else {
171 answer = choice;
172 }
173 } else {
174 // Free-form text input
175 answer = await ctx.ui.input(question);
176 }
177
178 if (answer === undefined || answer.trim() === "") {
179 return {
180 content: [{ type: "text", text: "User cancelled — did not answer the question." }],
181 details: { question, suggestions, answer: null },
182 };
183 }
184
185 return {
186 content: [{ type: "text", text: `User answered: ${answer}` }],
187 details: { question, suggestions, answer },
188 };
189 } finally {
190 release();
191 }
192 },
193 });
194}