/** * Question Tool - a question with options, single- or multi-select. * Full custom UI: options list + inline editor for "Type something..." (single-select * only), or space-toggled checkboxes when `multiSelect` is set. An optional `header` * labels the question. Escape in the editor returns to options; Escape in options cancels. * Multiple questions per call are not batched; ask sequentially. * On the TUI each question is also offered to a remote responder over pi.events * (REMOTE_QUESTION_CHANNEL); the first answer, local or remote, wins. */ import { randomUUID } from 'node:crypto' import * as os from 'node:os' import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent' import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui' import { Type } from 'typebox' import { readManagedSettings } from './internal/managed-settings.ts' import { readUserSetting } from './internal/settings-chain.ts' interface OptionWithDesc { label: string description?: string } type DisplayOption = OptionWithDesc & { isOther?: boolean } interface QuestionDetails { question: string header?: string options: string[] answer: string | null wasCustom?: boolean multiSelect?: boolean /** Auto-continued on askUserQuestionTimeout rather than answered or cancelled. */ timedOut?: boolean } // 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 SingleQuestion = Type.Object({ question: Type.String({ description: 'The question to ask the user' }), header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it, kept to 12 characters' })), options: Type.Array(OptionSchema, { description: 'Options for the user to choose from (2-4)', minItems: 2, maxItems: 4 }), multiSelect: Type.Optional(Type.Boolean({ description: 'Allow selecting several options (space toggles, enter confirms)' })), }) /** One question in the flat form, plus an optional batch for Claude's 1-4 questions. * The flat fields stay the documented path: a schema offering two equally optional * shapes gave smaller models nothing to follow, and they produced neither. */ export const QuestionParams = Type.Object({ question: Type.Optional(Type.String({ description: 'The question to ask. Required, unless asking several via questions.' })), options: Type.Optional(Type.Array(OptionSchema, { description: 'The 2-4 choices for this question, each {label, description?}. Required with question.', minItems: 2, maxItems: 4 })), header: Type.Optional(Type.String({ description: 'Optional short label shown above the question, kept to 12 characters' })), multiSelect: Type.Optional(Type.Boolean({ description: 'Optional: allow selecting several options (space toggles, enter confirms)' })), questions: Type.Optional(Type.Array(SingleQuestion, { description: 'Only to ask 2-4 questions in one call: each entry takes the same fields as above. Leave unset for a single question.', minItems: 1, maxItems: 4 })), }) export interface QuestionSpec { question: string header?: string options: DisplayOption[] multiSelect?: boolean } /** In-process, question-owned offer; arbitrary custom TUI components are not sent. */ export const REMOTE_QUESTION_CHANNEL = 'pi-code:question:v1' export type RemoteQuestionOutcome = { action: 'answer'; indices: number[] } | { action: 'text'; text: string } | { action: 'cancel' } | { action: 'pass' } export interface RemoteQuestionOffer { version: 1 requestId: string sessionId: string question: string header?: string options: OptionWithDesc[] multiSelect: boolean allowFreeText: boolean signal: AbortSignal /** Must be called during event emission; only one listener receives a settle handle. */ claim: () => ((outcome: RemoteQuestionOutcome) => boolean) | undefined /** The claimed responder reports input activity to reset a configured idle timer. */ touch: () => boolean } /** Normalize either accepted shape into the list of questions to ask. */ function questionList(params: Partial & { questions?: QuestionSpec[] }): QuestionSpec[] { if (params.questions && params.questions.length > 0) return params.questions if (typeof params.question === 'string') return [{ question: params.question, header: shortHeader(params.header), options: params.options ?? [], multiSelect: params.multiSelect }] return [] } /** Claude keeps a header short for the label slot. Truncating is the forgiving read: * rejecting the call costs a turn while the model recovers from a validation error, * which is a poor trade for a display detail. */ const HEADER_MAX = 12 export const shortHeader = (header: string | undefined): string | undefined => (header === undefined ? undefined : header.slice(0, HEADER_MAX)) /** Claude's three accepted askUserQuestionTimeout spellings (`60s`, `5m`, `10m`), as * milliseconds. Anything else, including unset, means no auto-continue. */ export function parseAskUserQuestionTimeout(value: unknown): number | undefined { if (typeof value !== 'string') return undefined const match = /^(\d+)([sm])$/.exec(value.trim()) if (!match) return undefined const amount = Number(match[1]) return match[2] === 's' ? amount * 1000 : amount * 60 * 1000 } /** Claude scopes askUserQuestionTimeout to "User or managed": a project's own * settings.json cannot set it, so a checked-out repository can never make the * user's own dialogs auto-answer themselves. Managed wins over the user's file, as * every managed setting does. `home` defaults to the real one and is a parameter * only so a test can point it at a fixture without mocking node:os. */ export function askUserQuestionTimeoutMs(home: string = os.homedir()): number | undefined { const managed = readManagedSettings() as { askUserQuestionTimeout?: unknown } const fromManaged = parseAskUserQuestionTimeout(managed.askUserQuestionTimeout) if (fromManaged !== undefined) return fromManaged return parseAskUserQuestionTimeout(readUserSetting(home, 'askUserQuestionTimeout')) } function checkbox(checked: boolean | undefined): string { if (checked === undefined) return '' return checked ? '[x] ' : '[ ] ' } function optionLine(opt: DisplayOption, index: number, selected: boolean, editMode: boolean, checked: boolean | undefined, theme: Theme): string { const label = `${index + 1}. ${checkbox(checked)}${opt.label}` const prefix = selected ? theme.fg('accent', '> ') : ' ' if (opt.isOther === true && editMode) { return prefix + theme.fg('accent', `${label} ✎`) } if (selected) { return prefix + theme.fg('accent', label) } return ` ${theme.fg('text', label)}` } interface QuestionView { width: number question: string header?: string options: DisplayOption[] optionIndex: number editMode: boolean multiSelect: boolean checked: boolean[] editor: Editor theme: Theme /** Claude: "You see a countdown for the last 20 seconds." Undefined the rest of * the idle window, and always when there is no configured timeout at all. */ countdownSeconds?: number } function buildQuestionLines(view: QuestionView): string[] { const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme, countdownSeconds } = view const lines: string[] = [] const add = (s: string) => lines.push(truncateToWidth(s, width)) add(theme.fg('accent', '─'.repeat(width))) if (header) add(theme.fg('muted', ` [${header}]`)) add(theme.fg('text', ` ${question}`)) lines.push('') for (let i = 0; i < options.length; i++) { const opt = options[i] const box = multiSelect && opt.isOther !== true ? checked[i] : undefined add(optionLine(opt, i, i === optionIndex, editMode, box, theme)) if (opt.description) { add(` ${theme.fg('muted', opt.description)}`) } } if (editMode) { lines.push('') add(theme.fg('muted', ' Your answer:')) for (const line of editor.render(width - 2)) { add(` ${line}`) } } lines.push('') add(theme.fg('dim', navHint(editMode, multiSelect))) if (countdownSeconds !== undefined) { add(theme.fg('warning', ` Auto-continuing in ${countdownSeconds}s if idle · press any key to stay`)) } add(theme.fg('accent', '─'.repeat(width))) return lines } function navHint(editMode: boolean, multiSelect: boolean): string { if (editMode) return ' Enter to submit • Esc to go back' if (multiSelect) return ' ↑↓ navigate • Space to toggle • Enter to confirm • Esc to cancel' return ' ↑↓ navigate • Enter to select • Esc to cancel' } /** The comma-joined labels of the checked options, in order. */ function selectedLabels(options: DisplayOption[], checked: boolean[]): string { return options .filter((_, i) => checked[i]) .map((o) => o.label) .join(', ') } /** The result line for a question that was not cancelled. */ function renderAnswered(details: QuestionDetails, answer: string, theme: Theme): Text { if (details.timedOut) { const already = answer ? theme.fg('muted', ` (already selected: ${answer})`) : '' return new Text(theme.fg('warning', '⏱ Auto-continued (no response)') + already, 0, 0) } if (details.wasCustom) { return new Text(theme.fg('success', '✓ ') + theme.fg('muted', '(wrote) ') + theme.fg('accent', answer), 0, 0) } if (details.multiSelect) { return new Text(theme.fg('success', '✓ ') + theme.fg('accent', answer || '(none)'), 0, 0) } const idx = details.options.indexOf(answer) + 1 const display = idx > 0 ? `${idx}. ${answer}` : answer return new Text(theme.fg('success', '✓ ') + theme.fg('accent', display), 0, 0) } export default function question(pi: ExtensionAPI) { pi.registerTool({ name: 'question', label: 'Question', description: 'Ask the user a question and let them pick from options. Use when you need user input to proceed. Pass question and options, for example {"question": "Which one?", "options": [{"label": "alpha"}, {"label": "beta"}]}. To ask 2-4 questions at once, pass questions instead, with the same fields per entry.', parameters: QuestionParams, // The host shows a custom dialog in one editor slot, so two questions running in // parallel leave the first unanswerable and the run unable to finish. executionMode: 'sequential', async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) { const specs = questionList(rawParams as Partial & { questions?: QuestionSpec[] }) if (specs.length === 0) { return { content: [{ type: 'text', text: 'Error: No question provided' }], details: { question: '', options: [], answer: null } as QuestionDetails } } if (specs.length === 1) return await askOne(specs[0], ctx, pi.events, signal) // Several questions are asked in sequence; a cancel ends the run, since the // remaining answers would be guesses about a flow the user just declined. const texts: string[] = [] const collected: QuestionDetails[] = [] for (const spec of specs) { const result = await askOne(spec, ctx, pi.events, signal) const detail = result.details as QuestionDetails collected.push(detail) texts.push(`${spec.question}\n${result.content[0].text}`) if (detail.answer === null) break } return { content: [{ type: 'text', text: texts.join('\n\n') }], details: { ...collected[0], questions: collected } as QuestionDetails } }, renderCall(args, theme, _context) { const multi = args.multiSelect === true const heading = args.header ? `[${shortHeader(String(args.header))}] ` : '' let text = theme.fg('toolTitle', theme.bold('question ')) + theme.fg('muted', heading + String(args.question ?? '')) const opts = Array.isArray(args.options) ? args.options : [] if (opts.length) { const labels = opts.map((o: OptionWithDesc) => o.label) const shown = multi ? labels : [...labels, 'Type something.'] const numbered = shown.map((o, i) => `${i + 1}. ${o}`) const optionsLine = ` Options${multi ? ' (multi)' : ''}: ${numbered.join(', ')}` text += `\n${theme.fg('dim', optionsLine)}` } 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) } return renderAnswered(details, details.answer, theme) }, }) } type QuestionAnswer = Awaited> /** A claimed offer raced against the open overlay: whichever answers first wins. */ interface RemoteQuestion { /** Routes remote answers into the overlay and remote activity into its idle timer. */ attach: (answer: (value: QuestionAnswer) => void, touch: () => void) => void /** Ends the offer; the responder's settle handle and touch are rejected from then on. */ close: () => void } type RemoteOffer = { kind: 'settled'; value: QuestionAnswer } | { kind: 'claimed'; remote: RemoteQuestion } function validIndices(indices: unknown, count: number, multiSelect: boolean): indices is number[] { if (!Array.isArray(indices) || (!multiSelect && indices.length !== 1)) return false return new Set(indices).size === indices.length && indices.every((index) => Number.isInteger(index) && index >= 1 && index <= count) } /** A remote outcome as the answer it stands for: null is a cancel, undefined is a * pass or anything this question cannot accept, which must never become an answer. */ function remoteAnswer(outcome: RemoteQuestionOutcome, options: DisplayOption[], multiSelect: boolean): QuestionAnswer | undefined { if (outcome === null || typeof outcome !== 'object') return undefined if (outcome.action === 'cancel') return null if (outcome.action === 'text') { const text = typeof outcome.text === 'string' ? outcome.text.trim() : '' return !multiSelect && text ? { answer: text, wasCustom: true } : undefined } if (outcome.action !== 'answer') return undefined // Read once and copied, so a getter or Proxy cannot answer with other indices // than the ones that passed validation. const raw: unknown = outcome.indices const indices: unknown = Array.isArray(raw) ? [...raw] : undefined if (!validIndices(indices, options.length, multiSelect)) return undefined const checked = options.map((_, index) => indices.includes(index + 1)) return { answer: selectedLabels(options, checked), wasCustom: false, ...(multiSelect ? {} : { index: indices[0] }) } } /** Undefined when nobody claims synchronously, so the overlay opens with no async gap. */ function offerRemoteQuestion(params: QuestionSpec, allOptions: DisplayOption[], ctx: ExtensionContext, events: ExtensionAPI['events'] | undefined, signal: AbortSignal | undefined): RemoteOffer | undefined { if (!events) return undefined if (signal?.aborted) return { kind: 'settled', value: null } const multiSelect = params.multiSelect === true const controller = new AbortController() let claimed = false let early: { value: QuestionAnswer } | undefined let deliver: ((value: QuestionAnswer) => void) | undefined let touchOverlay: (() => void) | undefined const close = (): void => { controller.abort() signal?.removeEventListener('abort', abort) } const settle = (value: QuestionAnswer): void => { close() if (deliver) deliver(value) else early = { value } } // Pi aborts the turn before a session replacement invalidates ctx, so this is // also the session-replacement path; ctx is never read after emission. const abort = (): void => settle(null) signal?.addEventListener('abort', abort, { once: true }) const offer: RemoteQuestionOffer = { version: 1, requestId: randomUUID(), sessionId: ctx.sessionManager.getSessionId(), question: params.question, header: shortHeader(params.header), options: params.options.map(({ label, description }) => ({ label, ...(description === undefined ? {} : { description }) })), multiSelect, allowFreeText: allOptions.some((option) => option.isOther === true), signal: controller.signal, claim: () => { if (claimed || controller.signal.aborted) return undefined claimed = true return (outcome) => { if (controller.signal.aborted) return false const value = remoteAnswer(outcome, params.options, multiSelect) if (value !== undefined) { settle(value) return true } // A pass or invalid reply only withdraws the remote side; the overlay stays // open, and a turn abort must still cancel it. controller.abort() return outcome?.action === 'pass' } }, touch: () => { if (!claimed || controller.signal.aborted) return false touchOverlay?.() return true }, } events.emit(REMOTE_QUESTION_CHANNEL, offer) if (early) return { kind: 'settled', value: early.value } if (!claimed || controller.signal.aborted) { close() return undefined } return { kind: 'claimed', remote: { attach: (answer, touch) => { deliver = answer touchOverlay = touch }, close, }, } } /** ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves * undefined immediately, which would read as a cancel without ever asking. Ask * through the dialog primitives there instead. askUserQuestionTimeout is a TUI * concept (a countdown, a keypress resetting it): the dialog-primitive fallback * has no keyboard or visible countdown to drive it, so it is not applied there. */ async function collectAnswer(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, events?: ExtensionAPI['events'], signal?: AbortSignal): Promise { if (ctx.mode !== 'tui') return askViaDialogs(params, ctx, allOptions, multiSelect) const offer = offerRemoteQuestion(params, allOptions, ctx, events, signal) if (offer?.kind === 'settled') return offer.value const remote = offer?.remote try { return await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs(), remote) } finally { remote?.close() } } async function askOne(params: QuestionSpec, ctx: ExtensionContext, events?: ExtensionAPI['events'], signal?: AbortSignal): Promise<{ content: Array<{ type: 'text'; text: string }>; details: QuestionDetails }> { if (!ctx.hasUI) { 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, } as QuestionDetails, } } if (params.options.length === 0) { return { content: [{ type: 'text', text: 'Error: No options provided' }], details: { question: params.question, options: [], answer: null } as QuestionDetails, } } const multiSelect = params.multiSelect === true // The free-text option does not compose with checkbox selection, so it is single-select only. const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }] const result = await collectAnswer(params, ctx, allOptions, multiSelect, events, signal) // Build simple options list for details; header/multiSelect appear only when set, // so single-select details are unchanged. const simpleOptions = params.options.map((o) => o.label) const base = { question: params.question, options: simpleOptions, ...(params.header ? { header: shortHeader(params.header) } : {}), ...(multiSelect ? { multiSelect: true } : {}) } if (!result) { return { content: [{ type: 'text', text: 'User cancelled the selection' }], details: { ...base, answer: null } as QuestionDetails, } } if (result.timedOut) { // Claude: "tells Claude you may be away from your keyboard, so Claude proceeds // on its own judgment and can re-ask later." Not framed as a cancel: `answer` is // '' rather than null, so renderResult and a batch's own null-check both read it // as "answered nothing, but not declined" rather than the user having said no. const already = multiSelect && result.answer ? ` Already selected: ${result.answer}.` : '' return { content: [{ type: 'text', text: `No response after the configured idle timeout; the user may be away from the keyboard.${already} Proceed on your own judgment; you can ask again later if needed.` }], details: { ...base, answer: result.answer, timedOut: true } as QuestionDetails, } } if (result.wasCustom) { return { content: [{ type: 'text', text: `User wrote: ${result.answer}` }], details: { ...base, answer: result.answer, wasCustom: true } as QuestionDetails, } } const selectionText = multiSelect ? `User selected: ${result.answer || '(none)'}` : `User selected: ${result.index}. ${result.answer}` return { content: [{ type: 'text', text: selectionText }], details: { ...base, answer: result.answer, wasCustom: false } as QuestionDetails, } } /** Claude: "You see a countdown for the last 20 seconds." */ const COUNTDOWN_WINDOW_MS = 20_000 /** Granularity of the idle-timer tick: fine enough that the countdown's displayed * second changes on time, coarse enough not to re-render needlessly often. */ const IDLE_TICK_MS = 250 /** Terminal path: the full custom overlay (options list, checkboxes, inline editor). * * `timeoutMs`, when set, is Claude's askUserQuestionTimeout: "After a question sits * that long with no input, the dialog closes on its own: it submits any options * you'd already selected and tells Claude you may be away from your keyboard, so * Claude proceeds on its own judgment and can re-ask later. You see a countdown for * the last 20 seconds. Press any key to restart the timer." Terminal focus-in * restarting the timer, the other documented reset trigger, is not implemented: * pi's TUI input stream is not confirmed to carry the terminal's own focus-report * escape sequences, and guessing at that risks misreading ordinary input as a * focus event on a terminal that reports it differently. Exported so the timer * mechanics are testable directly, independent of where timeoutMs itself is read * from (askUserQuestionTimeoutMs, tested separately). */ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number, remote?: RemoteQuestion): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> { return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null>((tui: Parameters[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null) => void) => { let optionIndex = 0 let editMode = false const checked: boolean[] = allOptions.map(() => false) let cachedLines: string[] | undefined let cachedWidth: number | undefined // deadline stays undefined for the whole overlay life when no timeout is // configured, so every idle-timer branch below is a no-op in that case. let deadline: number | undefined = timeoutMs !== undefined ? Date.now() + timeoutMs : undefined let idleTimer: ReturnType | undefined let lastCountdown: number | undefined function stopIdleTimer(): void { if (idleTimer !== undefined) clearInterval(idleTimer) idleTimer = undefined } /** Every exit path (an answer, a cancel, or the timeout itself) goes through * here, so the interval can never outlive the overlay it belongs to. */ function finish(value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null): void { stopIdleTimer() // Closed before done, so a remote reply racing a local answer is rejected. remote?.close() done(value) } function resetIdleTimer(): void { if (timeoutMs === undefined) return deadline = Date.now() + timeoutMs } remote?.attach(finish, resetIdleTimer) function fireTimeout(): void { // Claude: "submits any options you'd already selected". Single-select has // nothing pre-committed (a selection only exists once Enter confirms it), so // its timeout answer is empty rather than whatever option merely had focus. const answer = multiSelect ? selectedLabels(allOptions, checked) : '' finish({ answer, wasCustom: false, timedOut: true }) } function idleTick(): void { if (deadline === undefined) return const remainingMs = deadline - Date.now() if (remainingMs <= 0) { fireTimeout() return } const remainingSeconds = Math.ceil(remainingMs / 1000) const nextCountdown = remainingMs <= COUNTDOWN_WINDOW_MS ? remainingSeconds : undefined if (nextCountdown !== lastCountdown) { lastCountdown = nextCountdown refresh() } } if (timeoutMs !== undefined) { idleTimer = setInterval(idleTick, IDLE_TICK_MS) } 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), }, } const editor = new Editor(tui, editorTheme) editor.onSubmit = (value) => { const trimmed = value.trim() if (trimmed) { finish({ answer: trimmed, wasCustom: true }) } else { editMode = false editor.setText('') refresh() } } function refresh() { cachedLines = undefined tui.requestRender() } function handleEditInput(data: string): void { if (matchesKey(data, Key.escape)) { editMode = false editor.setText('') refresh() return } editor.handleInput(data) refresh() } function confirmSelection(): void { if (multiSelect) { finish({ answer: selectedLabels(allOptions, checked), wasCustom: false }) return } const selected = allOptions[optionIndex] if (selected.isOther) { editMode = true refresh() } else { finish({ answer: selected.label, wasCustom: false, index: optionIndex + 1 }) } } /** Moves the focus for an arrow key; false for any other key. */ function moveFocus(data: string): boolean { if (matchesKey(data, Key.up)) { optionIndex = Math.max(0, optionIndex - 1) } else if (matchesKey(data, Key.down)) { optionIndex = Math.min(allOptions.length - 1, optionIndex + 1) } else { return false } refresh() return true } function handleSelectInput(data: string): void { if (moveFocus(data)) return if (multiSelect && data === ' ') { checked[optionIndex] = !checked[optionIndex] refresh() return } if (matchesKey(data, Key.enter)) { confirmSelection() return } if (matchesKey(data, Key.escape)) { finish(null) } } function handleInput(data: string) { // Claude: "Press any key to restart the timer." Every branch below returns // through this function, so resetting unconditionally on entry covers all of // them, including the ones (arrow keys, space) that never reach `finish`. resetIdleTimer() if (editMode) handleEditInput(data) else handleSelectInput(data) } function render(width: number): string[] { if (cachedLines && cachedWidth === width) return cachedLines cachedWidth = width cachedLines = buildQuestionLines({ width, question: params.question, header: shortHeader(params.header), options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme, countdownSeconds: lastCountdown }) return cachedLines } return { render, invalidate: () => { cachedWidth = undefined cachedLines = undefined }, handleInput, // Belt and suspenders alongside finish()'s own stopIdleTimer: if the host ever // tears the overlay down through a path that does not go through `done` // (finish's only caller), the interval still gets cleared here. dispose: stopIdleTimer, } }) } /** Dialog-primitive fallback for UI without a terminal (RPC mode supports * select/input/notify but not custom components). Mirrors the overlay's result * shape; a dismissed dialog reads as a cancel, same as Escape in the overlay. */ async function askViaDialogs(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> { const header = shortHeader(params.header) const title = header ? `[${header}] ${params.question}` : params.question // Number the labels: ctx.ui.select returns the chosen label string, so duplicate // labels (or a model-supplied option named like the free-text entry) would be // ambiguous by text alone; the number is the unambiguous way back to the option. const labels = allOptions.map((option, i) => `${i + 1}. ${option.label}`) const choice = await ctx.ui.select(title, labels) if (choice === undefined) return null const index = labels.indexOf(choice) const chosen = allOptions[index] if (!multiSelect && chosen?.isOther === true) { const typed = await ctx.ui.input(params.question, 'Your answer') // A dismissed dialog cancels; a submitted empty answer is an (empty) answer, not a // cancel, so one accidental blank Enter does not abort the rest of a question batch. if (typed === undefined) return null return { answer: typed.trim(), wasCustom: true } } const answer = chosen?.label ?? choice return multiSelect ? { answer, wasCustom: false } : { answer, wasCustom: false, index: index + 1 } }