Przeglądaj źródła

refactor(core): route questions through forms (#35422)

Aiden Cline 1 miesiąc temu
rodzic
commit
780c99bc2e

+ 6 - 5
packages/core/src/form.ts

@@ -21,6 +21,7 @@ export type When = Form.When
 
 export const State = Form.State
 export type State = typeof State.Type
+export type TerminalState = Exclude<State, { readonly status: "pending" }>
 
 export const Answer = Form.Answer
 export type Answer = typeof Answer.Type
@@ -78,7 +79,7 @@ export interface ListInput {
 
 export interface Interface {
   readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError | InvalidFormError>
-  readonly ask: (input: CreateInput) => Effect.Effect<State, AlreadyExistsError | InvalidFormError>
+  readonly ask: (input: CreateInput) => Effect.Effect<TerminalState, AlreadyExistsError | InvalidFormError>
   readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
   readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
   readonly state: (id: ID) => Effect.Effect<State, NotFoundError>
@@ -91,7 +92,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Fo
 interface Entry {
   readonly form: Info
   readonly state: State
-  readonly deferred: Deferred.Deferred<State>
+  readonly deferred: Deferred.Deferred<TerminalState>
 }
 
 export const layer = Layer.effect(
@@ -141,7 +142,7 @@ export const layer = Layer.effect(
           const entry: Entry = {
             form,
             state: { status: "pending" },
-            deferred: yield* Deferred.make<State>(),
+            deferred: yield* Deferred.make<TerminalState>(),
           }
           yield* Cache.set(forms, id, entry)
           yield* events.publish(Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
@@ -185,7 +186,7 @@ export const layer = Layer.effect(
           if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
           const invalid = validateAnswer(entry.form, input.answer)
           if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
-          const next: State = { status: "answered", answer: input.answer }
+          const next: TerminalState = { status: "answered", answer: input.answer }
           yield* events.publish(Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer })
           yield* Cache.set(forms, input.id, { ...entry, state: next })
           yield* Deferred.succeed(entry.deferred, next)
@@ -198,7 +199,7 @@ export const layer = Layer.effect(
         Effect.gen(function* () {
           const entry = yield* find(id)
           if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
-          const next: State = { status: "cancelled" }
+          const next: TerminalState = { status: "cancelled" }
           yield* events.publish(Event.Cancelled, { id, sessionID: entry.form.sessionID })
           yield* Cache.set(forms, id, { ...entry, state: next })
           yield* Deferred.succeed(entry.deferred, next)

+ 1 - 1
packages/core/src/location-services.ts

@@ -81,7 +81,7 @@ const pluginSupervisorNode = makeLocationNode({
     Npm.node,
     PermissionV2.node,
     PluginRuntime.node,
-    QuestionV2.node,
+    Form.node,
     ReadToolFileSystem.node,
     Reference.node,
     Ripgrep.node,

+ 3 - 3
packages/core/src/plugin/internal.ts

@@ -14,6 +14,7 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
 import { ConfigSkillPlugin } from "../config/plugin/skill"
 import { EventV2 } from "../event"
 import { FileMutation } from "../file-mutation"
+import { Form } from "../form"
 import { FileSystem } from "../filesystem"
 import { FSUtil } from "../fs-util"
 import { Global } from "../global"
@@ -24,7 +25,6 @@ import { LocationMutation } from "../location-mutation"
 import { ModelsDev } from "../models-dev"
 import { Npm } from "../npm"
 import { PermissionV2 } from "../permission"
-import { QuestionV2 } from "../question"
 import { Reference } from "../reference"
 import { Ripgrep } from "../ripgrep"
 import { SessionInstructions } from "../session/instructions"
@@ -73,7 +73,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
   const npm = yield* Npm.Service
   const permission = yield* PermissionV2.Service
   const runtime = yield* PluginRuntime.Service
-  const question = yield* QuestionV2.Service
+  const form = yield* Form.Service
   const read = yield* ReadToolFileSystem.Service
   const reference = yield* Reference.Service
   const ripgrep = yield* Ripgrep.Service
@@ -102,7 +102,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
     Context.make(Npm.Service, npm),
     Context.make(PermissionV2.Service, permission),
     Context.make(PluginRuntime.Service, runtime),
-    Context.make(QuestionV2.Service, question),
+    Context.make(Form.Service, form),
     Context.make(ReadToolFileSystem.Service, read),
     Context.make(Reference.Service, reference),
     Context.make(Ripgrep.Service, ripgrep),

+ 6 - 8
packages/core/src/session/runner/llm.ts

@@ -16,7 +16,6 @@ import { Config } from "../../config"
 import { Database } from "../../database/database"
 import { EventV2 } from "../../event"
 import { Location } from "../../location"
-import { QuestionV2 } from "../../question"
 import { SystemContext } from "../../system-context/index"
 import { SystemContextBuiltIns } from "../../system-context/builtins"
 import { InstructionContext } from "../../instruction-context"
@@ -24,6 +23,7 @@ import { SkillGuidance } from "../../skill/guidance"
 import { ReferenceGuidance } from "../../reference/guidance"
 import { McpGuidance } from "../../mcp/guidance"
 import { SessionContextEntry } from "../context-entry"
+import { QuestionTool } from "../../tool/question"
 import { ToolRegistry } from "../../tool/registry"
 import { ToolOutputStore } from "../../tool-output-store"
 import { SessionContextCheckpoint } from "../context-checkpoint"
@@ -149,9 +149,8 @@ const layer = Layer.effect(
     const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
       Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
 
-    // Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
-    const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
-      cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
+    const isQuestionCancelled = (cause: Cause.Cause<unknown>) =>
+      cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionTool.CancelledError)
 
     const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
       Effect.all(
@@ -341,14 +340,13 @@ const layer = Layer.effect(
           if (streamInterrupted) yield* FiberSet.clear(toolFibers)
           const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
           const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
-          const questionDismissed = settled._tag === "Failure" && isQuestionRejected(settled.cause)
+          const questionCancelled = settled._tag === "Failure" && isQuestionCancelled(settled.cause)
 
-          if (questionDismissed || streamInterrupted || toolsInterrupted) {
+          if (questionCancelled || streamInterrupted || toolsInterrupted) {
             yield* FiberSet.clear(toolFibers)
             yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
             yield* serialized(publisher.failAssistant("Step interrupted"))
-            // Match V1: dismissing a question halts the loop like an interruption.
-            if (questionDismissed) return yield* Effect.interrupt
+            if (questionCancelled) return yield* Effect.interrupt
           }
           // A settled tool fiber failure is one of two things. A defect from a tool
           // implementation becomes a failed tool call the model can read, and the step still

+ 39 - 5
packages/core/src/tool/question.ts

@@ -3,6 +3,7 @@ export * as QuestionTool from "./question"
 import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Schema } from "effect"
+import { Form } from "../form"
 import { PermissionV2 } from "../permission"
 import { QuestionV2 } from "../question"
 import { Tool } from "./tool"
@@ -29,6 +30,12 @@ export const Output = Schema.Struct({
 })
 export type Output = typeof Output.Type
 
+export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("QuestionTool.CancelledError", {}) {
+  override get message() {
+    return "The user dismissed this question"
+  }
+}
+
 export const toModelOutput = (
   questions: ReadonlyArray<QuestionV2.Prompt>,
   answers: ReadonlyArray<QuestionV2.Answer>,
@@ -45,7 +52,7 @@ export const toModelOutput = (
 export const Plugin = {
   id: "opencode.tool.question",
   effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
-    const question = yield* QuestionV2.Service
+    const forms = yield* Form.Service
     const permission = yield* PermissionV2.Service
 
     yield* ctx.tool
@@ -69,15 +76,42 @@ export const Plugin = {
               .pipe(
                 Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
                 Effect.andThen(
-                  question
+                  forms
                     .ask({
                       sessionID: context.sessionID,
-                      questions: input.questions,
-                      tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
+                      metadata: {
+                        kind: "question",
+                        tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
+                      },
+                      mode: "form",
+                      fields: input.questions.map(
+                        (question, index): Form.Field => ({
+                          key: `q${index}`,
+                          title: question.header,
+                          description: question.question,
+                          type: question.multiple === true ? "multiselect" : "string",
+                          options: question.options.map((option) => ({
+                            value: option.label,
+                            label: option.label,
+                            description: option.description,
+                          })),
+                        custom: true,
+                        }),
+                      ),
                     })
                     .pipe(Effect.orDie),
                 ),
-                Effect.map((answers) => ({ answers })),
+                Effect.flatMap((state) => {
+                  if (state.status === "cancelled") return Effect.die(new CancelledError())
+                  return Effect.succeed({
+                    answers: input.questions.map((_, index): QuestionV2.Answer => {
+                      const value = state.answer[`q${index}`]
+                      if (value === undefined) return []
+                      if (typeof value === "object") return Array.from(value)
+                      return [String(value)]
+                    }),
+                  })
+                }),
               ),
         }),
       })

+ 22 - 1
packages/core/test/form.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect } from "bun:test"
-import { Effect, Exit } from "effect"
+import { Deferred, Effect, Exit, Fiber } from "effect"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
@@ -19,6 +19,27 @@ const input = {
 } satisfies Form.CreateInput
 
 describe("Form", () => {
+  it.effect("returns a terminal cancelled state from ask", () =>
+    Effect.gen(function* () {
+      const service = yield* Form.Service
+      const events = yield* EventV2.Service
+      const created = yield* Deferred.make<Form.Info>()
+      const unsubscribe = yield* events.listen((event) =>
+        event.type === Form.Event.Created.type
+          ? Deferred.succeed(created, (event.data as { readonly form: Form.Info }).form).pipe(Effect.asVoid)
+          : Effect.void,
+      )
+      yield* Effect.addFinalizer(() => unsubscribe)
+      const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
+      const form = yield* Deferred.await(created)
+
+      yield* service.cancel(form.id)
+
+      expect(yield* Fiber.join(fiber)).toEqual({ status: "cancelled" })
+      expect(yield* service.state(form.id)).toEqual({ status: "cancelled" })
+    }),
+  )
+
   it.effect("supports the temporary global mcp elicitation owner", () =>
     Effect.gen(function* () {
       const service = yield* Form.Service

+ 5 - 12
packages/core/test/session-runner.test.ts

@@ -21,7 +21,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
-import { QuestionV2 } from "@opencode-ai/core/question"
+import { Form } from "@opencode-ai/core/form"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { Snapshot } from "@opencode-ai/core/snapshot"
@@ -39,6 +39,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
 import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
 import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
+import { QuestionTool } from "@opencode-ai/core/tool/question"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { Config } from "@opencode-ai/core/config"
@@ -276,7 +277,7 @@ const it = testEffect(
     LayerNode.group([
       Database.node,
       EventV2.node,
-      QuestionV2.node,
+      Form.node,
       SessionProjector.node,
       SessionStore.node,
       AgentV2.node,
@@ -2822,19 +2823,17 @@ describe("SessionRunnerLLM", () => {
     }),
   )
 
-  it.effect("interrupts runner continuation when a question is dismissed", () =>
+  it.effect("interrupts runner continuation when a question is cancelled", () =>
     Effect.gen(function* () {
       yield* setup
       const session = yield* SessionV2.Service
       const registry = yield* ToolRegistry.Service
-      const questions = yield* QuestionV2.Service
       yield* registry.register({
         question: Tool.make({
           description: "Ask the user",
           input: Schema.Struct({}),
           output: Schema.Struct({}),
-          execute: (_, context) =>
-            questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
+          execute: () => Effect.die(new QuestionTool.CancelledError()),
         }),
       })
       yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
@@ -2851,12 +2850,6 @@ describe("SessionRunnerLLM", () => {
       ]
 
       const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
-      let pending = yield* questions.list()
-      while (pending.length === 0) {
-        yield* Effect.yieldNow
-        pending = yield* questions.list()
-      }
-      yield* questions.reject(pending[0]!.id)
       const exit = yield* Fiber.join(run)
 
       expect(exit._tag).toBe("Failure")

+ 67 - 19
packages/core/test/tool-question.test.ts

@@ -1,9 +1,9 @@
 import { describe, expect } from "bun:test"
-import { Effect, Exit, Fiber, Layer } from "effect"
+import { Cause, Effect, Exit, Fiber, Layer } from "effect"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { Form } from "@opencode-ai/core/form"
 import { PermissionV2 } from "@opencode-ai/core/permission"
-import { QuestionV2 } from "@opencode-ai/core/question"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { QuestionTool } from "@opencode-ai/core/tool/question"
@@ -14,7 +14,7 @@ import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefiniti
 
 const sessionID = SessionV2.ID.make("ses_question_tool_test")
 const assertions: PermissionV2.AssertInput[] = []
-let captured: QuestionV2.AskInput | undefined
+let captured: Form.CreateInput | undefined
 let reject = false
 let deny = false
 const capturedInput = () => captured
@@ -32,28 +32,38 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const question = Layer.succeed(
-  QuestionV2.Service,
-  QuestionV2.Service.of({
-    ask: (input: QuestionV2.AskInput) =>
+const form = Layer.succeed(
+  Form.Service,
+  Form.Service.of({
+    ask: (input: Form.CreateInput) =>
       Effect.sync(() => {
         captured = input
-      }).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
-    reply: () => Effect.die("unused"),
-    reject: () => Effect.die("unused"),
+      }).pipe(
+        Effect.andThen(
+          Effect.sync(
+            (): Form.TerminalState =>
+              reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build", q1: ["Dev"] } },
+          ),
+        ),
+      ),
+    create: () => Effect.die("unused"),
+    get: () => Effect.die("unused"),
     list: () => Effect.die("unused"),
+    state: () => Effect.die("unused"),
+    reply: () => Effect.die("unused"),
+    cancel: () => Effect.die("unused"),
   }),
 )
 const questionToolNode = makeLocationNode({
   name: "test/question-tool-plugin",
   layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)),
-  deps: [ToolRegistry.toolsNode, PermissionV2.node, QuestionV2.node],
+  deps: [ToolRegistry.toolsNode, PermissionV2.node, Form.node],
 })
 
 const it = testEffect(
   AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, questionToolNode]), [
     [PermissionV2.node, permission],
-    [QuestionV2.node, question],
+    [Form.node, form],
     [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
   ]),
 )
@@ -95,6 +105,12 @@ describe("QuestionTool", () => {
           question: "Which environment?",
           header: "Environment",
           options: [{ label: "Dev", description: "Development" }],
+          multiple: true,
+        },
+        {
+          question: "Anything else?",
+          header: "Optional",
+          options: [],
         },
       ]
 
@@ -109,14 +125,14 @@ describe("QuestionTool", () => {
         result: {
           type: "text",
           value:
-            'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
+            'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
         },
         output: {
-          structured: { answers: [["Build"], []] },
+          structured: { answers: [["Build"], ["Dev"], []] },
           content: [
             {
               type: "text",
-              text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
+              text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
             },
           ],
         },
@@ -124,8 +140,34 @@ describe("QuestionTool", () => {
       expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
       expect(capturedInput()).toEqual({
         sessionID,
-        questions,
-        tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
+        metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
+        mode: "form",
+        fields: [
+          {
+            key: "q0",
+            title: "Action",
+            description: "What should happen?",
+            options: [{ value: "Build", label: "Build", description: "Build it" }],
+            custom: true,
+            type: "string",
+          },
+          {
+            key: "q1",
+            title: "Environment",
+            description: "Which environment?",
+            options: [{ value: "Dev", label: "Dev", description: "Development" }],
+            custom: true,
+            type: "multiselect",
+          },
+          {
+            key: "q2",
+            title: "Optional",
+            description: "Anything else?",
+            options: [],
+            custom: true,
+            type: "string",
+          },
+        ],
       })
     }),
   )
@@ -144,8 +186,9 @@ describe("QuestionTool", () => {
       })
       expect(capturedInput()).toEqual({
         sessionID,
-        questions: [],
-        tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
+        metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
+        mode: "form",
+        fields: [],
       })
     }),
   )
@@ -164,6 +207,11 @@ describe("QuestionTool", () => {
 
       const exit = yield* Fiber.await(fiber)
       expect(Exit.isFailure(exit)).toBe(true)
+      if (Exit.isFailure(exit)) {
+        const error = Cause.squash(exit.cause)
+        expect(error).toBeInstanceOf(QuestionTool.CancelledError)
+        expect(error).toHaveProperty("message", "The user dismissed this question")
+      }
     }),
   )
 })

+ 0 - 29
packages/tui/src/context/data.tsx

@@ -10,7 +10,6 @@ import type {
   PermissionSavedInfo,
   PermissionV2Request,
   ProviderV2Info,
-  QuestionV2Request,
   ReferenceInfo,
   SessionMessage,
   SessionMessageAssistant,
@@ -57,7 +56,6 @@ type Data = {
     status: Record<string, DataSessionStatus>
     message: Record<string, SessionMessage[]>
     permission: Record<string, PermissionV2Request[]>
-    question: Record<string, QuestionV2Request[]>
     // Pending forms keyed by session ID.
     form: Record<string, FormInfo[]>
   }
@@ -93,7 +91,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         status: {},
         message: {},
         permission: {},
-        question: {},
         form: {},
       },
       project: {
@@ -591,24 +588,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             ),
           )
           break
-        case "question.v2.asked":
-          if (store.session.question[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
-          setStore("session", "question", event.data.sessionID, [
-            ...(store.session.question[event.data.sessionID] ?? []),
-            event.data,
-          ])
-          break
-        case "question.v2.replied":
-        case "question.v2.rejected":
-          setStore(
-            "session",
-            "question",
-            event.data.sessionID,
-            (store.session.question[event.data.sessionID] ?? []).filter(
-              (request) => request.id !== event.data.requestID,
-            ),
-          )
-          break
         case "form.created":
           if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
           setStore("session", "form", event.data.form.sessionID, [
@@ -743,14 +722,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             setStore("session", "permission", sessionID, mutable(await sdk.api.permission.list({ sessionID })))
           },
         },
-        question: {
-          list(sessionID: string) {
-            return store.session.question[sessionID]
-          },
-          async refresh(sessionID: string) {
-            setStore("session", "question", sessionID, mutable(await sdk.api.question.list({ sessionID })))
-          },
-        },
         form: {
           list(sessionID: string) {
             return store.session.form[sessionID]

+ 1 - 10
packages/tui/src/routes/session/index.tsx

@@ -58,7 +58,6 @@ import { usePromptRef } from "../../context/prompt"
 import { useEpilogue } from "../../context/epilogue"
 import { normalizePath } from "../../util/path"
 import { PermissionPrompt } from "./permission"
-import { QuestionPrompt } from "./question"
 import { FormPrompt } from "./form"
 import { DialogExportOptions } from "../../ui/dialog-export-options"
 import { sessionEpilogue } from "../../util/presentation"
@@ -182,10 +181,6 @@ export function Session() {
       (sessionID) => data.session.permission.list(sessionID) ?? [],
     )
   })
-  const questions = createMemo(() => {
-    if (session()?.parentID) return []
-    return data.session.question.list(route.sessionID) ?? []
-  })
   const forms = createMemo(() => {
     if (session()?.parentID) return []
     return data.session.form.list(route.sessionID) ?? []
@@ -194,7 +189,7 @@ export function Session() {
     open: false,
     tab: undefined as string | undefined,
   })
-  const disabled = createMemo(() => permissions().length > 0 || questions().length > 0 || forms().length > 0)
+  const disabled = createMemo(() => permissions().length > 0 || forms().length > 0)
 
   const pending = createMemo(() => {
     const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
@@ -251,7 +246,6 @@ export function Session() {
       await Promise.all([
         data.session.refresh(sessionID),
         data.session.permission.refresh(sessionID),
-        data.session.question.refresh(sessionID),
         data.session.form.refresh(sessionID),
       ])
       const info = data.session.get(sessionID)
@@ -944,9 +938,6 @@ export function Session() {
                   <Match when={permissions().length > 0}>
                     <PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
                   </Match>
-                  <Match when={questions().length > 0}>
-                    <QuestionPrompt request={questions()[0]} directory={session()?.location.directory} />
-                  </Match>
                   <Match when={forms().length > 0}>
                     <Show when={forms()[0]?.id} keyed>
                       {(_) => {

+ 0 - 514
packages/tui/src/routes/session/question.tsx

@@ -1,514 +0,0 @@
-import { createStore } from "solid-js/store"
-import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
-import { useRenderer } from "@opentui/solid"
-import type { TextareaRenderable } from "@opentui/core"
-import { selectedForeground, tint, useTheme } from "../../context/theme"
-import type { QuestionV2Answer, QuestionV2Request } from "@opencode-ai/sdk/v2"
-import { useSDK } from "../../context/sdk"
-import { SplitBorder } from "../../ui/border"
-import { useTuiConfig } from "../../config"
-import { useBindings, useOpencodeModeStack } from "../../keymap"
-
-const QUESTION_MODE = "question"
-
-export function QuestionPrompt(props: { request: QuestionV2Request; directory?: string }) {
-  const sdk = useSDK()
-  const { theme } = useTheme()
-  const renderer = useRenderer()
-  const tuiConfig = useTuiConfig()
-  const modeStack = useOpencodeModeStack()
-
-  const questions = createMemo(() => props.request.questions)
-  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 [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
-  const [store, setStore] = createStore({
-    tab: 0,
-    answers: [] as QuestionV2Answer[],
-    custom: [] as string[],
-    selected: 0,
-    editing: false,
-  })
-
-  let textarea: TextareaRenderable | undefined
-
-  const question = createMemo(() => questions()[store.tab])
-  const confirm = createMemo(() => !single() && store.tab === questions().length)
-  const options = createMemo(() => question()?.options ?? [])
-  const custom = createMemo(() => question()?.custom !== false)
-  const other = createMemo(() => custom() && 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() {
-    const answers = questions().map((_, i) => store.answers[i] ?? [])
-    void sdk.api.question.reply({
-      sessionID: props.request.sessionID,
-      requestID: props.request.id,
-      answers,
-    })
-  }
-
-  function reject() {
-    void sdk.api.question.reject({
-      sessionID: props.request.sessionID,
-      requestID: props.request.id,
-    })
-  }
-
-  function pick(answer: string, custom: boolean = false) {
-    const answers = [...store.answers]
-    answers[store.tab] = [answer]
-    setStore("answers", answers)
-    if (custom) {
-      const inputs = [...store.custom]
-      inputs[store.tab] = answer
-      setStore("custom", inputs)
-    }
-    if (single()) {
-      void sdk.api.question.reply({
-        sessionID: props.request.sessionID,
-        requestID: props.request.id,
-        answers: [[answer]],
-      })
-      return
-    }
-    setStore("tab", store.tab + 1)
-    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)
-  }
-
-  function moveTo(index: number) {
-    setStore("selected", index)
-  }
-
-  function selectTab(index: number) {
-    setStore("tab", index)
-    setStore("selected", 0)
-  }
-
-  function selectOption() {
-    if (other()) {
-      if (!multi()) {
-        setStore("editing", true)
-        return
-      }
-      const value = input()
-      if (value && customPicked()) {
-        toggle(value)
-        return
-      }
-      setStore("editing", true)
-      return
-    }
-    const opt = options()[store.selected]
-    if (!opt) return
-    if (multi()) {
-      toggle(opt.label)
-      return
-    }
-    pick(opt.label)
-  }
-
-  onMount(() => {
-    const popMode = modeStack.push(QUESTION_MODE)
-    onCleanup(popMode)
-  })
-
-  useBindings(() => ({
-    mode: QUESTION_MODE,
-    enabled: store.editing && !confirm(),
-    commands: [
-      {
-        name: "prompt.clear",
-        title: "Clear answer edit",
-        category: "Question",
-        run() {
-          const text = textarea?.plainText ?? ""
-          if (!text) {
-            setStore("editing", false)
-            return
-          }
-          textarea?.setText("")
-        },
-      },
-    ],
-    bindings: [
-      {
-        key: "escape",
-        desc: "Cancel answer edit",
-        group: "Question",
-        cmd: () => {
-          setStore("editing", false)
-        },
-      },
-      ...tuiConfig.keybinds.get("prompt.clear"),
-      {
-        key: "return",
-        desc: "Submit answer edit",
-        group: "Question",
-        cmd: () => {
-          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]
-              answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
-              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)
-        },
-      },
-    ],
-  }))
-
-  useBindings(() => {
-    const opts = options()
-    const total = opts.length + (custom() ? 1 : 0)
-    const max = Math.min(total, 9)
-
-    return {
-      mode: QUESTION_MODE,
-      enabled: !store.editing,
-      commands: [
-        {
-          name: "app.exit",
-          title: "Reject question",
-          category: "Question",
-          run() {
-            reject()
-          },
-        },
-      ],
-      bindings: [
-        {
-          key: "left",
-          desc: "Previous question",
-          group: "Question",
-          cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
-        },
-        {
-          key: "h",
-          desc: "Previous question",
-          group: "Question",
-          cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
-        },
-        { key: "right", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
-        { key: "l", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
-        {
-          key: "tab",
-          desc: "Next question",
-          group: "Question",
-          cmd: ({ event }: { event: { shift: boolean } }) => {
-            selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
-          },
-        },
-        ...(confirm()
-          ? [
-              { key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() },
-              { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
-              ...tuiConfig.keybinds.get("app.exit"),
-            ]
-          : [
-              ...Array.from({ length: max }, (_, index) => ({
-                key: String(index + 1),
-                desc: `Select answer ${index + 1}`,
-                group: "Question",
-                cmd: () => {
-                  moveTo(index)
-                  selectOption()
-                },
-              })),
-              {
-                key: "up",
-                desc: "Previous answer",
-                group: "Question",
-                cmd: () => moveTo((store.selected - 1 + total) % total),
-              },
-              {
-                key: "k",
-                desc: "Previous answer",
-                group: "Question",
-                cmd: () => moveTo((store.selected - 1 + total) % total),
-              },
-              { key: "down", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
-              { key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
-              { key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() },
-              { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
-              ...tuiConfig.keybinds.get("app.exit"),
-            ]),
-      ],
-    }
-  })
-
-  return (
-    <box
-      backgroundColor={theme.backgroundPanel}
-      border={["left"]}
-      borderColor={theme.accent}
-      customBorderChars={SplitBorder.customBorderChars}
-    >
-      <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
-        <Show when={!single()}>
-          <box flexDirection="row" gap={1} paddingLeft={1}>
-            <For each={questions()}>
-              {(q, index) => {
-                const isActive = () => index() === store.tab
-                const isAnswered = () => {
-                  return (store.answers[index()]?.length ?? 0) > 0
-                }
-                return (
-                  <box
-                    paddingLeft={1}
-                    paddingRight={1}
-                    backgroundColor={
-                      isActive()
-                        ? theme.accent
-                        : tabHover() === index()
-                          ? theme.backgroundElement
-                          : theme.backgroundPanel
-                    }
-                    onMouseOver={() => setTabHover(index())}
-                    onMouseOut={() => setTabHover(null)}
-                    onMouseUp={() => {
-                      if (renderer.getSelection()?.getSelectedText()) return
-                      selectTab(index())
-                    }}
-                  >
-                    <text
-                      fg={
-                        isActive()
-                          ? selectedForeground(theme, theme.accent)
-                          : isAnswered()
-                            ? theme.text
-                            : theme.textMuted
-                      }
-                    >
-                      {q.header}
-                    </text>
-                  </box>
-                )
-              }}
-            </For>
-            <box
-              paddingLeft={1}
-              paddingRight={1}
-              backgroundColor={
-                confirm() ? theme.accent : tabHover() === "confirm" ? theme.backgroundElement : theme.backgroundPanel
-              }
-              onMouseOver={() => setTabHover("confirm")}
-              onMouseOut={() => setTabHover(null)}
-              onMouseUp={() => {
-                if (renderer.getSelection()?.getSelectedText()) return
-                selectTab(questions().length)
-              }}
-            >
-              <text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
-            </box>
-          </box>
-        </Show>
-
-        <Show when={!confirm()}>
-          <box paddingLeft={1} gap={1}>
-            <box>
-              <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]?.includes(opt.label) ?? false
-                  return (
-                    <box
-                      onMouseOver={() => moveTo(i())}
-                      onMouseDown={() => moveTo(i())}
-                      onMouseUp={() => {
-                        if (renderer.getSelection()?.getSelectedText()) return
-                        selectOption()
-                      }}
-                    >
-                      <box flexDirection="row">
-                        <box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
-                          <text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
-                            {`${i() + 1}.`}
-                          </text>
-                        </box>
-                        <box backgroundColor={active() ? theme.backgroundElement : undefined}>
-                          <text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
-                            {multi() ? `[${picked() ? "✓" : " "}] ${opt.label}` : opt.label}
-                          </text>
-                        </box>
-                        <Show when={!multi()}>
-                          <text fg={theme.success}>{picked() ? " ✓" : ""}</text>
-                        </Show>
-                      </box>
-
-                      <box paddingLeft={3}>
-                        <text fg={theme.textMuted}>{opt.description}</text>
-                      </box>
-                    </box>
-                  )
-                }}
-              </For>
-              <Show when={custom()}>
-                <box
-                  onMouseOver={() => moveTo(options().length)}
-                  onMouseDown={() => moveTo(options().length)}
-                  onMouseUp={() => {
-                    if (renderer.getSelection()?.getSelectedText()) return
-                    selectOption()
-                  }}
-                >
-                  <box flexDirection="row">
-                    <box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
-                      <text fg={other() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
-                        {`${options().length + 1}.`}
-                      </text>
-                    </box>
-                    <box backgroundColor={other() ? theme.backgroundElement : undefined}>
-                      <text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
-                        {multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
-                      </text>
-                    </box>
-
-                    <Show when={!multi()}>
-                      <text fg={theme.success}>{customPicked() ? " ✓" : ""}</text>
-                    </Show>
-                  </box>
-                  <Show when={store.editing}>
-                    <box paddingLeft={3}>
-                      <textarea
-                        ref={(val: TextareaRenderable) => {
-                          textarea = val
-                          val.traits = { status: "ANSWER" }
-                          queueMicrotask(() => {
-                            val.focus()
-                            val.gotoLineEnd()
-                          })
-                        }}
-                        initialValue={input()}
-                        placeholder="Type your own answer"
-                        placeholderColor={theme.textMuted}
-                        minHeight={1}
-                        maxHeight={6}
-                        textColor={theme.text}
-                        focusedTextColor={theme.text}
-                        cursorColor={theme.primary}
-                      />
-                    </box>
-                  </Show>
-                  <Show when={!store.editing && input()}>
-                    <box paddingLeft={3}>
-                      <text fg={theme.textMuted}>{input()}</text>
-                    </box>
-                  </Show>
-                </box>
-              </Show>
-            </box>
-          </box>
-        </Show>
-
-        <Show when={confirm() && !single()}>
-          <box paddingLeft={1}>
-            <text fg={theme.text}>Review</text>
-          </box>
-          <For each={questions()}>
-            {(q, index) => {
-              const value = () => store.answers[index()]?.join(", ") ?? ""
-              const answered = () => Boolean(value())
-              return (
-                <box paddingLeft={1}>
-                  <text>
-                    <span style={{ fg: theme.textMuted }}>{q.header}:</span>{" "}
-                    <span style={{ fg: answered() ? theme.text : theme.error }}>
-                      {answered() ? value() : "(not answered)"}
-                    </span>
-                  </text>
-                </box>
-              )
-            }}
-          </For>
-        </Show>
-      </box>
-      <box
-        flexDirection="row"
-        flexShrink={0}
-        gap={1}
-        paddingLeft={2}
-        paddingRight={3}
-        paddingBottom={1}
-        justifyContent="space-between"
-      >
-        <box flexDirection="row" gap={2}>
-          <Show when={!single()}>
-            <text fg={theme.text}>
-              {"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
-            </text>
-          </Show>
-          <Show when={!confirm()}>
-            <text fg={theme.text}>
-              {"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
-            </text>
-          </Show>
-          <text fg={theme.text}>
-            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>
-        </box>
-      </box>
-    </box>
-  )
-}

+ 0 - 67
packages/tui/test/cli/tui/data.test.tsx

@@ -877,73 +877,6 @@ test("adds, dismisses, and refreshes form requests", async () => {
   }
 })
 
-test("adds and dismisses question requests from live events", async () => {
-  const events = createEventStream()
-  const calls = createFetch(undefined, events)
-  let data!: ReturnType<typeof useData>
-
-  function Probe() {
-    data = useData()
-    return <box />
-  }
-
-  const app = await testRender(() => (
-    <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
-        <ProjectProvider>
-          <DataProvider>
-            <Probe />
-          </DataProvider>
-        </ProjectProvider>
-      </SDKProvider>
-    </TestTuiContexts>
-  ))
-
-  try {
-    await wait(() => data.connection.status() === "connected")
-    emitEvent(events, {
-      id: "evt_question_asked_1",
-      created: 0,
-      type: "question.v2.asked",
-      data: {
-        id: "que_1",
-        sessionID: "ses_1",
-        questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }],
-      },
-    })
-    emitEvent(events, {
-      id: "evt_question_asked_2",
-      created: 0,
-      type: "question.v2.asked",
-      data: {
-        id: "que_2",
-        sessionID: "ses_1",
-        questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }],
-      },
-    })
-    await wait(() => data.session.question.list("ses_1")?.length === 2)
-
-    emitEvent(events, {
-      id: "evt_question_replied_1",
-      created: 0,
-      type: "question.v2.replied",
-      data: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
-    })
-    await wait(() => data.session.question.list("ses_1")?.length === 1)
-    expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2")
-
-    emitEvent(events, {
-      id: "evt_question_rejected_2",
-      created: 0,
-      type: "question.v2.rejected",
-      data: { sessionID: "ses_1", requestID: "que_2" },
-    })
-    await wait(() => data.session.question.list("ses_1")?.length === 0)
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
 test("settles pending tools when a live failure arrives", async () => {
   const events = createEventStream()
   const calls = createFetch((url) => {