Skip to content

Commit 1fe8bc1

Browse files
committed
Address five review findings on the edge-case fixes
Abandoning the reservation while the command display was paused for a guest left the display on the empty reserved layout with no bridge behind it, so the native toolbar never appeared for the rest of the command. The layout swap now happens as soon as the reservation stops, since it needs no UI loop; only a running display's routing and redraw are scheduled on its loop. The unfinished-output verdict that starts the next prompt on a fresh line is now dropped when a guest takes the terminal or the screen is erased, since the cursor is no longer where the output left it, and it ignores trailing control sequences -- a reset after the newline no longer counts as text on a new line, and a write made only of control says nothing. The size-poll test names the upstream coroutine it depends on. Starting the reserved toolbar on an unqualified backend binds nothing, rather than an adapter and bridge that could never reserve. Two small tests cover the defensive returns in the legacy fallback, so command_toolbar.py is back at its previous coverage. Validation: 2624 passed, 6 skipped with coverage, twice; five mutations 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.
1 parent 756dbbb commit 1fe8bc1

8 files changed

Lines changed: 208 additions & 11 deletions

CHANGELOG.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
- Background output printed above an active reserved prompt through `run_in_terminal()` or
55
`patch_stdout()` is preserved when the prompt redraws.
66
- Reserved rendering preserves unfinished command output when the main prompt returns by
7-
starting the prompt on a fresh line.
7+
starting the prompt on a fresh line. Trailing control sequences do not count as unfinished
8+
output, and a line another program finished during a terminal handoff is not given an extra
9+
blank line.
810
- Falling back from reserved rendering during a command restores the native toolbar layout and
9-
stdout proxy, so a recovered toolbar remains visible during the command.
11+
stdout proxy, so a recovered toolbar remains visible during the command, including when the
12+
fallback happens while the command has handed the terminal to another program.
1013
- Resuming the reserved command display after a terminal handoff preserves the cursor column of
1114
unfinished guest output, so later command output continues the same line.
1215
- A reserved toolbar started in a terminal below the minimum height now activates when the

cmd2/command_toolbar.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,9 @@ def __init__(self, cmd: "Cmd") -> None:
229229
# Legacy rendering still needs the filler to push its scrolling toolbar to the bottom.
230230
reserved = cmd.reserved_toolbar
231231
if reserved is not None and reserved.bridge is not None:
232-
self._layout = Layout(HSplit([Window(height=0)]))
232+
self._layout = self._reserved_layout()
233233
else:
234-
self._layout = Layout(HSplit([Window(height=0), Window(), self.toolbar]))
234+
self._layout = self._legacy_layout()
235235
self._display_stack: contextlib.ExitStack | None = None
236236
bindings = KeyBindings()
237237

@@ -398,15 +398,36 @@ def _install_legacy_proxy(self) -> None:
398398
stream.serializer = None
399399
stream.proxy = proxy
400400

401+
@staticmethod
402+
def _reserved_layout() -> Layout:
403+
"""Build the command display's layout for reserved rendering: nothing of its own."""
404+
return Layout(HSplit([Window(height=0)]))
405+
406+
def _legacy_layout(self) -> Layout:
407+
"""Build the command display's layout for legacy rendering: a filler and the toolbar."""
408+
return Layout(HSplit([Window(height=0), Window(), self.toolbar]))
409+
401410
def _reservation_stopped(self) -> None:
402-
"""Restore legacy layout and routing on the UI loop after safe physical release."""
411+
"""Fall back to the legacy display once the reservation has been physically released.
412+
413+
The layout swap happens here, unconditionally: it needs no UI loop, and the next
414+
resume reads it. The reservation can stop while the display is paused for a guest,
415+
when there is no running loop to switch the live layout on -- swapping only from the
416+
loop callback would bring the display back as the empty reserved layout with no
417+
bridge behind it, and the native toolbar would never appear for the rest of the
418+
command. Routing and the redraw of a display that *is* running need its loop.
419+
"""
420+
previous_layout = self._layout
421+
self._layout = self._legacy_layout()
403422
if self.app.loop is not None and self.app.is_running:
404-
self.app.loop.call_soon_threadsafe(self._restore_legacy_display)
423+
self.app.loop.call_soon_threadsafe(self._restore_legacy_display, previous_layout)
405424

406-
def _restore_legacy_display(self) -> None:
407-
"""Replace the empty reserved display once its bridge has been removed."""
408-
previous_layout = self._layout
409-
self._layout = Layout(HSplit([Window(height=0), Window(), self.toolbar]))
425+
def _restore_legacy_display(self, previous_layout: Layout) -> None:
426+
"""Switch a running display over to legacy routing and layout, on its own loop.
427+
428+
:param previous_layout: the reserved layout the display was started with, replaced on
429+
the application only if it is still the one in use
430+
"""
410431
if self._pausing or not self.app.is_running or self.app.is_done:
411432
return
412433
self._install_legacy_proxy()

cmd2/prompt_toolkit_bridge.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
and :mod:`tests.test_prompt_toolkit_bridge` holds it to that version by name.
2525
"""
2626

27+
import re
2728
from collections import deque
2829
from dataclasses import dataclass
2930
from typing import TYPE_CHECKING, Any
@@ -43,6 +44,13 @@
4344
from .terminal_display import TerminalDisplay
4445

4546

47+
#: Trailing control that moves nothing the user can see: carriage returns, CSI sequences such
48+
#: as an SGR reset, and OSC sequences such as a window title. Stripped before deciding whether
49+
#: output ended on a fresh line, so a reset emitted after the newline does not count as text
50+
#: on a new line, and a write made only of control says nothing about the line at all.
51+
_TRAILING_CONTROL = re.compile(r"(?:\r|\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\))+\Z")
52+
53+
4654
class ReservedModeFailureError(RuntimeError):
4755
"""Raised when reserved rendering cannot continue safely.
4856
@@ -263,7 +271,9 @@ def note_managed_write(self, prompt_anchor: int | None = None, *, data: str | No
263271
"""
264272
self._terminal_generation += 1
265273
if data:
266-
self._unfinished_command_output = not data.rstrip("\r").endswith("\n")
274+
visible = _TRAILING_CONTROL.sub("", data)
275+
if visible:
276+
self._unfinished_command_output = not visible.endswith("\n")
267277
self._prompt_anchor = prompt_anchor
268278
# Only the frame in flight goes; it was prepared against the cursor and content this
269279
# write moved, so committing it would emit a stale frame. The renderer's baseline is
@@ -274,6 +284,22 @@ def note_managed_write(self, prompt_anchor: int | None = None, *, data: str | No
274284
self._renderer._last_screen = self._committed_screen
275285
self._request_redraw()
276286

287+
@property
288+
def has_unfinished_command_output(self) -> bool:
289+
"""Whether the last visible command output stopped part-way through a line."""
290+
return self._unfinished_command_output
291+
292+
def forget_unfinished_command_output(self) -> None:
293+
"""Drop the verdict about a line in progress: the cursor is no longer where it left it.
294+
295+
A guest program that took the terminal, or an erase that cleared the screen, has moved
296+
the cursor since. The line the verdict described may well be finished by now, and
297+
adding a newline for it would push the next prompt down by a blank line. Output the
298+
guest itself left unfinished is not seen here -- it bypasses the serializer -- and is
299+
the guest's to finish, as it was before the reservation existed.
300+
"""
301+
self._unfinished_command_output = False
302+
277303
def finish_command_output(self) -> None:
278304
"""Start the next prompt on a fresh line if command output left one unfinished.
279305
@@ -674,6 +700,8 @@ def _erase_through_bridge(self, leave_alternate_screen: bool = True) -> None:
674700
# recovering at the old prompt row and erasing the callback's output.
675701
self._prompt_anchor = None
676702
self._invalidate_pending_cursor_reports()
703+
# The screen below the cursor is gone, and with it any line in progress.
704+
self._unfinished_command_output = False
677705
self.require_resynchronization("the renderer erased the screen")
678706

679707
def _clear_through_bridge(self) -> None:
@@ -699,6 +727,7 @@ def _clear_through_bridge(self) -> None:
699727
finally:
700728
self._prompt_anchor = None
701729
self._invalidate_pending_cursor_reports()
730+
self._unfinished_command_output = False
702731
self.require_resynchronization("the renderer cleared the screen")
703732

704733
# -- prepare and commit ----------------------------------------------------------------

cmd2/reserved_toolbar.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ def start(self) -> bool:
164164
raise RuntimeError("cannot locate the session's bottom toolbar window")
165165

166166
display = TerminalDisplay(app.output, reserved_rows=self._reserved_rows)
167+
if not display.terminal.supports_reservation:
168+
# Mode selection refuses an unqualified backend before this runs, so this is for
169+
# a caller using the class directly. Nothing is bound: a terminal below the floor
170+
# keeps a lease and a bridge for the resize that may make a reservation possible,
171+
# but an unqualified backend can never reserve, whatever its size.
172+
return False
167173
display.acquire()
168174

169175
self._display = display
@@ -290,6 +296,7 @@ def _invalidate_ownership(self, reason: str) -> None:
290296
self._painter.invalidate()
291297
if self._bridge is not None:
292298
self._bridge.forget_prompt_anchor()
299+
self._bridge.forget_unfinished_command_output()
293300
self._bridge.require_resynchronization(reason)
294301

295302
def refresh(self) -> bool:

tests/test_command_toolbar.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,3 +1149,30 @@ def test_a_surviving_display_stops_another_from_starting(toolbar_app, expire_sta
11491149
assert app._command_toolbar is None
11501150
finally:
11511151
blocked.set()
1152+
1153+
1154+
def test_installing_the_legacy_proxy_twice_keeps_the_first(toolbar_app) -> None:
1155+
"""Falling back to legacy routing on a display that already routes through a proxy must
1156+
not replace a proxy whose worker is mid-write; the newcomer is closed instead."""
1157+
app, _pipe, _output = toolbar_app
1158+
with app._command_toolbar_context():
1159+
display = app._command_toolbar
1160+
assert display is not None
1161+
proxy = display._proxy
1162+
assert proxy is not None
1163+
display._install_legacy_proxy()
1164+
assert display._proxy is proxy
1165+
assert all(stream.proxy is proxy for stream in display._streams)
1166+
1167+
1168+
def test_restoring_the_legacy_display_on_a_stopped_display_changes_nothing(toolbar_app) -> None:
1169+
"""The fallback can be scheduled just before the display stops; by the time it runs
1170+
there is no display to switch, and it must not install routing into a closed one."""
1171+
app, _pipe, _output = toolbar_app
1172+
with app._command_toolbar_context():
1173+
display = app._command_toolbar
1174+
assert display is not None
1175+
layout = app.main_session.app.layout
1176+
display._restore_legacy_display(display._layout)
1177+
assert display._proxy is None
1178+
assert app.main_session.app.layout is layout

tests/test_prompt_toolkit_bridge.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,30 @@ def test_managed_output_drops_the_frame_but_keeps_the_baseline(self) -> None:
714714
assert harness.renderer._last_screen is committed
715715
assert harness.bridge.needs_resynchronization is False
716716

717+
def test_output_ending_in_a_control_sequence_after_its_newline_is_finished(self) -> None:
718+
"""A reset emitted after the newline does not make the line unfinished."""
719+
harness = Harness()
720+
harness.bridge.note_managed_write(data="line\n\x1b[0m")
721+
assert harness.bridge.has_unfinished_command_output is False
722+
harness.bridge.note_managed_write(data="line\n\x1b]0;title\x07")
723+
assert harness.bridge.has_unfinished_command_output is False
724+
725+
def test_a_write_of_only_control_leaves_the_verdict_unchanged(self) -> None:
726+
"""Nothing visible was written, so nothing about the line in progress changed."""
727+
harness = Harness()
728+
harness.bridge.note_managed_write(data="PARTIAL")
729+
assert harness.bridge.has_unfinished_command_output is True
730+
harness.bridge.note_managed_write(data="\x1b[0m")
731+
assert harness.bridge.has_unfinished_command_output is True
732+
harness.bridge.note_managed_write(data="\n")
733+
assert harness.bridge.has_unfinished_command_output is False
734+
735+
def test_a_bare_carriage_return_keeps_the_line_unfinished(self) -> None:
736+
harness = Harness()
737+
harness.bridge.note_managed_write(data="progress 50%")
738+
harness.bridge.note_managed_write(data="\r")
739+
assert harness.bridge.has_unfinished_command_output is True
740+
717741
def test_a_managed_write_can_supply_the_new_prompt_origin(self) -> None:
718742
"""The layer that emitted the output is the one that knows where it ended."""
719743
harness = Harness()
@@ -1165,6 +1189,27 @@ def test_a_render_whose_commit_is_refused_asks_for_another(self) -> None:
11651189
harness.renderer.render(harness.app, harness.app.layout)
11661190
assert harness.bridge.redraw_pending is True
11671191

1192+
@pytest.mark.parametrize("operation", ["erase", "clear"])
1193+
def test_a_real_erase_forgets_unfinished_output(self, operation) -> None:
1194+
"""After the screen is erased there is no line in progress at the cursor."""
1195+
harness = self.bound()
1196+
# clear() asks for a cursor report through the event loop, which this harness lacks.
1197+
harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign]
1198+
harness.bridge.note_managed_write(data="PARTIAL")
1199+
assert harness.bridge.has_unfinished_command_output is True
1200+
with set_app(harness.app):
1201+
getattr(harness.renderer, operation)()
1202+
assert harness.bridge.has_unfinished_command_output is False
1203+
1204+
def test_a_suppressed_resize_erase_keeps_the_unfinished_verdict(self) -> None:
1205+
"""The suppressed path skips the erase to keep the line, so the verdict stands."""
1206+
harness = self.bound()
1207+
harness.bridge.note_managed_write(data="PARTIAL")
1208+
harness.bridge.set_render_suppressed(True)
1209+
with set_app(harness.app):
1210+
harness.renderer.erase()
1211+
assert harness.bridge.has_unfinished_command_output is True
1212+
11681213
def test_an_erase_is_emitted_inside_a_transaction(self) -> None:
11691214
harness = self.bound()
11701215
with set_app(harness.app):

tests/test_reserved_terminal.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,9 @@ def observe_size():
342342
task = asyncio.current_task()
343343
except RuntimeError:
344344
task = None
345+
# Identified by the name of prompt-toolkit's own polling coroutine,
346+
# Application._poll_output_size in the qualified 3.0.53. It is upstream
347+
# internal, so this is one of the places a version bump has to revisit.
345348
if task is not None and task.get_coro().__name__ == "_poll_output_size":
346349
polled.set()
347350
return size
@@ -423,6 +426,22 @@ def test_partial_output_survives_the_next_main_prompt(self, terminal_harness, li
423426
assert sum("IMPORTANT PARTIAL" in row for row in visible) == 1
424427
assert any("TEST> next" in row for row in visible)
425428

429+
def test_a_handoff_forgets_unfinished_output_so_the_prompt_is_not_pushed_down(self, terminal_harness) -> None:
430+
"""A partial write followed by a guest that finishes the line: the guest moved the
431+
cursor to a fresh line, so the prompt must not add another one on the strength of a
432+
verdict about output the guest has since completed."""
433+
harness, terminal = terminal_harness
434+
with harness.app._reserved_toolbar_context():
435+
with harness.app._command_toolbar_context():
436+
harness.app.stdout.write("PARTIAL")
437+
harness.app.stdout.flush()
438+
with harness.app.suspend_bottom_toolbar():
439+
harness.app.stdout.write("done\n")
440+
harness.app.stdout.flush()
441+
read_prompt(harness, terminal)
442+
assert terminal.screen.display[0].startswith("PARTIALdone")
443+
assert terminal.screen.display[1].startswith("TEST> next")
444+
426445
def test_guest_partial_output_survives_command_resume(self, terminal_harness) -> None:
427446
harness, terminal = terminal_harness
428447
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
@@ -572,6 +591,28 @@ def fail():
572591
assert wait_for(lambda: any("legacy output" in row for row in terminal.screen.display))
573592
assert terminal.screen.margins is None
574593

594+
def test_abandoning_reservation_while_suspended_restores_the_legacy_display(self, terminal_harness) -> None:
595+
"""The reservation can stop while the display is paused for a guest. There is no UI
596+
loop to switch the live layout on then, but the display must still come back as the
597+
legacy one -- filler, native toolbar, proxy -- rather than the empty reserved layout
598+
with no bridge behind it."""
599+
harness, terminal = terminal_harness
600+
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
601+
reserved = harness.app.reserved_toolbar
602+
display = harness.app._command_toolbar
603+
with harness.app.suspend_bottom_toolbar():
604+
reserved.stop()
605+
assert reserved.bridge is None
606+
assert len(display._layout.container.children) == 3
607+
assert display._proxy is not None
608+
assert all(stream.serializer is None for stream in display._streams)
609+
harness.app.main_session.bottom_toolbar = "RECOVERED"
610+
display.app.invalidate()
611+
assert wait_for(lambda: any(row.startswith("RECOVERED") for row in terminal.screen.display))
612+
harness.app.poutput("legacy output")
613+
assert wait_for(lambda: any("legacy output" in row for row in terminal.screen.display))
614+
assert terminal.screen.margins is None
615+
575616
def test_the_pager_draws_its_content_over_the_reserved_toolbar(self, terminal_harness) -> None:
576617
harness, terminal = terminal_harness
577618
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():

tests/test_reserved_toolbar.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,30 @@ def test_stopping_twice_does_nothing_the_second_time(self) -> None:
130130
finally:
131131
harness.close()
132132

133+
def test_an_unqualified_backend_binds_nothing(self) -> None:
134+
"""Capability is exact class identity, so a subclass is not qualified. Below the floor
135+
a lease and a bridge are kept for the resize that may make a reservation possible;
136+
an unqualified backend can never reserve, so nothing is bound at all."""
137+
138+
class OtherOutput(Vt100_Output):
139+
pass
140+
141+
stream = TtyStringIO()
142+
backend = OtherOutput(stream, lambda: Size(rows=24, columns=80))
143+
with create_pipe_input() as pipe:
144+
session: PromptSession[str] = PromptSession(input=pipe, output=backend, bottom_toolbar="STATUS")
145+
toolbar = ReservedToolbar(session, lambda: session.bottom_toolbar)
146+
# The session's own renderer writes cursor-shape sequences on creation; what
147+
# matters is that starting and stopping the toolbar adds nothing to them.
148+
before = stream.getvalue()
149+
assert toolbar.start() is False
150+
assert session.app.output is backend
151+
assert session.app.renderer.output is backend
152+
assert toolbar.bridge is None
153+
assert toolbar.is_active is False
154+
toolbar.stop()
155+
assert stream.getvalue() == before
156+
133157
def test_a_terminal_too_short_to_reserve_keeps_a_resize_bridge(self) -> None:
134158
"""Below the floor the full-size adapter retains a retry path and cleans up normally."""
135159
harness = Harness(rows=2)

0 commit comments

Comments
 (0)