From 26b83a5f5f0acf32599f6449b685bec5a136e3d8 Mon Sep 17 00:00:00 2001 From: Blake <266800570+blackpilledsoftware-prog@users.noreply.github.com> Date: Thu, 28 May 2026 23:41:34 -0700 Subject: [PATCH] fix(cli): ignore terminal focus reports (salvage of #16780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghostty/macOS window or tab navigation (Cmd+Shift+[ / ], Alt+Tab, etc.) can deliver terminal focus reports (CSI I / CSI O) to the running TUI. prompt_toolkit does not map those sequences by default, so its parser falls back to literal key presses (ESC, [, I/O) and inserts `[I` / `[O` into the prompt buffer after the ESC byte is handled. Fix: register the two sequences as Keys.Ignore in ANSI_SEQUENCES at parser level, plus a no-op kb.add(Keys.Ignore) handler so the default self-insert path never inserts focus-report bytes. Salvage notes: original PR put the helper in cli.py. Salvaged into hermes_cli/pt_input_extras.py alongside install_shift_enter_alias / install_ctrl_enter_alias to match the established pattern for ANSI_SEQUENCES augmentation. setdefault → in-check so any prior user registration wins. Closes #16780 --- cli.py | 25 ++++++++++-- hermes_cli/pt_input_extras.py | 37 ++++++++++++++++++ tests/cli/test_cli_terminal_shortcuts.py | 49 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 tests/cli/test_cli_terminal_shortcuts.py diff --git a/cli.py b/cli.py index d3097863b..84735ad3d 100644 --- a/cli.py +++ b/cli.py @@ -74,10 +74,15 @@ except (ImportError, AttributeError): _STEADY_CURSOR = None try: - from hermes_cli.pt_input_extras import install_shift_enter_alias, install_ctrl_enter_alias + from hermes_cli.pt_input_extras import ( + install_ctrl_enter_alias, + install_ignored_terminal_sequences, + install_shift_enter_alias, + ) install_shift_enter_alias() install_ctrl_enter_alias() - del install_shift_enter_alias, install_ctrl_enter_alias + install_ignored_terminal_sequences() + del install_shift_enter_alias, install_ctrl_enter_alias, install_ignored_terminal_sequences except Exception: pass import threading @@ -12717,7 +12722,21 @@ class HermesCLI: # Key bindings for the input area kb = KeyBindings() - + + from prompt_toolkit.keys import Keys as _IgnoreKeys + + @kb.add(_IgnoreKeys.Ignore, eager=True) + def handle_ignored_terminal_sequence(event): + """Consume parser-level ignored terminal sequences before self-insert. + + install_ignored_terminal_sequences() in hermes_cli.pt_input_extras + registers focus reports (CSI I / CSI O) as Keys.Ignore at the + VT100 parser level. Without this no-op binding the default + self-insert path would still fire and the bytes would land in + the buffer. + """ + return None + def handle_enter(event): """Handle Enter key - submit input. diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index 008c931cf..16a0f17ea 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -81,3 +81,40 @@ def install_ctrl_enter_alias() -> int: ANSI_SEQUENCES[seq] = alt_enter changed += 1 return changed + + +def install_ignored_terminal_sequences() -> int: + """Map terminal-emitted noise sequences to ``Keys.Ignore`` so they + are consumed by the VT100 parser before they reach key bindings or + the input buffer. + + Currently covers focus reports: + - ``\\x1b[I`` — terminal regained focus (focus in) + - ``\\x1b[O`` — terminal lost focus (focus out) + + Ghostty, iTerm2, and some xterm builds can emit these sequences when + the user switches tabs / windows or when a multiplexer toggles focus + tracking upstream. prompt_toolkit does not map these by default, so + its parser falls back to literal key presses (ESC, ``[``, ``I``/``O``) + and inserts ``[I``/``[O`` into the prompt buffer after the ESC byte + is handled. + + Registering them as ``Keys.Ignore`` is parser-level — strictly + cleaner than post-hoc regex stripping in the input sanitizer because + the bytes never reach the buffer. ``setdefault`` is used so any user + or downstream registration wins. + + Returns the number of sequences whose mapping was changed. + """ + try: + from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES + from prompt_toolkit.keys import Keys + except Exception: + return 0 + + changed = 0 + for seq in ("\x1b[I", "\x1b[O"): + if seq not in ANSI_SEQUENCES: + ANSI_SEQUENCES[seq] = Keys.Ignore + changed += 1 + return changed diff --git a/tests/cli/test_cli_terminal_shortcuts.py b/tests/cli/test_cli_terminal_shortcuts.py new file mode 100644 index 000000000..3b91ce610 --- /dev/null +++ b/tests/cli/test_cli_terminal_shortcuts.py @@ -0,0 +1,49 @@ +"""Regression tests for terminal navigation/focus escape sequences. + +Ghostty/macOS window and tab navigation can deliver terminal focus reports +(CSI I / CSI O) to the running TUI. These must be consumed by the input parser, +not inserted into the prompt buffer and cleaned up later. +""" + +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.keys import Keys + +from hermes_cli.pt_input_extras import install_ignored_terminal_sequences + + +def _parse_keys(data: str): + events = [] + parser = Vt100Parser(events.append) + parser.feed_and_flush(data) + return [(event.key, event.data) for event in events] + + +def test_focus_events_are_parser_level_ignored_before_prompt_buffer(): + install_ignored_terminal_sequences() + + assert _parse_keys("\x1b[O\x1b[Ihello") == [ + (Keys.Ignore, "\x1b[O"), + (Keys.Ignore, "\x1b[I"), + ("h", "h"), + ("e", "e"), + ("l", "l"), + ("l", "l"), + ("o", "o"), + ] + + +def test_regular_escape_shortcuts_still_parse_normally(): + install_ignored_terminal_sequences() + + assert _parse_keys("\x1bg") == [(Keys.Escape, "\x1b"), ("g", "g")] + + +def test_install_is_idempotent_and_setdefault_safe(): + """Second call should return 0 (no new mappings); existing user + registrations must not be overwritten.""" + first = install_ignored_terminal_sequences() + second = install_ignored_terminal_sequences() + # At most first should be 2 (both CSI I + CSI O), second always 0 + # since the entries are now present. + assert second == 0 + assert first in (0, 1, 2) # 0 if a prior test in same process already installed