Skip to content
Merged
Show file tree
Hide file tree
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
42 changes: 42 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7946,3 +7946,45 @@ def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
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"
8 changes: 8 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions ui-tui/src/__tests__/appChromeStatusRule.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions ui-tui/src/__tests__/statusRule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ describe('statusBarSegments', () => {
compressions: true,
voice: true,
bg: true,
subagents: true,
cost: true
})
})
Expand All @@ -89,6 +90,7 @@ describe('statusBarSegments', () => {
'compressions',
'voice',
'bg',
'subagents',
'cost'
]

Expand Down
10 changes: 10 additions & 0 deletions ui-tui/src/components/appChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ export interface StatusBarSegments {
compressions: boolean
cost: boolean
duration: boolean
subagents: boolean
voice: boolean
}

Expand All @@ -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
}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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">
{' │ '}
Expand Down
1 change: 1 addition & 0 deletions ui-tui/src/gatewayTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ export interface SessionUndoResponse {
}

export interface SessionUsageResponse {
active_subagents?: number
cache_read?: number
cache_write?: number
calls?: number
Expand Down
1 change: 1 addition & 0 deletions ui-tui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export interface SessionInfo {
}

export interface Usage {
active_subagents?: number
calls: number
compressions?: number
context_max?: number
Expand Down
Loading