/** * Ask Tool - Interactive user prompting during execution * * Use this tool when you need to ask the user questions during execution. * This allows you to: * 1. Gather user preferences or requirements * 2. Clarify ambiguous instructions * 3. Get decisions on implementation choices as you work * 4. Offer choices to the user about what direction to take * * Usage notes: * - Users will always be able to select "Other" to provide custom text input * - Use multi: true to allow multiple answers to be selected for a question * - Use recommended: to mark the default option; "(Recommended)" suffix is added automatically * - Questions may time out and auto-select the recommended option (configurable, disabled in plan mode) */ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme"; import askDescription from "../prompts/tools/ask.md" with { type: "text" }; import { renderStatusLine } from "../tui"; import type { ToolSession } from "."; import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils"; import { ToolAbortError } from "./tool-errors"; // ============================================================================= // Types // ============================================================================= const OptionItem = z.object({ label: z.string().describe("display label"), }); const QuestionItem = z.object({ id: z.string().describe("question id"), question: z.string().describe("question text"), options: z.array(OptionItem).describe("available options"), multi: z.boolean().describe("allow multiple selections").optional(), recommended: z.number().describe("recommended option index").optional(), }); const askSchema = z.object({ questions: z.array(QuestionItem).min(1).describe("questions to ask"), }); export type AskToolInput = z.infer; /** Result for a single question */ export interface QuestionResult { id: string; question: string; options: string[]; multi: boolean; selectedOptions: string[]; customInput?: string; } export interface AskToolDetails { question?: string; options?: string[]; multi?: boolean; selectedOptions?: string[]; customInput?: string; /** Multi-part question mode */ results?: QuestionResult[]; } // ============================================================================= // Constants // ============================================================================= const OTHER_OPTION = "Other (type your own)"; const RECOMMENDED_SUFFIX = " (Recommended)"; function getDoneOptionLabel(): string { return `${theme.status.success} Done selecting`; } /** Add "(Recommended)" suffix to the option at the given index if not already present */ function addRecommendedSuffix(labels: string[], recommendedIndex?: number): string[] { if (recommendedIndex === undefined || recommendedIndex < 0 || recommendedIndex >= labels.length) { return labels; } return labels.map((label, i) => { if (i === recommendedIndex && !label.endsWith(RECOMMENDED_SUFFIX)) { return label + RECOMMENDED_SUFFIX; } return label; }); } function getAutoSelectionOnTimeout(optionLabels: string[], recommended?: number): string[] { if (optionLabels.length === 0) return []; if (typeof recommended === "number" && recommended >= 0 && recommended < optionLabels.length) { return [optionLabels[recommended]]; } return [optionLabels[0]]; } /** Strip "(Recommended)" suffix from a label */ function stripRecommendedSuffix(label: string): string { return label.endsWith(RECOMMENDED_SUFFIX) ? label.slice(0, -RECOMMENDED_SUFFIX.length) : label; } // ============================================================================= // Question Selection Logic // ============================================================================= interface SelectionResult { selectedOptions: string[]; customInput?: string; timedOut: boolean; navigation?: "back" | "forward"; cancelled?: boolean; } interface NavigationControls { allowBack: boolean; allowForward: boolean; progressText?: string; } interface AskSingleQuestionOptions { recommended?: number; timeout?: number; signal?: AbortSignal; initialSelection?: Pick; navigation?: NavigationControls; } interface UIContext { select( prompt: string, options: string[], options_?: { initialIndex?: number; timeout?: number; signal?: AbortSignal; outline?: boolean; onTimeout?: () => void; onLeft?: () => void; onRight?: () => void; helpText?: string; }, ): Promise; editor( title: string, prefill?: string, dialogOptions?: { signal?: AbortSignal }, editorOptions?: { promptStyle?: boolean }, ): Promise; } async function askSingleQuestion( ui: UIContext, question: string, optionLabels: string[], multi: boolean, options: AskSingleQuestionOptions = {}, ): Promise { const { recommended, timeout, signal, initialSelection, navigation } = options; const doneLabel = getDoneOptionLabel(); let selectedOptions = [...(initialSelection?.selectedOptions ?? [])]; let customInput = initialSelection?.customInput; let timedOut = false; const selectOption = async ( prompt: string, optionsToShow: string[], initialIndex?: number, ): Promise<{ choice: string | undefined; timedOut: boolean; navigation?: "back" | "forward" }> => { let timeoutTriggered = false; const onTimeout = () => { timeoutTriggered = true; }; let navigationAction: "back" | "forward" | undefined; const helpText = navigation ? "up/down navigate enter select ←/→ question esc cancel" : "up/down navigate enter select esc cancel"; const dialogOptions = { initialIndex, timeout, signal, outline: true, onTimeout, helpText, onLeft: navigation?.allowBack ? () => { navigationAction = "back"; } : undefined, onRight: navigation?.allowForward ? () => { navigationAction = "forward"; } : undefined, }; const startMs = Date.now(); const choice = signal ? await untilAborted(signal, () => ui.select(prompt, optionsToShow, dialogOptions)) : await ui.select(prompt, optionsToShow, dialogOptions); if (!timeoutTriggered && choice === undefined && typeof timeout === "number") { timeoutTriggered = Date.now() - startMs >= timeout; } return { choice, timedOut: timeoutTriggered, navigation: navigationAction }; }; const promptForCustomInput = async (): Promise<{ input: string | undefined }> => { const dialogOptions = signal ? { signal } : undefined; const showCustomInput = () => ui.editor("Enter your response:", undefined, dialogOptions, { promptStyle: true }); const input = signal ? await untilAborted(signal, showCustomInput) : await showCustomInput(); return { input }; }; const promptWithProgress = navigation?.progressText ? `${question} (${navigation.progressText})` : question; if (multi) { const selected = new Set(selectedOptions); let cursorIndex = Math.min(Math.max(recommended ?? 0, 0), Math.max(optionLabels.length - 1, 0)); const firstSelected = selectedOptions[0]; if (firstSelected) { const selectedIndex = optionLabels.indexOf(firstSelected); if (selectedIndex >= 0) cursorIndex = selectedIndex; } while (true) { const opts: string[] = []; for (const opt of optionLabels) { const checkbox = selected.has(opt) ? theme.checkbox.checked : theme.checkbox.unchecked; opts.push(`${checkbox} ${opt}`); } if (!navigation?.allowForward && selected.size > 0) { opts.push(doneLabel); } opts.push(OTHER_OPTION); const prefix = selected.size > 0 ? `(${selected.size} selected) ` : ""; const { choice, timedOut: selectTimedOut, navigation: arrowNavigation, } = await selectOption(`${prefix}${promptWithProgress}`, opts, cursorIndex); if (arrowNavigation) { return { selectedOptions: Array.from(selected), customInput, timedOut, navigation: arrowNavigation }; } if (choice === undefined) { if (selectTimedOut) { timedOut = true; break; } return { selectedOptions: Array.from(selected), customInput, timedOut, cancelled: true }; } if (choice === doneLabel) break; if (choice === OTHER_OPTION) { if (selectTimedOut) { timedOut = true; break; } const customResult = await promptForCustomInput(); if (customResult.input === undefined) { break; } customInput = customResult.input; break; } const selectedIdx = opts.indexOf(choice); if (selectedIdx >= 0) { cursorIndex = selectedIdx; } const checkedPrefix = `${theme.checkbox.checked} `; const uncheckedPrefix = `${theme.checkbox.unchecked} `; let opt: string | undefined; if (choice.startsWith(checkedPrefix)) { opt = choice.slice(checkedPrefix.length); } else if (choice.startsWith(uncheckedPrefix)) { opt = choice.slice(uncheckedPrefix.length); } if (opt) { if (selected.has(opt)) { selected.delete(opt); } else { selected.add(opt); } } if (selectTimedOut) { timedOut = true; break; } } selectedOptions = Array.from(selected); } else { const displayLabels = addRecommendedSuffix(optionLabels, recommended); const optionsWithNavigation = [...displayLabels, OTHER_OPTION]; let initialIndex = recommended; const previouslySelected = selectedOptions[0]; if (previouslySelected) { const selectedIndex = optionLabels.indexOf(previouslySelected); if (selectedIndex >= 0) initialIndex = selectedIndex; } else if (customInput !== undefined) { initialIndex = displayLabels.length; } if (initialIndex !== undefined) { const maxIndex = Math.max(optionsWithNavigation.length - 1, 0); initialIndex = Math.max(0, Math.min(initialIndex, maxIndex)); } const { choice, timedOut: selectTimedOut, navigation: arrowNavigation, } = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex); timedOut = selectTimedOut; if (arrowNavigation) { return { selectedOptions, customInput, timedOut, navigation: arrowNavigation }; } if (choice === undefined) { if (!timedOut) { return { selectedOptions, customInput, timedOut, cancelled: true }; } } else if (choice === OTHER_OPTION) { if (!selectTimedOut) { const customResult = await promptForCustomInput(); if (customResult.input !== undefined) { customInput = customResult.input; selectedOptions = []; } // If editor was dismissed (undefined), keep prior selectedOptions/customInput intact } } else { selectedOptions = [stripRecommendedSuffix(choice)]; customInput = undefined; } if (navigation?.allowForward) { return { selectedOptions, customInput, timedOut, navigation: "forward" }; } } if (timedOut && selectedOptions.length === 0 && customInput === undefined) { selectedOptions = getAutoSelectionOnTimeout(optionLabels, recommended); } return { selectedOptions, customInput, timedOut }; } function formatQuestionResult(result: QuestionResult): string { if (result.customInput !== undefined) { return `${result.id}: "${result.customInput}"`; } if (result.selectedOptions.length > 0) { return result.multi ? `${result.id}: [${result.selectedOptions.join(", ")}]` : `${result.id}: ${result.selectedOptions[0]}`; } return `${result.id}: (cancelled)`; } // ============================================================================= // Tool Class // ============================================================================= type AskParams = AskToolInput; /** * Ask tool for interactive user prompting during execution. * * Allows gathering user preferences, clarifying instructions, and getting decisions * on implementation choices as the agent works. */ export class AskTool implements AgentTool { readonly name = "ask"; readonly label = "Ask"; readonly summary = "Ask the user a clarifying question"; readonly description: string; readonly parameters = askSchema; readonly strict = true; readonly loadMode = "discoverable"; constructor(private readonly session: ToolSession) { this.description = prompt.render(askDescription); } static createIf(session: ToolSession): AskTool | null { return session.hasUI ? new AskTool(session) : null; } /** Send terminal notification when ask tool is waiting for input */ #sendAskNotification(): void { const method = this.session.settings.get("ask.notify"); if (method === "off") return; TERMINAL.sendNotification("Waiting for input"); } async execute( _toolCallId: string, params: AskParams, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, ): Promise> { // Headless fallback if (!context?.hasUI || !context.ui) { context?.abort(); throw new ToolAbortError("Ask tool requires interactive mode"); } const extensionUi = context.ui; const ui: UIContext = { select: (prompt, options, dialogOptions) => extensionUi.select(prompt, options, dialogOptions), editor: (title, prefill, dialogOptions, editorOptions) => extensionUi.editor(title, prefill, dialogOptions, editorOptions), }; // Determine timeout based on settings and plan mode const planModeEnabled = this.session.getPlanModeState?.()?.enabled ?? false; // Settings.get("ask.timeout") returns seconds (0 = disabled), convert to ms const timeoutSeconds = this.session.settings.get("ask.timeout"); const settingsTimeout = timeoutSeconds === 0 ? null : timeoutSeconds * 1000; const timeout = planModeEnabled ? null : settingsTimeout; // Send notification if waiting and not suppressed this.#sendAskNotification(); if (params.questions.length === 0) { return { content: [{ type: "text" as const, text: "Error: questions must not be empty" }], details: {}, }; } const askQuestion = async ( q: AskParams["questions"][number], options?: { previous?: QuestionResult; navigation?: NavigationControls }, ) => { const optionLabels = q.options.map(o => o.label); try { const { selectedOptions, customInput, navigation, cancelled, timedOut } = await askSingleQuestion( ui, q.question, optionLabels, q.multi ?? false, { recommended: q.recommended, timeout: timeout ?? undefined, signal, initialSelection: options?.previous, navigation: options?.navigation, }, ); return { optionLabels, selectedOptions, customInput, navigation, cancelled, timedOut }; } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw new ToolAbortError("Ask input was cancelled"); } throw error; } }; if (params.questions.length === 1) { const [q] = params.questions; const { optionLabels, selectedOptions, customInput, cancelled, timedOut } = await askQuestion(q); if (!timedOut && (cancelled || (selectedOptions.length === 0 && customInput === undefined))) { context.abort(); throw new ToolAbortError("Ask tool was cancelled by the user"); } const details: AskToolDetails = { question: q.question, options: optionLabels, multi: q.multi ?? false, selectedOptions, customInput, }; const responseParts: string[] = []; if (selectedOptions.length > 0) { responseParts.push( q.multi ? `User selected: ${selectedOptions.join(", ")}` : `User selected: ${selectedOptions[0]}`, ); } if (customInput !== undefined) { responseParts.push( customInput.includes("\n") ? `User provided custom input:\n${customInput .split("\n") .map(line => ` ${line}`) .join("\n")}` : `User provided custom input: ${customInput}`, ); } const responseText = responseParts.length > 0 ? responseParts.join("\n") : "User cancelled the selection"; return { content: [{ type: "text" as const, text: responseText }], details }; } const resultsByIndex: Array = Array.from({ length: params.questions.length }); let questionIndex = 0; while (questionIndex < params.questions.length) { const q = params.questions[questionIndex]!; const previous = resultsByIndex[questionIndex]; const navigation: NavigationControls = { allowBack: questionIndex > 0, allowForward: true, progressText: `${questionIndex + 1}/${params.questions.length}`, }; const { optionLabels, selectedOptions, customInput, navigation: navAction, cancelled, timedOut, } = await askQuestion(q, { previous, navigation }); if (cancelled && !timedOut) { context.abort(); throw new ToolAbortError("Ask tool was cancelled by the user"); } resultsByIndex[questionIndex] = { id: q.id, question: q.question, options: optionLabels, multi: q.multi ?? false, selectedOptions, customInput, }; if (navAction === "back") { questionIndex = Math.max(0, questionIndex - 1); continue; } questionIndex += 1; } const results = resultsByIndex.map((result, index) => { if (result) return result; const q = params.questions[index]!; return { id: q.id, question: q.question, options: q.options.map(o => o.label), multi: q.multi ?? false, selectedOptions: [], }; }); const details: AskToolDetails = { results }; const responseLines = results.map(formatQuestionResult); const responseText = `User answers:\n${responseLines.join("\n")}`; return { content: [{ type: "text" as const, text: responseText }], details }; } } // ============================================================================= // TUI Renderer // ============================================================================= interface AskRenderArgs { question?: string; options?: Array<{ label: string }>; multi?: boolean; questions?: Array<{ id: string; question: string; options: Array<{ label: string }>; multi?: boolean; }>; } /** Render custom input as a single block with continuation lines (not one entry per line) */ function renderCustomInput( uiTheme: Theme, prefix: string, customInput: string, isLastEntry: boolean, includeLeadingNewline = true, ): string { const lines = customInput.split("\n"); const branch = isLastEntry ? uiTheme.tree.last : uiTheme.tree.branch; const firstLine = lines[0] ?? ""; let text = `${includeLeadingNewline ? "\n" : ""}${prefix}${uiTheme.fg("dim", branch)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", firstLine)}`; const continuationIndent = isLastEntry ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `; for (let i = 1; i < lines.length; i++) { text += `\n${prefix}${continuationIndent} ${uiTheme.fg("toolOutput", lines[i])}`; } return text; } export const askToolRenderer = { renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component { const label = formatTitle("Ask", uiTheme); const mdTheme = getMarkdownTheme(); const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) }; // Multi-part questions if (args.questions && args.questions.length > 0) { const container = new Container(); container.addChild(new Text(`${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`, 0, 0)); for (let i = 0; i < args.questions.length; i++) { const q = args.questions[i]; const isLastQ = i === args.questions.length - 1; const qBranch = isLastQ ? uiTheme.tree.last : uiTheme.tree.branch; const continuation = isLastQ ? " " : uiTheme.tree.vertical; const meta: string[] = []; if (q.multi) meta.push("multi"); if (q.options?.length) meta.push(`options:${q.options.length}`); const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : ""; container.addChild( new Text(` ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, 0, 0), ); container.addChild(new Markdown(q.question, 3, 0, mdTheme, accentStyle)); if (q.options?.length) { let optText = ""; for (let j = 0; j < q.options.length; j++) { const opt = q.options[j]; const isLastOpt = j === q.options.length - 1; const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch; const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t)); optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`; } container.addChild(new Text(optText, 0, 0)); } } return container; } // Single question if (!args.question) { return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0); } const container = new Container(); const meta: string[] = []; if (args.multi) meta.push("multi"); if (args.options?.length) meta.push(`options:${args.options.length}`); container.addChild(new Text(`${label}${formatMeta(meta, uiTheme)}`, 0, 0)); container.addChild(new Markdown(args.question, 1, 0, mdTheme, accentStyle)); if (args.options?.length) { let optText = ""; for (let i = 0; i < args.options.length; i++) { const opt = args.options[i]; const isLast = i === args.options.length - 1; const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t)); optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`; } container.addChild(new Text(optText, 0, 0)); } return container; }, renderResult( result: { content: Array<{ type: string; text?: string }>; details?: AskToolDetails }, _options: RenderResultOptions, uiTheme: Theme, ): Component { const { details } = result; const mdTheme = getMarkdownTheme(); const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) }; if (!details) { const txt = result.content[0]; const fallback = txt?.type === "text" && txt.text ? txt.text : ""; const header = renderStatusLine({ icon: "warning", title: "Ask" }, uiTheme); return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0); } // Multi-part results if (details.results && details.results.length > 0) { const hasAnySelection = details.results.some( r => r.customInput !== undefined || (r.selectedOptions && r.selectedOptions.length > 0), ); const header = renderStatusLine( { icon: hasAnySelection ? "success" : "warning", title: "Ask", meta: [`${details.results.length} questions`], }, uiTheme, ); const container = new Container(); container.addChild(new Text(header, 0, 0)); for (let i = 0; i < details.results.length; i++) { const r = details.results[i]; const isLastQuestion = i === details.results.length - 1; const branch = isLastQuestion ? uiTheme.tree.last : uiTheme.tree.branch; const continuation = isLastQuestion ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `; const hasSelection = r.customInput !== undefined || r.selectedOptions.length > 0; const statusIcon = hasSelection ? uiTheme.styledSymbol("status.success", "success") : uiTheme.styledSymbol("status.warning", "warning"); container.addChild( new Text(` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0), ); container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle)); const answerLines: string[] = []; for (let j = 0; j < r.selectedOptions.length; j++) { const isLast = j === r.selectedOptions.length - 1 && r.customInput === undefined; const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t => uiTheme.fg("toolOutput", t), ); answerLines.push( `${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`, ); } if (answerLines.length > 0) { container.addChild(new Text(answerLines.join("\n"), 0, 0)); } if (r.customInput !== undefined) { container.addChild(new Text(renderCustomInput(uiTheme, continuation, r.customInput, true, false), 0, 0)); } else if (r.selectedOptions.length === 0) { container.addChild( new Text( `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`, 0, 0, ), ); } } return container; } // Single question result if (!details.question) { const txt = result.content[0]; const fallback = txt?.type === "text" && txt.text ? txt.text : ""; return new Text(fallback, 0, 0); } const hasSelection = details.customInput !== undefined || (details.selectedOptions && details.selectedOptions.length > 0); const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme); const container = new Container(); container.addChild(new Text(header, 0, 0)); container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle)); const answerLines: string[] = []; if (details.selectedOptions && details.selectedOptions.length > 0) { for (let i = 0; i < details.selectedOptions.length; i++) { const isLast = i === details.selectedOptions.length - 1 && details.customInput === undefined; const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t => uiTheme.fg("toolOutput", t), ); answerLines.push( ` ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`, ); } } if (answerLines.length > 0) { container.addChild(new Text(answerLines.join("\n"), 0, 0)); } if (details.customInput !== undefined) { container.addChild(new Text(renderCustomInput(uiTheme, " ", details.customInput, true, false), 0, 0)); } else if (!details.selectedOptions || details.selectedOptions.length === 0) { container.addChild( new Text( ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`, 0, 0, ), ); } return container; }, };