fix(tui): background foreground subagents

This commit is contained in:
Kit Langton 2026-06-30 13:07:40 -04:00
commit 3005e8bb98
7 changed files with 388 additions and 84 deletions

View file

@ -57,6 +57,7 @@ import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { foregroundSubagentCount } from "../../util/subagent"
export type PromptProps = {
sessionID?: string
@ -160,12 +161,13 @@ export function Prompt(props: PromptProps) {
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const activeSubagents = createMemo(
() =>
data.session
.list()
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
.length,
const activeSubagents = createMemo(() =>
foregroundSubagentCount({
sessionID: props.sessionID,
sessions: data.session.list(),
messages: props.sessionID ? data.session.message.list(props.sessionID) : [],
status: (sessionID) => data.session.status(sessionID),
}),
)
const runningShells = createMemo(
() => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length,
@ -175,6 +177,7 @@ export function Prompt(props: PromptProps) {
const keymap = useOpencodeKeymap()
const agentShortcut = useCommandShortcut("agent.cycle")
const paletteShortcut = useCommandShortcut("command.palette.show")
const backgroundShortcut = useCommandShortcut("session.background")
const renderer = useRenderer()
const exit = useExit()
const dimensions = useTerminalDimensions()
@ -290,20 +293,6 @@ export function Prompt(props: PromptProps) {
}
})
// Far-right footer cluster: live work counts lead, then context/cost usage, all dot-joined.
// When empty, the cluster falls back to the hotkey hints.
const statusItems = createMemo(() => {
const agents = activeSubagents()
const shells = runningShells()
const stats = usage()
return [
agents ? `${agents} subagent${agents === 1 ? "" : "s"}` : undefined,
shells ? `${shells} shell${shells === 1 ? "" : "s"}` : undefined,
stats?.context,
stats?.cost,
].filter(Boolean)
})
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
@ -321,6 +310,22 @@ export function Prompt(props: PromptProps) {
interrupt: 0,
})
// Far-right footer cluster: live work counts lead, then context/cost usage, all dot-joined.
// When empty, the cluster falls back to the hotkey hints.
const statusItems = createMemo(() => {
const agents = activeSubagents()
const shells = runningShells()
const stats = usage()
const background = backgroundShortcut()
return [
agents ? `${agents} subagent${agents === 1 ? "" : "s"}` : undefined,
agents && store.prompt.input === "" && background ? `${background} background` : undefined,
shells ? `${shells} shell${shells === 1 ? "" : "s"}` : undefined,
stats?.context,
stats?.cost,
].filter(Boolean)
})
createEffect(
on(
() => props.sessionID,

View file

@ -7,9 +7,6 @@ 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
@ -26,10 +23,7 @@ export function SubagentsTab(props: { sessionID: string }) {
const { theme } = useTheme()
const fg = selectedForeground(theme)
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)
@ -116,33 +110,6 @@ 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",
@ -150,10 +117,7 @@ export function SubagentsTab(props: { sessionID: string }) {
hints: () => {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return []
return [
{ label: "interrupt", shortcut: interruptHint() },
{ label: "background", shortcut: backgroundHint() },
]
return [{ label: "interrupt", shortcut: interruptHint() }]
},
onClose: () => {
const parentID = session()?.parentID
@ -205,23 +169,12 @@ export function SubagentsTab(props: { sessionID: string }) {
if (!entry || entry.status !== "running") return
},
},
{
name: "composer.background",
title: "Background subagent",
category: "Composer",
run() {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return
void background()
},
},
],
bindings: [
{ 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+d", desc: "Interrupt subagent", group: "Subagents", cmd: "composer.subagent.interrupt" },
{ key: "ctrl+b", desc: "Background subagent", group: "Subagents", cmd: "composer.background" },
],
}))

View file

@ -71,6 +71,7 @@ import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keyma
import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location"
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
import { foregroundSubagentCount, subagentDisplayState } from "../../util/subagent"
addDefaultParsers(parsers.parsers)
@ -148,7 +149,7 @@ export function Session() {
}
const pluginRuntime = usePluginRuntime()
const route = useRouteData("session")
const { navigate } = useRoute()
const router = useRoute()
const data = useData()
const project = useProject()
const paths = useTuiPaths()
@ -242,6 +243,15 @@ export function Session() {
const sdk = useSDK()
const editor = useEditorContext()
const rows = createSessionRows(() => route.sessionID)
const backgroundSessionID = createMemo(() => session()?.parentID ?? route.sessionID)
const foregroundSubagents = createMemo(() =>
foregroundSubagentCount({
sessionID: backgroundSessionID(),
sessions: data.session.list(),
messages: data.session.message.list(backgroundSessionID()),
status: (sessionID) => data.session.status(sessionID),
}),
)
createEffect(
on(descendantSessionIDs, (sessionIDs) => {
@ -264,7 +274,7 @@ export function Session() {
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
router.navigate({ type: "home" })
return
}
@ -278,7 +288,7 @@ export function Session() {
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
router.navigate({ type: "home" })
})
})
@ -790,7 +800,7 @@ export function Session() {
run: async () => {
const current = location()
if (!current) return
const targetSessionID = session()?.parentID ?? route.sessionID
const targetSessionID = backgroundSessionID()
dialog.clear()
try {
const capabilities = await sdk.client.experimental.capabilities.get(
@ -834,7 +844,7 @@ export function Session() {
run: () => {
const parentID = session()?.parentID
if (parentID) {
navigate({
router.navigate({
type: "session",
sessionID: parentID,
})
@ -891,7 +901,15 @@ export function Session() {
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: () => renderer.currentFocusedEditor === null || (prompt?.focused === true && prompt.current.input === ""),
enabled: () =>
foregroundSubagents() > 0 &&
(renderer.currentFocusedEditor === null || (prompt?.focused === true && prompt.current.input === "")),
bindings: tuiConfig.keybinds.gather("session.background", sessionBackgroundBindingCommands),
}))
useBindings(() => ({
mode: "composer",
enabled: () => foregroundSubagents() > 0 && composer.open,
bindings: tuiConfig.keybinds.gather("session.background", sessionBackgroundBindingCommands),
}))
@ -2200,26 +2218,34 @@ function WebSearch(props: ToolProps) {
}
function Task(props: ToolProps) {
const { navigate } = useRoute()
const router = useRoute()
const data = useData()
const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId))
const description = createMemo(() => stringValue(props.input.description))
const display = createMemo(() =>
subagentDisplayState({
toolStatus: props.part.state.status,
metadata: props.metadata,
sessionStatus: (sessionID) => data.session.status(sessionID),
}),
)
return (
<InlineTool
icon={props.part.state.status === "completed" ? "✓" : "│"}
spinner={props.part.state.status === "running"}
icon={display().icon}
spinner={display().running}
complete={description()}
pending="Delegating..."
part={props.part}
onClick={() => {
const id = sessionID()
if (id) navigate({ type: "session", sessionID: id })
if (id) router.navigate({ type: "session", sessionID: id })
}}
>
{formatSubagentTitle(
Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"),
description() ?? "Subagent",
props.metadata.background === true,
display().background,
)}
</InlineTool>
)

View file

@ -0,0 +1,122 @@
const subagentTools = new Set(["subagent", "task"])
type SubagentDisplayStateInput = {
readonly toolStatus: string
readonly metadata: Record<string, unknown>
readonly sessionStatus: (sessionID: string) => string
}
type SessionSummary = {
readonly id: string
readonly parentID?: string
readonly title?: string
}
type TimelinePart = {
readonly type: string
readonly name?: string
readonly state?: {
readonly status: string
readonly input?: unknown
readonly structured?: Record<string, unknown>
}
}
type TimelineMessage = {
readonly type: string
readonly content?: readonly TimelinePart[]
}
export function foregroundSubagentCount(input: {
readonly sessionID?: string
readonly sessions: readonly SessionSummary[]
readonly messages: readonly TimelineMessage[]
readonly status: (sessionID: string) => string
}) {
if (!input.sessionID) return 0
const backgrounded = new Set(
input.messages.flatMap((message) =>
message.type === "assistant"
? (message.content ?? []).flatMap((part) => {
if (part.type !== "tool") return []
if (!part.name || !subagentTools.has(part.name)) return []
if (part.state?.status === "pending" || part.state?.structured?.background !== true) return []
const sessionID =
typeof part.state.structured.sessionID === "string"
? part.state.structured.sessionID
: typeof part.state.structured.sessionId === "string"
? part.state.structured.sessionId
: undefined
return sessionID ? [sessionID] : []
})
: [],
),
)
const runningSessionIDs = new Set(
input.sessions
.filter(
(session) =>
session.parentID === input.sessionID &&
input.status(session.id) === "running" &&
!backgrounded.has(session.id),
)
.map((session) => session.id),
)
const runningSessionTitles = new Set(
input.sessions
.filter((session) => runningSessionIDs.has(session.id) && session.title)
.map((session) => session.title),
)
const runningRows = input.messages.flatMap((message) =>
message.type === "assistant"
? (message.content ?? []).filter(
(part) =>
part.type === "tool" &&
part.name !== undefined &&
subagentTools.has(part.name) &&
part.state?.status === "running" &&
part.state.structured?.background !== true,
)
: [],
)
const runningRowSessionIDs = new Set(
runningRows.flatMap((part) => {
const sessionID = subagentSessionID(part.state?.structured ?? {})
return sessionID ? [sessionID] : []
}),
)
const anonymousRunningRows = runningRows.filter((part) => {
if (subagentSessionID(part.state?.structured ?? {})) return false
const input = part.state?.input
if (typeof input !== "object" || input === null || Array.isArray(input) || !("description" in input)) return true
return typeof input.description === "string" ? !runningSessionTitles.has(input.description) : true
}).length
const runningSessions = [...runningSessionIDs].filter((session) => !runningRowSessionIDs.has(session)).length
return runningSessions + runningRowSessionIDs.size + anonymousRunningRows
}
export function subagentDisplayState(input: SubagentDisplayStateInput) {
const sessionID = subagentSessionID(input.metadata)
const background = input.metadata.background === true
const childRunning = background && sessionID !== undefined && input.sessionStatus(sessionID) === "running"
const running = input.toolStatus === "running" || childRunning
return {
background,
running,
icon: running ? "│" : "✓",
}
}
function subagentSessionID(metadata: Record<string, unknown>) {
if (typeof metadata.sessionID === "string") return metadata.sessionID
if (typeof metadata.sessionId === "string") return metadata.sessionId
return undefined
}