diff --git a/cli.py b/cli.py index 09fef3a89c..01ed9a87a2 100644 --- a/cli.py +++ b/cli.py @@ -43,7 +43,7 @@ from urllib.parse import unquote, urlparse from contextlib import contextmanager from pathlib import Path from datetime import datetime -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Mapping logger = logging.getLogger(__name__) @@ -3777,6 +3777,97 @@ _TERMINAL_INPUT_MODE_RESET_SEQ = ( "\x1b[0m" # reset text attributes "\x1b[?25h" # ensure cursor visible ) +_EXTENDED_ENTER_KEYS_SEQ = "\x1b[>1u\x1b[>4;2m" + + +_BACKSLASH_LINE_CONTINUATION_RE = re.compile(r"\\[ \t]*$") + + +def _terminal_supports_extended_enter_keys(env: Optional[Mapping[str, str]] = None) -> bool: + """Whether it is safe/useful to request modified Enter key reporting. + + The classic CLI already maps Kitty CSI-u / xterm modifyOtherKeys Shift+Enter + byte sequences to the newline handler. Some terminals (notably iTerm2) only + emit those distinct sequences after the application asks for extended key + mode. Keep this allowlist aligned with the Ink TUI, which enables the same + modes for these terminals. + """ + if env is None: + env = os.environ + term_program = (env.get("TERM_PROGRAM") or "").strip() + term = (env.get("TERM") or "").strip().lower() + if env.get("WT_SESSION"): + return True + if term_program in {"iTerm.app", "WezTerm", "ghostty", "vscode"}: + return True + if env.get("KITTY_WINDOW_ID") or "kitty" in term: + return True + if term == "xterm-ghostty": + return True + if term.startswith("tmux") or term_program.lower() == "tmux": + return True + return False + + +def _enable_extended_enter_keys(output=None, env: Optional[Mapping[str, str]] = None) -> bool: + """Ask allowlisted terminals to report Shift+Enter distinctly. + + Writes both the Kitty keyboard protocol push (CSI >1u) and xterm + modifyOtherKeys level 2 (CSI >4;2m), mirroring the Ink TUI. The exit reset + sequence already pops/resets both modes, so this is safe across normal + exits, Ctrl+C, and SIGTERM cleanup. + """ + if not _terminal_supports_extended_enter_keys(env): + return False + try: + target = output + if target is not None and hasattr(target, "write_raw"): + target.write_raw(_EXTENDED_ENTER_KEYS_SEQ) + target.flush() + return True + stream = sys.stdout + if stream is not None and stream.isatty(): + stream.write(_EXTENDED_ENTER_KEYS_SEQ) + stream.flush() + return True + except Exception: + return False + return False + + +def _cli_multiline_shortcuts_enabled(config: Optional[Dict[str, Any]] = None) -> bool: + """Return whether classic CLI harness-standard multiline fallbacks are on. + + Default is on to match the norm in adjacent agent harnesses: Ctrl+J is a + documented no-setup newline shortcut in Claude Code, OpenCode defaults + ``input_newline`` to include ``ctrl+j``, and Codex exposes Ctrl+J/keymap + newline behavior. Users on unusual POSIX PTYs that send bare LF for plain + Enter can set ``display.cli_multiline_shortcuts: false`` to restore the + legacy c-j submit fallback. + """ + if config is None: + config = CLI_CONFIG + display = config.get("display") if isinstance(config, dict) else None + value = display.get("cli_multiline_shortcuts", True) if isinstance(display, dict) else True + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on", "enabled"}: + return True + if normalized in {"0", "false", "no", "off", "disabled"}: + return False + return True + + +def _is_backslash_line_continuation(text: str) -> bool: + """True when Enter should turn a trailing backslash into a newline.""" + return bool(_BACKSLASH_LINE_CONTINUATION_RE.search(text or "")) + + +def _apply_backslash_line_continuation(text: str) -> str: + """Replace a trailing ``\\`` marker with an actual newline.""" + return _BACKSLASH_LINE_CONTINUATION_RE.sub("", text or "") + "\n" def _preserve_ctrl_enter_newline() -> bool: @@ -3787,8 +3878,8 @@ def _preserve_ctrl_enter_newline() -> bool: 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. + some thin PTYs without SSH) still need c-j bound to submit when + display.cli_multiline_shortcuts is disabled, so we keep that legacy opt-out. See issue #22379. """ @@ -3817,22 +3908,33 @@ def _preserve_ctrl_enter_newline() -> bool: return False -def _bind_prompt_submit_keys(kb, handler) -> None: +def _bind_prompt_submit_keys( + kb, + handler, + *, + multiline_shortcuts_enabled: Optional[bool] = None, +) -> None: """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. + Enter is always submit. By default, c-j (Ctrl+J/LF) is left for the + multiline newline handler because that is the common agent-harness UX. + Users can set ``display.cli_multiline_shortcuts: false`` to restore the + legacy POSIX fallback that binds c-j to submit on local thin PTYs whose + plain Enter arrives as LF instead of CR. - Exception: on Windows, WSL, SSH sessions, Windows Terminal, and Ghostty, - 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. + Even when the setting is disabled, environments where Ctrl+Enter is known + to arrive as c-j (Windows, WSL, SSH, Windows Terminal, Ghostty) keep c-j + reserved for newline; otherwise Ctrl+Enter submits instead of composing. See _preserve_ctrl_enter_newline() and issue #22379. """ + if multiline_shortcuts_enabled is None: + multiline_shortcuts_enabled = _cli_multiline_shortcuts_enabled() kb.add("enter")(handler) - if sys.platform != "win32" and not _preserve_ctrl_enter_newline(): + if ( + sys.platform != "win32" + and not multiline_shortcuts_enabled + and not _preserve_ctrl_enter_newline() + ): kb.add("c-j")(handler) @@ -8618,7 +8720,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): ) _cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}") - _cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}") + _cprint(f" {_DIM}Multi-line: Ctrl+J, Alt+Enter, or \\+Enter for a new line{_RST}") _cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}") if _is_termux_environment(): _cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n") @@ -16356,6 +16458,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Key bindings for the input area kb = KeyBindings() + _multiline_shortcuts_enabled = _cli_multiline_shortcuts_enabled(self.config or CLI_CONFIG) + from prompt_toolkit.keys import Keys as _IgnoreKeys @kb.add(_IgnoreKeys.Ignore, eager=True) @@ -16510,7 +16614,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): return # --- Normal input routing --- - text = event.app.current_buffer.text.strip() + raw_text = event.app.current_buffer.text + if ( + _multiline_shortcuts_enabled + and event.app.current_buffer.cursor_position == len(raw_text) + and _is_backslash_line_continuation(raw_text) + ): + continued = _apply_backslash_line_continuation(raw_text) + event.app.current_buffer.text = continued + event.app.current_buffer.cursor_position = len(continued) + event.app.invalidate() + return + text = raw_text.strip() has_images = bool(self._attached_images) if text or has_images: # Handle /model directly on the UI thread so interactive pickers @@ -16664,7 +16779,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._inline_pastes(event.app.current_buffer) event.app.current_buffer.reset(append_to_history=True) - _bind_prompt_submit_keys(kb, handle_enter) + _bind_prompt_submit_keys( + kb, + handle_enter, + multiline_shortcuts_enabled=_multiline_shortcuts_enabled, + ) @kb.add('escape', 'enter') def handle_alt_enter(event): @@ -16677,19 +16796,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): """ event.current_buffer.insert_text('\n') - if _preserve_ctrl_enter_newline(): + if _multiline_shortcuts_enabled or _preserve_ctrl_enter_newline(): @kb.add('c-j') def handle_ctrl_enter_newline(event): - """Ctrl+Enter inserts a newline on Windows, WSL, SSH, and WT. + """Ctrl+J inserts a newline for multi-line input. - 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. + This is enabled by default to match Claude Code / Codex / + OpenCode behavior. On Windows Terminal and similar environments, + Ctrl+Enter is delivered as the same c-j key code, so this also + covers Ctrl+Enter there. Set display.cli_multiline_shortcuts: + false to restore legacy c-j submit behavior on unusual POSIX + PTYs where plain Enter arrives as LF. """ event.current_buffer.insert_text('\n') @@ -18960,8 +19077,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): except Exception: pass # The app enables focus reporting + mouse tracking; record that - # so _run_cleanup resets them on exit (#36823). + # so _run_cleanup resets them on exit (#36823). When multiline + # shortcuts are on, also ask supported terminals (e.g. iTerm2) + # to distinguish Shift+Enter from Enter; the same cleanup reset + # pops kitty keyboard mode and resets modifyOtherKeys. _mark_tui_input_modes_active() + if _multiline_shortcuts_enabled: + _enable_extended_enter_keys(app.output) # Drive the petdex mascot animation (no-op when no pet enabled). self._pet_start_anim() app.run() diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index d8c9ca62ab..4738aa42a9 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1173,6 +1173,13 @@ DEFAULT_CONFIG = { # "Steered into current run" confirmation bubble by setting this false. # The mid-turn steering itself still happens. "busy_steer_ack_enabled": True, + # Classic CLI multiline fallbacks beyond Alt+Enter. + # Default true matches Claude Code / Codex / OpenCode: Ctrl+J inserts + # a newline, a trailing backslash followed by Enter continues the draft, + # and supported terminals are asked to report Shift+Enter distinctly. + # Set false to restore the legacy c-j submit fallback on unusual POSIX + # PTYs whose plain Enter arrives as LF instead of CR. + "cli_multiline_shortcuts": True, # Which interface bare `hermes` (and `hermes chat`) launches by default: # "cli" — the classic prompt_toolkit REPL (default, preserves prior behavior) # "tui" — the modern Ink TUI (same as passing `--tui`) diff --git a/tests/cli/test_ctrl_enter_newline.py b/tests/cli/test_ctrl_enter_newline.py index 7249f9fa28..ba7cb2f564 100644 --- a/tests/cli/test_ctrl_enter_newline.py +++ b/tests/cli/test_ctrl_enter_newline.py @@ -24,6 +24,33 @@ from unittest.mock import patch import pytest +import sys + + +class FakeKeyBindings: + def __init__(self): + self.bound = [] + + def add(self, *keys, **_kwargs): + def _decorator(handler): + self.bound.append(keys) + return handler + + return _decorator + + +def _bind_submit_keys_for_local_linux(cli_mod, *, multiline_shortcuts_enabled): + with patch.object(sys, "platform", "linux"): + with patch.dict(os.environ, {}, clear=True): + with patch("builtins.open", side_effect=OSError("no /proc")): + kb = FakeKeyBindings() + cli_mod._bind_prompt_submit_keys( + kb, + lambda _event: None, + multiline_shortcuts_enabled=multiline_shortcuts_enabled, + ) + return kb + @pytest.mark.windows_only def test_native_windows_preserves_newline(): @@ -56,6 +83,76 @@ def test_ghostty_tmux_session_preserves_ctrl_j_newline(): assert cli_mod._preserve_ctrl_enter_newline() is True +def test_cli_multiline_shortcuts_default_on(): + """Hermes should default to the common harness behavior: Ctrl+J newline. + + Claude Code documents Ctrl+J as a no-setup newline shortcut, OpenCode's + default input_newline includes ctrl+j, and Codex exposes Ctrl+J/keymap + newline behavior. Keep Hermes aligned unless the user opts out. + """ + import cli as cli_mod + + assert cli_mod._cli_multiline_shortcuts_enabled({"display": {}}) is True + + +def test_cli_multiline_shortcuts_can_be_disabled(): + import cli as cli_mod + + assert cli_mod._cli_multiline_shortcuts_enabled( + {"display": {"cli_multiline_shortcuts": False}} + ) is False + + +def test_ctrl_j_is_not_submit_when_multiline_shortcuts_enabled(): + """With the default setting, c-j is reserved for the newline handler. + + This fixes local terminals like iTerm2 where Ctrl+J reaches prompt_toolkit + as c-j but the legacy POSIX fallback bound it to submit. + """ + import cli as cli_mod + + kb = _bind_submit_keys_for_local_linux( + cli_mod, + multiline_shortcuts_enabled=True, + ) + + assert ("enter",) in kb.bound + assert ("c-j",) not in kb.bound + + +def test_ctrl_j_legacy_submit_when_multiline_shortcuts_disabled(): + """Users can opt out to preserve Enter-as-LF submit fallback on odd PTYs.""" + import cli as cli_mod + + kb = _bind_submit_keys_for_local_linux( + cli_mod, + multiline_shortcuts_enabled=False, + ) + + assert ("enter",) in kb.bound + assert ("c-j",) in kb.bound + + +def test_backslash_enter_continuation_replaces_marker_with_newline(): + import cli as cli_mod + + assert cli_mod._apply_backslash_line_continuation("first line\\") == "first line\n" + assert cli_mod._apply_backslash_line_continuation("first line\\ ") == "first line\n" + + +def test_iterm_is_allowlisted_for_extended_enter_keys(): + """iTerm2 needs the app to request extended keys before Shift+Enter is distinct.""" + import cli as cli_mod + + assert cli_mod._terminal_supports_extended_enter_keys({"TERM_PROGRAM": "iTerm.app"}) is True + + +def test_unknown_terminal_does_not_enable_extended_enter_keys(): + import cli as cli_mod + + assert cli_mod._terminal_supports_extended_enter_keys({"TERM_PROGRAM": "unknown"}) is False + + @pytest.mark.linux_only def test_proc_version_microsoft_marker_preserves_newline(): """WSL detection via /proc when env vars are scrubbed (sudo etc.). diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 2f3d78fafc..1e8151597f 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -270,6 +270,14 @@ There are two ways to enter multi-line messages: 2. Returns the sum ``` +`Ctrl+J` and backslash continuation are enabled by default, matching Claude Code / Codex / OpenCode multiline shortcuts. On supported terminals such as iTerm2, Hermes also requests extended key reporting so `Shift+Enter` arrives as a distinct newline key. If your terminal sends LF for plain `Enter` and you need the legacy `Ctrl+J`-as-submit fallback, opt out: + +```yaml +# ~/.hermes/config.yaml +display: + cli_multiline_shortcuts: false +``` + :::info Pasting multi-line text is supported — use any of the newline keys above, or simply paste content directly. ::: @@ -285,7 +293,7 @@ Most terminals send the same byte sequence for `Enter` and `Shift+Enter` by defa | Windows Terminal Preview 1.25+ | Supported once the Kitty protocol is enabled in settings | | macOS Terminal.app, stock Windows Terminal (stable) | Not supported — `Shift+Enter` is indistinguishable from `Enter` | -Where the terminal cannot distinguish them, `Alt+Enter` and `Ctrl+J` continue to work everywhere. **On Windows Terminal specifically, `Alt+Enter` is captured by the terminal (toggles fullscreen) and never reaches Hermes — use `Ctrl+Enter` (delivered as `Ctrl+J`) or `Ctrl+J` directly for a newline.** +Where the terminal cannot distinguish them, `Alt+Enter` and `Ctrl+J` continue to work by default. **On Windows Terminal specifically, `Alt+Enter` is captured by the terminal (toggles fullscreen) and never reaches Hermes — use `Ctrl+Enter` (delivered as `Ctrl+J`) or `Ctrl+J` directly for a newline.** ## Redirecting the Agent Mid-Turn diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 97e53ab083..42af4f30ce 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1734,6 +1734,7 @@ display: skin: default # Built-in or custom CLI skin (see user-guide/features/skins) personality: "" # Legacy cosmetic field still surfaced in some summaries compact: false # Compact output mode (less whitespace) + cli_multiline_shortcuts: true # CLI: Ctrl+J, \ + Enter, and supported Shift+Enter insert newlines (false = legacy c-j submit fallback) resume_display: full # full (show previous messages on resume) | minimal (one-liner only) bell_on_complete: false # Play terminal bell when agent finishes (great for long tasks) show_reasoning: true # Show model reasoning/thinking above each response (default: true; toggle with /reasoning show|hide)