Преглед изворни кода

feat(question): support multi-select questions (#7386)

Dax пре 3 месеци
родитељ
комит
22dd70b75b

+ 7 - 1
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

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

+ 101 - 24
packages/opencode/src/cli/cmd/tui/routes/session/question.tsx

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

+ 14 - 6
packages/opencode/src/question/index.ts

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

+ 1 - 1
packages/opencode/src/server/question.ts

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

+ 6 - 1
packages/opencode/src/tool/question.ts

@@ -15,7 +15,12 @@ export const QuestionTool = Tool.define("question", {
       tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
       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 {
     return {
       title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
       title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,

+ 1 - 0
packages/opencode/src/tool/question.txt

@@ -6,4 +6,5 @@ Use this tool when you need to ask the user questions during execution. This all
 
 
 Usage notes:
 Usage notes:
 - Users will always be able to select "Other" to provide custom text input
 - 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
 - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label

+ 6 - 6
packages/opencode/test/question/question.test.ts

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

+ 2 - 1
packages/sdk/js/src/v2/gen/sdk.gen.ts

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

+ 11 - 2
packages/sdk/js/src/v2/gen/types.gen.ts

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

Разлика између датотеке није приказан због своје велике величине
+ 366 - 91
packages/sdk/openapi.json


Неке датотеке нису приказане због велике количине промена