From 68dcc35491c66201f4bc0c5586e74f41d17f6ad9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 12 Jul 2026 21:32:29 -0400 Subject: [PATCH] fix(tui): dismiss stale forms after failed reply --- packages/tui/src/routes/home.tsx | 12 ++----- packages/tui/src/routes/session/form.tsx | 35 +++++++++--------- packages/tui/src/routes/session/index.tsx | 7 +++- packages/tui/test/cli/tui/form.test.tsx | 43 ++++++++++++++++++++--- 4 files changed, 64 insertions(+), 33 deletions(-) diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 623772fcb628..d3647ab4730b 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -95,17 +95,9 @@ export function Home() { {(_) => { const form = forms()[0] return form ? ( - + - + data.session.form.refresh(form.sessionID, form.location)} /> ) : null diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index d09673d77534..5284cae330de 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -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" @@ -144,7 +144,7 @@ function requestOptions(form: FormWithLocation) { } } -export function FormPrompt(props: { form: FormWithLocation }) { +export function FormPrompt(props: { form: FormWithLocation; onNotFound: () => Promise }) { const sdk = useSDK() const { theme } = useTheme() const renderer = useRenderer() @@ -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( @@ -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) { @@ -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))) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index eda25233b96d..bab74f74ca9c 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -918,7 +918,12 @@ export function Session() { {(_) => { const form = forms()[0] - return form ? : null + return form ? ( + data.session.form.refresh(form.sessionID, form.location)} + /> + ) : null }} diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx index 0a28ba8de356..040880210f7f 100644 --- a/packages/tui/test/cli/tui/form.test.tsx +++ b/packages/tui/test/cli/tui/form.test.tsx @@ -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" @@ -18,12 +18,13 @@ 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( @@ -31,6 +32,11 @@ async function mountForm(root: string, width = 80) { 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, @@ -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 ( Promise.resolve({}) }}> - + + { + notFound.push(form.id) + setVisible(false) + return Promise.resolve() + }} + /> + @@ -96,9 +112,28 @@ async function mountForm(root: string, width = 80) { const app = await testRender(() => , { 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)