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:
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user