Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
9db7236
Stage 3: choose between reserved and legacy toolbar rendering
tleonhardt Sep 10, 2026
4d9cb58
Stage 3: own the reservation and its bindings for one command loop
tleonhardt Sep 10, 2026
3025372
Stage 3: hide the native toolbar window and paint the band instead
tleonhardt Sep 10, 2026
5210e85
Stage 3: choose the mode and own the toolbar for the command loop
tleonhardt Sep 10, 2026
c253de5
Stage 3 review: roll back a failed startup, and restore only what is …
tleonhardt Sep 10, 2026
d1ef338
Stage 3 review: put the terminal back after a half-written paint
tleonhardt Sep 10, 2026
411b124
Stage 3 review: only restore a cursor this paint actually saved
tleonhardt Sep 10, 2026
4071b6c
Stage 3: treat the cursor as unknown after a failed paint, and fall b…
tleonhardt Sep 10, 2026
c3a8c34
Stage 3: write command output through the terminal transaction
tleonhardt Sep 10, 2026
db9b04d
Stage 3: install the serializer for commands running under a reservation
tleonhardt Sep 10, 2026
dd9ec8c
Stage 3 review: invalidate after a failed write, and decide the desti…
tleonhardt Sep 10, 2026
7c4a664
Stage 3: route the application's own renders through the bridge
tleonhardt Sep 10, 2026
5c08606
Stage 3 review: release before falling back, and invalidate on the fa…
tleonhardt Sep 10, 2026
b01eeab
Stage 3 review: route the real abandonment through the owner, and mar…
tleonhardt Sep 10, 2026
ea8aaa9
Stage 3: separate pausing the display from giving the terminal away
tleonhardt Sep 10, 2026
6059956
Stage 3 review: stop the display in serialized mode, and forget what …
tleonhardt Sep 10, 2026
991f9a9
Stage 3 review: only the outermost suspension takes the rows back
tleonhardt Sep 10, 2026
bc3adad
Stage 3: route cursor reports through the bridge, and paint on commit…
tleonhardt Sep 10, 2026
471095d
Stage 3 review: keep the main prompt inside the reservation, and gate…
tleonhardt Sep 10, 2026
5fac73e
Stage 3 review: bound the display's teardown, not just the wait for it
tleonhardt Sep 10, 2026
aaccfcb
Stage 3 review: a pause that timed out relinquished nothing, and now …
tleonhardt Sep 10, 2026
aab9e23
Stage 3 review: refuse the terminal while a display that would not st…
tleonhardt Sep 10, 2026
c9a5abe
Stage 3 review: finish the deferred teardown before letting go of the…
tleonhardt Sep 10, 2026
21a0041
Stage 3: cover the command loop's own lifetime
tleonhardt Sep 10, 2026
8e70d3c
Fix two test-only failures CI found and this machine could not
tleonhardt Sep 10, 2026
8f745a2
Stage 3 review: an unbound bridge passes calls through instead of bre…
tleonhardt Sep 10, 2026
1a6a556
Stage 3 review: clear passes through when unbound, like the rest
tleonhardt Sep 10, 2026
290e9bc
Make the toolbar modes a StrEnum instead of loose strings
tleonhardt Sep 11, 2026
d36e36b
Fix the docs build the toolbar mode page broke
tleonhardt Sep 11, 2026
1c934de
Merge reserved_row_toolbar into stage3-lifecycle-integration
tleonhardt Sep 11, 2026
61d2969
Consolidate toolbar enablement into ToolbarMode and speed up lifecycl…
tleonhardt Sep 11, 2026
cb61617
Speed up toolbar tests with explicit synchronization
tleonhardt Sep 11, 2026
2621908
Reduce pager and subprocess test overhead
tleonhardt Sep 11, 2026
a734941
Fix reserved toolbar corruption at the terminal bottom
tleonhardt Sep 11, 2026
ec1cb98
Fix intermittent toolbar refresh assertion on Windows
tleonhardt Sep 11, 2026
de5cf49
Show reserved toolbar truncation and clip lines without wrapping
tleonhardt Sep 11, 2026
2184b71
Mark reserved toolbar truncation only for visible content, and show c…
tleonhardt Sep 11, 2026
5f640c1
Merge branch 'reserved_row_toolbar' into stage3-lifecycle-integration
tleonhardt Sep 12, 2026
08c7355
Establish the prompt origin natively on Windows, and fall back instea…
tleonhardt Sep 12, 2026
f3e8009
Give the corrupt-history tests their own temp files
tleonhardt Sep 12, 2026
61e7fd9
Fix reserved toolbar resize and partial-output redraws
tleonhardt Sep 12, 2026
ef04db1
Render the built-in pager in reserved toolbar mode
tleonhardt Sep 12, 2026
32f48d2
Fix partial-output loss at shutdown and two resize lifecycle defects
tleonhardt Sep 12, 2026
974290b
Clear suppression when inactive and preserve output across resize
tleonhardt Sep 12, 2026
98e62d5
Move displaced output out of the band when a resize shrinks onto it
tleonhardt Sep 12, 2026
756dbbb
Fixed a few edge-case bugs
tleonhardt Sep 12, 2026
1fe8bc1
Address five review findings on the edge-case fixes
tleonhardt Sep 12, 2026
33647c9
Keep the fallback layout when the pager closes
tleonhardt Sep 12, 2026
690c38e
Harden the pager's exit and the fallback under an open pager
tleonhardt Sep 12, 2026
e1b04c3
Fix the reserved-to-legacy fallback during paging on a console-less t…
tleonhardt Sep 12, 2026
bc69f36
Restore command display state after pager exit failures
tleonhardt Sep 12, 2026
e219606
Wait for completed resize redraws in terminal tests
tleonhardt Sep 12, 2026
0c8c09f
Preserve Windows VT processing for serialized command output
tleonhardt Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Harden the pager's exit and the fallback under an open pager
Seven review findings on the pager-layout fix, in one change.

The pager's teardown could skip its restore: if the display died between
the liveness check and the scheduled exit callback, or the exit raised
before restoring, page() left the full-screen flag and editing mode as the
pager's, and the next main prompt rendered in the alternate screen. The
restore now runs through one nested restore(), on the display's loop when
the exit runs and on the command thread when it does not, so it always
runs exactly once.

Falling back while the pager was open reset the renderer, which quits the
alternate screen and flashes the command output through, and swapped the
layout under the pager. While the pager is on screen the fallback now only
switches routing and redraws, so the native toolbar appears on the
pager's bottom row; the layout and renderer reset wait for the pager's
exit, which applies the display's current layout.

One accessor now applies the display's layout to the application, so the
resume, the fallback and the pager's exit all pick up whichever layout the
display has, and the pager no longer saves a copy to restore. The pager's
teardown and two other sites use the class's own thread_is_alive.

Tests: the mid-pager fallback test now asserts the toolbar is visible and
the pager still up while it is open, and that the alternate screen is
never quit on the wire; a new test fails the pager's exit and checks the
display is restored; pager tests share one driving helper, which closes a
pager that ignores its quit key so a regression fails instead of hanging.

Validation: 2626 passed, 6 skipped with coverage, twice; the mutations
that drop the unconditional restore and that reset under the pager each
fail their test; harness acceptance and dynamic gates PASS at 12, 24 and
40 rows, 23/23 observer controls. make check, make test and make docs-test
passed.
  • Loading branch information
tleonhardt committed Sep 12, 2026
commit 690c38e043129fd2afba88bf254892410d80cf54
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
- Falling back from reserved rendering during a command restores the native toolbar layout and
stdout proxy, so a recovered toolbar remains visible during the command, including when the
fallback happens while the command has handed the terminal to another program or while the
built-in pager is open.
built-in pager is open. A fallback during paging no longer flashes the command output through
the pager, and the pager's exit restores the display even if that exit fails.
- Resuming the reserved command display after a terminal handoff preserves the cursor column of
unfinished guest output, so later command output continues the same line.
- A reserved toolbar started in a terminal below the minimum height now activates when the
Expand Down
74 changes: 49 additions & 25 deletions cmd2/command_toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,9 @@ def _resume(self) -> None:
# a line the last command left in progress. Its final frame is a suppressed no-op
# instead, which leaves that output alone.
erase_when_done = self._reserved_bridge() is None
for name, value in (("layout", self._layout), ("key_bindings", self._bindings), ("erase_when_done", erase_when_done)):
stack.callback(setattr, self.app, "layout", self.app.layout)
self._apply_display_layout()
for name, value in (("key_bindings", self._bindings), ("erase_when_done", erase_when_done)):
stack.callback(setattr, self.app, name, getattr(self.app, name))
setattr(self.app, name, value)
self.app.after_render += self._display_started
Expand Down Expand Up @@ -417,23 +419,34 @@ def _reservation_stopped(self) -> None:
bridge behind it, and the native toolbar would never appear for the rest of the
command. Routing and the redraw of a display that *is* running need its loop.
"""
previous_layout = self._layout
self._layout = self._legacy_layout()
if self.app.loop is not None and self.app.is_running:
self.app.loop.call_soon_threadsafe(self._restore_legacy_display, previous_layout)
self.app.loop.call_soon_threadsafe(self._restore_legacy_display)

def _restore_legacy_display(self, previous_layout: Layout) -> None:
"""Switch a running display over to legacy routing and layout, on its own loop.
def _apply_display_layout(self) -> None:
"""Put the display's own layout on the application.

:param previous_layout: the reserved layout the display was started with, replaced on
the application only if it is still the one in use
The one place that assignment is made from ``_layout``. The display's layout can
change while a command runs -- the reservation being abandoned switches it to the
legacy one -- and everything that hands the application back to the display, whether
a resume or the pager's exit, comes through here and so picks up whichever it is now.
"""
self.app.layout = self._layout

def _restore_legacy_display(self) -> None:
"""Switch a running display over to legacy routing and layout, on its own loop."""
if self._pausing or not self.app.is_running or self.app.is_done:
return
self._install_legacy_proxy()
if self.app.layout is previous_layout:
self.app.layout = self._layout
self.app.erase_when_done = True
if self.app.full_screen:
# The pager is on screen. Its exit applies the display's layout and resets the
# renderer; doing either here would quit the alternate screen under it and flash
# the command output through. Routing is switched now, and a redraw shows the
# native toolbar on the pager's bottom row; the rest waits for the pager's exit.
self.app.invalidate()
return
self._apply_display_layout()
self.app.renderer.reset()
self.app.renderer.request_absolute_cursor_position()
self.app.invalidate()
Expand Down Expand Up @@ -550,7 +563,7 @@ def _pause(self) -> 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():
if self.thread_is_alive:
self._abandon_stuck_display()
self._finish_pause()
finally:
Expand Down Expand Up @@ -669,7 +682,7 @@ def call() -> None:
return value

def _check_running(self) -> None:
if self._thread is None or not self._thread.is_alive():
if not self.thread_is_alive:
if self._error is not None:
raise self._error
raise EOFError
Expand All @@ -691,12 +704,22 @@ def page(self, text: str, *, chop: bool) -> None:
filter=Condition(lambda: suspend_to_background_supported() and to_filter(self.cmd.main_session.enable_suspend)()),
)(self._suspend_binding)
layout = Layout(HSplit([pager.container, self.toolbar]), focused_element=pager.text)
# The layout is deliberately not saved here. The display's layout can change while the
# pager is open -- a reservation abandoned mid-page switches it to the legacy one -- and
# restoring the layout saved on entry would put the obsolete reserved layout back,
# leaving no toolbar. Pager exit reads the display's current layout instead.
previous = (self.app.key_bindings, self.app.editing_mode, self.app.full_screen)
entered = False
restored = False

def restore() -> None:
"""Give the application back to the display, whichever thread is doing it.

Plain attribute assignments, so this is safe from the display's loop and from the
command thread alike. The layout is not restored from a saved copy: the display's
layout can change while the pager is open, and the accessor applies the current one.
"""
nonlocal restored
restored = True
self._apply_display_layout()
self.app.key_bindings, self.app.editing_mode, self.app.full_screen = previous
self.app.renderer.full_screen = self.app.full_screen

def enter() -> None:
nonlocal entered
Expand All @@ -718,9 +741,7 @@ def leave() -> None:
return
entered = False
self.app.renderer.erase()
self.app.layout = self._layout
self.app.key_bindings, self.app.editing_mode, self.app.full_screen = previous
self.app.renderer.full_screen = self.app.full_screen
restore()
self.app.renderer.request_absolute_cursor_position()
# Back to ordinary command output, whose frames are suppressed again so the toolbar
# stays put. Command finalization and the next prompt lift this in turn.
Expand All @@ -740,13 +761,16 @@ def close() -> None:
while not pager.closed.wait(0.1):
self._check_running()
finally:
if self._thread is not None and self._thread.is_alive():
self._call_in_ui(leave)
else:
# The application's shutdown already reset the renderer.
self.app.layout = self._layout
self.app.key_bindings, self.app.editing_mode, self.app.full_screen = previous
self.app.renderer.full_screen = self.app.full_screen
try:
if self.thread_is_alive:
self._call_in_ui(leave)
finally:
# Restored here if the exit never ran: the display had already shut down (its
# shutdown reset the renderer itself), or the display died between the check
# and the callback, or the exit raised before restoring. Left as the pager's,
# the full-screen flag and editing mode would carry into the next prompt.
if not restored:
restore()

@contextlib.contextmanager
def suspend(self) -> Iterator[None]:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_command_toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,6 @@ def test_restoring_the_legacy_display_on_a_stopped_display_changes_nothing(toolb
display = app._command_toolbar
assert display is not None
layout = app.main_session.app.layout
display._restore_legacy_display(display._layout)
display._restore_legacy_display()
assert display._proxy is None
assert app.main_session.app.layout is layout
130 changes: 100 additions & 30 deletions tests/test_reserved_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
import time
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from typing import Any
from unittest import mock

import pyte
import pytest
from prompt_toolkit.application import run_in_terminal
from prompt_toolkit.data_structures import Size

from cmd2 import command_toolbar
from cmd2.reserved_toolbar import ReservedToolbar
from cmd2.utils import StdSim

Expand Down Expand Up @@ -560,6 +563,50 @@ def test_a_carriage_return_progress_line_ends_on_its_final_value(self, terminal_
assert terminal.screen.display[-1].startswith("STATUS")


PAGER_BODY = "\n".join(f"row {index:03d}" for index in range(200))


def run_pager(harness, terminal, while_open=None) -> bool:
"""Page a body taller than the screen on the command display, then quit the pager.

``while_open`` runs on the driving thread once the pager has painted its first screen.
The quit key is sent either way, so a pager that never draws fails the caller's
assertion instead of hanging the blocking ``page()`` call forever.

:return: whether the pager drew its first screen
"""
display = harness.app._command_toolbar
shown = threading.Event()
created: list[Any] = []
real_pager = command_toolbar.Pager

def make_pager(*args: Any, **kwargs: Any) -> Any:
pager = real_pager(*args, **kwargs)
created.append(pager)
return pager

def drive() -> None:
try:
if wait_for(lambda: terminal.screen.display[0].startswith("row 000")):
shown.set()
if while_open is not None:
while_open()
finally:
harness.pipe.send_text("q")
# A pager that no longer answers its quit key -- its layout swapped out from
# under it, say -- would leave page() blocked forever. Close it by hand so the
# test fails on the assertion instead of hanging.
if created and not wait_for(created[0].closed.is_set, timeout=3):
created[0].closed.set()
raise AssertionError("the pager did not close on its quit key")

with mock.patch.object(command_toolbar, "Pager", make_pager), ThreadPoolExecutor() as executor:
future = executor.submit(drive)
display.page(PAGER_BODY, chop=False)
future.result(timeout=5)
return shown.is_set()


class TestPager:
"""The built-in pager renders a full screen of its own, so its frames must not be
suppressed the way an ordinary command's empty frames are."""
Expand Down Expand Up @@ -621,21 +668,22 @@ def test_abandoning_reservation_while_the_pager_is_open_keeps_the_fallback_layou
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
reserved = harness.app.reserved_toolbar
display = harness.app._command_toolbar
body = "\n".join(f"row {index:03d}" for index in range(200))

def drive() -> None:
try:
if wait_for(lambda: terminal.screen.display[0].startswith("row 000")):
display.app.loop.call_soon_threadsafe(reserved.stop)
wait_for(lambda: reserved.bridge is None)
finally:
harness.pipe.send_text("q")

with ThreadPoolExecutor() as executor:
future = executor.submit(drive)
display.page(body, chop=False)
future.result(timeout=5)

seen: dict[str, bool] = {}

def stop_mid_page() -> None:
emitted_before = len(terminal.getvalue())
display.app.loop.call_soon_threadsafe(reserved.stop)
assert wait_for(lambda: reserved.bridge is None)
# The fallback must not drop out of the pager: its screen stays up, and the
# native toolbar is visible on its bottom row while it is open. The emulator
# does not model the alternate screen, so the flash a renderer reset would
# cause is checked on the wire: the sequence that quits it is never sent.
seen["toolbar"] = wait_for(lambda: terminal.screen.display[-1].startswith("STATUS"))
seen["pager"] = terminal.screen.display[0].startswith("row 000")
seen["stayed_in_pager"] = "\x1b[?1049l" not in terminal.getvalue()[emitted_before:]

assert run_pager(harness, terminal, while_open=stop_mid_page)
assert seen == {"toolbar": True, "pager": True, "stayed_in_pager": True}
assert reserved.bridge is None
assert len(display.app.layout.container.children) == 3
harness.app.main_session.bottom_toolbar = "RECOVERED"
Expand All @@ -645,30 +693,52 @@ def drive() -> None:

def test_the_pager_draws_its_content_over_the_reserved_toolbar(self, terminal_harness) -> None:
harness, terminal = terminal_harness
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
assert run_pager(harness, terminal), "the pager never drew its content"
# The toolbar is suppressed again for ordinary output once the pager has closed.
assert harness.app.reserved_toolbar.bridge._render_suppressed is True
assert terminal.screen.margins is None

def test_pager_teardown_restores_the_display_even_if_leaving_raises(self, terminal_harness, monkeypatch) -> None:
"""If the display cannot run the pager's exit on its own loop -- here the exit's erase
raises -- page() must still put the display back itself. Left as the pager's, the
full-screen flag and editing mode would carry into the next main prompt."""
harness, terminal = terminal_harness
created: list[Any] = []
real_pager = command_toolbar.Pager

def make_pager(*args: Any, **kwargs: Any) -> Any:
pager = real_pager(*args, **kwargs)
created.append(pager)
return pager

monkeypatch.setattr(command_toolbar, "Pager", make_pager)
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
display = harness.app._command_toolbar
body = "\n".join(f"row {index:03d}" for index in range(200))
shown = threading.Event()
bindings = display.app.key_bindings
editing_mode = display.app.editing_mode

def erase_fails() -> None:
raise ValueError("erase failed")

def drive() -> None:
# Wait until the pager has painted its first screen, then quit it. Quit either
# way, so a pager that never draws fails the assertion instead of hanging the
# blocking page() call forever.
try:
if wait_for(lambda: terminal.screen.display[0].startswith("row 000")):
shown.set()
finally:
harness.pipe.send_text("q")
assert wait_for(lambda: terminal.screen.display[0].startswith("row 000"))
# The exit's first act is an erase; make it raise, then end the pager without
# its quit key so the exit runs from page()'s own teardown.
monkeypatch.setattr(display.app.renderer, "erase", erase_fails)
created[0].closed.set()

with ThreadPoolExecutor() as executor:
future = executor.submit(drive)
display.page(body, chop=False)
with pytest.raises(ValueError, match="erase failed"):
display.page(PAGER_BODY, chop=False)
future.result(timeout=5)

assert shown.is_set(), "the pager never drew its content"
# The toolbar is suppressed again for ordinary output once the pager has closed.
assert harness.app.reserved_toolbar.bridge._render_suppressed is True
assert terminal.screen.margins is None
assert display.app.full_screen is False
assert display.app.renderer.full_screen is False
assert display.app.layout is display._layout
assert display.app.key_bindings is display._bindings or display.app.key_bindings is bindings
assert display.app.editing_mode is editing_mode

def test_output_that_fits_is_printed_without_a_pager(self, terminal_harness) -> None:
harness, terminal = terminal_harness
Expand Down
Loading