refactor(form): model links as fields (#36129)

This commit is contained in:
Aiden Cline 2026-07-10 00:22:01 -05:00 committed by GitHub
commit a6449cb45c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2409 additions and 1430 deletions

View file

@ -196,9 +196,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const api = OpenCode.make(options)
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.map((response) => response.location.directory),
Effect.catch(() =>
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
),
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
)
const reconnectEndpoint = input.server.reconnect
const reconnect = reconnectEndpoint
@ -411,11 +409,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: {
onSnapshot?: () => Promise<string[]>
pluginHost: TuiPluginHost
pair?: DialogPairCredentials
}) {
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost; pair?: DialogPairCredentials }) {
const log = useLog({ component: "app" })
const startup = useTuiStartup()
const tuiConfig = useTuiConfig()

View file

@ -6,8 +6,7 @@
import type {
AgentInfo,
CommandInfo,
FormFormInfo,
FormUrlInfo,
FormInfo,
IntegrationInfo,
LocationRef,
McpServer,
@ -38,7 +37,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
// server cannot recover their Location when settling them. Preserve the event Location
// until MCP elicitations carry session ownership.
export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef }
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
type LocationData = {
agent?: AgentInfo[]
@ -66,7 +65,7 @@ type Data = {
input: Record<string, string[]>
permission: Record<string, PermissionV2Request[]>
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
form: Record<string, FormInfo[]>
form: Record<string, FormWithLocation[]>
}
project: {
permission: Record<string, PermissionSavedInfo[]>
@ -1033,7 +1032,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: response.location.directory,
workspaceID: response.location.workspaceID,
}
const forms = response.data.reduce<Record<string, FormInfo[]>>(
const forms = response.data.reduce<Record<string, FormWithLocation[]>>(
(result, form) => ({
...result,
[form.sessionID]: [

View file

@ -4,19 +4,25 @@ 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 type { FormField, FormValue } from "@opencode-ai/sdk/v2"
import type { FormWithLocation } from "../../context/data"
import { useSDK } from "../../context/sdk"
import { useClipboard } from "../../context/clipboard"
import { SplitBorder } from "../../ui/border"
import { useToast } from "../../ui/toast"
import { useTuiConfig } from "../../config"
import { useBindings, useOpencodeModeStack } from "../../keymap"
const FORM_MODE = "form"
type Field = FormFormInfo["fields"][number]
type Field = Exclude<FormField, { type: "external" }>
function fieldLabel(field: Field) {
return field.title ?? field.key
function isField(field: FormField): field is Field {
return field.type !== "external"
}
function fieldLabel(field: FormField) {
return field.title ?? (field.type === "external" ? field.url : field.key)
}
function truncate(label: string, max: number) {
@ -24,7 +30,7 @@ function truncate(label: string, max: number) {
}
function validateText(field: Field, text: string): string | undefined {
if (field.type !== "string") return
if (field.type !== "string") return undefined
if (field.minLength !== undefined && text.length < field.minLength)
return `Must be at least ${field.minLength} characters`
if (field.maxLength !== undefined && text.length > field.maxLength)
@ -50,17 +56,19 @@ function validateText(field: Field, text: string): string | undefined {
return "Expected a date (YYYY-MM-DD)"
}
if (field.format === "date-time" && Number.isNaN(new Date(text).getTime())) return "Expected a date and time"
return undefined
}
function validateSelection(field: Field, value: FormValue | undefined) {
if (field.type !== "multiselect" || value === undefined) return
function validateSelection(field: Field, value: FormValue | undefined): string | undefined {
if (field.type !== "multiselect" || value === undefined) return undefined
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}`
return undefined
}
function validateValue(field: Field, value: FormValue | undefined) {
function validateValue(field: Field, value: FormValue | undefined): string | 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"
@ -72,14 +80,14 @@ function validateValue(field: Field, value: FormValue | undefined) {
if (field.options && !field.custom && !field.options.some((option) => option.value === value)) {
return "Select an available option"
}
return
return undefined
}
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
return undefined
}
if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no"
const invalid = validateSelection(field, value)
@ -91,6 +99,7 @@ function validateValue(field: Field, value: FormValue | undefined) {
) {
return "Select only available options"
}
return undefined
}
function fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] {
@ -117,11 +126,6 @@ function selectedRow(field: Field | undefined, value: FormValue | undefined) {
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) =>
@ -130,7 +134,7 @@ function display(field: Field, value: FormValue | undefined) {
return label(value)
}
function requestOptions(form: FormInfo) {
function requestOptions(form: FormWithLocation) {
if (form.sessionID !== "global" || !form.location) return undefined
return {
headers: {
@ -140,107 +144,32 @@ function requestOptions(form: FormInfo) {
}
}
export function FormPrompt(props: { form: FormInfo }) {
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
}
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 },
requestOptions(props.form),
)
},
},
],
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 },
requestOptions(props.form),
)
},
},
],
}))
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.accent}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
<text fg={theme.text}>{props.form.title}</text>
<Show when={message()}>
<text fg={theme.textMuted}>{message()}</text>
</Show>
<text fg={theme.secondary}>{props.form.url}</text>
</box>
<box flexDirection="row" flexShrink={0} gap={2} paddingLeft={2} paddingRight={3} paddingBottom={1}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>open link</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
</text>
</box>
</box>
)
}
function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
export function FormPrompt(props: { form: FormWithLocation }) {
const sdk = useSDK()
const { theme } = useTheme()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const tuiConfig = useTuiConfig()
const modeStack = useOpencodeModeStack()
const clipboard = useClipboard()
const toast = useToast()
const configuredFields = props.form.fields.filter(isField)
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [store, setStore] = createStore({
tab: 0,
answers: Object.fromEntries(
props.form.fields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])),
configuredFields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])),
) as Record<string, FormValue | undefined>,
custom: Object.fromEntries(
props.form.fields.flatMap((field) => {
const value = customDefault(field)
return value === undefined ? [] : [[field.key, value]]
configuredFields.flatMap((field) => {
if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return []
if (field.options.some((option) => option.value === field.default)) return []
return [[field.key, field.default]]
}),
) as Record<string, string>,
selected: selectedRow(props.form.fields[0], props.form.fields[0]?.default),
externalReady: {} as Record<string, boolean>,
selected: selectedRow(configuredFields[0], configuredFields[0]?.default),
editing: false,
error: "",
})
@ -248,9 +177,14 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
let textarea: TextareaRenderable | undefined
let review: ScrollBoxRenderable | undefined
const message = createMemo(() => {
const value = props.form.metadata?.["message"]
return typeof value === "string" ? value : undefined
})
const fields = createMemo(() => {
const answers: Record<string, FormValue | undefined> = {}
return props.form.fields.filter((field) => {
if (field.type === "external") return true
const active = (field.when ?? []).every((when) => {
const value = answers[when.key]
if (value === undefined) return false
@ -263,9 +197,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
})
const single = createMemo(() => {
const list = fields()
if (props.form.fields.length !== 1) return false
if (list.length !== 1) return false
const field = list[0]!
const field = list[0]
if (field.type === "external") return false
return field.type === "boolean" || (field.type === "string" && field.options !== undefined)
})
const tabs = createMemo(() => (single() ? 1 : fields().length + 1))
@ -280,10 +214,18 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
return value !== undefined
}).length,
)
const field = createMemo(() => fields()[Math.min(store.tab, fields().length - 1)])
const field = createMemo(() => fields()[store.tab])
const answerField = createMemo(() => {
const current = field()
return current && isField(current) ? current : undefined
})
const externalField = createMemo(() => {
const current = field()
return current?.type === "external" ? current : undefined
})
const confirm = createMemo(() => !single() && store.tab >= fields().length)
const rows = createMemo(() => {
const current = field()
const current = answerField()
if (!current) return []
const configured = fieldRows(current)
const value = store.answers[current.key]
@ -296,21 +238,32 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
})
const textual = createMemo(() => {
if (confirm()) return false
const current = field()
const current = answerField()
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()
const current = answerField()
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 multi = createMemo(() => answerField()?.type === "multiselect")
const actionLabel = createMemo(() => {
if (confirm()) return "submit"
const external = externalField()
if (external) {
if (store.answers[external.key] === true) return "continue"
return store.externalReady[external.key] ? "I finished" : "open link"
}
if (multi()) return "toggle"
if (single()) return "submit"
return "confirm"
})
const placeholder = createMemo(() => {
const current = field()
const current = answerField()
if (current?.type === "string") {
if (current.placeholder) return current.placeholder
if (current.format === "email") return "name@example.com"
@ -328,11 +281,11 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
return "Type your answer"
})
const other = createMemo(() => custom() && store.selected === rows().length)
const input = createMemo(() => store.custom[field()?.key ?? ""] ?? "")
const input = createMemo(() => store.custom[answerField()?.key ?? ""] ?? "")
const customPicked = createMemo(() => {
const value = input()
if (!value) return false
const answer = store.answers[field()?.key ?? ""]
const answer = store.answers[answerField()?.key ?? ""]
if (Array.isArray(answer)) return answer.includes(value)
return answer === value
})
@ -363,7 +316,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
}
function pick(value: FormValue, customValue?: string) {
const current = field()
const current = answerField()
if (!current) return
const invalid = validateValue(current, value)
if (invalid) {
@ -380,7 +333,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
}
function toggle(value: string) {
const current = field()
const current = answerField()
if (!current) return
const existing = store.answers[current.key]
const list = Array.isArray(existing) ? [...existing] : []
@ -392,7 +345,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
function validateCurrent() {
if (confirm()) return true
const current = field()
const current = answerField()
if (!current) return true
const invalid = validateValue(current, store.answers[current.key])
if (!invalid) return true
@ -404,7 +357,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
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("selected", next && isField(next) ? selectedRow(next, store.answers[next.key]) : 0)
setStore("editing", false)
setStore("error", "")
}
@ -433,7 +386,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
}
function commitInput(text: string) {
const current = field()
const current = answerField()
if (!current) return false
const isTextual = textual()
const isMulti = multi()
@ -507,9 +460,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
if (!single()) selectTab((store.tab + direction + tabs()) % tabs())
}
function selectTabFromMouse(target?: Field) {
function selectTabFromMouse(target?: FormField) {
const targetIndex = () => {
const index = target ? fields().findIndex((field) => field.key === target.key) : fields().length
const index = target ? fields().findIndex((field) => field === target) : fields().length
return index === -1 ? fields().length : index
}
const move = () => selectTab(targetIndex())
@ -524,6 +477,83 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
move()
}
function cancel() {
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
}
function openExternal() {
const current = externalField()
if (!current) return
setStore("error", "")
void open(current.url)
.then(() => setStore("externalReady", { ...store.externalReady, [current.key]: true }))
.catch(() => setStore("error", "Could not open the browser. Copy the URL and continue manually."))
}
function copyExternal() {
const current = externalField()
if (!current || !clipboard.write) return
void clipboard
.write(current.url)
.then(() => {
setStore("externalReady", { ...store.externalReady, [current.key]: true })
toast.show({ message: "Copied URL to clipboard", variant: "info" })
})
.catch(toast.error)
}
function acknowledgeExternal() {
const current = externalField()
if (!current) return
if (store.answers[current.key] === true) {
selectTab(store.tab + 1)
return
}
if (!store.externalReady[current.key]) {
openExternal()
return
}
answer(current.key, true)
selectTab(store.tab + 1)
}
function submit() {
const unacknowledged = fields().find((field) => field.type === "external" && store.answers[field.key] !== true)
if (unacknowledged) {
setStore("error", `External action must be acknowledged: ${fieldLabel(unacknowledged)}`)
return
}
const invalid = fields()
.filter(isField)
.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]
}),
),
},
requestOptions(props.form),
)
.catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
}
onMount(() => onCleanup(modeStack.push(FORM_MODE)))
useBindings(() => ({
@ -585,7 +615,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
group: "Form",
cmd: () => {
const text = textarea?.plainText?.trim() ?? ""
const current = field()
const current = answerField()
if (!current) return
if (textual()) {
submitInput(text)
@ -606,6 +636,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
useBindings(() => {
const total = rows().length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
const external = externalField()
return {
mode: FORM_MODE,
@ -615,12 +646,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
name: "app.exit",
title: "Dismiss form",
category: "Form",
run() {
void sdk.api.form.cancel(
{ sessionID: props.form.sessionID, formID: props.form.id },
requestOptions(props.form),
)
},
run: cancel,
},
],
bindings: [
@ -650,110 +676,86 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
group: "Form",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
},
...(confirm()
...(external
? [
{
key: "return",
desc: "Submit form",
desc:
store.answers[external.key] === true
? "Continue"
: store.externalReady[external.key]
? "Confirm completion"
: "Open link",
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]
}),
),
},
requestOptions(props.form),
)
.catch((error: unknown) => {
setStore(
"error",
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
},
cmd: acknowledgeExternal,
},
{
key: "escape",
desc: "Dismiss form",
group: "Form",
cmd: () => {
void sdk.api.form.cancel(
{ sessionID: props.form.sessionID, formID: props.form.id },
requestOptions(props.form),
)
},
},
{ 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) },
{ key: "c", desc: "Copy link", group: "Form", cmd: copyExternal },
{ key: "escape", desc: "Dismiss form", group: "Form", cmd: cancel },
...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()
: confirm()
? [
{
key: "return",
desc: "Submit form",
group: "Form",
cmd: submit,
},
})),
{
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 },
requestOptions(props.form),
)
{
key: "escape",
desc: "Dismiss form",
group: "Form",
cmd: cancel,
},
},
...tuiConfig.keybinds.get("app.exit"),
]),
{ 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: cancel,
},
...tuiConfig.keybinds.get("app.exit"),
]),
],
}
})
@ -769,14 +771,21 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
<box paddingLeft={1}>
<text fg={theme.textMuted}>{props.form.title}</text>
</box>
<Show when={message()}>
<box paddingLeft={1}>
<text fg={theme.text}>{message()}</text>
</box>
</Show>
<Show when={!single() && !tabbed()}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
</text>
<text fg={theme.textMuted}>
· {answered()}/{fields().length} answered
</text>
<Show when={fields().length > 0}>
<text fg={theme.textMuted}>
· {answered()}/{fields().length} completed
</text>
</Show>
</box>
</Show>
<Show when={!single() && tabbed()}>
@ -828,16 +837,45 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
</box>
</Show>
<Show when={!confirm() && field()}>
<Show when={!confirm() && externalField()}>
{(external) => (
<box paddingLeft={1} gap={1}>
<Show when={external().title}>
<text fg={theme.text}>{external().title}</text>
</Show>
<Show when={external().description}>
<text fg={theme.textMuted}>{external().description}</text>
</Show>
<text
fg={theme.primary}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
openExternal()
}}
>
{external().url}
</text>
<text fg={store.answers[external().key] === true ? theme.success : theme.textMuted}>
{store.answers[external().key] === true
? "✓ Acknowledged"
: store.externalReady[external().key]
? "Complete the external action, then press enter to confirm."
: "Open or copy the URL, complete the external action, then confirm."}
</text>
</box>
)}
</Show>
<Show when={!confirm() && answerField()}>
<box paddingLeft={1} gap={1}>
<box>
<text fg={theme.text}>
{field()!.description ?? fieldLabel(field()!)}
{field()!.required ? " (required)" : ""}
{answerField()!.description ?? fieldLabel(answerField()!)}
{answerField()!.required ? " (required)" : ""}
{multi() ? " (select all that apply)" : ""}
</text>
</box>
<Show when={textual() ? field()!.key : undefined} keyed>
<Show when={textual() ? answerField()!.key : undefined} keyed>
<box paddingLeft={1}>
<textarea
ref={(val: TextareaRenderable) => {
@ -848,7 +886,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
val.gotoLineEnd()
})
}}
initialValue={input() || display(field()!, store.answers[field()!.key])}
initialValue={input() || display(answerField()!, store.answers[answerField()!.key])}
placeholder={placeholder()}
placeholderColor={theme.textMuted}
minHeight={1}
@ -865,7 +903,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
{(row, i) => {
const active = () => i() === store.selected
const picked = () => {
const value = store.answers[field()?.key ?? ""]
const value = store.answers[answerField()?.key ?? ""]
if (Array.isArray(value)) return value.includes(String(row.value))
return value === row.value
}
@ -973,11 +1011,21 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
>
<For each={fields()}>
{(item) => {
const value = () => display(item, store.answers[item.key])
const answered = () => {
const value = store.answers[item.key]
return value !== undefined
if (item.type === "external") {
const acknowledged = () => store.answers[item.key] === true
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
<span style={{ fg: acknowledged() ? theme.success : theme.error }}>
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
</span>
</text>
</box>
)
}
const value = () => display(item, store.answers[item.key])
const answered = () => store.answers[item.key] !== undefined
const missing = () => !answered() && item.required === true
const invalid = () => validateValue(item, store.answers[item.key])
return (
@ -985,7 +1033,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
<text>
<span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
<span
style={{ fg: invalid() || missing() ? theme.error : answered() ? theme.text : theme.textMuted }}
style={{
fg: invalid() || missing() ? theme.error : answered() ? theme.text : theme.textMuted,
}}
>
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
</span>
@ -1012,23 +1062,32 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
</text>
</Show>
<Show when={!confirm() && !textual()}>
<Show when={!confirm() && !textual() && !externalField()}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
</Show>
<Show when={confirm()}>
<Show when={confirm() && fields().length > 0}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>scroll</span>
</text>
</Show>
<text fg={theme.text}>
enter{" "}
<span style={{ fg: theme.textMuted }}>
{confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
</span>
<text
fg={theme.text}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
if (confirm()) submit()
if (externalField()) acknowledgeExternal()
}}
>
enter <span style={{ fg: theme.textMuted }}>{actionLabel()}</span>
</text>
<text fg={theme.text}>
<Show when={externalField() && clipboard.write}>
<text fg={theme.text} onMouseUp={copyExternal}>
c <span style={{ fg: theme.textMuted }}>copy</span>
</text>
</Show>
<text fg={theme.text} onMouseUp={cancel}>
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
</text>
</box>