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 <vilkasdev@users.noreply.github.com>
This commit is contained in:
vilkasdev
2026-03-10 17:13:14 -07:00
committed by teknium1
parent ac53bf1d71
commit d502952bac
2 changed files with 145 additions and 4 deletions

84
cli.py
View File

@@ -20,6 +20,7 @@ import json
import atexit import atexit
import uuid import uuid
import textwrap import textwrap
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
@@ -54,6 +55,8 @@ except (ImportError, AttributeError):
import threading import threading
import queue import queue
_COMMAND_SPINNER_FRAMES = ("", "", "", "", "", "", "", "", "", "")
# Load .env from ~/.hermes/.env first, then project root as dev fallback # Load .env from ~/.hermes/.env first, then project root as dev fallback
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -1237,6 +1240,8 @@ class HermesCLI:
self._history_file = Path.home() / ".hermes_history" self._history_file = Path.home() / ".hermes_history"
self._last_invalidate: float = 0.0 # throttle UI repaints self._last_invalidate: float = 0.0 # throttle UI repaints
self._spinner_text: str = "" # thinking spinner text for TUI 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: def _invalidate(self, min_interval: float = 0.25) -> None:
"""Throttled UI repaint — prevents terminal blinking on slow/SSH connections.""" """Throttled UI repaint — prevents terminal blinking on slow/SSH connections."""
@@ -1305,6 +1310,44 @@ class HermesCLI:
self._spinner_text = text or "" self._spinner_text = text or ""
self._invalidate() 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: def _ensure_runtime_credentials(self) -> bool:
""" """
Ensure runtime credentials are resolved before agent use. Ensure runtime credentials are resolved before agent use.
@@ -2758,7 +2801,8 @@ class HermesCLI:
elif cmd_lower.startswith("/cron"): elif cmd_lower.startswith("/cron"):
self._handle_cron_command(cmd_original) self._handle_cron_command(cmd_original)
elif cmd_lower.startswith("/skills"): 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": elif cmd_lower == "/platforms" or cmd_lower == "/gateway":
self._show_gateway_status() self._show_gateway_status()
elif cmd_lower == "/verbose": elif cmd_lower == "/verbose":
@@ -2772,7 +2816,8 @@ class HermesCLI:
elif cmd_lower == "/paste": elif cmd_lower == "/paste":
self._handle_paste_command() self._handle_paste_command()
elif cmd_lower == "/reload-mcp": 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"): elif cmd_lower.startswith("/rollback"):
self._handle_rollback_command(cmd_original) self._handle_rollback_command(cmd_original)
elif cmd_lower.startswith("/skin"): elif cmd_lower.startswith("/skin"):
@@ -2981,7 +3026,8 @@ class HermesCLI:
with _lock: with _lock:
old_servers = set(_servers.keys()) old_servers = set(_servers.keys())
print("🔄 Reloading MCP servers...") if not self._command_running:
print("🔄 Reloading MCP servers...")
# Shutdown existing connections # Shutdown existing connections
shutdown_mcp_servers() shutdown_mcp_servers()
@@ -3441,6 +3487,10 @@ class HermesCLI:
self._approval_state = None # dict with command, description, choices, selected, response_queue self._approval_state = None # dict with command, description, choices, selected, response_queue
self._approval_deadline = 0 self._approval_deadline = 0
# Slash command loading state
self._command_running = False
self._command_status = ""
# Clipboard image attachments (paste images into the CLI) # Clipboard image attachments (paste images into the CLI)
self._attached_images: list[Path] = [] self._attached_images: list[Path] = []
self._image_counter = 0 self._image_counter = 0
@@ -3713,6 +3763,8 @@ class HermesCLI:
return [('class:clarify-selected', ' ')] return [('class:clarify-selected', ' ')]
if cli_ref._clarify_state: if cli_ref._clarify_state:
return [('class:prompt-working', '? ')] return [('class:prompt-working', '? ')]
if cli_ref._command_running:
return [('class:prompt-working', f"{cli_ref._command_spinner_frame()} ")]
if cli_ref._agent_running: if cli_ref._agent_running:
return [('class:prompt-working', ' ')] return [('class:prompt-working', ' ')]
return [('class:prompt', ' ')] return [('class:prompt', ' ')]
@@ -3724,6 +3776,7 @@ class HermesCLI:
style='class:input-area', style='class:input-area',
multiline=True, multiline=True,
wrap_lines=True, wrap_lines=True,
read_only=Condition(lambda: bool(cli_ref._command_running)),
history=FileHistory(str(self._history_file)), history=FileHistory(str(self._history_file)),
completer=SlashCommandCompleter(skill_commands_provider=lambda: _skill_commands), completer=SlashCommandCompleter(skill_commands_provider=lambda: _skill_commands),
complete_while_typing=True, complete_while_typing=True,
@@ -3808,6 +3861,10 @@ class HermesCLI:
return "type your answer here and press Enter" return "type your answer here and press Enter"
if cli_ref._clarify_state: if cli_ref._clarify_state:
return "" 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: if cli_ref._agent_running:
return "type a message + Enter to interrupt, Ctrl+C to cancel" return "type a message + Enter to interrupt, Ctrl+C to cancel"
return "" return ""
@@ -3847,10 +3904,16 @@ class HermesCLI:
('class:clarify-countdown', countdown), ('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 [] return []
def get_hint_height(): 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 return 1
# Keep a 1-line spacer while agent runs so output doesn't push # Keep a 1-line spacer while agent runs so output doesn't push
# right up against the top rule of the input area # 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 {}), **({'cursor': _STEADY_CURSOR} if _STEADY_CURSOR is not None else {}),
) )
self._app = app # Store reference for clarify_callback 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 # Background thread to process inputs and run agent
def process_loop(): def process_loop():

View File

@@ -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