From cf3b60173135bc8f20e027632a454940b79e5bf3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 30 Jun 2026 01:01:28 -0400 Subject: [PATCH] feat(tui): background running subagents --- .../instance/httpapi/handlers/experimental.ts | 4 +- .../routes/session/composer/subagents-tab.tsx | 101 ++++++++++-------- packages/tui/src/routes/session/index.tsx | 37 ++++++- 3 files changed, 96 insertions(+), 46 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 1433c2041a..003d5ab50e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -159,7 +159,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper params: { sessionID: SessionID } }) { if (!flags.experimentalBackgroundSubagents) return false - return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0 + const tasks = yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" }) + const subagents = yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "subagent" }) + return tasks.length > 0 || subagents.length > 0 }) const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx index a1c77147da..04ba3cb9c6 100644 --- a/packages/tui/src/routes/session/composer/subagents-tab.tsx +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -7,6 +7,9 @@ import { useTheme, selectedForeground } from "../../../context/theme" import { Locale } from "../../../util/locale" import { useBindings, useCommandShortcut } from "../../../keymap" import { useComposerTab } from "./index" +import { useSDK } from "../../../context/sdk" +import { useToast } from "../../../ui/toast" +import { errorMessage } from "../../../util/error" interface SubagentEntry { sessionID: string @@ -17,54 +20,42 @@ interface SubagentEntry { } export function SubagentsTab(props: { sessionID: string }) { - const route = useRouteData("session") + const routeData = useRouteData("session") + const route = useRoute() const data = useData() const { theme } = useTheme() const fg = selectedForeground(theme) - const navigate = useRoute().navigate const composer = useComposerTab() + const sdk = useSDK() + const toast = useToast() const interruptHint = useCommandShortcut("composer.subagent.interrupt") const backgroundHint = useCommandShortcut("composer.background") const session = createMemo(() => data.session.get(props.sessionID)) + const parentID = createMemo(() => session()?.parentID ?? props.sessionID) const entries = createMemo(() => { const current = session() if (!current) return [] - const result: SubagentEntry[] = [] - - if (current.parentID) { - const siblings = data.session.list().filter((s) => s.parentID === current.parentID) - for (const sibling of siblings) { - const agentMatch = sibling.title.match(/@(\w+) subagent/) - const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" - const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title - result.push({ - sessionID: sibling.id, - agent, - title: name, - status: data.session.status(sibling.id), - current: sibling.id === route.sessionID, - }) - } - } else { - const children = data.session.list().filter((s) => s.parentID === props.sessionID) - for (const child of children) { + return data.session + .list() + .filter((child) => child.parentID === parentID()) + .map((child) => { const agentMatch = child.title.match(/@(\w+) subagent/) - const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" - const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title - result.push({ + const agent = child.agent + ? Locale.titlecase(child.agent) + : agentMatch + ? Locale.titlecase(agentMatch[1]) + : "Subagent" + return { sessionID: child.id, agent, - title: name, + title: agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title, status: data.session.status(child.id), - current: child.id === route.sessionID, - }) - } - } - - return result + current: child.id === routeData.sessionID, + } + }) }) const [store, setStore] = createStore({ selected: 0 }) @@ -88,10 +79,10 @@ export function SubagentsTab(props: { sessionID: string }) { return } const list = entries() - if (selectedSessionID !== route.sessionID && list.length > 0) { + if (selectedSessionID !== routeData.sessionID && list.length > 0) { const currentIdx = list.findIndex((e) => e.current) const next = currentIdx >= 0 ? currentIdx : 0 - selectedSessionID = route.sessionID + selectedSessionID = routeData.sessionID setStore("selected", next) const scrollCurrentIntoView = () => scrollToIndex(next, true) scrollCurrentIntoView() @@ -125,6 +116,33 @@ export function SubagentsTab(props: { sessionID: string }) { } } + async function background() { + const parent = data.session.get(parentID()) + const location = parent?.location ?? session()?.location + if (!location) return + try { + const capabilities = await sdk.client.experimental.capabilities.get( + { directory: location.directory, workspace: location.workspaceID }, + { throwOnError: true }, + ) + if (!capabilities.data.backgroundSubagents) { + toast.show({ message: "Background subagents are not enabled", variant: "info", duration: 3000 }) + return + } + const result = await sdk.client.experimental.session.background( + { sessionID: parentID(), directory: location.directory, workspace: location.workspaceID }, + { throwOnError: true }, + ) + toast.show({ + message: result.data ? "Backgrounded running subagents" : "No running subagents to background", + variant: result.data ? "success" : "info", + duration: 3000, + }) + } catch (error) { + toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) + } + } + onMount(() => { const cleanup = composer.register({ id: "subagents", @@ -134,12 +152,12 @@ export function SubagentsTab(props: { sessionID: string }) { if (!entry || entry.status !== "running") return [] return [ { label: "interrupt", shortcut: interruptHint() }, - ...(entry.current ? [{ label: "background", shortcut: backgroundHint() }] : []), + { label: "background", shortcut: backgroundHint() }, ] }, onClose: () => { const parentID = session()?.parentID - if (parentID) navigate({ type: "session", sessionID: parentID }) + if (parentID) route.navigate({ type: "session", sessionID: parentID }) }, }) onCleanup(cleanup) @@ -175,7 +193,7 @@ export function SubagentsTab(props: { sessionID: string }) { category: "Composer", run() { const entry = entries()[store.selected] - if (entry) navigate({ type: "session", sessionID: entry.sessionID }) + if (entry) route.navigate({ type: "session", sessionID: entry.sessionID }) }, }, { @@ -193,7 +211,8 @@ export function SubagentsTab(props: { sessionID: string }) { category: "Composer", run() { const entry = selectedEntry() - if (!entry || entry.status !== "running" || !entry.current) return + if (!entry || entry.status !== "running") return + void background() }, }, ], @@ -208,11 +227,7 @@ export function SubagentsTab(props: { sessionID: string }) { return ( - (scroll = r)} - > + (scroll = r)}> 0} fallback={ No subagents}> {(entry, index) => { @@ -230,7 +245,7 @@ export function SubagentsTab(props: { sessionID: string }) { onMouseOver={() => setStore("selected", index())} onMouseUp={() => { setStore("selected", index()) - navigate({ type: "session", sessionID: entry.sessionID }) + route.navigate({ type: "session", sessionID: entry.sessionID }) }} > diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d611a31b63..ff06aaf3d2 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -99,13 +99,14 @@ const sessionBindingCommands = [ "messages.copy", "session.copy", "session.export", - "session.background", "session.child.first", "session.parent", "session.child.next", "session.child.previous", ] as const +const sessionBackgroundBindingCommands = ["session.background"] as const + const sessionGlobalBindingCommands = [ "session.page.up", "session.page.down", @@ -786,7 +787,33 @@ export function Session() { value: "session.background", category: "Session", hidden: true, - run: () => unavailable("Backgrounding subagents"), + run: async () => { + const current = location() + if (!current) return + const targetSessionID = session()?.parentID ?? route.sessionID + dialog.clear() + try { + const capabilities = await sdk.client.experimental.capabilities.get( + { directory: current.directory, workspace: current.workspaceID }, + { throwOnError: true }, + ) + if (!capabilities.data.backgroundSubagents) { + toast.show({ message: "Background subagents are not enabled", variant: "info", duration: 3000 }) + return + } + const result = await sdk.client.experimental.session.background( + { sessionID: targetSessionID, directory: current.directory, workspace: current.workspaceID }, + { throwOnError: true }, + ) + toast.show({ + message: result.data ? "Backgrounded running subagents" : "No running subagents to background", + variant: result.data ? "success" : "info", + duration: 3000, + }) + } catch (error) { + toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) + } + }, }, { title: "Toggle subagent picker", @@ -862,6 +889,12 @@ export function Session() { bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands), })) + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: () => renderer.currentFocusedEditor === null || (prompt?.focused === true && prompt.current.input === ""), + bindings: tuiConfig.keybinds.gather("session.background", sessionBackgroundBindingCommands), + })) + // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) createEffect(