From af3321b3684abc918aa811a6d84ab510e1017a3a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 22 Aug 2026 14:03:20 -0400 Subject: [PATCH 1/5] Added support for bracketed paste Translate newlines in pasted text into Enter keystrokes so multi-line pastes execute as sequential commands and multiline commands continue as expected. --- CHANGELOG.md | 2 ++ cmd2/cmd2.py | 67 +++++++++++++++++++++++++++++++-------------- tests/test_cmd2.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f72d081a..0a589e7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - Enhancements - Enabled Ctrl-Z suspension at the prompt + - Added bracketed paste support so multi-line pastes execute sequentially and multiline commands + continue as expected ## 4.2.0 (August 6, 2026) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index a1d9bb828..c0e9e3ae1 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -78,6 +78,8 @@ from prompt_toolkit.history import InMemoryHistory from prompt_toolkit.input import DummyInput, create_input from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent +from prompt_toolkit.keys import Keys from prompt_toolkit.output import DummyOutput, create_output from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title @@ -740,6 +742,49 @@ def _should_continue_multiline(self) -> bool: # No macro found or already processed. The statement is complete. return False + def _create_key_bindings(self, completekey: str) -> KeyBindings: + """Create and configure custom key bindings for the PromptSession.""" + key_bindings = KeyBindings() + + if completekey != self.DEFAULT_COMPLETEKEY: + + @key_bindings.add(completekey) + def _trigger_completion(event: KeyPressEvent) -> None: # pragma: no cover + """Trigger completion using the custom completion key.""" + b = event.current_buffer + if b.complete_state: + b.complete_next() + else: + b.start_completion(select_first=False) + + @key_bindings.add("enter", filter=filters.completion_is_selected) + def _accept_completion(event: KeyPressEvent) -> None: # pragma: no cover + """Accept a selected completion on Enter without submitting the command.""" + event.current_buffer.complete_state = None + + @key_bindings.add(Keys.BracketedPaste) + def _handle_bracketed_paste(event: KeyPressEvent) -> None: + """Handle bracketed paste by feeding lines as keystrokes separated by Enter. + + By default, prompt_toolkit inserts pasted text as a single buffer blob. + Translating newlines into Enter keystrokes allows multiple pasted commands + to execute sequentially and multiline commands to continue as expected. + """ + data = event.data.replace("\r\n", "\n").replace("\r", "\n") + if "\n" not in data: + event.current_buffer.insert_text(data) + return + + key_presses = [] + for i, line in enumerate(data.split("\n")): + if i > 0: + key_presses.append(KeyPress(Keys.ControlM, "\r")) + key_presses.extend(KeyPress(ch, ch) for ch in line) + + event.key_processor.feed_multiple(key_presses) + + return key_bindings + def _create_main_session( self, *, @@ -756,26 +801,6 @@ def _create_main_session( Otherwise, uses dummy drivers to support non-interactive streams like pipes or files. """ - # Configure custom key bindings - key_bindings = KeyBindings() - - # Add a binding for 'enter' that triggers only when a completion is selected. - # This allows accepting a completion without submitting the command. - @key_bindings.add("enter", filter=filters.completion_is_selected) - def _(event: Any) -> None: # pragma: no cover - event.current_buffer.complete_state = None - - if completekey != self.DEFAULT_COMPLETEKEY: - # Configure prompt_toolkit `KeyBindings` with the custom key for completion - @key_bindings.add(completekey) - def _(event: Any) -> None: # pragma: no cover - """Trigger completion.""" - b = event.current_buffer - if b.complete_state: - b.complete_next() - else: - b.start_completion(select_first=False) - # Base configuration kwargs: dict[str, Any] = { "auto_suggest": AutoSuggestFromHistory() if auto_suggest else None, @@ -787,7 +812,7 @@ def _(event: Any) -> None: # pragma: no cover "completer": Cmd2Completer(self), "enable_suspend": True, "history": Cmd2History(item.raw for item in self.history), - "key_bindings": key_bindings, + "key_bindings": self._create_key_bindings(completekey), "lexer": Cmd2Lexer(self), "multiline": filters.Condition(self._should_continue_multiline), "prompt_continuation": self.continuation_prompt, diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index a1c1eb568..3b11f5671 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -4752,3 +4752,71 @@ def do_base(self, _: argparse.Namespace) -> None: root_parser = cast(cmd2.Cmd2ArgumentParser, app.command_parsers.get(app.do_base)) subparsers_action = root_parser.get_subparsers_action() assert not subparsers_action._name_parser_map + + +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions", +) +def test_bracketed_paste_single_line(base_app) -> None: + """Test pasting single line text without newlines.""" + with create_pipe_input() as pipe_input: + base_app.main_session = PromptSession( + input=pipe_input, + output=DummyOutput(), + key_bindings=base_app.main_session.key_bindings, + multiline=base_app.main_session.multiline, + ) + + pipe_input.send_text("\x1b[200~help\x1b[201~\n") + line = base_app._read_command_line("prompt> ") + assert line == "help" + + +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions", +) +def test_bracketed_paste_multiple_commands(base_app) -> None: + """Test pasting multiple lines with newlines.""" + with create_pipe_input() as pipe_input: + base_app.main_session = PromptSession( + input=pipe_input, + output=DummyOutput(), + key_bindings=base_app.main_session.key_bindings, + multiline=base_app.main_session.multiline, + ) + + pipe_input.send_text("\x1b[200~help\nhistory\n\x1b[201~") + line1 = base_app._read_command_line("prompt> ") + assert line1 == "help" + line2 = base_app._read_command_line("prompt> ") + assert line2 == "history" + + +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions", +) +def test_bracketed_paste_multiline_command() -> None: + """Test pasting multiline command awaiting terminator.""" + + class MultilineApp(cmd2.Cmd): + def __init__(self) -> None: + super().__init__(multiline_commands=["sql"]) + + app = MultilineApp() + with create_pipe_input() as pipe_input: + app.main_session = PromptSession( + input=pipe_input, + output=DummyOutput(), + key_bindings=app.main_session.key_bindings, + multiline=app.main_session.multiline, + prompt_continuation=app.main_session.prompt_continuation, + ) + + pipe_input.send_text("\x1b[200~sql select 1\nfrom table;\nhelp\n\x1b[201~") + line1 = app._read_command_line("prompt> ") + assert line1 == "sql select 1\nfrom table;" + line2 = app._read_command_line("prompt> ") + assert line2 == "help" From 0460070e890823d5f39362f7b0af14cda11bb31d Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 22 Aug 2026 14:15:32 -0400 Subject: [PATCH 2/5] Added unit test for default PromptSession settings --- tests/test_cmd2.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 3b11f5671..614a57a0b 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -17,7 +17,7 @@ from prompt_toolkit.completion import DummyCompleter from prompt_toolkit.input import DummyInput, create_pipe_input from prompt_toolkit.output import DummyOutput -from prompt_toolkit.shortcuts import PromptSession +from prompt_toolkit.shortcuts import CompleteStyle, PromptSession from rich.style import Style from rich.text import Text @@ -4378,6 +4378,13 @@ def test_path_complete_users_windows(monkeypatch, base_app): assert expected in matches +def test_main_session_defaults(base_app: cmd2.Cmd) -> None: + """Verify default configuration of the main PromptSession.""" + assert base_app.main_session.complete_style == CompleteStyle.MULTI_COLUMN + assert base_app.main_session.complete_while_typing is False + assert base_app.main_session.enable_suspend is True + + def test_refresh_interval() -> None: # Test default value default_app = cmd2.Cmd() From cbeac7628842996a197fc4d5333bf4378923e7bf Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 22 Aug 2026 14:29:21 -0400 Subject: [PATCH 3/5] Updated CHANGELOG --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a589e7e7..3700678a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ - Enhancements - Enabled Ctrl-Z suspension at the prompt - - Added bracketed paste support so multi-line pastes execute sequentially and multiline commands - continue as expected + - Added bracketed paste support so multiple pasted commands execute sequentially and multiline + commands continue as expected. ## 4.2.0 (August 6, 2026) From 2867c406cd5e175a79cf2f21084cd8339f33abd7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 22 Aug 2026 15:53:12 -0400 Subject: [PATCH 4/5] Updated tests and increased coverage. --- tests/test_cmd2.py | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 614a57a0b..38985efa9 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1544,6 +1544,13 @@ def test_help_verbose_with_fake_command(capsys) -> None: assert cmds[1] not in out +def test_ipy_help(base_app: cmd2.Cmd) -> None: + """Verify that help for ipy builds its parser and displays correctly.""" + out, err = run_cmd(base_app, "help ipy") + assert "Run an interactive IPython shell." in out + assert not err + + def test_render_columns_no_strs(help_app: HelpApp) -> None: no_strs = [] result = help_app.render_columns(no_strs) @@ -4297,6 +4304,20 @@ class SynonymApp(cmd2.cmd2.Cmd): assert synonym_parser is help_parser +def test_command_parsers_contains() -> None: + class SampleApp(cmd2.Cmd): + def do_non_argparse(self, args: str) -> None: + """Plain command without an argparse decorator.""" + + app = SampleApp() + + # Argparse-based command returns True + assert app.do_help in app.command_parsers + + # Non-argparse command returns False + assert app.do_non_argparse not in app.command_parsers + + def test_custom_completekey_ctrl_k(): from prompt_toolkit.keys import Keys @@ -4805,25 +4826,19 @@ def test_bracketed_paste_multiple_commands(base_app) -> None: sys.platform.startswith("win"), reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions", ) -def test_bracketed_paste_multiline_command() -> None: +def test_bracketed_paste_multiline_command(multiline_app) -> None: """Test pasting multiline command awaiting terminator.""" - - class MultilineApp(cmd2.Cmd): - def __init__(self) -> None: - super().__init__(multiline_commands=["sql"]) - - app = MultilineApp() with create_pipe_input() as pipe_input: - app.main_session = PromptSession( + multiline_app.main_session = PromptSession( input=pipe_input, output=DummyOutput(), - key_bindings=app.main_session.key_bindings, - multiline=app.main_session.multiline, - prompt_continuation=app.main_session.prompt_continuation, + key_bindings=multiline_app.main_session.key_bindings, + multiline=multiline_app.main_session.multiline, + prompt_continuation=multiline_app.main_session.prompt_continuation, ) - pipe_input.send_text("\x1b[200~sql select 1\nfrom table;\nhelp\n\x1b[201~") - line1 = app._read_command_line("prompt> ") - assert line1 == "sql select 1\nfrom table;" - line2 = app._read_command_line("prompt> ") + pipe_input.send_text("\x1b[200~orate line 1\nline 2;\nhelp\n\x1b[201~") + line1 = multiline_app._read_command_line("prompt> ") + assert line1 == "orate line 1\nline 2;" + line2 = multiline_app._read_command_line("prompt> ") assert line2 == "help" From cc152f305a638bbcbdf4492cc584b5a949e3bd3b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 22 Aug 2026 16:00:25 -0400 Subject: [PATCH 5/5] Improved performance for inserting key presses. --- cmd2/cmd2.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c0e9e3ae1..f5becb330 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -779,7 +779,8 @@ def _handle_bracketed_paste(event: KeyPressEvent) -> None: for i, line in enumerate(data.split("\n")): if i > 0: key_presses.append(KeyPress(Keys.ControlM, "\r")) - key_presses.extend(KeyPress(ch, ch) for ch in line) + if line: + key_presses.append(KeyPress(Keys.Any, line)) event.key_processor.feed_multiple(key_presses)