Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1462c0f
Integrate reserved toolbar ownership across Stage 4 handoffs
tleonhardt Sep 12, 2026
c322cde
Keep the toolbar visible while the pager prepares its first frame
tleonhardt Sep 12, 2026
5b4acab
Guard POSIX suspend signal lookup on Windows
tleonhardt Sep 12, 2026
3354add
Add include_py to getting_started.py for ease of testing
tleonhardt Sep 12, 2026
1795cd9
Added raise_exception.py example pyscript for just testing a script t…
tleonhardt Sep 12, 2026
9649d4d
Keep terminal pipelines in the foreground job during suspend and resume
tleonhardt Sep 12, 2026
37645b5
Repaint reserved toolbar after minimum-height reacquisition
tleonhardt Sep 12, 2026
2b78110
Accept bash 5.1+ bracketed-paste output in the job-control test
tleonhardt Sep 12, 2026
c052401
Report from the job-control test's pager with os.write, not print
tleonhardt Sep 12, 2026
b1f80a6
Cover the reserved toolbar's remaining job-control and nested-prompt …
tleonhardt Sep 12, 2026
a785b96
Wait for the whole job to stop before typing at the shell in the job-…
tleonhardt Sep 12, 2026
c16e943
Fix forwarding of process-directed SIGINT to pipelines
tleonhardt Sep 12, 2026
ece4307
Avoid duplicate SIGINT delivery to terminal pipelines
tleonhardt Sep 12, 2026
4a79cb4
Fix POSIX pipeline signal races and speed up terminal tests
tleonhardt Sep 12, 2026
66365f5
Collect coverage from terminal test subprocesses
tleonhardt Sep 12, 2026
004f835
Cover terminal pipeline cleanup and resume paths
tleonhardt Sep 12, 2026
e327df6
Fix terminal input and wrapper job control in pipelines
tleonhardt Sep 14, 2026
b75e18e
Preserve ignored Ctrl-Z for session-led pipelines
tleonhardt Sep 14, 2026
a7a733f
Run shell producers inside terminal pipelines and isolate worker-thre…
tleonhardt Sep 14, 2026
d4566a0
Cover the shell command's fallback paths in terminal pipelines
tleonhardt Sep 14, 2026
0102c44
Keep the terminal lent across an interrupted pipeline write
tleonhardt Sep 14, 2026
7643b9a
Relay pipeline stops to the main thread with a thread-directed signal
tleonhardt Sep 14, 2026
d3b2630
Let the main thread relay a pipeline stop from a blocking write or wait
tleonhardt Sep 14, 2026
589b468
Keep the pipeline descriptor blocking for shell producers
tleonhardt Sep 14, 2026
f34262c
Added stage4_manual.py example for ease of testing
tleonhardt Sep 14, 2026
9f50afb
Stabilize orphaned-session pipeline terminal test
tleonhardt Sep 14, 2026
66513f6
Hold the display thread until a Ctrl-Z stop has taken it
tleonhardt Sep 15, 2026
b163a1e
Assert pipeline isolation without a platform branch in the toolbar test
tleonhardt Sep 15, 2026
416c6da
Cover both outcomes of the wait after sending SIGTSTP
tleonhardt Sep 15, 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
173 changes: 137 additions & 36 deletions cmd2/cmd2.py

Large diffs are not rendered by default.

63 changes: 61 additions & 2 deletions cmd2/command_toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
import signal
import sys
import threading
import time
from collections.abc import Callable, Iterator
from concurrent.futures import Future
from concurrent.futures import TimeoutError as FutureTimeoutError
from typing import TYPE_CHECKING, Any, TextIO, TypeVar, cast

from prompt_toolkit.application import Application, create_app_session
from prompt_toolkit.application import Application, create_app_session, run_in_terminal
from prompt_toolkit.application.current import _current_app_session, get_app_session
from prompt_toolkit.enums import EditingMode
from prompt_toolkit.filters import Condition, to_filter
Expand Down Expand Up @@ -43,6 +44,33 @@
_R = TypeVar("_R")


def suspend_process_group(suspend_group: bool = True) -> None:
"""Stop this process, or its whole process group, with SIGTSTP from any thread.

A process-directed stop signal can be taken by a thread other than the sender. Sent
from the display thread, it lands on the main thread, and the display thread carries
on for a few milliseconds: it puts the terminal back into raw mode and redraws before
the job has stopped. When the shell resumes the job it restores the modes it saved
beforehand, and nothing is left to undo that -- the terminal stays cooked, keys are
held until Enter, and every cursor-position query is echoed as ``^[[row;colR``.

So after sending the signal this thread waits for the stop to take it too. A stop
shows as time passing while asleep; a signal that was ignored, or discarded for an
orphaned process group, shows as nothing, and the wait ends on its own.

:param suspend_group: stop the whole process group, as a shell's Ctrl-Z would, rather
than only this process
"""
if not hasattr(signal, "SIGTSTP"): # pragma: no cover - POSIX only
return
os.kill(0 if suspend_group else os.getpid(), signal.SIGTSTP)
deadline = time.monotonic() + 0.25
while (before := time.monotonic()) < deadline:
time.sleep(0.01)
if time.monotonic() - before > 0.1:
return


def suspend_toolbar(func: _F) -> _F:
"""Give a method exclusive access to the terminal, reserved rows included.

Expand Down Expand Up @@ -217,6 +245,12 @@ def __init__(self, cmd: "Cmd") -> None:

session = cmd.main_session
self.app = session.app
# Ctrl-Z during a command is handled on the display's thread, where upstream's
# version would not hold the thread until the job has stopped. The reserved toolbar
# layers its row release over whatever is installed, so one it put there first is
# left in place; it releases the rows and then stops the process the same way.
if "suspend_to_background" not in vars(self.app):
cast("Any", self.app).suspend_to_background = self._suspend_to_background
# PromptSession has no public hook for replacing just its input area.
# Keep this small dependency on its layout shape in one place, and fail
# explicitly if upstream changes it. Reuse the actual toolbar container,
Expand Down Expand Up @@ -282,6 +316,11 @@ def suspend(event: KeyPressEvent) -> None:
self._bindings = bindings
self._suspend_binding = suspend

@staticmethod
def _suspend_to_background(suspend_group: bool = True) -> None:
"""Suspend like upstream's ``Application.suspend_to_background()``, from any thread."""
run_in_terminal(functools.partial(suspend_process_group, suspend_group))

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()
Expand Down Expand Up @@ -721,7 +760,9 @@ def page(self, text: str, *, chop: bool) -> None:
# Measuring the toolbar can invoke its callback; keep that work on the
# UI thread along with rendering and layout changes.
toolbar_height = self._call_in_ui(lambda: self.toolbar.preferred_height(size.columns, size.rows).preferred)
if output_fits(text, size.columns, max(0, size.rows - toolbar_height), chop=chop):
reserved = self.cmd.reserved_toolbar
available_rows = size.rows if reserved is not None and reserved.is_active else max(0, size.rows - toolbar_height)
if output_fits(text, size.columns, available_rows, chop=chop):
self.cmd.stdout.write(text)
self.cmd.stdout.flush()
return
Expand All @@ -736,6 +777,7 @@ def page(self, text: str, *, chop: bool) -> None:
entered = False
restored = False
close_error: BaseException | None = None
handoff = contextlib.ExitStack()

def restore() -> None:
"""Give the application back to the display, whichever thread is doing it.
Expand Down Expand Up @@ -763,6 +805,10 @@ def enter() -> None:
# duration -- otherwise the pager swaps in its layout and nothing is ever painted.
self._set_render_suppressed(False)
self.app.renderer.erase()
if reserved is not None:
# The very first pager layout must see the physical size. Releasing from
# enter_alternate_screen during replay is too late: that frame was measured.
handoff.enter_context(reserved.suspended(defer_band_clear=True))
self.app.layout = layout
self.app.key_bindings = pager.bindings
self.app.editing_mode = EditingMode.EMACS
Expand All @@ -776,8 +822,20 @@ def leave() -> None:
entered = False
try:
self.app.renderer.erase()
except BaseException:
# An erase can fail before quitting the alternate screen. Complete upstream's
# mode/buffer cleanup before returning the main-screen reservation; never
# retry the erase itself, which may already have changed visible output.
with contextlib.suppress(Exception):
transaction = (
reserved.lock.transaction("pager cleanup") if reserved is not None else contextlib.nullcontext()
)
with transaction:
self.app.renderer.reset()
raise
finally:
restore()
handoff.__exit__(*sys.exc_info())
self.app.renderer.request_absolute_cursor_position()
self.app.invalidate()

Expand Down Expand Up @@ -813,6 +871,7 @@ def close() -> None:
# the full-screen flag and editing mode would carry into the next prompt.
if not restored:
restore()
handoff.__exit__(*sys.exc_info())
# A reservation abandoned while the pager was open deferred its legacy fallback until
# the pager closed; now that it has, on the main screen, finish it.
if self._legacy_fallback_pending and self.thread_is_alive:
Expand Down
3 changes: 3 additions & 0 deletions cmd2/managed_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ def write(self, data: str) -> int:
"""
with self._lock.transaction("managed write"):
try:
before_write = getattr(self.bridge, "before_managed_write", None)
if before_write is not None:
before_write()
if self._output is None:
written = self._stream.write(data)
else:
Expand Down
10 changes: 10 additions & 0 deletions cmd2/prompt_toolkit_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,13 @@ def forget_unfinished_command_output(self) -> None:
"""
self._unfinished_command_output = False

def before_managed_write(self) -> None:
"""Remove retained pager-startup cells before output can scroll them into history.

Called inside the writer's transaction, before emitting any command text.
"""
self._display.clear_deferred_band()

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

Expand Down Expand Up @@ -831,6 +838,9 @@ def commit(self, prepared: PreparedRender) -> bool:
self.require_resynchronization("the frame was laid out for a different size")
return False
try:
# Preparation may be expensive for a large pager. Its main-screen bar is
# retained until this validated batch is ready to replace the screen.
self._display.clear_deferred_band()
prepared.batch.replay(self._display.output)
self._display.output.flush()
except Exception as error: # noqa: BLE001 - the terminal's state is now unknown
Expand Down
9 changes: 7 additions & 2 deletions cmd2/reserved_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def __init__(self, wrapped: Output, display: "TerminalDisplay") -> None:
"""
self._wrapped = wrapped
self._display = display
self._alternate_handoff_owned = False
# A plain attribute rather than a property: Output declares stdout as writable, and
# code that reaches for the real stream must find the backend's, not a copy of it.
self.stdout = getattr(wrapped, "stdout", None)
Expand Down Expand Up @@ -158,13 +159,17 @@ def enter_alternate_screen(self) -> None:
nothing about a reservation. Margins are restored before the switch so the main
buffer is left in the state the shell expects if the switch is never undone.
"""
self._display.release_region_for_handoff()
self._alternate_handoff_owned = not self._display.handoff_active
if self._alternate_handoff_owned:
self._display.release_region_for_handoff()
self._wrapped.enter_alternate_screen()

def quit_alternate_screen(self) -> None:
"""Return to the main buffer and re-establish the reservation for its geometry."""
self._wrapped.quit_alternate_screen()
self._display.reacquire_region_after_handoff()
if self._alternate_handoff_owned:
self._alternate_handoff_owned = False
self._display.reacquire_region_after_handoff()

def scroll_buffer_to_prompt(self) -> None:
"""Scroll the Windows viewport to the prompt, then re-check the viewport origin.
Expand Down
Loading