diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index d9b29c1fdd80..a54b4af31042 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/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index a5033b62e0bd..36f08b1c84b0 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -34,7 +34,6 @@ 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) notify(api, event.data.form.sessionID, "Input needs response", "question") diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index e6f7a4818c83..de3729bd7e9e 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -130,6 +130,17 @@ function display(field: Field, value: FormValue | undefined) { return label(value) } +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), + ...(form.location.workspaceID ? { "x-opencode-workspace": form.location.workspaceID } : {}), + }, + } +} + export function FormPrompt(props: { form: FormInfo }) { return props.form.mode === "url" ? : } @@ -154,7 +165,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 +186,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 +345,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 +552,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 +617,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 +664,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 +695,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 +747,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..1bce73771493 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,9 +260,11 @@ export function Session() { navigate({ type: "home" }) 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) + if (scroll) scroll.scrollBy(100_000) })().catch((error) => { if (route.sessionID !== sessionID) return toast.show({ @@ -929,12 +933,12 @@ export function Session() { setComposer("open", false)} /> - {null} + {null} 0}> 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 6c33f8496f04..25143099d1bb 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -877,6 +877,196 @@ 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_other", 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_created_global_default", + created: 1, + location: { directory }, + type: "form.created", + data: { form: { id: "frm_default", 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_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_default"]) + } 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