Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions hermes_cli/_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ def _inherited_flag(parser, *args, **kwargs):
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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,12 @@ def _ensure_hermes_home_managed(home: Path):
# 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
Expand Down Expand Up @@ -2284,7 +2290,7 @@ def _ensure_hermes_home_managed(home: Path):


# Config schema version - bump this when adding new required fields
"_config_version": 25,
"_config_version": 26,
}

# =============================================================================
Expand Down
90 changes: 85 additions & 5 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down
191 changes: 191 additions & 0 deletions tests/hermes_cli/test_default_interface_resolution.py
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 2 additions & 1 deletion website/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading