Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions packages/tui/src/context/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export type DataSessionStatus = "idle" | "running"

const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")

export type FormInfo = FormFormInfo | FormUrlInfo
export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef }

type LocationData = {
agent?: AgentV2Info[]
Expand All @@ -56,7 +56,7 @@ type Data = {
status: Record<string, DataSessionStatus>
message: Record<string, SessionMessage[]>
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: {
Expand Down Expand Up @@ -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
setStore("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
case "form.replied":
Expand Down Expand Up @@ -723,10 +723,28 @@ 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 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 })))
},
},
Expand Down Expand Up @@ -871,7 +889,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: defaultLocation().directory,
workspace: defaultLocation().workspaceID,
})
.then((response) => {
.then(async (response) => {
setStore(
"session",
"info",
Expand All @@ -880,6 +898,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}),
)
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.agent.refresh(),
Expand Down
1 change: 0 additions & 1 deletion packages/tui/src/feature-plugins/system/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ 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")
Expand Down
77 changes: 56 additions & 21 deletions packages/tui/src/routes/session/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ function display(field: Field, value: FormValue | undefined) {
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 }) {
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
}
Expand All @@ -154,7 +165,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),
)
},
},
],
Expand All @@ -172,7 +186,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),
)
},
},
],
Expand Down Expand Up @@ -328,11 +345,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",
Expand Down Expand Up @@ -532,7 +552,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)
Expand Down Expand Up @@ -594,7 +617,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),
)
},
},
],
Expand Down Expand Up @@ -638,16 +664,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",
Expand All @@ -666,7 +695,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) },
Expand Down Expand Up @@ -715,7 +747,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"),
Expand Down
14 changes: 9 additions & 5 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,10 @@ export function Session() {
)
})
const forms = createMemo(() => {
if (session()?.parentID) return []
return data.session.form.list(route.sessionID) ?? []
return [
...(session()?.parentID ? [] : (data.session.form.list(route.sessionID) ?? [])),
...(data.session.form.list("global", location()) ?? []),
]
})
const [composer, setComposer] = createStore({
open: false,
Expand Down Expand Up @@ -258,9 +260,11 @@ export function Session() {
navigate({ type: "home" })
return
}
await data.session.form.refresh("global", info.location)
if (route.sessionID !== sessionID) return
project.workspace.set(info.location.workspaceID)
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
if (scroll) scroll.scrollBy(100_000)
})().catch((error) => {
if (route.sessionID !== sessionID) return
toast.show({
Expand Down Expand Up @@ -929,12 +933,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>
Expand Down
9 changes: 7 additions & 2 deletions packages/tui/test/cli/cmd/tui/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ const formNotification: TuiAttentionNotifyInput = {
sound: { name: "question", when: "always" },
}

const globalFormNotification: TuiAttentionNotifyInput = {
...formNotification,
title: undefined,
}

const permissionNotification: TuiAttentionNotifyInput = {
title: "Demo session",
message: "Permission needs input",
Expand All @@ -173,12 +178,12 @@ describe("internal notifications TUI plugin", () => {
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()

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 () => {
Expand Down
Loading
Loading