diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index d49129b3b818..fbb286eba041 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -63,6 +63,8 @@ export class InvalidFormError extends Schema.TaggedErrorClass( message: Schema.String, }) {} +export class CancelledError extends Schema.TaggedErrorClass()("Form.CancelledError", {}) {} + export type CreateInput = | (Omit & { readonly id?: ID }) | (Omit & { readonly id?: ID }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 939bb4e731b8..d00f883a6cf0 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -16,6 +16,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" @@ -28,7 +29,6 @@ import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { PluginRuntime } from "../plugin/runtime" import { PermissionV2 } from "../permission" -import { QuestionV2 } from "../question" import { Reference } from "../reference" import { Ripgrep } from "../ripgrep" import { SessionInstructions } from "../session/instructions" @@ -69,6 +69,7 @@ export type Requirements = | EventV2.Service | FileMutation.Service | FileSystem.Service + | Form.Service | FSUtil.Service | Global.Service | HttpClient.HttpClient @@ -80,7 +81,6 @@ export type Requirements = | Npm.Service | PermissionV2.Service | PluginRuntime.Service - | QuestionV2.Service | ReadToolFileSystem.Service | Reference.Service | Ripgrep.Service @@ -116,13 +116,13 @@ const layer = Layer.effectDiscard( Context.make(EventV2.Service, yield* EventV2.Service), Context.make(FSUtil.Service, yield* FSUtil.Service), Context.make(FileSystem.Service, yield* FileSystem.Service), + Context.make(Form.Service, yield* Form.Service), Context.make(Global.Service, yield* Global.Service), Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient), Context.make(LocationMutation.Service, yield* LocationMutation.Service), Context.make(FileMutation.Service, yield* FileMutation.Service), Context.make(Image.Service, yield* Image.Service), Context.make(PermissionV2.Service, yield* PermissionV2.Service), - Context.make(QuestionV2.Service, yield* QuestionV2.Service), Context.make(ReadToolFileSystem.Service, yield* ReadToolFileSystem.Service), Context.make(SessionInstructions.Service, yield* SessionInstructions.Service), Context.make(SessionTodo.Service, yield* SessionTodo.Service), @@ -192,10 +192,10 @@ export const node = makeLocationNode({ EventV2.node, FSUtil.node, FileSystem.node, + Form.node, Global.node, httpClient, PermissionV2.node, - QuestionV2.node, ReadToolFileSystem.node, SessionInstructions.node, SessionTodo.node, diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 0aeaa0e0525b..bd9fdcf9c9a1 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -16,7 +16,7 @@ import { Config } from "../../config" import { Database } from "../../database/database" import { EventV2 } from "../../event" import { Location } from "../../location" -import { QuestionV2 } from "../../question" +import { Form } from "../../form" import { SystemContext } from "../../system-context/index" import { SystemContextBuiltIns } from "../../system-context/builtins" import { InstructionContext } from "../../instruction-context" @@ -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 isFormCancelled = (cause: Cause.Cause) => + cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof Form.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 formCancelled = settled._tag === "Failure" && isFormCancelled(settled.cause) - if (questionDismissed || streamInterrupted || toolsInterrupted) { + if (formCancelled || 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 (formCancelled) 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 218edb57e92a..20be82f06cc5 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" @@ -45,7 +46,7 @@ export const toModelOutput = ( export const Plugin = { id: "core-question-tool", 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 +70,40 @@ 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 !== "answered") return Effect.die(new Form.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/session-runner.test.ts b/packages/core/test/session-runner.test.ts index e6f14c8e197a..c96996205035 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -21,7 +21,6 @@ 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 { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { Snapshot } from "@opencode-ai/core/snapshot" @@ -276,7 +275,6 @@ const it = testEffect( LayerNode.group([ Database.node, EventV2.node, - QuestionV2.node, SessionProjector.node, SessionStore.node, AgentV2.node, @@ -2822,62 +2820,6 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("interrupts runner continuation when a question is dismissed", () => - 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), - }), - }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false }) - - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-question", name: "question", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [], - ] - - 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") - if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) - expect(requests).toHaveLength(1) - expect(yield* session.context(sessionID)).toMatchObject([ - { type: "user", text: "Ask then stop" }, - { - type: "assistant", - content: [ - { - type: "tool", - id: "call-question", - state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, - }, - ], - }, - ]) - }), - ) - it.effect("awaits started local tools before surfacing provider stream failure", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index dc015630220d..19a30122ff9f 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -2,8 +2,8 @@ import { describe, expect } from "bun:test" import { 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,35 @@ 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.State => (reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build" } })), + ), + ), + 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], ]), ) @@ -124,8 +131,26 @@ 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: "string", + }, + ], }) }), ) @@ -144,8 +169,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: [], }) }), ) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index cc9378dbac24..fab50ba16942 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,6 +1,8 @@ import type { AgentV2Info, CommandV2Info, + FormFormInfo, + FormUrlInfo, IntegrationInfo, LocationRef, McpServer, @@ -8,7 +10,6 @@ import type { PermissionSavedInfo, PermissionV2Request, ProviderV2Info, - QuestionV2Request, ReferenceInfo, SessionMessage, SessionMessageAssistant, @@ -27,6 +28,8 @@ import { createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" +export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef } + const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") type LocationData = { @@ -53,7 +56,8 @@ type Data = { status: Record message: Record permission: Record - question: Record + // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel. + form: Record } project: { permission: Record @@ -87,7 +91,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ status: {}, message: {}, permission: {}, - question: {}, + form: {}, }, project: { permission: {}, @@ -564,22 +568,20 @@ 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, + 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, [ + ...(store.session.form[event.data.form.sessionID] ?? []), + mutable({ ...event.data.form, location: event.location }), ]) break - case "question.v2.replied": - case "question.v2.rejected": + case "form.replied": + case "form.cancelled": setStore( "session", - "question", + "form", event.data.sessionID, - (store.session.question[event.data.sessionID] ?? []).filter( - (request) => request.id !== event.data.requestID, - ), + (store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id), ) break case "shell.created": @@ -660,6 +662,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) registerSession(sessionID) }, + async refreshChildren(sessionID: string) { + const visit = async (parentID: string, seen: Set): Promise => { + const children = mutable((await sdk.api.session.list({ parentID, limit: 200 })).data).filter( + (session) => !seen.has(session.id), + ) + for (const session of children) setStore("session", "info", session.id, session) + for (const session of children) registerSession(session.id) + await Promise.all(children.map((session) => visit(session.id, new Set([...seen, session.id])))) + } + await visit(sessionID, new Set([sessionID])) + }, message: { ids(sessionID: string) { return (store.session.message[sessionID] ?? []).map((message) => message.id) @@ -700,12 +713,34 @@ 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] + form: { + list(sessionID: string, ref?: LocationRef) { + const forms = store.session.form[sessionID] + if (sessionID !== "global" || !ref) return forms + const key = locationKey(ref) + return forms?.filter((form) => form.location && locationKey(form.location) === key) }, - async refresh(sessionID: string) { - setStore("session", "question", sessionID, mutable(await sdk.api.question.list({ sessionID }))) + async refresh(sessionID: string, ref?: LocationRef) { + if (sessionID === "global") { + const result = await sdk.api.form.listRequests({ location: locationQuery(ref ?? defaultLocation()) }) + const location = { directory: result.location.directory, workspaceID: result.location.workspaceID } + setStore( + "session", + "form", + sessionID, + mutable( + [ + ...(store.session.form[sessionID] ?? []).filter( + (form) => + form.location?.directory !== location.directory || form.location.workspaceID !== location.workspaceID, + ), + ...result.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })), + ], + ), + ) + return + } + setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID }))) }, }, }, @@ -849,7 +884,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: defaultLocation().directory, workspace: defaultLocation().workspaceID, }) - .then((response) => { + .then(async (response) => { setStore( "session", "info", @@ -858,6 +893,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }), ) for (const session of response.data) registerSession(session.id) + await Promise.all( + Object.values(store.session.info).flatMap((session) => + session.parentID ? [] : [result.session.refreshChildren(session.id)], + ), + ) + await Promise.all([ + ...Object.keys(store.session.info).flatMap((sessionID) => [ + result.session.permission.refresh(sessionID), + result.session.form.refresh(sessionID), + ]), + result.session.form.refresh("global"), + ]) }), sdk.api.session .active() diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index aa8dcace1977..77ea9cf1605d 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -29,30 +29,30 @@ function sessionErrorMessage(error: SessionError) { const tui: TuiPlugin = async (api) => { const active = new Set() const errored = new Set() - const questions = new Set() + const forms = new Set() const permissions = new Set() - api.event.on("question.asked", (event) => { - if (questions.has(event.data.id)) return - questions.add(event.data.id) - notify(api, event.data.sessionID, "Question needs input", "question") + api.event.on("form.created", (event) => { + if (forms.has(event.data.form.id)) return + forms.add(event.data.form.id) + notify(api, event.data.form.sessionID, "Input needs response", "question") }) - api.event.on("question.replied", (event) => { - questions.delete(event.data.requestID) + api.event.on("form.replied", (event) => { + forms.delete(event.data.id) }) - api.event.on("question.rejected", (event) => { - questions.delete(event.data.requestID) + api.event.on("form.cancelled", (event) => { + forms.delete(event.data.id) }) - api.event.on("permission.asked", (event) => { + api.event.on("permission.v2.asked", (event) => { if (permissions.has(event.data.id)) return permissions.add(event.data.id) notify(api, event.data.sessionID, "Permission needs input", "permission") }) - api.event.on("permission.replied", (event) => { + api.event.on("permission.v2.replied", (event) => { permissions.delete(event.data.requestID) }) diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index 068b51c7322c..6f4290495804 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -1,4 +1,5 @@ import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui" +import type { Message } from "@opencode-ai/sdk/v2" import type { TuiConfig } from "../config" import type { useEvent } from "../context/event" import type { useRoute } from "../context/route" @@ -120,10 +121,19 @@ function stateApi(sync: ReturnType, data: ReturnType @@ -134,13 +144,57 @@ function stateApi(sync: ReturnType, data: ReturnType { + const session = data.session.get(sessionID) + if (message.type === "user") + return [ + { + id: message.id, + sessionID, + role: "user", + time: message.time, + agent: session?.agent ?? "build", + model: session?.model + ? { providerID: session.model.providerID, modelID: session.model.id, variant: session.model.variant } + : { providerID: "", modelID: "" }, + }, + ] + if (message.type !== "assistant") return [] + return [ + { + id: message.id, + sessionID, + role: "assistant" as const, + time: message.time, + parentID: sessionID, + providerID: message.model.providerID, + modelID: message.model.id, + mode: "build", + agent: message.agent, + path: { + cwd: session?.location.directory ?? "", + root: session?.location.directory ?? "", + }, + cost: message.cost ?? 0, + tokens: message.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: message.model.variant, + }, + ] + }) }, status(sessionID) { return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" } }, permission(sessionID) { - return sync.data.permission[sessionID] ?? [] + return (data.session.permission.list(sessionID) ?? []).map((request) => ({ + id: request.id, + sessionID: request.sessionID, + permission: request.action, + patterns: request.resources, + metadata: request.metadata ?? {}, + always: request.save ?? [], + tool: request.source?.type === "tool" ? { messageID: request.source.messageID, callID: request.source.callID } : undefined, + })) }, question(sessionID) { return sync.data.question[sessionID] ?? [] diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx new file mode 100644 index 000000000000..075dc35f409d --- /dev/null +++ b/packages/tui/src/routes/session/form.tsx @@ -0,0 +1,919 @@ +import { createStore } from "solid-js/store" +import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" +import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" +import open from "open" +import { selectedForeground, tint, useTheme } from "../../context/theme" +import type { FormFormInfo, FormValue } from "@opencode-ai/sdk/v2" +import type { FormInfo } from "../../context/data" +import { useSDK } from "../../context/sdk" +import { SplitBorder } from "../../ui/border" +import { useTuiConfig } from "../../config" +import { useBindings, useOpencodeModeStack } from "../../keymap" + +const FORM_MODE = "form" + +type Field = FormFormInfo["fields"][number] + +function fieldLabel(field: Field) { + return field.title ?? field.key +} + +function truncate(label: string, max: number) { + return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label +} + +function validateText(field: Field, text: string): string | undefined { + if (field.type !== "string") return + if (field.minLength !== undefined && text.length < field.minLength) + return `Must be at least ${field.minLength} characters` + if (field.maxLength !== undefined && text.length > field.maxLength) + return `Must be at most ${field.maxLength} characters` + if (field.pattern !== undefined) { + try { + if (!new RegExp(field.pattern).test(text)) return `Must match pattern: ${field.pattern}` + } catch { + return + } + } + if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) return "Expected an email address" + if (field.format === "uri") { + try { + new URL(text) + } catch { + return "Expected a URL" + } + } + if (field.format === "date") { + const date = new Date(`${text}T00:00:00.000Z`) + if (!/^\d{4}-\d{2}-\d{2}$/.test(text) || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text) + return "Expected a date (YYYY-MM-DD)" + } + if (field.format === "date-time" && Number.isNaN(new Date(text).getTime())) return "Expected a date and time" +} + +function validateSelection(field: Field, value: FormValue | undefined) { + if (field.type !== "multiselect" || value === undefined) return + if (!Array.isArray(value)) return "Expected selections" + if (field.required && value.length === 0) return "Select at least one option" + if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}` + if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}` +} + +function fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] { + if (field.type === "boolean") + return [ + { value: true, label: "Yes" }, + { value: false, label: "No" }, + ] + if (field.type === "multiselect" || (field.type === "string" && field.options)) + return (field.options ?? []).map((option) => ({ + value: option.value, + label: option.label, + description: option.description, + })) + return [] +} + +function display(field: Field, value: FormValue | undefined) { + if (value === undefined) return "" + const label = (item: string | number | boolean) => + fieldRows(field).find((row) => row.value === item)?.label ?? String(item) + if (Array.isArray(value)) return value.map(label).join(", ") + return label(value) +} + +function requestOptions(form: FormInfo) { + if (!form.location) return undefined + return { + headers: { + "x-opencode-directory": encodeURIComponent(form.location.directory), + ...(form.location.workspaceID ? { "x-opencode-workspace": form.location.workspaceID } : {}), + }, + } +} + +export function FormPrompt(props: { form: FormInfo }) { + return props.form.mode === "url" ? : +} + +function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { + const sdk = useSDK() + const { theme } = useTheme() + const modeStack = useOpencodeModeStack() + const message = createMemo(() => { + const value = props.form.metadata?.["message"] + return typeof value === "string" ? value : undefined + }) + + onMount(() => onCleanup(modeStack.push(FORM_MODE))) + + useBindings(() => ({ + mode: FORM_MODE, + enabled: true, + commands: [ + { + name: "app.exit", + title: "Dismiss form", + category: "Form", + run() { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) + }, + }, + ], + bindings: [ + { + key: "return", + desc: "Open link", + group: "Form", + cmd: () => { + void open(props.form.url) + }, + }, + { + key: "escape", + desc: "Dismiss form", + group: "Form", + cmd: () => { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) + }, + }, + ], + })) + + return ( + + + {props.form.title ?? "Input requested"} + + {message()} + + {props.form.url} + + + + enter open link + + + esc dismiss + + + + ) +} + +function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { + const sdk = useSDK() + const { theme } = useTheme() + const renderer = useRenderer() + const dimensions = useTerminalDimensions() + const tuiConfig = useTuiConfig() + const modeStack = useOpencodeModeStack() + + const [tabHover, setTabHover] = createSignal(null) + const [store, setStore] = createStore({ + tab: 0, + answers: Object.fromEntries( + props.form.fields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])), + ) as Record, + custom: {} as Record, + selected: 0, + editing: false, + error: "", + }) + + let textarea: TextareaRenderable | undefined + let review: ScrollBoxRenderable | undefined + + const fields = createMemo(() => { + const answers: Record = {} + return props.form.fields.filter((field) => { + const active = (field.when ?? []).every((when) => { + const value = answers[when.key] + if (value === undefined) return false + const hit = Array.isArray(value) ? value.some((item) => item === when.value) : value === when.value + return when.op === "eq" ? hit : !hit + }) + if (active) answers[field.key] = store.answers[field.key] + return active + }) + }) + const single = createMemo(() => { + const list = fields() + if (props.form.fields.length !== 1) return false + if (list.length !== 1) return false + const field = list[0]! + return field.type === "boolean" || (field.type === "string" && field.options !== undefined) + }) + const tabs = createMemo(() => (single() ? 1 : fields().length + 1)) + const tabbed = createMemo(() => { + const width = fields().reduce( + (sum, item) => sum + truncate(fieldLabel(item), 24).length + 3, + "Confirm".length + 3, + ) + return width <= dimensions().width - 8 + }) + const answered = createMemo(() => + fields().filter((item) => { + const value = store.answers[item.key] + return Array.isArray(value) ? value.length > 0 : value !== undefined + }).length, + ) + const field = createMemo(() => fields()[Math.min(store.tab, fields().length - 1)]) + const confirm = createMemo(() => !single() && store.tab >= fields().length) + const rows = createMemo(() => (field() ? fieldRows(field()!) : [])) + const textual = createMemo(() => { + if (confirm()) return false + const current = field() + if (!current) return false + if (current.type === "number" || current.type === "integer") return true + return current.type === "string" && current.options === undefined + }) + const custom = createMemo(() => { + const current = field() + if (!current) return false + if (current.type === "string" && current.options !== undefined) return current.custom === true + if (current.type === "multiselect") return current.custom === true + return false + }) + const multi = createMemo(() => field()?.type === "multiselect") + const placeholder = createMemo(() => { + const current = field() + if (current?.type === "string") { + if (current.placeholder) return current.placeholder + if (current.format === "email") return "name@example.com" + if (current.format === "uri") return "https://example.com" + if (current.format === "date") return "YYYY-MM-DD" + if (current.format === "date-time") return "YYYY-MM-DDTHH:MM:SSZ" + } + if (current?.type === "number" || current?.type === "integer") { + const minimum = typeof current.minimum === "number" ? current.minimum : undefined + const maximum = typeof current.maximum === "number" ? current.maximum : undefined + if (minimum !== undefined && maximum !== undefined) return `${minimum}-${maximum}` + if (minimum !== undefined) return `at least ${minimum}` + if (maximum !== undefined) return `at most ${maximum}` + } + return "Type your answer" + }) + const other = createMemo(() => custom() && store.selected === rows().length) + const input = createMemo(() => store.custom[field()?.key ?? ""] ?? "") + const customPicked = createMemo(() => { + const value = input() + if (!value) return false + const answer = store.answers[field()?.key ?? ""] + if (Array.isArray(answer)) return answer.includes(value) + return answer === value + }) + + function answer(key: string, value: FormValue | undefined) { + setStore("answers", { ...store.answers, [key]: value }) + setStore("error", "") + } + + function pick(value: FormValue, customValue?: string) { + const current = field() + if (!current) return + answer(current.key, value) + if (customValue !== undefined) setStore("custom", { ...store.custom, [current.key]: customValue }) + if (single()) { + sdk.api.form + .reply({ + sessionID: props.form.sessionID, + formID: props.form.id, + answer: { [current.key]: value }, + }, requestOptions(props.form)) + .catch((error: unknown) => { + setStore( + "error", + typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : "Invalid answer", + ) + }) + return + } + setStore("tab", store.tab + 1) + setStore("selected", 0) + } + + function toggle(value: string) { + const current = field() + if (!current) return + const existing = store.answers[current.key] + const list = Array.isArray(existing) ? [...existing] : [] + const index = list.indexOf(value) + if (index === -1) list.push(value) + if (index !== -1) list.splice(index, 1) + answer(current.key, list.length === 0 ? undefined : list) + } + + function selectTab(index: number) { + setStore("tab", index) + setStore("selected", 0) + setStore("editing", false) + setStore("error", "") + } + + function selectOption() { + if (other()) { + if (!multi()) { + setStore("editing", true) + return + } + const value = input() + if (value && customPicked()) { + toggle(value) + return + } + setStore("editing", true) + return + } + const row = rows()[store.selected] + if (!row) return + if (multi()) { + toggle(String(row.value)) + return + } + pick(row.value) + } + + function submitText(text: string, direction: 1 | -1 = 1) { + const current = field() + if (!current) return + const move = () => selectTab((store.tab + direction + tabs()) % tabs()) + if (!text) { + answer(current.key, undefined) + setStore("editing", false) + if (!single()) move() + return + } + if (current.type === "number" || current.type === "integer") { + const value = Number(text) + if (!Number.isFinite(value) || (current.type === "integer" && !Number.isInteger(value))) { + setStore("error", current.type === "integer" ? "Expected an integer" : "Expected a number") + return + } + if (typeof current.minimum === "number" && value < current.minimum) { + setStore("error", `Must be at least ${current.minimum}`) + return + } + if (typeof current.maximum === "number" && value > current.maximum) { + setStore("error", `Must be at most ${current.maximum}`) + return + } + answer(current.key, value) + } + if (current.type === "string") { + const invalid = validateText(current, text) + if (invalid) { + setStore("error", invalid) + return + } + answer(current.key, text) + } + setStore("custom", { ...store.custom, [current.key]: text }) + setStore("editing", false) + move() + } + + onMount(() => onCleanup(modeStack.push(FORM_MODE))) + + useBindings(() => ({ + mode: FORM_MODE, + enabled: (store.editing || textual()) && !confirm(), + commands: [ + { + name: "prompt.clear", + title: "Clear answer edit", + category: "Form", + run() { + const text = textarea?.plainText ?? "" + if (!text) { + setStore("editing", false) + return + } + textarea?.setText("") + }, + }, + ], + bindings: [ + { + key: "escape", + desc: "Cancel answer edit", + group: "Form", + cmd: () => { + if (textual()) { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) + return + } + setStore("editing", false) + }, + }, + ...tuiConfig.keybinds.get("prompt.clear"), + { + key: "tab", + desc: "Next field", + group: "Form", + cmd: () => { + if (!textual()) return + const text = textarea?.plainText?.trim() ?? "" + submitText(text) + }, + }, + { + key: "shift+tab", + desc: "Previous field", + group: "Form", + cmd: () => { + if (!textual()) return + const text = textarea?.plainText?.trim() ?? "" + submitText(text, -1) + }, + }, + { + key: "return", + desc: "Submit answer edit", + group: "Form", + cmd: () => { + const text = textarea?.plainText?.trim() ?? "" + const current = field() + if (!current) return + + if (multi()) { + const prev = store.custom[current.key] + if (!text) { + if (prev) { + const existing = store.answers[current.key] + const list = Array.isArray(existing) ? existing.filter((item) => item !== prev) : [] + answer(current.key, list.length === 0 ? undefined : list) + setStore("custom", { ...store.custom, [current.key]: "" }) + } + setStore("editing", false) + return + } + const existing = store.answers[current.key] + const list = Array.isArray(existing) ? [...existing] : [] + if (prev) { + const index = list.indexOf(prev) + if (index !== -1) list.splice(index, 1) + } + if (!list.includes(text)) list.push(text) + answer(current.key, list) + setStore("custom", { ...store.custom, [current.key]: text }) + setStore("editing", false) + return + } + + if (textual()) { + submitText(text) + return + } + + if (!text) { + answer(current.key, undefined) + setStore("custom", { ...store.custom, [current.key]: "" }) + setStore("editing", false) + return + } + if (current.type === "string") { + const invalid = validateText(current, text) + if (invalid) { + setStore("error", invalid) + return + } + } + pick(text, text) + setStore("editing", false) + }, + }, + ], + })) + + useBindings(() => { + const total = rows().length + (custom() ? 1 : 0) + const max = Math.min(total, 9) + + return { + mode: FORM_MODE, + enabled: !store.editing && !textual(), + commands: [ + { + name: "app.exit", + title: "Dismiss form", + category: "Form", + run() { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) + }, + }, + ], + bindings: [ + { + key: "left", + desc: "Previous field", + group: "Form", + cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()), + }, + { + key: "h", + desc: "Previous field", + group: "Form", + cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()), + }, + { key: "right", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { key: "l", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { + key: "tab", + desc: "Next field", + group: "Form", + cmd: () => selectTab((store.tab + 1) % tabs()), + }, + { + key: "shift+tab", + desc: "Previous field", + group: "Form", + cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()), + }, + ...(confirm() + ? [ + { + key: "return", + desc: "Submit form", + group: "Form", + cmd: () => { + const invalid = fields().find((field) => validateSelection(field, store.answers[field.key])) + if (invalid) { + setStore("error", validateSelection(invalid, store.answers[invalid.key]) ?? "Invalid answer") + return + } + sdk.api.form + .reply({ + sessionID: props.form.sessionID, + formID: props.form.id, + answer: Object.fromEntries( + fields().flatMap((field) => { + const value = store.answers[field.key] + return value === undefined ? [] : [[field.key, value] as const] + }), + ), + }, requestOptions(props.form)) + .catch((error: unknown) => { + setStore( + "error", + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ? error.message + : "Invalid answer", + ) + }) + }, + }, + { + key: "escape", + desc: "Dismiss form", + group: "Form", + cmd: () => { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) + }, + }, + { key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) }, + { key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) }, + { key: "down", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) }, + { key: "j", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) }, + ...tuiConfig.keybinds.get("app.exit"), + ] + : [ + ...Array.from({ length: max }, (_, index) => ({ + key: String(index + 1), + desc: `Select answer ${index + 1}`, + group: "Form", + cmd: () => { + setStore("selected", index) + selectOption() + }, + })), + { + key: "up", + desc: "Previous answer", + group: "Form", + cmd: () => setStore("selected", (store.selected - 1 + total) % total), + }, + { + key: "k", + desc: "Previous answer", + group: "Form", + cmd: () => setStore("selected", (store.selected - 1 + total) % total), + }, + { key: "down", desc: "Next answer", group: "Form", cmd: () => setStore("selected", (store.selected + 1) % total) }, + { key: "j", desc: "Next answer", group: "Form", cmd: () => setStore("selected", (store.selected + 1) % total) }, + { key: "return", desc: "Select answer", group: "Form", cmd: () => selectOption() }, + { + key: "escape", + desc: "Dismiss form", + group: "Form", + cmd: () => { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) + }, + }, + ...tuiConfig.keybinds.get("app.exit"), + ]), + ], + } + }) + + return ( + + + + + {props.form.title} + + + + + + {confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`} + + + · {answered()}/{fields().length} answered + + + + + + + {(item, index) => { + const isTab = () => index() === store.tab + const isAnswered = () => store.answers[item.key] !== undefined + return ( + setTabHover(index())} + onMouseOut={() => setTabHover(null)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + selectTab(index()) + }} + > + + {truncate(fieldLabel(item), 24)} + + + ) + }} + + setTabHover("confirm")} + onMouseOut={() => setTabHover(null)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + selectTab(fields().length) + }} + > + Confirm + + + + + + + + + {field()!.description ?? fieldLabel(field()!)} + {field()!.required ? " (required)" : ""} + {multi() ? " (select all that apply)" : ""} + + + + +