feat(tui): track background subagents in the status bar (#51485)

Parity with the classic CLI status bar's ⛓ indicator (PR #51441). The
Ink TUI status bar now shows ⛓ N for live background/async subagents
(delegate_task batches + background single delegations).

- tui_gateway/server.py: _get_usage() embeds active_subagents from
  tools.async_delegation.active_count() — the same registry the CLI
  reads — onto the existing per-update usage payload, guarded so a
  raising active_count() leaves the field off without breaking usage.
- ui-tui appChrome: new 'subagents' status segment (breakpoint w>=92,
  slots between bg and cost in the shed-order), renders ⛓ N from
  usage.active_subagents.
- Usage / SessionUsageResponse types gain active_subagents?.

Distinct from the turn-scoped SpawnHud / /agents overlay, which mirror
live in-turn subagent.* events; this is the persistent registry count.
This commit is contained in:
Teknium 2026-06-23 11:32:00 -07:00 committed by GitHub
parent da80ac0042
commit 72bfc48e63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 104 additions and 0 deletions

View file

@ -7946,3 +7946,45 @@ def test_start_agent_build_passes_session_model_override(monkeypatch):
assert session["agent"].model == "claude-sonnet-4.6"
finally:
server._sessions.clear()
# ── _get_usage active_subagents (TUI status-bar ⛓ indicator) ──────────────
# Mirrors the classic CLI status bar: _get_usage embeds a live count of
# background/async subagents from tools.async_delegation.active_count() so the
# Ink status bar can render ⛓ N. Source of truth is the same registry the CLI
# reads; the field rides the existing per-update `usage` payload.
class _BareAgent:
"""Agent stub with no compressor — exercises the active_subagents path
independent of the `if comp:` context-percent block."""
model = "x"
def test_get_usage_includes_active_subagents(monkeypatch):
import tools.async_delegation as ad_mod
monkeypatch.setattr(ad_mod, "active_count", lambda: 4)
usage = server._get_usage(_BareAgent())
assert usage["active_subagents"] == 4
def test_get_usage_active_subagents_zero(monkeypatch):
import tools.async_delegation as ad_mod
monkeypatch.setattr(ad_mod, "active_count", lambda: 0)
usage = server._get_usage(_BareAgent())
assert usage["active_subagents"] == 0
def test_get_usage_safe_when_active_count_raises(monkeypatch):
"""A raising active_count() must not break the usage payload."""
import tools.async_delegation as ad_mod
def _boom():
raise RuntimeError("boom")
monkeypatch.setattr(ad_mod, "active_count", _boom)
usage = server._get_usage(_BareAgent())
# Field omitted, but the rest of the payload is intact.
assert "active_subagents" not in usage
assert usage["model"] == "x"

View file

@ -2612,6 +2612,14 @@ def _get_usage(agent) -> dict:
usage["context_max"] = ctx_max
usage["context_percent"] = max(0, min(100, round(ctx_used / ctx_max * 100)))
usage["compressions"] = getattr(comp, "compression_count", 0) or 0
# Live count of background/async subagents still running (delegate_task
# batches + background single delegations). Mirrors the classic CLI status
# bar's ⛓ indicator; sourced from the same async_delegation registry.
try:
from tools.async_delegation import active_count as _async_active_count
usage["active_subagents"] = _async_active_count()
except Exception:
pass
try:
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost

View file

@ -105,6 +105,46 @@ const baseProps = {
voiceLabel: ''
}
describe('StatusRule background-subagent indicator', () => {
it('renders ⛓ N on a wide terminal when subagents are running', () => {
const element = StatusRule({
...baseProps,
usage: { ...baseProps.usage, active_subagents: 3 }
})
expect(textContent(element)).toContain('⛓ 3')
})
it('omits the segment when no subagents are running', () => {
const element = StatusRule({
...baseProps,
usage: { ...baseProps.usage, active_subagents: 0 }
})
expect(textContent(element)).not.toContain('⛓')
})
it('omits the segment when the field is absent', () => {
const element = StatusRule({ ...baseProps })
expect(textContent(element)).not.toContain('⛓')
})
it('drops the subagent segment before the bg segment on a narrow terminal', () => {
// cols=44 is below the subagents breakpoint (92) but the bg breakpoint
// (88) too — both gone. Assert the lower-priority subagent indicator is
// not shown when space is tight even with a live count.
const element = StatusRule({
...baseProps,
cols: 44,
bgCount: 1,
usage: { ...baseProps.usage, active_subagents: 2 }
})
expect(textContent(element)).not.toContain('⛓')
})
})
describe('StatusRule session count click target', () => {
it('makes the live session count itself clickable', () => {
const openSwitcher = vi.fn()

View file

@ -68,6 +68,7 @@ describe('statusBarSegments', () => {
compressions: true,
voice: true,
bg: true,
subagents: true,
cost: true
})
})
@ -89,6 +90,7 @@ describe('statusBarSegments', () => {
'compressions',
'voice',
'bg',
'subagents',
'cost'
]

View file

@ -250,6 +250,7 @@ export interface StatusBarSegments {
compressions: boolean
cost: boolean
duration: boolean
subagents: boolean
voice: boolean
}
@ -263,6 +264,7 @@ export function statusBarSegments(cols: number): StatusBarSegments {
compressions: w >= 80,
voice: w >= 84,
bg: w >= 88,
subagents: w >= 92,
cost: w >= 96
}
}
@ -512,6 +514,8 @@ export function StatusRule({
const showVoice = segs.voice && !!voiceLabel && fits(SEP + stringWidth(voiceLabel))
const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText))
const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`))
const subagentCount = typeof usage.active_subagents === 'number' ? usage.active_subagents : 0
const showSubagents = segs.subagents && subagentCount > 0 && fits(SEP + stringWidth(`${subagentCount}`))
const showCostSeg = segs.cost && showCost && !!costText && fits(SEP + stringWidth(costText))
// No segs flag / no showCost coupling — it's a server-gated dev readout, lowest priority,
// so it consumes tail budget LAST and drops first on a narrow terminal.
@ -619,6 +623,12 @@ export function StatusRule({
{bgCount} bg
</Text>
) : null}
{showSubagents ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{subagentCount}
</Text>
) : null}
{showCostSeg ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}

View file

@ -310,6 +310,7 @@ export interface SessionUndoResponse {
}
export interface SessionUsageResponse {
active_subagents?: number
cache_read?: number
cache_write?: number
calls?: number

View file

@ -167,6 +167,7 @@ export interface SessionInfo {
}
export interface Usage {
active_subagents?: number
calls: number
compressions?: number
context_max?: number