fix(cli): stop hijacking ControlJ for Shift+Enter, it breaks Enter on WSL
Keys.ControlJ is also the literal LF byte ("\x0a") that some terminals send
for a plain Enter keypress -- prompt_toolkit's own default bindings handle
this by re-feeding it as ControlM/submit, and calls out WSL by name as the
case that needs it. Binding our Shift+Enter handler to ControlJ shadowed
that default, so on any terminal sending LF for Enter, pressing Enter only
ever inserted a newline and the prompt could never be submitted.
Register the CSI-u Shift+Enter sequences against Keys.ControlF3 instead: an
enum member prompt_toolkit declares but never wires to a default ANSI
sequence or key binding, so it's only reachable through our own mapping.
Also add a regression test that drives a real PromptSession/Vt100Parser
with a raw LF byte -- the existing key-binding test invoked handlers
directly against a mocked buffer, which exercises the handler logic but not
prompt_toolkit's key-resolution precedence, so it couldn't have caught this.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
committed by
Xubin Ren
co-authored by
Claude Opus 4.8
parent
18a230de75
commit
ea0516e655
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user