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:
+50
-1
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user