diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 0f69bf792..9e1501955 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -326,11 +326,19 @@ def _build_cli_key_bindings() -> KeyBindings: # 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. + # + # "\x1b[27;2;13~" (the older xterm modifyOtherKeys / rxvt encoding of + # Shift+Enter) is already registered by prompt_toolkit by default -- as + # Keys.ControlM, i.e. plain Enter/submit. A plain setdefault() would be a + # no-op against that existing entry, silently leaving this Shift+Enter + # variant behaving like a submit instead of inserting a newline. Assign + # directly to override it, since inserting a newline is the whole point + # of a Shift+Enter binding here. 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.ControlF3) + _aes.ANSI_SEQUENCES[_seq] = Keys.ControlF3 kb = KeyBindings() diff --git a/tests/cli/test_cli_input.py b/tests/cli/test_cli_input.py index b38cd20f8..824409f2a 100644 --- a/tests/cli/test_cli_input.py +++ b/tests/cli/test_cli_input.py @@ -118,6 +118,28 @@ async def test_raw_lf_enter_still_submits_like_wsl_terminals(): assert result == "hello" +@pytest.mark.asyncio +async def test_xterm_modifyotherkeys_shift_enter_inserts_newline(): + """"\\x1b[27;2;13~" (the older xterm modifyOtherKeys/rxvt encoding of + Shift+Enter) is registered by prompt_toolkit *by default* as plain + Enter/submit, so a `setdefault()`-based override would silently no-op + against it. This must actually insert a newline like the kitty CSI-u + encoding does, which only a real PromptSession/parser can confirm. + """ + 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[27;2;13~bar\r") + result = await session.prompt_async("> ") + + assert result == "foo\nbar" + + def test_thinking_spinner_pause_stops_and_restarts(): """Pause should stop the active spinner and restart it afterward.""" spinner = MagicMock()