fix: replace ANSI response box with Rich Panel + reduce widget flashing

Major UX improvements:

1. Response box now uses a Rich Panel rendered through ChatConsole
   instead of hand-rolled ANSI box-drawing borders. Rich Panels
   adapt to terminal width at render time, wrap content inside
   the borders properly, and use skin colors natively.

2. ChatConsole now reads terminal width at render time via
   shutil.get_terminal_size() instead of defaulting to 80 cols.
   All Rich output adapts to the current terminal size.

3. User-input separator reduced to fixed 40-char width so it
   never wraps regardless of terminal resize.

4. Approval and clarify countdown repaints throttled to every 5s
   (was 1s), dramatically reducing flicker in Kitty/ghostty.
   Selection changes still trigger instant repaints via key bindings.

5. Sudo widget now uses dynamic _panel_box_width() instead of
   hardcoded border strings.

Tests: 2860 passed.
This commit is contained in:
teknium1 2026-03-10 07:04:02 -07:00
parent e590caf8d8
commit 8eefbef91c

39
cli.py
View file

@ -714,6 +714,8 @@ class ChatConsole:
def print(self, *args, **kwargs): def print(self, *args, **kwargs):
self._buffer.seek(0) self._buffer.seek(0)
self._buffer.truncate() self._buffer.truncate()
# Read terminal width at render time so panels adapt to current size
self._inner.width = shutil.get_terminal_size((80, 24)).columns
self._inner.print(*args, **kwargs) self._inner.print(*args, **kwargs)
output = self._buffer.getvalue() output = self._buffer.getvalue()
for line in output.rstrip("\n").split("\n"): for line in output.rstrip("\n").split("\n"):
@ -3078,6 +3080,10 @@ class HermesCLI:
# Trigger prompt_toolkit repaint from this (non-main) thread # Trigger prompt_toolkit repaint from this (non-main) thread
self._invalidate() self._invalidate()
# Poll for the user's response. The countdown in the hint line
# updates on each invalidate — but frequent repaints cause visible
# flicker in some terminals (Kitty, ghostty). We only refresh the
# countdown every 5 s; selection changes (↑/↓) trigger instant
# Poll for the user's response. The countdown in the hint line # Poll for the user's response. The countdown in the hint line
# updates on each invalidate — but frequent repaints cause visible # updates on each invalidate — but frequent repaints cause visible
# flicker in some terminals (Kitty, ghostty). We only refresh the # flicker in some terminals (Kitty, ghostty). We only refresh the
@ -3098,6 +3104,9 @@ class HermesCLI:
if now - _last_countdown_refresh >= 5.0: if now - _last_countdown_refresh >= 5.0:
_last_countdown_refresh = now _last_countdown_refresh = now
self._invalidate() self._invalidate()
if now - _last_countdown_refresh >= 5.0:
_last_countdown_refresh = now
self._invalidate()
# Timed out — tear down the UI and let the agent decide # Timed out — tear down the UI and let the agent decide
self._clarify_state = None self._clarify_state = None
@ -3239,8 +3248,7 @@ class HermesCLI:
# Add user message to history # Add user message to history
self.conversation_history.append({"role": "user", "content": message}) self.conversation_history.append({"role": "user", "content": message})
w = min(shutil.get_terminal_size().columns, 120) _cprint(f"{_GOLD}{'' * 40}{_RST}")
_cprint(f"{_GOLD}{'' * w}{_RST}")
print(flush=True) print(flush=True)
try: try:
@ -3315,28 +3323,25 @@ class HermesCLI:
response = response + "\n\n---\n_[Interrupted - processing new message]_" response = response + "\n\n---\n_[Interrupted - processing new message]_"
if response: if response:
# Cap at 120 so borders don't wrap when shrinking from fullscreen # Use a Rich Panel for the response box — adapts to terminal
w = min(shutil.get_terminal_size().columns, 120) # width at render time instead of hard-coding border length.
# Use skin branding for response box label
try: try:
from hermes_cli.skin_engine import get_active_skin from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin() _skin = get_active_skin()
label = _skin.get_branding("response_label", "⚕ Hermes") label = _skin.get_branding("response_label", "⚕ Hermes")
_resp_color = _skin.get_color("response_border", "") _resp_color = _skin.get_color("response_border", "#CD7F32")
if _resp_color:
_resp_start = f"\033[38;2;{int(_resp_color[1:3], 16)};{int(_resp_color[3:5], 16)};{int(_resp_color[5:7], 16)}m"
else:
_resp_start = _GOLD
except Exception: except Exception:
label = "⚕ Hermes" label = "⚕ Hermes"
_resp_start = _GOLD _resp_color = "#CD7F32"
fill = w - 2 - len(label) # 2 for ╭ and ╮
top = f"{_resp_start}╭─{label}{'' * max(fill - 1, 0)}{_RST}"
bot = f"{_resp_start}{'' * (w - 2)}{_RST}"
# Render box + response as a single _cprint call so _chat_console = ChatConsole()
# nothing can interleave between the box borders. _chat_console.print(Panel(
_cprint(f"\n{top}\n{response}\n\n{bot}") response,
title=f"[bold]{label}[/bold]",
title_align="left",
border_style=_resp_color,
padding=(1, 2),
))
# Play terminal bell when agent finishes (if enabled). # Play terminal bell when agent finishes (if enabled).
# Works over SSH — the bell propagates to the user's terminal. # Works over SSH — the bell propagates to the user's terminal.