mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
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:
parent
da80ac0042
commit
72bfc48e63
7 changed files with 104 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{' │ '}
|
||||
|
|
|
|||
|
|
@ -310,6 +310,7 @@ export interface SessionUndoResponse {
|
|||
}
|
||||
|
||||
export interface SessionUsageResponse {
|
||||
active_subagents?: number
|
||||
cache_read?: number
|
||||
cache_write?: number
|
||||
calls?: number
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ export interface SessionInfo {
|
|||
}
|
||||
|
||||
export interface Usage {
|
||||
active_subagents?: number
|
||||
calls: number
|
||||
compressions?: number
|
||||
context_max?: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue