diff --git a/contributors/emails/christos@despotak.is b/contributors/emails/christos@despotak.is new file mode 100644 index 0000000000..fb75bbbaf3 --- /dev/null +++ b/contributors/emails/christos@despotak.is @@ -0,0 +1 @@ +despotak diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index 220e0baf82..4b81e7f187 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -269,10 +269,32 @@ def _modify_other_keys_aliases(ANSI_SEQUENCES: dict, Keys) -> dict[str, object]: for mod in _lock_twins(m) if key is not None else (): _put(twin_fmt.format(mod=mod), key) + keypad_twins = { + 57414: "\x1b[13;{mod}u", # Enter + **{code: "\x1b[1;{mod}" + suffix for code, suffix in + zip((57417, 57418, 57419, 57420, 57423, 57424), "DCABHF")}, + **{code: f"\x1b[{number};{{mod}}~" for code, number in + ((57421, 5), (57422, 6), (57425, 2), (57426, 3))}, + } for code, key_val in _kitty_functional_map(Keys).items(): _put(f"\x1b[{code}u", key_val) for mod in _lock_twins(1): # with a lock on these arrive as ESC[;129u etc. _put(f"\x1b[{code};{mod}u", key_val) + twin = keypad_twins.get(code) + if isinstance(key_val, str) and not isinstance(key_val, Keys) and len(key_val) == 1: + twin = f"\x1b[{ord(key_val)};{{mod}}u" + # Modified keypad keys inherit existing non-keypad semantics, not new bindings. + # Some twins (Alt+Enter, lock variants) are still in this builder's pending aliases. + for modifier in range(2, 9): + for mod in _lock_variants(modifier): + equivalent = None + if twin is not None: + source = twin.format(mod=mod) + equivalent = ANSI_SEQUENCES.get(source, aliases.get(source)) + elif key_val is Keys.Ignore: + equivalent = Keys.Ignore + if equivalent is not None: + _put(f"\x1b[{code};{mod}u", equivalent) return aliases diff --git a/tests/cli/test_kitty_keypad_parity.py b/tests/cli/test_kitty_keypad_parity.py new file mode 100644 index 0000000000..9eecf2cdce --- /dev/null +++ b/tests/cli/test_kitty_keypad_parity.py @@ -0,0 +1,84 @@ +"""Modified kitty keypad keys must behave like their non-keypad twins (#97290).""" + +import asyncio + +import pytest +from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES +from prompt_toolkit.input.vt100_parser import Vt100Parser + +from hermes_cli import pt_input_extras + + +@pytest.fixture(autouse=True) +def _aliases_installed(): + pt_input_extras.install_shift_enter_alias() + pt_input_extras.install_ctrl_enter_alias() + pt_input_extras.install_modify_other_keys_aliases() + pt_input_extras.install_keypress_data_normalization() + + +def _parse(sequence): + presses = [] + parser = Vt100Parser(presses.append) + for char in sequence: + parser.feed(char) + parser.flush() + return [press.key for press in presses] + + +def test_modified_keypad_mirrors_its_non_keypad_twin(): + """Every supported modifier inherits its twin, including lock bits, only in CSI-u.""" + twins = { + 57414: "\x1b[13;{mod}u", + **{code: "\x1b[1;{mod}" + suffix for code, suffix in + zip((57417, 57418, 57419, 57420, 57423, 57424), "DCABHF")}, + **{code: f"\x1b[{number};{{mod}}~" for code, number in + ((57421, 5), (57422, 6), (57425, 2), (57426, 3))}, + **{57399 + digit: f"\x1b[{ord(str(digit))};{{mod}}u" for digit in range(10)}, + } + for code, template in twins.items(): + for base_mod in range(2, 9): + for offset in (0, 64, 128, 192): + modifier = base_mod + offset + sequence = f"\x1b[{code};{modifier}u" + twin = template.format(mod=modifier) + if twin in ANSI_SEQUENCES: + assert _parse(sequence) == _parse(twin), (code, modifier) + else: + assert sequence not in ANSI_SEQUENCES + assert f"\x1b[27;{modifier};{code}~" not in ANSI_SEQUENCES + + # First-writer-wins applies to the twin too, even when this builder stages a default. + from prompt_toolkit.keys import Keys + installed = dict(ANSI_SEQUENCES) + installed["\x1b[13;3u"] = Keys.ControlA + aliases = pt_input_extras._modify_other_keys_aliases(installed, Keys) + assert aliases["\x1b[57414;3u"] == installed["\x1b[13;3u"] + + +@pytest.mark.parametrize("prefix", ["alpha", "alpha ", "alpha!?.,", "[Pasted text #1: 15 lines]"]) +def test_keypad_alt_enter_inserts_newlines_at_the_cursor(prefix): + """Extended Enter must reach the newline handler, never self-insert raw CSI text.""" + from prompt_toolkit import Application + from prompt_toolkit.buffer import Buffer + from prompt_toolkit.document import Document + from prompt_toolkit.input import create_pipe_input + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.layout import BufferControl, Layout, Window + from prompt_toolkit.output import DummyOutput + + from hermes_cli.cli_tui_mixin import CLITuiMixin + + async def probe(sequence): + buf = Buffer(document=Document(prefix + "suffix", len(prefix))) + kb = KeyBindings() + kb.add("escape", "enter")(CLITuiMixin()._tui_insert_newline) + kb.add("c-q")(lambda event: event.app.exit(result=buf.text)) + with create_pipe_input() as inp: + app = Application(layout=Layout(Window(BufferControl(buf))), key_bindings=kb, + input=inp, output=DummyOutput()) + return await asyncio.wait_for(app.run_async( + pre_run=lambda: inp.send_text(sequence * 2 + "\x11")), timeout=5) + + for sequence in ("\x1b[57414;3u", "\x1b[57414;131u", "\x1b[13;3u", "\x1b\r"): + assert asyncio.run(probe(sequence)) == prefix + "\n\nsuffix" diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index a0176b8ec9..da7ea37bb0 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -334,6 +334,8 @@ display: :::info Pasting multi-line text is supported — use any of the newline keys above, or simply paste content directly. + +In terminals using the Kitty keyboard protocol, `Alt+Enter` on the numeric keypad also inserts a newline, including next to a collapsed paste. Modified keypad navigation keys follow their non-keypad equivalents. ::: ### Shift+Enter compatibility diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/cli.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/cli.md index 683d679f89..90393421cc 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/cli.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/cli.md @@ -221,6 +221,8 @@ personalities: :::info 支持粘贴多行文本——使用上述任意换行键,或直接粘贴内容。 + +在使用 Kitty 键盘协议的终端中,数字小键盘上的 `Alt+Enter` 也会插入换行符,即使光标紧邻折叠的粘贴内容。带修饰键的小键盘导航键与对应的非小键盘按键行为一致。 ::: ### Shift+Enter 兼容性