diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 01db01dd..df4ed5ce 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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(), ) diff --git a/tests/cli/test_cli_input.py b/tests/cli/test_cli_input.py index 34046e8d..977df8c1 100644 --- a/tests/cli/test_cli_input.py +++ b/tests/cli/test_cli_input.py @@ -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():