Skip to content
Draft
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
75 changes: 60 additions & 15 deletions packages/tui/src/routes/session/composer/subagents-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ import { useTheme, selectedForeground } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { useBindings, useCommandShortcut } from "../../../keymap"
import { useComposerTab } from "./index"
import { Spinner } from "../../../component/spinner"
import type { SessionMessageInfo } from "@opencode-ai/client"

interface SubagentEntry {
sessionID: string
agent: string
title: string
status: string
background: boolean
current: boolean
}

Expand All @@ -26,6 +29,7 @@ export function SubagentsTab(props: { sessionID: string }) {
const navigate = useRoute().navigate
const composer = useComposerTab()
const interruptHint = useCommandShortcut("composer.subagent.interrupt")
const backgroundHint = useCommandShortcut("composer.subagent.background")

const session = createMemo(() => data.session.get(props.sessionID))

Expand All @@ -39,27 +43,37 @@ export function SubagentsTab(props: { sessionID: string }) {
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 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),
background: isBackgroundSubagent(data.session.message.list(current.parentID), sibling.id, sibling.title),
current: sibling.id === route.sessionID,
})
}
} else {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
const agentMatch = child.title.match(/@(\w+) subagent/)
const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "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({
sessionID: child.id,
agent,
title: name,
status: data.session.status(child.id),
background: isBackgroundSubagent(data.session.message.list(props.sessionID), child.id, child.title),
current: child.id === route.sessionID,
})
}
Expand Down Expand Up @@ -133,7 +147,10 @@ export function SubagentsTab(props: { sessionID: string }) {
hints: () => {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return []
return [{ label: "interrupt", shortcut: interruptHint() }]
return [
...(!entry.background && backgroundHint() ? [{ label: "background", shortcut: backgroundHint() }] : []),
{ label: "interrupt", shortcut: interruptHint() },
]
},
onClose: () => {
const parentID = session()?.parentID
Expand Down Expand Up @@ -176,6 +193,16 @@ export function SubagentsTab(props: { sessionID: string }) {
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
},
},
{
name: "composer.subagent.background",
title: "Background blocking tools",
category: "Session",
run() {
const entry = selectedEntry()
if (!entry || entry.status !== "running" || entry.background) return
void sdk.api.session.background({ sessionID: session()?.parentID ?? props.sessionID })
},
},
{
name: "composer.subagent.interrupt",
title: "Interrupt subagent",
Expand All @@ -191,25 +218,18 @@ export function SubagentsTab(props: { sessionID: string }) {
{ key: "up", desc: "Previous subagent", group: "Subagents", cmd: "composer.subagent.up" },
{ key: "down", desc: "Next subagent", group: "Subagents", cmd: "composer.subagent.down" },
{ key: "return", desc: "Navigate to subagent", group: "Subagents", cmd: "composer.subagent.select" },
{ key: "ctrl+b", desc: "Background subagent", group: "Subagents", cmd: "composer.subagent.background" },
{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "composer.subagent.interrupt" },
],
}))

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) => {
const active = createMemo(() => index() === selected())
const status = createMemo(() => {
if (entry.status === "running") return "Running"
return ""
})
return (
<box
flexDirection="row"
Expand All @@ -222,18 +242,30 @@ export function SubagentsTab(props: { sessionID: string }) {
navigate({ type: "session", sessionID: entry.sessionID })
}}
>
<box width={2} flexShrink={0}>
<Show when={entry.status === "running" && !entry.background}>
<Spinner color={active() ? fg : theme.textMuted} />
</Show>
</box>
<box flexGrow={1} minWidth={0} flexDirection="row">
<text
fg={active() ? fg : theme.textMuted}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
{entry.agent}{" "}
</text>
<text
fg={active() ? fg : entry.current ? theme.primary : theme.text}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
{entry.agent}: {entry.title}
{entry.title}
</text>
</box>
<Show when={status()}>
<Show when={entry.status === "running" && entry.background}>
<text fg={active() ? fg : theme.textMuted} wrapMode="none">
{status()}
background
</text>
</Show>
</box>
Expand All @@ -245,3 +277,16 @@ export function SubagentsTab(props: { sessionID: string }) {
</Show>
)
}

function isBackgroundSubagent(messages: SessionMessageInfo[], sessionID: string, title: string) {
return messages.some((message) =>
message.type === "assistant"
? message.content.some((part) => {
if (part.type !== "tool" || part.name !== "subagent" || part.state.status === "streaming") return false
if (part.state.input.background === true && part.state.input.description === title) return true
if (part.state.status === "running") return false
return part.state.structured.sessionID === sessionID && part.state.structured.status === "running"
})
: false,
)
}
Loading