diff --git a/cli.py b/cli.py index c82e85dc..87f1c1a7 100755 --- a/cli.py +++ b/cli.py @@ -45,6 +45,11 @@ from prompt_toolkit.widgets import TextArea from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit import print_formatted_text as _pt_print from prompt_toolkit.formatted_text import ANSI as _PT_ANSI +try: + from prompt_toolkit.cursor_shapes import CursorShape + _STEADY_CURSOR = CursorShape.BLOCK # Non-blinking block cursor +except (ImportError, AttributeError): + _STEADY_CURSOR = None import threading import queue @@ -1187,6 +1192,7 @@ class HermesCLI: # History file for persistent input recall across sessions 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 def _invalidate(self, min_interval: float = 0.25) -> None: """Throttled UI repaint — prevents terminal blinking on slow/SSH connections.""" @@ -1250,6 +1256,11 @@ class HermesCLI: return changed + def _on_thinking(self, text: str) -> None: + """Called by agent when thinking starts/stops. Updates TUI spinner.""" + self._spinner_text = text or "" + self._invalidate() + def _ensure_runtime_credentials(self) -> bool: """ Ensure runtime credentials are resolved before agent use. @@ -1388,6 +1399,7 @@ class HermesCLI: clarify_callback=self._clarify_callback, honcho_session_key=self.session_id, fallback_model=self._fallback_model, + thinking_callback=self._on_thinking, ) # Apply any pending title now that the session exists in the DB if self._pending_title and self._session_db: @@ -3666,6 +3678,20 @@ class HermesCLI: # right up against the top rule of the input area return 1 if cli_ref._agent_running else 0 + def get_spinner_text(): + txt = cli_ref._spinner_text + if not txt: + return [] + return [('class:hint', f' {txt}')] + + def get_spinner_height(): + return 1 if cli_ref._spinner_text else 0 + + spinner_widget = Window( + content=FormattedTextControl(get_spinner_text), + height=get_spinner_height, + ) + spacer = Window( content=FormattedTextControl(get_hint_text), height=get_hint_height, @@ -3848,6 +3874,7 @@ class HermesCLI: sudo_widget, approval_widget, clarify_widget, + spinner_widget, spacer, input_rule_top, image_bar, @@ -3902,6 +3929,7 @@ class HermesCLI: style=style, full_screen=False, mouse_support=False, + **({'cursor': _STEADY_CURSOR} if _STEADY_CURSOR is not None else {}), ) self._app = app # Store reference for clarify_callback @@ -3970,6 +3998,7 @@ class HermesCLI: self.chat(user_input, images=submit_images or None) finally: self._agent_running = False + self._spinner_text = "" app.invalidate() # Refresh status line except Exception as e: diff --git a/run_agent.py b/run_agent.py index f03d3cb1..cd6be255 100644 --- a/run_agent.py +++ b/run_agent.py @@ -172,6 +172,7 @@ class AIAgent: provider_data_collection: str = None, session_id: str = None, tool_progress_callback: callable = None, + thinking_callback: callable = None, clarify_callback: callable = None, step_callback: callable = None, max_tokens: int = None, @@ -256,6 +257,7 @@ class AIAgent: self.api_mode = "chat_completions" self.tool_progress_callback = tool_progress_callback + self.thinking_callback = thinking_callback self.clarify_callback = clarify_callback self.step_callback = step_callback self._last_reported_tool = None # Track for "new tool" mode @@ -3325,9 +3327,13 @@ class AIAgent: # Animated thinking spinner in quiet mode face = random.choice(KawaiiSpinner.KAWAII_THINKING) verb = random.choice(KawaiiSpinner.THINKING_VERBS) - spinner_type = random.choice(['brain', 'sparkle', 'pulse', 'moon', 'star']) - thinking_spinner = KawaiiSpinner(f"{face} {verb}...", spinner_type=spinner_type) - thinking_spinner.start() + if self.thinking_callback: + # CLI TUI mode: use prompt_toolkit widget instead of raw spinner + self.thinking_callback(f"{face} {verb}...") + else: + spinner_type = random.choice(['brain', 'sparkle', 'pulse', 'moon', 'star']) + thinking_spinner = KawaiiSpinner(f"{face} {verb}...", spinner_type=spinner_type) + thinking_spinner.start() # Log request details if verbose if self.verbose_logging: @@ -3364,6 +3370,8 @@ class AIAgent: if thinking_spinner: thinking_spinner.stop("") thinking_spinner = None + if self.thinking_callback: + self.thinking_callback("") if not self.quiet_mode: print(f"{self.log_prefix}⏱️ API call completed in {api_duration:.2f}s") @@ -3404,6 +3412,8 @@ class AIAgent: if thinking_spinner: thinking_spinner.stop(f"(´;ω;`) oops, retrying...") thinking_spinner = None + if self.thinking_callback: + self.thinking_callback("") # This is often rate limiting or provider returning malformed response retry_count += 1 @@ -3573,6 +3583,8 @@ class AIAgent: if thinking_spinner: thinking_spinner.stop("") thinking_spinner = None + if self.thinking_callback: + self.thinking_callback("") api_elapsed = time.time() - api_start_time print(f"{self.log_prefix}⚡ Interrupted during API call.") self._persist_session(messages, conversation_history) @@ -3585,6 +3597,8 @@ class AIAgent: if thinking_spinner: thinking_spinner.stop(f"(╥_╥) error, retrying...") thinking_spinner = None + if self.thinking_callback: + self.thinking_callback("") status_code = getattr(api_error, "status_code", None) if (