Compare commits

...

1 Commits

Author SHA1 Message Date
kitlangton 22e9b60909 fix(tui): recover from stale forms 2026-08-23 18:54:30 +00:00
2 changed files with 114 additions and 18 deletions
+41 -9
View File
@@ -11,8 +11,8 @@ import {
} from "@opentui/core"
import open from "open"
import { useTheme, useThemes } from "../../context/theme"
import type { FormAnswer, FormField, FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
import { isFormNotFoundError, type FormAnswer, type FormField, type FormValue } from "@opencode-ai/client"
import { useData, type FormWithLocation } from "../../context/data"
import { useClient } from "../../context/client"
import { useClipboard } from "../../context/clipboard"
import { SplitBorder } from "../../ui/border"
@@ -57,6 +57,7 @@ export function FormPrompt(props: {
onCancel?: () => void | Promise<void>
}) {
const client = useClient()
const data = useData()
const themes = useThemes()
const theme = useTheme("elevated")
const themeMode = themes.mode
@@ -70,6 +71,7 @@ export function FormPrompt(props: {
const initial = formInitialValues(props.form.fields)
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [dismissed, setDismissed] = createSignal(false)
const [reviewHeight, setReviewHeight] = createSignal(1)
const [reviewScrollable, setReviewScrollable] = createSignal(false)
const [store, setStore] = createStore({
@@ -85,6 +87,7 @@ export function FormPrompt(props: {
let textarea: TextareaRenderable | undefined
let review: ScrollBoxRenderable | undefined
let measureReview: (() => void) | undefined
let releaseMode: (() => void) | undefined
const message = createMemo(() => {
const value = props.form.metadata?.["message"]
@@ -267,7 +270,13 @@ export function FormPrompt(props: {
requestOptions(props.form),
),
)
.catch((error: unknown) => setStore("error", errorMessage(error)))
.catch((error: unknown) => {
if (isFormNotFoundError(error)) {
dismissStale()
return
}
setStore("error", errorMessage(error))
})
}
function replySingle(field: FormAnswerField, value: FormValue) {
@@ -457,11 +466,30 @@ export function FormPrompt(props: {
}
function cancel() {
if (props.onCancel) {
void props.onCancel()
return
}
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
void Promise.resolve()
.then(() =>
props.onCancel
? props.onCancel()
: client.api.form.cancel(
{ sessionID: props.form.sessionID, formID: props.form.id },
requestOptions(props.form),
),
)
.catch((error: unknown) => {
if (isFormNotFoundError(error)) {
dismissStale()
return
}
setStore("error", errorMessage(error))
})
}
function dismissStale() {
releaseMode?.()
releaseMode = undefined
setDismissed(true)
data.session.form.invalidate(props.form.sessionID, props.form.location)
void data.session.form.sync(props.form.sessionID, props.form.location).catch(toast.error)
}
function openExternal() {
@@ -523,7 +551,10 @@ export function FormPrompt(props: {
)
}
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
onMount(() => {
releaseMode = keymap.mode.push(FORM_MODE)
onCleanup(() => releaseMode?.())
})
Keymap.createLayer(() => ({
mode: FORM_MODE,
@@ -752,6 +783,7 @@ export function FormPrompt(props: {
return (
<box
visible={!dismissed()}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
+73 -9
View File
@@ -4,6 +4,7 @@ import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import type { FormWithLocation } from "../../../src/context/data"
import { DataProvider } from "../../../src/context/data"
import { ClientProvider } from "../../../src/context/client"
import { ThemeProvider } from "../../../src/context/theme"
import { Keymap } from "../../../src/context/keymap"
@@ -12,7 +13,7 @@ import { ToastProvider } from "../../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
async function mountForm(
root: string,
@@ -20,11 +21,14 @@ async function mountForm(
fields?: FormWithLocation["fields"],
height = 20,
clipboardText?: string,
replyStatus = 204,
cancelStatus = 204,
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const replies: unknown[] = []
const cancellations: unknown[] = []
const copied: string[] = []
const events = createEventStream()
const transport = createFetch(
@@ -32,9 +36,22 @@ async function mountForm(
url.pathname === "/api/session/ses_test/form/frm_test/reply"
? request.json().then((answer) => {
replies.push(answer)
return new Response(null, { status: 204 })
if (replyStatus === 204) return new Response(null, { status: 204 })
return json(
{ _tag: "FormNotFoundError", id: "frm_test", message: "Form not found: frm_test" },
{ status: replyStatus },
)
})
: undefined,
: url.pathname === "/api/session/ses_test/form/frm_test/cancel"
? (() => {
cancellations.push(true)
if (cancelStatus === 204) return new Response(null, { status: 204 })
return json(
{ _tag: "FormNotFoundError", id: "frm_test", message: "Form not found: frm_test" },
{ status: cancelStatus },
)
})()
: undefined,
events,
)
const config = createTuiResolvedConfig()
@@ -75,11 +92,13 @@ async function mountForm(
<ConfigProvider config={config}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<FormPrompt form={form} />
</ToastProvider>
</ThemeProvider>
<DataProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<FormPrompt form={form} />
</ToastProvider>
</ThemeProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
@@ -90,9 +109,54 @@ async function mountForm(
const app = await testRender(() => <Harness />, { width, height, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Authorization required"))
return { app, copied, replies }
return { app, cancellations, copied, replies }
}
test("dismisses a stale form when replying reports it missing", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(
tmp.path,
80,
[{ key: "target", type: "string", options: [{ value: "staging", label: "Staging" }] }],
20,
undefined,
404,
)
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.replies.length === 1)
await prompt.app.waitForFrame((frame) => !frame.includes("Authorization required"))
expect(prompt.app.captureCharFrame()).not.toContain("Authorization required")
expect(prompt.app.captureCharFrame()).not.toContain("Form not found")
} finally {
prompt.app.renderer.destroy()
}
})
test("dismisses a stale form when cancelling reports it missing", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(
tmp.path,
80,
[{ key: "target", type: "string", options: [{ value: "staging", label: "Staging" }] }],
20,
undefined,
204,
404,
)
try {
prompt.app.mockInput.pressEscape()
await prompt.app.waitFor(() => prompt.cancellations.length === 1)
await prompt.app.waitForFrame((frame) => !frame.includes("Authorization required"))
expect(prompt.app.captureCharFrame()).not.toContain("Authorization required")
expect(prompt.app.captureCharFrame()).not.toContain("Form not found")
} 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)