refactor(tui): simplify form prompts

This commit is contained in:
Aiden Cline 2026-07-03 12:49:40 -05:00
parent b181216ce5
commit d6472bfda7
2 changed files with 126 additions and 131 deletions

View file

@ -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<string, FormValue | undefined>) {
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 (
<Switch>
<Match when={props.form.mode === "url" && props.form}>{(form) => <UrlPrompt form={form()} />}</Match>
<Match when={props.form.mode === "form" && props.form}>{(form) => <FieldsPrompt form={form()} />}</Match>
</Switch>
)
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
}
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<string, FormValue | undefined>
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(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<string, FormValue | undefined>,
custom: {} as Record<string, string>,
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 (
<box
onMouseOver={() => 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" } }) {
</For>
<Show when={custom()}>
<box
onMouseOver={() => moveTo(rows().length)}
onMouseDown={() => moveTo(rows().length)}
onMouseOver={() => setStore("selected", rows().length)}
onMouseDown={() => setStore("selected", rows().length)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectOption()

View file

@ -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<number | "confirm" | null>(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 (
<box
onMouseOver={() => 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?:
</For>
<Show when={custom()}>
<box
onMouseOver={() => moveTo(options().length)}
onMouseDown={() => moveTo(options().length)}
onMouseOver={() => setStore("selected", options().length)}
onMouseDown={() => setStore("selected", options().length)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectOption()