feat(cli): support multiline input via Shift+Enter / Alt+Enter

The interactive prompt used a single-line buffer (multiline=False), so
there was no way to compose a multi-line message before submitting.

Switch to a multiline-capable buffer with custom key bindings: Enter still
submits (unchanged feel), Alt+Enter always inserts a newline, and
Shift+Enter inserts a newline on terminals that emit a distinguishable
CSI-u sequence for it (kitty, iTerm2 with the fixterms protocol) by mapping
those sequences onto the otherwise-unused ControlJ key.
This commit is contained in:
wangjunwei
2026-07-07 15:41:08 +08:00
committed by Xubin Ren
parent c5e053f83b
commit 18a230de75
2 changed files with 90 additions and 8 deletions
+50 -1
View File
@@ -50,6 +50,8 @@ from prompt_toolkit import PromptSession, print_formatted_text # noqa: E402
from prompt_toolkit.application import run_in_terminal # noqa: E402 from prompt_toolkit.application import run_in_terminal # noqa: E402
from prompt_toolkit.formatted_text import ANSI, HTML # noqa: E402 from prompt_toolkit.formatted_text import ANSI, HTML # noqa: E402
from prompt_toolkit.history import FileHistory # noqa: E402 from prompt_toolkit.history import FileHistory # noqa: E402
from prompt_toolkit.key_binding import KeyBindings # noqa: E402
from prompt_toolkit.keys import Keys # noqa: E402
from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402 from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402
from rich.console import Console # noqa: E402 from rich.console import Console # noqa: E402
from rich.markdown import Markdown # noqa: E402 from rich.markdown import Markdown # noqa: E402
@@ -300,6 +302,49 @@ def _restore_terminal() -> None:
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS) termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
def _build_cli_key_bindings() -> KeyBindings:
"""Key bindings for the interactive prompt.
Behaviour:
* Enter -> submit the current input (keeps the familiar
single-line Enter-to-send feel even though the buffer
is multiline-capable).
* Alt+Enter -> insert a newline. Universally supported across
terminal emulators, so this is the reliable way to
compose multi-line input.
* Shift+Enter -> insert a newline *if* the terminal sends a dedicated
sequence for it (kitty / iTerm2 with the CSI-u /
fixterms keyboard protocol). Plain terminals collapse
Shift+Enter into Enter and cannot be distinguished, in
which case Alt+Enter is the fallback.
"""
# prompt_toolkit has no symbolic Keys.ShiftEnter and @kb.add() rejects raw
# escape strings, so register the CSI-u Shift+Enter sequences against a
# spare key symbol (ControlJ) and bind that. Terminals that never emit
# these sequences simply won't trigger the binding.
with suppress(Exception):
from prompt_toolkit.input import ansi_escape_sequences as _aes
for _seq in ("\x1b[13;2u", "\x1b[27;2;13~"):
_aes.ANSI_SEQUENCES.setdefault(_seq, Keys.ControlJ)
kb = KeyBindings()
@kb.add("enter")
def _(event):
event.current_buffer.validate_and_handle()
@kb.add("escape", "enter") # Alt+Enter / Meta+Enter
def _(event):
event.current_buffer.insert_text("\n")
@kb.add(Keys.ControlJ) # Shift+Enter on CSI-u capable terminals
def _(event):
event.current_buffer.insert_text("\n")
return kb
def _init_prompt_session() -> None: def _init_prompt_session() -> None:
"""Create the prompt_toolkit session with persistent file history.""" """Create the prompt_toolkit session with persistent file history."""
global _PROMPT_SESSION, _SAVED_TERM_ATTRS global _PROMPT_SESSION, _SAVED_TERM_ATTRS
@@ -318,7 +363,11 @@ def _init_prompt_session() -> None:
_PROMPT_SESSION = PromptSession( _PROMPT_SESSION = PromptSession(
history=SafeFileHistory(str(history_file)), history=SafeFileHistory(str(history_file)),
enable_open_in_editor=False, enable_open_in_editor=False,
multiline=False, # Enter submits (single line mode) # Multiline-capable buffer; Enter still submits via the custom key
# bindings, while Shift+Enter (supported terminals) or Alt+Enter adds
# a newline.
multiline=True,
key_bindings=_build_cli_key_bindings(),
) )
+35 -2
View File
@@ -1,4 +1,3 @@
import asyncio
from contextlib import nullcontext from contextlib import nullcontext
from io import StringIO from io import StringIO
from unittest.mock import AsyncMock, MagicMock, call, patch from unittest.mock import AsyncMock, MagicMock, call, patch
@@ -58,8 +57,42 @@ def test_init_prompt_session_creates_session():
assert commands._PROMPT_SESSION is not None assert commands._PROMPT_SESSION is not None
MockSession.assert_called_once() MockSession.assert_called_once()
_, kwargs = MockSession.call_args _, kwargs = MockSession.call_args
assert kwargs["multiline"] is False # Buffer is multiline-capable so Shift+Enter / Alt+Enter can insert
# newlines; Enter-to-submit is restored via custom key bindings.
assert kwargs["multiline"] is True
assert kwargs["enable_open_in_editor"] is False assert kwargs["enable_open_in_editor"] is False
assert kwargs.get("key_bindings") is not None
def test_cli_key_bindings_enter_submits_and_alt_enter_newlines():
"""Enter submits the buffer; Alt+Enter and Shift+Enter insert a newline."""
from prompt_toolkit.keys import Keys
kb = commands._build_cli_key_bindings()
def _keys(binding):
return tuple(getattr(k, "value", k) for k in binding.keys)
bound = {_keys(b): b for b in kb.bindings}
# Enter -> submit
enter = bound[(Keys.Enter.value,)]
buf = MagicMock()
enter.call(MagicMock(current_buffer=buf))
buf.validate_and_handle.assert_called_once()
buf.insert_text.assert_not_called()
# Alt+Enter (escape, enter) -> newline
alt_enter = bound[(Keys.Escape.value, Keys.Enter.value)]
buf = MagicMock()
alt_enter.call(MagicMock(current_buffer=buf))
buf.insert_text.assert_called_once_with("\n")
# ControlJ (mapped Shift+Enter on CSI-u terminals) -> newline
ctrl_j = bound[(Keys.ControlJ.value,)]
buf = MagicMock()
ctrl_j.call(MagicMock(current_buffer=buf))
buf.insert_text.assert_called_once_with("\n")
def test_thinking_spinner_pause_stops_and_restarts(): def test_thinking_spinner_pause_stops_and_restarts():