fix(cli): reset terminal input modes on TUI exit to stop focus/mouse leaks
When the TUI exits via Ctrl+C, SIGTERM/SIGHUP, or a crash, prompt_toolkit's teardown can be bypassed, leaving DEC 1004 (focus reporting) and 1000/1002/1003 (mouse tracking) enabled. The terminal then emits raw ESC[I/ESC[O focus events and fragmented SGR mouse reports as visible text in whatever runs next in the same tab. _run_cleanup() — the once-only cleanup that runs on every catchable exit path (atexit-registered + called on the normal/EOF/interrupt exit) — now emits _TERMINAL_INPUT_MODE_RESET_SEQ (the same disable sequence the in-session leak recovery already uses) as its FIRST step, so the terminal is usable immediately on Ctrl+C and a later teardown step raising can't skip it. The reset is gated on a new _tui_input_modes_active flag (set right before app.run(), cleared once the modes are disabled) so non-TUI one-shot CLI runs — which share _run_cleanup via atexit — don't emit codes for modes they never enabled. Writes to sys.stdout when it's the terminal, else falls back to /dev/tty. SIGKILL is uncatchable and the kanban worker's os._exit(0) bypasses atexit, but both are non-TTY/non-TUI so there is nothing to reset there. Adds tests/cli/test_tui_terminal_reset_on_exit.py (9): emits on a TTY when the TUI ran, no-ops when the TUI never ran, /dev/tty fallback when stdout is redirected, no-op when neither is available, swallows stdout errors, flag set and cleared, and wired into _run_cleanup as the first step even when a later step raises. Fixes #36823 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ef3a650f05
commit
038ed94a6c
@@ -872,6 +872,17 @@ _cleanup_done = False
|
||||
# Weak reference to the active AIAgent for memory provider shutdown at exit
|
||||
_active_agent_ref = None
|
||||
_deferred_agent_startup_done = False
|
||||
# Set True once the TUI's prompt_toolkit app starts (which enables focus
|
||||
# reporting + mouse tracking). Gates the on-exit terminal reset so non-TUI
|
||||
# one-shot CLI runs — which also register _run_cleanup via atexit — don't emit
|
||||
# escape codes for modes they never enabled (#36823).
|
||||
_tui_input_modes_active = False
|
||||
|
||||
|
||||
def _mark_tui_input_modes_active() -> None:
|
||||
"""Record that the TUI app started, so _run_cleanup resets input modes."""
|
||||
global _tui_input_modes_active
|
||||
_tui_input_modes_active = True
|
||||
|
||||
|
||||
def _prepare_deferred_agent_startup() -> None:
|
||||
@@ -927,6 +938,12 @@ def _run_cleanup():
|
||||
return
|
||||
_cleanup_done = True
|
||||
|
||||
# Reset terminal input modes first, before the slower resource teardown
|
||||
# below (MCP / browser / memory shutdown can take seconds). On Ctrl+C the
|
||||
# user's terminal becomes usable immediately, and a later step raising
|
||||
# can't skip the reset (#36823). No-op unless the TUI actually ran.
|
||||
_reset_terminal_input_modes_on_exit()
|
||||
|
||||
try:
|
||||
_cleanup_all_terminals()
|
||||
except Exception:
|
||||
@@ -972,6 +989,50 @@ def _run_cleanup():
|
||||
pass
|
||||
|
||||
|
||||
def _reset_terminal_input_modes_on_exit() -> None:
|
||||
"""Best-effort: disable focus reporting + mouse tracking on TUI exit so they
|
||||
don't leak into the next shell session sharing the tab.
|
||||
|
||||
prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM /
|
||||
SIGHUP and crashes can bypass its unwind, leaving the modes enabled. The
|
||||
terminal then emits raw ``ESC[I`` / ``ESC[O`` focus events and fragmented
|
||||
SGR mouse reports as visible text in whatever runs next in the same tab
|
||||
(#36823). Called from ``_run_cleanup`` (atexit-registered + invoked on the
|
||||
normal / EOF / interrupt exit paths) this covers normal quit, Ctrl+C and
|
||||
SIGTERM/SIGHUP. ``kill -9`` is uncatchable, and the kanban worker's
|
||||
``os._exit(0)`` path bypasses ``atexit``; neither runs this — but both are
|
||||
non-TTY / non-TUI, so there is nothing to reset there.
|
||||
|
||||
Gated on ``_tui_input_modes_active`` so one-shot non-TUI CLI runs (which
|
||||
share ``_run_cleanup`` via ``atexit``) never emit these codes. Writes to the
|
||||
controlling terminal directly: by exit, prompt_toolkit's own output is torn
|
||||
down, so ``sys.stdout`` is the real fd; falls back to ``/dev/tty`` when
|
||||
stdout is redirected away from the terminal.
|
||||
"""
|
||||
global _tui_input_modes_active
|
||||
if not _tui_input_modes_active:
|
||||
return
|
||||
# About to disable the modes — clear the flag so a re-armed _run_cleanup (or
|
||||
# a long-lived process that reuses it) doesn't re-emit them.
|
||||
_tui_input_modes_active = False
|
||||
# Prefer stdout when it's the terminal; otherwise the TUI may have driven
|
||||
# /dev/tty while stdout was redirected — reset there instead of nowhere.
|
||||
try:
|
||||
stream = sys.stdout
|
||||
if stream is not None and stream.isatty():
|
||||
stream.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
stream.flush()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open("/dev/tty", "w", encoding="ascii") as tty:
|
||||
tty.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
tty.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Git Worktree Isolation (#652)
|
||||
# =============================================================================
|
||||
@@ -15096,6 +15157,9 @@ class HermesCLI:
|
||||
pass # No running loop -- nothing to patch
|
||||
except Exception:
|
||||
pass
|
||||
# The app enables focus reporting + mouse tracking; record that
|
||||
# so _run_cleanup resets them on exit (#36823).
|
||||
_mark_tui_input_modes_active()
|
||||
app.run()
|
||||
except (EOFError, KeyboardInterrupt, BrokenPipeError):
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user