diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index df4ed5ce..0f69bf79 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -319,14 +319,18 @@ def _build_cli_key_bindings() -> KeyBindings: 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. + # escape strings, so register the CSI-u Shift+Enter sequences against + # Keys.ControlF3 -- an enum member prompt_toolkit declares but never wires + # to a default ANSI sequence or key binding, unlike e.g. Keys.ControlJ, + # which is also the literal LF byte ("\x0a") that some terminals (WSL is + # the case prompt_toolkit itself calls out) send for a plain Enter + # keypress. Aliasing to ControlJ made Enter stop submitting there, since + # our handler shadowed prompt_toolkit's own "treat \n as \r" fallback. 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) + _aes.ANSI_SEQUENCES.setdefault(_seq, Keys.ControlF3) kb = KeyBindings() @@ -338,7 +342,7 @@ def _build_cli_key_bindings() -> KeyBindings: def _(event): event.current_buffer.insert_text("\n") - @kb.add(Keys.ControlJ) # Shift+Enter on CSI-u capable terminals + @kb.add(Keys.ControlF3) # Shift+Enter on CSI-u capable terminals def _(event): event.current_buffer.insert_text("\n") diff --git a/tests/cli/test_cli_input.py b/tests/cli/test_cli_input.py index 977df8c1..b38cd20f 100644 --- a/tests/cli/test_cli_input.py +++ b/tests/cli/test_cli_input.py @@ -88,13 +88,36 @@ def test_cli_key_bindings_enter_submits_and_alt_enter_newlines(): 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,)] + # ControlF3 (synthetic carrier for the Shift+Enter CSI-u sequence) -> newline. + # Not ControlJ: that's also the literal LF byte some terminals (e.g. WSL) + # send for a plain Enter keypress, so binding it would break submit there. + shift_enter = bound[(Keys.ControlF3.value,)] buf = MagicMock() - ctrl_j.call(MagicMock(current_buffer=buf)) + shift_enter.call(MagicMock(current_buffer=buf)) buf.insert_text.assert_called_once_with("\n") +@pytest.mark.asyncio +async def test_raw_lf_enter_still_submits_like_wsl_terminals(): + """A raw LF byte (\\x0a) is what some terminals -- e.g. WSL -- send for a + plain Enter keypress. It must submit the buffer, not insert a newline; + a mock buffer can't catch a key binding shadowing prompt_toolkit's own + default \\n-as-\\r handling, so this drives a real PromptSession/parser. + """ + 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("hello\x0aworld\r") + result = await session.prompt_async("> ") + + assert result == "hello" + + def test_thinking_spinner_pause_stops_and_restarts(): """Pause should stop the active spinner and restart it afterward.""" spinner = MagicMock()