diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f72d081a..3700678a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - Enhancements - Enabled Ctrl-Z suspension at the prompt + - Added bracketed paste support so multiple pasted commands 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..f5becb330 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,50 @@ 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")) + if line: + key_presses.append(KeyPress(Keys.Any, line)) + + event.key_processor.feed_multiple(key_presses) + + return key_bindings + def _create_main_session( self, *, @@ -756,26 +802,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 +813,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..38985efa9 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 @@ -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 @@ -4378,6 +4399,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() @@ -4752,3 +4780,65 @@ 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(multiline_app) -> None: + """Test pasting multiline command awaiting terminator.""" + with create_pipe_input() as pipe_input: + multiline_app.main_session = PromptSession( + input=pipe_input, + output=DummyOutput(), + 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~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"