Bladeren bron

feat(core): global form support (#35959)

Aiden Cline 1 maand geleden
bovenliggende
commit
b44a981eef

+ 1 - 1
packages/client/src/effect/api/api.ts

@@ -526,7 +526,7 @@ type Endpoint13_2Request = Parameters<RawClient["server.form"]["session.form.cre
 export type Endpoint13_2Input = {
   readonly sessionID: Endpoint13_2Request["params"]["sessionID"]
   readonly id?: Endpoint13_2Request["payload"]["id"]
-  readonly title?: Endpoint13_2Request["payload"]["title"]
+  readonly title: Endpoint13_2Request["payload"]["title"]
   readonly metadata?: Endpoint13_2Request["payload"]["metadata"]
   readonly mode: Endpoint13_2Request["payload"]["mode"]
   readonly fields?: Endpoint13_2Request["payload"]["fields"]

+ 1 - 1
packages/client/src/effect/generated/client.ts

@@ -621,7 +621,7 @@ type Endpoint13_2Request = Parameters<RawClient["server.form"]["session.form.cre
 type Endpoint13_2Input = {
   readonly sessionID: Endpoint13_2Request["params"]["sessionID"]
   readonly id?: Endpoint13_2Request["payload"]["id"]
-  readonly title?: Endpoint13_2Request["payload"]["title"]
+  readonly title: Endpoint13_2Request["payload"]["title"]
   readonly metadata?: Endpoint13_2Request["payload"]["metadata"]
   readonly mode: Endpoint13_2Request["payload"]["mode"]
   readonly fields?: Endpoint13_2Request["payload"]["fields"]

+ 17 - 17
packages/client/src/promise/generated/types.ts

@@ -2598,7 +2598,7 @@ export type FormRequestListOutput = {
     | {
         readonly id: string
         readonly sessionID: string
-        readonly title?: string
+        readonly title: string
         readonly metadata?: { readonly [x: string]: JsonValue }
         readonly mode: "form"
         readonly fields: ReadonlyArray<
@@ -2695,7 +2695,7 @@ export type FormRequestListOutput = {
     | {
         readonly id: string
         readonly sessionID: string
-        readonly title?: string
+        readonly title: string
         readonly metadata?: { readonly [x: string]: JsonValue }
         readonly mode: "url"
         readonly url: string
@@ -2710,7 +2710,7 @@ export type FormListOutput = {
     | {
         readonly id: string
         readonly sessionID: string
-        readonly title?: string
+        readonly title: string
         readonly metadata?: { readonly [x: string]: JsonValue }
         readonly mode: "form"
         readonly fields: ReadonlyArray<
@@ -2807,7 +2807,7 @@ export type FormListOutput = {
     | {
         readonly id: string
         readonly sessionID: string
-        readonly title?: string
+        readonly title: string
         readonly metadata?: { readonly [x: string]: JsonValue }
         readonly mode: "url"
         readonly url: string
@@ -2819,7 +2819,7 @@ export type FormCreateInput = {
   readonly sessionID: { readonly sessionID: string }["sessionID"]
   readonly id?: {
     readonly id?: string | null
-    readonly title?: string
+    readonly title: string
     readonly metadata?: { readonly [x: string]: JsonValue }
     readonly mode: "form" | "url"
     readonly fields?: ReadonlyArray<
@@ -2914,9 +2914,9 @@ export type FormCreateInput = {
     > | 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

+ 1 - 0
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 },

+ 9 - 1
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<Form.Field>) =>
-        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([

+ 2 - 0
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: [],

+ 1 - 1
packages/opencode/test/cli/run/noninteractive.test.ts

@@ -10,7 +10,7 @@ function ok<T>(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 {

+ 1 - 1
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),
 }
 

+ 8 - 8
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<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
@@ -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<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
@@ -9878,7 +9878,7 @@ export type FormMultiselectFieldV2 = {
 export type FormFormInfoV2 = {
   id: string
   sessionID: string
-  title?: string
+  title: string
   metadata?: FormMetadata
   mode: "form"
   fields: Array<FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2>
@@ -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<FormStringField1 | FormNumberField1 | FormIntegerField1 | FormBooleanField1 | FormMultiselectField1>
@@ -10622,7 +10622,7 @@ export type FormFormInfo1 = {
 export type FormUrlInfo1 = {
   id: string
   sessionID: string
-  title?: string
+  title: string
   metadata?: FormMetadata1
   mode: "url"
   url: string

+ 5 - 2
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()}

+ 54 - 8
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<string, SessionMessageInfo[]>
     input: Record<string, string[]>
     permission: Record<string, PermissionV2Request[]>
-    // Pending forms keyed by session ID.
+    // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
     form: Record<string, FormInfo[]>
   }
   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<Record<string, FormInfo[]>>(
             (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

+ 15 - 4
packages/tui/src/feature-plugins/system/notifications.ts

@@ -6,11 +6,17 @@ const id = "internal:notifications"
 
 type SessionError = Extract<V2Event, { type: "session.error" }>["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<string>()
 
   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) => {

+ 30 - 2
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() {
           <box height={1} minHeight={0} flexShrink={1} />
           <box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
             <pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
-              <Prompt ref={bind} right={<pluginRuntime.Slot name="home_prompt_right" />} placeholders={placeholder} />
+              <Prompt
+                ref={bind}
+                right={<pluginRuntime.Slot name="home_prompt_right" />}
+                placeholders={placeholder}
+                disabled={forms().length > 0}
+              />
             </pluginRuntime.Slot>
           </box>
           <pluginRuntime.Slot name="home_bottom" />
@@ -94,6 +102,26 @@ export function Home() {
         <box width="100%" flexShrink={0}>
           <pluginRuntime.Slot name="home_footer" mode="single_winner" />
         </box>
+        <Show when={forms()[0]?.id} keyed>
+          {(_) => {
+            const form = forms()[0]
+            return form ? (
+              <box
+                position="absolute"
+                zIndex={2000}
+                left={0}
+                right={0}
+                bottom={1}
+                paddingLeft={2}
+                paddingRight={2}
+              >
+                <box width="100%">
+                  <FormPrompt form={form} />
+                </box>
+              </box>
+            ) : null
+          }}
+        </Show>
       </HomeSessionDestinationProvider>
     </LocationProvider>
   )

+ 59 - 27
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" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
 }
@@ -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}
     >
       <box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
-        <text fg={theme.text}>{props.form.title ?? "Input requested"}</text>
+        <text fg={theme.text}>{props.form.title}</text>
         <Show when={message()}>
           <text fg={theme.textMuted}>{message()}</text>
         </Show>
@@ -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}
     >
       <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
-        <Show when={props.form.title}>
-          <box paddingLeft={1}>
-            <text fg={theme.textMuted}>{props.form.title}</text>
-          </box>
-        </Show>
+        <box paddingLeft={1}>
+          <text fg={theme.textMuted}>{props.form.title}</text>
+        </box>
         <Show when={!single() && !tabbed()}>
           <box flexDirection="row" gap={1} paddingLeft={1}>
             <text fg={theme.textMuted}>

+ 22 - 5
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() {
               <box flexShrink={0}>
                 <Composer
                   sessionID={route.sessionID}
-                  open={composer.open || !!session()?.parentID}
+                  open={composer.open || (!!session()?.parentID && forms().length === 0)}
                   defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
                   onClose={() => setComposer("open", false)}
                 />
                 <Switch>
-                  <Match when={composer.open || !!session()?.parentID}>{null}</Match>
+                  <Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
                   <Match when={permissions().length > 0}>
                     <PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
                   </Match>

+ 29 - 8
packages/tui/test/cli/cmd/tui/notifications.test.ts

@@ -77,6 +77,7 @@ function form(id: string, sessionID = "session"): Extract<V2Event, { type: "form
   return {
     id,
     sessionID,
+    title: "Input requested",
     mode: "form",
     fields: [],
   }
@@ -138,12 +139,22 @@ const questionNotification: TuiAttentionNotifyInput = {
 }
 
 const formNotification: TuiAttentionNotifyInput = {
-  title: "Demo session",
+  title: "Input requested",
   message: "Input needs response",
   notification: { when: "blurred" },
   sound: { name: "question", when: "always" },
 }
 
+const titledFormNotification: TuiAttentionNotifyInput = {
+  ...formNotification,
+  title: "Confirm deployment",
+}
+
+const globalFormNotification: TuiAttentionNotifyInput = {
+  ...formNotification,
+  title: "demo-mcp is requesting input",
+}
+
 const permissionNotification: TuiAttentionNotifyInput = {
   title: "Demo session",
   message: "Permission needs input",
@@ -155,19 +166,29 @@ describe("internal notifications TUI plugin", () => {
   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" },

+ 238 - 7
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<typeof useData>
   let sdk!: ReturnType<typeof useSDK>
@@ -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<typeof useData>
+  let sdk!: ReturnType<typeof useSDK>
+
+  function Probe() {
+    data = useData()
+    sdk = useSDK()
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  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<typeof useData>
+  let sdk!: ReturnType<typeof useSDK>
+
+  function Probe() {
+    data = useData()
+    sdk = useSDK()
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  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<string, number>()
+  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<typeof useData>
+
+  function Probe() {
+    data = useData()
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  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")