diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 52b81064..cd03e0ee 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -310,8 +310,33 @@ def _build_cli_key_bindings() -> KeyBindings: * 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 for multi-line input. + * Alt+Enter -> insert a newline for multi-line input. Universally + supported, so this is the reliable multiline shortcut. + * Shift+Enter -> insert a newline on terminals that emit the CSI-u + (kitty / fixterms) keyboard-protocol encoding for it. """ + # Terminals speaking the CSI-u (kitty / fixterms) keyboard protocol -- e.g. + # kitty, Ghostty and WezTerm when it is enabled -- send Shift+Enter as the + # escape sequence "\x1b[13;2u". prompt_toolkit 3.0 has no support for that + # protocol (no way to negotiate it, no default mapping for the sequence), so + # when such a terminal sends it the Vt100Parser fails to recognise the + # sequence and dumps the raw bytes ("^[[13;2u") straight into the buffer. + # + # Register the sequence so it parses as one keypress bound to insert a + # newline instead. "\x1b[13;2u" is absent from prompt_toolkit's default + # ANSI_SEQUENCES table, so this is a pure addition -- setdefault() overrides + # nothing -- carried on Keys.ControlF3, an enum member prompt_toolkit + # declares but never wires to a default sequence or binding. + # + # This is best-effort: without protocol negotiation we can only *react* to a + # CSI-u sequence a terminal already emits, not *request* one. Terminals that + # collapse Shift+Enter into a plain Enter are indistinguishable and fall back + # to Alt+Enter, which is why Alt+Enter stays the primary shortcut. + with suppress(Exception): + from prompt_toolkit.input import ansi_escape_sequences as _aes + + _aes.ANSI_SEQUENCES.setdefault("\x1b[13;2u", Keys.ControlF3) + kb = KeyBindings() @kb.add("enter") @@ -327,6 +352,10 @@ def _build_cli_key_bindings() -> KeyBindings: def _(event): event.current_buffer.insert_text("\n") + @kb.add(Keys.ControlF3) # Shift+Enter on CSI-u capable terminals + def _(event): + event.current_buffer.insert_text("\n") + return kb diff --git a/tests/cli/test_cli_input.py b/tests/cli/test_cli_input.py index c7857f1f..b958ecb4 100644 --- a/tests/cli/test_cli_input.py +++ b/tests/cli/test_cli_input.py @@ -127,6 +127,29 @@ async def test_alt_enter_inserts_newline_on_lf_terminals(): assert result == "foo\nbar" +@pytest.mark.asyncio +async def test_csi_u_shift_enter_inserts_newline_not_raw_escape(): + """Terminals speaking the CSI-u (kitty / fixterms) keyboard protocol send + Shift+Enter as "\\x1b[13;2u". prompt_toolkit has no support for that protocol + and would otherwise dump the raw escape bytes ("^[[13;2u") into the buffer; + the binding must instead parse it as one keypress and insert a newline. Only + a real PromptSession/parser exercises the ANSI_SEQUENCES registration. + """ + from prompt_toolkit.application import create_app_session + from prompt_toolkit.input import create_pipe_input + from prompt_toolkit.output import DummyOutput + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=DummyOutput()): + commands._init_prompt_session() + session = commands._PROMPT_SESSION + pipe_input.send_text("foo\x1b[13;2ubar\r") + result = await session.prompt_async("> ") + + # A newline is inserted and no raw escape bytes leak into the result. + assert result == "foo\nbar" + + def test_thinking_spinner_pause_stops_and_restarts(): """Pause should stop the active spinner and restart it afterward.""" spinner = MagicMock()