From a9b9a855e296f8c4a2eb9475b04981e766b9e7fe Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 27 Apr 2026 21:38:32 -0500 Subject: [PATCH] fix(cli): ignore terminal focus reports --- cli.py | 36 ++++++++++++++++++++-- tests/cli/test_cli_terminal_shortcuts.py | 38 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 tests/cli/test_cli_terminal_shortcuts.py diff --git a/cli.py b/cli.py index 1b50d227361bd..d401444c43a6c 100644 --- a/cli.py +++ b/cli.py @@ -55,6 +55,34 @@ from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit import print_formatted_text as _pt_print from prompt_toolkit.formatted_text import ANSI as _PT_ANSI + + +def _register_prompt_toolkit_ignored_terminal_sequences() -> None: + """Teach prompt_toolkit to consume terminal focus reports. + + Ghostty/macOS tab or window navigation can send VT100 focus events + (``CSI I`` / ``CSI O``) to the foreground application. prompt_toolkit does + not map those sequences by default, so its parser falls back to literal + key presses (ESC, ``[``, ``I``/``O``), which inserts ``[I``/``[O`` into the + prompt after the ESC byte is handled. + + Register them as ``Keys.Ignore`` at the VT100 parser table so they are + swallowed before key bindings or the input buffer ever see them. This is + intentionally parser-level rather than a post-hoc text sanitizer. + """ + try: + from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES + from prompt_toolkit.keys import Keys + + ANSI_SEQUENCES.setdefault("\x1b[I", Keys.Ignore) # focus in + ANSI_SEQUENCES.setdefault("\x1b[O", Keys.Ignore) # focus out + except Exception: + # Defensive: never make CLI startup depend on prompt_toolkit internals. + pass + + +_register_prompt_toolkit_ignored_terminal_sequences() + try: from prompt_toolkit.cursor_shapes import CursorShape _STEADY_CURSOR = CursorShape.BLOCK # Non-blinking block cursor @@ -9359,6 +9387,12 @@ def run(self): # Key bindings for the input area kb = KeyBindings() + from prompt_toolkit.keys import Keys + + @kb.add(Keys.Ignore, eager=True) + def handle_ignored_terminal_sequence(event): + """Consume parser-level ignored terminal sequences before self-insert.""" + return None @kb.add('enter') def handle_enter(event): @@ -9914,8 +9948,6 @@ def _start_recording(): threading.Thread(target=_start_recording, daemon=True).start() event.app.invalidate() - from prompt_toolkit.keys import Keys - @kb.add(Keys.BracketedPaste, eager=True) def handle_paste(event): """Handle terminal paste — detect clipboard images. diff --git a/tests/cli/test_cli_terminal_shortcuts.py b/tests/cli/test_cli_terminal_shortcuts.py new file mode 100644 index 0000000000000..c04c9e0cf995c --- /dev/null +++ b/tests/cli/test_cli_terminal_shortcuts.py @@ -0,0 +1,38 @@ +"""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 cli import _register_prompt_toolkit_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(): + _register_prompt_toolkit_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(): + _register_prompt_toolkit_ignored_terminal_sequences() + + assert _parse_keys("\x1bg") == [(Keys.Escape, "\x1b"), ("g", "g")]