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
13 changes: 12 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;<codepoint>~`` 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.
"""
Expand Down
125 changes: 125 additions & 0 deletions hermes_cli/pt_input_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,131 @@ 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;<codepoint>~`` instead of the raw control byte (``\\x01`` etc.).
Kitty keyboard protocol emits ``ESC[<codepoint>;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;<codepoint>~`` and ``ESC[<codepoint>;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;<codepoint>~`` and
``ESC[<codepoint>;3u`` → ``(Keys.Escape, <letter>)`` — 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+<ch> 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+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
# 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

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, <letter>) ----
# Under modifyOtherKeys, Alt+a = ESC[27;3;97~. Without mapping, this
# leaks as literal text. prompt_toolkit handles bare Alt+letter as
# (Escape, <letter>), 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_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
# 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.
# 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'
shift_map[ch] = upper_char
shift_map[ch - 32] = upper_char
_install_paired(2, shift_map)

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
Expand Down
Loading
Loading