Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
CHANGELOG
=========

Unreleased
----------

New features:
- Distinguish Ctrl-Enter and Ctrl-Shift-Enter from plain Enter on terminals
that support xterm's `modifyOtherKeys` protocol. The protocol is enabled
automatically at startup and disabled on exit. New `Keys.ControlEnter`
and `Keys.ControlShiftEnter` values can be used in key bindings (e.g.
`@bindings.add("c-enter")`). Terminals that don't implement the protocol
silently keep the previous behavior of submitting on any Enter variant.
Shift-Enter alone is intentionally not exposed: terminals disagree about
whether to escape it, so it continues to behave like plain Enter for
consistency.

3.0.52: 2025-08-27
------------------

Expand Down
47 changes: 47 additions & 0 deletions examples/prompts/modified-enter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env python
"""
Demo for modified-Enter key bindings (Ctrl-Enter and Ctrl-Shift-Enter).

prompt_toolkit enables xterm's `modifyOtherKeys` protocol at startup, so
terminals that implement it (xterm, iTerm2 with the option enabled, kitty,
WezTerm, Alacritty, foot, ghostty, Windows Terminal, ...) can distinguish
these from plain Enter.

Run this and try pressing each combination. Plain Enter still submits.
Terminals that don't support the protocol will just submit on any Enter
variant — that's the expected fallback.

Shift-Enter alone is not included: many terminals that support
modifyOtherKeys still send plain '\\r' for it, so a binding would fire
inconsistently.
"""

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("c-s-enter")
def _(event):
_announce("Ctrl-Shift-Enter")

print("Try Ctrl-Enter and Ctrl-Shift-Enter. Plain Enter submits.")
text = prompt("> ", key_bindings=bindings)
print(f"You said: {text!r}")


if __name__ == "__main__":
main()
13 changes: 8 additions & 5 deletions src/prompt_toolkit/input/ansi_escape_sequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,16 @@
"\x1b[23;2~": Keys.F23,
"\x1b[24;2~": Keys.F24,
# --
# CSI 27 disambiguated modified "other" keys (xterm)
# CSI 27 disambiguated modified "other" keys (xterm modifyOtherKeys).
# Ref: https://invisible-island.net/xterm/modified-keys.html
# These are currently unsupported, so just re-map some common ones to the
# unmodified versions
# prompt_toolkit enables this protocol at startup (see renderer.py), so
# terminals that support it will send these sequences for modified Enter.
# Shift-Enter is deliberately mapped back to plain Enter: terminals
# disagree about whether to escape it, so we treat both variants the
# same to get consistent behavior across terminals.
"\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
"\x1b[27;5;13~": Keys.ControlEnter, # Ctrl + Enter
"\x1b[27;6;13~": Keys.ControlShiftEnter, # Ctrl + Shift + Enter
# --
# Control + function keys.
"\x1b[1;5P": Keys.ControlF1,
Expand Down
10 changes: 10 additions & 0 deletions src/prompt_toolkit/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ class Keys(str, Enum):

BackTab = "s-tab" # shift + tab

# Modified Enter keys. Only distinguishable from plain Enter when the
# terminal has the xterm `modifyOtherKeys` protocol (or equivalent)
# enabled — otherwise the terminal sends plain '\r' for all of these.
# Shift-Enter is intentionally not exposed: many terminals that support
# modifyOtherKeys still send plain '\r' for it (they only escape keys
# that would otherwise collide with a control character), so a binding
# would fire inconsistently.
ControlEnter = "c-enter"
ControlShiftEnter = "c-s-enter"

F1 = "f1"
F2 = "f2"
F3 = "f3"
Expand Down
13 changes: 12 additions & 1 deletion src/prompt_toolkit/output/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from __future__ import annotations

from abc import ABCMeta, abstractmethod
from typing import TextIO
from contextlib import contextmanager
from typing import Iterator, TextIO

from prompt_toolkit.cursor_shapes import CursorShape
from prompt_toolkit.data_structures import Size
Expand Down Expand Up @@ -187,6 +188,16 @@ def enable_bracketed_paste(self) -> None:
def disable_bracketed_paste(self) -> None:
"For vt100 only."

@contextmanager
def modify_other_keys(self) -> Iterator[None]:
"""
For vt100 only. Context manager that enables xterm's
"modifyOtherKeys" protocol (mode 2) for the duration of the block,
so the terminal disambiguates modified keys like Ctrl-Enter from
their unmodified counterparts, and restores the default on exit.
"""
yield

def reset_cursor_key_mode(self) -> None:
"""
For vt100 only.
Expand Down
48 changes: 47 additions & 1 deletion src/prompt_toolkit/output/vt100.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
import io
import os
import sys
from collections.abc import Callable, Hashable, Iterable, Sequence
from collections.abc import Callable, Hashable, Iterable, Iterator, Sequence
from contextlib import contextmanager
from typing import TextIO

from prompt_toolkit.cursor_shapes import CursorShape
Expand All @@ -29,6 +30,27 @@
]


# xterm "modifyOtherKeys" (XTMODKEYS) control sequences.
# Ref: https://invisible-island.net/xterm/modified-keys.html
#
# Format: CSI > Pp ; Pv m
# Pp = 4 selects the `modifyOtherKeys` resource.
# Pv = 2 sets the resource to level 2 ("disambiguate every modifiable
# key"), so the terminal emits CSI 27 sequences for keys like
# Ctrl-Enter that would otherwise collide with control chars.
#
# The reset form drops the Pv parameter (`CSI > 4 m`), which asks xterm
# to *restore* the resource to its startup value — it is NOT a force-to-0.
# This respects a user who already configured modifyOtherKeys non-zero
# via xterm resources or terminal preferences. (To force state 0 we
# would write `\x1b[>4;0m` instead; we deliberately don't.)
#
# xterm exposes no push/pop for this resource; "reset-to-original" via
# the Pv-less form is the closest equivalent and is what we emit on exit.
_MODIFY_OTHER_KEYS_LEVEL_2 = "\x1b[>4;2m"
_MODIFY_OTHER_KEYS_RESET = "\x1b[>4m"


FG_ANSI_COLORS = {
"ansidefault": 39,
# Low intensity.
Expand Down Expand Up @@ -445,6 +467,11 @@ def __init__(
# not.)
self._cursor_visible: bool | None = None

# Reference count for `modify_other_keys`. We only emit the enable
# sequence on 0 -> 1 and the disable sequence on 1 -> 0, so nested
# or concurrent callers compose correctly.
self._modify_other_keys_depth = 0

@classmethod
def from_pty(
cls,
Expand Down Expand Up @@ -611,6 +638,25 @@ def enable_bracketed_paste(self) -> None:
def disable_bracketed_paste(self) -> None:
self.write_raw("\x1b[?2004l")

@contextmanager
def modify_other_keys(self) -> Iterator[None]:
# Reference-counted: the enable sequence is emitted only on the
# outermost enter and the reset sequence only on the outermost
# exit, so nested `with` blocks (or multiple independent holders)
# don't fight over the terminal state. See the module-level
# comment on `_MODIFY_OTHER_KEYS_LEVEL_2` / `_MODIFY_OTHER_KEYS_RESET`
# for what the sequences mean and why "reset" is used for cleanup
# rather than a force-to-0.
if self._modify_other_keys_depth == 0:
self.write_raw(_MODIFY_OTHER_KEYS_LEVEL_2)
self._modify_other_keys_depth += 1
try:
yield
finally:
self._modify_other_keys_depth -= 1
if self._modify_other_keys_depth == 0:
self.write_raw(_MODIFY_OTHER_KEYS_RESET)

def reset_cursor_key_mode(self) -> None:
"""
For vt100 only.
Expand Down
21 changes: 21 additions & 0 deletions src/prompt_toolkit/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from asyncio import FIRST_COMPLETED, Future, ensure_future, sleep, wait
from collections import deque
from collections.abc import Callable, Hashable
from contextlib import AbstractContextManager, nullcontext
from enum import Enum
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -362,6 +363,8 @@ def __init__(
self._in_alternate_screen = False
self._mouse_support_enabled = False
self._bracketed_paste_enabled = False
self._modify_other_keys_enabled = False
self._modify_other_keys_cm: AbstractContextManager[None] = nullcontext()
self._cursor_key_mode_reset = False

# Future set when we are waiting for a CPR flag.
Expand Down Expand Up @@ -421,6 +424,14 @@ def reset(self, _scroll: bool = False, leave_alternate_screen: bool = True) -> N
self.output.disable_bracketed_paste()
self._bracketed_paste_enabled = False

# Disable modifyOtherKeys, so that the terminal returns to its
# default Enter/Tab/Backspace behavior for the next program. Safe
# to call unconditionally: the initial cm is a nullcontext whose
# __exit__ is a no-op.
self._modify_other_keys_cm.__exit__(None, None, None)
self._modify_other_keys_cm = nullcontext()
self._modify_other_keys_enabled = False

self.output.reset_cursor_shape()
self.output.show_cursor()

Expand Down Expand Up @@ -609,6 +620,16 @@ def render(
self.output.enable_bracketed_paste()
self._bracketed_paste_enabled = True

# Enable xterm modifyOtherKeys so modified keys like Ctrl-Enter are
# sent as distinct CSI 27 sequences. The context manager is held on
# the renderer and released in reset(), so the terminal is restored
# even on a crash path that goes through reset(). Terminals that
# don't support this silently ignore the request.
if not self._modify_other_keys_enabled:
self._modify_other_keys_cm = self.output.modify_other_keys()
self._modify_other_keys_cm.__enter__()
self._modify_other_keys_enabled = True

# Reset cursor key mode.
if not self._cursor_key_mode_reset:
self.output.reset_cursor_key_mode()
Expand Down
Loading