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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## 4.2.4 (TBD)

- Bug Fixes
- Fixed the right prompt being redrawn beside every accepted command line in the scrollback.
prompt-toolkit includes it in the final frame of each prompt, which is the frame left on the
terminal; it is now hidden there, as the bottom toolbar already was, and stays on the live
prompt line only

## 4.2.3 (September 2, 2026)

- Bug Fixes
Expand Down
28 changes: 28 additions & 0 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
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.layout import Window, walk
from prompt_toolkit.layout.containers import ConditionalContainer, FloatContainer
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 @@ -552,6 +554,8 @@ def __init__(
enable_rprompt=enable_rprompt,
refresh_interval=refresh_interval,
)
if enable_rprompt:
self._hide_rprompt_when_done(self.main_session)

# The session currently holding focus (either the main REPL or a command's
# custom prompt). Completion and UI logic should reference this variable
Expand Down Expand Up @@ -845,6 +849,30 @@ def _create_main_session(
)
return PromptSession(**kwargs)

@staticmethod
def _hide_rprompt_when_done(session: PromptSession[str]) -> None:
"""Keep the right prompt out of the frame that is committed to the scrollback.

prompt-toolkit ends every ``Application.run()`` with a final render, and that
frame is what stays on the terminal. Its bottom toolbar container is filtered
with ``~is_done`` so it is excluded, but the right prompt is a plain ``Float``
with no such filter, which left a copy of the right prompt beside every
accepted command line. This applies the same rule the toolbar already gets.

The right prompt is cosmetic, so an upstream layout change must not stop the
application from starting. If the float cannot be found, nothing happens and
the previous behavior remains.

:param session: the PromptSession whose right prompt should be hidden when done
"""
for container in walk(session.layout.container):
if not isinstance(container, FloatContainer):
continue
for float_ in container.floats:
content = float_.content
if isinstance(content, Window) and content.style == "class:rprompt":
float_.content = ConditionalContainer(content, filter=~filters.is_done)

def find_commandsets(
self, commandset_type: type[CommandSet[Any]], *, subclass_match: bool = False
) -> list[CommandSet[Any]]:
Expand Down
35 changes: 35 additions & 0 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from unittest import mock

import pytest
from prompt_toolkit.application import create_app_session, get_app
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import DummyCompleter
from prompt_toolkit.input import DummyInput, create_pipe_input
Expand Down Expand Up @@ -4444,6 +4445,40 @@ def test_enable_rprompt() -> None:
assert custom_app.main_session.rprompt is None


def test_rprompt_is_not_drawn_into_the_committed_line() -> None:
"""The right prompt belongs to the live prompt line, not to the scrollback.

prompt-toolkit renders the right prompt as a Float with no ``~is_done`` filter,
unlike its bottom toolbar container, so by default it is part of the final frame
that gets committed to the terminal. That left a copy of the right prompt beside
every accepted command line in the scrollback.
"""
drawn_while_done = []

class RPromptApp(cmd2.Cmd):
def get_rprompt(self):
drawn_while_done.append(get_app().is_done)
return [("", "RPROMPT")]

output = DummyOutput()
# Bind the ambient app session to this input and output. Without it, prompt-toolkit
# builds a real one on demand for calls such as patch_stdout() in _read_raw_input(),
# which needs a Windows console that CI does not provide.
with create_pipe_input() as pipe_input, create_app_session(input=pipe_input, output=output):
app = RPromptApp(allow_cli_args=False, enable_rprompt=True)
app.main_session = PromptSession(
input=pipe_input,
output=output,
rprompt=app.get_rprompt,
)
cmd2.cmd2.Cmd._hide_rprompt_when_done(app.main_session)
pipe_input.send_text("hello\n")
assert app._read_raw_input("(Cmd) ", app.main_session) == "hello"

assert drawn_while_done, "the right prompt was never rendered at all"
assert True not in drawn_while_done, "the right prompt was drawn into the committed final frame"


def test_get_bottom_toolbar(base_app: cmd2.Cmd) -> None:
assert base_app.get_bottom_toolbar() is None

Expand Down
Loading