From 684d288b9ce71f1cbedced3f1a9be4893e5e6390 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 09:43:38 -0500 Subject: [PATCH 1/4] fix(tui): scope global forms by location --- packages/tui/src/context/data.tsx | 42 ++++- packages/tui/src/routes/session/form.tsx | 76 ++++++--- packages/tui/src/routes/session/index.tsx | 7 +- packages/tui/test/cli/tui/data.test.tsx | 180 ++++++++++++++++++++++ packages/tui/test/fixture/tui-sdk.ts | 2 + 5 files changed, 277 insertions(+), 30 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index d9b29c1fdd80..45c0b2625ffa 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -30,7 +30,7 @@ export type DataSessionStatus = "idle" | "running" const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") -export type FormInfo = FormFormInfo | FormUrlInfo +export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef } type LocationData = { agent?: AgentV2Info[] @@ -56,7 +56,7 @@ type Data = { status: Record message: Record permission: Record - // Pending forms keyed by session ID. + // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel. form: Record } project: { @@ -592,7 +592,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break setStore("session", "form", event.data.form.sessionID, [ ...(store.session.form[event.data.form.sessionID] ?? []), - mutable(event.data.form), + mutable({ ...event.data.form, location: event.location }), ]) break case "form.replied": @@ -723,10 +723,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, form: { - list(sessionID: string) { - return store.session.form[sessionID] + list(sessionID: string, ref?: LocationRef) { + const forms = store.session.form[sessionID] + if (sessionID !== "global") return forms + if (!ref) return + const key = locationKey(ref) + return forms?.filter((form) => form.location && locationKey(form.location) === key) }, - async refresh(sessionID: string) { + async refresh(sessionID: string, ref?: LocationRef) { + if (sessionID === "global") { + const result = await sdk.api.form.listRequests({ location: locationQuery(ref ?? defaultLocation()) }) + const location = { directory: result.location.directory, workspaceID: result.location.workspaceID } + const key = locationKey(location) + setStore("session", "form", sessionID, [ + ...(store.session.form[sessionID] ?? []).filter( + (form) => !form.location || locationKey(form.location) !== key, + ), + ...mutable( + result.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })), + ), + ]) + return + } setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID }))) }, }, @@ -871,7 +889,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: defaultLocation().directory, workspace: defaultLocation().workspaceID, }) - .then((response) => { + .then(async (response) => { setStore( "session", "info", @@ -880,6 +898,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }), ) for (const session of response.data) registerSession(session.id) + await Promise.all( + Array.from( + new Map( + Object.values(store.session.info).map( + (session) => [locationKey(session.location), session.location] as const, + ), + ).values(), + (location) => result.session.form.refresh("global", location), + ), + ) }), result.location.refresh(), result.location.agent.refresh(), diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index e6f7a4818c83..685e6c58b9f2 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -130,6 +130,16 @@ function display(field: Field, value: FormValue | undefined) { return label(value) } +function requestOptions(form: FormInfo) { + if (!form.location) return undefined + return { + headers: { + "x-opencode-directory": encodeURIComponent(form.location.directory), + ...(form.location.workspaceID ? { "x-opencode-workspace": form.location.workspaceID } : {}), + }, + } +} + export function FormPrompt(props: { form: FormInfo }) { return props.form.mode === "url" ? : } @@ -154,7 +164,10 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { title: "Dismiss form", category: "Form", run() { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + void sdk.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) }, }, ], @@ -172,7 +185,10 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { desc: "Dismiss form", group: "Form", cmd: () => { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + void sdk.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) }, }, ], @@ -328,11 +344,14 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { function replySingle(field: Field, value: FormValue) { sdk.api.form - .reply({ - sessionID: props.form.sessionID, - formID: props.form.id, - answer: { [field.key]: value }, - }) + .reply( + { + sessionID: props.form.sessionID, + formID: props.form.id, + answer: { [field.key]: value }, + }, + requestOptions(props.form), + ) .catch((error: unknown) => { setStore( "error", @@ -532,7 +551,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { group: "Form", cmd: () => { if (textual()) { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + void sdk.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) return } setStore("editing", false) @@ -594,7 +616,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { title: "Dismiss form", category: "Form", run() { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + void sdk.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) }, }, ], @@ -638,16 +663,19 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { 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] - }), - ), - }) + .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", @@ -666,7 +694,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { desc: "Dismiss form", group: "Form", cmd: () => { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + 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) }, @@ -715,7 +746,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { desc: "Dismiss form", group: "Form", cmd: () => { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + void sdk.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) }, }, ...tuiConfig.keybinds.get("app.exit"), diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 93bffbeb7c33..683e7633d855 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -182,8 +182,10 @@ export function Session() { ) }) const forms = createMemo(() => { - if (session()?.parentID) return [] - return data.session.form.list(route.sessionID) ?? [] + return [ + ...(session()?.parentID ? [] : (data.session.form.list(route.sessionID) ?? [])), + ...(data.session.form.list("global", location()) ?? []), + ] }) const [composer, setComposer] = createStore({ open: false, @@ -258,6 +260,7 @@ export function Session() { navigate({ type: "home" }) return } + await data.session.form.refresh("global", info.location) project.workspace.set(info.location.workspaceID) editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 6c33f8496f04..5024bcbedfb8 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -877,6 +877,186 @@ test("adds, dismisses, and refreshes form requests", async () => { } }) +test("tracks global forms by location", async () => { + const events = createEventStream() + const calls = createFetch(undefined, events) + const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" } + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.connection.status() === "connected") + events.emit({ + id: "evt_form_created_global", + created: 0, + location: other, + type: "form.created", + data: { form: { id: "frm_global", sessionID: "global", mode: "form", fields: [] } }, + }) + + await wait(() => data.session.form.list("global", other)?.length === 1) + expect(data.session.form.list("global", { directory }) ?? []).toEqual([]) + + events.emit({ + id: "evt_form_replied_global", + created: 1, + location: other, + type: "form.replied", + data: { id: "frm_global", sessionID: "global", answer: {} }, + }) + await wait(() => data.session.form.list("global", other)?.length === 0) + } finally { + app.renderer.destroy() + } +}) + +test("refreshes global forms for the requested location", async () => { + const events = createEventStream() + const requests: URL[] = [] + const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" } + const calls = createFetch((url) => { + if (url.pathname !== "/api/form/request") return + requests.push(url) + const requestedDirectory = url.searchParams.get("location[directory]") ?? directory + const requestedWorkspace = url.searchParams.get("location[workspace]") ?? undefined + return json({ + location: { + directory: requestedDirectory, + workspaceID: requestedWorkspace, + project: { id: "proj_test", directory: requestedDirectory }, + }, + data: [ + { + id: requestedDirectory === other.directory ? "frm_other" : "frm_default", + sessionID: "global", + mode: "form", + fields: [], + }, + ], + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.connection.status() === "connected") + await data.session.form.refresh("global", { directory }) + await data.session.form.refresh("global", other) + + expect(requests).toHaveLength(2) + expect(requests[1]?.searchParams.get("location[directory]")).toBe(other.directory) + expect(requests[1]?.searchParams.get("location[workspace]")).toBe(other.workspaceID) + expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual(["frm_other"]) + expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"]) + } finally { + app.renderer.destroy() + } +}) + +test("refreshes global forms once per loaded location after reconnect", async () => { + const events = createEventStream() + const requests: URL[] = [] + const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" } + const calls = createFetch((url) => { + if (url.pathname === "/api/session" && url.searchParams.has("parentID")) return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session") + return json({ + data: [ + { id: "ses_default", title: "Default", location: { directory }, time: { created: 0, updated: 0 } }, + { id: "ses_other_1", title: "Other one", location: other, time: { created: 0, updated: 0 } }, + { id: "ses_other_2", title: "Other two", location: other, time: { created: 0, updated: 0 } }, + ], + cursor: {}, + }) + if (url.pathname !== "/api/form/request") return + requests.push(url) + const requestedDirectory = url.searchParams.get("location[directory]") ?? directory + const requestedWorkspace = url.searchParams.get("location[workspace]") ?? undefined + return json({ + location: { + directory: requestedDirectory, + workspaceID: requestedWorkspace, + project: { id: "proj_test", directory: requestedDirectory }, + }, + data: [], + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => requests.length === 2) + await Bun.sleep(20) + requests.length = 0 + + events.disconnect() + + await wait(() => requests.length === 2, 4000) + await Bun.sleep(20) + expect(requests).toHaveLength(2) + expect( + requests.map((url) => [ + url.searchParams.get("location[directory]") ?? directory, + url.searchParams.get("location[workspace]") ?? undefined, + ]), + ).toEqual([ + [directory, undefined], + [other.directory, other.workspaceID], + ]) + expect(data.connection.status()).toBe("connected") + } finally { + app.renderer.destroy() + } +}) + test("settles pending tools when a live failure arrives", async () => { const events = createEventStream() const calls = createFetch((url) => { diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 5ebe0a63f49d..8f6a3511227c 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -99,6 +99,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType Date: Sun, 5 Jul 2026 22:47:20 -0500 Subject: [PATCH 2/4] fix(tui): preserve global form ownership --- packages/tui/src/context/data.tsx | 21 +++++++++++++++++-- .../feature-plugins/system/notifications.ts | 17 ++++++++++----- packages/tui/src/routes/session/index.tsx | 12 ++++++++--- .../test/cli/cmd/tui/notifications.test.ts | 9 ++++++-- packages/tui/test/cli/tui/data.test.tsx | 12 ++++++++++- 5 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 45c0b2625ffa..a8b91548c56d 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -69,6 +69,12 @@ function locationKey(location: LocationRef) { return JSON.stringify([location.directory, location.workspaceID]) } +function matchesGlobalForm(form: FormInfo, id: string, location?: LocationRef) { + if (form.id !== id) return false + if (!form.location || !location) return true + return locationKey(form.location) === locationKey(location) +} + function locationQuery(ref?: LocationRef) { return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined } @@ -589,7 +595,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) break case "form.created": - if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break + if ( + store.session.form[event.data.form.sessionID]?.some((form) => + event.data.form.sessionID === "global" + ? matchesGlobalForm(form, event.data.form.id, event.location) + : form.id === event.data.form.id, + ) + ) + break setStore("session", "form", event.data.form.sessionID, [ ...(store.session.form[event.data.form.sessionID] ?? []), mutable({ ...event.data.form, location: event.location }), @@ -601,7 +614,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ "session", "form", event.data.sessionID, - (store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id), + (store.session.form[event.data.sessionID] ?? []).filter((form) => + event.data.sessionID === "global" + ? !matchesGlobalForm(form, event.data.id, event.location) + : form.id !== event.data.id, + ), ) break case "shell.created": diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index a5033b62e0bd..ce39c916eed0 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -26,6 +26,13 @@ function sessionErrorMessage(error: SessionError) { return "Session error" } +function formKey( + event: Extract, + id: string, +) { + return JSON.stringify([event.location?.directory, event.location?.workspaceID, id]) +} + const tui: TuiPlugin = async (api) => { const active = new Set() const errored = new Set() @@ -34,18 +41,18 @@ const tui: TuiPlugin = async (api) => { const permissions = new Set() api.event.on("form.created", (event) => { - if (event.data.form.sessionID === "global") return - if (forms.has(event.data.form.id)) return - forms.add(event.data.form.id) + const key = formKey(event, event.data.form.id) + if (forms.has(key)) return + forms.add(key) notify(api, event.data.form.sessionID, "Input needs response", "question") }) api.event.on("form.replied", (event) => { - forms.delete(event.data.id) + forms.delete(formKey(event, event.data.id)) }) api.event.on("form.cancelled", (event) => { - forms.delete(event.data.id) + forms.delete(formKey(event, event.data.id)) }) api.event.on("question.asked", (event) => { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 683e7633d855..6a5919f5e1d9 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -187,6 +187,11 @@ export function Session() { ...(data.session.form.list("global", location()) ?? []), ] }) + const formKey = createMemo(() => { + const form = forms()[0] + if (!form) return + return JSON.stringify([form.location?.directory, form.location?.workspaceID, form.id]) + }) const [composer, setComposer] = createStore({ open: false, tab: undefined as string | undefined, @@ -261,6 +266,7 @@ export function Session() { return } await data.session.form.refresh("global", info.location) + if (route.sessionID !== sessionID) return project.workspace.set(info.location.workspaceID) editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) @@ -932,17 +938,17 @@ export function Session() { setComposer("open", false)} /> - {null} + {null} 0}> 0}> - + {(_) => { const form = forms()[0] return form ? : null diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 0014292d23e6..c8e8668da6dc 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -155,6 +155,11 @@ const formNotification: TuiAttentionNotifyInput = { sound: { name: "question", when: "always" }, } +const globalFormNotification: TuiAttentionNotifyInput = { + ...formNotification, + title: undefined, +} + const permissionNotification: TuiAttentionNotifyInput = { title: "Demo session", message: "Permission needs input", @@ -173,12 +178,12 @@ describe("internal notifications TUI plugin", () => { expect(harness.notifications).toEqual([formNotification, questionNotification, permissionNotification]) }) - test("ignores global forms until the TUI can render them", async () => { + test("notifies for global forms once the TUI can render them", async () => { const harness = await setup() harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1", "global") } }) - expect(harness.notifications).toEqual([]) + expect(harness.notifications).toEqual([globalFormNotification]) }) test("dedupes pending forms, questions, and permissions until they are resolved", async () => { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 5024bcbedfb8..f79fcf9f9e44 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -914,13 +914,23 @@ test("tracks global forms by location", async () => { expect(data.session.form.list("global", { directory }) ?? []).toEqual([]) events.emit({ - id: "evt_form_replied_global", + id: "evt_form_created_global_default", created: 1, + location: { directory }, + type: "form.created", + data: { form: { id: "frm_global", sessionID: "global", mode: "form", fields: [] } }, + }) + await wait(() => data.session.form.list("global", { directory })?.length === 1) + + events.emit({ + id: "evt_form_replied_global", + created: 2, location: other, type: "form.replied", data: { id: "frm_global", sessionID: "global", answer: {} }, }) await wait(() => data.session.form.list("global", other)?.length === 0) + expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_global"]) } finally { app.renderer.destroy() } From 96bfd4912d1d64e51f2d76c044924442bba4e738 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 23:16:56 -0500 Subject: [PATCH 3/4] refactor(tui): simplify global form identity --- packages/tui/src/context/data.tsx | 23 +++---------------- .../feature-plugins/system/notifications.ts | 16 ++++--------- packages/tui/src/routes/session/index.tsx | 9 ++------ packages/tui/test/cli/tui/data.test.tsx | 8 +++---- 4 files changed, 13 insertions(+), 43 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index a8b91548c56d..a54b4af31042 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -69,12 +69,6 @@ function locationKey(location: LocationRef) { return JSON.stringify([location.directory, location.workspaceID]) } -function matchesGlobalForm(form: FormInfo, id: string, location?: LocationRef) { - if (form.id !== id) return false - if (!form.location || !location) return true - return locationKey(form.location) === locationKey(location) -} - function locationQuery(ref?: LocationRef) { return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined } @@ -595,14 +589,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) break case "form.created": - if ( - store.session.form[event.data.form.sessionID]?.some((form) => - event.data.form.sessionID === "global" - ? matchesGlobalForm(form, event.data.form.id, event.location) - : form.id === event.data.form.id, - ) - ) - break + if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break setStore("session", "form", event.data.form.sessionID, [ ...(store.session.form[event.data.form.sessionID] ?? []), mutable({ ...event.data.form, location: event.location }), @@ -614,11 +601,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ "session", "form", event.data.sessionID, - (store.session.form[event.data.sessionID] ?? []).filter((form) => - event.data.sessionID === "global" - ? !matchesGlobalForm(form, event.data.id, event.location) - : form.id !== event.data.id, - ), + (store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id), ) break case "shell.created": @@ -754,7 +737,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const key = locationKey(location) setStore("session", "form", sessionID, [ ...(store.session.form[sessionID] ?? []).filter( - (form) => !form.location || locationKey(form.location) !== key, + (form) => form.location && locationKey(form.location) !== key, ), ...mutable( result.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })), diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index ce39c916eed0..36f08b1c84b0 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -26,13 +26,6 @@ function sessionErrorMessage(error: SessionError) { return "Session error" } -function formKey( - event: Extract, - id: string, -) { - return JSON.stringify([event.location?.directory, event.location?.workspaceID, id]) -} - const tui: TuiPlugin = async (api) => { const active = new Set() const errored = new Set() @@ -41,18 +34,17 @@ const tui: TuiPlugin = async (api) => { const permissions = new Set() api.event.on("form.created", (event) => { - const key = formKey(event, event.data.form.id) - if (forms.has(key)) return - forms.add(key) + if (forms.has(event.data.form.id)) return + forms.add(event.data.form.id) notify(api, event.data.form.sessionID, "Input needs response", "question") }) api.event.on("form.replied", (event) => { - forms.delete(formKey(event, event.data.id)) + forms.delete(event.data.id) }) api.event.on("form.cancelled", (event) => { - forms.delete(formKey(event, event.data.id)) + forms.delete(event.data.id) }) api.event.on("question.asked", (event) => { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6a5919f5e1d9..1bce73771493 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -187,11 +187,6 @@ export function Session() { ...(data.session.form.list("global", location()) ?? []), ] }) - const formKey = createMemo(() => { - const form = forms()[0] - if (!form) return - return JSON.stringify([form.location?.directory, form.location?.workspaceID, form.id]) - }) const [composer, setComposer] = createStore({ open: false, tab: undefined as string | undefined, @@ -269,7 +264,7 @@ export function Session() { if (route.sessionID !== sessionID) return project.workspace.set(info.location.workspaceID) editor.reconnect(info.location.directory) - if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + if (scroll) scroll.scrollBy(100_000) })().catch((error) => { if (route.sessionID !== sessionID) return toast.show({ @@ -948,7 +943,7 @@ export function Session() { 0}> - + {(_) => { const form = forms()[0] return form ? : null diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index f79fcf9f9e44..25143099d1bb 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -907,7 +907,7 @@ test("tracks global forms by location", async () => { created: 0, location: other, type: "form.created", - data: { form: { id: "frm_global", sessionID: "global", mode: "form", fields: [] } }, + data: { form: { id: "frm_other", sessionID: "global", mode: "form", fields: [] } }, }) await wait(() => data.session.form.list("global", other)?.length === 1) @@ -918,7 +918,7 @@ test("tracks global forms by location", async () => { created: 1, location: { directory }, type: "form.created", - data: { form: { id: "frm_global", sessionID: "global", mode: "form", fields: [] } }, + data: { form: { id: "frm_default", sessionID: "global", mode: "form", fields: [] } }, }) await wait(() => data.session.form.list("global", { directory })?.length === 1) @@ -927,10 +927,10 @@ test("tracks global forms by location", async () => { created: 2, location: other, type: "form.replied", - data: { id: "frm_global", sessionID: "global", answer: {} }, + data: { id: "frm_other", sessionID: "global", answer: {} }, }) await wait(() => data.session.form.list("global", other)?.length === 0) - expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_global"]) + expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"]) } finally { app.renderer.destroy() } From 1043615df289f45e4c7f59dabe82319622fca13b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 23:43:51 -0500 Subject: [PATCH 4/4] docs(tui): explain global form routing --- packages/tui/src/routes/session/form.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 685e6c58b9f2..de3729bd7e9e 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -132,6 +132,7 @@ function display(field: Field, value: FormValue | undefined) { function requestOptions(form: FormInfo) { if (!form.location) return undefined + // Global forms have no session row, so the server needs their location to select the owning Form.Service. return { headers: { "x-opencode-directory": encodeURIComponent(form.location.directory),