From d29384eef44c7e3ea125e46ec9a92467c95d457a Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Wed, 14 Oct 2026 15:03:13 +0400 Subject: [PATCH] fix(tui): resolve Num Lock and unmapped modified keys instead of typing escape text Kitty reports Num/Caps Lock as modifier bits, so Left, Shift+Enter and Ctrl+Backspace arrived as ESC[1;129D, ESC[13;130u, ESC[127;133u. Strip lock bits, fall back from Shift (Shift+Backspace acts as Backspace), map Alt+F-keys to Esc+key, and swallow remaining unbound chords. Also pin SHELL=/bin/sh in the headless timeout test, which uses POSIX $$. --- src/lecode/tui/app.py | 49 ++++++++++++++++++++++++++++++++++++++++-- tests/test_headless.py | 3 ++- tests/test_tui_app.py | 45 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/lecode/tui/app.py b/src/lecode/tui/app.py index d804ddb..e6f09d6 100644 --- a/src/lecode/tui/app.py +++ b/src/lecode/tui/app.py @@ -251,6 +251,34 @@ def _register_key_sequences() -> None: ANSI_SEQUENCES.setdefault("\x1b[27;2;127~", Keys.ControlH) +_LOCK_BITS = 0xC0 # Kitty reports Caps Lock (64) and Num Lock (128) as modifiers +_SHIFT_BIT, _ALT_BIT = 0x01, 0x02 +_CSI_KEY_RE = re.compile(r"\x1b\[(?:27;(\d+);(\d+)~|(\d+);(\d+)([A-Za-z~u]))") + + +def _drop_modifiers(sequence: str, mask: int) -> str | None: + """Rewrite a CSI key sequence without the ``mask`` modifier bits. + + None if it is not a modified CSI key or has none of those bits. Num Lock + alone turns Left into ``ESC[1;129D``, Ctrl+Backspace into ``ESC[127;133u``. + A fully plain key comes back as its bare character (``\x7f``, ``a``). + """ + m = _CSI_KEY_RE.fullmatch(sequence) + if m is None: + return None + xterm = m[1] is not None # modifyOtherKeys: ESC[27;mod;code~ + num, mod, final = (m[2], m[1], "~") if xterm else (m[3], m[4], m[5]) + bits = int(mod) - 1 + if not bits & mask: + return None + mod = str((bits & ~mask) + 1) + if mod != "1": + return f"\x1b[27;{mod};{num}~" if xterm else f"\x1b[{num};{mod}{final}" + if xterm or final == "u": + return chr(int(num)) + return f"\x1b[{'' if num == '1' and final.isalpha() else num}{final}" + + #: Mode sequences that make Shift+Enter distinguishable from plain Enter: #: ``ESC [ > 1 u`` pushes the Kitty keyboard protocol's disambiguate flag #: (Kitty, Ghostty, WezTerm, foot, Contour) and ``ESC [ > 4 ; 2 m`` sets @@ -1433,7 +1461,24 @@ def match_xterm_unicode(sequence: str): if 127 < code <= 0x10FFFF and chr(code).isprintable(): char = chr(code) return char if modified[1] == "2" else (Keys.Escape, char) - return None + # Num/Caps Lock never matters; Shift only changes keys we do not + # bind (Shift+Backspace acts as Backspace). Alt+F-key is Esc then F-key. + for mask in (_LOCK_BITS, _SHIFT_BIT, _ALT_BIT): + stripped = _drop_modifiers(sequence, mask) + if stripped is None: + continue + if len(stripped) == 1: + base = get_match(stripped) or stripped + else: + base = match_xterm_unicode(stripped) + if base is None: + return None + if mask != _ALT_BIT: + return base + ok = isinstance(base, str) or (isinstance(base, Keys) and base != Keys.Ignore) + return (Keys.Escape, base) if ok else Keys.Ignore + # Any other chord (Ctrl+1, Super+x...) is unbound: swallow it, not as text. + return Keys.Ignore if _CSI_KEY_RE.fullmatch(sequence) else None def normalize_xterm_shift(key: KeyPress) -> None: nonlocal xterm_alt @@ -1446,7 +1491,7 @@ def normalize_xterm_shift(key: KeyPress) -> None: key = KeyPress(key.key, key.key) elif key.key == Keys.Escape and key.data.startswith(("\x1b[27;3;", "\x1b[27;4;")): xterm_alt = True - if key.data.startswith("\x1b[27;2;") and len(key.key) == 1: + if key.data.startswith("\x1b[") and len(key.key) == 1: key = KeyPress(key.key, key.key) feed_key(key) diff --git a/tests/test_headless.py b/tests/test_headless.py index 3efc4ee..a5442a5 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -461,7 +461,8 @@ async def attach(*_args, **_kwargs): assert closed == ["workers", "provider", "background", "mcp", "runtime"] -def test_headless_timeout_kills_foreground_shell(headless): +def test_headless_timeout_kills_foreground_shell(headless, monkeypatch): + monkeypatch.setenv("SHELL", "/bin/sh") # the command uses POSIX `$$`, invalid in fish tmp_path, use_script = headless pid_file = tmp_path / "shell.pid" use_script( diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index ab1de4e..137f49f 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -1521,6 +1521,51 @@ async def test_xterm_shift_character_inserts_character_not_escape_text( await task +async def test_num_lock_modifier_is_ignored_in_kitty_keys(tmp_path, monkeypatch): + """Num Lock adds 128 to Kitty's modifier field: Left, Shift+Enter, Ctrl+Backspace.""" + app, _, _ = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.create_task(app.run(input=inp, output=DummyOutput())) + try: + await wait_for(lambda: app._input_area is not None) + inp.send_text("ab\x1b[1;129DX\x1b[13;130uY\x1b[127;133u") + await wait_for(lambda: app._input_area.text.startswith("aX")) + await wait_for(lambda: app._input_area.text == "aX\nb") + finally: + inp.send_text("\x03/quit\r") + await task + + +@pytest.mark.parametrize( + ("sequence", "expected"), + [ + ("\x1b[127;2u", "aX"), # Shift+Backspace + ("\x1b[127;130u", "aX"), # Shift+Backspace, Num Lock + ("\x1b[127;194u", "aX"), # Shift+Backspace, Caps+Num Lock + ("\x1b[27;2;127~", "aX"), # xterm Shift+Backspace + ("\x1b[32;2u", "ab X"), # Shift+Space + ("\x1b[27;2u", "abX"), # Shift+Esc + ("\x1b[49;5u", "abX"), # Ctrl+1: unbound, swallowed not typed + ("\x1b[120;9u", "abX"), # Super+x + ("\x1b[15;3~", "abX"), # Alt+F5 + ], +) +async def test_unbound_modified_keys_never_leak_escape_text( + tmp_path, monkeypatch, sequence, expected +): + app, _, _ = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.create_task(app.run(input=inp, output=DummyOutput())) + try: + await wait_for(lambda: app._input_area is not None) + inp.send_text(f"ab{sequence}X") + await wait_for(lambda: app._input_area.text.endswith("X")) + assert app._input_area.text == expected + finally: + inp.send_text("\x03/quit\r") + await task + + async def test_xterm_alt_unicode_preserves_character(tmp_path, monkeypatch): app, _, _ = make_app(tmp_path, monkeypatch, []) with create_pipe_input() as inp: