From 66e3fde11137df72e5c654421fa2c0fd9418ae31 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Wed, 19 Aug 2026 11:02:20 +0800 Subject: [PATCH] fix(cli): map kitty CSI-u lock-bit variants so key combos survive NumLock kitty and ghostty OR the CapsLock (64) / NumLock (128) state into the CSI-u modifier parameter. With NumLock on, Ctrl+C arrives as ESC[99;133u (5 + 128) instead of ESC[99;5u; the alias table had no entry for it, so every key combo leaked as literal text like [127;133u (#89651). Install every CSI-u alias with the lock-bit variants (+64/+128/+192); the xterm modifyOtherKeys encoding never carries lock bits, so the ESC[27;N;CP~ form is left untouched. The Esc-key registration now covers modifier 1 as well (1+128=129 is a lone Esc with NumLock on). --- hermes_cli/pt_input_extras.py | 35 +++++++++--- tests/cli/test_modify_other_keys_aliases.py | 59 +++++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index d2800b0299d01..dfdd04ae737f6 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -181,6 +181,11 @@ def install_modify_other_keys_aliases() -> int: 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. + * **Lock-bit variants**: every CSI-u mapping above is also installed + with the CapsLock (64) and NumLock (128) bits ORed into the modifier + parameter — kitty/ghostty include them while a lock is on, and + without the variants every key combo dies with the lock enabled + (``ESC[99;133u`` instead of ``ESC[99;5u``, #89651). * **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 @@ -244,15 +249,24 @@ def install_modify_other_keys_aliases() -> int: changed = 0 + # Kitty CSI-u encodes CapsLock/NumLock state as extra modifier bits + # (caps=64, num=128) ORed into the parameter: with NumLock on, Ctrl+C + # arrives as ESC[99;133u (5 + 128) instead of ESC[99;5u. Terminals + # that report these bits (kitty, ghostty) break every key combo while + # a lock is on (#89651) unless the lock variants are mapped too. The + # xterm modifyOtherKeys encoding never carries the lock bits, so only + # the CSI-u form needs them. + _CSI_U_LOCK_BIT_VARIANTS = (0, 64, 128, 192) + def _install_paired(modifier: int, mapping: dict) -> None: """Install both modifyOtherKeys (ESC[27;N;CP~) and CSI-u (ESC[CP;Nu) mappings for the given modifier and codepoint→key mapping.""" nonlocal changed for codepoint, key_val in mapping.items(): - for seq in ( - f"\x1b[27;{modifier};{codepoint}~", - f"\x1b[{codepoint};{modifier}u", - ): + seqs = [f"\x1b[27;{modifier};{codepoint}~"] + for lock_bits in _CSI_U_LOCK_BIT_VARIANTS: + seqs.append(f"\x1b[{codepoint};{modifier + lock_bits}u") + for seq in seqs: if seq not in ANSI_SEQUENCES: ANSI_SEQUENCES[seq] = key_val changed += 1 @@ -319,9 +333,16 @@ def _install_paired(modifier: int, mapping: dict) -> None: # 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)]: + # Modifiers run from 1 to 16: kitty reports Cmd as the super bit + # (mod 9+) — same reason install_cmd_backspace_alias maps 9/10 — and + # the lock-bit variants of the modifier-less form (1+64/128/192) are + # how a lone Esc keypress arrives with a lock on. Lock bits (caps/num) + # get the same variant treatment as _install_paired. + for seq in ["\x1b[27u"] + [ + f"\x1b[27;{m + lock_bits}u" + for m in range(1, 17) + for lock_bits in _CSI_U_LOCK_BIT_VARIANTS + ]: if seq not in ANSI_SEQUENCES: ANSI_SEQUENCES[seq] = Keys.Escape changed += 1 diff --git a/tests/cli/test_modify_other_keys_aliases.py b/tests/cli/test_modify_other_keys_aliases.py index 040963722e141..a4d35d918b7eb 100644 --- a/tests/cli/test_modify_other_keys_aliases.py +++ b/tests/cli/test_modify_other_keys_aliases.py @@ -433,3 +433,62 @@ def test_cmd_backspace_alias_not_clobbered(): install_cmd_backspace_alias() install_modify_other_keys_aliases() assert _parse("\x1b[127;9u") == [Keys.ControlU] + + +# --------------------------------------------------------------------------- +# Lock-bit variants (#89651): kitty/ghostty OR the CapsLock (64) / NumLock +# (128) state into the CSI-u modifier parameter, so with a lock enabled +# every combo arrives shifted (ESC[99;133u instead of ESC[99;5u) and died +# as literal text without these aliases. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("letter", CTRL_LETTERS) +def test_ctrl_letter_with_numlock_parses_as_raw_byte(letter): + """Ctrl+ with NumLock on (modifier + 128) must parse identically + to the raw control byte — the exact garbage from #89651 ([127;133u).""" + raw_byte = chr(ord(letter) - ord('a') + 1) + raw_result = _parse(raw_byte) + + numlock_seq = f"\x1b[{ord(letter)};133u" # 5 + 128 + assert _parse(numlock_seq) == raw_result, ( + f"NumLock Ctrl+{letter} ({numlock_seq!r}) should parse identically " + f"to raw {raw_byte!r}" + ) + + +@pytest.mark.parametrize("letter", ["a", "c", "z"]) +def test_ctrl_letter_with_capslock_parses_as_raw_byte(letter): + raw_byte = chr(ord(letter) - ord('a') + 1) + capslock_seq = f"\x1b[{ord(letter)};69u" # 5 + 64 + assert _parse(capslock_seq) == _parse(raw_byte) + + +def test_ctrl_c_with_both_locks_parses_as_raw_byte(): + """Ctrl+C with CapsLock and NumLock both on (5 + 64 + 128 = 197).""" + assert _parse("\x1b[99;197u") == _parse("\x03") + + +def test_alt_letter_with_numlock_keeps_escape_prefix(): + assert _parse("\x1b[97;131u") == [Keys.Escape, "a"] # 3 + 128 + + +def test_shift_letter_with_capslock_types_uppercase(): + assert _parse("\x1b[97;66u") == ["A"] # 2 + 64 + + +def test_esc_key_with_numlock_is_escape(): + assert _parse("\x1b[27;129u") == [Keys.Escape] # 1 + 128 + assert _parse("\x1b[27;133u") == [Keys.Escape] # 5 + 128 + + +def test_ctrl_backspace_with_numlock_is_backward_kill_word(): + """The exact sequence from the #89651 report ([127;133u).""" + assert _parse("\x1b[127;133u") == [Keys.Escape, Keys.ControlH] + + +def test_modify_other_keys_tilde_form_has_no_lock_variants(): + """The xterm modifyOtherKeys encoding never carries lock bits, so no + +64/+128 variants of the ESC[27;N;CP~ form may be installed.""" + for seq in ("\x1b[27;69;99~", "\x1b[27;133;99~", "\x1b[27;197;99~"): + assert seq not in ANSI_SEQUENCES