feat(tui): nudge toward /agents dashboard when delegation starts

The TUI already ships a rich /agents spawn-tree dashboard (live tree,
timeline, per-child tokens/cost/files/tools, kill/pause), but nothing
surfaced it — during delegation the transcript stayed quiet and users
had to already know to type /agents.

Drop a one-time transient activity hint ("subagents working · /agents
to watch live") the first time a turn starts delegating, matching the
existing "· /logs to inspect" house style. Guards keep it unobtrusive:

- fires at most once per turn (resets on message.start)
- silent when the /agents overlay is already open
- gated by display.tui_agents_nudge (default true)

Hooked on subagent.start, not subagent.spawn_requested: the delegate
progress callback in tools/delegate_tool.py only relays start/complete
to the gateway and drops spawn_requested, so start is the first
delegation event the TUI reliably receives. spawn_requested is wired
too for the future case, guarded once-per-turn.

Adds the display.tui_agents_nudge config default and gatewayTypes entry.
This commit is contained in:
kshitijk4poor
2026-05-29 21:28:12 +05:30
parent 6928692cec
commit 5a72e82fd8
4 changed files with 208 additions and 4 deletions

View File

@ -1183,6 +1183,11 @@ DEFAULT_CONFIG = {
# Mirrors `hermes -c` muscle memory. Default off so existing
# users aren't surprised. HERMES_TUI_RESUME=<id> always wins.
"tui_auto_resume_recent": False,
# When true (default), `hermes --tui` drops a one-time hint
# ("subagents working · /agents to watch live") the first time a turn
# starts delegating, nudging the user toward the live spawn-tree
# dashboard. Set false to suppress the hint.
"tui_agents_nudge": True,
"bell_on_complete": False,
"show_reasoning": False,
"streaming": False,

View File

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { turnController } from '../app/turnController.js'
import { getTurnState, resetTurnState } from '../app/turnStore.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
@ -897,6 +897,117 @@ describe('createGatewayEventHandler', () => {
expect(getTurnState().subagents.find(s => s.id === 'sa-weird')?.status).toBe('completed')
})
it('nudges toward /agents on the first spawn_requested of a turn', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.spawn_requested'
} as any)
const hints = getTurnState().activity.filter(a => a.text.includes('/agents'))
expect(hints).toHaveLength(1)
expect(hints[0]).toMatchObject({ tone: 'info' })
})
it('nudges toward /agents on subagent.start (spawn_requested dropped in CLI path)', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// In the real CLI→gateway path the delegate callback drops
// spawn_requested, so `start` is the first event the TUI sees.
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
})
it('nudges at most once per turn and resets on the next message.start', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Multiple spawns in one turn → a single hint.
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
onEvent({
payload: { goal: 'child b', subagent_id: 'sa-b', task_index: 1 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
// New turn clears activity AND the once-per-turn guard → nudges again.
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: { goal: 'child c', subagent_id: 'sa-c', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
})
it('does not nudge when the /agents overlay is already open', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// User already has the dashboard open → nothing to advertise.
patchOverlayState({ agents: true })
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)
})
it('nudges if the /agents overlay is closed mid-turn while delegation continues', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Overlay open on the first delegation event → suppressed, but the
// turn's nudge credit must NOT be burned (the user is watching).
patchOverlayState({ agents: true })
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)
// User closes the dashboard mid-turn → the next delegation event nudges.
patchOverlayState({ agents: false })
onEvent({
payload: { goal: 'child b', subagent_id: 'sa-b', task_index: 1 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
})
it('does not nudge when display.tui_agents_nudge is false', async () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
// config.get → full returns the disable flag.
ctx.gateway.rpc = vi.fn(async (method: string) =>
method === 'config.get' ? { config: { display: { tui_agents_nudge: false } } } : null
)
const onEvent = createGatewayEventHandler(ctx)
// Eager config fetch fires at creation; let it resolve before any spawn
// (mirrors real usage — config lands well before the first delegation).
await Promise.resolve()
await Promise.resolve()
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)
})
it('drops stale reasoning/tool/todos events after ctrl-c until the next message starts', () => {
// Repro for the discord report: ctrl-c interrupts, but late reasoning/tool
// events from the still-winding-down agent loop kept populating the UI for

View File

@ -17,7 +17,7 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { getOverlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { getUiState, patchUiState } from './uiStore.js'
@ -123,6 +123,78 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// render a /warning close to the configured cap without spamming the RPC.
let lastDelegationFetchAt = 0
// ── Shared full-config read ──────────────────────────────────────────
//
// Several concerns need `display.*` flags at startup (the /agents nudge
// gate below, the auto-resume check in the `gateway.ready` handler).
// Memoize the `config.get full` RPC so we make exactly one round-trip
// instead of one per concern. Resolves to null on RPC failure; callers
// treat null as "use defaults".
let fullConfigPromise: null | Promise<ConfigFullResponse | null> = null
const getFullConfigOnce = (): Promise<ConfigFullResponse | null> => {
fullConfigPromise ??= rpc<ConfigFullResponse>('config.get', { key: 'full' }).catch(() => null)
return fullConfigPromise
}
// ── Nudge toward /agents on delegation ───────────────────────────────
//
// When `display.tui_agents_nudge` is enabled (default true), the first
// time a turn starts delegating we drop a single transient activity hint
// ("subagents working · /agents to watch live") so the user discovers the
// spawn-tree dashboard instead of staring at a quiet transcript — without
// hijacking the screen by force-opening an overlay. Guards:
// • fires at most once per turn (`agentsNudgedThisTurn`)
// • silent if the overlay is already open (nothing to advertise)
// Reset on `message.start`. The config flag is fetched once, lazily;
// until it resolves we assume the default (on).
let agentsNudgeEnabled = true
let agentsNudgeConfigFetched = false
let agentsNudgedThisTurn = false
const ensureAgentsNudgeConfig = () => {
if (agentsNudgeConfigFetched) {
return
}
agentsNudgeConfigFetched = true
getFullConfigOnce().then(cfg => {
// Only an explicit `false` disables it; absent/unknown keeps default on.
if (cfg?.config?.display?.tui_agents_nudge === false) {
agentsNudgeEnabled = false
}
})
}
const maybeNudgeAgents = () => {
ensureAgentsNudgeConfig()
if (!agentsNudgeEnabled || agentsNudgedThisTurn) {
return
}
// Already watching → no point advertising the dashboard. Don't burn the
// turn's nudge credit here: if the user closes the overlay later in the
// same turn while delegation is still ongoing, a subsequent event should
// still be allowed to nudge. The flag is only set once we actually push.
if (getOverlayState().agents) {
return
}
agentsNudgedThisTurn = true
turnController.pushActivity('subagents working · /agents to watch live', 'info')
}
const resetAgentsNudgeTurnState = () => {
agentsNudgedThisTurn = false
}
// Kick off the config fetch eagerly at handler creation so the flag is
// resolved well before the first delegation of any real session (which
// only happens after gateway.ready + a user turn).
ensureAgentsNudgeConfig()
const refreshDelegationStatus = (force = false) => {
const now = Date.now()
@ -244,8 +316,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// forging a brand-new one. Mirrors classic CLI's `hermes -c` /
// `hermes --tui` muscle memory and addresses the audit's "session
// unrecoverable after disconnection" gap. Default off so existing
// users aren't surprised.
rpc<ConfigFullResponse>('config.get', { key: 'full' })
// users aren't surprised. (Shares the memoized full-config read.)
getFullConfigOnce()
.then(cfg => {
if (!cfg?.config?.display?.tui_auto_resume_recent) {
patchUiState({ status: 'forging session…' })
@ -332,6 +404,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
case 'message.start':
resetAgentsNudgeTurnState()
turnController.startMessage()
return
@ -618,6 +691,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// Preserve completed state if a later event races in before this one.
turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'queued' }))
// First sign of delegation this turn → nudge toward /agents.
maybeNudgeAgents()
// Prime the status-bar HUD: fetch caps (once every 5s) so we can
// warn as depth/concurrency approaches the configured ceiling.
if (getDelegationState().maxSpawnDepth === null) {
@ -631,6 +707,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
case 'subagent.start':
turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'running' }))
// `subagent.start` is the first delegation event the TUI reliably
// receives (the delegate callback drops `spawn_requested` in the
// CLI→gateway path), so nudge here too. Once-per-turn guarded, so
// hooking both events is safe.
maybeNudgeAgents()
return
case 'subagent.thinking': {
const text = String(ev.payload.text ?? '').trim()

View File

@ -62,6 +62,12 @@ export interface ConfigDisplayConfig {
show_reasoning?: boolean
streaming?: boolean
thinking_mode?: string
/**
* Nudge the user toward the /agents spawn-tree dashboard the first time a
* turn starts delegating, via a one-time transient activity hint. Opens
* nothing — just advertises the command. Default true.
*/
tui_agents_nudge?: boolean
tui_auto_resume_recent?: boolean
tui_compact?: boolean
/** Legacy alias for display.mouse_tracking. */