From 03bf85d83b0d35cb779ef0f6beb37ea6915b9ecc Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:39:19 +0530 Subject: [PATCH] fix(cli): restore Kitty keyboard protocol push and complete the extended-key alias table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 4c34eeb416 fixed dead Ctrl+C by removing the Kitty protocol push (CSI >1u) from _EXTENDED_ENTER_KEYS_SEQ, keeping only modifyOtherKeys level 2. That regressed kitty-the-terminal completely: kitty removed xterm modifyOtherKeys support (kovidgoyal/kitty#4075) and only speaks its own protocol, so after the removal kitty users lost Shift+Enter and every other extended key — the CSI >4;2m we still pushed is a no-op there (kitty even logs a PARSE ERROR for it). The original reason for removing the push is obsolete: #87511 mapped CSI-u control sequences, so Ctrl+C as ESC[99;5u now parses to Keys.ControlC and fires the existing c-c binding. (The kernel-INTR concern in that commit was moot — prompt_toolkit's raw mode clears ISIG, so Ctrl+C is always handled by the binding, never the kernel.) Restore the dual push (CSI >1u + CSI >4;2m), exactly mirroring the Ink TUI, and complete the alias table for what the kitty disambiguate flag actually emits — #87511 left real gaps, some of which its PR body wrongly claimed were covered: - Esc key: ESC[27u (+ modifiers) — previously leaked '[27u' as text - Ctrl+Backspace -> backward-kill-word (#78285 was closed on the wrong claim that codepoint-127 mapping existed; it did not) - Shift+Space -> space (#86866's second symptom; the Ctrl+Space mapping never covered modifier 2) - Alt+Enter -> newline tuple; Shift+Tab -> BackTab; Ctrl+Tab -> Tab; Alt/Shift+Backspace - Multi-modifier letters (Shift+Alt 4, Ctrl+Shift 6, Ctrl+Alt 7, Ctrl+Alt+Shift 8) normalized onto their Ctrl/Escape-prefix targets, both unshifted (kitty) and shifted (mok emitters) codepoints - Kitty PUA functional keys: keypad -> non-keypad equivalents, F13-F24, and Ignore for lock/media/modifier-event keys so they are consumed instead of leaking (kitty emits these even in legacy mode) Also: clear the VT100 parser's prefix cache after installing (stale answers could misparse), and re-push extended keys after _recover_terminal_input_modes' reset — the recovery previously popped both modes mid-session and never re-enabled them, silently killing Shift+Enter until restart. Refs #87511, #87074, #56684, #56645, #78285, #86866, #87390. --- cli.py | 50 ++++--- hermes_cli/pt_input_extras.py | 142 +++++++++++++++++++- tests/cli/test_modify_other_keys_aliases.py | 134 +++++++++++++++++- 3 files changed, 306 insertions(+), 20 deletions(-) diff --git a/cli.py b/cli.py index 7534cf7f8b..0132350986 100644 --- a/cli.py +++ b/cli.py @@ -3887,7 +3887,7 @@ _TERMINAL_INPUT_MODE_RESET_SEQ = ( "\x1b[0m" # reset text attributes "\x1b[?25h" # ensure cursor visible ) -_EXTENDED_ENTER_KEYS_SEQ = "\x1b[>4;2m" +_EXTENDED_ENTER_KEYS_SEQ = "\x1b[>1u\x1b[>4;2m" _BACKSLASH_LINE_CONTINUATION_RE = re.compile(r"\\[ \t]*$") @@ -3920,25 +3920,29 @@ def _terminal_supports_extended_enter_keys(env: Optional[Mapping[str, str]] = No def _enable_extended_enter_keys(output=None, env: Optional[Mapping[str, str]] = None) -> bool: - """Ask allowlisted terminals to report Shift+Enter distinctly. + """Ask allowlisted terminals to report modified keys distinctly. - Writes xterm modifyOtherKeys level 2 (CSI >4;2m), mirroring the Ink TUI. - We do NOT push the Kitty keyboard protocol (CSI >1u) here because - prompt_toolkit 3.x cannot parse Kitty CSI-u sequences for control - characters — Ctrl+C arrives as ``\\x1b[99;5u`` instead of ``\\x03``, - which neither prompt_toolkit's key bindings nor the kernel's INTR - mechanism can match, leaving Ctrl+C completely dead (#56684). + Writes the Kitty keyboard protocol push (CSI >1u, disambiguate mode) AND + xterm modifyOtherKeys level 2 (CSI >4;2m), mirroring the Ink TUI — + terminals honor whichever protocol they implement. Both are needed: + kitty-the-terminal removed modifyOtherKeys support entirely (it only + speaks its own protocol), while tmux/VS Code only accept modifyOtherKeys. - modifyOtherKeys=2 re-encodes ALL Ctrl+key combos as - ``ESC[27;5;~`` instead of raw control bytes. + Under either protocol the terminal re-encodes modified keys as escape + sequences — Kitty disambiguate mode as ``ESC[;u`` (plus + the Esc key as ``ESC[27u``), modifyOtherKeys=2 as + ``ESC[27;;~``. Stock prompt_toolkit 3.x maps almost + none of these, which is why the CSI >1u push was temporarily removed in + #87074 (Ctrl+C arrived as ``ESC[99;5u`` and died, #56684). ``install_modify_other_keys_aliases()`` (called at CLI startup from - ``hermes_cli.pt_input_extras``) populates prompt_toolkit's - ``ANSI_SEQUENCES`` with the full Ctrl+letter / Ctrl+digit / Ctrl+symbol - and Alt+letter mappings under both the modifyOtherKeys and CSI-u formats, - so every existing key binding continues to fire (#87711). + ``hermes_cli.pt_input_extras``) now populates ``ANSI_SEQUENCES`` with the + full Ctrl/Alt/Shift/multi-modifier and functional-key tables under BOTH + formats, so every existing key binding continues to fire — including + Ctrl+C, which is handled by prompt_toolkit's ``c-c`` binding (raw mode + clears ISIG, so the kernel INTR path was never in play for the CLI). - The exit reset sequence already pops/resets both modes, so this is - safe across normal exits, Ctrl+C, and SIGTERM cleanup. + The exit reset sequence pops/resets both modes, so this is safe across + normal exits, Ctrl+C, and SIGTERM cleanup. """ if not _terminal_supports_extended_enter_keys(env): return False @@ -8531,6 +8535,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): 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 @@ -19280,8 +19294,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # The app enables focus reporting + mouse tracking; record that # so _run_cleanup resets them on exit (#36823). When multiline # shortcuts are on, also ask supported terminals (e.g. iTerm2) - # to distinguish Shift+Enter from Enter; the same cleanup reset - # pops kitty keyboard mode and resets modifyOtherKeys. + # to report modified keys distinctly (kitty protocol + + # modifyOtherKeys); the cleanup reset pops both modes. _mark_tui_input_modes_active() if _multiline_shortcuts_enabled: _enable_extended_enter_keys(app.output) diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index 6c1151ab32..d2800b0299 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -12,6 +12,24 @@ can be unit-tested without importing the whole CLI runtime. from __future__ import annotations +def _clear_vt100_prefix_cache() -> None: + """Drop prompt_toolkit's memoized "is this a prefix of a longer match?" + answers after mutating ``ANSI_SEQUENCES``. + + The cache is module-global and populated lazily per distinct prefix, so + parsers created before an install (or primed by earlier tests) would + otherwise keep stale ``False`` answers and misparse newly registered + sequences. Call after any install that changed the table. + """ + try: + from prompt_toolkit.input.vt100_parser import ( + _IS_PREFIX_OF_LONGER_MATCH_CACHE, + ) + _IS_PREFIX_OF_LONGER_MATCH_CACHE.clear() + except Exception: + pass + + def install_shift_enter_alias() -> int: """Map Shift+Enter byte sequences to the (Escape, ControlM) key tuple that Alt+Enter produces, so the existing Alt+Enter newline handler @@ -48,6 +66,8 @@ def install_shift_enter_alias() -> int: if ANSI_SEQUENCES.get(seq) != alt_enter: ANSI_SEQUENCES[seq] = alt_enter changed += 1 + if changed: + _clear_vt100_prefix_cache() return changed @@ -80,6 +100,8 @@ def install_ctrl_enter_alias() -> int: if ANSI_SEQUENCES.get(seq) != alt_enter: ANSI_SEQUENCES[seq] = alt_enter changed += 1 + if changed: + _clear_vt100_prefix_cache() return changed @@ -123,6 +145,8 @@ def install_cmd_backspace_alias() -> int: if ANSI_SEQUENCES.get(seq) != key: ANSI_SEQUENCES[seq] = key changed += 1 + if changed: + _clear_vt100_prefix_cache() return changed @@ -140,7 +164,7 @@ def install_modify_other_keys_aliases() -> int: Stock prompt_toolkit 3.x only maps ``ESC[27;5;13~`` (Ctrl+Enter = Ctrl+M); all other Ctrl+letter combos are unmapped and leak as literal text or get swallowed — breaking Ctrl+A, Ctrl+C, Ctrl+D, Ctrl+E, Ctrl+K, Ctrl+R, - Ctrl+U, Ctrl+W, Ctrl+Z, etc. (#56684, #87711). + Ctrl+U, Ctrl+W, Ctrl+Z, etc. (#56684, #86866, #87390). This function populates ``ANSI_SEQUENCES`` for the full set: @@ -152,6 +176,25 @@ def install_modify_other_keys_aliases() -> int: * **Alt+letter** (a–z, A–Z): ``ESC[27;3;~`` and ``ESC[;3u`` → ``(Keys.Escape, )`` — matching how prompt_toolkit handles a bare ``ESC`` followed by a character. + * **Shift+letter** (a–z): → the uppercase character. + * **Multi-modifier letters** (Shift+Alt=4, Ctrl+Shift=6, Ctrl+Alt=7, + Ctrl+Alt+Shift=8): normalized onto the same targets — Ctrl-bearing + combos behave as the Ctrl key (Alt adds an ``Escape`` prefix), + matching how dte/kakoune normalize these protocols. + * **Esc key**: ``ESC[27u`` / ``ESC[27;u`` (Kitty disambiguate mode + reports Esc this way, #56684) → ``Keys.Escape``. + * **Modified Enter/Tab/Backspace/Space**: Alt+Enter → the Alt+Enter + newline tuple; Shift+Tab → ``BackTab``; Ctrl+Tab → plain Tab; + Ctrl/Alt+Backspace → ``(Escape, ControlH)`` (backward-kill-word, + matching the Ink TUI and Desktop, #78285); Shift+Backspace → plain + backspace; Shift+Space → a plain space (#86866); Alt+Space → + ``(Escape, " ")``. + * **Kitty functional keys** (Private Use Area codepoints): keypad keys + → their non-keypad equivalents (KP_ENTER → Enter, KP_4 → '4', + KP_LEFT → Left, …); F13–F24 → ``Keys.F13``..``F24``; lock/media/ + modifier-event keys → ``Keys.Ignore`` so they are consumed instead of + leaking as literal text. kitty emits these CSI-u forms even in legacy + mode for keys that have no legacy encoding. Existing mappings (including those installed by ``install_shift_enter_alias`` / ``install_ctrl_enter_alias``) are never @@ -248,6 +291,101 @@ def install_modify_other_keys_aliases() -> int: shift_map[ch - 32] = upper_char _install_paired(2, shift_map) + # -- Multi-modifier letters: Shift+Alt (4), Ctrl+Shift (6), + # Ctrl+Alt (7), Ctrl+Alt+Shift (8) ---- + # The Kitty protocol always reports the UNSHIFTED codepoint; some + # modifyOtherKeys emitters send the shifted one — map both cases. + # Ctrl-bearing combos normalize onto the Ctrl key (Alt adds an Escape + # prefix), Shift+Alt onto (Escape, UPPER) — the same normalization + # dte/kakoune apply to these protocols. Without these, Ctrl+Shift+R + # etc. leak as literal text under either protocol. + shift_alt_map: dict[int, tuple] = {} + ctrl_shift_map: dict[int, object] = {} + ctrl_alt_map: dict[int, tuple] = {} + for ch in range(ord('a'), ord('z') + 1): + upper_char = chr(ch - 32) + ctrl_key = ctrl_key_map.get(ch) + for cp in (ch, ch - 32): + shift_alt_map[cp] = (Keys.Escape, upper_char) + if ctrl_key is not None: + ctrl_shift_map[cp] = ctrl_key + ctrl_alt_map[cp] = (Keys.Escape, ctrl_key) + _install_paired(4, shift_alt_map) + _install_paired(6, ctrl_shift_map) + _install_paired(7, ctrl_alt_map) + _install_paired(8, ctrl_alt_map) # Ctrl+Alt+Shift — same normalization + + # -- The Esc KEY under Kitty disambiguate mode: ESC[27u (+ modifiers) -- + # Disambiguate mode reports the Esc key as CSI-u so it is + # distinguishable from the ESC byte that starts escape sequences + # (#56684 — previously leaked "[27u" as literal text into the prompt). + # Modifiers run to 16 because kitty reports Cmd as the super bit + # (mod 9+) — same reason install_cmd_backspace_alias maps 9/10. + for seq in ["\x1b[27u"] + [f"\x1b[27;{m}u" for m in range(2, 17)]: + if seq not in ANSI_SEQUENCES: + ANSI_SEQUENCES[seq] = Keys.Escape + changed += 1 + + # -- Modified Enter / Tab / Backspace / Space ---- + # Shift+Enter / Ctrl+Enter are installed by install_shift_enter_alias / + # install_ctrl_enter_alias (which run first and win via setdefault). + _install_paired(2, { + 9: Keys.BackTab, # Shift+Tab — same as the legacy ESC[Z + 127: Keys.ControlH, # Shift+Backspace — plain backspace + 32: " ", # Shift+Space — still a space (#86866) + }) + _install_paired(3, { + 13: (Keys.Escape, Keys.ControlM), # Alt+Enter — newline tuple + 127: (Keys.Escape, Keys.ControlH), # Alt+Backspace — backward-kill-word + 32: (Keys.Escape, " "), # Alt+Space + }) + _install_paired(5, { + 9: Keys.ControlI, # Ctrl+Tab — degrade to Tab + 127: (Keys.Escape, Keys.ControlH), # Ctrl+Backspace — backward-kill-word, + # matching Ink TUI + Desktop (#78285) + }) + + # -- Kitty functional keys (Private Use Area codepoints) ---- + # kitty emits these CSI-u encodings even in LEGACY mode for keys that + # have no legacy encoding, so unmapped they leak as literal text in any + # kitty session regardless of which modes were pushed. + functional_map: dict[int, object] = {} + for d in range(10): # KP_0..KP_9 → digits + functional_map[57399 + d] = str(d) + functional_map.update({ # KP operators / punctuation + 57409: ".", 57410: "/", 57411: "*", 57412: "-", + 57413: "+", 57414: Keys.ControlM, 57415: "=", 57416: ",", + }) + functional_map.update({ # KP navigation → non-keypad keys + 57417: Keys.Left, 57418: Keys.Right, 57419: Keys.Up, + 57420: Keys.Down, 57421: Keys.PageUp, 57422: Keys.PageDown, + 57423: Keys.Home, 57424: Keys.End, 57425: Keys.Insert, + 57426: Keys.Delete, + }) + for n in range(13, 25): # F13..F24 + functional_map[57376 + (n - 13)] = getattr(Keys, f"F{n}") + # No prompt_toolkit equivalent (lock keys, PrintScreen, Menu, F25-F35, + # KP_BEGIN, media keys, bare modifier events): consume as Ignore + # instead of leaking literal text. + for code in ( + list(range(57358, 57364)) # locks, PrintScreen, Pause, Menu + + list(range(57388, 57399)) # F25..F35 + + [57427] # KP_BEGIN + + list(range(57428, 57455)) # media keys + modifier key events + ): + functional_map.setdefault(code, Keys.Ignore) + for code, key_val in functional_map.items(): + seq = f"\x1b[{code}u" + if seq not in ANSI_SEQUENCES: + ANSI_SEQUENCES[seq] = key_val + changed += 1 + + # New longer sequences can flip "is this a prefix of a longer match?" + # answers the VT100 parser already cached — drop the cache so parsers + # created before this install (or in earlier tests) can't misparse. + if changed: + _clear_vt100_prefix_cache() + return changed @@ -285,4 +423,6 @@ def install_ignored_terminal_sequences() -> int: if seq not in ANSI_SEQUENCES: ANSI_SEQUENCES[seq] = Keys.Ignore changed += 1 + if changed: + _clear_vt100_prefix_cache() return changed diff --git a/tests/cli/test_modify_other_keys_aliases.py b/tests/cli/test_modify_other_keys_aliases.py index fe8dfee3c4..040963722e 100644 --- a/tests/cli/test_modify_other_keys_aliases.py +++ b/tests/cli/test_modify_other_keys_aliases.py @@ -27,13 +27,18 @@ from hermes_cli.pt_input_extras import install_modify_other_keys_aliases @pytest.fixture(autouse=True) def _ensure_alias_installed(): """Install the alias for each test, then restore ANSI_SEQUENCES to its - prior state so 294 mappings don't leak into sibling test files.""" + prior state so the hundreds of installed mappings don't leak into + sibling test files.""" from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES as _seq saved = dict(_seq) install_modify_other_keys_aliases() yield _seq.clear() _seq.update(saved) + # Drop the parser's prefix cache too — it was computed against the + # augmented table and would go stale after the restore above. + from prompt_toolkit.input.vt100_parser import _IS_PREFIX_OF_LONGER_MATCH_CACHE + _IS_PREFIX_OF_LONGER_MATCH_CACHE.clear() def _parse(byte_seq: str): @@ -301,3 +306,130 @@ def test_plain_enter_remains_distinct(): assert enter != alt_enter assert len(enter) == 1 assert len(alt_enter) == 2 + + +# --------------------------------------------------------------------------- +# Kitty keyboard protocol: the Esc KEY (disambiguate mode, #56684) +# --------------------------------------------------------------------------- + +def test_kitty_plain_escape_key(): + """The Esc key under kitty disambiguate mode (ESC[27u) must parse as + Keys.Escape, not leak '[27u' as literal text.""" + assert _parse("\x1b[27u") == [Keys.Escape] + + +@pytest.mark.parametrize("modifier", range(2, 9)) +def test_kitty_modified_escape_key(modifier): + """Modified Esc (Shift+Esc etc.) still behaves as Escape.""" + assert _parse(f"\x1b[27;{modifier}u") == [Keys.Escape] + + +# --------------------------------------------------------------------------- +# Modified Enter / Tab / Backspace / Space +# --------------------------------------------------------------------------- + +def test_alt_enter_produces_newline_tuple(): + """Alt+Enter under either protocol must equal bare ESC+CR (newline).""" + alt_enter = _parse("\x1b\r") + assert _parse("\x1b[13;3u") == alt_enter + assert _parse("\x1b[27;3;13~") == alt_enter + + +def test_shift_tab_produces_backtab(): + """Shift+Tab must behave like the legacy ESC[Z (BackTab).""" + assert _parse("\x1b[9;2u") == [Keys.BackTab] + assert _parse("\x1b[27;2;9~") == [Keys.BackTab] + + +def test_ctrl_backspace_produces_backward_kill_word(): + """Ctrl+Backspace must produce the (Escape, ControlH) tuple that fires + prompt_toolkit's backward-kill-word — parity with the Ink TUI and + Desktop (#78285).""" + expected = [Keys.Escape, Keys.ControlH] + assert _parse("\x1b[127;5u") == expected + assert _parse("\x1b[27;5;127~") == expected + + +def test_alt_backspace_produces_backward_kill_word(): + """Alt+Backspace = ESC+DEL = backward-kill-word.""" + assert _parse("\x1b[127;3u") == [Keys.Escape, Keys.ControlH] + assert _parse("\x1b[27;3;127~") == [Keys.Escape, Keys.ControlH] + + +def test_shift_backspace_is_plain_backspace(): + assert _parse("\x1b[127;2u") == _parse("\x7f") + + +def test_shift_space_inserts_space(): + """Shift+Space must insert a space, not leak escape text (#86866).""" + assert _parse("\x1b[32;2u") == [" "] + assert _parse("\x1b[27;2;32~") == [" "] + + +# --------------------------------------------------------------------------- +# Multi-modifier combos (Ctrl+Shift / Ctrl+Alt / Shift+Alt / Ctrl+Alt+Shift) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("letter", ["c", "r", "z"]) +def test_ctrl_shift_letter_behaves_as_ctrl(letter): + """Ctrl+Shift+ (modifier 6) must fire the same key as + Ctrl+ — both the unshifted and shifted codepoint variants.""" + expected = _parse(chr(ord(letter) & 0x1F)) + for cp in (ord(letter), ord(letter) - 32): + assert _parse(f"\x1b[{cp};6u") == expected + assert _parse(f"\x1b[27;6;{cp}~") == expected + + +def test_ctrl_alt_letter_behaves_as_escape_ctrl(): + """Ctrl+Alt+a (modifier 7) = Escape prefix + ControlA.""" + assert _parse("\x1b[97;7u") == [Keys.Escape, Keys.ControlA] + + +def test_ctrl_alt_shift_letter_behaves_as_escape_ctrl(): + """Ctrl+Alt+Shift+a (modifier 8) = Escape prefix + ControlA.""" + assert _parse("\x1b[97;8u") == [Keys.Escape, Keys.ControlA] + + +def test_shift_alt_letter_behaves_as_escape_upper(): + """Shift+Alt+f (modifier 4) = Escape prefix + 'F'.""" + assert _parse("\x1b[102;4u") == [Keys.Escape, "F"] + + +# --------------------------------------------------------------------------- +# Kitty functional keys (Private Use Area codepoints) +# --------------------------------------------------------------------------- + +def test_keypad_enter_behaves_as_enter(): + assert _parse("\x1b[57414u") == _parse("\r") + + +@pytest.mark.parametrize("digit", range(10)) +def test_keypad_digits_type_digits(digit): + assert _parse(f"\x1b[{57399 + digit}u") == [str(digit)] + + +def test_keypad_navigation_maps_to_arrows(): + assert _parse("\x1b[57417u") == [Keys.Left] + assert _parse("\x1b[57418u") == [Keys.Right] + assert _parse("\x1b[57419u") == [Keys.Up] + assert _parse("\x1b[57420u") == [Keys.Down] + + +def test_f13_maps_to_f13(): + assert _parse("\x1b[57376u") == [Keys.F13] + + +@pytest.mark.parametrize("code", [57358, 57428, 57441, 57448]) +def test_lock_media_modifier_events_are_consumed(code): + """Caps Lock, media keys, and bare modifier press events must be + consumed (Keys.Ignore), never leak as literal text.""" + result = _parse(f"\x1b[{code}u") + assert result == [Keys.Ignore], f"CSI {code}u leaked: {result!r}" + + +def test_cmd_backspace_alias_not_clobbered(): + """install_cmd_backspace_alias's super-modifier mappings must survive.""" + from hermes_cli.pt_input_extras import install_cmd_backspace_alias + install_cmd_backspace_alias() + install_modify_other_keys_aliases() + assert _parse("\x1b[127;9u") == [Keys.ControlU]