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.formatted_text import ANSI, HTML # 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 rich.console import Console # 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)
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:
"""Create the prompt_toolkit session with persistent file history."""
global _PROMPT_SESSION, _SAVED_TERM_ATTRS
@@ -318,7 +363,11 @@ def _init_prompt_session() -> None:
_PROMPT_SESSION = PromptSession(
history=SafeFileHistory(str(history_file)),
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(),
)
+40 -7
View File
@@ -1,4 +1,3 @@
import asyncio
from contextlib import nullcontext
from io import StringIO
from unittest.mock import AsyncMock, MagicMock, call, patch
@@ -26,7 +25,7 @@ async def test_read_interactive_input_async_returns_input(mock_prompt_session):
mock_prompt_session.prompt_async.return_value = "hello world"
result = await commands._read_interactive_input_async()
assert result == "hello world"
mock_prompt_session.prompt_async.assert_called_once()
args, _ = mock_prompt_session.prompt_async.call_args
@@ -46,20 +45,54 @@ def test_init_prompt_session_creates_session():
"""Test that _init_prompt_session initializes the global session."""
# Ensure global is None before test
commands._PROMPT_SESSION = None
with patch("nanobot.cli.commands.PromptSession") as MockSession, \
patch("nanobot.cli.commands.FileHistory") as MockHistory, \
patch("pathlib.Path.home") as mock_home:
mock_home.return_value = MagicMock()
commands._init_prompt_session()
assert commands._PROMPT_SESSION is not None
MockSession.assert_called_once()
_, 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.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():