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
Fix partial-output loss at shutdown and two resize lifecycle defects
Three reserved-mode defects a code review found, each reproduced first and
each mutation-checked.

Partial output was erased when a command finished. The command display
stops with erase_when_done set, so its shutdown erased the current line --
including output a command left without a trailing newline. Render
suppression only covered intermediate refreshes. In reserved mode the
display now stops with erase_when_done off, and suppression is held until
the display has fully stopped, so its final frame is a suppressed no-op
that leaves the output alone.

A resize during a quiet command went unnoticed. The background application
cannot receive the window-change signal, and prompt-toolkit's size poll
read the reserved adapter, which reported a cached virtual size. The
adapter now measures the physical height on each call and subtracts the
reservation, so the poll sees the change and reinstalls the region. A
terminal that resets its scroll margins on resize would also let
prompt-toolkit's resize-time erase run unbounded and scroll the old
toolbar row up; the region is now reinstalled before that erase.

Growing back above the two-row floor did not reacquire the reservation.
A terminal shrunk below the floor is released but still leased, and the
resize check bailed out on the missing geometry. It now distinguishes a
temporarily ineligible terminal from a released owner and remeasures,
reacquiring once the terminal is eligible again.

Validation: 2602 passed, 6 skipped with coverage; the reserved-rendering
modules at 100% line coverage; the mutations for each fix fail a 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 32f48d21cc95fa16563577860d68881b3e5c80d4
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
- The built-in pager (used by `Cmd.ppaged()`) again displays its content in reserved toolbar
mode. It had rendered nothing while still accepting its keys, because the command display's
renderer frames were being suppressed.
- Command output that does not end in a newline is no longer erased when a command finishes in
reserved toolbar mode. The command display's shutdown had cleared the line still in progress.
- Resizing the terminal during a quiet command is now detected on its own, through
prompt-toolkit's terminal-size poll, instead of only when another event forces a redraw.
- Growing the terminal back after it had shrunk below the two-row minimum now reacquires the
reserved rows. A 24-to-2-to-24 resize sequence had left reserved rendering inactive.

- Breaking Changes
- Replaced `enable_bottom_toolbar` with `bottom_toolbar_mode` in `Cmd.__init__()`. The default,
Expand Down
15 changes: 13 additions & 2 deletions cmd2/command_toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,13 @@ def _resume(self) -> None:
self._ready.clear()
self._error = None
stack = self._display_stack = contextlib.ExitStack()
for name, value in (("layout", self._layout), ("key_bindings", self._bindings), ("erase_when_done", True)):
# Legacy rendering erases the display when it stops, so the main prompt can take the
# toolbar's rows back. Reserved rendering must not: the display draws nothing of its
# own, so its shutdown erase would only clear the command output below it -- including
# 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, name, getattr(self.app, name))
setattr(self.app, name, value)
self.app.after_render += self._display_started
Expand Down Expand Up @@ -451,7 +457,9 @@ def _exit(self) -> None:

def _pause(self) -> None:
self._pausing = True
self._set_render_suppressed(False)
# Render suppression stays on until the display has fully stopped: its final frame at
# shutdown must be the suppressed no-op, not an emitted one that erases the command
# output. _finish_pause lifts it, once the terminal is the main prompt's again.
try:
try:
# Hold off other threads while the proxy drains so their output is never
Expand Down Expand Up @@ -491,6 +499,9 @@ def _finish_pause(self) -> None:
Only safe once the display's thread has ended: until then it is still using the layout
and key bindings this puts back.
"""
# The display has stopped, so its frames no longer need suppressing. The terminal is
# the main prompt's again, and the prompt must render for real.
self._set_render_suppressed(False)
self._thread = None
# Return the borrowed application to the main prompt, including on proxy failures.
# The upstream toolbar owned a separate application.
Expand Down
24 changes: 20 additions & 4 deletions cmd2/prompt_toolkit_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,14 +548,24 @@ def _reconfigure_if_resized(self) -> None:

The prompt origin is forgotten: a resize reflows the screen, so the remembered row is
no longer where the prompt starts, and recovery re-establishes it from the terminal.

A terminal that shrank below the two-row floor is released but still leased -- it is
temporarily ineligible, not given up. When it grows back, its geometry is ``None`` yet
the reservation must be reacquired, so a released-but-leased display is remeasured here
too, distinct from a display whose loop has ended and holds no lease at all.
"""
geometry = self._display.geometry
if geometry is None:
return
with self._lock.transaction("resize"):
if self._display.terminal.physical_size() == geometry.physical_size:
if geometry is None:
# Released owner: the loop has ended and there is nothing to reacquire. A
# display still holding its lease is only temporarily below the floor, and
# reconfigure reinstalls the region once the terminal is eligible again.
if self._display.lease_depth == 0 or not self._display.reconfigure():
return
elif self._display.terminal.physical_size() == geometry.physical_size:
return
self._display.reconfigure()
else:
self._display.reconfigure()
self.forget_prompt_anchor()
self.note_geometry_change()

Expand Down Expand Up @@ -614,6 +624,12 @@ def _erase_through_bridge(self, leave_alternate_screen: bool = True) -> None:
if not self._bound:
self._originals["erase"](leave_alternate_screen)
return
# A resize is the reason the renderer is erasing here (prompt-toolkit erases, requests
# the cursor, then redraws). A terminal that resets its scroll margins on resize would
# make this bounded erase run against the whole screen, deleting lines and scrolling
# the old toolbar row up into the output. Reinstalling the region for the new size
# first -- which also clears the old band -- keeps the erase bounded.
self._reconfigure_if_resized()
self._last_emission_committed = False
with self._lock.transaction("erase"):
try:
Expand Down
13 changes: 11 additions & 2 deletions cmd2/reserved_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,21 @@ def is_reserved(self) -> bool:
def get_size(self) -> Size:
"""Report the usable region as the terminal's size.

:return: the virtual size, one reservation shorter than the physical terminal
The height is measured from the backend on each call and the reservation subtracted,
rather than returned from the cached geometry. The command display runs on a thread
where the window-change signal cannot be delivered, so prompt-toolkit's size poll --
which reads this -- is the only notice a resize gives it. A cached size would hide the
change until something else remeasured, and the poll would never fire the resize.
Column count and the reserved-row count come from the installed geometry; only the
physical height is re-read.

:return: the usable size, one reservation shorter than the physical terminal
"""
geometry = self._display.geometry
if geometry is None:
return self._wrapped.get_size()
return geometry.virtual_size
physical = self._wrapped.get_size()
return Size(rows=max(0, physical.rows - geometry.reserved_rows), columns=physical.columns)

def get_rows_below_cursor_position(self) -> int:
"""Report the distance from the cursor to the bottom of the *usable* region.
Expand Down
24 changes: 24 additions & 0 deletions tests/test_prompt_toolkit_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,30 @@ def test_a_managed_write_can_supply_the_new_prompt_origin(self) -> None:
harness.resynchronize()
assert "\x1b[7;1H" in harness.written()

def test_a_resize_below_the_floor_and_back_reacquires_the_reservation(self) -> None:
"""Review finding: a terminal shrunk below the two-row floor is released but still
leased. When it grows back, a render must remeasure and reacquire, not give up because
the geometry is momentarily gone."""
harness = Harness(rows=24)
harness.resize(2)
assert harness.display.geometry is None
assert harness.display.lease_depth == 1

harness.size = Size(rows=24, columns=40)
harness.bridge._reconfigure_if_resized()
assert harness.display.is_reserved
assert harness.display.geometry is not None
assert harness.display.geometry.physical_rows == 24
assert harness.bridge.needs_resynchronization is True

def test_a_render_below_the_floor_holds_without_reacquiring(self) -> None:
"""While still too short, the remeasure keeps returning nothing rather than raising."""
harness = Harness(rows=24)
harness.resize(2)
assert harness.display.geometry is None
harness.bridge._reconfigure_if_resized()
assert harness.display.geometry is None

def test_recovery_refuses_an_anchor_outside_the_usable_region(self) -> None:
"""Review finding 5: a shrunken terminal makes a remembered row point into the band."""
harness = Harness(rows=24)
Expand Down
8 changes: 5 additions & 3 deletions tests/test_reserved_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ def test_the_physical_layer_still_sees_the_true_height(self) -> None:
assert adapter.get_size().rows == 23

def test_the_virtual_size_follows_a_resize(self) -> None:
output, _stream = make_output(rows=24)
"""The height is re-read from the backend, so a resize shows through immediately --
before any reconfigure -- which is what lets prompt-toolkit's size poll notice it."""
output, _stream, screen = make_shrinkable_output(rows=24, columns=80)
display = TerminalDisplay(output)
display.acquire()
adapter = display.output
# Publish a taller generation the way reconfigure() does.
display._geometry = Geometry(generation=99, physical_rows=40, columns=80, reserved_rows=1)
assert adapter.get_size() == Size(rows=23, columns=80)
screen.rows = 40
assert adapter.get_size() == Size(rows=39, columns=80)


Expand Down
20 changes: 16 additions & 4 deletions tests/test_reserved_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,10 +240,11 @@ def test_a_resize_during_a_command_reinstalls_the_region_and_repaints(self, term
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
ui = harness.app._command_toolbar.app
resize(harness, terminal, rows, columns)
# The resize is applied on the next render. During a command that is a refresh
# tick; the poll cannot see it, because the reserved adapter reports the virtual
# size until the region is remeasured. Drive that render here.
ui.loop.call_soon_threadsafe(ui.invalidate)
# A resize during a command reaches the display through prompt-toolkit's size poll,
# which reads the adapter -- now reporting the true physical height (see
# test_the_virtual_size_follows_a_resize) -- and calls _on_resize. Drive that
# handler once, deterministically, rather than racing the async poll's interval.
ui.loop.call_soon_threadsafe(ui._on_resize)
assert wait_for(lambda: terminal.screen.margins == pyte.screens.Margins(0, rows - 2))
assert ui.output.get_size() == Size(rows=rows - 1, columns=columns)
assert wait_for(lambda: terminal.screen.display[-1].startswith("STATUS"))
Expand Down Expand Up @@ -319,6 +320,17 @@ def test_partial_output_on_the_last_usable_row_survives_too(self, terminal_harne
assert terminal.screen.display[usable - 2].startswith("PARTIALEND")
assert terminal.screen.display[-1].startswith("STATUS")

def test_partial_output_survives_the_command_display_shutdown(self, terminal_harness) -> None:
"""Leaving the command context stops the empty display, whose shutdown must not erase
the command output still on the line."""
harness, terminal = terminal_harness
with harness.app._reserved_toolbar_context():
with harness.app._command_toolbar_context():
harness.app.stdout.write("PARTIAL")
harness.app.stdout.flush()
assert terminal.screen.display[0].startswith("PARTIAL")
assert terminal.screen.display[-1].startswith("STATUS")

def test_a_carriage_return_progress_line_ends_on_its_final_value(self, terminal_harness) -> None:
"""A \r-updated progress line is a sequence of partial writes; each must survive its redraw."""
harness, terminal = terminal_harness
Expand Down