diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index d49129b3b818..7cdda87b4c2a 100644 --- a/packages/core/src/form.ts +++ b/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 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 - readonly ask: (input: CreateInput) => Effect.Effect + readonly ask: (input: CreateInput) => Effect.Effect readonly get: (id: ID) => Effect.Effect readonly list: (input?: ListInput) => Effect.Effect> readonly state: (id: ID) => Effect.Effect @@ -91,7 +92,7 @@ export class Service extends Context.Service()("@opencode/Fo interface Entry { readonly form: Info readonly state: State - readonly deferred: Deferred.Deferred + readonly deferred: Deferred.Deferred } 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(), + deferred: yield* Deferred.make(), } 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) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index bbb60482c154..21823f583b2c 100644 --- a/packages/core/src/location-services.ts +++ b/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, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 63637c96b083..2789cc3c2e5b 100644 --- a/packages/core/src/plugin/internal.ts +++ b/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), diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 0aeaa0e0525b..d4b2c5250ca3 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/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) => 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) => - cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) + const isQuestionCancelled = (cause: Cause.Cause) => + 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 diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 9bf4249add6b..dbabb0174748 100644 --- a/packages/core/src/tool/question.ts +++ b/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()("QuestionTool.CancelledError", {}) { + override get message() { + return "The user dismissed this question" + } +} + export const toModelOutput = ( questions: ReadonlyArray, answers: ReadonlyArray, @@ -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)] + }), + }) + }), ), }), }) diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts index e032cf6c7145..8defff611fa3 100644 --- a/packages/core/test/form.test.ts +++ b/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() + 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 diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6856b6a7ce84..cdf1ae37d446 100644 --- a/packages/core/test/session-runner.test.ts +++ b/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") diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index dc015630220d..b9d49e892e23 100644 --- a/packages/core/test/tool-question.test.ts +++ b/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") + } }), ) }) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 1528b83cd0b6..d9b29c1fdd80 100644 --- a/packages/tui/src/context/data.tsx +++ b/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 message: Record permission: Record - question: Record // Pending forms keyed by session ID. form: Record } @@ -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] diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 83f64ba27fec..93bffbeb7c33 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/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() { 0}> - 0}> - - 0}> {(_) => { diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx deleted file mode 100644 index 8659884a451a..000000000000 --- a/packages/tui/src/routes/session/question.tsx +++ /dev/null @@ -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(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 ( - - - - - - {(q, index) => { - const isActive = () => index() === store.tab - const isAnswered = () => { - return (store.answers[index()]?.length ?? 0) > 0 - } - return ( - setTabHover(index())} - onMouseOut={() => setTabHover(null)} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - selectTab(index()) - }} - > - - {q.header} - - - ) - }} - - setTabHover("confirm")} - onMouseOut={() => setTabHover(null)} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - selectTab(questions().length) - }} - > - Confirm - - - - - - - - - {question()?.question} - {multi() ? " (select all that apply)" : ""} - - - - - {(opt, i) => { - const active = () => i() === store.selected - const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false - return ( - moveTo(i())} - onMouseDown={() => moveTo(i())} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - selectOption() - }} - > - - - - {`${i() + 1}.`} - - - - - {multi() ? `[${picked() ? "✓" : " "}] ${opt.label}` : opt.label} - - - - {picked() ? " ✓" : ""} - - - - - {opt.description} - - - ) - }} - - - moveTo(options().length)} - onMouseDown={() => moveTo(options().length)} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - selectOption() - }} - > - - - - {`${options().length + 1}.`} - - - - - {multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"} - - - - - {customPicked() ? " ✓" : ""} - - - - -