fix(cli): restore Kitty keyboard protocol push and complete the extended-key alias table

Commit 4c34eeb416 fixed dead Ctrl+C by removing the Kitty protocol push
(CSI >1u) from _EXTENDED_ENTER_KEYS_SEQ, keeping only modifyOtherKeys
level 2. That regressed kitty-the-terminal completely: kitty removed
xterm modifyOtherKeys support (kovidgoyal/kitty#4075) and only speaks
its own protocol, so after the removal kitty users lost Shift+Enter and
every other extended key — the CSI >4;2m we still pushed is a no-op
there (kitty even logs a PARSE ERROR for it).

The original reason for removing the push is obsolete: #87511 mapped
CSI-u control sequences, so Ctrl+C as ESC[99;5u now parses to
Keys.ControlC and fires the existing c-c binding. (The kernel-INTR
concern in that commit was moot — prompt_toolkit's raw mode clears
ISIG, so Ctrl+C is always handled by the binding, never the kernel.)

Restore the dual push (CSI >1u + CSI >4;2m), exactly mirroring the Ink
TUI, and complete the alias table for what the kitty disambiguate flag
actually emits — #87511 left real gaps, some of which its PR body
wrongly claimed were covered:

- Esc key: ESC[27u (+ modifiers) — previously leaked '[27u' as text
- Ctrl+Backspace -> backward-kill-word (#78285 was closed on the wrong
  claim that codepoint-127 mapping existed; it did not)
- Shift+Space -> space (#86866's second symptom; the Ctrl+Space
  mapping never covered modifier 2)
- Alt+Enter -> newline tuple; Shift+Tab -> BackTab; Ctrl+Tab -> Tab;
  Alt/Shift+Backspace
- Multi-modifier letters (Shift+Alt 4, Ctrl+Shift 6, Ctrl+Alt 7,
  Ctrl+Alt+Shift 8) normalized onto their Ctrl/Escape-prefix targets,
  both unshifted (kitty) and shifted (mok emitters) codepoints
- Kitty PUA functional keys: keypad -> non-keypad equivalents,
  F13-F24, and Ignore for lock/media/modifier-event keys so they are
  consumed instead of leaking (kitty emits these even in legacy mode)

Also: clear the VT100 parser's prefix cache after installing (stale
answers could misparse), and re-push extended keys after
_recover_terminal_input_modes' reset — the recovery previously popped
both modes mid-session and never re-enabled them, silently killing
Shift+Enter until restart.

Refs #87511, #87074, #56684, #56645, #78285, #86866, #87390.
This commit is contained in:
kshitij
2026-08-16 15:39:19 +05:30
parent 2be183142c
commit 03bf85d83b
3 changed files with 306 additions and 20 deletions

50
cli.py
View File

@@ -3887,7 +3887,7 @@ _TERMINAL_INPUT_MODE_RESET_SEQ = (
"\x1b[0m" # reset text attributes
"\x1b[?25h" # ensure cursor visible
)
_EXTENDED_ENTER_KEYS_SEQ = "\x1b[>4;2m"
_EXTENDED_ENTER_KEYS_SEQ = "\x1b[>1u\x1b[>4;2m"
_BACKSLASH_LINE_CONTINUATION_RE = re.compile(r"\\[ \t]*$")
@@ -3920,25 +3920,29 @@ def _terminal_supports_extended_enter_keys(env: Optional[Mapping[str, str]] = No
def _enable_extended_enter_keys(output=None, env: Optional[Mapping[str, str]] = None) -> bool:
"""Ask allowlisted terminals to report Shift+Enter distinctly.
"""Ask allowlisted terminals to report modified keys distinctly.
Writes xterm modifyOtherKeys level 2 (CSI >4;2m), mirroring the Ink TUI.
We do NOT push the Kitty keyboard protocol (CSI >1u) here because
prompt_toolkit 3.x cannot parse Kitty CSI-u sequences for control
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).
Writes the Kitty keyboard protocol push (CSI >1u, disambiguate mode) AND
xterm modifyOtherKeys level 2 (CSI >4;2m), mirroring the Ink TUI —
terminals honor whichever protocol they implement. Both are needed:
kitty-the-terminal removed modifyOtherKeys support entirely (it only
speaks its own protocol), while tmux/VS Code only accept modifyOtherKeys.
modifyOtherKeys=2 re-encodes ALL Ctrl+key combos as
``ESC[27;5;<codepoint>~`` instead of raw control bytes.
Under either protocol the terminal re-encodes modified keys as escape
sequences — Kitty disambiguate mode as ``ESC[<codepoint>;<mod>u`` (plus
the Esc key as ``ESC[27u``), modifyOtherKeys=2 as
``ESC[27;<mod>;<codepoint>~``. Stock prompt_toolkit 3.x maps almost
none of these, which is why the CSI >1u push was temporarily removed in
#87074 (Ctrl+C arrived as ``ESC[99;5u`` and died, #56684).
``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).
``hermes_cli.pt_input_extras``) now populates ``ANSI_SEQUENCES`` with the
full Ctrl/Alt/Shift/multi-modifier and functional-key tables under BOTH
formats, so every existing key binding continues to fire — including
Ctrl+C, which is handled by prompt_toolkit's ``c-c`` binding (raw mode
clears ISIG, so the kernel INTR path was never in play for the CLI).
The exit reset sequence already pops/resets both modes, so this is
safe across normal exits, Ctrl+C, and SIGTERM cleanup.
The exit reset sequence pops/resets both modes, so this is safe across
normal exits, Ctrl+C, and SIGTERM cleanup.
"""
if not _terminal_supports_extended_enter_keys(env):
return False
@@ -8531,6 +8535,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
except Exception:
return
# The reset sequence above pops kitty keyboard mode and resets
# modifyOtherKeys too — re-request extended keys so Shift+Enter /
# modified-key reporting isn't silently dead for the rest of the
# session after a recovery (sibling of the startup push).
try:
if _cli_multiline_shortcuts_enabled(self.config or CLI_CONFIG):
_enable_extended_enter_keys(output)
except Exception:
pass
logger.warning("Recovered terminal input modes after leak: %s", reason)
if not self._input_mode_recovery_notice_shown:
self._input_mode_recovery_notice_shown = True
@@ -19280,8 +19294,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# The app enables focus reporting + mouse tracking; record that
# so _run_cleanup resets them on exit (#36823). When multiline
# shortcuts are on, also ask supported terminals (e.g. iTerm2)
# to distinguish Shift+Enter from Enter; the same cleanup reset
# pops kitty keyboard mode and resets modifyOtherKeys.
# to report modified keys distinctly (kitty protocol +
# modifyOtherKeys); the cleanup reset pops both modes.
_mark_tui_input_modes_active()
if _multiline_shortcuts_enabled:
_enable_extended_enter_keys(app.output)

View File

@@ -12,6 +12,24 @@ can be unit-tested without importing the whole CLI runtime.
from __future__ import annotations
def _clear_vt100_prefix_cache() -> None:
"""Drop prompt_toolkit's memoized "is this a prefix of a longer match?"
answers after mutating ``ANSI_SEQUENCES``.
The cache is module-global and populated lazily per distinct prefix, so
parsers created before an install (or primed by earlier tests) would
otherwise keep stale ``False`` answers and misparse newly registered
sequences. Call after any install that changed the table.
"""
try:
from prompt_toolkit.input.vt100_parser import (
_IS_PREFIX_OF_LONGER_MATCH_CACHE,
)
_IS_PREFIX_OF_LONGER_MATCH_CACHE.clear()
except Exception:
pass
def install_shift_enter_alias() -> int:
"""Map Shift+Enter byte sequences to the (Escape, ControlM) key tuple
that Alt+Enter produces, so the existing Alt+Enter newline handler
@@ -48,6 +66,8 @@ def install_shift_enter_alias() -> int:
if ANSI_SEQUENCES.get(seq) != alt_enter:
ANSI_SEQUENCES[seq] = alt_enter
changed += 1
if changed:
_clear_vt100_prefix_cache()
return changed
@@ -80,6 +100,8 @@ def install_ctrl_enter_alias() -> int:
if ANSI_SEQUENCES.get(seq) != alt_enter:
ANSI_SEQUENCES[seq] = alt_enter
changed += 1
if changed:
_clear_vt100_prefix_cache()
return changed
@@ -123,6 +145,8 @@ def install_cmd_backspace_alias() -> int:
if ANSI_SEQUENCES.get(seq) != key:
ANSI_SEQUENCES[seq] = key
changed += 1
if changed:
_clear_vt100_prefix_cache()
return changed
@@ -140,7 +164,7 @@ def install_modify_other_keys_aliases() -> int:
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).
Ctrl+U, Ctrl+W, Ctrl+Z, etc. (#56684, #86866, #87390).
This function populates ``ANSI_SEQUENCES`` for the full set:
@@ -152,6 +176,25 @@ def install_modify_other_keys_aliases() -> int:
* **Alt+letter** (a–z, A–Z): ``ESC[27;3;<codepoint>~`` and
``ESC[<codepoint>;3u`` → ``(Keys.Escape, <letter>)`` — matching how
prompt_toolkit handles a bare ``ESC`` followed by a character.
* **Shift+letter** (a–z): → the uppercase character.
* **Multi-modifier letters** (Shift+Alt=4, Ctrl+Shift=6, Ctrl+Alt=7,
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.
* **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
newline tuple; Shift+Tab → ``BackTab``; Ctrl+Tab → plain Tab;
Ctrl/Alt+Backspace → ``(Escape, ControlH)`` (backward-kill-word,
matching the Ink TUI and Desktop, #78285); Shift+Backspace → plain
backspace; Shift+Space → a plain space (#86866); Alt+Space →
``(Escape, " ")``.
* **Kitty functional keys** (Private Use Area codepoints): keypad keys
→ their non-keypad equivalents (KP_ENTER → Enter, KP_4 → '4',
KP_LEFT → Left, …); F13–F24 → ``Keys.F13``..``F24``; lock/media/
modifier-event keys → ``Keys.Ignore`` so they are consumed instead of
leaking as literal text. kitty emits these CSI-u forms even in legacy
mode for keys that have no legacy encoding.
Existing mappings (including those installed by
``install_shift_enter_alias`` / ``install_ctrl_enter_alias``) are never
@@ -248,6 +291,101 @@ def install_modify_other_keys_aliases() -> int:
shift_map[ch - 32] = upper_char
_install_paired(2, shift_map)
# -- Multi-modifier letters: Shift+Alt (4), Ctrl+Shift (6),
# Ctrl+Alt (7), Ctrl+Alt+Shift (8) ----
# The Kitty protocol always reports the UNSHIFTED codepoint; some
# modifyOtherKeys emitters send the shifted one — map both cases.
# Ctrl-bearing combos normalize onto the Ctrl key (Alt adds an Escape
# prefix), Shift+Alt onto (Escape, UPPER) — the same normalization
# dte/kakoune apply to these protocols. Without these, Ctrl+Shift+R
# etc. leak as literal text under either protocol.
shift_alt_map: dict[int, tuple] = {}
ctrl_shift_map: dict[int, object] = {}
ctrl_alt_map: dict[int, tuple] = {}
for ch in range(ord('a'), ord('z') + 1):
upper_char = chr(ch - 32)
ctrl_key = ctrl_key_map.get(ch)
for cp in (ch, ch - 32):
shift_alt_map[cp] = (Keys.Escape, upper_char)
if ctrl_key is not None:
ctrl_shift_map[cp] = ctrl_key
ctrl_alt_map[cp] = (Keys.Escape, ctrl_key)
_install_paired(4, shift_alt_map)
_install_paired(6, ctrl_shift_map)
_install_paired(7, ctrl_alt_map)
_install_paired(8, ctrl_alt_map) # Ctrl+Alt+Shift — same normalization
# -- The Esc KEY under Kitty disambiguate mode: ESC[27u (+ modifiers) --
# 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)]:
if seq not in ANSI_SEQUENCES:
ANSI_SEQUENCES[seq] = Keys.Escape
changed += 1
# -- Modified Enter / Tab / Backspace / Space ----
# Shift+Enter / Ctrl+Enter are installed by install_shift_enter_alias /
# install_ctrl_enter_alias (which run first and win via setdefault).
_install_paired(2, {
9: Keys.BackTab, # Shift+Tab — same as the legacy ESC[Z
127: Keys.ControlH, # Shift+Backspace — plain backspace
32: " ", # Shift+Space — still a space (#86866)
})
_install_paired(3, {
13: (Keys.Escape, Keys.ControlM), # Alt+Enter — newline tuple
127: (Keys.Escape, Keys.ControlH), # Alt+Backspace — backward-kill-word
32: (Keys.Escape, " "), # Alt+Space
})
_install_paired(5, {
9: Keys.ControlI, # Ctrl+Tab — degrade to Tab
127: (Keys.Escape, Keys.ControlH), # Ctrl+Backspace — backward-kill-word,
# matching Ink TUI + Desktop (#78285)
})
# -- Kitty functional keys (Private Use Area codepoints) ----
# kitty emits these CSI-u encodings even in LEGACY mode for keys that
# have no legacy encoding, so unmapped they leak as literal text in any
# kitty session regardless of which modes were pushed.
functional_map: dict[int, object] = {}
for d in range(10): # KP_0..KP_9 → digits
functional_map[57399 + d] = str(d)
functional_map.update({ # KP operators / punctuation
57409: ".", 57410: "/", 57411: "*", 57412: "-",
57413: "+", 57414: Keys.ControlM, 57415: "=", 57416: ",",
})
functional_map.update({ # KP navigation → non-keypad keys
57417: Keys.Left, 57418: Keys.Right, 57419: Keys.Up,
57420: Keys.Down, 57421: Keys.PageUp, 57422: Keys.PageDown,
57423: Keys.Home, 57424: Keys.End, 57425: Keys.Insert,
57426: Keys.Delete,
})
for n in range(13, 25): # F13..F24
functional_map[57376 + (n - 13)] = getattr(Keys, f"F{n}")
# No prompt_toolkit equivalent (lock keys, PrintScreen, Menu, F25-F35,
# KP_BEGIN, media keys, bare modifier events): consume as Ignore
# instead of leaking literal text.
for code in (
list(range(57358, 57364)) # locks, PrintScreen, Pause, Menu
+ list(range(57388, 57399)) # F25..F35
+ [57427] # KP_BEGIN
+ list(range(57428, 57455)) # media keys + modifier key events
):
functional_map.setdefault(code, Keys.Ignore)
for code, key_val in functional_map.items():
seq = f"\x1b[{code}u"
if seq not in ANSI_SEQUENCES:
ANSI_SEQUENCES[seq] = key_val
changed += 1
# New longer sequences can flip "is this a prefix of a longer match?"
# answers the VT100 parser already cached — drop the cache so parsers
# created before this install (or in earlier tests) can't misparse.
if changed:
_clear_vt100_prefix_cache()
return changed
@@ -285,4 +423,6 @@ def install_ignored_terminal_sequences() -> int:
if seq not in ANSI_SEQUENCES:
ANSI_SEQUENCES[seq] = Keys.Ignore
changed += 1
if changed:
_clear_vt100_prefix_cache()
return changed

View File

@@ -27,13 +27,18 @@ from hermes_cli.pt_input_extras import install_modify_other_keys_aliases
@pytest.fixture(autouse=True)
def _ensure_alias_installed():
"""Install the alias for each test, then restore ANSI_SEQUENCES to its
prior state so 294 mappings don't leak into sibling test files."""
prior state so the hundreds of installed 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)
# Drop the parser's prefix cache too — it was computed against the
# augmented table and would go stale after the restore above.
from prompt_toolkit.input.vt100_parser import _IS_PREFIX_OF_LONGER_MATCH_CACHE
_IS_PREFIX_OF_LONGER_MATCH_CACHE.clear()
def _parse(byte_seq: str):
@@ -301,3 +306,130 @@ def test_plain_enter_remains_distinct():
assert enter != alt_enter
assert len(enter) == 1
assert len(alt_enter) == 2
# ---------------------------------------------------------------------------
# Kitty keyboard protocol: the Esc KEY (disambiguate mode, #56684)
# ---------------------------------------------------------------------------
def test_kitty_plain_escape_key():
"""The Esc key under kitty disambiguate mode (ESC[27u) must parse as
Keys.Escape, not leak '[27u' as literal text."""
assert _parse("\x1b[27u") == [Keys.Escape]
@pytest.mark.parametrize("modifier", range(2, 9))
def test_kitty_modified_escape_key(modifier):
"""Modified Esc (Shift+Esc etc.) still behaves as Escape."""
assert _parse(f"\x1b[27;{modifier}u") == [Keys.Escape]
# ---------------------------------------------------------------------------
# Modified Enter / Tab / Backspace / Space
# ---------------------------------------------------------------------------
def test_alt_enter_produces_newline_tuple():
"""Alt+Enter under either protocol must equal bare ESC+CR (newline)."""
alt_enter = _parse("\x1b\r")
assert _parse("\x1b[13;3u") == alt_enter
assert _parse("\x1b[27;3;13~") == alt_enter
def test_shift_tab_produces_backtab():
"""Shift+Tab must behave like the legacy ESC[Z (BackTab)."""
assert _parse("\x1b[9;2u") == [Keys.BackTab]
assert _parse("\x1b[27;2;9~") == [Keys.BackTab]
def test_ctrl_backspace_produces_backward_kill_word():
"""Ctrl+Backspace must produce the (Escape, ControlH) tuple that fires
prompt_toolkit's backward-kill-word — parity with the Ink TUI and
Desktop (#78285)."""
expected = [Keys.Escape, Keys.ControlH]
assert _parse("\x1b[127;5u") == expected
assert _parse("\x1b[27;5;127~") == expected
def test_alt_backspace_produces_backward_kill_word():
"""Alt+Backspace = ESC+DEL = backward-kill-word."""
assert _parse("\x1b[127;3u") == [Keys.Escape, Keys.ControlH]
assert _parse("\x1b[27;3;127~") == [Keys.Escape, Keys.ControlH]
def test_shift_backspace_is_plain_backspace():
assert _parse("\x1b[127;2u") == _parse("\x7f")
def test_shift_space_inserts_space():
"""Shift+Space must insert a space, not leak escape text (#86866)."""
assert _parse("\x1b[32;2u") == [" "]
assert _parse("\x1b[27;2;32~") == [" "]
# ---------------------------------------------------------------------------
# Multi-modifier combos (Ctrl+Shift / Ctrl+Alt / Shift+Alt / Ctrl+Alt+Shift)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("letter", ["c", "r", "z"])
def test_ctrl_shift_letter_behaves_as_ctrl(letter):
"""Ctrl+Shift+<letter> (modifier 6) must fire the same key as
Ctrl+<letter> — both the unshifted and shifted codepoint variants."""
expected = _parse(chr(ord(letter) & 0x1F))
for cp in (ord(letter), ord(letter) - 32):
assert _parse(f"\x1b[{cp};6u") == expected
assert _parse(f"\x1b[27;6;{cp}~") == expected
def test_ctrl_alt_letter_behaves_as_escape_ctrl():
"""Ctrl+Alt+a (modifier 7) = Escape prefix + ControlA."""
assert _parse("\x1b[97;7u") == [Keys.Escape, Keys.ControlA]
def test_ctrl_alt_shift_letter_behaves_as_escape_ctrl():
"""Ctrl+Alt+Shift+a (modifier 8) = Escape prefix + ControlA."""
assert _parse("\x1b[97;8u") == [Keys.Escape, Keys.ControlA]
def test_shift_alt_letter_behaves_as_escape_upper():
"""Shift+Alt+f (modifier 4) = Escape prefix + 'F'."""
assert _parse("\x1b[102;4u") == [Keys.Escape, "F"]
# ---------------------------------------------------------------------------
# Kitty functional keys (Private Use Area codepoints)
# ---------------------------------------------------------------------------
def test_keypad_enter_behaves_as_enter():
assert _parse("\x1b[57414u") == _parse("\r")
@pytest.mark.parametrize("digit", range(10))
def test_keypad_digits_type_digits(digit):
assert _parse(f"\x1b[{57399 + digit}u") == [str(digit)]
def test_keypad_navigation_maps_to_arrows():
assert _parse("\x1b[57417u") == [Keys.Left]
assert _parse("\x1b[57418u") == [Keys.Right]
assert _parse("\x1b[57419u") == [Keys.Up]
assert _parse("\x1b[57420u") == [Keys.Down]
def test_f13_maps_to_f13():
assert _parse("\x1b[57376u") == [Keys.F13]
@pytest.mark.parametrize("code", [57358, 57428, 57441, 57448])
def test_lock_media_modifier_events_are_consumed(code):
"""Caps Lock, media keys, and bare modifier press events must be
consumed (Keys.Ignore), never leak as literal text."""
result = _parse(f"\x1b[{code}u")
assert result == [Keys.Ignore], f"CSI {code}u leaked: {result!r}"
def test_cmd_backspace_alias_not_clobbered():
"""install_cmd_backspace_alias's super-modifier mappings must survive."""
from hermes_cli.pt_input_extras import install_cmd_backspace_alias
install_cmd_backspace_alias()
install_modify_other_keys_aliases()
assert _parse("\x1b[127;9u") == [Keys.ControlU]