diff --git a/cli.py b/cli.py index 064eb3618a..596794909f 100644 --- a/cli.py +++ b/cli.py @@ -70,6 +70,13 @@ try: _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 diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py new file mode 100644 index 0000000000..41b4727a5a --- /dev/null +++ b/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 diff --git a/tests/cli/test_cli_shift_enter_newline.py b/tests/cli/test_cli_shift_enter_newline.py new file mode 100644 index 0000000000..4ea15a7c8b --- /dev/null +++ b/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", +) + + +@pytest.fixture(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 diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index d62f347668..3831f5c3c2 100644 --- a/website/docs/getting-started/quickstart.md +++ b/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 diff --git a/website/docs/guides/tips.md b/website/docs/guides/tips.md index 4d21b73579..b8f140bd48 100644 --- a/website/docs/guides/tips.md +++ b/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 diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index be92044fc5..997c58cad8 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -92,7 +92,7 @@ When resuming a previous session (`hermes -c` or `hermes --resume `), 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. | | `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,21 @@ 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 | +| macOS Terminal.app, stock Windows Terminal | Not supported — `Shift+Enter` is indistinguishable from `Enter` | + +Where the terminal cannot distinguish them, `Alt+Enter` and `Ctrl+J` continue to work everywhere. + ## Interrupting the Agent You can interrupt the agent at any point: