Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
68 changes: 47 additions & 21 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
*,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
92 changes: 91 additions & 1 deletion tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"
Loading