From 1cebb3bad8a1516f1f7bb825a4011959c382511c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 8 May 2026 06:23:25 -0700 Subject: [PATCH] feat: Ctrl+Enter inserts newline on Windows Terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows Terminal intercepts Alt+Enter for its fullscreen shortcut, leaving Windows users with no Enter-involving way to insert a newline in the Hermes prompt. Fix it by reclaiming c-j on Windows only: - _bind_prompt_submit_keys now binds c-j (LF) to submit only on POSIX, where thin PTYs (docker exec, some SSH configs) deliver Enter as LF. On Windows plain Enter is always c-m, so c-j is free. - Windows-only prompt binding: c-j inserts a newline. Windows Terminal sends Ctrl+Enter as LF, so the user-facing keystroke is Ctrl+Enter — no terminal settings changes required. - Alt+Enter binding unchanged; still works on mac/Linux/WSL. - Test TestPromptToolkitTerminalCompatibility::test_lf_enter_binds_to_submit_handler split into platform-aware assertions for POSIX vs win32. - Fixed the Ctrl+J claim in hermes_cli/tips.py (was wrong before this commit even on POSIX) to point Windows users at Ctrl+Enter. Tradeoff: on Windows, raw Ctrl+J (without Enter) also inserts a newline, since WT collapses Ctrl+Enter and Ctrl+J to the same c-j keycode. No conflicting Hermes binding existed for Ctrl+J, so this is a harmless side effect. --- cli.py | 40 ++++++++++++++++++++++++++++++++++---- hermes_cli/tips.py | 2 +- tests/cli/test_cli_init.py | 34 ++++++++++++++++++++++++-------- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/cli.py b/cli.py index 236f3e5d87..cf5abcba14 100644 --- a/cli.py +++ b/cli.py @@ -1848,9 +1848,20 @@ _TERMINAL_INPUT_MODE_RESET_SEQ = ( def _bind_prompt_submit_keys(kb, handler) -> None: - """Bind both CR and LF terminal Enter forms to the submit handler.""" - for key in ("enter", "c-j"): - kb.add(key)(handler) + """Bind terminal Enter forms to the submit handler. + + Enter is always submit. On POSIX we also bind c-j (LF) to submit because + 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. + """ + kb.add("enter")(handler) + if sys.platform != "win32": + kb.add("c-j")(handler) def _disable_prompt_toolkit_cpr_warning(app) -> None: @@ -10636,9 +10647,30 @@ class HermesCLI: @kb.add('escape', 'enter') def handle_alt_enter(event): - """Alt+Enter inserts a newline for multi-line input.""" + """Alt+Enter inserts a newline for multi-line input. + + Works on mac/Linux/WSL. On Windows Terminal this keystroke is + intercepted at the terminal layer (toggles fullscreen) and never + reaches here — Windows users get newline via Ctrl+Enter instead + (bound below as c-j, since WT delivers Ctrl+Enter as LF). + """ event.current_buffer.insert_text('\n') + if sys.platform == "win32": + @kb.add('c-j') + def handle_ctrl_enter_newline_windows(event): + """Ctrl+Enter inserts a newline on Windows. + + 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. + """ + event.current_buffer.insert_text('\n') + # VSCode/Cursor bind Ctrl+G to "Find Next" at the editor level, so # the keystroke never reaches the embedded terminal. Alt+G is unbound # in those IDEs and arrives here as ('escape', 'g') — register it as diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 77329d9f87..51f4dd2c0b 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -54,7 +54,7 @@ TIPS = [ "Combine multiple references: \"Review @file:main.py and @file:test.py for consistency.\"", # --- Keybindings --- - "Alt+Enter (or Ctrl+J) inserts a newline for multi-line input.", + "Alt+Enter inserts a newline for multi-line input. (Windows Terminal intercepts Alt+Enter — use Ctrl+Enter instead.)", "Ctrl+C interrupts the agent. Double-press within 2 seconds to force exit.", "Ctrl+Z suspends Hermes to the background — run fg in your shell to resume.", "Tab accepts auto-suggestion ghost text or autocompletes slash commands.", diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index c9ecf2c7df..43bfaf23d8 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -163,22 +163,40 @@ class TestBusyInputMode: class TestPromptToolkitTerminalCompatibility: - def test_lf_enter_binds_to_submit_handler(self): - """Some thin PTYs deliver Enter as LF/c-j instead of CR/enter.""" + 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. + """ + import sys as _sys + from unittest.mock import patch as _patch from prompt_toolkit.key_binding import KeyBindings from cli import _bind_prompt_submit_keys - kb = KeyBindings() - def submit_handler(event): return None - _bind_prompt_submit_keys(kb, submit_handler) + # POSIX: both enter and c-j submit + with _patch.object(_sys, "platform", "linux"): + 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 - 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 + # Windows: only enter submits; c-j is free for the newline binding + # added separately in the prompt setup. + with _patch.object(_sys, "platform", "win32"): + 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 def test_cpr_warning_callback_is_disabled(self): from cli import _disable_prompt_toolkit_cpr_warning