diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 4fc59d926..506e74999 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -6126,55 +6126,56 @@ def _prompt_model_selection( _DIM = "\033[2m" _RESET = "\033[0m" - # Try arrow-key menu first, fall back to number input + # Try arrow-key menu first, fall back to number input. + # Uses the shared curses radiolist (ESC/arrow-key handling that works + # across terminals, incl. those that emit raw escape sequences) instead + # of simple_term_menu, which conflicts with /dev/tty and left ESC/arrow + # keys unreliable in the setup model picker. try: - from simple_term_menu import TerminalMenu + from hermes_cli.curses_ui import curses_radiolist - choices = [f" {_label(mid)}" for mid in ordered] - choices.append(" Enter custom model name") - choices.append(" Skip (keep current)") + choices = [_label(mid) for mid in ordered] + choices.append("Enter custom model name") + choices.append("Skip (keep current)") _upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/") unavailable_footer = unavailable_message.strip() if not unavailable_footer and _unavailable: unavailable_footer = f"Upgrade at {_upgrade_url} for paid models" - # Print the unavailable block BEFORE the menu via regular print(). - # simple_term_menu pads title lines to terminal width (causes wrapping), - # so we keep the title minimal and use stdout for the static block. - # clear_screen=False means our printed output stays visible above. + # The pricing column header (and any unavailable-models block) is shown + # as a multi-line description above the list so it survives the curses + # screen clear. menu_title already embeds the aligned price header. + desc_lines: list[str] = [] + if has_pricing: + # menu_title is "Select default model:\n
/Mtok" + # Keep only the header portion for the description. + header_part = menu_title.split("\n", 1) + if len(header_part) > 1: + desc_lines.extend(header_part[1].splitlines()) if _unavailable: - print(menu_title) - print() for mid in _unavailable: - print(f"{_DIM} {_label(mid)}{_RESET}") - print() - print(f"{_DIM} ── {unavailable_footer} ──{_RESET}") - print() - effective_title = "Available free models:" - else: - effective_title = menu_title + desc_lines.append(f" {_label(mid)}") + desc_lines.append(f" ── {unavailable_footer} ──") + description = "\n".join(desc_lines) if desc_lines else None - menu = TerminalMenu( + idx = curses_radiolist( + "Select default model:", choices, - cursor_index=default_idx, - menu_cursor="-> ", - menu_cursor_style=("fg_green", "bold"), - menu_highlight_style=("fg_green",), - cycle_cursor=True, - clear_screen=False, - title=effective_title, + selected=default_idx, + cancel_returns=-1, + description=description, ) - idx = menu.show() - from hermes_cli.curses_ui import flush_stdin - flush_stdin() - if idx is None: + if idx < 0: return None print() if idx < len(ordered): return ordered[idx] elif idx == len(ordered): - custom = input("Enter model name: ").strip() + try: + custom = input("Enter model name: ").strip() + except (EOFError, KeyboardInterrupt): + return None return custom if custom else None return None except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1cb4bd3d6..1941dc2af 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4455,23 +4455,17 @@ def _remove_custom_provider(config): choices.append("Cancel") try: - from simple_term_menu import TerminalMenu + from hermes_cli.curses_ui import curses_radiolist - menu = TerminalMenu( - [f" {c}" for c in choices], - cursor_index=0, - menu_cursor="-> ", - menu_cursor_style=("fg_red", "bold"), - menu_highlight_style=("fg_red",), - cycle_cursor=True, - clear_screen=False, - title="Select provider to remove:", + idx = curses_radiolist( + "Select provider to remove:", + list(choices), + selected=0, + cancel_returns=-1, ) - idx = menu.show() - from hermes_cli.curses_ui import flush_stdin - - flush_stdin() print() + if idx < 0: + idx = None except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): for i, c in enumerate(choices, 1): print(f" {i}. {c}") @@ -4538,27 +4532,19 @@ def _model_flow_named_custom(config, provider_info): print(f"Found {len(models)} model(s):\n") try: - from simple_term_menu import TerminalMenu + from hermes_cli.curses_ui import curses_radiolist menu_items = [ - f" {m} (current)" if m == saved_model else f" {m}" for m in models - ] + [" Cancel"] - menu = TerminalMenu( + f"{m} (current)" if m == saved_model else m for m in models + ] + ["Cancel"] + idx = curses_radiolist( + f"Select model from {name}:", menu_items, - cursor_index=default_idx, - menu_cursor="-> ", - menu_cursor_style=("fg_green", "bold"), - menu_highlight_style=("fg_green",), - cycle_cursor=True, - clear_screen=False, - title=f"Select model from {name}:", + selected=default_idx, + cancel_returns=-1, ) - idx = menu.show() - from hermes_cli.curses_ui import flush_stdin - - flush_stdin() print() - if idx is None or idx >= len(models): + if idx < 0 or idx >= len(models): print("Cancelled.") return model_name = models[idx] @@ -4735,26 +4721,18 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""): default_idx = 0 try: - from simple_term_menu import TerminalMenu + from hermes_cli.curses_ui import curses_radiolist - choices = [f" {_label(effort)}" for effort in ordered] - choices.append(f" {disable_label}") - choices.append(f" {skip_label}") - menu = TerminalMenu( + choices = [_label(effort) for effort in ordered] + choices.append(disable_label) + choices.append(skip_label) + idx = curses_radiolist( + "Select reasoning effort:", choices, - cursor_index=default_idx, - menu_cursor="-> ", - menu_cursor_style=("fg_green", "bold"), - menu_highlight_style=("fg_green",), - cycle_cursor=True, - clear_screen=False, - title="Select reasoning effort:", + selected=default_idx, + cancel_returns=-1, ) - idx = menu.show() - from hermes_cli.curses_ui import flush_stdin - - flush_stdin() - if idx is None: + if idx < 0: return None print() if idx < len(ordered): diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index b65bffabf..39d4a8018 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -305,7 +305,7 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list appended at the end — the user toggles items with Space and confirms with Enter on "Continue →". - Falls back to a numbered toggle interface when simple_term_menu is + Falls back to a numbered toggle interface when curses is unavailable. Returns: diff --git a/tests/hermes_cli/test_custom_provider_model_switch.py b/tests/hermes_cli/test_custom_provider_model_switch.py index 0f3a76a1a..45415f50f 100644 --- a/tests/hermes_cli/test_custom_provider_model_switch.py +++ b/tests/hermes_cli/test_custom_provider_model_switch.py @@ -45,7 +45,7 @@ class TestCustomProviderModelSwitch: } with patch("hermes_cli.models.fetch_api_models", return_value=["model-A", "model-B"]) as mock_fetch, \ - patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ patch("builtins.input", return_value="2"), \ patch("builtins.print"): _model_flow_named_custom({}, provider_info) @@ -70,7 +70,7 @@ class TestCustomProviderModelSwitch: } with patch("hermes_cli.models.fetch_api_models", return_value=["model-A", "model-B"]), \ - patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ patch("builtins.input", return_value="2"), \ patch("builtins.print"): _model_flow_named_custom({}, provider_info) @@ -116,7 +116,7 @@ class TestCustomProviderModelSwitch: } with patch("hermes_cli.models.fetch_api_models", return_value=["model-X"]), \ - patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ patch("builtins.input", return_value="1"), \ patch("builtins.print"): _model_flow_named_custom({}, provider_info) @@ -140,7 +140,7 @@ class TestCustomProviderModelSwitch: } with patch("hermes_cli.models.fetch_api_models", return_value=["claude-3"]) as mock_fetch, \ - patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ patch("builtins.input", return_value="1"), \ patch("builtins.print"): _model_flow_named_custom({}, provider_info) @@ -173,7 +173,7 @@ class TestCustomProviderModelSwitch: } with patch("hermes_cli.models.fetch_api_models", return_value=["llama-3"]), \ - patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ patch("builtins.input", return_value="1"), \ patch("builtins.print"): _model_flow_named_custom({}, provider_info) @@ -210,7 +210,7 @@ class TestCustomProviderModelSwitch: } with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.6-35b-fast"]) as mock_fetch, \ - patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ patch("builtins.input", return_value="1"), \ patch("builtins.print"): _model_flow_named_custom({}, provider_info) @@ -251,7 +251,7 @@ class TestCustomProviderModelSwitch: } with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.6-35b-fast"]), \ - patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ patch("builtins.input", return_value="1"), \ patch("builtins.print"): _model_flow_named_custom({}, provider_info) @@ -309,7 +309,7 @@ class TestCustomProviderModelSwitch: side_effect=_pick_neuralwatt), \ patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.6-35b-fast"]) as mock_fetch, \ - patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ patch("builtins.input", return_value="1"), \ patch("builtins.print"): select_provider_and_model() @@ -422,7 +422,7 @@ class TestCustomProviderModelSwitch: side_effect=_pick_neuralwatt), \ patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.6-35b-fast"]) as mock_fetch, \ - patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ patch("builtins.input", return_value="1"), \ patch("builtins.print"): select_provider_and_model() @@ -486,7 +486,7 @@ class TestCustomProviderModelSwitch: "hermes_cli.models.fetch_api_models", return_value=["claude-opus-4-7"], ) as mock_fetch, \ - patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ patch("builtins.input", return_value="1"), \ patch("builtins.print"): _model_flow_named_custom({}, provider_info) @@ -551,7 +551,7 @@ class TestCustomProviderModelSwitch: "hermes_cli.models.fetch_api_models", return_value=["claude-opus-4-7"], ), \ - patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ patch("builtins.input", return_value="1"), \ patch("builtins.print"): _model_flow_named_custom({}, provider_info) diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index aef758f09..75eb5b8dc 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -191,14 +191,12 @@ class TestProviderPersistsAfterModelSave: } # Patch fetch_api_models so the named custom flow returns one model; - # patch simple_term_menu to force the input() fallback; patch input to - # auto-select the first model from the fallback prompt. - fake_menu_module = MagicMock() - fake_menu_module.TerminalMenu.side_effect = OSError("no tty in test") + # force the curses menu to error so the input() fallback runs; patch + # input to auto-select the first model from the fallback prompt. with patch("hermes_cli.auth._save_model_choice"), \ patch("hermes_cli.auth.deactivate_provider"), \ patch("hermes_cli.models.fetch_api_models", return_value=["gpt-5.4"]), \ - patch.dict("sys.modules", {"simple_term_menu": fake_menu_module}), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=OSError("no tty in test")), \ patch("builtins.input", return_value="1"): _model_flow_named_custom({}, provider_info) diff --git a/tests/hermes_cli/test_reasoning_effort_menu.py b/tests/hermes_cli/test_reasoning_effort_menu.py index 3d360a4f2..79063587f 100644 --- a/tests/hermes_cli/test_reasoning_effort_menu.py +++ b/tests/hermes_cli/test_reasoning_effort_menu.py @@ -1,24 +1,15 @@ -import sys -import types - - from hermes_cli.main import _prompt_reasoning_effort_selection -class _FakeTerminalMenu: - last_choices = None - - def __init__(self, choices, **kwargs): - _FakeTerminalMenu.last_choices = choices - self._cursor_index = kwargs.get("cursor_index") - - def show(self): - return self._cursor_index - - def test_reasoning_menu_orders_minimal_before_low(monkeypatch): - fake_module = types.SimpleNamespace(TerminalMenu=_FakeTerminalMenu) - monkeypatch.setitem(sys.modules, "simple_term_menu", fake_module) + captured = {} + + def _fake_radiolist(title, items, *, selected=0, cancel_returns=None, description=None): + captured["items"] = items + captured["selected"] = selected + return selected # pick the pre-selected (current) entry + + monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _fake_radiolist) selected = _prompt_reasoning_effort_selection( ["low", "minimal", "medium", "high"], @@ -26,9 +17,9 @@ def test_reasoning_menu_orders_minimal_before_low(monkeypatch): ) assert selected == "medium" - assert _FakeTerminalMenu.last_choices[:4] == [ - " minimal", - " low", - " medium ← currently in use", - " high", + assert captured["items"][:4] == [ + "minimal", + "low", + "medium ← currently in use", + "high", ] diff --git a/tests/hermes_cli/test_setup_menu_curses_migration.py b/tests/hermes_cli/test_setup_menu_curses_migration.py new file mode 100644 index 000000000..9f6560b1e --- /dev/null +++ b/tests/hermes_cli/test_setup_menu_curses_migration.py @@ -0,0 +1,84 @@ +"""Regression tests confirming the setup model/provider/reasoning pickers route +through the shared curses radiolist (ESC + arrow-key handling that works across +terminals, incl. Ghostty) instead of simple_term_menu. + +Guards against silently regressing back to simple_term_menu, whose ESC/arrow +handling was unreliable in `hermes setup` (the provider->model sub-menu). +""" +from unittest.mock import patch + + +def test_prompt_model_selection_uses_curses_radiolist(): + from hermes_cli.auth import _prompt_model_selection + + seen = {} + + def _fake(title, items, *, selected=0, cancel_returns=None, description=None): + seen["title"] = title + seen["items"] = items + return 1 # pick second model + + with patch("hermes_cli.curses_ui.curses_radiolist", side_effect=_fake), \ + patch("builtins.print"): + result = _prompt_model_selection(["model-a", "model-b"]) + + assert result == "model-b" + assert seen["title"] == "Select default model:" + # Items are the models plus the custom/skip entries. + assert seen["items"][:2] == ["model-a", "model-b"] + assert "Skip (keep current)" in seen["items"] + + +def test_prompt_model_selection_esc_cancels(): + from hermes_cli.auth import _prompt_model_selection + + # curses_radiolist returns the cancel sentinel (-1) on ESC. + with patch("hermes_cli.curses_ui.curses_radiolist", return_value=-1), \ + patch("builtins.print"): + result = _prompt_model_selection(["model-a", "model-b"]) + + assert result is None + + +def test_reasoning_effort_uses_curses_radiolist(): + from hermes_cli.main import _prompt_reasoning_effort_selection + + with patch("hermes_cli.curses_ui.curses_radiolist", return_value=2), \ + patch("builtins.print"): + result = _prompt_reasoning_effort_selection(["low", "medium", "high"], current_effort="") + + assert result == "high" + + +def test_reasoning_effort_esc_cancels(): + from hermes_cli.main import _prompt_reasoning_effort_selection + + with patch("hermes_cli.curses_ui.curses_radiolist", return_value=-1), \ + patch("builtins.print"): + result = _prompt_reasoning_effort_selection(["low", "medium", "high"], current_effort="") + + assert result is None + + +def test_model_selection_with_pricing_passes_description(): + """When pricing is supplied, the aligned header is passed as the curses + description (multi-line text above the list), not lost.""" + from hermes_cli.auth import _prompt_model_selection + + seen = {} + + def _fake(title, items, *, selected=0, cancel_returns=None, description=None): + seen["description"] = description + return len(items) - 1 # Skip + + pricing = { + "model-a": {"prompt": "0.000001", "completion": "0.000002"}, + "model-b": {"prompt": "0.000003", "completion": "0.000004"}, + } + with patch("hermes_cli.curses_ui.curses_radiolist", side_effect=_fake), \ + patch("builtins.print"): + _prompt_model_selection(["model-a", "model-b"], pricing=pricing) + + # The description should carry the In/Out price header. + assert seen["description"] is not None + assert "In" in seen["description"] and "Out" in seen["description"] diff --git a/tests/hermes_cli/test_terminal_menu_fallbacks.py b/tests/hermes_cli/test_terminal_menu_fallbacks.py index a12830499..626858af4 100644 --- a/tests/hermes_cli/test_terminal_menu_fallbacks.py +++ b/tests/hermes_cli/test_terminal_menu_fallbacks.py @@ -1,25 +1,21 @@ -"""Regression tests for numbered fallbacks when TerminalMenu cannot initialize.""" +"""Regression tests for numbered fallbacks when the interactive curses menu +cannot initialize (e.g. non-TTY, curses unavailable, terminal error).""" import subprocess -import sys -import types from hermes_cli.config import load_config, save_config -class _BrokenTerminalMenu: - def __init__(self, *args, **kwargs): - raise subprocess.CalledProcessError(2, ["tput", "clear"]) +def _raise_menu(*args, **kwargs): + # Mimic curses_radiolist hitting an unrecoverable terminal error so the + # caller's except clause routes to the numbered-input fallback. + raise subprocess.CalledProcessError(2, ["tput", "clear"]) -def test_prompt_model_selection_falls_back_on_terminalmenu_runtime_error(monkeypatch): +def test_prompt_model_selection_falls_back_on_menu_runtime_error(monkeypatch): from hermes_cli.auth import _prompt_model_selection - monkeypatch.setitem( - sys.modules, - "simple_term_menu", - types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu), - ) + monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _raise_menu) responses = iter(["2"]) monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses)) @@ -28,14 +24,10 @@ def test_prompt_model_selection_falls_back_on_terminalmenu_runtime_error(monkeyp assert selected == "model-b" -def test_prompt_reasoning_effort_falls_back_on_terminalmenu_runtime_error(monkeypatch): +def test_prompt_reasoning_effort_falls_back_on_menu_runtime_error(monkeypatch): from hermes_cli.main import _prompt_reasoning_effort_selection - monkeypatch.setitem( - sys.modules, - "simple_term_menu", - types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu), - ) + monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _raise_menu) responses = iter(["3"]) monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses)) @@ -44,15 +36,11 @@ def test_prompt_reasoning_effort_falls_back_on_terminalmenu_runtime_error(monkey assert selected == "high" -def test_remove_custom_provider_falls_back_on_terminalmenu_runtime_error(tmp_path, monkeypatch): +def test_remove_custom_provider_falls_back_on_menu_runtime_error(tmp_path, monkeypatch): from hermes_cli.main import _remove_custom_provider monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - monkeypatch.setitem( - sys.modules, - "simple_term_menu", - types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu), - ) + monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _raise_menu) cfg = load_config() cfg["custom_providers"] = [ @@ -72,15 +60,11 @@ def test_remove_custom_provider_falls_back_on_terminalmenu_runtime_error(tmp_pat ] -def test_named_custom_provider_model_picker_falls_back_on_terminalmenu_runtime_error(tmp_path, monkeypatch): +def test_named_custom_provider_model_picker_falls_back_on_menu_runtime_error(tmp_path, monkeypatch): from hermes_cli.main import _model_flow_named_custom monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - monkeypatch.setitem( - sys.modules, - "simple_term_menu", - types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu), - ) + monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _raise_menu) monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *args, **kwargs: ["model-a", "model-b"]) monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)