From b181216ce57836c1805300db0ea8dafeac4e0a6d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 3 Jul 2026 11:41:51 -0500 Subject: [PATCH 01/20] feat(tui): render forms and route question tool through form service --- packages/core/src/plugin/internal.ts | 4 + packages/core/src/tool/question.ts | 44 +- packages/core/test/tool-question.test.ts | 56 +- packages/tui/src/context/data.tsx | 31 + packages/tui/src/routes/session/form.tsx | 876 ++++++++++++++++++++++ packages/tui/src/routes/session/index.tsx | 14 +- 6 files changed, 1004 insertions(+), 21 deletions(-) create mode 100644 packages/tui/src/routes/session/form.tsx diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 939bb4e731b8..2afa17bee128 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" @@ -69,6 +70,7 @@ export type Requirements = | EventV2.Service | FileMutation.Service | FileSystem.Service + | Form.Service | FSUtil.Service | Global.Service | HttpClient.HttpClient @@ -116,6 +118,7 @@ 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), @@ -192,6 +195,7 @@ export const node = makeLocationNode({ EventV2.node, FSUtil.node, FileSystem.node, + Form.node, Global.node, httpClient, PermissionV2.node, diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 218edb57e92a..5a9bed88a1fa 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" @@ -42,10 +43,35 @@ export const toModelOutput = ( return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.` } +// Each question becomes one field keyed by position; answers translate back positionally. +export const toField = (question: QuestionV2.Prompt, index: number): Form.Field => { + const shared = { + key: `q${index}`, + title: question.header, + description: question.question, + options: question.options.map((option) => ({ + value: option.label, + label: option.label, + description: option.description, + })), + custom: true, + } + if (question.multiple === true) return { ...shared, type: "multiselect" } + return { ...shared, type: "string" } +} + +export const toAnswers = (questions: ReadonlyArray, answer: Form.Answer) => + questions.map((_, index): QuestionV2.Answer => { + const value = answer[`q${index}`] + if (value === undefined) return [] + if (typeof value === "object") return Array.from(value) + return [String(value)] + }) + 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 +95,23 @@ 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(toField), }) .pipe(Effect.orDie), ), - Effect.map((answers) => ({ answers })), + Effect.flatMap((state) => { + // The runner halts the loop on this exact defect (session/runner/llm.ts). + if (state.status !== "answered") return Effect.die(new QuestionV2.RejectedError()) + return Effect.succeed({ answers: toAnswers(input.questions, state.answer) }) + }), ), }), }) 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 d70a71be5ce2..1c92ec25a263 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, @@ -27,6 +29,8 @@ import { createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" +export type FormInfo = FormFormInfo | FormUrlInfo + type LocationData = { agent?: AgentV2Info[] command?: CommandV2Info[] @@ -52,6 +56,8 @@ type Data = { 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 @@ -86,6 +92,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message: {}, permission: {}, question: {}, + form: {}, }, project: { permission: {}, @@ -587,6 +594,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ), ) 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, [ + ...(store.session.form[event.data.form.sessionID] ?? []), + mutable(event.data.form), + ]) + break + case "form.replied": + case "form.cancelled": + setStore( + "session", + "form", + event.data.sessionID, + (store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id), + ) + break case "shell.created": setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({ ...data, @@ -714,6 +737,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "question", sessionID, mutable(await sdk.api.question.list({ sessionID }))) }, }, + form: { + list(sessionID: string) { + return store.session.form[sessionID] + }, + async refresh(sessionID: string) { + setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID }))) + }, + }, }, project: { permission: { diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx new file mode 100644 index 000000000000..d5d43a9b8460 --- /dev/null +++ b/packages/tui/src/routes/session/form.tsx @@ -0,0 +1,876 @@ +import { createStore } from "solid-js/store" +import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } 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, FormWhen } 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] + +// Mirrors core when-evaluation in packages/core/src/form.ts. +function matches(when: FormWhen, value: FormValue | undefined) { + 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 +} + +function isActive(field: Field, answers: Record) { + return (field.when ?? []).every((when) => matches(when, answers[when.key])) +} + +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 +} + +// Mirrors core validateField in packages/core/src/form.ts. +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" && !isUri(text)) return "Expected a URL" + if (field.format === "date" && !isDate(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 isUri(value: string) { + try { + new URL(value) + return true + } catch { + return false + } +} + +function isDate(value: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false + const date = new Date(`${value}T00:00:00.000Z`) + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value +} + +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) +} + +export function FormPrompt(props: { form: FormInfo }) { + return ( + + {(form) => } + {(form) => } + + ) +} + +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(() => { + const popMode = modeStack.push(FORM_MODE) + onCleanup(popMode) + }) + + 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 }) + }, + }, + ], + 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 }) + }, + }, + ], + })) + + 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 defaults = Object.fromEntries( + props.form.fields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])), + ) as Record + + const [tabHover, setTabHover] = createSignal(null) + const [store, setStore] = createStore({ + tab: 0, + answers: defaults, + custom: {} as Record, + selected: 0, + editing: false, + error: "", + }) + + let textarea: TextareaRenderable | undefined + let review: ScrollBoxRenderable | undefined + + const fields = createMemo(() => props.form.fields.filter((field) => isActive(field, store.answers))) + const single = createMemo(() => { + const list = fields() + 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) => store.answers[item.key] !== 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 submit() { + const entries = fields().flatMap((field) => { + const value = store.answers[field.key] + return value === undefined ? [] : [[field.key, value] as const] + }) + sdk.api.form + .reply({ + sessionID: props.form.sessionID, + formID: props.form.id, + answer: Object.fromEntries(entries), + }) + .catch((error: unknown) => { + const message = + typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : "Invalid answer" + setStore("error", message) + }) + } + + function cancel() { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + } + + 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()) { + void sdk.api.form.reply({ + sessionID: props.form.sessionID, + formID: props.form.id, + answer: { [current.key]: value }, + }) + 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) + } + + function moveTo(index: number) { + setStore("selected", index) + } + + 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(() => { + const popMode = modeStack.push(FORM_MODE) + onCleanup(popMode) + }) + + 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()) { + cancel() + 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() ?? "" + if (text) submitText(text) + if (!text) selectTab((store.tab + 1) % tabs()) + }, + }, + { + key: "shift+tab", + desc: "Previous field", + group: "Form", + cmd: () => { + if (!textual()) return + const text = textarea?.plainText?.trim() ?? "" + if (text) submitText(text, -1) + if (!text) selectTab((store.tab - 1 + tabs()) % tabs()) + }, + }, + { + 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) + 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 + } + 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() { + cancel() + }, + }, + ], + 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: () => submit() }, + { key: "escape", desc: "Dismiss form", group: "Form", cmd: () => cancel() }, + { 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: () => { + moveTo(index) + selectOption() + }, + })), + { + key: "up", + desc: "Previous answer", + group: "Form", + cmd: () => moveTo((store.selected - 1 + total) % total), + }, + { + key: "k", + desc: "Previous answer", + group: "Form", + cmd: () => moveTo((store.selected - 1 + total) % total), + }, + { key: "down", desc: "Next answer", group: "Form", cmd: () => moveTo((store.selected + 1) % total) }, + { key: "j", desc: "Next answer", group: "Form", cmd: () => moveTo((store.selected + 1) % total) }, + { key: "return", desc: "Select answer", group: "Form", cmd: () => selectOption() }, + { key: "escape", desc: "Dismiss form", group: "Form", cmd: () => cancel() }, + ...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)" : ""} + + + + +