Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 9732e8163a chore(tui): preserve surrounding formatting 2026-07-12 21:29:47 -04:00
Kit Langton ad264cca37 fix(tui): always dismiss stale forms 2026-07-12 21:29:17 -04:00
6 changed files with 79 additions and 21 deletions
+8
View File
@@ -950,6 +950,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const key = locationKey(ref)
return forms?.filter((form) => form.location && locationKey(form.location) === key)
},
dismiss(sessionID: string, formID: string) {
setStore(
"session",
"form",
sessionID,
(store.session.form[sessionID] ?? []).filter((form) => form.id !== formID),
)
},
async refresh(sessionID: string, ref?: LocationRef) {
if (sessionID === "global") {
const response = await sdk.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) })
+1 -1
View File
@@ -105,7 +105,7 @@ export function Home() {
paddingRight={2}
>
<box width="100%">
<FormPrompt form={form} />
<FormPrompt form={form} onDismiss={() => data.session.form.dismiss(form.sessionID, form.id)} />
</box>
</box>
) : null
+16 -7
View File
@@ -4,7 +4,13 @@ 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 {
isFormAlreadySettledError,
isFormNotFoundError,
isSessionNotFoundError,
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 +150,7 @@ function requestOptions(form: FormWithLocation) {
}
}
export function FormPrompt(props: { form: FormWithLocation }) {
export function FormPrompt(props: { form: FormWithLocation; onDismiss: () => void }) {
const sdk = useSDK()
const { theme } = useTheme()
const renderer = useRenderer()
@@ -478,7 +484,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}
function cancel() {
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
props.onDismiss()
void sdk.api.form
.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
.catch((error) => {
if (isFormNotFoundError(error) || isFormAlreadySettledError(error) || isSessionNotFoundError(error)) return
toast.error(error)
})
}
function openExternal() {
@@ -581,10 +593,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
group: "Form",
cmd: () => {
if (textual()) {
void sdk.api.form.cancel(
{ sessionID: props.form.sessionID, formID: props.form.id },
requestOptions(props.form),
)
cancel()
return
}
setStore("editing", false)
+6 -1
View File
@@ -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}
onDismiss={() => data.session.form.dismiss(form.sessionID, form.id)}
/>
) : null
}}
</Show>
</Match>
+14 -3
View File
@@ -1736,9 +1736,20 @@ test("adds, dismisses, and refreshes form requests", async () => {
})
await wait(() => data.session.form.list("ses_1")?.length === 1)
data.session.form.dismiss("ses_1", "frm_1")
await wait(() => data.session.form.list("ses_1")?.length === 0)
emitEvent(events, {
id: "evt_form_created_after_local_dismiss",
created: 2,
type: "form.created",
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", fields: formFields } },
})
await wait(() => data.session.form.list("ses_1")?.length === 1)
emitEvent(events, {
id: "evt_form_replied_1",
created: 2,
created: 3,
type: "form.replied",
data: { sessionID: "ses_1", id: "frm_1", answer: {} },
})
@@ -1746,13 +1757,13 @@ test("adds, dismisses, and refreshes form requests", async () => {
emitEvent(events, {
id: "evt_form_created_2",
created: 3,
created: 4,
type: "form.created",
data: { form: { id: "frm_2", sessionID: "ses_1", title: "Input requested", fields: formFields } },
})
emitEvent(events, {
id: "evt_form_cancelled_2",
created: 4,
created: 5,
type: "form.cancelled",
data: { sessionID: "ses_1", id: "frm_2" },
})
+34 -9
View File
@@ -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"
@@ -24,16 +24,25 @@ async function mountForm(root: string, width = 80) {
await Bun.write(path.join(state, "kv.json"), "{}")
const replies: unknown[] = []
const cancellations: 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)
return new Response(null, { status: 204 })
})
: undefined,
url.pathname === "/api/session/ses_test/form/frm_test/cancel"
? (() => {
cancellations.push("frm_test")
return Response.json(
{ _tag: "FormNotFoundError", id: "frm_test", message: "Form not found: frm_test" },
{ status: 404 },
)
})()
: 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()
@@ -57,6 +66,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
@@ -81,7 +91,9 @@ 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} onDismiss={() => setVisible(false)} />
</Show>
</ToastProvider>
</ThemeProvider>
</KVProvider>
@@ -96,9 +108,22 @@ 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, cancellations, copied, replies }
}
test("dismisses locally when the server no longer has the form", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path)
try {
prompt.app.mockInput.pressEscape()
await prompt.app.waitForFrame((frame) => !frame.includes("Authorization required"))
await prompt.app.waitFor(() => prompt.cancellations.length === 1)
expect(prompt.cancellations).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)