feat(core): global form support (#35959)
This commit is contained in:
parent
79415f625a
commit
b44a981eef
17 changed files with 493 additions and 93 deletions
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue