diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index d2800b0299d0..5b1d170e7f85 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -150,6 +150,65 @@ def install_cmd_backspace_alias() -> int: return changed +def _install_literal_key_data_patch() -> bool: + """Make character-valued ``ANSI_SEQUENCES`` entries insert themselves. + + prompt_toolkit's VT100 parser builds every key press as + ``KeyPress(key=, data=)``, and the default + ``Keys.Any`` binding inserts ``event.data`` — the *bytes*, not the key. + That is invisible for entries resolving to a ``Keys`` member (bindings + match on ``key``, and ``data`` is unused), which is every entry stock + prompt_toolkit ships. + + It is not invisible for the character-valued entries registered above. + Mapping ``ESC[27;2;72~`` → ``"H"`` makes the parser emit + ``KeyPress(key="H", data="\\x1b[27;2;72~")``, so the prompt still + receives the raw escape sequence as literal text — the mapping alone + fixes what the key *is* but not what it *types*. + + This narrows ``data`` to the character for exactly that case. The + parser's fallback path already calls the handler with ``key is data`` + for ordinary typing, and stock prompt_toolkit ships no character-valued + table entries, so nothing else changes shape. + + Idempotent; returns True when this call installed the patch. + """ + try: + from prompt_toolkit.input.vt100_parser import Vt100Parser + from prompt_toolkit.keys import Keys + except Exception: + return False + + # `_call_handler` is prompt_toolkit-private. Fetch it defensively so a + # future rename degrades to the same no-op as a missing module, rather + # than raising through install_modify_other_keys_aliases() into cli.py's + # blanket `except Exception: pass` — which would silently skip the + # installers that run after it. + original_call_handler = getattr(Vt100Parser, "_call_handler", None) + if original_call_handler is None: + return False + + # The idempotency marker rides on the wrapper rather than the class, so + # it cannot outlive the wrapper it describes: if anything later replaces + # `_call_handler`, the marker goes with it and we wrap the replacement + # instead of skipping on a stale flag. + if getattr(original_call_handler, "_hermes_literal_key_data", False): + return False + + def _call_handler(self, key, insert_text): # type: ignore[no-untyped-def] + if isinstance(key, str) and not isinstance(key, Keys) and len(key) == 1: + insert_text = key + return original_call_handler(self, key, insert_text) + + _call_handler._hermes_literal_key_data = True # type: ignore[attr-defined] + + try: + Vt100Parser._call_handler = _call_handler # type: ignore[assignment] + except Exception: + return False + return True + + 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 @@ -386,6 +445,11 @@ def _install_paired(modifier: int, mapping: dict) -> None: if changed: _clear_vt100_prefix_cache() + # Character-valued entries above (Shift+letter, Shift+Space, keypad + # digits) need the parser to type the character rather than the escape + # sequence that produced it. + _install_literal_key_data_patch() + return changed diff --git a/tests/cli/test_modify_other_keys_insert_text.py b/tests/cli/test_modify_other_keys_insert_text.py new file mode 100644 index 000000000000..7d9dde592cdd --- /dev/null +++ b/tests/cli/test_modify_other_keys_insert_text.py @@ -0,0 +1,188 @@ +"""Regression tests for the character-valued half of +``install_modify_other_keys_aliases()`` — Shift+letter under +modifyOtherKeys level 2 must *type a capital*, not leak its escape sequence. + +Mapping ``ESC[27;2;72~`` → ``"H"`` in ``ANSI_SEQUENCES`` fixes what the key +*is*, but prompt_toolkit's parser reports ``KeyPress(key="H", +data="\\x1b[27;2;72~")`` and the default ``Keys.Any`` binding inserts +``event.data``. The raw sequence therefore still reached the prompt buffer. + +Key-level assertions cannot catch that — ``_parse()`` compares ``KeyPress.key``, +which was already correct. These tests drive a real ``PromptSession`` over a +pipe input and assert on the *submitted text*, which is what users see. +""" + +from __future__ import annotations + +import pytest + +from prompt_toolkit.input import create_pipe_input +from prompt_toolkit.keys import Keys +from prompt_toolkit.output import DummyOutput +from prompt_toolkit.shortcuts import PromptSession + +from hermes_cli.pt_input_extras import ( + install_ctrl_enter_alias, + install_modify_other_keys_aliases, + install_shift_enter_alias, +) + + +def _parser_class(): + """Resolve ``Vt100Parser`` per call, the way the installer does. + + `tests/cli/test_bracketed_paste_timeout.py` reloads + ``prompt_toolkit.input.vt100_parser``, which rebinds the module's class + to a new object. A module-level import here would hold the pre-reload + class and patch something the parser no longer uses. + """ + from prompt_toolkit.input.vt100_parser import Vt100Parser + return Vt100Parser + + +@pytest.fixture(autouse=True) +def _ensure_alias_installed(): + from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES as _seq + saved = dict(_seq) + # The parser patch is process-global; restore it too so it cannot leak + # into sibling test files the way the table mappings must not. + parser_cls = _parser_class() + saved_call_handler = parser_cls._call_handler + # Same order as cli.py — also proves the Enter aliases are not clobbered. + install_shift_enter_alias() + install_ctrl_enter_alias() + install_modify_other_keys_aliases() + yield + _seq.clear() + _seq.update(saved) + parser_cls._call_handler = saved_call_handler + from prompt_toolkit.input.vt100_parser import _IS_PREFIX_OF_LONGER_MATCH_CACHE + _IS_PREFIX_OF_LONGER_MATCH_CACHE.clear() + + +def _type(byte_seq: str) -> str: + """Feed raw terminal bytes to a real prompt and return what was submitted.""" + with create_pipe_input() as inp: + inp.send_text(byte_seq + "\r") + return PromptSession(input=inp, output=DummyOutput()).prompt() + + +def _mok(codepoint: int, modifier: int = 2) -> str: + return f"\x1b[27;{modifier};{codepoint}~" + + +def _csiu(codepoint: int, modifier: int = 2) -> str: + return f"\x1b[{codepoint};{modifier}u" + + +def test_shift_letter_types_a_capital(): + assert _type(_mok(ord("h")) + "ello") == "Hello" + + +def test_shift_letter_shifted_codepoint_form_types_a_capital(): + """Terminals that report the already-shifted codepoint (72 = 'H').""" + assert _type(_mok(ord("H")) + "ello") == "Hello" + + +def test_shift_letter_csi_u_form_types_a_capital(): + assert _type(_csiu(ord("h")) + "ello") == "Hello" + + +def test_mixed_sentence_round_trips(): + typed = _mok(ord("h")) + "ermes " + _mok(ord("c")) + "LI" + assert _type(typed) == "Hermes CLI" + + +def test_shift_space_types_a_space(): + assert _type("a" + _mok(32) + "b") == "a b" + + +@pytest.mark.parametrize("letter", ["a", "m", "z"]) +def test_every_letter_inserts_its_own_character(letter): + assert _type(_mok(ord(letter))) == letter.upper() + + +def test_key_press_data_matches_the_character(): + """The mechanism, asserted directly: data drives insertion, so it must + be the character rather than the bytes that produced it.""" + presses = [] + parser = _parser_class()(presses.append) + for ch in _mok(ord("q")): + parser.feed(ch) + parser.flush() + assert len(presses) == 1 + assert presses[0].key == "Q" + assert presses[0].data == "Q" + + +def test_ctrl_combo_bindings_still_fire(): + """Keys-valued entries are untouched: Ctrl+A moves to line start.""" + assert _type("bc" + _mok(ord("a"), modifier=5) + "X") == "Xbc" + + +def test_named_keys_keep_their_raw_data(): + presses = [] + parser = _parser_class()(presses.append) + for ch in "\x1b[A": + parser.feed(ch) + parser.flush() + assert [(kp.key, kp.data) for kp in presses] == [(Keys.Up, "\x1b[A")] + + +def test_shift_enter_alias_is_not_clobbered(): + from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES + assert ANSI_SEQUENCES["\x1b[27;2;13~"] == (Keys.Escape, Keys.ControlM) + + +def test_plain_typing_is_unaffected(): + assert _type("Hi there!") == "Hi there!" + assert _type("café ☕") == "café ☕" + + +def test_bracketed_paste_is_unaffected(): + assert _type("\x1b[200~Pasted TEXT\x1b[201~") == "Pasted TEXT" + + +def test_missing_private_method_degrades_to_a_no_op(monkeypatch): + """`_call_handler` is prompt_toolkit-private. If a future release renames + it, the install must return False rather than raise — an exception here + would propagate into cli.py's blanket handler and silently skip the + installers that run after it.""" + from hermes_cli import pt_input_extras + + monkeypatch.delattr(_parser_class(), "_call_handler", raising=False) + assert pt_input_extras._install_literal_key_data_patch() is False + # The caller keeps working and still reports its table registrations. + assert install_modify_other_keys_aliases() >= 0 + + +def test_patch_is_idempotent_and_does_not_stack(): + from hermes_cli import pt_input_extras + + parser_cls = _parser_class() + first = parser_cls._call_handler + assert pt_input_extras._install_literal_key_data_patch() is False + assert parser_cls._call_handler is first + + +def test_marker_cannot_outlive_the_wrapper(monkeypatch): + """If something else replaces `_call_handler`, the marker goes with it, + so the next install wraps the replacement instead of skipping.""" + from hermes_cli import pt_input_extras + + calls = [] + + def _foreign_call_handler(self, key, insert_text): + calls.append((key, insert_text)) + + parser_cls = _parser_class() + monkeypatch.setattr(parser_cls, "_call_handler", _foreign_call_handler) + assert pt_input_extras._install_literal_key_data_patch() is True + assert parser_cls._call_handler is not _foreign_call_handler + + # The replacement is wrapped, not bypassed, and still sees fixed data. + parser = parser_cls(lambda kp: None) + for ch in _mok(ord("q")): + parser.feed(ch) + parser.flush() + assert ("Q", "Q") in calls