From aff7c939ee15610d58ab0d4be0f60967576de033 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:46:40 +0530 Subject: [PATCH 1/2] fix(cli): map all Ctrl/Alt/Shift+key combos under modifyOtherKeys level 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 4c34eeb416 stopped pushing the Kitty keyboard protocol (CSI >1u) because Ctrl+C arrived as ESC[99;5u instead of \x03, breaking SIGINT. But modifyOtherKeys level 2 (CSI >4;2m) was kept so Shift+Enter stays distinguishable from Enter. Under modifyOtherKeys=2, terminals re-encode EVERY Ctrl+key combo as ESC[27;5;~ instead of the raw control byte. 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. Shift+letter combos (ESC[27;2;~) have the same problem, causing the 'caps locked sessions' symptom where typed text appears corrupted or stuck. Fix: add install_modify_other_keys_aliases() to pt_input_extras.py that populates prompt_toolkit's ANSI_SEQUENCES dict with 294 mappings covering: - Ctrl+letter (a-z): ESC[27;5;~ and ESC[;5u -> Keys.ControlA..Z - Ctrl+digit (0-9): same formats -> Keys.Control0..9 - Ctrl+symbol ([ \ ] ^ _ @ Space): same formats -> matching Keys.Control* - Alt+letter (a-z, A-Z): both formats -> (Escape, ) tuple - Shift+letter (a-z, A-Z): both formats -> uppercase character Uses setdefault semantics — never clobbers existing mappings from install_shift_enter_alias or install_ctrl_enter_alias. The Ink TUI (Node.js) already handles this via a regex parser; prompt_toolkit 3.x uses dict lookup only, so we populate the dict. Refs #56684, #87711. --- cli.py | 13 +- hermes_cli/pt_input_extras.py | 139 +++++++++ tests/cli/test_modify_other_keys_aliases.py | 297 ++++++++++++++++++++ 3 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 tests/cli/test_modify_other_keys_aliases.py diff --git a/cli.py b/cli.py index f2e255e8b6635..7534cf7f8bcc2 100644 --- a/cli.py +++ b/cli.py @@ -81,13 +81,15 @@ install_cmd_backspace_alias, install_ctrl_enter_alias, install_ignored_terminal_sequences, + install_modify_other_keys_aliases, install_shift_enter_alias, ) install_shift_enter_alias() install_ctrl_enter_alias() install_cmd_backspace_alias() + install_modify_other_keys_aliases() install_ignored_terminal_sequences() - del install_shift_enter_alias, install_ctrl_enter_alias, install_cmd_backspace_alias, install_ignored_terminal_sequences + del install_shift_enter_alias, install_ctrl_enter_alias, install_cmd_backspace_alias, install_modify_other_keys_aliases, install_ignored_terminal_sequences except Exception: pass import threading @@ -3926,6 +3928,15 @@ def _enable_extended_enter_keys(output=None, env: Optional[Mapping[str, str]] = 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). + + modifyOtherKeys=2 re-encodes ALL Ctrl+key combos as + ``ESC[27;5;~`` instead of raw control bytes. + ``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). + The exit reset sequence already pops/resets both modes, so this is safe across normal exits, Ctrl+C, and SIGTERM cleanup. """ diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index 2efd5d8bc3baf..42d24ef90420e 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -126,6 +126,145 @@ def install_cmd_backspace_alias() -> int: return changed +def install_modify_other_keys_aliases() -> int: + """Map Ctrl+key and Alt+key sequences emitted under ``modifyOtherKeys`` level 2 + and Kitty CSI-u to the same ``Keys``.* values that the raw control bytes + already map to. + + When the terminal is in ``modifyOtherKeys=2`` mode (pushed by + ``_enable_extended_enter_keys`` so Shift+Enter is distinguishable from + Enter), the terminal re-encodes *every* Ctrl+key combo as + ``ESC[27;5;~`` instead of the raw control byte (``\\x01`` etc.). + Kitty keyboard protocol emits ``ESC[;5u``. + + 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). + + This function populates ``ANSI_SEQUENCES`` for the full set: + + * **Ctrl+letter** (a–z): ``ESC[27;5;~`` and ``ESC[;5u`` + → ``Keys.ControlA`` .. ``Keys.ControlZ`` + * **Ctrl+digit** (0–9): same formats → ``Keys.Control0`` .. ``Keys.Control9`` + * **Ctrl+symbol** (``[`` ``\\`` ``]`` ``^`` ``_`` `` `` ``@``): + same formats → the same ``Keys`` value the raw control byte maps to. + * **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. + + Existing mappings (including those installed by + ``install_shift_enter_alias`` / ``install_ctrl_enter_alias``) are never + overwritten — ``setdefault`` semantics. + + Returns the number of sequences whose mapping was newly installed. + """ + try: + from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES + from prompt_toolkit.keys import Keys + except Exception: + return 0 + + # -- Ctrl+letter / Ctrl+digit / Ctrl+symbol → Keys.Control* ---- + # codepoint -> Keys value. The raw control byte for Ctrl+ is + # chr(ord(ch) & 0x1f) (i.e. ord(ch) - 96 for lowercase). We map the + # *extended* sequence to the same Keys value that the raw byte maps to, + # so prompt_toolkit's existing key bindings fire identically. + ctrl_key_map: dict[int, object] = {} + + # a-z: Ctrl+A = \x01 = Keys.ControlA, ..., Ctrl+Z = \x1a = Keys.ControlZ + for ch in range(ord('a'), ord('z') + 1): + raw = chr(ch & 0x1F) # 0x01..0x1a + existing = ANSI_SEQUENCES.get(raw) + if existing is not None: + ctrl_key_map[ch] = existing + + # 0-9: Ctrl+0 = \x00? No — Ctrl+0 doesn't produce a control byte. + # Under modifyOtherKeys, Ctrl+0 sends ESC[27;5;48~. prompt_toolkit has + # Keys.Control0..Control9; map them. These don't have raw-byte mappings + # in ANSI_SEQUENCES, so map them directly. + ctrl_key_map[ord('0')] = Keys.Control0 + ctrl_key_map[ord('1')] = Keys.Control1 + ctrl_key_map[ord('2')] = Keys.Control2 + ctrl_key_map[ord('3')] = Keys.Control3 + ctrl_key_map[ord('4')] = Keys.Control4 + ctrl_key_map[ord('5')] = Keys.Control5 + ctrl_key_map[ord('6')] = Keys.Control6 + ctrl_key_map[ord('7')] = Keys.Control7 + ctrl_key_map[ord('8')] = Keys.Control8 + ctrl_key_map[ord('9')] = Keys.Control9 + + # Symbols that produce control chars: + # Ctrl+@ (64) = \x00 = Keys.ControlAt + # Ctrl+[ (91) = \x1b = Keys.Escape + # Ctrl+\ (92) = \x1c = Keys.ControlBackslash + # Ctrl+] (93) = \x1d = Keys.ControlSquareClose + # Ctrl+^ (94) = \x1e = Keys.ControlCircumflex + # Ctrl+_ (95) = \x1f = Keys.ControlUnderscore + # Ctrl+Space(32) = \x00 = Keys.ControlAt (prompt_toolkit maps \x00 → ControlAt) + for codepoint in (64, 91, 92, 93, 94, 95, 32): + raw = chr(codepoint & 0x1F) + existing = ANSI_SEQUENCES.get(raw) + if existing is not None: + ctrl_key_map[codepoint] = existing + + changed = 0 + for codepoint, key_val in ctrl_key_map.items(): + # modifyOtherKeys format: ESC[27;5;~ + seq_mok = f"\x1b[27;5;{codepoint}~" + if seq_mok not in ANSI_SEQUENCES: + ANSI_SEQUENCES[seq_mok] = key_val + changed += 1 + # CSI-u format: ESC[;5u + seq_csiu = f"\x1b[{codepoint};5u" + if seq_csiu not in ANSI_SEQUENCES: + ANSI_SEQUENCES[seq_csiu] = key_val + changed += 1 + + # -- Alt+letter → (Escape, ) ---- + # Under modifyOtherKeys, Alt+a = ESC[27;3;97~. Without mapping, this + # leaks as literal text. prompt_toolkit handles bare Alt+letter as + # (Escape, ), so we map the extended sequences to the same tuple. + for ch in range(ord('a'), ord('z') + 1): + letter = chr(ch) + upper = chr(ch - 32) # uppercase variant + alt_key: tuple = (Keys.Escape, letter) + for codepoint, alt_val in ((ch, alt_key), (ch - 32, (Keys.Escape, upper))): + seq_mok = f"\x1b[27;3;{codepoint}~" + if seq_mok not in ANSI_SEQUENCES: + ANSI_SEQUENCES[seq_mok] = alt_val + changed += 1 + seq_csiu = f"\x1b[{codepoint};3u" + if seq_csiu not in ANSI_SEQUENCES: + ANSI_SEQUENCES[seq_csiu] = alt_val + changed += 1 + + # -- Shift+letter → uppercase letter ---- + # Under modifyOtherKeys=2, some terminals re-encode Shift+a as + # ESC[27;2;97~. Without mapping, this leaks as literal escape + + # "[27;2;97~" in the prompt buffer — the "caps locked" / "every key + # combo is broken" symptom (#87711). + # Map Shift+letter to the uppercase character so typing works normally. + # This is safe across all Latin keyboard layouts: Shift always uppercases + # letters. Shift+digit symbols are layout-specific (US: '!', AZERTY: '¹', + # etc.) so they are NOT mapped here — if the terminal sends those under + # modifyOtherKeys, they will leak, but that's better than wrong input. + for ch in range(ord('a'), ord('z') + 1): + upper_char = chr(ch - 32) # 'A'..'Z' + for codepoint in (ch, ch - 32): + target = upper_char + seq_mok = f"\x1b[27;2;{codepoint}~" + if seq_mok not in ANSI_SEQUENCES: + ANSI_SEQUENCES[seq_mok] = target + changed += 1 + seq_csiu = f"\x1b[{codepoint};2u" + if seq_csiu not in ANSI_SEQUENCES: + ANSI_SEQUENCES[seq_csiu] = target + 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 diff --git a/tests/cli/test_modify_other_keys_aliases.py b/tests/cli/test_modify_other_keys_aliases.py new file mode 100644 index 0000000000000..ae917089aee54 --- /dev/null +++ b/tests/cli/test_modify_other_keys_aliases.py @@ -0,0 +1,297 @@ +"""Regression tests for issue #87711 — Ctrl+key / Alt+key combos broken +under modifyOtherKeys level 2. + +When the CLI pushes ``ESC[>4;2m`` (modifyOtherKeys=2) to supported +terminals so Shift+Enter is distinguishable from Enter, the terminal +re-encodes EVERY Ctrl+key combo as ``ESC[27;5;~`` instead of +the raw control byte (``\\x01`` etc.). prompt_toolkit 3.x only ships a +mapping for ``ESC[27;5;13~`` (Ctrl+Enter = Ctrl+M); all other Ctrl+letter +combos are unmapped and leak as literal text or get swallowed. + +``install_modify_other_keys_aliases()`` populates ``ANSI_SEQUENCES`` with +the full set so every Ctrl+combo continues to fire the same key binding +the raw byte would. +""" + +from __future__ import annotations + +import pytest + +from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.keys import Keys + +from hermes_cli.pt_input_extras import install_modify_other_keys_aliases + + +@pytest.fixture(autouse=True) +def _ensure_alias_installed(): + """Make every test idempotent — install the alias once per test run.""" + install_modify_other_keys_aliases() + + +def _parse(byte_seq: str): + """Feed bytes through prompt_toolkit's VT100 parser and return the + list of KeyPress objects.""" + out = [] + parser = Vt100Parser(out.append) + for ch in byte_seq: + parser.feed(ch) + parser.flush() + return [kp.key for kp in out] + + +# --------------------------------------------------------------------------- +# Ctrl+letter: a-z +# --------------------------------------------------------------------------- + +CTRL_LETTERS = [chr(c) for c in range(ord('a'), ord('z') + 1)] + + +@pytest.mark.parametrize("letter", CTRL_LETTERS) +def test_modify_other_keys_ctrl_letter_parses_as_raw_byte(letter): + """Ctrl+ under modifyOtherKeys must parse identically to the + raw control byte that prompt_toolkit already understands.""" + raw_byte = chr(ord(letter) - ord('a') + 1) # Ctrl+a = \x01, etc. + raw_result = _parse(raw_byte) + assert len(raw_result) == 1, f"raw byte {raw_byte!r} should produce 1 keypress" + + # modifyOtherKeys format + mok_seq = f"\x1b[27;5;{ord(letter)}~" + mok_result = _parse(mok_seq) + assert mok_result == raw_result, ( + f"modifyOtherKeys Ctrl+{letter} ({mok_seq!r}) should parse identically " + f"to raw {raw_byte!r}; got {mok_result!r} vs {raw_result!r}" + ) + + # CSI-u format + csiu_seq = f"\x1b[{ord(letter)};5u" + csiu_result = _parse(csiu_seq) + assert csiu_result == raw_result, ( + f"CSI-u Ctrl+{letter} ({csiu_seq!r}) should parse identically " + f"to raw {raw_byte!r}; got {csiu_result!r} vs {raw_result!r}" + ) + + +@pytest.mark.parametrize("letter", CTRL_LETTERS) +def test_modify_other_keys_ctrl_letter_single_keypress(letter): + """Each Ctrl+letter sequence must produce exactly one keypress — + a partial match would emit Escape plus literal text.""" + for seq in (f"\x1b[27;5;{ord(letter)}~", f"\x1b[{ord(letter)};5u"): + result = _parse(seq) + assert len(result) == 1, ( + f"{seq!r} should produce exactly 1 keypress, got {len(result)}: {result!r}" + ) + + +# --------------------------------------------------------------------------- +# Critical individual shortcuts +# --------------------------------------------------------------------------- + +def test_ctrl_c_under_modify_other_keys(): + """Ctrl+C must produce Keys.ControlC, not literal text (#56684).""" + assert _parse("\x1b[27;5;99~") == [Keys.ControlC] + assert _parse("\x1b[99;5u") == [Keys.ControlC] + + +def test_ctrl_a_under_modify_other_keys(): + """Ctrl+A (line start) must still fire.""" + assert _parse("\x1b[27;5;97~") == [Keys.ControlA] + assert _parse("\x1b[97;5u") == [Keys.ControlA] + + +def test_ctrl_e_under_modify_other_keys(): + """Ctrl+E (line end) must still fire.""" + assert _parse("\x1b[27;5;101~") == [Keys.ControlE] + assert _parse("\x1b[101;5u") == [Keys.ControlE] + + +def test_ctrl_u_under_modify_other_keys(): + """Ctrl+U (kill line) must still fire.""" + assert _parse("\x1b[27;5;117~") == [Keys.ControlU] + assert _parse("\x1b[117;5u") == [Keys.ControlU] + + +def test_ctrl_k_under_modify_other_keys(): + """Ctrl+K (kill to end) must still fire.""" + assert _parse("\x1b[27;5;107~") == [Keys.ControlK] + assert _parse("\x1b[107;5u") == [Keys.ControlK] + + +def test_ctrl_r_under_modify_other_keys(): + """Ctrl+R (reverse search) must still fire.""" + assert _parse("\x1b[27;5;114~") == [Keys.ControlR] + assert _parse("\x1b[114;5u") == [Keys.ControlR] + + +def test_ctrl_d_under_modify_other_keys(): + """Ctrl+D (EOF / delete) must still fire.""" + assert _parse("\x1b[27;5;100~") == [Keys.ControlD] + assert _parse("\x1b[100;5u") == [Keys.ControlD] + + +def test_ctrl_w_under_modify_other_keys(): + """Ctrl+W (delete word) must still fire.""" + assert _parse("\x1b[27;5;119~") == [Keys.ControlW] + assert _parse("\x1b[119;5u") == [Keys.ControlW] + + +def test_ctrl_z_under_modify_other_keys(): + """Ctrl+Z (suspend) must still fire.""" + assert _parse("\x1b[27;5;122~") == [Keys.ControlZ] + assert _parse("\x1b[122;5u") == [Keys.ControlZ] + + +def test_ctrl_l_under_modify_other_keys(): + """Ctrl+L (clear screen) must still fire.""" + assert _parse("\x1b[27;5;108~") == [Keys.ControlL] + assert _parse("\x1b[108;5u") == [Keys.ControlL] + + +# --------------------------------------------------------------------------- +# Ctrl+digit: 0-9 +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("digit", [str(d) for d in range(10)]) +def test_modify_other_keys_ctrl_digit(digit): + """Ctrl+digit under modifyOtherKeys must map to Keys.Control.""" + codepoint = ord(digit) + expected = getattr(Keys, f"Control{digit}") + + mok_seq = f"\x1b[27;5;{codepoint}~" + assert _parse(mok_seq) == [expected], ( + f"modifyOtherKeys Ctrl+{digit} ({mok_seq!r}) should parse as {expected}" + ) + + csiu_seq = f"\x1b[{codepoint};5u" + assert _parse(csiu_seq) == [expected], ( + f"CSI-u Ctrl+{digit} ({csiu_seq!r}) should parse as {expected}" + ) + + +# --------------------------------------------------------------------------- +# Ctrl+symbol +# --------------------------------------------------------------------------- + +def test_ctrl_left_bracket_under_modify_other_keys(): + """Ctrl+[ = Escape under modifyOtherKeys.""" + assert _parse("\x1b[27;5;91~") == _parse("\x1b") + assert _parse("\x1b[91;5u") == _parse("\x1b") + + +def test_ctrl_backslash_under_modify_other_keys(): + """Ctrl+\\ = ControlBackslash under modifyOtherKeys.""" + assert _parse("\x1b[27;5;92~") == [Keys.ControlBackslash] + assert _parse("\x1b[92;5u") == [Keys.ControlBackslash] + + +def test_ctrl_right_bracket_under_modify_other_keys(): + """Ctrl+] = ControlSquareClose under modifyOtherKeys.""" + assert _parse("\x1b[27;5;93~") == [Keys.ControlSquareClose] + assert _parse("\x1b[93;5u") == [Keys.ControlSquareClose] + + +def test_ctrl_space_under_modify_other_keys(): + """Ctrl+Space = ControlAt (NUL) under modifyOtherKeys.""" + assert _parse("\x1b[27;5;32~") == _parse("\x00") + assert _parse("\x1b[32;5u") == _parse("\x00") + + +# --------------------------------------------------------------------------- +# Alt+letter +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("letter", [chr(c) for c in range(ord('a'), ord('z') + 1)]) +def test_modify_other_keys_alt_letter_parses_as_escape_letter(letter): + """Alt+ under modifyOtherKeys must parse to the same tuple + that a bare ESC+ produces.""" + bare_result = _parse(f"\x1b{letter}") + assert len(bare_result) == 2, f"bare ESC+{letter} should produce 2 keypresses" + + mok_seq = f"\x1b[27;3;{ord(letter)}~" + mok_result = _parse(mok_seq) + assert mok_result == bare_result, ( + f"modifyOtherKeys Alt+{letter} ({mok_seq!r}) should parse identically " + f"to bare ESC+{letter}; got {mok_result!r} vs {bare_result!r}" + ) + + csiu_seq = f"\x1b[{ord(letter)};3u" + csiu_result = _parse(csiu_seq) + assert csiu_result == bare_result, ( + f"CSI-u Alt+{letter} ({csiu_seq!r}) should parse identically " + f"to bare ESC+{letter}; got {csiu_result!r} vs {bare_result!r}" + ) + + +# --------------------------------------------------------------------------- +# Idempotency and non-clobbering +# --------------------------------------------------------------------------- + +def test_install_is_idempotent(): + install_modify_other_keys_aliases() + assert install_modify_other_keys_aliases() == 0 + + +# --------------------------------------------------------------------------- +# Shift+letter → uppercase +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("letter", [chr(c) for c in range(ord('a'), ord('z') + 1)]) +def test_modify_other_keys_shift_letter_produces_uppercase(letter): + """Shift+ under modifyOtherKeys must produce the uppercase + character, not leak as literal escape text — the 'caps locked' bug.""" + upper = letter.upper() + # modifyOtherKeys format + mok_seq = f"\x1b[27;2;{ord(letter)}~" + assert _parse(mok_seq) == [upper], ( + f"modifyOtherKeys Shift+{letter} ({mok_seq!r}) should produce '{upper}'" + ) + # CSI-u format + csiu_seq = f"\x1b[{ord(letter)};2u" + assert _parse(csiu_seq) == [upper], ( + f"CSI-u Shift+{letter} ({csiu_seq!r}) should produce '{upper}'" + ) + + +def test_does_not_clobber_shift_enter_alias(): + """install_modify_other_keys_aliases must not overwrite mappings + installed by install_shift_enter_alias (modifier=2, not 5).""" + from hermes_cli.pt_input_extras import install_shift_enter_alias + install_shift_enter_alias() + assert ANSI_SEQUENCES["\x1b[27;2;13~"] == (Keys.Escape, Keys.ControlM) + assert ANSI_SEQUENCES["\x1b[13;2u"] == (Keys.Escape, Keys.ControlM) + + +def test_does_not_clobber_ctrl_enter_alias(): + """install_modify_other_keys_aliases must not overwrite mappings + installed by install_ctrl_enter_alias (which maps Ctrl+Enter).""" + from hermes_cli.pt_input_extras import install_ctrl_enter_alias + install_ctrl_enter_alias() + # Ctrl+Enter (modifier=5, codepoint=13) is mapped to (Escape, ControlM) + assert ANSI_SEQUENCES["\x1b[27;5;13~"] == (Keys.Escape, Keys.ControlM) + assert ANSI_SEQUENCES["\x1b[13;5u"] == (Keys.Escape, Keys.ControlM) + + +def test_ctrl_enter_still_works_under_modify_other_keys(): + """Ctrl+Enter must produce the Alt+Enter newline tuple, not plain Ctrl+M. + This is the install_ctrl_enter_alias behavior — our new function must + not clobber it.""" + from hermes_cli.pt_input_extras import install_ctrl_enter_alias + install_ctrl_enter_alias() + install_modify_other_keys_aliases() + + alt_enter = _parse("\x1b\r") + ctrl_enter_mok = _parse("\x1b[27;5;13~") + ctrl_enter_csiu = _parse("\x1b[13;5u") + assert ctrl_enter_mok == alt_enter + assert ctrl_enter_csiu == alt_enter + + +def test_plain_enter_remains_distinct(): + """Plain Enter must keep producing a single keypress (submit), not + the two-key Alt+Enter tuple.""" + enter = _parse("\r") + alt_enter = _parse("\x1b\r") + assert enter != alt_enter + assert len(enter) == 1 + assert len(alt_enter) == 2 From 4a1ed2339f0d83635cc6906118dab7142fb4a5d6 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:59:01 +0530 Subject: [PATCH 2/2] refactor: extract _install_paired helper, fix misleading comment, isolate test fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply findings from /simplify-code 3-agent review: 1. Extract _install_paired() inner helper — the Ctrl, Alt, and Shift sections all repeated the same mok+csiu sequence generation pattern (~30 lines of duplication). Now each section builds a dict and delegates to _install_paired(modifier, mapping). 2. Replace 10 hardcoded Ctrl+digit lines with a loop matching the Ctrl+letter pattern above it. 3. Fix misleading comment: claimed 'Ctrl+0 doesn't produce a control byte' but chr(ord('0') & 0x1F) = 0x10 = ControlP. The code was correct (maps directly to Keys.Control0..9); only the comment was wrong. 4. Add comment explaining why Shift+letter maps both lowercase and uppercase codepoints (some terminals send the already-shifted codepoint with modifier=2). 5. Test fixture: snapshot/restore ANSI_SEQUENCES in teardown so 294 mappings don't leak into sibling test files (global mutable state). --- hermes_cli/pt_input_extras.py | 76 +++++++++------------ tests/cli/test_modify_other_keys_aliases.py | 8 ++- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index 42d24ef90420e..6c1151ab32bbf 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -179,20 +179,11 @@ def install_modify_other_keys_aliases() -> int: if existing is not None: ctrl_key_map[ch] = existing - # 0-9: Ctrl+0 = \x00? No — Ctrl+0 doesn't produce a control byte. - # Under modifyOtherKeys, Ctrl+0 sends ESC[27;5;48~. prompt_toolkit has - # Keys.Control0..Control9; map them. These don't have raw-byte mappings - # in ANSI_SEQUENCES, so map them directly. - ctrl_key_map[ord('0')] = Keys.Control0 - ctrl_key_map[ord('1')] = Keys.Control1 - ctrl_key_map[ord('2')] = Keys.Control2 - ctrl_key_map[ord('3')] = Keys.Control3 - ctrl_key_map[ord('4')] = Keys.Control4 - ctrl_key_map[ord('5')] = Keys.Control5 - ctrl_key_map[ord('6')] = Keys.Control6 - ctrl_key_map[ord('7')] = Keys.Control7 - ctrl_key_map[ord('8')] = Keys.Control8 - ctrl_key_map[ord('9')] = Keys.Control9 + # 0-9: Ctrl+digit codepoints don't have a useful raw-byte mapping + # (e.g. chr(ord('0') & 0x1F) = 0x10 = ControlP, not Control0), so map + # them directly to Keys.Control0..Keys.Control9. + for d in range(10): + ctrl_key_map[ord('0') + d] = getattr(Keys, f"Control{d}") # Symbols that produce control chars: # Ctrl+@ (64) = \x00 = Keys.ControlAt @@ -209,35 +200,34 @@ def install_modify_other_keys_aliases() -> int: ctrl_key_map[codepoint] = existing changed = 0 - for codepoint, key_val in ctrl_key_map.items(): - # modifyOtherKeys format: ESC[27;5;~ - seq_mok = f"\x1b[27;5;{codepoint}~" - if seq_mok not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq_mok] = key_val - changed += 1 - # CSI-u format: ESC[;5u - seq_csiu = f"\x1b[{codepoint};5u" - if seq_csiu not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq_csiu] = key_val - changed += 1 + + 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", + ): + if seq not in ANSI_SEQUENCES: + ANSI_SEQUENCES[seq] = key_val + changed += 1 + + # Ctrl+letter / Ctrl+digit / Ctrl+symbol (modifier 5) + _install_paired(5, ctrl_key_map) # -- Alt+letter → (Escape, ) ---- # Under modifyOtherKeys, Alt+a = ESC[27;3;97~. Without mapping, this # leaks as literal text. prompt_toolkit handles bare Alt+letter as # (Escape, ), so we map the extended sequences to the same tuple. + alt_map: dict[int, tuple] = {} for ch in range(ord('a'), ord('z') + 1): letter = chr(ch) upper = chr(ch - 32) # uppercase variant - alt_key: tuple = (Keys.Escape, letter) - for codepoint, alt_val in ((ch, alt_key), (ch - 32, (Keys.Escape, upper))): - seq_mok = f"\x1b[27;3;{codepoint}~" - if seq_mok not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq_mok] = alt_val - changed += 1 - seq_csiu = f"\x1b[{codepoint};3u" - if seq_csiu not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq_csiu] = alt_val - changed += 1 + alt_map[ch] = (Keys.Escape, letter) + alt_map[ch - 32] = (Keys.Escape, upper) + _install_paired(3, alt_map) # -- Shift+letter → uppercase letter ---- # Under modifyOtherKeys=2, some terminals re-encode Shift+a as @@ -249,18 +239,14 @@ def install_modify_other_keys_aliases() -> int: # letters. Shift+digit symbols are layout-specific (US: '!', AZERTY: '¹', # etc.) so they are NOT mapped here — if the terminal sends those under # modifyOtherKeys, they will leak, but that's better than wrong input. + # Map both the lowercase and uppercase codepoints — some terminals send + # the already-shifted codepoint (65 for 'A') with modifier=2. + shift_map: dict[int, str] = {} for ch in range(ord('a'), ord('z') + 1): upper_char = chr(ch - 32) # 'A'..'Z' - for codepoint in (ch, ch - 32): - target = upper_char - seq_mok = f"\x1b[27;2;{codepoint}~" - if seq_mok not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq_mok] = target - changed += 1 - seq_csiu = f"\x1b[{codepoint};2u" - if seq_csiu not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq_csiu] = target - changed += 1 + shift_map[ch] = upper_char + shift_map[ch - 32] = upper_char + _install_paired(2, shift_map) return changed diff --git a/tests/cli/test_modify_other_keys_aliases.py b/tests/cli/test_modify_other_keys_aliases.py index ae917089aee54..fe8dfee3c4106 100644 --- a/tests/cli/test_modify_other_keys_aliases.py +++ b/tests/cli/test_modify_other_keys_aliases.py @@ -26,8 +26,14 @@ @pytest.fixture(autouse=True) def _ensure_alias_installed(): - """Make every test idempotent — install the alias once per test run.""" + """Install the alias for each test, then restore ANSI_SEQUENCES to its + prior state so 294 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) def _parse(byte_seq: str):