mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
2124ad72a2
commit
70bc52e408
4 changed files with 210 additions and 24 deletions
69
cli.py
69
cli.py
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
105
tests/cli/test_ctrl_enter_newline.py
Normal file
105
tests/cli/test_ctrl_enter_newline.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue