diff --git a/agent/onboarding.py b/agent/onboarding.py index eed832ab90..7b755ef47e 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -80,6 +80,26 @@ def tool_progress_hint_cli() -> str: ) +def busy_input_hint_tui() -> str: + """Hint shown the first time a user sends a message while the TUI is busy. + + The TUI auto-queues messages sent mid-turn and uses double-Enter on empty + input as the interrupt gesture. There is no ``/busy`` knob to flip — this + hint teaches the keybind instead of a command. + """ + return ( + "queued for after the current turn — press Enter twice on an empty " + "line to interrupt the current turn instead. This tip only shows once." + ) + + +def tool_progress_hint_tui() -> str: + return ( + "that tool ran for a while — use /verbose to cycle tool-progress " + "display modes (all → new → off → verbose). This tip only shows once." + ) + + # ------------------------------------------------------------------------- # State read / write # ------------------------------------------------------------------------- @@ -137,8 +157,10 @@ __all__ = [ "TOOL_PROGRESS_FLAG", "busy_input_hint_gateway", "busy_input_hint_cli", + "busy_input_hint_tui", "tool_progress_hint_gateway", "tool_progress_hint_cli", + "tool_progress_hint_tui", "is_seen", "mark_seen", ] diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py index a14c7d1797..ec88c1cc30 100644 --- a/tests/agent/test_onboarding.py +++ b/tests/agent/test_onboarding.py @@ -10,10 +10,12 @@ from agent.onboarding import ( TOOL_PROGRESS_FLAG, busy_input_hint_cli, busy_input_hint_gateway, + busy_input_hint_tui, is_seen, mark_seen, tool_progress_hint_cli, tool_progress_hint_gateway, + tool_progress_hint_tui, ) @@ -128,6 +130,14 @@ class TestHintMessages: def test_tool_progress_hints_mention_verbose(self): assert "/verbose" in tool_progress_hint_gateway() assert "/verbose" in tool_progress_hint_cli() + assert "/verbose" in tool_progress_hint_tui() + + def test_busy_input_hint_tui_teaches_double_enter(self): + msg = busy_input_hint_tui() + # TUI uses double-Enter as the interrupt gesture, not /busy. + assert "Enter" in msg + assert "queued" in msg.lower() + assert "/busy" not in msg def test_hints_are_not_empty(self): for hint in ( @@ -135,8 +145,10 @@ class TestHintMessages: busy_input_hint_gateway("interrupt"), busy_input_hint_cli("queue"), busy_input_hint_cli("interrupt"), + busy_input_hint_tui(), tool_progress_hint_gateway(), tool_progress_hint_cli(), + tool_progress_hint_tui(), ): assert hint.strip() diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 42caaacc58..196e1ee517 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -542,3 +542,94 @@ def test_dispatch_unknown_long_method_still_goes_inline(server): resp = server.dispatch({"id": "r4", "method": "some.method", "params": {}}) assert resp["result"] == {"ok": True} + + +# ── onboarding.claim ───────────────────────────────────────────────── + + +def test_onboarding_claim_rejects_unknown_flag(server): + resp = server.handle_request({ + "id": "o1", + "method": "onboarding.claim", + "params": {"flag": "bogus_flag"}, + }) + assert "error" in resp + assert resp["error"]["code"] == 4002 + assert "unknown onboarding flag" in resp["error"]["message"] + + +def test_onboarding_claim_busy_input_returns_tui_hint(server, tmp_path, monkeypatch): + """First claim returns the TUI hint text and marks the config.yaml flag.""" + monkeypatch.setattr(server, "_hermes_home", tmp_path) + # Bust cached cfg so the new _hermes_home is re-read. + server._cfg_cache = None + server._cfg_mtime = None + + resp = server.handle_request({ + "id": "o2", + "method": "onboarding.claim", + "params": {"flag": "busy_input_prompt"}, + }) + + assert "result" in resp + result = resp["result"] + assert result["claimed"] is True + assert isinstance(result["hint"], str) and result["hint"].strip() + # The TUI hint must teach the double-Enter gesture, not the /busy knob. + assert "Enter" in result["hint"] + assert "/busy" not in result["hint"] + + # config.yaml should now be written with the flag set. + cfg_path = tmp_path / "config.yaml" + assert cfg_path.exists() + import yaml + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"]["busy_input_prompt"] is True + + +def test_onboarding_claim_second_call_returns_null_hint(server, tmp_path, monkeypatch): + """Second claim on the same flag reads config.yaml and returns hint=null.""" + import yaml + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"onboarding": {"seen": {"tool_progress_prompt": True}}}) + ) + monkeypatch.setattr(server, "_hermes_home", tmp_path) + server._cfg_cache = None + server._cfg_mtime = None + + resp = server.handle_request({ + "id": "o3", + "method": "onboarding.claim", + "params": {"flag": "tool_progress_prompt"}, + }) + + assert "result" in resp + assert resp["result"]["claimed"] is False + assert resp["result"]["hint"] is None + + +def test_onboarding_claim_flags_are_independent(server, tmp_path, monkeypatch): + """Claiming one flag does not affect the other.""" + monkeypatch.setattr(server, "_hermes_home", tmp_path) + server._cfg_cache = None + server._cfg_mtime = None + + # Claim busy_input_prompt first + resp1 = server.handle_request({ + "id": "o4a", + "method": "onboarding.claim", + "params": {"flag": "busy_input_prompt"}, + }) + assert resp1["result"]["claimed"] is True + + # tool_progress_prompt must still be claimable. Cache bust because the + # first claim wrote to disk mid-test. + server._cfg_cache = None + server._cfg_mtime = None + resp2 = server.handle_request({ + "id": "o4b", + "method": "onboarding.claim", + "params": {"flag": "tool_progress_prompt"}, + }) + assert resp2["result"]["claimed"] is True + assert "/verbose" in resp2["result"]["hint"] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03631bf174..419a911e76 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1016,6 +1016,64 @@ def _tool_summary(name: str, result: str, duration_s: float | None) -> str | Non return f"{text or 'Completed'}{suffix}" if (text or dur) else None +# ── Onboarding hint emission ───────────────────────────────────────── +# First-touch hints are latched to config.yaml (onboarding.seen.) +# and shared with CLI + gateway so each hint fires at most once per +# install across all surfaces. Best-effort — never raises. + +_ONBOARDING_HINTS_EMITTED: set[str] = set() + + +def _maybe_emit_onboarding_hint(sid: str, flag: str) -> bool: + """Atomically claim an onboarding flag and emit its hint to Ink. + + Returns True if a hint was emitted this call, False if the flag was + already seen (or if anything went wrong — onboarding must never + interrupt the normal event flow). Also deduplicates within a single + process run via ``_ONBOARDING_HINTS_EMITTED`` so concurrent callers + can't double-emit before the config.yaml write lands. + """ + if flag in _ONBOARDING_HINTS_EMITTED: + return False + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + TOOL_PROGRESS_FLAG, + busy_input_hint_tui, + is_seen, + mark_seen, + tool_progress_hint_tui, + ) + except Exception: + return False + + try: + cfg = _load_cfg() + except Exception: + cfg = {} + if is_seen(cfg, flag): + _ONBOARDING_HINTS_EMITTED.add(flag) + return False + + if flag == BUSY_INPUT_FLAG: + hint_text = busy_input_hint_tui() + elif flag == TOOL_PROGRESS_FLAG: + hint_text = tool_progress_hint_tui() + else: + return False + + _ONBOARDING_HINTS_EMITTED.add(flag) + try: + mark_seen(_hermes_home / "config.yaml", flag) + except Exception: + pass + try: + _emit("onboarding.hint", sid, {"flag": flag, "text": hint_text}) + except Exception: + return False + return True + + def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): session = _sessions.get(sid) if session is not None: @@ -1067,6 +1125,20 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result if _tool_progress_enabled(sid) or payload.get("inline_diff"): _emit("tool.complete", sid, payload) + # First-touch onboarding: the first time a tool runs >= 30s in the + # noisiest progress mode ("all"), emit a one-time hint suggesting + # /verbose. Claim is atomic via config.yaml so the hint fires at + # most once per install across CLI + gateway + TUI. + try: + if ( + duration_s is not None + and duration_s >= 30.0 + and _session_tool_progress_mode(sid) == "all" + ): + _maybe_emit_onboarding_hint(sid, "tool_progress_prompt") + except Exception as _hint_err: # pragma: no cover — onboarding is best-effort + logger.debug("tui onboarding tool-progress hint failed: %s", _hint_err) + def _on_tool_progress( sid: str, @@ -1934,6 +2006,53 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "interrupted"}) +# ── Methods: onboarding ────────────────────────────────────────────── +# First-touch hint latch, shared with CLI + gateway via config.yaml +# (``onboarding.seen.``). Ink calls ``onboarding.claim`` the first +# time it hits a behavior fork (busy enqueue, long tool completion); the +# method atomically returns the hint text AND marks the flag seen, so a +# second fast trigger in the same session never double-renders. + +_VALID_ONBOARDING_FLAGS = {"busy_input_prompt", "tool_progress_prompt"} + + +@method("onboarding.claim") +def _(rid, params: dict) -> dict: + flag = str(params.get("flag", "") or "").strip() + if flag not in _VALID_ONBOARDING_FLAGS: + return _err(rid, 4002, f"unknown onboarding flag: {flag}") + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + TOOL_PROGRESS_FLAG, + busy_input_hint_tui, + is_seen, + mark_seen, + tool_progress_hint_tui, + ) + except Exception as e: # pragma: no cover — onboarding is best-effort + return _ok(rid, {"hint": None, "claimed": False, "error": str(e)}) + + cfg = _load_cfg() + if is_seen(cfg, flag): + return _ok(rid, {"hint": None, "claimed": False}) + + if flag == BUSY_INPUT_FLAG: + hint = busy_input_hint_tui() + elif flag == TOOL_PROGRESS_FLAG: + hint = tool_progress_hint_tui() + else: # defensive — validated above + return _err(rid, 4002, f"unknown onboarding flag: {flag}") + + # Mark seen atomically before returning. If persistence fails, still + # return the hint so the user sees it at least once this session. + try: + mark_seen(_hermes_home / "config.yaml", flag) + except Exception: + pass + return _ok(rid, {"hint": hint, "claimed": True}) + + # ── Delegation: subagent tree observability + controls ─────────────── # Powers the TUI's /agents overlay (see ui-tui/src/components/agentsOverlay). # The registry lives in tools/delegate_tool — these handlers are thin diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 15cf00a5a9..0bd2faecf4 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -570,6 +570,17 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: sys(`error: ${message}`) setStatus('ready') } + + return + case 'onboarding.hint': { + const text = String(ev.payload?.text || '').trim() + + if (text) { + sys(`(tip) ${text}`) + } + + return + } } } } diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index f09dc36340..8414126c32 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -3,7 +3,7 @@ import { type MutableRefObject, useCallback, useRef } from 'react' import { attachedImageNotice } from '../domain/messages.js' import { looksLikeSlashCommand } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' -import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' +import type { InputDetectDropResponse, OnboardingClaimResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' import { PASTE_SNIPPET_RE } from '../protocol/paste.js' @@ -218,6 +218,22 @@ export function useSubmission(opts: UseSubmissionOptions) { composerActions.pushHistory(full) if (getUiState().busy) { + // First-touch onboarding: teach the TUI's auto-queue + double-Enter + // interrupt pattern the first time the user hits it. Claim is + // atomic server-side (config.yaml latch), shared with CLI + gateway. + gw.request('onboarding.claim', { flag: 'busy_input_prompt' }) + .then(raw => { + const r = asRpcResult(raw) + const text = r?.hint + + if (typeof text === 'string' && text.trim()) { + sys(`(tip) ${text.trim()}`) + } + }) + .catch(() => { + // Onboarding is best-effort — never block the enqueue path. + }) + return composerActions.enqueue(full) } @@ -229,7 +245,7 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef] + [appendMessage, composerActions, composerRefs, gw, interpolate, send, sendQueued, shellExec, slashRef, sys] ) const submit = useCallback( diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index e64d113c22..ebaa24f2bd 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -174,6 +174,11 @@ export interface PromptSubmitResponse { ok?: boolean } +export interface OnboardingClaimResponse { + claimed?: boolean + hint?: null | string +} + export interface BackgroundStartResponse { task_id?: string } @@ -417,3 +422,4 @@ export type GatewayEvent = type: 'message.complete' } | { payload?: { message?: string }; session_id?: string; type: 'error' } + | { payload: { flag: string; text: string }; session_id?: string; type: 'onboarding.hint' } diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 8c1b179b67..2b936e34e3 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -106,6 +106,18 @@ The TUI's status line tracks agent state in real time: The per-skin status-bar colors and thresholds are shared with the classic CLI — see [Skins](features/skins.md) for customization. +## Interrupting and queueing + +The TUI's busy-input model is different from the classic CLI's `display.busy_input_mode` knob. There is no mode to configure — both behaviors are always available: + +- **Single Enter while busy** — message is **queued** and sent as the next turn after the agent finishes. +- **Double Enter on an empty line while busy** — **interrupts** the current turn. +- **Double Enter on an empty line with queued messages and no running turn** — drains the next queued message. + +The first time you send a message while the agent is working, the TUI prints a one-time `(tip)` line explaining the double-Enter gesture. It fires once per install — the same `onboarding.seen.busy_input_prompt` latch used by the classic CLI and the gateway. Delete that key from `~/.hermes/config.yaml` to see the tip again. + +Similarly, the first time a tool runs for 30 seconds or longer while you're in the noisiest `tool_progress: all` mode, the TUI prints a one-time `(tip)` about `/verbose` for cycling display modes. Latched under `onboarding.seen.tool_progress_prompt`. + ## Configuration The TUI respects all standard Hermes config: `~/.hermes/config.yaml`, profiles, personalities, skins, quick commands, credential pools, memory providers, tool/skill enablement. No TUI-specific config file exists.