diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index d5d43a9b846..8817c45778b 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -1,10 +1,10 @@ import { createStore } from "solid-js/store" -import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js" +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, FormWhen } from "@opencode-ai/sdk/v2" +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" @@ -15,17 +15,6 @@ 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 } @@ -34,7 +23,6 @@ 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) @@ -49,24 +37,19 @@ function validateText(field: Field, text: string): string | undefined { } } 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 + if (field.format === "uri") { + try { + new URL(text) + } catch { + return "Expected a URL" + } } -} - -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 + 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 fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] { @@ -93,12 +76,7 @@ function display(field: Field, value: FormValue | undefined) { } export function FormPrompt(props: { form: FormInfo }) { - return ( - - {(form) => } - {(form) => } - - ) + return props.form.mode === "url" ? : } function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { @@ -110,10 +88,7 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { return typeof value === "string" ? value : undefined }) - onMount(() => { - const popMode = modeStack.push(FORM_MODE) - onCleanup(popMode) - }) + onMount(() => onCleanup(modeStack.push(FORM_MODE))) useBindings(() => ({ mode: FORM_MODE, @@ -182,14 +157,12 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { 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, + answers: Object.fromEntries( + props.form.fields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])), + ) as Record, custom: {} as Record, selected: 0, editing: false, @@ -199,7 +172,16 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { let textarea: TextareaRenderable | undefined let review: ScrollBoxRenderable | undefined - const fields = createMemo(() => props.form.fields.filter((field) => isActive(field, store.answers))) + const fields = createMemo(() => + props.form.fields.filter((field) => + (field.when ?? []).every((when) => { + const value = store.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 + }), + ), + ) const single = createMemo(() => { const list = fields() if (list.length !== 1) return false @@ -266,30 +248,6 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { 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 @@ -318,10 +276,6 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { answer(current.key, list) } - function moveTo(index: number) { - setStore("selected", index) - } - function selectTab(index: number) { setStore("tab", index) setStore("selected", 0) @@ -391,10 +345,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { move() } - onMount(() => { - const popMode = modeStack.push(FORM_MODE) - onCleanup(popMode) - }) + onMount(() => onCleanup(modeStack.push(FORM_MODE))) useBindings(() => ({ mode: FORM_MODE, @@ -421,7 +372,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { group: "Form", cmd: () => { if (textual()) { - cancel() + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) return } setStore("editing", false) @@ -515,7 +466,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { title: "Dismiss form", category: "Form", run() { - cancel() + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) }, }, ], @@ -548,8 +499,43 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { }, ...(confirm() ? [ - { key: "return", desc: "Submit form", group: "Form", cmd: () => submit() }, - { key: "escape", desc: "Dismiss form", group: "Form", cmd: () => cancel() }, + { + key: "return", + desc: "Submit form", + group: "Form", + cmd: () => { + 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) }, @@ -562,7 +548,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { desc: `Select answer ${index + 1}`, group: "Form", cmd: () => { - moveTo(index) + setStore("selected", index) selectOption() }, })), @@ -570,18 +556,25 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { key: "up", desc: "Previous answer", group: "Form", - cmd: () => moveTo((store.selected - 1 + total) % total), + cmd: () => setStore("selected", (store.selected - 1 + total) % total), }, { key: "k", desc: "Previous answer", group: "Form", - cmd: () => moveTo((store.selected - 1 + total) % total), + cmd: () => setStore("selected", (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: "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: () => cancel() }, + { + 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"), ]), ], @@ -711,8 +704,8 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { } return ( moveTo(i())} - onMouseDown={() => moveTo(i())} + onMouseOver={() => setStore("selected", i())} + onMouseDown={() => setStore("selected", i())} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return selectOption() @@ -744,8 +737,8 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { moveTo(rows().length)} - onMouseDown={() => moveTo(rows().length)} + onMouseOver={() => setStore("selected", rows().length)} + onMouseDown={() => setStore("selected", rows().length)} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return selectOption() diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index 8659884a451..3123b0740f5 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -20,7 +20,7 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) - const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select) + const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) const [tabHover, setTabHover] = createSignal(null) const [store, setStore] = createStore({ tab: 0, @@ -45,22 +45,6 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: return store.answers[store.tab]?.includes(value) ?? false }) - function submit() { - const answers = questions().map((_, i) => store.answers[i] ?? []) - void sdk.api.question.reply({ - sessionID: props.request.sessionID, - requestID: props.request.id, - answers, - }) - } - - function reject() { - void sdk.api.question.reject({ - sessionID: props.request.sessionID, - requestID: props.request.id, - }) - } - function pick(answer: string, custom: boolean = false) { const answers = [...store.answers] answers[store.tab] = [answer] @@ -93,10 +77,6 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: setStore("answers", answers) } - function moveTo(index: number) { - setStore("selected", index) - } - function selectTab(index: number) { setStore("tab", index) setStore("selected", 0) @@ -125,10 +105,7 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: pick(opt.label) } - onMount(() => { - const popMode = modeStack.push(QUESTION_MODE) - onCleanup(popMode) - }) + onMount(() => onCleanup(modeStack.push(QUESTION_MODE))) useBindings(() => ({ mode: QUESTION_MODE, @@ -220,7 +197,7 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: title: "Reject question", category: "Question", run() { - reject() + void sdk.api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }) }, }, ], @@ -249,8 +226,26 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: }, ...(confirm() ? [ - { key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() }, - { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, + { + key: "return", + desc: "Submit answer", + group: "Question", + cmd: () => { + void sdk.api.question.reply({ + sessionID: props.request.sessionID, + requestID: props.request.id, + answers: questions().map((_, i) => store.answers[i] ?? []), + }) + }, + }, + { + key: "escape", + desc: "Reject question", + group: "Question", + cmd: () => { + void sdk.api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }) + }, + }, ...tuiConfig.keybinds.get("app.exit"), ] : [ @@ -259,7 +254,7 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: desc: `Select answer ${index + 1}`, group: "Question", cmd: () => { - moveTo(index) + setStore("selected", index) selectOption() }, })), @@ -267,18 +262,25 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: key: "up", desc: "Previous answer", group: "Question", - cmd: () => moveTo((store.selected - 1 + total) % total), + cmd: () => setStore("selected", (store.selected - 1 + total) % total), }, { key: "k", desc: "Previous answer", group: "Question", - cmd: () => moveTo((store.selected - 1 + total) % total), + cmd: () => setStore("selected", (store.selected - 1 + total) % total), }, - { key: "down", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) }, - { key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) }, + { key: "down", desc: "Next answer", group: "Question", cmd: () => setStore("selected", (store.selected + 1) % total) }, + { key: "j", desc: "Next answer", group: "Question", cmd: () => setStore("selected", (store.selected + 1) % total) }, { key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() }, - { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, + { + key: "escape", + desc: "Reject question", + group: "Question", + cmd: () => { + void sdk.api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }) + }, + }, ...tuiConfig.keybinds.get("app.exit"), ]), ], @@ -367,8 +369,8 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false return ( moveTo(i())} - onMouseDown={() => moveTo(i())} + onMouseOver={() => setStore("selected", i())} + onMouseDown={() => setStore("selected", i())} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return selectOption() @@ -399,8 +401,8 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: moveTo(options().length)} - onMouseDown={() => moveTo(options().length)} + onMouseOver={() => setStore("selected", options().length)} + onMouseDown={() => setStore("selected", options().length)} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return selectOption()