fix: stop terminal border flashing with steady cursor and TUI spinner widget
Cherry-picked and improved from PR #470 (fixes #464). Problem: On Ubuntu 24.04 with ghostty + tmux, the prompt input box border lines flash due to cursor blink and raw spinner terminal writes conflicting with prompt_toolkit's rendering. Changes: - cli.py: Add CursorShape.BLOCK to Application() to disable cursor blink - cli.py: Add thinking_callback + spinner_widget in TUI layout so thinking status displays as a proper prompt_toolkit widget instead of raw terminal writes that conflict with the TUI renderer - run_agent.py: Add thinking_callback parameter to AIAgent; when set, uses the callback instead of KawaiiSpinner for thinking display What was NOT changed (preserving existing behavior): - agent/display.py: Untouched. KawaiiSpinner _write() stdout capture, _animate() logic, and 0.12s frame interval all preserved. This protects subagent stdout redirection and keeps smooth animations for non-CLI contexts (gateway, batch runner). - Original emoji spinner types (brain/sparkle/pulse/moon/star) preserved for all non-CLI contexts. Fixes from original PR #470: - CursorShape.STEADY_BLOCK -> CursorShape.BLOCK (STEADY_BLOCK doesn't exist in prompt_toolkit 3.0.52) - Removed duplicate self._spinner_text = '' line - Removed redundant nested if-checks Tested: 2706 tests pass, interactive CLI verified via tmux.
This commit is contained in:
parent
88f8bcde38
commit
ee4008431a
2 changed files with 46 additions and 3 deletions
29
cli.py
29
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:
|
||||
|
|
|
|||
20
run_agent.py
20
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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue