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}