From 5a72e82fd8175597a82d4599ae35d20b1fb8fc89 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 29 May 2026 21:28:12 +0530 Subject: [PATCH] feat(tui): nudge toward /agents dashboard when delegation starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/config.py | 5 + .../createGatewayEventHandler.test.ts | 113 +++++++++++++++++- ui-tui/src/app/createGatewayEventHandler.ts | 88 +++++++++++++- ui-tui/src/gatewayTypes.ts | 6 + 4 files changed, 208 insertions(+), 4 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e2c59a694..ff473c235 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1183,6 +1183,11 @@ DEFAULT_CONFIG = { # Mirrors `hermes -c` muscle memory. Default off so existing # users aren't surprised. HERMES_TUI_RESUME= 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, diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 0a3e42273..897875b2c 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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 diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 26d6cfacd..70264b0c7 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 = null + + const getFullConfigOnce = (): Promise => { + fullConfigPromise ??= rpc('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('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() diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ae1f38e9b..447dec3ea 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -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. */