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
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()
del install_shift_enter_alias
install_ctrl_enter_alias()
del install_shift_enter_alias, install_ctrl_enter_alias
except Exception:
pass
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:
"""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
instead of CR without this, Enter appears dead on those terminals.
On Windows, Windows Terminal delivers Ctrl+Enter as a distinct c-j key
while plain Enter is c-m, so we leave c-j unbound here it becomes the
multi-line newline keystroke, giving Windows users an Enter-involving
newline without any terminal settings changes.
Exception: on Windows, WSL, SSH sessions, and Windows Terminal,
c-j is the wire encoding of Ctrl+Enter (a distinct keystroke from
plain Enter / c-m). We leave c-j unbound there so the c-j newline
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)
if sys.platform != "win32":
if sys.platform != "win32" and not _preserve_ctrl_enter_newline():
kb.add("c-j")(handler)
@ -10855,18 +10889,19 @@ class HermesCLI:
"""
event.current_buffer.insert_text('\n')
if sys.platform == "win32":
if _preserve_ctrl_enter_newline():
@kb.add('c-j')
def handle_ctrl_enter_newline_windows(event):
"""Ctrl+Enter inserts a newline on Windows.
def handle_ctrl_enter_newline(event):
"""Ctrl+Enter inserts a newline on Windows, WSL, SSH, and WT.
Windows Terminal delivers Ctrl+Enter as LF (c-j), distinct
from plain Enter (c-m). This binding makes Ctrl+Enter the
Windows equivalent of Alt+Enter, giving an Enter-involving
newline keystroke without requiring terminal settings changes.
Ctrl+J (the raw LF keystroke) also triggers this by virtue
of being the same key code a harmless side effect since
Ctrl+J has no conflicting Hermes binding.
Windows Terminal (incl. WSL/SSH sessions through it) delivers
Ctrl+Enter as LF (c-j), distinct from plain Enter (c-m). This
binding makes Ctrl+Enter the equivalent of Alt+Enter on those
terminals, giving an Enter-involving newline keystroke
without requiring terminal settings changes. Ctrl+J (the raw
LF keystroke) also triggers this by virtue of being the same
key code a harmless side effect since Ctrl+J has no
conflicting Hermes binding. See issue #22379.
"""
event.current_buffer.insert_text('\n')

View file

@ -49,3 +49,35 @@ def install_shift_enter_alias() -> int:
ANSI_SEQUENCES[seq] = alt_enter
changed += 1
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):
"""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
PTYs (docker exec, certain SSH configurations). On Windows c-j is
reclaimed as the newline keystroke because Windows Terminal
delivers Ctrl+Enter as LF, and we want an Enter-involving newline
without requiring terminal-settings changes.
On a bare local POSIX TTY (no SSH/WSL/WT) we keep c-j submit so
Enter works on thin PTYs (docker exec, certain ssh configurations).
On Windows, WSL, SSH sessions, and Windows Terminal we leave c-j
unbound here so it can be used as the Ctrl+Enter newline keystroke
without conflicting with submit. See issue #22379.
"""
import sys as _sys
import os as _os
from unittest.mock import patch as _patch
from prompt_toolkit.key_binding import KeyBindings
@ -181,14 +182,27 @@ class TestPromptToolkitTerminalCompatibility:
def submit_handler(event):
return None
# POSIX: both enter and c-j submit
with _patch.object(_sys, "platform", "linux"):
# Bare local POSIX (no SSH/WSL markers): both enter and c-j submit.
with _patch.object(_sys, "platform", "linux"), \
_patch.dict(_os.environ, {}, 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 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
# added separately in the prompt setup.
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