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 "@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}