Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions src/lecode/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion tests/test_headless.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
45 changes: 45 additions & 0 deletions tests/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading