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