diff --git a/CHANGELOG.md b/CHANGELOG.md index 55965220c..45bff09ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 5ceffb085..c4a21c831 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -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 @@ -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 @@ -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]]: diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index d2822bcd6..6f4ae3656 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -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 @@ -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