Commit b1ae5ca3b9f4

Vincent Demeester <vincent@sbr.pm>
2026-03-17 17:45:31
feat(ask-user): add multiSelect for checkbox-style input
Added multiSelect boolean parameter that renders suggestions as toggleable checkboxes via SettingsList. Space/Enter toggles items, Tab confirms, Esc cancels. Returns all selected items as a comma-separated list with a 'selected' array in details.
1 parent 81814b6
Changed files (1)
dots
pi
agent
extensions
dots/pi/agent/extensions/ask-user.ts
@@ -2,15 +2,18 @@
  * Ask User Tool
  *
  * Allows the AI to ask the user a question and wait for their response.
- * Similar to Claude Code's "ask" tool. Supports both free-form text input
- * and optional multiple-choice suggestions.
+ * Supports free-form text input, single-choice suggestions, and
+ * multi-select checkboxes.
  *
  * Usage by the AI:
  *   ask_user({ question: "Which database should I use?" })
  *   ask_user({ question: "How should I handle errors?", suggestions: ["retry", "fail fast", "log and continue"] })
+ *   ask_user({ question: "Which features?", suggestions: ["auth", "logging", "caching"], multiSelect: true })
  */
 
 import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent";
+import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui";
 import { Type } from "@sinclair/typebox";
 
 export default function (pi: ExtensionAPI) {
@@ -23,6 +26,7 @@ export default function (pi: ExtensionAPI) {
 			"Use ask_user when you need user input, clarification, or a decision before proceeding.",
 			"Prefer ask_user over guessing when the choice significantly affects the outcome.",
 			"Keep questions concise and specific. Provide suggestions when there are obvious options.",
+			"Use multiSelect: true when the user may want to pick more than one option from the suggestions.",
 		],
 		parameters: Type.Object({
 			question: Type.String({ description: "The question to ask the user" }),
@@ -32,6 +36,12 @@ export default function (pi: ExtensionAPI) {
 						"Optional list of suggested answers. User can pick one or type their own response.",
 				}),
 			),
+			multiSelect: Type.Optional(
+				Type.Boolean({
+					description:
+						"If true, user can select multiple suggestions (checkbox-style). Requires suggestions to be provided.",
+				}),
+			),
 		}),
 
 		async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -47,19 +57,80 @@ export default function (pi: ExtensionAPI) {
 				};
 			}
 
-			const { question, suggestions } = params;
+			const { question, suggestions, multiSelect } = params;
 			const hasSuggestions = suggestions && suggestions.length > 0;
 
 			let answer: string | undefined;
 
-			if (hasSuggestions) {
-				// Show suggestions as selectable options + free-form input
+			if (multiSelect && hasSuggestions) {
+				// Multi-select: checkbox-style toggles using SettingsList
+				const selected = new Set<string>();
+
+				const result = await ctx.ui.custom<string[] | null>((tui, theme, _kb, done) => {
+					const container = new Container();
+
+					container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
+					container.addChild(new Text(theme.fg("accent", theme.bold(question)), 1, 0));
+					container.addChild(new Text(theme.fg("dim", "Space/Enter toggle • Tab confirm • Esc cancel"), 1, 0));
+
+					const items: SettingItem[] = suggestions.map((s) => ({
+						id: s,
+						label: s,
+						currentValue: "☐",
+						values: ["☐", "☑"],
+					}));
+
+					const settingsList = new SettingsList(
+						items,
+						Math.min(items.length + 2, 15),
+						getSettingsListTheme(),
+						(id, newValue) => {
+							if (newValue === "☑") {
+								selected.add(id);
+							} else {
+								selected.delete(id);
+							}
+						},
+						() => done(null), // Esc → cancel
+					);
+					container.addChild(settingsList);
+
+					container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
+
+					return {
+						render: (w) => container.render(w),
+						invalidate: () => container.invalidate(),
+						handleInput: (data) => {
+							// Tab = confirm selections
+							if (data === "\t") {
+								done(Array.from(selected));
+								return;
+							}
+							settingsList.handleInput(data);
+							tui.requestRender();
+						},
+					};
+				});
+
+				if (result === null || result.length === 0) {
+					return {
+						content: [{ type: "text", text: "User cancelled — did not select any options." }],
+						details: { question, suggestions, multiSelect: true, answer: null, selected: [] },
+					};
+				}
+
+				answer = result.join(", ");
+				return {
+					content: [{ type: "text", text: `User selected: ${answer}` }],
+					details: { question, suggestions, multiSelect: true, answer, selected: result },
+				};
+			} else if (hasSuggestions) {
+				// Single-select: show suggestions as selectable options + free-form input
 				const freeFormOption = "✎ Type a different answer…";
 				const options = [...suggestions, freeFormOption];
 				const choice = await ctx.ui.select(question, options);
 
 				if (choice === undefined) {
-					// User cancelled
 					return {
 						content: [{ type: "text", text: "User cancelled — did not answer the question." }],
 						details: { question, suggestions, answer: null },
@@ -67,7 +138,6 @@ export default function (pi: ExtensionAPI) {
 				}
 
 				if (choice === freeFormOption) {
-					// User chose to type their own answer
 					answer = await ctx.ui.input(question);
 				} else {
 					answer = choice;