Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions packages/tui/src/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,9 @@ export function Home() {
{(_) => {
const form = forms()[0]
return form ? (
<box
position="absolute"
zIndex={2000}
left={0}
right={0}
bottom={1}
paddingLeft={2}
paddingRight={2}
>
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
<box width="100%">
<FormPrompt form={form} />
<FormPrompt form={form} onNotFound={() => data.session.form.refresh(form.sessionID, form.location)} />
</box>
</box>
) : null
Expand Down
35 changes: 17 additions & 18 deletions packages/tui/src/routes/session/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 { FormField, FormValue } from "@opencode-ai/client"
import { isFormNotFoundError, type FormField, type FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
import { useSDK } from "../../context/sdk"
import { useClipboard } from "../../context/clipboard"
Expand Down Expand Up @@ -144,7 +144,7 @@ function requestOptions(form: FormWithLocation) {
}
}

export function FormPrompt(props: { form: FormWithLocation }) {
export function FormPrompt(props: { form: FormWithLocation; onNotFound: () => Promise<void> }) {
const sdk = useSDK()
const { theme } = useTheme()
const renderer = useRenderer()
Expand Down Expand Up @@ -295,6 +295,19 @@ export function FormPrompt(props: { form: FormWithLocation }) {
setStore("error", "")
}

function replyFailed(error: unknown) {
if (isFormNotFoundError(error)) {
void props.onNotFound().catch(() => setStore("error", error.message))
return
}
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
}

function replySingle(field: Field, value: FormValue) {
sdk.api.form
.reply(
Expand All @@ -305,14 +318,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
},
requestOptions(props.form),
)
.catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
.catch(replyFailed)
}

function pick(value: FormValue, customValue?: string) {
Expand Down Expand Up @@ -544,14 +550,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
},
requestOptions(props.form),
)
.catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
.catch(replyFailed)
}

onMount(() => onCleanup(modeStack.push(FORM_MODE)))
Expand Down
7 changes: 6 additions & 1 deletion packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -918,7 +918,12 @@ export function Session() {
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? <FormPrompt form={form} /> : null
return form ? (
<FormPrompt
form={form}
onNotFound={() => data.session.form.refresh(form.sessionID, form.location)}
/>
) : null
}}
</Show>
</Match>
Expand Down
43 changes: 39 additions & 4 deletions packages/tui/test/cli/tui/form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 { createSignal, onCleanup, Show } from "solid-js"
import { ClipboardProvider } from "../../../src/context/clipboard"
import type { FormWithLocation } from "../../../src/context/data"
import { KVProvider } from "../../../src/context/kv"
Expand All @@ -18,19 +18,25 @@ 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) {
async function mountForm(root: string, width = 80, missing = false) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
await Bun.write(path.join(state, "kv.json"), "{}")

const replies: unknown[] = []
const notFound: string[] = []
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)
if (missing)
return Response.json(
{ _tag: "FormNotFoundError", id: "frm_test", message: "Form not found: frm_test" },
{ status: 404 },
)
return new Response(null, { status: 204 })
})
: undefined,
Expand All @@ -57,6 +63,7 @@ async function mountForm(root: string, width = 80) {
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)
const [visible, setVisible] = createSignal(true)

return (
<TestTuiContexts
Expand All @@ -81,7 +88,16 @@ async function mountForm(root: string, width = 80) {
<KVProvider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<FormPrompt form={form} />
<Show when={visible()}>
<FormPrompt
form={form}
onNotFound={() => {
notFound.push(form.id)
setVisible(false)
return Promise.resolve()
}}
/>
</Show>
</ToastProvider>
</ThemeProvider>
</KVProvider>
Expand All @@ -96,9 +112,28 @@ async function mountForm(root: string, width = 80) {
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 }
return { app, copied, notFound, replies }
}

test("dismisses a form that no longer exists when submitting", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, true)
try {
prompt.app.mockInput.pressKey("right")
prompt.app.mockInput.pressKey("left")
prompt.app.mockInput.pressKey("c")
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
prompt.app.mockInput.pressEnter()

await prompt.app.waitForFrame((frame) => !frame.includes("Authorization required"))
expect(prompt.notFound).toEqual(["frm_test"])
} finally {
prompt.app.renderer.destroy()
}
})

test("requires explicit acknowledgement before submitting an external field", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path)
Expand Down
Loading