Skip to content
Closed
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
2 changes: 2 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
40 changes: 40 additions & 0 deletions hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
5 changes: 3 additions & 2 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from typing import Any

from utils import is_truthy_value
from hermes_constants import INDICATOR_STYLES

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
148 changes: 148 additions & 0 deletions tests/cli/test_indicator_command.py
Original file line number Diff line number Diff line change
@@ -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()
12 changes: 4 additions & 8 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})
Expand Down