From d502952bace229883c077b2e88f562d201e7a8de Mon Sep 17 00:00:00 2001 From: vilkasdev Date: Tue, 10 Mar 2026 17:13:14 -0700 Subject: [PATCH] fix(cli): add loading indicators for slow slash commands Shows an immediate status message and braille spinner for slow slash commands (/skills search|browse|inspect|install, /reload-mcp). Makes input read-only while the command runs so the CLI doesn't appear frozen. Cherry-picked from PR #714 by vilkasdev, rebased onto current main with conflict resolution and bug fix (get_hint_text duplicate return). Fixes #636 Co-authored-by: vilkasdev --- cli.py | 84 +++++++++++++++++++++++++++-- tests/test_cli_loading_indicator.py | 65 ++++++++++++++++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 tests/test_cli_loading_indicator.py diff --git a/cli.py b/cli.py index 357a3759..fc1f9f82 100755 --- a/cli.py +++ b/cli.py @@ -20,6 +20,7 @@ import json import atexit import uuid import textwrap +from contextlib import contextmanager from pathlib import Path from datetime import datetime from typing import List, Dict, Any, Optional @@ -54,6 +55,8 @@ except (ImportError, AttributeError): import threading import queue +_COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") + # Load .env from ~/.hermes/.env first, then project root as dev fallback from dotenv import load_dotenv @@ -1237,6 +1240,8 @@ class HermesCLI: self._history_file = Path.home() / ".hermes_history" self._last_invalidate: float = 0.0 # throttle UI repaints self._spinner_text: str = "" # thinking spinner text for TUI + self._command_running = False + self._command_status = "" def _invalidate(self, min_interval: float = 0.25) -> None: """Throttled UI repaint — prevents terminal blinking on slow/SSH connections.""" @@ -1305,6 +1310,44 @@ class HermesCLI: self._spinner_text = text or "" self._invalidate() + def _slow_command_status(self, command: str) -> str: + """Return a user-facing status message for slower slash commands.""" + cmd_lower = command.lower().strip() + if cmd_lower.startswith("/skills search"): + return "Searching skills..." + if cmd_lower.startswith("/skills browse"): + return "Loading skills..." + if cmd_lower.startswith("/skills inspect"): + return "Inspecting skill..." + if cmd_lower.startswith("/skills install"): + return "Installing skill..." + if cmd_lower.startswith("/skills"): + return "Processing skills command..." + if cmd_lower == "/reload-mcp": + return "Reloading MCP servers..." + return "Processing command..." + + def _command_spinner_frame(self) -> str: + """Return the current spinner frame for slow slash commands.""" + import time as _time + + frame_idx = int(_time.monotonic() * 10) % len(_COMMAND_SPINNER_FRAMES) + return _COMMAND_SPINNER_FRAMES[frame_idx] + + @contextmanager + def _busy_command(self, status: str): + """Expose a temporary busy state in the TUI while a slash command runs.""" + self._command_running = True + self._command_status = status + self._invalidate(min_interval=0.0) + try: + print(f"⏳ {status}") + yield + finally: + self._command_running = False + self._command_status = "" + self._invalidate(min_interval=0.0) + def _ensure_runtime_credentials(self) -> bool: """ Ensure runtime credentials are resolved before agent use. @@ -2758,7 +2801,8 @@ class HermesCLI: elif cmd_lower.startswith("/cron"): self._handle_cron_command(cmd_original) elif cmd_lower.startswith("/skills"): - self._handle_skills_command(cmd_original) + with self._busy_command(self._slow_command_status(cmd_original)): + self._handle_skills_command(cmd_original) elif cmd_lower == "/platforms" or cmd_lower == "/gateway": self._show_gateway_status() elif cmd_lower == "/verbose": @@ -2772,7 +2816,8 @@ class HermesCLI: elif cmd_lower == "/paste": self._handle_paste_command() elif cmd_lower == "/reload-mcp": - self._reload_mcp() + with self._busy_command(self._slow_command_status(cmd_original)): + self._reload_mcp() elif cmd_lower.startswith("/rollback"): self._handle_rollback_command(cmd_original) elif cmd_lower.startswith("/skin"): @@ -2981,7 +3026,8 @@ class HermesCLI: with _lock: old_servers = set(_servers.keys()) - print("🔄 Reloading MCP servers...") + if not self._command_running: + print("🔄 Reloading MCP servers...") # Shutdown existing connections shutdown_mcp_servers() @@ -3441,6 +3487,10 @@ class HermesCLI: self._approval_state = None # dict with command, description, choices, selected, response_queue self._approval_deadline = 0 + # Slash command loading state + self._command_running = False + self._command_status = "" + # Clipboard image attachments (paste images into the CLI) self._attached_images: list[Path] = [] self._image_counter = 0 @@ -3713,6 +3763,8 @@ class HermesCLI: return [('class:clarify-selected', '✎ ❯ ')] if cli_ref._clarify_state: return [('class:prompt-working', '? ❯ ')] + if cli_ref._command_running: + return [('class:prompt-working', f"{cli_ref._command_spinner_frame()} ❯ ")] if cli_ref._agent_running: return [('class:prompt-working', '⚕ ❯ ')] return [('class:prompt', '❯ ')] @@ -3724,6 +3776,7 @@ class HermesCLI: style='class:input-area', multiline=True, wrap_lines=True, + read_only=Condition(lambda: bool(cli_ref._command_running)), history=FileHistory(str(self._history_file)), completer=SlashCommandCompleter(skill_commands_provider=lambda: _skill_commands), complete_while_typing=True, @@ -3808,6 +3861,10 @@ class HermesCLI: return "type your answer here and press Enter" if cli_ref._clarify_state: return "" + if cli_ref._command_running: + frame = cli_ref._command_spinner_frame() + status = cli_ref._command_status or "Processing command..." + return f"{frame} {status}" if cli_ref._agent_running: return "type a message + Enter to interrupt, Ctrl+C to cancel" return "" @@ -3847,10 +3904,16 @@ class HermesCLI: ('class:clarify-countdown', countdown), ] + if cli_ref._command_running: + frame = cli_ref._command_spinner_frame() + return [ + ('class:hint', f' {frame} command in progress · input temporarily disabled'), + ] + return [] def get_hint_height(): - if cli_ref._sudo_state or cli_ref._approval_state or cli_ref._clarify_state: + if cli_ref._sudo_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running: return 1 # Keep a 1-line spacer while agent runs so output doesn't push # right up against the top rule of the input area @@ -4160,6 +4223,19 @@ class HermesCLI: **({'cursor': _STEADY_CURSOR} if _STEADY_CURSOR is not None else {}), ) self._app = app # Store reference for clarify_callback + + def spinner_loop(): + import time as _time + + while not self._should_exit: + if self._command_running and self._app: + self._invalidate(min_interval=0.1) + _time.sleep(0.1) + else: + _time.sleep(0.05) + + spinner_thread = threading.Thread(target=spinner_loop, daemon=True) + spinner_thread.start() # Background thread to process inputs and run agent def process_loop(): diff --git a/tests/test_cli_loading_indicator.py b/tests/test_cli_loading_indicator.py new file mode 100644 index 00000000..6cec9eca --- /dev/null +++ b/tests/test_cli_loading_indicator.py @@ -0,0 +1,65 @@ +"""Regression tests for loading feedback on slow slash commands.""" + +from unittest.mock import patch + +from cli import HermesCLI + + +class TestCLILoadingIndicator: + def _make_cli(self): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj._app = None + cli_obj._last_invalidate = 0.0 + cli_obj._command_running = False + cli_obj._command_status = "" + return cli_obj + + def test_skills_command_sets_busy_state_and_prints_status(self, capsys): + cli_obj = self._make_cli() + seen = {} + + def fake_handle(cmd: str): + seen["cmd"] = cmd + seen["running"] = cli_obj._command_running + seen["status"] = cli_obj._command_status + print("skills done") + + with patch.object(cli_obj, "_handle_skills_command", side_effect=fake_handle), \ + patch.object(cli_obj, "_invalidate") as invalidate_mock: + assert cli_obj.process_command("/skills search kubernetes") + + output = capsys.readouterr().out + assert "⏳ Searching skills..." in output + assert "skills done" in output + assert seen == { + "cmd": "/skills search kubernetes", + "running": True, + "status": "Searching skills...", + } + assert cli_obj._command_running is False + assert cli_obj._command_status == "" + assert invalidate_mock.call_count == 2 + + def test_reload_mcp_sets_busy_state_and_prints_status(self, capsys): + cli_obj = self._make_cli() + seen = {} + + def fake_reload(): + seen["running"] = cli_obj._command_running + seen["status"] = cli_obj._command_status + print("reload done") + + with patch.object(cli_obj, "_reload_mcp", side_effect=fake_reload), \ + patch.object(cli_obj, "_invalidate") as invalidate_mock: + assert cli_obj.process_command("/reload-mcp") + + output = capsys.readouterr().out + assert "⏳ Reloading MCP servers..." in output + assert "reload done" in output + assert seen == { + "running": True, + "status": "Reloading MCP servers...", + } + assert cli_obj._command_running is False + assert cli_obj._command_status == "" + assert invalidate_mock.call_count == 2