Skip to content

Commit 93c020c

Browse files
authored
Added support for bracketed paste (#1733)
Multiple pasted commands now execute sequentially and multiline commands continue as expected.
1 parent 9c7a891 commit 93c020c

3 files changed

Lines changed: 140 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
- Enhancements
44
- Enabled Ctrl-Z suspension at the prompt
5+
- Added bracketed paste support so multiple pasted commands execute sequentially and multiline
6+
commands continue as expected.
57

68
## 4.2.0 (August 6, 2026)
79

cmd2/cmd2.py

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@
7878
from prompt_toolkit.history import InMemoryHistory
7979
from prompt_toolkit.input import DummyInput, create_input
8080
from prompt_toolkit.key_binding import KeyBindings
81+
from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent
82+
from prompt_toolkit.keys import Keys
8183
from prompt_toolkit.output import DummyOutput, create_output
8284
from prompt_toolkit.patch_stdout import patch_stdout
8385
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title
@@ -740,6 +742,50 @@ def _should_continue_multiline(self) -> bool:
740742
# No macro found or already processed. The statement is complete.
741743
return False
742744

745+
def _create_key_bindings(self, completekey: str) -> KeyBindings:
746+
"""Create and configure custom key bindings for the PromptSession."""
747+
key_bindings = KeyBindings()
748+
749+
if completekey != self.DEFAULT_COMPLETEKEY:
750+
751+
@key_bindings.add(completekey)
752+
def _trigger_completion(event: KeyPressEvent) -> None: # pragma: no cover
753+
"""Trigger completion using the custom completion key."""
754+
b = event.current_buffer
755+
if b.complete_state:
756+
b.complete_next()
757+
else:
758+
b.start_completion(select_first=False)
759+
760+
@key_bindings.add("enter", filter=filters.completion_is_selected)
761+
def _accept_completion(event: KeyPressEvent) -> None: # pragma: no cover
762+
"""Accept a selected completion on Enter without submitting the command."""
763+
event.current_buffer.complete_state = None
764+
765+
@key_bindings.add(Keys.BracketedPaste)
766+
def _handle_bracketed_paste(event: KeyPressEvent) -> None:
767+
"""Handle bracketed paste by feeding lines as keystrokes separated by Enter.
768+
769+
By default, prompt_toolkit inserts pasted text as a single buffer blob.
770+
Translating newlines into Enter keystrokes allows multiple pasted commands
771+
to execute sequentially and multiline commands to continue as expected.
772+
"""
773+
data = event.data.replace("\r\n", "\n").replace("\r", "\n")
774+
if "\n" not in data:
775+
event.current_buffer.insert_text(data)
776+
return
777+
778+
key_presses = []
779+
for i, line in enumerate(data.split("\n")):
780+
if i > 0:
781+
key_presses.append(KeyPress(Keys.ControlM, "\r"))
782+
if line:
783+
key_presses.append(KeyPress(Keys.Any, line))
784+
785+
event.key_processor.feed_multiple(key_presses)
786+
787+
return key_bindings
788+
743789
def _create_main_session(
744790
self,
745791
*,
@@ -756,26 +802,6 @@ def _create_main_session(
756802
Otherwise, uses dummy drivers to support non-interactive streams like
757803
pipes or files.
758804
"""
759-
# Configure custom key bindings
760-
key_bindings = KeyBindings()
761-
762-
# Add a binding for 'enter' that triggers only when a completion is selected.
763-
# This allows accepting a completion without submitting the command.
764-
@key_bindings.add("enter", filter=filters.completion_is_selected)
765-
def _(event: Any) -> None: # pragma: no cover
766-
event.current_buffer.complete_state = None
767-
768-
if completekey != self.DEFAULT_COMPLETEKEY:
769-
# Configure prompt_toolkit `KeyBindings` with the custom key for completion
770-
@key_bindings.add(completekey)
771-
def _(event: Any) -> None: # pragma: no cover
772-
"""Trigger completion."""
773-
b = event.current_buffer
774-
if b.complete_state:
775-
b.complete_next()
776-
else:
777-
b.start_completion(select_first=False)
778-
779805
# Base configuration
780806
kwargs: dict[str, Any] = {
781807
"auto_suggest": AutoSuggestFromHistory() if auto_suggest else None,
@@ -787,7 +813,7 @@ def _(event: Any) -> None: # pragma: no cover
787813
"completer": Cmd2Completer(self),
788814
"enable_suspend": True,
789815
"history": Cmd2History(item.raw for item in self.history),
790-
"key_bindings": key_bindings,
816+
"key_bindings": self._create_key_bindings(completekey),
791817
"lexer": Cmd2Lexer(self),
792818
"multiline": filters.Condition(self._should_continue_multiline),
793819
"prompt_continuation": self.continuation_prompt,

tests/test_cmd2.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from prompt_toolkit.completion import DummyCompleter
1818
from prompt_toolkit.input import DummyInput, create_pipe_input
1919
from prompt_toolkit.output import DummyOutput
20-
from prompt_toolkit.shortcuts import PromptSession
20+
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession
2121
from rich.style import Style
2222
from rich.text import Text
2323

@@ -1544,6 +1544,13 @@ def test_help_verbose_with_fake_command(capsys) -> None:
15441544
assert cmds[1] not in out
15451545

15461546

1547+
def test_ipy_help(base_app: cmd2.Cmd) -> None:
1548+
"""Verify that help for ipy builds its parser and displays correctly."""
1549+
out, err = run_cmd(base_app, "help ipy")
1550+
assert "Run an interactive IPython shell." in out
1551+
assert not err
1552+
1553+
15471554
def test_render_columns_no_strs(help_app: HelpApp) -> None:
15481555
no_strs = []
15491556
result = help_app.render_columns(no_strs)
@@ -4297,6 +4304,20 @@ class SynonymApp(cmd2.cmd2.Cmd):
42974304
assert synonym_parser is help_parser
42984305

42994306

4307+
def test_command_parsers_contains() -> None:
4308+
class SampleApp(cmd2.Cmd):
4309+
def do_non_argparse(self, args: str) -> None:
4310+
"""Plain command without an argparse decorator."""
4311+
4312+
app = SampleApp()
4313+
4314+
# Argparse-based command returns True
4315+
assert app.do_help in app.command_parsers
4316+
4317+
# Non-argparse command returns False
4318+
assert app.do_non_argparse not in app.command_parsers
4319+
4320+
43004321
def test_custom_completekey_ctrl_k():
43014322
from prompt_toolkit.keys import Keys
43024323

@@ -4378,6 +4399,13 @@ def test_path_complete_users_windows(monkeypatch, base_app):
43784399
assert expected in matches
43794400

43804401

4402+
def test_main_session_defaults(base_app: cmd2.Cmd) -> None:
4403+
"""Verify default configuration of the main PromptSession."""
4404+
assert base_app.main_session.complete_style == CompleteStyle.MULTI_COLUMN
4405+
assert base_app.main_session.complete_while_typing is False
4406+
assert base_app.main_session.enable_suspend is True
4407+
4408+
43814409
def test_refresh_interval() -> None:
43824410
# Test default value
43834411
default_app = cmd2.Cmd()
@@ -4752,3 +4780,65 @@ def do_base(self, _: argparse.Namespace) -> None:
47524780
root_parser = cast(cmd2.Cmd2ArgumentParser, app.command_parsers.get(app.do_base))
47534781
subparsers_action = root_parser.get_subparsers_action()
47544782
assert not subparsers_action._name_parser_map
4783+
4784+
4785+
@pytest.mark.skipif(
4786+
sys.platform.startswith("win"),
4787+
reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions",
4788+
)
4789+
def test_bracketed_paste_single_line(base_app) -> None:
4790+
"""Test pasting single line text without newlines."""
4791+
with create_pipe_input() as pipe_input:
4792+
base_app.main_session = PromptSession(
4793+
input=pipe_input,
4794+
output=DummyOutput(),
4795+
key_bindings=base_app.main_session.key_bindings,
4796+
multiline=base_app.main_session.multiline,
4797+
)
4798+
4799+
pipe_input.send_text("\x1b[200~help\x1b[201~\n")
4800+
line = base_app._read_command_line("prompt> ")
4801+
assert line == "help"
4802+
4803+
4804+
@pytest.mark.skipif(
4805+
sys.platform.startswith("win"),
4806+
reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions",
4807+
)
4808+
def test_bracketed_paste_multiple_commands(base_app) -> None:
4809+
"""Test pasting multiple lines with newlines."""
4810+
with create_pipe_input() as pipe_input:
4811+
base_app.main_session = PromptSession(
4812+
input=pipe_input,
4813+
output=DummyOutput(),
4814+
key_bindings=base_app.main_session.key_bindings,
4815+
multiline=base_app.main_session.multiline,
4816+
)
4817+
4818+
pipe_input.send_text("\x1b[200~help\nhistory\n\x1b[201~")
4819+
line1 = base_app._read_command_line("prompt> ")
4820+
assert line1 == "help"
4821+
line2 = base_app._read_command_line("prompt> ")
4822+
assert line2 == "history"
4823+
4824+
4825+
@pytest.mark.skipif(
4826+
sys.platform.startswith("win"),
4827+
reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions",
4828+
)
4829+
def test_bracketed_paste_multiline_command(multiline_app) -> None:
4830+
"""Test pasting multiline command awaiting terminator."""
4831+
with create_pipe_input() as pipe_input:
4832+
multiline_app.main_session = PromptSession(
4833+
input=pipe_input,
4834+
output=DummyOutput(),
4835+
key_bindings=multiline_app.main_session.key_bindings,
4836+
multiline=multiline_app.main_session.multiline,
4837+
prompt_continuation=multiline_app.main_session.prompt_continuation,
4838+
)
4839+
4840+
pipe_input.send_text("\x1b[200~orate line 1\nline 2;\nhelp\n\x1b[201~")
4841+
line1 = multiline_app._read_command_line("prompt> ")
4842+
assert line1 == "orate line 1\nline 2;"
4843+
line2 = multiline_app._read_command_line("prompt> ")
4844+
assert line2 == "help"

0 commit comments

Comments
 (0)