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
Fixed a few edge-case bugs
- Resuming after a terminal handoff preserves the cursor column, so partial output continues correctly
- Initially undersized terminals retain a resize path and activate reserved rendering after growth
- Unfinished command output is preserved by starting the next main prompt on a fresh line
- Abandoning reserved rendering restores the active command toolbar’s legacy layout and output proxy
- Fixed the stale prompt origin: erasing now invalidates the old anchor and pending cursor reports, so background output survives the resumed prompt
  • Loading branch information
tleonhardt committed Sep 12, 2026
commit 756dbbb8e5ae64f502be6deb38bfa9afa0267e19
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
## 4.3.0 (TBD)

- Bug Fixes
- Background output printed above an active reserved prompt through `run_in_terminal()` or
`patch_stdout()` is preserved when the prompt redraws.
- Reserved rendering preserves unfinished command output when the main prompt returns by
starting the prompt on a fresh line.
- 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.
- 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
terminal grows, including during a quiet command.
- 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. Trailing
Expand Down
3 changes: 3 additions & 0 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -3802,6 +3802,9 @@ def _read_raw_input(
"""
owns_the_reservation = session is self.main_session
with self._quiesce_bottom_toolbar() if owns_the_reservation else self.suspend_bottom_toolbar():
reserved = self._reserved_toolbar
if owns_the_reservation and reserved is not None and reserved.bridge is not None:
reserved.bridge.finish_command_output()
return self._read_raw_input_now(prompt, session, **prompt_kwargs)

def _read_raw_input_now(
Expand Down
44 changes: 40 additions & 4 deletions cmd2/command_toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ def __init__(self, cmd: "Cmd") -> None:
# nothing, so command output owns the region and the renderer's frames are no-ops.
# Legacy rendering still needs the filler to push its scrolling toolbar to the bottom.
reserved = cmd.reserved_toolbar
if reserved is not None and reserved.is_active:
if reserved is not None and reserved.bridge is not None:
self._layout = Layout(HSplit([Window(height=0)]))
else:
self._layout = Layout(HSplit([Window(height=0), Window(), self.toolbar]))
Expand Down Expand Up @@ -286,12 +286,12 @@ def _display_started_without_app(self) -> None:
self._ready.set()

def _reserved_bridge(self) -> Any:
"""Return the renderer bridge, when a reservation is holding the toolbar.
"""Return the retained bridge, including while below the reservation height floor.

:return: the bridge, or ``None`` in legacy rendering
"""
reserved = self.cmd.reserved_toolbar
if reserved is None or not reserved.is_active:
if reserved is None:
return None
return reserved.bridge

Expand All @@ -302,6 +302,11 @@ def start(self) -> None:
self._ready.clear()
self._error = None
try:
reserved = self.cmd.reserved_toolbar
if reserved is not None:
previous_handler = reserved.stopped_handler
reserved.stopped_handler = self._reservation_stopped
stack.callback(setattr, reserved, "stopped_handler", previous_handler)
stack.enter_context(create_app_session(input=self.app.input, output=self.app.output))
# Only replace terminal streams. In particular, preserve redirected stderr
# and self.stdout when a nested command has redirected its output to a file.
Expand Down Expand Up @@ -343,6 +348,9 @@ def _resume(self) -> None:
stack.callback(self.app.after_render.remove_handler, self._display_started)
bridge = self._reserved_bridge()
if bridge is not None:
# Set before the worker's first frame, including after a guest handoff.
# Recovery at that frame must restore command modes without homing its cursor.
bridge.set_render_suppressed(True)
# 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.
Expand Down Expand Up @@ -373,14 +381,42 @@ def run() -> None:
raise self._error
if self._install_serializers():
return
self._install_legacy_proxy()

def _install_legacy_proxy(self) -> None:
"""Route output through the native toolbar's erase-and-redraw proxy."""
# 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)
with self._lock:
if self._proxy is not None:
proxy.close()
return
self._proxy = proxy
self._serialized = False
for stream in self._streams:
stream.serializer = None
stream.proxy = proxy

def _reservation_stopped(self) -> None:
"""Restore legacy layout and routing on the UI loop after safe physical release."""
if self.app.loop is not None and self.app.is_running:
self.app.loop.call_soon_threadsafe(self._restore_legacy_display)

def _restore_legacy_display(self) -> None:
"""Replace the empty reserved display once its bridge has been removed."""
previous_layout = self._layout
self._layout = Layout(HSplit([Window(height=0), Window(), self.toolbar]))
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
self.app.renderer.reset()
self.app.renderer.request_absolute_cursor_position()
self.app.invalidate()

def _install_serializers(self) -> bool:
"""Route output straight to the terminal when a reservation is holding the toolbar.

Expand All @@ -392,7 +428,7 @@ def _install_serializers(self) -> bool:
:return: whether serialized writing was installed
"""
reserved = self.cmd.reserved_toolbar
if reserved is None or not reserved.is_active:
if reserved is None or reserved.bridge is None:
return False
with self._lock:
self._serialized = True
Expand Down
2 changes: 1 addition & 1 deletion cmd2/managed_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def write(self, data: str) -> int:
# output goes below an empty command display, so a line in progress -- one that
# did not end in a newline -- stays on screen for the next write to continue.
if self.bridge is not None:
self.bridge.note_managed_write()
self.bridge.note_managed_write(data=data)
return written

def flush(self) -> None:
Expand Down
44 changes: 37 additions & 7 deletions cmd2/prompt_toolkit_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ def __init__(self, renderer: "Renderer", display: "TerminalDisplay", lock: Termi
self._after_render_original: Any = None
self._after_render_installed: Any = None
self._render_suppressed = False
self._unfinished_command_output = False

# -- what is known ---------------------------------------------------------------------

Expand Down Expand Up @@ -232,7 +233,7 @@ def set_redraw_scheduler(self, scheduler: "Callable[[], None]") -> None:
"""
self._redraw_scheduler = scheduler

def note_managed_write(self, prompt_anchor: int | None = None) -> None:
def note_managed_write(self, prompt_anchor: int | None = None, *, data: str | None = None) -> None:
"""Record that command output reached the terminal.

In reserved mode command output goes straight to the terminal, below an empty command
Expand All @@ -258,8 +259,11 @@ def note_managed_write(self, prompt_anchor: int | None = None) -> None:
:param prompt_anchor: the physical row the prompt now starts on, where a caller happens
to know it. Passing nothing forgets any remembered origin rather than keeping a row
the output has moved past.
:param data: emitted command text, for deciding whether the next prompt needs a new line
"""
self._terminal_generation += 1
if data:
self._unfinished_command_output = not data.rstrip("\r").endswith("\n")
self._prompt_anchor = prompt_anchor
# Only the frame in flight goes; it was prepared against the cursor and content this
# write moved, so committing it would emit a stale frame. The renderer's baseline is
Expand All @@ -270,6 +274,20 @@ def note_managed_write(self, prompt_anchor: int | None = None) -> None:
self._renderer._last_screen = self._committed_screen
self._request_redraw()

def finish_command_output(self) -> None:
"""Start the next prompt on a fresh line if command output left one unfinished.

The reserved erase deletes whole lines. Moving past a partial line before the
prompt's first render preserves that line, including at the scrolling margin.
"""
with self._lock.transaction("finish command output"):
if self._unfinished_command_output:
self._display.output.write_raw("\r\n")
self._display.output.flush()
self._unfinished_command_output = False
self._prompt_anchor = None
self._terminal_generation += 1

def note_owner_change(self) -> None:
"""Record that a different UI owner now holds the terminal."""
self._owner_generation += 1
Expand Down Expand Up @@ -630,8 +648,8 @@ 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
# A resize can be the reason the renderer is erasing here (prompt-toolkit erases,
# requests the cursor, then redraws). A terminal that resets its 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.
Expand All @@ -651,6 +669,11 @@ def _erase_through_bridge(self, leave_alternate_screen: bool = True) -> None:
# 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.
# run_in_terminal also erases before handing output to its callback. Its
# immediate redraw must wait for the post-output cursor report, rather than
# recovering at the old prompt row and erasing the callback's output.
self._prompt_anchor = None
self._invalidate_pending_cursor_reports()
self.require_resynchronization("the renderer erased the screen")

def _clear_through_bridge(self) -> None:
Expand Down Expand Up @@ -840,6 +863,11 @@ def resynchronize(self) -> None:
# this call queues for the terminal, and recovery would then write cursor and
# mode sequences into a terminal nothing is allowed to emit to any more.
raise ReservedModeFailureError("reserved emission has stopped; release before rendering again")
if self._render_suppressed:
# A guest may leave an unfinished output line at any column. The empty
# command display needs its terminal modes restored, not a prompt origin.
self._establish(policy, None)
return
# The origin is read *here*, not before the wait. Recovery can queue behind
# another writer for as long as that writer holds the terminal, and what it does
# in the meantime -- emitting output, moving the prompt, resizing -- is exactly
Expand All @@ -866,18 +894,19 @@ def resynchronize(self) -> None:
# would repaint the prompt over committed output.
self.request_cursor_position()

def _establish(self, policy: TerminalModePolicy, origin: int) -> None:
def _establish(self, policy: TerminalModePolicy, origin: int | None) -> None:
"""Put the terminal into the known state, from inside the transaction.

Recovery is marked complete here rather than after the lock is given back: whoever
takes the terminal next must not find a recovery still owed against work that has
already been done.

:param policy: the mode policy to establish
:param origin: the physical row to place the cursor on
:param origin: the prompt's physical row, or ``None`` to preserve the command cursor
"""
output = self._display.output
output.write_raw(f"\x1b[{origin};1H")
if origin is not None:
output.write_raw(f"\x1b[{origin};1H")
# Upstream enables bracketed paste on every render and latches a flag beside the
# emission, so the policy here is not conditional: it is on, and the flag is made
# to agree with an enable that actually reached the terminal.
Expand All @@ -895,7 +924,8 @@ def _establish(self, policy: TerminalModePolicy, origin: int) -> None:
self._needs_resynchronization = False
self._resynchronization_reason = None
self._in_flight = None
self._initialize_renderer(policy, origin)
if origin is not None:
self._initialize_renderer(policy, origin)

def _usable_rows(self) -> int:
"""How many rows the application may use right now.
Expand Down
22 changes: 12 additions & 10 deletions cmd2/reserved_toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from prompt_toolkit.styles import DynamicStyle

from .prompt_toolkit_bridge import PromptToolkitBridge
from .reserved_output import ReservedOutput
from .terminal_display import TerminalDisplay
from .terminal_transaction import TerminalLock
from .theme import get_pt_theme
Expand Down Expand Up @@ -105,6 +106,7 @@ def __init__(
self._native_toolbar: ConditionalContainer | None = None
self._original_filter: Any = None
self._installed_filter: Any = None
self.stopped_handler: Callable[[], None] | None = None

@property
def is_active(self) -> bool:
Expand Down Expand Up @@ -145,13 +147,13 @@ def lock(self) -> TerminalLock:
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.
A terminal too short for the floor keeps its lease and resize bridge. The adapter
exposes the full terminal until growth makes a reservation possible.

:return: whether a reservation was installed
"""
if self._display is not None:
return True
return self.is_active

app = self._session.app
native = native_toolbar_container(self._session)
Expand All @@ -162,17 +164,15 @@ def start(self) -> bool:
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
# would make every later acquire a no-op at depth two.
display.release()
return False
display.acquire()

self._display = display
try:
self._original_app_output = app.output
self._original_renderer_output = app.renderer.output
self._bound_output = display.output
# Keep a live view even if startup is below the height floor. Binding the raw
# backend there would leave Application.output unadapted after reacquisition.
self._bound_output = display.output if display.is_reserved else ReservedOutput(app.output, display)
app.output = self._bound_output
app.renderer.output = self._bound_output

Expand Down Expand Up @@ -221,7 +221,7 @@ def start(self) -> bool:
with suppress(Exception):
self.stop()
raise
return True
return self.is_active

def take_pending_error(self) -> BaseException | None:
"""Take the failure waiting to be reported, if there is one.
Expand Down Expand Up @@ -394,6 +394,8 @@ def stop(self) -> None:
self._original_app_output = None
self._original_renderer_output = None
display.release()
if self.stopped_handler is not None:
self.stopped_handler()

def __enter__(self) -> Self:
"""Start the reservation."""
Expand Down
3 changes: 3 additions & 0 deletions docs/features/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ retains prompt-toolkit's layout. The number of reserved rows is not yet configur

### Refreshing the Toolbar

In reserved rendering, if command output ends without a newline, cmd2 adds one before displaying the
next main prompt. This preserves the unfinished output above the prompt.

The toolbar is rendered by `prompt-toolkit` and is naturally redrawn whenever the prompt is
refreshed. If you want the toolbar to update automatically during input and command execution (for
example, to display a clock), you can set `refresh_interval` in the [cmd2.Cmd.__init__][]
Expand Down
2 changes: 1 addition & 1 deletion tests/test_managed_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def __init__(self) -> None:
self.notes: list[Any] = []
self.anchors: list[int | None] = []

def note_managed_write(self, prompt_anchor: int | None = None) -> None:
def note_managed_write(self, prompt_anchor: int | None = None, *, data: str | None = None) -> None:
self.notes.append(current_transaction())
self.anchors.append(prompt_anchor)

Expand Down
6 changes: 4 additions & 2 deletions tests/test_prompt_toolkit_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -1401,15 +1401,17 @@ def test_a_failed_cleanup_tells_the_owner_to_release(self) -> None:
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:
@pytest.mark.parametrize("operation", ["clear", "erase"])
def test_a_reply_in_transit_when_the_screen_cleared_is_not_reused(self, operation) -> 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()
getattr(harness.renderer, operation)()
assert harness.bridge.prompt_anchor is None
harness.bridge.request_cursor_position() # request B, about the cleared screen

# Reply A arrives late. It describes the screen before the clear.
Expand Down
Loading