From 9db7236df2291e650764fc034b319b31812eb0ce Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 00:15:42 -0400 Subject: [PATCH 01/36] Stage 3: choose between reserved and legacy toolbar rendering Reserved rendering depends on things cmd2 does not control -- which backend prompt-toolkit selected, which version of it is installed, whether there is a terminal at all -- so one module decides those prerequisites before anything binds a bridge or writes a margin sequence. The two non-default modes answer the same question differently on purpose. 'auto' falls back to legacy rendering for anything it has not qualified, because a backend that looks close enough is still a guess and a wrong guess corrupts the screen the user is working in. 'reserved' refuses to start rather than fall back: a caller who asked for it and silently got legacy rendering has been given the behaviour they ruled out, and would learn that from a flickering toolbar rather than an error. Qualification is by exact prompt-toolkit version. The package requirement is what cmd2 installs against; the qualified set is what the mechanism has been tested against, and it grows only through the qualification gates. The default stays legacy and nothing selects a mode yet, so no behaviour changes. Documentation and the changelog wait for Stage 5, as the plan sequences them. --- cmd2/cmd2.py | 23 ++++++ cmd2/toolbar_mode.py | 129 +++++++++++++++++++++++++++++++++ tests/test_toolbar_mode.py | 141 +++++++++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 cmd2/toolbar_mode.py create mode 100644 tests/test_toolbar_mode.py diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7d4b466c2..b9c438992 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -174,6 +174,7 @@ ) from .styles import Cmd2Style from .theme import get_pt_theme +from .toolbar_mode import validate_toolbar_mode from .types import ( BoundCommandFunc, BoundCompleter, @@ -376,6 +377,7 @@ def __init__( allow_redirection: bool = True, auto_load_commands: bool = False, auto_suggest: bool = True, + bottom_toolbar_mode: str = "legacy", complete_in_thread: bool = True, command_sets: Iterable[CommandSet[Any]] | None = None, enable_bottom_toolbar: bool = False, @@ -420,6 +422,13 @@ def __init__( This allows CommandSets with custom constructor parameters to be loaded. This also allows the a set of CommandSets to be provided when `auto_load_commands` is set to False + :param bottom_toolbar_mode: how the bottom toolbar is rendered. ``"legacy"``, the + default, redraws it with the prompt. ``"reserved"`` keeps + it in terminal rows withheld from scrolling, and raises + ``ValueError`` where that is not available; ``"auto"`` + uses reserved rendering only on qualified terminals and + falls back silently. Reserved rendering is experimental + and not yet a supported configuration. :param enable_bottom_toolbar: if ``True``, enables a bottom toolbar at the main prompt and during commands. Override ``get_bottom_toolbar()`` to define its content. :param enable_rprompt: if ``True``, enables a right prompt while at the main prompt. @@ -547,6 +556,10 @@ def __init__( self._persistent_history_length = persistent_history_length self._initialize_history(persistent_history_file) + # How the bottom toolbar is rendered. Validated here rather than at the first prompt + # so that a typo fails where it was written. + self._bottom_toolbar_mode = validate_toolbar_mode(bottom_toolbar_mode) + # Create the main PromptSession self.main_session = self._create_main_session( auto_suggest=auto_suggest, @@ -1508,6 +1521,16 @@ def allow_style_type(value: str) -> ru.AllowStyle: ) ) + @property + def bottom_toolbar_mode(self) -> str: + """How the bottom toolbar is rendered: ``"auto"``, ``"reserved"`` or ``"legacy"``. + + Read-only after construction: the reservation is established once for the lifetime of + the command loop, so changing this while one is running would leave the terminal and + the setting describing different things. + """ + return self._bottom_toolbar_mode + @property def allow_style(self) -> ru.AllowStyle: """Property needed to support do_set when it reads allow_style.""" diff --git a/cmd2/toolbar_mode.py b/cmd2/toolbar_mode.py new file mode 100644 index 000000000..573172455 --- /dev/null +++ b/cmd2/toolbar_mode.py @@ -0,0 +1,129 @@ +"""Choose between reserved-row and legacy toolbar rendering. + +Reserved rendering depends on things cmd2 does not control: which output backend +prompt-toolkit selected, which version of prompt-toolkit is installed, whether there is a +terminal at all. This module is the one place those prerequisites are decided, before +anything binds a bridge or writes a margin sequence. + +The two non-default modes answer the same question differently, on purpose: + +``auto`` falls back to legacy rendering for anything it has not qualified. A backend that +looks close enough is still a guess, and a wrong guess here corrupts the screen the user is +working in rather than merely rendering poorly. + +``reserved`` refuses to start instead of falling back. A caller who asked for reserved +rendering and silently got legacy rendering has been given the behaviour they ruled out, and +would find out from a flickering toolbar rather than from an error. + +Qualification is by exact version, not by the package requirement. ``prompt-toolkit>=3.0.53`` +is what cmd2 *installs against*; this set is what the reserved-row mechanism has actually been +tested against, and it grows only when a version has been through the qualification gates. +""" + +from importlib.metadata import version as _installed_version +from typing import TYPE_CHECKING, Literal + +from .terminal_display import PhysicalTerminal + +if TYPE_CHECKING: # pragma: no cover + from prompt_toolkit.output import Output + +#: The modes a caller may ask for. +TOOLBAR_MODES: tuple[str, ...] = ("auto", "reserved", "legacy") + +#: prompt-toolkit versions the reserved-row mechanism has been qualified against. The bridge +#: reaches into renderer internals whose shape is not part of any public API, so this is an +#: exact set rather than a floor. +QUALIFIED_PROMPT_TOOLKIT_VERSIONS = frozenset({"3.0.53"}) + +ToolbarMode = Literal["auto", "reserved", "legacy"] + + +def validate_toolbar_mode(mode: str) -> str: + """Check that a mode name is one cmd2 offers. + + :param mode: the requested mode + :return: the mode, unchanged + :raises ValueError: if the name is not a mode + """ + if mode not in TOOLBAR_MODES: + offered = ", ".join(sorted(TOOLBAR_MODES)) + raise ValueError(f"{mode!r} is not a bottom toolbar mode; choose one of {offered}") + return mode + + +def dependency_capability(version: str | None = None) -> tuple[bool, str]: + """Decide whether the installed prompt-toolkit is one the reservation is qualified for. + + :param version: the version to judge; the installed one by default + :return: whether it is qualified, and a reason suitable for diagnostics + """ + installed = version if version is not None else _installed_version("prompt_toolkit") + if installed in QUALIFIED_PROMPT_TOOLKIT_VERSIONS: + return True, "qualified prompt-toolkit" + qualified = ", ".join(sorted(QUALIFIED_PROMPT_TOOLKIT_VERSIONS)) + return False, f"prompt-toolkit {installed} is not qualified for reserved rendering (qualified: {qualified})" + + +def select_toolbar_mode( + mode: str, + output: "Output", + *, + toolbar_enabled: bool, + interactive: bool, + version: str | None = None, +) -> tuple[str, str]: + """Decide how the toolbar will be rendered for this session. + + :param mode: the requested mode + :param output: the backend prompt-toolkit selected + :param toolbar_enabled: whether a bottom toolbar is configured at all + :param interactive: whether input and output are a terminal + :param version: the prompt-toolkit version to judge; the installed one by default + :return: the mode to use -- always ``"reserved"`` or ``"legacy"`` -- and, when falling + back from ``auto``, the reason it fell back + :raises ValueError: if the mode is not a mode, or if ``reserved`` was required and a + prerequisite is missing + """ + validate_toolbar_mode(mode) + if mode == "legacy": + return "legacy", "" + + reason = _unmet_prerequisite(output, toolbar_enabled=toolbar_enabled, interactive=interactive, version=version) + if reason is None: + return "reserved", "" + if mode == "reserved": + raise ValueError(f"reserved bottom toolbar mode is not available here: {reason}") + return "legacy", reason + + +def _unmet_prerequisite( + output: "Output", + *, + toolbar_enabled: bool, + interactive: bool, + version: str | None, +) -> str | None: + """Find the first prerequisite reserved rendering does not have. + + Ordered from the cheapest and most user-visible outwards, so the reported reason is the + one a caller can act on: being told the backend is unqualified is unhelpful when the real + problem is that no toolbar was configured. + + :param output: the backend prompt-toolkit selected + :param toolbar_enabled: whether a bottom toolbar is configured at all + :param interactive: whether input and output are a terminal + :param version: the prompt-toolkit version to judge; the installed one by default + :return: the reason, or ``None`` when every prerequisite is met + """ + if not toolbar_enabled: + return "no bottom toolbar is configured" + if not interactive: + return "the session is not interactive" + supported, reason = dependency_capability(version) + if not supported: + return reason + supported, reason = PhysicalTerminal(output).capability() + if not supported: + return reason + return None diff --git a/tests/test_toolbar_mode.py b/tests/test_toolbar_mode.py new file mode 100644 index 000000000..6ac29b702 --- /dev/null +++ b/tests/test_toolbar_mode.py @@ -0,0 +1,141 @@ +"""Tests for choosing between reserved and legacy toolbar rendering. + +Selection is deliberately conservative and deliberately loud. ``auto`` falls back to legacy +rendering for anything it has not qualified, because a wrong guess corrupts the user's screen +rather than merely rendering poorly; ``reserved`` refuses to start rather than silently giving +the caller the legacy behaviour they asked it not to use. +""" + +import io + +import pytest +from prompt_toolkit.data_structures import Size +from prompt_toolkit.output import DummyOutput +from prompt_toolkit.output.vt100 import Vt100_Output + +import cmd2 +from cmd2.toolbar_mode import ( + QUALIFIED_PROMPT_TOOLKIT_VERSIONS, + TOOLBAR_MODES, + dependency_capability, + select_toolbar_mode, + validate_toolbar_mode, +) + + +def qualified_output() -> Vt100_Output: + """Build a backend the reservation is qualified for.""" + return Vt100_Output(io.StringIO(), lambda: Size(rows=24, columns=80)) + + +class TestValidation: + @pytest.mark.parametrize("mode", TOOLBAR_MODES) + def test_every_documented_mode_is_accepted(self, mode: str) -> None: + assert validate_toolbar_mode(mode) == mode + + def test_an_unknown_mode_names_the_ones_that_exist(self) -> None: + with pytest.raises(ValueError, match=r"auto.*legacy.*reserved"): + validate_toolbar_mode("pinned") + + def test_the_modes_are_the_three_the_design_names(self) -> None: + assert set(TOOLBAR_MODES) == {"auto", "reserved", "legacy"} + + +class TestDependencyQualification: + def test_the_installed_prompt_toolkit_is_the_qualified_one(self) -> None: + """A dependency upgrade must fail here rather than quietly rendering differently.""" + supported, reason = dependency_capability() + assert supported is True, reason + + def test_only_exactly_qualified_versions_count(self) -> None: + """The package requirement stays a range; qualification does not follow it.""" + assert frozenset({"3.0.53"}) == QUALIFIED_PROMPT_TOOLKIT_VERSIONS + + def test_an_unqualified_version_is_reported_with_its_number(self) -> None: + supported, reason = dependency_capability("3.0.99") + assert supported is False + assert "3.0.99" in reason + + +class TestAutomaticSelection: + def test_a_qualified_terminal_selects_reserved(self) -> None: + mode, reason = select_toolbar_mode("auto", qualified_output(), toolbar_enabled=True, interactive=True) + assert mode == "reserved" + assert reason == "" + + def test_an_unqualified_backend_falls_back(self) -> None: + mode, reason = select_toolbar_mode("auto", DummyOutput(), toolbar_enabled=True, interactive=True) + assert mode == "legacy" + assert "dummy output" in reason + + def test_an_unqualified_dependency_falls_back(self) -> None: + mode, reason = select_toolbar_mode( + "auto", qualified_output(), toolbar_enabled=True, interactive=True, version="3.0.99" + ) + assert mode == "legacy" + assert "3.0.99" in reason + + def test_a_disabled_toolbar_falls_back(self) -> None: + """With no toolbar there is nothing to reserve a row for.""" + mode, reason = select_toolbar_mode("auto", qualified_output(), toolbar_enabled=False, interactive=True) + assert mode == "legacy" + assert "toolbar" in reason + + def test_a_non_interactive_session_falls_back(self) -> None: + """Redirected output has no terminal to reserve rows in.""" + mode, reason = select_toolbar_mode("auto", qualified_output(), toolbar_enabled=True, interactive=False) + assert mode == "legacy" + assert "interactive" in reason + + +class TestForcedModes: + def test_legacy_is_selected_whatever_the_terminal_supports(self) -> None: + mode, reason = select_toolbar_mode("legacy", qualified_output(), toolbar_enabled=True, interactive=True) + assert mode == "legacy" + assert reason == "" + + def test_reserved_is_selected_when_everything_qualifies(self) -> None: + mode, _reason = select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=True) + assert mode == "reserved" + + def test_forcing_reserved_on_an_unqualified_backend_is_an_error(self) -> None: + """Falling back silently would give the caller the behaviour they ruled out.""" + with pytest.raises(ValueError, match="dummy output"): + select_toolbar_mode("reserved", DummyOutput(), toolbar_enabled=True, interactive=True) + + def test_forcing_reserved_on_an_unqualified_dependency_is_an_error(self) -> None: + with pytest.raises(ValueError, match=r"3\.0\.99"): + select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=True, version="3.0.99") + + def test_forcing_reserved_without_a_toolbar_is_an_error(self) -> None: + with pytest.raises(ValueError, match="toolbar"): + select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=False, interactive=True) + + def test_forcing_reserved_without_a_terminal_is_an_error(self) -> None: + with pytest.raises(ValueError, match="interactive"): + select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=False) + + def test_an_unknown_mode_is_rejected_before_anything_is_inspected(self) -> None: + with pytest.raises(ValueError, match="pinned"): + select_toolbar_mode("pinned", qualified_output(), toolbar_enabled=True, interactive=True) + + +class TestConstructorWiring: + def test_the_default_is_legacy(self) -> None: + """Reserved rendering is opt-in until it has been through the release gates.""" + assert cmd2.Cmd(allow_cli_args=False).bottom_toolbar_mode == "legacy" + + @pytest.mark.parametrize("mode", TOOLBAR_MODES) + def test_a_requested_mode_is_remembered(self, mode: str) -> None: + app = cmd2.Cmd(allow_cli_args=False, enable_bottom_toolbar=True, bottom_toolbar_mode=mode) + assert app.bottom_toolbar_mode == mode + + def test_an_unknown_mode_is_rejected_at_construction(self) -> None: + """Not at the first prompt: a typo should fail where it was written.""" + with pytest.raises(ValueError, match="pinned"): + cmd2.Cmd(allow_cli_args=False, bottom_toolbar_mode="pinned") + + def test_the_mode_is_read_only(self) -> None: + app = cmd2.Cmd(allow_cli_args=False) + with pytest.raises(AttributeError): + app.bottom_toolbar_mode = "reserved" # type: ignore[misc] From 4d9cb585b60857a8fe85b4985223c0b968ff2ad2 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 00:18:26 -0400 Subject: [PATCH 02/36] Stage 3: own the reservation and its bindings for one command loop Binding is two assignments, not one. Application.output is what the application and its session report, but the renderer keeps its own reference to the output it was constructed with, so binding only the application leaves the renderer drawing through the unwrapped backend -- straight over the reserved row. Restoration is just as exact: the originals go back only where the adapter is still installed, since something else may have rebound them in between and a stale object is worse than a newer one. A backend left wrapped would report a terminal one row shorter than it is to whatever runs next. The toolbar's content is read through a callable rather than captured, so a caller assigning a new bottom_toolbar to the session still reaches the band. Nothing constructs this yet; the default mode is still legacy. --- cmd2/reserved_toolbar.py | 167 ++++++++++++++++++++++++ tests/test_reserved_toolbar.py | 232 +++++++++++++++++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 cmd2/reserved_toolbar.py create mode 100644 tests/test_reserved_toolbar.py diff --git a/cmd2/reserved_toolbar.py b/cmd2/reserved_toolbar.py new file mode 100644 index 000000000..4e3dc7ba9 --- /dev/null +++ b/cmd2/reserved_toolbar.py @@ -0,0 +1,167 @@ +"""Own the reservation, the bridge and the painter for one command loop. + +This is the seam between cmd2's lifecycle and the reserved-row machinery. Everything it does +is a pair: acquire and release, bind and restore, build and drop. The pairing matters more +than the individual halves -- a reservation left installed makes every later line of shell +output scroll inside a region the shell knows nothing about, and a backend left wrapped +reports a terminal one row shorter than it is to whatever runs next. + +Binding is two assignments, not one. ``Application.output`` is what the application and its +session report, but the renderer keeps its *own* reference to the output it was constructed +with, so binding only the application leaves the renderer drawing through the unwrapped +backend -- straight over the reserved row. Both are restored on the way out, and only if they +still hold what this object put there: something else may have replaced them in between, and +putting a stale object back would be worse than leaving the newer one alone. + +The toolbar's content is read through a callable rather than captured, so a caller assigning a +new ``bottom_toolbar`` to the session still reaches the band. +""" + +from types import TracebackType +from typing import TYPE_CHECKING, Any, Self + +from prompt_toolkit.styles import DynamicStyle + +from .prompt_toolkit_bridge import PromptToolkitBridge +from .terminal_display import TerminalDisplay +from .terminal_transaction import TerminalLock +from .theme import get_pt_theme +from .toolbar_painter import ToolbarPainter + +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Callable + + from prompt_toolkit.formatted_text import AnyFormattedText + from prompt_toolkit.shortcuts import PromptSession + + +class ReservedToolbar: + """The reserved-row toolbar's lifetime, tied to one prompt session.""" + + def __init__( + self, + session: "PromptSession[Any]", + content: "Callable[[], AnyFormattedText]", + reserved_rows: int = 1, + ) -> None: + """Prepare a reservation for a session's terminal. + + Nothing is acquired or bound until :meth:`start`; a session that never enters its + command loop must leave the terminal exactly as it found it. + + :param session: the main prompt session whose terminal is reserved + :param content: called to obtain the toolbar's formatted text + :param reserved_rows: rows to withhold at the bottom of the screen + """ + self._session = session + self.content = content + self._reserved_rows = reserved_rows + self._display: TerminalDisplay | None = None + self._bridge: PromptToolkitBridge | None = None + self._painter: ToolbarPainter | None = None + self._lock = TerminalLock() + self._bound_output: Any = None + self._original_output: Any = None + + @property + def is_active(self) -> bool: + """Whether a reservation is installed and the application is bound to it.""" + return self._display is not None + + @property + def display(self) -> TerminalDisplay: + """The display owning the reservation. + + :raises RuntimeError: if no reservation is held + """ + if self._display is None: + raise RuntimeError("the reserved toolbar is not started") + return self._display + + @property + def bridge(self) -> PromptToolkitBridge | None: + """The renderer bridge while active, else ``None``.""" + return self._bridge + + @property + def painter(self) -> ToolbarPainter | None: + """The band painter while active, else ``None``.""" + return self._painter + + @property + def lock(self) -> TerminalLock: + """The terminal transaction lock every cmd2-controlled writer shares.""" + return self._lock + + def start(self) -> bool: + """Acquire the reservation and bind the application to it. + + A terminal too short for the floor is not an error: the lease is simply refused, and + the application keeps rendering through its own backend exactly as it did before. + + :return: whether a reservation was installed + """ + if self._display is not None: + return True + + app = self._session.app + display = TerminalDisplay(app.output, reserved_rows=self._reserved_rows) + if not display.acquire(): + # Nothing was installed, so there is nothing to release; leaving the lease held + # would make every later acquire a no-op at depth two. + display.release() + return False + + self._display = display + self._original_output = app.output + self._bound_output = display.output + app.output = self._bound_output + app.renderer.output = self._bound_output + + self._bridge = PromptToolkitBridge(renderer=app.renderer, display=display, lock=self._lock) + self._painter = ToolbarPainter( + display=display, + lock=self._lock, + style=DynamicStyle(get_pt_theme), + color_depth=app.color_depth, + default_style="class:bottom-toolbar", + ) + return True + + def stop(self) -> None: + """Restore the application's bindings and release the reservation. + + Safe to call when nothing was started and safe to call twice: teardown reaches this + from the loop's ``finally`` and from explicit shutdown, and neither knows about the + other. + """ + display, self._display = self._display, None + self._bridge = None + self._painter = None + if display is None: + return + + app = self._session.app + # Only put the original back where the adapter is still installed. Something else may + # have rebound these in between, and a stale object is worse than a newer one. + if app.output is self._bound_output: + app.output = self._original_output + if app.renderer.output is self._bound_output: + app.renderer.output = self._original_output + self._bound_output = None + self._original_output = None + display.release() + + def __enter__(self) -> Self: + """Start the reservation.""" + self.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Stop the reservation, including when the body raised.""" + self.stop() diff --git a/tests/test_reserved_toolbar.py b/tests/test_reserved_toolbar.py new file mode 100644 index 000000000..ef2a29c9c --- /dev/null +++ b/tests/test_reserved_toolbar.py @@ -0,0 +1,232 @@ +"""Tests for owning the reservation across one command loop. + +Binding is the part that has to be exactly right. The renderer keeps its own reference to the +output it was built with, so changing only ``Application.output`` leaves the renderer drawing +through the unwrapped backend -- over the reserved row. Restoration has to be just as exact: +a backend left wrapped after the loop ends would report a short terminal to whatever runs next. +""" + +import io +from typing import Any + +import pytest +from prompt_toolkit.data_structures import Size +from prompt_toolkit.input import create_pipe_input +from prompt_toolkit.output.vt100 import Vt100_Output +from prompt_toolkit.shortcuts import PromptSession + +from cmd2.reserved_output import ReservedOutput +from cmd2.reserved_toolbar import ReservedToolbar + + +class TtyStringIO(io.StringIO): + """A stream that claims to be a terminal, as the backend requires.""" + + def isatty(self) -> bool: + return True + + +class Harness: + """A prompt session over a terminal whose size the test controls.""" + + def __init__(self, rows: int = 24, columns: int = 80) -> None: + self.stream = TtyStringIO() + self.size = Size(rows=rows, columns=columns) + self.backend = Vt100_Output(self.stream, lambda: self.size) + self._pipe = create_pipe_input() + self.pipe = self._pipe.__enter__() + self.session: PromptSession[str] = PromptSession(input=self.pipe, output=self.backend, bottom_toolbar="STATUS") + self.toolbar = ReservedToolbar(self.session, lambda: self.session.bottom_toolbar) + self.clear() + + def close(self) -> None: + """Release the pipe input.""" + self._pipe.__exit__(None, None, None) + + def clear(self) -> None: + """Discard everything written so far.""" + self.stream.truncate(0) + self.stream.seek(0) + + def written(self) -> str: + """Everything written since the last clear.""" + return self.stream.getvalue() + + @property + def app(self) -> Any: + """The session's application.""" + return self.session.app + + +class TestBinding: + def test_starting_binds_the_application_and_its_renderer(self) -> None: + harness = Harness() + try: + assert harness.toolbar.start() is True + adapter = harness.toolbar.display.output + assert isinstance(adapter, ReservedOutput) + assert harness.app.output is adapter + assert harness.app.renderer.output is adapter + finally: + harness.close() + + def test_the_renderer_is_bound_as_well_as_the_application(self) -> None: + """The renderer holds its own reference; binding only the application misses it.""" + harness = Harness() + try: + harness.toolbar.start() + assert harness.app.renderer.output is harness.app.output + finally: + harness.close() + + def test_the_application_sees_the_usable_height(self) -> None: + harness = Harness(rows=24) + try: + harness.toolbar.start() + assert harness.app.output.get_size() == Size(rows=23, columns=80) + finally: + harness.close() + + def test_the_region_is_installed_before_anything_renders(self) -> None: + harness = Harness(rows=24) + try: + harness.toolbar.start() + assert "\x1b[1;23r" in harness.written() + finally: + harness.close() + + def test_stopping_restores_the_original_objects(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.stop() + assert harness.app.output is harness.backend + assert harness.app.renderer.output is harness.backend + finally: + harness.close() + + def test_stopping_releases_the_region(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + harness.clear() + harness.toolbar.stop() + assert "\x1b[r" in harness.written() + finally: + harness.close() + + def test_stopping_twice_does_nothing_the_second_time(self) -> None: + """Teardown runs from more than one place; it has to be safe to repeat.""" + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.stop() + harness.clear() + harness.toolbar.stop() + assert harness.written() == "" + finally: + harness.close() + + def test_a_terminal_too_short_to_reserve_binds_nothing(self) -> None: + """Below the two-row floor there is no reservation, so nothing should be wrapped.""" + harness = Harness(rows=2) + try: + assert harness.toolbar.start() is False + assert harness.app.output is harness.backend + assert harness.app.renderer.output is harness.backend + assert harness.toolbar.is_active is False + finally: + harness.close() + + def test_the_context_manager_restores_when_the_body_raises(self) -> None: + harness = Harness() + try: + with pytest.raises(ZeroDivisionError), harness.toolbar: + raise ZeroDivisionError + assert harness.app.output is harness.backend + assert harness.toolbar.is_active is False + finally: + harness.close() + + +class TestComponents: + def test_the_bridge_and_painter_exist_while_active(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + assert harness.toolbar.bridge is not None + assert harness.toolbar.painter is not None + assert harness.toolbar.is_active is True + finally: + harness.close() + + def test_they_are_gone_once_stopped(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.stop() + assert harness.toolbar.bridge is None + assert harness.toolbar.painter is None + finally: + harness.close() + + def test_the_bridge_is_bound_to_the_application_renderer(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + assert harness.toolbar.bridge is not None + assert harness.toolbar.bridge._renderer is harness.app.renderer + finally: + harness.close() + + def test_the_painter_reads_the_content_provider_dynamically(self) -> None: + """Assigning a new ``bottom_toolbar`` must reach the band without a restart.""" + harness = Harness() + try: + harness.toolbar.start() + harness.session.bottom_toolbar = "CHANGED" + painter = harness.toolbar.painter + assert painter is not None + prepared = painter.prepare(harness.toolbar.content) + assert prepared is not None + assert "CHANGED" in "".join(cell.char for cell in prepared.frame.rows[0]) + finally: + harness.close() + + def test_one_lock_is_shared_by_the_bridge_and_the_painter(self) -> None: + """Two locks would serialize each writer against itself and neither against the other.""" + harness = Harness() + try: + harness.toolbar.start() + assert harness.toolbar.painter._lock is harness.toolbar.bridge._lock + finally: + harness.close() + + def test_asking_for_the_display_before_starting_says_so(self) -> None: + harness = Harness() + try: + with pytest.raises(RuntimeError, match="not started"): + _ = harness.toolbar.display + finally: + harness.close() + + def test_the_shared_lock_is_reachable(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + assert harness.toolbar.lock is harness.toolbar.painter._lock + finally: + harness.close() + + def test_starting_twice_keeps_the_first_reservation(self) -> None: + """A second start must not stack a lease the single stop would not release.""" + harness = Harness() + try: + harness.toolbar.start() + display = harness.toolbar.display + assert harness.toolbar.start() is True + assert harness.toolbar.display is display + harness.toolbar.stop() + assert harness.app.output is harness.backend + finally: + harness.close() From 3025372bebd26742c1f284189a4a0de8bd962c75 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 00:20:56 -0400 Subject: [PATCH 03/36] Stage 3: hide the native toolbar window and paint the band instead Reserved mode suppresses the window prompt-toolkit would draw the toolbar in, and paints the reserved rows itself. Both layouts share one container object, so one suppression covers the main prompt and the command display alike. The window is hidden by asking whether the reservation is live rather than by latching a False into its filter. A restoration that never runs -- a teardown that raised, a caller that dropped the object -- then leaves a filter that heals itself instead of a toolbar that is gone for the rest of the session. The content provider is left alone. Callers read bottom_toolbar to mean "a toolbar is configured", so suppressing by setting it to None would answer a different question than the one being asked. The band is painted when the reservation starts, so the toolbar is there from the first prompt rather than from the first refresh. --- cmd2/reserved_toolbar.py | 62 ++++++++++++++ tests/test_reserved_toolbar.py | 143 ++++++++++++++++++++++++++++++++- 2 files changed, 203 insertions(+), 2 deletions(-) diff --git a/cmd2/reserved_toolbar.py b/cmd2/reserved_toolbar.py index 4e3dc7ba9..b2379e780 100644 --- a/cmd2/reserved_toolbar.py +++ b/cmd2/reserved_toolbar.py @@ -20,6 +20,9 @@ from types import TracebackType from typing import TYPE_CHECKING, Any, Self +from prompt_toolkit.filters import Condition +from prompt_toolkit.layout import HSplit, Window +from prompt_toolkit.layout.containers import ConditionalContainer from prompt_toolkit.styles import DynamicStyle from .prompt_toolkit_bridge import PromptToolkitBridge @@ -35,6 +38,29 @@ from prompt_toolkit.shortcuts import PromptSession +def native_toolbar_container(session: "PromptSession[Any]") -> ConditionalContainer | None: + """Find the window prompt-toolkit draws the bottom toolbar in. + + The shape is checked explicitly rather than assumed: ``PromptSession`` offers no public + hook for its toolbar window, so this is a dependency on its layout that has to fail + visibly when upstream changes it -- not quietly suppress the wrong container. + + :param session: the prompt session to look in + :return: the toolbar's container, or ``None`` if this layout has no recognizable one + """ + root = session.app.layout.container + if not isinstance(root, HSplit) or not root.children: + return None + candidate = root.children[-1] + if ( + isinstance(candidate, ConditionalContainer) + and isinstance(candidate.content, Window) + and candidate.content.style == "class:bottom-toolbar" + ): + return candidate + return None + + class ReservedToolbar: """The reserved-row toolbar's lifetime, tied to one prompt session.""" @@ -62,6 +88,8 @@ def __init__( self._lock = TerminalLock() self._bound_output: Any = None self._original_output: Any = None + self._native_toolbar: ConditionalContainer | None = None + self._original_filter: Any = None @property def is_active(self) -> bool: @@ -105,6 +133,13 @@ def start(self) -> bool: return True app = self._session.app + native = native_toolbar_container(self._session) + if native is None: + # Selection is supposed to have established this already. Reaching here means the + # layout changed underneath us, and reserving rows while the native toolbar still + # draws would put two toolbars on the screen. + raise RuntimeError("cannot locate the session's bottom toolbar window") + display = TerminalDisplay(app.output, reserved_rows=self._reserved_rows) if not display.acquire(): # Nothing was installed, so there is nothing to release; leaving the lease held @@ -118,6 +153,14 @@ def start(self) -> bool: app.output = self._bound_output app.renderer.output = self._bound_output + # Hidden by asking whether the reservation is live rather than by latching a False. + # A restoration that never runs -- a teardown that raised, a caller that dropped this + # object -- then leaves a filter that heals itself instead of a toolbar that is gone + # for the rest of the session. + self._native_toolbar = native + self._original_filter = native.filter + native.filter = native.filter & Condition(lambda: not self.is_active) + self._bridge = PromptToolkitBridge(renderer=app.renderer, display=display, lock=self._lock) self._painter = ToolbarPainter( display=display, @@ -126,8 +169,22 @@ def start(self) -> bool: color_depth=app.color_depth, default_style="class:bottom-toolbar", ) + self.refresh() return True + def refresh(self) -> bool: + """Evaluate the toolbar's content and paint whatever changed. + + :return: whether anything was written + """ + painter = self._painter + if painter is None: + return False + prepared = painter.prepare(self.content) + if prepared is None: + return False + return painter.paint(prepared) + def stop(self) -> None: """Restore the application's bindings and release the reservation. @@ -141,6 +198,11 @@ def stop(self) -> None: if display is None: return + native, self._native_toolbar = self._native_toolbar, None + if native is not None and self._original_filter is not None: + native.filter = self._original_filter + self._original_filter = None + app = self._session.app # Only put the original back where the adapter is still installed. Something else may # have rebound these in between, and a stale object is worse than a newer one. diff --git a/tests/test_reserved_toolbar.py b/tests/test_reserved_toolbar.py index ef2a29c9c..a0e6f084f 100644 --- a/tests/test_reserved_toolbar.py +++ b/tests/test_reserved_toolbar.py @@ -12,11 +12,12 @@ import pytest from prompt_toolkit.data_structures import Size from prompt_toolkit.input import create_pipe_input +from prompt_toolkit.layout import HSplit, Layout, Window from prompt_toolkit.output.vt100 import Vt100_Output from prompt_toolkit.shortcuts import PromptSession from cmd2.reserved_output import ReservedOutput -from cmd2.reserved_toolbar import ReservedToolbar +from cmd2.reserved_toolbar import ReservedToolbar, native_toolbar_container class TtyStringIO(io.StringIO): @@ -29,7 +30,7 @@ def isatty(self) -> bool: class Harness: """A prompt session over a terminal whose size the test controls.""" - def __init__(self, rows: int = 24, columns: int = 80) -> None: + def __init__(self, rows: int = 24, columns: int = 80, toolbar: Any = "STATUS") -> None: self.stream = TtyStringIO() self.size = Size(rows=rows, columns=columns) self.backend = Vt100_Output(self.stream, lambda: self.size) @@ -230,3 +231,141 @@ def test_starting_twice_keeps_the_first_reservation(self) -> None: assert harness.app.output is harness.backend finally: harness.close() + + +class TestNativeToolbarSuppression: + def test_the_native_toolbar_window_is_hidden_while_reserved(self) -> None: + """Two toolbars would be drawn otherwise: the native one and the painted band.""" + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + assert container.filter() is True + harness.toolbar.start() + assert container.filter() is False + finally: + harness.close() + + def test_the_original_filter_is_restored(self) -> None: + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + original = container.filter + harness.toolbar.start() + harness.toolbar.stop() + assert container.filter is original + assert container.filter() is True + finally: + harness.close() + + def test_the_window_reappears_even_if_the_filter_is_never_restored(self) -> None: + """The suppression asks whether the toolbar is active rather than latching a False.""" + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + harness.toolbar.start() + suppressed = container.filter + harness.toolbar.stop() + assert suppressed() is True + finally: + harness.close() + + def test_the_content_provider_is_left_alone(self) -> None: + """Callers read this attribute to mean 'a toolbar is configured'.""" + harness = Harness() + try: + harness.toolbar.start() + assert harness.session.bottom_toolbar == "STATUS" + finally: + harness.close() + + def test_an_unrecognized_layout_is_refused_explicitly(self) -> None: + harness = Harness() + try: + harness.session.app.layout = Layout(Window()) + with pytest.raises(RuntimeError, match="bottom toolbar"): + harness.toolbar.start() + finally: + harness.close() + + def test_an_unrecognized_layout_has_no_container_to_find(self) -> None: + harness = Harness() + try: + harness.session.app.layout = Layout(Window()) + assert native_toolbar_container(harness.session) is None + finally: + harness.close() + + def test_a_layout_whose_last_child_is_not_the_toolbar_has_no_container(self) -> None: + """The shape check is about the toolbar window, not merely about the root's type.""" + harness = Harness() + try: + harness.session.app.layout = Layout(HSplit([Window(), Window()])) + assert native_toolbar_container(harness.session) is None + finally: + harness.close() + + +class TestFirstPaint: + def test_the_band_is_painted_when_the_reservation_starts(self) -> None: + """The toolbar has to be there from the first prompt, not from the first refresh.""" + harness = Harness() + try: + harness.toolbar.start() + assert "\x1b[24;1H" in harness.written() + assert "STATUS" in harness.written() + finally: + harness.close() + + def test_refreshing_repaints_changed_content(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + harness.session.bottom_toolbar = "CHANGED" + harness.clear() + assert harness.toolbar.refresh() is True + # Only the cells that differ from "STATUS" are rewritten -- the shared "A" is + # left alone -- so the band is checked through the frame the painter published + # rather than by looking for the whole string in the stream. + painter = harness.toolbar.painter + assert painter is not None + assert painter.last_frame is not None + assert "".join(cell.char for cell in painter.last_frame.rows[0]).startswith("CHANGED") + assert "\x1b[24;" in harness.written() + finally: + harness.close() + + def test_refreshing_unchanged_content_writes_nothing(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + harness.clear() + assert harness.toolbar.refresh() is False + assert harness.written() == "" + finally: + harness.close() + + def test_a_failing_content_callback_paints_nothing(self) -> None: + """The painter keeps the last good frame; the refresh simply reports it wrote nothing.""" + + def boom() -> str: + raise RuntimeError("callback failed") + + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.content = boom + harness.clear() + assert harness.toolbar.refresh() is False + assert harness.written() == "" + finally: + harness.close() + + def test_refreshing_while_stopped_does_nothing(self) -> None: + harness = Harness() + try: + assert harness.toolbar.refresh() is False + finally: + harness.close() From 5210e85ba8aaaae4930618633d332d61b9446889 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 00:23:18 -0400 Subject: [PATCH 04/36] Stage 3: choose the mode and own the toolbar for the command loop The mode is chosen when the loop starts rather than at construction, because the answer depends on the session in use then: a caller may have replaced main_session since, and the terminal it renders to is what decides whether a reservation is possible at all. The layout is one of those prerequisites, so an unrecognized one falls back under auto and is an error under forced reserved -- the same policy as every other prerequisite, rather than a second mechanism. The reservation is established after the intro has been printed. Installing it around output that belongs to the terminal's ordinary scrollback would put a scroll region over text the user expects to keep. A refused reservation is not a refused loop: below the two-row floor there is nothing to reserve and the toolbar renders natively, so the object is kept even while inactive because the terminal can grow back. --- cmd2/cmd2.py | 47 ++++++++- cmd2/toolbar_mode.py | 15 ++- tests/test_reserved_lifecycle.py | 171 +++++++++++++++++++++++++++++++ tests/test_toolbar_mode.py | 14 +++ 4 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 tests/test_reserved_lifecycle.py diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index b9c438992..918364791 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -165,6 +165,7 @@ StatementParser, shlex_split, ) +from .reserved_toolbar import ReservedToolbar, native_toolbar_container from .rich_utils import ( Cmd2BaseConsole, Cmd2ExceptionConsole, @@ -174,7 +175,7 @@ ) from .styles import Cmd2Style from .theme import get_pt_theme -from .toolbar_mode import validate_toolbar_mode +from .toolbar_mode import select_toolbar_mode, validate_toolbar_mode from .types import ( BoundCommandFunc, BoundCompleter, @@ -559,6 +560,7 @@ def __init__( # How the bottom toolbar is rendered. Validated here rather than at the first prompt # so that a typo fails where it was written. self._bottom_toolbar_mode = validate_toolbar_mode(bottom_toolbar_mode) + self._reserved_toolbar: ReservedToolbar | None = None # Create the main PromptSession self.main_session = self._create_main_session( @@ -2147,6 +2149,42 @@ def suspend_bottom_toolbar(self) -> Iterator[None]: with self._command_toolbar.suspend(): yield + @property + def reserved_toolbar(self) -> "ReservedToolbar | None": + """The reserved-row toolbar owning the terminal, or ``None`` in legacy mode.""" + return self._reserved_toolbar + + @contextlib.contextmanager + def _reserved_toolbar_context(self) -> Iterator[None]: + """Own the reserved-row toolbar for the lifetime of one command loop. + + The mode is chosen here rather than at construction because the answer depends on the + session in use *now*: a caller may have replaced ``main_session`` since, and the + terminal it renders to is what decides whether a reservation is possible. + + A refused reservation is not a refused loop. Below the two-row floor there is nothing + to reserve and the toolbar renders natively, which is why the object is kept even when + it is inactive -- the terminal can grow back. + """ + mode, _reason = select_toolbar_mode( + self._bottom_toolbar_mode, + self.main_session.app.output, + toolbar_enabled=self.main_session.bottom_toolbar is not None, + interactive=self._is_tty_session(self.main_session), + layout_supported=native_toolbar_container(self.main_session) is not None, + ) + if mode == "legacy": + yield + return + + toolbar = ReservedToolbar(self.main_session, lambda: self.main_session.bottom_toolbar) + self._reserved_toolbar = toolbar + try: + with toolbar: + yield + finally: + self._reserved_toolbar = None + @contextlib.contextmanager def _command_toolbar_context(self) -> Iterator[None]: """Display the toolbar around commands launched by the interactive command loop.""" @@ -6066,9 +6104,12 @@ def cmdloop(self, intro: RenderableType = "") -> int: if self.intro: self.poutput(self.intro) - # And then call _cmdloop() to enter the main loop + # And then call _cmdloop() to enter the main loop. The reservation is established + # here, after the intro has been printed: it must not be installed around output that + # belongs to the terminal's ordinary scrollback. try: - self._cmdloop() + with self._reserved_toolbar_context(): + self._cmdloop() finally: # Restore original signal handlers however the loop ended. Leaving cmd2's # handlers installed would outlive the application in its host process. diff --git a/cmd2/toolbar_mode.py b/cmd2/toolbar_mode.py index 573172455..b96df4ae4 100644 --- a/cmd2/toolbar_mode.py +++ b/cmd2/toolbar_mode.py @@ -71,6 +71,7 @@ def select_toolbar_mode( *, toolbar_enabled: bool, interactive: bool, + layout_supported: bool = True, version: str | None = None, ) -> tuple[str, str]: """Decide how the toolbar will be rendered for this session. @@ -79,6 +80,8 @@ def select_toolbar_mode( :param output: the backend prompt-toolkit selected :param toolbar_enabled: whether a bottom toolbar is configured at all :param interactive: whether input and output are a terminal + :param layout_supported: whether the session's layout has a toolbar window that reserved + rendering can recognize and hide :param version: the prompt-toolkit version to judge; the installed one by default :return: the mode to use -- always ``"reserved"`` or ``"legacy"`` -- and, when falling back from ``auto``, the reason it fell back @@ -89,7 +92,13 @@ def select_toolbar_mode( if mode == "legacy": return "legacy", "" - reason = _unmet_prerequisite(output, toolbar_enabled=toolbar_enabled, interactive=interactive, version=version) + reason = _unmet_prerequisite( + output, + toolbar_enabled=toolbar_enabled, + interactive=interactive, + layout_supported=layout_supported, + version=version, + ) if reason is None: return "reserved", "" if mode == "reserved": @@ -102,6 +111,7 @@ def _unmet_prerequisite( *, toolbar_enabled: bool, interactive: bool, + layout_supported: bool, version: str | None, ) -> str | None: """Find the first prerequisite reserved rendering does not have. @@ -113,6 +123,7 @@ def _unmet_prerequisite( :param output: the backend prompt-toolkit selected :param toolbar_enabled: whether a bottom toolbar is configured at all :param interactive: whether input and output are a terminal + :param layout_supported: whether the session's toolbar window can be located :param version: the prompt-toolkit version to judge; the installed one by default :return: the reason, or ``None`` when every prerequisite is met """ @@ -120,6 +131,8 @@ def _unmet_prerequisite( return "no bottom toolbar is configured" if not interactive: return "the session is not interactive" + if not layout_supported: + return "the session's layout has no bottom toolbar window to replace" supported, reason = dependency_capability(version) if not supported: return reason diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py new file mode 100644 index 000000000..b90140276 --- /dev/null +++ b/tests/test_reserved_lifecycle.py @@ -0,0 +1,171 @@ +"""Tests for choosing and owning reserved rendering across a cmd2 command loop. + +The lifetime boundary is the point of these: the reservation is established after the intro +has been printed and released however the loop ends, so the terminal a user gets back at the +shell is the one they started with. +""" + +import io +from typing import Any + +import pytest +from prompt_toolkit.data_structures import Size +from prompt_toolkit.input import create_pipe_input +from prompt_toolkit.output.vt100 import Vt100_Output +from prompt_toolkit.shortcuts import PromptSession + +import cmd2 + + +class TtyStringIO(io.StringIO): + """A stream that claims to be a terminal, as the backend requires.""" + + def isatty(self) -> bool: + return True + + +class Harness: + """A cmd2 application whose main session renders to a terminal the test can read.""" + + def __init__(self, mode: str = "reserved", rows: int = 24, toolbar: Any = "STATUS") -> None: + self.stream = TtyStringIO() + self.size = Size(rows=rows, columns=80) + self.backend = Vt100_Output(self.stream, lambda: self.size) + self._pipe = create_pipe_input() + self.pipe = self._pipe.__enter__() + self.app = cmd2.Cmd(allow_cli_args=False, bottom_toolbar_mode=mode) + self.app.main_session = PromptSession(input=self.pipe, output=self.backend, bottom_toolbar=toolbar) + self.clear() + + def close(self) -> None: + """Release the pipe input.""" + self._pipe.__exit__(None, None, None) + + def clear(self) -> None: + """Discard everything written so far.""" + self.stream.truncate(0) + self.stream.seek(0) + + def written(self) -> str: + """Everything written since the last clear.""" + return self.stream.getvalue() + + +class TestSelection: + def test_legacy_reserves_nothing(self) -> None: + harness = Harness(mode="legacy") + try: + with harness.app._reserved_toolbar_context(): + assert harness.app.reserved_toolbar is None + assert harness.written() == "" + finally: + harness.close() + + def test_reserved_owns_the_terminal_for_the_body(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + assert toolbar.is_active is True + assert "\x1b[1;23r" in harness.written() + assert harness.app.reserved_toolbar is None + finally: + harness.close() + + def test_the_reservation_is_released_when_the_loop_ends(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + harness.clear() + assert "\x1b[r" in harness.written() + finally: + harness.close() + + def test_the_reservation_is_released_when_the_loop_raises(self) -> None: + harness = Harness(mode="reserved") + try: + with pytest.raises(ZeroDivisionError), harness.app._reserved_toolbar_context(): + raise ZeroDivisionError + assert harness.app.reserved_toolbar is None + assert "\x1b[r" in harness.written() + finally: + harness.close() + + def test_auto_falls_back_when_no_toolbar_is_configured(self) -> None: + harness = Harness(mode="auto", toolbar=None) + try: + with harness.app._reserved_toolbar_context(): + assert harness.app.reserved_toolbar is None + finally: + harness.close() + + def test_auto_reserves_on_a_qualified_terminal(self) -> None: + harness = Harness(mode="auto") + try: + with harness.app._reserved_toolbar_context(): + assert harness.app.reserved_toolbar is not None + finally: + harness.close() + + def test_forcing_reserved_without_a_toolbar_fails_before_the_loop(self) -> None: + """The caller ruled out legacy rendering; falling back would ignore that.""" + harness = Harness(mode="reserved", toolbar=None) + try: + with pytest.raises(ValueError, match="toolbar"), harness.app._reserved_toolbar_context(): + pass + finally: + harness.close() + + def test_a_terminal_too_short_to_reserve_still_runs(self) -> None: + """The floor is not an error: the loop runs, with the toolbar rendered natively.""" + harness = Harness(mode="reserved", rows=2) + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + assert toolbar.is_active is False + finally: + harness.close() + + +class TestPaintedToolbar: + def test_the_toolbar_is_painted_on_the_reserved_row(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + assert "\x1b[24;1H" in harness.written() + assert "STATUS" in harness.written() + finally: + harness.close() + + def test_the_content_comes_from_the_session_as_it_is_now(self) -> None: + """Assigning a new provider mid-session has to reach the band.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + harness.app.main_session.bottom_toolbar = "REPLACED" + harness.clear() + assert toolbar.refresh() is True + painter = toolbar.painter + assert painter is not None + assert painter.last_frame is not None + row = "".join(cell.char for cell in painter.last_frame.rows[0]) + assert row.startswith("REPLACED") + finally: + harness.close() + + def test_a_callable_provider_is_resolved(self) -> None: + """cmd2 sets ``bottom_toolbar`` to a method; the band must call it, not print it.""" + harness = Harness(mode="reserved", toolbar=lambda: "FROM CALLABLE") + try: + with harness.app._reserved_toolbar_context(): + painter = harness.app.reserved_toolbar.painter # type: ignore[union-attr] + assert painter is not None + assert painter.last_frame is not None + row = "".join(cell.char for cell in painter.last_frame.rows[0]) + assert row.startswith("FROM CALLABLE") + finally: + harness.close() diff --git a/tests/test_toolbar_mode.py b/tests/test_toolbar_mode.py index 6ac29b702..0461c5c68 100644 --- a/tests/test_toolbar_mode.py +++ b/tests/test_toolbar_mode.py @@ -139,3 +139,17 @@ def test_the_mode_is_read_only(self) -> None: app = cmd2.Cmd(allow_cli_args=False) with pytest.raises(AttributeError): app.bottom_toolbar_mode = "reserved" # type: ignore[misc] + + +class TestLayoutPrerequisite: + def test_an_unrecognized_layout_falls_back_under_auto(self) -> None: + """Reserved rendering has to hide the native toolbar, and cannot find it here.""" + mode, reason = select_toolbar_mode( + "auto", qualified_output(), toolbar_enabled=True, interactive=True, layout_supported=False + ) + assert mode == "legacy" + assert "layout" in reason + + def test_forcing_reserved_on_an_unrecognized_layout_is_an_error(self) -> None: + with pytest.raises(ValueError, match="layout"): + select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=True, layout_supported=False) From c253de5aa16ca2e00f55ff180efd4b0178bb97d1 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 00:42:16 -0400 Subject: [PATCH 05/36] Stage 3 review: roll back a failed startup, and restore only what is still ours A caller using this as a context manager never reaches __exit__ when __enter__ raises, so a failure after the acquisition -- the first paint, say -- left the margins installed, both outputs wrapped and the native toolbar suppressed: a terminal nobody owned and nobody would release. Startup now unwinds itself, best-effort, and lets the original failure propagate. The application's output and the renderer's are saved separately. They are usually the same object, but nothing guarantees it, and restoring one over the other handed the renderer a terminal it never had. The toolbar filter is restored only while it is still the one this object installed, which is the rule the outputs already followed. A filter a caller installed while reserved rendering was live is theirs, not ours to discard. --- cmd2/reserved_toolbar.py | 80 +++++++++++++++--------- tests/test_reserved_toolbar.py | 107 +++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 29 deletions(-) diff --git a/cmd2/reserved_toolbar.py b/cmd2/reserved_toolbar.py index b2379e780..9ac006193 100644 --- a/cmd2/reserved_toolbar.py +++ b/cmd2/reserved_toolbar.py @@ -17,6 +17,7 @@ new ``bottom_toolbar`` to the session still reaches the band. """ +from contextlib import suppress from types import TracebackType from typing import TYPE_CHECKING, Any, Self @@ -87,9 +88,14 @@ def __init__( self._painter: ToolbarPainter | None = None self._lock = TerminalLock() self._bound_output: Any = None - self._original_output: Any = None + # The application's output and the renderer's are saved separately. They are usually + # the same object, but nothing guarantees it, and restoring one over the other would + # hand the renderer a terminal it never had. + self._original_app_output: Any = None + self._original_renderer_output: Any = None self._native_toolbar: ConditionalContainer | None = None self._original_filter: Any = None + self._installed_filter: Any = None @property def is_active(self) -> bool: @@ -148,28 +154,41 @@ def start(self) -> bool: return False self._display = display - self._original_output = app.output - self._bound_output = display.output - app.output = self._bound_output - app.renderer.output = self._bound_output - - # Hidden by asking whether the reservation is live rather than by latching a False. - # A restoration that never runs -- a teardown that raised, a caller that dropped this - # object -- then leaves a filter that heals itself instead of a toolbar that is gone - # for the rest of the session. - self._native_toolbar = native - self._original_filter = native.filter - native.filter = native.filter & Condition(lambda: not self.is_active) - - self._bridge = PromptToolkitBridge(renderer=app.renderer, display=display, lock=self._lock) - self._painter = ToolbarPainter( - display=display, - lock=self._lock, - style=DynamicStyle(get_pt_theme), - color_depth=app.color_depth, - default_style="class:bottom-toolbar", - ) - self.refresh() + try: + self._original_app_output = app.output + self._original_renderer_output = app.renderer.output + self._bound_output = display.output + app.output = self._bound_output + app.renderer.output = self._bound_output + + # Hidden by asking whether the reservation is live rather than by latching a + # False. A restoration that never runs -- a teardown that raised, a caller that + # dropped this object -- then leaves a filter that heals itself instead of a + # toolbar that is gone for the rest of the session. + self._native_toolbar = native + self._original_filter = native.filter + self._installed_filter = native.filter & Condition(lambda: not self.is_active) + native.filter = self._installed_filter + + self._bridge = PromptToolkitBridge(renderer=app.renderer, display=display, lock=self._lock) + self._painter = ToolbarPainter( + display=display, + lock=self._lock, + style=DynamicStyle(get_pt_theme), + color_depth=app.color_depth, + default_style="class:bottom-toolbar", + ) + self.refresh() + except BaseException: + # Everything after the acquisition has to come back off. A caller using this as a + # context manager never reaches ``__exit__`` when ``__enter__`` raises, so a + # failure here would otherwise leave the margins installed, both outputs wrapped + # and the native toolbar suppressed -- a terminal nobody owns and nobody will + # release. Cleanup is best-effort: if it fails too, the original failure is the + # one worth propagating. + with suppress(Exception): + self.stop() + raise return True def refresh(self) -> bool: @@ -198,20 +217,23 @@ def stop(self) -> None: if display is None: return + # Everything here restores only what is still ours. Something else may have replaced + # any of it while the reservation was live, and putting a stale object back is worse + # than leaving a newer one alone. native, self._native_toolbar = self._native_toolbar, None - if native is not None and self._original_filter is not None: + if native is not None and native.filter is self._installed_filter: native.filter = self._original_filter self._original_filter = None + self._installed_filter = None app = self._session.app - # Only put the original back where the adapter is still installed. Something else may - # have rebound these in between, and a stale object is worse than a newer one. if app.output is self._bound_output: - app.output = self._original_output + app.output = self._original_app_output if app.renderer.output is self._bound_output: - app.renderer.output = self._original_output + app.renderer.output = self._original_renderer_output self._bound_output = None - self._original_output = None + self._original_app_output = None + self._original_renderer_output = None display.release() def __enter__(self) -> Self: diff --git a/tests/test_reserved_toolbar.py b/tests/test_reserved_toolbar.py index a0e6f084f..95779151e 100644 --- a/tests/test_reserved_toolbar.py +++ b/tests/test_reserved_toolbar.py @@ -11,6 +11,7 @@ import pytest from prompt_toolkit.data_structures import Size +from prompt_toolkit.filters import Condition from prompt_toolkit.input import create_pipe_input from prompt_toolkit.layout import HSplit, Layout, Window from prompt_toolkit.output.vt100 import Vt100_Output @@ -18,6 +19,7 @@ from cmd2.reserved_output import ReservedOutput from cmd2.reserved_toolbar import ReservedToolbar, native_toolbar_container +from cmd2.toolbar_painter import ToolbarPainter class TtyStringIO(io.StringIO): @@ -369,3 +371,108 @@ def test_refreshing_while_stopped_does_nothing(self) -> None: assert harness.toolbar.refresh() is False finally: harness.close() + + +class TestStartupFailure: + def test_a_failed_first_paint_leaves_no_reservation_behind(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Review finding: __enter__ raising means __exit__ never runs, so start must roll back.""" + + def boom(self: Any, prepared: Any) -> bool: + raise OSError("terminal went away") + + harness = Harness() + try: + monkeypatch.setattr(ToolbarPainter, "paint", boom) + harness.clear() + with pytest.raises(OSError, match="terminal went away"): + harness.toolbar.start() + + assert harness.toolbar.is_active is False + assert harness.app.output is harness.backend + assert harness.app.renderer.output is harness.backend + assert "\x1b[r" in harness.written() + finally: + harness.close() + + def test_a_failed_start_restores_the_native_toolbar(self, monkeypatch: pytest.MonkeyPatch) -> None: + def boom(self: Any, prepared: Any) -> bool: + raise OSError("terminal went away") + + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + original = container.filter + monkeypatch.setattr(ToolbarPainter, "paint", boom) + with pytest.raises(OSError, match="terminal went away"): + harness.toolbar.start() + assert container.filter is original + finally: + harness.close() + + def test_a_failed_start_can_be_retried(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Rollback has to leave the object usable, not merely leave the terminal clean.""" + failures = {"count": 1} + real_paint = ToolbarPainter.paint + + def sometimes(self: Any, prepared: Any) -> bool: + if failures["count"]: + failures["count"] -= 1 + raise OSError("terminal went away") + return bool(real_paint(self, prepared)) + + harness = Harness() + try: + monkeypatch.setattr(ToolbarPainter, "paint", sometimes) + with pytest.raises(OSError, match="terminal went away"): + harness.toolbar.start() + assert harness.toolbar.is_active is False + assert harness.toolbar.start() is True + assert harness.toolbar.is_active is True + finally: + harness.close() + + +class TestRestorationOwnership: + def test_the_renderer_gets_its_own_original_output_back(self) -> None: + """Review finding: the renderer's output need not be the application's.""" + harness = Harness() + try: + other = Vt100_Output(TtyStringIO(), lambda: Size(rows=24, columns=80)) + harness.app.renderer.output = other + + harness.toolbar.start() + harness.toolbar.stop() + + assert harness.app.output is harness.backend + assert harness.app.renderer.output is other + finally: + harness.close() + + def test_a_filter_installed_while_reserved_survives_teardown(self) -> None: + """Review finding: restoring unconditionally discards whatever replaced ours.""" + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + harness.toolbar.start() + + replacement = Condition(lambda: True) + container.filter = replacement + harness.toolbar.stop() + + assert container.filter is replacement + finally: + harness.close() + + def test_the_filter_is_restored_when_it_is_still_ours(self) -> None: + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + original = container.filter + harness.toolbar.start() + harness.toolbar.stop() + assert container.filter is original + finally: + harness.close() From d1ef33894162761d28befaf095775e88a95f184c Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 00:48:42 -0400 Subject: [PATCH 06/36] Stage 3 review: put the terminal back after a half-written paint The backend buffers a paint and flushes it as one write, so a failure part-way through that write leaves the terminal holding a prefix: cursor saved and moved into the band, autowrap off, some cells replaced and some not. Unwinding the Python call undoes none of it -- those sequences are already on the wire. The painter now restores wrap mode and cursor on that path, before anything else touches the terminal. Releasing the margins first would save a cursor still sitting in the band and put it back there afterwards, which is what the rollback was doing. It also discards its baseline: the band is showing something no frame describes, so the next paint has to be a full one rather than a diff against a frame that was never finished. Fixing this in the painter rather than in startup covers every caller. An ordinary refresh mid-session fails the same way and left the same state behind. The regression drives a real partial emission -- a stream that writes a prefix of one batch and then raises. The earlier tests replaced paint() outright, so nothing was ever emitted and they could not have seen this. --- cmd2/toolbar_painter.py | 74 +++++++++++++++++++-------- tests/test_reserved_toolbar.py | 38 ++++++++++++++ tests/test_toolbar_painter.py | 93 ++++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 21 deletions(-) diff --git a/cmd2/toolbar_painter.py b/cmd2/toolbar_painter.py index 51a0f40ec..a1eab3bbe 100644 --- a/cmd2/toolbar_painter.py +++ b/cmd2/toolbar_painter.py @@ -25,6 +25,7 @@ where the painter owns the cursor. """ +from contextlib import suppress from dataclasses import dataclass from typing import TYPE_CHECKING @@ -374,33 +375,64 @@ def paint(self, prepared: PreparedFrame) -> bool: return False top_row = geometry.physical_rows - geometry.reserved_rows + 1 - # Anything another writer left buffered goes out first, so the band is painted - # after the output it was meant to follow rather than in the middle of it. - self._output.flush() - # DECSC saves the cursor *and* the current attributes, and DECRC restores both, so - # the renderer's next write lands where and how it expects. - self._output.write_raw(cursor_save_sequence()) - self._output.disable_autowrap() - for row_index, column, cells in runs: - self._output.write_raw(_cursor_position_sequence(top_row + row_index, column + 1)) - style: str | None = None - for cell in cells: - if cell.is_continuation: - continue - if cell.style != style: - self._output.set_attributes(prepared.attrs[cell.style], prepared.color_depth) - style = cell.style - self._output.write(cell.char) - if self._autowrap_after_paint: - self._output.enable_autowrap() - self._output.write_raw(cursor_restore_sequence()) - self._output.flush() + try: + # Anything another writer left buffered goes out first, so the band is painted + # after the output it was meant to follow rather than in the middle of it. + self._output.flush() + # DECSC saves the cursor *and* the current attributes, and DECRC restores + # both, so the renderer's next write lands where and how it expects. + self._output.write_raw(cursor_save_sequence()) + self._output.disable_autowrap() + for row_index, column, cells in runs: + self._output.write_raw(_cursor_position_sequence(top_row + row_index, column + 1)) + style: str | None = None + for cell in cells: + if cell.is_continuation: + continue + if cell.style != style: + self._output.set_attributes(prepared.attrs[cell.style], prepared.color_depth) + style = cell.style + self._output.write(cell.char) + if self._autowrap_after_paint: + self._output.enable_autowrap() + self._output.write_raw(cursor_restore_sequence()) + self._output.flush() + except BaseException: + self._recover_from_failed_paint() + raise self._last_frame = frame self._last_attrs = prepared.attrs self._last_band = band return True + def _recover_from_failed_paint(self) -> None: + """Undo what a half-finished paint left on the terminal. + + The backend buffers a paint and flushes it as one write, so a failure part-way through + that write leaves the terminal holding a prefix: the cursor saved and moved into the + band, autowrap off, some cells replaced and some not. None of that is undone by + unwinding the Python call -- the sequences are already on the wire. + + Two things follow. The wrap mode and cursor are put back, because leaving autowrap off + makes the next ordinary line of output wrap where it should not, and leaving the cursor + in the band makes the next write land in the toolbar. And the baseline is discarded: + the band is now showing something no frame describes, so the next paint has to be a + full one rather than a diff against a frame that was never finished. + + The restoration is itself a write to a terminal that has just failed one, so its own + failure is suppressed -- the original is the one worth propagating. + """ + self.invalidate() + with suppress(Exception): + if self._autowrap_after_paint: + self._output.enable_autowrap() + # DECRC returns to whatever was last saved. After a partial batch that is either + # this paint's own save or the one the margin change made, so the cursor lands + # somewhere known rather than wherever the truncated write stopped. + self._output.write_raw(cursor_restore_sequence()) + self._output.flush() + def _cursor_position_sequence(row: int, column: int) -> str: """Build a one-based absolute cursor move. diff --git a/tests/test_reserved_toolbar.py b/tests/test_reserved_toolbar.py index 95779151e..303fd66f7 100644 --- a/tests/test_reserved_toolbar.py +++ b/tests/test_reserved_toolbar.py @@ -476,3 +476,41 @@ def test_the_filter_is_restored_when_it_is_still_ours(self) -> None: assert container.filter is original finally: harness.close() + + +class PartialWriteStream(TtyStringIO): + """Writes a prefix of one flushed batch and then fails, as a real terminal can.""" + + def __init__(self, fail_on_write: int, keep: int = 12) -> None: + super().__init__() + self._writes = 0 + self._fail_on_write = fail_on_write + self._keep = keep + + def write(self, text: str) -> int: + self._writes += 1 + if self._writes == self._fail_on_write: + super().write(text[: self._keep]) + raise OSError("terminal went away") + return super().write(text) + + +class TestPartialStartupPaint: + def test_a_half_written_first_paint_leaves_no_state_behind(self) -> None: + """Review finding: rollback released the margins over a cursor still in the band.""" + harness = Harness() + try: + # Batch one installs the margins; batch two is the first paint. + harness.stream = PartialWriteStream(fail_on_write=2) + harness.backend.stdout = harness.stream + with pytest.raises(OSError, match="terminal went away"): + harness.toolbar.start() + + written = harness.stream.getvalue() + # Wrap mode and cursor are put back before the margins are released, so the + # release does not save a cursor that is still inside the band. + assert written.index("\x1b[?7h") < written.index("\x1b[r") + assert harness.toolbar.is_active is False + assert harness.app.output is harness.backend + finally: + harness.close() diff --git a/tests/test_toolbar_painter.py b/tests/test_toolbar_painter.py index 8a7894163..044723fd2 100644 --- a/tests/test_toolbar_painter.py +++ b/tests/test_toolbar_painter.py @@ -9,6 +9,7 @@ import io import re import threading +from typing import Any import pytest from prompt_toolkit.data_structures import Size @@ -617,3 +618,95 @@ def toolbar_paint() -> None: # The writer blocked inside leaf I/O, holding no higher-level lock -- which is what # keeps the rest of cmd2 able to make progress while the terminal is backed up. assert stream.locks_held_while_blocked == () + + +class PartialWriteStream(io.StringIO): + """Writes a prefix of one flushed batch and then fails, as a real terminal can. + + A test that replaces ``paint`` entirely never emits anything, so it cannot see what a + half-written batch leaves behind. The backend buffers a whole paint and flushes it in one + ``write``, so cutting that write short is what puts the terminal into the state this is + about: autowrap off, cursor in the band, nothing restored. + """ + + def __init__(self, fail_on_write: int, keep: int = 12) -> None: + super().__init__() + self._writes = 0 + self._fail_on_write = fail_on_write + self._keep = keep + + def isatty(self) -> bool: + return True + + def write(self, text: str) -> int: + self._writes += 1 + if self._writes == self._fail_on_write: + super().write(text[: self._keep]) + raise OSError("terminal went away") + return super().write(text) + + +class TestPartialPaint: + def make(self, fail_on_write: int = 2) -> tuple[ToolbarPainter, PartialWriteStream, Any]: + """Build a painter over a terminal that fails part-way through one flushed batch. + + Batch one is the margin install, so the default targets the first paint. + """ + stream = PartialWriteStream(fail_on_write=fail_on_write) + screen = {"rows": 24, "columns": 5} + output = Vt100_Output(stream, lambda: Size(rows=screen["rows"], columns=screen["columns"])) + display = ResizableDisplay(output, screen) + assert display.acquire() is True + painter = ToolbarPainter( + display=display, + lock=TerminalLock(), + style=DummyStyle(), + color_depth=ColorDepth.DEPTH_8_BIT, + ) + return painter, stream, display + + def test_a_partial_paint_restores_wrap_and_cursor_state(self) -> None: + """Leaving autowrap off would make the next ordinary line wrap where it should not.""" + painter, stream, _display = self.make() + prepared = painter.prepare(lambda: "hi") + assert prepared is not None + with pytest.raises(OSError, match="terminal went away"): + painter.paint(prepared) + + written = stream.getvalue() + assert "\x1b[?7l" in written # the paint really did start emitting + assert written.endswith("\x1b[?7h\x1b8") # and the cleanup really did finish it + + def test_a_partial_paint_discards_the_baseline(self) -> None: + """Some cells were overwritten and some were not; what the band shows is unknown.""" + painter, _stream, _display = self.make(fail_on_write=3) + first = painter.prepare(lambda: "hi") + assert first is not None + assert painter.paint(first) is True + assert painter.last_frame is not None + + second = painter.prepare(lambda: "zz") + assert second is not None + with pytest.raises(OSError, match="terminal went away"): + painter.paint(second) + assert painter.last_frame is None + + def test_the_next_paint_after_a_failure_is_a_full_one(self) -> None: + """A diff against the discarded baseline would skip the cells that never arrived.""" + painter, stream, _display = self.make(fail_on_write=3) + first = painter.prepare(lambda: "hi") + assert first is not None + painter.paint(first) + + second = painter.prepare(lambda: "zz") + assert second is not None + with pytest.raises(OSError, match="terminal went away"): + painter.paint(second) + + stream.truncate(0) + stream.seek(0) + again = painter.prepare(lambda: "hi") + assert again is not None + assert painter.paint(again) is True + # The whole band, from its first column: not just the cells that differ from "hi". + assert "\x1b[24;1Hhi " in re.sub(r"\x1b\[[0-9;]*m", "", stream.getvalue()) From 411b124e3353a04894aa9d12d96de040a235441d Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 00:58:19 -0400 Subject: [PATCH 07/36] Stage 3 review: only restore a cursor this paint actually saved The paint's opening flush drains whatever another writer left buffered, so a failure there belongs to that output rather than to this paint. Nothing of the band has been emitted, and the terminal's saved position is still the one some earlier operation put there -- the margin change's, most likely. Restoring it would move the cursor backwards over output written since, and the next write would overwrite it. An old saved position is not a recovery origin, so cleanup now asks whether this paint got as far as saving one. Everything else about the path is unchanged: the baseline is still discarded either way, because a paint that raised anywhere leaves a band no frame describes. --- cmd2/toolbar_painter.py | 19 +++++++++++++++++-- tests/test_toolbar_painter.py | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/cmd2/toolbar_painter.py b/cmd2/toolbar_painter.py index a1eab3bbe..b4805315c 100644 --- a/cmd2/toolbar_painter.py +++ b/cmd2/toolbar_painter.py @@ -375,6 +375,9 @@ def paint(self, prepared: PreparedFrame) -> bool: return False top_row = geometry.physical_rows - geometry.reserved_rows + 1 + # Whether *this* paint has saved the cursor yet. The opening flush belongs to + # whoever wrote before us, and a failure there is not a partial paint. + saved_cursor = False try: # Anything another writer left buffered goes out first, so the band is painted # after the output it was meant to follow rather than in the middle of it. @@ -382,6 +385,7 @@ def paint(self, prepared: PreparedFrame) -> bool: # DECSC saves the cursor *and* the current attributes, and DECRC restores # both, so the renderer's next write lands where and how it expects. self._output.write_raw(cursor_save_sequence()) + saved_cursor = True self._output.disable_autowrap() for row_index, column, cells in runs: self._output.write_raw(_cursor_position_sequence(top_row + row_index, column + 1)) @@ -398,7 +402,7 @@ def paint(self, prepared: PreparedFrame) -> bool: self._output.write_raw(cursor_restore_sequence()) self._output.flush() except BaseException: - self._recover_from_failed_paint() + self._recover_from_failed_paint(saved_cursor) raise self._last_frame = frame @@ -406,7 +410,7 @@ def paint(self, prepared: PreparedFrame) -> bool: self._last_band = band return True - def _recover_from_failed_paint(self) -> None: + def _recover_from_failed_paint(self, saved_cursor: bool) -> None: """Undo what a half-finished paint left on the terminal. The backend buffers a paint and flushes it as one write, so a failure part-way through @@ -420,10 +424,21 @@ def _recover_from_failed_paint(self) -> None: the band is now showing something no frame describes, so the next paint has to be a full one rather than a diff against a frame that was never finished. + None of it applies when the paint failed before saving the cursor. The opening flush + drains whatever another writer left buffered, and a failure there belongs to that + output, not to this paint: the terminal's saved position is still the one some earlier + operation put there -- the margin change's, most likely -- and restoring it would move + the cursor backwards over output written since, which the next write would overwrite. + An old saved position is not a recovery origin. + The restoration is itself a write to a terminal that has just failed one, so its own failure is suppressed -- the original is the one worth propagating. + + :param saved_cursor: whether this paint got as far as saving the cursor """ self.invalidate() + if not saved_cursor: + return with suppress(Exception): if self._autowrap_after_paint: self._output.enable_autowrap() diff --git a/tests/test_toolbar_painter.py b/tests/test_toolbar_painter.py index 044723fd2..8ee9fa076 100644 --- a/tests/test_toolbar_painter.py +++ b/tests/test_toolbar_painter.py @@ -710,3 +710,27 @@ def test_the_next_paint_after_a_failure_is_a_full_one(self) -> None: assert painter.paint(again) is True # The whole band, from its first column: not just the cells that differ from "hi". assert "\x1b[24;1Hhi " in re.sub(r"\x1b\[[0-9;]*m", "", stream.getvalue()) + + def test_a_failure_before_any_paint_bytes_restores_nothing(self) -> None: + """The initial flush drains another writer; this paint has saved no cursor yet. + + DECRC would return to whatever was saved last -- the margin change's cursor, from + before the command that has been writing since -- and later output would then overwrite + what is already on the screen. + """ + painter, stream, display = self.make(fail_on_write=2) + prepared = painter.prepare(lambda: "hi") + assert prepared is not None + + # Something else has buffered output, so the paint's opening flush has work to do. + display.terminal.output.write("hello") + stream.truncate(0) + stream.seek(0) + + with pytest.raises(OSError, match="terminal went away"): + painter.paint(prepared) + + written = stream.getvalue() + assert "\x1b8" not in written + assert "\x1b[?7h" not in written + assert painter.last_frame is None From 4071b6c8cf8908c70acefd94f023c35711efbb94 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 01:08:39 -0400 Subject: [PATCH 08/36] Stage 3: treat the cursor as unknown after a failed paint, and fall back if it repeats The backend clears its buffer before writing it, so a failed flush cannot say whether the terminal received a prefix of the batch or none of it. Buffering the cursor save does not prove the terminal got it. Either way the cursor is somewhere this process no longer knows, which makes it the renderer's problem as much as the painter's -- the next frame would be drawn from a believed position that may not be where the cursor is. A failed refresh therefore leaves recovery owed before anything renders again. A failure never reaches the command that was running. The toolbar is cosmetic and the command is not its to interrupt, so the error is kept for the caller to report once, and an error the painter is still holding is taken before the painter is dropped -- one the user never sees is the same as no error handling. One failure is a bad moment; two in a row is a terminal that has gone away. The second gives the rows back and lets the native toolbar render again, because compatibility rendering starts only after the reservation is released, never alongside it. Establishing and refreshing differ deliberately: a band that cannot be painted at all is a reservation that cannot be established, so startup still rolls back and reports rather than running with rows nothing can draw in. --- cmd2/reserved_toolbar.py | 69 ++++++++++++++- tests/test_reserved_toolbar.py | 157 +++++++++++++++++++++++++++++++-- 2 files changed, 220 insertions(+), 6 deletions(-) diff --git a/cmd2/reserved_toolbar.py b/cmd2/reserved_toolbar.py index 9ac006193..7e23e7958 100644 --- a/cmd2/reserved_toolbar.py +++ b/cmd2/reserved_toolbar.py @@ -39,6 +39,12 @@ from prompt_toolkit.shortcuts import PromptSession +#: Consecutive failed paints before the reservation is given up. One is a bad moment -- a +#: window resize mid-write, a transient device error. Two in a row is a terminal that is not +#: coming back, and holding rows in it helps nobody. +_MAX_CONSECUTIVE_PAINT_FAILURES = 2 + + def native_toolbar_container(session: "PromptSession[Any]") -> ConditionalContainer | None: """Find the window prompt-toolkit draws the bottom toolbar in. @@ -93,6 +99,8 @@ def __init__( # hand the renderer a terminal it never had. self._original_app_output: Any = None self._original_renderer_output: Any = None + self._pending_error: BaseException | None = None + self._consecutive_paint_failures = 0 self._native_toolbar: ConditionalContainer | None = None self._original_filter: Any = None self._installed_filter: Any = None @@ -178,7 +186,11 @@ def start(self) -> bool: color_depth=app.color_depth, default_style="class:bottom-toolbar", ) - self.refresh() + # Strict on the way in: a band that cannot be painted at all is a reservation + # that cannot be established, and the rollback below hands the terminal back + # rather than leaving the caller with rows nothing can draw in. Once established, + # the same failure is survivable -- see :meth:`refresh`. + self._paint_once() except BaseException: # Everything after the acquisition has to come back off. A caller using this as a # context manager never reaches ``__exit__`` when ``__enter__`` raises, so a @@ -191,9 +203,36 @@ def start(self) -> bool: raise return True + def take_pending_error(self) -> BaseException | None: + """Take the failure waiting to be reported, if there is one. + + :return: the error to report once, or ``None`` + """ + error, self._pending_error = self._pending_error, None + if error is None and self._painter is not None: + error = self._painter.take_pending_error() + return error + def refresh(self) -> bool: """Evaluate the toolbar's content and paint whatever changed. + A failure here never reaches the command that was running. The toolbar is cosmetic and + the command is not its to interrupt, so the error is kept for the caller to report and + the terminal is put back into a state the next frame can trust. + + :return: whether anything was written + """ + try: + painted = self._paint_once() + except Exception as error: # noqa: BLE001 - a failed paint must not end a command + self._paint_failed(error) + return False + self._consecutive_paint_failures = 0 + return painted + + def _paint_once(self) -> bool: + """Evaluate the content and paint it, letting any failure out. + :return: whether anything was written """ painter = self._painter @@ -204,6 +243,29 @@ def refresh(self) -> bool: return False return painter.paint(prepared) + def _paint_failed(self, error: BaseException) -> None: + """Record a failed paint and decide whether the reservation can continue. + + The backend clears its buffer before writing it, so a failed flush cannot say whether + the terminal received a prefix of the batch or none of it. Either way the cursor is + somewhere this process no longer knows, which makes it the renderer's problem as much + as the painter's: the next frame would be drawn from a believed position that may not + be where the cursor is. Recovery is therefore owed before anything renders again. + + One failure is a bad moment; two in a row is a terminal that has gone away. The second + gives the rows back and lets the native toolbar render again, because compatibility + rendering starts only after the reservation has been released -- never alongside it. + + :param error: what the paint raised + """ + self._pending_error = error + self._consecutive_paint_failures += 1 + if self._bridge is not None: + self._bridge.require_resynchronization("a toolbar paint failed; the cursor's position is unknown") + if self._consecutive_paint_failures >= _MAX_CONSECUTIVE_PAINT_FAILURES: + with suppress(Exception): + self.stop() + def stop(self) -> None: """Restore the application's bindings and release the reservation. @@ -213,7 +275,12 @@ def stop(self) -> None: """ display, self._display = self._display, None self._bridge = None + # The painter is dropped, so anything it was holding to report goes with it unless it + # is taken now. An error the user never sees is the same as no error handling at all. + if self._painter is not None and self._pending_error is None: + self._pending_error = self._painter.take_pending_error() self._painter = None + self._consecutive_paint_failures = 0 if display is None: return diff --git a/tests/test_reserved_toolbar.py b/tests/test_reserved_toolbar.py index 303fd66f7..a44e6f987 100644 --- a/tests/test_reserved_toolbar.py +++ b/tests/test_reserved_toolbar.py @@ -479,17 +479,27 @@ def test_the_filter_is_restored_when_it_is_still_ours(self) -> None: class PartialWriteStream(TtyStringIO): - """Writes a prefix of one flushed batch and then fails, as a real terminal can.""" + """Writes a prefix of chosen flushed batches and then fails, as a real terminal can.""" - def __init__(self, fail_on_write: int, keep: int = 12) -> None: + def __init__(self, *fail_on_writes: int, keep: int = 12) -> None: super().__init__() self._writes = 0 - self._fail_on_write = fail_on_write + self._fail_on_writes = set(fail_on_writes) + self._armed = False self._keep = keep + def fail_next(self) -> None: + """Fail the next flushed batch, whenever it comes. + + Counting batches is brittle -- cleanup after a failure emits one of its own -- so + tests that care about *which* paint fails arm it directly instead. + """ + self._armed = True + def write(self, text: str) -> int: self._writes += 1 - if self._writes == self._fail_on_write: + if self._armed or self._writes in self._fail_on_writes: + self._armed = False super().write(text[: self._keep]) raise OSError("terminal went away") return super().write(text) @@ -501,7 +511,7 @@ def test_a_half_written_first_paint_leaves_no_state_behind(self) -> None: harness = Harness() try: # Batch one installs the margins; batch two is the first paint. - harness.stream = PartialWriteStream(fail_on_write=2) + harness.stream = PartialWriteStream(2) harness.backend.stdout = harness.stream with pytest.raises(OSError, match="terminal went away"): harness.toolbar.start() @@ -514,3 +524,140 @@ def test_a_half_written_first_paint_leaves_no_state_behind(self) -> None: assert harness.app.output is harness.backend finally: harness.close() + + +class TestRefreshFailure: + def make(self, *fail_on_writes: int) -> Harness: + """Build a toolbar over a terminal that fails the chosen flushed batches. + + Batch one installs the margins and batch two is the first paint, so refreshes start + at batch three. + """ + harness = Harness() + harness.stream = PartialWriteStream(*fail_on_writes) + harness.backend.stdout = harness.stream + return harness + + def test_a_failed_paint_does_not_take_the_command_down(self) -> None: + """The toolbar is cosmetic; the command that was running is not its to interrupt.""" + harness = self.make(3) + try: + harness.toolbar.start() + harness.session.bottom_toolbar = "CHANGED" + assert harness.toolbar.refresh() is False + finally: + harness.close() + + def test_a_failed_paint_makes_the_bridge_resynchronize(self) -> None: + """Buffering the cursor save does not prove the terminal received it.""" + harness = self.make(3) + try: + harness.toolbar.start() + bridge = harness.toolbar.bridge + assert bridge is not None + harness.session.bottom_toolbar = "CHANGED" + harness.toolbar.refresh() + assert bridge.needs_resynchronization is True + assert "cursor" in (bridge.resynchronization_reason or "") + finally: + harness.close() + + def test_the_failure_is_reported_once(self) -> None: + harness = self.make(3) + try: + harness.toolbar.start() + harness.session.bottom_toolbar = "CHANGED" + harness.toolbar.refresh() + assert isinstance(harness.toolbar.take_pending_error(), OSError) + assert harness.toolbar.take_pending_error() is None + finally: + harness.close() + + def test_a_terminal_that_keeps_failing_gives_the_rows_back(self) -> None: + """A terminal that fails twice is not coming back; legacy rendering is the fallback.""" + harness = Harness() + try: + harness.toolbar.start() + container = native_toolbar_container(harness.session) + assert container is not None + + harness.stream = AlwaysFailingStream() + harness.backend.stdout = harness.stream + harness.session.bottom_toolbar = "ONE" + harness.toolbar.refresh() + assert harness.toolbar.is_active is True + + harness.session.bottom_toolbar = "TWO" + harness.toolbar.refresh() + assert harness.toolbar.is_active is False + # The native toolbar renders again, which is what "fall back" means here. + assert container.filter() is True + finally: + harness.close() + + def test_a_successful_paint_forgets_earlier_failures(self) -> None: + """Only *consecutive* failures mean the terminal is gone: fail, recover, fail again.""" + harness = self.make() + try: + harness.toolbar.start() + harness.session.bottom_toolbar = "ONE" + harness.stream.fail_next() + assert harness.toolbar.refresh() is False + harness.toolbar.take_pending_error() + + harness.session.bottom_toolbar = "TWO" + assert harness.toolbar.refresh() is True + + harness.session.bottom_toolbar = "THREE" + harness.stream.fail_next() + assert harness.toolbar.refresh() is False + # The success in between reset the count, so this is failure one again. + assert harness.toolbar.is_active is True + finally: + harness.close() + + def test_nothing_is_pending_when_nothing_failed(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + assert harness.toolbar.take_pending_error() is None + finally: + harness.close() + + def test_an_unreported_content_error_survives_teardown(self) -> None: + """The painter is dropped on stop; an error it still held would go with it.""" + + def boom() -> str: + raise RuntimeError("callback failed") + + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.content = boom + harness.toolbar.refresh() + harness.toolbar.stop() + assert isinstance(harness.toolbar.take_pending_error(), RuntimeError) + finally: + harness.close() + + def test_a_failing_content_callback_is_reported_too(self) -> None: + """The painter keeps the last frame; the error still has to reach the user once.""" + + def boom() -> str: + raise RuntimeError("callback failed") + + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.content = boom + assert harness.toolbar.refresh() is False + assert isinstance(harness.toolbar.take_pending_error(), RuntimeError) + finally: + harness.close() + + +class AlwaysFailingStream(TtyStringIO): + """A terminal that has gone away.""" + + def write(self, text: str) -> int: + raise OSError("terminal went away") From c3a8c3475c09ead785712830f1152e95f814c795 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 01:25:45 -0400 Subject: [PATCH 09/36] Stage 3: write command output through the terminal transaction In legacy rendering, output written while a command runs goes through prompt-toolkit's stdout proxy, which erases the toolbar, prints, and draws it again. That erase-and-redraw is the flicker the reserved row exists to remove: with rows withheld from scrolling, output can go straight to the terminal and the toolbar stays where it is. Straight to the terminal, but not at any moment. The write and its flush happen inside the terminal transaction, so a command's output and a toolbar paint reach the terminal one after the other rather than interleaved. The bridge is told inside that same transaction. This is the contract Stage 2b could not enforce from where it sat: output moves the cursor and may scroll the screen, and telling the bridge after the lock is released would tell it about a terminal that may have changed again. Inside, "the terminal changed" and "the change was recorded" are one event. No prompt origin is claimed. Where the cursor ends up after arbitrary output -- wrapped lines, embedded control sequences, a resize mid-write -- is not something this layer knows, and a guess would put the next prompt over committed output. ToolbarStream picks its destination under the routing lock and then releases it before writing. Carrying a routing lock into the terminal transaction is the deadlock the ordering rule exists to prevent; the legacy proxy path keeps holding it, because there the write is what must not race a proxy being closed. --- cmd2/command_toolbar.py | 31 ++++- cmd2/managed_output.py | 84 +++++++++++ tests/test_managed_output.py | 260 +++++++++++++++++++++++++++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 cmd2/managed_output.py create mode 100644 tests/test_managed_output.py diff --git a/cmd2/command_toolbar.py b/cmd2/command_toolbar.py index 96faacb31..d51d9eee6 100644 --- a/cmd2/command_toolbar.py +++ b/cmd2/command_toolbar.py @@ -28,6 +28,7 @@ if TYPE_CHECKING: from .cmd2 import Cmd + from .managed_output import SerializedTerminalWriter _F = TypeVar("_F", bound=Callable[..., Any]) _R = TypeVar("_R") @@ -72,12 +73,21 @@ def _start_write_thread(self) -> threading.Thread: class ToolbarStream: - """Keep a stable stream identity across suspensions and cmd2 redirections.""" + """Keep a stable stream identity across suspensions and cmd2 redirections. + + Output has three possible destinations, in priority order. A *serializer* is installed in + reserved mode: the toolbar sits in rows withheld from scrolling, so output goes straight + to the terminal under the terminal transaction rather than through a proxy that erases the + toolbar and draws it again. A *proxy* is prompt-toolkit's, used in legacy rendering to put + output above a toolbar that does scroll. With neither, the terminal stream itself -- which + is what a suspended toolbar leaves behind. + """ def __init__(self, original: TextIO, lock: "threading.RLock") -> None: """Wrap a terminal stream while preserving its ordinary file attributes.""" self.original = original self.proxy: StdoutProxy | None = None + self.serializer: SerializedTerminalWriter | None = None # Shared with the toolbar so a write from another thread cannot land on a proxy # that is being closed. Such a write is accepted by the dead proxy and discarded. self._lock = lock @@ -85,14 +95,33 @@ def __init__(self, original: TextIO, lock: "threading.RLock") -> None: def write(self, data: str) -> int: """Write above the toolbar, or directly while the toolbar is suspended.""" + serializer = self._serializer() + if serializer is not None: + # Deliberately outside the routing lock. The serializer takes the terminal lock, + # which is the last lock in the output path, and carrying a routing lock into it + # is the deadlock the ordering rule exists to prevent. The serializer revalidates + # what it needs once it holds the terminal. + return serializer.write(data) with self._lock: return (self.proxy or self.original).write(data) def flush(self) -> None: """Flush the currently active output stream.""" + serializer = self._serializer() + if serializer is not None: + serializer.flush() + return with self._lock: (self.proxy or self.original).flush() + def _serializer(self) -> "SerializedTerminalWriter | None": + """Read the installed serializer under the routing lock, then let it go. + + :return: the serializer, or ``None`` when output is routed the legacy way + """ + with self._lock: + return self.serializer + def __getattr__(self, name: str) -> Any: """Delegate file attributes to the original terminal stream.""" return getattr(self.original, name) diff --git a/cmd2/managed_output.py b/cmd2/managed_output.py new file mode 100644 index 000000000..394e6d180 --- /dev/null +++ b/cmd2/managed_output.py @@ -0,0 +1,84 @@ +"""Write command output to a reserved terminal, in order and on the record. + +In legacy rendering, output written while a command runs goes through prompt-toolkit's stdout +proxy, which erases the toolbar, prints, and draws it again. That erase-and-redraw is the +flicker the reserved row exists to remove: with rows withheld from scrolling, output can go +straight to the terminal and the toolbar simply stays where it is. + +Straight to the terminal, but not at any moment. Two rules make that safe. + +**One writer at a time.** The write and its flush happen inside the terminal transaction, the +same one the painter and the renderer take, so a command's output and a toolbar paint reach +the terminal one after the other rather than interleaved. + +**The bridge learns inside that transaction.** Output moves the cursor and may scroll the +screen, which invalidates what the renderer believes about the prompt. Telling the bridge +after the lock is released would tell it about a terminal that may have changed again in +between; telling it inside is what makes "the terminal changed" and "the change was recorded" +one event. + +No prompt origin is claimed. Where the cursor ended up after arbitrary output -- wrapped +lines, embedded control sequences, a resize mid-write -- is not something this layer knows, and +a guess would put the next prompt over committed output. Recovery asks the terminal instead. +""" + +from typing import TYPE_CHECKING, Any + +from .terminal_transaction import TerminalLock + +if TYPE_CHECKING: # pragma: no cover + from typing import TextIO + + +class SerializedTerminalWriter: + """A stream that writes to the terminal under the terminal transaction lock.""" + + def __init__(self, stream: "TextIO", lock: TerminalLock, bridge: Any = None) -> None: + """Wrap the terminal's own stream. + + :param stream: the *original* terminal stream. Never a proxy over it: a physical + writer that routed back into a proxy would queue its own output behind itself. + :param lock: the terminal transaction lock every cmd2-controlled writer shares + :param bridge: the renderer bridge to inform of managed output, if one is active + """ + self._stream = stream + self._lock = lock + self.bridge = bridge + + @property + def stream(self) -> "TextIO": + """The terminal stream being written to.""" + return self._stream + + def write(self, data: str) -> int: + """Write command output to the terminal, then record that it happened. + + :param data: the text to write + :return: the number of characters written + """ + with self._lock.transaction("managed write"): + written = self._stream.write(data) + # Flushed before the lock is given up. Left buffered, this output would reach the + # terminal after whatever paints next, which is the ordering the transaction is + # supposed to establish. + self._stream.flush() + if self.bridge is not None: + self.bridge.note_managed_write() + return written + + def flush(self) -> None: + """Flush the terminal stream. + + Nothing is recorded: a flush moves no cursor and scrolls nothing, so it invalidates + nothing either. The write that produced the buffered output already said so. + """ + with self._lock.transaction("managed flush"): + self._stream.flush() + + def __getattr__(self, name: str) -> Any: + """Delegate file attributes to the terminal stream. + + :param name: the attribute to fetch + :return: the underlying stream's attribute + """ + return getattr(self._stream, name) diff --git a/tests/test_managed_output.py b/tests/test_managed_output.py new file mode 100644 index 000000000..a8a009d9b --- /dev/null +++ b/tests/test_managed_output.py @@ -0,0 +1,260 @@ +"""Tests for writing command output to a reserved terminal. + +The rule this module exists to keep is an ordering one: a command's output and the toolbar's +paint reach the terminal one after the other, never interleaved, and the bridge learns that +output happened *inside* the same transaction that emitted it. Told afterwards, it would be +told about a terminal that had already changed again. +""" + +import io +import threading +from typing import Any + +from cmd2.command_toolbar import ToolbarStream +from cmd2.managed_output import SerializedTerminalWriter +from cmd2.terminal_transaction import TerminalLock, current_transaction + + +class RecordingBridge: + """A stand-in for the bridge, recording when it was told and by whom.""" + + def __init__(self) -> None: + self.notes: list[Any] = [] + self.anchors: list[int | None] = [] + + def note_managed_write(self, prompt_anchor: int | None = None) -> None: + self.notes.append(current_transaction()) + self.anchors.append(prompt_anchor) + + +class RecordingStream(io.StringIO): + """A terminal stream that records the transaction each write and flush ran in.""" + + def __init__(self) -> None: + super().__init__() + self.transactions: list[Any] = [] + self.flushes = 0 + + def write(self, text: str) -> int: + self.transactions.append(current_transaction()) + return super().write(text) + + def flush(self) -> None: + self.flushes += 1 + super().flush() + + +def make(bridge: RecordingBridge | None = None) -> tuple[SerializedTerminalWriter, RecordingStream, TerminalLock]: + """Build a writer over a recording stream.""" + stream = RecordingStream() + lock = TerminalLock() + return SerializedTerminalWriter(stream, lock, bridge), stream, lock + + +class TestWriting: + def test_output_reaches_the_stream(self) -> None: + writer, stream, _lock = make() + writer.write("hello\n") + assert stream.getvalue() == "hello\n" + + def test_the_count_of_characters_written_is_returned(self) -> None: + writer, _stream, _lock = make() + assert writer.write("hello") == 5 + + def test_every_write_is_flushed(self) -> None: + """The toolbar paints after this; unflushed output would appear after the paint.""" + writer, stream, _lock = make() + writer.write("hello") + assert stream.flushes >= 1 + + def test_the_write_happens_inside_a_terminal_transaction(self) -> None: + writer, stream, _lock = make() + writer.write("hello") + assert stream.transactions + assert all(state is not None for state in stream.transactions) + + def test_an_empty_write_still_takes_the_terminal(self) -> None: + """Ordering is about the sequence of transactions, not about how much was written.""" + writer, stream, _lock = make() + assert writer.write("") == 0 + assert stream.flushes >= 1 + + def test_flushing_takes_the_terminal(self) -> None: + writer, stream, _lock = make() + writer.flush() + assert stream.flushes >= 1 + + def test_the_stream_is_reachable(self) -> None: + writer, stream, _lock = make() + assert writer.stream is stream + + def test_file_attributes_come_from_the_stream(self) -> None: + """Callers ask streams whether they are a terminal, and what their encoding is.""" + writer, stream, _lock = make() + assert writer.readable() == stream.readable() + + +class TestInvalidationContract: + def test_the_bridge_is_told_inside_the_emitting_transaction(self) -> None: + """Told afterwards, it would be told about a terminal that has changed again.""" + bridge = RecordingBridge() + writer, _stream, _lock = make(bridge) + writer.write("hello\n") + assert len(bridge.notes) == 1 + assert bridge.notes[0] is not None + + def test_the_prompt_origin_is_not_claimed(self) -> None: + """The output moved the cursor and may have scrolled; where it ended is not known.""" + bridge = RecordingBridge() + writer, _stream, _lock = make(bridge) + writer.write("hello\n") + assert bridge.anchors == [None] + + def test_a_flush_alone_does_not_claim_output_happened(self) -> None: + bridge = RecordingBridge() + writer, _stream, _lock = make(bridge) + writer.flush() + assert bridge.notes == [] + + def test_a_writer_without_a_bridge_still_writes(self) -> None: + """Output outside a reserved session has nothing to invalidate.""" + writer, stream, _lock = make() + writer.write("hello") + assert stream.getvalue() == "hello" + + def test_the_bridge_can_be_attached_later(self) -> None: + """The streams outlive any one reservation; the bridge does not.""" + writer, _stream, _lock = make() + bridge = RecordingBridge() + writer.bridge = bridge + writer.write("hello") + assert len(bridge.notes) == 1 + + +class TestOrdering: + def test_a_write_and_a_paint_do_not_interleave(self) -> None: + """Both take the same lock, so one completes before the other starts.""" + writer, stream, lock = make() + both_inside = threading.Barrier(2, timeout=0.2) + start = threading.Barrier(2, timeout=5) + overlaps: list[int] = [] + + def emit_output() -> None: + start.wait() + writer.write("output\n") + + def paint() -> None: + start.wait() + with lock.transaction("paint"): + try: + both_inside.wait() + except threading.BrokenBarrierError: + return + overlaps.append(1) + + original_write = stream.write + + def watched(text: str) -> int: + try: + both_inside.wait() + except threading.BrokenBarrierError: + pass + else: + overlaps.append(1) + return original_write(text) + + stream.write = watched # type: ignore[method-assign] + threads = [threading.Thread(target=emit_output), threading.Thread(target=paint)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + assert overlaps == [] + + def test_writes_from_two_threads_are_not_torn(self) -> None: + writer, stream, _lock = make() + + def emit(text: str) -> None: + for _ in range(20): + writer.write(text) + + threads = [threading.Thread(target=emit, args=(text,)) for text in ("aaaa", "bbbb")] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + written = stream.getvalue() + assert len(written) == 160 + assert written.count("aaaa") == 20 + assert written.count("bbbb") == 20 + + +class TestToolbarStreamRouting: + """The stream a command writes to has three possible destinations, in priority order.""" + + def make_stream(self) -> tuple[ToolbarStream, RecordingStream]: + """Build a toolbar stream over a recording terminal stream.""" + original = RecordingStream() + return ToolbarStream(original, threading.RLock()), original + + def test_output_goes_to_the_terminal_when_nothing_is_installed(self) -> None: + stream, original = self.make_stream() + stream.write("hello") + assert original.getvalue() == "hello" + + def test_the_serializer_takes_priority_over_the_proxy(self) -> None: + """In reserved mode the proxy's erase-and-redraw is exactly what must not happen.""" + stream, original = self.make_stream() + proxy = RecordingStream() + stream.proxy = proxy # type: ignore[assignment] + stream.serializer = SerializedTerminalWriter(original, TerminalLock()) + + stream.write("hello") + assert original.getvalue() == "hello" + assert proxy.getvalue() == "" + + def test_the_proxy_is_used_when_no_serializer_is_installed(self) -> None: + stream, original = self.make_stream() + proxy = RecordingStream() + stream.proxy = proxy # type: ignore[assignment] + + stream.write("hello") + assert proxy.getvalue() == "hello" + assert original.getvalue() == "" + + def test_the_routing_lock_is_released_before_the_terminal_is_taken(self) -> None: + """Lock order: a routing lock held into the terminal transaction is the deadlock.""" + original = RecordingStream() + routing = threading.RLock() + stream = ToolbarStream(original, routing) + stream.serializer = SerializedTerminalWriter(original, TerminalLock()) + + held: list[bool] = [] + real_write = original.write + + def watched(text: str) -> int: + # Asked from another thread: the routing lock is re-entrant, so the writing + # thread could always take it again regardless of whether it still holds it. + def probe() -> None: + acquired = routing.acquire(blocking=False) + held.append(not acquired) + if acquired: + routing.release() + + prober = threading.Thread(target=probe) + prober.start() + prober.join(timeout=5) + return real_write(text) + + original.write = watched # type: ignore[method-assign] + worker = threading.Thread(target=lambda: stream.write("hello")) + worker.start() + worker.join(timeout=5) + assert held == [False] + + def test_flushing_follows_the_same_priority(self) -> None: + stream, original = self.make_stream() + stream.serializer = SerializedTerminalWriter(original, TerminalLock()) + stream.flush() + assert original.flushes >= 1 From db9b04d2c572695b33c3631fb20ef6f2aa1f2178 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 01:29:19 -0400 Subject: [PATCH 10/36] Stage 3: install the serializer for commands running under a reservation The stdout proxy exists to put output above a toolbar that scrolls with the screen: it erases the toolbar, prints, and draws it again. A reserved toolbar does not scroll, so none of that is needed, and doing it anyway would put back exactly the flicker the reservation removes. The command display therefore installs serialized writers instead of a proxy when a reservation is holding the toolbar, and takes them off again when it stops. Nothing is drained on the way out: a serialized write has reached the terminal before it returns, so there is no queued work to lose. Legacy rendering is untouched, including the routing lock held across a proxy write, which is what stops a write landing on a proxy being closed. --- cmd2/command_toolbar.py | 33 ++++++++++++-- tests/test_reserved_lifecycle.py | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/cmd2/command_toolbar.py b/cmd2/command_toolbar.py index d51d9eee6..b16c49e03 100644 --- a/cmd2/command_toolbar.py +++ b/cmd2/command_toolbar.py @@ -24,11 +24,11 @@ from prompt_toolkit.patch_stdout import StdoutProxy from prompt_toolkit.utils import suspend_to_background_supported +from .managed_output import SerializedTerminalWriter from .pager import Pager, output_fits if TYPE_CHECKING: from .cmd2 import Cmd - from .managed_output import SerializedTerminalWriter _F = TypeVar("_F", bound=Callable[..., Any]) _R = TypeVar("_R") @@ -168,6 +168,7 @@ def __init__(self, cmd: "Cmd") -> None: self._thread: threading.Thread | None = None self._streams: list[ToolbarStream] = [] self._proxy: StdoutProxy | None = None + self._serialized = False self._lock = threading.RLock() self._pausing = False @@ -286,6 +287,8 @@ def run() -> None: self._ready.wait() if self._error is not None: raise self._error + if self._install_serializers(): + return # The worker already combines queued writes. A batching sleep would also # delay close(), which runs at each command finalization boundary. proxy = _ContextStdoutProxy(raw=True, sleep_between_writes=0) @@ -294,6 +297,25 @@ def run() -> None: for stream in self._streams: stream.proxy = proxy + def _install_serializers(self) -> bool: + """Route output straight to the terminal when a reservation is holding the toolbar. + + The stdout proxy exists to put output above a toolbar that scrolls with the screen: + it erases the toolbar, prints, and draws it again. A reserved toolbar does not scroll, + so none of that is needed -- and doing it anyway would reintroduce exactly the flicker + the reservation removes. + + :return: whether serialized writing was installed + """ + reserved = self.cmd.reserved_toolbar + if reserved is None or not reserved.is_active: + return False + with self._lock: + self._serialized = True + for stream in self._streams: + stream.serializer = SerializedTerminalWriter(stream.original, reserved.lock, reserved.bridge) + return True + def _app_exited(self) -> None: """Give the terminal back to the streams when the display stops on its own. @@ -309,9 +331,10 @@ def _app_exited(self) -> None: with self._lock: # Leave self._proxy set so that the next _pause() still drains and closes # it. With the display gone, its worker writes to the terminal directly. - started = self._proxy is not None + started = self._proxy is not None or self._serialized for stream in self._streams: stream.proxy = None + stream.serializer = None # A proxy exists only once _resume() has handed startup failures to the command # thread, so reporting here does not duplicate the exception it raises. @@ -346,8 +369,12 @@ def _pause(self) -> None: self._proxy.close() finally: self._proxy = None + self._serialized = False for stream in self._streams: stream.proxy = None + # Nothing to drain: a serialized write reaches the terminal + # before it returns, so there is no queued work to lose. + stream.serializer = None finally: # The lock is released before joining, since the toolbar thread may be # blocked writing through a stream that is waiting on it. @@ -383,7 +410,7 @@ def stop(self) -> None: @property def is_active(self) -> bool: """Whether the display currently owns the terminal.""" - return self._proxy is not None and self.app.is_running + return (self._proxy is not None or self._serialized) and self.app.is_running def _call_in_ui(self, func: Callable[[], _R]) -> _R: """Change UI state on its event loop, propagating failures to the command.""" diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py index b90140276..e262e7def 100644 --- a/tests/test_reserved_lifecycle.py +++ b/tests/test_reserved_lifecycle.py @@ -34,6 +34,9 @@ def __init__(self, mode: str = "reserved", rows: int = 24, toolbar: Any = "STATU self._pipe = create_pipe_input() self.pipe = self._pipe.__enter__() self.app = cmd2.Cmd(allow_cli_args=False, bottom_toolbar_mode=mode) + # The command's output and the toolbar's paints share one terminal, as they do in + # life: the stream cmd2 writes to is the stream the backend renders to. + self.app.stdout = self.stream self.app.main_session = PromptSession(input=self.pipe, output=self.backend, bottom_toolbar=toolbar) self.clear() @@ -169,3 +172,77 @@ def test_a_callable_provider_is_resolved(self) -> None: assert row.startswith("FROM CALLABLE") finally: harness.close() + + +class TestCommandOutputRouting: + def test_reserved_mode_serializes_output_instead_of_proxying_it(self) -> None: + """The proxy's erase-and-redraw is the flicker; the reservation removes the need.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + assert display._proxy is None + assert display._streams + assert all(stream.serializer is not None for stream in display._streams) + finally: + harness.close() + + def test_legacy_mode_still_proxies(self) -> None: + harness = Harness(mode="legacy") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + assert display._proxy is not None + assert all(stream.serializer is None for stream in display._streams) + finally: + harness.close() + + def test_command_output_reaches_the_terminal(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + harness.clear() + harness.app.poutput("command output") + assert "command output" in harness.written() + finally: + harness.close() + + def test_command_output_tells_the_bridge_inside_the_write(self) -> None: + """The Stage 3 contract: the invalidation is part of the emitting transaction.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + bridge = toolbar.bridge + assert bridge is not None + harness.app.poutput("command output") + assert bridge.needs_resynchronization is True + assert bridge.prompt_anchor is None + finally: + harness.close() + + def test_the_display_reports_itself_active_while_serialized(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + assert display.is_active is True + finally: + harness.close() + + def test_the_streams_are_given_back_when_the_display_stops(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + with harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + streams = list(display._streams) + assert all(stream.serializer is None for stream in streams) + assert harness.app.stdout is harness.app.stdout + finally: + harness.close() From dd9ec8c7638de2f82f7d597f064352851a44ac6d Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 01:36:09 -0400 Subject: [PATCH 11/36] Stage 3 review: invalidate after a failed write, and decide the destination once A write that raised may have emitted part of its text and may have scrolled the screen doing it, and a stream cannot say which. The bridge is therefore told whether or not the write succeeded, still inside the transaction: invalidating after output that never arrived costs a repaint, while not invalidating after output that did costs a prompt drawn over it. ToolbarStream now chooses its destination and acts in one routing-lock acquisition, or acts after a single one. Deciding under the lock, releasing it, and taking it again to act let the destination change in between -- a serializer installed in that gap was skipped, and its write reached the terminal outside any transaction. The serializer still runs after the release, because it takes the terminal lock and carrying a routing lock into that is the deadlock the ordering rule exists to prevent. --- cmd2/command_toolbar.py | 38 ++++++------ cmd2/managed_output.py | 22 ++++--- tests/test_managed_output.py | 113 ++++++++++++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 29 deletions(-) diff --git a/cmd2/command_toolbar.py b/cmd2/command_toolbar.py index b16c49e03..1ba56e1f2 100644 --- a/cmd2/command_toolbar.py +++ b/cmd2/command_toolbar.py @@ -95,32 +95,28 @@ def __init__(self, original: TextIO, lock: "threading.RLock") -> None: def write(self, data: str) -> int: """Write above the toolbar, or directly while the toolbar is suspended.""" - serializer = self._serializer() - if serializer is not None: - # Deliberately outside the routing lock. The serializer takes the terminal lock, - # which is the last lock in the output path, and carrying a routing lock into it - # is the deadlock the ordering rule exists to prevent. The serializer revalidates - # what it needs once it holds the terminal. - return serializer.write(data) with self._lock: - return (self.proxy or self.original).write(data) + serializer = self.serializer + if serializer is None: + return (self.proxy or self.original).write(data) + # Chosen and performed in one acquisition, or performed after a single one. Deciding + # under the lock, releasing it, and then taking it again to act would let the + # destination change in between -- a serializer installed in that gap would be + # skipped, and its write would reach the terminal outside any transaction. + # + # The serializer runs after the release because it takes the terminal lock, which is + # the last lock in the output path: carrying a routing lock into it is the deadlock + # the ordering rule exists to prevent. + return serializer.write(data) def flush(self) -> None: """Flush the currently active output stream.""" - serializer = self._serializer() - if serializer is not None: - serializer.flush() - return - with self._lock: - (self.proxy or self.original).flush() - - def _serializer(self) -> "SerializedTerminalWriter | None": - """Read the installed serializer under the routing lock, then let it go. - - :return: the serializer, or ``None`` when output is routed the legacy way - """ with self._lock: - return self.serializer + serializer = self.serializer + if serializer is None: + (self.proxy or self.original).flush() + return + serializer.flush() def __getattr__(self, name: str) -> Any: """Delegate file attributes to the original terminal stream.""" diff --git a/cmd2/managed_output.py b/cmd2/managed_output.py index 394e6d180..93f32ce30 100644 --- a/cmd2/managed_output.py +++ b/cmd2/managed_output.py @@ -57,13 +57,21 @@ def write(self, data: str) -> int: :return: the number of characters written """ with self._lock.transaction("managed write"): - written = self._stream.write(data) - # Flushed before the lock is given up. Left buffered, this output would reach the - # terminal after whatever paints next, which is the ordering the transaction is - # supposed to establish. - self._stream.flush() - if self.bridge is not None: - self.bridge.note_managed_write() + try: + written = self._stream.write(data) + # Flushed before the lock is given up. Left buffered, this output would reach + # the terminal after whatever paints next, which is the ordering the + # transaction is supposed to establish. + self._stream.flush() + finally: + # Recorded whether or not the write succeeded, and still inside the + # transaction. A write that raised may have emitted part of its text and may + # have scrolled the screen doing it -- a stream cannot say which -- so the + # bridge must not be left believing the terminal is as it was. Invalidating + # after output that never arrived costs a repaint; not invalidating after + # output that did costs a prompt drawn over it. + if self.bridge is not None: + self.bridge.note_managed_write() return written def flush(self) -> None: diff --git a/tests/test_managed_output.py b/tests/test_managed_output.py index a8a009d9b..9ffdbff27 100644 --- a/tests/test_managed_output.py +++ b/tests/test_managed_output.py @@ -8,7 +8,9 @@ import io import threading -from typing import Any +from typing import Any, Self + +import pytest from cmd2.command_toolbar import ToolbarStream from cmd2.managed_output import SerializedTerminalWriter @@ -258,3 +260,112 @@ def test_flushing_follows_the_same_priority(self) -> None: stream.serializer = SerializedTerminalWriter(original, TerminalLock()) stream.flush() assert original.flushes >= 1 + + +class FailingStream(RecordingStream): + """A terminal that fails a chosen operation, having possibly emitted something first.""" + + def __init__(self, fail_write: bool = False, fail_flush: bool = False) -> None: + super().__init__() + self._fail_write = fail_write + self._fail_flush = fail_flush + + def write(self, text: str) -> int: + if self._fail_write: + super().write(text[:3]) + raise OSError("terminal went away") + return super().write(text) + + def flush(self) -> None: + super().flush() + if self._fail_flush: + raise OSError("terminal went away") + + +class TestFailedWrites: + def test_a_failed_write_still_invalidates(self) -> None: + """Part of the output may be on the screen; the bridge cannot be left believing not.""" + bridge = RecordingBridge() + writer = SerializedTerminalWriter(FailingStream(fail_write=True), TerminalLock(), bridge) + with pytest.raises(OSError, match="terminal went away"): + writer.write("hello") + assert len(bridge.notes) == 1 + assert bridge.notes[0] is not None + + def test_a_failed_flush_still_invalidates(self) -> None: + bridge = RecordingBridge() + writer = SerializedTerminalWriter(FailingStream(fail_flush=True), TerminalLock(), bridge) + with pytest.raises(OSError, match="terminal went away"): + writer.write("hello") + assert len(bridge.notes) == 1 + + def test_the_original_failure_is_what_reaches_the_caller(self) -> None: + bridge = RecordingBridge() + writer = SerializedTerminalWriter(FailingStream(fail_write=True), TerminalLock(), bridge) + with pytest.raises(OSError, match="terminal went away"): + writer.write("hello") + + def test_the_invalidation_happens_inside_the_transaction(self) -> None: + """Told after the lock is given up, it would describe a terminal already changed.""" + bridge = RecordingBridge() + writer = SerializedTerminalWriter(FailingStream(fail_flush=True), TerminalLock(), bridge) + with pytest.raises(OSError, match="terminal went away"): + writer.write("hello") + assert bridge.notes[0] is not None + + +class CountingLock: + """A routing lock that counts how many times it was taken.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self.acquisitions = 0 + + def __enter__(self) -> Self: + self._lock.acquire() + self.acquisitions += 1 + return self + + def __exit__(self, *args: object) -> None: + self._lock.release() + + def acquire(self, *args: Any, **kwargs: Any) -> bool: + self.acquisitions += 1 + return self._lock.acquire(*args, **kwargs) + + def release(self) -> None: + self._lock.release() + + +class TestRoutingIsDecidedOnce: + """One acquisition per write, or the destination can change between the two.""" + + def test_a_legacy_write_takes_the_routing_lock_once(self) -> None: + lock = CountingLock() + stream = ToolbarStream(RecordingStream(), lock) # type: ignore[arg-type] + stream.write("hello") + assert lock.acquisitions == 1 + + def test_a_serialized_write_takes_the_routing_lock_once(self) -> None: + lock = CountingLock() + original = RecordingStream() + stream = ToolbarStream(original, lock) # type: ignore[arg-type] + stream.serializer = SerializedTerminalWriter(original, TerminalLock()) + stream.write("hello") + assert lock.acquisitions == 1 + + def test_a_flush_takes_the_routing_lock_once(self) -> None: + lock = CountingLock() + stream = ToolbarStream(RecordingStream(), lock) # type: ignore[arg-type] + stream.flush() + assert lock.acquisitions == 1 + + def test_a_serializer_installed_after_the_decision_is_not_missed_by_the_next_write(self) -> None: + """A write in flight keeps its destination; the one after it sees the new one.""" + original = RecordingStream() + stream = ToolbarStream(original, threading.RLock()) + stream.write("before") + stream.serializer = SerializedTerminalWriter(original, TerminalLock()) + stream.write("after") + assert original.transactions[0] is None + assert original.transactions[-1] is not None From 7c4a664679bc48d984c332164bd17343c3823521 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 01:55:02 -0400 Subject: [PATCH 12/36] Stage 3: route the application's own renders through the bridge The bridge existed and was wired to the renderer, but nothing called it: prompt-toolkit still rendered directly whenever its event loop decided to, so a frame could interleave with a command's output or a toolbar paint. Bounded erases and virtual geometry meant it could not destroy the band, but the ordering the design asks for was not there. Renders, erases and clears now go through the bridge. Preparation runs off the lock, as before, and the recorded batch is replayed inside one transaction -- the same transaction command output and paints take, which is what puts all three writers in one queue. The interception is installed on the renderer instance and removed on the way out. Patching the class would change every renderer in the process, including ones cmd2 does not own. Preparation calls the saved original rather than the attribute, or the wrapper would call itself forever; a mutation that removes that indirection hangs, which is how it was checked. A render that finds recovery owed performs it first. It runs on the UI thread, which is where recovery's callbacks belong anyway, and a frame prepared before the terminal is resynchronized would be diffed against a screen nobody has seen. When recovery cannot finish -- no known origin, waiting on the terminal to say where the cursor is -- the frame is skipped rather than guessed at. An erase or a clear leaves recovery owed: both move the cursor and clear what was below it, so nothing may be diffed against what was there. --- cmd2/prompt_toolkit_bridge.py | 102 ++++++++++++++- cmd2/reserved_toolbar.py | 5 + tests/test_prompt_toolkit_bridge.py | 192 ++++++++++++++++++++++++++++ tests/test_reserved_toolbar.py | 46 +++++++ 4 files changed, 343 insertions(+), 2 deletions(-) diff --git a/cmd2/prompt_toolkit_bridge.py b/cmd2/prompt_toolkit_bridge.py index 9ee14d58b..a5b8c1f61 100644 --- a/cmd2/prompt_toolkit_bridge.py +++ b/cmd2/prompt_toolkit_bridge.py @@ -124,6 +124,11 @@ def __init__(self, renderer: "Renderer", display: "TerminalDisplay", lock: Termi self._prompt_anchor: int | None = None self._resynchronization_reason: str | None = None self._pending_cpr: deque[Generations] = deque() + # The renderer methods this bridge replaced, by name, empty while unbound. Kept so + # preparation can call the real render: calling the attribute would re-enter the + # wrapper and never terminate. + self._originals: dict[str, Any] = {} + self._bound_app: Application[Any] | None = None # -- what is known --------------------------------------------------------------------- @@ -287,12 +292,104 @@ def _retire(self) -> None: self._in_flight = None self._renderer._last_screen = None + # -- binding --------------------------------------------------------------------------- + + def bind(self, app: "Application[Any]") -> None: + """Route the application's own renders through this bridge. + + prompt-toolkit renders from its event loop whenever it decides to, so intercepting is + the only way those frames come under the transaction. The interception is installed on + the renderer *instance* and removed again on the way out -- patching the class would + change every renderer in the process, including ones cmd2 does not own. + + :param app: the application whose renders are being intercepted + """ + if self._originals: + return + renderer = self._renderer + replacements = { + "render": self._render_through_bridge, + "erase": self._erase_through_bridge, + "clear": self._clear_through_bridge, + } + self._originals = {name: getattr(renderer, name) for name in replacements} + self._bound_app = app + for name, replacement in replacements.items(): + # Set by name so the replacement lands on this instance. Assigning the class + # attribute would change every renderer in the process, including ones cmd2 does + # not own. + setattr(renderer, name, replacement) + if self._redraw_scheduler is None: + self._redraw_scheduler = app.invalidate + + def unbind(self) -> None: + """Give the renderer its own methods back. + + Safe to call when nothing was bound: teardown reaches this from more than one place. + """ + originals, self._originals = self._originals, {} + for name, original in originals.items(): + setattr(self._renderer, name, original) + self._bound_app = None + + def _render_through_bridge(self, app: "Application[Any]", layout: Any, is_done: bool = False) -> None: + """Prepare and commit one frame in place of upstream's direct render. + + Runs on the UI thread, which is where recovery's callbacks belong too, so an owed + recovery is done here rather than deferred: a frame prepared before the terminal has + been resynchronized would be diffed against a screen nobody has seen. + + :param app: the application being rendered + :param layout: the layout to render; upstream passes ``app.layout`` + :param is_done: whether this is the final frame of a prompt + """ + if self._reserved_emission_stopped: + # Reserved rendering has been abandoned. Upstream still owns its renderer, and + # its frames are what the user sees from here on. + self._originals["render"](app, layout, is_done) + return + + if self._needs_resynchronization: + self.resynchronize() + if self._needs_resynchronization: + # Recovery is waiting on the terminal to say where the cursor is. Drawing now + # would guess at the origin, which is the thing recovery exists to avoid. + return + + prepared = self.prepare(app, layout, is_done=is_done) + if prepared is None: + self._request_redraw() + return + if not self.commit(prepared): + self._request_redraw() + + def _erase_through_bridge(self, leave_alternate_screen: bool = True) -> None: + """Erase under the transaction, and treat what is left as unknown. + + An erase moves the cursor and clears the screen below it, so nothing may be diffed + against what was there. Upstream's own ``reset()`` runs inside it, which is exactly the + state the bridge must not inherit beliefs from. + + :param leave_alternate_screen: passed through to upstream + """ + with self._lock.transaction("erase"): + self._originals["erase"](leave_alternate_screen) + self.require_resynchronization("the renderer erased the screen") + + def _clear_through_bridge(self) -> None: + """Clear under the transaction, and treat what is left as unknown.""" + with self._lock.transaction("clear"): + self._originals["clear"]() + self.require_resynchronization("the renderer cleared the screen") + # -- prepare and commit ---------------------------------------------------------------- - def prepare(self, app: "Application[Any]") -> PreparedRender | None: + def prepare(self, app: "Application[Any]", layout: Any = None, *, is_done: bool = False) -> PreparedRender | None: """Record a full renderer frame without emitting anything. :param app: the application to render + :param layout: the layout to render; the application's own by default + :param is_done: whether this is the final frame of a prompt :return: the prepared frame, or ``None`` if one cannot be prepared right now """ assert_no_terminal_transaction("preparing a renderer frame") @@ -315,8 +412,9 @@ def prepare(self, app: "Application[Any]") -> PreparedRender | None: # state is provisional -- a second render or an input dispatch started from one of # them would consume a screen that does not exist. self._preparing = True + render = self._originals.get("render", self._renderer.render) try: - self._renderer.render(app, app.layout) + render(app, app.layout if layout is None else layout, is_done) except Exception as error: # noqa: BLE001 - a layout callback must not end a command self._pending_error = error self.require_resynchronization("preparing the frame raised") diff --git a/cmd2/reserved_toolbar.py b/cmd2/reserved_toolbar.py index 7e23e7958..f6e651eaa 100644 --- a/cmd2/reserved_toolbar.py +++ b/cmd2/reserved_toolbar.py @@ -179,6 +179,9 @@ def start(self) -> bool: native.filter = self._installed_filter self._bridge = PromptToolkitBridge(renderer=app.renderer, display=display, lock=self._lock) + # From here the application's own renders go through prepare and commit, which is + # what puts them in the same queue as command output and toolbar paints. + self._bridge.bind(app) self._painter = ToolbarPainter( display=display, lock=self._lock, @@ -274,6 +277,8 @@ def stop(self) -> None: other. """ display, self._display = self._display, None + if self._bridge is not None: + self._bridge.unbind() self._bridge = None # The painter is dropped, so anything it was holding to report goes with it unless it # is taken now. An error the user never sees is the same as no error handling at all. diff --git a/tests/test_prompt_toolkit_bridge.py b/tests/test_prompt_toolkit_bridge.py index 74bcdc65a..7078ab178 100644 --- a/tests/test_prompt_toolkit_bridge.py +++ b/tests/test_prompt_toolkit_bridge.py @@ -889,3 +889,195 @@ def test_a_frame_in_flight_while_recovery_is_owed_is_not_committed(self) -> None assert harness.bridge.commit(prepared) is False assert harness.written() == "" + + +class RecordingTtyStream(TtyStringIO): + """A terminal that records the transaction each write ran in.""" + + def __init__(self) -> None: + super().__init__() + self.transactions: list[Any] = [] + + def write(self, text: str) -> int: + self.transactions.append(current_transaction()) + return super().write(text) + + +class TestRenderInterception: + """Once bound, prompt-toolkit's own renders go through prepare and commit.""" + + def bound(self, content: Any = "hello") -> Harness: + """Build a harness whose renderer is intercepted by the bridge.""" + harness = Harness(content=content) + harness.stream_recorder = RecordingTtyStream() + harness.backend.stdout = harness.stream_recorder + harness.bridge.bind(harness.app) + return harness + + def test_a_render_reaches_the_terminal(self) -> None: + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert "hello" in harness.stream_recorder.getvalue() + + def test_a_render_is_emitted_inside_a_terminal_transaction(self) -> None: + """This is the whole point: renders serialize against paints and command output.""" + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.stream_recorder.transactions + assert all(state is not None for state in harness.stream_recorder.transactions) + + def test_layout_callbacks_still_run_outside_the_transaction(self) -> None: + seen: list[object] = [] + + def content() -> str: + seen.append(current_transaction()) + return "hello" + + harness = self.bound(content=content) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert seen + assert all(state is None for state in seen) + + def test_the_frame_is_committed_rather_than_left_in_flight(self) -> None: + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.bridge.in_flight is None + assert harness.bridge.can_dispatch_input is True + + def test_preparation_does_not_re_enter_the_interception(self) -> None: + """The recorded render has to be upstream's, not the wrapper calling itself.""" + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + # A recursive wrapper would never terminate; reaching here with output proves it did. + assert "hello" in harness.stream_recorder.getvalue() + + def test_a_render_owed_recovery_recovers_first(self) -> None: + harness = self.bound() + harness.bridge.set_prompt_anchor(3) + harness.bridge.require_resynchronization("a command wrote") + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.bridge.needs_resynchronization is False + assert "\x1b[3;1H" in harness.stream_recorder.getvalue() + + def test_a_render_with_no_known_origin_asks_and_waits(self) -> None: + """Recovery cannot finish without an origin, so this frame is not drawn.""" + harness = self.bound() + harness.bridge.forget_prompt_anchor() + harness.bridge.require_resynchronization("a command wrote") + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert "\x1b[6n" in harness.stream_recorder.getvalue() + assert harness.bridge.needs_resynchronization is True + + def test_an_erase_is_emitted_inside_a_transaction(self) -> None: + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.stream_recorder.transactions.clear() + harness.renderer.erase() + assert harness.stream_recorder.transactions + assert all(state is not None for state in harness.stream_recorder.transactions) + + def test_an_erase_leaves_recovery_owed(self) -> None: + """It moved the cursor and cleared the screen below it; nothing may diff against that.""" + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.renderer.erase() + assert harness.bridge.needs_resynchronization is True + + def test_binding_can_be_undone(self) -> None: + harness = Harness() + original_render = harness.renderer.render + original_erase = harness.renderer.erase + harness.bridge.bind(harness.app) + assert harness.renderer.render is not original_render + harness.bridge.unbind() + assert harness.renderer.render == original_render + assert harness.renderer.erase == original_erase + + def test_unbinding_twice_is_harmless(self) -> None: + harness = Harness() + harness.bridge.bind(harness.app) + harness.bridge.unbind() + harness.bridge.unbind() + + def test_a_stale_frame_emits_nothing_and_asks_for_another(self) -> None: + """A command write during preparation retires the frame; the redraw is rescheduled.""" + redraws: list[int] = [] + harness = self.bound(content=lambda: harness.bridge.note_managed_write() or "hello") + harness.bridge.set_redraw_scheduler(lambda: redraws.append(1)) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.stream_recorder.getvalue() == "" + assert redraws + + def test_binding_twice_keeps_the_first_interception(self) -> None: + """A second bind would save the wrapper as the original and never unwind.""" + harness = self.bound() + wrapper = harness.renderer.render + harness.bridge.bind(harness.app) + assert harness.renderer.render == wrapper + harness.bridge.unbind() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.bridge.in_flight is None + + def test_an_abandoned_reservation_renders_upstream_directly(self) -> None: + """Compatibility rendering is the fallback, and it is upstream's own renderer.""" + harness = self.bound() + harness.bridge.stop_reserved_emission(OSError("terminal went away")) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert "hello" in harness.stream_recorder.getvalue() + assert harness.stream_recorder.transactions + assert all(state is None for state in harness.stream_recorder.transactions) + + def test_a_frame_that_cannot_be_prepared_asks_for_another(self) -> None: + redraws: list[int] = [] + harness = self.bound() + harness.bridge.set_redraw_scheduler(lambda: redraws.append(1)) + with set_app(harness.app): + prepared = harness.bridge.prepare(harness.app) + assert prepared is not None + # A frame is already in flight, so the intercepted render cannot prepare one. + harness.renderer.render(harness.app, harness.app.layout) + assert redraws + + def test_a_frame_that_cannot_commit_asks_for_another(self) -> None: + """Retired between preparing and committing: nothing is emitted, a redraw is asked for.""" + redraws: list[int] = [] + handover = RetiringLock() + harness = Harness(lock=TerminalLock(lock=handover)) + harness.stream_recorder = RecordingTtyStream() + harness.backend.stdout = harness.stream_recorder + harness.bridge.bind(harness.app) + harness.bridge.set_redraw_scheduler(lambda: redraws.append(1)) + + # Preflight and publish take the terminal first; the third acquisition is the commit. + handover.schedule(None, None, harness.bridge.note_owner_change) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + + assert harness.stream_recorder.getvalue() == "" + assert redraws + + def test_a_clear_is_emitted_inside_a_transaction_and_invalidates(self) -> None: + harness = self.bound() + # Upstream's clear() ends by scheduling a cursor-position request on the event loop, + # which only exists while the application is running. The wrapper is what is under + # test here, not that scheduling. + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.stream_recorder.transactions.clear() + harness.renderer.clear() + assert harness.stream_recorder.transactions + assert all(state is not None for state in harness.stream_recorder.transactions) + assert harness.bridge.needs_resynchronization is True diff --git a/tests/test_reserved_toolbar.py b/tests/test_reserved_toolbar.py index a44e6f987..fa9d8b3e7 100644 --- a/tests/test_reserved_toolbar.py +++ b/tests/test_reserved_toolbar.py @@ -661,3 +661,49 @@ class AlwaysFailingStream(TtyStringIO): def write(self, text: str) -> int: raise OSError("terminal went away") + + +class TestRenderBinding: + def test_starting_routes_the_application_renders_through_the_bridge(self) -> None: + harness = Harness() + try: + original = harness.app.renderer.render + harness.toolbar.start() + assert harness.app.renderer.render is not original + finally: + harness.close() + + def test_stopping_gives_the_renderer_its_methods_back(self) -> None: + harness = Harness() + try: + original = harness.app.renderer.render + harness.toolbar.start() + harness.toolbar.stop() + assert harness.app.renderer.render == original + finally: + harness.close() + + def test_a_failed_start_unbinds_as_well(self, monkeypatch: pytest.MonkeyPatch) -> None: + def boom(self: Any, prepared: Any) -> bool: + raise OSError("terminal went away") + + harness = Harness() + try: + original = harness.app.renderer.render + monkeypatch.setattr(ToolbarPainter, "paint", boom) + with pytest.raises(OSError, match="terminal went away"): + harness.toolbar.start() + assert harness.app.renderer.render == original + finally: + harness.close() + + def test_the_redraw_scheduler_asks_the_application(self) -> None: + """A frame that could not be committed has to come back, and the app owns that.""" + harness = Harness() + try: + harness.toolbar.start() + bridge = harness.toolbar.bridge + assert bridge is not None + assert bridge._redraw_scheduler == harness.app.invalidate + finally: + harness.close() From 5c08606b3f09378c10708a2cb5cd2c5447efa1ca Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 02:02:37 -0400 Subject: [PATCH 13/36] Stage 3 review: release before falling back, and invalidate on the failing paths Abandoning reserved emission did not release the rows, so rendering upstream directly from that branch wrote outside the transaction into a terminal that was still reserved. It now renders nothing and tells the owner, which is the only thing that can give the rows back and unbind the renderer. Compatibility rendering then follows that release rather than racing it: with the bridge unbound, upstream's own render is back on the renderer and nothing routes through here at all. An erase or clear that raised part-way has still moved the cursor and cleared some of what was below it, and a stream cannot say how much, so invalidation moved inside the transaction and onto both paths -- the rule the managed writer already follows. A clear also moves the prompt. Whatever row it started on it is not that row now, so the remembered origin is forgotten rather than carried across, and the cursor reports already in flight -- which describe the screen before the clear -- are discarded with it. Unbinding restores only the methods still holding this bridge's replacements. Another caller may have wrapped the renderer since, and putting the original back over theirs would silently undo it. That is the rule the outputs and the toolbar filter already followed. --- cmd2/prompt_toolkit_bridge.py | 69 +++++++++++++--- cmd2/reserved_toolbar.py | 16 ++++ tests/test_prompt_toolkit_bridge.py | 121 +++++++++++++++++++++++++++- tests/test_reserved_toolbar.py | 32 ++++++++ 4 files changed, 226 insertions(+), 12 deletions(-) diff --git a/cmd2/prompt_toolkit_bridge.py b/cmd2/prompt_toolkit_bridge.py index a5b8c1f61..c82e24eef 100644 --- a/cmd2/prompt_toolkit_bridge.py +++ b/cmd2/prompt_toolkit_bridge.py @@ -128,7 +128,11 @@ def __init__(self, renderer: "Renderer", display: "TerminalDisplay", lock: Termi # preparation can call the real render: calling the attribute would re-enter the # wrapper and never terminate. self._originals: dict[str, Any] = {} + # What this bridge put in their place, so teardown can tell its own replacements from + # something another caller installed afterwards. + self._installed: dict[str, Any] = {} self._bound_app: Application[Any] | None = None + self._emission_stopped_handler: Callable[[], None] | None = None # -- what is known --------------------------------------------------------------------- @@ -259,14 +263,28 @@ def require_resynchronization(self, reason: str) -> None: self._resynchronization_reason = reason self._retire() + def set_emission_stopped_handler(self, handler: "Callable[[], None]") -> None: + """Install what to call when reserved rendering has to be abandoned. + + The owner of the reservation is what runs here: rendering cannot resume until the rows + have been given back and this bridge unbound, and only the owner can do either. + + :param handler: called once, when emission is abandoned + """ + self._emission_stopped_handler = handler + def stop_reserved_emission(self, error: BaseException) -> None: """Abandon reserved rendering after a failure that could not be cleaned up. :param error: what went wrong, to be reported once """ + if self._reserved_emission_stopped: + return self._reserved_emission_stopped = True self._pending_error = error self._retire() + if self._emission_stopped_handler is not None: + self._emission_stopped_handler() def set_prompt_anchor(self, physical_row: int) -> None: """Record the physical row the prompt starts on. @@ -313,6 +331,7 @@ def bind(self, app: "Application[Any]") -> None: "clear": self._clear_through_bridge, } self._originals = {name: getattr(renderer, name) for name in replacements} + self._installed = dict(replacements) self._bound_app = app for name, replacement in replacements.items(): # Set by name so the replacement lands on this instance. Assigning the class @@ -328,8 +347,13 @@ def unbind(self) -> None: Safe to call when nothing was bound: teardown reaches this from more than one place. """ originals, self._originals = self._originals, {} + installed, self._installed = self._installed, {} for name, original in originals.items(): - setattr(self._renderer, name, original) + # Restored only where this bridge's replacement is still in place. Another caller + # may have wrapped the renderer since -- for tracing, for a test -- and putting + # the original back over theirs would silently undo it. + if getattr(self._renderer, name, None) == installed.get(name): + setattr(self._renderer, name, original) self._bound_app = None def _render_through_bridge(self, app: "Application[Any]", layout: Any, is_done: bool = False) -> None: @@ -344,9 +368,11 @@ def _render_through_bridge(self, app: "Application[Any]", layout: Any, is_done: :param is_done: whether this is the final frame of a prompt """ if self._reserved_emission_stopped: - # Reserved rendering has been abandoned. Upstream still owns its renderer, and - # its frames are what the user sees from here on. - self._originals["render"](app, layout, is_done) + # Abandoned, but the rows are still withheld until the owner releases them. + # Rendering upstream directly from here would write outside the transaction and + # into a terminal that is still reserved. Compatibility rendering follows the + # release: once the owner has unbound this bridge, upstream's own render is back + # on the renderer and nothing routes through here at all. return if self._needs_resynchronization: @@ -373,14 +399,29 @@ def _erase_through_bridge(self, leave_alternate_screen: bool = True) -> None: :param leave_alternate_screen: passed through to upstream """ with self._lock.transaction("erase"): - self._originals["erase"](leave_alternate_screen) - self.require_resynchronization("the renderer erased the screen") + try: + self._originals["erase"](leave_alternate_screen) + finally: + # Recorded whether or not it finished. An erase that raised part-way has still + # moved the cursor and cleared some of what was below it, and a stream cannot + # say how much. + self.require_resynchronization("the renderer erased the screen") def _clear_through_bridge(self) -> None: - """Clear under the transaction, and treat what is left as unknown.""" + """Clear under the transaction, and treat what is left as unknown. + + A clear also moves the prompt. Whatever row it started on, it is not that row now, so + the remembered origin is forgotten rather than carried across -- recovery would + otherwise place the next frame where the prompt used to be. Cursor reports already in + flight describe the screen before the clear and are discarded with it. + """ with self._lock.transaction("clear"): - self._originals["clear"]() - self.require_resynchronization("the renderer cleared the screen") + try: + self._originals["clear"]() + finally: + self._prompt_anchor = None + self._discard_pending_cursor_reports() + self.require_resynchronization("the renderer cleared the screen") # -- prepare and commit ---------------------------------------------------------------- @@ -730,6 +771,16 @@ def report_cursor_row(self, row: int) -> bool: self._renderer.report_absolute_cursor_row(row) return True + def _discard_pending_cursor_reports(self) -> None: + """Drop every outstanding request, settling the bookkeeping each one owns. + + Used where the screen changed underneath the requests themselves. Left in the queue, + the next reply to arrive would be matched to a request made about a different screen. + """ + while self._pending_cpr: + self._pending_cpr.popleft() + self._settle_renderer_cpr() + def _settle_renderer_cpr(self) -> None: """Resolve one of the renderer's own pending reports, if it has any. diff --git a/cmd2/reserved_toolbar.py b/cmd2/reserved_toolbar.py index f6e651eaa..43d11cb8d 100644 --- a/cmd2/reserved_toolbar.py +++ b/cmd2/reserved_toolbar.py @@ -182,6 +182,11 @@ def start(self) -> bool: # From here the application's own renders go through prepare and commit, which is # what puts them in the same queue as command output and toolbar paints. self._bridge.bind(app) + # When the bridge abandons reserved rendering it cannot resume anything itself: + # the rows are still withheld and the renderer is still routed through it. Giving + # them back is this object's job, and it is what lets compatibility rendering + # start. + self._bridge.set_emission_stopped_handler(self._emission_stopped) self._painter = ToolbarPainter( display=display, lock=self._lock, @@ -246,6 +251,17 @@ def _paint_once(self) -> bool: return False return painter.paint(prepared) + def _emission_stopped(self) -> None: + """Release the reservation after the bridge has given up on it. + + The error the bridge is holding is taken here rather than left with it: the bridge is + dropped a moment later, and an error the user never sees is the same as none. + """ + if self._bridge is not None and self._pending_error is None: + self._pending_error = self._bridge.take_pending_error() + with suppress(Exception): + self.stop() + def _paint_failed(self, error: BaseException) -> None: """Record a failed paint and decide whether the reservation can continue. diff --git a/tests/test_prompt_toolkit_bridge.py b/tests/test_prompt_toolkit_bridge.py index 7078ab178..1bec1218c 100644 --- a/tests/test_prompt_toolkit_bridge.py +++ b/tests/test_prompt_toolkit_bridge.py @@ -1029,14 +1029,20 @@ def test_binding_twice_keeps_the_first_interception(self) -> None: harness.renderer.render(harness.app, harness.app.layout) assert harness.bridge.in_flight is None - def test_an_abandoned_reservation_renders_upstream_directly(self) -> None: - """Compatibility rendering is the fallback, and it is upstream's own renderer.""" + def test_compatibility_rendering_follows_the_release(self) -> None: + """The fallback is upstream's own renderer -- reached by unbinding, not by calling it.""" harness = self.bound() harness.bridge.stop_reserved_emission(OSError("terminal went away")) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.stream_recorder.getvalue() == "" + + # What the owner does on release: give the rows back, then unbind. + harness.display.release() + harness.bridge.unbind() with set_app(harness.app): harness.renderer.render(harness.app, harness.app.layout) assert "hello" in harness.stream_recorder.getvalue() - assert harness.stream_recorder.transactions assert all(state is None for state in harness.stream_recorder.transactions) def test_a_frame_that_cannot_be_prepared_asks_for_another(self) -> None: @@ -1081,3 +1087,112 @@ def test_a_clear_is_emitted_inside_a_transaction_and_invalidates(self) -> None: assert harness.stream_recorder.transactions assert all(state is not None for state in harness.stream_recorder.transactions) assert harness.bridge.needs_resynchronization is True + + +class AlwaysFailingTtyStream(TtyStringIO): + """A terminal that has gone away, having possibly emitted something first.""" + + def write(self, text: str) -> int: + super().write(text[:4]) + raise OSError("terminal went away") + + +class TestReviewRegressionsRoundThree: + def bound(self, content: Any = "hello") -> Harness: + """Build a harness whose renderer is intercepted by the bridge.""" + harness = Harness(content=content) + harness.stream_recorder = RecordingTtyStream() + harness.backend.stdout = harness.stream_recorder + harness.bridge.bind(harness.app) + return harness + + def test_abandoned_emission_renders_nothing_until_the_owner_releases(self) -> None: + """Review finding: compatibility rendering starts after the release, not before it.""" + harness = self.bound() + harness.bridge.stop_reserved_emission(OSError("terminal went away")) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.stream_recorder.getvalue() == "" + assert harness.display.is_reserved is True + + def test_abandoning_emission_tells_the_owner_to_release(self) -> None: + released: list[int] = [] + harness = self.bound() + harness.bridge.set_emission_stopped_handler(lambda: released.append(1)) + harness.bridge.stop_reserved_emission(OSError("terminal went away")) + assert released == [1] + + def test_a_failed_erase_still_invalidates(self) -> None: + """Review finding: it emitted something before it raised, and moved the cursor.""" + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.backend.stdout = AlwaysFailingTtyStream() + + with pytest.raises(OSError, match="terminal went away"), set_app(harness.app): + harness.renderer.erase() + assert harness.bridge.needs_resynchronization is True + + def test_a_failed_clear_still_invalidates(self) -> None: + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.backend.stdout = AlwaysFailingTtyStream() + + with pytest.raises(OSError, match="terminal went away"), set_app(harness.app): + harness.renderer.clear() + assert harness.bridge.needs_resynchronization is True + + def test_clearing_forgets_where_the_prompt_was(self) -> None: + """Review finding: the clear moved the cursor, so the remembered row is not it.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + harness.bridge.set_prompt_anchor(7) + with set_app(harness.app): + harness.renderer.clear() + assert harness.bridge.prompt_anchor is None + + harness.clear() + harness.stream_recorder.truncate(0) + harness.stream_recorder.seek(0) + harness.resynchronize() + assert "\x1b[7;1H" not in harness.stream_recorder.getvalue() + + def test_clearing_discards_outstanding_cursor_reports(self) -> None: + """A reply describing the screen before the clear must not establish an origin.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + harness.bridge.request_cursor_position() + with set_app(harness.app): + harness.renderer.clear() + assert harness.bridge.report_cursor_row(4) is False + assert harness.bridge.prompt_anchor is None + + def test_unbinding_leaves_a_newer_method_alone(self) -> None: + """Review finding: restoring unconditionally discards whatever replaced ours.""" + harness = self.bound() + replacement = lambda *args, **kwargs: None # noqa: E731 + harness.renderer.render = replacement # type: ignore[method-assign] + harness.bridge.unbind() + assert harness.renderer.render is replacement + + def test_unbinding_restores_the_methods_that_are_still_ours(self) -> None: + harness = Harness() + original_erase = harness.renderer.erase + harness.bridge.bind(harness.app) + replacement = lambda *args, **kwargs: None # noqa: E731 + harness.renderer.render = replacement # type: ignore[method-assign] + harness.bridge.unbind() + assert harness.renderer.render is replacement + assert harness.renderer.erase == original_erase + + def test_abandoning_emission_twice_notifies_once(self) -> None: + """The owner releases once; telling it again would release a reservation it re-took.""" + released: list[int] = [] + harness = self.bound() + harness.bridge.set_emission_stopped_handler(lambda: released.append(1)) + harness.bridge.stop_reserved_emission(OSError("terminal went away")) + harness.bridge.stop_reserved_emission(OSError("and again")) + assert released == [1] + assert str(harness.bridge.take_pending_error()) == "terminal went away" diff --git a/tests/test_reserved_toolbar.py b/tests/test_reserved_toolbar.py index fa9d8b3e7..00e6ad8aa 100644 --- a/tests/test_reserved_toolbar.py +++ b/tests/test_reserved_toolbar.py @@ -707,3 +707,35 @@ def test_the_redraw_scheduler_asks_the_application(self) -> None: assert bridge._redraw_scheduler == harness.app.invalidate finally: harness.close() + + +class TestAbandonedEmission: + def test_the_owner_releases_when_the_bridge_gives_up(self) -> None: + """Rendering cannot resume until the rows are back and the bridge is unbound.""" + harness = Harness() + try: + original_render = harness.app.renderer.render + harness.toolbar.start() + bridge = harness.toolbar.bridge + assert bridge is not None + harness.clear() + + bridge.stop_reserved_emission(OSError("terminal went away")) + + assert harness.toolbar.is_active is False + assert "\x1b[r" in harness.written() + assert harness.app.renderer.render == original_render + assert harness.app.output is harness.backend + finally: + harness.close() + + def test_the_failure_is_still_reported(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + bridge = harness.toolbar.bridge + assert bridge is not None + bridge.stop_reserved_emission(OSError("terminal went away")) + assert isinstance(harness.toolbar.take_pending_error(), OSError) + finally: + harness.close() From b01eeabb2494140f1b5dd2941af0d312e05b8443 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 10:16:23 -0400 Subject: [PATCH 14/36] Stage 3 review: route the real abandonment through the owner, and mark stale requests Cleanup failure -- the one path that actually abandons reserved emission -- stopped emission by setting the flag itself, so the owner was never told: the rows stayed withheld and the renderer stayed bound. It goes through the same door as every other abandonment now. The later call that would have notified could not help, since it finds emission already stopped and returns. Emptying the pending cursor-report queue could not discard the replies: they are already in the terminal's hands. The next one to arrive was then matched against whatever request came after the clear -- the oldest reply answering the newest question, publishing an origin from a screen that no longer exists. The entries stay in the queue now, marked, so each reply is still consumed in order and each one is refused. --- cmd2/prompt_toolkit_bridge.py | 35 +++++++++++++--------- tests/test_prompt_toolkit_bridge.py | 45 +++++++++++++++++++++++++++++ tests/test_reserved_toolbar.py | 23 +++++++++++++++ 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/cmd2/prompt_toolkit_bridge.py b/cmd2/prompt_toolkit_bridge.py index c82e24eef..d3f6fed77 100644 --- a/cmd2/prompt_toolkit_bridge.py +++ b/cmd2/prompt_toolkit_bridge.py @@ -123,7 +123,10 @@ def __init__(self, renderer: "Renderer", display: "TerminalDisplay", lock: Termi self._pending_error: BaseException | None = None self._prompt_anchor: int | None = None self._resynchronization_reason: str | None = None - self._pending_cpr: deque[Generations] = deque() + # One entry per outstanding request, in the order they went out. An entry is ``None`` + # once the screen it asked about is gone: the reply is still coming, so the place has + # to be kept, but nothing it says can be believed. + self._pending_cpr: deque[Generations | None] = deque() # The renderer methods this bridge replaced, by name, empty while unbound. Kept so # preparation can call the real render: calling the attribute would re-enter the # wrapper and never terminate. @@ -420,7 +423,7 @@ def _clear_through_bridge(self) -> None: self._originals["clear"]() finally: self._prompt_anchor = None - self._discard_pending_cursor_reports() + self._invalidate_pending_cursor_reports() self.require_resynchronization("the renderer cleared the screen") # -- prepare and commit ---------------------------------------------------------------- @@ -544,8 +547,11 @@ def _attempt_cleanup(self) -> bool: output.flush() self._display.reconfigure() except Exception as error: # noqa: BLE001 - cleanup failing is itself the answer - self._reserved_emission_stopped = True - self._pending_error = error + # Through the same door as every other abandonment, so the owner is told and can + # give the rows back. Setting the flag here directly would stop emission while + # leaving the reservation installed and the renderer bound -- and the later call + # that would have notified now returns early, having found it already stopped. + self.stop_reserved_emission(error) return False return True @@ -725,7 +731,9 @@ def report_cursor_row(self, row: int) -> bool: Replies carry no generation on the wire, so they are correlated by order against the requests this bridge made. A reply from before a geometry change describes a screen - that no longer exists and must not satisfy the request made after it. + that no longer exists and must not satisfy the request made after it. Requests whose + screen has since been cleared away are kept in the queue but marked: their replies are + still coming and still have to be consumed in order, and none of them can be believed. A reply is stale when anything about the terminal has changed since the request went out -- a resize, an owner change, or managed output that moved the cursor the terminal @@ -757,7 +765,7 @@ def report_cursor_row(self, row: int) -> bool: # Popped whatever the outcome: replies correlate by order, so dropping one without # taking it off the queue would answer every later request with its predecessor. generations = self._pending_cpr.popleft() - if generations != self.generations(): + if generations is None or generations != self.generations(): self._settle_renderer_cpr() return False @@ -771,15 +779,16 @@ def report_cursor_row(self, row: int) -> bool: self._renderer.report_absolute_cursor_row(row) return True - def _discard_pending_cursor_reports(self) -> None: - """Drop every outstanding request, settling the bookkeeping each one owns. + def _invalidate_pending_cursor_reports(self) -> None: + """Mark every outstanding request unbelievable, without forgetting that it is coming. - Used where the screen changed underneath the requests themselves. Left in the queue, - the next reply to arrive would be matched to a request made about a different screen. + Used where the screen changed underneath the requests themselves. Emptying the queue + would not stop the replies: they are already in the terminal's hands, and the next one + to arrive would be matched against whatever request came *after* the change -- the + oldest reply answering the newest question. The entries stay, marked, so each reply is + still consumed in order and each one is refused. """ - while self._pending_cpr: - self._pending_cpr.popleft() - self._settle_renderer_cpr() + self._pending_cpr = deque([None] * len(self._pending_cpr)) def _settle_renderer_cpr(self) -> None: """Resolve one of the renderer's own pending reports, if it has any. diff --git a/tests/test_prompt_toolkit_bridge.py b/tests/test_prompt_toolkit_bridge.py index 1bec1218c..043546146 100644 --- a/tests/test_prompt_toolkit_bridge.py +++ b/tests/test_prompt_toolkit_bridge.py @@ -1196,3 +1196,48 @@ def test_abandoning_emission_twice_notifies_once(self) -> None: harness.bridge.stop_reserved_emission(OSError("and again")) assert released == [1] assert str(harness.bridge.take_pending_error()) == "terminal went away" + + def test_a_failed_cleanup_tells_the_owner_to_release(self) -> None: + """Review finding: the one path that really abandons emission skipped the transition.""" + released: list[int] = [] + harness = self.bound() + harness.bridge.set_emission_stopped_handler(lambda: released.append(1)) + + prepared = harness.bridge.prepare(harness.app) + assert prepared is not None + harness.backend.stdout = AlwaysFailingTtyStream() + assert harness.bridge.commit(prepared) is False + + assert harness.bridge.reserved_emission_stopped is True + assert released == [1] + + def test_a_reply_in_transit_when_the_screen_cleared_is_not_reused(self) -> None: + """Review finding: emptying the queue lets the next reply answer the wrong request.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + harness.bridge.set_prompt_anchor(7) + + harness.bridge.request_cursor_position() # request A, about the pre-clear screen + with set_app(harness.app): + harness.renderer.clear() + harness.bridge.request_cursor_position() # request B, about the cleared screen + + # Reply A arrives late. It describes the screen before the clear. + assert harness.bridge.report_cursor_row(9) is False + assert harness.bridge.prompt_anchor is None + + # Reply B is the one that establishes the origin. + assert harness.bridge.report_cursor_row(4) is True + assert harness.bridge.prompt_anchor == 4 + + def test_the_renderers_own_bookkeeping_is_settled_for_each_stale_reply(self) -> None: + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + harness.bridge.request_cursor_position() + pending: Future[None] = Future() + harness.renderer._waiting_for_cpr_futures.append(pending) + with set_app(harness.app): + harness.renderer.clear() + + assert harness.bridge.report_cursor_row(9) is False + assert pending.done() is True diff --git a/tests/test_reserved_toolbar.py b/tests/test_reserved_toolbar.py index 00e6ad8aa..42cf992a4 100644 --- a/tests/test_reserved_toolbar.py +++ b/tests/test_reserved_toolbar.py @@ -739,3 +739,26 @@ def test_the_failure_is_still_reported(self) -> None: assert isinstance(harness.toolbar.take_pending_error(), OSError) finally: harness.close() + + def test_a_failed_cleanup_releases_the_rows(self) -> None: + """The path that truly abandons emission has to reach the owner like any other. + + Driven at the cleanup itself: a real partial commit needs a running event loop to + render a prompt session, and what is under test here is the wiring from "cleanup + failed" to "the owner gave the rows back", not how the commit got there. + """ + harness = Harness() + try: + original_render = harness.app.renderer.render + harness.toolbar.start() + bridge = harness.toolbar.bridge + assert bridge is not None + + harness.backend.stdout = AlwaysFailingStream() + assert bridge._attempt_cleanup() is False + + assert bridge.reserved_emission_stopped is True + assert harness.toolbar.is_active is False + assert harness.app.renderer.render == original_render + finally: + harness.close() From ea8aaa9f0d034c91bbaf9406fb2a3b6ffd35b35c Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 10:22:33 -0400 Subject: [PATCH 15/36] Stage 3: separate pausing the display from giving the terminal away The reserved row makes a distinction that did not exist before. Stopping the renderer and the input reader is one thing; handing the physical terminal to something else is another, and until now one decorator meant both. Command finalization needs only the first. It runs at the end of every command to restore terminal input settings, and the toolbar has to still be there when the next prompt appears -- so it now quiesces the display and keeps the rows. Everything that hands the terminal to a program which knows nothing about a scroll region -- shell commands, editors, embedded interpreters, external pagers, and the public suspend_bottom_toolbar() callers do the same -- gives the rows back for the duration and takes them again after. The release happens inside the pause, not around it: the renderer has to be quiet before the margins go, or a frame could land in rows that are no longer reserved. The lease is kept across the loan, and the geometry is measured afresh on the way back, because the guest may have resized the window. What it left on the screen is unknown, so the band's contents and the renderer's beliefs are both discarded rather than trusted, and the band is repainted. --- cmd2/cmd2.py | 25 +++++++- cmd2/command_toolbar.py | 23 ++++++- cmd2/reserved_toolbar.py | 35 ++++++++++- tests/test_reserved_lifecycle.py | 103 +++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 4 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 918364791..3f6d687ed 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2142,6 +2142,29 @@ def suspend_bottom_toolbar(self) -> Iterator[None]: Use this context manager around application-specific calls to ``input()``, other terminal UIs, or subprocesses that inherit the terminal. cmd2 automatically suspends its toolbar for its own input prompts, external pagers, and shell commands. + + In reserved mode this also gives the reserved rows back, because a program that + inherits the terminal knows nothing about a scroll region and would find its output + confined to rows it never asked for. The rows are taken again afterwards. + """ + with self._quiesce_bottom_toolbar(): + reserved = self._reserved_toolbar + if reserved is None: + yield + else: + # Inside the pause, not around it: the renderer has to be quiet before the + # margins go, or a frame could land in rows that are no longer reserved. + with reserved.suspended(): + yield + + @contextlib.contextmanager + def _quiesce_bottom_toolbar(self) -> Iterator[None]: + """Stop the command display without giving the terminal away. + + This is the other half of the distinction the reserved row makes necessary. Pausing + the renderer and the input reader is one thing; handing the physical terminal to + something else is another, and the ordinary end of a command needs only the first -- + the toolbar has to still be there when the next prompt appears. """ if self._command_toolbar is None: yield @@ -3246,7 +3269,7 @@ def onecmd_plus_hooks( return stop - @command_toolbar.suspend_toolbar + @command_toolbar.quiesce_toolbar def _run_cmdfinalization_hooks(self, stop: bool, statement: Statement | None) -> bool: """Run the command finalization hooks.""" if self._initial_termios_settings is not None and self.stdin.isatty(): # type: ignore[unreachable] diff --git a/cmd2/command_toolbar.py b/cmd2/command_toolbar.py index 1ba56e1f2..62355322d 100644 --- a/cmd2/command_toolbar.py +++ b/cmd2/command_toolbar.py @@ -35,7 +35,12 @@ def suspend_toolbar(func: _F) -> _F: - """Give a method exclusive access to the terminal.""" + """Give a method exclusive access to the terminal, reserved rows included. + + For methods that hand the terminal to something else: a shell command, an editor, an + embedded interpreter, an external pager. Anything that does not know about a scroll region + must not be given one. + """ @functools.wraps(func) def wrapped(self: "Cmd", *args: Any, **kwargs: Any) -> Any: @@ -45,6 +50,22 @@ def wrapped(self: "Cmd", *args: Any, **kwargs: Any) -> Any: return cast(_F, wrapped) +def quiesce_toolbar(func: _F) -> _F: + """Stop the command display for a method without giving the terminal away. + + For methods that need the renderer and the input reader quiet but are still part of the + command loop -- command finalization above all, which restores terminal input settings at + the end of every command and must leave the toolbar exactly where it was. + """ + + @functools.wraps(func) + def wrapped(self: "Cmd", *args: Any, **kwargs: Any) -> Any: + with self._quiesce_bottom_toolbar(): + return func(self, *args, **kwargs) + + return cast(_F, wrapped) + + def pipe_target(stream: Any) -> Any: """Return the stream a pipe process can inherit, or ``None`` if its output must be captured. diff --git a/cmd2/reserved_toolbar.py b/cmd2/reserved_toolbar.py index 43d11cb8d..6989f5876 100644 --- a/cmd2/reserved_toolbar.py +++ b/cmd2/reserved_toolbar.py @@ -17,7 +17,7 @@ new ``bottom_toolbar`` to the session still reaches the band. """ -from contextlib import suppress +from contextlib import contextmanager, suppress from types import TracebackType from typing import TYPE_CHECKING, Any, Self @@ -33,7 +33,7 @@ from .toolbar_painter import ToolbarPainter if TYPE_CHECKING: # pragma: no cover - from collections.abc import Callable + from collections.abc import Callable, Iterator from prompt_toolkit.formatted_text import AnyFormattedText from prompt_toolkit.shortcuts import PromptSession @@ -221,6 +221,37 @@ def take_pending_error(self) -> BaseException | None: error = self._painter.take_pending_error() return error + @contextmanager + def suspended(self) -> "Iterator[None]": + """Give the rows back for the duration of the block, and take them again after. + + A program that inherits the terminal -- a shell command, an editor, an external pager + -- knows nothing about a scroll region, and one left installed would confine its + output to rows it never asked for. The lease is kept: this is a loan, not a release, + and the geometry is measured afresh on the way back because the guest may have resized + the window. + + What the guest left on the screen is unknown, so the band's contents and the + renderer's beliefs are both discarded rather than trusted. + """ + display = self._display + if display is None or not display.is_reserved: + yield + return + + with self._lock.transaction("suspend"): + display.release_region_for_handoff() + try: + yield + finally: + with self._lock.transaction("resume"): + display.reacquire_region_after_handoff() + if self._painter is not None: + self._painter.invalidate() + if self._bridge is not None: + self._bridge.require_resynchronization("the terminal was handed to another program") + self.refresh() + def refresh(self) -> bool: """Evaluate the toolbar's content and paint whatever changed. diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py index e262e7def..1f55bde8a 100644 --- a/tests/test_reserved_lifecycle.py +++ b/tests/test_reserved_lifecycle.py @@ -246,3 +246,106 @@ def test_the_streams_are_given_back_when_the_display_stops(self) -> None: assert harness.app.stdout is harness.app.stdout finally: harness.close() + + +class TestSuspension: + """Two kinds of pause, and each site says which one it means.""" + + def test_finalization_keeps_the_rows(self) -> None: + """The ordinary end of a command: the toolbar must still be there afterwards.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + harness.clear() + harness.app._run_cmdfinalization_hooks(False, None) + assert toolbar.display.is_reserved is True + assert "\x1b[r" not in harness.written() + finally: + harness.close() + + def test_quiescing_keeps_the_rows(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + harness.clear() + with harness.app._quiesce_bottom_toolbar(): + assert toolbar.display.is_reserved is True + assert "\x1b[r" not in harness.written() + finally: + harness.close() + + def test_suspending_gives_the_rows_back(self) -> None: + """A program that inherits the terminal knows nothing about a scroll region.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + harness.clear() + with harness.app.suspend_bottom_toolbar(): + assert toolbar.display.is_reserved is False + assert "\x1b[r" in harness.written() + assert toolbar.display.is_reserved is True + finally: + harness.close() + + def test_the_band_is_repainted_when_the_terminal_comes_back(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + with harness.app.suspend_bottom_toolbar(): + harness.clear() + assert "STATUS" in harness.written() + finally: + harness.close() + + def test_coming_back_leaves_recovery_owed(self) -> None: + """Another program owned the screen; nothing may be diffed against what it left.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + bridge = toolbar.bridge + assert bridge is not None + with harness.app.suspend_bottom_toolbar(): + pass + assert bridge.needs_resynchronization is True + finally: + harness.close() + + def test_nested_suspensions_are_safe(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + with harness.app.suspend_bottom_toolbar(), harness.app.suspend_bottom_toolbar(): + assert toolbar.display.is_reserved is False + assert toolbar.display.is_reserved is True + finally: + harness.close() + + def test_suspension_restores_the_rows_when_the_body_raises(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + with pytest.raises(ZeroDivisionError), harness.app.suspend_bottom_toolbar(): + raise ZeroDivisionError + assert toolbar.display.is_reserved is True + finally: + harness.close() + + def test_legacy_suspension_is_unchanged(self) -> None: + harness = Harness(mode="legacy") + try: + with harness.app._reserved_toolbar_context(), harness.app.suspend_bottom_toolbar(): + assert harness.app.reserved_toolbar is None + finally: + harness.close() From 605995624247cf70ec77142eedd68cc00c04a436 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 10:30:18 -0400 Subject: [PATCH 16/36] Stage 3 review: stop the display in serialized mode, and forget what changed hands Suspension asked whether a stdout proxy was installed, which in reserved mode is never -- serialized writers take its place. Both kinds of pause therefore left the command display running: its renderer drawing and its input reader reading, alongside a guest that had been given the terminal. What has to stop is the display, whichever way its output is routed. The prompt origin is forgotten as the terminal changes hands rather than after the guest has finished with it. From the moment of the handoff the remembered row describes a screen someone else is writing on, and anything rendering against it would paint over their output. Owning the display is not the same as holding a region, and one property was answering for both. A terminal below the two-row floor left the toolbar neither painted nor natively rendered: no band to paint in, and the native window still suppressed by an owner that called itself active. Activity now means rows are actually reserved, so the native toolbar renders whenever they are not. A handoff with no region installed still runs the protocol. There is nothing to give back, but the guest may resize the window, and the return path is where that is noticed and the rows are taken again. --- cmd2/command_toolbar.py | 9 ++- cmd2/reserved_toolbar.py | 36 ++++++++--- tests/test_reserved_lifecycle.py | 105 +++++++++++++++++++++++++++++++ tests/test_reserved_toolbar.py | 9 +++ 4 files changed, 150 insertions(+), 9 deletions(-) diff --git a/cmd2/command_toolbar.py b/cmd2/command_toolbar.py index 62355322d..16c765f8c 100644 --- a/cmd2/command_toolbar.py +++ b/cmd2/command_toolbar.py @@ -528,8 +528,13 @@ def close() -> None: @contextlib.contextmanager def suspend(self) -> Iterator[None]: - """Temporarily restore ordinary terminal access, including nested suspensions.""" - if self._proxy is None: + """Temporarily restore ordinary terminal access, including nested suspensions. + + Whether output is proxied or serialized, what has to stop is the display: its renderer + draws and its input reader reads, and a guest given the terminal alongside either of + them is sharing it rather than owning it. + """ + if self._proxy is None and not self._serialized: yield return with self.cmd.sigint_protection: diff --git a/cmd2/reserved_toolbar.py b/cmd2/reserved_toolbar.py index 6989f5876..f8ddc8faf 100644 --- a/cmd2/reserved_toolbar.py +++ b/cmd2/reserved_toolbar.py @@ -107,8 +107,14 @@ def __init__( @property def is_active(self) -> bool: - """Whether a reservation is installed and the application is bound to it.""" - return self._display is not None + """Whether rows are reserved right now and the band is this object's to paint. + + Owning the display is not the same as holding a region. A terminal below the two-row + floor, or one on loan to a guest program, leaves this object in charge of the + reservation *and* leaves no reservation installed -- and while there is no band, the + native toolbar is what has to render. + """ + return self._display is not None and self._display.is_reserved @property def display(self) -> TerminalDisplay: @@ -233,25 +239,41 @@ def suspended(self) -> "Iterator[None]": What the guest left on the screen is unknown, so the band's contents and the renderer's beliefs are both discarded rather than trusted. + + A terminal with no region installed -- one below the two-row floor -- still goes + through this. There is nothing to give back, but the guest may resize the window, and + the return path is where that is noticed and the rows are taken again. """ display = self._display - if display is None or not display.is_reserved: + if display is None: yield return with self._lock.transaction("suspend"): display.release_region_for_handoff() + # Forgotten as the terminal changes hands, not after the guest has finished with + # it. From this moment the remembered row describes a screen someone else is + # writing on, and anything that rendered against it would paint over their output. + self._invalidate_ownership("the terminal was handed to another program") try: yield finally: with self._lock.transaction("resume"): display.reacquire_region_after_handoff() - if self._painter is not None: - self._painter.invalidate() - if self._bridge is not None: - self._bridge.require_resynchronization("the terminal was handed to another program") + self._invalidate_ownership("the terminal came back from another program") self.refresh() + def _invalidate_ownership(self, reason: str) -> None: + """Discard everything that described the screen before ownership changed. + + :param reason: why, for diagnostics + """ + if self._painter is not None: + self._painter.invalidate() + if self._bridge is not None: + self._bridge.forget_prompt_anchor() + self._bridge.require_resynchronization(reason) + def refresh(self) -> bool: """Evaluate the toolbar's content and paint whatever changed. diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py index 1f55bde8a..a84a3dc8b 100644 --- a/tests/test_reserved_lifecycle.py +++ b/tests/test_reserved_lifecycle.py @@ -9,12 +9,14 @@ from typing import Any import pytest +from prompt_toolkit.application.current import set_app from prompt_toolkit.data_structures import Size from prompt_toolkit.input import create_pipe_input from prompt_toolkit.output.vt100 import Vt100_Output from prompt_toolkit.shortcuts import PromptSession import cmd2 +from cmd2.reserved_toolbar import native_toolbar_container class TtyStringIO(io.StringIO): @@ -349,3 +351,106 @@ def test_legacy_suspension_is_unchanged(self) -> None: assert harness.app.reserved_toolbar is None finally: harness.close() + + +class TestSuspensionWithALiveDisplay: + """A pause that does not stop the display leaves two programs sharing the terminal.""" + + def test_quiescing_stops_the_command_display(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + assert display.app.is_running is True + with harness.app._quiesce_bottom_toolbar(): + assert display.app.is_running is False + assert display.app.is_running is True + finally: + harness.close() + + def test_suspending_stops_the_command_display(self) -> None: + """The guest owns the terminal, so cmd2's input reader must not be reading it.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + with harness.app.suspend_bottom_toolbar(): + assert display.app.is_running is False + assert display.app.is_running is True + finally: + harness.close() + + def test_output_is_serialized_again_after_a_suspension(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + with harness.app.suspend_bottom_toolbar(): + assert all(stream.serializer is None for stream in display._streams) + assert all(stream.serializer is not None for stream in display._streams) + finally: + harness.close() + + +class TestHandoffRecovery: + def test_the_prompt_origin_is_forgotten_across_a_handoff(self) -> None: + """The guest moved the cursor; recovery would otherwise repaint over its output.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + bridge = toolbar.bridge + assert bridge is not None + bridge.set_prompt_anchor(7) + + with harness.app.suspend_bottom_toolbar(): + pass + + assert bridge.prompt_anchor is None + harness.clear() + with set_app(harness.app.main_session.app): + bridge.resynchronize() + assert "\x1b[7;1H" not in harness.written() + finally: + harness.close() + + def test_a_terminal_too_short_on_return_gives_the_toolbar_back(self) -> None: + """No region means no band to paint in, so the native toolbar has to render again.""" + harness = Harness(mode="reserved", rows=24) + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + container = native_toolbar_container(harness.app.main_session) + assert container is not None + assert container.filter() is False + + with harness.app.suspend_bottom_toolbar(): + harness.size = Size(rows=2, columns=80) + + assert toolbar.display.is_reserved is False + assert toolbar.is_active is False + assert container.filter() is True + finally: + harness.close() + + def test_a_terminal_that_grows_back_takes_the_rows_again(self) -> None: + harness = Harness(mode="reserved", rows=24) + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + with harness.app.suspend_bottom_toolbar(): + harness.size = Size(rows=2, columns=80) + assert toolbar.is_active is False + + harness.size = Size(rows=24, columns=80) + with harness.app.suspend_bottom_toolbar(): + pass + assert toolbar.is_active is True + finally: + harness.close() diff --git a/tests/test_reserved_toolbar.py b/tests/test_reserved_toolbar.py index 42cf992a4..44bf3ed0d 100644 --- a/tests/test_reserved_toolbar.py +++ b/tests/test_reserved_toolbar.py @@ -762,3 +762,12 @@ def test_a_failed_cleanup_releases_the_rows(self) -> None: assert harness.app.renderer.render == original_render finally: harness.close() + + def test_suspending_a_toolbar_that_never_started_does_nothing(self) -> None: + harness = Harness() + try: + with harness.toolbar.suspended(): + pass + assert harness.written() == "" + finally: + harness.close() From 991f9a9afcd9249d0c4050a4590463269c258dbd Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 10:47:02 -0400 Subject: [PATCH 17/36] Stage 3 review: only the outermost suspension takes the rows back Suspensions nest -- cmd2 suspends around its own external commands and callers suspend around theirs -- and running the handoff protocol for each one meant an inner block ending reinstalled the margins and repainted the band while the guest the outer block had handed the terminal to was still using it. Painting a band over someone else's screen is the same mistake as never releasing at all. This was a regression from making region-less handoffs run the protocol: before that, a nested suspension found no region installed and did nothing, so the nesting was safe by accident rather than by design. Depth is now tracked explicitly. The existing nested test asserted inside both contexts and inside neither of the intervals that matter; it now checks the one between the inner and outer exits, which is where the terminal is still the guest's. --- cmd2/reserved_toolbar.py | 33 ++++++++++++++++++++++---------- tests/test_reserved_lifecycle.py | 23 ++++++++++++++++++++-- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/cmd2/reserved_toolbar.py b/cmd2/reserved_toolbar.py index f8ddc8faf..78e1cb209 100644 --- a/cmd2/reserved_toolbar.py +++ b/cmd2/reserved_toolbar.py @@ -100,6 +100,7 @@ def __init__( self._original_app_output: Any = None self._original_renderer_output: Any = None self._pending_error: BaseException | None = None + self._suspend_depth = 0 self._consecutive_paint_failures = 0 self._native_toolbar: ConditionalContainer | None = None self._original_filter: Any = None @@ -243,25 +244,37 @@ def suspended(self) -> "Iterator[None]": A terminal with no region installed -- one below the two-row floor -- still goes through this. There is nothing to give back, but the guest may resize the window, and the return path is where that is noticed and the rows are taken again. + + Suspensions nest, and only the outermost one changes anything. cmd2 suspends around + its own external commands and callers suspend around theirs, so an inner block ending + says nothing about whose terminal it is: the guest the outer block handed it to still + has it, and reinstalling margins or painting a band over their screen would be the + same mistake as never releasing at all. """ display = self._display if display is None: yield return - with self._lock.transaction("suspend"): - display.release_region_for_handoff() - # Forgotten as the terminal changes hands, not after the guest has finished with - # it. From this moment the remembered row describes a screen someone else is - # writing on, and anything that rendered against it would paint over their output. - self._invalidate_ownership("the terminal was handed to another program") + outermost = self._suspend_depth == 0 + self._suspend_depth += 1 try: + if outermost: + with self._lock.transaction("suspend"): + display.release_region_for_handoff() + # Forgotten as the terminal changes hands, not after the guest has + # finished with it. From this moment the remembered row describes a screen + # someone else is writing on, and anything that rendered against it would + # paint over their output. + self._invalidate_ownership("the terminal was handed to another program") yield finally: - with self._lock.transaction("resume"): - display.reacquire_region_after_handoff() - self._invalidate_ownership("the terminal came back from another program") - self.refresh() + self._suspend_depth -= 1 + if outermost: + with self._lock.transaction("resume"): + display.reacquire_region_after_handoff() + self._invalidate_ownership("the terminal came back from another program") + self.refresh() def _invalidate_ownership(self, reason: str) -> None: """Discard everything that described the screen before ownership changed. diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py index a84a3dc8b..c119b7f5b 100644 --- a/tests/test_reserved_lifecycle.py +++ b/tests/test_reserved_lifecycle.py @@ -320,18 +320,37 @@ def test_coming_back_leaves_recovery_owed(self) -> None: finally: harness.close() - def test_nested_suspensions_are_safe(self) -> None: + def test_only_the_outermost_suspension_takes_the_rows_back(self) -> None: + """The interval between the inner and outer exits is still the guest's terminal.""" harness = Harness(mode="reserved") try: with harness.app._reserved_toolbar_context(): toolbar = harness.app.reserved_toolbar assert toolbar is not None - with harness.app.suspend_bottom_toolbar(), harness.app.suspend_bottom_toolbar(): + with harness.app.suspend_bottom_toolbar(): + assert toolbar.display.is_reserved is False + with harness.app.suspend_bottom_toolbar(): + assert toolbar.display.is_reserved is False + # The inner context is done, the outer one is not: the guest still owns + # the terminal, so nothing may have been reinstalled here. assert toolbar.display.is_reserved is False assert toolbar.display.is_reserved is True finally: harness.close() + def test_an_inner_suspension_paints_nothing(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app.suspend_bottom_toolbar(): + harness.clear() + with harness.app.suspend_bottom_toolbar(): + pass + written = harness.written() + assert "\x1b[1;23r" not in written + assert "STATUS" not in written + finally: + harness.close() + def test_suspension_restores_the_rows_when_the_body_raises(self) -> None: harness = Harness(mode="reserved") try: From bc3adad92a47e66e82a152ade87d107be0e1ad45 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 11:09:58 -0400 Subject: [PATCH 18/36] Stage 3: route cursor reports through the bridge, and paint on committed frames Upstream both asks for cursor reports and receives them: its own key binding calls report_absolute_cursor_row when the reply arrives, and its renderer asks whenever it needs the height. Neither went through the bridge, so in practice the band-row validation never ran on a real reply, and a request upstream made had its reply arrive uncorrelated and discarded -- leaving the prompt's height unknown, which is the failure the design names as the reason for the rule. Both are intercepted now. A request is recorded only if one actually went out: in full-screen mode, and on backends that answer natively, upstream fills in the height and returns, and recording those would leave entries in the queue that no reply will ever consume. The band is repainted after each frame the terminal actually received. That ties it to the refresh cadence the session already has -- its interval, its invalidations, its key presses -- rather than inventing a second timer, and it paints after the prompt rather than into the middle of it. Upstream's own after-render event cannot serve: it fires during preparation, when the frame exists only as a recording. --- cmd2/prompt_toolkit_bridge.py | 50 ++++++++++- cmd2/reserved_toolbar.py | 5 ++ tests/test_prompt_toolkit_bridge.py | 134 ++++++++++++++++++++++++++++ tests/test_reserved_lifecycle.py | 33 +++++++ 4 files changed, 221 insertions(+), 1 deletion(-) diff --git a/cmd2/prompt_toolkit_bridge.py b/cmd2/prompt_toolkit_bridge.py index d3f6fed77..c845f3c35 100644 --- a/cmd2/prompt_toolkit_bridge.py +++ b/cmd2/prompt_toolkit_bridge.py @@ -136,6 +136,7 @@ def __init__(self, renderer: "Renderer", display: "TerminalDisplay", lock: Termi self._installed: dict[str, Any] = {} self._bound_app: Application[Any] | None = None self._emission_stopped_handler: Callable[[], None] | None = None + self._frame_committed_handler: Callable[[], object] | None = None # -- what is known --------------------------------------------------------------------- @@ -266,6 +267,17 @@ def require_resynchronization(self, reason: str) -> None: self._resynchronization_reason = reason self._retire() + def set_frame_committed_handler(self, handler: "Callable[[], object]") -> None: + """Install what to call after a frame has actually reached the terminal. + + Runs off the lock and only for a committed frame. Upstream's own after-render event + fires during preparation, when the frame exists only as a recording, so it cannot + answer "has the user seen this". + + :param handler: called after each committed frame; its return value is ignored + """ + self._frame_committed_handler = handler + def set_emission_stopped_handler(self, handler: "Callable[[], None]") -> None: """Install what to call when reserved rendering has to be abandoned. @@ -332,6 +344,13 @@ def bind(self, app: "Application[Any]") -> None: "render": self._render_through_bridge, "erase": self._erase_through_bridge, "clear": self._clear_through_bridge, + # Upstream both asks for cursor reports and receives them: its own key binding + # calls report_absolute_cursor_row when the reply arrives. Unrecorded, a request + # would have its reply arrive uncorrelated and be discarded, leaving the prompt's + # height unknown; unvalidated, a reply from inside the reserved band would set a + # height of zero or less and never say so. + "request_absolute_cursor_position": self._request_cursor_position_through_bridge, + "report_absolute_cursor_row": self._report_cursor_row_through_bridge, } self._originals = {name: getattr(renderer, name) for name in replacements} self._installed = dict(replacements) @@ -391,6 +410,34 @@ def _render_through_bridge(self, app: "Application[Any]", layout: Any, is_done: return if not self.commit(prepared): self._request_redraw() + return + if self._frame_committed_handler is not None: + self._frame_committed_handler() + + def _request_cursor_position_through_bridge(self) -> None: + """Let upstream ask for the cursor, and record the request if one went out. + + Upstream does not always emit one: in full-screen mode, and on backends that answer + natively, it fills in the height and returns. Recording those would leave entries in + the queue that no reply will ever consume, so what is recorded is what the renderer + actually started waiting for. + """ + renderer = self._renderer + with self._lock.transaction("cursor position request"): + if self._reserved_emission_stopped: + return + generations = self.generations() + outstanding = len(renderer._waiting_for_cpr_futures) + self._originals["request_absolute_cursor_position"]() + if len(renderer._waiting_for_cpr_futures) > outstanding: + self._pending_cpr.append(generations) + + def _report_cursor_row_through_bridge(self, row: int) -> None: + """Take a reply upstream's key binding delivered, through the same validation. + + :param row: the one-based physical row the terminal reported + """ + self.report_cursor_row(row) def _erase_through_bridge(self, leave_alternate_screen: bool = True) -> None: """Erase under the transaction, and treat what is left as unknown. @@ -776,7 +823,8 @@ def report_cursor_row(self, row: int) -> bool: return False self._prompt_anchor = row - self._renderer.report_absolute_cursor_row(row) + report = self._originals.get("report_absolute_cursor_row", self._renderer.report_absolute_cursor_row) + report(row) return True def _invalidate_pending_cursor_reports(self) -> None: diff --git a/cmd2/reserved_toolbar.py b/cmd2/reserved_toolbar.py index 78e1cb209..1dc1c98f4 100644 --- a/cmd2/reserved_toolbar.py +++ b/cmd2/reserved_toolbar.py @@ -194,6 +194,11 @@ def start(self) -> bool: # them back is this object's job, and it is what lets compatibility rendering # start. self._bridge.set_emission_stopped_handler(self._emission_stopped) + # The band is repainted after each frame the terminal actually received. That ties + # it to the refresh cadence the session already has -- its refresh interval, its + # invalidations, its key presses -- rather than inventing a second timer, and it + # paints after the prompt rather than into the middle of it. + self._bridge.set_frame_committed_handler(self.refresh) self._painter = ToolbarPainter( display=display, lock=self._lock, diff --git a/tests/test_prompt_toolkit_bridge.py b/tests/test_prompt_toolkit_bridge.py index 043546146..125e8dbe7 100644 --- a/tests/test_prompt_toolkit_bridge.py +++ b/tests/test_prompt_toolkit_bridge.py @@ -9,6 +9,7 @@ recorded operations and the flags left behind are the ones production would see. """ +import asyncio import io import threading from collections import deque @@ -23,6 +24,7 @@ from prompt_toolkit.layout import Layout, Window from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.output.vt100 import Vt100_Output +from prompt_toolkit.renderer import CPR_Support from cmd2.output_recorder import PreflightFacts from cmd2.prompt_toolkit_bridge import ( @@ -1241,3 +1243,135 @@ def test_the_renderers_own_bookkeeping_is_settled_for_each_stale_reply(self) -> assert harness.bridge.report_cursor_row(9) is False assert pending.done() is True + + +class TestCursorReportInterception: + """Upstream asks for and receives cursor reports itself; both go through the bridge.""" + + @pytest.fixture(autouse=True) + def _event_loop(self) -> Any: + """Upstream builds an asyncio Future per request, which needs a loop to attach to. + + In life that loop is the application's. Here there is no application running, so one + is provided for the duration rather than the request path being stubbed out -- the + bookkeeping under test is exactly what that Future is part of. + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + yield + finally: + asyncio.set_event_loop(None) + loop.close() + + def bound(self) -> Harness: + harness = Harness() + harness.stream_recorder = RecordingTtyStream() + harness.backend.stdout = harness.stream_recorder + # As it is once a reply has been seen. Left unknown, upstream schedules a timeout task + # on the event loop, which exists only while the application is running. + harness.renderer.cpr_support = CPR_Support.SUPPORTED + harness.bridge.bind(harness.app) + return harness + + def test_an_upstream_request_is_recorded(self) -> None: + """Unrecorded, its reply would arrive uncorrelated and be thrown away.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position() + assert "\x1b[6n" in harness.stream_recorder.getvalue() + assert harness.bridge.report_cursor_row(4) is True + assert harness.renderer._min_available_height == 23 - 4 + 1 + + def test_a_reply_delivered_by_upstream_is_validated(self) -> None: + """Named regression 13.1: a row in the band must not set a height at all.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position() + harness.renderer.report_absolute_cursor_row(24) + assert harness.renderer._min_available_height == 0 + assert harness.bridge.needs_resynchronization is True + + def test_a_valid_reply_delivered_by_upstream_is_used(self) -> None: + harness = self.bound() + harness.renderer.request_absolute_cursor_position() + harness.renderer.report_absolute_cursor_row(6) + assert harness.renderer._min_available_height == 23 - 6 + 1 + assert harness.bridge.prompt_anchor == 6 + + def test_a_request_that_emitted_nothing_is_not_recorded(self) -> None: + """On a terminal that does not answer there is no reply to wait for.""" + harness = self.bound() + harness.renderer.cpr_support = CPR_Support.NOT_SUPPORTED + harness.renderer.request_absolute_cursor_position() + # Nothing outstanding, so a reply now would be answering a question never asked. + assert harness.bridge.report_cursor_row(4) is False + + def test_reporting_does_not_re_enter_the_interception(self) -> None: + harness = self.bound() + harness.renderer.request_absolute_cursor_position() + harness.renderer.report_absolute_cursor_row(6) + assert harness.renderer._min_available_height > 0 + + def test_an_abandoned_reservation_asks_for_nothing(self) -> None: + """Emission has stopped; a request would put bytes into a terminal being given back.""" + harness = self.bound() + harness.bridge.stop_reserved_emission(OSError("terminal went away")) + harness.stream_recorder.truncate(0) + harness.stream_recorder.seek(0) + harness.renderer.request_absolute_cursor_position() + assert harness.stream_recorder.getvalue() == "" + + def test_unbinding_restores_both(self) -> None: + harness = Harness() + request = harness.renderer.request_absolute_cursor_position + report = harness.renderer.report_absolute_cursor_row + harness.bridge.bind(harness.app) + harness.bridge.unbind() + assert harness.renderer.request_absolute_cursor_position == request + assert harness.renderer.report_absolute_cursor_row == report + + +class TestCommittedFrameNotification: + def test_a_committed_frame_notifies_once(self) -> None: + committed: list[int] = [] + harness = Harness() + harness.bridge.bind(harness.app) + harness.bridge.set_frame_committed_handler(lambda: committed.append(1)) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert committed == [1] + + def test_a_frame_that_could_not_be_prepared_notifies_nobody(self) -> None: + committed: list[int] = [] + harness = Harness(content=lambda: harness.bridge.note_managed_write() or "hello") + harness.bridge.bind(harness.app) + harness.bridge.set_frame_committed_handler(lambda: committed.append(1)) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert committed == [] + + def test_a_frame_that_could_not_be_committed_notifies_nobody(self) -> None: + """After-render work belongs to frames the terminal actually received. + + Retired between preparing and committing, so the frame gets as far as the commit and + is refused there -- the case a preparation that never started cannot reach. + """ + committed: list[int] = [] + handover = RetiringLock() + harness = Harness(lock=TerminalLock(lock=handover)) + harness.bridge.bind(harness.app) + harness.bridge.set_frame_committed_handler(lambda: committed.append(1)) + + # Preflight and publish take the terminal first; the third acquisition is the commit. + handover.schedule(None, None, harness.bridge.note_owner_change) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert committed == [] + + def test_the_notification_runs_outside_the_transaction(self) -> None: + seen: list[object] = [] + harness = Harness() + harness.bridge.bind(harness.app) + harness.bridge.set_frame_committed_handler(lambda: seen.append(current_transaction())) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert seen == [None] diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py index c119b7f5b..6ef74eb8b 100644 --- a/tests/test_reserved_lifecycle.py +++ b/tests/test_reserved_lifecycle.py @@ -473,3 +473,36 @@ def test_a_terminal_that_grows_back_takes_the_rows_again(self) -> None: assert toolbar.is_active is True finally: harness.close() + + +class TestRefreshCadence: + """The band is repainted after each frame the terminal actually received. + + Driving a real render here would need a running event loop -- a prompt session loads its + history through one -- so the wiring is checked here and the behaviour it hangs on, that a + committed frame notifies and an uncommitted one does not, is covered against a real + renderer in the bridge's own tests. + """ + + def test_a_committed_frame_repaints_the_band(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + bridge = toolbar.bridge + assert bridge is not None + assert bridge._frame_committed_handler == toolbar.refresh + + harness.app.main_session.bottom_toolbar = "UPDATED" + harness.clear() + bridge._frame_committed_handler() + + painter = toolbar.painter + assert painter is not None + assert painter.last_frame is not None + row = "".join(cell.char for cell in painter.last_frame.rows[0]) + assert row.startswith("UPDATED") + assert "UPDATED" in harness.written() + finally: + harness.close() From 471095dd5d568ef42a234509511582d53e5cc8c0 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 17:27:10 -0400 Subject: [PATCH 19/36] Stage 3 review: keep the main prompt inside the reservation, and gate after_render The main prompt went through the same physical suspension as an external program, so every prompt gave the rows back, showed the native toolbar and reset the margins -- which is the stable-toolbar requirement inverted. The prompt the reservation exists for now keeps it; any other session is one cmd2 has not bound to the reservation and still gets the terminal to itself. Upstream fires after_render once render() returns, whatever the wrapper decided, so a skipped frame told everything downstream that a frame was on the screen. It is gated on the emission actually reaching the terminal now. Gating it exposed that the command display used that same event to mean "the display has started" -- two different questions sharing one signal. Waiting for a committed frame made starting the display depend on a cursor-position round trip, and with a terminal that never answers it never started at all. Readiness now hangs on a render attempt, which is what it was really asking about. The readiness wait is also bounded. It had no timeout, so anything that stopped the signal arriving held the command thread forever -- which is how this was found, as a hung suite rather than a failing test. A display that cannot start now says so, and the command runs without it. --- cmd2/cmd2.py | 26 +++++++++++- cmd2/command_toolbar.py | 43 +++++++++++++++++-- cmd2/prompt_toolkit_bridge.py | 64 +++++++++++++++++++++++++++++ tests/test_command_toolbar.py | 20 +++++++++ tests/test_prompt_toolkit_bridge.py | 58 ++++++++++++++++++++++++++ tests/test_reserved_lifecycle.py | 64 +++++++++++++++++++++++++++++ 6 files changed, 270 insertions(+), 5 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 3f6d687ed..10f86ff6f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3739,7 +3739,6 @@ def _is_tty_session(session: PromptSession[str]) -> bool: # a DummyOutput. return not isinstance(session.input, DummyInput) - @command_toolbar.suspend_toolbar def _read_raw_input( self, prompt: Callable[[], ANSI | str] | ANSI | str, @@ -3752,6 +3751,31 @@ def _read_raw_input( UI with completion and `patch_stdout` protection. Otherwise it performs a direct line read from `stdin`. + The command display is stopped either way, but only some prompts give the terminal + away with it. The main prompt is the one the reservation exists for: it renders + through the reserved output, and the toolbar has to still be there while the user is + typing -- that is what "stable across ordinary commands" means. Any other session is + an application prompt cmd2 has not bound to the reservation, so it gets the terminal + to itself, rows included. + + :param prompt: the prompt text or a callable that returns the prompt. + :param session: the PromptSession instance to use for reading. + :param prompt_kwargs: additional arguments passed directly to session.prompt(). + :return: the stripped input string. + :raises EOFError: if the input stream is closed or the user signals EOF (e.g., Ctrl+D) + """ + owns_the_reservation = session is self.main_session + with self._quiesce_bottom_toolbar() if owns_the_reservation else self.suspend_bottom_toolbar(): + return self._read_raw_input_now(prompt, session, **prompt_kwargs) + + def _read_raw_input_now( + self, + prompt: Callable[[], ANSI | str] | ANSI | str, + session: PromptSession[str], + **prompt_kwargs: Any, + ) -> str: + """Read one line, with the display already stopped by the caller. + :param prompt: the prompt text or a callable that returns the prompt. :param session: the PromptSession instance to use for reading. :param prompt_kwargs: additional arguments passed directly to session.prompt(). diff --git a/cmd2/command_toolbar.py b/cmd2/command_toolbar.py index 16c765f8c..d63c21cab 100644 --- a/cmd2/command_toolbar.py +++ b/cmd2/command_toolbar.py @@ -30,6 +30,10 @@ if TYPE_CHECKING: from .cmd2 import Cmd +#: How long to wait for the display to report that it has started. Long enough that a busy +#: machine is not mistaken for a broken one, short enough that a command is never held forever. +_STARTUP_TIMEOUT = 10.0 + _F = TypeVar("_F", bound=Callable[..., Any]) _R = TypeVar("_R") @@ -246,9 +250,29 @@ def suspend(event: KeyPressEvent) -> None: self._bindings = bindings self._suspend_binding = suspend - def _after_render(self, app: Application[str]) -> None: # noqa: ARG002 + def _display_started(self, app: Application[str]) -> None: # noqa: ARG002 + """Report that the display is up and has finished its first frame.""" + self._ready.set() + + def _display_started_without_app(self) -> None: + """Report readiness from a render attempt that produced no frame. + + A skipped frame still means the application is running and rendering. Waiting for one + that commits would make starting the display depend on a cursor-position round trip, + and a terminal that never answers would never let the command begin. + """ self._ready.set() + def _reserved_bridge(self) -> Any: + """Return the renderer bridge, when a reservation is holding the toolbar. + + :return: the bridge, or ``None`` in legacy rendering + """ + reserved = self.cmd.reserved_toolbar + if reserved is None or not reserved.is_active: + return None + return reserved.bridge + def start(self) -> None: """Start rendering and protect terminal output.""" stack = contextlib.ExitStack() @@ -283,8 +307,15 @@ def _resume(self) -> None: for name, value in (("layout", self._layout), ("key_bindings", self._bindings), ("erase_when_done", True)): stack.callback(setattr, self.app, name, getattr(self.app, name)) setattr(self.app, name, value) - self.app.after_render += self._after_render - stack.callback(self.app.after_render.remove_handler, self._after_render) + self.app.after_render += self._display_started + stack.callback(self.app.after_render.remove_handler, self._display_started) + bridge = self._reserved_bridge() + if bridge is not None: + # In reserved mode a frame can be skipped, and the after-render event is withheld + # for those because nothing reached the terminal. Readiness is a different + # question -- the display is up either way -- so it hangs on the attempt instead. + bridge.set_render_attempted_handler(self._display_started_without_app) + stack.callback(bridge.set_render_attempted_handler, None) context = contextvars.copy_context() def run() -> None: @@ -301,7 +332,11 @@ def run() -> None: self._thread = threading.Thread(target=context.run, args=(run,), name="cmd2-toolbar", daemon=True) self._thread.start() - self._ready.wait() + if not self._ready.wait(timeout=_STARTUP_TIMEOUT): + # Bounded so a display that never reports itself started fails here instead of + # holding the command thread forever. The toolbar is cosmetic; a command waiting + # indefinitely on one is not a trade anyone would choose. + raise TimeoutError(f"the bottom toolbar did not start within {_STARTUP_TIMEOUT} seconds") if self._error is not None: raise self._error if self._install_serializers(): diff --git a/cmd2/prompt_toolkit_bridge.py b/cmd2/prompt_toolkit_bridge.py index c845f3c35..36fe0621c 100644 --- a/cmd2/prompt_toolkit_bridge.py +++ b/cmd2/prompt_toolkit_bridge.py @@ -137,6 +137,12 @@ def __init__(self, renderer: "Renderer", display: "TerminalDisplay", lock: Termi self._bound_app: Application[Any] | None = None self._emission_stopped_handler: Callable[[], None] | None = None self._frame_committed_handler: Callable[[], object] | None = None + self._render_attempted_handler: Callable[[], object] | None = None + # Whether the last thing this bridge tried to emit actually reached the terminal. + self._last_emission_committed = False + self._after_render_event: Any = None + self._after_render_original: Any = None + self._after_render_installed: Any = None # -- what is known --------------------------------------------------------------------- @@ -278,6 +284,18 @@ def set_frame_committed_handler(self, handler: "Callable[[], object]") -> None: """ self._frame_committed_handler = handler + def set_render_attempted_handler(self, handler: "Callable[[], object] | None") -> None: + """Install what to call after each render attempt, whatever came of it. + + Distinct from the committed-frame handler on purpose. "A frame reached the terminal" + and "the renderer has been through a frame" are different facts, and something waiting + for the display to start needs the second: a frame skipped while recovery is owed + still means the application is running and rendering. + + :param handler: called after every render attempt, or ``None`` to remove it + """ + self._render_attempted_handler = handler + def set_emission_stopped_handler(self, handler: "Callable[[], None]") -> None: """Install what to call when reserved rendering has to be abandoned. @@ -355,6 +373,15 @@ def bind(self, app: "Application[Any]") -> None: self._originals = {name: getattr(renderer, name) for name in replacements} self._installed = dict(replacements) self._bound_app = app + # Upstream fires this after ``render()`` returns, whatever the wrapper decided to do, + # so a frame the bridge skipped would still tell everything waiting on a rendered + # frame that one had happened -- including the command display's readiness signal. + self._after_render_event = app.after_render + self._after_render_original = app.after_render.fire + self._after_render_installed = self._fire_after_render_through_bridge + # By name, as with the renderer's methods: this replacement belongs to this event + # object, not to the class every application's events are built from. + setattr(app.after_render, "fire", self._after_render_installed) # noqa: B010 for name, replacement in replacements.items(): # Set by name so the replacement lands on this instance. Assigning the class # attribute would change every renderer in the process, including ones cmd2 does @@ -368,6 +395,12 @@ def unbind(self) -> None: Safe to call when nothing was bound: teardown reaches this from more than one place. """ + event, self._after_render_event = self._after_render_event, None + if event is not None and getattr(event, "fire", None) == self._after_render_installed: + setattr(event, "fire", self._after_render_original) # noqa: B010 + self._after_render_original = None + self._after_render_installed = None + originals, self._originals = self._originals, {} installed, self._installed = self._installed, {} for name, original in originals.items(): @@ -379,6 +412,19 @@ def unbind(self) -> None: self._bound_app = None def _render_through_bridge(self, app: "Application[Any]", layout: Any, is_done: bool = False) -> None: + """Prepare and commit one frame, telling anything waiting that an attempt was made. + + :param app: the application being rendered + :param layout: the layout to render; upstream passes ``app.layout`` + :param is_done: whether this is the final frame of a prompt + """ + try: + self._render_frame(app, layout, is_done) + finally: + if self._render_attempted_handler is not None: + self._render_attempted_handler() + + def _render_frame(self, app: "Application[Any]", layout: Any, is_done: bool = False) -> None: """Prepare and commit one frame in place of upstream's direct render. Runs on the UI thread, which is where recovery's callbacks belong too, so an owed @@ -389,6 +435,7 @@ def _render_through_bridge(self, app: "Application[Any]", layout: Any, is_done: :param layout: the layout to render; upstream passes ``app.layout`` :param is_done: whether this is the final frame of a prompt """ + self._last_emission_committed = False if self._reserved_emission_stopped: # Abandoned, but the rows are still withheld until the owner releases them. # Rendering upstream directly from here would write outside the transaction and @@ -411,9 +458,22 @@ def _render_through_bridge(self, app: "Application[Any]", layout: Any, is_done: if not self.commit(prepared): self._request_redraw() return + self._last_emission_committed = True if self._frame_committed_handler is not None: self._frame_committed_handler() + def _fire_after_render_through_bridge(self) -> None: + """Tell the application a frame was rendered, but only if one actually was. + + A skipped frame -- recovery owed and unfinished, a preparation refused, a commit + retired -- emitted nothing. Everything downstream of this event believes a frame is on + the screen: layout metadata is published from it, and the command display treats it as + the signal that its first frame has been drawn. + """ + if not self._last_emission_committed: + return + self._after_render_original() + def _request_cursor_position_through_bridge(self) -> None: """Let upstream ask for the cursor, and record the request if one went out. @@ -448,9 +508,11 @@ def _erase_through_bridge(self, leave_alternate_screen: bool = True) -> None: :param leave_alternate_screen: passed through to upstream """ + self._last_emission_committed = False with self._lock.transaction("erase"): try: self._originals["erase"](leave_alternate_screen) + self._last_emission_committed = True finally: # Recorded whether or not it finished. An erase that raised part-way has still # moved the cursor and cleared some of what was below it, and a stream cannot @@ -465,9 +527,11 @@ def _clear_through_bridge(self) -> None: otherwise place the next frame where the prompt used to be. Cursor reports already in flight describe the screen before the clear and are discarded with it. """ + self._last_emission_committed = False with self._lock.transaction("clear"): try: self._originals["clear"]() + self._last_emission_committed = True finally: self._prompt_anchor = None self._invalidate_pending_cursor_reports() diff --git a/tests/test_command_toolbar.py b/tests/test_command_toolbar.py index b2cf2d099..7d9c6bac9 100644 --- a/tests/test_command_toolbar.py +++ b/tests/test_command_toolbar.py @@ -806,3 +806,23 @@ def test_builtin_pager_does_not_capture_redirected_output(toolbar_app, monkeypat pager.assert_not_called() assert "Cmd2 Commands" in target.read_text(encoding="utf-8") assert "Cmd2 Commands" not in output.getvalue() + + +def test_command_toolbar_startup_does_not_wait_forever(toolbar_app, monkeypatch, capsys) -> None: + """A display that never reports itself started must not hold the command thread. + + The readiness signal comes from the display's own thread, so anything that stops it + arriving -- a render that never completes, a frame skipped forever -- would otherwise + block the command that is waiting to run. + """ + app, _, _ = toolbar_app + monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar.CommandToolbar, "_display_started", lambda *args: None) + monkeypatch.setattr(command_toolbar.CommandToolbar, "_display_started_without_app", lambda *args: None) + + ran = [] + with app._command_toolbar_context(): + ran.append(True) + + assert ran == [True] + assert "did not start" in capsys.readouterr().err diff --git a/tests/test_prompt_toolkit_bridge.py b/tests/test_prompt_toolkit_bridge.py index 125e8dbe7..a21b50bcc 100644 --- a/tests/test_prompt_toolkit_bridge.py +++ b/tests/test_prompt_toolkit_bridge.py @@ -1375,3 +1375,61 @@ def test_the_notification_runs_outside_the_transaction(self) -> None: with set_app(harness.app): harness.renderer.render(harness.app, harness.app.layout) assert seen == [None] + + def test_a_skipped_frame_fires_no_after_render(self) -> None: + """Upstream fires the event after render() returns, whatever the wrapper decided.""" + fired: list[int] = [] + harness = Harness() + harness.bridge.bind(harness.app) + harness.app.after_render += lambda _app: fired.append(1) + harness.bridge.forget_prompt_anchor() + harness.bridge.require_resynchronization("a command wrote") + + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.app.after_render.fire() + assert fired == [] + + def test_a_committed_frame_fires_after_render(self) -> None: + fired: list[int] = [] + harness = Harness() + harness.bridge.bind(harness.app) + harness.app.after_render += lambda _app: fired.append(1) + + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.app.after_render.fire() + assert fired == [1] + + def test_an_erase_fires_after_render(self) -> None: + """It reached the terminal, so whatever waits on a frame has had one.""" + fired: list[int] = [] + harness = Harness() + harness.bridge.bind(harness.app) + harness.app.after_render += lambda _app: fired.append(1) + + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.renderer.erase() + harness.app.after_render.fire() + assert fired == [1] + + def test_unbinding_restores_the_event(self) -> None: + fired: list[int] = [] + harness = Harness() + original = harness.app.after_render.fire + harness.bridge.bind(harness.app) + harness.bridge.unbind() + assert harness.app.after_render.fire == original + + harness.app.after_render += lambda _app: fired.append(1) + harness.app.after_render.fire() + assert fired == [1] + + def test_an_event_replaced_while_bound_is_left_alone(self) -> None: + harness = Harness() + harness.bridge.bind(harness.app) + replacement = lambda: None # noqa: E731 + harness.app.after_render.fire = replacement # type: ignore[method-assign] + harness.bridge.unbind() + assert harness.app.after_render.fire is replacement diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py index 6ef74eb8b..723ef473a 100644 --- a/tests/test_reserved_lifecycle.py +++ b/tests/test_reserved_lifecycle.py @@ -506,3 +506,67 @@ def test_a_committed_frame_repaints_the_band(self) -> None: assert "UPDATED" in harness.written() finally: harness.close() + + +class TestPromptSuspension: + """The main prompt is inside the reservation; other prompts are not, yet.""" + + def test_the_main_prompt_keeps_the_rows(self) -> None: + """The toolbar has to survive every ordinary command, prompt included.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + seen: list[bool] = [] + harness.app.main_session.prompt = lambda *a, **k: ( + seen.append( # type: ignore[method-assign] + toolbar.display.is_reserved + ) + or "" + ) + + harness.clear() + harness.app._read_raw_input("> ", harness.app.main_session) + + assert seen == [True] + assert "\x1b[r" not in harness.written() + finally: + harness.close() + + def test_the_main_prompt_leaves_the_native_toolbar_hidden(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + container = native_toolbar_container(harness.app.main_session) + assert container is not None + seen: list[bool] = [] + harness.app.main_session.prompt = lambda *a, **k: ( + seen.append( # type: ignore[method-assign] + container.filter() + ) + or "" + ) + + harness.app._read_raw_input("> ", harness.app.main_session) + assert seen == [False] + finally: + harness.close() + + def test_another_session_still_gets_the_terminal_to_itself(self) -> None: + """A prompt cmd2 has not bound to the reservation renders outside it, for now.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + other = PromptSession(input=harness.pipe, output=harness.backend) + seen: list[bool] = [] + other.prompt = lambda *a, **k: seen.append(toolbar.display.is_reserved) or "" # type: ignore[method-assign] + + harness.app._read_raw_input("> ", other) + + assert seen == [False] + assert toolbar.display.is_reserved is True + finally: + harness.close() From 5fac73e4a96ce1e88863751423a5bd82dc34abfa Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 17:36:16 -0400 Subject: [PATCH 20/36] Stage 3 review: bound the display's teardown, not just the wait for it The readiness timeout only bounded the waiting. Cleanup then joined the display thread with no timeout, so a render callback blocked inside it held the command thread anyway -- the hang moved rather than went away. The join is bounded now. A thread that will not finish is reported and kept: it is a daemon and still owns the application, so clearing the reference would let a second display start over it, and one terminal cannot have two input readers. Resuming refuses for the same reason, and a suspension that cannot restore its display says so once and carries on without a toolbar -- the command that suspended it is not this failure's to end. The earlier test suppressed the readiness signal while leaving the worker responsive, so the join it performed always returned promptly and the case could not appear. The new tests block a real render callback. --- cmd2/command_toolbar.py | 35 +++++++++++++++++++-- tests/test_command_toolbar.py | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/cmd2/command_toolbar.py b/cmd2/command_toolbar.py index d63c21cab..d1c05c0c4 100644 --- a/cmd2/command_toolbar.py +++ b/cmd2/command_toolbar.py @@ -34,6 +34,10 @@ #: machine is not mistaken for a broken one, short enough that a command is never held forever. _STARTUP_TIMEOUT = 10.0 +#: How long to wait for the display's thread to finish. Bounded for the same reason: a render +#: callback blocked inside it would otherwise hold whoever is tearing the display down. +_SHUTDOWN_TIMEOUT = 10.0 + _F = TypeVar("_F", bound=Callable[..., Any]) _R = TypeVar("_R") @@ -87,6 +91,14 @@ def pipe_target(stream: Any) -> Any: return stream +class _DisplayStillRunningError(RuntimeError): + """Raised when a previous display's thread has not finished. + + One terminal cannot have two input readers, so a display that would not stop is a display + that cannot be started again. + """ + + class _ContextStdoutProxy(StdoutProxy): """Keep stdout's flush worker in the toolbar's isolated application session.""" @@ -301,6 +313,10 @@ def _restore_stream(obj: Any, name: str, stream: ToolbarStream) -> None: setattr(obj, name, stream.original) def _resume(self) -> None: + if self._thread is not None and self._thread.is_alive(): + # A previous display never finished. One terminal cannot have two input readers, + # and the old one still holds the application. + raise _DisplayStillRunningError("the bottom toolbar's previous display is still running") self._ready.clear() self._error = None stack = self._display_stack = contextlib.ExitStack() @@ -433,8 +449,15 @@ def _pause(self) -> None: if self.app.is_running and self.app.loop is not None: self.app.loop.call_soon_threadsafe(self._exit) if self._thread is not None: - self._thread.join() - self._thread = None + self._thread.join(timeout=_SHUTDOWN_TIMEOUT) + if self._thread.is_alive(): + # Bounded, so a render callback blocked inside the display cannot hold + # the thread that is tearing it down. The reference is kept rather + # than cleared: the thread is still running the application, and + # starting a second one would put two input readers on one terminal. + self.cmd.perror(f"The bottom toolbar did not stop within {_SHUTDOWN_TIMEOUT} seconds") + else: + self._thread = None # Return the borrowed application to the main prompt, including on # proxy failures. The upstream toolbar owned a separate application. if self._display_stack is not None: @@ -578,4 +601,10 @@ def suspend(self) -> Iterator[None]: yield finally: with self.cmd.sigint_protection: - self._resume() + try: + self._resume() + except _DisplayStillRunningError as exc: + # The display that would not stop is still holding the application, so + # there is nothing to come back to. Report it once and carry on without a + # toolbar: the command that suspended it is not this failure's to end. + self.cmd.perror(f"Bottom toolbar not restored: {exc}") diff --git a/tests/test_command_toolbar.py b/tests/test_command_toolbar.py index 7d9c6bac9..f2e3e5b19 100644 --- a/tests/test_command_toolbar.py +++ b/tests/test_command_toolbar.py @@ -826,3 +826,61 @@ def test_command_toolbar_startup_does_not_wait_forever(toolbar_app, monkeypatch, assert ran == [True] assert "did not start" in capsys.readouterr().err + + +def test_command_toolbar_startup_timeout_does_not_block_on_cleanup(toolbar_app, monkeypatch, capsys) -> None: + """A blocked render callback must not hold the command thread through teardown either. + + The readiness wait being bounded is only half of it: the display thread is still inside + the callback, so the join that follows has to be bounded too. This blocks the callback for + real rather than suppressing the readiness signal, which is what the earlier test did and + why it could not see this. + """ + app, _, _ = toolbar_app + blocked = threading.Event() + monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + + def blocking_toolbar() -> str: + blocked.wait(timeout=10) + return "STATUS" + + app.main_session.bottom_toolbar = blocking_toolbar + ran = [] + try: + started = time.monotonic() + with app._command_toolbar_context(): + ran.append(True) + elapsed = time.monotonic() - started + + assert ran == [True] + # Both bounded waits, and nothing unbounded between them. + assert elapsed < 5 + assert "did not start" in capsys.readouterr().err + finally: + blocked.set() + + +def test_command_toolbar_does_not_start_a_second_display_over_a_stuck_one(toolbar_app, monkeypatch, capsys) -> None: + """One terminal, one input reader: a display that would not stop cannot be restarted.""" + app, _, _ = toolbar_app + blocked = threading.Event() + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + + try: + with app._command_toolbar_context(): + display = app._command_toolbar + assert display is not None + first_thread = display._thread + + # Block the display inside a render, so its thread cannot finish. + app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" + display.app.invalidate() + + with app.suspend_bottom_toolbar(): + pass + + assert display._thread is first_thread + assert "not restored" in capsys.readouterr().err + finally: + blocked.set() From aaccfcbe540b576988445f87ed11da2cddd09dc6 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 17:47:30 -0400 Subject: [PATCH 21/36] Stage 3 review: a pause that timed out relinquished nothing, and now says so Bounding the join stopped the hang but left the caller believing the display had stopped. It had not: the thread was still inside the application, still rendering and still reading input. Suspension went on to release the margins and run the guest, so two programs shared one terminal; the borrowed layout and key bindings went back to the main prompt underneath a thread still using them; and the display object was dropped at the end of the command, taking the only record of the problem with it. A timed-out pause now raises. Nothing that follows a successful pause happens: no handoff, no restoration, no typeahead transfer. The caller is the only one who knows what it was about to do with the terminal, so the failure goes to it rather than being reported and swallowed. The refusal is recorded on the application rather than on the display object, which the next command replaces. The thread outlives it, and one terminal cannot have two input readers. Startup keeps propagating the failure that brought it there rather than a secondary cleanup failure -- the session is disabled either way, and "did not start" is the useful half. A stop that fails during ordinary teardown still propagates, as it did before. Guarding _resume() against a live thread became unreachable once _pause() refuses instead of returning, so it is gone rather than left as a branch no test can reach. --- cmd2/command_toolbar.py | 55 ++++++++++++++++++---------- tests/test_command_toolbar.py | 69 ++++++++++++++++++++++++++++++----- 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/cmd2/command_toolbar.py b/cmd2/command_toolbar.py index d1c05c0c4..ff77781a9 100644 --- a/cmd2/command_toolbar.py +++ b/cmd2/command_toolbar.py @@ -92,10 +92,10 @@ def pipe_target(stream: Any) -> Any: class _DisplayStillRunningError(RuntimeError): - """Raised when a previous display's thread has not finished. + """Raised when the display's thread did not finish within its timeout. - One terminal cannot have two input readers, so a display that would not stop is a display - that cannot be started again. + Nothing was relinquished: the thread is still inside the application, so whatever was + about to be done with the terminal must not be. """ @@ -304,7 +304,11 @@ def start(self) -> None: stack.callback(self._restore_stream, obj, name, wrapper) self._resume() except BaseException: - self.stop() + # A cleanup that cannot finish is reported by the state it leaves behind -- the + # display is disabled for the session either way -- and the failure that brought + # us here is the one worth propagating. + with contextlib.suppress(Exception): + self.stop() raise @staticmethod @@ -313,10 +317,6 @@ def _restore_stream(obj: Any, name: str, stream: ToolbarStream) -> None: setattr(obj, name, stream.original) def _resume(self) -> None: - if self._thread is not None and self._thread.is_alive(): - # A previous display never finished. One terminal cannot have two input readers, - # and the old one still holds the application. - raise _DisplayStillRunningError("the bottom toolbar's previous display is still running") self._ready.clear() self._error = None stack = self._display_stack = contextlib.ExitStack() @@ -449,13 +449,11 @@ def _pause(self) -> None: if self.app.is_running and self.app.loop is not None: self.app.loop.call_soon_threadsafe(self._exit) if self._thread is not None: + # Bounded, so a render callback blocked inside the display cannot hold the + # thread that is tearing it down. self._thread.join(timeout=_SHUTDOWN_TIMEOUT) if self._thread.is_alive(): - # Bounded, so a render callback blocked inside the display cannot hold - # the thread that is tearing it down. The reference is kept rather - # than cleared: the thread is still running the application, and - # starting a second one would put two input readers on one terminal. - self.cmd.perror(f"The bottom toolbar did not stop within {_SHUTDOWN_TIMEOUT} seconds") + self._abandon_stuck_display() else: self._thread = None # Return the borrowed application to the main prompt, including on @@ -471,6 +469,29 @@ def _pause(self) -> None: finally: self._pausing = False + def _abandon_stuck_display(self) -> None: + """Report that the display did not stop, and refuse to pretend it did. + + A pause that timed out relinquished nothing. The thread is still inside the + application: still rendering, still reading input. Everything that would normally + follow a pause assumes the opposite -- the caller hands the terminal to a guest, the + borrowed layout and key bindings go back to the main prompt, and the display object is + dropped at the end of the command. Each of those would be acting on an application + that is still running. + + So nothing further happens here. The application's state is left as the running thread + expects to find it, and the failure is raised rather than reported and swallowed, + because only the caller knows what it was about to do with the terminal. + + The refusal is recorded on the application rather than on this object, which the next + command replaces. The thread outlives it, and one terminal cannot have two input + readers. + + :raises _DisplayStillRunningError: always + """ + self.cmd._command_toolbar_disabled = True + raise _DisplayStillRunningError(f"the bottom toolbar did not stop within {_SHUTDOWN_TIMEOUT} seconds") + def stop(self) -> None: """Flush output, stop rendering, and restore the terminal and its streams.""" try: @@ -601,10 +622,4 @@ def suspend(self) -> Iterator[None]: yield finally: with self.cmd.sigint_protection: - try: - self._resume() - except _DisplayStillRunningError as exc: - # The display that would not stop is still holding the application, so - # there is nothing to come back to. Report it once and carry on without a - # toolbar: the command that suspended it is not this failure's to end. - self.cmd.perror(f"Bottom toolbar not restored: {exc}") + self._resume() diff --git a/tests/test_command_toolbar.py b/tests/test_command_toolbar.py index f2e3e5b19..77edd00e0 100644 --- a/tests/test_command_toolbar.py +++ b/tests/test_command_toolbar.py @@ -1,5 +1,6 @@ """Command toolbar lifecycle and terminal integration tests.""" +import contextlib import sys import threading import time @@ -861,26 +862,74 @@ def blocking_toolbar() -> str: blocked.set() -def test_command_toolbar_does_not_start_a_second_display_over_a_stuck_one(toolbar_app, monkeypatch, capsys) -> None: - """One terminal, one input reader: a display that would not stop cannot be restarted.""" +def _block_the_display(app, blocked: threading.Event) -> None: + """Wedge the running display inside a render callback it cannot leave.""" + app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" + app._command_toolbar.app.invalidate() + + +def test_command_toolbar_suspension_does_not_hand_over_a_terminal_it_still_owns(toolbar_app, monkeypatch) -> None: + """A pause that timed out did not stop anything, and must not pretend otherwise.""" app, _, _ = toolbar_app blocked = threading.Event() monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + entered = [] try: - with app._command_toolbar_context(): + # The command context's own teardown fails the same way, for the same reason: the + # display never stopped. That is the established contract for a stop that fails. + with contextlib.suppress(RuntimeError), app._command_toolbar_context(): display = app._command_toolbar assert display is not None - first_thread = display._thread + _block_the_display(app, blocked) - # Block the display inside a render, so its thread cannot finish. - app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" - display.app.invalidate() + with pytest.raises(RuntimeError, match="did not stop"), app.suspend_bottom_toolbar(): + entered.append(True) - with app.suspend_bottom_toolbar(): + # The guest never ran: the display still owns the application and the terminal. + assert entered == [] + assert display.app.is_running is True + finally: + blocked.set() + + +def test_command_toolbar_that_would_not_stop_is_not_used_again(toolbar_app, monkeypatch) -> None: + """The surviving thread outlives this display object, so the refusal has to as well.""" + app, _, _ = toolbar_app + blocked = threading.Event() + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + + try: + with contextlib.suppress(RuntimeError), app._command_toolbar_context(): + _block_the_display(app, blocked) + with contextlib.suppress(RuntimeError), app.suspend_bottom_toolbar(): + pass + + assert app._command_toolbar_disabled is True + + # A later command must not start a second display over the one still running. + with app._command_toolbar_context(): + assert app._command_toolbar is None + finally: + blocked.set() + + +def test_command_toolbar_that_would_not_stop_keeps_the_application(toolbar_app, monkeypatch) -> None: + """Its layout and bindings are still in use; restoring them would pull them out from under it.""" + app, _, _ = toolbar_app + blocked = threading.Event() + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + + try: + with contextlib.suppress(RuntimeError), app._command_toolbar_context(): + display = app._command_toolbar + assert display is not None + layout = display.app.layout + _block_the_display(app, blocked) + + with contextlib.suppress(RuntimeError), app.suspend_bottom_toolbar(): pass - assert display._thread is first_thread - assert "not restored" in capsys.readouterr().err + assert display.app.layout is layout finally: blocked.set() From aab9e2341eb447386bb88e413e7169517ea62dc8 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 18:02:31 -0400 Subject: [PATCH 22/36] Stage 3 review: refuse the terminal while a display that would not stop still holds it Disabling future command displays only stopped another one from starting. The thread that would not stop was still inside the application -- rendering, and reading the same input -- and everything else went on as though the terminal were free: startup reported its timeout, the command ran, and the next suspension released the margins and handed a guest a terminal cmd2 did not own. The application now keeps a reference to the display that did not let go, and refuses to pause, prompt or hand over until it does. That covers the paths that matter through the two context managers every caller already goes through: the main prompt, nested prompts, external commands, pagers and finalization. The refusal heals itself. A render callback that finally returns, a subprocess that finally exits, and the thread ends; the next check finds it gone and stops refusing, rather than leaving the session broken for a condition that has passed. --- cmd2/cmd2.py | 27 +++++++++++ cmd2/command_toolbar.py | 9 +++- tests/test_command_toolbar.py | 77 ++++++++++++++++++++++++++++++++ tests/test_reserved_lifecycle.py | 28 ++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 10f86ff6f..afbd8d980 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -561,6 +561,9 @@ def __init__( # so that a typo fails where it was written. self._bottom_toolbar_mode = validate_toolbar_mode(bottom_toolbar_mode) self._reserved_toolbar: ReservedToolbar | None = None + # A command display whose thread did not stop when it was asked to. It still owns the + # terminal, so nothing may be handed the terminal until it lets go. + self._display_holding_terminal: command_toolbar.CommandToolbar | None = None # Create the main PromptSession self.main_session = self._create_main_session( @@ -2135,6 +2138,28 @@ def get_bottom_toolbar(self) -> AnyFormattedText: """ return None + def _require_terminal_ownership(self) -> None: + """Refuse to use the terminal while a display that would not stop still holds it. + + A display that timed out on shutdown is disabled for the rest of the session, but that + only stops another one from starting. Its thread is still inside the application: + rendering, and reading the same input. Handing that terminal to a guest, or prompting + on it, would put two readers on one device and interleave their output. + + The check heals itself. The thread may finish late -- a render callback that finally + returned, a subprocess that finally exited -- and once it has, the terminal is ours + again and this stops refusing. + + :raises RuntimeError: while the surviving display still holds the terminal + """ + display = self._display_holding_terminal + if display is None: + return + if not display.thread_is_alive: + self._display_holding_terminal = None + return + raise RuntimeError("the bottom toolbar's display has not released the terminal") + @contextlib.contextmanager def suspend_bottom_toolbar(self) -> Iterator[None]: """Temporarily hide the command toolbar and give exclusive access to the terminal. @@ -2149,6 +2174,7 @@ def suspend_bottom_toolbar(self) -> Iterator[None]: """ with self._quiesce_bottom_toolbar(): reserved = self._reserved_toolbar + if reserved is None: yield else: @@ -2166,6 +2192,7 @@ def _quiesce_bottom_toolbar(self) -> Iterator[None]: something else is another, and the ordinary end of a command needs only the first -- the toolbar has to still be there when the next prompt appears. """ + self._require_terminal_ownership() if self._command_toolbar is None: yield else: diff --git a/cmd2/command_toolbar.py b/cmd2/command_toolbar.py index ff77781a9..162d5f878 100644 --- a/cmd2/command_toolbar.py +++ b/cmd2/command_toolbar.py @@ -485,11 +485,13 @@ def _abandon_stuck_display(self) -> None: The refusal is recorded on the application rather than on this object, which the next command replaces. The thread outlives it, and one terminal cannot have two input - readers. + readers -- so the application keeps a reference to the display that did not let go, + and refuses to hand the terminal anywhere until it does. :raises _DisplayStillRunningError: always """ self.cmd._command_toolbar_disabled = True + self.cmd._display_holding_terminal = self raise _DisplayStillRunningError(f"the bottom toolbar did not stop within {_SHUTDOWN_TIMEOUT} seconds") def stop(self) -> None: @@ -503,6 +505,11 @@ def stop(self) -> None: self._stack.close() self._stack = None + @property + def thread_is_alive(self) -> bool: + """Whether the display's thread is still running.""" + return self._thread is not None and self._thread.is_alive() + @property def is_active(self) -> bool: """Whether the display currently owns the terminal.""" diff --git a/tests/test_command_toolbar.py b/tests/test_command_toolbar.py index 77edd00e0..c1963d407 100644 --- a/tests/test_command_toolbar.py +++ b/tests/test_command_toolbar.py @@ -933,3 +933,80 @@ def test_command_toolbar_that_would_not_stop_keeps_the_application(toolbar_app, assert display.app.layout is layout finally: blocked.set() + + +def test_a_surviving_display_blocks_later_handoffs(toolbar_app, monkeypatch) -> None: + """Disabling future displays is not enough: the old one still owns the terminal.""" + app, _, _ = toolbar_app + blocked = threading.Event() + monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + entered = [] + + try: + app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" + with app._command_toolbar_context(): + pass + + # The command display is gone as an object, but its thread is not. + assert app._command_toolbar is None + with pytest.raises(RuntimeError, match="terminal"), app.suspend_bottom_toolbar(): + entered.append(True) + assert entered == [] + finally: + blocked.set() + + +def test_a_surviving_display_blocks_the_prompt(toolbar_app, monkeypatch) -> None: + """Two readers on one terminal is not a state to keep prompting in.""" + app, _, _ = toolbar_app + blocked = threading.Event() + monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + + try: + app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" + with app._command_toolbar_context(): + pass + + with pytest.raises(RuntimeError, match="terminal"): + app._read_raw_input("> ", app.main_session) + finally: + blocked.set() + + +def test_the_refusal_lifts_when_the_display_finally_exits(toolbar_app, monkeypatch) -> None: + """The thread may yet finish, and the session should not stay broken if it does.""" + app, _, _ = toolbar_app + blocked = threading.Event() + monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + + app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" + with app._command_toolbar_context(): + pass + surviving = app._display_holding_terminal + assert surviving is not None + + blocked.set() + surviving._thread.join(timeout=5) + + with app.suspend_bottom_toolbar(): + pass + assert app._display_holding_terminal is None + + +def test_a_surviving_display_stops_another_from_starting(toolbar_app, monkeypatch) -> None: + app, _, _ = toolbar_app + blocked = threading.Event() + monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + + try: + app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" + with app._command_toolbar_context(): + pass + with app._command_toolbar_context(): + assert app._command_toolbar is None + finally: + blocked.set() diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py index 723ef473a..1c20f4170 100644 --- a/tests/test_reserved_lifecycle.py +++ b/tests/test_reserved_lifecycle.py @@ -570,3 +570,31 @@ def test_another_session_still_gets_the_terminal_to_itself(self) -> None: assert toolbar.display.is_reserved is True finally: harness.close() + + +class TestSurvivingDisplay: + def test_the_rows_are_not_released_for_a_terminal_we_do_not_own(self) -> None: + """A guest cannot be given rows back while another reader still holds the terminal.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + + # Stand in for a display whose thread never stopped. + harness.app._display_holding_terminal = StuckDisplay() + + harness.clear() + with pytest.raises(RuntimeError, match="terminal"), harness.app.suspend_bottom_toolbar(): + pass + + assert toolbar.display.is_reserved is True + assert "\x1b[r" not in harness.written() + finally: + harness.close() + + +class StuckDisplay: + """A command display whose thread will not finish.""" + + thread_is_alive = True From c9a5abef0d949cf2a304bd5c832f712a50c8a1cd Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 18:10:39 -0400 Subject: [PATCH 23/36] Stage 3 review: finish the deferred teardown before letting go of the refusal A pause that timed out left the application as the running thread expected to find it: its layout, its key bindings, its erase-on-done setting. That was the right thing to do while the thread was alive. Once the thread ended, that state belonged to nobody, and simply dropping the refusal handed the next prompt an application still dressed as the command display. The teardown the pause could not do is finished at the moment the refusal lifts, and the refusal lifts only if it could be. Both halves of the pause -- returning the borrowed application, and handing over the keys typed meanwhile -- are now one method, so the deferred path cannot drift from the ordinary one. The regression asserts the layout, bindings and erase setting are back, not just that the refusal is gone. Checking only the refusal is what let this through. --- cmd2/cmd2.py | 5 +++- cmd2/command_toolbar.py | 46 +++++++++++++++++++++++--------- tests/test_command_toolbar.py | 16 ++++++++++- tests/test_reserved_lifecycle.py | 4 +++ 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index afbd8d980..0b03ec25d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2155,7 +2155,10 @@ def _require_terminal_ownership(self) -> None: display = self._display_holding_terminal if display is None: return - if not display.thread_is_alive: + if display.complete_abandoned_shutdown(): + # Its thread has ended, so the teardown its timed-out pause could not do has been + # finished here. Clearing the reference without that would hand the next prompt an + # application still dressed as the command display. self._display_holding_terminal = None return raise RuntimeError("the bottom toolbar's display has not released the terminal") diff --git a/cmd2/command_toolbar.py b/cmd2/command_toolbar.py index 162d5f878..7e0afdf72 100644 --- a/cmd2/command_toolbar.py +++ b/cmd2/command_toolbar.py @@ -454,21 +454,43 @@ def _pause(self) -> None: self._thread.join(timeout=_SHUTDOWN_TIMEOUT) if self._thread.is_alive(): self._abandon_stuck_display() - else: - self._thread = None - # Return the borrowed application to the main prompt, including on - # proxy failures. The upstream toolbar owned a separate application. - if self._display_stack is not None: - self._display_stack.close() - self._display_stack = None - # Application.run() saves its unprocessed queue before the thread - # exits. Those keys arrived after the ones handled by save_key(). - pending_keys = get_typeahead(self.app.input) - store_typeahead(self.app.input, self._keys + pending_keys) - self._keys.clear() + self._finish_pause() finally: self._pausing = False + def _finish_pause(self) -> None: + """Give the borrowed application back and hand over the keys typed meanwhile. + + Only safe once the display's thread has ended: until then it is still using the layout + and key bindings this puts back. + """ + self._thread = None + # Return the borrowed application to the main prompt, including on proxy failures. + # The upstream toolbar owned a separate application. + if self._display_stack is not None: + self._display_stack.close() + self._display_stack = None + # Application.run() saves its unprocessed queue before the thread exits. Those keys + # arrived after the ones handled by save_key(). + pending_keys = get_typeahead(self.app.input) + store_typeahead(self.app.input, self._keys + pending_keys) + self._keys.clear() + + def complete_abandoned_shutdown(self) -> bool: + """Finish the teardown a timed-out pause could not do, if the thread has since ended. + + A pause that gave up left the application as the running thread expected to find it: + its layout, its key bindings, its erase-on-done setting. Once the thread is gone that + state belongs to nobody, and the next prompt would otherwise render as the command + display. + + :return: whether the display has now been fully torn down + """ + if self.thread_is_alive: + return False + self._finish_pause() + return True + def _abandon_stuck_display(self) -> None: """Report that the display did not stop, and refuse to pretend it did. diff --git a/tests/test_command_toolbar.py b/tests/test_command_toolbar.py index c1963d407..c6f1a49ec 100644 --- a/tests/test_command_toolbar.py +++ b/tests/test_command_toolbar.py @@ -976,24 +976,38 @@ def test_a_surviving_display_blocks_the_prompt(toolbar_app, monkeypatch) -> None def test_the_refusal_lifts_when_the_display_finally_exits(toolbar_app, monkeypatch) -> None: - """The thread may yet finish, and the session should not stay broken if it does.""" + """The thread may yet finish, and the session should not stay broken if it does. + + Lifting the refusal is not the whole of it. The pause that timed out never restored the + application it had borrowed, so the prompt that comes next would render with the command + display's layout and key bindings unless that teardown is finished first. + """ app, _, _ = toolbar_app blocked = threading.Event() monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + prompt_layout = app.main_session.app.layout + prompt_bindings = app.main_session.app.key_bindings + prompt_erase = app.main_session.app.erase_when_done + app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" with app._command_toolbar_context(): pass surviving = app._display_holding_terminal assert surviving is not None + assert app.main_session.app.layout is not prompt_layout blocked.set() surviving._thread.join(timeout=5) with app.suspend_bottom_toolbar(): pass + assert app._display_holding_terminal is None + assert app.main_session.app.layout is prompt_layout + assert app.main_session.app.key_bindings is prompt_bindings + assert app.main_session.app.erase_when_done == prompt_erase def test_a_surviving_display_stops_another_from_starting(toolbar_app, monkeypatch) -> None: diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py index 1c20f4170..8965182b1 100644 --- a/tests/test_reserved_lifecycle.py +++ b/tests/test_reserved_lifecycle.py @@ -598,3 +598,7 @@ class StuckDisplay: """A command display whose thread will not finish.""" thread_is_alive = True + + def complete_abandoned_shutdown(self) -> bool: + """Report that the teardown cannot be finished while the thread runs.""" + return False From 21a0041f6b68358a3bd443603ec21ebe8329fb4f Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 18:24:43 -0400 Subject: [PATCH 24/36] Stage 3: cover the command loop's own lifetime The reservation seen from outside: a real cmdloop, start to finish. It takes the rows, gives them back, and leaves the application rendering through its own backend again -- and a second loop in the same process does it all over. A finalization hook that raises does not cost the toolbar. Finalization runs at the end of every command, which is exactly when the toolbar has to still be there, and it is the site the design singles out for keeping the reservation while the display is quiesced. --- tests/test_reserved_lifecycle.py | 74 ++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py index 8965182b1..4cd38ea61 100644 --- a/tests/test_reserved_lifecycle.py +++ b/tests/test_reserved_lifecycle.py @@ -16,6 +16,7 @@ from prompt_toolkit.shortcuts import PromptSession import cmd2 +from cmd2.plugin import CommandFinalizationData from cmd2.reserved_toolbar import native_toolbar_container @@ -602,3 +603,76 @@ class StuckDisplay: def complete_abandoned_shutdown(self) -> bool: """Report that the teardown cannot be finished while the thread runs.""" return False + + +class TestCommandLoop: + """The reservation's lifetime seen from the outside: a real cmdloop, start to finish.""" + + def run_loop(self, harness: Harness, *commands: str) -> None: + """Feed the loop some commands and let it exit.""" + harness.pipe.send_text("".join(f"{command}\n" for command in (*commands, "quit"))) + harness.app.cmdloop() + + def test_a_loop_installs_and_releases_the_reservation(self) -> None: + harness = Harness(mode="reserved") + try: + self.run_loop(harness) + written = harness.written() + assert "\x1b[1;23r" in written + assert written.rindex("\x1b[r") > written.index("\x1b[1;23r") + assert harness.app.reserved_toolbar is None + finally: + harness.close() + + def test_the_terminal_is_left_as_it_was_found(self) -> None: + """Margins reset, and the application rendering through its own backend again.""" + harness = Harness(mode="reserved") + try: + app = harness.app.main_session.app + backend = harness.backend + self.run_loop(harness) + assert app.output is backend + assert app.renderer.output is backend + assert harness.written().endswith("\x1b[r") or "\x1b[r" in harness.written() + finally: + harness.close() + + def test_a_second_loop_reserves_again(self) -> None: + """Repeated loops in one process: each takes the rows and gives them back.""" + harness = Harness(mode="reserved") + try: + self.run_loop(harness) + harness.clear() + self.run_loop(harness) + written = harness.written() + assert "\x1b[1;23r" in written + assert "\x1b[r" in written + assert harness.app.reserved_toolbar is None + finally: + harness.close() + + def test_a_failing_finalization_hook_keeps_the_reservation(self) -> None: + """Finalization runs at the end of every command; a broken hook is not a lost toolbar.""" + reserved_during: list[bool] = [] + + def failing_hook(data: CommandFinalizationData) -> CommandFinalizationData: + toolbar = harness.app.reserved_toolbar + reserved_during.append(toolbar is not None and toolbar.display.is_reserved) + raise RuntimeError("hook failed") + + harness = Harness(mode="reserved") + try: + harness.app.register_cmdfinalization_hook(failing_hook) + self.run_loop(harness, "help") + assert reserved_during + assert all(reserved_during) + finally: + harness.close() + + def test_a_legacy_loop_reserves_nothing(self) -> None: + harness = Harness(mode="legacy") + try: + self.run_loop(harness) + assert "\x1b[1;23r" not in harness.written() + finally: + harness.close() From 8e70d3ceb6ec1dbdaca490beb81d6cc7e4ddc4ca Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 18:45:32 -0400 Subject: [PATCH 25/36] Fix two test-only failures CI found and this machine could not Wedging the display inside a render callback only asked for a redraw, which schedules one rather than performing it. The pause that followed could reach the event loop first, in which case the display exited cleanly and there was no wedged thread to test against -- so the test asserted a refusal that never happened, or hit the resume path and waited out the startup timeout. It now waits until the callback has actually been entered. The reserved-lifecycle harness never bound an ambient application session, so prompt-toolkit built one on demand -- patch_stdout() in _read_raw_input() does -- and on Windows that means asking for a console the CI runner does not have. The existing toolbar fixture already binds one for this reason; the new harness now does the same. Both are test defects. Neither reproduced here: this machine runs the free-threaded build, which was among the configurations that passed. --- tests/test_command_toolbar.py | 18 ++++++++++++++++-- tests/test_reserved_lifecycle.py | 9 ++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/test_command_toolbar.py b/tests/test_command_toolbar.py index c6f1a49ec..a7f2b2237 100644 --- a/tests/test_command_toolbar.py +++ b/tests/test_command_toolbar.py @@ -863,9 +863,23 @@ def blocking_toolbar() -> str: def _block_the_display(app, blocked: threading.Event) -> None: - """Wedge the running display inside a render callback it cannot leave.""" - app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" + """Wedge the running display inside a render callback it cannot leave. + + Waits until the callback has actually been entered. Asking for a redraw only *schedules* + one, so returning before it runs leaves a race: the pause that follows may reach the + display's event loop first, in which case it exits cleanly and there is no wedged thread + to test against. + """ + entered = threading.Event() + + def blocking_toolbar() -> str: + entered.set() + blocked.wait(timeout=10) + return "STATUS" + + app.main_session.bottom_toolbar = blocking_toolbar app._command_toolbar.app.invalidate() + assert entered.wait(timeout=5), "the display never reached the blocking callback" def test_command_toolbar_suspension_does_not_hand_over_a_terminal_it_still_owns(toolbar_app, monkeypatch) -> None: diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py index 4cd38ea61..63577877e 100644 --- a/tests/test_reserved_lifecycle.py +++ b/tests/test_reserved_lifecycle.py @@ -9,6 +9,7 @@ from typing import Any import pytest +from prompt_toolkit.application import create_app_session from prompt_toolkit.application.current import set_app from prompt_toolkit.data_structures import Size from prompt_toolkit.input import create_pipe_input @@ -36,6 +37,11 @@ def __init__(self, mode: str = "reserved", rows: int = 24, toolbar: Any = "STATU self.backend = Vt100_Output(self.stream, lambda: self.size) self._pipe = create_pipe_input() self.pipe = self._pipe.__enter__() + # Bind the ambient app session to this terminal. Without it, prompt-toolkit builds a + # real one on demand -- patch_stdout() in _read_raw_input() does -- and on Windows that + # means asking for a console the CI runner does not have. + self._session_context = create_app_session(input=self.pipe, output=self.backend) + self._session_context.__enter__() self.app = cmd2.Cmd(allow_cli_args=False, bottom_toolbar_mode=mode) # The command's output and the toolbar's paints share one terminal, as they do in # life: the stream cmd2 writes to is the stream the backend renders to. @@ -44,7 +50,8 @@ def __init__(self, mode: str = "reserved", rows: int = 24, toolbar: Any = "STATU self.clear() def close(self) -> None: - """Release the pipe input.""" + """Release the app session and the pipe input.""" + self._session_context.__exit__(None, None, None) self._pipe.__exit__(None, None, None) def clear(self) -> None: From 8f745a286b5ac877f27727136f9541b4028f0e47 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 18:53:22 -0400 Subject: [PATCH 26/36] Stage 3 review: an unbound bridge passes calls through instead of breaking them Unbinding leaves a wrapper somebody else installed over ours in place, because it is not ours to remove -- and that wrapper goes on calling in here afterwards. Clearing the saved originals at the same time left those calls with nothing to delegate to: a render that emitted nothing, and a fired after-render event that raised on a None. The originals are kept now, and every intercepted method checks whether the bridge is still bound. Unbound it is not the terminal's owner, so it passes the call straight through to upstream rather than taking the lock, preparing a frame, or withholding a notification it has no business withholding. The earlier restoration tests used standalone replacements, which never called back in, so they could not see this. The new ones delegate. --- cmd2/prompt_toolkit_bridge.py | 36 +++++++-- tests/test_prompt_toolkit_bridge.py | 118 ++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 8 deletions(-) diff --git a/cmd2/prompt_toolkit_bridge.py b/cmd2/prompt_toolkit_bridge.py index 36fe0621c..d0cf94402 100644 --- a/cmd2/prompt_toolkit_bridge.py +++ b/cmd2/prompt_toolkit_bridge.py @@ -127,10 +127,12 @@ def __init__(self, renderer: "Renderer", display: "TerminalDisplay", lock: Termi # once the screen it asked about is gone: the reply is still coming, so the place has # to be kept, but nothing it says can be believed. self._pending_cpr: deque[Generations | None] = deque() - # The renderer methods this bridge replaced, by name, empty while unbound. Kept so - # preparation can call the real render: calling the attribute would re-enter the - # wrapper and never terminate. + # The upstream methods this bridge wraps, by name. Kept after unbinding as well as + # during: preparation calls the real render through this rather than the attribute, + # which would re-enter the wrapper and never terminate -- and a wrapper somebody else + # installed over ours may outlive the binding and still call in here. self._originals: dict[str, Any] = {} + self._bound = False # What this bridge put in their place, so teardown can tell its own replacements from # something another caller installed afterwards. self._installed: dict[str, Any] = {} @@ -355,7 +357,7 @@ def bind(self, app: "Application[Any]") -> None: :param app: the application whose renders are being intercepted """ - if self._originals: + if self._bound: return renderer = self._renderer replacements = { @@ -373,6 +375,7 @@ def bind(self, app: "Application[Any]") -> None: self._originals = {name: getattr(renderer, name) for name in replacements} self._installed = dict(replacements) self._bound_app = app + self._bound = True # Upstream fires this after ``render()`` returns, whatever the wrapper decided to do, # so a frame the bridge skipped would still tell everything waiting on a rendered # frame that one had happened -- including the command display's readiness signal. @@ -398,12 +401,11 @@ def unbind(self) -> None: event, self._after_render_event = self._after_render_event, None if event is not None and getattr(event, "fire", None) == self._after_render_installed: setattr(event, "fire", self._after_render_original) # noqa: B010 - self._after_render_original = None self._after_render_installed = None - originals, self._originals = self._originals, {} + self._bound = False installed, self._installed = self._installed, {} - for name, original in originals.items(): + for name, original in self._originals.items(): # Restored only where this bridge's replacement is still in place. Another caller # may have wrapped the renderer since -- for tracing, for a test -- and putting # the original back over theirs would silently undo it. @@ -414,10 +416,19 @@ def unbind(self) -> None: def _render_through_bridge(self, app: "Application[Any]", layout: Any, is_done: bool = False) -> None: """Prepare and commit one frame, telling anything waiting that an attempt was made. + Unbound, this passes straight through. A wrapper installed over this one -- for + tracing, for a test -- is left in place by :meth:`unbind` precisely because it is not + ours to remove, and it goes on calling in here afterwards. The bridge is no longer the + terminal's owner then, so the honest answer is upstream's own behaviour rather than an + error. + :param app: the application being rendered :param layout: the layout to render; upstream passes ``app.layout`` :param is_done: whether this is the final frame of a prompt """ + if not self._bound: + self._originals["render"](app, layout, is_done) + return try: self._render_frame(app, layout, is_done) finally: @@ -470,7 +481,7 @@ def _fire_after_render_through_bridge(self) -> None: the screen: layout metadata is published from it, and the command display treats it as the signal that its first frame has been drawn. """ - if not self._last_emission_committed: + if self._bound and not self._last_emission_committed: return self._after_render_original() @@ -483,6 +494,9 @@ def _request_cursor_position_through_bridge(self) -> None: actually started waiting for. """ renderer = self._renderer + if not self._bound: + self._originals["request_absolute_cursor_position"]() + return with self._lock.transaction("cursor position request"): if self._reserved_emission_stopped: return @@ -497,6 +511,9 @@ def _report_cursor_row_through_bridge(self, row: int) -> None: :param row: the one-based physical row the terminal reported """ + if not self._bound: + self._originals["report_absolute_cursor_row"](row) + return self.report_cursor_row(row) def _erase_through_bridge(self, leave_alternate_screen: bool = True) -> None: @@ -508,6 +525,9 @@ def _erase_through_bridge(self, leave_alternate_screen: bool = True) -> None: :param leave_alternate_screen: passed through to upstream """ + if not self._bound: + self._originals["erase"](leave_alternate_screen) + return self._last_emission_committed = False with self._lock.transaction("erase"): try: diff --git a/tests/test_prompt_toolkit_bridge.py b/tests/test_prompt_toolkit_bridge.py index a21b50bcc..04cdb9400 100644 --- a/tests/test_prompt_toolkit_bridge.py +++ b/tests/test_prompt_toolkit_bridge.py @@ -1433,3 +1433,121 @@ def test_an_event_replaced_while_bound_is_left_alone(self) -> None: harness.app.after_render.fire = replacement # type: ignore[method-assign] harness.bridge.unbind() assert harness.app.after_render.fire is replacement + + +class TestDelegatingWrappers: + """Someone else's wrapper may outlive the bridge and still call into it.""" + + @pytest.fixture(autouse=True) + def _event_loop(self) -> Any: + """Upstream builds an asyncio Future per cursor request, which needs a loop.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + yield + finally: + asyncio.set_event_loop(None) + loop.close() + + def bound(self) -> Harness: + harness = Harness() + harness.stream_recorder = RecordingTtyStream() + harness.backend.stdout = harness.stream_recorder + harness.renderer.cpr_support = CPR_Support.SUPPORTED + harness.bridge.bind(harness.app) + return harness + + def test_a_wrapper_delegating_to_the_bridge_still_renders_after_unbinding(self) -> None: + """Review finding: the wrapper is kept, so what it delegates to has to keep working.""" + harness = self.bound() + calls: list[int] = [] + delegate = harness.renderer.render + + def tracing_render(*args: Any, **kwargs: Any) -> None: + calls.append(1) + delegate(*args, **kwargs) + + harness.renderer.render = tracing_render # type: ignore[method-assign] + harness.bridge.unbind() + + harness.stream_recorder.truncate(0) + harness.stream_recorder.seek(0) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + + assert calls == [1] + assert "hello" in harness.stream_recorder.getvalue() + + def test_an_unbound_bridge_renders_without_taking_the_terminal(self) -> None: + """It is not the terminal's owner any more, so it passes the call straight through.""" + harness = self.bound() + delegate = harness.renderer.render + + def passing_render(*args: Any, **kwargs: Any) -> None: + delegate(*args, **kwargs) + + harness.renderer.render = passing_render # type: ignore[method-assign] + harness.bridge.unbind() + + harness.stream_recorder.truncate(0) + harness.stream_recorder.seek(0) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert all(state is None for state in harness.stream_recorder.transactions) + + def test_a_wrapper_delegating_to_the_bridge_can_still_fire_after_render(self) -> None: + harness = self.bound() + fired: list[int] = [] + delegate = harness.app.after_render.fire + + def tracing_fire() -> None: + fired.append(1) + delegate() + + harness.app.after_render.fire = tracing_fire # type: ignore[method-assign] + harness.bridge.unbind() + + handled: list[int] = [] + harness.app.after_render += lambda _app: handled.append(1) + harness.app.after_render.fire() + + assert fired == [1] + assert handled == [1] + + def test_delegated_erase_and_clear_still_work_after_unbinding(self) -> None: + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + erase, clear = harness.renderer.erase, harness.renderer.clear + + def passing_erase(*args: Any, **kwargs: Any) -> None: + erase(*args, **kwargs) + + def passing_clear() -> None: + clear() + + harness.renderer.erase = passing_erase # type: ignore[method-assign] + harness.renderer.clear = passing_clear # type: ignore[method-assign] + harness.bridge.unbind() + + with set_app(harness.app): + harness.renderer.erase() + harness.renderer.clear() + + def test_delegated_cursor_reports_still_work_after_unbinding(self) -> None: + harness = self.bound() + request = harness.renderer.request_absolute_cursor_position + report = harness.renderer.report_absolute_cursor_row + + def passing_request() -> None: + request() + + def passing_report(row: int) -> None: + report(row) + + harness.renderer.request_absolute_cursor_position = passing_request # type: ignore[method-assign] + harness.renderer.report_absolute_cursor_row = passing_report # type: ignore[method-assign] + harness.bridge.unbind() + + harness.renderer.request_absolute_cursor_position() + harness.renderer.report_absolute_cursor_row(4) + assert harness.renderer._min_available_height == 23 - 4 + 1 From 1a6a5561756f28bebc757a483591bf4c182ca47a Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 10 Sep 2026 19:19:34 -0400 Subject: [PATCH 27/36] Stage 3 review: clear passes through when unbound, like the rest The pass-through guard reached every intercepted method except this one. A retained delegating wrapper therefore had a retired bridge take its lock and invalidate its state on someone else's behalf -- and taking the lock is not harmless: the caller may hold a higher-level lock, and the ordering rule forbids that nesting, so acting as owner turned someone else's clear into an exception. The test could not have caught it. It asserted the delegated call returned, which it did whenever nothing else held a lock. It now asserts what the guard is actually for: no terminal transaction is taken, under a higher-level lock, and the retired bridge's own state is left alone. --- cmd2/prompt_toolkit_bridge.py | 7 ++++++ tests/test_prompt_toolkit_bridge.py | 34 ++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/cmd2/prompt_toolkit_bridge.py b/cmd2/prompt_toolkit_bridge.py index d0cf94402..fc7a8a7f9 100644 --- a/cmd2/prompt_toolkit_bridge.py +++ b/cmd2/prompt_toolkit_bridge.py @@ -546,7 +546,14 @@ def _clear_through_bridge(self) -> None: the remembered origin is forgotten rather than carried across -- recovery would otherwise place the next frame where the prompt used to be. Cursor reports already in flight describe the screen before the clear and are discarded with it. + + Unbound, this passes straight through, for the reason given on the render wrapper: a + retired bridge is not the terminal's owner, and taking its lock or invalidating its + state on someone else's behalf would be acting as one. """ + if not self._bound: + self._originals["clear"]() + return self._last_emission_committed = False with self._lock.transaction("clear"): try: diff --git a/tests/test_prompt_toolkit_bridge.py b/tests/test_prompt_toolkit_bridge.py index 04cdb9400..597d20f3c 100644 --- a/tests/test_prompt_toolkit_bridge.py +++ b/tests/test_prompt_toolkit_bridge.py @@ -32,7 +32,7 @@ ReservedModeFailureError, ) from cmd2.terminal_display import TerminalDisplay -from cmd2.terminal_transaction import TerminalLock, current_transaction +from cmd2.terminal_transaction import HigherLevelLock, TerminalLock, current_transaction class TtyStringIO(io.StringIO): @@ -1514,7 +1514,13 @@ def tracing_fire() -> None: assert fired == [1] assert handled == [1] - def test_delegated_erase_and_clear_still_work_after_unbinding(self) -> None: + def test_delegated_erase_and_clear_take_no_transaction_after_unbinding(self) -> None: + """A retired bridge is not the terminal's owner and must not act as one. + + Taking its lock is not harmless: the caller may hold a higher-level lock, and the + ordering rule forbids that nesting -- so acting as owner turns someone else's clear + into an exception. + """ harness = self.bound() harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] erase, clear = harness.renderer.erase, harness.renderer.clear @@ -1528,11 +1534,33 @@ def passing_clear() -> None: harness.renderer.erase = passing_erase # type: ignore[method-assign] harness.renderer.clear = passing_clear # type: ignore[method-assign] harness.bridge.unbind() + harness.bridge.require_resynchronization("before the delegated calls") - with set_app(harness.app): + harness.stream_recorder.transactions.clear() + with set_app(harness.app), HigherLevelLock("routing"): harness.renderer.erase() harness.renderer.clear() + assert harness.stream_recorder.transactions + assert all(state is None for state in harness.stream_recorder.transactions) + + def test_a_delegated_clear_does_not_invalidate_the_retired_bridge(self) -> None: + """Its state describes a terminal it no longer owns; changing it means nothing.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + harness.bridge.set_prompt_anchor(7) + clear = harness.renderer.clear + + def passing_clear() -> None: + clear() + + harness.renderer.clear = passing_clear # type: ignore[method-assign] + harness.bridge.unbind() + + with set_app(harness.app): + harness.renderer.clear() + assert harness.bridge.prompt_anchor == 7 + def test_delegated_cursor_reports_still_work_after_unbinding(self) -> None: harness = self.bound() request = harness.renderer.request_absolute_cursor_position From 290e9bca3eb5947adf2972bff6af1031c99381d3 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 11 Sep 2026 11:31:53 -0400 Subject: [PATCH 28/36] Make the toolbar modes a StrEnum instead of loose strings The set of modes was written down three times: a tuple of strings, an unused Literal alias, and the spellings compared against throughout. Now it is written down once, as the enum, and every comparison is against a member rather than a spelling. A StrEnum rather than a Literal because the codebase already uses one for its other user-facing name sets, because it gives the value set a single home that the set command can read later, and because the members are strings: passing bottom_toolbar_mode="reserved" still works and still compares equal to ToolbarMode.RESERVED, so nothing that treated these as strings has to change. Exported from the package and given an API page, since it is the type of a public constructor parameter and the API index is explicit that anything undocumented is private. The page documents the enum only; selection and validation stay internal. The feature's own documentation is still Stage 5's. --- cmd2/__init__.py | 2 ++ cmd2/cmd2.py | 28 +++++++++------- cmd2/toolbar_mode.py | 69 +++++++++++++++++++++++++------------- docs/api/index.md | 1 + docs/api/toolbar_mode.md | 3 ++ mkdocs.yml | 1 + tests/test_toolbar_mode.py | 51 ++++++++++++++++++---------- 7 files changed, 103 insertions(+), 52 deletions(-) create mode 100644 docs/api/toolbar_mode.md diff --git a/cmd2/__init__.py b/cmd2/__init__.py index c4aafcb1d..b91049e01 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -64,6 +64,7 @@ reset_theme, update_theme, ) +from .toolbar_mode import ToolbarMode from .utils import ( CustomCompletionSettings, Settable, @@ -120,6 +121,7 @@ "stylize", # Styles "Cmd2Style", + "ToolbarMode", # Theme "get_theme", "reset_theme", diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 0b03ec25d..61d28e1aa 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -175,7 +175,7 @@ ) from .styles import Cmd2Style from .theme import get_pt_theme -from .toolbar_mode import select_toolbar_mode, validate_toolbar_mode +from .toolbar_mode import ToolbarMode, select_toolbar_mode, validate_toolbar_mode from .types import ( BoundCommandFunc, BoundCompleter, @@ -378,7 +378,7 @@ def __init__( allow_redirection: bool = True, auto_load_commands: bool = False, auto_suggest: bool = True, - bottom_toolbar_mode: str = "legacy", + bottom_toolbar_mode: ToolbarMode = ToolbarMode.LEGACY, complete_in_thread: bool = True, command_sets: Iterable[CommandSet[Any]] | None = None, enable_bottom_toolbar: bool = False, @@ -423,13 +423,15 @@ def __init__( This allows CommandSets with custom constructor parameters to be loaded. This also allows the a set of CommandSets to be provided when `auto_load_commands` is set to False - :param bottom_toolbar_mode: how the bottom toolbar is rendered. ``"legacy"``, the - default, redraws it with the prompt. ``"reserved"`` keeps - it in terminal rows withheld from scrolling, and raises - ``ValueError`` where that is not available; ``"auto"`` - uses reserved rendering only on qualified terminals and - falls back silently. Reserved rendering is experimental - and not yet a supported configuration. + :param bottom_toolbar_mode: how the bottom toolbar is rendered, as a + [cmd2.ToolbarMode][] or its name. + ``ToolbarMode.LEGACY``, the default, redraws it with the + prompt. ``ToolbarMode.RESERVED`` keeps it in terminal rows + withheld from scrolling and raises ``ValueError`` where + that is not available; ``ToolbarMode.AUTO`` uses reserved + rendering only on qualified terminals and falls back + silently. Reserved rendering is experimental and not yet a + supported configuration. :param enable_bottom_toolbar: if ``True``, enables a bottom toolbar at the main prompt and during commands. Override ``get_bottom_toolbar()`` to define its content. :param enable_rprompt: if ``True``, enables a right prompt while at the main prompt. @@ -1527,12 +1529,14 @@ def allow_style_type(value: str) -> ru.AllowStyle: ) @property - def bottom_toolbar_mode(self) -> str: - """How the bottom toolbar is rendered: ``"auto"``, ``"reserved"`` or ``"legacy"``. + def bottom_toolbar_mode(self) -> ToolbarMode: + """How the bottom toolbar is rendered. Read-only after construction: the reservation is established once for the lifetime of the command loop, so changing this while one is running would leave the terminal and the setting describing different things. + + :return: the mode this application was constructed with """ return self._bottom_toolbar_mode @@ -2226,7 +2230,7 @@ def _reserved_toolbar_context(self) -> Iterator[None]: interactive=self._is_tty_session(self.main_session), layout_supported=native_toolbar_container(self.main_session) is not None, ) - if mode == "legacy": + if mode is ToolbarMode.LEGACY: yield return diff --git a/cmd2/toolbar_mode.py b/cmd2/toolbar_mode.py index b96df4ae4..c22f6a7c5 100644 --- a/cmd2/toolbar_mode.py +++ b/cmd2/toolbar_mode.py @@ -1,5 +1,9 @@ """Choose between reserved-row and legacy toolbar rendering. +Reserved rendering is experimental. ``legacy`` is the default and the only configuration the +project supports today; the others are qualified terminal by terminal, and the set of qualified +combinations is what decides whether ``auto`` selects it at all. + Reserved rendering depends on things cmd2 does not control: which output backend prompt-toolkit selected, which version of prompt-toolkit is installed, whether there is a terminal at all. This module is the one place those prerequisites are decided, before @@ -20,36 +24,55 @@ tested against, and it grows only when a version has been through the qualification gates. """ +from enum import StrEnum from importlib.metadata import version as _installed_version -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING from .terminal_display import PhysicalTerminal if TYPE_CHECKING: # pragma: no cover from prompt_toolkit.output import Output -#: The modes a caller may ask for. -TOOLBAR_MODES: tuple[str, ...] = ("auto", "reserved", "legacy") - #: prompt-toolkit versions the reserved-row mechanism has been qualified against. The bridge #: reaches into renderer internals whose shape is not part of any public API, so this is an #: exact set rather than a floor. QUALIFIED_PROMPT_TOOLKIT_VERSIONS = frozenset({"3.0.53"}) -ToolbarMode = Literal["auto", "reserved", "legacy"] + +class ToolbarMode(StrEnum): + """How the bottom toolbar is rendered. + + A string enum rather than bare strings: this is the one place the set of modes is written + down, and every comparison in the codebase is against a member rather than a spelling. + Because the members *are* strings, ``bottom_toolbar_mode="reserved"`` keeps working and + keeps comparing equal to :attr:`RESERVED`. + """ + + #: Use reserved rows where the terminal qualifies, and fall back silently where it does + #: not. A backend that looks close enough is still a guess, and a wrong guess corrupts the + #: screen the user is working in. + AUTO = "auto" + + #: Require reserved rows, and refuse to start without them. A caller who asked for this + #: and silently got legacy rendering has been given the behaviour they ruled out. + RESERVED = "reserved" + + #: Redraw the toolbar with the prompt, as cmd2 always has. + LEGACY = "legacy" -def validate_toolbar_mode(mode: str) -> str: +def validate_toolbar_mode(mode: "ToolbarMode | str") -> ToolbarMode: """Check that a mode name is one cmd2 offers. - :param mode: the requested mode - :return: the mode, unchanged + :param mode: the requested mode, as a member or as its name + :return: the corresponding member :raises ValueError: if the name is not a mode """ - if mode not in TOOLBAR_MODES: - offered = ", ".join(sorted(TOOLBAR_MODES)) - raise ValueError(f"{mode!r} is not a bottom toolbar mode; choose one of {offered}") - return mode + try: + return ToolbarMode(mode) + except ValueError: + offered = ", ".join(sorted(member.value for member in ToolbarMode)) + raise ValueError(f"{mode!r} is not a bottom toolbar mode; choose one of {offered}") from None def dependency_capability(version: str | None = None) -> tuple[bool, str]: @@ -66,31 +89,31 @@ def dependency_capability(version: str | None = None) -> tuple[bool, str]: def select_toolbar_mode( - mode: str, + mode: "ToolbarMode | str", output: "Output", *, toolbar_enabled: bool, interactive: bool, layout_supported: bool = True, version: str | None = None, -) -> tuple[str, str]: +) -> tuple[ToolbarMode, str]: """Decide how the toolbar will be rendered for this session. - :param mode: the requested mode + :param mode: the requested mode, as a member or as its name :param output: the backend prompt-toolkit selected :param toolbar_enabled: whether a bottom toolbar is configured at all :param interactive: whether input and output are a terminal :param layout_supported: whether the session's layout has a toolbar window that reserved rendering can recognize and hide :param version: the prompt-toolkit version to judge; the installed one by default - :return: the mode to use -- always ``"reserved"`` or ``"legacy"`` -- and, when falling - back from ``auto``, the reason it fell back + :return: the mode to use -- always :attr:`~ToolbarMode.RESERVED` or + :attr:`~ToolbarMode.LEGACY` -- and, when falling back from ``auto``, the reason :raises ValueError: if the mode is not a mode, or if ``reserved`` was required and a prerequisite is missing """ - validate_toolbar_mode(mode) - if mode == "legacy": - return "legacy", "" + requested = validate_toolbar_mode(mode) + if requested is ToolbarMode.LEGACY: + return ToolbarMode.LEGACY, "" reason = _unmet_prerequisite( output, @@ -100,10 +123,10 @@ def select_toolbar_mode( version=version, ) if reason is None: - return "reserved", "" - if mode == "reserved": + return ToolbarMode.RESERVED, "" + if requested is ToolbarMode.RESERVED: raise ValueError(f"reserved bottom toolbar mode is not available here: {reason}") - return "legacy", reason + return ToolbarMode.LEGACY, reason def _unmet_prerequisite( diff --git a/docs/api/index.md b/docs/api/index.md index d412daaf6..7db05b493 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -34,4 +34,5 @@ incremented according to the [Semantic Version Specification](https://semver.org - [cmd2.string_utils](./string_utils.md) - string utility functions - [cmd2.styles](./styles.md) - cmd2-specific Rich styles and a StrEnum of their corresponding names - [cmd2.theme](./theme.md) - provides a centralized theming system for cmd2 +- [cmd2.toolbar_mode](./toolbar_mode.md) - StrEnum of the ways the bottom toolbar can be rendered - [cmd2.utils](./utils.md) - various utility classes and functions diff --git a/docs/api/toolbar_mode.md b/docs/api/toolbar_mode.md new file mode 100644 index 000000000..d14627f7b --- /dev/null +++ b/docs/api/toolbar_mode.md @@ -0,0 +1,3 @@ +# cmd2.toolbar_mode + +::: cmd2.toolbar_mode options: members: - ToolbarMode diff --git a/mkdocs.yml b/mkdocs.yml index f20c2aa07..10a784bd7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -216,6 +216,7 @@ nav: - api/string_utils.md - api/styles.md - api/theme.md + - api/toolbar_mode.md - api/utils.md - Version Upgrades: - upgrades.md diff --git a/tests/test_toolbar_mode.py b/tests/test_toolbar_mode.py index 0461c5c68..e7f4ca7e7 100644 --- a/tests/test_toolbar_mode.py +++ b/tests/test_toolbar_mode.py @@ -16,7 +16,7 @@ import cmd2 from cmd2.toolbar_mode import ( QUALIFIED_PROMPT_TOOLKIT_VERSIONS, - TOOLBAR_MODES, + ToolbarMode, dependency_capability, select_toolbar_mode, validate_toolbar_mode, @@ -29,16 +29,25 @@ def qualified_output() -> Vt100_Output: class TestValidation: - @pytest.mark.parametrize("mode", TOOLBAR_MODES) - def test_every_documented_mode_is_accepted(self, mode: str) -> None: - assert validate_toolbar_mode(mode) == mode + @pytest.mark.parametrize("mode", list(ToolbarMode)) + def test_every_mode_is_accepted(self, mode: ToolbarMode) -> None: + assert validate_toolbar_mode(mode) is mode + + @pytest.mark.parametrize("mode", [member.value for member in ToolbarMode]) + def test_the_plain_string_is_accepted_too(self, mode: str) -> None: + """The members are strings, so a caller who writes one gets the member back.""" + assert validate_toolbar_mode(mode) is ToolbarMode(mode) def test_an_unknown_mode_names_the_ones_that_exist(self) -> None: with pytest.raises(ValueError, match=r"auto.*legacy.*reserved"): validate_toolbar_mode("pinned") def test_the_modes_are_the_three_the_design_names(self) -> None: - assert set(TOOLBAR_MODES) == {"auto", "reserved", "legacy"} + assert {member.value for member in ToolbarMode} == {"auto", "reserved", "legacy"} + + def test_a_mode_is_its_own_string(self) -> None: + """Nothing that compared these to strings before has to change.""" + assert ToolbarMode.RESERVED == "reserved" class TestDependencyQualification: @@ -60,43 +69,43 @@ def test_an_unqualified_version_is_reported_with_its_number(self) -> None: class TestAutomaticSelection: def test_a_qualified_terminal_selects_reserved(self) -> None: mode, reason = select_toolbar_mode("auto", qualified_output(), toolbar_enabled=True, interactive=True) - assert mode == "reserved" + assert mode is ToolbarMode.RESERVED assert reason == "" def test_an_unqualified_backend_falls_back(self) -> None: mode, reason = select_toolbar_mode("auto", DummyOutput(), toolbar_enabled=True, interactive=True) - assert mode == "legacy" + assert mode is ToolbarMode.LEGACY assert "dummy output" in reason def test_an_unqualified_dependency_falls_back(self) -> None: mode, reason = select_toolbar_mode( "auto", qualified_output(), toolbar_enabled=True, interactive=True, version="3.0.99" ) - assert mode == "legacy" + assert mode is ToolbarMode.LEGACY assert "3.0.99" in reason def test_a_disabled_toolbar_falls_back(self) -> None: """With no toolbar there is nothing to reserve a row for.""" mode, reason = select_toolbar_mode("auto", qualified_output(), toolbar_enabled=False, interactive=True) - assert mode == "legacy" + assert mode is ToolbarMode.LEGACY assert "toolbar" in reason def test_a_non_interactive_session_falls_back(self) -> None: """Redirected output has no terminal to reserve rows in.""" mode, reason = select_toolbar_mode("auto", qualified_output(), toolbar_enabled=True, interactive=False) - assert mode == "legacy" + assert mode is ToolbarMode.LEGACY assert "interactive" in reason class TestForcedModes: def test_legacy_is_selected_whatever_the_terminal_supports(self) -> None: mode, reason = select_toolbar_mode("legacy", qualified_output(), toolbar_enabled=True, interactive=True) - assert mode == "legacy" + assert mode is ToolbarMode.LEGACY assert reason == "" def test_reserved_is_selected_when_everything_qualifies(self) -> None: mode, _reason = select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=True) - assert mode == "reserved" + assert mode is ToolbarMode.RESERVED def test_forcing_reserved_on_an_unqualified_backend_is_an_error(self) -> None: """Falling back silently would give the caller the behaviour they ruled out.""" @@ -123,12 +132,20 @@ def test_an_unknown_mode_is_rejected_before_anything_is_inspected(self) -> None: class TestConstructorWiring: def test_the_default_is_legacy(self) -> None: """Reserved rendering is opt-in until it has been through the release gates.""" - assert cmd2.Cmd(allow_cli_args=False).bottom_toolbar_mode == "legacy" + assert cmd2.Cmd(allow_cli_args=False).bottom_toolbar_mode is ToolbarMode.LEGACY - @pytest.mark.parametrize("mode", TOOLBAR_MODES) - def test_a_requested_mode_is_remembered(self, mode: str) -> None: + @pytest.mark.parametrize("mode", list(ToolbarMode)) + def test_a_requested_mode_is_remembered(self, mode: ToolbarMode) -> None: app = cmd2.Cmd(allow_cli_args=False, enable_bottom_toolbar=True, bottom_toolbar_mode=mode) - assert app.bottom_toolbar_mode == mode + assert app.bottom_toolbar_mode is mode + + def test_a_mode_given_as_a_string_is_remembered_as_the_member(self) -> None: + """Existing calls pass strings; they get the same behaviour and a real member back.""" + app = cmd2.Cmd(allow_cli_args=False, enable_bottom_toolbar=True, bottom_toolbar_mode="reserved") + assert app.bottom_toolbar_mode is ToolbarMode.RESERVED + + def test_the_enum_is_importable_from_the_package(self) -> None: + assert cmd2.ToolbarMode is ToolbarMode def test_an_unknown_mode_is_rejected_at_construction(self) -> None: """Not at the first prompt: a typo should fail where it was written.""" @@ -147,7 +164,7 @@ def test_an_unrecognized_layout_falls_back_under_auto(self) -> None: mode, reason = select_toolbar_mode( "auto", qualified_output(), toolbar_enabled=True, interactive=True, layout_supported=False ) - assert mode == "legacy" + assert mode is ToolbarMode.LEGACY assert "layout" in reason def test_forcing_reserved_on_an_unrecognized_layout_is_an_error(self) -> None: From d36e36b17f425a2a7c1399b01f627227c992e1ea Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 11 Sep 2026 11:39:21 -0400 Subject: [PATCH 29/36] Fix the docs build the toolbar mode page broke The page used a mkdocstrings options block to publish only the enum. This build reads the whole block as the identifier, fails to collect it, and stops -- so the page never rendered and the cross-references to cmd2.ToolbarMode never resolved. The page is now the same bare form as every other one, and the module's helpers are underscore-prefixed so that form publishes only what is meant to be public. Selection, validation and the dependency check are cmd2's business, not its users'; the enum and the qualified-version set are not. I reported this as building cleanly when it did not. The build writes to build/html, and I had been deleting the wrong directory and reading a stale result. Verified now by removing build/ and running the same command CI does: the previous commit fails, this one does not. --- cmd2/cmd2.py | 6 ++--- cmd2/toolbar_mode.py | 10 ++++----- docs/api/toolbar_mode.md | 2 +- tests/test_toolbar_mode.py | 46 ++++++++++++++++++++------------------ 4 files changed, 33 insertions(+), 31 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 61d28e1aa..530eda68d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -175,7 +175,7 @@ ) from .styles import Cmd2Style from .theme import get_pt_theme -from .toolbar_mode import ToolbarMode, select_toolbar_mode, validate_toolbar_mode +from .toolbar_mode import ToolbarMode, _select_toolbar_mode, _validate_toolbar_mode from .types import ( BoundCommandFunc, BoundCompleter, @@ -561,7 +561,7 @@ def __init__( # How the bottom toolbar is rendered. Validated here rather than at the first prompt # so that a typo fails where it was written. - self._bottom_toolbar_mode = validate_toolbar_mode(bottom_toolbar_mode) + self._bottom_toolbar_mode = _validate_toolbar_mode(bottom_toolbar_mode) self._reserved_toolbar: ReservedToolbar | None = None # A command display whose thread did not stop when it was asked to. It still owns the # terminal, so nothing may be handed the terminal until it lets go. @@ -2223,7 +2223,7 @@ def _reserved_toolbar_context(self) -> Iterator[None]: to reserve and the toolbar renders natively, which is why the object is kept even when it is inactive -- the terminal can grow back. """ - mode, _reason = select_toolbar_mode( + mode, _reason = _select_toolbar_mode( self._bottom_toolbar_mode, self.main_session.app.output, toolbar_enabled=self.main_session.bottom_toolbar is not None, diff --git a/cmd2/toolbar_mode.py b/cmd2/toolbar_mode.py index c22f6a7c5..3e983a3c7 100644 --- a/cmd2/toolbar_mode.py +++ b/cmd2/toolbar_mode.py @@ -61,7 +61,7 @@ class ToolbarMode(StrEnum): LEGACY = "legacy" -def validate_toolbar_mode(mode: "ToolbarMode | str") -> ToolbarMode: +def _validate_toolbar_mode(mode: "ToolbarMode | str") -> ToolbarMode: """Check that a mode name is one cmd2 offers. :param mode: the requested mode, as a member or as its name @@ -75,7 +75,7 @@ def validate_toolbar_mode(mode: "ToolbarMode | str") -> ToolbarMode: raise ValueError(f"{mode!r} is not a bottom toolbar mode; choose one of {offered}") from None -def dependency_capability(version: str | None = None) -> tuple[bool, str]: +def _dependency_capability(version: str | None = None) -> tuple[bool, str]: """Decide whether the installed prompt-toolkit is one the reservation is qualified for. :param version: the version to judge; the installed one by default @@ -88,7 +88,7 @@ def dependency_capability(version: str | None = None) -> tuple[bool, str]: return False, f"prompt-toolkit {installed} is not qualified for reserved rendering (qualified: {qualified})" -def select_toolbar_mode( +def _select_toolbar_mode( mode: "ToolbarMode | str", output: "Output", *, @@ -111,7 +111,7 @@ def select_toolbar_mode( :raises ValueError: if the mode is not a mode, or if ``reserved`` was required and a prerequisite is missing """ - requested = validate_toolbar_mode(mode) + requested = _validate_toolbar_mode(mode) if requested is ToolbarMode.LEGACY: return ToolbarMode.LEGACY, "" @@ -156,7 +156,7 @@ def _unmet_prerequisite( return "the session is not interactive" if not layout_supported: return "the session's layout has no bottom toolbar window to replace" - supported, reason = dependency_capability(version) + supported, reason = _dependency_capability(version) if not supported: return reason supported, reason = PhysicalTerminal(output).capability() diff --git a/docs/api/toolbar_mode.md b/docs/api/toolbar_mode.md index d14627f7b..370a86124 100644 --- a/docs/api/toolbar_mode.md +++ b/docs/api/toolbar_mode.md @@ -1,3 +1,3 @@ # cmd2.toolbar_mode -::: cmd2.toolbar_mode options: members: - ToolbarMode +::: cmd2.toolbar_mode diff --git a/tests/test_toolbar_mode.py b/tests/test_toolbar_mode.py index e7f4ca7e7..6de7b4b61 100644 --- a/tests/test_toolbar_mode.py +++ b/tests/test_toolbar_mode.py @@ -17,9 +17,9 @@ from cmd2.toolbar_mode import ( QUALIFIED_PROMPT_TOOLKIT_VERSIONS, ToolbarMode, - dependency_capability, - select_toolbar_mode, - validate_toolbar_mode, + _dependency_capability, + _select_toolbar_mode, + _validate_toolbar_mode, ) @@ -31,16 +31,16 @@ def qualified_output() -> Vt100_Output: class TestValidation: @pytest.mark.parametrize("mode", list(ToolbarMode)) def test_every_mode_is_accepted(self, mode: ToolbarMode) -> None: - assert validate_toolbar_mode(mode) is mode + assert _validate_toolbar_mode(mode) is mode @pytest.mark.parametrize("mode", [member.value for member in ToolbarMode]) def test_the_plain_string_is_accepted_too(self, mode: str) -> None: """The members are strings, so a caller who writes one gets the member back.""" - assert validate_toolbar_mode(mode) is ToolbarMode(mode) + assert _validate_toolbar_mode(mode) is ToolbarMode(mode) def test_an_unknown_mode_names_the_ones_that_exist(self) -> None: with pytest.raises(ValueError, match=r"auto.*legacy.*reserved"): - validate_toolbar_mode("pinned") + _validate_toolbar_mode("pinned") def test_the_modes_are_the_three_the_design_names(self) -> None: assert {member.value for member in ToolbarMode} == {"auto", "reserved", "legacy"} @@ -53,7 +53,7 @@ def test_a_mode_is_its_own_string(self) -> None: class TestDependencyQualification: def test_the_installed_prompt_toolkit_is_the_qualified_one(self) -> None: """A dependency upgrade must fail here rather than quietly rendering differently.""" - supported, reason = dependency_capability() + supported, reason = _dependency_capability() assert supported is True, reason def test_only_exactly_qualified_versions_count(self) -> None: @@ -61,24 +61,24 @@ def test_only_exactly_qualified_versions_count(self) -> None: assert frozenset({"3.0.53"}) == QUALIFIED_PROMPT_TOOLKIT_VERSIONS def test_an_unqualified_version_is_reported_with_its_number(self) -> None: - supported, reason = dependency_capability("3.0.99") + supported, reason = _dependency_capability("3.0.99") assert supported is False assert "3.0.99" in reason class TestAutomaticSelection: def test_a_qualified_terminal_selects_reserved(self) -> None: - mode, reason = select_toolbar_mode("auto", qualified_output(), toolbar_enabled=True, interactive=True) + mode, reason = _select_toolbar_mode("auto", qualified_output(), toolbar_enabled=True, interactive=True) assert mode is ToolbarMode.RESERVED assert reason == "" def test_an_unqualified_backend_falls_back(self) -> None: - mode, reason = select_toolbar_mode("auto", DummyOutput(), toolbar_enabled=True, interactive=True) + mode, reason = _select_toolbar_mode("auto", DummyOutput(), toolbar_enabled=True, interactive=True) assert mode is ToolbarMode.LEGACY assert "dummy output" in reason def test_an_unqualified_dependency_falls_back(self) -> None: - mode, reason = select_toolbar_mode( + mode, reason = _select_toolbar_mode( "auto", qualified_output(), toolbar_enabled=True, interactive=True, version="3.0.99" ) assert mode is ToolbarMode.LEGACY @@ -86,47 +86,47 @@ def test_an_unqualified_dependency_falls_back(self) -> None: def test_a_disabled_toolbar_falls_back(self) -> None: """With no toolbar there is nothing to reserve a row for.""" - mode, reason = select_toolbar_mode("auto", qualified_output(), toolbar_enabled=False, interactive=True) + mode, reason = _select_toolbar_mode("auto", qualified_output(), toolbar_enabled=False, interactive=True) assert mode is ToolbarMode.LEGACY assert "toolbar" in reason def test_a_non_interactive_session_falls_back(self) -> None: """Redirected output has no terminal to reserve rows in.""" - mode, reason = select_toolbar_mode("auto", qualified_output(), toolbar_enabled=True, interactive=False) + mode, reason = _select_toolbar_mode("auto", qualified_output(), toolbar_enabled=True, interactive=False) assert mode is ToolbarMode.LEGACY assert "interactive" in reason class TestForcedModes: def test_legacy_is_selected_whatever_the_terminal_supports(self) -> None: - mode, reason = select_toolbar_mode("legacy", qualified_output(), toolbar_enabled=True, interactive=True) + mode, reason = _select_toolbar_mode("legacy", qualified_output(), toolbar_enabled=True, interactive=True) assert mode is ToolbarMode.LEGACY assert reason == "" def test_reserved_is_selected_when_everything_qualifies(self) -> None: - mode, _reason = select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=True) + mode, _reason = _select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=True) assert mode is ToolbarMode.RESERVED def test_forcing_reserved_on_an_unqualified_backend_is_an_error(self) -> None: """Falling back silently would give the caller the behaviour they ruled out.""" with pytest.raises(ValueError, match="dummy output"): - select_toolbar_mode("reserved", DummyOutput(), toolbar_enabled=True, interactive=True) + _select_toolbar_mode("reserved", DummyOutput(), toolbar_enabled=True, interactive=True) def test_forcing_reserved_on_an_unqualified_dependency_is_an_error(self) -> None: with pytest.raises(ValueError, match=r"3\.0\.99"): - select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=True, version="3.0.99") + _select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=True, version="3.0.99") def test_forcing_reserved_without_a_toolbar_is_an_error(self) -> None: with pytest.raises(ValueError, match="toolbar"): - select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=False, interactive=True) + _select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=False, interactive=True) def test_forcing_reserved_without_a_terminal_is_an_error(self) -> None: with pytest.raises(ValueError, match="interactive"): - select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=False) + _select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=False) def test_an_unknown_mode_is_rejected_before_anything_is_inspected(self) -> None: with pytest.raises(ValueError, match="pinned"): - select_toolbar_mode("pinned", qualified_output(), toolbar_enabled=True, interactive=True) + _select_toolbar_mode("pinned", qualified_output(), toolbar_enabled=True, interactive=True) class TestConstructorWiring: @@ -161,7 +161,7 @@ def test_the_mode_is_read_only(self) -> None: class TestLayoutPrerequisite: def test_an_unrecognized_layout_falls_back_under_auto(self) -> None: """Reserved rendering has to hide the native toolbar, and cannot find it here.""" - mode, reason = select_toolbar_mode( + mode, reason = _select_toolbar_mode( "auto", qualified_output(), toolbar_enabled=True, interactive=True, layout_supported=False ) assert mode is ToolbarMode.LEGACY @@ -169,4 +169,6 @@ def test_an_unrecognized_layout_falls_back_under_auto(self) -> None: def test_forcing_reserved_on_an_unrecognized_layout_is_an_error(self) -> None: with pytest.raises(ValueError, match="layout"): - select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=True, layout_supported=False) + _select_toolbar_mode( + "reserved", qualified_output(), toolbar_enabled=True, interactive=True, layout_supported=False + ) From 61d29698ce96b3a14475e264d7207ee18817f091 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 11 Sep 2026 15:11:10 -0400 Subject: [PATCH 30/36] Consolidate toolbar enablement into ToolbarMode and speed up lifecycle tests Replace the enable_bottom_toolbar constructor flag with ToolbarMode.OFF, which is now the default. Other modes enable the toolbar and its built-in pager. Update the enabled example to AUTO and document the API migration. Preserve the reserved/auto/legacy selection policies while allowing OFF to bypass reservation qualification. Cover toolbar and pager enablement for every enum member. Make the lifecycle test terminal answer cursor-position requests through the real input reader instead of forcing every shutdown to time out. Shorten test-only shutdown, unanswered-CPR, and Escape expiry intervals while preserving real blocked callbacks and the corresponding error paths. Production timeouts are unchanged. The generic test-runner and isolation improvements from PR #1759 have already been merged through reserved_row_toolbar. With those changes and this refactor, make test reports 2,531 passed and 6 skipped in 5.47s, with 99.5% coverage on the local Python 3.14 free-threaded build. make check and make docs-test passed. --- CHANGELOG.md | 9 +++++++-- cmd2/cmd2.py | 24 +++++++++++------------- cmd2/toolbar_mode.py | 21 ++++++++++++--------- docs/features/initialization.md | 2 +- docs/features/prompt.md | 9 +++++++-- docs/upgrades.md | 3 ++- examples/getting_started.py | 2 +- tests/test_cmd2.py | 11 +++++------ tests/test_command_toolbar.py | 31 ++++++++++++++++++++----------- tests/test_pager.py | 4 ++++ tests/test_reserved_lifecycle.py | 16 ++++++++++++++++ tests/test_toolbar_mode.py | 25 +++++++++++++++---------- 12 files changed, 101 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 631602c60..604b32938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ ## 4.3.0 (TBD) +- Breaking Changes + - Replaced `enable_bottom_toolbar` with `bottom_toolbar_mode` in `Cmd.__init__()`. The default, + `cmd2.ToolbarMode.OFF`, disables the toolbar. Use `cmd2.ToolbarMode.AUTO` where you previously + passed `enable_bottom_toolbar=True`. + - Enhancements - - `enable_bottom_toolbar=True` now keeps the toolbar visible and refreshing during command - execution + - `bottom_toolbar_mode=cmd2.ToolbarMode.AUTO` now keeps the toolbar visible and refreshing + during command execution - `Cmd.read_input()` and `Cmd.read_secret()` now keep the bottom toolbar visible while they wait for input, refreshing at the same `refresh_interval` as the main prompt, instead of the toolbar disappearing for the duration of the nested prompt. `Cmd.select()` is unchanged, since diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 530eda68d..574347361 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -378,10 +378,9 @@ def __init__( allow_redirection: bool = True, auto_load_commands: bool = False, auto_suggest: bool = True, - bottom_toolbar_mode: ToolbarMode = ToolbarMode.LEGACY, + bottom_toolbar_mode: ToolbarMode = ToolbarMode.OFF, complete_in_thread: bool = True, command_sets: Iterable[CommandSet[Any]] | None = None, - enable_bottom_toolbar: bool = False, enable_rprompt: bool = False, include_ipy: bool = False, include_py: bool = False, @@ -425,15 +424,16 @@ def __init__( when `auto_load_commands` is set to False :param bottom_toolbar_mode: how the bottom toolbar is rendered, as a [cmd2.ToolbarMode][] or its name. - ``ToolbarMode.LEGACY``, the default, redraws it with the - prompt. ``ToolbarMode.RESERVED`` keeps it in terminal rows + ``ToolbarMode.OFF``, the default, disables it. Other modes + enable it at the main prompt and during commands; override + ``get_bottom_toolbar()`` to define its content. + ``ToolbarMode.LEGACY`` redraws it with the prompt. + ``ToolbarMode.RESERVED`` keeps it in terminal rows withheld from scrolling and raises ``ValueError`` where that is not available; ``ToolbarMode.AUTO`` uses reserved rendering only on qualified terminals and falls back silently. Reserved rendering is experimental and not yet a supported configuration. - :param enable_bottom_toolbar: if ``True``, enables a bottom toolbar at the main prompt and during commands. - Override ``get_bottom_toolbar()`` to define its content. :param enable_rprompt: if ``True``, enables a right prompt while at the main prompt. Override ``get_rprompt()`` to define its content. :param include_ipy: should the "ipy" command be included for an embedded IPython shell @@ -572,7 +572,6 @@ def __init__( auto_suggest=auto_suggest, complete_in_thread=complete_in_thread, completekey=completekey, - enable_bottom_toolbar=enable_bottom_toolbar, enable_rprompt=enable_rprompt, refresh_interval=refresh_interval, ) @@ -673,7 +672,7 @@ def __init__( # The embedded pager shares the main toolbar. Applications can opt back # into their configured external pager by setting this to False. - self.use_builtin_pager = enable_bottom_toolbar + self.use_builtin_pager = self._bottom_toolbar_mode is not ToolbarMode.OFF # Set the pager(s) for use when displaying output using a pager if sys.platform.startswith("win"): @@ -825,7 +824,6 @@ def _create_main_session( auto_suggest: bool, complete_in_thread: bool, completekey: str, - enable_bottom_toolbar: bool, enable_rprompt: bool, refresh_interval: float, ) -> PromptSession[str]: @@ -838,7 +836,7 @@ def _create_main_session( # Base configuration kwargs: dict[str, Any] = { "auto_suggest": AutoSuggestFromHistory() if auto_suggest else None, - "bottom_toolbar": self.get_bottom_toolbar if enable_bottom_toolbar else None, + "bottom_toolbar": self.get_bottom_toolbar if self._bottom_toolbar_mode is not ToolbarMode.OFF else None, "color_depth": pt_resolve_color_depth(), "complete_style": CompleteStyle.MULTI_COLUMN, "complete_in_thread": complete_in_thread, @@ -2125,8 +2123,8 @@ def ppretty( def get_bottom_toolbar(self) -> AnyFormattedText: """Get the bottom toolbar content. - This method is called by prompt-toolkit at the main prompt and during commands if ``enable_bottom_toolbar`` - was set to ``True`` during initialization. Because prompt-toolkit executes this callback + This method is called by prompt-toolkit at the main prompt and during commands if ``bottom_toolbar_mode`` + was set to a mode other than ``ToolbarMode.OFF`` during initialization. Because prompt-toolkit executes this callback on every UI refresh (such as on every keypress or at scheduled refresh intervals), keeping this function highly optimized is critical to ensuring the CLI remains responsive. @@ -2230,7 +2228,7 @@ def _reserved_toolbar_context(self) -> Iterator[None]: interactive=self._is_tty_session(self.main_session), layout_supported=native_toolbar_container(self.main_session) is not None, ) - if mode is ToolbarMode.LEGACY: + if mode is not ToolbarMode.RESERVED: yield return diff --git a/cmd2/toolbar_mode.py b/cmd2/toolbar_mode.py index 3e983a3c7..a83e896ec 100644 --- a/cmd2/toolbar_mode.py +++ b/cmd2/toolbar_mode.py @@ -1,15 +1,15 @@ -"""Choose between reserved-row and legacy toolbar rendering. +"""Choose whether and how to render the bottom toolbar. -Reserved rendering is experimental. ``legacy`` is the default and the only configuration the -project supports today; the others are qualified terminal by terminal, and the set of qualified -combinations is what decides whether ``auto`` selects it at all. +The toolbar is disabled by default. Enabled toolbars can use legacy rendering or experimental +reserved rendering, qualified terminal by terminal. The set of qualified combinations decides +whether ``auto`` selects reserved rendering. Reserved rendering depends on things cmd2 does not control: which output backend prompt-toolkit selected, which version of prompt-toolkit is installed, whether there is a terminal at all. This module is the one place those prerequisites are decided, before anything binds a bridge or writes a margin sequence. -The two non-default modes answer the same question differently, on purpose: +The two modes that can reserve rows answer the same question differently, on purpose: ``auto`` falls back to legacy rendering for anything it has not qualified. A backend that looks close enough is still a guess, and a wrong guess here corrupts the screen the user is @@ -48,6 +48,9 @@ class ToolbarMode(StrEnum): keeps comparing equal to :attr:`RESERVED`. """ + #: Disable the bottom toolbar (the default). + OFF = "off" + #: Use reserved rows where the terminal qualifies, and fall back silently where it does #: not. A backend that looks close enough is still a guess, and a wrong guess corrupts the #: screen the user is working in. @@ -106,14 +109,14 @@ def _select_toolbar_mode( :param layout_supported: whether the session's layout has a toolbar window that reserved rendering can recognize and hide :param version: the prompt-toolkit version to judge; the installed one by default - :return: the mode to use -- always :attr:`~ToolbarMode.RESERVED` or - :attr:`~ToolbarMode.LEGACY` -- and, when falling back from ``auto``, the reason + :return: the selected mode (``off``, ``legacy``, or ``reserved``) and, when + falling back from ``auto``, the reason :raises ValueError: if the mode is not a mode, or if ``reserved`` was required and a prerequisite is missing """ requested = _validate_toolbar_mode(mode) - if requested is ToolbarMode.LEGACY: - return ToolbarMode.LEGACY, "" + if requested in (ToolbarMode.OFF, ToolbarMode.LEGACY): + return requested, "" reason = _unmet_prerequisite( output, diff --git a/docs/features/initialization.md b/docs/features/initialization.md index 88e6d79d7..1321f8790 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -52,7 +52,7 @@ Here are instance attributes of `cmd2.Cmd` which developers might wish to overri - **max_completion_table_items**: The maximum number of completion results allowed for a completion table to appear (Default: 50) - **pager**: sets the pager command used by the `Cmd.ppaged()` method for displaying wrapped output using a pager - **pager_chop**: sets the pager command used by the `Cmd.ppaged()` method for displaying chopped/truncated output using a pager -- **use_builtin_pager**: defaults to `enable_bottom_toolbar`. While the command toolbar is running, `Cmd.ppaged()` uses an embedded pager that keeps that toolbar visible. Set to `False` to always use the external `pager`/`pager_chop` commands. This is an opt-*out* only: setting it to `True` does nothing unless `enable_bottom_toolbar` is also set, because the embedded pager shares the toolbar's display. +- **use_builtin_pager**: defaults to whether `bottom_toolbar_mode` is enabled. While the command toolbar is running, `Cmd.ppaged()` uses an embedded pager that keeps that toolbar visible. Set to `False` to always use the external `pager`/`pager_chop` commands. This is an opt-*out* only: setting it to `True` does nothing unless the bottom toolbar is enabled, because the embedded pager shares the toolbar's display. - **py_bridge_name**: name by which embedded Python environments and scripts refer to the `cmd2` application by in order to call commands (Default: `app`) - **py_locals**: dictionary that defines specific variables/functions available in Python shells and scripts (provides more fine-grained control than making everything available with **self_in_py**) - **quiet**: if `True`, then completely suppress nonessential output (Default: `False`) diff --git a/docs/features/prompt.md b/docs/features/prompt.md index 0d443d7f5..88fa04943 100644 --- a/docs/features/prompt.md +++ b/docs/features/prompt.md @@ -66,14 +66,19 @@ output appears above the toolbar. ### Enabling the Toolbar -To enable the toolbar, set `enable_bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor: +To enable the toolbar, set `bottom_toolbar_mode=cmd2.ToolbarMode.AUTO` in the [cmd2.Cmd.__init__][] +constructor: ```py class App(cmd2.Cmd): def __init__(self): - super().__init__(enable_bottom_toolbar=True) + super().__init__(bottom_toolbar_mode=cmd2.ToolbarMode.AUTO) ``` +The default is `cmd2.ToolbarMode.OFF`. `AUTO` uses reserved terminal rows where supported and falls +back to `LEGACY` rendering elsewhere. Select `LEGACY` to always redraw the toolbar with the prompt, +or `RESERVED` to require reserved rows and raise an error if unavailable. + ### Customizing Toolbar Content You can customize the content of the toolbar by overriding the [cmd2.Cmd.get_bottom_toolbar][] diff --git a/docs/upgrades.md b/docs/upgrades.md index 4504b98e3..4cbca6903 100644 --- a/docs/upgrades.md +++ b/docs/upgrades.md @@ -36,7 +36,8 @@ While we have strived to maintain compatibility, there are some differences: `cmd2` now supports an optional, persistent bottom toolbar. This can be used to display information such as the application name, current state, or even a real-time clock. -- **Enablement**: Set `enable_bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor. +- **Enablement**: Set `bottom_toolbar_mode=cmd2.ToolbarMode.AUTO` in the [cmd2.Cmd.__init__][] + constructor. - **Customization**: Override the [cmd2.Cmd.get_bottom_toolbar][] method to return the content you wish to display. diff --git a/examples/getting_started.py b/examples/getting_started.py index 1c2e3ed83..97dc05747 100755 --- a/examples/getting_started.py +++ b/examples/getting_started.py @@ -57,7 +57,7 @@ def __init__(self) -> None: super().__init__( auto_suggest=True, - enable_bottom_toolbar=True, + bottom_toolbar_mode=cmd2.ToolbarMode.AUTO, enable_rprompt=True, include_ipy=True, persistent_history_file="cmd2_history.dat", diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 58eeecb00..fe9c87816 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -4521,17 +4521,17 @@ def test_refresh_interval() -> None: assert custom_app.main_session.refresh_interval == 5.0 -def test_enable_bottom_toolbar() -> None: +def test_bottom_toolbar_mode() -> None: # Test default default_app = cmd2.Cmd() assert default_app.main_session.bottom_toolbar is None - # Test True - custom_app = cmd2.Cmd(enable_bottom_toolbar=True) + # Test enabled + custom_app = cmd2.Cmd(bottom_toolbar_mode=cmd2.ToolbarMode.AUTO) assert custom_app.main_session.bottom_toolbar == custom_app.get_bottom_toolbar - # Test False - custom_app = cmd2.Cmd(enable_bottom_toolbar=False) + # Test disabled + custom_app = cmd2.Cmd(bottom_toolbar_mode=cmd2.ToolbarMode.OFF) assert custom_app.main_session.bottom_toolbar is None @@ -4653,7 +4653,6 @@ def test_create_main_session_with_custom_tty() -> None: app._create_main_session( auto_suggest=True, completekey=app.DEFAULT_COMPLETEKEY, - enable_bottom_toolbar=False, enable_rprompt=False, complete_in_thread=False, refresh_interval=0.0, diff --git a/tests/test_command_toolbar.py b/tests/test_command_toolbar.py index a7f2b2237..af386a4d8 100644 --- a/tests/test_command_toolbar.py +++ b/tests/test_command_toolbar.py @@ -17,7 +17,7 @@ from prompt_toolkit.layout import HSplit, Layout, Window from prompt_toolkit.shortcuts import PromptSession -from cmd2 import Cmd, command_toolbar +from cmd2 import Cmd, ToolbarMode, command_toolbar from .conftest import RecordingOutput, Terminal @@ -260,7 +260,7 @@ def responds_to_cpr(self) -> bool: return True -def test_command_toolbar_flushes_writes_waiting_on_cursor_reports() -> None: +def test_command_toolbar_flushes_writes_waiting_on_cursor_reports(monkeypatch) -> None: app = Cmd(allow_cli_args=False) output = Terminal() app.stdout = output @@ -272,6 +272,15 @@ def test_command_toolbar_flushes_writes_waiting_on_cursor_reports() -> None: bottom_toolbar="STATUS", refresh_interval=0.01, ) + # Keep a real unanswered request, but expire its shutdown wait promptly. + # This test checks that queued output survives expiry, not the timeout duration. + renderer = app.main_session.app.renderer + wait_for_cpr = renderer.wait_for_cpr_responses + + async def expire_cpr() -> None: + await wait_for_cpr(timeout=0.01) + + monkeypatch.setattr(renderer, "wait_for_cpr_responses", expire_cpr) # Terminal writes wait for a pending cursor position report, so stopping the # display must not cancel them out from under the text. with app._command_toolbar_context(): @@ -605,7 +614,7 @@ def test_cmdloop_restores_signal_handlers_when_the_loop_fails(toolbar_app, monke @pytest.mark.parametrize("enabled", [False, True]) def test_command_toolbar_headless(enabled) -> None: - app = Cmd(allow_cli_args=False, enable_bottom_toolbar=enabled) + app = Cmd(allow_cli_args=False, bottom_toolbar_mode=ToolbarMode.AUTO if enabled else ToolbarMode.OFF) with mock.patch("cmd2.command_toolbar.CommandToolbar") as toolbar, app._command_toolbar_context(): toolbar.assert_not_called() @@ -840,7 +849,7 @@ def test_command_toolbar_startup_timeout_does_not_block_on_cleanup(toolbar_app, app, _, _ = toolbar_app blocked = threading.Event() monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) def blocking_toolbar() -> str: blocked.wait(timeout=10) @@ -886,7 +895,7 @@ def test_command_toolbar_suspension_does_not_hand_over_a_terminal_it_still_owns( """A pause that timed out did not stop anything, and must not pretend otherwise.""" app, _, _ = toolbar_app blocked = threading.Event() - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) entered = [] try: @@ -911,7 +920,7 @@ def test_command_toolbar_that_would_not_stop_is_not_used_again(toolbar_app, monk """The surviving thread outlives this display object, so the refusal has to as well.""" app, _, _ = toolbar_app blocked = threading.Event() - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) try: with contextlib.suppress(RuntimeError), app._command_toolbar_context(): @@ -932,7 +941,7 @@ def test_command_toolbar_that_would_not_stop_keeps_the_application(toolbar_app, """Its layout and bindings are still in use; restoring them would pull them out from under it.""" app, _, _ = toolbar_app blocked = threading.Event() - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) try: with contextlib.suppress(RuntimeError), app._command_toolbar_context(): @@ -954,7 +963,7 @@ def test_a_surviving_display_blocks_later_handoffs(toolbar_app, monkeypatch) -> app, _, _ = toolbar_app blocked = threading.Event() monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) entered = [] try: @@ -976,7 +985,7 @@ def test_a_surviving_display_blocks_the_prompt(toolbar_app, monkeypatch) -> None app, _, _ = toolbar_app blocked = threading.Event() monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) try: app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" @@ -999,7 +1008,7 @@ def test_the_refusal_lifts_when_the_display_finally_exits(toolbar_app, monkeypat app, _, _ = toolbar_app blocked = threading.Event() monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) prompt_layout = app.main_session.app.layout prompt_bindings = app.main_session.app.key_bindings @@ -1028,7 +1037,7 @@ def test_a_surviving_display_stops_another_from_starting(toolbar_app, monkeypatc app, _, _ = toolbar_app blocked = threading.Event() monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2) + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) try: app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" diff --git a/tests/test_pager.py b/tests/test_pager.py index a2a4283ba..5204bd42c 100644 --- a/tests/test_pager.py +++ b/tests/test_pager.py @@ -269,6 +269,8 @@ def test_pager_search_abort_keys(toolbar_app, key) -> None: registered explicitly so that they still work when the main prompt uses Vi mode. """ app, pipe, _ = toolbar_app + # The pipe sends complete escape sequences; only a bare Escape needs this timer. + app.main_session.app.ttimeoutlen = 0.01 lines = [f"row {index:03d}" for index in range(100)] def script(keys) -> None: @@ -292,6 +294,8 @@ def test_pager_close_keys(toolbar_app, key) -> None: sequences that arrive with more bytes behind them. """ app, pipe, _ = toolbar_app + # The pipe sends complete escape sequences; only a bare Escape needs this timer. + app.main_session.app.ttimeoutlen = 0.01 entered = threading.Event() closed = threading.Event() diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py index 63577877e..6e398f4f6 100644 --- a/tests/test_reserved_lifecycle.py +++ b/tests/test_reserved_lifecycle.py @@ -6,6 +6,7 @@ """ import io +from collections.abc import Callable from typing import Any import pytest @@ -24,6 +25,20 @@ class TtyStringIO(io.StringIO): """A stream that claims to be a terminal, as the backend requires.""" + reply: Callable[[str], None] | None = None + + def write(self, data: str) -> int: + """Answer each cursor request through the real input reader. + + These lifecycle tests use a fixed prompt origin; screen geometry is tested + separately. Leaving requests unanswered makes every shutdown wait a second. + """ + written = super().write(data) + if self.reply is not None: + for _ in range(data.count("\x1b[6n")): + self.reply("\x1b[1;1R") + return written + def isatty(self) -> bool: return True @@ -37,6 +52,7 @@ def __init__(self, mode: str = "reserved", rows: int = 24, toolbar: Any = "STATU self.backend = Vt100_Output(self.stream, lambda: self.size) self._pipe = create_pipe_input() self.pipe = self._pipe.__enter__() + self.stream.reply = self.pipe.send_text # Bind the ambient app session to this terminal. Without it, prompt-toolkit builds a # real one on demand -- patch_stdout() in _read_raw_input() does -- and on Windows that # means asking for a console the CI runner does not have. diff --git a/tests/test_toolbar_mode.py b/tests/test_toolbar_mode.py index 6de7b4b61..6911e7366 100644 --- a/tests/test_toolbar_mode.py +++ b/tests/test_toolbar_mode.py @@ -42,8 +42,8 @@ def test_an_unknown_mode_names_the_ones_that_exist(self) -> None: with pytest.raises(ValueError, match=r"auto.*legacy.*reserved"): _validate_toolbar_mode("pinned") - def test_the_modes_are_the_three_the_design_names(self) -> None: - assert {member.value for member in ToolbarMode} == {"auto", "reserved", "legacy"} + def test_the_available_modes(self) -> None: + assert {member.value for member in ToolbarMode} == {"off", "auto", "reserved", "legacy"} def test_a_mode_is_its_own_string(self) -> None: """Nothing that compared these to strings before has to change.""" @@ -98,9 +98,12 @@ def test_a_non_interactive_session_falls_back(self) -> None: class TestForcedModes: - def test_legacy_is_selected_whatever_the_terminal_supports(self) -> None: - mode, reason = _select_toolbar_mode("legacy", qualified_output(), toolbar_enabled=True, interactive=True) - assert mode is ToolbarMode.LEGACY + @pytest.mark.parametrize("requested", [ToolbarMode.OFF, ToolbarMode.LEGACY]) + def test_modes_without_reservations_need_no_qualification(self, requested: ToolbarMode) -> None: + mode, reason = _select_toolbar_mode( + requested, DummyOutput(), toolbar_enabled=False, interactive=False, layout_supported=False, version="0" + ) + assert mode is requested assert reason == "" def test_reserved_is_selected_when_everything_qualifies(self) -> None: @@ -130,18 +133,20 @@ def test_an_unknown_mode_is_rejected_before_anything_is_inspected(self) -> None: class TestConstructorWiring: - def test_the_default_is_legacy(self) -> None: - """Reserved rendering is opt-in until it has been through the release gates.""" - assert cmd2.Cmd(allow_cli_args=False).bottom_toolbar_mode is ToolbarMode.LEGACY + def test_the_default_is_off(self) -> None: + """No toolbar is configured unless the caller selects an enabled mode.""" + assert cmd2.Cmd(allow_cli_args=False).bottom_toolbar_mode is ToolbarMode.OFF @pytest.mark.parametrize("mode", list(ToolbarMode)) def test_a_requested_mode_is_remembered(self, mode: ToolbarMode) -> None: - app = cmd2.Cmd(allow_cli_args=False, enable_bottom_toolbar=True, bottom_toolbar_mode=mode) + app = cmd2.Cmd(allow_cli_args=False, bottom_toolbar_mode=mode) assert app.bottom_toolbar_mode is mode + assert (app.main_session.bottom_toolbar is not None) is (mode is not ToolbarMode.OFF) + assert app.use_builtin_pager is (mode is not ToolbarMode.OFF) def test_a_mode_given_as_a_string_is_remembered_as_the_member(self) -> None: """Existing calls pass strings; they get the same behaviour and a real member back.""" - app = cmd2.Cmd(allow_cli_args=False, enable_bottom_toolbar=True, bottom_toolbar_mode="reserved") + app = cmd2.Cmd(allow_cli_args=False, bottom_toolbar_mode="reserved") assert app.bottom_toolbar_mode is ToolbarMode.RESERVED def test_the_enum_is_importable_from_the_package(self) -> None: From cb61617a45493dbc63a97725e5e78cfaae63ff1b Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 11 Sep 2026 16:11:55 -0400 Subject: [PATCH 31/36] Speed up toolbar tests with explicit synchronization Replace fixed sleeps and barrier-expiration checks with observed lock contention and controlled timeout expiration. Keep real display threads, subprocess output, and ownership assertions; reuse the running pipe fixture to avoid paying the subprocess startup probe in toolbar pipe tests. Wait for render callbacks to start before expiring readiness, and join blocked workers during fixture cleanup. Exercise pending UI calls and pager EOF without waiting for polling intervals. Full-suite timings on the same local Python 3.14 environment: | Execution | Before | After | |------------------------|--------|-------| | Parallel with coverage | 4.92s | 3.74s | | Serial with coverage | 11.87s | 8.96s | Validation: 2531 passed, 6 skipped; 1770 repeated parallel tests passed. Four mutations were killed. make check and make docs-test passed. --- tests/conftest.py | 26 ++++ tests/test_command_toolbar.py | 192 +++++++++++++++++++++-------- tests/test_managed_output.py | 52 +++----- tests/test_terminal_transaction.py | 42 +++---- 4 files changed, 201 insertions(+), 111 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e025b3948..d021d65f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import os import subprocess import sys +import threading from collections.abc import Callable from contextlib import redirect_stderr from typing import ( @@ -291,3 +292,28 @@ def toolbar_app(): refresh_interval=0.01, ) yield app, pipe, output + + +class ContendedLock: + """A real reentrant lock that reports a competing acquisition without a sleep.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self.contended = threading.Event() + + def acquire(self) -> bool: + if self._lock.acquire(blocking=False): + return True + self.contended.set() + assert self._lock.acquire(timeout=5), "owner never released the lock" + return True + + def release(self) -> None: + self._lock.release() + + def __enter__(self): + self.acquire() + return self + + def __exit__(self, *args): + self.release() diff --git a/tests/test_command_toolbar.py b/tests/test_command_toolbar.py index af386a4d8..e7f05b501 100644 --- a/tests/test_command_toolbar.py +++ b/tests/test_command_toolbar.py @@ -19,7 +19,7 @@ from cmd2 import Cmd, ToolbarMode, command_toolbar -from .conftest import RecordingOutput, Terminal +from .conftest import ContendedLock, RecordingOutput, Terminal def test_command_toolbar_refresh_and_output(toolbar_app, monkeypatch) -> None: @@ -89,7 +89,7 @@ def command(statement, **kwargs): assert app.stdout is output -def test_command_toolbar_pipe_output(toolbar_app) -> None: +def test_command_toolbar_pipe_output(toolbar_app, running_pipe_process) -> None: app, _, output = toolbar_app with app._command_toolbar_context(): app.onecmd_plus_hooks(f'help | "{sys.executable}" -c "import sys; print(sys.stdin.read().upper())"') @@ -110,7 +110,7 @@ def __getattr__(self, name): @pytest.mark.parametrize("builtin_pager", [False, True]) -def test_command_toolbar_pipe_process_inherits_terminal(toolbar_app, tmp_path, builtin_pager) -> None: +def test_command_toolbar_pipe_process_inherits_terminal(toolbar_app, tmp_path, builtin_pager, running_pipe_process) -> None: app, _, _ = toolbar_app app.use_builtin_pager = builtin_pager destination = tmp_path / "terminal.txt" @@ -289,31 +289,35 @@ async def expire_cpr() -> None: assert "last words\n" in output.getvalue() -def test_command_toolbar_suspension_waits_for_in_flight_writes(toolbar_app) -> None: +def test_command_toolbar_suspension_waits_for_in_flight_writes(toolbar_app, monkeypatch) -> None: app, _, output = toolbar_app writing = threading.Event() + observed = ContendedLock() + original_init = command_toolbar.CommandToolbar.__init__ + + def init(display, *args, **kwargs): + original_init(display, *args, **kwargs) + display._lock = observed + + monkeypatch.setattr(command_toolbar.CommandToolbar, "__init__", init) with app._command_toolbar_context(): proxy = app._command_toolbar._proxy proxy_write = proxy.write def slow_write(data: str) -> int: - # Widen the window in which suspending could close this proxy. A closed - # proxy accepts writes and discards them, so the output would vanish. + # Do not finish the write until suspension actually tries to take its lock. writing.set() - time.sleep(0.1) + assert observed.contended.wait(5), "pause did not wait for the writer" return proxy_write(data) proxy.write = slow_write - thread = threading.Thread(target=lambda: app.poutput("in flight")) - thread.start() - assert writing.wait(2) - - # A command reaches this at every finalization boundary while its own - # threads are still printing. - with app.suspend_bottom_toolbar(): - pass - thread.join() + with ThreadPoolExecutor(max_workers=1) as pool: + pending = pool.submit(app.poutput, "in flight") + assert writing.wait(5) + with app.suspend_bottom_toolbar(): + pass + pending.result(timeout=5) assert "in flight\n" in output.getvalue() @@ -419,7 +423,7 @@ def already_exiting(): assert app.main_session.app.layout is app.main_session.layout -def test_command_toolbar_ui_call_propagates_failures(toolbar_app) -> None: +def test_command_toolbar_ui_call_propagates_failures(toolbar_app, monkeypatch) -> None: app, _, _ = toolbar_app def fail(exception: BaseException) -> None: @@ -439,7 +443,37 @@ def fail(exception: BaseException) -> None: toolbar._call_in_ui(lambda: fail(TimeoutError("slow ui call"))) # A callback that outlives the poll interval keeps waiting instead of giving up. - assert toolbar._call_in_ui(lambda: time.sleep(0.2) or "finished") == "finished" + entered = threading.Event() + release = threading.Event() + + class PendingFuture(Future): + polled = False + + def result(self, timeout=None): + if not self.polled: + self.polled = True + assert timeout is not None + assert entered.wait(5) + raise FutureTimeoutError + return super().result(timeout=5) + + def finish(): + entered.set() + assert release.wait(5) + return "finished" + + check_running = toolbar._check_running + + def checked(): + check_running() + release.set() + + monkeypatch.setattr(command_toolbar, "Future", PendingFuture) + monkeypatch.setattr(toolbar, "_check_running", checked) + try: + assert toolbar._call_in_ui(finish) == "finished" + finally: + release.set() def test_command_toolbar_ui_call_returns_a_result_that_lands_during_the_poll(toolbar_app, monkeypatch) -> None: @@ -487,7 +521,7 @@ def test_command_toolbar_ui_call_after_display_stopped(toolbar_app) -> None: toolbar._call_in_ui(lambda: None) -def test_command_toolbar_ui_call_reports_display_failure(toolbar_app, capsys) -> None: +def test_command_toolbar_ui_call_reports_display_failure(toolbar_app, capsys, monkeypatch) -> None: app, _, _ = toolbar_app with app._command_toolbar_context(): toolbar = app._command_toolbar @@ -495,6 +529,16 @@ def test_command_toolbar_ui_call_reports_display_failure(toolbar_app, capsys) -> schedule = loop.call_soon_threadsafe failed = threading.Event() + class FailedDisplayFuture(Future): + def result(self, timeout=None): + assert timeout is not None + toolbar._thread.join(5) + assert not toolbar._thread.is_alive() + assert not self.done() + raise FutureTimeoutError + + monkeypatch.setattr(command_toolbar, "Future", FailedDisplayFuture) + def die(*args, **kwargs): # The display dies instead of running the queued callback, so the future # the command is waiting on never resolves. Only drop that one request, @@ -788,7 +832,7 @@ def external(*args, **kwargs): assert app._command_toolbar.is_active -def test_builtin_pager_eof_restores_prompt(toolbar_app) -> None: +def test_builtin_pager_eof_restores_prompt(toolbar_app, monkeypatch) -> None: app, pipe, output = toolbar_app layout = app.main_session.app.layout @@ -797,6 +841,23 @@ def close_input(ui): pipe.close() app.main_session.app.after_render += close_input + original_pager = command_toolbar.Pager + + def pager(*args, **kwargs): + instance = original_pager(*args, **kwargs) + + def expired(timeout=None): + assert timeout is not None + display = app._command_toolbar + display._thread.join(5) + assert not display._thread.is_alive() + assert not instance.closed.is_set() + return False + + monkeypatch.setattr(instance.closed, "wait", expired) + return instance + + monkeypatch.setattr(command_toolbar, "Pager", pager) with pytest.raises(EOFError), app._command_toolbar_context(): app._command_toolbar.page("line\n" * 100, chop=False) assert app.main_session.app.layout is layout @@ -818,7 +879,55 @@ def test_builtin_pager_does_not_capture_redirected_output(toolbar_app, monkeypat assert "Cmd2 Commands" not in output.getvalue() -def test_command_toolbar_startup_does_not_wait_forever(toolbar_app, monkeypatch, capsys) -> None: +@pytest.fixture +def expire_startup(monkeypatch): + """Expire only this display's readiness wait, after its real render has started. + + The five-second waits are failure watchdogs, not simulated startup delays. Keep + blocked workers alive for ownership assertions and join them before fixture teardown. + """ + displays = [] + releases = [] + + def install(app, *, block=True): + entered = threading.Event() + release = threading.Event() + releases.append(release) + original_init = command_toolbar.CommandToolbar.__init__ + + def toolbar(): + entered.set() + if block: + assert release.wait(5), "test never released the display" + return "STATUS" + + def init(display, *args, **kwargs): + original_init(display, *args, **kwargs) + displays.append(display) + + def expired(timeout=None): + assert entered.wait(5), "display never entered its render callback" + assert timeout is not None, "startup must bound its readiness wait" + return False + + monkeypatch.setattr(display._ready, "wait", expired) + + app.main_session.bottom_toolbar = toolbar + monkeypatch.setattr(command_toolbar.CommandToolbar, "__init__", init) + if block: + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) + return release + + yield install + for release in releases: + release.set() + for display in displays: + if display._thread is not None: + display._thread.join(5) + assert not display._thread.is_alive() + + +def test_command_toolbar_startup_does_not_wait_forever(toolbar_app, monkeypatch, expire_startup, capsys) -> None: """A display that never reports itself started must not hold the command thread. The readiness signal comes from the display's own thread, so anything that stops it @@ -826,7 +935,7 @@ def test_command_toolbar_startup_does_not_wait_forever(toolbar_app, monkeypatch, block the command that is waiting to run. """ app, _, _ = toolbar_app - monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) + expire_startup(app, block=False) monkeypatch.setattr(command_toolbar.CommandToolbar, "_display_started", lambda *args: None) monkeypatch.setattr(command_toolbar.CommandToolbar, "_display_started_without_app", lambda *args: None) @@ -838,7 +947,7 @@ def test_command_toolbar_startup_does_not_wait_forever(toolbar_app, monkeypatch, assert "did not start" in capsys.readouterr().err -def test_command_toolbar_startup_timeout_does_not_block_on_cleanup(toolbar_app, monkeypatch, capsys) -> None: +def test_command_toolbar_startup_timeout_does_not_block_on_cleanup(toolbar_app, expire_startup, capsys) -> None: """A blocked render callback must not hold the command thread through teardown either. The readiness wait being bounded is only half of it: the display thread is still inside @@ -847,15 +956,8 @@ def test_command_toolbar_startup_timeout_does_not_block_on_cleanup(toolbar_app, why it could not see this. """ app, _, _ = toolbar_app - blocked = threading.Event() - monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) - - def blocking_toolbar() -> str: - blocked.wait(timeout=10) - return "STATUS" + blocked = expire_startup(app) - app.main_session.bottom_toolbar = blocking_toolbar ran = [] try: started = time.monotonic() @@ -958,16 +1060,13 @@ def test_command_toolbar_that_would_not_stop_keeps_the_application(toolbar_app, blocked.set() -def test_a_surviving_display_blocks_later_handoffs(toolbar_app, monkeypatch) -> None: +def test_a_surviving_display_blocks_later_handoffs(toolbar_app, expire_startup) -> None: """Disabling future displays is not enough: the old one still owns the terminal.""" app, _, _ = toolbar_app - blocked = threading.Event() - monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) + blocked = expire_startup(app) entered = [] try: - app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" with app._command_toolbar_context(): pass @@ -980,15 +1079,12 @@ def test_a_surviving_display_blocks_later_handoffs(toolbar_app, monkeypatch) -> blocked.set() -def test_a_surviving_display_blocks_the_prompt(toolbar_app, monkeypatch) -> None: +def test_a_surviving_display_blocks_the_prompt(toolbar_app, expire_startup) -> None: """Two readers on one terminal is not a state to keep prompting in.""" app, _, _ = toolbar_app - blocked = threading.Event() - monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) + blocked = expire_startup(app) try: - app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" with app._command_toolbar_context(): pass @@ -998,7 +1094,7 @@ def test_a_surviving_display_blocks_the_prompt(toolbar_app, monkeypatch) -> None blocked.set() -def test_the_refusal_lifts_when_the_display_finally_exits(toolbar_app, monkeypatch) -> None: +def test_the_refusal_lifts_when_the_display_finally_exits(toolbar_app, expire_startup) -> None: """The thread may yet finish, and the session should not stay broken if it does. Lifting the refusal is not the whole of it. The pause that timed out never restored the @@ -1006,15 +1102,12 @@ def test_the_refusal_lifts_when_the_display_finally_exits(toolbar_app, monkeypat display's layout and key bindings unless that teardown is finished first. """ app, _, _ = toolbar_app - blocked = threading.Event() - monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) + blocked = expire_startup(app) prompt_layout = app.main_session.app.layout prompt_bindings = app.main_session.app.key_bindings prompt_erase = app.main_session.app.erase_when_done - app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" with app._command_toolbar_context(): pass surviving = app._display_holding_terminal @@ -1033,14 +1126,11 @@ def test_the_refusal_lifts_when_the_display_finally_exits(toolbar_app, monkeypat assert app.main_session.app.erase_when_done == prompt_erase -def test_a_surviving_display_stops_another_from_starting(toolbar_app, monkeypatch) -> None: +def test_a_surviving_display_stops_another_from_starting(toolbar_app, expire_startup) -> None: app, _, _ = toolbar_app - blocked = threading.Event() - monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2) - monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) + blocked = expire_startup(app) try: - app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS" with app._command_toolbar_context(): pass with app._command_toolbar_context(): diff --git a/tests/test_managed_output.py b/tests/test_managed_output.py index 9ffdbff27..e2bf6ad6a 100644 --- a/tests/test_managed_output.py +++ b/tests/test_managed_output.py @@ -8,6 +8,7 @@ import io import threading +from concurrent.futures import ThreadPoolExecutor from typing import Any, Self import pytest @@ -16,6 +17,8 @@ from cmd2.managed_output import SerializedTerminalWriter from cmd2.terminal_transaction import TerminalLock, current_transaction +from .conftest import ContendedLock + class RecordingBridge: """A stand-in for the bridge, recording when it was told and by whom.""" @@ -135,43 +138,20 @@ def test_the_bridge_can_be_attached_later(self) -> None: class TestOrdering: def test_a_write_and_a_paint_do_not_interleave(self) -> None: - """Both take the same lock, so one completes before the other starts.""" - writer, stream, lock = make() - both_inside = threading.Barrier(2, timeout=0.2) - start = threading.Barrier(2, timeout=5) - overlaps: list[int] = [] - - def emit_output() -> None: - start.wait() - writer.write("output\n") - - def paint() -> None: - start.wait() + """A managed write cannot reach the stream while a paint owns the terminal.""" + observed = ContendedLock() + lock = TerminalLock(lock=observed) + stream = RecordingStream() + writer = SerializedTerminalWriter(stream, lock, None) + with ThreadPoolExecutor(max_workers=1) as pool: with lock.transaction("paint"): - try: - both_inside.wait() - except threading.BrokenBarrierError: - return - overlaps.append(1) - - original_write = stream.write - - def watched(text: str) -> int: - try: - both_inside.wait() - except threading.BrokenBarrierError: - pass - else: - overlaps.append(1) - return original_write(text) - - stream.write = watched # type: ignore[method-assign] - threads = [threading.Thread(target=emit_output), threading.Thread(target=paint)] - for thread in threads: - thread.start() - for thread in threads: - thread.join(timeout=5) - assert overlaps == [] + pending = pool.submit(writer.write, "output\n") + assert observed.contended.wait(5), "write bypassed the paint's lock" + assert stream.getvalue() == "" + assert not pending.done() + stream.write("paint\n") + assert pending.result(timeout=5) == len("output\n") + assert stream.getvalue() == "paint\noutput\n" def test_writes_from_two_threads_are_not_torn(self) -> None: writer, stream, _lock = make() diff --git a/tests/test_terminal_transaction.py b/tests/test_terminal_transaction.py index cc17ef496..099013e08 100644 --- a/tests/test_terminal_transaction.py +++ b/tests/test_terminal_transaction.py @@ -11,7 +11,7 @@ import queue import threading import time -from concurrent.futures import Future +from concurrent.futures import Future, ThreadPoolExecutor from typing import Any, Self import pytest @@ -26,6 +26,8 @@ held_higher_level_locks, ) +from .conftest import ContendedLock + class Sentinel: """A stand-in for a blocking primitive that records being entered instead of blocking.""" @@ -278,32 +280,24 @@ def worker() -> None: class TestSerialization: def test_two_threads_never_hold_the_terminal_at_once(self) -> None: - """The lock is what serializes emission; without it the two bodies meet. - - The overlap is detected with a barrier rather than a sleep. A sleep would only make - an overlap *likely* to be observed; a barrier that both threads must reach inside the - transaction can only be satisfied if they are genuinely inside it together. - """ - terminal = TerminalLock() - both_inside = threading.Barrier(2, timeout=0.2) - start = threading.Barrier(2, timeout=5) - overlaps: list[int] = [] + """A contender must wait until the owning transaction finishes.""" + observed = ContendedLock() + terminal = TerminalLock(lock=observed) + order = [] def emit() -> None: - start.wait() with terminal.transaction("paint"): - try: - both_inside.wait() - except threading.BrokenBarrierError: - return - overlaps.append(1) - - threads = [threading.Thread(target=emit) for _ in range(2)] - for thread in threads: - thread.start() - for thread in threads: - thread.join(timeout=5) - assert overlaps == [] + order.append("contender") + + with ThreadPoolExecutor(max_workers=1) as pool: + with terminal.transaction("owner"): + pending = pool.submit(emit) + assert observed.contended.wait(5), "contender bypassed the terminal lock" + assert order == [] + assert not pending.done() + order.append("owner") + pending.result(timeout=5) + assert order == ["owner", "contender"] class TestDiagnostics: From 26219082ecf61b7c2ec0f17ffd43efa74ae3e87f Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 11 Sep 2026 16:21:08 -0400 Subject: [PATCH 32/36] Reduce pager and subprocess test overhead Keep the long-line pager input taller than the terminal while reducing it from 100 wrapped rows to 30, and rely on input and resize invalidation instead of periodic redraws. Skip site initialization in the Python pipe children while retaining real subprocess I/O. Model completed editor processes explicitly so cleanup succeeds instead of raising on an unconfigured MagicMock. Assert communication, successful status, and absence of errors in the editor tests. Median test-body timings across 10 serial runs without coverage: | Test | Before | After | |-------------------------|--------|--------| | Long-line pager wrapped | 19.7ms | 15.9ms | | Long-line pager chopped | 18.1ms | 13.5ms | | Pipe output | 39.3ms | 30.3ms | | Edit file | 2.1ms | 0.5ms | Navigation and search-abort tests remain unchanged because experiments showed no reliable improvement. Validation: 2531 passed, 6 skipped in 3.82s with coverage; 320 repeated parallel tests passed. make check and make docs-test passed. Overall suite timing remains within normal variation. --- tests/test_cmd2.py | 33 +++++++++++++++++++++++++++------ tests/test_command_toolbar.py | 7 +++++-- tests/test_pager.py | 6 +++++- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index fe9c87816..ec75d35f2 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1015,16 +1015,23 @@ def test_edit_file(base_app, request, monkeypatch) -> None: base_app.editor = "fooedit" # Mock out the subprocess.Popen call so we don't actually open an editor - m = mock.MagicMock(name="Popen") + # Model a completed editor, including communicate(), so ProcReader cleanup + # succeeds instead of raising while unpacking an unconfigured MagicMock. + process = mock.Mock(stdout=None, stderr=None, returncode=0) + process.communicate.return_value = (None, None) + m = mock.Mock(name="Popen", return_value=process) monkeypatch.setattr("subprocess.Popen", m) test_dir = os.path.dirname(request.module.__file__) filename = os.path.join(test_dir, "script.txt") - run_cmd(base_app, f"edit {filename}") + _, errors = run_cmd(base_app, f"edit {filename}") # We think we have an editor, so should expect a Popen call m.assert_called_once() + process.communicate.assert_called_once_with() + assert errors == [] + assert base_app.last_result == 0 @pytest.mark.parametrize("file_name", odd_file_names) @@ -1045,16 +1052,23 @@ def test_edit_file_with_spaces(base_app, request, monkeypatch) -> None: base_app.editor = "fooedit" # Mock out the subprocess.Popen call so we don't actually open an editor - m = mock.MagicMock(name="Popen") + # Model a completed editor, including communicate(), so ProcReader cleanup + # succeeds instead of raising while unpacking an unconfigured MagicMock. + process = mock.Mock(stdout=None, stderr=None, returncode=0) + process.communicate.return_value = (None, None) + m = mock.Mock(name="Popen", return_value=process) monkeypatch.setattr("subprocess.Popen", m) test_dir = os.path.dirname(request.module.__file__) filename = os.path.join(test_dir, "my commands.txt") - run_cmd(base_app, f'edit "{filename}"') + _, errors = run_cmd(base_app, f'edit "{filename}"') # We think we have an editor, so should expect a Popen call m.assert_called_once() + process.communicate.assert_called_once_with() + assert errors == [] + assert base_app.last_result == 0 def test_edit_blank(base_app, monkeypatch) -> None: @@ -1062,13 +1076,20 @@ def test_edit_blank(base_app, monkeypatch) -> None: base_app.editor = "fooedit" # Mock out the subprocess.Popen call so we don't actually open an editor - m = mock.MagicMock(name="Popen") + # Model a completed editor, including communicate(), so ProcReader cleanup + # succeeds instead of raising while unpacking an unconfigured MagicMock. + process = mock.Mock(stdout=None, stderr=None, returncode=0) + process.communicate.return_value = (None, None) + m = mock.Mock(name="Popen", return_value=process) monkeypatch.setattr("subprocess.Popen", m) - run_cmd(base_app, "edit") + _, errors = run_cmd(base_app, "edit") # We have an editor, so should expect a Popen call m.assert_called_once() + process.communicate.assert_called_once_with() + assert errors == [] + assert base_app.last_result == 0 def test_base_py_interactive(base_app) -> None: diff --git a/tests/test_command_toolbar.py b/tests/test_command_toolbar.py index e7f05b501..ac4d07a63 100644 --- a/tests/test_command_toolbar.py +++ b/tests/test_command_toolbar.py @@ -90,9 +90,10 @@ def command(statement, **kwargs): def test_command_toolbar_pipe_output(toolbar_app, running_pipe_process) -> None: + # The child needs only sys; -S skips site initialization while retaining real pipe I/O. app, _, output = toolbar_app with app._command_toolbar_context(): - app.onecmd_plus_hooks(f'help | "{sys.executable}" -c "import sys; print(sys.stdin.read().upper())"') + app.onecmd_plus_hooks(f'help | "{sys.executable}" -S -c "import sys; print(sys.stdin.read().upper())"') assert "CMD2 COMMANDS" in output.getvalue() @@ -128,7 +129,9 @@ def command(statement, **kwargs): with destination.open("w+") as handle: app.stdout = FileTerminal(handle) with mock.patch.object(app, "onecmd", side_effect=command), app._command_toolbar_context(): - app.onecmd_plus_hooks(f'custom | "{sys.executable}" -c "import sys; sys.stdout.write(sys.stdin.read().upper())"') + app.onecmd_plus_hooks( + f'custom | "{sys.executable}" -S -c "import sys; sys.stdout.write(sys.stdin.read().upper())"' + ) # The terminal goes back to the toolbar once the pipe process has exited. assert app._command_toolbar.app.is_running assert app.stdout.proxy is not None diff --git a/tests/test_pager.py b/tests/test_pager.py index 5204bd42c..8dfae5d08 100644 --- a/tests/test_pager.py +++ b/tests/test_pager.py @@ -58,6 +58,8 @@ def test_output_fits_measures_styled_and_wide_text(chop) -> None: def test_pager_long_line_navigation_resize_and_typeahead(toolbar_app, chop) -> None: app, pipe, _ = toolbar_app app.main_session.bottom_toolbar = "STATUS ONE\nSTATUS TWO" + # Input and the explicit resize invalidate the display; no periodic redraw is needed. + app.main_session.app.refresh_interval = None entered, scrolled, resized = (threading.Event() for _ in range(3)) def observe(ui): @@ -90,7 +92,9 @@ def interact(): with ThreadPoolExecutor() as executor: interaction = executor.submit(interact) with app._command_toolbar_context(): - app._command_toolbar.page("界" * 4000, chop=chop) + # Thirty rows at 80 columns: still taller than the initial 24-row + # terminal, and longer after the resize, without rendering 100 rows. + app._command_toolbar.page("界" * 1200, chop=chop) interaction.result(timeout=2) assert app._read_raw_input("Next: ", app.main_session) == "next" From a734941f864675d22d76efb7eb8d13f4c064faf8 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 11 Sep 2026 16:48:23 -0400 Subject: [PATCH 33/36] Fix reserved toolbar corruption at the terminal bottom Make room for the reserved band before installing scroll margins at startup and after a terminal handoff. Index down while full-screen margins are still installed, then move back up before saving the cursor. This preserves output and keeps the prompt cursor out of the toolbar without requiring CPR before the input reader starts. Erase owned toolbar cells before releasing the margins so short shell output cannot leave toolbar fragments in the screen or scrollback. Skip the erase if the geometry changed or cannot be measured, while still resetting the margins. Add pyte as a test-only dependency and exercise actual cursor reports, scrolling, output preservation, and subsequent prompts. Account for pyte's DECRC clamping difference from the reproduced terminal behavior. Retarget partial-paint fault injection so acquisition flush changes do not make it fail the wrong operation. Validation: - 2547 passed, 6 skipped in 3.56s with coverage - 320 repeated parallel regression tests passed - Three mutations killed: startup adjustment, handoff adjustment, erase - make check and make docs-test passed - Isolated tmux checks passed at 12, 24, and 40 rows, including bottom-row startup, repeated overflowing shell commands, 160/160 exact output lines, working subsequent prompts, and full margins restored on quit - User's manual macOS testing confirmed both reported bugs are fixed --- cmd2/scroll_region.py | 5 +- cmd2/terminal_display.py | 51 ++++++++-- pyproject.toml | 2 + tests/test_reserved_output.py | 4 +- tests/test_reserved_terminal.py | 170 ++++++++++++++++++++++++++++++++ tests/test_reserved_toolbar.py | 37 +++---- tests/test_terminal_display.py | 16 +-- tests/test_toolbar_painter.py | 6 +- 8 files changed, 253 insertions(+), 38 deletions(-) create mode 100644 tests/test_reserved_terminal.py diff --git a/cmd2/scroll_region.py b/cmd2/scroll_region.py index bbb215671..96c6a5a27 100644 --- a/cmd2/scroll_region.py +++ b/cmd2/scroll_region.py @@ -33,8 +33,9 @@ The caller is responsible for the cursor not being inside the reserved band when the region is established: this helper cannot discover the cursor's row without a cursor-position report, -which needs input it does not have. Placing the prompt within the usable area belongs to the -layer that owns the terminal. +which needs input it does not have. The physical-terminal owner makes room under the cursor +while full-screen margins are still installed, before saving its position and narrowing the +region. A region needs at least two usable rows. DECSTBM requires the bottom margin to be greater than the top, so a degenerate ``ESC [ 1 ; 1 r`` is ignored and the terminal silently keeps diff --git a/cmd2/terminal_display.py b/cmd2/terminal_display.py index 5c4fdbc20..b002c364f 100644 --- a/cmd2/terminal_display.py +++ b/cmd2/terminal_display.py @@ -226,6 +226,25 @@ def write_margin_change(self, sequence: str) -> None: # now rather than whenever something else happens to flush. self._output.flush() + def make_room_for_region(self, geometry: Geometry) -> None: + """Keep the cursor and preceding output above the band before narrowing margins. + + Full-screen margins must still be installed. Indexing down by the band's height + scrolls only when the cursor is near the bottom. Moving back up by the same amount + then leaves it at its original row, or at the last usable row if scrolling occurred. + IND preserves the column and is independent of the tty's newline translation. + + This needs no CPR, so it also works before the application's input reader starts. + The cursor saved by the margin change must be this adjusted position, not the old + physical row which may now belong to the band. Flush the adjustment before the + margin operation, so a failure there cannot strand it in the backend's buffer. + + :param geometry: the eligible geometry whose band needs room + """ + rows = geometry.reserved_rows + self._output.write_raw("\x1bD" * rows + f"\x1b[{rows}A") + self._output.flush() + def install_region(self, geometry: Geometry) -> None: """Install the scroll region described by ``geometry``. @@ -234,9 +253,22 @@ def install_region(self, geometry: Geometry) -> None: """ self.write_margin_change(scroll_region_sequence(geometry.physical_rows, geometry.reserved_rows)) - def release_region(self) -> None: - """Restore full-screen scroll margins.""" - self.write_margin_change(reset_scroll_region_sequence()) + def release_region(self, geometry: Geometry | None = None) -> None: + """Restore full-screen margins, removing owned toolbar cells before they can scroll. + + :param geometry: the region being released, if one was successfully installed + """ + sequence = reset_scroll_region_sequence() + if geometry is not None: + # A resize or viewport move can invalidate the owned band before teardown. + # If measurement fails, still reset the margins; do not guess where to erase. + with suppress(Exception): + if self.measure(geometry.generation, geometry.reserved_rows) == geometry: + # ED deliberately reaches the physical bottom here: these are the rows + # being returned, not application output. The save/restore below also + # preserves the prompt's cursor and attributes around the erase. + sequence = f"\x1b[{geometry.usable_rows + 1};1H\x1b[0m\x1b[J" + sequence + self.write_margin_change(sequence) class TerminalDisplay: @@ -319,6 +351,7 @@ def acquire(self) -> bool: geometry = self._measure() if not geometry.is_eligible: return False + self._terminal.make_room_for_region(geometry) self._terminal.install_region(geometry) except BaseException: # Give the lease back *first*. Cleanup can fail too -- a terminal that could not @@ -365,8 +398,8 @@ def _teardown(self) -> None: self._handoff_active = False if self._geometry is None: return - self._geometry = None - self._terminal.release_region() + geometry, self._geometry = self._geometry, None + self._terminal.release_region(geometry) def reconfigure(self) -> bool: """Resample the terminal and re-establish the reservation for the new geometry. @@ -393,6 +426,10 @@ def reconfigure(self) -> bool: self._adapter = None self._terminal.release_region() return False + if self._geometry is None: + # The guest may have scrolled to the physical bottom, just as the shell + # that launched us may have done before the initial acquisition. + self._terminal.make_room_for_region(geometry) self._terminal.install_region(geometry) self._geometry = geometry if self._adapter is None: @@ -417,8 +454,8 @@ def release_region_for_handoff(self) -> None: self._handoff_active = True if self._geometry is None: return - self._geometry = None - self._terminal.release_region() + geometry, self._geometry = self._geometry, None + self._terminal.release_region(geometry) def reacquire_region_after_handoff(self) -> None: """Re-establish the reservation after a program hands the terminal back. diff --git a/pyproject.toml b/pyproject.toml index ec98c9ef6..e3ce52543 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dev = [ "mkdocstrings[python]>=1", "mypy>=2.3.1", "prek>=0.3.5", + "pyte>=0.8.2", "pytest>=8.1.1", "pytest-cov>=5", "pytest-mock>=3.14.1", @@ -62,6 +63,7 @@ quality = ["prek>=0.3.5"] test = [ "codecov>=2.1", "coverage>=7.11.3", + "pyte>=0.8.2", "pytest>=8.1.1", "pytest-cov>=5", "pytest-mock>=3.14.1", diff --git a/tests/test_reserved_output.py b/tests/test_reserved_output.py index 92a77447b..28dfa613b 100644 --- a/tests/test_reserved_output.py +++ b/tests/test_reserved_output.py @@ -248,7 +248,7 @@ def test_the_region_is_anchored_at_row_one(self) -> None: """The arithmetic above is only sound because of this.""" output, stream = make_output(rows=24) TerminalDisplay(output).acquire() - assert stream.getvalue() == "\x1b7\x1b[1;23r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;23r\x1b8" def test_a_cursor_in_the_reserved_band_gives_a_nonpositive_height(self) -> None: """The R5 hazard, stated in geometry terms: rejecting it belongs to the bridge, but @@ -361,7 +361,7 @@ def test_entering_the_alternate_screen_restores_full_margins_first(self) -> None adapter, stream, display = make_reserved(rows=24) adapter.enter_alternate_screen() adapter.flush() - assert stream.getvalue().startswith("\x1b7\x1b[r\x1b8"), "margins were left installed" + assert stream.getvalue().startswith("\x1b7\x1b[24;1H\x1b[0m\x1b[J\x1b[r\x1b8"), "margins were left installed" assert not display.is_reserved def test_leaving_the_alternate_screen_re_establishes_the_region(self) -> None: diff --git a/tests/test_reserved_terminal.py b/tests/test_reserved_terminal.py new file mode 100644 index 000000000..cf1440e88 --- /dev/null +++ b/tests/test_reserved_terminal.py @@ -0,0 +1,170 @@ +"""Reservation boundaries interpreted by a terminal, rather than a fixed row-one CPR stub.""" + +import io +import sys +import threading +from types import SimpleNamespace + +import pyte +import pytest + +from cmd2.reserved_toolbar import ReservedToolbar +from cmd2.utils import StdSim + +from .test_reserved_lifecycle import Harness + + +class TerminalScreen(pyte.HistoryScreen): + """Keep DECRC's physical position even when it is outside the scroll margins. + + pyte 0.8.2 unconditionally clamps a restored cursor to the margins. iTerm2 and + tmux retain the physical row when origin mode is off; that distinction is the + bug these tests exercise. Let pyte restore against the whole screen in that mode. + """ + + def restore_cursor(self) -> None: + margins = self.margins + if self.savepoints and not self.savepoints[-1].origin: + self.margins = None + try: + super().restore_cursor() + finally: + self.margins = margins + + +class EmulatedTerminal(io.StringIO): + """Parse output and answer CPR where the cursor actually is when the query arrives.""" + + def __init__(self, rows, reply) -> None: + super().__init__() + self.screen = TerminalScreen(80, rows, history=1000) + self.reports = [] + + def respond(data): + self.reports.append(self.screen.cursor.y + 1) + reply(data) + + self.screen.write_process_input = respond + self.parser = pyte.Stream(self.screen) + self.buffer = SimpleNamespace(write=lambda data: self.write(data.decode("utf-8"))) + + def write(self, data): + count = super().write(data) + # The tty's output processing supplies carriage returns to subprocess newlines. + self.parser.feed(data.replace("\n", "\r\n")) + return count + + def isatty(self) -> bool: + return True + + +@pytest.fixture +def terminal_harness(request): + rows = getattr(request, "param", 24) + harness = Harness(rows=rows) + stream = EmulatedTerminal(rows, harness.pipe.send_text) + harness.stream = stream + harness.backend.stdout = stream + harness.app.stdout = stream + try: + yield harness, stream + finally: + harness.close() + + +def read_prompt(harness, terminal) -> None: + """Use the real input reader and CPR binding, then accept a prompt with known height.""" + ui = harness.app.main_session.app + sent = False + + def ready(app): + nonlocal sent + if not sent and app.renderer._min_available_height > 0: + sent = True + harness.pipe.send_text("next\n") + + ui.after_render += ready + watchdog = threading.Timer(5, harness.pipe.close) + watchdog.daemon = True + watchdog.start() + try: + assert harness.app._read_raw_input("TEST> ", harness.app.main_session) == "next" + assert sent + assert terminal.reports + assert all(row < terminal.screen.lines for row in terminal.reports) + assert terminal.screen.display[-1].startswith("STATUS") + finally: + watchdog.cancel() + watchdog.join() + ui.after_render -= ready + + +@pytest.mark.parametrize("row", [1, 22, 23, 24]) +@pytest.mark.parametrize("reserved", [1, 2]) +def test_acquisition_keeps_existing_output_and_cursor_above_the_band(terminal_harness, row, reserved) -> None: + harness, terminal = terminal_harness + terminal.write(f"\x1b[{row};1Hexisting output") + with ReservedToolbar(harness.app.main_session, lambda: "STATUS", reserved_rows=reserved): + expected_row = min(row, 24 - reserved) + assert terminal.screen.cursor.y + 1 == expected_row + assert terminal.screen.cursor.x == len("existing output") + assert terminal.screen.display[expected_row - 1].startswith("existing output") + assert terminal.screen.display[24 - reserved].startswith("STATUS") + assert terminal.screen.margins is None + + +@pytest.mark.parametrize("terminal_harness", [12, 24, 40], indirect=True) +def test_a_prompt_started_on_the_bottom_row_gets_a_usable_cursor_report(terminal_harness, capsys) -> None: + harness, terminal = terminal_harness + terminal.write(f"\x1b[{terminal.screen.lines};1H") + with harness.app._reserved_toolbar_context(): + # Fail before trying input if startup has left the cursor in the band. + assert terminal.screen.cursor.y < terminal.screen.lines - 1 + read_prompt(harness, terminal) + assert "doesn't support cursor position requests" not in capsys.readouterr().err + assert terminal.screen.margins is None + + +@pytest.mark.parametrize("terminal_harness", [12, 24, 40], indirect=True) +def test_shell_output_scrolling_to_the_bottom_returns_a_working_prompt(terminal_harness, capfd) -> None: + harness, terminal = terminal_harness + # Exercise a real shell subprocess and ProcReader, echoing its captured bytes to + # the same terminal as the toolbar. This supplies a real pipe rather than fileno() + # on the in-memory terminal. + harness.app.stdout = StdSim(terminal, echo=True) + with harness.app._reserved_toolbar_context(): + command = f"!\"{sys.executable}\" -S -c \"print('out\\n' * 80, end='')\"" + harness.app.onecmd_plus_hooks(command) + assert harness.app.last_result == 0 + assert terminal.screen.cursor.y == terminal.screen.lines - 2 + assert terminal.screen.display[-3].rstrip() == "out" + assert terminal.screen.display[-1].startswith("STATUS") + # Every emitted line must remain in the viewport or scrollback exactly once. + history = ["".join(line[x].data for x in sorted(line)) for line in terminal.screen.history.top] + assert sum(line.rstrip() == "out" for line in history + terminal.screen.display) == 80 + read_prompt(harness, terminal) + assert "doesn't support cursor position requests" not in capfd.readouterr().err + assert terminal.screen.margins is None + + +def test_release_does_not_erase_rows_from_a_changed_viewport(terminal_harness) -> None: + harness, terminal = terminal_harness + with harness.app._reserved_toolbar_context(): + # The reserved row was cropped by a resize; the new bottom row is application + # output. CUP to the old row would clamp there and erase somebody else's text. + terminal.screen.resize(lines=12, columns=80) + harness.size = type(harness.size)(rows=12, columns=80) + terminal.write("\x1b[12;1Hkeep this output") + assert terminal.screen.display[-1].startswith("keep this output") + assert terminal.screen.margins is None + + +def test_release_still_resets_margins_when_geometry_cannot_be_measured(terminal_harness, monkeypatch) -> None: + harness, terminal = terminal_harness + with harness.app._reserved_toolbar_context(): + + def unavailable(): + raise OSError("terminal size unavailable") + + monkeypatch.setattr(harness.backend, "get_size", unavailable) + assert terminal.screen.margins is None diff --git a/tests/test_reserved_toolbar.py b/tests/test_reserved_toolbar.py index 44bf3ed0d..23d5d9b02 100644 --- a/tests/test_reserved_toolbar.py +++ b/tests/test_reserved_toolbar.py @@ -481,10 +481,8 @@ def test_the_filter_is_restored_when_it_is_still_ours(self) -> None: class PartialWriteStream(TtyStringIO): """Writes a prefix of chosen flushed batches and then fails, as a real terminal can.""" - def __init__(self, *fail_on_writes: int, keep: int = 12) -> None: + def __init__(self, *, keep: int = 12) -> None: super().__init__() - self._writes = 0 - self._fail_on_writes = set(fail_on_writes) self._armed = False self._keep = keep @@ -497,8 +495,7 @@ def fail_next(self) -> None: self._armed = True def write(self, text: str) -> int: - self._writes += 1 - if self._armed or self._writes in self._fail_on_writes: + if self._armed: self._armed = False super().write(text[: self._keep]) raise OSError("terminal went away") @@ -510,9 +507,16 @@ def test_a_half_written_first_paint_leaves_no_state_behind(self) -> None: """Review finding: rollback released the margins over a cursor still in the band.""" harness = Harness() try: - # Batch one installs the margins; batch two is the first paint. - harness.stream = PartialWriteStream(2) + harness.stream = PartialWriteStream() harness.backend.stdout = harness.stream + + def first_paint(): + # Arm after acquisition, when the painter evaluates its content. Counting + # startup flushes would fail the wrong operation when acquisition changes. + harness.stream.fail_next() + return "TOOLBAR" + + harness.session.bottom_toolbar = first_paint with pytest.raises(OSError, match="terminal went away"): harness.toolbar.start() @@ -527,22 +531,19 @@ def test_a_half_written_first_paint_leaves_no_state_behind(self) -> None: class TestRefreshFailure: - def make(self, *fail_on_writes: int) -> Harness: - """Build a toolbar over a terminal that fails the chosen flushed batches. - - Batch one installs the margins and batch two is the first paint, so refreshes start - at batch three. - """ + def make(self) -> Harness: + """Build a toolbar over a terminal whose next write can be failed explicitly.""" harness = Harness() - harness.stream = PartialWriteStream(*fail_on_writes) + harness.stream = PartialWriteStream() harness.backend.stdout = harness.stream return harness def test_a_failed_paint_does_not_take_the_command_down(self) -> None: """The toolbar is cosmetic; the command that was running is not its to interrupt.""" - harness = self.make(3) + harness = self.make() try: harness.toolbar.start() + harness.stream.fail_next() harness.session.bottom_toolbar = "CHANGED" assert harness.toolbar.refresh() is False finally: @@ -550,9 +551,10 @@ def test_a_failed_paint_does_not_take_the_command_down(self) -> None: def test_a_failed_paint_makes_the_bridge_resynchronize(self) -> None: """Buffering the cursor save does not prove the terminal received it.""" - harness = self.make(3) + harness = self.make() try: harness.toolbar.start() + harness.stream.fail_next() bridge = harness.toolbar.bridge assert bridge is not None harness.session.bottom_toolbar = "CHANGED" @@ -563,9 +565,10 @@ def test_a_failed_paint_makes_the_bridge_resynchronize(self) -> None: harness.close() def test_the_failure_is_reported_once(self) -> None: - harness = self.make(3) + harness = self.make() try: harness.toolbar.start() + harness.stream.fail_next() harness.session.bottom_toolbar = "CHANGED" harness.toolbar.refresh() assert isinstance(harness.toolbar.take_pending_error(), OSError) diff --git a/tests/test_terminal_display.py b/tests/test_terminal_display.py index 8a6f94836..43b116136 100644 --- a/tests/test_terminal_display.py +++ b/tests/test_terminal_display.py @@ -148,7 +148,7 @@ def test_acquiring_installs_the_region_and_flushes_it(self) -> None: output, stream = make_output(rows=24) display = TerminalDisplay(output) assert display.acquire() - assert stream.getvalue() == "\x1b7\x1b[1;23r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;23r\x1b8" assert not output._buffer def test_releasing_restores_full_screen_margins_immediately(self) -> None: @@ -158,7 +158,7 @@ def test_releasing_restores_full_screen_margins_immediately(self) -> None: display.acquire() stream.truncate(0), stream.seek(0) display.release() - assert stream.getvalue() == "\x1b7\x1b[r\x1b8" + assert stream.getvalue() == "\x1b7\x1b[24;1H\x1b[0m\x1b[J\x1b[r\x1b8" assert not output._buffer def test_the_region_is_reset_when_the_body_raises(self) -> None: @@ -170,7 +170,7 @@ def blow_up() -> None: with pytest.raises(RuntimeError, match="boom"), TerminalDisplay(output): blow_up() - assert stream.getvalue() == "\x1b7\x1b[r\x1b8" + assert stream.getvalue() == "\x1b7\x1b[24;1H\x1b[0m\x1b[J\x1b[r\x1b8" def test_release_is_idempotent(self) -> None: """Cleanup paths call this freely; a second reset would move the cursor again.""" @@ -213,7 +213,7 @@ def test_a_fresh_size_is_read_at_every_activation(self) -> None: screen.rows = 40 stream.truncate(0), stream.seek(0) display.acquire() - assert stream.getvalue() == "\x1b7\x1b[1;39r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;39r\x1b8" assert display.geometry is not None assert display.geometry.usable_rows == 39 @@ -323,7 +323,7 @@ def test_entering_the_alternate_screen_restores_full_margins(self) -> None: display.acquire() stream.truncate(0), stream.seek(0) display.release_region_for_handoff() - assert stream.getvalue() == "\x1b7\x1b[r\x1b8" + assert stream.getvalue() == "\x1b7\x1b[24;1H\x1b[0m\x1b[J\x1b[r\x1b8" assert not display.is_reserved def test_the_lease_survives_a_handoff(self) -> None: @@ -344,7 +344,7 @@ def test_returning_measures_afresh_rather_than_restoring_the_old_snapshot(self) screen.rows = 40 stream.truncate(0), stream.seek(0) display.reacquire_region_after_handoff() - assert stream.getvalue() == "\x1b7\x1b[1;39r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;39r\x1b8" assert display.geometry is not None assert display.geometry.usable_rows == 39 @@ -536,7 +536,7 @@ def test_a_resize_during_a_handoff_is_applied_when_the_terminal_comes_back(self) stream.truncate(0), stream.seek(0) display.reacquire_region_after_handoff() - assert stream.getvalue() == "\x1b7\x1b[1;39r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;39r\x1b8" assert display.geometry is not None assert display.geometry.usable_rows == 39 @@ -597,7 +597,7 @@ def sometimes_fails(sequence: str) -> None: stream.truncate(0), stream.seek(0) assert display.acquire() assert display.lease_depth == 1 - assert stream.getvalue() == "\x1b7\x1b[1;23r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;23r\x1b8" def test_a_measurement_failure_returns_the_lease_too(self) -> None: """Measuring sits inside the rollback: an ioctl that fails must not strand a lease.""" diff --git a/tests/test_toolbar_painter.py b/tests/test_toolbar_painter.py index 8ee9fa076..8eaced32f 100644 --- a/tests/test_toolbar_painter.py +++ b/tests/test_toolbar_painter.py @@ -650,13 +650,15 @@ class TestPartialPaint: def make(self, fail_on_write: int = 2) -> tuple[ToolbarPainter, PartialWriteStream, Any]: """Build a painter over a terminal that fails part-way through one flushed batch. - Batch one is the margin install, so the default targets the first paint. + Count relative to the completed acquisition, so changes to startup flushing + cannot make a paint regression fail in margin installation instead. """ - stream = PartialWriteStream(fail_on_write=fail_on_write) + stream = PartialWriteStream(fail_on_write=-1) screen = {"rows": 24, "columns": 5} output = Vt100_Output(stream, lambda: Size(rows=screen["rows"], columns=screen["columns"])) display = ResizableDisplay(output, screen) assert display.acquire() is True + stream._fail_on_write = stream._writes + fail_on_write - 1 painter = ToolbarPainter( display=display, lock=TerminalLock(), From ec1cb98024cbc833a96c6d0d2ddf58a64c642d77 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 11 Sep 2026 17:08:18 -0400 Subject: [PATCH 34/36] Fix intermittent toolbar refresh assertion on Windows Wait for after_render and inspect the completed toolbar frame instead of signaling readiness from the content provider and searching raw output. BEFORE and AFTER share the R in column five, so a correct incremental redraw can emit AFTE while retaining the existing R on screen. Validation: 100 repeated parallel runs passed; full suite 2547 passed, 6 skipped in 3.51s. make check and make docs-test passed locally. Windows CI confirmation remains pending. --- tests/test_command_toolbar.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/test_command_toolbar.py b/tests/test_command_toolbar.py index ac4d07a63..32c852a83 100644 --- a/tests/test_command_toolbar.py +++ b/tests/test_command_toolbar.py @@ -30,11 +30,21 @@ def test_command_toolbar_refresh_and_output(toolbar_app, monkeypatch) -> None: def toolbar(): threads.append(threading.current_thread()) - if state[0] == "AFTER": - refreshed.set() return state[0] + def after_render(ui): + # Content evaluation precedes drawing. Wait for the completed frame, and + # inspect its cells: BEFORE and AFTER share the R in column five, so a + # correct incremental redraw may emit only AFTE rather than the whole word. + screen = ui.renderer._last_screen + if screen is not None: + size = ui.output.get_size() + band = "".join(screen.data_buffer[size.rows - 1][x].char for x in range(size.columns)) + if band.rstrip() == "AFTER": + refreshed.set() + app.main_session.bottom_toolbar = toolbar + app.main_session.app.after_render += after_render monkeypatch.setattr(sys, "stdout", output) original_stderr = sys.stderr with app._command_toolbar_context(): @@ -48,7 +58,6 @@ def toolbar(): assert "command output\n" in output.getvalue() assert "standard output" in output.getvalue() - assert "AFTER" in output.getvalue() assert all(thread is not threading.main_thread() and not thread.is_alive() for thread in threads) assert app.stdout is output assert sys.stdout is output From de5cf49c448f2ff0f226d58a50ef9319cf1d00f1 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 11 Sep 2026 18:10:19 -0400 Subject: [PATCH 35/36] Show reserved toolbar truncation and clip lines without wrapping --- CHANGELOG.md | 5 +++ cmd2/cmd2.py | 4 ++ cmd2/toolbar_painter.py | 65 ++++++++++++++++++++------------- docs/features/prompt.md | 18 +++++++++ tests/test_reserved_terminal.py | 30 ++++++++++++++- tests/test_toolbar_painter.py | 53 +++++++++++++++++++-------- 6 files changed, 133 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 604b32938..c26c99e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## 4.3.0 (TBD) +- Bug Fixes + - Reserved bottom toolbars now indicate clipped content with a right-edge ellipsis (`…`). Long + lines are truncated without wrapping, and newlines beyond the reserved row are indicated + instead of silently hiding content, including when the first line is empty. + - Breaking Changes - Replaced `enable_bottom_toolbar` with `bottom_toolbar_mode` in `Cmd.__init__()`. The default, `cmd2.ToolbarMode.OFF`, disables the toolbar. Use `cmd2.ToolbarMode.AUTO` where you previously diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 574347361..a4107835b 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2132,6 +2132,10 @@ def get_bottom_toolbar(self) -> AnyFormattedText: your application. This could be information like the application name, current state, or even a real-time clock. + Reserved rendering uses one row and clips each logical line without wrapping. An + ellipsis in the rightmost column indicates omitted text or lines after a newline. + Widths are measured in terminal columns; wide characters are never split. + During command execution this callback runs in a background UI thread. Protect shared state with a lock when necessary. The built-in pager shares this toolbar. It is suspended while another prompt, external pager, or interactive shell owns the terminal. diff --git a/cmd2/toolbar_painter.py b/cmd2/toolbar_painter.py index b4805315c..a54e21c22 100644 --- a/cmd2/toolbar_painter.py +++ b/cmd2/toolbar_painter.py @@ -21,6 +21,9 @@ mouse map, so a handler here would never be called; rendering the visible part is the honest subset rather than advertising support that does not exist. +**Lines are clipped with an ellipsis.** Newlines advance to the next reserved row; +horizontal overflow never wraps. Omitted columns or rows are indicated at the right edge. + **Carriage returns are dropped and tabs are expanded.** Both are cursor motion in a context where the painter owns the cursor. """ @@ -104,8 +107,9 @@ def build( Content that does not fill the frame is padded with default-styled spaces: the pad is what overwrites a longer previous frame, so it is content rather than absence of it. - Content taller than the band is truncated here -- growing the toolbar is a geometry - transition, and writing the extra rows would put them outside the reservation. + Each logical line is clipped to the terminal width without wrapping. A right-edge + ellipsis marks omitted columns, or omitted lines on the last reserved row. Growing + the toolbar is a geometry transition, never a consequence of content overflow. :param content: the formatted text to lay out :param width: the terminal width in columns @@ -120,6 +124,8 @@ def build( raise ValueError(f"a frame needs a positive height, got {height}") rows = _layout(content, width, default_style) + if len(rows) > height: + _mark_truncated(rows[height - 1], default_style) blank = tuple(Cell(" ", default_style) for _ in range(width)) while len(rows) < height: rows.append(list(blank)) @@ -129,9 +135,9 @@ def build( def measure_toolbar_height(content: "AnyFormattedText", width: int) -> int: """Measure how many rows content needs at a given width. - This is what sizes the reservation, so it counts wrapping and explicit newlines the same - way :meth:`ToolbarFrame.build` lays them out. Empty content still measures one row: an - empty toolbar is an intentional visibility change, not a request for no reservation. + Only explicit newlines add rows; horizontal overflow is clipped, as in + :meth:`ToolbarFrame.build`. Empty content still measures one row. This helper does not + change the reservation, whose height is chosen by its owner. :param content: the formatted text to measure :param width: the terminal width in columns @@ -143,22 +149,38 @@ def measure_toolbar_height(content: "AnyFormattedText", width: int) -> int: return max(1, len(_layout(content, width, ""))) +def _mark_truncated(row: list[Cell], default_style: str) -> None: + """Mark omitted content in the rightmost column without splitting a wide character. + + :param row: a padded, nonempty row to modify + :param default_style: the style for the indicator and any cleared wide-character cell + """ + if row[-1].is_continuation: + row[-2] = Cell(" ", default_style) + row[-1] = Cell("…", default_style) + + def _layout(content: "AnyFormattedText", width: int, default_style: str) -> list[list[Cell]]: - """Lay content out into as many full-width rows as it needs. + """Clip each logical line to one padded row, marking horizontal overflow. :param content: the formatted text to lay out :param width: the terminal width in columns - :param default_style: the style for padding cells + :param default_style: the style for padding and truncation indicators :return: the rows, each padded to ``width`` cells """ rows: list[list[Cell]] = [] row: list[Cell] = [] + clipped = False def finish_row() -> None: - """Pad the row in progress and start a new one.""" + """Pad and mark the row in progress, then start the next logical line.""" + nonlocal clipped row.extend(Cell(" ", default_style) for _ in range(width - len(row))) + if clipped: + _mark_truncated(row, default_style) rows.append(list(row)) row.clear() + clipped = False for fragment in to_formatted_text(content): style, text = fragment[0], fragment[1] @@ -168,24 +190,19 @@ def finish_row() -> None: if char == "\n": finish_row() continue - if char == "\r": + if char == "\r" or clipped: continue if char == "\t": spaces = TAB_WIDTH - (len(row) % TAB_WIDTH) - for _ in range(spaces): - if len(row) == width: - finish_row() - row.append(Cell(" ", style)) + available = width - len(row) + row.extend(Cell(" ", style) for _ in range(min(spaces, available))) + clipped = spaces > available continue char_width = get_cwidth(char) if char_width == 0 and row: - # A combining mark belongs to the character it follows; it occupies no column - # of its own, so it joins that cell rather than becoming one. After a wide - # character the cell to the left is that character's right half, and the mark - # belongs to the half that carries the text -- attaching it to the - # continuation cell would give the mark a column of its own and shift every - # later cell one place right of where it is on the screen. + # Combining marks still belong to a retained character at the right edge. + # Once a character is clipped, its marks must be discarded with it. base = len(row) - 1 if row[base].is_continuation: base -= 1 @@ -194,16 +211,14 @@ def finish_row() -> None: continue columns = max(1, char_width) if len(row) + columns > width: - # Padding rather than splitting: half a wide character at the right edge is - # what makes the terminal wrap the row itself, which would put toolbar cells - # in a row the frame does not own. - finish_row() + clipped = True + continue row.append(Cell(char, style)) if columns == 2: row.append(Cell("", style, is_continuation=True)) - if row: - finish_row() + # An empty string is one empty line; a trailing newline introduces another one. + finish_row() return rows diff --git a/docs/features/prompt.md b/docs/features/prompt.md index 88fa04943..9c3c3d882 100644 --- a/docs/features/prompt.md +++ b/docs/features/prompt.md @@ -96,6 +96,24 @@ def get_bottom_toolbar(self) -> AnyFormattedText: ] ``` +### Reserved Toolbar Overflow + +Reserved rendering currently reserves one terminal row. Each logical line is clipped to the terminal +width; long lines do not wrap. A newline starts another logical line, which is omitted when there is +no reserved row available. A leading newline therefore leaves an empty first line. + +Whenever content is omitted, an ellipsis (`…`) appears in the rightmost column of the affected row. +Omitted lines are indicated on the last reserved row. The indicator uses the toolbar's default +style. For example, at a width of eight columns, `abcdefgh` fits unchanged, `abcdefghi` becomes +`abcdefg…`, and `Ready\nDetails` becomes `Ready …`. A leading newline displays spaces followed by +`…`, rather than a completely blank toolbar. + +Widths are measured in terminal display columns, including wide and combining characters. Truncation +never splits a wide character; it may leave a space before the ellipsis. Tabs expand to eight-column +tab stops, and carriage returns are ignored. These rules apply to `RESERVED` and to `AUTO` when it +selects reserved rendering. `LEGACY` retains prompt-toolkit's layout. The number of reserved rows is +not yet configurable through `Cmd`. + ### Refreshing the Toolbar The toolbar is rendered by `prompt-toolkit` and is naturally redrawn whenever the prompt is diff --git a/tests/test_reserved_terminal.py b/tests/test_reserved_terminal.py index cf1440e88..a6ff6de97 100644 --- a/tests/test_reserved_terminal.py +++ b/tests/test_reserved_terminal.py @@ -72,7 +72,7 @@ def terminal_harness(request): harness.close() -def read_prompt(harness, terminal) -> None: +def read_prompt(harness, terminal, expected_toolbar="STATUS") -> None: """Use the real input reader and CPR binding, then accept a prompt with known height.""" ui = harness.app.main_session.app sent = False @@ -92,13 +92,39 @@ def ready(app): assert sent assert terminal.reports assert all(row < terminal.screen.lines for row in terminal.reports) - assert terminal.screen.display[-1].startswith("STATUS") + assert terminal.screen.display[-1].startswith(expected_toolbar) finally: watchdog.cancel() watchdog.join() ui.after_render -= ready +@pytest.mark.parametrize( + ("content", "expected"), + [ + ("STATUS\nsecond line", "STATUS" + " " * 73 + "…"), + ("\nSTATUS", " " * 79 + "…"), + ("S" * 81, "S" * 79 + "…"), + ("S" * 78 + "广x", "S" * 78 + " …"), + ], +) +def test_clipped_toolbar_survives_commands_and_prompt_refresh(terminal_harness, content, expected) -> None: + harness, terminal = terminal_harness + harness.app.main_session.bottom_toolbar = content + with harness.app._reserved_toolbar_context(): + assert terminal.screen.display[-1] == expected + with harness.app._command_toolbar_context(): + harness.app.poutput("ordinary output") + assert terminal.screen.display[-1] == expected + read_prompt(harness, terminal, expected_toolbar=expected) + # A shorter dynamic replacement must remove the old text and indicator. + harness.app.main_session.bottom_toolbar = "OK" + assert harness.app.reserved_toolbar.refresh() + assert terminal.screen.display[-1] == "OK" + " " * 78 + assert terminal.screen.margins is None + assert terminal.screen.display[-1].strip() == "" + + @pytest.mark.parametrize("row", [1, 22, 23, 24]) @pytest.mark.parametrize("reserved", [1, 2]) def test_acquisition_keeps_existing_output_and_cursor_above_the_band(terminal_harness, row, reserved) -> None: diff --git a/tests/test_toolbar_painter.py b/tests/test_toolbar_painter.py index 8eaced32f..bcc1939ef 100644 --- a/tests/test_toolbar_painter.py +++ b/tests/test_toolbar_painter.py @@ -34,6 +34,29 @@ def styles_of(frame: ToolbarFrame, row: int = 0) -> list[str]: class TestShape: + @pytest.mark.parametrize( + ("content", "width", "height", "expected"), + [ + ("\nSTATUS", 8, 1, [" …"]), + ("STATUS\nnext", 8, 1, ["STATUS …"]), + ("abcd", 4, 1, ["abcd"]), + ("abcde", 4, 1, ["abc…"]), + ("abcdef\nxy", 4, 2, ["abc…", "xy "]), + ("a\nb\nc\nd", 4, 3, ["a ", "b ", "c …"]), + ("abc\n", 4, 1, ["abc…"]), + ("广", 1, 1, ["…"]), + ("a广x", 3, 1, ["a …"]), + ("e\u0301abc", 3, 1, ["e\u0301a…"]), + ("abx\u0301", 3, 1, ["abx\u0301"]), + ("abcde\u0301\nz", 4, 2, ["abc…", "z "]), + ("\tX\ny", 4, 2, [" …", "y "]), + ], + ) + def test_truncation_is_visible_and_does_not_wrap(self, content, width, height, expected) -> None: + frame = ToolbarFrame.build(content, width=width, height=height) + assert [text_of(frame, row) for row in range(height)] == expected + assert all(len(row) == width for row in frame.rows) + def test_a_frame_is_always_exactly_its_declared_size(self) -> None: frame = ToolbarFrame.build("hi", width=10, height=2) assert len(frame.rows) == 2 @@ -45,16 +68,16 @@ def test_short_content_is_padded_with_default_style_spaces(self) -> None: assert text_of(frame) == "hi " assert styles_of(frame) == ["class:toolbar"] * 5 - def test_content_wider_than_the_terminal_wraps(self) -> None: + def test_content_wider_than_the_terminal_is_clipped(self) -> None: frame = ToolbarFrame.build("abcdef", width=3, height=2) - assert text_of(frame, 0) == "abc" - assert text_of(frame, 1) == "def" + assert text_of(frame, 0) == "ab…" + assert text_of(frame, 1) == " " def test_content_taller_than_the_band_is_truncated(self) -> None: """Growing past the band is a geometry transition, never a write outside it.""" frame = ToolbarFrame.build("one\ntwo\nthree", width=10, height=2) assert text_of(frame, 0) == "one " - assert text_of(frame, 1) == "two " + assert text_of(frame, 1) == "two …" def test_an_explicit_newline_starts_a_row(self) -> None: frame = ToolbarFrame.build("a\nb", width=3, height=2) @@ -105,10 +128,10 @@ def test_a_wide_character_occupies_two_cells(self) -> None: def test_a_wide_character_is_never_split_at_the_right_edge(self) -> None: """Half a wide character at the edge is what wraps a row into the one below it.""" frame = ToolbarFrame.build("a广", width=2, height=2) - assert text_of(frame, 0) == "a " - assert frame.rows[1][0].char == "广" + assert text_of(frame, 0) == "a…" + assert text_of(frame, 1) == " " - def test_the_pad_before_a_wrapped_wide_character_uses_the_default_style(self) -> None: + def test_the_truncation_indicator_uses_the_default_style(self) -> None: frame = ToolbarFrame.build([("bold", "a广")], width=2, height=2, default_style="base") assert styles_of(frame, 0) == ["bold", "base"] @@ -137,8 +160,8 @@ class TestMeasurement: def test_a_short_toolbar_is_one_row(self) -> None: assert measure_toolbar_height("hi", width=10) == 1 - def test_wrapping_is_counted(self) -> None: - assert measure_toolbar_height("abcdef", width=3) == 2 + def test_horizontal_overflow_does_not_add_rows(self) -> None: + assert measure_toolbar_height("abcdef", width=3) == 1 def test_newlines_are_counted(self) -> None: assert measure_toolbar_height("a\nb\nc", width=10) == 3 @@ -148,10 +171,10 @@ def test_empty_content_still_measures_one_row(self) -> None: assert measure_toolbar_height("", width=10) == 1 def test_measurement_matches_the_frame_it_would_build(self) -> None: - content = [("bold", "wide 广 content that wraps around")] + content = [("bold", "wide 广 content that is clipped\nlast row")] height = measure_toolbar_height(content, width=12) frame = ToolbarFrame.build(content, width=12, height=height) - # Nothing was truncated: the last row is where the content ended. + # Horizontal clipping does not move content onto the last logical line. assert measure_toolbar_height(content, width=12) == len(frame.rows) assert text_of(frame, height - 1).strip() != "" @@ -181,12 +204,12 @@ def test_a_frame_reports_its_own_size(self) -> None: def test_an_empty_frame_reports_zero_width(self) -> None: assert ToolbarFrame(rows=()).width == 0 - def test_a_tab_wraps_when_it_reaches_the_edge(self) -> None: - """The expansion is cells, so it wraps like any other run of them.""" + def test_a_tab_is_clipped_when_it_reaches_the_edge(self) -> None: + """Tab expansion cannot spill into the next reserved row.""" frame = ToolbarFrame.build("a\tb", width=4, height=3) - assert text_of(frame, 0) == "a " + assert text_of(frame, 0) == "a …" assert text_of(frame, 1) == " " - assert text_of(frame, 2) == "b " + assert text_of(frame, 2) == " " def test_a_combining_mark_after_a_wide_character_joins_that_character(self) -> None: """Attaching it to the continuation cell instead shifts every later column.""" From 2184b71f0ce25e8a717f081e40f157c8454c9e24 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 11 Sep 2026 18:54:31 -0400 Subject: [PATCH 36/36] Mark reserved toolbar truncation only for visible content, and show control characters Only content the user would have seen earns the ellipsis. Trailing spaces, a trailing tab and a trailing newline overflow by nothing visible, so a status line padded to the terminal width or ending in a newline no longer loses its last character to an indicator announcing that nothing was lost. Control characters are shown in the caret notation the renderer uses instead of being written raw into the band, where an escape sequence in a plain string was executed by the terminal. A leading combining character is drawn on a space so that it occupies the one column the terminal gives it, rather than a cell of its own that shifted every later column of the diff baseline. The truncation indicator is a module constant, since U+2026 is ambiguous-width and a terminal that draws it two columns wide needs a substitute. Layout now uses prompt-toolkit's split_lines, stops scanning a line once visible content is lost, and shares one pad cell per frame: a short row lays out in 5 us instead of 67, and a 20 KB line in 68 us instead of 425. The unused height helper is removed with its tests, and the geometry comment that claimed toolbar height depends on width now describes what is true. Validation: 2579 passed, 6 skipped with coverage; toolbar_painter.py at 100% line coverage; eight mutations killed; make check, make test, make docs-test and git diff --check passed. --- CHANGELOG.md | 5 +- cmd2/terminal_display.py | 2 +- cmd2/toolbar_painter.py | 213 ++++++++++++++++++++++------------ docs/features/prompt.md | 19 +-- tests/test_toolbar_painter.py | 122 +++++++++++++------ 5 files changed, 239 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c26c99e89..2710dcbbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ - Bug Fixes - Reserved bottom toolbars now indicate clipped content with a right-edge ellipsis (`…`). Long lines are truncated without wrapping, and newlines beyond the reserved row are indicated - instead of silently hiding content, including when the first line is empty. + instead of silently hiding content, including when the first line is empty. Trailing + whitespace and a trailing newline are not marked, since nothing visible is lost. + - Reserved bottom toolbars display control characters in caret notation, as legacy rendering + does, instead of sending them to the terminal. - Breaking Changes - Replaced `enable_bottom_toolbar` with `bottom_toolbar_mode` in `Cmd.__init__()`. The default, diff --git a/cmd2/terminal_display.py b/cmd2/terminal_display.py index b002c364f..c83a5eee3 100644 --- a/cmd2/terminal_display.py +++ b/cmd2/terminal_display.py @@ -92,7 +92,7 @@ class Geometry: #: True viewport height, read from the unwrapped backend. physical_rows: int - #: Terminal width. Toolbar height is measured against this, so a width change is a new + #: Terminal width. The band is laid out against this, so a width change is a new #: generation even when the height is unchanged. columns: int diff --git a/cmd2/toolbar_painter.py b/cmd2/toolbar_painter.py index a54e21c22..79310360c 100644 --- a/cmd2/toolbar_painter.py +++ b/cmd2/toolbar_painter.py @@ -22,10 +22,16 @@ subset rather than advertising support that does not exist. **Lines are clipped with an ellipsis.** Newlines advance to the next reserved row; -horizontal overflow never wraps. Omitted columns or rows are indicated at the right edge. +horizontal overflow never wraps. Omitted columns or rows are indicated at the right edge, +but only when something visible was omitted: trailing whitespace and a trailing newline +overflow by nothing the user could have seen. **Carriage returns are dropped and tabs are expanded.** Both are cursor motion in a context where the painter owns the cursor. + +**Control characters are shown, not sent.** An escape becomes ``^[`` and a C1 control +becomes ``<85>``, exactly as the renderer shows them, so a stray sequence in a plain string +is displayed inside the band rather than executed there. """ from contextlib import suppress @@ -33,6 +39,8 @@ from typing import TYPE_CHECKING from prompt_toolkit.formatted_text import to_formatted_text +from prompt_toolkit.formatted_text.utils import split_lines +from prompt_toolkit.layout.screen import Char from prompt_toolkit.output import ColorDepth from prompt_toolkit.utils import get_cwidth @@ -42,7 +50,7 @@ if TYPE_CHECKING: # pragma: no cover from collections.abc import Callable, Mapping - from prompt_toolkit.formatted_text import AnyFormattedText + from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples from prompt_toolkit.styles import Attrs, BaseStyle from .terminal_display import TerminalDisplay @@ -52,9 +60,24 @@ #: does not model. TAB_WIDTH = 8 +#: What marks omitted content in the rightmost column of a row. U+2026 is East-Asian-ambiguous +#: width: prompt-toolkit measures it as one column, and so does every terminal cmd2 is +#: qualified on, but a terminal configured to draw ambiguous characters two columns wide would +#: draw this one over the edge. There is no portable way to detect that configuration, so the +#: glyph is a constant an application can replace rather than a value the painter guesses. +TRUNCATION_INDICATOR = "…" + #: Fragments whose style contains this carry raw terminal control rather than text. _ZERO_WIDTH_ESCAPE = "[ZeroWidthEscape]" +#: The style class the renderer gives a control character's caret notation, so that a theme +#: styling ``^[`` in a prompt styles it the same way in the band. +_CONTROL_STYLE = "class:control-character" + +#: Characters that occupy a column without showing anything. A tab is expanded to these and a +#: carriage return is dropped, so past the right edge all three are the same: nothing lost. +_BLANK = " \t\r" + @dataclass(frozen=True) class Cell: @@ -108,13 +131,16 @@ def build( Content that does not fill the frame is padded with default-styled spaces: the pad is what overwrites a longer previous frame, so it is content rather than absence of it. Each logical line is clipped to the terminal width without wrapping. A right-edge - ellipsis marks omitted columns, or omitted lines on the last reserved row. Growing - the toolbar is a geometry transition, never a consequence of content overflow. + ellipsis marks omitted columns, or omitted lines on the last reserved row -- but only + when what was omitted would have been visible. Trailing spaces, a trailing tab and a + trailing newline overflow by nothing anyone can see, and marking them would replace a + real character to announce that nothing was lost. Growing the toolbar is a geometry + transition, never a consequence of content overflow. :param content: the formatted text to lay out :param width: the terminal width in columns :param height: the height of the reserved band in rows - :param default_style: the style for padding cells + :param default_style: the style for padding and truncation-indicator cells :return: the frame :raises ValueError: if ``width`` or ``height`` is not positive """ @@ -123,30 +149,22 @@ def build( if height < 1: raise ValueError(f"a frame needs a positive height, got {height}") - rows = _layout(content, width, default_style) - if len(rows) > height: - _mark_truncated(rows[height - 1], default_style) - blank = tuple(Cell(" ", default_style) for _ in range(width)) + # One pad cell shared by every blank column. Cells are immutable and compared by + # value, and this runs on every refresh, where constructing one per column was most + # of the cost of laying out a short toolbar. + pad = Cell(" ", default_style) + lines = list(split_lines(to_formatted_text(content))) + rows: list[list[Cell]] = [] + for line in lines[:height]: + row, clipped = _clip_line(line, width, pad) + if clipped: + _mark_truncated(row, default_style) + rows.append(row) + if any(_has_visible_text(line) for line in lines[height:]): + _mark_truncated(rows[-1], default_style) while len(rows) < height: - rows.append(list(blank)) - return cls(rows=tuple(tuple(row) for row in rows[:height])) - - -def measure_toolbar_height(content: "AnyFormattedText", width: int) -> int: - """Measure how many rows content needs at a given width. - - Only explicit newlines add rows; horizontal overflow is clipped, as in - :meth:`ToolbarFrame.build`. Empty content still measures one row. This helper does not - change the reservation, whose height is chosen by its owner. - - :param content: the formatted text to measure - :param width: the terminal width in columns - :return: the number of rows required, at least one - :raises ValueError: if ``width`` is not positive - """ - if width < 1: - raise ValueError(f"measuring needs a positive width, got {width}") - return max(1, len(_layout(content, width, ""))) + rows.append([pad] * width) + return cls(rows=tuple(tuple(row) for row in rows)) def _mark_truncated(row: list[Cell], default_style: str) -> None: @@ -157,69 +175,110 @@ def _mark_truncated(row: list[Cell], default_style: str) -> None: """ if row[-1].is_continuation: row[-2] = Cell(" ", default_style) - row[-1] = Cell("…", default_style) + row[-1] = Cell(TRUNCATION_INDICATOR, default_style) + + +def _displayed(char: str, style: str) -> tuple[str, str]: + """Decide what the terminal is shown for one character of content. + Control characters are shown in the caret notation the renderer uses -- ``^[`` for an + escape, ``<85>`` for a C1 control -- rather than written to the terminal, where an escape + would be executed inside the band. A C1 control is a sequence introducer on many + terminals, so it is as dangerous as ``ESC`` and mapped the same way. + + :param char: the character from the content + :param style: the fragment's style + :return: the text to draw and the style to draw it in + """ + mapped = Char.display_mappings.get(char) + if mapped is None: + return char, style + return mapped, f"{style} {_CONTROL_STYLE}".strip() -def _layout(content: "AnyFormattedText", width: int, default_style: str) -> list[list[Cell]]: - """Clip each logical line to one padded row, marking horizontal overflow. - :param content: the formatted text to lay out +def _has_visible_text(line: "StyleAndTextTuples") -> bool: + """Decide whether a logical line would show anything at all. + + :param line: the line's fragments + :return: whether any character occupies a column with something in it + """ + for fragment in line: + style, text = fragment[0], fragment[1] + if _ZERO_WIDTH_ESCAPE in style: + continue + for char in text: + if char in _BLANK: + continue + shown, _ = _displayed(char, style) + if any(piece not in _BLANK and get_cwidth(piece) > 0 for piece in shown): + return True + return False + + +def _clip_line(line: "StyleAndTextTuples", width: int, pad: Cell) -> tuple[list[Cell], bool]: + """Lay one logical line out as a padded row, stopping at the first visible character lost. + + :param line: the line's fragments :param width: the terminal width in columns - :param default_style: the style for padding and truncation indicators - :return: the rows, each padded to ``width`` cells + :param pad: the cell that fills columns the content does not reach + :return: the row, and whether visible content was clipped from it """ - rows: list[list[Cell]] = [] row: list[Cell] = [] - clipped = False - - def finish_row() -> None: - """Pad and mark the row in progress, then start the next logical line.""" - nonlocal clipped - row.extend(Cell(" ", default_style) for _ in range(width - len(row))) - if clipped: - _mark_truncated(row, default_style) - rows.append(list(row)) - row.clear() - clipped = False - - for fragment in to_formatted_text(content): + # Whether a character has been dropped past the right edge. A combining mark that + # follows one belongs to it, not to whatever cell happens to be last. + dropped = False + + for fragment in line: style, text = fragment[0], fragment[1] if _ZERO_WIDTH_ESCAPE in style: continue for char in text: - if char == "\n": - finish_row() - continue - if char == "\r" or clipped: + if char == "\r": continue if char == "\t": - spaces = TAB_WIDTH - (len(row) % TAB_WIDTH) available = width - len(row) - row.extend(Cell(" ", style) for _ in range(min(spaces, available))) - clipped = spaces > available - continue - - char_width = get_cwidth(char) - if char_width == 0 and row: - # Combining marks still belong to a retained character at the right edge. - # Once a character is clipped, its marks must be discarded with it. - base = len(row) - 1 - if row[base].is_continuation: - base -= 1 - previous = row[base] - row[base] = Cell(previous.char + char, previous.style, previous.is_continuation) - continue - columns = max(1, char_width) - if len(row) + columns > width: - clipped = True + if available <= 0: + dropped = True + continue + spaces = TAB_WIDTH - (len(row) % TAB_WIDTH) + row.extend([Cell(" ", style)] * min(spaces, available)) continue - row.append(Cell(char, style)) - if columns == 2: - row.append(Cell("", style, is_continuation=True)) - # An empty string is one empty line; a trailing newline introduces another one. - finish_row() - return rows + shown, cell_style = _displayed(char, style) + for piece in shown: + piece_width = get_cwidth(piece) + if piece_width == 0: + if dropped: + # Its base is gone; attaching it to the last retained cell would + # decorate a character it was never part of. + continue + if row: + base = len(row) - 1 + if row[base].is_continuation: + base -= 1 + previous = row[base] + row[base] = Cell(previous.char + piece, previous.style, previous.is_continuation) + else: + # Nothing to combine with. Drawn on a space so that it occupies the + # one column the terminal gives it; a cell of its own would be + # modelled at a column the terminal never advances past. + row.append(Cell(" " + piece, cell_style)) + continue + columns = max(1, piece_width) + if len(row) + columns > width: + dropped = True + if piece in _BLANK: + continue + # Visible content is lost from here on, and nothing after it can be + # shown, so there is no reason to look at the rest of the line. + row.extend([pad] * (width - len(row))) + return row, True + row.append(Cell(piece, cell_style)) + if columns == 2: + row.append(Cell("", cell_style, is_continuation=True)) + + row.extend([pad] * (width - len(row))) + return row, False @dataclass(frozen=True) @@ -282,7 +341,7 @@ def __init__( :param lock: the terminal transaction lock shared by all cmd2-controlled output :param style: the style rules used to resolve fragment styles :param color_depth: the color depth to render attributes at - :param default_style: the style for padding cells + :param default_style: the style for padding and truncation-indicator cells :param autowrap_after_paint: the committed autowrap policy to restore afterwards. Upstream's renderer leaves autowrap enabled between frames, which is the default here; a bridge that has committed a different policy passes it instead. diff --git a/docs/features/prompt.md b/docs/features/prompt.md index 9c3c3d882..7f5fe6326 100644 --- a/docs/features/prompt.md +++ b/docs/features/prompt.md @@ -102,17 +102,20 @@ Reserved rendering currently reserves one terminal row. Each logical line is cli width; long lines do not wrap. A newline starts another logical line, which is omitted when there is no reserved row available. A leading newline therefore leaves an empty first line. -Whenever content is omitted, an ellipsis (`…`) appears in the rightmost column of the affected row. -Omitted lines are indicated on the last reserved row. The indicator uses the toolbar's default -style. For example, at a width of eight columns, `abcdefgh` fits unchanged, `abcdefghi` becomes -`abcdefg…`, and `Ready\nDetails` becomes `Ready …`. A leading newline displays spaces followed by -`…`, rather than a completely blank toolbar. +Whenever visible content is omitted, an ellipsis (`…`) appears in the rightmost column of the +affected row. Omitted lines are indicated on the last reserved row. The indicator uses the toolbar's +default style. For example, at a width of eight columns, `abcdefgh` fits unchanged, `abcdefghi` +becomes `abcdefg…`, and `Ready\nDetails` becomes `Ready …`. A leading newline displays spaces +followed by `…`, rather than a completely blank toolbar. Overflow that nobody could have seen is not +marked: trailing spaces, a trailing tab, and a trailing newline are dropped silently, so text padded +to the terminal width or ending in a newline shows no indicator. Widths are measured in terminal display columns, including wide and combining characters. Truncation never splits a wide character; it may leave a space before the ellipsis. Tabs expand to eight-column -tab stops, and carriage returns are ignored. These rules apply to `RESERVED` and to `AUTO` when it -selects reserved rendering. `LEGACY` retains prompt-toolkit's layout. The number of reserved rows is -not yet configurable through `Cmd`. +tab stops, and carriage returns are ignored. Control characters are displayed in caret notation, as +`LEGACY` rendering displays them, rather than sent to the terminal: an escape character appears as +`^[`. These rules apply to `RESERVED` and to `AUTO` when it selects reserved rendering. `LEGACY` +retains prompt-toolkit's layout. The number of reserved rows is not yet configurable through `Cmd`. ### Refreshing the Toolbar diff --git a/tests/test_toolbar_painter.py b/tests/test_toolbar_painter.py index bcc1939ef..603327c81 100644 --- a/tests/test_toolbar_painter.py +++ b/tests/test_toolbar_painter.py @@ -18,9 +18,10 @@ from prompt_toolkit.output.vt100 import Vt100_Output from prompt_toolkit.styles import BaseStyle, DummyStyle, DynamicStyle, Style +from cmd2 import toolbar_painter from cmd2.terminal_display import TerminalDisplay from cmd2.terminal_transaction import TerminalLock, current_transaction, held_higher_level_locks -from cmd2.toolbar_painter import Cell, ToolbarFrame, ToolbarPainter, measure_toolbar_height +from cmd2.toolbar_painter import Cell, ToolbarFrame, ToolbarPainter def text_of(frame: ToolbarFrame, row: int = 0) -> str: @@ -43,13 +44,14 @@ class TestShape: ("abcde", 4, 1, ["abc…"]), ("abcdef\nxy", 4, 2, ["abc…", "xy "]), ("a\nb\nc\nd", 4, 3, ["a ", "b ", "c …"]), - ("abc\n", 4, 1, ["abc…"]), + ("abc\n\nx", 4, 1, ["abc…"]), ("广", 1, 1, ["…"]), ("a广x", 3, 1, ["a …"]), ("e\u0301abc", 3, 1, ["e\u0301a…"]), ("abx\u0301", 3, 1, ["abx\u0301"]), ("abcde\u0301\nz", 4, 2, ["abc…", "z "]), ("\tX\ny", 4, 2, [" …", "y "]), + ("abcdefgh x", 8, 1, ["abcdefg…"]), ], ) def test_truncation_is_visible_and_does_not_wrap(self, content, width, height, expected) -> None: @@ -57,6 +59,44 @@ def test_truncation_is_visible_and_does_not_wrap(self, content, width, height, e assert [text_of(frame, row) for row in range(height)] == expected assert all(len(row) == width for row in frame.rows) + @pytest.mark.parametrize( + ("content", "width", "height", "expected"), + [ + ("abcdefgh\t", 8, 1, ["abcdefgh"]), + ("abcdefgh ", 8, 1, ["abcdefgh"]), + ("abc".ljust(12), 8, 1, ["abc "]), + ("status".center(10), 8, 1, [" status"]), + ("abcdefgh \u0301", 8, 1, ["abcdefgh"]), + ("Ready\n", 8, 1, ["Ready "]), + ("abc\n", 4, 1, ["abc "]), + ("abc\n ", 4, 1, ["abc "]), + ("abc\n\t\n", 4, 1, ["abc "]), + ("abc\n\u200b", 4, 1, ["abc "]), + ], + ) + def test_omitted_whitespace_is_not_truncation(self, content, width, height, expected) -> None: + """Only content the user would have seen earns the indicator. + + Padding to the column count with ``ljust``, a trailing tab, and the newline that ends + ``Console.export_text()`` all overflow by cells nobody can see. Marking those replaces + a real character with an ellipsis to announce that nothing was lost. + """ + frame = ToolbarFrame.build(content, width=width, height=height) + assert [text_of(frame, row) for row in range(height)] == expected + + def test_an_omitted_line_holding_only_escape_fragments_is_not_truncation(self) -> None: + """Escape fragments are never drawn, so a line made of them omits nothing visible.""" + content = [("", "abc\n"), ("[ZeroWidthEscape]", "\x1b[6n")] + frame = ToolbarFrame.build(content, width=4, height=1) + assert text_of(frame) == "abc " + + def test_a_combining_mark_after_a_dropped_character_is_dropped_with_it(self) -> None: + """Its base is gone, so attaching it to the last retained cell would mark the wrong one.""" + frame = ToolbarFrame.build("abcdefghi\u0301", width=8, height=1) + assert text_of(frame) == "abcdefg…" + frame = ToolbarFrame.build("abcdefgh \u0301", width=8, height=1) + assert frame.rows[0][7].char == "h" + def test_a_frame_is_always_exactly_its_declared_size(self) -> None: frame = ToolbarFrame.build("hi", width=10, height=2) assert len(frame.rows) == 2 @@ -126,7 +166,7 @@ def test_a_wide_character_occupies_two_cells(self) -> None: assert text_of(frame) == "广 " def test_a_wide_character_is_never_split_at_the_right_edge(self) -> None: - """Half a wide character at the edge is what wraps a row into the one below it.""" + """A wide character that does not fit is clipped whole and marked; nothing wraps.""" frame = ToolbarFrame.build("a广", width=2, height=2) assert text_of(frame, 0) == "a…" assert text_of(frame, 1) == " " @@ -135,16 +175,47 @@ def test_the_truncation_indicator_uses_the_default_style(self) -> None: frame = ToolbarFrame.build([("bold", "a广")], width=2, height=2, default_style="base") assert styles_of(frame, 0) == ["bold", "base"] + def test_the_truncation_indicator_is_a_module_constant(self, monkeypatch) -> None: + """U+2026 is ambiguous-width; a terminal that draws it two columns wide needs a substitute.""" + monkeypatch.setattr(toolbar_painter, "TRUNCATION_INDICATOR", "~") + frame = ToolbarFrame.build("abcde", width=4, height=1) + assert text_of(frame) == "abc~" + def test_a_combining_character_joins_the_cell_before_it(self) -> None: frame = ToolbarFrame.build("e\u0301x", width=4, height=1) assert frame.rows[0][0].char == "e\u0301" assert frame.rows[0][1].char == "x" - def test_a_leading_combining_character_gets_its_own_cell(self) -> None: - """There is nothing to combine with; dropping it would silently lose content.""" - frame = ToolbarFrame.build("\u0301a", width=4, height=1) - assert frame.rows[0][0].char == "\u0301" - assert frame.rows[0][1].char == "a" + def test_a_leading_combining_character_is_drawn_on_a_space(self) -> None: + """There is nothing to combine with, and a cell drawn in zero columns would put every + later cell one column right of where the terminal shows it. + """ + frame = ToolbarFrame.build("\u0301xy", width=3, height=1) + assert [cell.char for cell in frame.rows[0]] == [" \u0301", "x", "y"] + assert all(cell.width == 1 for cell in frame.rows[0]) + + @pytest.mark.parametrize( + ("content", "expected"), + [ + ("a\x1b[2Jb", "a^[[2Jb "), + ("a\x07b", "a^Gb "), + ("a\x85b", "a<85>b "), + ("a\x7fb", "a^?b "), + ("a\x00b", "a^@b "), + ], + ) + def test_control_characters_are_shown_in_caret_notation(self, content, expected) -> None: + """Raw control from a plain string would otherwise be executed inside the band. + + The renderer shows ``^[`` for an escape; the painter shows the same thing, so the two + modes agree and a stray sequence in a callback cannot clear the screen. + """ + frame = ToolbarFrame.build(content, width=8, height=1) + assert text_of(frame) == expected + + def test_control_characters_carry_the_renderer_s_style_class(self) -> None: + frame = ToolbarFrame.build([("bold", "a\x1bb")], width=4, height=1) + assert styles_of(frame) == ["bold", "bold class:control-character", "bold class:control-character", "bold"] def test_a_tab_advances_to_the_next_tab_stop(self) -> None: frame = ToolbarFrame.build("a\tb", width=12, height=1) @@ -156,29 +227,6 @@ def test_a_carriage_return_does_not_reach_the_terminal(self) -> None: assert text_of(frame) == "ab " -class TestMeasurement: - def test_a_short_toolbar_is_one_row(self) -> None: - assert measure_toolbar_height("hi", width=10) == 1 - - def test_horizontal_overflow_does_not_add_rows(self) -> None: - assert measure_toolbar_height("abcdef", width=3) == 1 - - def test_newlines_are_counted(self) -> None: - assert measure_toolbar_height("a\nb\nc", width=10) == 3 - - def test_empty_content_still_measures_one_row(self) -> None: - """An empty toolbar is an intentional visibility change, not a zero-row reservation.""" - assert measure_toolbar_height("", width=10) == 1 - - def test_measurement_matches_the_frame_it_would_build(self) -> None: - content = [("bold", "wide 广 content that is clipped\nlast row")] - height = measure_toolbar_height(content, width=12) - frame = ToolbarFrame.build(content, width=12, height=height) - # Horizontal clipping does not move content onto the last logical line. - assert measure_toolbar_height(content, width=12) == len(frame.rows) - assert text_of(frame, height - 1).strip() != "" - - class TestValidation: def test_a_frame_needs_a_positive_width(self) -> None: with pytest.raises(ValueError, match="width"): @@ -193,10 +241,6 @@ def test_a_cell_reports_its_display_width(self) -> None: assert Cell("广", "").width == 2 assert Cell("", "", is_continuation=True).width == 0 - def test_measuring_needs_a_positive_width(self) -> None: - with pytest.raises(ValueError, match="width"): - measure_toolbar_height("hi", width=0) - def test_a_frame_reports_its_own_size(self) -> None: frame = ToolbarFrame.build("hi", width=6, height=2) assert (frame.height, frame.width) == (2, 6) @@ -339,6 +383,14 @@ def test_nothing_is_cleared_before_painting(self) -> None: for erase in ("\x1b[K", "\x1b[0K", "\x1b[2K", "\x1b[J", "\x1b[M"): assert erase not in written + def test_a_control_character_never_reaches_the_terminal_raw(self) -> None: + """The frame test proves the layout; this proves the wire, which is what matters.""" + harness = Harness(columns=12) + harness.paint("a\x1b[2Jb") + written = harness.written() + assert "\x1b[2J" not in written + assert "a^[[2Jb" in harness.visible() + def test_a_shorter_frame_pads_its_tail_rather_than_erasing_it(self) -> None: harness = Harness() harness.paint("abcd")