fix: make CLI multiline shortcuts work by default

This commit is contained in:
John Lussier
2026-07-08 18:12:38 -07:00
committed by Teknium
parent 24af6685b0
commit 2ae7884ffa
5 changed files with 263 additions and 28 deletions
+149 -27
View File
@@ -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()
+7
View File
@@ -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`)
+97
View File
@@ -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.).
+9 -1
View File
@@ -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
+1
View File
@@ -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)