Compare commits

...

3 Commits

Author SHA1 Message Date
Dax Raad
a5f5eec7db core: simplify question tool output formatting 2026-01-08 17:31:24 -05:00
Dax Raad
a70132a7f3 fix(question): include option descriptions in tool output 2026-01-08 17:29:11 -05:00
Dax Raad
59a5eba370 feat(question): support multi-select questions 2026-01-08 17:25:06 -05:00
10 changed files with 1411 additions and 325 deletions

View File

@@ -1840,6 +1840,12 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
function Question(props: ToolProps<typeof QuestionTool>) {
const { theme } = useTheme()
const count = createMemo(() => props.input.questions?.length ?? 0)
function format(answer?: string[]) {
if (!answer?.length) return "(no answer)"
return answer.join(", ")
}
return (
<Switch>
<Match when={props.metadata.answers}>
@@ -1849,7 +1855,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
{(q, i) => (
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>{q.question}</text>
<text fg={theme.text}>{props.metadata.answers?.[i()] || "(no answer)"}</text>
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
</box>
)}
</For>

View File

@@ -4,7 +4,7 @@ import { useKeyboard } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme } from "../../context/theme"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
@@ -17,11 +17,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const bindings = useTextareaKeybindings()
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1)
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
const [store, setStore] = createStore({
tab: 0,
answers: [] as string[],
answers: [] as QuestionAnswer[],
custom: [] as string[],
selected: 0,
editing: false,
@@ -34,10 +34,15 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const options = createMemo(() => question()?.options ?? [])
const other = createMemo(() => store.selected === options().length)
const input = createMemo(() => store.custom[store.tab] ?? "")
const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
const value = input()
if (!value) return false
return store.answers[store.tab]?.includes(value) ?? false
})
function submit() {
// Fill in empty answers with empty strings
const answers = questions().map((_, i) => store.answers[i] ?? "")
const answers = questions().map((_, i) => store.answers[i] ?? [])
sdk.client.question.reply({
requestID: props.request.id,
answers,
@@ -52,7 +57,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
function pick(answer: string, custom: boolean = false) {
const answers = [...store.answers]
answers[store.tab] = answer
answers[store.tab] = [answer]
setStore("answers", answers)
if (custom) {
const inputs = [...store.custom]
@@ -62,7 +67,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
if (single()) {
sdk.client.question.reply({
requestID: props.request.id,
answers: [answer],
answers: [[answer]],
})
return
}
@@ -70,6 +75,17 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
setStore("selected", 0)
}
function toggle(answer: string) {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
const index = next.indexOf(answer)
if (index === -1) next.push(answer)
if (index !== -1) next.splice(index, 1)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
}
const dialog = useDialog()
useKeyboard((evt) => {
@@ -82,11 +98,49 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}
if (evt.name === "return") {
evt.preventDefault()
const text = textarea?.plainText?.trim()
if (text) {
pick(text, true)
const text = textarea?.plainText?.trim() ?? ""
const prev = store.custom[store.tab]
if (!text) {
if (prev) {
const inputs = [...store.custom]
inputs[store.tab] = ""
setStore("custom", inputs)
}
const answers = [...store.answers]
if (prev) {
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
}
if (!prev) {
answers[store.tab] = []
}
setStore("answers", answers)
setStore("editing", false)
return
}
if (multi()) {
const inputs = [...store.custom]
inputs[store.tab] = text
setStore("custom", inputs)
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (prev) {
const index = next.indexOf(prev)
if (index !== -1) next.splice(index, 1)
}
if (!next.includes(text)) next.push(text)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(text, true)
setStore("editing", false)
return
}
// Let textarea handle all other keys
@@ -133,13 +187,25 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
if (evt.name === "return") {
evt.preventDefault()
if (other()) {
setStore("editing", true)
} else {
const opt = opts[store.selected]
if (opt) {
pick(opt.label)
if (!multi()) {
setStore("editing", true)
return
}
const value = input()
if (value && customPicked()) {
toggle(value)
return
}
setStore("editing", true)
return
}
const opt = opts[store.selected]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
@@ -162,7 +228,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<For each={questions()}>
{(q, index) => {
const isActive = () => index() === store.tab
const isAnswered = () => store.answers[index()] !== undefined
const isAnswered = () => {
return (store.answers[index()]?.length ?? 0) > 0
}
return (
<box
paddingLeft={1}
@@ -185,13 +253,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<Show when={!confirm()}>
<box paddingLeft={1} gap={1}>
<box>
<text fg={theme.text}>{question()?.question}</text>
<text fg={theme.text}>
{question()?.question}
{multi() ? " (select all that apply)" : ""}
</text>
</box>
<box>
<For each={options()}>
{(opt, i) => {
const active = () => i() === store.selected
const picked = () => store.answers[store.tab] === opt.label
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<box>
<box flexDirection="row" gap={1}>
@@ -212,17 +283,18 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box>
<box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : input() ? theme.success : theme.text}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
{options().length + 1}. Type your own answer
</text>
</box>
<text fg={theme.success}>{input() ? "✓" : ""}</text>
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
</box>
<Show when={store.editing}>
<box paddingLeft={3}>
<textarea
ref={(val: TextareaRenderable) => (textarea = val)}
focused
initialValue={input()}
placeholder="Type your own answer"
textColor={theme.text}
focusedTextColor={theme.text}
@@ -247,11 +319,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
</box>
<For each={questions()}>
{(q, index) => {
const answer = () => store.answers[index()]
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>{q.header}:</text>
<text fg={answer() ? theme.text : theme.error}>{answer() ?? "(not answered)"}</text>
<text fg={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</text>
</box>
)
}}
@@ -279,8 +352,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
</text>
</Show>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>{confirm() ? "submit" : single() ? "submit" : "confirm"}</span>
enter{" "}
<span style={{ fg: theme.textMuted }}>
{confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
</text>

View File

@@ -23,6 +23,7 @@ export namespace Question {
question: z.string().describe("Complete question"),
header: z.string().max(12).describe("Very short label (max 12 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
})
.meta({
ref: "QuestionInfo",
@@ -46,8 +47,15 @@ export namespace Question {
})
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({
ref: "QuestionAnswer",
})
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z.array(z.string()).describe("User answers in order of questions"),
answers: z
.array(Answer)
.describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
@@ -58,7 +66,7 @@ export namespace Question {
z.object({
sessionID: z.string(),
requestID: z.string(),
answers: z.array(z.string()),
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
@@ -75,7 +83,7 @@ export namespace Question {
string,
{
info: Request
resolve: (answers: string[]) => void
resolve: (answers: Answer[]) => void
reject: (e: any) => void
}
> = {}
@@ -89,13 +97,13 @@ export namespace Question {
sessionID: string
questions: Info[]
tool?: { messageID: string; callID: string }
}): Promise<string[]> {
}): Promise<Answer[]> {
const s = await state()
const id = Identifier.ascending("question")
log.info("asking", { id, questions: input.questions.length })
return new Promise<string[]>((resolve, reject) => {
return new Promise<Answer[]>((resolve, reject) => {
const info: Request = {
id,
sessionID: input.sessionID,
@@ -111,7 +119,7 @@ export namespace Question {
})
}
export async function reply(input: { requestID: string; answers: string[] }): Promise<void> {
export async function reply(input: { requestID: string; answers: Answer[] }): Promise<void> {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) {

View File

@@ -52,7 +52,7 @@ export const QuestionRoute = new Hono()
requestID: z.string(),
}),
),
validator("json", z.object({ answers: z.array(z.string()) })),
validator("json", Question.Reply),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")

View File

@@ -15,7 +15,12 @@ export const QuestionTool = Tool.define("question", {
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const formatted = params.questions.map((q, i) => `"${q.question}"="${answers[i] ?? "Unanswered"}"`).join(", ")
function format(answer: Question.Answer | undefined) {
if (!answer?.length) return "Unanswered"
return answer.join(", ")
}
const formatted = params.questions.map((q, i) => `"${q.question}"="${format(answers[i])}"`).join(", ")
return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,

View File

@@ -6,4 +6,5 @@ Use this tool when you need to ask the user questions during execution. This all
Usage notes:
- Users will always be able to select "Other" to provide custom text input
- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label

View File

@@ -82,11 +82,11 @@ test("reply - resolves the pending ask with answers", async () => {
await Question.reply({
requestID,
answers: ["Option 1"],
answers: [["Option 1"]],
})
const answers = await askPromise
expect(answers).toEqual(["Option 1"])
expect(answers).toEqual([["Option 1"]])
},
})
})
@@ -115,7 +115,7 @@ test("reply - removes from pending list", async () => {
await Question.reply({
requestID: pending[0].id,
answers: ["Option 1"],
answers: [["Option 1"]],
})
const pendingAfter = await Question.list()
@@ -131,7 +131,7 @@ test("reply - does nothing for unknown requestID", async () => {
fn: async () => {
await Question.reply({
requestID: "que_unknown",
answers: ["Option 1"],
answers: [["Option 1"]],
})
// Should not throw
},
@@ -244,11 +244,11 @@ test("ask - handles multiple questions", async () => {
await Question.reply({
requestID: pending[0].id,
answers: ["Build", "Dev"],
answers: [["Build"], ["Dev"]],
})
const answers = await askPromise
expect(answers).toEqual(["Build", "Dev"])
expect(answers).toEqual([["Build"], ["Dev"]])
},
})
})

View File

@@ -84,6 +84,7 @@ import type {
PtyRemoveResponses,
PtyUpdateErrors,
PtyUpdateResponses,
QuestionAnswer,
QuestionListResponses,
QuestionRejectErrors,
QuestionRejectResponses,
@@ -1815,7 +1816,7 @@ export class Question extends HeyApiClient {
parameters: {
requestID: string
directory?: string
answers?: Array<string>
answers?: Array<QuestionAnswer>
},
options?: Options<never, ThrowOnError>,
) {

View File

@@ -541,6 +541,10 @@ export type QuestionInfo = {
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
}
export type QuestionRequest = {
@@ -561,12 +565,14 @@ export type EventQuestionAsked = {
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type EventQuestionReplied = {
type: "question.replied"
properties: {
sessionID: string
requestID: string
answers: Array<string>
answers: Array<QuestionAnswer>
}
}
@@ -3630,7 +3636,10 @@ export type QuestionListResponse = QuestionListResponses[keyof QuestionListRespo
export type QuestionReplyData = {
body?: {
answers: Array<string>
/**
* User answers in order of questions (each answer is an array of selected labels)
*/
answers: Array<QuestionAnswer>
}
path: {
requestID: string

File diff suppressed because it is too large Load Diff