From d6b0c23f8769b200084b2ef24fb58b6b54cf5339 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 20:49:44 -0500 Subject: [PATCH] feat(cli): configurable default interface (cli vs tui) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `display.interface` config key so users can make the modern TUI the default for bare `hermes` / `hermes chat` without exporting HERMES_TUI=1 in every shell. Default stays "cli" to preserve current behavior. Add a `--cli` flag (mirrors `--tui`) so an explicit invocation can force the classic prompt_toolkit REPL even when `display.interface: tui` is configured. Precedence (highest first): `--cli` > `--tui`/`HERMES_TUI=1` > config `display.interface` > classic REPL. Two resolvers enforce it: * `_resolve_use_tui(args)` — the args-aware resolver used by `cmd_chat` and the Termux fast-TUI path (uses full load_config()). * `_wants_tui_early(argv)` — a dependency-free early resolver used by mouse-residue suppression and the Termux fast paths, which run before argparse / hermes_cli.config are importable (minimal cached YAML read). Both `--cli` and `--tui` are registered via `_inherited_flag`, so they are carried across self-relaunch automatically. - config: add display.interface ("cli" default), bump _config_version 25->26. The generic missing-field migration + load_config() deep-merge seed the key for existing configs; no bespoke migration block needed. - docs: document --cli flag and display.interface in cli-commands.md and the TUI user guide. - tests: new test_default_interface_resolution.py covering resolver precedence at every layer, early resolver edge cases (missing/garbage config), parser flags, and relaunch inheritance. --- hermes_cli/_parser.py | 16 ++ hermes_cli/config.py | 8 +- hermes_cli/main.py | 90 ++++++++- .../test_default_interface_resolution.py | 191 ++++++++++++++++++ website/docs/reference/cli-commands.md | 3 +- website/docs/user-guide/tui.md | 13 +- 6 files changed, 312 insertions(+), 9 deletions(-) create mode 100644 tests/hermes_cli/test_default_interface_resolution.py diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index cf4ffc34e..870ed1b65 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -41,6 +41,8 @@ _EPILOGUE = """ Examples: hermes Start interactive chat hermes chat -q "Hello" Single query mode + hermes --tui Launch the modern TUI (or set display.interface: tui) + hermes --cli Force the classic REPL (overrides display.interface: tui) hermes -c Resume the most recent session hermes -c "my project" Resume a session by name (latest in lineage) hermes --resume Resume a specific session by ID @@ -218,6 +220,13 @@ def build_top_level_parser(): default=False, help="Launch the modern TUI instead of the classic REPL", ) + _inherited_flag( + parser, + "--cli", + action="store_true", + default=False, + help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)", + ) _inherited_flag( parser, "--dev", @@ -369,6 +378,13 @@ def build_top_level_parser(): default=False, help="Launch the modern TUI instead of the classic REPL", ) + _inherited_flag( + chat_parser, + "--cli", + action="store_true", + default=False, + help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)", + ) _inherited_flag( chat_parser, "--dev", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index cec27809f..4cb822b19 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1283,6 +1283,12 @@ DEFAULT_CONFIG = { # behavior of showing tool-call summaries inline. "resume_skip_tool_only": True, "busy_input_mode": "interrupt", # interrupt | queue | steer + # Which interface bare `hermes` (and `hermes chat`) launches by default: + # "cli" — the classic prompt_toolkit REPL (default, preserves prior behavior) + # "tui" — the modern Ink TUI (same as passing `--tui`) + # Explicit flags always win over this setting: `--cli` forces the classic + # REPL and `--tui` (or HERMES_TUI=1) forces the TUI regardless of config. + "interface": "cli", # When true, `hermes --tui` auto-resumes the most recent human- # facing session on launch instead of forging a fresh one. # Mirrors `hermes -c` muscle memory. Default off so existing @@ -2284,7 +2290,7 @@ DEFAULT_CONFIG = { # Config schema version - bump this when adding new required fields - "_config_version": 25, + "_config_version": 26, } # ============================================================================= diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 53f066cb9..64f1bda0e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -105,6 +105,58 @@ def _set_process_title() -> None: pass +# Cheap, dependency-free read of `display.interface` from config.yaml for the +# earliest hot-path decisions (mouse-residue suppression, Termux fast launch) +# that run *before* hermes_cli.config is importable. Mirrors the explicit +# precedence used everywhere else: `--cli` always wins, then `--tui`/env, then +# this config value. Cached so the multiple early callers don't re-parse YAML. +_EARLY_INTERFACE_CACHE: "list | None" = None + + +def _config_default_interface_early() -> str: + """Return the configured default interface ("cli"/"tui") via a minimal + YAML read. Best-effort: any error falls back to "cli" (legacy behavior).""" + global _EARLY_INTERFACE_CACHE + if _EARLY_INTERFACE_CACHE is not None: + return _EARLY_INTERFACE_CACHE[0] + value = "cli" + try: + home = os.environ.get("HERMES_HOME") + if home: + cfg_path = os.path.join(home, "config.yaml") + else: + cfg_path = os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml") + if os.path.exists(cfg_path): + import yaml as _yaml_iface + + with open(cfg_path, encoding="utf-8") as _f: + raw = _yaml_iface.safe_load(_f) or {} + disp = raw.get("display", {}) + if isinstance(disp, dict): + iface = disp.get("interface") + if isinstance(iface, str) and iface.strip().lower() == "tui": + value = "tui" + except Exception: + value = "cli" # best-effort — default to classic REPL on any error + _EARLY_INTERFACE_CACHE = [value] + return value + + +def _wants_tui_early(argv: "list[str] | None" = None) -> bool: + """Earliest TUI decision, usable before argparse/config imports. + + Precedence: explicit ``--cli`` wins (forces classic REPL), then + ``--tui``/``HERMES_TUI=1``, then ``display.interface`` in config. + """ + if argv is None: + argv = sys.argv[1:] + if "--cli" in argv: + return False + if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv: + return True + return _config_default_interface_early() == "tui" + + # Mouse-tracking residue suppression — runs BEFORE every other import on the # TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the # Python launcher is still doing imports (≈100–300ms in cooked + echo mode, @@ -116,7 +168,7 @@ def _set_process_title() -> None: def _suppress_mouse_residue_early() -> None: if os.environ.get("HERMES_TUI_NO_EARLY_DISABLE") == "1": return - if not (os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]): + if not _wants_tui_early(): return try: # Skip when stdout is redirected (`hermes --tui … >log`, CI capture): @@ -1768,9 +1820,34 @@ def _sync_bundled_skills_quietly() -> None: pass +def _resolve_use_tui(args) -> bool: + """Decide whether to launch the TUI for a chat/bare invocation. + + Precedence (highest first): + 1. ``--cli`` flag → always classic REPL + 2. ``--tui`` flag / ``HERMES_TUI=1`` → always TUI + 3. ``display.interface`` config value ("cli" | "tui") + 4. default → classic REPL + + Explicit flags always win over config so muscle memory and scripts keep + working regardless of the configured default. + """ + if getattr(args, "cli", False): + return False + if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1": + return True + try: + from hermes_cli.config import load_config + + iface = (load_config().get("display", {}) or {}).get("interface", "cli") + return isinstance(iface, str) and iface.strip().lower() == "tui" + except Exception: + return False + + def cmd_chat(args): """Run interactive chat CLI.""" - use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1" + use_tui = _resolve_use_tui(args) # Resolve --continue into --resume with the latest session or by name continue_val = getattr(args, "continue_last", None) @@ -11984,7 +12061,10 @@ def _try_termux_fast_cli_launch() -> bool: argv = sys.argv[1:] if "-h" in argv or "--help" in argv: return False - if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv: + # Let the TUI fast path (or full dispatch) handle anything that resolves to + # the TUI — explicit --tui/env or display.interface=tui. `--cli` forces this + # to stay False so the classic fast path still runs. + if _wants_tui_early(argv): return False if _is_termux_fast_version_argv(argv): @@ -12059,7 +12139,7 @@ def _try_termux_fast_tui_launch() -> bool: if "-h" in sys.argv[1:] or "--help" in sys.argv[1:]: return False - wants_tui = os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:] + wants_tui = _wants_tui_early(sys.argv[1:]) if not wants_tui: return False @@ -12078,7 +12158,7 @@ def _try_termux_fast_tui_launch() -> bool: return False if getattr(args, "command", None) not in {None, "chat"}: return False - if not (getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"): + if not _resolve_use_tui(args): return False cmd_chat(args) diff --git a/tests/hermes_cli/test_default_interface_resolution.py b/tests/hermes_cli/test_default_interface_resolution.py new file mode 100644 index 000000000..c04f8093b --- /dev/null +++ b/tests/hermes_cli/test_default_interface_resolution.py @@ -0,0 +1,191 @@ +"""Tests for the configurable default interface (cli vs tui). + +`hermes` launches the classic prompt_toolkit REPL by default, but users can +flip ``display.interface: tui`` in config.yaml to make the modern Ink TUI the +default for bare ``hermes`` / ``hermes chat``. Explicit flags always win: + + --cli forces the classic REPL (highest precedence) + --tui / HERMES_TUI=1 forces the TUI + display.interface the configured default + (unset) classic REPL + +These tests pin that precedence at every layer that makes the decision: + + * ``_resolve_use_tui(args)`` — the canonical args-aware resolver used by + ``cmd_chat`` and the Termux fast-TUI path. + * ``_wants_tui_early(argv)`` — the dependency-free early resolver used by + mouse-residue suppression and the Termux fast paths, before argparse and + ``hermes_cli.config`` are importable. + * the argument parser — both ``--cli`` and ``--tui`` parse at the top + level and under the ``chat`` subcommand and are relaunch-inherited. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace + +import pytest + +from hermes_cli import main as m + + +@pytest.fixture(autouse=True) +def _reset_early_cache(monkeypatch): + # The early resolver memoizes the config read; clear it so each test sees + # a fresh value, and make sure no stray HERMES_TUI leaks in. + monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None) + monkeypatch.delenv("HERMES_TUI", raising=False) + yield + monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None) + + +def _args(**kw): + kw.setdefault("cli", False) + kw.setdefault("tui", False) + return SimpleNamespace(**kw) + + +def _patch_config(monkeypatch, interface): + import hermes_cli.config as cfg + + monkeypatch.setattr( + cfg, "load_config", lambda: {"display": {"interface": interface}} + ) + + +# --------------------------------------------------------------------------- +# _resolve_use_tui — args-aware resolver +# --------------------------------------------------------------------------- +class TestResolveUseTui: + def test_cli_flag_beats_config_tui(self, monkeypatch): + _patch_config(monkeypatch, "tui") + assert m._resolve_use_tui(_args(cli=True)) is False + + def test_cli_flag_beats_tui_flag_and_env(self, monkeypatch): + _patch_config(monkeypatch, "tui") + monkeypatch.setenv("HERMES_TUI", "1") + assert m._resolve_use_tui(_args(cli=True, tui=True)) is False + + def test_tui_flag_beats_config_cli(self, monkeypatch): + _patch_config(monkeypatch, "cli") + assert m._resolve_use_tui(_args(tui=True)) is True + + def test_env_beats_config_cli(self, monkeypatch): + _patch_config(monkeypatch, "cli") + monkeypatch.setenv("HERMES_TUI", "1") + assert m._resolve_use_tui(_args()) is True + + def test_config_tui_with_no_flags(self, monkeypatch): + _patch_config(monkeypatch, "tui") + assert m._resolve_use_tui(_args()) is True + + def test_config_cli_is_default(self, monkeypatch): + _patch_config(monkeypatch, "cli") + assert m._resolve_use_tui(_args()) is False + + def test_interface_value_is_case_insensitive(self, monkeypatch): + _patch_config(monkeypatch, "TUI") + assert m._resolve_use_tui(_args()) is True + + def test_load_config_failure_falls_back_to_cli(self, monkeypatch): + import hermes_cli.config as cfg + + def boom(): + raise RuntimeError("config unreadable") + + monkeypatch.setattr(cfg, "load_config", boom) + assert m._resolve_use_tui(_args()) is False + + +# --------------------------------------------------------------------------- +# _wants_tui_early — dependency-free early resolver +# --------------------------------------------------------------------------- +class TestWantsTuiEarly: + @pytest.fixture + def home_with_interface(self, tmp_path, monkeypatch): + def _make(interface): + (tmp_path / "config.yaml").write_text( + f"display:\n interface: {interface}\n" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None) + + return _make + + def test_config_tui_bare_argv(self, home_with_interface): + home_with_interface("tui") + assert m._wants_tui_early([]) is True + + def test_cli_flag_overrides_config_tui(self, home_with_interface): + home_with_interface("tui") + assert m._wants_tui_early(["--cli"]) is False + + def test_tui_flag_with_config_cli(self, home_with_interface): + home_with_interface("cli") + assert m._wants_tui_early(["--tui"]) is True + + def test_env_with_config_cli(self, home_with_interface, monkeypatch): + home_with_interface("cli") + monkeypatch.setenv("HERMES_TUI", "1") + assert m._wants_tui_early([]) is True + + def test_config_cli_bare_argv(self, home_with_interface): + home_with_interface("cli") + assert m._wants_tui_early([]) is False + + def test_missing_config_defaults_to_cli(self, tmp_path, monkeypatch): + # HERMES_HOME points at an empty dir — no config.yaml. + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None) + assert m._wants_tui_early([]) is False + + def test_unreadable_config_defaults_to_cli(self, tmp_path, monkeypatch): + # Garbage YAML must not crash the hot path; falls back to cli. + (tmp_path / "config.yaml").write_text("this: : : not valid yaml\n") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None) + assert m._wants_tui_early([]) is False + + +# --------------------------------------------------------------------------- +# argument parser — flags exist at both levels and are relaunch-inherited +# --------------------------------------------------------------------------- +class TestParserFlags: + def _parser(self): + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, _chat = build_top_level_parser() + return parser + + def test_top_level_cli_flag(self): + args = self._parser().parse_args(["--cli"]) + assert args.cli is True and args.tui is False + + def test_top_level_tui_flag(self): + args = self._parser().parse_args(["--tui"]) + assert args.tui is True and args.cli is False + + def test_chat_subcommand_cli_flag(self): + args = self._parser().parse_args(["chat", "--cli"]) + assert args.cli is True + + def test_chat_subcommand_tui_flag(self): + args = self._parser().parse_args(["chat", "--tui"]) + assert args.tui is True + + def test_cli_and_tui_are_relaunch_inherited(self): + from hermes_cli.relaunch import _INHERITED_FLAGS_TABLE + + inherited = {flag for flag, _takes_value in _INHERITED_FLAGS_TABLE} + assert "--cli" in inherited + assert "--tui" in inherited + + +# --------------------------------------------------------------------------- +# config default — shipped default preserves classic behavior +# --------------------------------------------------------------------------- +def test_default_config_interface_is_cli(): + from hermes_cli.config import DEFAULT_CONFIG + + assert DEFAULT_CONFIG["display"]["interface"] == "cli" diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 439e64a42..e9bfc9b78 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -29,7 +29,8 @@ hermes [global-options] [subcommand/options] | `--pass-session-id` | Include the session ID in the agent's system prompt. | | `--ignore-user-config` | Ignore `~/.hermes/config.yaml` and fall back to built-in defaults. Credentials in `.env` are still loaded. | | `--ignore-rules` | Skip auto-injection of `AGENTS.md`, `SOUL.md`, `.cursorrules`, memory, and preloaded skills. | -| `--tui` | Launch the [TUI](../user-guide/tui.md) instead of the classic CLI. Equivalent to `HERMES_TUI=1`. | +| `--tui` | Launch the [TUI](../user-guide/tui.md) instead of the classic CLI. Equivalent to `HERMES_TUI=1`. Always wins over `display.interface`. | +| `--cli` | Force the classic prompt_toolkit REPL. Use this to override `display.interface: tui` for a single invocation. | | `--dev` | With `--tui`: run the TypeScript sources directly via `tsx` instead of the prebuilt bundle (for TUI contributors). | ## Top-level commands diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 5be74faaa..e759a5a63 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -36,7 +36,16 @@ hermes # now uses the TUI hermes chat # same ``` -The classic CLI remains available as the default. Anything documented in [CLI Interface](cli.md) — slash commands, quick commands, skill preloading, personalities, multi-line input, interrupts — works in the TUI identically. +Or make it the persistent default in `~/.hermes/config.yaml`: + +```yaml +display: + interface: tui # "cli" (default) or "tui" +``` + +With `display.interface: tui`, a bare `hermes` (and `hermes chat`) launches the TUI. Explicit flags always win — run `hermes --cli` to drop back to the classic REPL for a single invocation, or `hermes --tui` / `HERMES_TUI=1` to force the TUI when the config default is `cli`. + +The classic CLI remains the shipped default. Anything documented in [CLI Interface](cli.md) — slash commands, quick commands, skill preloading, personalities, multi-line input, interrupts — works in the TUI identically. ## Why the TUI @@ -283,7 +292,7 @@ This is the same channel the web dashboard's embedded TUI uses (see [Web Dashboa ## Reverting to the classic CLI -Launching `hermes` (without `--tui`) stays on the classic CLI. To make a machine prefer the TUI, set `HERMES_TUI=1` in your shell profile. To go back, unset it. +Launching `hermes` (without `--tui`) stays on the classic CLI by default. To make a machine prefer the TUI, set `display.interface: tui` in `~/.hermes/config.yaml` (persistent) or `HERMES_TUI=1` in your shell profile (per-shell). To go back, set `interface: cli` / unset the env var, or pass `hermes --cli` for a one-off. If the TUI fails to launch (no Node, missing bundle, TTY issue), Hermes prints a diagnostic and falls back — rather than leaving you stuck.