fix(tui): close slash parity gaps with CLI (#20339)

* fix(tui): close slash parity gaps with CLI

Route unsupported /skills subcommands through slash.exec, support /new <name>
titles, and handle /redraw natively so TUI behavior matches classic CLI. Also
filter gateway-only commands out of the TUI catalog while keeping /status
discoverable.

* fix(tui): run remaining CLI parity paths natively

Forward chat launch flags into the TUI runtime and handle live-session status
and skill reloads in the gateway process so TUI state no longer depends on the
slash worker's stale CLI instance.

* fix(tui): block stale snapshot restores

Prevent snapshot restore from running through the isolated slash worker because
it mutates disk state without refreshing the live TUI agent.

* chore: uptick

* fix(tui): guard async session title updates

Handle failures from the fire-and-forget session.title RPC so title-setting errors do not surface as unhandled promise rejections while preserving session-scoped messaging.
This commit is contained in:
brooklyn! 2026-05-05 13:42:39 -07:00 committed by GitHub
parent acca3ec3af
commit 794f48766c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1266 additions and 284 deletions

File diff suppressed because it is too large Load diff

View file

@ -36,7 +36,14 @@ def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod):
calls.append(source)
return "20260408_235959_a1b2c3" if source == "tui" else None
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
def fake_launch(
resume_session_id=None,
tui_dev=False,
model=None,
provider=None,
toolsets=None,
**kwargs,
):
captured["resume"] = resume_session_id
raise SystemExit(0)
@ -63,7 +70,14 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai
return "20260408_235959_d4e5f6"
return None
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
def fake_launch(
resume_session_id=None,
tui_dev=False,
model=None,
provider=None,
toolsets=None,
**kwargs,
):
captured["resume"] = resume_session_id
raise SystemExit(0)
@ -81,7 +95,14 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai
def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod):
captured = {}
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
def fake_launch(
resume_session_id=None,
tui_dev=False,
model=None,
provider=None,
toolsets=None,
**kwargs,
):
captured["resume"] = resume_session_id
raise SystemExit(0)
@ -99,7 +120,14 @@ def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod)
def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod):
captured = {}
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
def fake_launch(
resume_session_id=None,
tui_dev=False,
model=None,
provider=None,
toolsets=None,
**kwargs,
):
captured.update(
{
"model": model,
@ -130,7 +158,14 @@ def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod):
def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod):
captured = {}
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
def fake_launch(
resume_session_id=None,
tui_dev=False,
model=None,
provider=None,
toolsets=None,
**kwargs,
):
captured["toolsets"] = toolsets
raise SystemExit(0)
@ -142,22 +177,74 @@ def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod):
assert captured["toolsets"] == "web,terminal"
def test_cmd_chat_tui_forwards_chat_flags(monkeypatch, main_mod):
captured = {}
def fake_launch(resume_session_id=None, **kwargs):
captured["resume_session_id"] = resume_session_id
captured.update(kwargs)
raise SystemExit(0)
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
with pytest.raises(SystemExit):
main_mod.cmd_chat(
_args(
skills=["foo,bar"],
verbose=True,
quiet=True,
query="hello",
image="/tmp/cat.png",
worktree=True,
checkpoints=True,
pass_session_id=True,
max_turns=7,
accept_hooks=True,
)
)
assert captured["skills"] == ["foo,bar"]
assert captured["verbose"] is True
assert captured["quiet"] is True
assert captured["query"] == "hello"
assert captured["image"] == "/tmp/cat.png"
assert captured["worktree"] is True
assert captured["checkpoints"] is True
assert captured["pass_session_id"] is True
assert captured["max_turns"] == 7
assert captured["accept_hooks"] is True
def test_main_top_level_tui_accepts_toolsets(monkeypatch, main_mod):
captured = {}
import hermes_cli.config as config_mod
monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "--toolsets", "web,terminal"])
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None))
monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None))
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
types.SimpleNamespace(discover_plugins=lambda: None),
)
monkeypatch.setitem(
sys.modules,
"tools.mcp_tool",
types.SimpleNamespace(discover_mcp_tools=lambda: None),
)
monkeypatch.setattr(config_mod, "load_config", lambda: {})
monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None)
monkeypatch.setitem(
sys.modules,
"agent.shell_hooks",
types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None),
types.SimpleNamespace(
register_from_config=lambda _cfg, accept_hooks=False: None
),
)
monkeypatch.setattr(
main_mod,
"cmd_chat",
lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}),
)
monkeypatch.setattr(main_mod, "cmd_chat", lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}))
main_mod.main()
@ -169,27 +256,49 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
import hermes_cli.config as config_mod
monkeypatch.setattr(sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"])
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None))
monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None))
monkeypatch.setattr(
sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"]
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
types.SimpleNamespace(discover_plugins=lambda: None),
)
monkeypatch.setitem(
sys.modules,
"tools.mcp_tool",
types.SimpleNamespace(discover_mcp_tools=lambda: None),
)
monkeypatch.setattr(config_mod, "load_config", lambda: {})
monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None)
monkeypatch.setitem(
sys.modules,
"agent.shell_hooks",
types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None),
types.SimpleNamespace(
register_from_config=lambda _cfg, accept_hooks=False: None
),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.oneshot",
types.SimpleNamespace(run_oneshot=lambda prompt, **kwargs: captured.update({"prompt": prompt, **kwargs}) or 0),
types.SimpleNamespace(
run_oneshot=lambda prompt, **kwargs: captured.update(
{"prompt": prompt, **kwargs}
)
or 0
),
)
with pytest.raises(SystemExit) as exc:
main_mod.main()
assert exc.value.code == 0
assert captured == {"prompt": "hello", "model": None, "provider": None, "toolsets": "web,terminal"}
assert captured == {
"prompt": "hello",
"model": None,
"provider": None,
"toolsets": "web,terminal",
}
def _stub_plugin_discovery(monkeypatch):
@ -256,7 +365,9 @@ def test_oneshot_accepts_plugin_toolset_after_discovery(monkeypatch):
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})),
types.SimpleNamespace(
discover_plugins=lambda: discovered.update({"ready": True})
),
)
valid, error = _validate_explicit_toolsets("plugin_demo")
@ -328,7 +439,9 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
monkeypatch.setattr(main_mod.subprocess, "call", fake_call)
with pytest.raises(SystemExit):
main_mod._launch_tui(model="nous/hermes-test", provider="nous", toolsets="web, terminal")
main_mod._launch_tui(
model="nous/hermes-test", provider="nous", toolsets="web, terminal"
)
env = captured["env"]
assert env["HERMES_MODEL"] == "nous/hermes-test"

View file

@ -70,9 +70,7 @@ def test_dispatch_rejects_non_object_request():
def test_dispatch_rejects_non_object_params():
resp = server.dispatch(
{"id": "1", "method": "session.create", "params": []}
)
resp = server.dispatch({"id": "1", "method": "session.create", "params": []})
assert resp == {
"jsonrpc": "2.0",
@ -133,12 +131,16 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})
status_resp = server.dispatch(
{"id": "voice-status", "method": "voice.toggle", "params": {"action": "status"}}
{
"id": "voice-status",
"method": "voice.toggle",
"params": {"action": "status"},
}
)
assert status_resp["result"]["record_key"] == "ctrl+b", (
f"voice.record_key fell back to default for voice={bad!r}"
)
assert (
status_resp["result"]["record_key"] == "ctrl+b"
), f"voice.record_key fell back to default for voice={bad!r}"
# Round-4 follow-up: the YAML root itself may be a non-dict. A
# hand-edit that collapses config.yaml to a scalar / list would
@ -148,12 +150,16 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda r=bad_root: r)
status_resp = server.dispatch(
{"id": "voice-status-root", "method": "voice.toggle", "params": {"action": "status"}}
{
"id": "voice-status-root",
"method": "voice.toggle",
"params": {"action": "status"},
}
)
assert status_resp["result"]["record_key"] == "ctrl+b", (
f"voice.record_key fell back to default for root={bad_root!r}"
)
assert (
status_resp["result"]["record_key"] == "ctrl+b"
), f"voice.record_key fell back to default for root={bad_root!r}"
def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
@ -174,7 +180,9 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
monkeypatch.setitem(
sys.modules,
"hermes_cli.voice",
types.SimpleNamespace(start_continuous=fake_start_continuous, stop_continuous=lambda: None),
types.SimpleNamespace(
start_continuous=fake_start_continuous, stop_continuous=lambda: None
),
)
monkeypatch.setenv("HERMES_VOICE", "1")
@ -183,10 +191,16 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})
resp = server.dispatch(
{"id": "voice-record", "method": "voice.record", "params": {"action": "start"}}
{
"id": "voice-record",
"method": "voice.record",
"params": {"action": "start"},
}
)
assert "result" in resp, f"voice.record raised for voice={bad!r}: {resp.get('error')}"
assert (
"result" in resp
), f"voice.record raised for voice={bad!r}: {resp.get('error')}"
assert resp["result"]["status"] == "recording"
assert captured["silence_threshold"] == 200
assert captured["silence_duration"] == 3.0
@ -204,16 +218,20 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda c=bad_bool_cfg: {"voice": c})
resp = server.dispatch(
{"id": "voice-record-bool", "method": "voice.record", "params": {"action": "start"}}
{
"id": "voice-record-bool",
"method": "voice.record",
"params": {"action": "start"},
}
)
assert "result" in resp, f"voice.record raised for bool cfg={bad_bool_cfg!r}"
assert captured["silence_threshold"] == 200, (
f"bool silence_threshold leaked through for {bad_bool_cfg!r}"
)
assert captured["silence_duration"] == 3.0, (
f"bool silence_duration leaked through for {bad_bool_cfg!r}"
)
assert (
captured["silence_threshold"] == 200
), f"bool silence_threshold leaked through for {bad_bool_cfg!r}"
assert (
captured["silence_duration"] == 3.0
), f"bool silence_duration leaked through for {bad_bool_cfg!r}"
def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch):
@ -281,7 +299,9 @@ def test_load_enabled_toolsets_accepts_plugin_env_after_discovery(monkeypatch):
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})),
types.SimpleNamespace(
discover_plugins=lambda: discovered.update({"ready": True})
),
)
assert server._load_enabled_toolsets() == ["plugin_demo"]
@ -302,7 +322,9 @@ def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys):
"read_raw_config",
lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
)
monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}})
monkeypatch.setattr(
config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
)
# Sorted: ["kanban", "memory"]. `kanban` is auto-recovered by
# _get_platform_tools because it's a non-configurable platform toolset
@ -324,7 +346,9 @@ def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, caps
import hermes_cli.config as config_mod
monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}})
monkeypatch.setattr(
config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
)
assert server._load_enabled_toolsets() == ["kanban", "memory"]
assert "using configured CLI toolsets" in capsys.readouterr().err
@ -340,7 +364,9 @@ def test_load_enabled_toolsets_warns_when_config_fallback_fails(monkeypatch, cap
import hermes_cli.config as config_mod
monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
monkeypatch.setattr(
config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))
)
assert server._load_enabled_toolsets() is None
assert "could not be loaded" in capsys.readouterr().err
@ -351,7 +377,9 @@ def test_load_enabled_toolsets_honors_builtin_env_if_config_fails(monkeypatch):
import hermes_cli.config as config_mod
monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
monkeypatch.setattr(
config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))
)
assert server._load_enabled_toolsets() == ["web"]
@ -362,7 +390,9 @@ def test_load_enabled_toolsets_all_env_means_all(monkeypatch):
assert server._load_enabled_toolsets() is None
def test_load_enabled_toolsets_all_env_warns_about_ignored_extra_entries(monkeypatch, capsys):
def test_load_enabled_toolsets_all_env_warns_about_ignored_extra_entries(
monkeypatch, capsys
):
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "all,nope")
assert server._load_enabled_toolsets() is None
@ -1801,9 +1831,7 @@ def test_session_compress_uses_compress_helper(monkeypatch):
emit.assert_any_call("session.info", "sid", {"model": "x"})
# Final status.update clears the pinned "compressing" indicator so the
# status bar can revert to the neutral state when compaction finishes.
emit.assert_any_call(
"status.update", "sid", {"kind": "status", "text": "ready"}
)
emit.assert_any_call("status.update", "sid", {"kind": "status", "text": "ready"})
def test_session_compress_syncs_session_key_after_rotation(monkeypatch):
@ -2050,6 +2078,120 @@ def test_commands_catalog_includes_tui_mouse_command():
assert "/mouse" in tui_pairs
def test_commands_catalog_filters_gateway_only_commands_and_keeps_status_visible():
resp = server.handle_request(
{"id": "1", "method": "commands.catalog", "params": {}}
)
pairs = dict(resp["result"]["pairs"])
canon = resp["result"]["canon"]
assert "/status" in pairs
assert canon["/status"] == "/status"
assert "/topic" not in pairs
assert "/approve" not in pairs
assert "/deny" not in pairs
assert "/sethome" not in pairs
assert "/topic" not in canon
assert "/approve" not in canon
assert "/deny" not in canon
assert "/set-home" not in canon
def test_session_status_reads_live_gateway_agent(monkeypatch):
agent = types.SimpleNamespace(
model="live-model",
provider="live-provider",
session_total_tokens=1234,
)
server._sessions["sid"] = _session(agent=agent, running=True)
class _DB:
def get_session(self, key):
assert key == "session-key"
return {
"title": "Live TUI",
"started_at": 1_700_000_000,
"updated_at": 1_700_000_060,
}
monkeypatch.setattr(server, "_get_db", lambda: _DB())
try:
resp = server.handle_request(
{"id": "1", "method": "session.status", "params": {"session_id": "sid"}}
)
finally:
server._sessions.pop("sid", None)
out = resp["result"]["output"]
assert "Hermes TUI Status" in out
assert "Session ID: session-key" in out
assert "Title: Live TUI" in out
assert "Model: live-model (live-provider)" in out
assert "Tokens: 1,234" in out
assert "Agent Running: Yes" in out
def test_skills_reload_runs_in_gateway_process(monkeypatch):
import agent.skill_commands as skill_commands
called = {}
monkeypatch.setattr(
skill_commands,
"reload_skills",
lambda: called.setdefault(
"result",
{
"added": [{"name": "new-skill", "description": "demo"}],
"removed": [],
"total": 42,
},
),
)
resp = server.handle_request({"id": "1", "method": "skills.reload", "params": {}})
assert called["result"]["total"] == 42
assert "new-skill" in resp["result"]["output"]
assert "42 skill(s) available" in resp["result"]["output"]
def test_snapshot_restore_is_blocked_from_tui_worker():
server._sessions["sid"] = _session()
try:
worker_resp = server.handle_request(
{
"id": "1",
"method": "slash.exec",
"params": {"command": "snapshot restore latest", "session_id": "sid"},
}
)
dispatch_resp = server.handle_request(
{
"id": "2",
"method": "command.dispatch",
"params": {
"arg": "restore latest",
"name": "snapshot",
"session_id": "sid",
},
}
)
finally:
server._sessions.pop("sid", None)
assert worker_resp["error"]["code"] == 4018
assert (
"snapshot restore mutates live config/state" in worker_resp["error"]["message"]
)
assert dispatch_resp["result"]["type"] == "exec"
assert (
"/snapshot restore is blocked in the TUI" in dispatch_resp["result"]["output"]
)
def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
monkeypatch.setattr(
server,
@ -4161,9 +4303,7 @@ def test_reload_env_rpc_calls_hermes_cli_reload_env(monkeypatch):
fake = types.SimpleNamespace(reload_env=_fake_reload)
with patch.dict(sys.modules, {"hermes_cli.config": fake}):
resp = server.handle_request(
{"id": "1", "method": "reload.env", "params": {}}
)
resp = server.handle_request({"id": "1", "method": "reload.env", "params": {}})
assert resp["result"] == {"updated": 7}
assert calls["n"] == 1
@ -4175,9 +4315,7 @@ def test_reload_env_rpc_surfaces_errors(monkeypatch):
fake = types.SimpleNamespace(reload_env=_broken)
with patch.dict(sys.modules, {"hermes_cli.config": fake}):
resp = server.handle_request(
{"id": "1", "method": "reload.env", "params": {}}
)
resp = server.handle_request({"id": "1", "method": "reload.env", "params": {}})
assert "error" in resp
assert "env path locked" in resp["error"]["message"]
@ -4188,7 +4326,9 @@ def test_reload_env_rpc_surfaces_errors(monkeypatch):
def _setup_make_agent_mocks(monkeypatch, cfg):
monkeypatch.setattr(server, "_load_cfg", lambda: cfg)
monkeypatch.setattr(server, "_resolve_startup_runtime", lambda: ("test-model", None))
monkeypatch.setattr(
server, "_resolve_startup_runtime", lambda: ("test-model", None)
)
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda requested=None, target_model=None: {
@ -4219,7 +4359,9 @@ def test_make_agent_reads_nested_max_turns(monkeypatch):
def test_make_agent_nested_max_turns_takes_priority(monkeypatch):
_setup_make_agent_mocks(monkeypatch, {"agent": {"max_turns": 500}, "max_turns": 100})
_setup_make_agent_mocks(
monkeypatch, {"agent": {"max_turns": 500}, "max_turns": 100}
)
with patch("run_agent.AIAgent") as mock_agent:
server._make_agent("sid1", "key1")
@ -4309,6 +4451,8 @@ def test_config_show_displays_nested_max_turns(monkeypatch):
resp = server.handle_request({"id": "1", "method": "config.show", "params": {}})
sections = resp["result"]["sections"]
agent_rows = next(section["rows"] for section in sections if section["title"] == "Agent")
agent_rows = next(
section["rows"] for section in sections if section["title"] == "Agent"
)
assert ["Max Turns", "120"] in agent_rows

View file

@ -5,6 +5,7 @@ Without resolve_runtime_provider(), bare-slug models in config
provider/base_url/api_key empty in AIAgent, causing HTTP 404.
"""
import os
from unittest.mock import MagicMock, patch
@ -97,6 +98,48 @@ def test_make_agent_ignores_display_personality_without_system_prompt():
assert mock_agent.call_args.kwargs["ephemeral_system_prompt"] is None
def test_make_agent_honors_tui_launch_env_flags():
fake_runtime = {
"provider": "openrouter",
"base_url": "https://api.synthetic.new/v1",
"api_key": "sk-test",
"api_mode": "chat_completions",
"command": None,
"args": None,
"credential_pool": None,
}
fake_cfg = {"agent": {"system_prompt": ""}, "model": {"default": "glm-5"}}
with (
patch.dict(
os.environ,
{
"HERMES_TUI_MAX_TURNS": "7",
"HERMES_TUI_CHECKPOINTS": "1",
"HERMES_TUI_PASS_SESSION_ID": "1",
"HERMES_IGNORE_RULES": "1",
},
),
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
),
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent
_make_agent("sid-env", "key-env")
kwargs = mock_agent.call_args.kwargs
assert kwargs["max_iterations"] == 7
assert kwargs["checkpoints_enabled"] is True
assert kwargs["pass_session_id"] is True
assert kwargs["skip_context_files"] is True
assert kwargs["skip_memory"] is True
def test_probe_config_health_flags_null_sections():
"""Bare YAML keys (`agent:` with no value) parse as None and silently
drop nested settings; probe must surface them so users can fix."""

View file

@ -157,7 +157,9 @@ _LONG_HANDLERS = frozenset(
)
try:
_rpc_pool_workers = max(2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "4"))
_rpc_pool_workers = max(
2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "4")
)
except (ValueError, TypeError):
_rpc_pool_workers = 4
_pool = concurrent.futures.ThreadPoolExecutor(
@ -567,7 +569,10 @@ def _start_agent_build(sid: str, session: dict) -> None:
register_gateway_notify,
load_permanent_allowlist,
)
register_gateway_notify(key, lambda data: _emit("approval.request", sid, data))
register_gateway_notify(
key, lambda data: _emit("approval.request", sid, data)
)
notify_registered = True
load_permanent_allowlist()
except Exception:
@ -598,6 +603,7 @@ def _start_agent_build(sid: str, session: dict) -> None:
if notify_registered:
try:
from tools.approval import unregister_gateway_notify
unregister_gateway_notify(key)
except Exception:
pass
@ -877,6 +883,9 @@ def _load_show_reasoning() -> bool:
def _load_tool_progress_mode() -> str:
env = os.environ.get("HERMES_TUI_TOOL_PROGRESS", "").strip().lower()
if env in {"off", "new", "all", "verbose"}:
return env
raw = (_load_cfg().get("display") or {}).get("tool_progress", "all")
if raw is False:
return "off"
@ -938,7 +947,11 @@ def _load_enabled_toolsets() -> list[str] | None:
from hermes_cli.tools_config import _parse_enabled_flag
raw_cfg = read_raw_config()
mcp_servers = raw_cfg.get("mcp_servers") if isinstance(raw_cfg.get("mcp_servers"), dict) else {}
mcp_servers = (
raw_cfg.get("mcp_servers")
if isinstance(raw_cfg.get("mcp_servers"), dict)
else {}
)
for name, server_cfg in mcp_servers.items():
if not isinstance(server_cfg, dict):
continue
@ -952,7 +965,11 @@ def _load_enabled_toolsets() -> list[str] | None:
mcp_valid = [name for name in unresolved if name in mcp_names]
disabled = [name for name in unresolved if name in mcp_disabled]
unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled]
unknown = [
name
for name in unresolved
if name not in mcp_names and name not in mcp_disabled
]
valid = built_in + mcp_valid
if unknown:
@ -973,7 +990,9 @@ def _load_enabled_toolsets() -> list[str] | None:
if valid:
return valid
fallback_notice = "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets"
fallback_notice = (
"[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets"
)
try:
from hermes_cli.config import load_config
@ -1715,10 +1734,28 @@ def _apply_personality_to_session(
def _cfg_max_turns(cfg: dict, default: int) -> int:
try:
env_max = int(os.environ.get("HERMES_TUI_MAX_TURNS", "") or 0)
if env_max > 0:
return env_max
except (TypeError, ValueError):
pass
agent_cfg = cfg.get("agent") or {}
return int(agent_cfg.get("max_turns") or cfg.get("max_turns") or default)
def _parse_tui_skills_env() -> list[str]:
raw = os.environ.get("HERMES_TUI_SKILLS", "")
skills: list[str] = []
seen: set[str] = set()
for part in raw.replace("\n", ",").split(","):
item = part.strip()
if item and item not in seen:
seen.add(item)
skills.append(item)
return skills
def _background_agent_kwargs(agent, task_id: str) -> dict:
cfg = _load_cfg()
@ -1788,6 +1825,20 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
cfg = _load_cfg()
agent_cfg = cfg.get("agent") or {}
system_prompt = (agent_cfg.get("system_prompt", "") or "").strip()
startup_skills = _parse_tui_skills_env()
if startup_skills:
from agent.skill_commands import build_preloaded_skills_prompt
skills_prompt, _loaded_skills, missing_skills = build_preloaded_skills_prompt(
startup_skills,
task_id=session_id or key,
)
if missing_skills:
raise ValueError(f"Unknown skill(s): {', '.join(missing_skills)}")
if skills_prompt:
system_prompt = "\n\n".join(
part for part in (system_prompt, skills_prompt) if part
).strip()
model, requested_provider = _resolve_startup_runtime()
runtime = resolve_runtime_provider(
requested=requested_provider,
@ -1812,6 +1863,10 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
session_id=session_id or key,
session_db=_get_db(),
ephemeral_system_prompt=system_prompt or None,
checkpoints_enabled=is_truthy_value(os.environ.get("HERMES_TUI_CHECKPOINTS")),
pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")),
skip_context_files=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
skip_memory=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
**_agent_cbs(sid),
)
@ -1856,10 +1911,8 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
# prompt_toolkit; the TUI has no equivalent print surface, so without
# this callback the review would write the skill/memory change silently.
try:
agent.background_review_callback = (
lambda message, _sid=sid: _emit(
"review.summary", _sid, {"text": str(message)}
)
agent.background_review_callback = lambda message, _sid=sid: _emit(
"review.summary", _sid, {"text": str(message)}
)
except Exception:
# Bare AIAgents that don't expose the attribute (unlikely, but keep
@ -2269,7 +2322,71 @@ def _(rid, params: dict) -> dict:
if err:
return err
agent = session.get("agent")
return _ok(rid, _get_usage(agent) if agent is not None else {"calls": 0, "input": 0, "output": 0, "total": 0})
return _ok(
rid,
(
_get_usage(agent)
if agent is not None
else {"calls": 0, "input": 0, "output": 0, "total": 0}
),
)
@method("session.status")
def _(rid, params: dict) -> dict:
session, err = _sess_nowait(params, rid)
if err:
return err
from hermes_constants import display_hermes_home
key = session.get("session_key") or params.get("session_id") or ""
agent = session.get("agent")
meta = {}
db = _get_db()
if db and key:
try:
meta = db.get_session(key) or {}
except Exception:
meta = {}
def _dt(value, fallback: datetime | None = None) -> datetime:
if value:
try:
return datetime.fromtimestamp(float(value))
except Exception:
pass
return fallback or datetime.now()
created = _dt(meta.get("started_at"))
updated = created
for field in ("updated_at", "last_updated_at", "last_activity_at"):
if meta.get(field):
updated = _dt(meta.get(field), created)
break
usage = _get_usage(agent) if agent is not None else {}
provider = getattr(agent, "provider", None) or "unknown"
model = getattr(agent, "model", None) or "(unknown)"
lines = [
"Hermes TUI Status",
"",
f"Session ID: {key}",
f"Path: {display_hermes_home()}",
]
title = (meta.get("title") or "").strip()
if title:
lines.append(f"Title: {title}")
lines.extend(
[
f"Model: {model} ({provider})",
f"Created: {created.strftime('%Y-%m-%d %H:%M')}",
f"Last Activity: {updated.strftime('%Y-%m-%d %H:%M')}",
f"Tokens: {int(usage.get('total') or 0):,}",
f"Agent Running: {'Yes' if session.get('running') else 'No'}",
]
)
return _ok(rid, {"output": "\n".join(lines)})
@method("session.history")
@ -2375,7 +2492,9 @@ def _(rid, params: dict) -> dict:
after_count = len(messages)
# Re-read system prompt + tools after compression — _compress_context
# may have rebuilt the system prompt (_cached_system_prompt=None).
_sys_prompt_after = getattr(_agent, "_cached_system_prompt", "") or _sys_prompt
_sys_prompt_after = (
getattr(_agent, "_cached_system_prompt", "") or _sys_prompt
)
_tools_after = getattr(_agent, "tools", None) or _tools
after_tokens = (
estimate_request_tokens_rough(
@ -2823,7 +2942,15 @@ def _(rid, params: dict) -> dict:
def run_after_agent_ready() -> None:
err = _wait_agent(session, rid)
if err:
_emit("error", sid, {"message": err.get("error", {}).get("message", "agent initialization failed")})
_emit(
"error",
sid,
{
"message": err.get("error", {}).get(
"message", "agent initialization failed"
)
},
)
with session["history_lock"]:
session["running"] = False
return
@ -2867,7 +2994,9 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
provider=getattr(agent, "provider", "") or "",
config_context_length=getattr(agent, "_config_context_length", None),
config_context_length=getattr(
agent, "_config_context_length", None
),
)
ctx = preprocess_context_references(
prompt,
@ -3024,18 +3153,14 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
# ("✓ Goal achieved" / "⏸ budget exhausted") is surfaced as
# a system line so the user sees progress regardless of
# outcome. Mirrors gateway/run._post_turn_goal_continuation.
if (
status == "complete"
and isinstance(raw, str)
and raw.strip()
):
if status == "complete" and isinstance(raw, str) and raw.strip():
try:
from hermes_cli.goals import GoalManager
sid_key = session.get("session_key") or ""
if sid_key:
try:
goals_cfg = (_load_cfg().get("goals") or {})
goals_cfg = _load_cfg().get("goals") or {}
goal_max_turns = int(goals_cfg.get("max_turns", 20) or 20)
except Exception:
goal_max_turns = 20
@ -3045,7 +3170,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
)
if goal_mgr.is_active():
decision = goal_mgr.evaluate_after_turn(
raw, user_initiated=True,
raw,
user_initiated=True,
)
verdict_msg = decision.get("message") or ""
if verdict_msg:
@ -3578,7 +3704,9 @@ def _(rid, params: dict) -> dict:
arg = str(value or "").strip().lower()
if arg in ("show", "on"):
cfg = _load_cfg()
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
display = (
cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
)
sections = (
display.get("sections")
if isinstance(display.get("sections"), dict)
@ -3594,7 +3722,9 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"key": key, "value": "show"})
if arg in ("hide", "off"):
cfg = _load_cfg()
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
display = (
cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
)
sections = (
display.get("sections")
if isinstance(display.get("sections"), dict)
@ -3625,7 +3755,9 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4002, f"unknown details_mode: {value}")
cfg = _load_cfg()
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
sections = display.get("sections") if isinstance(display.get("sections"), dict) else {}
sections = (
display.get("sections") if isinstance(display.get("sections"), dict) else {}
)
display["details_mode"] = nv
for section in _DETAIL_SECTION_NAMES:
sections[section] = nv
@ -3952,6 +4084,7 @@ def _(rid, params: dict) -> dict:
if not user_confirm:
try:
from hermes_cli.config import load_config as _load_config
_cfg = _load_config()
_approvals = _cfg.get("approvals") if isinstance(_cfg, dict) else None
_confirm_required = True
@ -3965,15 +4098,18 @@ def _(rid, params: dict) -> dict:
# Ink's ops.ts reads ``status`` and prints ``message`` to
# the transcript; a follow-up invocation with confirm=true
# (or an `always` choice that flips the config) proceeds.
return _ok(rid, {
"status": "confirm_required",
"message": (
"⚠️ /reload-mcp invalidates the prompt cache (next "
"message re-sends full input tokens). Reply `/reload-mcp "
"now` to proceed, or `/reload-mcp always` to proceed and "
"silence this prompt permanently."
),
})
return _ok(
rid,
{
"status": "confirm_required",
"message": (
"⚠️ /reload-mcp invalidates the prompt cache (next "
"message re-sends full input tokens). Reply `/reload-mcp "
"now` to proceed, or `/reload-mcp always` to proceed and "
"silence this prompt permanently."
),
},
)
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools
@ -3989,6 +4125,7 @@ def _(rid, params: dict) -> dict:
if bool(params.get("always", False)):
try:
from cli import save_config_value as _save_cfg
_save_cfg("approvals.mcp_reload_confirm", False)
except Exception as _exc:
logger.warning("Failed to persist mcp_reload_confirm=false: %s", _exc)
@ -4025,7 +4162,6 @@ _TUI_HIDDEN: frozenset[str] = frozenset(
"set-home",
"update",
"commands",
"status",
"approve",
"deny",
}
@ -4051,6 +4187,8 @@ _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset(
}
)
_WORKER_BLOCKED_COMMANDS: frozenset[str] = frozenset({"snapshot", "snap"})
@method("commands.catalog")
def _(rid, params: dict) -> dict:
@ -4069,14 +4207,14 @@ def _(rid, params: dict) -> dict:
cat_order: list[str] = []
for cmd in COMMAND_REGISTRY:
if cmd.name in _TUI_HIDDEN or cmd.gateway_only:
continue
c = f"/{cmd.name}"
canon[c.lower()] = c
for a in cmd.aliases:
canon[f"/{a}".lower()] = c
if cmd.name in _TUI_HIDDEN:
continue
desc = _build_description(cmd)
all_pairs.append([c, desc])
@ -4373,7 +4511,7 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4001, "no session key")
try:
goals_cfg = (_load_cfg().get("goals") or {})
goals_cfg = _load_cfg().get("goals") or {}
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
except Exception:
max_turns = 20
@ -4431,6 +4569,21 @@ def _(rid, params: dict) -> dict:
{"type": "send", "notice": notice, "message": state.goal},
)
if name in ("snapshot", "snap"):
subcommand = arg.split(maxsplit=1)[0].lower() if arg else ""
if subcommand in {"restore", "rewind"}:
return _ok(
rid,
{
"type": "exec",
"output": (
"/snapshot restore is blocked in the TUI because it changes "
"config/state on disk while the live agent has cached settings. "
"Run it in the classic CLI, then restart the TUI."
),
},
)
return _err(rid, 4018, f"not a quick/plugin/skill command: {name}")
@ -4967,6 +5120,7 @@ def _(rid, params: dict) -> dict:
# Build final list in CANONICAL_PROVIDERS order, merging auth data
from hermes_cli.auth import PROVIDER_REGISTRY as _auth_reg
ordered: list = []
for entry in CANONICAL_PROVIDERS:
if entry.slug in authed_map:
@ -4974,24 +5128,30 @@ def _(rid, params: dict) -> dict:
else:
pconfig = _auth_reg.get(entry.slug)
auth_type = pconfig.auth_type if pconfig else "api_key"
key_env = pconfig.api_key_env_vars[0] if (pconfig and pconfig.api_key_env_vars) else ""
key_env = (
pconfig.api_key_env_vars[0]
if (pconfig and pconfig.api_key_env_vars)
else ""
)
if auth_type == "api_key" and key_env:
warning = f"paste {key_env} to activate"
else:
warning = f"run `hermes model` to configure ({auth_type})"
ordered.append({
"slug": entry.slug,
"name": _PROVIDER_LABELS.get(entry.slug, entry.label),
"is_current": entry.slug == current_provider,
"is_user_defined": False,
"models": [],
"total_models": 0,
"source": "built-in",
"authenticated": False,
"auth_type": auth_type,
"key_env": key_env,
"warning": warning,
})
ordered.append(
{
"slug": entry.slug,
"name": _PROVIDER_LABELS.get(entry.slug, entry.label),
"is_current": entry.slug == current_provider,
"is_user_defined": False,
"models": [],
"total_models": 0,
"source": "built-in",
"authenticated": False,
"auth_type": auth_type,
"key_env": key_env,
"warning": warning,
}
)
# Append user-defined/custom providers not in canonical list
ordered.extend(authed_extra)
@ -5037,9 +5197,10 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4002, f"unknown provider: {slug}")
if pconfig.auth_type != "api_key":
return _err(
rid, 4003,
rid,
4003,
f"{pconfig.name} uses {pconfig.auth_type} auth — "
f"run `hermes model` to configure"
f"run `hermes model` to configure",
)
if not pconfig.api_key_env_vars:
return _err(rid, 4004, f"no env var defined for {pconfig.name}")
@ -5049,6 +5210,7 @@ def _(rid, params: dict) -> dict:
save_env_value(env_var, api_key)
# Also set in current process so list_authenticated_providers sees it
import os
os.environ[env_var] = api_key
# Refresh provider data
@ -5132,11 +5294,14 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4005, f"no credentials found for {slug}")
provider_name = pconfig.name if pconfig else slug
return _ok(rid, {
"slug": slug,
"name": provider_name,
"disconnected": True,
})
return _ok(
rid,
{
"slug": slug,
"name": provider_name,
"disconnected": True,
},
)
except Exception as e:
return _err(rid, 5035, str(e))
@ -5222,6 +5387,15 @@ def _(rid, params: dict) -> dict:
rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}"
)
if _cmd_base in _WORKER_BLOCKED_COMMANDS:
subcommand = _cmd_arg.split(maxsplit=1)[0].lower() if _cmd_arg else ""
if subcommand in {"restore", "rewind"}:
return _err(
rid,
4018,
"snapshot restore mutates live config/state; use command.dispatch for /snapshot restore",
)
try:
from agent.skill_commands import get_skill_commands
@ -5471,8 +5645,17 @@ def _(rid, params: dict) -> dict:
voice_cfg = _voice_cfg_dict()
threshold = voice_cfg.get("silence_threshold")
duration = voice_cfg.get("silence_duration")
safe_threshold = threshold if isinstance(threshold, (int, float)) and not isinstance(threshold, bool) else 200
safe_duration = duration if isinstance(duration, (int, float)) and not isinstance(duration, bool) else 3.0
safe_threshold = (
threshold
if isinstance(threshold, (int, float))
and not isinstance(threshold, bool)
else 200
)
safe_duration = (
duration
if isinstance(duration, (int, float)) and not isinstance(duration, bool)
else 3.0
)
start_continuous(
on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}),
on_status=lambda s: _voice_emit("voice.status", {"state": s}),
@ -5772,7 +5955,9 @@ def _browser_connect(rid, params: dict) -> dict:
raw_url = params.get("url")
if raw_url is not None and not isinstance(raw_url, str):
return _err(rid, 4015, f"browser url must be a string, got {type(raw_url).__name__}")
return _err(
rid, 4015, f"browser url must be a string, got {type(raw_url).__name__}"
)
url = (raw_url or "").strip() or DEFAULT_BROWSER_CDP_URL
sid = params.get("session_id") or ""
@ -6225,6 +6410,31 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5024, str(e))
@method("skills.reload")
def _(rid, params: dict) -> dict:
try:
from agent.skill_commands import reload_skills
result = reload_skills()
added = result.get("added") or []
removed = result.get("removed") or []
total = int(result.get("total") or 0)
lines = ["Reloading skills..."]
if not added and not removed:
lines.append("No new skills detected.")
if added:
lines.append("Added skills:")
lines.extend(f" - {item.get('name', '')}" for item in added)
if removed:
lines.append("Removed skills:")
lines.extend(f" - {item.get('name', '')}" for item in removed)
lines.append(f"{total} skill(s) available")
return _ok(rid, {"output": "\n".join(lines), "result": result})
except Exception as e:
return _err(rid, 5025, str(e))
# ── Methods: shell ───────────────────────────────────────────────────

View file

@ -18,6 +18,27 @@ describe('createSlashHandler', () => {
expect(getOverlayState().picker).toBe(true)
})
it('handles /redraw locally without slash worker fallback', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/redraw')).toBe(true)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('ui redrawn')
})
it('routes /status to live session.status instead of slash worker', async () => {
patchUiState({ sid: 'sid-abc' })
const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/status')).toBe(true)
expect(rpc).toHaveBeenCalledWith('session.status', { session_id: 'sid-abc' })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(ctx.transcript.page).toHaveBeenCalledWith('Hermes TUI Status', 'Status')
})
})
it('keeps typed /model switches session-scoped by default', async () => {
patchUiState({ sid: 'sid-abc' })
@ -157,12 +178,49 @@ describe('createSlashHandler', () => {
})
})
it('shows usage for an unknown /skills subcommand', () => {
it('delegates non-native /skills subcommands to slash.exec', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills zzz')
createSlashHandler(ctx)('/skills check')
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills'))
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
command: 'skills check',
session_id: null
})
})
it('passes /new <title> through to the session lifecycle', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/new sprint planning')
getOverlayState().confirm?.onConfirm()
expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', 'sprint planning')
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
})
it('reloads skills in the live gateway and refreshes the catalog', async () => {
const rpc = vi.fn((method: string) => {
if (method === 'skills.reload') {
return Promise.resolve({ output: '42 skill(s) available' })
}
if (method === 'commands.catalog') {
return Promise.resolve({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
}
return Promise.resolve({})
})
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/reload-skills')
expect(rpc).toHaveBeenCalledWith('skills.reload', {})
await vi.waitFor(() => {
expect(ctx.transcript.page).toHaveBeenCalledWith('42 skill(s) available', 'Reload Skills')
expect(ctx.local.setCatalog).toHaveBeenCalledWith(
expect.objectContaining({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
)
})
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
// Regressions from Copilot review on #19835: /voice output + frontend
@ -192,9 +250,7 @@ describe('createSlashHandler', () => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice mode enabled')
expect(ctx.transcript.sys).toHaveBeenCalledWith(' Alt+R to start/stop recording')
})
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(
expect.objectContaining({ ch: 'r', mod: 'alt' })
)
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'r', mod: 'alt' }))
})
it('/voice falls back to Ctrl+B when the gateway response omits record_key', async () => {
@ -447,17 +503,17 @@ describe('createSlashHandler', () => {
local: {
catalog: {
canon: {
'/status': '/status',
'/statusbar': '/statusbar'
'/profile': '/profile',
'/plugins': '/plugins'
}
}
}
})
expect(createSlashHandler(ctx)('/status')).toBe(true)
expect(createSlashHandler(ctx)('/profile')).toBe(true)
await vi.waitFor(() => {
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
command: 'status',
command: 'profile',
session_id: null
})
})
@ -675,7 +731,8 @@ const buildLocal = () => ({
catalog: null,
getHistoryItems: vi.fn(() => []),
getLastUserMsg: vi.fn(() => ''),
maybeWarn: vi.fn()
maybeWarn: vi.fn(),
setCatalog: vi.fn()
})
const buildSession = () => ({

View file

@ -1,5 +1,6 @@
import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js'
import { STREAM_BATCH_MS } from '../config/timing.js'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import { SETUP_REQUIRED_TITLE, buildSetupRequiredSections } from '../content/setup.js'
import type {
CommandsCatalogResponse,
ConfigFullResponse,
@ -64,6 +65,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
let pendingThinkingStatus = ''
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
let startupPromptSubmitted = false
// Inject the disk-save callback into turnController so recordMessageComplete
// can fire-and-forget a persist without having to plumb a gateway ref around.
@ -146,6 +148,36 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}, ms)
}
const scheduleStartupPrompt = () => {
if (startupPromptSubmitted || (!STARTUP_QUERY && !STARTUP_IMAGE)) {
return
}
startupPromptSubmitted = true
setTimeout(async () => {
let sid = getUiState().sid
for (let i = 0; !sid && i < 40; i += 1) {
await new Promise(resolve => setTimeout(resolve, 100))
sid = getUiState().sid
}
if (!sid) {
return sys('startup query skipped: no active session')
}
if (STARTUP_IMAGE) {
try {
await rpc('image.attach', { path: STARTUP_IMAGE, session_id: sid })
} catch (e) {
sys(`startup image attach failed: ${rpcErrorMessage(e)}`)
}
}
submitRef.current(STARTUP_QUERY || 'What do you see in this image?')
}, 0)
}
// Terminal statuses are never overwritten by late-arriving live events —
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
// `failed` or `interrupted` terminal state (Copilot review #14045).
@ -181,6 +213,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (STARTUP_RESUME_ID) {
patchUiState({ status: 'resuming…' })
resumeById(STARTUP_RESUME_ID)
scheduleStartupPrompt()
return
}
@ -196,6 +229,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (!cfg?.config?.display?.tui_auto_resume_recent) {
patchUiState({ status: 'forging session…' })
newSession()
scheduleStartupPrompt()
return
}
@ -206,17 +240,20 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (target) {
patchUiState({ status: 'resuming most recent…' })
resumeById(target)
scheduleStartupPrompt()
return
}
patchUiState({ status: 'forging session…' })
newSession()
scheduleStartupPrompt()
})
})
.catch(() => {
patchUiState({ status: 'forging session…' })
newSession()
scheduleStartupPrompt()
})
}

View file

@ -190,7 +190,7 @@ export interface InputHandlerActions {
die: () => void
dispatchSubmission: (full: string) => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string) => void
newSession: (msg?: string, title?: string) => void
sys: (text: string) => void
}
@ -232,7 +232,7 @@ export interface GatewayEventHandlerContext {
session: {
STARTUP_RESUME_ID: string
colsRef: MutableRefObject<number>
newSession: (msg?: string) => void
newSession: (msg?: string, title?: string) => void
resetSession: () => void
resumeById: (id: string) => void
setCatalog: StateSetter<null | SlashCatalog>
@ -272,12 +272,13 @@ export interface SlashHandlerContext {
getHistoryItems: () => Msg[]
getLastUserMsg: () => string
maybeWarn: (value: unknown) => void
setCatalog: StateSetter<null | SlashCatalog>
}
session: {
closeSession: (targetSid?: null | string) => Promise<unknown>
die: () => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string) => void
newSession: (msg?: string, title?: string) => void
resetVisibleHistory: (info?: null | SessionInfo) => void
resumeById: (id: string) => void
setSessionStartedAt: StateSetter<number>

View file

@ -1,11 +1,14 @@
import { forceRedraw } from '@hermes/ink'
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
import type {
ConfigGetValueResponse,
ConfigSetResponse,
SessionSaveResponse,
SessionStatusResponse,
SessionSteerResponse,
SessionTitleResponse,
SessionUndoResponse
@ -112,16 +115,17 @@ export const coreCommands: SlashCommand[] = [
aliases: ['new'],
help: 'start a new session',
name: 'clear',
run: (_arg, ctx, cmd) => {
run: (arg, ctx, cmd) => {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}
const isNew = cmd.startsWith('/new')
const requestedTitle = isNew ? arg.trim() : ''
const commit = () => {
patchUiState({ status: 'forging session…' })
ctx.session.newSession(isNew ? 'new session started' : undefined)
ctx.session.newSession(isNew ? 'new session started' : undefined, requestedTitle || undefined)
}
if (NO_CONFIRM_DESTRUCTIVE) {
@ -141,6 +145,30 @@ export const coreCommands: SlashCommand[] = [
}
},
{
help: 'force a full UI repaint',
name: 'redraw',
run: (_arg, ctx) => {
forceRedraw(process.stdout)
ctx.transcript.sys('ui redrawn')
}
},
{
help: 'show live session info',
name: 'status',
run: (_arg, ctx) => {
if (!ctx.sid) {
return ctx.transcript.sys('no active session')
}
ctx.gateway
.rpc<SessionStatusResponse>('session.status', { session_id: ctx.sid })
.then(ctx.guarded<SessionStatusResponse>(r => ctx.transcript.page(r.output || '(no status)', 'Status')))
.catch(ctx.guardedErr)
}
},
{
help: 'resume a prior session',
name: 'resume',

View file

@ -1,5 +1,6 @@
import type {
BrowserManageResponse,
CommandsCatalogResponse,
DelegationPauseResponse,
ProcessStopResponse,
ReloadEnvResponse,
@ -56,6 +57,10 @@ interface SkillsBrowseResponse {
total_pages?: number
}
interface SkillsReloadResponse {
output?: string
}
export const opsCommands: SlashCommand[] = [
{
help: 'stop background processes',
@ -435,10 +440,44 @@ export const opsCommands: SlashCommand[] = [
}
},
{
aliases: ['reload_skills'],
help: 're-scan installed skills in the live TUI gateway',
name: 'reload-skills',
run: (_arg, ctx) => {
ctx.gateway
.rpc<SkillsReloadResponse>('skills.reload', {})
.then(
ctx.guarded<SkillsReloadResponse>(r => {
ctx.transcript.page(r.output || 'skills reloaded', 'Reload Skills')
ctx.gateway
.rpc<CommandsCatalogResponse>('commands.catalog', {})
.then(
ctx.guarded<CommandsCatalogResponse>(catalog => {
if (!catalog?.pairs) {
return
}
ctx.local.setCatalog({
canon: (catalog.canon ?? {}) as Record<string, string>,
categories: catalog.categories ?? [],
pairs: catalog.pairs as [string, string][],
skillCount: (catalog.skill_count ?? 0) as number,
sub: (catalog.sub ?? {}) as Record<string, string[]>
})
})
)
.catch(() => {})
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'browse, inspect, install skills',
name: 'skills',
run: (arg, ctx) => {
run: (arg, ctx, cmd) => {
const text = arg.trim()
if (!text) {
@ -449,6 +488,22 @@ export const opsCommands: SlashCommand[] = [
const query = rest.join(' ').trim()
const { rpc } = ctx.gateway
const { panel, sys } = ctx.transcript
const runViaSlashWorker = () => {
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
const body = r?.output || '/skills: no output'
const formatted = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = formatted.length > 180 || formatted.split('\n').filter(Boolean).length > 2
long ? ctx.transcript.page(formatted, 'Skills') : ctx.transcript.sys(formatted)
})
.catch(ctx.guardedErr)
}
if (sub === 'list') {
rpc<SkillsListResponse>('skills.manage', { action: 'list' })
@ -593,7 +648,7 @@ export const opsCommands: SlashCommand[] = [
return
}
sys('usage: /skills [list | inspect <n> | install <n> | search <q> | browse [page]]')
runViaSlashWorker()
}
},

View file

@ -1,4 +1,4 @@
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -16,8 +16,8 @@ import type {
} from '../gatewayTypes.js'
import { useGitBranch } from '../hooks/useGitBranch.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { appendTranscriptMessage } from '../lib/messages.js'
import { composerPromptWidth } from '../lib/inputMetrics.js'
import { appendTranscriptMessage } from '../lib/messages.js'
import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js'
@ -631,7 +631,8 @@ export function useMainApp(gw: GatewayClient) {
catalog,
getHistoryItems: () => historyItemsRef.current,
getLastUserMsg: () => lastUserMsgRef.current,
maybeWarn
maybeWarn,
setCatalog
},
session: {
closeSession: session.closeSession,
@ -723,9 +724,12 @@ export function useMainApp(gw: GatewayClient) {
const anyPanelVisible = SECTION_NAMES.some(
s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
)
const thinkingPanelVisible = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const toolsPanelVisible = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const activityPanelVisible = sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const thinkingPanelVisible =
sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const toolsPanelVisible =
sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const activityPanelVisible =
sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const showProgressArea = useTurnSelector(state =>
anyPanelVisible
@ -738,7 +742,9 @@ export function useMainApp(gw: GatewayClient) {
const hasTrailTools = Boolean(segment.tools?.length)
if (segment.kind === 'trail' && !segment.text) {
return (thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
return (
(thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
)
}
return (

View file

@ -2,7 +2,7 @@ import { writeFileSync } from 'node:fs'
import type { ScrollBoxHandle } from '@hermes/ink'
import { evictInkCaches } from '@hermes/ink'
import { type RefObject, useCallback } from 'react'
import { useCallback, type RefObject } from 'react'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
@ -12,6 +12,7 @@ import type {
SessionCloseResponse,
SessionCreateResponse,
SessionResumeResponse,
SessionTitleResponse,
SetupStatusResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
@ -122,7 +123,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
)
const newSession = useCallback(
async (msg?: string) => {
async (msg?: string, title?: string) => {
const setup = await rpc<SetupStatusResponse>('setup.status', {})
if (setup?.provider_configured === false) {
@ -141,6 +142,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
}
const info = r.info ?? null
const requestedTitle = title?.trim() ?? ''
resetSession()
setSessionStartedAt(Date.now())
@ -168,6 +170,30 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
if (msg) {
sys(msg)
}
if (requestedTitle) {
rpc<SessionTitleResponse>('session.title', {
session_id: r.session_id,
title: requestedTitle
})
.then(result => {
if (!result || getUiState().sid !== r.session_id) {
return
}
const nextTitle = (result.title ?? requestedTitle).trim()
const suffix = result.pending ? ' (queued while session initializes)' : ''
sys(`session title set: ${nextTitle}${suffix}`)
})
.catch((err: unknown) => {
if (getUiState().sid !== r.session_id) {
return
}
const message = err instanceof Error ? err.message : String(err)
sys(`warning: failed to set session title: ${message}`)
})
}
},
[closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
)

View file

@ -1,6 +1,8 @@
const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
export const STARTUP_QUERY = (process.env.HERMES_TUI_QUERY ?? '').trim()
export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
export const MOUSE_TRACKING = !truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)

View file

@ -176,6 +176,10 @@ export interface SessionUsageResponse {
total?: number
}
export interface SessionStatusResponse {
output?: string
}
export interface SessionCompressResponse {
after_messages?: number
after_tokens?: number