/** * Ask Question Tool - Single or multi-select question with options * Single-select keeps the inline editor for "Type something..." * Multi-select uses Space to toggle and Enter to submit */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Editor, type EditorTheme, Key, matchesKey, Text, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import { Type } from "typebox"; interface OptionWithDesc { label: string; description?: string; } type DisplayOption = OptionWithDesc & { isOther?: boolean }; interface QuestionDetails { question: string; options: string[]; answer: string | string[] | null; wasCustom?: boolean; multiSelect?: boolean; allowEmpty?: boolean; } interface SingleChoiceResult { answer: string; wasCustom: boolean; index?: number; multiSelect?: false; } interface MultiChoiceResult { answers: string[]; indices: number[]; multiSelect: true; } // Options with labels and optional descriptions const OptionSchema = Type.Object({ label: Type.String({ description: "Display label for the option" }), description: Type.Optional(Type.String({ description: "Optional description shown below label" })), }); const QuestionParams = Type.Object({ question: Type.String({ description: "The question to ask the user" }), options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }), multiSelect: Type.Optional( Type.Boolean({ description: "Allow selecting multiple options with Space before submitting" }), ), allowEmpty: Type.Optional( Type.Boolean({ description: "Allow submitting no options in multi-select mode" }), ), }); export default function askQuestion(pi: ExtensionAPI) { pi.registerTool({ name: "ask_question", label: "Ask Question", description: "Ask the user a single or multi-select question and wait for their selection. Use when a decision or clarification is required before proceeding.", promptSnippet: "Ask the user to choose from structured options before proceeding", promptGuidelines: [ "Use ask_question when the user's choice is required and the available options are known; do not guess on their behalf.", "Set multiSelect=true for independent options. Set allowEmpty=true only when selecting nothing is a valid explicit answer.", ], parameters: QuestionParams, executionMode: "sequential", async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const multiSelect = params.multiSelect === true; const allowEmpty = multiSelect && params.allowEmpty === true; if (ctx.mode !== "tui") { return { content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }], details: { question: params.question, options: params.options.map((o) => o.label), answer: null, multiSelect, allowEmpty, } as QuestionDetails, }; } if (params.options.length === 0) { return { content: [{ type: "text", text: "Error: No options provided" }], details: { question: params.question, options: [], answer: null, multiSelect, allowEmpty, } as QuestionDetails, }; } const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: "Type something.", isOther: true }]; const result = await ctx.ui.custom( (tui, theme, _kb, done) => { let optionIndex = 0; let editMode = false; let validationMessage: string | undefined; const selectedIndices = new Set(); let cachedLines: string[] | undefined; const editorTheme: EditorTheme = { borderColor: (s) => theme.fg("accent", s), selectList: { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }, }; // The extension host and direct TUI import can resolve to separate package instances. const editor = new Editor( tui as unknown as ConstructorParameters[0], editorTheme, ); function submitCustomAnswer(value: string) { const trimmed = value.trim(); if (trimmed) { done({ answer: trimmed, wasCustom: true }); } else { editMode = false; editor.setText(""); refresh(); } } editor.onSubmit = submitCustomAnswer; function refresh() { cachedLines = undefined; tui.requestRender(); } function handleInput(data: string) { if (editMode) { if (matchesKey(data, Key.escape)) { editMode = false; editor.setText(""); refresh(); return; } // The global editor submit binding may be remapped, but this // single-line prompt explicitly promises Enter to submit. if (matchesKey(data, Key.enter)) { submitCustomAnswer(editor.getText()); return; } editor.handleInput(data); refresh(); return; } if (matchesKey(data, Key.up)) { optionIndex = Math.max(0, optionIndex - 1); validationMessage = undefined; refresh(); return; } if (matchesKey(data, Key.down)) { optionIndex = Math.min(allOptions.length - 1, optionIndex + 1); validationMessage = undefined; refresh(); return; } if (multiSelect && matchesKey(data, Key.space)) { if (selectedIndices.has(optionIndex)) { selectedIndices.delete(optionIndex); } else { selectedIndices.add(optionIndex); } validationMessage = undefined; refresh(); return; } if (matchesKey(data, Key.enter)) { if (multiSelect) { const indices = [...selectedIndices].sort((a, b) => a - b); if (indices.length === 0 && !allowEmpty) { validationMessage = "Select at least one option before submitting."; refresh(); return; } done({ answers: indices.map((index) => allOptions[index].label), indices: indices.map((index) => index + 1), multiSelect: true, }); return; } const selected = allOptions[optionIndex]; if (selected.isOther) { editMode = true; refresh(); } else { done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 }); } return; } if (matchesKey(data, Key.escape)) { done(null); } } function render(width: number): string[] { if (cachedLines) return cachedLines; const lines: string[] = []; const renderWidth = Math.max(1, width); function addWrapped(text: string) { lines.push(...wrapTextWithAnsi(text, renderWidth)); } function addWrappedWithPrefix(prefix: string, text: string) { const prefixWidth = visibleWidth(prefix); if (prefixWidth >= renderWidth) { addWrapped(prefix + text); return; } const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth); const continuationPrefix = " ".repeat(prefixWidth); for (let i = 0; i < wrapped.length; i++) { lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`); } } lines.push(theme.fg("accent", "─".repeat(renderWidth))); addWrappedWithPrefix(" ", theme.fg("text", params.question)); lines.push(""); for (let i = 0; i < allOptions.length; i++) { const opt = allOptions[i]; const selected = i === optionIndex; const checked = multiSelect && selectedIndices.has(i); const isOther = opt.isOther === true; const prefix = selected ? theme.fg("accent", "> ") : " "; const checkbox = multiSelect ? `[${checked ? "x" : " "}] ` : ""; const label = `${i + 1}. ${checkbox}${opt.label}${isOther && editMode ? " ✎" : ""}`; const color = selected || checked || (isOther && editMode) ? "accent" : "text"; addWrappedWithPrefix(prefix, theme.fg(color, label)); // Show description if present if (opt.description) { addWrappedWithPrefix(" ", theme.fg("muted", opt.description)); } } if (editMode) { lines.push(""); addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:")); for (const line of editor.render(Math.max(1, renderWidth - 2))) { lines.push(` ${line}`); } } if (validationMessage) { lines.push(""); addWrappedWithPrefix(" ", theme.fg("warning", validationMessage)); } lines.push(""); if (editMode) { addWrappedWithPrefix(" ", theme.fg("dim", "Enter to submit • Esc to go back")); } else if (multiSelect) { const emptyHint = allowEmpty ? " • empty selection allowed" : ""; addWrappedWithPrefix( " ", theme.fg("dim", `↑↓ navigate • Space toggle • Enter submit${emptyHint} • Esc cancel`), ); } else { addWrappedWithPrefix(" ", theme.fg("dim", "↑↓ navigate • Enter to select • Esc to cancel")); } lines.push(theme.fg("accent", "─".repeat(renderWidth))); cachedLines = lines; return lines; } return { render, invalidate: () => { cachedLines = undefined; }, handleInput, }; }, ); // Build simple options list for details const simpleOptions = params.options.map((o) => o.label); if (!result) { return { content: [{ type: "text", text: "User cancelled the selection" }], details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails, }; } if (result.multiSelect === true) { const selection = result.answers.length === 0 ? "User selected no options" : `User selected: ${result.indices .map((index, position) => `${index}. ${result.answers[position]}`) .join(", ")}`; return { content: [{ type: "text", text: selection }], details: { question: params.question, options: simpleOptions, answer: result.answers, multiSelect: true, allowEmpty, } as QuestionDetails, }; } if (result.wasCustom) { return { content: [{ type: "text", text: `User wrote: ${result.answer}` }], details: { question: params.question, options: simpleOptions, answer: result.answer, wasCustom: true, } as QuestionDetails, }; } return { content: [{ type: "text", text: `User selected: ${result.index}. ${result.answer}` }], details: { question: params.question, options: simpleOptions, answer: result.answer, wasCustom: false, } as QuestionDetails, }; }, renderCall(args, theme, _context) { let text = theme.fg("toolTitle", theme.bold("ask_question ")) + theme.fg("muted", args.question); const opts = Array.isArray(args.options) ? args.options : []; if (opts.length) { const labels = opts.map((o: OptionWithDesc) => o.label); const shownOptions = args.multiSelect ? labels : [...labels, "Type something."]; const numbered = shownOptions.map((o, i) => `${i + 1}. ${o}`); const mode = args.multiSelect ? args.allowEmpty ? "multi-select, empty allowed" : "multi-select" : "single-select"; text += `\n${theme.fg("dim", ` Mode: ${mode}`)}`; text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`; } return new Text(text, 0, 0); }, renderResult(result, _options, theme, _context) { const details = result.details as QuestionDetails | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } if (details.answer === null) { return new Text(theme.fg("warning", "Cancelled"), 0, 0); } if (Array.isArray(details.answer)) { if (details.answer.length === 0) { return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "No options selected"), 0, 0); } const display = details.answer .map((answer) => { const index = details.options.indexOf(answer) + 1; return index > 0 ? `${index}. ${answer}` : answer; }) .join(", "); return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0); } if (details.wasCustom) { return new Text( theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer), 0, 0, ); } const idx = details.options.indexOf(details.answer) + 1; const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer; return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0); }, }); }