feat(askquestion): fix race condition and improve TUI UX

This commit is contained in:
iljod 2025-12-22 14:35:40 +01:00
parent c6e9a5c800
commit 3a23fec31e
10 changed files with 873 additions and 12 deletions

View file

@ -0,0 +1,161 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
export namespace AskQuestion {
/**
* Schema for a single question option
*/
export const OptionSchema = z.object({
value: z.string().describe("Short identifier for the option"),
label: z.string().describe("Display label for the option"),
description: z.string().optional().describe("Additional context for the option"),
})
export type Option = z.infer<typeof OptionSchema>
/**
* Schema for a single question in the wizard
*/
export const QuestionSchema = z.object({
id: z.string().describe("Unique identifier for the question"),
label: z.string().describe("Short tab label, e.g. 'UI Framework'"),
question: z.string().describe("The full question to ask the user"),
options: z.array(OptionSchema).min(2).max(8).describe("2-8 suggested answer options"),
multiSelect: z.boolean().optional().describe("Allow selecting multiple options"),
})
export type Question = z.infer<typeof QuestionSchema>
/**
* Schema for a single answer from the user
*/
export const AnswerSchema = z.object({
questionId: z.string().describe("ID of the question being answered"),
values: z.array(z.string()).describe("Selected option value(s)"),
customText: z.string().optional().describe("Custom text if user typed their own response"),
})
export type Answer = z.infer<typeof AnswerSchema>
/**
* Bus events for askquestion flow
*/
export const Event = {
/**
* Published by the askquestion tool when it needs user input
*/
Requested: BusEvent.define(
"askquestion.requested",
z.object({
sessionID: z.string(),
messageID: z.string(),
callID: z.string(),
questions: z.array(QuestionSchema),
}),
),
/**
* Published by the TUI when user submits answers
*/
Answered: BusEvent.define(
"askquestion.answered",
z.object({
sessionID: z.string(),
callID: z.string(),
answers: z.array(AnswerSchema),
}),
),
/**
* Published when user cancels the question wizard
*/
Cancelled: BusEvent.define(
"askquestion.cancelled",
z.object({
sessionID: z.string(),
callID: z.string(),
}),
),
}
/**
* Pending askquestion requests waiting for user response
*/
interface PendingRequest {
sessionID: string
messageID: string
callID: string
questions: Question[]
resolve: (answers: Answer[]) => void
reject: (error: Error) => void
}
// Global map of pending requests by callID
const pendingRequests = new Map<string, PendingRequest>()
/**
* Register a pending askquestion request
*/
export function register(
callID: string,
sessionID: string,
messageID: string,
questions: Question[],
): Promise<Answer[]> {
return new Promise((resolve, reject) => {
pendingRequests.set(callID, {
sessionID,
messageID,
callID,
questions,
resolve,
reject,
})
})
}
/**
* Get a pending request
*/
export function get(callID: string): PendingRequest | undefined {
return pendingRequests.get(callID)
}
/**
* Get all pending requests for a session
*/
export function getForSession(sessionID: string): PendingRequest[] {
return Array.from(pendingRequests.values()).filter((r) => r.sessionID === sessionID)
}
/**
* Respond to a pending askquestion request
*/
export function respond(callID: string, answers: Answer[]): boolean {
const pending = pendingRequests.get(callID)
if (!pending) return false
pending.resolve(answers)
pendingRequests.delete(callID)
return true
}
/**
* Cancel a pending askquestion request
*/
export function cancel(callID: string): boolean {
const pending = pendingRequests.get(callID)
if (!pending) return false
pending.reject(new Error("User cancelled the question wizard"))
pendingRequests.delete(callID)
return true
}
/**
* Clean up pending requests for a session (e.g., on abort)
*/
export function cleanup(sessionID: string): void {
for (const [callID, request] of pendingRequests) {
if (request.sessionID === sessionID) {
request.reject(new Error("Session aborted"))
pendingRequests.delete(callID)
}
}
}
}

View file

@ -67,6 +67,8 @@ import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { Filesystem } from "@/util/filesystem"
import { DialogSubagent } from "./dialog-subagent.tsx"
import { DialogAskQuestion } from "../../ui/dialog-askquestion.tsx"
import type { AskQuestion } from "@/askquestion"
addDefaultParsers(parsers.parsers)
@ -197,6 +199,38 @@ export function Session() {
}
})
// Detect pending askquestion tools from synced message parts
// Access via session.messages -> parts for proper Solid.js reactivity
const pendingAskQuestionFromSync = createMemo(() => {
const sessionMessages = sync.data.message[route.sessionID] ?? []
for (const message of sessionMessages) {
const parts = sync.data.part[message.id] ?? []
for (const part of parts) {
if (part.type !== "tool") continue
const toolPart = part as ToolPart
if (toolPart.tool !== "askquestion") continue
if (toolPart.state.status !== "running") continue
const metadata = toolPart.state.metadata as
| { status?: string; questions?: AskQuestion.Question[] }
| undefined
if (metadata?.status !== "waiting") continue
return {
callID: toolPart.callID,
messageId: toolPart.messageID,
questions: (metadata.questions ?? []) as AskQuestion.Question[],
}
}
}
return null
})
let scroll: ScrollBoxRenderable
let prompt: PromptRef
const keybind = useKeybind()
@ -1000,17 +1034,59 @@ export function Session() {
</For>
</scrollbox>
<box flexShrink={0}>
<Prompt
ref={(r) => {
prompt = r
promptRef.set(r)
}}
disabled={permissions().length > 0}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
/>
<Switch>
<Match when={pendingAskQuestionFromSync()}>
{(pending) => (
<DialogAskQuestion
questions={pending().questions}
onSubmit={async (answers) => {
await fetch(`${sdk.url}/askquestion/respond`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
callID: pending().callID,
sessionID: route.sessionID,
answers,
}),
}).catch(() => {
toast.show({
message: "Failed to submit answers",
variant: "error",
})
})
}}
onCancel={async () => {
await fetch(`${sdk.url}/askquestion/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
callID: pending().callID,
sessionID: route.sessionID,
}),
}).catch(() => {
toast.show({
message: "Failed to cancel",
variant: "error",
})
})
}}
/>
)}
</Match>
<Match when={!pendingAskQuestionFromSync()}>
<Prompt
ref={(r) => {
prompt = r
promptRef.set(r)
}}
disabled={permissions().length > 0}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
/>
</Match>
</Switch>
</box>
<Show when={!sidebarVisible()}>
<Footer />

View file

@ -0,0 +1,413 @@
import { InputRenderable, ScrollBoxRenderable, TextAttributes, RGBA } from "@opentui/core"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { batch, createEffect, createMemo, For, Show, on, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import type { AskQuestion } from "@/askquestion"
export interface DialogAskQuestionProps {
questions: AskQuestion.Question[]
onSubmit: (answers: AskQuestion.Answer[]) => void
onCancel: () => void
}
interface QuestionState {
selectedOption: number
selectedValues: string[]
customText?: string
}
export function DialogAskQuestion(props: DialogAskQuestionProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const fg = selectedForeground(theme)
// State for the wizard
const [store, setStore] = createStore({
activeTab: 0,
questionStates: props.questions.map(() => ({
selectedOption: 0,
selectedValues: [] as string[],
customText: undefined as string | undefined,
})) as QuestionState[],
isTypingCustom: false,
customInputValue: "",
})
// Current question based on active tab
const currentQuestion = createMemo(() => props.questions[store.activeTab])
const currentState = createMemo(() => store.questionStates[store.activeTab])
// Options including "Type something..." at the end
const optionsWithCustom = createMemo(() => [
...currentQuestion().options,
{ value: "__custom__", label: "Type something.", description: "Enter your own response" },
])
// Check if all questions have at least one answer
const allAnswered = createMemo(() =>
store.questionStates.every((state) => state.selectedValues.length > 0 || state.customText),
)
// Check if current question is answered
const currentAnswered = createMemo(() => {
const state = currentState()
return state.selectedValues.length > 0 || state.customText
})
let scrollRef: ScrollBoxRenderable
let inputRef: InputRenderable
// Handle keyboard navigation
useKeyboard((evt) => {
if (store.isTypingCustom) {
// In custom input mode
if (evt.name === "escape") {
setStore("isTypingCustom", false)
setStore("customInputValue", "")
} else if (evt.name === "return") {
const value = store.customInputValue.trim()
if (value) {
setStore(
produce((s) => {
s.questionStates[s.activeTab].customText = value
s.questionStates[s.activeTab].selectedValues = []
}),
)
}
setStore("isTypingCustom", false)
setStore("customInputValue", "")
// Auto-advance to next question or submit
if (store.activeTab < props.questions.length - 1) {
setStore("activeTab", store.activeTab + 1)
}
}
return
}
// Tab/arrow navigation between questions
if (evt.name === "tab" || evt.name === "right") {
if (store.activeTab < props.questions.length - 1) {
setStore("activeTab", store.activeTab + 1)
} else if (allAnswered()) {
// Submit when on last tab and all answered
handleSubmit()
}
} else if (evt.shift && evt.name === "tab") {
if (store.activeTab > 0) {
setStore("activeTab", store.activeTab - 1)
}
} else if (evt.name === "left") {
if (store.activeTab > 0) {
setStore("activeTab", store.activeTab - 1)
}
}
// Up/down navigation within options
else if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
const current = currentState().selectedOption
const max = optionsWithCustom().length - 1
setStore(
produce((s) => {
s.questionStates[s.activeTab].selectedOption = current > 0 ? current - 1 : max
}),
)
} else if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
const current = currentState().selectedOption
const max = optionsWithCustom().length - 1
setStore(
produce((s) => {
s.questionStates[s.activeTab].selectedOption = current < max ? current + 1 : 0
}),
)
}
// Space to toggle selection (especially useful for multi-select)
else if (evt.name === "space") {
const selectedIdx = currentState().selectedOption
const option = optionsWithCustom()[selectedIdx]
if (option.value === "__custom__") {
// Open custom input
setStore("isTypingCustom", true)
setTimeout(() => inputRef?.focus(), 10)
} else {
const question = currentQuestion()
setStore(
produce((s) => {
const state = s.questionStates[s.activeTab]
state.customText = undefined
if (question.multiSelect) {
// Toggle for multi-select
const idx = state.selectedValues.indexOf(option.value)
if (idx >= 0) {
state.selectedValues.splice(idx, 1)
} else {
state.selectedValues.push(option.value)
}
} else {
// Select for single-select (same as Enter)
state.selectedValues = [option.value]
if (s.activeTab < props.questions.length - 1) {
s.activeTab++
}
}
}),
)
// Auto-submit if single-select on last question
if (!currentQuestion().multiSelect) {
setTimeout(() => {
if (allAnswered()) {
handleSubmit()
}
}, 50)
}
}
}
// Enter to select option (single-select) or confirm and advance (multi-select)
else if (evt.name === "return") {
const selectedIdx = currentState().selectedOption
const option = optionsWithCustom()[selectedIdx]
const question = currentQuestion()
if (option.value === "__custom__") {
// Open custom input
setStore("isTypingCustom", true)
setTimeout(() => inputRef?.focus(), 10)
} else if (question.multiSelect) {
// For multi-select: Enter confirms current selections and advances
if (currentAnswered()) {
if (store.activeTab < props.questions.length - 1) {
setStore("activeTab", store.activeTab + 1)
} else if (allAnswered()) {
handleSubmit()
}
} else {
// If nothing selected yet, toggle the current option
setStore(
produce((s) => {
const state = s.questionStates[s.activeTab]
state.customText = undefined
state.selectedValues.push(option.value)
}),
)
}
} else {
// Single-select: select and advance
setStore(
produce((s) => {
const state = s.questionStates[s.activeTab]
state.customText = undefined
state.selectedValues = [option.value]
if (s.activeTab < props.questions.length - 1) {
s.activeTab++
}
}),
)
// Auto-submit if this was the last question
setTimeout(() => {
if (allAnswered()) {
handleSubmit()
}
}, 50)
}
}
// Number keys for quick selection (1-5)
else if (evt.name >= "1" && evt.name <= "5") {
const idx = parseInt(evt.name) - 1
if (idx < currentQuestion().options.length) {
const option = currentQuestion().options[idx]
setStore(
produce((s) => {
const state = s.questionStates[s.activeTab]
state.customText = undefined
if (currentQuestion().multiSelect) {
const existingIdx = state.selectedValues.indexOf(option.value)
if (existingIdx >= 0) {
state.selectedValues.splice(existingIdx, 1)
} else {
state.selectedValues.push(option.value)
}
} else {
state.selectedValues = [option.value]
if (s.activeTab < props.questions.length - 1) {
s.activeTab++
}
}
}),
)
}
}
// Escape to cancel
else if (evt.name === "escape") {
props.onCancel()
}
// Ctrl+Enter to submit
else if (evt.ctrl && evt.name === "return") {
if (allAnswered()) {
handleSubmit()
}
}
})
function handleSubmit() {
const answers: AskQuestion.Answer[] = props.questions.map((q, i) => {
const state = store.questionStates[i]
return {
questionId: q.id,
values: state.selectedValues,
customText: state.customText,
}
})
props.onSubmit(answers)
}
const height = createMemo(() => Math.min(15, Math.floor(dimensions().height / 2)))
return (
<box flexDirection="column" gap={1}>
{/* Tab bar */}
<box flexDirection="row" paddingLeft={2} paddingRight={2} gap={2}>
<text fg={theme.textMuted}></text>
<For each={props.questions}>
{(question, index) => {
const isActive = createMemo(() => store.activeTab === index())
const isAnswered = createMemo(() => {
const state = store.questionStates[index()]
return state.selectedValues.length > 0 || state.customText
})
return (
<box
flexDirection="row"
gap={1}
paddingRight={1}
onMouseUp={() => setStore("activeTab", index())}
>
<text fg={isAnswered() ? theme.success : theme.textMuted}>
{isAnswered() ? "●" : "○"}
</text>
<text
fg={isActive() ? theme.text : theme.textMuted}
attributes={isActive() ? TextAttributes.BOLD : undefined}
>
{question.label}
</text>
</box>
)
}}
</For>
<Show when={allAnswered()}>
<box flexDirection="row" gap={1}>
<text fg={theme.success}></text>
<text
fg={theme.success}
attributes={TextAttributes.BOLD}
onMouseUp={handleSubmit}
>
Submit
</text>
</box>
</Show>
<text fg={theme.textMuted}></text>
</box>
{/* Current question */}
<box paddingLeft={2} paddingRight={2} flexDirection="column" gap={0}>
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
{currentQuestion().question}
</text>
<Show when={currentQuestion().multiSelect}>
<text fg={theme.textMuted}>(select multiple, press Enter to confirm)</text>
</Show>
</box>
{/* Options */}
<scrollbox
ref={(r: ScrollBoxRenderable) => (scrollRef = r)}
maxHeight={height()}
paddingLeft={2}
paddingRight={2}
scrollbarOptions={{ visible: false }}
>
<For each={optionsWithCustom()}>
{(option, index) => {
const isSelected = createMemo(() => currentState().selectedOption === index())
const isChosen = createMemo(() => {
if (option.value === "__custom__") {
return !!currentState().customText
}
return currentState().selectedValues.includes(option.value)
})
return (
<box
flexDirection="row"
backgroundColor={isSelected() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={1}
paddingRight={1}
gap={1}
onMouseUp={() => {
setStore(
produce((s) => {
s.questionStates[s.activeTab].selectedOption = index()
}),
)
}}
>
{/* Use different icons for single vs multi select */}
<text fg={isSelected() ? fg : theme.textMuted} flexShrink={0}>
{option.value === "__custom__"
? ""
: currentQuestion().multiSelect
? (isChosen() ? "[✓]" : "[ ]")
: (isChosen() ? "●" : "○")
}
</text>
<text
fg={isSelected() ? fg : isChosen() ? theme.success : theme.text}
attributes={isChosen() ? TextAttributes.BOLD : undefined}
>
{option.label}
</text>
<Show when={option.description && option.value !== "__custom__"}>
<text fg={isSelected() ? fg : theme.textMuted}>
{option.description}
</text>
</Show>
</box>
)
}}
</For>
</scrollbox>
{/* Custom input (when active) */}
<Show when={store.isTypingCustom}>
<box paddingLeft={2} paddingRight={2}>
<input
ref={(r) => (inputRef = r)}
placeholder="Type your response..."
cursorColor={theme.primary}
focusedTextColor={theme.text}
focusedBackgroundColor={theme.backgroundPanel}
onInput={(value) => setStore("customInputValue", value)}
/>
</box>
</Show>
{/* Instructions */}
<box paddingLeft={2} paddingRight={2} paddingTop={1}>
<text fg={theme.textMuted}>
{currentQuestion().multiSelect
? "Space to toggle · Enter to confirm · ↑↓ to navigate · Esc to cancel"
: "Enter/Space to select · ↑↓ to navigate · Esc to cancel"
}
</text>
</box>
</box>
)
}

View file

@ -47,6 +47,7 @@ import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket } from "hono/bun"
import { errors } from "./error"
import { Pty } from "@/pty"
import { AskQuestion } from "@/askquestion"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@ -1506,6 +1507,76 @@ export namespace Server {
return c.json(true)
},
)
.post(
"/askquestion/respond",
describeRoute({
summary: "Respond to askquestion",
description: "Submit answers to a pending askquestion tool call.",
operationId: "askquestion.respond",
responses: {
200: {
description: "Response submitted successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"json",
z.object({
sessionID: z.string(),
callID: z.string(),
answers: AskQuestion.AnswerSchema.array(),
}),
),
async (c) => {
const { callID, answers } = c.req.valid("json")
// The partID is the callID for the askquestion
const success = AskQuestion.respond(callID, answers)
if (!success) {
throw new Error("No pending askquestion found with this ID")
}
return c.json(true)
},
)
.post(
"/askquestion/cancel",
describeRoute({
summary: "Cancel askquestion",
description: "Cancel a pending askquestion tool call.",
operationId: "askquestion.cancel",
responses: {
200: {
description: "Cancelled successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"json",
z.object({
sessionID: z.string(),
callID: z.string(),
}),
),
async (c) => {
const { callID } = c.req.valid("json")
const success = AskQuestion.cancel(callID)
if (!success) {
throw new Error("No pending askquestion found with this ID")
}
return c.json(true)
},
)
.get(
"/command",
describeRoute({

View file

@ -371,6 +371,7 @@ export namespace Session {
export const updatePart = fn(UpdatePartInput, async (input) => {
const part = "delta" in input ? input.part : input
const delta = "delta" in input ? input.delta : undefined
await Storage.write(["part", part.messageID, part.id], part)
Bus.publish(MessageV2.Event.PartUpdated, {
part,

View file

@ -614,7 +614,19 @@ export namespace SessionPrompt {
extra: { model: input.model },
agent: input.agent.name,
metadata: async (val) => {
const match = input.processor.partFromToolCall(options.toolCallId)
// Wait for the part to be in "running" state with retries
// This handles the race condition where execute() is called before
// the tool-call event has been processed
let match = input.processor.partFromToolCall(options.toolCallId)
let retries = 0
const maxRetries = 20 // 20 * 50ms = 1 second max wait
while ((!match || match.state.status !== "running") && retries < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 50))
match = input.processor.partFromToolCall(options.toolCallId)
retries++
}
if (match && match.state.status === "running") {
await Session.updatePart({
...match,
@ -628,6 +640,11 @@ export namespace SessionPrompt {
},
},
})
} else {
log.warn("metadata: part not found or not running after waiting", {
toolCallId: options.toolCallId,
retries,
})
}
},
})

View file

@ -14,6 +14,14 @@ is a critical violation. ZERO exceptions.
Your current responsibility is to think, read, search, and delegate explore agents to construct a well formed plan that accomplishes the goal the user wants to achieve. Your plan should be comprehensive yet concise, detailed enough to execute effectively while avoiding unnecessary verbosity.
## Clarifying Questions
Use the `askquestion` tool to gather requirements before finalizing your plan. This tool presents a wizard-style interface with multiple questions. Generate descriptive tab labels like "UI Framework", "Database", "Auth Strategy". Questions should help you understand:
- Technology preferences and constraints
- Scale and performance requirements
- Existing integrations or dependencies
- Priority tradeoffs when multiple approaches exist
Ask the user clarifying questions or ask for their opinion when weighing tradeoffs.
**NOTE:** At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.

View file

@ -0,0 +1,80 @@
import { Tool } from "./tool"
import DESCRIPTION from "./askquestion.txt"
import z from "zod"
import { AskQuestion } from "../askquestion"
import { defer } from "@/util/defer"
export const AskQuestionTool = Tool.define(
"askquestion",
{
description: DESCRIPTION,
parameters: z.object({
questions: z
.array(AskQuestion.QuestionSchema)
.min(1)
.max(6)
.describe("1-6 questions to ask in wizard flow"),
}),
async execute(params, ctx) {
// Update the tool metadata to show what we're waiting for
// IMPORTANT: Must await to ensure Part is synced before we block waiting for response
await ctx.metadata({
title: `Asking ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
metadata: {
questions: params.questions,
status: "waiting",
},
})
// Register the pending request and wait for response
const answerPromise = AskQuestion.register(
ctx.callID!,
ctx.sessionID,
ctx.messageID,
params.questions,
)
// Handle abort signal
const abortHandler = () => {
AskQuestion.cancel(ctx.callID!)
}
ctx.abort.addEventListener("abort", abortHandler)
using _ = defer(() => ctx.abort.removeEventListener("abort", abortHandler))
// Wait for user response
const answers = await answerPromise
// Format the answers for the LLM
const formattedAnswers = answers
.map((answer) => {
const question = params.questions.find((q) => q.id === answer.questionId)
const questionLabel = question?.label ?? answer.questionId
if (answer.customText) {
return `**${questionLabel}**: ${answer.customText} (custom response)`
}
const selectedLabels = answer.values
.map((v) => {
const option = question?.options.find((o) => o.value === v)
return option?.label ?? v
})
.join(", ")
return `**${questionLabel}**: ${selectedLabels}`
})
.join("\n")
return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
metadata: {
questions: params.questions.map((q) => q.label),
answers: answers,
status: "completed",
},
output: `User responses:\n\n${formattedAnswers}`,
}
},
},
)

View file

@ -0,0 +1,32 @@
# AskUserQuestion
Use this tool during planning to ask the user multiple clarifying questions in a wizard-style flow. This enables you to:
1. Gather user preferences on multiple dimensions at once
2. Clarify ambiguous requirements before planning
3. Get decisions on implementation choices (framework, deployment, etc.)
4. Present tradeoffs and let the user decide
## Parameters
- **questions**: An array of 1-6 questions, each with:
- **id**: Unique identifier for the question
- **label**: Short tab label (2-3 words), e.g. "UI Framework", "Database", "Auth Strategy"
- **question**: The full question text to display
- **options**: 2-5 answer options, each with value, label, and optional description
- **multiSelect**: (optional) Set to true to allow multiple selections
## Guidelines
- Generate descriptive tab labels that clearly indicate what each question is about
- Provide 2-8 distinct, actionable answer options per question
- Users can always type a custom response for any question
- Order questions logically (broad decisions before specific ones)
- Keep questions focused - don't ask too many at once (max 6)
## Example
When user says "Build a new web app", you might ask:
- "UI Framework" → React, Vue, Svelte, Vanilla JS
- "Styling" → Tailwind CSS, CSS Modules, Styled Components
- "Backend" → Node.js, Python, Go, Serverless
- "Database" → PostgreSQL, MongoDB, SQLite, None

View file

@ -10,6 +10,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { AskQuestionTool } from "./askquestion"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool"
import { Instance } from "../project/instance"
@ -103,6 +104,7 @@ export namespace ToolRegistry {
TodoReadTool,
WebSearchTool,
CodeSearchTool,
AskQuestionTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...custom,