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

This commit is contained in:
Aiden Cline 2026-07-08 17:25:34 -05:00 committed by GitHub
commit b44a981eef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 493 additions and 93 deletions

View file

@ -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>
)

View file

@ -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}>

View file

@ -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>