diff --git a/CHANGELOG b/CHANGELOG index aba20450f..2006e6c03 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,22 @@ CHANGELOG ========= +Unreleased +---------- + +New features: +- Support the Kitty keyboard protocol (flag 1, "disambiguate") on + terminals that implement it — kitty, ghostty, wezterm, foot, + Alacritty, iTerm2 (with "Report modifiers using CSI u" enabled in + Preferences → Profiles → Keys), and others. The renderer pushes the + protocol on startup and pops it on exit, so modified keys like + Ctrl-Enter, Shift-Enter, Ctrl-Shift-Enter, and Alt-letter arrive as + distinct `CSI u` sequences. New key values `Keys.ControlEnter`, + `Keys.ControlShiftEnter`, and `Keys.ShiftEnter` can be used in + bindings (e.g. `@bindings.add("c-enter")`). Terminals that don't + implement the protocol silently ignore the push — no regression, + plain Enter continues to submit. + 3.0.52: 2025-08-27 ------------------ diff --git a/docs/pages/advanced_topics/index.rst b/docs/pages/advanced_topics/index.rst index 4c4fcc9ca..e00c2ba77 100644 --- a/docs/pages/advanced_topics/index.rst +++ b/docs/pages/advanced_topics/index.rst @@ -8,6 +8,7 @@ Advanced topics :maxdepth: 1 key_bindings + kitty_keyboard_protocol styling filters rendering_flow diff --git a/docs/pages/advanced_topics/kitty_keyboard_protocol.rst b/docs/pages/advanced_topics/kitty_keyboard_protocol.rst new file mode 100644 index 000000000..39b01fa4a --- /dev/null +++ b/docs/pages/advanced_topics/kitty_keyboard_protocol.rst @@ -0,0 +1,253 @@ +.. _kitty_keyboard_protocol: + + +Kitty keyboard protocol +======================= + +Maintainer-facing notes on prompt_toolkit's support for the `Kitty +keyboard protocol `_. + +Despite its name, the Kitty protocol is supported by a wide range of +terminal emulators across platforms and is not limited to the Kitty +terminal itself. + +Only flag 1 ("disambiguate escape codes") is currently implemented. +The spec also defines progressive-enhancement flags for reporting +press/release/repeat events, alternate keys, all keys as escape codes, +and associated text; none of those are implemented here. + + +Why +--- + +Under legacy terminal encodings, many modifier+key combinations are +ambiguous or impossible — :kbd:`c-enter` sends the same ``\r`` as plain +:kbd:`enter`, :kbd:`s-enter` is indistinguishable from :kbd:`enter` on +most terminals, :kbd:`m-b` (Alt-b) is reported as an Esc-prefix that +collides with pressing :kbd:`escape` followed by ``b``. The Kitty +protocol fixes all of this by escaping modified keys into ``CSI u`` +sequences with explicit modifier bits. + +prompt_toolkit pushes flag 1 ("disambiguate escape codes") on startup +and pops it on exit, so supporting terminals deliver modified keys as +distinct ``Keys`` values, and non-supporting terminals silently keep +their existing behavior. + + + +What the code does +------------------ + +Output +~~~~~~ + +``src/prompt_toolkit/output/kitty_keyboard.py`` owns the wire-format +constants and exposes ``kitty_keyboard_protocol(output, flags)`` — a +context manager that pushes the given flags on entry and pops on exit. +A depth counter (lazily attached to the ``Output`` instance by the +context manager, not a first-class field on ``Output``) ensures nested +holders compose correctly: outermost enter pushes and flushes, +outermost exit pops and flushes. Entering a nested context with a +different ``flags`` value raises ``ValueError`` rather than silently +corrupting the terminal's flag stack. + +Input +~~~~~ + +``src/prompt_toolkit/input/kitty_keyboard.py`` owns the ``CSI u`` +decoder. Covered: + +- The four functional keys whose single-byte legacy encoding collides + with a ``Ctrl+letter``: :kbd:`enter` (=``\r``=:kbd:`c-m`), + :kbd:`tab` (=``\t``=:kbd:`c-i`), :kbd:`escape` + (=``\x1b``=:kbd:`c-[`), :kbd:`backspace` (=``\x7f``/:kbd:`c-h`). + These are the only keys flag 1 actually re-encodes as ``CSI u``. + Mapped to the nearest existing ``Keys`` value with Shift / Ctrl / + Ctrl-Shift promotion where an enum exists. Arrow keys, the + navigation block (:kbd:`home` / :kbd:`end` / :kbd:`pageup` / + :kbd:`pagedown` / :kbd:`insert` / :kbd:`delete`), and + :kbd:`f1`–:kbd:`f12` are **not** handled here — under flag 1 the + Kitty spec keeps them in their legacy ``CSI ~`` / + ``CSI `` / ``SS3 `` encoding even when modified, so + they continue to travel through ``ANSI_SEQUENCES`` (which already + has the full modifier matrix). +- Printable Unicode keys with Ctrl (mapped to ``Keys.ControlX``) and + Ctrl+Shift digits (mapped to ``Keys.ControlShift1`` …). +- Alt as a meta prefix: emitted as ``(Keys.Escape, base_key)`` to match + prompt_toolkit's long-standing convention for meta-prefixed keys, so + existing bindings like ``('escape', 'b')`` keep working. +- CapsLock and NumLock modifier bits are stripped before decoding so + terminals that report them don't break bindings. + +``src/prompt_toolkit/input/vt100_parser.py`` dispatches ``CSI … u`` +sequences to the decoder (after the static ``ANSI_SEQUENCES`` lookup, +so pre-existing fixed-form entries still win) and recognizes the +``CSI ? u`` query response as ``Keys.KittyKeyboardResponse``. + +Renderer and capability detection +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``Renderer`` pushes flag 1 on first render and pops it on reset. At the +same time it writes a ``CSI ? u`` query. The binding in +``src/prompt_toolkit/key_binding/bindings/kitty_keyboard.py`` consumes +the response and flips ``renderer.kitty_support`` from ``UNKNOWN`` to +``SUPPORTED``. Terminals that don't implement the protocol silently +ignore both the push and the query; ``kitty_support`` stays at +``UNKNOWN`` and the terminal keeps sending legacy byte sequences. +Callers that want to branch on capability (e.g. to surface a hint to +the user) can read ``app.renderer.kitty_support`` — ``Application`` +exposes its renderer as a public attribute, and the value is one of +``KittySupport.UNKNOWN`` or ``KittySupport.SUPPORTED`` (imported from +``prompt_toolkit.renderer``). + +Legacy xterm ``modifyOtherKeys`` fallback +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +prompt_toolkit does **not** push ``\x1b[>4;Nm`` to enable xterm's +``modifyOtherKeys``. But the parser still folds +``CSI 27 ; ; 13 ~`` (Shift-, Ctrl-, and Ctrl-Shift-Enter under +``modifyOtherKeys``) back to ``Keys.ControlM``. That's a passive +compatibility shim: if a user's terminal or tmux has +``modifyOtherKeys`` enabled independently, modified :kbd:`enter` still +submits the form instead of silently doing nothing. Users who want +distinct bindings for :kbd:`c-enter` / :kbd:`s-enter` need a +Kitty-capable terminal. + +New ``Keys`` values +~~~~~~~~~~~~~~~~~~~ + +- ``Keys.ControlEnter``, ``Keys.ControlShiftEnter``, + ``Keys.ShiftEnter`` — Kitty-only modifier+Enter distinctions. On + non-Kitty terminals these bindings don't fire; plain :kbd:`enter` + fires instead (the protocol-less fallback). +- ``Keys.ControlTab``, ``Keys.ControlShiftTab`` — Kitty-only + modifier+Tab distinctions. Plain :kbd:`tab` (``Keys.ControlI``) and + Shift-Tab (``Keys.BackTab``) were already distinguishable; Ctrl-Tab + and Ctrl-Shift-Tab only come through under the protocol. On + non-Kitty terminals they fold back to their legacy equivalents. +- ``Keys.ControlEscape``, ``Keys.ControlShiftEscape`` — Kitty-only + modifier+Escape distinctions, alongside the pre-existing + ``Keys.ShiftEscape``. Same non-Kitty fallback behavior. +- ``Keys.ControlBackspace``, ``Keys.ShiftBackspace``, + ``Keys.ControlShiftBackspace`` — Kitty-only modifier+Backspace + distinctions. Unlike modified Enter, there is no safe legacy + fallback: on most non-Kitty terminals Ctrl-Backspace is + indistinguishable from plain Backspace or from Ctrl-H, so we do + not fold these down — a binding on one of them will simply not + fire on non-Kitty terminals. +- ``Keys.KittyKeyboardResponse`` — internal sentinel for the query + response parser-to-binding dispatch. + + +What could be done in the future +-------------------------------- + +Higher flags +~~~~~~~~~~~~ + +The protocol defines further enhancement flags beyond "disambiguate": + +- **Flag 2 — report event types.** Distinguishes press / release / + repeat. Useful for full-screen apps and tooling; not for a shell. + Would require adding an ``event_type`` field to ``KeyPress``, which + is a coordinated API change. +- **Flag 4 — report alternate keys.** Sends the base-layout keycode + alongside the current-layout one; helpful for non-US keyboard + layouts where a binding is conceptually on the "unshifted key at + that position". +- **Flag 8 — report all keys as escape codes.** Even unmodified + letters arrive as ``CSI u``. Dramatically changes input and needs + corresponding decoder work. +- **Flag 16 — report associated text.** Only meaningful with flag 8. + +More functional keys +~~~~~~~~~~~~~~~~~~~~ + +The decoder's ``_FUNCTIONAL`` table stops at :kbd:`f12` and omits +keypad / media / system keys (Play, Mute, brightness, Left Super, …) +that Kitty can report. Extending the table and adding matching +``Keys`` values is mechanical. + +Press/release-aware bindings +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Bindings today fire on press only. If flag 2 support is ever added, +``KeyBindings.add`` would need an opt-in parameter for release/repeat +events, and the key processor would need to carry the event type. A +large surface; best tackled together with any flag-2 work. + + +Wire format reference +--------------------- + +**Push flags.** + ``CSI > u`` pushes ```` onto the terminal's stack so + the pop on exit restores the pre-push state. prompt_toolkit always + pushes ``flags=1`` (disambiguate). The spec also defines + ``CSI = ; u`` which *modifies* the top of the stack + in place (mode 1 set, 2 OR, 3 AND-NOT); we don't use it because it + offers no clean restore. + +**Pop flags.** + ``CSI < u`` pops one entry. ``CSI < N u`` pops N. + +**Query flags.** + ``CSI ? u``; terminal answers ``CSI ? u`` if supported, + silence otherwise. + +**Key event.** + ``CSI [:] ; [:] ; + u``. Every key, functional or not, terminates in + ``u`` — that's the whole point of the protocol versus the legacy + ``CSI ~`` encoding it replaces. Modifiers encode as + ``1 + bitmask`` — the ``+1`` ensures an omitted modifier field + can't be confused with "Shift pressed". Keycode is a Unicode + codepoint for printable keys, functional-key codes otherwise + (Enter=13, Escape=27, F1=57364, …). Event-type is ``1`` for press, + ``2`` for repeat, ``3`` for release; under flag 1 only press + events are sent, but the decoder defensively drops the other two. + + +Known sharp edges +----------------- + +- **Modified Enter does not submit by default.** :kbd:`c-enter`, + :kbd:`s-enter`, and :kbd:`c-s-enter` are delivered as distinct keys + on a Kitty-capable terminal, but no binding is attached to them out + of the box — the default ``accept-line`` handler stays on plain + :kbd:`enter` only. This is deliberate: if we routed modified Enter + to ``accept-line``, anyone who has long-standing muscle memory around + "Ctrl-Enter inserts a newline in a multi-line prompt" would suddenly + find their input submitted on a Kitty terminal but not elsewhere — + the same physical gesture doing two different things depending on + the terminal. Users who want :kbd:`c-enter` to submit can bind it + explicitly:: + + bindings = KeyBindings() + + @bindings.add("c-enter") + def _(event): + event.current_buffer.validate_and_handle() + + The same applies to :kbd:`c-tab`, :kbd:`c-s-tab`, :kbd:`c-escape`, + and :kbd:`c-s-escape` — they're available as distinct keys under the + protocol, but we don't assign them any default semantics. + +- **Tmux pass-through.** Requires ``set -g extended-keys on`` *and* an + underlying terminal that supports the protocol. If the underlying + terminal doesn't, tmux swallows the query and ``kitty_support`` + stays at ``UNKNOWN``. +- **Detection latency.** The query response is asynchronous; if a + terminal is slow, the first few keys may arrive before + ``kitty_support`` flips to ``SUPPORTED``. That only affects the + capability signal — the push itself applies immediately, so the + terminal's first keystroke is already in the new encoding. +- **Functional-key codes are not universal.** The Kitty spec pins + Enter=13 (which coincides with ``\r``) but implementations disagree + on some rarer functional codes. Worth spot-checking new ones against + kitty, ghostty, wezterm, foot. +- **Alt vs. Esc-prefix.** Kitty reports Alt as a modifier; the legacy + path reports it as ``(Esc, letter)``. The decoder emits + ``(Keys.Escape, base_key)`` for Alt-prefixed keys to match legacy + convention — so a binding registered as ``('escape', 'b')`` matches + Alt-b either way. diff --git a/examples/prompts/kitty-key-checklist.py b/examples/prompts/kitty-key-checklist.py new file mode 100644 index 000000000..1d9f1efe6 --- /dev/null +++ b/examples/prompts/kitty-key-checklist.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python +""" +Interactive checklist for Kitty-only key gestures. + +prompt_toolkit pushes the Kitty keyboard protocol (flag 1) on startup, +so terminals that implement it (kitty, ghostty, wezterm, foot, +Alacritty, recent iTerm2 with CSI u reporting enabled, …) can +distinguish modifier+Enter, modifier+Tab, modifier+Escape, and +modifier+Backspace combinations that collapse to a single byte on +legacy terminals. + +The bottom toolbar lists every such gesture. Press one and its row +turns green. On terminals without the protocol, rows stay grey — +that's the expected fallback, not a bug. Press plain Enter to exit. + +Pass ``--no-kitty`` to suppress the protocol push so you can verify +that, without it, the gestures are indistinguishable from their legacy +equivalents and the rows stay grey — useful for reproducing how the +prompt behaves on a terminal that doesn't implement the protocol, +from inside one that does. +""" + +import argparse + +from prompt_toolkit import PromptSession +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.styles import Style + +KITTY_KEYS: list[tuple[str, str]] = [ + ("c-enter", "Ctrl-Enter"), + ("s-enter", "Shift-Enter"), + ("c-s-enter", "Ctrl-Shift-Enter"), + ("c-tab", "Ctrl-Tab"), + ("c-s-tab", "Ctrl-Shift-Tab"), + ("c-escape", "Ctrl-Escape"), + ("c-s-escape", "Ctrl-Shift-Escape"), + ("c-backspace", "Ctrl-Backspace"), + ("s-backspace", "Shift-Backspace"), + ("c-s-backspace", "Ctrl-Shift-Backspace"), +] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) + parser.add_argument( + "--no-kitty", + action="store_true", + help=( + "Don't push the Kitty keyboard protocol. Modified keys then " + "arrive as their legacy single-byte equivalents and the bindings " + "in this example never fire — useful to verify the non-Kitty " + "fallback from a Kitty-capable terminal." + ), + ) + args = parser.parse_args() + + pressed: set[str] = set() + + def toolbar(): + header = ( + "Kitty protocol DISABLED (--no-kitty) — rows stay grey:\n" + if args.no_kitty + else "Kitty-only gestures — press each to turn it green:\n" + ) + lines = [("", header)] + for binding, label in KITTY_KEYS: + if binding in pressed: + lines.append(("class:done", f" [x] {label}\n")) + else: + lines.append(("class:todo", f" [ ] {label}\n")) + remaining = len(KITTY_KEYS) - len(pressed) + if remaining: + lines.append(("", f"\n{remaining} remaining — plain Enter to exit.")) + else: + lines.append(("class:done", "\nAll gestures recorded. Enter to exit.")) + return lines + + bindings = KeyBindings() + + def make_handler(binding: str): + def handler(event): + pressed.add(binding) + event.app.invalidate() + + return handler + + for binding, _label in KITTY_KEYS: + bindings.add(binding)(make_handler(binding)) + + style = Style.from_dict( + { + "bottom-toolbar": "noreverse", + "bottom-toolbar.text": "", + "done": "fg:ansigreen bold", + "todo": "fg:ansibrightblack", + } + ) + + session = PromptSession( + "> ", + bottom_toolbar=toolbar, + key_bindings=bindings, + style=style, + refresh_interval=0.5, + ) + + if args.no_kitty: + # Short-circuit the renderer's one-shot push/query block by + # flipping its "already pushed" flag so the conditional at the + # top of Renderer.render() skips both the push and the probe. + # We hook `Application.on_reset` rather than setting the flag + # directly — `Application.run()` calls `renderer.reset()` + # first, which would clear a pre-set flag; `on_reset` fires + # *after* that reset and before the first render, which is the + # window we need. Reaching into the private attribute is fine + # for a demo; there is no public "disable kitty" knob on + # Application/Renderer today. + def _suppress_kitty(_app): + session.app.renderer._kitty_keyboard_pushed = True + + session.app.on_reset += _suppress_kitty + + session.prompt() + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/kitty-key-probe.py b/examples/prompts/kitty-key-probe.py new file mode 100644 index 000000000..cf34c2bd5 --- /dev/null +++ b/examples/prompts/kitty-key-probe.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python +""" +Manual checklist for Kitty keyboard protocol decoding. + +Walks through a list of keys, asks you to press each one, and reports +whether the decoded ``Keys`` value matches what the spec says it should +be — together with the raw bytes the terminal actually sent. + +Useful for spot-checking the codepoint table after a fix, and for +finding terminals that disagree with the spec on a particular code. + +Skip a key with ``s`` (e.g. when you just want to move on); mark a key +as absent with ``n`` (e.g. no Insert on a laptop keyboard); abort with +Ctrl-C. + +By default the Kitty protocol is enabled. Use ``--no-kitty`` to test +legacy mode only, or ``--both`` to run the full probe list twice +(first without, then with the protocol) so you can compare side by +side. + +Run on a Kitty-protocol terminal (kitty, ghostty, wezterm, foot, +recent Alacritty, iTerm2 with CSI u reporting on, …). On a terminal +that doesn't speak the protocol every prompt will report the legacy +escape sequence — also useful, since you'll see the fallback path. +""" + +from __future__ import annotations + +import argparse +import os +import select +import sys +import termios +import tty +from typing import NamedTuple + +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.keys import Keys + +_KITTY_PUSH = "\x1b[>1u" +_KITTY_POP = "\x1b[PageUp, End<->PageDown, +# Insert/Delete missing); leave them at the front for visibility. +PROBES: list[Probe] = [ + Probe("Home", Keys.Home), + Probe("End", Keys.End), + Probe("Page Up", Keys.PageUp), + Probe("Page Down", Keys.PageDown), + Probe("Insert", Keys.Insert), + Probe("Delete", Keys.Delete), + Probe("Up arrow", Keys.Up), + Probe("Down arrow", Keys.Down), + Probe("Left arrow", Keys.Left), + Probe("Right arrow", Keys.Right), + Probe("Tab", Keys.ControlI), + Probe("Shift-Tab", Keys.BackTab), + Probe("Enter", Keys.ControlM), + Probe("Ctrl-Enter", Keys.ControlEnter), + Probe("Shift-Enter", Keys.ShiftEnter), + Probe("Ctrl-Shift-Enter", Keys.ControlShiftEnter), + Probe("F1", Keys.F1), + Probe("Ctrl-F1", Keys.ControlF1), + Probe("Ctrl-A", Keys.ControlA), + Probe("Ctrl-Shift-Home", Keys.ControlShiftHome), +] + +_PROMPT_WIDTH = max(len(p.prompt) for p in PROBES) +_EXPECT_WIDTH = max(len(p.expected.value) for p in PROBES) + + +def _read_one_event(fd: int, parser: Vt100Parser, sink: list) -> bytes: + """ + Block until the user presses something, then return the raw bytes. + Escape sequences arrive in one read on a sane TTY; we additionally + coalesce anything that lands within 50ms so a multi-byte CSI doesn't + get split into two events. + """ + sink.clear() + raw = b"" + # Initial blocking read. + raw += os.read(fd, 1024) + # Drain anything that arrived together (the rest of a CSI tail, etc.). + while True: + ready, _, _ = select.select([fd], [], [], 0.05) + if not ready: + break + raw += os.read(fd, 1024) + parser.feed(raw.decode("latin-1", errors="replace")) + return raw + + +def _format_raw(raw: bytes) -> str: + parts = [] + for b in raw: + if b == 0x1B: + parts.append("ESC") + elif 0x20 <= b < 0x7F: + parts.append(chr(b)) + else: + parts.append(f"\\x{b:02x}") + return "".join(parts) + + +def _run_probes(fd: int, *, kitty: bool) -> None: + """Run the probe list once. If *kitty* is True, enable the protocol.""" + sink: list = [] + parser = Vt100Parser(lambda kp: sink.append(kp)) + pass_count = 0 + fail_count = 0 + skip_count = 0 + nokey_count = 0 + failures: list[tuple[Probe, str, str]] = [] + total = len(PROBES) + + mode_label = f"{_GREEN}kitty ON{_RESET}" if kitty else f"{_YELLOW}kitty OFF{_RESET}" + + try: + if kitty: + sys.stdout.write(_KITTY_PUSH) + sys.stdout.flush() + + sys.stdout.write( + f"\r\n{_BOLD}=== Kitty keyboard probe ({mode_label}{_BOLD}) ==={_RESET}\r\n" + f"Press the requested key. {_DIM}'s'{_RESET} to skip. " + f"{_DIM}'n'{_RESET} for no such key. " + f"{_DIM}Ctrl-C{_RESET} to abort.\r\n\r\n" + ) + sys.stdout.flush() + + for i, probe in enumerate(PROBES, 1): + counter = f"{_DIM}[{i:2d}/{total}]{_RESET}" + key_name = f"{_BOLD}{probe.prompt:<{_PROMPT_WIDTH}}{_RESET}" + expect = f"{_DIM}expect {probe.expected.value!r:<{_EXPECT_WIDTH}}{_RESET}" + sys.stdout.write(f" {counter} {key_name} {expect} ") + sys.stdout.flush() + + raw = _read_one_event(fd, parser, sink) + + # Skip handling: lone 's' or 'S'. + if raw in (b"s", b"S") and sink and sink[0].key == "s": + sys.stdout.write(f"{_YELLOW}SKIP{_RESET}\r\n") + skip_count += 1 + continue + + # No such key on this keyboard: lone 'n' or 'N'. + if raw in (b"n", b"N") and sink and sink[0].key == "n": + sys.stdout.write(f"{_DIM}N/A{_RESET} {_DIM}(no such key){_RESET}\r\n") + nokey_count += 1 + continue + + keys_seen = [kp.key for kp in sink] + actual = ( + keys_seen[0].value + if isinstance(keys_seen[0], Keys) + else str(keys_seen[0]) + ) if keys_seen else "" + + raw_pretty = _format_raw(raw) + + if keys_seen and keys_seen[0] == probe.expected: + sys.stdout.write( + f"{_GREEN}{_BOLD}PASS{_RESET} {_DIM}{raw_pretty}{_RESET}\r\n" + ) + pass_count += 1 + else: + sys.stdout.write( + f"{_RED}{_BOLD}FAIL{_RESET} " + f"got {_RED}{actual!r}{_RESET} " + f"{_DIM}raw={raw_pretty}{_RESET}\r\n" + ) + failures.append((probe, actual, raw_pretty)) + fail_count += 1 + + sys.stdout.flush() + + except KeyboardInterrupt: + sys.stdout.write(f"\r\n{_DIM}(aborted){_RESET}\r\n") + finally: + if kitty: + sys.stdout.write(_KITTY_POP) + sys.stdout.flush() + + # Summary + print() + parts = [] + if pass_count: + parts.append(f"{_GREEN}{pass_count} pass{_RESET}") + if fail_count: + parts.append(f"{_RED}{fail_count} fail{_RESET}") + if skip_count: + parts.append(f"{_YELLOW}{skip_count} skip{_RESET}") + if nokey_count: + parts.append(f"{_DIM}{nokey_count} n/a{_RESET}") + print(f"{_BOLD}Summary ({mode_label}{_BOLD}):{_RESET} {', '.join(parts)}") + + if failures: + print(f"\n{_RED}{_BOLD}Mismatches:{_RESET}") + for probe, actual, raw_pretty in failures: + print( + f" {_CYAN}{probe.prompt:<{_PROMPT_WIDTH}}{_RESET} " + f"expected {_GREEN}{probe.expected.value!r:<{_EXPECT_WIDTH}}{_RESET} " + f"got {_RED}{actual!r}{_RESET} " + f"{_DIM}raw={raw_pretty}{_RESET}" + ) + + +def main() -> None: + ap = argparse.ArgumentParser(description="Kitty keyboard protocol probe") + group = ap.add_mutually_exclusive_group() + group.add_argument( + "--kitty", action="store_true", default=True, + help="enable Kitty protocol (default)", + ) + group.add_argument( + "--no-kitty", action="store_true", + help="run without enabling Kitty protocol (legacy mode)", + ) + group.add_argument( + "--both", action="store_true", + help="run twice: first without Kitty, then with Kitty, to compare", + ) + args = ap.parse_args() + + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + + try: + tty.setcbreak(fd) + + if args.both: + _run_probes(fd, kitty=False) + sys.stdout.write( + f"\r\n{_DIM}{'─' * 60}{_RESET}\r\n" + ) + sys.stdout.flush() + _run_probes(fd, kitty=True) + elif args.no_kitty: + _run_probes(fd, kitty=False) + else: + _run_probes(fd, kitty=True) + + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/modified-enter.py b/examples/prompts/modified-enter.py new file mode 100644 index 000000000..c1c11ddeb --- /dev/null +++ b/examples/prompts/modified-enter.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +""" +Demo for modified-Enter key bindings (Ctrl-Enter, Ctrl-Shift-Enter, +Shift-Enter). + +prompt_toolkit pushes the Kitty keyboard protocol (flag 1) on startup, +so terminals that implement it (kitty, ghostty, wezterm, foot, +Alacritty, recent iTerm2 with CSI u reporting enabled, …) can +distinguish these from plain Enter. + +Run this and try pressing each combination. Plain Enter still submits +(the `c-m` / `enter` binding shipped by ``PromptSession`` defaults +fires as usual — our custom bindings below don't override it). +Terminals that don't support the protocol will just submit on any Enter +variant — that's the expected fallback. +""" + +from prompt_toolkit import prompt +from prompt_toolkit.application import run_in_terminal +from prompt_toolkit.key_binding import KeyBindings + + +def main(): + bindings = KeyBindings() + + def _announce(label): + def _print(): + print(f"[{label}] pressed") + + run_in_terminal(_print) + + @bindings.add("c-enter") + def _(event): + _announce("Ctrl-Enter") + + @bindings.add("s-enter") + def _(event): + _announce("Shift-Enter") + + @bindings.add("c-s-enter") + def _(event): + _announce("Ctrl-Shift-Enter") + + print("Try Ctrl-Enter, Shift-Enter, Ctrl-Shift-Enter. Plain Enter submits.") + text = prompt("> ", key_bindings=bindings) + print(f"You said: {text!r}") + + +if __name__ == "__main__": + main() diff --git a/src/prompt_toolkit/input/ansi_escape_sequences.py b/src/prompt_toolkit/input/ansi_escape_sequences.py index 1fba418b7..1a5a6df05 100644 --- a/src/prompt_toolkit/input/ansi_escape_sequences.py +++ b/src/prompt_toolkit/input/ansi_escape_sequences.py @@ -122,10 +122,17 @@ "\x1b[23;2~": Keys.F23, "\x1b[24;2~": Keys.F24, # -- - # CSI 27 disambiguated modified "other" keys (xterm) + # xterm `modifyOtherKeys` CSI 27 disambiguated modified Enter. + # prompt_toolkit does not implement xterm's modifyOtherKeys protocol + # itself # Ref: https://invisible-island.net/xterm/modified-keys.html - # These are currently unsupported, so just re-map some common ones to the - # unmodified versions + # (we disambiguate via the Kitty keyboard protocol instead), + # but a user whose terminal or tmux has it enabled independently + # will still send these sequences. Map them all to plain Enter so + # modifier+Enter at least submits the form, rather than doing + # nothing at all. Users who want a distinct Ctrl-Enter / Shift-Enter + # binding need a Kitty-capable terminal, where the `CSI u` decoder + # produces the richer Keys. "\x1b[27;2;13~": Keys.ControlM, # Shift + Enter "\x1b[27;5;13~": Keys.ControlM, # Ctrl + Enter "\x1b[27;6;13~": Keys.ControlM, # Ctrl + Shift + Enter diff --git a/src/prompt_toolkit/input/kitty_keyboard.py b/src/prompt_toolkit/input/kitty_keyboard.py new file mode 100644 index 000000000..5529d5ef3 --- /dev/null +++ b/src/prompt_toolkit/input/kitty_keyboard.py @@ -0,0 +1,254 @@ +""" +Decoder for the Kitty keyboard protocol (input side). + +Only flag 1 (disambiguate) is currently handled; higher flags (event +types, alternate keys, report-all-as-escape, associated text) are +ignored when volunteered by the terminal. + +Ref: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + +Wire format: + + CSI [:][;[:][;]] u + +- `keycode` Unicode codepoint for printable keys, or one of the + Kitty "functional-key" codes below (Enter=13, Escape=27, + Arrow Up=57352, F1=57364, …). +- `modifiers` Wire value = 1 + bitmask over (Shift=1, Alt=2, Ctrl=4, + Super=8, Hyper=16, Meta=32, CapsLock=64, NumLock=128). + Omitted when no modifiers are active. +- Alt-keys, event-type, and associated text are reported only under + higher protocol flags that require progressive enhancement. + +The decoder returns something the existing `Vt100Parser._call_handler` +already knows how to dispatch: a single `Keys` enum value, a tuple whose +elements are `Keys` or `str`, or `None` when we can't decode (the parser +then falls through to its generic handling). +""" + +from __future__ import annotations + +import re + +from ..keys import Keys + +# Full CSI u match. Parameter bytes are digits, semicolons, colons. +KITTY_CSI_U_RE = re.compile(r"^\x1b\[[\d:;]+u\Z") + +# Prefix of a potential CSI u sequence — used by the parser's +# "is-this-a-prefix-of-something-longer?" cache so we keep reading bytes. +KITTY_CSI_U_PREFIX_RE = re.compile(r"^\x1b\[[\d:;]*\Z") + +# Response to the `CSI ? u` query — `CSI ? u`. Only sent by +# terminals that actually implement the protocol. The flags value is +# discarded; the arrival of any response is the capability signal. +KITTY_QUERY_RESPONSE_RE = re.compile(r"^\x1b\[\?\d*u\Z") + +# Matching prefix regex so the parser keeps reading bytes. Tight enough +# to require the `?`, otherwise it overlaps with `_cpr_response_prefix_re`. +KITTY_QUERY_RESPONSE_PREFIX_RE = re.compile(r"^\x1b\[\?\d*\Z") + + +# Modifier bit flags. The on-wire value is `1 + sum(bits)`. +_SHIFT = 1 +_ALT = 2 +_CTRL = 4 +# Super / Hyper / Meta (bits 8 / 16 / 32) — ignored for now; routing +# them to `Keys` values requires progressive enhancement flags beyond +# flag 1 and corresponding new enum entries. +_CAPSLOCK = 64 +_NUMLOCK = 128 +_IGNORED_MODS = _CAPSLOCK | _NUMLOCK + + +# Functional-key codes from the Kitty spec that can reach this decoder +# under flag 1 ("disambiguate escape codes"), mapped to the nearest +# existing prompt_toolkit `Keys` value. Everything else listed in the +# spec's functional-key table keeps its legacy encoding under flag 1 +# and travels through `ANSI_SEQUENCES` instead: +# +# - Arrow keys and the navigation block (Home / End / PageUp / +# PageDown / Insert / Delete): legacy `CSI ` / `CSI ~`. +# - F1-F12: legacy `SS3 ` (F1-F4) / `CSI ~` (F5-F12). +# - CapsLock / ScrollLock / NumLock / PrintScreen / Pause / Menu: no +# legacy encoding and no matching `Keys` enum, so out of scope here. +# +# What remains are the four keys whose single-byte legacy encoding +# collides with a Ctrl+letter (Enter=\r=C-m, Tab=\t=C-i, Esc=\x1b=C-[, +# Backspace=\x7f/C-h) — those are the only gestures flag 1 actually +# re-encodes as `CSI u`. +_FUNCTIONAL: dict[int, Keys] = { + 13: Keys.ControlM, # Enter + 9: Keys.ControlI, # Tab + 27: Keys.Escape, + 127: Keys.ControlH, # Backspace +} + + +_DecodeResult = Keys | str | tuple[Keys | str, ...] | None + + +def decode_csi_u(sequence: str) -> _DecodeResult: + """ + Decode a complete CSI u sequence into a `Keys` value (or a tuple of + `Keys` / `str`) that `Vt100Parser._call_handler` can dispatch. + + Returns `None` for sequences we don't recognize — the caller treats + that as "no match" and falls through to its usual handling. + """ + if not KITTY_CSI_U_RE.match(sequence): + return None + + parts = sequence[2:-1].split(";") + + keycode_str = parts[0].split(":")[0] + try: + keycode = int(keycode_str) + except ValueError: + return None + + mods_mask = 0 + if len(parts) >= 2: + mod_subparts = parts[1].split(":") + try: + mod_wire = int(mod_subparts[0]) + except ValueError: + return None + # The spec guarantees mod_wire >= 1 (it encodes `1 + bitmask`). + # A conforming terminal never sends 0 or a negative value; if one + # does, treat it as malformed. Without this guard, `0 - 1 = -1` + # turns Python's arbitrary-precision integer into a mask with + # every bit set, producing phantom Ctrl/Alt/Shift on unmodified + # keys. + if mod_wire < 1: + return None + mods_mask = mod_wire - 1 + # Event type sub-field: 1=press (default), 2=repeat, 3=release. + # Under flag 1 only press events should ever be sent, but a + # non-conformant terminal (or one upgraded to flag 2 by another + # process sharing the tty — e.g. tmux) may volunteer them. If we + # decoded them as press events, a c-enter binding that submits + # the form would fire twice (once on press, once on release). + # An empty sub-field (`13;5:u`) is technically malformed — the + # spec has no "omitted sub-parameter means default" rule — but + # treating it as the default (press) is strictly more forgiving + # than dropping the keypress on the floor, and matches how the + # modifier field itself defaults when the whole field is absent. + if len(mod_subparts) >= 2 and mod_subparts[1] != "": + try: + event_type = int(mod_subparts[1]) + except ValueError: + return None + if event_type != 1: + return None + + mods_mask &= ~_IGNORED_MODS # strip CapsLock / NumLock + + base = _decode_keycode(keycode, mods_mask) + if base is None: + return None + + # Alt prefix: emit (Escape, base_key), matching prompt_toolkit's + # long-standing convention for meta-prefixed keys. + if mods_mask & _ALT: + if isinstance(base, tuple): + return (Keys.Escape,) + base + return (Keys.Escape, base) + return base + + +def _decode_keycode(keycode: int, mods: int) -> _DecodeResult: + """ + Resolve a (keycode, modifier-mask) pair to the best `Keys` value, + raw character, or tuple thereof. + + `keycode` is either a Kitty functional-key code (see `_FUNCTIONAL`) + or a Unicode codepoint for a printable key. `mods` is the decoded + bitmask (already with CapsLock / NumLock stripped and the wire-level + `+1` removed). The Alt bit is left in place: the caller uses it to + decide whether to wrap the result in `(Keys.Escape, …)`, so the + returned value here is the base key *without* the meta prefix. + + Returns `None` when the keycode isn't one we recognize; the caller + treats that as "no match". + """ + ctrl = bool(mods & _CTRL) + shift = bool(mods & _SHIFT) + + if keycode in _FUNCTIONAL: + return _apply_modifiers(_FUNCTIONAL[keycode], ctrl, shift) + + # Printable Unicode keys with meaningful modifiers. + if 32 <= keycode <= 0x10FFFF: + char = chr(keycode) + + if "a" <= char <= "z": + if ctrl: + # No distinct `Keys.ControlShiftA` etc.; the Shift bit is + # silently folded into `ControlX`. + return Keys["Control" + char.upper()] + # Plain letter (or Alt-prefix letter — the meta wrap is + # applied one level up in decode_csi_u). + return char + + if "0" <= char <= "9": + if ctrl and shift: + return Keys["ControlShift" + char] + if ctrl: + return Keys["Control" + char] + return char + + # Other printable — just pass through the character. + return char + + return None + + +def _apply_modifiers(base: Keys, ctrl: bool, shift: bool) -> Keys: + """ + Promote a plain functional `Keys` value to its richer Ctrl / Shift / + Ctrl-Shift variant when prompt_toolkit has an enum for it. + + `base` is one of the four unmodified functional keys from + `_FUNCTIONAL` — Enter, Tab, Escape, Backspace. `ctrl` and `shift` + are booleans decoded from the modifier mask; the Alt bit is handled + one level up in `decode_csi_u`, so it is intentionally not a + parameter here. + """ + if base is Keys.ControlM: # Enter + if ctrl and shift: + return Keys.ControlShiftEnter + if ctrl: + return Keys.ControlEnter + if shift: + return Keys.ShiftEnter + return Keys.ControlM + + if base is Keys.ControlI: # Tab + if ctrl and shift: + return Keys.ControlShiftTab + if ctrl: + return Keys.ControlTab + if shift: + return Keys.BackTab + return Keys.ControlI + + if base is Keys.Escape: + if ctrl and shift: + return Keys.ControlShiftEscape + if ctrl: + return Keys.ControlEscape + if shift: + return Keys.ShiftEscape + return Keys.Escape + + if base is Keys.ControlH: # Backspace (keycode 127) + if ctrl and shift: + return Keys.ControlShiftBackspace + if ctrl: + return Keys.ControlBackspace + if shift: + return Keys.ShiftBackspace + return Keys.ControlH + + return base diff --git a/src/prompt_toolkit/input/vt100_parser.py b/src/prompt_toolkit/input/vt100_parser.py index 34ea11057..db81c2563 100644 --- a/src/prompt_toolkit/input/vt100_parser.py +++ b/src/prompt_toolkit/input/vt100_parser.py @@ -10,6 +10,13 @@ from ..key_binding.key_processor import KeyPress from ..keys import Keys from .ansi_escape_sequences import ANSI_SEQUENCES +from .kitty_keyboard import ( + KITTY_CSI_U_PREFIX_RE, + KITTY_CSI_U_RE, + KITTY_QUERY_RESPONSE_PREFIX_RE, + KITTY_QUERY_RESPONSE_RE, + decode_csi_u, +) __all__ = [ "Vt100Parser", @@ -46,10 +53,14 @@ class _IsPrefixOfLongerMatchCache(dict[str, bool]): """ def __missing__(self, prefix: str) -> bool: - # (hard coded) If this could be a prefix of a CPR response, return - # True. - if _cpr_response_prefix_re.match(prefix) or _mouse_event_prefix_re.match( - prefix + # (hard coded) If this could be a prefix of a CPR response, a + # mouse event, or a Kitty-keyboard CSI u sequence, return True so + # the parser keeps reading bytes. + if ( + _cpr_response_prefix_re.match(prefix) + or _mouse_event_prefix_re.match(prefix) + or KITTY_CSI_U_PREFIX_RE.match(prefix) + or KITTY_QUERY_RESPONSE_PREFIX_RE.match(prefix) ): result = True else: @@ -101,9 +112,16 @@ def _start_parser(self) -> None: self._input_parser = self._input_parser_generator() self._input_parser.send(None) # type: ignore - def _get_match(self, prefix: str) -> None | Keys | tuple[Keys, ...]: + def _get_match( + self, prefix: str + ) -> None | Keys | str | tuple[Keys | str, ...]: """ Return the key (or keys) that maps to this prefix. + + A single `Keys` is the common case; a `str` appears when we pass + through a raw character (e.g. Alt+letter decodes to the letter, + with `Keys.Escape` prepended by the caller); a tuple is used for + meta-prefixed or multi-key sequences. """ # (hard coded) If we match a CPR response, return Keys.CPRResponse. # (This one doesn't fit in the ANSI_SEQUENCES, because it contains @@ -114,11 +132,27 @@ def _get_match(self, prefix: str) -> None | Keys | tuple[Keys, ...]: elif _mouse_event_re.match(prefix): return Keys.Vt100MouseEvent + elif KITTY_QUERY_RESPONSE_RE.match(prefix): + return Keys.KittyKeyboardResponse + # Otherwise, use the mappings. try: return ANSI_SEQUENCES[prefix] except KeyError: - return None + pass + + # Kitty keyboard protocol CSI u. Checked after ANSI_SEQUENCES so + # pre-existing static entries keep winning over the generic + # decoder — notably mintty's `CSI 1 ; 5 ` encoding for + # Ctrl+digit (`\x1b[1;5u` = Ctrl-5), documented at + # https://github.com/mintty/mintty/wiki/Keycodes and mapped in + # `ansi_escape_sequences.py`. + if KITTY_CSI_U_RE.match(prefix): + decoded = decode_csi_u(prefix) + if decoded is not None: + return decoded + + return None def _input_parser_generator(self) -> Generator[None, str | _Flush, None]: """ @@ -171,7 +205,7 @@ def _input_parser_generator(self) -> Generator[None, str | _Flush, None]: prefix = prefix[1:] def _call_handler( - self, key: str | Keys | tuple[Keys, ...], insert_text: str + self, key: str | Keys | tuple[Keys | str, ...], insert_text: str ) -> None: """ Callback to handler. diff --git a/src/prompt_toolkit/key_binding/bindings/basic.py b/src/prompt_toolkit/key_binding/bindings/basic.py index ad18df981..2e80598a8 100644 --- a/src/prompt_toolkit/key_binding/bindings/basic.py +++ b/src/prompt_toolkit/key_binding/bindings/basic.py @@ -132,6 +132,19 @@ def load_basic_bindings() -> KeyBindings: @handle("insert") @handle("s-insert") @handle("c-insert") + # Kitty keyboard protocol keys. When the terminal speaks the protocol, + # modified Enter/Tab/Escape arrive as distinct `Keys` values rather than + # their legacy-byte-equivalent. Without swallowing them here, an unbound + # c-enter / s-enter / ... would fall through to the `Keys.Any` handler + # below and be inserted verbatim as `"c-enter"` into the buffer. + @handle("c-enter") + @handle("s-enter") + @handle("c-s-enter") + @handle("c-tab") + @handle("c-s-tab") + @handle("c-escape") + @handle("s-escape") + @handle("c-s-escape") @handle("") @handle(Keys.Ignore) def _ignore(event: E) -> None: diff --git a/src/prompt_toolkit/key_binding/bindings/kitty_keyboard.py b/src/prompt_toolkit/key_binding/bindings/kitty_keyboard.py new file mode 100644 index 000000000..4865dea9f --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/kitty_keyboard.py @@ -0,0 +1,36 @@ +""" +Binding that consumes the Kitty keyboard protocol `CSI ? u` +response and reports the result to the Renderer's capability state. +""" + +from __future__ import annotations + +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.keys import Keys + +from ..key_bindings import KeyBindings + +__all__ = ["load_kitty_keyboard_bindings"] + +E = KeyPressEvent + + +def load_kitty_keyboard_bindings() -> KeyBindings: + key_bindings = KeyBindings() + + # `eager=True` so this binding pre-empts any catch-all `Keys.Any` + # binding (e.g. `self_insert` from basic bindings). Without it, a + # custom KeyBindings setup that omits load_kitty_keyboard_bindings + # but keeps a Keys.Any handler would inject the raw `\x1b[?1u` + # response into the buffer. + @key_bindings.add( + Keys.KittyKeyboardResponse, eager=True, save_before=lambda e: False + ) + def _(event: E) -> None: + """ + A `CSI ? u` response came back — the terminal speaks the + Kitty keyboard protocol. Flip the renderer's capability flag. + """ + event.app.renderer.report_kitty_keyboard_response() + + return key_bindings diff --git a/src/prompt_toolkit/key_binding/defaults.py b/src/prompt_toolkit/key_binding/defaults.py index 6c2657116..fe66673e5 100644 --- a/src/prompt_toolkit/key_binding/defaults.py +++ b/src/prompt_toolkit/key_binding/defaults.py @@ -15,6 +15,9 @@ load_emacs_search_bindings, load_emacs_shift_selection_bindings, ) +from prompt_toolkit.key_binding.bindings.kitty_keyboard import ( + load_kitty_keyboard_bindings, +) from prompt_toolkit.key_binding.bindings.mouse import load_mouse_bindings from prompt_toolkit.key_binding.bindings.vi import ( load_vi_bindings, @@ -59,5 +62,6 @@ def load_key_bindings() -> KeyBindingsBase: # Active, even when no buffer has been focused. load_mouse_bindings(), load_cpr_bindings(), + load_kitty_keyboard_bindings(), ] ) diff --git a/src/prompt_toolkit/keys.py b/src/prompt_toolkit/keys.py index ee52aee86..3165b1898 100644 --- a/src/prompt_toolkit/keys.py +++ b/src/prompt_toolkit/keys.py @@ -122,6 +122,31 @@ class Keys(str, Enum): ControlShiftPageDown = "c-s-pagedown" BackTab = "s-tab" # shift + tab + ControlTab = "c-tab" + ControlShiftTab = "c-s-tab" + + ControlEscape = "c-escape" + ControlShiftEscape = "c-s-escape" + + # Modified Enter keys. Only distinguishable from plain Enter under + # the Kitty keyboard protocol (which prompt_toolkit pushes on + # startup). On terminals that don't implement Kitty, all three fold + # back to plain Enter so a bound shortcut there doesn't "do nothing" + # — it at least submits the form. + ControlEnter = "c-enter" + ControlShiftEnter = "c-s-enter" + ShiftEnter = "s-enter" + + # Modified Backspace keys. Only distinguishable from plain Backspace + # under the Kitty keyboard protocol. On terminals that don't + # implement Kitty, these don't fire at all — a bound shortcut is + # silently inert there. Unlike modified Enter, there is no safe + # legacy fallback: Ctrl-Backspace on most legacy terminals is + # indistinguishable from plain Backspace or from Ctrl-H, so we + # don't fold it down to either. + ControlBackspace = "c-backspace" + ShiftBackspace = "s-backspace" + ControlShiftBackspace = "c-s-backspace" F1 = "f1" F2 = "f2" @@ -181,6 +206,7 @@ class Keys(str, Enum): ScrollDown = "" CPRResponse = "" + KittyKeyboardResponse = "" Vt100MouseEvent = "" WindowsMouseEvent = "" BracketedPaste = "" diff --git a/src/prompt_toolkit/output/kitty_keyboard.py b/src/prompt_toolkit/output/kitty_keyboard.py new file mode 100644 index 000000000..328a8fb32 --- /dev/null +++ b/src/prompt_toolkit/output/kitty_keyboard.py @@ -0,0 +1,114 @@ +""" +Helpers for the Kitty keyboard protocol (output side). + +Ref: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + +The protocol exposes progressive enhancement flags that a program pushes +onto a terminal-maintained stack, then pops on exit: + + CSI > u — push onto the stack + CSI < u — pop one entry + CSI ? u — query current flags (response: CSI ? u) + +(`CSI = ; u` also exists — it *modifies* the top of the +stack in place rather than pushing. We want a clean restore on exit, so +we push.) + +Flag bits (from the spec): + + 0b1 (1) Disambiguate escape codes + 0b10 (2) Report event types (press/release/repeat) + 0b100 (4) Report alternate keys + 0b1000 (8) Report all keys as escape codes + 0b10000 (16) Report associated text + +Currently uses only `disambiguate` (flag 1). That's enough to get Ctrl-Enter +and friends while leaving plain printable / Shifted keys reporting as +legacy bytes — so no parser additions are needed for input the terminal +doesn't rewrite. + +The terminal itself maintains the flags stack, so nested `with` blocks +compose correctly at the wire level. We still track a local depth +counter per `Output` instance (and the currently-active flag value) so +we can raise on a nested call that would change the flags, rather than +silently corrupt the terminal's stack. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .base import Output + + +__all__ = [ + "KITTY_FLAG_DISAMBIGUATE", + "KITTY_FLAG_REPORT_EVENT_TYPES", + "KITTY_FLAG_REPORT_ALTERNATE_KEYS", + "KITTY_FLAG_REPORT_ALL_KEYS", + "KITTY_FLAG_REPORT_TEXT", + "KITTY_QUERY", + "kitty_keyboard_protocol", +] + + +KITTY_FLAG_DISAMBIGUATE = 0b00001 +KITTY_FLAG_REPORT_EVENT_TYPES = 0b00010 +KITTY_FLAG_REPORT_ALTERNATE_KEYS = 0b00100 +KITTY_FLAG_REPORT_ALL_KEYS = 0b01000 +KITTY_FLAG_REPORT_TEXT = 0b10000 + + +# Sent to detect protocol support; terminal answers `CSI ? u` if +# supported, silence if not. +KITTY_QUERY = "\x1b[?u" + + +@contextmanager +def kitty_keyboard_protocol( + output: Output, flags: int = KITTY_FLAG_DISAMBIGUATE +) -> Iterator[None]: + """ + Push the given Kitty keyboard flags on entry, pop on exit. + + Nested blocks on the same `Output` share the push/pop pair — only + the outermost enter writes the enable sequence and only the + outermost exit writes the pop. Each enter/exit calls + ``output.flush()`` so a caller can rely on the sequence having + actually reached the terminal before waiting for input (e.g. for + the `CSI ? u` query response). + + A nested call must use the same `flags` as the outer one. Mixing + flags would leave the terminal in a state the inner caller didn't + ask for, and the inner pop would cancel the outer push; we raise + `ValueError` rather than silently corrupt the stack. + """ + # Lazily install counter + flags state on the Output. We do it here + # rather than on Output.__init__ so this module stays fully + # self-contained and the rest of the codebase has no mention of it. + depth: int = getattr(output, "_kitty_keyboard_depth", 0) + if depth == 0: + # Push: `CSI > u`. + output.write_raw(f"\x1b[>{flags}u") + output.flush() + output._kitty_keyboard_flags = flags # type: ignore[attr-defined] + else: + active = getattr(output, "_kitty_keyboard_flags", flags) + if active != flags: + raise ValueError( + f"kitty_keyboard_protocol already entered with flags=" + f"{active!r}; nested call requested flags={flags!r}. " + f"Mixing flags across nested contexts is unsupported." + ) + output._kitty_keyboard_depth = depth + 1 # type: ignore[attr-defined] + try: + yield + finally: + output._kitty_keyboard_depth -= 1 # type: ignore[attr-defined] + if output._kitty_keyboard_depth == 0: # type: ignore[attr-defined] + # Pop one entry off the terminal's flag stack. + output.write_raw("\x1b[ N self.output.disable_bracketed_paste() self._bracketed_paste_enabled = False + # Pop the kitty protocol push if one is active. The ExitStack + # call is a no-op when the stack is empty — we don't guard here. + # `kitty_support` is deliberately NOT reset: tmux attach/detach + # (the one scenario where the underlying terminal could actually + # change capability) is invisible to the inner process — no + # signal fires, no `reset()` gets called at that boundary — so + # re-querying on every reset just burns a round-trip and opens a + # window where a confirmed-supporting terminal reads as UNKNOWN. + # Same pattern as `cpr_support`, which is also sticky. + self._kitty_keyboard_stack.close() + self._kitty_keyboard_pushed = False + self.output.reset_cursor_shape() self.output.show_cursor() @@ -609,6 +641,44 @@ def render( self.output.enable_bracketed_paste() self._bracketed_paste_enabled = True + # Enable Kitty keyboard disambiguate mode and (only when we don't + # already know the answer) probe for support. Terminals that + # don't implement the protocol silently ignore both the query + # and the push, leaving `kitty_support` at UNKNOWN. + if not self._kitty_keyboard_pushed: + # Query first, then push. The response to `CSI ? u` reports + # the currently-active flags, so emitting it before the push + # keeps the response reflective of the terminal's pre-push + # state (a future version that reads the response's flags to + # decide whether to push at all needs this ordering). Skip + # the query when we already know the answer — re-asking on + # every render burns a round-trip and has no value: the + # underlying terminal doesn't change capability under us. + if self.kitty_support is KittySupport.UNKNOWN: + self.output.write_raw(KITTY_QUERY) + # ExitStack.enter_context takes care of the tricky part: if + # __enter__ raises, nothing is registered with the stack, so + # the later `.close()` from reset() won't try to pop a CM + # that was never fully pushed (which would decrement the + # Output's depth counter below zero and permanently corrupt + # it for this Output's remaining lifetime). + # + # Mark as pushed *before* entering so a raise from + # `kitty_keyboard_protocol` (e.g. nested flags mismatch) does + # not put us in an infinite retry loop — the guard at line + # 648 would otherwise keep re-firing the same exception on + # every render. The stack stays empty in that case, so the + # matching `close()` in `reset()` remains a no-op. + self._kitty_keyboard_pushed = True + self._kitty_keyboard_stack.enter_context( + kitty_keyboard_protocol(self.output) + ) + # Flush so the query and push reach the terminal before any + # input arrives — otherwise the response can race the screen + # render's flush further down and a fast user keystroke can + # be processed while `kitty_support` still reads UNKNOWN. + self.output.flush() + # Reset cursor key mode. if not self._cursor_key_mode_reset: self.output.reset_cursor_key_mode() @@ -732,6 +802,14 @@ def render( if is_done: self.reset() + def report_kitty_keyboard_response(self) -> None: + """ + Called from the input side when a `CSI ? u` response is + decoded. The arrival of any response is enough to confirm the + protocol is implemented. + """ + self.kitty_support = KittySupport.SUPPORTED + def erase(self, leave_alternate_screen: bool = True) -> None: """ Hide all output and put the cursor back at the first line. This is for diff --git a/tests/test_kitty_keyboard.py b/tests/test_kitty_keyboard.py new file mode 100644 index 000000000..deff1cc24 --- /dev/null +++ b/tests/test_kitty_keyboard.py @@ -0,0 +1,324 @@ +"""Tests for Kitty keyboard protocol input decoding and output helpers.""" + +from __future__ import annotations + +import io + +import pytest + +from prompt_toolkit.input.kitty_keyboard import decode_csi_u +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.keys import Keys +from prompt_toolkit.output import DummyOutput +from prompt_toolkit.output.kitty_keyboard import ( + KITTY_FLAG_DISAMBIGUATE, + KITTY_FLAG_REPORT_EVENT_TYPES, + kitty_keyboard_protocol, +) +from prompt_toolkit.output.vt100 import Vt100_Output +from prompt_toolkit.renderer import KittySupport, Renderer +from prompt_toolkit.styles import Style + +# ---------- decoder (unit-level) ---------- + + +@pytest.mark.parametrize( + "seq,expected", + [ + # Functional keys with Ctrl / Shift / Ctrl-Shift modifiers. + ("\x1b[13;5u", Keys.ControlEnter), + ("\x1b[13;6u", Keys.ControlShiftEnter), + ("\x1b[13;2u", Keys.ShiftEnter), # Kitty escapes Shift-Enter reliably + ("\x1b[13u", Keys.ControlM), # plain Enter + ("\x1b[9;2u", Keys.BackTab), # Shift-Tab + ("\x1b[9;5u", Keys.ControlTab), + ("\x1b[9;6u", Keys.ControlShiftTab), + ("\x1b[27u", Keys.Escape), + ("\x1b[27;2u", Keys.ShiftEscape), + ("\x1b[27;5u", Keys.ControlEscape), + ("\x1b[27;6u", Keys.ControlShiftEscape), + ("\x1b[127u", Keys.ControlH), # plain Backspace + ("\x1b[127;2u", Keys.ShiftBackspace), + ("\x1b[127;5u", Keys.ControlBackspace), + ("\x1b[127;6u", Keys.ControlShiftBackspace), + # NOTE: arrow keys, navigation block (Insert / Delete / Home / + # End / PageUp / PageDown), and F1–F12 intentionally do *not* + # appear here. Under flag 1 (disambiguate) — the only flag + # prompt_toolkit pushes — the Kitty spec keeps these keys in + # their legacy `CSI ~` / `CSI ` / `SS3 ` + # encoding even when modified. They travel through + # `ANSI_SEQUENCES` (see `test_parser_routes_legacy_modified_arrow` + # below), not through the CSI u decoder. Their functional + # codepoints (57345–57375) would only appear under flag 8 + # (report-all-as-escape); adding handling for them belongs + # with any flag-8 work, not here. + # Ctrl + letter. + ("\x1b[97;5u", Keys.ControlA), + ("\x1b[111;5u", Keys.ControlO), + ("\x1b[122;5u", Keys.ControlZ), + # Ctrl + digit. + ("\x1b[49;5u", Keys.Control1), + # Ctrl + Shift + digit. + ("\x1b[49;6u", Keys.ControlShift1), + ], +) +def test_decode_simple(seq, expected): + assert decode_csi_u(seq) == expected + + +def test_decode_strips_alternate_key_segment(): + # Format: `CSI [:] ; u`. We ignore + # the alternate-key segment; Ctrl-a with alt-key=A should still + # resolve to Keys.ControlA. + assert decode_csi_u("\x1b[97:65;5u") == Keys.ControlA + + +def test_decode_rejects_keycode_above_unicode_max(): + assert decode_csi_u(f"\x1b[{0x110000};1u") is None + + +def test_decode_alt_prefix_for_letter(): + # Alt-a: modifiers = 1 + 2 = 3 (Alt). Emitted as (Escape, 'a') tuple + # to match prompt_toolkit's meta-prefix convention. + assert decode_csi_u("\x1b[97;3u") == (Keys.Escape, "a") + + +def test_decode_alt_ctrl_letter(): + # Alt-Ctrl-a: modifiers = 1 + 2 + 4 = 7. Expect (Escape, ControlA). + assert decode_csi_u("\x1b[97;7u") == (Keys.Escape, Keys.ControlA) + + +def test_decode_ignores_capslock_and_numlock(): + # Ctrl-Enter with CapsLock and NumLock both on: modifiers = + # 1 + 4 + 64 + 128 = 197. Should still resolve to Ctrl-Enter. + assert decode_csi_u("\x1b[13;197u") == Keys.ControlEnter + + +def test_decode_rejects_non_csi_u(): + assert decode_csi_u("\x1b[A") is None + assert decode_csi_u("") is None + assert decode_csi_u("hello") is None + + +def test_decode_rejects_malformed_modifier_wire_value(): + # The spec guarantees the modifier wire value is 1 + bitmask, so + # anything below 1 is non-conforming. Without guarding, `int("0") - 1 + # == -1` becomes a Python infinite-precision integer mask with every + # bit set and `\x1b[13;0u` decodes as (Escape, ControlShiftEnter). + assert decode_csi_u("\x1b[13;0u") is None + assert decode_csi_u("\x1b[97;0u") is None + + +def test_decode_rejects_non_numeric_modifier(): + assert decode_csi_u("\x1b[13;au") is None + + +def test_decode_tolerates_missing_modifier(): + # No modifier segment at all: plain Enter maps back to ControlM. + assert decode_csi_u("\x1b[13u") == Keys.ControlM + + +@pytest.mark.parametrize("event_type", [2, 3]) +def test_decode_drops_release_and_repeat_events(event_type): + # Event-type 2 (repeat) and 3 (release) are filtered. Without this, + # a c-enter binding that submits the form would fire twice on a + # single keypress on a terminal that volunteers release events + # (which can happen if another process on the tty — tmux, screen + # — has pushed flag 2 underneath us). + assert decode_csi_u(f"\x1b[13;5:{event_type}u") is None + # Press is event-type 1, with or without the explicit ":1" suffix. + assert decode_csi_u("\x1b[13;5:1u") == Keys.ControlEnter + assert decode_csi_u("\x1b[13;5u") == Keys.ControlEnter + + +def test_decode_rejects_non_numeric_event_type(): + assert decode_csi_u("\x1b[13;5:xu") is None + + +def test_decode_tolerates_empty_event_type_subfield(): + # `CSI 13;5:u` — modifier field present but event-type sub-parameter + # empty. Technically malformed (the spec doesn't define an implicit + # default for a present-but-empty sub-parameter), but dropping the + # keypress on the floor is strictly worse than treating it as the + # default `press`. Regression guard against `int("")` raising + # ValueError and silently eating the key. + assert decode_csi_u("\x1b[13;5:u") == Keys.ControlEnter + assert decode_csi_u("\x1b[97;5:u") == Keys.ControlA + + +# ---------- parser integration ---------- + + +class _Collector: + def __init__(self): + self.presses = [] + + def __call__(self, kp): + self.presses.append(kp) + + +def test_parser_routes_kitty_sequences(): + c = _Collector() + p = Vt100Parser(c) + p.feed("\x1b[13;5u") # Ctrl-Enter + p.feed("\x1b[111;5u") # Ctrl-O + p.feed("\x0d") # legacy plain Enter should still work + assert [kp.key for kp in c.presses] == [ + Keys.ControlEnter, + Keys.ControlO, + Keys.ControlM, + ] + + +def test_parser_routes_alt_letter_as_escape_prefix_tuple(): + # Alt+a (modifier = 1 + 2 = 3). decode_csi_u returns the tuple + # `(Keys.Escape, "a")`, and `_call_handler` should split that into + # two KeyPress events — Escape with the full raw sequence in `data`, + # and the literal "a" with empty `data` so we don't double-insert. + c = _Collector() + p = Vt100Parser(c) + raw = "\x1b[97;3u" + p.feed(raw) + assert [kp.key for kp in c.presses] == [Keys.Escape, "a"] + assert c.presses[0].data == raw + assert c.presses[1].data == "" + + +@pytest.mark.parametrize( + "raw", + [ + "\x1b[27;2;13~", # Shift-Enter via xterm modifyOtherKeys + "\x1b[27;5;13~", # Ctrl-Enter via xterm modifyOtherKeys + "\x1b[27;6;13~", # Ctrl-Shift-Enter via xterm modifyOtherKeys + ], +) +def test_xterm_csi_27_enter_variants_fall_back_to_plain_enter(raw): + # prompt_toolkit doesn't implement xterm's modifyOtherKeys itself, + # but a terminal whose user configuration has it on will still send + # these sequences. Map all three to plain Enter so modifier+Enter at + # least submits the form, rather than silently doing nothing. + c = _Collector() + p = Vt100Parser(c) + p.feed(raw) + assert [kp.key for kp in c.presses] == [Keys.ControlM] + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("\x1b[1;5A", Keys.ControlUp), + ("\x1b[1;5B", Keys.ControlDown), + ("\x1b[1;2C", Keys.ShiftRight), + ("\x1b[1;6D", Keys.ControlShiftLeft), + ("\x1b[5;5~", Keys.ControlPageUp), + ("\x1b[3;2~", Keys.ShiftDelete), + ], +) +def test_parser_routes_legacy_modified_arrow(raw, expected): + # Under flag 1 (disambiguate) — the flag prompt_toolkit pushes — + # modified arrows, navigation keys, and F1–F4 stay in their legacy + # `CSI … ~` / `CSI … A..D` encoding rather than switching to CSI u. + # This is what every Kitty-compatible terminal actually sends for + # Ctrl-Up et al, and it's handled by the static `ANSI_SEQUENCES` + # table, not the Kitty decoder. Included in this file to keep the + # real flag-1 coverage close to the CSI u coverage above. + c = _Collector() + p = Vt100Parser(c) + p.feed(raw) + assert [kp.key for kp in c.presses] == [expected] + + +def test_parser_prefers_static_table_over_generic_decoder(): + # mintty's own Ctrl+digit encoding is `CSI 1 ; 5 ` + # (https://github.com/mintty/mintty/wiki/Keycodes), which happens to + # terminate in `u`. The static ANSI_SEQUENCES entry must still win + # over the generic Kitty CSI u decoder so Ctrl-5 keeps mapping to + # `Keys.Control5` rather than being reinterpreted. + c = _Collector() + p = Vt100Parser(c) + p.feed("\x1b[1;5u") + assert [kp.key for kp in c.presses] == [Keys.Control5] + + +def test_parser_preserves_insert_text_for_kitty_sequence(): + c = _Collector() + p = Vt100Parser(c) + raw = "\x1b[13;5u" + p.feed(raw) + # The decoded KeyPress carries the original raw sequence as `data`. + assert c.presses[0].data == raw + + +# ---------- output helpers ---------- + + +def _buf_output() -> tuple[io.StringIO, Vt100_Output]: + buf = io.StringIO() + return buf, Vt100_Output(buf, lambda: (24, 80), term="xterm") + + +def test_output_push_and_pop(): + buf, out = _buf_output() + with kitty_keyboard_protocol(out): + pass + out.flush() + assert buf.getvalue() == "\x1b[>1u\x1b[{flags}u\x1b[1u\x1b[1u" in buf.getvalue() + + +def test_output_nested_with_mismatched_flags_raises(): + _, out = _buf_output() + with kitty_keyboard_protocol(out, flags=1): + with pytest.raises(ValueError, match="Mixing flags"): + with kitty_keyboard_protocol(out, flags=3): + pass + + +# ---------- renderer capability detection ---------- + + +def test_renderer_report_kitty_keyboard_response_flips_capability(): + # The binding in key_binding/bindings/kitty_keyboard.py calls this + # method when a `CSI ? u` response arrives; it's the only + # transition from UNKNOWN -> SUPPORTED, so the state machine is + # worth guarding directly. + renderer = Renderer(Style([]), DummyOutput(), full_screen=False) + assert renderer.kitty_support is KittySupport.UNKNOWN + renderer.report_kitty_keyboard_response() + assert renderer.kitty_support is KittySupport.SUPPORTED