fix(cli): make Ctrl+Enter insert newline on WSL/SSH/Windows Terminal (#22777)

Native Windows, WSL, SSH sessions, and Windows Terminal all send
Ctrl+Enter as bare LF (c-j). Hermes was binding c-j as submit on
every POSIX platform, so Ctrl+Enter submitted instead of inserting
a newline on those terminals. Reported in #22379.

Add _preserve_ctrl_enter_newline() predicate that detects the
environments where Ctrl+Enter must produce a newline (sys.platform
== 'win32', SSH_CONNECTION/SSH_CLIENT/SSH_TTY env, WT_SESSION,
WSL_DISTRO_NAME, /proc/version 'microsoft' marker). Gate the
c-j-as-submit binding off in those environments and gate the
c-j-as-newline handler on. Local POSIX TTYs without those markers
(docker exec, plain ssh from a Mac) keep c-j as submit so plain
Enter still works on thin PTYs.

Add install_ctrl_enter_alias() in hermes_cli/pt_input_extras.py
mapping the three CSI-u / modifyOtherKeys variants of Ctrl+Enter
('\x1b[13;5u', '\x1b[27;5;13~', '\x1b[27;5;13u') to the
(Escape, ControlM) tuple Alt+Enter produces. This lets Kitty /
mintty / xterm-with-modifyOtherKeys users over SSH get a Ctrl+Enter
newline through the existing Alt+Enter handler.

9 new tests + extended existing test_lf_enter_binds_to_submit_handler_posix
to cover bare-local vs SSH branches.

Closes #22379.
This commit is contained in:
Teknium 2026-05-09 12:48:14 -07:00 committed by GitHub
parent 2124ad72a2
commit 70bc52e408
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 210 additions and 24 deletions

69
cli.py
View file

@ -72,9 +72,10 @@ except (ImportError, AttributeError):
_STEADY_CURSOR = None _STEADY_CURSOR = None
try: try:
from hermes_cli.pt_input_extras import install_shift_enter_alias from hermes_cli.pt_input_extras import install_shift_enter_alias, install_ctrl_enter_alias
install_shift_enter_alias() install_shift_enter_alias()
del install_shift_enter_alias install_ctrl_enter_alias()
del install_shift_enter_alias, install_ctrl_enter_alias
except Exception: except Exception:
pass pass
import threading import threading
@ -1862,6 +1863,37 @@ _TERMINAL_INPUT_MODE_RESET_SEQ = (
) )
def _preserve_ctrl_enter_newline() -> bool:
"""Detect environments where Ctrl+Enter must produce a newline, not submit.
Native Windows, WSL, SSH sessions, and Windows Terminal all send Ctrl+Enter
as bare LF (c-j). On those terminals c-j must NOT be bound to submit;
binding it to submit makes Ctrl+Enter (intended as 'newline like Alt+Enter')
submit instead. Local POSIX TTYs that deliver Enter as LF (docker exec,
some thin PTYs without SSH) still need c-j bound to submit, so we keep
that binding for those.
See issue #22379.
"""
if sys.platform == "win32":
return True
if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")):
return True
if os.environ.get("WT_SESSION"):
return True
if "microsoft" in os.environ.get("WSL_DISTRO_NAME", "").lower():
return True
# WSL detection — env vars can be scrubbed under sudo, also peek /proc.
for p in ("/proc/version", "/proc/sys/kernel/osrelease"):
try:
with open(p, "r", encoding="utf-8", errors="ignore") as f:
if "microsoft" in f.read().lower():
return True
except OSError:
continue
return False
def _bind_prompt_submit_keys(kb, handler) -> None: def _bind_prompt_submit_keys(kb, handler) -> None:
"""Bind terminal Enter forms to the submit handler. """Bind terminal Enter forms to the submit handler.
@ -1869,13 +1901,15 @@ def _bind_prompt_submit_keys(kb, handler) -> None:
some thin PTYs (docker exec, certain SSH flavors) deliver Enter as LF some thin PTYs (docker exec, certain SSH flavors) deliver Enter as LF
instead of CR without this, Enter appears dead on those terminals. instead of CR without this, Enter appears dead on those terminals.
On Windows, Windows Terminal delivers Ctrl+Enter as a distinct c-j key Exception: on Windows, WSL, SSH sessions, and Windows Terminal,
while plain Enter is c-m, so we leave c-j unbound here it becomes the c-j is the wire encoding of Ctrl+Enter (a distinct keystroke from
multi-line newline keystroke, giving Windows users an Enter-involving plain Enter / c-m). We leave c-j unbound there so the c-j newline
newline without any terminal settings changes. handler registered separately can fire giving the user an
Enter-involving newline keystroke without terminal settings changes.
See _preserve_ctrl_enter_newline() and issue #22379.
""" """
kb.add("enter")(handler) kb.add("enter")(handler)
if sys.platform != "win32": if sys.platform != "win32" and not _preserve_ctrl_enter_newline():
kb.add("c-j")(handler) kb.add("c-j")(handler)
@ -10855,18 +10889,19 @@ class HermesCLI:
""" """
event.current_buffer.insert_text('\n') event.current_buffer.insert_text('\n')
if sys.platform == "win32": if _preserve_ctrl_enter_newline():
@kb.add('c-j') @kb.add('c-j')
def handle_ctrl_enter_newline_windows(event): def handle_ctrl_enter_newline(event):
"""Ctrl+Enter inserts a newline on Windows. """Ctrl+Enter inserts a newline on Windows, WSL, SSH, and WT.
Windows Terminal delivers Ctrl+Enter as LF (c-j), distinct Windows Terminal (incl. WSL/SSH sessions through it) delivers
from plain Enter (c-m). This binding makes Ctrl+Enter the Ctrl+Enter as LF (c-j), distinct from plain Enter (c-m). This
Windows equivalent of Alt+Enter, giving an Enter-involving binding makes Ctrl+Enter the equivalent of Alt+Enter on those
newline keystroke without requiring terminal settings changes. terminals, giving an Enter-involving newline keystroke
Ctrl+J (the raw LF keystroke) also triggers this by virtue without requiring terminal settings changes. Ctrl+J (the raw
of being the same key code a harmless side effect since LF keystroke) also triggers this by virtue of being the same
Ctrl+J has no conflicting Hermes binding. key code a harmless side effect since Ctrl+J has no
conflicting Hermes binding. See issue #22379.
""" """
event.current_buffer.insert_text('\n') event.current_buffer.insert_text('\n')

View file

@ -49,3 +49,35 @@ def install_shift_enter_alias() -> int:
ANSI_SEQUENCES[seq] = alt_enter ANSI_SEQUENCES[seq] = alt_enter
changed += 1 changed += 1
return changed return changed
def install_ctrl_enter_alias() -> int:
"""Map Ctrl+Enter byte sequences to the (Escape, ControlM) key tuple
that Alt+Enter produces, so the existing Alt+Enter newline handler
fires for terminals that emit a distinct Ctrl+Enter.
Sequences mapped:
- "\\x1b[13;5u" Kitty keyboard protocol / CSI-u, modifier=5 (Ctrl)
- "\\x1b[27;5;13~" xterm modifyOtherKeys=2, modifier=5 (Ctrl)
- "\\x1b[27;5;13u" alternate ordering some emitters use
Stock prompt_toolkit doesn't map any of these. Without this alias,
Kitty/mintty/xterm-with-modifyOtherKeys users over SSH never get a
Ctrl+Enter newline the keystroke arrives as a raw CSI sequence that
falls through to the default character-insert handler. See #22379.
Returns the number of sequences whose mapping was changed.
"""
try:
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
from prompt_toolkit.keys import Keys
except Exception:
return 0
alt_enter = (Keys.Escape, Keys.ControlM)
changed = 0
for seq in ("\x1b[13;5u", "\x1b[27;5;13~", "\x1b[27;5;13u"):
if ANSI_SEQUENCES.get(seq) != alt_enter:
ANSI_SEQUENCES[seq] = alt_enter
changed += 1
return changed

View file

@ -166,13 +166,14 @@ class TestPromptToolkitTerminalCompatibility:
def test_lf_enter_binds_to_submit_handler_posix(self): def test_lf_enter_binds_to_submit_handler_posix(self):
"""Some thin PTYs deliver Enter as LF/c-j instead of CR/enter. """Some thin PTYs deliver Enter as LF/c-j instead of CR/enter.
On POSIX we keep the c-j submit binding so Enter works on thin On a bare local POSIX TTY (no SSH/WSL/WT) we keep c-j submit so
PTYs (docker exec, certain SSH configurations). On Windows c-j is Enter works on thin PTYs (docker exec, certain ssh configurations).
reclaimed as the newline keystroke because Windows Terminal On Windows, WSL, SSH sessions, and Windows Terminal we leave c-j
delivers Ctrl+Enter as LF, and we want an Enter-involving newline unbound here so it can be used as the Ctrl+Enter newline keystroke
without requiring terminal-settings changes. without conflicting with submit. See issue #22379.
""" """
import sys as _sys import sys as _sys
import os as _os
from unittest.mock import patch as _patch from unittest.mock import patch as _patch
from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding import KeyBindings
@ -181,14 +182,27 @@ class TestPromptToolkitTerminalCompatibility:
def submit_handler(event): def submit_handler(event):
return None return None
# POSIX: both enter and c-j submit # Bare local POSIX (no SSH/WSL markers): both enter and c-j submit.
with _patch.object(_sys, "platform", "linux"): with _patch.object(_sys, "platform", "linux"), \
_patch.dict(_os.environ, {}, clear=True), \
_patch("builtins.open", side_effect=OSError("no /proc")):
kb = KeyBindings() kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler) _bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings} bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler assert bindings[("c-m",)] is submit_handler
assert bindings[("c-j",)] is submit_handler assert bindings[("c-j",)] is submit_handler
# POSIX over SSH: c-j stays free so Ctrl+Enter (sent as LF by
# Windows Terminal / Kitty / mintty over SSH) inserts a newline.
with _patch.object(_sys, "platform", "linux"), \
_patch.dict(_os.environ, {"SSH_CONNECTION": "1.2.3.4 5 6.7.8.9 22"}, clear=True), \
_patch("builtins.open", side_effect=OSError("no /proc")):
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
# Windows: only enter submits; c-j is free for the newline binding # Windows: only enter submits; c-j is free for the newline binding
# added separately in the prompt setup. # added separately in the prompt setup.
with _patch.object(_sys, "platform", "win32"): with _patch.object(_sys, "platform", "win32"):

View file

@ -0,0 +1,105 @@
"""Regression tests for issue #22379 — Ctrl+Enter newline over SSH/WSL.
prompt_toolkit treats c-j (LF) as Enter on POSIX so thin PTYs (docker exec,
some BSD ssh) that send LF for plain Enter still work. But Windows Terminal
(native, WSL, and SSH-forwarded sessions) sends Ctrl+Enter as bare LF same
byte. Without environment-aware gating, binding c-j to submit means
Ctrl+Enter submits instead of inserting a newline.
These tests pin the gating predicate and the resulting binding behavior.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import patch
def test_native_windows_preserves_newline():
import cli as cli_mod
with patch.object(sys, "platform", "win32"):
assert cli_mod._preserve_ctrl_enter_newline() is True
def test_ssh_session_preserves_newline_on_linux():
import cli as cli_mod
with patch.object(sys, "platform", "linux"):
with patch.dict(os.environ, {"SSH_CONNECTION": "1.2.3.4 5 6.7.8.9 22"}, clear=False):
assert cli_mod._preserve_ctrl_enter_newline() is True
def test_ssh_tty_alone_preserves_newline():
import cli as cli_mod
with patch.object(sys, "platform", "linux"):
# Strip out anything that might leak truth
with patch.dict(os.environ, {"SSH_TTY": "/dev/pts/0"}, clear=True):
assert cli_mod._preserve_ctrl_enter_newline() is True
def test_wsl_distro_name_preserves_newline():
import cli as cli_mod
with patch.object(sys, "platform", "linux"):
with patch.dict(os.environ, {"WSL_DISTRO_NAME": "Ubuntu-Microsoft"}, clear=True):
assert cli_mod._preserve_ctrl_enter_newline() is True
def test_windows_terminal_session_preserves_newline():
import cli as cli_mod
with patch.object(sys, "platform", "linux"):
with patch.dict(os.environ, {"WT_SESSION": "abc-def"}, clear=True):
assert cli_mod._preserve_ctrl_enter_newline() is True
def test_pure_local_linux_does_not_preserve():
"""A bare local Linux TTY (no SSH/WSL/WT) keeps c-j → submit so docker exec
style Enter-as-LF stays usable."""
import cli as cli_mod
# Stub out /proc reads — those are the WSL fallback signal.
with patch.object(sys, "platform", "linux"):
with patch.dict(os.environ, {}, clear=True):
with patch("builtins.open", side_effect=OSError("no /proc")):
assert cli_mod._preserve_ctrl_enter_newline() is False
def test_proc_version_microsoft_marker_preserves_newline():
"""WSL detection via /proc when env vars are scrubbed (sudo etc.)."""
import cli as cli_mod
from io import StringIO
with patch.object(sys, "platform", "linux"):
with patch.dict(os.environ, {}, clear=True):
real_open = open
def _fake_open(path, *args, **kwargs):
if "/proc/version" in str(path) or "/proc/sys/kernel/osrelease" in str(path):
return StringIO("Linux version 5.15.167.4-microsoft-standard-WSL2")
return real_open(path, *args, **kwargs)
with patch("builtins.open", side_effect=_fake_open):
assert cli_mod._preserve_ctrl_enter_newline() is True
# ---------------------------------------------------------------------------
# install_ctrl_enter_alias() — ANSI sequence mappings for enhanced terminals
# ---------------------------------------------------------------------------
def test_install_ctrl_enter_alias_maps_csi_u_sequences():
"""Kitty / xterm modifyOtherKeys / mintty Ctrl+Enter sequences alias to
Alt+Enter (Escape, ControlM) so the existing newline handler fires."""
from hermes_cli.pt_input_extras import install_ctrl_enter_alias
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
from prompt_toolkit.keys import Keys
install_ctrl_enter_alias()
alt_enter = (Keys.Escape, Keys.ControlM)
for seq in ("\x1b[13;5u", "\x1b[27;5;13~", "\x1b[27;5;13u"):
assert ANSI_SEQUENCES.get(seq) == alt_enter, (
f"Ctrl+Enter sequence {seq!r} not mapped to Alt+Enter tuple"
)
def test_install_ctrl_enter_alias_idempotent():
"""Running it twice doesn't double-count or break."""
from hermes_cli.pt_input_extras import install_ctrl_enter_alias
install_ctrl_enter_alias()
second = install_ctrl_enter_alias()
assert second == 0 # no further changes after first install