refactor(form): model links as fields (#36129)
This commit is contained in:
parent
6a85f0d3db
commit
a6449cb45c
24 changed files with 2409 additions and 1430 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]: [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -77,16 +77,12 @@ function question(id: string, sessionID = "session"): QuestionRequest {
|
|||
}
|
||||
}
|
||||
|
||||
function form(
|
||||
id: string,
|
||||
sessionID = "session",
|
||||
): Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"] {
|
||||
function form(id: string, sessionID = "session"): Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"] {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
title: "Input requested",
|
||||
mode: "form",
|
||||
fields: [],
|
||||
fields: [{ key: "authorization", type: "external", url: "https://example.com" }],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ import { createSessionRows, type SessionRow } from "../../../src/routes/session/
|
|||
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
const formFields = [{ key: "authorization", type: "external", url: "https://example.com" }] satisfies [
|
||||
{
|
||||
key: string
|
||||
type: "external"
|
||||
url: string
|
||||
},
|
||||
]
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
|
|
@ -1510,7 +1518,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/session/ses_1/form") return
|
||||
return json({
|
||||
data: [{ id: "frm_remote", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] }],
|
||||
data: [{ id: "frm_remote", sessionID: "ses_1", title: "Input requested", fields: formFields }],
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
|
@ -1540,13 +1548,13 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||
id: "evt_form_created_1",
|
||||
created: 0,
|
||||
type: "form.created",
|
||||
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
|
||||
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", fields: formFields } },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_form_created_duplicate",
|
||||
created: 1,
|
||||
type: "form.created",
|
||||
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
|
||||
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", fields: formFields } },
|
||||
})
|
||||
await wait(() => data.session.form.list("ses_1")?.length === 1)
|
||||
|
||||
|
|
@ -1562,7 +1570,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||
id: "evt_form_created_2",
|
||||
created: 3,
|
||||
type: "form.created",
|
||||
data: { form: { id: "frm_2", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
|
||||
data: { form: { id: "frm_2", sessionID: "ses_1", title: "Input requested", fields: formFields } },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_form_cancelled_2",
|
||||
|
|
@ -1612,7 +1620,7 @@ test("tracks global forms by location", async () => {
|
|||
location: other,
|
||||
type: "form.created",
|
||||
data: {
|
||||
form: { id: "frm_other", sessionID: "global", title: "Input requested", mode: "form", fields: [] },
|
||||
form: { id: "frm_other", sessionID: "global", title: "Input requested", fields: formFields },
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -1625,7 +1633,7 @@ test("tracks global forms by location", async () => {
|
|||
location: { directory },
|
||||
type: "form.created",
|
||||
data: {
|
||||
form: { id: "frm_default", sessionID: "global", title: "Input requested", mode: "form", fields: [] },
|
||||
form: { id: "frm_default", sessionID: "global", title: "Input requested", fields: formFields },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.form.list("global", { directory })?.length === 1)
|
||||
|
|
@ -1664,8 +1672,7 @@ test("refreshes global forms for the requested location", async () => {
|
|||
id: requestedDirectory === other.directory ? "frm_other" : "frm_default",
|
||||
sessionID: "global",
|
||||
title: "Input requested",
|
||||
mode: "form",
|
||||
fields: [],
|
||||
fields: formFields,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -1743,8 +1750,7 @@ test("refreshes global forms once per loaded location after reconnect", async ()
|
|||
id: `frm_${requestedDirectory === other.directory ? "other" : "default"}_${count}`,
|
||||
sessionID: "global",
|
||||
title: "Input requested",
|
||||
mode: "form",
|
||||
fields: [],
|
||||
fields: formFields,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -1803,13 +1809,12 @@ test("refreshes global forms once per loaded location after reconnect", async ()
|
|||
test("reconciles all pending form requests when the event stream reconnects", async () => {
|
||||
const events = createEventStream()
|
||||
let requests = [
|
||||
{ id: "frm_old", sessionID: "ses_old", title: "Input requested", mode: "form" as const, fields: [] },
|
||||
{ id: "frm_old", sessionID: "ses_old", title: "Input requested", fields: formFields },
|
||||
{
|
||||
id: "frm_keep",
|
||||
sessionID: "ses_keep",
|
||||
title: "Input requested",
|
||||
mode: "url" as const,
|
||||
url: "https://example.com",
|
||||
fields: [{ key: "authorization", type: "external" as const, url: "https://example.com" }],
|
||||
},
|
||||
]
|
||||
let calls = 0
|
||||
|
|
@ -1841,7 +1846,7 @@ test("reconciles all pending form requests when the event stream reconnects", as
|
|||
await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old")
|
||||
expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep")
|
||||
|
||||
requests = [{ id: "frm_new", sessionID: "ses_new", title: "Input requested", mode: "form" as const, fields: [] }]
|
||||
requests = [{ id: "frm_new", sessionID: "ses_new", title: "Input requested", fields: formFields }]
|
||||
events.disconnect()
|
||||
|
||||
await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")
|
||||
|
|
|
|||
139
packages/tui/test/cli/tui/form.test.tsx
Normal file
139
packages/tui/test/cli/tui/form.test.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { ClipboardProvider } from "../../../src/context/clipboard"
|
||||
import type { FormWithLocation } from "../../../src/context/data"
|
||||
import { KVProvider } from "../../../src/context/kv"
|
||||
import { SDKProvider } from "../../../src/context/sdk"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { TuiConfigProvider } from "../../../src/config"
|
||||
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk"
|
||||
|
||||
async function mountForm(root: string, width = 80) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
await Bun.write(path.join(state, "kv.json"), "{}")
|
||||
|
||||
const replies: unknown[] = []
|
||||
const copied: string[] = []
|
||||
const events = createEventStream()
|
||||
const transport = createFetch(
|
||||
(url, request) =>
|
||||
url.pathname === "/api/session/ses_test/form/frm_test/reply"
|
||||
? request.json().then((answer) => {
|
||||
replies.push(answer)
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
: undefined,
|
||||
events,
|
||||
)
|
||||
const config = createTuiResolvedConfig()
|
||||
const form = {
|
||||
id: "frm_test",
|
||||
sessionID: "ses_test",
|
||||
title: "Authorization required",
|
||||
fields: [
|
||||
{
|
||||
key: "authorization",
|
||||
type: "external",
|
||||
url: "https://example.com/authorize",
|
||||
title: "Authorize access",
|
||||
},
|
||||
],
|
||||
} satisfies FormWithLocation
|
||||
const { FormPrompt } = await import("../../../src/routes/session/form")
|
||||
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const off = registerOpencodeKeymap(keymap, renderer, config)
|
||||
onCleanup(off)
|
||||
|
||||
return (
|
||||
<TestTuiContexts
|
||||
directory={root}
|
||||
paths={{
|
||||
home: root,
|
||||
state,
|
||||
worktree: root,
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider
|
||||
value={{
|
||||
write(text) {
|
||||
copied.push(text)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}}
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<TuiConfigProvider config={config}>
|
||||
<SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<FormPrompt form={form} />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</KVProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</ClipboardProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width, height: 20, kittyKeyboard: true })
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("Authorization required"))
|
||||
return { app, copied, replies }
|
||||
}
|
||||
|
||||
test("requires explicit acknowledgement before submitting an external field", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path)
|
||||
try {
|
||||
prompt.app.mockInput.pressKey("right")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("(acknowledgement required)"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("External action must be acknowledged"))
|
||||
expect(prompt.replies).toEqual([])
|
||||
|
||||
prompt.app.mockInput.pressKey("left")
|
||||
prompt.app.mockInput.pressKey("c")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
|
||||
expect(prompt.copied).toEqual(["https://example.com/authorize"])
|
||||
expect(prompt.replies).toEqual([])
|
||||
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
|
||||
expect(prompt.replies).toEqual([])
|
||||
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitFor(() => prompt.replies.length === 1)
|
||||
expect(prompt.replies).toEqual([{ answer: { authorization: true } }])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("includes external acknowledgements in progress", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 32)
|
||||
try {
|
||||
expect(prompt.app.captureCharFrame()).toContain("0/1")
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
|
@ -5,9 +5,11 @@ export const worktree = "/tmp/opencode"
|
|||
export const directory = `${worktree}/packages/tui`
|
||||
|
||||
export function json(data: unknown, init?: ResponseInit) {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (!headers.has("content-type")) headers.set("content-type", "application/json")
|
||||
return new Response(JSON.stringify(data), {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -63,14 +65,15 @@ export function createEventStream() {
|
|||
}
|
||||
}
|
||||
|
||||
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
|
||||
export type FetchHandler = (url: URL, request: Request) => Response | undefined | Promise<Response | undefined>
|
||||
|
||||
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
|
||||
const session = [] as URL[]
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
async function fetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/session") session.push(url)
|
||||
const overridden = await override?.(url)
|
||||
const overridden = await override?.(url, request)
|
||||
if (overridden) return overridden
|
||||
if (url.pathname === "/api/event" && events) return events.v2()
|
||||
|
||||
|
|
@ -122,7 +125,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||
if (url.pathname === "/session") return json([])
|
||||
if (url.pathname === "/vcs") return json({ branch: "main" })
|
||||
throw new Error(`unexpected request: ${url.pathname}`)
|
||||
}) as typeof globalThis.fetch
|
||||
}
|
||||
fetch.preconnect = () => {}
|
||||
return { fetch, session }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue