feat(onboarding): port first-touch hints to the TUI (#16054)

PR #16046 added /busy and /verbose hints to the classic CLI and the
gateway runner but skipped the Ink TUI (and therefore the dashboard
/chat page, which embeds the TUI via PTY).  This extends the same
latch to the TUI with TUI-native wording.

The TUI's busy-input model is not the /busy knob from the CLI —
single Enter while busy auto-queues, double Enter on an empty line
interrupts.  The new busy-input hint teaches THAT gesture instead of
telling the user to flip a config that does not apply.

Changes:
- agent/onboarding.py — add busy_input_hint_tui() + tool_progress_hint_tui()
- tui_gateway/server.py — onboarding.claim JSON-RPC (Ink triggers busy
  hint on enqueue) + _maybe_emit_onboarding_hint helper hooked into
  _on_tool_complete for the 30s/tool_progress=all path.  Same
  config.yaml latch so each hint fires at most once per install across
  CLI, gateway, and TUI combined.
- ui-tui/src/gatewayTypes.ts — OnboardingClaimResponse + onboarding.hint event
- ui-tui/src/app/createGatewayEventHandler.ts — render the hint event as sys()
- ui-tui/src/app/useSubmission.ts — claim busy_input_prompt on first
  busy enqueue
- tests/agent/test_onboarding.py — +3 cases for TUI hint shape
- tests/tui_gateway/test_protocol.py — +4 cases for onboarding.claim
- website/docs/user-guide/tui.md — new 'Interrupting and queueing'
  section explaining the TUI's double-Enter model and the hints

Validation:
scripts/run_tests.sh tests/agent/test_onboarding.py \
  tests/tui_gateway/test_protocol.py \
  tests/gateway/test_busy_session_ack.py
  -> 66 passed
npm --prefix ui-tui run type-check -> clean
npm --prefix ui-tui run lint       -> clean
npm --prefix ui-tui run build      -> clean
This commit is contained in:
Teknium
2026-04-26 06:24:19 -07:00
committed by GitHub
parent 1e37ddc929
commit ffd2621039
8 changed files with 291 additions and 2 deletions

View File

@@ -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()

View File

@@ -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"]