From f967ec13678568a2abcd6e4ad3a32f962f7a3c13 Mon Sep 17 00:00:00 2001 From: M Bussonnier Date: Wed, 15 Apr 2026 14:41:04 +0200 Subject: [PATCH 1/3] Support the Kitty keyboard protocol (flag 1, disambiguate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push the Kitty keyboard protocol on startup and pop it on exit so supporting terminals deliver modified keys — Ctrl-Enter, Shift-Enter, Ctrl-Shift-Enter, Alt-letter, and full modifier coverage on the navigation block — as distinct CSI u sequences instead of being collapsed into their unmodified equivalents. Terminals that don't implement the protocol silently ignore the push, so there is no regression for them. Detection mirrors the existing CPR machinery: the renderer emits `CSI ? u`, a new binding under key_binding/bindings/ consumes the response and flips `Renderer.kitty_support` from UNKNOWN to SUPPORTED, letting callers branch on capability if they want. The bulk of the code lives in new files: - input/kitty_keyboard.py — CSI u decoder, functional-key table, query-response parser - output/kitty_keyboard.py — push/pop context manager with reference-counted depth, sequence constants - key_binding/bindings/kitty_keyboard.py — response-consuming binding - docs/pages/advanced_topics/kitty_keyboard_protocol.rst — maintainer notes on wire format, capability detection, and known sharp edges Touch-points in existing code are small and mirror the CPR pattern: one import + one regex + one dispatch branch in vt100_parser.py, a push/query/pop trio in renderer.py, one binding registration in key_binding/defaults.py, four new enum values in keys.py. prompt_toolkit does not push xterm's modifyOtherKeys. Users whose terminal or tmux has it enabled independently still get the existing `CSI 27` Enter fallback in ansi_escape_sequences.py, which folds all three modifier+Enter variants to plain Keys.ControlM so the form at least submits rather than silently doing nothing. New Keys: ControlEnter, ControlShiftEnter, ShiftEnter, KittyKeyboardResponse. Bindings registered as `c-enter` / `s-enter` / `c-s-enter` fire on Kitty-capable terminals; on non-Kitty terminals they don't fire (plain Enter fires instead, as before). --- CHANGELOG | 16 + docs/pages/advanced_topics/index.rst | 1 + .../kitty_keyboard_protocol.rst | 244 +++++++++++++ examples/prompts/kitty-key-probe.py | 264 ++++++++++++++ examples/prompts/modified-enter.py | 50 +++ .../input/ansi_escape_sequences.py | 13 +- src/prompt_toolkit/input/kitty_keyboard.py | 295 ++++++++++++++++ src/prompt_toolkit/input/vt100_parser.py | 48 ++- .../key_binding/bindings/basic.py | 13 + .../key_binding/bindings/kitty_keyboard.py | 36 ++ src/prompt_toolkit/key_binding/defaults.py | 4 + src/prompt_toolkit/keys.py | 15 + src/prompt_toolkit/output/kitty_keyboard.py | 114 +++++++ src/prompt_toolkit/renderer.py | 71 ++++ tests/test_kitty_keyboard.py | 322 ++++++++++++++++++ 15 files changed, 1496 insertions(+), 10 deletions(-) create mode 100644 docs/pages/advanced_topics/kitty_keyboard_protocol.rst create mode 100644 examples/prompts/kitty-key-probe.py create mode 100644 examples/prompts/modified-enter.py create mode 100644 src/prompt_toolkit/input/kitty_keyboard.py create mode 100644 src/prompt_toolkit/key_binding/bindings/kitty_keyboard.py create mode 100644 src/prompt_toolkit/output/kitty_keyboard.py create mode 100644 tests/test_kitty_keyboard.py diff --git a/CHANGELOG b/CHANGELOG index aba20450f2..2006e6c036 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 4c4fcc9cac..e00c2ba772 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 0000000000..0c82bfb80c --- /dev/null +++ b/docs/pages/advanced_topics/kitty_keyboard_protocol.rst @@ -0,0 +1,244 @@ +.. _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: + +- Functional keys from the Kitty spec: :kbd:`enter`, :kbd:`tab`, + :kbd:`escape`, :kbd:`backspace`, arrows, navigation block + (:kbd:`home` / :kbd:`end` / :kbd:`pageup` / :kbd:`pagedown` / + :kbd:`insert` / :kbd:`delete`), :kbd:`f1`–:kbd:`f12`. Mapped to the + nearest existing ``Keys`` value with Shift / Ctrl / Ctrl-Shift + promotion where an enum exists. +- 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.KittyKeyboardResponse`` — internal sentinel for the query + response parser-to-binding dispatch. + +Backspace (:kbd:`backspace`) is decoded from the Kitty functional-key +code 127, but prompt_toolkit has no distinct ``Keys`` values for +:kbd:`c-backspace` / :kbd:`s-backspace`, so modified Backspace silently +folds back to plain Backspace (``Keys.ControlH``) — same behavior as on +legacy terminals. + + +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-probe.py b/examples/prompts/kitty-key-probe.py new file mode 100644 index 0000000000..cf34c2bd57 --- /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 0000000000..c1c11ddeb3 --- /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 1fba418b73..1a5a6df05b 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 0000000000..e1e9f4deef --- /dev/null +++ b/src/prompt_toolkit/input/kitty_keyboard.py @@ -0,0 +1,295 @@ +""" +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, mapped to the nearest +# existing prompt_toolkit `Keys` value. Only keys likely to appear under +# `disambiguate` (flag 1) are included. +_FUNCTIONAL: dict[int, Keys] = { + 13: Keys.ControlM, # Enter + 9: Keys.ControlI, # Tab + 27: Keys.Escape, + 127: Keys.ControlH, # Backspace + # 57358 = CapsLock, + # 57359 = ScrollLock, + # 57360 = NumLock, + # 57361 = PrintScreen, + # 57362 = Pause, + # 57363 = Menu — no corresponding `Keys` enum, so left out of the table. + 57364: Keys.F1, + 57365: Keys.F2, + 57366: Keys.F3, + 57367: Keys.F4, + 57368: Keys.F5, + 57369: Keys.F6, + 57370: Keys.F7, + 57371: Keys.F8, + 57372: Keys.F9, + 57373: Keys.F10, + 57374: Keys.F11, + 57375: Keys.F12, +} + + +# Navigation keys that have distinct `Keys` values for every +# (Shift, Ctrl, Ctrl-Shift) variant. Tuple order: (shift, ctrl, ctrl+shift). +_NAV_MAP: dict[Keys, tuple[Keys, Keys, Keys]] = { + Keys.Up: (Keys.ShiftUp, Keys.ControlUp, Keys.ControlShiftUp), + Keys.Down: (Keys.ShiftDown, Keys.ControlDown, Keys.ControlShiftDown), + Keys.Left: (Keys.ShiftLeft, Keys.ControlLeft, Keys.ControlShiftLeft), + Keys.Right: (Keys.ShiftRight, Keys.ControlRight, Keys.ControlShiftRight), + Keys.Home: (Keys.ShiftHome, Keys.ControlHome, Keys.ControlShiftHome), + Keys.End: (Keys.ShiftEnd, Keys.ControlEnd, Keys.ControlShiftEnd), + Keys.PageUp: (Keys.ShiftPageUp, Keys.ControlPageUp, Keys.ControlShiftPageUp), + Keys.PageDown: ( + Keys.ShiftPageDown, + Keys.ControlPageDown, + Keys.ControlShiftPageDown, + ), + Keys.Insert: (Keys.ShiftInsert, Keys.ControlInsert, Keys.ControlShiftInsert), + Keys.Delete: (Keys.ShiftDelete, Keys.ControlDelete, Keys.ControlShiftDelete), +} + + +_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 unmodified functional keys from `_FUNCTIONAL` + (Enter, Tab, Escape, arrows, navigation block, F1–F12). `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. + + When no matching richer enum exists (e.g. plain Shift-Enter, + Shift-Fn, Shift-Backspace), the base key is returned unchanged. The + caller should treat those as "modifier silently folded away" rather + than "decode failure". + """ + 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 + _ctrl_key: Keys | None + if base in _NAV_MAP: + shift_key, _ctrl_key, ctrl_shift_key = _NAV_MAP[base] + if ctrl and shift: + return ctrl_shift_key + if ctrl: + return _ctrl_key + if shift: + return shift_key + return base + + # F1..F24 — Ctrl+ is a distinct enum; Shift+ is mapped to FN+12 in + # prompt_toolkit's existing convention (F1 → F13 etc.), but we don't + # emulate that here; Shift+Fn just returns Fn for now. + if base.value.startswith("f") and base.value[1:].isdigit(): + if ctrl: + ctrl_key: Keys | None = getattr(Keys, "Control" + base.name, None) + if ctrl_key is not None: + return ctrl_key + return base + + return base diff --git a/src/prompt_toolkit/input/vt100_parser.py b/src/prompt_toolkit/input/vt100_parser.py index 34ea110575..db81c2563c 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 ad18df981c..2e80598a85 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 0000000000..4865dea9f2 --- /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 6c26571160..fe66673e50 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 ee52aee86a..4ce7e41094 100644 --- a/src/prompt_toolkit/keys.py +++ b/src/prompt_toolkit/keys.py @@ -122,6 +122,20 @@ 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" F1 = "f1" F2 = "f2" @@ -181,6 +195,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 0000000000..328a8fb320 --- /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,37 @@ 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). + self._kitty_keyboard_stack.enter_context( + kitty_keyboard_protocol(self.output) + ) + self._kitty_keyboard_pushed = True + # 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 +795,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 0000000000..4a992425f4 --- /dev/null +++ b/tests/test_kitty_keyboard.py @@ -0,0 +1,322 @@ +"""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), + # NOTE: arrow keys, navigation block (Insert / Delete / Home / + # End / PageUp / PageDown), and F1–F4 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. Codepoints 57348–57363 + # are only ever emitted under flag 8 (report-all-as-escape); + # adding them back 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), + # F-keys. + ("\x1b[57364u", Keys.F1), + ("\x1b[57364;5u", Keys.ControlF1), + # 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 From 4591051b38d7007a8fc10982662f02f3184fca4c Mon Sep 17 00:00:00 2001 From: M Bussonnier Date: Sat, 18 Apr 2026 14:41:10 +0200 Subject: [PATCH 2/3] checklist --- .../kitty_keyboard_protocol.rst | 25 +++--- examples/prompts/kitty-key-checklist.py | 83 +++++++++++++++++++ src/prompt_toolkit/input/kitty_keyboard.py | 33 ++------ src/prompt_toolkit/keys.py | 11 +++ src/prompt_toolkit/renderer.py | 9 +- tests/test_kitty_keyboard.py | 4 + 6 files changed, 127 insertions(+), 38 deletions(-) create mode 100644 examples/prompts/kitty-key-checklist.py diff --git a/docs/pages/advanced_topics/kitty_keyboard_protocol.rst b/docs/pages/advanced_topics/kitty_keyboard_protocol.rst index 0c82bfb80c..fff13a8d71 100644 --- a/docs/pages/advanced_topics/kitty_keyboard_protocol.rst +++ b/docs/pages/advanced_topics/kitty_keyboard_protocol.rst @@ -58,11 +58,15 @@ Input decoder. Covered: - Functional keys from the Kitty spec: :kbd:`enter`, :kbd:`tab`, - :kbd:`escape`, :kbd:`backspace`, arrows, navigation block - (:kbd:`home` / :kbd:`end` / :kbd:`pageup` / :kbd:`pagedown` / - :kbd:`insert` / :kbd:`delete`), :kbd:`f1`–:kbd:`f12`. Mapped to the + :kbd:`escape`, :kbd:`backspace`, :kbd:`f1`–:kbd:`f12`. Mapped to the nearest existing ``Keys`` value with Shift / Ctrl / Ctrl-Shift - promotion where an enum exists. + promotion where an enum exists. Arrow keys and the navigation block + (:kbd:`home` / :kbd:`end` / :kbd:`pageup` / :kbd:`pagedown` / + :kbd:`insert` / :kbd:`delete`) 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 @@ -120,15 +124,16 @@ New ``Keys`` values - ``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. -Backspace (:kbd:`backspace`) is decoded from the Kitty functional-key -code 127, but prompt_toolkit has no distinct ``Keys`` values for -:kbd:`c-backspace` / :kbd:`s-backspace`, so modified Backspace silently -folds back to plain Backspace (``Keys.ControlH``) — same behavior as on -legacy terminals. - What could be done in the future -------------------------------- diff --git a/examples/prompts/kitty-key-checklist.py b/examples/prompts/kitty-key-checklist.py new file mode 100644 index 0000000000..2cb9104d4b --- /dev/null +++ b/examples/prompts/kitty-key-checklist.py @@ -0,0 +1,83 @@ +#!/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. +""" + +from prompt_toolkit import prompt +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(): + pressed: set[str] = set() + + def toolbar(): + lines = [("", "Kitty-only gestures — press each to turn it green:\n")] + 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", + } + ) + + prompt( + "> ", + bottom_toolbar=toolbar, + key_bindings=bindings, + style=style, + refresh_interval=0.5, + ) + + +if __name__ == "__main__": + main() diff --git a/src/prompt_toolkit/input/kitty_keyboard.py b/src/prompt_toolkit/input/kitty_keyboard.py index e1e9f4deef..da5ce31f62 100644 --- a/src/prompt_toolkit/input/kitty_keyboard.py +++ b/src/prompt_toolkit/input/kitty_keyboard.py @@ -90,26 +90,6 @@ } -# Navigation keys that have distinct `Keys` values for every -# (Shift, Ctrl, Ctrl-Shift) variant. Tuple order: (shift, ctrl, ctrl+shift). -_NAV_MAP: dict[Keys, tuple[Keys, Keys, Keys]] = { - Keys.Up: (Keys.ShiftUp, Keys.ControlUp, Keys.ControlShiftUp), - Keys.Down: (Keys.ShiftDown, Keys.ControlDown, Keys.ControlShiftDown), - Keys.Left: (Keys.ShiftLeft, Keys.ControlLeft, Keys.ControlShiftLeft), - Keys.Right: (Keys.ShiftRight, Keys.ControlRight, Keys.ControlShiftRight), - Keys.Home: (Keys.ShiftHome, Keys.ControlHome, Keys.ControlShiftHome), - Keys.End: (Keys.ShiftEnd, Keys.ControlEnd, Keys.ControlShiftEnd), - Keys.PageUp: (Keys.ShiftPageUp, Keys.ControlPageUp, Keys.ControlShiftPageUp), - Keys.PageDown: ( - Keys.ShiftPageDown, - Keys.ControlPageDown, - Keys.ControlShiftPageDown, - ), - Keys.Insert: (Keys.ShiftInsert, Keys.ControlInsert, Keys.ControlShiftInsert), - Keys.Delete: (Keys.ShiftDelete, Keys.ControlDelete, Keys.ControlShiftDelete), -} - - _DecodeResult = Keys | str | tuple[Keys | str, ...] | None @@ -271,16 +251,15 @@ def _apply_modifiers(base: Keys, ctrl: bool, shift: bool) -> Keys: if shift: return Keys.ShiftEscape return Keys.Escape - _ctrl_key: Keys | None - if base in _NAV_MAP: - shift_key, _ctrl_key, ctrl_shift_key = _NAV_MAP[base] + + if base is Keys.ControlH: # Backspace (keycode 127) if ctrl and shift: - return ctrl_shift_key + return Keys.ControlShiftBackspace if ctrl: - return _ctrl_key + return Keys.ControlBackspace if shift: - return shift_key - return base + return Keys.ShiftBackspace + return Keys.ControlH # F1..F24 — Ctrl+ is a distinct enum; Shift+ is mapped to FN+12 in # prompt_toolkit's existing convention (F1 → F13 etc.), but we don't diff --git a/src/prompt_toolkit/keys.py b/src/prompt_toolkit/keys.py index 4ce7e41094..3165b1898a 100644 --- a/src/prompt_toolkit/keys.py +++ b/src/prompt_toolkit/keys.py @@ -137,6 +137,17 @@ class Keys(str, Enum): 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" F3 = "f3" diff --git a/src/prompt_toolkit/renderer.py b/src/prompt_toolkit/renderer.py index 9ff328b979..682528f186 100644 --- a/src/prompt_toolkit/renderer.py +++ b/src/prompt_toolkit/renderer.py @@ -662,10 +662,17 @@ def render( # 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) ) - self._kitty_keyboard_pushed = True # 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 diff --git a/tests/test_kitty_keyboard.py b/tests/test_kitty_keyboard.py index 4a992425f4..6ee49f0537 100644 --- a/tests/test_kitty_keyboard.py +++ b/tests/test_kitty_keyboard.py @@ -37,6 +37,10 @@ ("\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–F4 intentionally do *not* # appear here. Under flag 1 (disambiguate) — the only flag From 37aa89c1ca6dcd0d3ab4b9ae2b33c5cdb45352d9 Mon Sep 17 00:00:00 2001 From: M Bussonnier Date: Sat, 18 Apr 2026 14:47:21 +0200 Subject: [PATCH 3/3] cleanup FX and manual checklist --- .../kitty_keyboard_protocol.rst | 24 ++++---- examples/prompts/kitty-key-checklist.py | 50 +++++++++++++++- src/prompt_toolkit/input/kitty_keyboard.py | 60 +++++++------------ tests/test_kitty_keyboard.py | 12 ++-- 4 files changed, 86 insertions(+), 60 deletions(-) diff --git a/docs/pages/advanced_topics/kitty_keyboard_protocol.rst b/docs/pages/advanced_topics/kitty_keyboard_protocol.rst index fff13a8d71..39b01fa4ae 100644 --- a/docs/pages/advanced_topics/kitty_keyboard_protocol.rst +++ b/docs/pages/advanced_topics/kitty_keyboard_protocol.rst @@ -57,16 +57,20 @@ Input ``src/prompt_toolkit/input/kitty_keyboard.py`` owns the ``CSI u`` decoder. Covered: -- Functional keys from the Kitty spec: :kbd:`enter`, :kbd:`tab`, - :kbd:`escape`, :kbd:`backspace`, :kbd:`f1`–:kbd:`f12`. Mapped to the - nearest existing ``Keys`` value with Shift / Ctrl / Ctrl-Shift - promotion where an enum exists. Arrow keys and the navigation block - (:kbd:`home` / :kbd:`end` / :kbd:`pageup` / :kbd:`pagedown` / - :kbd:`insert` / :kbd:`delete`) 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). +- 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 diff --git a/examples/prompts/kitty-key-checklist.py b/examples/prompts/kitty-key-checklist.py index 2cb9104d4b..1d9f1efe6d 100644 --- a/examples/prompts/kitty-key-checklist.py +++ b/examples/prompts/kitty-key-checklist.py @@ -12,9 +12,17 @@ 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. """ -from prompt_toolkit import prompt +import argparse + +from prompt_toolkit import PromptSession from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.styles import Style @@ -33,10 +41,28 @@ 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(): - lines = [("", "Kitty-only gestures — press each to turn it green:\n")] + 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")) @@ -70,7 +96,7 @@ def handler(event): } ) - prompt( + session = PromptSession( "> ", bottom_toolbar=toolbar, key_bindings=bindings, @@ -78,6 +104,24 @@ def handler(event): 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/src/prompt_toolkit/input/kitty_keyboard.py b/src/prompt_toolkit/input/kitty_keyboard.py index da5ce31f62..5529d5ef33 100644 --- a/src/prompt_toolkit/input/kitty_keyboard.py +++ b/src/prompt_toolkit/input/kitty_keyboard.py @@ -61,32 +61,27 @@ _IGNORED_MODS = _CAPSLOCK | _NUMLOCK -# Functional-key codes from the Kitty spec, mapped to the nearest -# existing prompt_toolkit `Keys` value. Only keys likely to appear under -# `disambiguate` (flag 1) are included. +# 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 - # 57358 = CapsLock, - # 57359 = ScrollLock, - # 57360 = NumLock, - # 57361 = PrintScreen, - # 57362 = Pause, - # 57363 = Menu — no corresponding `Keys` enum, so left out of the table. - 57364: Keys.F1, - 57365: Keys.F2, - 57366: Keys.F3, - 57367: Keys.F4, - 57368: Keys.F5, - 57369: Keys.F6, - 57370: Keys.F7, - 57371: Keys.F8, - 57372: Keys.F9, - 57373: Keys.F10, - 57374: Keys.F11, - 57375: Keys.F12, } @@ -214,16 +209,11 @@ 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 unmodified functional keys from `_FUNCTIONAL` - (Enter, Tab, Escape, arrows, navigation block, F1–F12). `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 + `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. - - When no matching richer enum exists (e.g. plain Shift-Enter, - Shift-Fn, Shift-Backspace), the base key is returned unchanged. The - caller should treat those as "modifier silently folded away" rather - than "decode failure". """ if base is Keys.ControlM: # Enter if ctrl and shift: @@ -261,14 +251,4 @@ def _apply_modifiers(base: Keys, ctrl: bool, shift: bool) -> Keys: return Keys.ShiftBackspace return Keys.ControlH - # F1..F24 — Ctrl+ is a distinct enum; Shift+ is mapped to FN+12 in - # prompt_toolkit's existing convention (F1 → F13 etc.), but we don't - # emulate that here; Shift+Fn just returns Fn for now. - if base.value.startswith("f") and base.value[1:].isdigit(): - if ctrl: - ctrl_key: Keys | None = getattr(Keys, "Control" + base.name, None) - if ctrl_key is not None: - return ctrl_key - return base - return base diff --git a/tests/test_kitty_keyboard.py b/tests/test_kitty_keyboard.py index 6ee49f0537..deff1cc246 100644 --- a/tests/test_kitty_keyboard.py +++ b/tests/test_kitty_keyboard.py @@ -42,24 +42,22 @@ ("\x1b[127;5u", Keys.ControlBackspace), ("\x1b[127;6u", Keys.ControlShiftBackspace), # NOTE: arrow keys, navigation block (Insert / Delete / Home / - # End / PageUp / PageDown), and F1–F4 intentionally do *not* + # 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. Codepoints 57348–57363 - # are only ever emitted under flag 8 (report-all-as-escape); - # adding them back belongs with any flag-8 work, not here. + # 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), - # F-keys. - ("\x1b[57364u", Keys.F1), - ("\x1b[57364;5u", Keys.ControlF1), # Ctrl + Shift + digit. ("\x1b[49;6u", Keys.ControlShift1), ],