feat(tui): allow backgrounding synchronous subagents (#30488)

This commit is contained in:
Kit Langton
2026-06-04 23:40:52 -04:00
committed by GitHub
parent 8c0edca175
commit 3003867c25
26 changed files with 527 additions and 35 deletions
+2
View File
@@ -816,6 +816,7 @@ export const RunCommand = effectCmd({
initialInput,
createSession: createFreshSession,
thinking,
backgroundSubagents: flags.experimentalBackgroundSubagents,
demo: args.demo,
})
} catch (error) {
@@ -849,6 +850,7 @@ export const RunCommand = effectCmd({
files,
initialInput,
thinking,
backgroundSubagents: flags.experimentalBackgroundSubagents,
demo: args.demo,
})
} catch (error) {
@@ -84,6 +84,7 @@ type RunFooterOptions = {
theme: RunTheme
keymap: Keymap<Renderable, KeyEvent>
tuiConfig: RunTuiConfig
backgroundSubagents: boolean
diffStyle: RunDiffStyle
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
@@ -92,6 +93,7 @@ type RunFooterOptions = {
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onExit?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
treeSitterClient?: TreeSitterClient
@@ -294,6 +296,7 @@ export class RunFooter implements FooterApi {
theme: options.theme,
diffStyle: options.diffStyle,
tuiConfig: options.tuiConfig,
backgroundSubagents: options.backgroundSubagents,
history: options.history,
agent: options.agentLabel,
onSubmit: footer.handlePrompt,
@@ -302,6 +305,7 @@ export class RunFooter implements FooterApi {
onQuestionReject: footer.handleQuestionReject,
onCycle: footer.handleCycle,
onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground,
onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit,
onRequestExit: footer.setRequestExitHandler,
@@ -86,6 +86,7 @@ type RunFooterViewProps = {
theme?: RunTheme
diffStyle?: RunDiffStyle
tuiConfig: RunTuiConfig
backgroundSubagents: boolean
history?: RunPrompt[]
agent: string
onSubmit: (input: RunPrompt) => boolean
@@ -94,6 +95,7 @@ type RunFooterViewProps = {
onQuestionReject: (input: QuestionReject) => void | Promise<void>
onCycle: () => void
onInterrupt: () => boolean
onBackground?: () => void
onInputClear: () => void
onExitRequest?: () => boolean
onRequestExit?: (fn: (() => boolean) | undefined) => void
@@ -158,6 +160,9 @@ export function RunFooterView(props: RunFooterViewProps) {
label: count === 1 ? "agent" : "agents",
}
})
const foregroundSubagents = createMemo(
() => props.backgroundSubagents && tabs().some((item) => item.status === "running" && !item.background),
)
const queuedIndicator = createMemo(() => {
const count = queuedPrompts().length
if (count === 0) return
@@ -214,6 +219,15 @@ export function RunFooterView(props: RunFooterViewProps) {
props.tuiConfig,
) ?? "",
)
const backgroundShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.background"] })
.get("session.background"),
props.tuiConfig,
) ?? "",
)
const hints = createMemo(() => hintFlags(term().width))
const busy = createMemo(() => props.state().phase === "running")
const armed = createMemo(() => props.state().interrupt > 0)
@@ -375,6 +389,21 @@ export function RunFooterView(props: RunFooterViewProps) {
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(),
priority: 1,
commands: [
{
name: "session.background",
title: "Background subagents",
category: "Session",
run: () => props.onBackground?.(),
},
],
bindings: props.tuiConfig.keybinds.get("session.background"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
@@ -774,6 +803,13 @@ export function RunFooterView(props: RunFooterViewProps) {
</text>
)}
</Show>
<Show when={foregroundSubagents() && backgroundShortcut()}>
<text id="run-direct-footer-background-label" fg={theme().text} wrapMode="none" truncate>
<span style={{ fg: theme().highlight }}> </span>
<span style={{ fg: theme().highlight }}>{backgroundShortcut()}</span>{" "}
<span style={{ fg: theme().muted }}>background</span>
</text>
</Show>
<Show when={queuedIndicator()}>
{(info) => (
<text id="run-direct-footer-queued-label" fg={theme().text} wrapMode="none" truncate>
@@ -63,6 +63,7 @@ export type LifecycleInput = {
model: RunInput["model"]
variant: string | undefined
tuiConfig: RunTuiConfig
backgroundSubagents: boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void>
@@ -70,6 +71,7 @@ export type LifecycleInput = {
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
}
@@ -237,6 +239,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
wrote,
keymap,
tuiConfig: input.tuiConfig,
backgroundSubagents: input.backgroundSubagents,
diffStyle: input.tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply,
onQuestionReply: input.onQuestionReply,
@@ -245,6 +248,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onModelSelect: input.onModelSelect,
onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt,
onBackground: input.onBackground,
onSubagentSelect: input.onSubagentSelect,
})
@@ -52,6 +52,7 @@ type RunRuntimeInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
backgroundSubagents: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
@@ -70,6 +71,7 @@ type RunLocalInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
backgroundSubagents: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
@@ -253,6 +255,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
model: state.model,
variant: state.activeVariant,
tuiConfig,
backgroundSubagents: input.backgroundSubagents,
onPermissionReply: async (next) => {
if (state.demo?.permission(next)) {
return
@@ -372,6 +375,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
state.aborting = false
})
},
onBackground: () => {
if (!hasSession(input, state)) return
void ctx.sdk.experimental.session.background({ sessionID: state.sessionID }).catch(() => {})
},
onSubagentSelect: (sessionID) => {
state.selectSubagent?.(sessionID)
log?.write("subagent.select", {
@@ -794,6 +801,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
@@ -848,6 +856,7 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
@@ -83,6 +83,7 @@ export function sameSubagentTab(a: FooterSubagentTab | undefined, b: FooterSubag
a.label === b.label &&
a.description === b.description &&
a.status === b.status &&
a.background === b.background &&
a.title === b.title &&
a.toolCalls === b.toolCalls &&
a.lastUpdatedAt === b.lastUpdatedAt
@@ -303,6 +304,7 @@ function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
label,
description,
status,
background: metadata(part, "background") === true,
title: stateTitle(part),
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
lastUpdatedAt: stateUpdatedAt(part),
@@ -68,6 +68,7 @@ export type RunInput = {
files: RunFilePart[]
initialInput?: string
thinking: boolean
backgroundSubagents: boolean
demo?: boolean
}
@@ -184,6 +185,7 @@ export type FooterSubagentTab = {
label: string
description: string
status: "running" | "completed" | "error"
background?: boolean
title?: string
toolCalls?: number
lastUpdatedAt: number
@@ -92,6 +92,7 @@ export const Definitions = {
session_share: keybind("none", "Share current session"),
session_unshare: keybind("none", "Unshare current session"),
session_interrupt: keybind("escape", "Interrupt current session"),
session_background: keybind("ctrl+b", "Background synchronous subagents"),
session_compact: keybind("<leader>c", "Compact the session"),
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
@@ -291,6 +292,7 @@ export const CommandMap = {
session_share: "session.share",
session_unshare: "session.unshare",
session_interrupt: "session.interrupt",
session_background: "session.background",
session_compact: "session.compact",
session_toggle_timestamps: "session.toggle.timestamps",
session_toggle_generic_tool_output: "session.toggle.generic_tool_output",
@@ -198,6 +198,17 @@ export function Session() {
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const foregroundTasks = createMemo(() =>
messages().flatMap((message) =>
(sync.data.part[message.id] ?? []).filter(
(part): part is ToolPart =>
part.type === "tool" &&
part.tool === "task" &&
part.state.status === "running" &&
part.state.metadata?.background !== true,
),
),
)
const permissions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
@@ -1008,6 +1019,20 @@ export function Session() {
dialog.clear()
},
},
{
title: "Background subagents",
value: "session.background",
category: "Session",
hidden: true,
enabled: foregroundTasks().length > 0,
run: () => {
void sdk.client.experimental.session.background({
sessionID: route.sessionID,
workspace: project.workspace.current(),
})
dialog.clear()
},
},
{
title: "Go to child session",
value: "session.child.first",
@@ -1088,6 +1113,13 @@ export function Session() {
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: foregroundTasks().length > 0,
priority: 1,
bindings: tuiConfig.keybinds.get("session.background"),
}))
const revertInfo = createMemo(() => session()?.revert)
const revertMessageID = createMemo(() => revertInfo()?.messageID)
@@ -1453,6 +1485,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
})
const childShortcut = useCommandShortcut("session.child.first")
const backgroundShortcut = useCommandShortcut("session.background")
return (
<>
@@ -1476,6 +1509,19 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<text fg={theme.text}>
{childShortcut()}
<span style={{ fg: theme.textMuted }}> view subagents</span>
<Show
when={props.parts.some(
(x) =>
x.type === "tool" &&
x.tool === "task" &&
x.state.status === "running" &&
x.state.metadata?.background !== true,
)}
>
<span style={{ fg: theme.textMuted }}> · </span>
{backgroundShortcut()}
<span style={{ fg: theme.textMuted }}> background</span>
</Show>
</text>
</box>
</Show>