refactor(hermes_cli): collapse EIO/loop-dispatch/terminal-write duplication in cli_terminal_mixin, compact docs

This commit is contained in:
Teknium
2026-09-02 20:14:57 -07:00
parent 113f04616b
commit 0ed22f614c
+146 -293
View File
@@ -1,8 +1,7 @@
"""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).
Mixin bound onto ``HermesCLI`` via the MRO. cli.py-internal symbols are imported LAZILY
inside each method (``from cli import ...``) — never at module load (import cycle).
"""
from __future__ import annotations
@@ -18,6 +17,39 @@ import time
from hermes_constants import get_hermes_home
def _is_eio(exc: BaseException) -> bool:
return getattr(exc, "errno", None) == errno.EIO
def _run_on_app_loop(app, fn) -> None:
"""Run *fn* on the app's asyncio loop when one exists, else inline (fail-open)."""
try:
loop = getattr(app, "loop", None)
except Exception:
loop = None
if loop is not None:
try:
loop.call_soon_threadsafe(fn)
return
except Exception:
pass
fn()
def _write_terminal_sequence(app, seq: str) -> None:
"""Write a raw escape *seq* via the app output (write_raw > write) or stdout."""
output = getattr(app, "output", None) if app else None
if output and hasattr(output, "write_raw"):
output.write_raw(seq)
output.flush()
elif output and hasattr(output, "write"):
output.write(seq)
output.flush()
else:
sys.stdout.write(seq)
sys.stdout.flush()
class CLITerminalMixin:
"""Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive CLI"""
@@ -36,22 +68,32 @@ class CLITerminalMixin:
f" ({reason})" if reason else "",
)
def _app_invalidate(self, app, where: str, *, swallow: bool) -> None:
"""``app.invalidate()``: EIO freezes paints, other OSErrors re-raise, and any
other exception re-raises unless *swallow*."""
try:
app.invalidate()
except OSError as exc:
if _is_eio(exc):
self._mark_terminal_io_broken(where)
return
raise
except Exception:
if not swallow:
raise
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.
For spinner frames, streaming token flushes and other repaints that fire many
times per second: the throttle prevents blinking on slow/SSH links, 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).
Do NOT use for user-blocking modal prompts (approval / clarify / sudo): those
must paint immediately via ``_paint_now``. Sent through this throttle, an
unrelated repaint within the 250ms window — or an in-flight resize — silently
drops the modal's entry paint, so it never renders and times out unseen (#41098).
"""
if getattr(self, "_terminal_io_broken", False):
return
@@ -60,53 +102,28 @@ class CLITerminalMixin:
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
self._app_invalidate(self._app, "invalidate", swallow=False)
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).
Deliberately bypasses the ``_invalidate`` throttle and resize-recovery guard —
a modal the user is waiting on must never be dropped (#41098) — mirroring the
direct ``event.app.invalidate()`` the modal key-binding handlers use.
"""
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
self._app_invalidate(app, "paint_now", swallow=True)
def _force_full_redraw(self) -> None:
"""Force a clean full-screen repaint of the prompt_toolkit UI.
"""Force a clean full-screen repaint of the prompt_toolkit UI (Ctrl+L, ``/redraw``).
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).
Recovers from terminal buffer drift caused by external redraws we can't detect
(cmux/tmux tab switches, ``clear`` from a subshell, SSH window restores): they
repaint without SIGWINCH, so prompt_toolkit's tracked ``_cursor_pos`` is stale
and the next incremental redraw stacks on old content (ghost status bars).
"""
from cli import _replay_output_history
if getattr(self, "_terminal_io_broken", False):
@@ -114,59 +131,36 @@ class CLITerminalMixin:
app = getattr(self, "_app", None)
if not app:
return
self._clear_prompt_toolkit_screen(
app,
rebuild_scrollback=self._redraw_rebuilds_scrollback(),
)
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
self._app_invalidate(app, "force_full_redraw", swallow=True)
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.
Terminals with focus tracking (Ghostty, iTerm2, xterm, muxes toggling DECSET
1004) emit ``\\x1b[I`` when the Hermes tab becomes visible again; emulators may
coalesce hidden-tab output, so on regain the incremental diff stacks on stale
content (#60920 focus-regain variant, #25337). Self-gating — terminals without
focus tracking never emit it. Rate-limited so a burst (rapid Alt+Tab, 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:
if now - getattr(self, "_last_focus_regain_redraw", 0.0) < 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.
"""Whether redraw/resize recovery should also clear scrollback (CSI 3J).
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.
Some terminal/tmux stacks move prompt_toolkit's bottom chrome into scrollback on
maximize/restore; CSI 2J cannot remove those rows, so affected users opt in to 3J
followed by the bounded output-history replay.
"""
from cli import CLI_CONFIG
display_config = CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else {}
@@ -180,22 +174,16 @@ class CLITerminalMixin:
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.
An in-flight ``CSI 6n`` cursor query may answer (``ESC[<row>;<col>R``) after the
input parser tore down: the reply leaks as literal text and the VT100 parser can
stall mid-escape, so the terminal looks frozen. ``flush_stdin()`` drains stray
bytes (no-op on non-TTY), then ``_force_full_redraw()`` repaints cleanly; each
step self-guards so one failing never blocks the other. A dead PTY (EIO) skips
the redraw — painting a broken fd is the #81521 redraw storm.
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.
Do NOT clear output history here: the interruption marker is printed under
``_suspend_output_history`` in chat(), so the replay reproduces the response
without duplicating the marker (#60920).
"""
if getattr(self, "_terminal_io_broken", False):
return
@@ -204,13 +192,6 @@ class CLITerminalMixin:
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:
@@ -229,113 +210,55 @@ class CLITerminalMixin:
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.
# Drop cached screen + cursor state so the next _redraw() starts from a
# known (0, 0) origin and re-renders every cell instead of diffing stale.
renderer.reset(leave_alternate_screen=False)
except OSError as exc:
if getattr(exc, "errno", None) == errno.EIO:
if _is_eio(exc):
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``).
Unlike ``_force_full_redraw`` this never clears scrollback: the startup banner
lives there and ``_replay_output_history`` cannot reconstruct it. prompt_toolkit's
own resize path runs with its renderer cursor cache intact — its
``Application._on_resize()`` erases via the cached cursor position, and resetting
the renderer first loses the origin and strands stale prompt glyphs.
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.
``_status_bar_suppressed_after_resize`` hides the dynamic status bar / input rules
while the reflow settles: on column shrink the terminal reflows already-painted
rows into scrollback before prompt_toolkit erases them, so a fresh bar looks
duplicated (#19280, #22976). Suppression alone cannot erase an already-reflowed
OLD bar (``renderer.erase()`` does ``cursor_up(_cursor_pos.y)`` with the y cached
at the OLD width, undershooting the reflowed rows), so on an OBSERVED width
change we wipe the viewport (CSI 2J — banner-safe; 3J only when
``display.cli_rebuild_scrollback_on_redraw``) and replay the transcript before
delegating. Same-width SIGWINCH (tmux attach, GNOME tab bar, focus signals) and
the first signal without a baseline are left untouched: a 2J+replay against
preserved scrollback duplicates everything in ``_OUTPUT_HISTORY`` (#65293);
``_install_resize_recovery`` seeds the baseline so an initial maximize still
counts. The stale-previous_screen crash on tmux attach is handled by
``_hermes_call_output_screen_diff``'s retry (#83874).
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.
Suppression is transient: a debounced timer clears it and repaints once the
reflow settles, so the bar returns during idle (previously it stayed hidden
until the next submitted input). The next-submit clear 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
)
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(),
app, rebuild_scrollback=self._redraw_rebuilds_scrollback()
)
_replay_output_history()
except Exception:
@@ -350,8 +273,8 @@ class CLITerminalMixin:
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.
Debounced: a fresh resize cancels the pending timer, so a resize storm repaints
the bar only once it stops.
"""
try:
old_timer = getattr(self, "_status_bar_unsuppress_timer", None)
@@ -368,20 +291,7 @@ class CLITerminalMixin:
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 = threading.Timer(delay, lambda: _run_on_app_loop(app, _clear))
timer.daemon = True
self._status_bar_unsuppress_timer = timer
timer.start()
@@ -407,17 +317,7 @@ class CLITerminalMixin:
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()
_run_on_app_loop(app, _run_recovery)
with lock:
if old_timer is not None:
@@ -435,25 +335,15 @@ class CLITerminalMixin:
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.
"""Route ``app._on_resize`` through the debounced ghost-clearing recovery
(#5474/#49120) and seed the width 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.
The seed keeps the session's FIRST SIGWINCH honest (#65293): without it a benign
signal is indistinguishable from a real resize. It reads ``app.output`` directly,
NOT ``_get_tui_terminal_width``: before ``app.run()`` ``get_app()`` is the
DummyApplication whose DummyOutput reports a hardcoded 80 columns, and seeding
that fake width would make the first real signal look like a change.
``app.output`` is what the running resize handler measures, so both are comparable.
"""
width = None
try:
@@ -474,19 +364,13 @@ class CLITerminalMixin:
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.
"""
"""Save a clipboard image to ~/.hermes/images/ and attach it; True if 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"
img_path = get_hermes_home() / "images" / f"clip_{ts}_{self._image_counter}.png"
if save_clipboard_image(img_path):
self._attached_images.append(img_path)
return True
@@ -494,12 +378,10 @@ class CLITerminalMixin:
return False
def _write_osc52_clipboard(self, text: str) -> None:
"""Copy *text* to terminal clipboard via OSC 52.
"""Copy *text* to the 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.
Wrapped for tmux/screen passthrough (mirrors ui-tui/src/lib/osc52.ts) — without
the DCS wrapper the multiplexer consumes the sequence and the copy is lost.
"""
payload = base64.b64encode(text.encode("utf-8")).decode("ascii")
seq = f"\x1b]52;c;{payload}\x07"
@@ -507,18 +389,7 @@ class CLITerminalMixin:
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()
_write_terminal_sequence(getattr(self, "_app", None), seq)
def _recover_terminal_input_modes(self, *, reason: str) -> None:
"""Best-effort reset when leaked mouse reports indicate mode drift."""
@@ -538,25 +409,15 @@ class CLITerminalMixin:
return
self._last_input_mode_recovery = now
out = getattr(self, "_app", None)
output = getattr(out, "output", None) if out else None
app = getattr(self, "_app", None)
output = getattr(app, "output", None) if app 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()
_write_terminal_sequence(app, _TERMINAL_INPUT_MODE_RESET_SEQ)
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).
# The reset pops kitty keyboard mode and resets modifyOtherKeys too — re-request
# extended keys so Shift+Enter isn't silently dead for the rest of the session.
try:
if _cli_multiline_shortcuts_enabled(self.config or CLI_CONFIG):
_enable_extended_enter_keys(output)
@@ -574,18 +435,12 @@ class CLITerminalMixin:
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).
See ``_heal_cooked_mode_drift``: a lost ``run_in_terminal`` cooked→raw restore
leaves the tty line-buffering while prompt_toolkit believes it owns raw mode —
the CLI looks dead but the process is healthy. Called from ``process_loop``'s
idle branch so it self-heals within ~1s of idling. Skipped while a
``run_in_terminal`` window legitimately holds cooked mode, while the agent is
running (approval/sudo prompts manipulate the tty), and on Windows (no termios).
"""
from cli import _DIM, _RST, _cprint, _heal_cooked_mode_drift, logger
if os.name == "nt":
@@ -593,7 +448,6 @@ class CLITerminalMixin:
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()
@@ -611,9 +465,8 @@ class CLITerminalMixin:
"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()
self._invalidate() # so the prompt is visibly alive again
except Exception:
pass
if not self._termios_drift_notice_shown: