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:
78
cli.py
78
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,6 +2801,7 @@ class HermesCLI:
|
||||
elif cmd_lower.startswith("/cron"):
|
||||
self._handle_cron_command(cmd_original)
|
||||
elif cmd_lower.startswith("/skills"):
|
||||
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()
|
||||
@@ -2772,6 +2816,7 @@ class HermesCLI:
|
||||
elif cmd_lower == "/paste":
|
||||
self._handle_paste_command()
|
||||
elif cmd_lower == "/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)
|
||||
@@ -2981,6 +3026,7 @@ class HermesCLI:
|
||||
with _lock:
|
||||
old_servers = set(_servers.keys())
|
||||
|
||||
if not self._command_running:
|
||||
print("🔄 Reloading MCP servers...")
|
||||
|
||||
# Shutdown existing connections
|
||||
@@ -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
|
||||
@@ -4161,6 +4224,19 @@ class HermesCLI:
|
||||
)
|
||||
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():
|
||||
while not self._should_exit:
|
||||
|
||||
65
tests/test_cli_loading_indicator.py
Normal file
65
tests/test_cli_loading_indicator.py
Normal 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
|
||||
Reference in New Issue
Block a user