feat(tui): background running subagents

This commit is contained in:
Kit Langton 2026-06-30 01:01:28 -04:00
commit cf3b601731
3 changed files with 96 additions and 46 deletions

View file

@ -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* () {

View file

@ -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<SubagentEntry[]>(() => {
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 (
<Show when={composer.active("subagents")}>
<scrollbox
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No subagents</text>}>
<For each={entries()}>
{(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 })
}}
>
<box flexGrow={1} minWidth={0} flexDirection="row">

View file

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