diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 82a7150c9e71..1528b83cd0b6 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, @@ -728,6 +751,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/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 416ba8c466ec..a5033b62e0bd 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -29,9 +29,25 @@ 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 (event.data.form.sessionID === "global") return + 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/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx new file mode 100644 index 000000000000..e6f7a4818c83 --- /dev/null +++ b/packages/tui/src/routes/session/form.tsx @@ -0,0 +1,1009 @@ +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 `Invalid pattern: ${field.pattern}` + } + } + 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 validateValue(field: Field, value: FormValue | undefined) { + if (value === undefined) return field.required ? "Answer required" : undefined + if (field.required && (value === "" || (Array.isArray(value) && value.length === 0))) { + return field.type === "multiselect" ? "Select at least one option" : "Answer required" + } + if (field.type === "string") { + if (typeof value !== "string") return "Expected text" + const invalid = validateText(field, value) + if (invalid) return invalid + if (field.options && !field.custom && !field.options.some((option) => option.value === value)) { + return "Select an available option" + } + return + } + if (field.type === "number" || field.type === "integer") { + if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number" + if (field.type === "integer" && !Number.isInteger(value)) return "Expected an integer" + if (typeof field.minimum === "number" && value < field.minimum) return `Must be at least ${field.minimum}` + if (typeof field.maximum === "number" && value > field.maximum) return `Must be at most ${field.maximum}` + return + } + if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no" + const invalid = validateSelection(field, value) + if (invalid) return invalid + if ( + Array.isArray(value) && + !field.custom && + value.some((item) => !field.options.some((option) => option.value === item)) + ) { + return "Select only available options" + } +} + +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 selectedRow(field: Field | undefined, value: FormValue | undefined) { + if (!field || value === undefined || Array.isArray(value)) return 0 + const rows = fieldRows(field) + const index = rows.findIndex((row) => row.value === value) + if (index !== -1) return index + if (typeof value === "string" && field.type === "string" && field.options && field.custom) return rows.length + return 0 +} + +function customDefault(field: Field) { + if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return + if (!field.options.some((option) => option.value === field.default)) return field.default +} + +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.length === 0 ? "(none)" : 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: Object.fromEntries( + props.form.fields.flatMap((field) => { + const value = customDefault(field) + return value === undefined ? [] : [[field.key, value]] + }), + ) as Record, + selected: selectedRow(props.form.fields[0], props.form.fields[0]?.default), + 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 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(() => { + const current = field() + if (!current) return [] + const configured = fieldRows(current) + const value = store.answers[current.key] + if (current.type !== "multiselect" || !Array.isArray(value)) return configured + const known = new Set(configured.map((row) => row.value)) + return [ + ...configured, + ...value.filter((item) => !known.has(item)).map((item) => ({ value: item, label: item, description: undefined })), + ] + }) + 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 replySingle(field: Field, value: FormValue) { + sdk.api.form + .reply({ + sessionID: props.form.sessionID, + formID: props.form.id, + answer: { [field.key]: value }, + }) + .catch((error: unknown) => { + setStore( + "error", + typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : "Invalid answer", + ) + }) + } + + function pick(value: FormValue, customValue?: string) { + const current = field() + if (!current) return + const invalid = validateValue(current, value) + if (invalid) { + setStore("error", invalid) + return + } + answer(current.key, value) + if (customValue !== undefined) setStore("custom", { ...store.custom, [current.key]: customValue }) + if (single()) { + replySingle(current, value) + return + } + selectTab(store.tab + 1) + } + + 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 validateCurrent() { + if (confirm()) return true + const current = field() + if (!current) return true + const invalid = validateValue(current, store.answers[current.key]) + if (!invalid) return true + setStore("error", invalid) + return false + } + + function selectTab(index: number) { + if (!confirm() && index > store.tab && !validateCurrent()) return + const next = fields()[index] + setStore("tab", index) + setStore("selected", selectedRow(next, next ? store.answers[next.key] : undefined)) + 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 commitInput(text: string) { + const current = field() + if (!current) return false + const isTextual = textual() + const isMulti = multi() + if (!text) { + const previous = store.custom[current.key] + const existing = store.answers[current.key] + const values = Array.isArray(existing) ? existing.filter((value) => value !== previous) : [] + const value = !isTextual && isMulti && Array.isArray(existing) ? values : undefined + const invalid = validateValue(current, value) + if (invalid) { + setStore("error", invalid) + return false + } + answer(current.key, value) + setStore("custom", { ...store.custom, [current.key]: "" }) + setStore("editing", false) + return true + } + + if (isTextual && (current.type === "number" || current.type === "integer")) { + const value = Number(text) + const invalid = validateValue(current, value) + if (invalid) { + setStore("error", invalid) + return false + } + answer(current.key, value) + } + + if (isTextual && current.type === "string") { + const invalid = validateValue(current, text) + if (invalid) { + setStore("error", invalid) + return false + } + answer(current.key, text) + } + + if (!isTextual && isMulti) { + const previous = store.custom[current.key] + const existing = store.answers[current.key] + const values = Array.isArray(existing) ? [...existing] : [] + if (previous) { + const index = values.indexOf(previous) + if (index !== -1) values.splice(index, 1) + } + if (!values.includes(text)) values.push(text) + answer(current.key, values) + } + + if (!isTextual && !isMulti) { + const invalid = validateValue(current, text) + if (invalid) { + setStore("error", invalid) + return false + } + answer(current.key, text) + } + + const configured = current.type === "string" && current.options?.some((option) => option.value === text) + setStore("custom", { ...store.custom, [current.key]: isMulti || configured ? "" : text }) + setStore("editing", false) + return true + } + + function submitInput(text: string, direction: 1 | -1 = 1) { + if (!commitInput(text)) { + if (direction === -1) selectTab((store.tab + direction + tabs()) % tabs()) + return + } + if (!single()) selectTab((store.tab + direction + tabs()) % tabs()) + } + + function selectTabFromMouse(target?: Field) { + const targetIndex = () => { + const index = target ? fields().findIndex((field) => field.key === target.key) : fields().length + return index === -1 ? fields().length : index + } + const move = () => selectTab(targetIndex()) + if (!textual() && !store.editing) { + move() + return + } + if (!commitInput(textarea?.plainText?.trim() ?? "")) { + if (targetIndex() < store.tab) move() + return + } + 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: () => { + const text = textarea?.plainText?.trim() ?? "" + submitInput(text) + }, + }, + { + key: "shift+tab", + desc: "Previous field", + group: "Form", + cmd: () => { + const text = textarea?.plainText?.trim() ?? "" + submitInput(text, -1) + }, + }, + { + key: "return", + desc: "Submit answer edit", + group: "Form", + cmd: () => { + const text = textarea?.plainText?.trim() ?? "" + const current = field() + if (!current) return + if (textual()) { + submitInput(text) + return + } + const wasMulti = multi() + if (!commitInput(text) || wasMulti || !text) return + if (single()) { + replySingle(current, text) + return + } + selectTab(store.tab + 1) + }, + }, + ], + })) + + 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) => validateValue(field, store.answers[field.key])) + if (invalid) { + setStore("error", validateValue(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 + selectTabFromMouse(item) + }} + > + + {truncate(fieldLabel(item), 24)} + + + ) + }} + + setTabHover("confirm")} + onMouseOut={() => setTabHover(null)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + selectTabFromMouse() + }} + > + Confirm + + + + + + + + + {field()!.description ?? fieldLabel(field()!)} + {field()!.required ? " (required)" : ""} + {multi() ? " (select all that apply)" : ""} + + + + +