diff --git a/CHANGELOG b/CHANGELOG index aba20450f..17aae6df5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 ------------------ diff --git a/examples/prompts/modified-enter.py b/examples/prompts/modified-enter.py new file mode 100644 index 000000000..57155d863 --- /dev/null +++ b/examples/prompts/modified-enter.py @@ -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() diff --git a/src/prompt_toolkit/input/ansi_escape_sequences.py b/src/prompt_toolkit/input/ansi_escape_sequences.py index 1fba418b7..bd3a53779 100644 --- a/src/prompt_toolkit/input/ansi_escape_sequences.py +++ b/src/prompt_toolkit/input/ansi_escape_sequences.py @@ -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, diff --git a/src/prompt_toolkit/keys.py b/src/prompt_toolkit/keys.py index ee52aee86..319b0f1e4 100644 --- a/src/prompt_toolkit/keys.py +++ b/src/prompt_toolkit/keys.py @@ -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" diff --git a/src/prompt_toolkit/output/base.py b/src/prompt_toolkit/output/base.py index 6ba09fdd0..5e01911d2 100644 --- a/src/prompt_toolkit/output/base.py +++ b/src/prompt_toolkit/output/base.py @@ -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 @@ -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. diff --git a/src/prompt_toolkit/output/vt100.py b/src/prompt_toolkit/output/vt100.py index b2712254f..96c113753 100644 --- a/src/prompt_toolkit/output/vt100.py +++ b/src/prompt_toolkit/output/vt100.py @@ -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 @@ -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. @@ -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, @@ -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. diff --git a/src/prompt_toolkit/renderer.py b/src/prompt_toolkit/renderer.py index fcfde223e..7c71553cc 100644 --- a/src/prompt_toolkit/renderer.py +++ b/src/prompt_toolkit/renderer.py @@ -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 @@ -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. @@ -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() @@ -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()