From b44a981eef29a6dec452a203585bc681cab85bed Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:25:34 -0500 Subject: [PATCH] feat(core): global form support (#35959) --- packages/client/src/effect/api/api.ts | 2 +- .../client/src/effect/generated/client.ts | 2 +- .../client/src/promise/generated/types.ts | 34 +-- packages/core/src/tool/question.ts | 1 + packages/core/test/form.test.ts | 10 +- packages/core/test/tool-question.test.ts | 2 + .../test/cli/run/noninteractive.test.ts | 2 +- packages/schema/src/form.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 16 +- packages/tui/src/component/prompt/index.tsx | 7 +- packages/tui/src/context/data.tsx | 62 ++++- .../feature-plugins/system/notifications.ts | 19 +- packages/tui/src/routes/home.tsx | 32 ++- packages/tui/src/routes/session/form.tsx | 86 ++++-- packages/tui/src/routes/session/index.tsx | 27 +- .../test/cli/cmd/tui/notifications.test.ts | 37 ++- packages/tui/test/cli/tui/data.test.tsx | 245 +++++++++++++++++- 17 files changed, 493 insertions(+), 93 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 0c7b7a3af38..64d11eefaa2 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -526,7 +526,7 @@ type Endpoint13_2Request = Parameters | null readonly url?: string | null }["id"] - readonly title?: { + readonly title: { readonly id?: string | null - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" | "url" readonly fields?: ReadonlyArray< @@ -3013,7 +3013,7 @@ export type FormCreateInput = { }["title"] readonly metadata?: { readonly id?: string | null - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" | "url" readonly fields?: ReadonlyArray< @@ -3110,7 +3110,7 @@ export type FormCreateInput = { }["metadata"] readonly mode: { readonly id?: string | null - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" | "url" readonly fields?: ReadonlyArray< @@ -3207,7 +3207,7 @@ export type FormCreateInput = { }["mode"] readonly fields?: { readonly id?: string | null - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" | "url" readonly fields?: ReadonlyArray< @@ -3304,7 +3304,7 @@ export type FormCreateInput = { }["fields"] readonly url?: { readonly id?: string | null - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" | "url" readonly fields?: ReadonlyArray< @@ -3406,7 +3406,7 @@ export type FormCreateOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" readonly fields: ReadonlyArray< @@ -3503,7 +3503,7 @@ export type FormCreateOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "url" readonly url: string @@ -3520,7 +3520,7 @@ export type FormGetOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" readonly fields: ReadonlyArray< @@ -3617,7 +3617,7 @@ export type FormGetOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "url" readonly url: string @@ -5339,7 +5339,7 @@ export type EventSubscribeOutput = | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: unknown } readonly mode: "form" readonly fields: ReadonlyArray< @@ -5436,7 +5436,7 @@ export type EventSubscribeOutput = | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: unknown } readonly mode: "url" readonly url: string diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index d5217f85058..646efd4b3b1 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -81,6 +81,7 @@ export const Plugin = { forms .ask({ sessionID: context.sessionID, + title: "Questions", metadata: { kind: "question", tool: { messageID: context.assistantMessageID, callID: context.toolCallID }, diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts index 8defff611fa..f5cf16e159d 100644 --- a/packages/core/test/form.test.ts +++ b/packages/core/test/form.test.ts @@ -14,6 +14,7 @@ const formID = Form.ID.create("frm_test") const input = { id: formID, sessionID: SessionSchema.ID.make("ses_test"), + title: "Test form", mode: "form", fields: [{ key: "name", type: "string", required: true }], } satisfies Form.CreateInput @@ -45,10 +46,12 @@ describe("Form", () => { const service = yield* Form.Service const created = yield* service.create({ sessionID: "global", + title: "MCP input", mode: "form", fields: [{ key: "name", type: "string", required: true }], }) expect(created.sessionID).toBe("global") + expect(created.title).toBe("MCP input") const owned = yield* service.list({ sessionID: "global" }) expect(owned.map((form) => form.id)).toEqual([created.id]) @@ -64,6 +67,7 @@ describe("Form", () => { const service = yield* Form.Service const created = yield* service.create({ sessionID: "global", + title: "Conditional form", mode: "form", fields: [ { key: "confirm", type: "boolean", required: true }, @@ -96,6 +100,7 @@ describe("Form", () => { ] const created = yield* service.create({ sessionID: "global", + title: "Multiselect form", mode: "form", fields: [ { key: "langs", type: "multiselect", options }, @@ -118,6 +123,7 @@ describe("Form", () => { const service = yield* Form.Service const created = yield* service.create({ sessionID: "global", + title: "Dependent form", mode: "form", fields: [ { key: "a", type: "boolean" }, @@ -160,6 +166,7 @@ describe("Form", () => { ] const created = yield* service.create({ sessionID: "global", + title: "Selection form", mode: "form", fields: [ { key: "langs", type: "multiselect", options }, @@ -191,6 +198,7 @@ describe("Form", () => { const service = yield* Form.Service const created = yield* service.create({ sessionID: "global", + title: "Cascading form", mode: "form", fields: [ { key: "a", type: "boolean" }, @@ -213,7 +221,7 @@ describe("Form", () => { Effect.gen(function* () { const service = yield* Form.Service const flipCreate = (fields: ReadonlyArray) => - service.create({ sessionID: "global", mode: "form", fields }).pipe(Effect.flip) + service.create({ sessionID: "global", title: "Invalid form", mode: "form", fields }).pipe(Effect.flip) expect( yield* flipCreate([ diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 7afb2158c87..63b6f3a912b 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -156,6 +156,7 @@ describe("QuestionTool", () => { expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }]) expect(capturedInput()).toEqual({ sessionID, + title: "Questions", metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } }, mode: "form", fields: [ @@ -202,6 +203,7 @@ describe("QuestionTool", () => { }) expect(capturedInput()).toEqual({ sessionID, + title: "Questions", metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } }, mode: "form", fields: [], diff --git a/packages/opencode/test/cli/run/noninteractive.test.ts b/packages/opencode/test/cli/run/noninteractive.test.ts index 377d6c582b4..1ed352333e4 100644 --- a/packages/opencode/test/cli/run/noninteractive.test.ts +++ b/packages/opencode/test/cli/run/noninteractive.test.ts @@ -10,7 +10,7 @@ function ok(data: T) { } function form(id: string, sessionID: string): FormInfo { - return { id, sessionID, mode: "form", fields: [] } + return { id, sessionID, title: "Input requested", mode: "form", fields: [] } } function formCreated(info: FormInfo): V2Event { diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts index df9d455f139..98f863f7fa5 100644 --- a/packages/schema/src/form.ts +++ b/packages/schema/src/form.ts @@ -106,7 +106,7 @@ const InfoBase = { // elicitations can be attributed to real sessions, revert this to SessionID. Do not rely // on non-session owners anywhere else. sessionID: Schema.String, - title: Schema.String.pipe(optional), + title: Schema.String, metadata: Metadata.pipe(optional), } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0edaac8a7c1..87d7ae6b9c4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3556,7 +3556,7 @@ export type FormMultiselectField = { export type FormFormInfo = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata mode: "form" fields: Array @@ -3565,7 +3565,7 @@ export type FormFormInfo = { export type FormUrlInfo = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata mode: "url" url: string @@ -5832,7 +5832,7 @@ export type ProjectCurrent = { export type FormCreatePayload = { id?: string - title?: string + title: string metadata?: FormMetadata mode: "form" | "url" fields?: Array @@ -9878,7 +9878,7 @@ export type FormMultiselectFieldV2 = { export type FormFormInfoV2 = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata mode: "form" fields: Array @@ -9887,7 +9887,7 @@ export type FormFormInfoV2 = { export type FormUrlInfoV2 = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata mode: "url" url: string @@ -9895,7 +9895,7 @@ export type FormUrlInfoV2 = { export type FormCreatePayloadV2 = { id?: string | null - title?: string + title: string metadata?: FormMetadata mode: "form" | "url" fields?: Array< @@ -10613,7 +10613,7 @@ export type FormMultiselectField1 = { export type FormFormInfo1 = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata1 mode: "form" fields: Array @@ -10622,7 +10622,7 @@ export type FormFormInfo1 = { export type FormUrlInfo1 = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata1 mode: "url" url: string diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0fcbfa96d58..aea91edd6fb 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -629,7 +629,7 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - if (props.visible === false || dialog.stack.length > 0) { + if (props.visible === false || props.disabled || dialog.stack.length > 0) { if (input.focused) input.blur() return } @@ -1452,7 +1452,10 @@ export function Prompt(props: PromptProps) { input.cursorColor = theme.text }, 0) }} - onMouseDown={(r: MouseEvent) => r.target?.focus()} + onMouseDown={(r: MouseEvent) => { + if (props.disabled) return + r.target?.focus() + }} focusedBackgroundColor={theme.backgroundElement} cursorColor={props.disabled ? theme.backgroundElement : theme.text} syntaxStyle={syntax()} diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 8ac669492a0..4249df8fa29 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -35,7 +35,10 @@ export type DataSessionStatus = "idle" | "running" const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") -export type FormInfo = FormFormInfo | FormUrlInfo +// Global MCP elicitations temporarily use "global" instead of a real session ID, so the +// server cannot recover their Location when settling them. Preserve the event Location +// until MCP elicitations carry session ownership. +export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef } type LocationData = { agent?: AgentInfo[] @@ -62,7 +65,7 @@ type Data = { message: Record input: 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: { @@ -734,7 +737,11 @@ 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.sessionID === "global" + ? { ...event.data.form, location: event.location } + : event.data.form, + ), ]) break case "form.replied": @@ -849,10 +856,31 @@ 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 response = await sdk.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) }) + const location = { + directory: response.location.directory, + workspaceID: response.location.workspaceID, + } + const key = locationKey(location) + setStore("session", "form", sessionID, [ + ...(store.session.form[sessionID] ?? []).filter( + (form) => form.location && locationKey(form.location) !== key, + ), + ...mutable( + response.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })), + ), + ]) + return + } setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID }))) }, }, @@ -1009,10 +1037,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "permission", reconcile(permissions)) }), sdk.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { + const location = { + directory: response.location.directory, + workspaceID: response.location.workspaceID, + } const forms = mutable(response.data).reduce>( (result, form) => ({ ...result, - [form.sessionID]: [...(result[form.sessionID] ?? []), form], + [form.sessionID]: [ + ...(result[form.sessionID] ?? []), + form.sessionID === "global" ? { ...form, location } : form, + ], }), {}, ) @@ -1029,9 +1064,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.skill.refresh(), result.shell.refresh(), ]) - .then((settled) => { + .then(async (settled) => { for (const failure of settled.filter((item) => item.status === "rejected")) console.error("Failed to refresh default location data", failure.reason) + const key = locationKey(defaultLocation()) + const locations = new Map( + Object.values(store.session.info).map((session) => [locationKey(session.location), session.location] as const), + ) + const refreshed = await Promise.allSettled( + Array.from(locations) + .filter(([location]) => location !== key) + .map(([, location]) => result.session.form.refresh("global", location)), + ) + for (const failure of refreshed.filter((item) => item.status === "rejected")) + console.error("Failed to refresh global forms", failure.reason) }) .finally(() => { bootstrapping = undefined diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index b8f2727bae2..3933b20c54a 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -6,11 +6,17 @@ const id = "internal:notifications" type SessionError = Extract["data"]["error"] -function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) { +function notify( + api: TuiPluginApi, + sessionID: string | undefined, + message: string, + sound: TuiAttentionSoundName, + title?: string, +) { const session = sessionID ? api.state.session.get(sessionID) : undefined const isSubagent = session?.parentID !== undefined void api.attention.notify({ - title: session?.title, + title: title ?? session?.title, message, notification: isSubagent ? false : { when: "blurred" }, sound: { name: sound, when: "always" }, @@ -34,10 +40,15 @@ 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") + notify( + api, + event.data.form.sessionID, + "Input needs response", + "question", + event.data.form.title, + ) }) api.event.on("form.replied", (event) => { diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 1d5ba75c97a..459dbf944e5 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -1,5 +1,5 @@ import { Prompt, type PromptRef } from "../component/prompt" -import { createEffect, createMemo, createSignal, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { Logo } from "../component/logo" import { useSync } from "../context/sync" import { Toast } from "../ui/toast" @@ -14,6 +14,7 @@ import { useTuiConfig } from "../config" import { HomeSessionDestinationProvider } from "./home/session-destination" import { useData } from "../context/data" import { LocationProvider } from "../context/location" +import { FormPrompt } from "./session/form" let once = false const placeholder = { @@ -33,6 +34,8 @@ export function Home() { const dimensions = useTerminalDimensions() const tuiConfig = useTuiConfig() const data = useData() + // Global MCP elicitations can arrive without a session route, so keep them reachable from Home. + const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? []) const promptMaxWidth = createMemo(() => { const configured = tuiConfig.prompt?.max_width if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7)) @@ -84,7 +87,12 @@ export function Home() { - } placeholders={placeholder} /> + } + placeholders={placeholder} + disabled={forms().length > 0} + /> @@ -94,6 +102,26 @@ export function Home() { + + {(_) => { + const form = forms()[0] + return form ? ( + + + + + + ) : null + }} + ) diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index e6f7a4818c8..5f143370cef 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.sessionID !== "global" || !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), + ) }, }, ], @@ -186,7 +202,7 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { customBorderChars={SplitBorder.customBorderChars} > - {props.form.title ?? "Input requested"} + {props.form.title} {message()} @@ -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"), @@ -732,11 +766,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { customBorderChars={SplitBorder.customBorderChars} > - - - {props.form.title} - - + + {props.form.title} + diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index dc619fc6693..d809061f5ff 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -185,8 +185,11 @@ export function Session() { ) }) const forms = createMemo(() => { - if (session()?.parentID) return [] - return data.session.form.list(route.sessionID) ?? [] + const global = data.session.form.list("global", location()) ?? [] + if (session()?.parentID) return global + return [route.sessionID, ...descendantSessionIDs()] + .flatMap((sessionID) => data.session.form.list(sessionID) ?? []) + .concat(global) }) const [composer, setComposer] = createStore({ open: false, @@ -239,7 +242,12 @@ export function Session() { createEffect( on(descendantSessionIDs, (sessionIDs) => { - void Promise.all(sessionIDs.map((sessionID) => data.session.permission.refresh(sessionID))) + void Promise.all( + sessionIDs.flatMap((sessionID) => [ + data.session.permission.refresh(sessionID), + data.session.form.refresh(sessionID), + ]), + ) }), ) @@ -261,6 +269,15 @@ export function Session() { navigate({ type: "home" }) return } + void data.session.form + .refresh("global", info.location) + .catch((error) => + toast.show({ + message: `Failed to refresh global forms: ${errorMessage(error)}`, + variant: "error", + duration: 5000, + }), + ) project.workspace.set(info.location.workspaceID) editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) @@ -957,12 +974,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 ac3abc95815..adacd106b91 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -77,6 +77,7 @@ function form(id: string, sessionID = "session"): Extract { test("notifies for form, question, and permission requests with blurred notifications and always-on sounds", async () => { const harness = await setup() - harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1") } }) + harness.emit({ + id: "event-1", + created: 0, + type: "form.created", + data: { form: { ...form("form-1"), title: "Confirm deployment" } }, + }) harness.emit({ id: "event-2", created: 0, type: "question.asked", data: question("question-1") }) harness.emit({ id: "event-3", created: 0, type: "permission.asked", data: permission("permission-1") }) - expect(harness.notifications).toEqual([formNotification, questionNotification, permissionNotification]) + expect(harness.notifications).toEqual([titledFormNotification, 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") } }) + harness.emit({ + id: "event-1", + created: 0, + type: "form.created", + data: { form: { ...form("form-1", "global"), title: "demo-mcp is requesting input" } }, + }) - expect(harness.notifications).toEqual([]) + expect(harness.notifications).toEqual([globalFormNotification]) }) test("dedupes pending forms, questions, and permissions until they are resolved", async () => { @@ -243,14 +264,14 @@ describe("internal notifications TUI plugin", () => { id: "event-1", created: 0, type: "form.created", - data: { form: form("form-1", "subagent") }, + data: { form: { ...form("form-1", "subagent"), title: "Questions" } }, }) harness.emit(executionStarted("event-2", "subagent")) harness.emit(executionSucceeded("event-3", "subagent")) expect(harness.notifications).toEqual([ { - title: "Subagent session", + title: "Questions", message: "Input needs response", notification: false, sound: { name: "question", when: "always" }, diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index e0612bb059c..d6648ac2f61 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1485,7 +1485,9 @@ test("adds, dismisses, and refreshes form requests", async () => { const events = createEventStream() const calls = createFetch((url) => { if (url.pathname !== "/api/session/ses_1/form") return - return json({ data: [{ id: "frm_remote", sessionID: "ses_1", mode: "form", fields: [] }] }) + return json({ + data: [{ id: "frm_remote", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] }], + }) }, events) let data!: ReturnType let sdk!: ReturnType @@ -1514,13 +1516,13 @@ test("adds, dismisses, and refreshes form requests", async () => { id: "evt_form_created_1", created: 0, type: "form.created", - data: { form: { id: "frm_1", sessionID: "ses_1", mode: "form", fields: [] } }, + data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } }, }) emitEvent(events, { id: "evt_form_created_duplicate", created: 1, type: "form.created", - data: { form: { id: "frm_1", sessionID: "ses_1", mode: "form", fields: [] } }, + data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } }, }) await wait(() => data.session.form.list("ses_1")?.length === 1) @@ -1536,7 +1538,7 @@ test("adds, dismisses, and refreshes form requests", async () => { id: "evt_form_created_2", created: 3, type: "form.created", - data: { form: { id: "frm_2", sessionID: "ses_1", mode: "form", fields: [] } }, + data: { form: { id: "frm_2", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } }, }) emitEvent(events, { id: "evt_form_cancelled_2", @@ -1553,11 +1555,238 @@ 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 + let sdk!: ReturnType + + function Probe() { + data = useData() + sdk = useSDK() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => sdk.connection.status() === "connected") + events.emit({ + id: "evt_form_created_global_other", + created: 0, + location: other, + type: "form.created", + data: { + form: { id: "frm_other", sessionID: "global", title: "Input requested", 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", title: "Input requested", mode: "form", fields: [] }, + }, + }) + await wait(() => data.session.form.list("global", { directory })?.length === 1) + + events.emit({ + id: "evt_form_replied_global_other", + 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", + title: "Input requested", + mode: "form", + fields: [], + }, + ], + }) + }, events) + let data!: ReturnType + let sdk!: ReturnType + + function Probe() { + data = useData() + sdk = useSDK() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => sdk.connection.status() === "connected" && requests.length > 0) + requests.length = 0 + + 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 counts = new Map() + const home = { directory: process.cwd() } + const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" } + const calls = createFetch((url) => { + if (url.pathname === "/api/location") + return json({ ...home, project: { id: "proj_test", directory: home.directory } }) + if (url.pathname === "/api/session") + return json({ + data: [ + { id: "ses_default", title: "Default", location: home, 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]") ?? home.directory + const requestedWorkspace = url.searchParams.get("location[workspace]") ?? undefined + const count = (counts.get(requestedDirectory) ?? 0) + 1 + counts.set(requestedDirectory, count) + return json({ + location: { + directory: requestedDirectory, + workspaceID: requestedWorkspace, + project: { id: "proj_test", directory: requestedDirectory }, + }, + data: [ + { + id: `frm_${requestedDirectory === other.directory ? "other" : "default"}_${count}`, + sessionID: "global", + title: "Input requested", + mode: "form", + fields: [], + }, + ], + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait( + () => + data.session.form.list("global", home)?.[0]?.id === "frm_default_1" && + data.session.form.list("global", other)?.[0]?.id === "frm_other_1", + ) + expect(requests).toHaveLength(2) + requests.length = 0 + + events.disconnect() + + await wait( + () => + data.session.form.list("global", home)?.[0]?.id === "frm_default_2" && + data.session.form.list("global", other)?.[0]?.id === "frm_other_2", + 4000, + ) + expect(requests).toHaveLength(2) + expect( + requests.map((url) => [ + url.searchParams.get("location[directory]") ?? directory, + url.searchParams.get("location[workspace]") ?? undefined, + ]), + ).toEqual([ + [home.directory, undefined], + [other.directory, other.workspaceID], + ]) + } finally { + app.renderer.destroy() + } +}) + test("reconciles all pending form requests when the event stream reconnects", async () => { const events = createEventStream() let requests = [ - { id: "frm_old", sessionID: "ses_old", mode: "form" as const, fields: [] }, - { id: "frm_keep", sessionID: "ses_keep", mode: "url" as const, url: "https://example.com" }, + { id: "frm_old", sessionID: "ses_old", title: "Input requested", mode: "form" as const, fields: [] }, + { + id: "frm_keep", + sessionID: "ses_keep", + title: "Input requested", + mode: "url" as const, + url: "https://example.com", + }, ] let calls = 0 const fetch = createFetch((url) => { @@ -1588,7 +1817,9 @@ test("reconciles all pending form requests when the event stream reconnects", as await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old") expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep") - requests = [{ id: "frm_new", sessionID: "ses_new", mode: "form" as const, fields: [] }] + requests = [ + { id: "frm_new", sessionID: "ses_new", title: "Input requested", mode: "form" as const, fields: [] }, + ] events.disconnect() await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")