diff --git a/cli.py b/cli.py index 585b664f2b2..b85ee0ee916 100644 --- a/cli.py +++ b/cli.py @@ -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') diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index 41b4727a5a5..008c931cfb7 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -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 diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index 43bfaf23d84..ee5ffb390d1 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -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"): diff --git a/tests/cli/test_ctrl_enter_newline.py b/tests/cli/test_ctrl_enter_newline.py new file mode 100644 index 00000000000..57056ab0e18 --- /dev/null +++ b/tests/cli/test_ctrl_enter_newline.py @@ -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