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:
84
cli.py
84
cli.py
@@ -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():
|
||||||
|
|||||||
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