From 3a19f15660dbfa4c4dd46c62870a96897f588378 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 09:37:48 -0500 Subject: [PATCH 01/13] feat(tui): render session forms --- packages/tui/src/context/data.tsx | 55 +- .../feature-plugins/system/notifications.ts | 15 + packages/tui/src/plugin/adapters.tsx | 11 +- packages/tui/src/routes/session/form.tsx | 910 ++++++++++++++++++ packages/tui/src/routes/session/index.tsx | 36 +- .../test/cli/cmd/tui/notifications.test.ts | 62 +- packages/tui/test/cli/tui/data.test.tsx | 122 +++ packages/tui/test/fixture/tui-sdk.ts | 8 +- 8 files changed, 1193 insertions(+), 26 deletions(-) create mode 100644 packages/tui/src/routes/session/form.tsx diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 82a7150c9e71..62ea98e1deeb 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, @@ -29,6 +31,8 @@ export type DataSessionStatus = "idle" | "running" const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") +export type FormInfo = FormFormInfo | FormUrlInfo + type LocationData = { agent?: AgentV2Info[] command?: CommandV2Info[] @@ -54,6 +58,8 @@ type Data = { message: Record permission: Record question: Record + // Pending forms keyed by session ID. + form: Record } project: { permission: Record @@ -88,6 +94,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message: {}, permission: {}, question: {}, + form: {}, }, project: { permission: {}, @@ -602,6 +609,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, @@ -680,6 +703,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) @@ -728,6 +762,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: { @@ -869,7 +911,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: defaultLocation().directory, workspace: defaultLocation().workspaceID, }) - .then((response) => { + .then(async (response) => { setStore( "session", "info", @@ -878,6 +920,17 @@ 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.location.refresh(), result.location.agent.refresh(), diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 416ba8c466ec..36f08b1c84b0 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -29,9 +29,24 @@ function sessionErrorMessage(error: SessionError) { const tui: TuiPlugin = async (api) => { const active = new Set() const errored = new Set() + const forms = new Set() const questions = new Set() const permissions = new Set() + 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("form.replied", (event) => { + forms.delete(event.data.id) + }) + + api.event.on("form.cancelled", (event) => { + forms.delete(event.data.id) + }) + api.event.on("question.asked", (event) => { if (questions.has(event.data.id)) return questions.add(event.data.id) diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index 068b51c7322c..4f6c3810d5b2 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -123,7 +123,16 @@ function stateApi(sync: ReturnType, data: ReturnType diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx new file mode 100644 index 000000000000..9219cf5d41ab --- /dev/null +++ b/packages/tui/src/routes/session/form.tsx @@ -0,0 +1,910 @@ +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) +} + +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 }) + }, + }, + ], + 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 [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 }, + }) + .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 }) + 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 }) + }, + }, + ], + 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] + }), + ), + }) + .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 }) + }, + }, + { 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 }) + }, + }, + ...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)" : ""} + + + + +