From e9c47c70422dc0a3367e325c6974b09d2c5bf9cc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 13:21:59 -0500 Subject: [PATCH 01/12] fix(tui): honor launch model overrides --- hermes_cli/main.py | 19 +++++-- tests/hermes_cli/test_tui_resume_flow.py | 64 ++++++++++++++++++++++-- tests/test_tui_gateway_server.py | 34 +++++++++++++ tui_gateway/server.py | 44 ++++++++++++++-- 4 files changed, 152 insertions(+), 9 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e821e9bac0..5ba269c223 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1028,7 +1028,12 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: return [node, str(root / "dist" / "entry.js")], root -def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False): +def _launch_tui( + resume_session_id: Optional[str] = None, + tui_dev: bool = False, + model: Optional[str] = None, + provider: Optional[str] = None, +): """Replace current process with the TUI.""" tui_dir = PROJECT_ROOT / "ui-tui" @@ -1038,6 +1043,12 @@ def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False): ) env.setdefault("HERMES_PYTHON", sys.executable) env.setdefault("HERMES_CWD", os.getcwd()) + if model: + env["HERMES_MODEL"] = model + env["HERMES_INFERENCE_MODEL"] = model + if provider: + env["HERMES_TUI_PROVIDER"] = provider + env["HERMES_INFERENCE_PROVIDER"] = provider # Guarantee an 8GB V8 heap + exposed GC for the TUI. Default node cap is # ~1.5–4GB depending on version and can fatal-OOM on long sessions with # large transcripts / reasoning blobs. Token-level merge: respect any @@ -1176,6 +1187,8 @@ def cmd_chat(args): _launch_tui( getattr(args, "resume", None), tui_dev=getattr(args, "tui_dev", False), + model=getattr(args, "model", None), + provider=getattr(args, "provider", None), ) # Import and run the CLI @@ -6913,7 +6926,7 @@ For more help on a command: default=None, help=( "Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). " - "Applies to -z/--oneshot. Also settable via HERMES_INFERENCE_MODEL env var." + "Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var." ), ) parser.add_argument( @@ -6921,7 +6934,7 @@ For more help on a command: default=None, help=( "Provider override for this invocation (e.g. openrouter, anthropic). " - "Applies to -z/--oneshot. Also settable via HERMES_INFERENCE_PROVIDER env var." + "Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var." ), ) parser.add_argument( diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index c7e551ea1c..6044b04a4b 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -1,4 +1,5 @@ from argparse import Namespace +from pathlib import Path import sys import types @@ -8,8 +9,11 @@ import pytest def _args(**overrides): base = { "continue_last": None, + "model": None, + "provider": None, "resume": None, "tui": True, + "tui_dev": False, } base.update(overrides) return Namespace(**base) @@ -31,7 +35,7 @@ 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): + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None): captured["resume"] = resume_session_id raise SystemExit(0) @@ -58,7 +62,7 @@ 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): + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None): captured["resume"] = resume_session_id raise SystemExit(0) @@ -76,7 +80,7 @@ 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): + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None): captured["resume"] = resume_session_id raise SystemExit(0) @@ -89,6 +93,60 @@ def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod) assert captured["resume"] == "20260409_000000_aa11bb" +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): + captured.update( + { + "model": model, + "provider": provider, + "resume": resume_session_id, + "tui_dev": tui_dev, + } + ) + raise SystemExit(0) + + monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) + + with pytest.raises(SystemExit): + main_mod.cmd_chat( + _args(model="anthropic/claude-sonnet-4.6", provider="anthropic") + ) + + assert captured == { + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "resume": None, + "tui_dev": False, + } + + +def test_launch_tui_exports_model_and_provider(monkeypatch, main_mod): + captured = {} + + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), + ) + + def fake_call(argv, cwd=None, env=None): + captured.update({"argv": argv, "cwd": cwd, "env": env}) + return 1 + + monkeypatch.setattr(main_mod.subprocess, "call", fake_call) + + with pytest.raises(SystemExit): + main_mod._launch_tui(model="nous/hermes-test", provider="nous") + + env = captured["env"] + assert env["HERMES_MODEL"] == "nous/hermes-test" + assert env["HERMES_INFERENCE_MODEL"] == "nous/hermes-test" + assert env["HERMES_TUI_PROVIDER"] == "nous" + assert env["HERMES_INFERENCE_PROVIDER"] == "nous" + + def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys): import hermes_cli.main as main_mod diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 107d238977..4b4e837c5e 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -83,6 +83,40 @@ def test_status_callback_accepts_single_message_argument(): ) +def test_resolve_model_uses_inference_model_env(monkeypatch): + monkeypatch.delenv("HERMES_MODEL", raising=False) + monkeypatch.setenv("HERMES_INFERENCE_MODEL", "anthropic/claude-sonnet-4.6") + + assert server._resolve_model() == "anthropic/claude-sonnet-4.6" + + +def test_startup_runtime_uses_tui_provider_env(monkeypatch): + monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test") + monkeypatch.setenv("HERMES_TUI_PROVIDER", "nous") + monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) + + assert server._resolve_startup_runtime() == ("nous/hermes-test", "nous") + + +def test_startup_runtime_detects_provider_for_model_env(monkeypatch): + monkeypatch.setenv("HERMES_MODEL", "sonnet") + monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False) + monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) + monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}}) + + def fake_detect(model, current_provider): + assert model == "sonnet" + assert current_provider == "auto" + return "anthropic", "anthropic/claude-sonnet-4.6" + + monkeypatch.setattr("hermes_cli.models.detect_provider_for_model", fake_detect) + + assert server._resolve_startup_runtime() == ( + "anthropic/claude-sonnet-4.6", + "anthropic", + ) + + def _session(agent=None, **extra): return { "agent": agent if agent is not None else types.SimpleNamespace(), diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 891b6128e3..f7a0dd08ee 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -560,7 +560,7 @@ def resolve_skin() -> dict: def _resolve_model() -> str: - env = os.environ.get("HERMES_MODEL", "") + env = os.environ.get("HERMES_MODEL", "") or os.environ.get("HERMES_INFERENCE_MODEL", "") if env: return env m = _load_cfg().get("model", "") @@ -571,6 +571,40 @@ def _resolve_model() -> str: return "anthropic/claude-sonnet-4" +def _resolve_startup_runtime() -> tuple[str, str | None]: + model = _resolve_model() + explicit_provider = ( + os.environ.get("HERMES_TUI_PROVIDER", "") + or os.environ.get("HERMES_INFERENCE_PROVIDER", "") + ).strip() + if explicit_provider: + return model, explicit_provider + + explicit_model = ( + os.environ.get("HERMES_MODEL", "") + or os.environ.get("HERMES_INFERENCE_MODEL", "") + ).strip() + if not explicit_model: + return model, None + + try: + from hermes_cli.models import detect_provider_for_model + + cfg = _load_cfg().get("model") or {} + current_provider = ( + str(cfg.get("provider") or "").strip().lower() + if isinstance(cfg, dict) + else "" + ) or "auto" + detected = detect_provider_for_model(explicit_model, current_provider) + if detected: + provider, detected_model = detected + return detected_model, provider + except Exception: + pass + return model, None + + def _write_config_key(key_path: str, value): cfg = _load_cfg() current = cfg @@ -1277,9 +1311,13 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): cfg = _load_cfg() system_prompt = ((cfg.get("agent") or {}).get("system_prompt", "") or "").strip() - runtime = resolve_runtime_provider(requested=None) + model, requested_provider = _resolve_startup_runtime() + runtime = resolve_runtime_provider( + requested=requested_provider, + target_model=model or None, + ) return AIAgent( - model=_resolve_model(), + model=model, provider=runtime.get("provider"), base_url=runtime.get("base_url"), api_key=runtime.get("api_key"), From 57b43fdd4bf9496d4dc3e8e70df382c61f8e18e8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 13:25:43 -0500 Subject: [PATCH 02/12] fix(tui): preserve provider precedence on startup --- tests/test_tui_gateway_server.py | 8 ++++++++ tui_gateway/server.py | 7 ++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 4b4e837c5e..1daf77bd2f 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -98,6 +98,14 @@ def test_startup_runtime_uses_tui_provider_env(monkeypatch): assert server._resolve_startup_runtime() == ("nous/hermes-test", "nous") +def test_startup_runtime_does_not_treat_inference_provider_as_explicit(monkeypatch): + monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test") + monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False) + monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "nous") + + assert server._resolve_startup_runtime() == ("nous/hermes-test", None) + + def test_startup_runtime_detects_provider_for_model_env(monkeypatch): monkeypatch.setenv("HERMES_MODEL", "sonnet") monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f7a0dd08ee..fa01a68fb4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -573,10 +573,7 @@ def _resolve_model() -> str: def _resolve_startup_runtime() -> tuple[str, str | None]: model = _resolve_model() - explicit_provider = ( - os.environ.get("HERMES_TUI_PROVIDER", "") - or os.environ.get("HERMES_INFERENCE_PROVIDER", "") - ).strip() + explicit_provider = os.environ.get("HERMES_TUI_PROVIDER", "").strip() if explicit_provider: return model, explicit_provider @@ -595,7 +592,7 @@ def _resolve_startup_runtime() -> tuple[str, str | None]: str(cfg.get("provider") or "").strip().lower() if isinstance(cfg, dict) else "" - ) or "auto" + ) or os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip().lower() or "auto" detected = detect_provider_for_model(explicit_model, current_provider) if detected: provider, detected_model = detected From 4db58d45d4e06fc819b8ff6729548bd9d7d02a8a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 13:29:15 -0500 Subject: [PATCH 03/12] fix(tui): address startup provider review --- tests/test_tui_gateway_server.py | 61 +++++++++++++++++++++++++++++++- tui_gateway/server.py | 11 ++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 1daf77bd2f..43a368ce81 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -85,11 +85,21 @@ def test_status_callback_accepts_single_message_argument(): def test_resolve_model_uses_inference_model_env(monkeypatch): monkeypatch.delenv("HERMES_MODEL", raising=False) - monkeypatch.setenv("HERMES_INFERENCE_MODEL", "anthropic/claude-sonnet-4.6") + monkeypatch.setenv("HERMES_INFERENCE_MODEL", " anthropic/claude-sonnet-4.6\n") assert server._resolve_model() == "anthropic/claude-sonnet-4.6" +def test_resolve_model_strips_config_model(monkeypatch): + monkeypatch.delenv("HERMES_MODEL", raising=False) + monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False) + monkeypatch.setattr( + server, "_load_cfg", lambda: {"model": {"default": " nous/hermes-test "}} + ) + + assert server._resolve_model() == "nous/hermes-test" + + def test_startup_runtime_uses_tui_provider_env(monkeypatch): monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test") monkeypatch.setenv("HERMES_TUI_PROVIDER", "nous") @@ -457,6 +467,55 @@ def test_config_set_model_syncs_inference_provider_env(monkeypatch): assert os.environ["HERMES_INFERENCE_PROVIDER"] == "anthropic" +def test_config_set_model_syncs_tui_provider_env(monkeypatch): + class Agent: + model = "gpt-5.3-codex" + provider = "openai-codex" + base_url = "" + api_key = "" + + def switch_model(self, **kwargs): + self.model = kwargs["new_model"] + self.provider = kwargs["new_provider"] + + agent = Agent() + server._sessions["sid"] = _session(agent=agent) + monkeypatch.setenv("HERMES_TUI_PROVIDER", "openai-codex") + monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + + def fake_switch_model(**kwargs): + return types.SimpleNamespace( + success=True, + new_model="anthropic/claude-sonnet-4.6", + target_provider="anthropic", + api_key="key", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + warning_message="", + ) + + monkeypatch.setattr("hermes_cli.model_switch.switch_model", fake_switch_model) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "model", + "value": "anthropic/claude-sonnet-4.6 --provider anthropic", + }, + } + ) + + assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6" + assert os.environ["HERMES_TUI_PROVIDER"] == "anthropic" + finally: + server._sessions.clear() + + def test_config_set_personality_rejects_unknown_name(monkeypatch): monkeypatch.setattr( server, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index fa01a68fb4..9f188de645 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -560,14 +560,17 @@ def resolve_skin() -> dict: def _resolve_model() -> str: - env = os.environ.get("HERMES_MODEL", "") or os.environ.get("HERMES_INFERENCE_MODEL", "") + env = ( + os.environ.get("HERMES_MODEL", "") + or os.environ.get("HERMES_INFERENCE_MODEL", "") + ).strip() if env: return env m = _load_cfg().get("model", "") if isinstance(m, dict): - return m.get("default", "") + return str(m.get("default", "") or "").strip() if isinstance(m, str) and m: - return m + return m.strip() return "anthropic/claude-sonnet-4" @@ -773,6 +776,8 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: # original one persisted in config or env. if result.target_provider: os.environ["HERMES_INFERENCE_PROVIDER"] = result.target_provider + if os.environ.get("HERMES_TUI_PROVIDER"): + os.environ["HERMES_TUI_PROVIDER"] = result.target_provider if persist_global: _persist_model_switch(result) return {"value": result.new_model, "warning": result.warning_message or ""} From 2dfcc8087a8e9e8a653be102c69dd85ae78ddccf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 13:47:18 -0500 Subject: [PATCH 04/12] fix(tui): avoid network lookup during startup --- tests/test_tui_gateway_server.py | 21 ++++++++++++++- tui_gateway/server.py | 46 +++++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 43a368ce81..1610c52939 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -112,6 +112,9 @@ def test_startup_runtime_does_not_treat_inference_provider_as_explicit(monkeypat monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test") monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False) monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "nous") + monkeypatch.setattr( + server, "_detect_static_provider_for_model", lambda model, provider: None + ) assert server._resolve_startup_runtime() == ("nous/hermes-test", None) @@ -127,7 +130,7 @@ def test_startup_runtime_detects_provider_for_model_env(monkeypatch): assert current_provider == "auto" return "anthropic", "anthropic/claude-sonnet-4.6" - monkeypatch.setattr("hermes_cli.models.detect_provider_for_model", fake_detect) + monkeypatch.setattr(server, "_detect_static_provider_for_model", fake_detect) assert server._resolve_startup_runtime() == ( "anthropic/claude-sonnet-4.6", @@ -135,6 +138,22 @@ def test_startup_runtime_detects_provider_for_model_env(monkeypatch): ) +def test_startup_runtime_does_not_call_network_detector(monkeypatch): + monkeypatch.setenv("HERMES_MODEL", "sonnet") + monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False) + monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) + monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}}) + monkeypatch.setattr( + "hermes_cli.models.detect_provider_for_model", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("network detector called")), + ) + + model, provider = server._resolve_startup_runtime() + + assert model + assert provider in {None, "anthropic"} + + def _session(agent=None, **extra): return { "agent": agent if agent is not None else types.SimpleNamespace(), diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 9f188de645..557fec19c2 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -574,6 +574,48 @@ def _resolve_model() -> str: return "anthropic/claude-sonnet-4" +def _detect_static_provider_for_model(model_name: str, current_provider: str) -> tuple[str, str] | None: + """Startup-safe provider detection: static catalogs only, no network fetches.""" + name = (model_name or "").strip() + if not name: + return None + + try: + from hermes_cli.models import ( + _PROVIDER_ALIASES, + _PROVIDER_LABELS, + _PROVIDER_MODELS, + normalize_provider, + ) + except Exception: + return None + + name_lower = name.lower() + normalized_current = normalize_provider(current_provider) + resolved_provider = _PROVIDER_ALIASES.get(name_lower, name_lower) + if resolved_provider not in {"custom", "openrouter"}: + default_models = _PROVIDER_MODELS.get(resolved_provider, []) + if ( + resolved_provider in _PROVIDER_LABELS + and default_models + and resolved_provider != normalized_current + ): + return resolved_provider, default_models[0] + + aggregators = {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"} + current_models = _PROVIDER_MODELS.get(normalized_current, []) + if any(name_lower == m.lower() for m in current_models): + return None + + for provider, models in _PROVIDER_MODELS.items(): + if provider == normalized_current or provider in aggregators: + continue + if any(name_lower == m.lower() for m in models): + return provider, name + + return None + + def _resolve_startup_runtime() -> tuple[str, str | None]: model = _resolve_model() explicit_provider = os.environ.get("HERMES_TUI_PROVIDER", "").strip() @@ -588,15 +630,13 @@ def _resolve_startup_runtime() -> tuple[str, str | None]: return model, None try: - from hermes_cli.models import detect_provider_for_model - cfg = _load_cfg().get("model") or {} current_provider = ( str(cfg.get("provider") or "").strip().lower() if isinstance(cfg, dict) else "" ) or os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip().lower() or "auto" - detected = detect_provider_for_model(explicit_model, current_provider) + detected = _detect_static_provider_for_model(explicit_model, current_provider) if detected: provider, detected_model = detected return detected_model, provider From e48a497d166bfc32743d503fd00136c9140af5fe Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 13:56:16 -0500 Subject: [PATCH 05/12] fix(tui): share static model detection --- hermes_cli/models.py | 99 +++++++++++++++----------------- tests/test_tui_gateway_server.py | 11 +++- tui_gateway/server.py | 58 ++++--------------- 3 files changed, 66 insertions(+), 102 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 3a902ffdf5..494c3d2a9a 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1379,27 +1379,35 @@ def curated_models_for_provider( return [(m, "") for m in models] -def detect_provider_for_model( +def _provider_keys(provider: str) -> set[str]: + key = (provider or "").strip().lower() + normalized = normalize_provider(provider) + return {k for k in (key, normalized) if k} + + +def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool: + return any( + name_lower == model.lower() + for provider in providers + for model in _PROVIDER_MODELS.get(provider, []) + ) + + +def detect_static_provider_for_model( model_name: str, current_provider: str, ) -> Optional[tuple[str, str]]: - """Auto-detect the best provider for a model name. + """Auto-detect a provider from static catalogs only. Returns ``(provider_id, model_name)`` — the model name may be remapped - (e.g. bare ``deepseek-chat`` → ``deepseek/deepseek-chat`` for OpenRouter). Returns ``None`` when no confident match is found. - - Priority: - 0. Bare provider name → switch to that provider's default model - 1. Direct provider with credentials (highest) - 2. Direct provider without credentials → remap to OpenRouter slug - 3. OpenRouter catalog match """ name = (model_name or "").strip() if not name: return None name_lower = name.lower() + current_keys = _provider_keys(current_provider) # --- Step 0: bare provider name typed as model --- # If someone types `/model nous` or `/model anthropic`, treat it as a @@ -1412,7 +1420,7 @@ def detect_provider_for_model( if ( resolved_provider in _PROVIDER_LABELS and default_models - and resolved_provider != normalize_provider(current_provider) + and resolved_provider not in current_keys ): return (resolved_provider, default_models[0]) @@ -1420,56 +1428,43 @@ def detect_provider_for_model( _AGGREGATORS = {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"} # If the model belongs to the current provider's catalog, don't suggest switching - current_models = _PROVIDER_MODELS.get(current_provider, []) - if any(name_lower == m.lower() for m in current_models): + if _model_in_provider_catalog(name_lower, current_keys): return None # --- Step 1: check static provider catalogs for a direct match --- - direct_match: Optional[str] = None for pid, models in _PROVIDER_MODELS.items(): - if pid == current_provider or pid in _AGGREGATORS: + if pid in current_keys or pid in _AGGREGATORS: continue if any(name_lower == m.lower() for m in models): - direct_match = pid - break + return (pid, name) - if direct_match: - # Check if we have credentials for this provider — env vars, - # credential pool, or auth store entries. - has_creds = False - try: - from hermes_cli.auth import PROVIDER_REGISTRY - pconfig = PROVIDER_REGISTRY.get(direct_match) - if pconfig: - for env_var in pconfig.api_key_env_vars: - if os.getenv(env_var, "").strip(): - has_creds = True - break - except Exception: - pass - # Also check credential pool and auth store — covers OAuth, - # Claude Code tokens, and other non-env-var credentials (#10300). - if not has_creds: - try: - from agent.credential_pool import load_pool - pool = load_pool(direct_match) - if pool.has_credentials(): - has_creds = True - except Exception: - pass - if not has_creds: - try: - from hermes_cli.auth import _load_auth_store - store = _load_auth_store() - if direct_match in store.get("providers", {}) or direct_match in store.get("credential_pool", {}): - has_creds = True - except Exception: - pass + return None - # Always return the direct provider match. If credentials are - # missing, the client init will give a clear error rather than - # silently routing through the wrong provider (#10300). - return (direct_match, name) + +def detect_provider_for_model( + model_name: str, + current_provider: str, +) -> Optional[tuple[str, str]]: + """Auto-detect the best provider for a model name. + + Returns ``(provider_id, model_name)`` — the model name may be remapped + (e.g. bare ``deepseek-chat`` → ``deepseek/deepseek-chat`` for OpenRouter). + Returns ``None`` when no confident match is found. + + Priority: + 0. Bare provider name → switch to that provider's default model + 1. Direct provider static catalog match + 2. OpenRouter catalog match + """ + name = (model_name or "").strip() + if not name: + return None + + static_match = detect_static_provider_for_model(name, current_provider) + if static_match: + return static_match + if _model_in_provider_catalog(name.lower(), _provider_keys(current_provider)): + return None # --- Step 2: check OpenRouter catalog --- # First try exact match (handles provider/model format) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 1610c52939..8bb6f003bf 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -113,7 +113,8 @@ def test_startup_runtime_does_not_treat_inference_provider_as_explicit(monkeypat monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False) monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "nous") monkeypatch.setattr( - server, "_detect_static_provider_for_model", lambda model, provider: None + "hermes_cli.models.detect_static_provider_for_model", + lambda model, provider: None, ) assert server._resolve_startup_runtime() == ("nous/hermes-test", None) @@ -130,7 +131,9 @@ def test_startup_runtime_detects_provider_for_model_env(monkeypatch): assert current_provider == "auto" return "anthropic", "anthropic/claude-sonnet-4.6" - monkeypatch.setattr(server, "_detect_static_provider_for_model", fake_detect) + monkeypatch.setattr( + "hermes_cli.models.detect_static_provider_for_model", fake_detect + ) assert server._resolve_startup_runtime() == ( "anthropic/claude-sonnet-4.6", @@ -145,7 +148,9 @@ def test_startup_runtime_does_not_call_network_detector(monkeypatch): monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}}) monkeypatch.setattr( "hermes_cli.models.detect_provider_for_model", - lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("network detector called")), + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("network detector called") + ), ) model, provider = server._resolve_startup_runtime() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 557fec19c2..7f981663a3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -574,48 +574,6 @@ def _resolve_model() -> str: return "anthropic/claude-sonnet-4" -def _detect_static_provider_for_model(model_name: str, current_provider: str) -> tuple[str, str] | None: - """Startup-safe provider detection: static catalogs only, no network fetches.""" - name = (model_name or "").strip() - if not name: - return None - - try: - from hermes_cli.models import ( - _PROVIDER_ALIASES, - _PROVIDER_LABELS, - _PROVIDER_MODELS, - normalize_provider, - ) - except Exception: - return None - - name_lower = name.lower() - normalized_current = normalize_provider(current_provider) - resolved_provider = _PROVIDER_ALIASES.get(name_lower, name_lower) - if resolved_provider not in {"custom", "openrouter"}: - default_models = _PROVIDER_MODELS.get(resolved_provider, []) - if ( - resolved_provider in _PROVIDER_LABELS - and default_models - and resolved_provider != normalized_current - ): - return resolved_provider, default_models[0] - - aggregators = {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"} - current_models = _PROVIDER_MODELS.get(normalized_current, []) - if any(name_lower == m.lower() for m in current_models): - return None - - for provider, models in _PROVIDER_MODELS.items(): - if provider == normalized_current or provider in aggregators: - continue - if any(name_lower == m.lower() for m in models): - return provider, name - - return None - - def _resolve_startup_runtime() -> tuple[str, str | None]: model = _resolve_model() explicit_provider = os.environ.get("HERMES_TUI_PROVIDER", "").strip() @@ -630,13 +588,19 @@ def _resolve_startup_runtime() -> tuple[str, str | None]: return model, None try: + from hermes_cli.models import detect_static_provider_for_model + cfg = _load_cfg().get("model") or {} current_provider = ( - str(cfg.get("provider") or "").strip().lower() - if isinstance(cfg, dict) - else "" - ) or os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip().lower() or "auto" - detected = _detect_static_provider_for_model(explicit_model, current_provider) + ( + str(cfg.get("provider") or "").strip().lower() + if isinstance(cfg, dict) + else "" + ) + or os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip().lower() + or "auto" + ) + detected = detect_static_provider_for_model(explicit_model, current_provider) if detected: provider, detected_model = detected return detected_model, provider From 5e52011de363992247cae6ecff3cb125616b0406 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 13:58:59 -0500 Subject: [PATCH 06/12] fix(tui): bind provider as model alias --- hermes_cli/commands.py | 3 ++- tests/test_tui_gateway_server.py | 8 ++++++++ ui-tui/src/__tests__/createSlashHandler.test.ts | 8 ++++++++ ui-tui/src/app/slash/commands/session.ts | 1 + ui-tui/src/app/slash/commands/setup.ts | 12 ------------ 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index efff57180e..4d650487b4 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -103,7 +103,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ # Configuration CommandDef("config", "Show current configuration", "Configuration", cli_only=True), - CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--provider name] [--global]"), + CommandDef("model", "Switch model for this session", "Configuration", + aliases=("provider",), args_hint="[model] [--provider name] [--global]"), CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info", cli_only=True), diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 8bb6f003bf..4fd2322b5b 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -321,6 +321,14 @@ def test_setup_status_reports_provider_config(monkeypatch): assert resp["result"]["provider_configured"] is False +def test_complete_slash_includes_provider_alias(): + resp = server.handle_request( + {"id": "1", "method": "complete.slash", "params": {"text": "/pro"}} + ) + + assert any(item["text"] == "provider" for item in resp["result"]["items"]) + + def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch): monkeypatch.setattr(server, "_hermes_home", tmp_path) agent = types.SimpleNamespace(reasoning_config=None) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index eba1d56d86..9a255f704c 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -17,6 +17,14 @@ describe('createSlashHandler', () => { expect(getOverlayState().picker).toBe(true) }) + it('treats /provider as a local /model alias', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/provider')).toBe(true) + expect(getOverlayState().modelPicker).toBe(true) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + }) + it('opens the skills hub locally for bare /skills', () => { const ctx = buildCtx() diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index cf36fee6c8..1049ee34d8 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -58,6 +58,7 @@ export const sessionCommands: SlashCommand[] = [ { help: 'change or show model', + aliases: ['provider'], name: 'model', run: (arg, ctx) => { if (ctx.session.guardBusySessionSwitch('change models')) { diff --git a/ui-tui/src/app/slash/commands/setup.ts b/ui-tui/src/app/slash/commands/setup.ts index d9a948e541..bf1a29c685 100644 --- a/ui-tui/src/app/slash/commands/setup.ts +++ b/ui-tui/src/app/slash/commands/setup.ts @@ -5,18 +5,6 @@ import { runExternalSetup } from '../../setupHandoff.js' import type { SlashCommand } from '../types.js' export const setupCommands: SlashCommand[] = [ - { - help: 'configure LLM provider + model (launches `hermes model`)', - name: 'provider', - run: (_arg, ctx) => - void runExternalSetup({ - args: ['model'], - ctx, - done: 'provider updated — starting session…', - launcher: launchHermesCommand, - suspend: withInkSuspended - }) - }, { help: 'run full setup wizard (launches `hermes setup`)', name: 'setup', From 48bdd2445e8e794a91141fe4f583a0486f65dc4a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 14:08:54 -0500 Subject: [PATCH 07/12] fix(tui): apply ui-tui fix pass and restore type-check - run the requested ui-tui lint+format pass and include resulting formatting updates - guard text-measure cache eviction key in hermes-ink so ui-tui type-check stays green --- .../src/ink/components/AlternateScreen.tsx | 6 +++++- ui-tui/packages/hermes-ink/src/ink/dom.ts | 14 ++++++++++++- .../hermes-ink/src/ink/termio/osc.test.ts | 21 +++++++++++-------- .../createGatewayEventHandler.test.ts | 5 ++++- .../src/__tests__/createSlashHandler.test.ts | 4 +--- ui-tui/src/__tests__/details.test.ts | 2 +- ui-tui/src/app.tsx | 2 +- ui-tui/src/app/slash/commands/core.ts | 12 ++++++----- ui-tui/src/app/turnController.ts | 1 + ui-tui/src/app/uiStore.ts | 2 +- ui-tui/src/app/useInputHandlers.ts | 16 +++++++------- ui-tui/src/app/useMainApp.ts | 16 +++++++------- ui-tui/src/components/appChrome.tsx | 6 +----- ui-tui/src/components/messageLine.tsx | 5 ++--- ui-tui/src/components/thinking.tsx | 17 +++++++++++---- ui-tui/src/lib/platform.ts | 6 ++---- 16 files changed, 79 insertions(+), 56 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx index f5fb660bed..6bf9f513aa 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx @@ -53,7 +53,11 @@ export function AlternateScreen(t0: Props) { } writeRaw( - ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING) + ENTER_ALT_SCREEN + + ERASE_SCROLLBACK + + ERASE_SCREEN + + CURSOR_HOME + + (mouseTracking ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING) ) ink?.setAltScreenActive(true, mouseTracking) diff --git a/ui-tui/packages/hermes-ink/src/ink/dom.ts b/ui-tui/packages/hermes-ink/src/ink/dom.ts index 9ff1be4119..1a1ad4af49 100644 --- a/ui-tui/packages/hermes-ink/src/ink/dom.ts +++ b/ui-tui/packages/hermes-ink/src/ink/dom.ts @@ -323,27 +323,38 @@ const measureTextNode = function ( widthMode: LayoutMeasureMode ): { width: number; height: number } { const elem = node.nodeName !== '#text' ? (node as DOMElement) : node.parentNode + if (elem && elem.nodeName === 'ink-text') { let cache = elem._textMeasureCache + if (!cache) { cache = { gen: 0, entries: new Map() } elem._textMeasureCache = cache } + const key = `${width}|${widthMode}` const hit = cache.entries.get(key) + if (hit && hit._gen === cache.gen) { return hit.result } + const result = computeTextMeasure(node, width, widthMode) + // Enforce cap with FIFO eviction to avoid unbounded growth during // pathological frames where yoga probes many widths. if (cache.entries.size >= MEASURE_CACHE_CAP) { const firstKey = cache.entries.keys().next().value - cache.entries.delete(firstKey) + if (firstKey !== undefined) { + cache.entries.delete(firstKey) + } } + cache.entries.set(key, { _gen: cache.gen, result }) + return result } + return computeTextMeasure(node, width, widthMode) } @@ -475,6 +486,7 @@ export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => { for (const child of node.childNodes) { clearYogaNodeReferences(child) } + node._textMeasureCache = undefined } diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts index 02ea9ebd2c..4860544479 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts @@ -9,18 +9,21 @@ describe('shouldEmitClipboardSequence', () => { }) it('keeps OSC enabled for remote or plain local terminals', () => { - expect(shouldEmitClipboardSequence({ SSH_CONNECTION: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)).toBe( - true - ) + expect( + shouldEmitClipboardSequence({ SSH_CONNECTION: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv) + ).toBe(true) expect(shouldEmitClipboardSequence({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true) }) it('honors explicit env override', () => { - expect(shouldEmitClipboardSequence({ HERMES_TUI_CLIPBOARD_OSC52: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)).toBe( - true - ) - expect(shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe( - false - ) + expect( + shouldEmitClipboardSequence({ + HERMES_TUI_CLIPBOARD_OSC52: '1', + TMUX: '/tmp/tmux-1/default,1,0' + } as NodeJS.ProcessEnv) + ).toBe(true) + expect( + shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv) + ).toBe(false) }) }) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index f8d88a50f6..991c87a1c6 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -226,7 +226,10 @@ describe('createGatewayEventHandler', () => { const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```' - onEvent({ payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any) + onEvent({ + payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' }, + type: 'tool.complete' + } as any) onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any) expect(appended).toHaveLength(1) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 9a255f704c..3df9f2818c 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -126,9 +126,7 @@ describe('createSlashHandler', () => { const ctx = buildCtx() createSlashHandler(ctx)('/details tools blink') expect(getUiState().sections.tools).toBeUndefined() - expect(ctx.transcript.sys).toHaveBeenCalledWith( - 'usage: /details
[hidden|collapsed|expanded|reset]' - ) + expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /details
[hidden|collapsed|expanded|reset]') }) it('shows tool enable usage when names are missing', () => { diff --git a/ui-tui/src/__tests__/details.test.ts b/ui-tui/src/__tests__/details.test.ts index 15ef681dc2..0f567b2f72 100644 --- a/ui-tui/src/__tests__/details.test.ts +++ b/ui-tui/src/__tests__/details.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isSectionName, parseDetailsMode, resolveSections, sectionMode, SECTION_NAMES } from '../domain/details.js' +import { isSectionName, parseDetailsMode, resolveSections, SECTION_NAMES, sectionMode } from '../domain/details.js' describe('parseDetailsMode', () => { it('accepts the canonical modes case-insensitively', () => { diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 522e982958..5fc0ba9888 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,8 +1,8 @@ import { useStore } from '@nanostores/react' import { GatewayProvider } from './app/gatewayContext.js' -import { useMainApp } from './app/useMainApp.js' import { $uiState } from './app/uiStore.js' +import { useMainApp } from './app/useMainApp.js' import { AppLayout } from './components/appLayout.js' import type { GatewayClient } from './gatewayClient.js' diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index a252d8d453..518eb668a5 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,7 +1,7 @@ import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' -import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js' +import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js' import type { ConfigGetValueResponse, ConfigSetResponse, @@ -40,8 +40,10 @@ const flagFromArg = (arg: string, current: boolean): boolean | null => { const RESET_WORDS = new Set(['reset', 'clear', 'default']) const CYCLE_WORDS = new Set(['cycle', 'toggle']) + const DETAILS_USAGE = 'usage: /details [hidden|collapsed|expanded|cycle] or /details
[hidden|collapsed|expanded|reset]' + const DETAILS_SECTION_USAGE = 'usage: /details
[hidden|collapsed|expanded|reset]' export const coreCommands: SlashCommand[] = [ @@ -97,9 +99,7 @@ export const coreCommands: SlashCommand[] = [ } patchUiState({ mouseTracking: next }) - ctx.gateway - .rpc('config.set', { key: 'mouse', value: next ? 'on' : 'off' }) - .catch(() => {}) + ctx.gateway.rpc('config.set', { key: 'mouse', value: next ? 'on' : 'off' }).catch(() => {}) queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next ? 'on' : 'off'}`)) } @@ -178,7 +178,9 @@ export const coreCommands: SlashCommand[] = [ gateway .rpc('config.get', { key: 'details_mode' }) .then(r => { - if (ctx.stale()) return + if (ctx.stale()) { + return + } const mode = parseDetailsMode(r?.value) ?? ui.detailsMode patchUiState({ detailsMode: mode }) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index f45cab241c..1041b4d4f5 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -300,6 +300,7 @@ class TurnController { const hasDiffSegment = segments.some(msg => msg.kind === 'diff') const detailsBelongBeforeDiff = hasDiffSegment && (tools.length > 0 || Boolean(savedReasoning)) + const finalMessages = detailsBelongBeforeDiff ? insertBeforeFirstDiff(segments, { kind: 'trail', diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 260b26ab5a..fc17a6948f 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -1,8 +1,8 @@ import { atom } from 'nanostores' +import { MOUSE_TRACKING } from '../config/env.js' import { ZERO } from '../domain/usage.js' import { DEFAULT_THEME } from '../theme.js' -import { MOUSE_TRACKING } from '../config/env.js' import type { UiState } from './interfaces.js' diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 47fe8a2166..294a44ca6f 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -159,16 +159,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { voice.setProcessing(false) } - gateway - .rpc('voice.record', { action }) - .catch((e: Error) => { - // Revert optimistic UI on failure. - if (starting) { - voice.setRecording(false) - } + gateway.rpc('voice.record', { action }).catch((e: Error) => { + // Revert optimistic UI on failure. + if (starting) { + voice.setRecording(false) + } - actions.sys(`voice error: ${e.message}`) - }) + actions.sys(`voice error: ${e.message}`) + }) } useInput((ch, key) => { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index d2e5494a95..0230e0b1fd 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -640,14 +640,14 @@ export function useMainApp(gw: GatewayClient) { const showProgressArea = anyPanelVisible ? Boolean( ui.busy || - turn.outcome || - turn.streamPendingTools.length || - turn.streamSegments.length || - turn.subagents.length || - turn.tools.length || - turn.turnTrail.length || - hasReasoning || - turn.activity.length + turn.outcome || + turn.streamPendingTools.length || + turn.streamSegments.length || + turn.subagents.length || + turn.tools.length || + turn.turnTrail.length || + hasReasoning || + turn.activity.length ) : turn.activity.some(item => item.tone !== 'info') diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 7b697eedce..001c89b91f 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -218,11 +218,7 @@ export function StatusRule({ {voiceLabel ? ( {' │ '} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 3fc40528a3..fc6f78e924 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,8 +1,8 @@ import { Ansi, Box, NoSelect, Text } from '@hermes/ink' import { memo } from 'react' -import { sectionMode } from '../domain/details.js' import { LONG_MSG } from '../config/limits.js' +import { sectionMode } from '../domain/details.js' import { userDisplay } from '../domain/messages.js' import { ROLE } from '../domain/roles.js' import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js' @@ -72,8 +72,7 @@ export const MessageLine = memo(function MessageLine({ const { body, glyph, prefix } = ROLE[msg.role](t) const showDetails = - (toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || - (thinkingMode !== 'hidden' && Boolean(thinking)) + (toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking)) const content = (() => { if (msg.kind === 'slash') { diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index e2cfc47663..2d52102b51 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,5 +1,5 @@ import { Box, NoSelect, Text } from '@hermes/ink' -import { memo, useEffect, useMemo, useState, type ReactNode } from 'react' +import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { THINKING_COT_MAX } from '../config/limits.js' @@ -919,13 +919,22 @@ export const ToolTrail = memo(function ToolTrail({ // hidden sections stay hidden so the override is honoured. const expandAll = () => { - if (visible.thinking !== 'hidden') setOpenThinking(true) - if (visible.tools !== 'hidden') setOpenTools(true) + if (visible.thinking !== 'hidden') { + setOpenThinking(true) + } + + if (visible.tools !== 'hidden') { + setOpenTools(true) + } + if (visible.subagents !== 'hidden') { setOpenSubagents(true) setDeepSubagents(true) } - if (visible.activity !== 'hidden') setOpenMeta(true) + + if (visible.activity !== 'hidden') { + setOpenMeta(true) + } } const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index 9e85da16f8..6913df4bc8 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -43,7 +43,5 @@ export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean }, * accept Cmd+B (the platform action modifier) so existing macOS muscle memory * keeps working. */ -export const isVoiceToggleKey = ( - key: { ctrl: boolean; meta: boolean; super?: boolean }, - ch: string -): boolean => (key.ctrl || isActionMod(key)) && ch.toLowerCase() === 'b' +export const isVoiceToggleKey = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string): boolean => + (key.ctrl || isActionMod(key)) && ch.toLowerCase() === 'b' From fdcbd2257b2dc6962066aedf61db738b51c69d68 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 14:13:02 -0500 Subject: [PATCH 08/12] fix(tui): resolve startup model aliases statically - expand short model aliases like sonnet/opus via static catalogs during startup runtime resolution - keep startup alias resolution network-free and add regression tests in models and tui gateway suites --- hermes_cli/models.py | 61 ++++++++++++++++++++++++++++++-- tests/hermes_cli/test_models.py | 11 ++++++ tests/test_tui_gateway_server.py | 18 ++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 494c3d2a9a..93a5111c56 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1393,6 +1393,59 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool: ) +_AGGREGATOR_PROVIDERS = frozenset( + {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"} +) + + +def _resolve_static_model_alias( + name_lower: str, + current_keys: set[str], +) -> Optional[tuple[str, str]]: + """Resolve short aliases (e.g. sonnet/opus) using static catalogs only.""" + try: + from hermes_cli.model_switch import MODEL_ALIASES + except Exception: + return None + + identity = MODEL_ALIASES.get(name_lower) + if identity is None: + return None + + vendor = identity.vendor + family = identity.family + + def _match(provider: str) -> Optional[str]: + models = _PROVIDER_MODELS.get(provider, []) + if not models: + return None + prefix = ( + f"{vendor}/{family}" + if provider in _AGGREGATOR_PROVIDERS + else family + ).lower() + for model in models: + if model.lower().startswith(prefix): + return model + return None + + for provider in current_keys: + if matched := _match(provider): + return provider, matched + + for provider in _PROVIDER_MODELS: + if provider in current_keys or provider in _AGGREGATOR_PROVIDERS: + continue + if matched := _match(provider): + return provider, matched + + for provider in _AGGREGATOR_PROVIDERS: + if provider in current_keys and (matched := _match(provider)): + return provider, matched + + return None + + def detect_static_provider_for_model( model_name: str, current_provider: str, @@ -1409,6 +1462,10 @@ def detect_static_provider_for_model( name_lower = name.lower() current_keys = _provider_keys(current_provider) + alias_match = _resolve_static_model_alias(name_lower, current_keys) + if alias_match: + return alias_match + # --- Step 0: bare provider name typed as model --- # If someone types `/model nous` or `/model anthropic`, treat it as a # provider switch and pick the first model from that provider's catalog. @@ -1425,15 +1482,13 @@ def detect_static_provider_for_model( return (resolved_provider, default_models[0]) # Aggregators list other providers' models — never auto-switch TO them - _AGGREGATORS = {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"} - # If the model belongs to the current provider's catalog, don't suggest switching if _model_in_provider_catalog(name_lower, current_keys): return None # --- Step 1: check static provider catalogs for a direct match --- for pid, models in _PROVIDER_MODELS.items(): - if pid in current_keys or pid in _AGGREGATORS: + if pid in current_keys or pid in _AGGREGATOR_PROVIDERS: continue if any(name_lower == m.lower() for m in models): return (pid, name) diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index b493fd2b63..d0201a3e80 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -256,6 +256,17 @@ class TestDetectProviderForModel: """Models belonging to the current provider should not trigger a switch.""" assert detect_provider_for_model("gpt-5.3-codex", "openai-codex") is None + def test_short_alias_resolves_to_static_model(self): + """Short aliases (e.g. sonnet) should resolve without network lookups.""" + with patch( + "hermes_cli.models.fetch_openrouter_models", + side_effect=AssertionError("network lookup should not run"), + ): + result = detect_provider_for_model("sonnet", "auto") + assert result is not None + assert result[0] == "anthropic" + assert result[1].startswith("claude-sonnet") + def test_openrouter_slug_match(self): """Models in the OpenRouter catalog should be found.""" with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS): diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 4fd2322b5b..d4cdab8421 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -141,6 +141,24 @@ def test_startup_runtime_detects_provider_for_model_env(monkeypatch): ) +def test_startup_runtime_resolves_short_alias_without_network(monkeypatch): + monkeypatch.setenv("HERMES_MODEL", "sonnet") + monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False) + monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) + monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}}) + monkeypatch.setattr( + "hermes_cli.models.fetch_openrouter_models", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("network lookup should not run") + ), + ) + + model, provider = server._resolve_startup_runtime() + + assert provider == "anthropic" + assert model.startswith("claude-sonnet") + + def test_startup_runtime_does_not_call_network_detector(monkeypatch): monkeypatch.setenv("HERMES_MODEL", "sonnet") monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False) From a046483e86016a78feddf9801fdb93f85c53837a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 14:17:04 -0500 Subject: [PATCH 09/12] fix(tui): share overlay close controls - add reusable overlay key and help-text helpers for picker-style overlays - make model, session, skills, and pager hints consistently support Esc/q close behavior --- ui-tui/packages/hermes-ink/src/ink/dom.ts | 1 + ui-tui/src/components/appOverlays.tsx | 9 ++-- ui-tui/src/components/modelPicker.tsx | 36 +++++++------- ui-tui/src/components/overlayControls.tsx | 41 ++++++++++++++++ ui-tui/src/components/sessionPicker.tsx | 14 +++--- ui-tui/src/components/skillsHub.tsx | 58 ++++++++++++----------- 6 files changed, 102 insertions(+), 57 deletions(-) create mode 100644 ui-tui/src/components/overlayControls.tsx diff --git a/ui-tui/packages/hermes-ink/src/ink/dom.ts b/ui-tui/packages/hermes-ink/src/ink/dom.ts index 1a1ad4af49..938f01f814 100644 --- a/ui-tui/packages/hermes-ink/src/ink/dom.ts +++ b/ui-tui/packages/hermes-ink/src/ink/dom.ts @@ -345,6 +345,7 @@ const measureTextNode = function ( // pathological frames where yoga probes many widths. if (cache.entries.size >= MEASURE_CACHE_CAP) { const firstKey = cache.entries.keys().next().value + if (firstKey !== undefined) { cache.entries.delete(firstKey) } diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 331fb58733..9e1b6ded30 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -9,6 +9,7 @@ import { $uiState } from '../app/uiStore.js' import { FloatBox } from './appChrome.js' import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' +import { OverlayControls } from './overlayControls.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { SessionPicker } from './sessionPicker.js' import { SkillsHub } from './skillsHub.js' @@ -162,11 +163,11 @@ export function FloatingOverlays({ ))} - + {overlay.pager.offset + pagerPageSize < overlay.pager.lines.length - ? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})` - : `end · ↑↓/jk · b/PgUp back · g top · q close (${overlay.pager.lines.length} lines)`} - + ? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})` + : `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`} + diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 7927f3b736..3e5c8c3648 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -7,6 +7,8 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes. import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' +import { OverlayControls, useOverlayKeys } from './overlayControls.js' + const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 @@ -71,20 +73,20 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const models = provider?.models ?? [] const names = useMemo(() => providerDisplayNames(providers), [providers]) - useInput((ch, key) => { - if (key.escape) { - if (stage === 'model') { - setStage('provider') - setModelIdx(0) - - return - } - - onCancel() + const back = () => { + if (stage === 'model') { + setStage('provider') + setModelIdx(0) return } + onCancel() + } + + useOverlayKeys({ onBack: back, onClose: onCancel }) + + useInput((ch, key) => { const count = stage === 'provider' ? providers.length : models.length const sel = stage === 'provider' ? providerIdx : modelIdx const setSel = stage === 'provider' ? setProviderIdx : setModelIdx @@ -155,7 +157,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return ( error: {err} - Esc to cancel + Esc/q cancel ) } @@ -164,7 +166,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return ( no authenticated providers - Esc to cancel + Esc/q cancel ) } @@ -221,9 +223,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke persist: {persistGlobal ? 'global' : 'session'} · g toggle - - ↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel - + ↑/↓ select · Enter choose · 1-9,0 quick · Esc/q cancel ) } @@ -283,9 +283,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke persist: {persistGlobal ? 'global' : 'session'} · g toggle - - {models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back' : 'Enter/Esc back'} - + + {models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back · q close' : 'Enter/Esc back · q close'} + ) } diff --git a/ui-tui/src/components/overlayControls.tsx b/ui-tui/src/components/overlayControls.tsx new file mode 100644 index 0000000000..03d28127ed --- /dev/null +++ b/ui-tui/src/components/overlayControls.tsx @@ -0,0 +1,41 @@ +import { Text, useInput } from '@hermes/ink' + +import type { Theme } from '../theme.js' + +type TextWrap = 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'wrap' | 'wrap-char' | 'wrap-trim' + +export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKeysOptions) { + useInput((ch, key) => { + if (disabled) { + return + } + + if (ch.toLowerCase() === 'q') { + return onClose() + } + + if (key.escape) { + return onBack ? onBack() : onClose() + } + }) +} + +export function OverlayControls({ children, t, wrap = 'truncate-end' }: OverlayControlsProps) { + return ( + + {children} + + ) +} + +interface OverlayControlsProps { + children: string + t: Theme + wrap?: TextWrap +} + +interface OverlayKeysOptions { + disabled?: boolean + onBack?: () => void + onClose: () => void +} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index c840782399..fa1529ef4c 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -6,6 +6,8 @@ import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' +import { OverlayControls, useOverlayKeys } from './overlayControls.js' + const VISIBLE = 15 const MIN_WIDTH = 60 const MAX_WIDTH = 120 @@ -33,6 +35,8 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) const { stdout } = useStdout() const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + useOverlayKeys({ onClose: onCancel }) + useEffect(() => { gw.request('session.list', { limit: 20 }) .then(raw => { @@ -56,10 +60,6 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) }, [gw]) useInput((ch, key) => { - if (key.escape) { - return onCancel() - } - if (key.upArrow && sel > 0) { setSel(s => s - 1) } @@ -87,7 +87,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) return ( error: {err} - Esc to cancel + Esc/q cancel ) } @@ -96,7 +96,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) return ( no previous sessions - Esc to cancel + Esc/q cancel ) } @@ -141,7 +141,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) })} {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} - ↑/↓ select · Enter resume · 1-9 quick · Esc cancel + ↑/↓ select · Enter resume · 1-9 quick · Esc/q cancel ) } diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 1bff92c0c8..44710645a8 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -5,6 +5,8 @@ import type { GatewayClient } from '../gatewayClient.js' import { rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' +import { OverlayControls, useOverlayKeys } from './overlayControls.js' + const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 @@ -48,6 +50,27 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : [] const skillName = skills[skillIdx] ?? '' + const back = () => { + if (stage === 'actions') { + setStage('skill') + setInfo(null) + setErr('') + + return + } + + if (stage === 'skill') { + setStage('category') + setSkillIdx(0) + + return + } + + onClose() + } + + useOverlayKeys({ disabled: installing, onBack: back, onClose }) + const inspect = (name: string) => { setInfo(null) setErr('') @@ -72,27 +95,6 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { return } - if (key.escape) { - if (stage === 'actions') { - setStage('skill') - setInfo(null) - setErr('') - - return - } - - if (stage === 'skill') { - setStage('category') - setSkillIdx(0) - - return - } - - onClose() - - return - } - if (stage === 'actions') { if (key.return) { setStage('skill') @@ -193,7 +195,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { return ( error: {err} - Esc to cancel + Esc/q cancel ) } @@ -202,7 +204,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { return ( no skills available - Esc to cancel + Esc/q cancel ) } @@ -238,7 +240,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { })} {off + VISIBLE < rows.length && ↓ {rows.length - off - VISIBLE} more} - ↑/↓ select · Enter open · 1-9,0 quick · Esc cancel + ↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel ) } @@ -274,9 +276,9 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { })} {off + VISIBLE < skills.length && ↓ {skills.length - off - VISIBLE} more} - - {skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back' : 'Esc back'} - + + {skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'} + ) } @@ -294,7 +296,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { {err ? error: {err} : null} {installing ? installing… : null} - i reinspect · x reinstall · Enter/Esc back + i reinspect · x reinstall · Enter/Esc back · q close ) } From c6fdf48b79ebd336015a24bc52fdd2c1c69a5df5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 14:17:57 -0500 Subject: [PATCH 10/12] fix(tui): sync inference model after switches - keep HERMES_INFERENCE_MODEL aligned with HERMES_MODEL after in-TUI model switches - clarify static provider detection remapping docs --- hermes_cli/models.py | 3 ++- tests/test_tui_gateway_server.py | 2 ++ tui_gateway/server.py | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 93a5111c56..10b63cbbb4 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1452,7 +1452,8 @@ def detect_static_provider_for_model( ) -> Optional[tuple[str, str]]: """Auto-detect a provider from static catalogs only. - Returns ``(provider_id, model_name)`` — the model name may be remapped + Returns ``(provider_id, model_name)``. The model name may be remapped + when a static alias or bare provider name resolves to a catalog default. Returns ``None`` when no confident match is found. """ name = (model_name or "").strip() diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index d4cdab8421..f7eacb6859 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -562,6 +562,8 @@ def test_config_set_model_syncs_tui_provider_env(monkeypatch): assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6" assert os.environ["HERMES_TUI_PROVIDER"] == "anthropic" + assert os.environ["HERMES_MODEL"] == "anthropic/claude-sonnet-4.6" + assert os.environ["HERMES_INFERENCE_MODEL"] == "anthropic/claude-sonnet-4.6" finally: server._sessions.clear() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7f981663a3..81a2a98e01 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -774,6 +774,7 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: _emit("session.info", sid, _session_info(agent)) os.environ["HERMES_MODEL"] = result.new_model + os.environ["HERMES_INFERENCE_MODEL"] = result.new_model # Keep the process-level provider env var in sync with the user's explicit # choice so any ambient re-resolution (credential pool refresh, compressor # rebuild, aux clients) resolves to the new provider instead of the From 6e83d90eb4904677d530480d41b642362f514026 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 14:23:45 -0500 Subject: [PATCH 11/12] refactor(tui): tighten overlay helpers - rename overlay help text component to match its role - share picker window math across model, session, and skills overlays --- ui-tui/src/components/appOverlays.tsx | 6 ++-- ui-tui/src/components/modelPicker.tsx | 44 ++++++++++------------- ui-tui/src/components/overlayControls.tsx | 21 +++++++---- ui-tui/src/components/sessionPicker.tsx | 18 +++++----- ui-tui/src/components/skillsHub.tsx | 43 ++++++++++------------ 5 files changed, 63 insertions(+), 69 deletions(-) diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 9e1b6ded30..25342598b8 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -9,7 +9,7 @@ import { $uiState } from '../app/uiStore.js' import { FloatBox } from './appChrome.js' import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' -import { OverlayControls } from './overlayControls.js' +import { OverlayHint } from './overlayControls.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { SessionPicker } from './sessionPicker.js' import { SkillsHub } from './skillsHub.js' @@ -163,11 +163,11 @@ export function FloatingOverlays({ ))} - + {overlay.pager.offset + pagerPageSize < overlay.pager.lines.length ? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})` : `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`} - + diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 3e5c8c3648..83c8abaab7 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -7,20 +7,12 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes. import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' -import { OverlayControls, useOverlayKeys } from './overlayControls.js' +import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js' const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 -const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) - -const visibleItems = (items: string[], sel: number) => { - const off = pageOffset(items.length, sel) - - return { items: items.slice(off, off + VISIBLE), off } -} - export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) { const [providers, setProviders] = useState([]) const [currentModel, setCurrentModel] = useState('') @@ -135,16 +127,16 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const n = ch === '0' ? 10 : parseInt(ch, 10) if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) { - const off = pageOffset(count, sel) + const offset = windowOffset(count, sel, VISIBLE) if (stage === 'provider') { - const next = off + n - 1 + const next = offset + n - 1 if (providers[next]) { setProviderIdx(next) } - } else if (provider && models[off + n - 1]) { - onSelect(`${models[off + n - 1]} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`) + } else if (provider && models[offset + n - 1]) { + onSelect(`${models[offset + n - 1]} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`) } } }) @@ -157,7 +149,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return ( error: {err} - Esc/q cancel + Esc/q cancel ) } @@ -166,7 +158,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return ( no authenticated providers - Esc/q cancel + Esc/q cancel ) } @@ -176,7 +168,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke (p, i) => `${p.is_current ? '*' : ' '} ${names[i]} · ${p.total_models ?? p.models?.length ?? 0} models` ) - const { items, off } = visibleItems(rows, providerIdx) + const { items, offset } = windowItems(rows, providerIdx, VISIBLE) return ( @@ -191,12 +183,12 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke {provider?.warning ? `warning: ${provider.warning}` : ' '} - {off > 0 ? ` ↑ ${off} more` : ' '} + {offset > 0 ? ` ↑ ${offset} more` : ' '} {Array.from({ length: VISIBLE }, (_, i) => { const row = items[i] - const idx = off + i + const idx = offset + i return row ? ( - {off + VISIBLE < rows.length ? ` ↓ ${rows.length - off - VISIBLE} more` : ' '} + {offset + VISIBLE < rows.length ? ` ↓ ${rows.length - offset - VISIBLE} more` : ' '} persist: {persistGlobal ? 'global' : 'session'} · g toggle - ↑/↓ select · Enter choose · 1-9,0 quick · Esc/q cancel + ↑/↓ select · Enter choose · 1-9,0 quick · Esc/q cancel ) } - const { items, off } = visibleItems(models, modelIdx) + const { items, offset } = windowItems(models, modelIdx, VISIBLE) return ( @@ -243,12 +235,12 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke {provider?.warning ? `warning: ${provider.warning}` : ' '} - {off > 0 ? ` ↑ ${off} more` : ' '} + {offset > 0 ? ` ↑ ${offset} more` : ' '} {Array.from({ length: VISIBLE }, (_, i) => { const row = items[i] - const idx = off + i + const idx = offset + i if (!row) { return !models.length && i === 0 ? ( @@ -277,15 +269,15 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke })} - {off + VISIBLE < models.length ? ` ↓ ${models.length - off - VISIBLE} more` : ' '} + {offset + VISIBLE < models.length ? ` ↓ ${models.length - offset - VISIBLE} more` : ' '} persist: {persistGlobal ? 'global' : 'session'} · g toggle - + {models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back · q close' : 'Enter/Esc back · q close'} - + ) } diff --git a/ui-tui/src/components/overlayControls.tsx b/ui-tui/src/components/overlayControls.tsx index 03d28127ed..d6f885fdcd 100644 --- a/ui-tui/src/components/overlayControls.tsx +++ b/ui-tui/src/components/overlayControls.tsx @@ -2,8 +2,6 @@ import { Text, useInput } from '@hermes/ink' import type { Theme } from '../theme.js' -type TextWrap = 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'wrap' | 'wrap-char' | 'wrap-trim' - export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKeysOptions) { useInput((ch, key) => { if (disabled) { @@ -20,18 +18,29 @@ export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKey }) } -export function OverlayControls({ children, t, wrap = 'truncate-end' }: OverlayControlsProps) { +export function OverlayHint({ children, t }: OverlayHintProps) { return ( - + {children} ) } -interface OverlayControlsProps { +export const windowOffset = (count: number, selected: number, visible: number) => + Math.max(0, Math.min(selected - Math.floor(visible / 2), count - visible)) + +export function windowItems(items: T[], selected: number, visible: number) { + const offset = windowOffset(items.length, selected, visible) + + return { + items: items.slice(offset, offset + visible), + offset + } +} + +interface OverlayHintProps { children: string t: Theme - wrap?: TextWrap } interface OverlayKeysOptions { diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index fa1529ef4c..8e936b989b 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -6,7 +6,7 @@ import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' -import { OverlayControls, useOverlayKeys } from './overlayControls.js' +import { OverlayHint, useOverlayKeys, windowOffset } from './overlayControls.js' const VISIBLE = 15 const MIN_WIDTH = 60 @@ -87,7 +87,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) return ( error: {err} - Esc/q cancel + Esc/q cancel ) } @@ -96,12 +96,12 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) return ( no previous sessions - Esc/q cancel + Esc/q cancel ) } - const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE)) + const offset = windowOffset(items.length, sel, VISIBLE) return ( @@ -109,10 +109,10 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) Resume Session - {off > 0 && ↑ {off} more} + {offset > 0 && ↑ {offset} more} - {items.slice(off, off + VISIBLE).map((s, vi) => { - const i = off + vi + {items.slice(offset, offset + VISIBLE).map((s, vi) => { + const i = offset + vi const selected = sel === i return ( @@ -140,8 +140,8 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) ) })} - {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} - ↑/↓ select · Enter resume · 1-9 quick · Esc/q cancel + {offset + VISIBLE < items.length && ↓ {items.length - offset - VISIBLE} more} + ↑/↓ select · Enter resume · 1-9 quick · Esc/q cancel ) } diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 44710645a8..3284b145f5 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -5,20 +5,12 @@ import type { GatewayClient } from '../gatewayClient.js' import { rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' -import { OverlayControls, useOverlayKeys } from './overlayControls.js' +import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js' const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 -const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) - -const visibleItems = (items: string[], sel: number) => { - const off = pageOffset(items.length, sel) - - return { items: items.slice(off, off + VISIBLE), off } -} - export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const [skillsByCat, setSkillsByCat] = useState>({}) const [selectedCat, setSelectedCat] = useState('') @@ -161,8 +153,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const n = ch === '0' ? 10 : parseInt(ch, 10) if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) { - const off = pageOffset(count, sel) - const next = off + n - 1 + const next = windowOffset(count, sel, VISIBLE) + n - 1 if (stage === 'category') { const cat = cats[next] @@ -195,7 +186,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { return ( error: {err} - Esc/q cancel + Esc/q cancel ) } @@ -204,14 +195,14 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { return ( no skills available - Esc/q cancel + Esc/q cancel ) } if (stage === 'category') { const rows = cats.map(c => `${c} · ${skillsByCat[c]?.length ?? 0} skills`) - const { items, off } = visibleItems(rows, catIdx) + const { items, offset } = windowItems(rows, catIdx, VISIBLE) return ( @@ -220,10 +211,10 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { select a category - {off > 0 && ↑ {off} more} + {offset > 0 && ↑ {offset} more} {items.map((row, i) => { - const idx = off + i + const idx = offset + i return ( ↓ {rows.length - off - VISIBLE} more} - ↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel + {offset + VISIBLE < rows.length && ↓ {rows.length - offset - VISIBLE} more} + ↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel ) } if (stage === 'skill') { - const { items, off } = visibleItems(skills, skillIdx) + const { items, offset } = windowItems(skills, skillIdx, VISIBLE) return ( @@ -256,10 +247,10 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { {skills.length} skill(s) {!skills.length ? no skills in this category : null} - {off > 0 && ↑ {off} more} + {offset > 0 && ↑ {offset} more} {items.map((row, i) => { - const idx = off + i + const idx = offset + i return ( ↓ {skills.length - off - VISIBLE} more} - + {offset + VISIBLE < skills.length && ( + ↓ {skills.length - offset - VISIBLE} more + )} + {skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'} - + ) } @@ -296,7 +289,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { {err ? error: {err} : null} {installing ? installing… : null} - i reinspect · x reinstall · Enter/Esc back · q close + i reinspect · x reinstall · Enter/Esc back · q close ) } From 919274b60ef0652039e04a80c561a90c62d3ff28 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 14:26:35 -0500 Subject: [PATCH 12/12] fix(tui): align overlay q shortcut casing Keep shared overlay close behavior consistent with pager and agents overlays by binding lowercase q only. --- ui-tui/src/components/overlayControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/src/components/overlayControls.tsx b/ui-tui/src/components/overlayControls.tsx index d6f885fdcd..3087d4aecd 100644 --- a/ui-tui/src/components/overlayControls.tsx +++ b/ui-tui/src/components/overlayControls.tsx @@ -8,7 +8,7 @@ export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKey return } - if (ch.toLowerCase() === 'q') { + if (ch === 'q') { return onClose() }