feat(cli): configurable default interface (cli vs tui)
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.
This commit is contained in:
@ -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 <session_id> 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",
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@ -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)
|
||||
|
||||
191
tests/hermes_cli/test_default_interface_resolution.py
Normal file
191
tests/hermes_cli/test_default_interface_resolution.py
Normal file
@ -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"
|
||||
@ -29,7 +29,8 @@ hermes [global-options] <command> [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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user