Shift+Enter and multiline input shortcut handling

NousResearch/hermes-agent · cluster 5346 · environments

Data as of

8 open items. Fix merged in #22130. Check it covers your case.

The short answer

4open issues
4open PRs
26items grouped
Known?
Yes. 26 related items grouped here, 9 issues already closed.
Fix event
A fix was merged to main in PR #22130. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #22777. That is separate from the fix event.
Still open
4 issues and 4 PRs.

These issues and pull requests concern the CLI and TUI failing to insert a newline when Shift+Enter, Alt+Enter, Ctrl+J, or similar key combinations are pressed, across various terminals and platforms including macOS, WSL, Docker, and Windows. Multiple fixes and feature additions address mapping these key sequences to newline insertion rather than prompt submission.

Open issues4

  • Issue#22908[Bug]: Shift+Enter no longer inserts a newline in classic Hermes CLI
  • Issue#22909[Bug]: TUI multiline input has no reliable modified-Enter shortcut on macOS
  • Issue#35057macOS iTerm2: Shift+Enter should insert newline in prompt_toolkit CLI
  • Issue#56407[Feature]: add `hermes terminal-setup` for native Shift+Enter newline

Open PRs4

  • PR#23921fix(cli): preserve LF as newline in Warp terminal (Shift+Enter regression #22908)
  • PR#32860fix(cli): preserve Ctrl-J newline in CR/LF terminals
  • PR#44115feat(cli): add backslash+Enter newline for terminals without Shift+Enter (#35057)
  • PR#56587feat(cli): add safe Shift+Enter terminal setup

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #22130 feat(cli): recognise Shift+Enter as a newline key (salvage #21545 + Windows docs)

    merged to main

    files: cli.py, hermes_cli/pt_input_extras.py, scripts/release.py, tests/cli/test_cli_shift_enter_newline.py, website/docs/getting-started/quickstart.md, website/docs/guides/tips.md, website/docs/user-guide/cli.md

    Show the diff
    • cli.py
      @@ -70,6 +70,13 @@
           _STEADY_CURSOR = CursorShape.BLOCK  # Non-blinking block cursor
       except (ImportError, AttributeError):
           _STEADY_CURSOR = None
      +
      +try:
      +    from hermes_cli.pt_input_extras import install_shift_enter_alias
      +    install_shift_enter_alias()
      +    del install_shift_enter_alias
      +except Exception:
      +    pass
       import threading
       import queue
       
    • hermes_cli/pt_input_extras.py
      @@ -0,0 +1,51 @@
      +"""Augmentations to prompt_toolkit's input-parsing tables.
      +
      +Imported once at CLI startup. Each helper installs a small mapping into
      +prompt_toolkit's `ANSI_SEQUENCES` so byte sequences emitted by modern
      +keyboard protocols (Kitty / xterm `modifyOtherKeys`) decode to existing
      +key tuples Hermes already binds.
      +
      +Kept in a standalone module — separate from `cli.py` — so the registrations
      +can be unit-tested without importing the whole CLI runtime.
      +"""
      +
      +from __future__ import annotations
      +
      +
      +def install_shift_enter_alias() -> int:
      +    """Map Shift+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 Shift+Enter.
      +
      +    Sequences mapped:
      +      - "\\x1b[13;2u"     — Kitty keyboard protocol / CSI-u, modifier=2 (Shift)
      +      - "\\x1b[27;2;13~"  — xterm modifyOtherKeys=2, modifier=2 (Shift)
      +      - "\\x1b[27;2;13u"  — alternate ordering some emitters use
      +
      +    The CSI-u sequence is not in stock prompt_toolkit. The modifyOtherKeys
      +    variant `\\x1b[27;2;13~` IS in stock prompt_toolkit but mapped to plain
      +    `Keys.ControlM` — i.e. Shift+Enter behaves identically to Enter, which
      +    is the very bug this helper exists to fix. We therefore overwrite
      +    those two specific keys (and `\\x1b[27;2;13u`) unconditionally; other
      +    `\\x1b[27;...;13~` sequences (Ctrl+Enter, Alt+Enter via modifyOtherKeys
      +    variants 5/6/etc.) are left untouched.
      +
      +    Default macOS Terminal and stock Windows Terminal still send the same
      +    byte for Enter and Shift+Enter, so there is no fix for those terminals
      +    at the application layer — the sequences above never reach Hermes.
      +
      +    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;2u", "\x1b[27;2;13~", "\x1b[27;2;13u"):
      +        if ANSI_SEQUENCES.get(seq) != alt_enter:
      +            ANSI_SEQUENCES[seq] = alt_enter
      +            changed += 1
      +    return changed
    • scripts/release.py
      @@ -57,6 +57,7 @@
           "[redacted:email]": "Gutslabs",
           "[redacted:email]": "nocturnum91",
           "[redacted:email]": "Abd0r",
      +    "[redacted:email]": "Abd0r",
           "[redacted:email]": "AJV20",
           "[redacted:email]": "masonjames",
           "[redacted:email]": "liquidchen",
    • tests/cli/test_cli_shift_enter_newline.py
      @@ -0,0 +1,88 @@
      +"""Verify Shift+Enter byte sequences parse to the same key tuple Alt+Enter
      +produces, so the existing Alt+Enter newline handler in `cli.py` fires for
      +terminals that emit a distinct Shift+Enter under the Kitty keyboard protocol
      +or xterm modifyOtherKeys mode.
      +"""
      +
      +from __future__ import annotations
      +
      +import pytest
      +
      +from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
      +from prompt_toolkit.input.vt100_parser import Vt100Parser
      +from prompt_toolkit.keys import Keys
      +
      +from hermes_cli.pt_input_extras import install_shift_enter_alias
      +
      +
      +SHIFT_ENTER_SEQUENCES = (
      +    "\x1b[13;2u",      # Kitty / CSI-u, modifier=2 (Shift)
      +    "\x1b[27;2;13~",   # xterm modifyOtherKeys=2
      +    "\x1b[27;2;13u",
      +)
      +
      +
      [redacted:email](autouse=True)
      +def _ensure_alias_installed():
      +    """Make every test idempotent — install the alias once per test run."""
      +    install_shift_enter_alias()
      +
      +
      +def _parse(byte_seq: str):
      +    out = []
      +    parser = Vt100Parser(out.append)
      +    for ch in byte_seq:
      +        parser.feed(ch)
      +    parser.flush()
      +    return [kp.key for kp in out]
      +
      +
      +def test_install_registers_all_three_sequences():
      +    for seq in SHIFT_ENTER_SEQUENCES:
      +        assert seq in ANSI_SEQUENCES, f"missing mapping for {seq!r}"
      +        assert ANSI_SEQUENCES[seq] == (Keys.Escape, Keys.ControlM)
      +
      +
      +def test_install_overwrites_stock_modifyotherkeys_shift_enter():
      +    """Stock prompt_toolkit maps `\\x1b[27;2;13~` to plain Keys.ControlM —
      +    i.e. it drops the Shift modifier and treats Shift+Enter like Enter,
      +    which is the bug this helper exists to fix. The install must overwrite
      +    that entry."""
      +    seq = "\x1b[27;2;13~"
      +    ANSI_SEQUENCES[seq] = Keys.ControlM
      +    install_shift_enter_alias()
      +    assert ANSI_SEQUENCES[seq] == (Keys.Escape, Keys.ControlM)
      +
      +
      +def test_install_returns_zero_when_already_correct():
      +    """Idempotency — running install twice should not report a second change."""
      +    install_shift_enter_alias()
      +    assert install_shift_enter_alias() == 0
      +
      +
      +def test_csi_u_shift_enter_parses_as_alt_enter():
      +    """Kitty keyboard protocol Shift+Enter must parse to the same key tuple
      +    Alt+Enter produces, so the existing handler is reused."""
      +    alt_enter = _parse("\x1b\r")
      +    shift_enter = _parse("\x1b[13;2u")
      +    assert shift_enter == alt_enter, (
      +        f"Shift+Enter via CSI-u should parse identically to Alt+Enter; "
      +        f"got {shift_enter!r} vs {alt_enter!r}"
      +    )
      +
      +
      +def test_modify_other_keys_shift_enter_parses_as_alt_enter():
      +    """xterm modifyOtherKeys=2 Shift+Enter must parse identically to Alt+Enter."""
      +    alt_enter = _parse("\x1b\r")
      +    shift_enter = _parse("\x1b[27;2;13~")
      +    assert shift_enter == alt_enter
      +
      +
      +def test_plain_enter_remains_distinct_from_alt_enter():
      +    """Plain Enter must keep emitting a single key (submit), not a two-key
      +    Alt+Enter tuple — otherwise we would have broken submit."""
      +    enter = _parse("\r")
      +    alt_enter = _parse("\x1b\r")
      +    assert enter != alt_enter
      +    assert len(enter) == 1
      +    assert len(alt_enter) == 2
    • website/docs/getting-started/quickstart.md
      @@ -204,7 +204,7 @@ Type `/` to see an autocomplete dropdown of all commands:
       
       ### Multi-line input
       
      -Press `Alt+Enter` or `Ctrl+J` to add a new line. Great for pasting code or writing detailed prompts.
      +Press `Alt+Enter`, `Ctrl+J`, or `Shift+Enter` to add a new line. `Shift+Enter` requires a terminal that sends it as a distinct sequence (Kitty / foot / WezTerm / Ghostty by default; iTerm2 / Alacritty / VS Code terminal once the Kitty keyboard protocol is enabled). `Alt+Enter` and `Ctrl+J` work in every terminal.
       
       ### Interrupt the agent
       
    • website/docs/guides/tips.md
      @@ -36,7 +36,7 @@ Before writing a long prompt explaining how to do something, check if there's al
       
       ### Multi-Line Input
       
      -Press **Alt+Enter** (or **Ctrl+J**) to insert a newline without sending. This lets you compose multi-line prompts, paste code blocks, or structure complex requests before hitting Enter to send.
      +Press **Alt+Enter**, **Ctrl+J**, or **Shift+Enter** to insert a newline without sending. `Shift+Enter` only works when the terminal sends it as a distinct keystroke (Kitty / foot / WezTerm / Ghostty by default; iTerm2 / Alacritty / VS Code terminal once the Kitty keyboard protocol is enabled). The other two work in every terminal.
       
       ### Paste Detection
       
    • website/docs/user-guide/cli.md
      @@ -92,7 +92,7 @@ When resuming a previous session (`hermes -c` or `hermes --resume <id>`), a "Pre
       | Key | Action |
       |-----|--------|
       | `Enter` | Send message |
      -| `Alt+Enter` or `Ctrl+J` | New line (multi-line input) |
      +| `Alt+Enter`, `Ctrl+J`, or `Shift+Enter` | New line (multi-line input). `Shift+Enter` requires a terminal that distinguishes it from `Enter` — see below. On Windows Terminal, `Alt+Enter` is captured by the terminal (fullscreen toggle); use `Ctrl+Enter` or `Ctrl+J` instead. |
       | `Alt+V` | Paste an image from the clipboard when supported by the terminal |
       | `Ctrl+V` | Paste text and opportunistically attach clipboard images |
       | `Ctrl+B` | Start/stop voice recording when voice mode is enabled (`voice.record_key`, default: `ctrl+b`) |
      @@ -204,7 +204,7 @@ personalities:
       
       There are two ways to enter multi-line messages:
       
      -1. **`Alt+Enter` or `Ctrl+J`** — inserts a new line
      +1. **`Alt+Enter`, `Ctrl+J`, or `Shift+Enter`** — inserts a new line
       2. **Backslash continuation** — end a line with `\` to continue:
       
       ```
      @@ -214,9 +214,22 @@ There are two ways to enter multi-line messages:
       ```
       
       :::info
      -Pasting multi-line text is supported — use `Alt+Enter` or `Ctrl+J` to insert newlines, or simply paste content directly.
      +Pasting multi-line text is supported — use any of the newline keys above, or simply paste content directly.
       :::
       
      +### Shift+Enter compatibility
      +
      +Most terminals send the same byte sequence for `Enter` and `Shift+Enter` by default, so applications cannot distinguish them. Hermes recognises `Shift+Enter` only when the terminal sends a distinct sequence via the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) or xterm's `modifyOtherKeys` mode.
      +
      +| Terminal | Status |
      +|---|---|
      +| Kitty, foot, WezTerm, Ghostty | Distinct `Shift+Enter` enabled by default |
      +| iTerm2 (recent), Alacritty, VS Code terminal, Warp | Supported once the Kitty protocol is enabled in settings |
      +| 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.**
      +
       ## Interrupting the Agent
       
       You can interrupt the agent at any point:
  • #22777 fix(cli): make Ctrl+Enter insert newline on WSL/SSH/Windows Terminal

    merged to main

    files: cli.py, hermes_cli/pt_input_extras.py, tests/cli/test_cli_init.py, tests/cli/test_ctrl_enter_newline.py

    Show the diff
    • cli.py
      @@ -72,9 +72,10 @@
           _STEADY_CURSOR = None
       
       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()
      -    del install_shift_enter_alias
      +    install_ctrl_enter_alias()
      +    del install_shift_enter_alias, install_ctrl_enter_alias
       except Exception:
           pass
       import threading
      @@ -1862,6 +1863,37 @@ def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
       )
       
       
      +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:
           """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
           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.
      +    Exception: on Windows, WSL, SSH sessions, and Windows Terminal,
      +    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.
      +    See _preserve_ctrl_enter_newline() and issue #22379.
           """
           kb.add("enter")(handler)
      -    if sys.platform != "win32":
      +    if sys.platform != "win32" and not _preserve_ctrl_enter_newline():
               kb.add("c-j")(handler)
       
       
      @@ -10855,18 +10889,19 @@ def handle_alt_enter(event):
                   """
                   event.current_buffer.insert_text('\n')
       
      -        if sys.platform == "win32":
      +        if _preserve_ctrl_enter_newline():
                   @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.
      +            def handle_ctrl_enter_newline(event):
      +                """Ctrl+Enter inserts a newline on Windows, WSL, SSH, and WT.
      +
      +                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.
                       """
                       event.current_buffer.insert_text('\n')
       
    • hermes_cli/pt_input_extras.py
      @@ -49,3 +49,35 @@ def install_shift_enter_alias() -> int:
                   ANSI_SEQUENCES[seq] = alt_enter
                   changed += 1
           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
    • tests/cli/test_cli_init.py
      @@ -166,13 +166,14 @@ class TestPromptToolkitTerminalCompatibility:
           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.
      +        On a bare local POSIX TTY (no SSH/WSL/WT) we keep c-j → submit so
      +        Enter works on thin PTYs (docker exec, certain ssh configurations).
      +        On Windows, WSL, SSH sessions, and Windows Terminal we leave c-j
      +        unbound here so it can be used as the Ctrl+Enter newline keystroke
      +        without conflicting with submit. See issue #22379.
               """
               import sys as _sys
      +        import os as _os
               from unittest.mock import patch as _patch
               from prompt_toolkit.key_binding import KeyBindings
       
      @@ -181,14 +182,27 @@ def test_lf_enter_binds_to_submit_handler_posix(self):
               def submit_handler(event):
                   return None
       
      -        # POSIX: both enter and c-j submit
      -        with _patch.object(_sys, "platform", "linux"):
      +        # Bare local POSIX (no SSH/WSL markers): both enter and c-j submit.
      +        with _patch.object(_sys, "platform", "linux"), \
      +             _patch.dict(_os.environ, {}, 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 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
               # added separately in the prompt setup.
               with _patch.object(_sys, "platform", "win32"):
    • tests/cli/test_ctrl_enter_newline.py
      @@ -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

7 PRs closed without merging

From the close comments: merged via another change 2 · closed without comment 1 · partial fix 1 · other 3

  • #5846 fix(cli): map Shift+Enter newline sequence · partial fix → #852, #1798, #2331, #2345, #2347, #2349, #2511, #3798, #3852
  • #21545 feat(cli): recognise Shift+Enter as a newline key · merged via another change → #22130
  • #22911 fix(cli): preserve classic multiline on macOS · other → c193fdf, cf8862c
  • #22983 fix(cli): restore Shift+Enter newline in classic Hermes CLI · closed without comment
  • #23650 fix(cli): restore local shift-enter newline · other → e021ec5
Show 2 more
  • #25111 fix(cli): preserve Ctrl-J newline on macOS · other → #22911, #23448, #22908
  • #61215 fix(cli): enable standard multiline shortcuts · merged via another change → #86768
Closed issues · 9
  • Closed#5346[UX] Add Shift+Enter newline support in CLI input
  • Closed#11086feat: Add Shift+Enter support for multi-line input in chat input box
  • Closed#15824[Docker] Shift+Enter / Alt+Enter newline does not work in TUI via docker exec
  • Closed#21512TUI: Shift+Enter and Alt+Enter both submit instead of inserting newline in Warp terminal
  • Closed#22379Ctrl+Enter newline submits prompt over SSH/WSL because c-j is bound as submit
  • Closed#23573[Feature]: When using Hermes Agent in WSL, I used to be able to press **Ctrl+J** to wrap lines when entering commands, but this no longer works now. Currently, **Ctrl+J**, **Shift+Enter**, and **Alt+Enter** all fail to create a new line.
  • Closed#24837[Bug][Regression]: Shift+Enter / Option+Enter submit prompt instead of inserting newline on Hermes CLI (not --tui) since 0.13.0
  • Closed#24890TUI: Shift+Enter submits instead of newline on macOS Terminal.app and Alacritty
  • Closed#25568[Bug]: Shift+Enter triggers send instead of newline
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.06: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
environments
Merged PRs
#22130, #22777
Closed issues
9
Attempts
7 PRs closed without merging
Fix link checked
9 May 2026

For your agent

The same facts, typed.