mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-18 11:23:31 +00:00
refactor(tui): simplify form prompts
This commit is contained in:
parent
b181216ce5
commit
d6472bfda7
2 changed files with 126 additions and 131 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue