Files
hermes-agent/hermes_cli/cli_terminal_mixin.py
T
Teknium eb74a00c71 refactor(cli): split HermesCLI into 10 cohesive mixins (cli.py 22,284 -> 9,150)
326 methods lifted by AST (bodies identical; ast.dump-verified) into
hermes_cli/cli_{tui,status_bar,voice,model_switch,session,stream,modal,
terminal,info,loops}_mixin.py. cli.py-internal symbols resolve via lazy
'from cli import ...' inside each method (no import cycle; patch('cli.X')
keeps working). The three 'global' writers (_skill_commands, _cli_wake_owner)
now write the cli module attribute explicitly so the origin's readers still
see them. Dropped imports left unused in cli.py; kept display_hermes_home /
build_welcome_banner as re-exports (mixins + tests resolve them via cli).
Repointed two AST change-detector tests to cli_tui_mixin.py; one test
fixture now keeps 'cli' in sys.modules across its patch.dict scope.
2026-09-02 15:42:24 -07:00

625 lines
28 KiB
Python

"""Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive CLI
Mixin split out of ``cli.py``; bound onto ``HermesCLI`` via the MRO. cli.py-internal
symbols are imported LAZILY inside each method (``from cli import ...``) — the mixin
never imports ``cli`` at module load time (import cycle).
"""
from __future__ import annotations
import base64
import errno
import os
import shutil
import sys
import threading
import time
from hermes_constants import get_hermes_home
class CLITerminalMixin:
"""Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive CLI"""
def _mark_terminal_io_broken(self, reason: str = "") -> None:
"""Stop UI paints after the PTY/stdout becomes unusable (#81521)."""
from cli import logger
if getattr(self, "_terminal_io_broken", False):
return
self._terminal_io_broken = True
try:
self._pet_stop_anim()
except Exception:
pass
logger.warning(
"Terminal I/O broken%s — freezing UI paints to avoid redraw storm (#81521)",
f" ({reason})" if reason else "",
)
def _invalidate(self, min_interval: float = 0.25) -> None:
"""Throttled UI repaint for high-frequency background updates.
Use this for spinner frames, streaming token flushes, and other
repaints that can fire many times per second — the throttle prevents
terminal blinking on slow/SSH connections, and the resize-recovery
guard avoids stamping footer/status-bar chrome into scrollback while a
SIGWINCH reflow is in flight.
Do NOT use this for user-blocking modal prompts (approval / clarify /
sudo). Those are rare, one-shot, user-blocking events that must paint
immediately; route them through ``self._app.invalidate()`` directly, the
same way the modal key-binding handlers already do. Sending a modal's
entry paint through this throttle lets an unrelated background repaint
within the 250ms window — or an in-flight resize — silently drop it, so
the prompt never renders and times out unseen (#41098).
"""
if getattr(self, "_terminal_io_broken", False):
return
if getattr(self, "_resize_recovery_pending", False):
return
now = time.monotonic()
if hasattr(self, "_app") and self._app and (now - getattr(self, "_last_invalidate", 0.0)) >= min_interval:
self._last_invalidate = now
try:
self._app.invalidate()
except OSError as exc:
if getattr(exc, "errno", None) == errno.EIO:
self._mark_terminal_io_broken("invalidate")
return
raise
def _paint_now(self) -> None:
"""Immediate, unthrottled repaint for user-blocking modal prompts.
Background-thread callbacks (approval / clarify / sudo) set their modal
state then call this to make the panel visible at once. It deliberately
bypasses the ``_invalidate`` throttle and resize-recovery guard — a
modal the user is actively waiting on must never be dropped — mirroring
the direct ``event.app.invalidate()`` the modal key-binding handlers
already use. See ``_invalidate`` for why the throttle must not gate
these paints (#41098).
"""
if getattr(self, "_terminal_io_broken", False):
return
app = getattr(self, "_app", None)
if app is not None:
try:
app.invalidate()
except OSError as exc:
if getattr(exc, "errno", None) == errno.EIO:
self._mark_terminal_io_broken("paint_now")
return
raise
except Exception:
pass
def _force_full_redraw(self) -> None:
"""Force a clean full-screen repaint of the prompt_toolkit UI.
Used to recover from terminal buffer drift caused by external
redraws we can't detect — e.g. macOS cmux / tmux tab switches,
``clear`` issued from a subshell, or SSH window restores. These
wipe or repaint the terminal without firing SIGWINCH, so
prompt_toolkit's tracked ``_cursor_pos`` no longer matches reality
and the next incremental redraw stacks on top of stale content
(ghost status bars, duplicated prompts).
Bound to Ctrl+L and exposed as the ``/redraw`` slash command,
matching the standard terminal-UX convention (bash, zsh, fish,
vim, htop).
"""
from cli import _replay_output_history
if getattr(self, "_terminal_io_broken", False):
return
app = getattr(self, "_app", None)
if not app:
return
self._clear_prompt_toolkit_screen(
app,
rebuild_scrollback=self._redraw_rebuilds_scrollback(),
)
if getattr(self, "_terminal_io_broken", False):
return
_replay_output_history()
self._pet_queue_kitty_frame()
try:
app.invalidate()
except OSError as exc:
if getattr(exc, "errno", None) == errno.EIO:
self._mark_terminal_io_broken("force_full_redraw")
return
raise
except Exception:
pass
def _schedule_focus_regain_redraw(self, min_interval: float = 1.0) -> None:
"""Repaint after a terminal focus-in report (``CSI I``), rate-limited.
Terminals with focus tracking active (Ghostty, iTerm2, xterm builds,
multiplexers that toggle DECSET 1004 upstream) emit ``\\x1b[I`` when
the Hermes tab/window becomes visible again. Emulators can coalesce
or drop hidden-tab output and repaint the surface while we're
invisible, so on regain prompt_toolkit's incremental diff stacks on
stale content — a second copy of the composer/prompt chrome next to
the ghost of the old one (#60920 focus-regain variant, #25337).
The stock handling maps ``CSI I``/``CSI O`` to ``Keys.Ignore`` so the
bytes never pollute the input buffer; this hook additionally routes
focus-in through the same recovery as Ctrl+L / ``/redraw``. It is
self-gating: terminals that never enable focus tracking never emit
the sequence, so nothing changes for them. Rate-limited so a burst of
focus reports (rapid Alt+Tab, mux pane hops) repaints at most once
per ``min_interval`` seconds.
"""
now = time.monotonic()
last = getattr(self, "_last_focus_regain_redraw", 0.0)
if now - last < min_interval:
return
self._last_focus_regain_redraw = now
self._force_full_redraw()
@staticmethod
def _redraw_rebuilds_scrollback() -> bool:
"""Return whether CLI redraw/resize recovery should clear scrollback.
Some terminal/tmux stacks move prompt_toolkit's non-fullscreen bottom
chrome into scrollback when the window is maximized/restored. A normal
CSI 2J viewport clear cannot remove those stale prompt/input-rule rows,
so users who hit that class of bug need CSI 3J as well, followed by the
existing bounded output-history replay.
"""
from cli import CLI_CONFIG
display_config = CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else {}
if not isinstance(display_config, dict):
display_config = {}
raw = display_config.get("cli_rebuild_scrollback_on_redraw", False)
if isinstance(raw, str):
return raw.strip().lower() in {"1", "true", "yes", "on", "always"}
return bool(raw)
def _recover_terminal_after_interrupt(self) -> None:
"""Recover the terminal after an interrupted agent turn (#33271).
When the user interrupts a running turn by typing a new message,
prompt_toolkit may have an in-flight ``CSI 6n`` cursor-position query
whose reply (``ESC[<row>;<col>R``) arrives on stdin after the input
parser has torn down. The reply then leaks as literal text
(``^[[19;1R``) and the VT100 parser can stall in a partial-escape
state, accepting no further keystrokes — the terminal appears frozen.
Two steps recover a sane state:
1. ``flush_stdin()`` drains stray escape bytes from the OS input
buffer (``termios.tcflush(TCIFLUSH)``; no-op on non-TTY).
2. ``_force_full_redraw()`` drops prompt_toolkit's cached
screen/cursor state and forces a clean repaint.
Both steps are independently safe and self-guard, so a failure of one
never prevents the other. If the PTY is already dead (EIO), skip the
redraw entirely — painting a broken fd is the #81521 redraw storm.
"""
if getattr(self, "_terminal_io_broken", False):
return
try:
from hermes_cli.curses_ui import flush_stdin
flush_stdin()
except Exception:
pass
# #60920: The interruption marker is now printed with
# _suspend_output_history in chat(), so _OUTPUT_HISTORY only
# contains the normal response text (no marker text). Do NOT
# clear history here — _force_full_redraw → _replay_output_history
# replays the response correctly without duplicating the marker.
# The /redraw + Ctrl+L paths also preserve replay for scrollback
# recovery as intended.
self._force_full_redraw()
def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False) -> None:
"""Clear the terminal and reset prompt_toolkit renderer state."""
if getattr(self, "_terminal_io_broken", False):
return
try:
renderer = app.renderer
out = renderer.output
out.reset_attributes()
out.erase_screen()
if rebuild_scrollback:
try:
out.write_raw("\x1b[3J")
except Exception:
pass
out.cursor_goto(0, 0)
out.flush()
# Drop prompt_toolkit's cached screen + cursor state so the
# next _redraw() starts from a known (0, 0) origin and
# re-renders every cell rather than diffing against stale.
renderer.reset(leave_alternate_screen=False)
except OSError as exc:
if getattr(exc, "errno", None) == errno.EIO:
self._mark_terminal_io_broken("clear_screen")
return
pass
except Exception:
pass
def _recover_after_resize(self, app, original_on_resize) -> None:
"""Recover a resized classic CLI without desynchronizing cursor state.
Unlike _force_full_redraw, we do NOT clear the physical screen or
scrollback here. The startup banner and tool summary are printed
before prompt_toolkit owns the live chrome, so they live in normal
terminal scrollback. Erasing the screen on SIGWINCH removes that
startup UI and ``_replay_output_history`` cannot reconstruct it
(the banner was never added to ``_OUTPUT_HISTORY``).
Let prompt_toolkit's own resize path run with its renderer cursor
cache intact. Its Application._on_resize() starts with
renderer.erase(leave_alternate_screen=False), which needs the cached
cursor position to move back to the live prompt origin before
erase_down(). Resetting the renderer before that erase loses the
origin and can leave stale prompt glyphs after a narrow resize.
We also flag ``_status_bar_suppressed_after_resize`` so the dynamic
status bar and input separator rules stay hidden while the terminal
reflow settles. On column shrink the terminal reflows already-rendered
status bar rows into scrollback before prompt_toolkit can erase them;
drawing a fresh full-width bar immediately makes the old and new
versions look duplicated (#19280, #22976).
Suppression alone is not enough on a WIDTH change. prompt_toolkit's
``renderer.erase()`` does ``cursor_up(_cursor_pos.y)`` + ``erase_down()``
using the ``_cursor_pos.y`` cached from the LAST render at the OLD
width (renderer.py). When the column count shrinks, the terminal
reflows each already-painted full-width chrome row into 2+ physical
rows, so the cached ``y`` undershoots: ``cursor_up`` does not climb
past the reflowed rows and ``erase_down`` leaves the stale bar stranded
ABOVE the live origin. The next paint then stacks a fresh bar below it
— the duplicated-status-bar report (two bars, two elapsed readings).
Suppression hides the *new* bar but never erases the already-reflowed
*old* one, so the ghost survives the whole suppression window.
Fix: on a width change, wipe the visible viewport with ``erase_screen``
(CSI 2J) BEFORE delegating to prompt_toolkit's resize, then let its
repaint redraw from a clean origin. This is banner-safe: 2J clears
only the visible screen, NOT scrollback history (that is CSI 3J, which
we do not send here — ``rebuild_scrollback=False``), so the startup
banner that scrolled into history is preserved and
``_replay_output_history`` is not needed. Row-count-only changes skip
the clear (no reflow, so no ghost) to avoid an unnecessary repaint.
The suppression is transient: a short follow-up timer clears it and
repaints once the reflow has settled, so the bar returns on its own
during idle. Previously the flag was only cleared on the next
*submitted* user input, so a resize/reflow (tmux pane change, SSH
window restore, font zoom) followed by idle left the status bar hidden
indefinitely even while the refresh clock kept ticking (the dynamic
chrome rendered at height 0 on every repaint). The next-submit clear
at the input loop remains as a fast path.
"""
from cli import _replay_output_history
self._status_bar_suppressed_after_resize = True
# On a WIDTH change the terminal has already reflowed the old full-width
# chrome into extra physical rows that prompt_toolkit's stale-cursor
# erase (cursor_up(_cursor_pos.y) cached at the OLD width) will not
# reach, leaving a duplicated status bar stranded above the live origin.
# Ctrl+L / /redraw clears it cleanly, so route the resize path through
# the SAME recovery: wipe the visible viewport (banner-safe — CSI 2J
# by default; CSI 3J only when display.cli_rebuild_scrollback_on_redraw
# is enabled) and replay the transcript so nothing is lost.
# Same-width SIGWINCH (tmux attach, benign focus/tab signals) is left
# untouched — no clear, no replay — because a 2J without replay erases
# the visible transcript and a replay against preserved scrollback
# duplicates it (#65293). The stale-previous_screen crash tmux attach
# used to trigger is handled by _hermes_call_output_screen_diff's
# retry-with-first-paint instead (#83874).
try:
new_width = self._get_tui_terminal_width()
except Exception:
new_width = None
prev_width = getattr(self, "_last_resize_width", None)
# Replay only on an OBSERVED width change. The first signal of a
# session must not count as one (#65293): GNOME Terminal and friends
# deliver benign SIGWINCHes (tab bar appearing, monitor-scale change,
# focus events), and a 2J+replay against preserved scrollback
# duplicates everything ``_OUTPUT_HISTORY`` holds — after a resume
# that is the entire "Previous Conversation" recap plus the first
# live exchange. ``_install_resize_recovery`` seeds the baseline at
# startup, so an initial maximize/restore still differs from it and
# is still recovered; with no baseline (width probe failed) this
# signal just records one for the next comparison.
width_changed = (
new_width is not None
and prev_width is not None
and new_width != prev_width
)
if width_changed:
try:
self._clear_prompt_toolkit_screen(
app,
rebuild_scrollback=self._redraw_rebuilds_scrollback(),
)
_replay_output_history()
except Exception:
pass
if new_width is not None:
self._last_resize_width = new_width
if width_changed:
self._pet_queue_kitty_frame()
original_on_resize()
self._schedule_status_bar_unsuppress(app)
def _schedule_status_bar_unsuppress(self, app, delay: float = 0.35) -> None:
"""Clear the post-resize status-bar suppression after the reflow settles.
Debounced: a fresh resize cancels the pending unsuppress and restarts
the timer, so a resize storm only repaints the bar once it stops.
"""
try:
old_timer = getattr(self, "_status_bar_unsuppress_timer", None)
if old_timer is not None:
try:
old_timer.cancel()
except Exception:
pass
def _clear():
self._status_bar_suppressed_after_resize = False
try:
app.invalidate()
except Exception:
pass
def _fire():
try:
loop = getattr(app, "loop", None)
except Exception:
loop = None
if loop is not None:
try:
loop.call_soon_threadsafe(_clear)
return
except Exception:
pass
_clear()
timer = threading.Timer(delay, _fire)
timer.daemon = True
self._status_bar_unsuppress_timer = timer
timer.start()
except Exception:
# Fail open: never leave the bar stuck hidden.
self._status_bar_suppressed_after_resize = False
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:
"""Debounce resize redraws so footer chrome is not stamped into scrollback."""
try:
old_timer = getattr(self, "_resize_recovery_timer", None)
lock = getattr(self, "_resize_recovery_lock", None)
if lock is None:
lock = threading.Lock()
self._resize_recovery_lock = lock
def _timer_fired(timer_ref):
def _run_recovery():
with lock:
if getattr(self, "_resize_recovery_timer", None) is not timer_ref:
return
self._resize_recovery_timer = None
self._resize_recovery_pending = False
self._recover_after_resize(app, original_on_resize)
try:
loop = app.loop # type: ignore[attr-defined]
except Exception:
loop = None
if loop is not None:
try:
loop.call_soon_threadsafe(_run_recovery)
return
except Exception:
pass
_run_recovery()
with lock:
if old_timer is not None:
try:
old_timer.cancel()
except Exception:
pass
self._resize_recovery_pending = True
timer = threading.Timer(delay, lambda: _timer_fired(timer))
timer.daemon = True
self._resize_recovery_timer = timer
timer.start()
except Exception:
self._resize_recovery_pending = False
self._recover_after_resize(app, original_on_resize)
def _install_resize_recovery(self, app) -> None:
"""Route prompt_toolkit's ``_on_resize`` through the debounced
ghost-clearing recovery (#5474/#49120) and record the current terminal
width as the baseline for width-change detection.
Seeding the baseline here is what keeps the session's FIRST SIGWINCH
honest (#65293): ``_recover_after_resize`` replays the transcript only
on an observed width change, and without a startup baseline it could
not tell a benign signal (GNOME Terminal tab bar, monitor-scale
change) from a real one. An initial maximize/restore still differs
from the seeded width, so it is still recovered.
The probe reads ``app.output`` directly — NOT
``_get_tui_terminal_width`` — because this runs before ``app.run()``,
when ``get_app()`` still returns prompt_toolkit's DummyApplication
whose DummyOutput reports a hardcoded 80 columns; seeding that fake
width would make the first real signal look like a width change and
resurrect the duplicate-replay bug this exists to fix.
``app.output`` is the same object the running app's resize handler
measures, so install-time and signal-time widths are comparable.
"""
width = None
try:
width = app.output.get_size().columns
except Exception:
width = None
if not width or width <= 0:
try:
width = shutil.get_terminal_size((80, 24)).columns
except Exception:
width = None
self._last_resize_width = width
original_on_resize = app._on_resize
def _resize_clear_ghosts():
self._schedule_resize_recovery(app, original_on_resize)
app._on_resize = _resize_clear_ghosts
def _try_attach_clipboard_image(self) -> bool:
"""Check clipboard for an image and attach it if found.
Saves the image to ~/.hermes/images/ and appends the path to
``_attached_images``. Returns True if an image was attached.
"""
from cli import datetime
from hermes_cli.clipboard import save_clipboard_image
img_dir = get_hermes_home() / "images"
self._image_counter += 1
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
img_path = img_dir / f"clip_{ts}_{self._image_counter}.png"
if save_clipboard_image(img_path):
self._attached_images.append(img_path)
return True
self._image_counter -= 1
return False
def _write_osc52_clipboard(self, text: str) -> None:
"""Copy *text* to terminal clipboard via OSC 52.
Wrapped for tmux/screen passthrough (mirrors the TUI's
wrapForMultiplexer in ui-tui/src/lib/osc52.ts) — without the DCS
wrapper the multiplexer consumes the sequence and the copy is
silently lost.
"""
payload = base64.b64encode(text.encode("utf-8")).decode("ascii")
seq = f"\x1b]52;c;{payload}\x07"
if os.environ.get("TMUX"):
seq = "\x1bPtmux;" + seq.replace("\x1b", "\x1b\x1b") + "\x1b\\"
elif os.environ.get("STY"):
seq = "\x1bP" + seq + "\x1b\\"
out = getattr(self, "_app", None)
output = getattr(out, "output", None) if out else None
if output and hasattr(output, "write_raw"):
output.write_raw(seq)
output.flush()
return
if output and hasattr(output, "write"):
output.write(seq)
output.flush()
return
sys.stdout.write(seq)
sys.stdout.flush()
def _recover_terminal_input_modes(self, *, reason: str) -> None:
"""Best-effort reset when leaked mouse reports indicate mode drift."""
from cli import (
CLI_CONFIG,
_DIM,
_RST,
_TERMINAL_INPUT_MODE_RESET_SEQ,
_cli_multiline_shortcuts_enabled,
_cprint,
_enable_extended_enter_keys,
logger,
)
now = time.monotonic()
# Rate-limit to avoid thrashing if a terminal floods reports.
if now - self._last_input_mode_recovery < 0.5:
return
self._last_input_mode_recovery = now
out = getattr(self, "_app", None)
output = getattr(out, "output", None) if out else None
try:
if output and hasattr(output, "write_raw"):
output.write_raw(_TERMINAL_INPUT_MODE_RESET_SEQ)
output.flush()
elif output and hasattr(output, "write"):
output.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
output.flush()
else:
sys.stdout.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
sys.stdout.flush()
except Exception:
return
# The reset sequence above pops kitty keyboard mode and resets
# modifyOtherKeys too — re-request extended keys so Shift+Enter /
# modified-key reporting isn't silently dead for the rest of the
# session after a recovery (sibling of the startup push).
try:
if _cli_multiline_shortcuts_enabled(self.config or CLI_CONFIG):
_enable_extended_enter_keys(output)
except Exception:
pass
logger.warning("Recovered terminal input modes after leak: %s", reason)
if not self._input_mode_recovery_notice_shown:
self._input_mode_recovery_notice_shown = True
_cprint(
f" {_DIM}Recovered terminal input modes after leaked mouse reports. "
f"If this repeats, run /new or restart this tab.{_RST}"
)
def _check_termios_drift(self) -> None:
"""Watchdog: heal the tty if it drifted back to cooked mode.
See ``_heal_cooked_mode_drift`` for the failure class (a lost
``run_in_terminal`` cooked→raw restore leaves the terminal
line-buffering keystrokes while the prompt_toolkit app believes it
owns raw mode — the CLI looks dead but the process is healthy).
Called from ``process_loop``'s idle branch, so a drifted terminal
self-heals within ~a second of the agent going idle instead of
requiring an external ``stty`` rescue. Skipped while a
``run_in_terminal`` window is legitimately holding cooked mode
(``app._running_in_terminal``), while the agent is running (approval
prompts and sudo prompts legitimately manipulate the tty), and on
Windows (no termios).
"""
from cli import _DIM, _RST, _cprint, _heal_cooked_mode_drift, logger
if os.name == "nt":
return
app = getattr(self, "_app", None)
if app is None or not getattr(app, "_is_running", False):
return
# A run_in_terminal window is *supposed* to be cooked — don't fight it.
if getattr(app, "_running_in_terminal", False):
return
now = time.monotonic()
if now - self._last_termios_drift_check < 1.0:
return
self._last_termios_drift_check = now
try:
if not sys.stdin.isatty():
return
fd = sys.stdin.fileno()
except Exception:
return
if _heal_cooked_mode_drift(fd):
logger.warning(
"Healed cooked-mode termios drift on stdin — a "
"run_in_terminal cooked→raw restore was lost."
)
# Redraw so the prompt is visibly alive again.
try:
self._invalidate()
except Exception:
pass
if not self._termios_drift_notice_shown:
self._termios_drift_notice_shown = True
_cprint(
f" {_DIM}Recovered terminal from cooked-mode drift "
f"(input should respond normally again).{_RST}"
)