Skip to content

Commit 22dd70b

Browse files
authored
feat(question): support multi-select questions (#7386)
1 parent b4f8de0 commit 22dd70b

10 files changed

Lines changed: 1411 additions & 325 deletions

File tree

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1840,6 +1840,12 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
18401840
function Question(props: ToolProps<typeof QuestionTool>) {
18411841
const { theme } = useTheme()
18421842
const count = createMemo(() => props.input.questions?.length ?? 0)
1843+
1844+
function format(answer?: string[]) {
1845+
if (!answer?.length) return "(no answer)"
1846+
return answer.join(", ")
1847+
}
1848+
18431849
return (
18441850
<Switch>
18451851
<Match when={props.metadata.answers}>
@@ -1849,7 +1855,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
18491855
{(q, i) => (
18501856
<box flexDirection="row" gap={1}>
18511857
<text fg={theme.textMuted}>{q.question}</text>
1852-
<text fg={theme.text}>{props.metadata.answers?.[i()] || "(no answer)"}</text>
1858+
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
18531859
</box>
18541860
)}
18551861
</For>

packages/opencode/src/cli/cmd/tui/routes/session/question.tsx

Lines changed: 101 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useKeyboard } from "@opentui/solid"
44
import type { TextareaRenderable } from "@opentui/core"
55
import { useKeybind } from "../../context/keybind"
66
import { useTheme } from "../../context/theme"
7-
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
7+
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
88
import { useSDK } from "../../context/sdk"
99
import { SplitBorder } from "../../component/border"
1010
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
@@ -17,11 +17,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
1717
const bindings = useTextareaKeybindings()
1818

1919
const questions = createMemo(() => props.request.questions)
20-
const single = createMemo(() => questions().length === 1)
21-
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single)
20+
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
21+
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
2222
const [store, setStore] = createStore({
2323
tab: 0,
24-
answers: [] as string[],
24+
answers: [] as QuestionAnswer[],
2525
custom: [] as string[],
2626
selected: 0,
2727
editing: false,
@@ -34,10 +34,15 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
3434
const options = createMemo(() => question()?.options ?? [])
3535
const other = createMemo(() => store.selected === options().length)
3636
const input = createMemo(() => store.custom[store.tab] ?? "")
37+
const multi = createMemo(() => question()?.multiple === true)
38+
const customPicked = createMemo(() => {
39+
const value = input()
40+
if (!value) return false
41+
return store.answers[store.tab]?.includes(value) ?? false
42+
})
3743

3844
function submit() {
39-
// Fill in empty answers with empty strings
40-
const answers = questions().map((_, i) => store.answers[i] ?? "")
45+
const answers = questions().map((_, i) => store.answers[i] ?? [])
4146
sdk.client.question.reply({
4247
requestID: props.request.id,
4348
answers,
@@ -52,7 +57,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
5257

5358
function pick(answer: string, custom: boolean = false) {
5459
const answers = [...store.answers]
55-
answers[store.tab] = answer
60+
answers[store.tab] = [answer]
5661
setStore("answers", answers)
5762
if (custom) {
5863
const inputs = [...store.custom]
@@ -62,14 +67,25 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
6267
if (single()) {
6368
sdk.client.question.reply({
6469
requestID: props.request.id,
65-
answers: [answer],
70+
answers: [[answer]],
6671
})
6772
return
6873
}
6974
setStore("tab", store.tab + 1)
7075
setStore("selected", 0)
7176
}
7277

78+
function toggle(answer: string) {
79+
const existing = store.answers[store.tab] ?? []
80+
const next = [...existing]
81+
const index = next.indexOf(answer)
82+
if (index === -1) next.push(answer)
83+
if (index !== -1) next.splice(index, 1)
84+
const answers = [...store.answers]
85+
answers[store.tab] = next
86+
setStore("answers", answers)
87+
}
88+
7389
const dialog = useDialog()
7490

7591
useKeyboard((evt) => {
@@ -82,11 +98,49 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
8298
}
8399
if (evt.name === "return") {
84100
evt.preventDefault()
85-
const text = textarea?.plainText?.trim()
86-
if (text) {
87-
pick(text, true)
101+
const text = textarea?.plainText?.trim() ?? ""
102+
const prev = store.custom[store.tab]
103+
104+
if (!text) {
105+
if (prev) {
106+
const inputs = [...store.custom]
107+
inputs[store.tab] = ""
108+
setStore("custom", inputs)
109+
}
110+
111+
const answers = [...store.answers]
112+
if (prev) {
113+
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
114+
}
115+
if (!prev) {
116+
answers[store.tab] = []
117+
}
118+
setStore("answers", answers)
119+
setStore("editing", false)
120+
return
121+
}
122+
123+
if (multi()) {
124+
const inputs = [...store.custom]
125+
inputs[store.tab] = text
126+
setStore("custom", inputs)
127+
128+
const existing = store.answers[store.tab] ?? []
129+
const next = [...existing]
130+
if (prev) {
131+
const index = next.indexOf(prev)
132+
if (index !== -1) next.splice(index, 1)
133+
}
134+
if (!next.includes(text)) next.push(text)
135+
const answers = [...store.answers]
136+
answers[store.tab] = next
137+
setStore("answers", answers)
88138
setStore("editing", false)
139+
return
89140
}
141+
142+
pick(text, true)
143+
setStore("editing", false)
90144
return
91145
}
92146
// Let textarea handle all other keys
@@ -133,13 +187,25 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
133187
if (evt.name === "return") {
134188
evt.preventDefault()
135189
if (other()) {
136-
setStore("editing", true)
137-
} else {
138-
const opt = opts[store.selected]
139-
if (opt) {
140-
pick(opt.label)
190+
if (!multi()) {
191+
setStore("editing", true)
192+
return
193+
}
194+
const value = input()
195+
if (value && customPicked()) {
196+
toggle(value)
197+
return
141198
}
199+
setStore("editing", true)
200+
return
142201
}
202+
const opt = opts[store.selected]
203+
if (!opt) return
204+
if (multi()) {
205+
toggle(opt.label)
206+
return
207+
}
208+
pick(opt.label)
143209
}
144210

145211
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
@@ -162,7 +228,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
162228
<For each={questions()}>
163229
{(q, index) => {
164230
const isActive = () => index() === store.tab
165-
const isAnswered = () => store.answers[index()] !== undefined
231+
const isAnswered = () => {
232+
return (store.answers[index()]?.length ?? 0) > 0
233+
}
166234
return (
167235
<box
168236
paddingLeft={1}
@@ -185,13 +253,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
185253
<Show when={!confirm()}>
186254
<box paddingLeft={1} gap={1}>
187255
<box>
188-
<text fg={theme.text}>{question()?.question}</text>
256+
<text fg={theme.text}>
257+
{question()?.question}
258+
{multi() ? " (select all that apply)" : ""}
259+
</text>
189260
</box>
190261
<box>
191262
<For each={options()}>
192263
{(opt, i) => {
193264
const active = () => i() === store.selected
194-
const picked = () => store.answers[store.tab] === opt.label
265+
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
195266
return (
196267
<box>
197268
<box flexDirection="row" gap={1}>
@@ -212,17 +283,18 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
212283
<box>
213284
<box flexDirection="row" gap={1}>
214285
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
215-
<text fg={other() ? theme.secondary : input() ? theme.success : theme.text}>
286+
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
216287
{options().length + 1}. Type your own answer
217288
</text>
218289
</box>
219-
<text fg={theme.success}>{input() ? "✓" : ""}</text>
290+
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
220291
</box>
221292
<Show when={store.editing}>
222293
<box paddingLeft={3}>
223294
<textarea
224295
ref={(val: TextareaRenderable) => (textarea = val)}
225296
focused
297+
initialValue={input()}
226298
placeholder="Type your own answer"
227299
textColor={theme.text}
228300
focusedTextColor={theme.text}
@@ -247,11 +319,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
247319
</box>
248320
<For each={questions()}>
249321
{(q, index) => {
250-
const answer = () => store.answers[index()]
322+
const value = () => store.answers[index()]?.join(", ") ?? ""
323+
const answered = () => Boolean(value())
251324
return (
252325
<box flexDirection="row" gap={1} paddingLeft={1}>
253326
<text fg={theme.textMuted}>{q.header}:</text>
254-
<text fg={answer() ? theme.text : theme.error}>{answer() ?? "(not answered)"}</text>
327+
<text fg={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</text>
255328
</box>
256329
)
257330
}}
@@ -279,8 +352,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
279352
</text>
280353
</Show>
281354
<text fg={theme.text}>
282-
enter <span style={{ fg: theme.textMuted }}>{confirm() ? "submit" : single() ? "submit" : "confirm"}</span>
355+
enter{" "}
356+
<span style={{ fg: theme.textMuted }}>
357+
{confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
358+
</span>
283359
</text>
360+
284361
<text fg={theme.text}>
285362
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
286363
</text>

packages/opencode/src/question/index.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export namespace Question {
2323
question: z.string().describe("Complete question"),
2424
header: z.string().max(12).describe("Very short label (max 12 chars)"),
2525
options: z.array(Option).describe("Available choices"),
26+
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
2627
})
2728
.meta({
2829
ref: "QuestionInfo",
@@ -46,8 +47,15 @@ export namespace Question {
4647
})
4748
export type Request = z.infer<typeof Request>
4849

50+
export const Answer = z.array(z.string()).meta({
51+
ref: "QuestionAnswer",
52+
})
53+
export type Answer = z.infer<typeof Answer>
54+
4955
export const Reply = z.object({
50-
answers: z.array(z.string()).describe("User answers in order of questions"),
56+
answers: z
57+
.array(Answer)
58+
.describe("User answers in order of questions (each answer is an array of selected labels)"),
5159
})
5260
export type Reply = z.infer<typeof Reply>
5361

@@ -58,7 +66,7 @@ export namespace Question {
5866
z.object({
5967
sessionID: z.string(),
6068
requestID: z.string(),
61-
answers: z.array(z.string()),
69+
answers: z.array(Answer),
6270
}),
6371
),
6472
Rejected: BusEvent.define(
@@ -75,7 +83,7 @@ export namespace Question {
7583
string,
7684
{
7785
info: Request
78-
resolve: (answers: string[]) => void
86+
resolve: (answers: Answer[]) => void
7987
reject: (e: any) => void
8088
}
8189
> = {}
@@ -89,13 +97,13 @@ export namespace Question {
8997
sessionID: string
9098
questions: Info[]
9199
tool?: { messageID: string; callID: string }
92-
}): Promise<string[]> {
100+
}): Promise<Answer[]> {
93101
const s = await state()
94102
const id = Identifier.ascending("question")
95103

96104
log.info("asking", { id, questions: input.questions.length })
97105

98-
return new Promise<string[]>((resolve, reject) => {
106+
return new Promise<Answer[]>((resolve, reject) => {
99107
const info: Request = {
100108
id,
101109
sessionID: input.sessionID,
@@ -111,7 +119,7 @@ export namespace Question {
111119
})
112120
}
113121

114-
export async function reply(input: { requestID: string; answers: string[] }): Promise<void> {
122+
export async function reply(input: { requestID: string; answers: Answer[] }): Promise<void> {
115123
const s = await state()
116124
const existing = s.pending[input.requestID]
117125
if (!existing) {

packages/opencode/src/server/question.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const QuestionRoute = new Hono()
5252
requestID: z.string(),
5353
}),
5454
),
55-
validator("json", z.object({ answers: z.array(z.string()) })),
55+
validator("json", Question.Reply),
5656
async (c) => {
5757
const params = c.req.valid("param")
5858
const json = c.req.valid("json")

packages/opencode/src/tool/question.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ export const QuestionTool = Tool.define("question", {
1515
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
1616
})
1717

18-
const formatted = params.questions.map((q, i) => `"${q.question}"="${answers[i] ?? "Unanswered"}"`).join(", ")
18+
function format(answer: Question.Answer | undefined) {
19+
if (!answer?.length) return "Unanswered"
20+
return answer.join(", ")
21+
}
22+
23+
const formatted = params.questions.map((q, i) => `"${q.question}"="${format(answers[i])}"`).join(", ")
1924

2025
return {
2126
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,

packages/opencode/src/tool/question.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ Use this tool when you need to ask the user questions during execution. This all
66

77
Usage notes:
88
- Users will always be able to select "Other" to provide custom text input
9+
- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one
910
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label

0 commit comments

Comments
 (0)