diff --git a/cli.py b/cli.py index 8cf25b13f568..ce16315ae521 100644 --- a/cli.py +++ b/cli.py @@ -10066,6 +10066,8 @@ def process_command(self, command: str) -> bool: self._handle_wake_command(cmd_original) elif canonical == "busy": self._handle_busy_command(cmd_original) + elif canonical == "indicator": + self._handle_indicator_command(cmd_original) else: # Check for user-defined quick commands (bypass agent loop, no LLM call) base_cmd = cmd_lower.split()[0] diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index c9ae87938048..105d7c256b07 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -3056,6 +3056,46 @@ def _handle_busy_command(self, cmd: str): else: _cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (session only){_RST}") + def _handle_indicator_command(self, cmd: str): + """Handle /indicator — pick the TUI busy-indicator style. + + Usage: + /indicator Show the current busy-indicator style + /indicator status Show the current busy-indicator style + /indicator kaomoji Animated kaomoji faces (default) + /indicator emoji Emoji spinner + /indicator unicode Braille spinner + /indicator ascii Plain ASCII spinner + + Persists to ``display.tui_status_indicator`` — the same config key the + TUI reads — so the change is picked up the next time the TUI renders. + """ + from cli import _ACCENT, _DIM, _RST, _cprint, save_config_value + from hermes_constants import DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES + styles = INDICATOR_STYLES + current = ( + (self.config.get("display") or {}).get("tui_status_indicator", DEFAULT_INDICATOR_STYLE) + ) + + parts = cmd.strip().split(maxsplit=1) + if len(parts) < 2 or parts[1].strip().lower() == "status": + _cprint(f" {_ACCENT}Busy-indicator style: {current}{_RST}") + _cprint(f" {_DIM}Usage: /indicator [{'|'.join(styles)}]{_RST}") + return + + arg = parts[1].strip().lower() + if arg not in styles: + _cprint(f" {_DIM}(._.) Unknown indicator style: {arg}{_RST}") + _cprint(f" {_DIM}Usage: /indicator [{'|'.join(styles)}]{_RST}") + return + + self.config.setdefault("display", {})["tui_status_indicator"] = arg + if save_config_value("display.tui_status_indicator", arg): + _cprint(f" {_ACCENT}✓ Busy-indicator style set to '{arg}' (saved to config){_RST}") + _cprint(f" {_DIM}The TUI picks up the new style on its next render.{_RST}") + else: + _cprint(f" {_ACCENT}✓ Busy-indicator style set to '{arg}' (session only){_RST}") + def _handle_fast_command(self, cmd: str): """Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode). diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 52c7bc91d163..34280abc2464 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -21,6 +21,7 @@ from typing import Any from utils import is_truthy_value +from hermes_constants import INDICATOR_STYLES logger = logging.getLogger(__name__) @@ -226,8 +227,8 @@ class CommandDef: CommandDef("skin", "Show or change the display skin/theme", "Configuration", cli_only=True, args_hint="[name]"), CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration", - cli_only=True, args_hint="[kaomoji|emoji|unicode|ascii]", - subcommands=("kaomoji", "emoji", "unicode", "ascii")), + cli_only=True, args_hint=f"[{'|'.join(INDICATOR_STYLES)}]", + subcommands=INDICATOR_STYLES), CommandDef("voice", "Toggle voice mode", "Configuration", args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), CommandDef("wake", "Toggle the 'Hey Hermes' wake word listener", "Configuration", diff --git a/hermes_constants.py b/hermes_constants.py index bd3db68f1313..c5f33e47468d 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -18,6 +18,14 @@ "_HERMES_HOME_OVERRIDE", default=_UNSET ) +# ── TUI busy-indicator styles ───────────────────────────────────────── +# Single source of truth shared by the CLI /indicator command, the TUI +# gateway config handler, and the /help command registry. Keep in sync +# with ``INDICATOR_STYLES`` / ``DEFAULT_INDICATOR_STYLE`` in +# ``ui-tui/src/app/interfaces.ts`` on the frontend side. +INDICATOR_STYLES: tuple[str, ...] = ("ascii", "emoji", "kaomoji", "unicode") +DEFAULT_INDICATOR_STYLE: str = "kaomoji" + def set_hermes_home_override(path: str | Path | None) -> Token: """Set a context-local Hermes home override and return its reset token. diff --git a/tests/cli/test_indicator_command.py b/tests/cli/test_indicator_command.py new file mode 100644 index 000000000000..68b2d3a97878 --- /dev/null +++ b/tests/cli/test_indicator_command.py @@ -0,0 +1,148 @@ +"""Tests for the /indicator CLI command and busy-indicator style config. + +The /indicator command is registered in COMMAND_REGISTRY (and advertised by +/help, tab-completion and the tips system) but used to have no dispatch branch +in HermesCLI.process_command — so typing it printed "Unknown command: +/indicator". These tests lock in the dispatch wiring and the handler behavior. +""" + +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from cli import HermesCLI + + +def _import_cli(): + import hermes_cli.config as config_mod + + if not hasattr(config_mod, "save_env_value_secure"): + config_mod.save_env_value_secure = lambda key, value: { + "success": True, + "stored_as": key, + "validated": False, + } + + import cli as cli_mod + + return cli_mod + + +def _make_cli(): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.config = {} + cli_obj.console = MagicMock() + cli_obj.agent = None + cli_obj.conversation_history = [] + cli_obj.session_id = None + cli_obj._pending_input = MagicMock() + return cli_obj + + +class TestIndicatorDispatch(unittest.TestCase): + """The command must route to its handler — not fall through to "Unknown".""" + + def test_indicator_dispatches_to_handler(self): + cli_obj = _make_cli() + with patch.object(cli_obj, "_handle_indicator_command") as mock_handler: + result = cli_obj.process_command("/indicator emoji") + + mock_handler.assert_called_once_with("/indicator emoji") + self.assertTrue(result) + + def test_indicator_is_not_unknown_command(self): + cli_obj = _make_cli() + with ( + patch("cli._cprint") as mock_cprint, + patch("cli.save_config_value", return_value=True), + ): + result = cli_obj.process_command("/indicator emoji") + + printed = " ".join(str(c) for c in mock_cprint.call_args_list) + self.assertNotIn("Unknown command", printed) + self.assertTrue(result) + + +class TestHandleIndicatorCommand(unittest.TestCase): + def _stub(self, current=None): + config = {} + if current is not None: + config["display"] = {"tui_status_indicator": current} + return SimpleNamespace(config=config) + + def test_no_args_shows_status(self): + cli_mod = _import_cli() + stub = self._stub("emoji") + with ( + patch.object(cli_mod, "_cprint") as mock_cprint, + patch.object(cli_mod, "save_config_value") as mock_save, + ): + cli_mod.HermesCLI._handle_indicator_command(stub, "/indicator") + + mock_save.assert_not_called() + printed = " ".join(str(c) for c in mock_cprint.call_args_list) + self.assertIn("emoji", printed) + + def test_status_argument_shows_status(self): + cli_mod = _import_cli() + stub = self._stub() # no display config -> default kaomoji + with ( + patch.object(cli_mod, "_cprint") as mock_cprint, + patch.object(cli_mod, "save_config_value") as mock_save, + ): + cli_mod.HermesCLI._handle_indicator_command(stub, "/indicator status") + + mock_save.assert_not_called() + printed = " ".join(str(c) for c in mock_cprint.call_args_list) + self.assertIn("kaomoji", printed) + + def test_valid_style_saves_to_config_key(self): + cli_mod = _import_cli() + stub = self._stub("kaomoji") + with ( + patch.object(cli_mod, "_cprint"), + patch.object(cli_mod, "save_config_value", return_value=True) as mock_save, + ): + cli_mod.HermesCLI._handle_indicator_command(stub, "/indicator unicode") + + # Persists to the SAME key the TUI reads, and mirrors it in memory. + mock_save.assert_called_once_with("display.tui_status_indicator", "unicode") + self.assertEqual(stub.config["display"]["tui_status_indicator"], "unicode") + + def test_invalid_style_prints_usage_and_does_not_save(self): + cli_mod = _import_cli() + stub = self._stub("kaomoji") + with ( + patch.object(cli_mod, "_cprint") as mock_cprint, + patch.object(cli_mod, "save_config_value") as mock_save, + ): + cli_mod.HermesCLI._handle_indicator_command(stub, "/indicator rainbow") + + mock_save.assert_not_called() + # The stored value must be untouched. + self.assertEqual(stub.config["display"]["tui_status_indicator"], "kaomoji") + printed = " ".join(str(c) for c in mock_cprint.call_args_list) + self.assertIn("Usage: /indicator", printed) + + +class TestIndicatorRegistry(unittest.TestCase): + def test_indicator_in_registry(self): + from hermes_cli.commands import COMMAND_REGISTRY + + names = [c.name for c in COMMAND_REGISTRY] + self.assertIn("indicator", names) + + def test_indicator_subcommands_match_handler(self): + from hermes_cli.commands import COMMAND_REGISTRY + from hermes_constants import INDICATOR_STYLES + + indicator = next(c for c in COMMAND_REGISTRY if c.name == "indicator") + self.assertEqual(indicator.category, "Configuration") + # The registered styles are what the handler accepts — single source of truth. + self.assertEqual( + set(indicator.subcommands), set(INDICATOR_STYLES) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ca5876ac00be..726d1a172c55 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -24,6 +24,8 @@ set_secret_scope, ) from hermes_constants import ( + DEFAULT_INDICATOR_STYLE, + INDICATOR_STYLES, get_hermes_home, get_hermes_home_override, reset_hermes_home_override, @@ -2624,12 +2626,6 @@ def _set_session_cwd(session: dict, cwd: str) -> str: # ── Config I/O ──────────────────────────────────────────────────────── -# Keep aligned with `INDICATOR_STYLES` / `DEFAULT_INDICATOR_STYLE` in -# ``ui-tui/src/app/interfaces.ts`` — both ends validate against the -# same shape so `config.get indicator` and the live TUI render agree. -_INDICATOR_STYLES: tuple[str, ...] = ("ascii", "emoji", "kaomoji", "unicode") -_INDICATOR_DEFAULT = "kaomoji" - _DASHBOARD_TURN_ISOLATION_DEFAULT = False _DASHBOARD_COMPUTE_HOST_HEARTBEAT_SECS_DEFAULT = 15 _DASHBOARD_COMPUTE_HOST_RESPAWN_MAX_DEFAULT = 3 @@ -10628,11 +10624,11 @@ def _resolve_toggle(current: bool) -> bool: # non-string inputs (0, False, []) still surface as themselves # in the error message instead of looking like a blank value. raw = ("" if value is None else str(value)).strip().lower() - if raw not in _INDICATOR_STYLES: + if raw not in INDICATOR_STYLES: return _err( rid, 4002, - f"unknown indicator: {raw!r}; pick one of {'|'.join(_INDICATOR_STYLES)}", + f"unknown indicator: {raw!r}; pick one of {'|'.join(INDICATOR_STYLES)}", ) _write_config_key("display.tui_status_indicator", raw) return _ok(rid, {"key": key, "value": raw})