fix: make CLI multiline shortcuts work by default
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user