Compare commits

...

4 Commits

Author SHA1 Message Date
Aiden Cline 1043615df2 docs(tui): explain global form routing 2026-07-05 23:43:51 -05:00
Aiden Cline 96bfd4912d refactor(tui): simplify global form identity 2026-07-05 23:16:56 -05:00
Aiden Cline 57dd2fba7d fix(tui): preserve global form ownership 2026-07-05 22:47:20 -05:00
Aiden Cline 684d288b9c fix(tui): scope global forms by location 2026-07-05 22:36:04 -05:00
7 changed files with 299 additions and 36 deletions
+35 -7
View File
@@ -30,7 +30,7 @@ export type DataSessionStatus = "idle" | "running"
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
export type FormInfo = FormFormInfo | FormUrlInfo export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef }
type LocationData = { type LocationData = {
agent?: AgentV2Info[] agent?: AgentV2Info[]
@@ -56,7 +56,7 @@ type Data = {
status: Record<string, DataSessionStatus> status: Record<string, DataSessionStatus>
message: Record<string, SessionMessage[]> message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]> 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[]> form: Record<string, FormInfo[]>
} }
project: { project: {
@@ -592,7 +592,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
setStore("session", "form", event.data.form.sessionID, [ setStore("session", "form", event.data.form.sessionID, [
...(store.session.form[event.data.form.sessionID] ?? []), ...(store.session.form[event.data.form.sessionID] ?? []),
mutable(event.data.form), mutable({ ...event.data.form, location: event.location }),
]) ])
break break
case "form.replied": case "form.replied":
@@ -723,10 +723,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}, },
}, },
form: { form: {
list(sessionID: string) { list(sessionID: string, ref?: LocationRef) {
return store.session.form[sessionID] const forms = store.session.form[sessionID]
if (sessionID !== "global") return forms
if (!ref) return
const key = locationKey(ref)
return forms?.filter((form) => form.location && locationKey(form.location) === key)
}, },
async refresh(sessionID: string) { async refresh(sessionID: string, ref?: LocationRef) {
if (sessionID === "global") {
const result = await sdk.api.form.listRequests({ location: locationQuery(ref ?? defaultLocation()) })
const location = { directory: result.location.directory, workspaceID: result.location.workspaceID }
const key = locationKey(location)
setStore("session", "form", sessionID, [
...(store.session.form[sessionID] ?? []).filter(
(form) => form.location && locationKey(form.location) !== key,
),
...mutable(
result.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })),
),
])
return
}
setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID }))) setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID })))
}, },
}, },
@@ -871,7 +889,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: defaultLocation().directory, directory: defaultLocation().directory,
workspace: defaultLocation().workspaceID, workspace: defaultLocation().workspaceID,
}) })
.then((response) => { .then(async (response) => {
setStore( setStore(
"session", "session",
"info", "info",
@@ -880,6 +898,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}), }),
) )
for (const session of response.data) registerSession(session.id) for (const session of response.data) registerSession(session.id)
await Promise.all(
Array.from(
new Map(
Object.values(store.session.info).map(
(session) => [locationKey(session.location), session.location] as const,
),
).values(),
(location) => result.session.form.refresh("global", location),
),
)
}), }),
result.location.refresh(), result.location.refresh(),
result.location.agent.refresh(), result.location.agent.refresh(),
@@ -34,7 +34,6 @@ const tui: TuiPlugin = async (api) => {
const permissions = new Set<string>() const permissions = new Set<string>()
api.event.on("form.created", (event) => { api.event.on("form.created", (event) => {
if (event.data.form.sessionID === "global") return
if (forms.has(event.data.form.id)) return if (forms.has(event.data.form.id)) return
forms.add(event.data.form.id) 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")
+56 -21
View File
@@ -130,6 +130,17 @@ function display(field: Field, value: FormValue | undefined) {
return label(value) return label(value)
} }
function requestOptions(form: FormInfo) {
if (!form.location) return undefined
// Global forms have no session row, so the server needs their location to select the owning Form.Service.
return {
headers: {
"x-opencode-directory": encodeURIComponent(form.location.directory),
...(form.location.workspaceID ? { "x-opencode-workspace": form.location.workspaceID } : {}),
},
}
}
export function FormPrompt(props: { form: FormInfo }) { export function FormPrompt(props: { form: FormInfo }) {
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} /> return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
} }
@@ -154,7 +165,10 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
title: "Dismiss form", title: "Dismiss form",
category: "Form", category: "Form",
run() { run() {
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) void sdk.api.form.cancel(
{ sessionID: props.form.sessionID, formID: props.form.id },
requestOptions(props.form),
)
}, },
}, },
], ],
@@ -172,7 +186,10 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
desc: "Dismiss form", desc: "Dismiss form",
group: "Form", group: "Form",
cmd: () => { cmd: () => {
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) void sdk.api.form.cancel(
{ sessionID: props.form.sessionID, formID: props.form.id },
requestOptions(props.form),
)
}, },
}, },
], ],
@@ -328,11 +345,14 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
function replySingle(field: Field, value: FormValue) { function replySingle(field: Field, value: FormValue) {
sdk.api.form sdk.api.form
.reply({ .reply(
sessionID: props.form.sessionID, {
formID: props.form.id, sessionID: props.form.sessionID,
answer: { [field.key]: value }, formID: props.form.id,
}) answer: { [field.key]: value },
},
requestOptions(props.form),
)
.catch((error: unknown) => { .catch((error: unknown) => {
setStore( setStore(
"error", "error",
@@ -532,7 +552,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
group: "Form", group: "Form",
cmd: () => { cmd: () => {
if (textual()) { 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 return
} }
setStore("editing", false) setStore("editing", false)
@@ -594,7 +617,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
title: "Dismiss form", title: "Dismiss form",
category: "Form", category: "Form",
run() { run() {
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) void sdk.api.form.cancel(
{ sessionID: props.form.sessionID, formID: props.form.id },
requestOptions(props.form),
)
}, },
}, },
], ],
@@ -638,16 +664,19 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
return return
} }
sdk.api.form sdk.api.form
.reply({ .reply(
sessionID: props.form.sessionID, {
formID: props.form.id, sessionID: props.form.sessionID,
answer: Object.fromEntries( formID: props.form.id,
fields().flatMap((field) => { answer: Object.fromEntries(
const value = store.answers[field.key] fields().flatMap((field) => {
return value === undefined ? [] : [[field.key, value] as const] const value = store.answers[field.key]
}), return value === undefined ? [] : [[field.key, value] as const]
), }),
}) ),
},
requestOptions(props.form),
)
.catch((error: unknown) => { .catch((error: unknown) => {
setStore( setStore(
"error", "error",
@@ -666,7 +695,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
desc: "Dismiss form", desc: "Dismiss form",
group: "Form", group: "Form",
cmd: () => { 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) }, { key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
@@ -715,7 +747,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
desc: "Dismiss form", desc: "Dismiss form",
group: "Form", group: "Form",
cmd: () => { 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"), ...tuiConfig.keybinds.get("app.exit"),
+9 -5
View File
@@ -182,8 +182,10 @@ export function Session() {
) )
}) })
const forms = createMemo(() => { const forms = createMemo(() => {
if (session()?.parentID) return [] return [
return data.session.form.list(route.sessionID) ?? [] ...(session()?.parentID ? [] : (data.session.form.list(route.sessionID) ?? [])),
...(data.session.form.list("global", location()) ?? []),
]
}) })
const [composer, setComposer] = createStore({ const [composer, setComposer] = createStore({
open: false, open: false,
@@ -258,9 +260,11 @@ export function Session() {
navigate({ type: "home" }) navigate({ type: "home" })
return return
} }
await data.session.form.refresh("global", info.location)
if (route.sessionID !== sessionID) return
project.workspace.set(info.location.workspaceID) project.workspace.set(info.location.workspaceID)
editor.reconnect(info.location.directory) editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) if (scroll) scroll.scrollBy(100_000)
})().catch((error) => { })().catch((error) => {
if (route.sessionID !== sessionID) return if (route.sessionID !== sessionID) return
toast.show({ toast.show({
@@ -929,12 +933,12 @@ export function Session() {
<box flexShrink={0}> <box flexShrink={0}>
<Composer <Composer
sessionID={route.sessionID} sessionID={route.sessionID}
open={composer.open || !!session()?.parentID} open={composer.open || (!!session()?.parentID && forms().length === 0)}
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)} defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
onClose={() => setComposer("open", false)} onClose={() => setComposer("open", false)}
/> />
<Switch> <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}> <Match when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} /> <PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
</Match> </Match>
@@ -155,6 +155,11 @@ const formNotification: TuiAttentionNotifyInput = {
sound: { name: "question", when: "always" }, sound: { name: "question", when: "always" },
} }
const globalFormNotification: TuiAttentionNotifyInput = {
...formNotification,
title: undefined,
}
const permissionNotification: TuiAttentionNotifyInput = { const permissionNotification: TuiAttentionNotifyInput = {
title: "Demo session", title: "Demo session",
message: "Permission needs input", message: "Permission needs input",
@@ -173,12 +178,12 @@ describe("internal notifications TUI plugin", () => {
expect(harness.notifications).toEqual([formNotification, questionNotification, permissionNotification]) expect(harness.notifications).toEqual([formNotification, questionNotification, permissionNotification])
}) })
test("ignores global forms until the TUI can render them", async () => { test("notifies for global forms once the TUI can render them", async () => {
const harness = await setup() 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") } })
expect(harness.notifications).toEqual([]) expect(harness.notifications).toEqual([globalFormNotification])
}) })
test("dedupes pending forms, questions, and permissions until they are resolved", async () => { test("dedupes pending forms, questions, and permissions until they are resolved", async () => {
+190
View File
@@ -877,6 +877,196 @@ test("adds, dismisses, and refreshes form requests", async () => {
} }
}) })
test("tracks global forms by location", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
let data!: ReturnType<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.connection.status() === "connected")
events.emit({
id: "evt_form_created_global",
created: 0,
location: other,
type: "form.created",
data: { form: { id: "frm_other", sessionID: "global", mode: "form", fields: [] } },
})
await wait(() => data.session.form.list("global", other)?.length === 1)
expect(data.session.form.list("global", { directory }) ?? []).toEqual([])
events.emit({
id: "evt_form_created_global_default",
created: 1,
location: { directory },
type: "form.created",
data: { form: { id: "frm_default", sessionID: "global", mode: "form", fields: [] } },
})
await wait(() => data.session.form.list("global", { directory })?.length === 1)
events.emit({
id: "evt_form_replied_global",
created: 2,
location: other,
type: "form.replied",
data: { id: "frm_other", sessionID: "global", answer: {} },
})
await wait(() => data.session.form.list("global", other)?.length === 0)
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"])
} finally {
app.renderer.destroy()
}
})
test("refreshes global forms for the requested location", async () => {
const events = createEventStream()
const requests: URL[] = []
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
const calls = createFetch((url) => {
if (url.pathname !== "/api/form/request") return
requests.push(url)
const requestedDirectory = url.searchParams.get("location[directory]") ?? directory
const requestedWorkspace = url.searchParams.get("location[workspace]") ?? undefined
return json({
location: {
directory: requestedDirectory,
workspaceID: requestedWorkspace,
project: { id: "proj_test", directory: requestedDirectory },
},
data: [
{
id: requestedDirectory === other.directory ? "frm_other" : "frm_default",
sessionID: "global",
mode: "form",
fields: [],
},
],
})
}, events)
let data!: ReturnType<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.connection.status() === "connected")
await data.session.form.refresh("global", { directory })
await data.session.form.refresh("global", other)
expect(requests).toHaveLength(2)
expect(requests[1]?.searchParams.get("location[directory]")).toBe(other.directory)
expect(requests[1]?.searchParams.get("location[workspace]")).toBe(other.workspaceID)
expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual(["frm_other"])
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"])
} finally {
app.renderer.destroy()
}
})
test("refreshes global forms once per loaded location after reconnect", async () => {
const events = createEventStream()
const requests: URL[] = []
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
const calls = createFetch((url) => {
if (url.pathname === "/api/session" && url.searchParams.has("parentID")) return json({ data: [], cursor: {} })
if (url.pathname === "/api/session")
return json({
data: [
{ id: "ses_default", title: "Default", location: { directory }, time: { created: 0, updated: 0 } },
{ id: "ses_other_1", title: "Other one", location: other, time: { created: 0, updated: 0 } },
{ id: "ses_other_2", title: "Other two", location: other, time: { created: 0, updated: 0 } },
],
cursor: {},
})
if (url.pathname !== "/api/form/request") return
requests.push(url)
const requestedDirectory = url.searchParams.get("location[directory]") ?? directory
const requestedWorkspace = url.searchParams.get("location[workspace]") ?? undefined
return json({
location: {
directory: requestedDirectory,
workspaceID: requestedWorkspace,
project: { id: "proj_test", directory: requestedDirectory },
},
data: [],
})
}, events)
let data!: ReturnType<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(() => requests.length === 2)
await Bun.sleep(20)
requests.length = 0
events.disconnect()
await wait(() => requests.length === 2, 4000)
await Bun.sleep(20)
expect(requests).toHaveLength(2)
expect(
requests.map((url) => [
url.searchParams.get("location[directory]") ?? directory,
url.searchParams.get("location[workspace]") ?? undefined,
]),
).toEqual([
[directory, undefined],
[other.directory, other.workspaceID],
])
expect(data.connection.status()).toBe("connected")
} finally {
app.renderer.destroy()
}
})
test("settles pending tools when a live failure arrives", async () => { test("settles pending tools when a live failure arrives", async () => {
const events = createEventStream() const events = createEventStream()
const calls = createFetch((url) => { const calls = createFetch((url) => {
+2
View File
@@ -99,6 +99,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] }) return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/mcp") if (url.pathname === "/api/mcp")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] }) return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/form/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/session") return json({ data: [], cursor: {} }) if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {}, watermarks: {} }) if (url.pathname === "/api/session/active") return json({ data: {}, watermarks: {} })
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] }) if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })