Skip to content
Closed
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
35 changes: 28 additions & 7 deletions hermes_cli/pt_input_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;<mod>u`` (Kitty disambiguate mode
reports Esc this way, #56684) → ``Keys.Escape``.
* **Modified Enter/Tab/Backspace/Space**: Alt+Enter → the Alt+Enter
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions tests/cli/test_modify_other_keys_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+<letter> 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
Loading