Skip to content

Commit fe7c2ad

Browse files
Flush window title updates without waiting for a prompt (#1790)
* Flush window title updates on the prompt event loop * Fix window title routing during select * Explain select session traversal for title updates * Route synchronous title updates through each Cmd output * Write window titles on the running prompt's event loop instead of locking the output Replace the locked output proxy from #1783 with a smaller change confined to set_window_title(). The title is written and flushed on main_session.output. When another thread calls it while a prompt or select() menu is running, the write is scheduled on that prompt's event loop so it doesn't interleave with rendering. A pending title is written when the prompt finishes in case the loop stops before running the callback. This drops the _LockedOutput proxy, the main_session property setter, and the forced per-Cmd output driver, restoring prompt-toolkit's dumb-terminal fallback, the ambient AppSession output when stdout is sys.stdout, and isinstance checks on the renderer's output. * Don't assert when a worker's title becomes visible during a prompt The loop thread can write the title before the worker checks the stream, which happens on free-threaded Python. Only a call on the loop thread is guaranteed to have written it before returning. * Harden window title writes found in review - Read the select() AppSession and its app once in _get_prompt_loop(), since the main thread clears them when select() finishes. - Ignore output errors when a title is written on a prompt's event loop or during prompt and select() cleanup, so they can't disrupt the prompt or replace its result or exception. - Use a reentrant lock so a signal handler which sets the title while one is being written doesn't deadlock. - Write titles to a terminal stdout when stdin isn't a terminal and the main session uses a DummyOutput. - Document that a title set from another thread appears once the prompt's loop runs its next callback. * Fix window title edge cases found in second review - When a signal handler sets the title while this thread is writing one, only record it. The outer call writes pending titles until none remain, so the latest title wins and writes never nest in the same stream. - Ignore ValueError from a closed stream as well as OSError when writing a title quietly. - Write a title left pending by a prompt after patch_stdout() exits, so it can't race with its writer thread. - Create the fallback terminal output the first time a title needs it, from the stdout captured when the app was created. This also covers a main_session replaced later by one with a DummyOutput, and titles set while output is redirected. - Share the list of terminal initialization errors between the main session and the title fallback. * Schedule every title on a running prompt's loop and check stdout lazily - Treat a prompt as running while its application's loop is set, which covers prompt-toolkit's teardown after it clears is_running but can still write to the terminal. - Schedule the title on the prompt's loop even when called on that loop, so a signal handler can't nest a title write inside prompt-toolkit's own flush. - Check whether the fallback stdout is a terminal the first time a title needs it, so constructing a Cmd with a stdout that lacks isatty() or is closed works as it did before. - Describe the stdout fallback in the set_window_title() docstring and the prompt docs. - Run test workers through futures so their exceptions reach the test without a blind except and noqa. * Coalesce queued title callbacks and clarify when titles use the stdout fallback - While a title callback is queued on a prompt's loop, later titles only replace the pending title, so a burst of updates schedules one callback. The callback and prompt cleanup clear the queued loop. - Keep the stdout this app was created with in its own field, and track whether its fallback output has been checked with a flag instead of clearing that field. A cached_property doesn't work here, because inspect.getmembers() evaluates properties while a Cmd is constructed. - Describe all the cases where prompts use a dummy output, and note that a direct write from another thread isn't synchronized with other writers to the terminal. * Give the prompt cleanup test a VT100 output for patch_stdout() On a Windows CI runner the default app session output is a Win32Output, which raises NoConsoleScreenBufferError because there's no console. Run the test inside create_app_session() like the other prompt tests. * Keep synchronous window title errors with their caller (#1793) --------- Co-authored-by: KennyMcSimpson <70093673+KennyMcSimpson@users.noreply.github.com>
1 parent 57c3a66 commit fe7c2ad

6 files changed

Lines changed: 1022 additions & 25 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
## 4.4.0 (TBD)
22

33
- Breaking Changes
4+
- `set_window_title()` is now an instance method. Call `app.set_window_title(title)` or
5+
`self.set_window_title(title)` so title changes use that application's terminal.
46
- `History.get()` and `History.span()` now raise `HistoryIndexError`, an `IndexError` subclass,
57
for invalid indices and bounded ranges selecting no history items. Unbounded ranges (`:` and
68
`..`) can still return an empty dictionary. Starts before the beginning of history are
@@ -32,6 +34,12 @@
3234
it directly would then have retained current-session entries hidden from unbounded ranges and
3335
searches. Interactive `history` use was unaffected, since `cmd2` only truncates when
3436
persisting history at exit.
37+
- Fixed `set_window_title()` leaving title changes buffered until the next prompt render. The
38+
title is now flushed before the method returns when no prompt is running. While a prompt or
39+
`select()` menu is running, it is written on that prompt's event loop so it doesn't interleave
40+
with rendering, and output errors are ignored so they can't disrupt the prompt. Titles are
41+
still written to a terminal stdout when prompts use a dummy output, such as when stdin isn't a
42+
terminal.
3543

3644
## 4.3.1 (October 24, 2026)
3745

‎cmd2/cmd2.py‎

Lines changed: 166 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
# import this module, many of these imports are lazy-loaded
3030
# i.e. we only import the module when we use it.
3131
import argparse
32+
import asyncio
3233
import contextlib
3334
import copy
3435
import dataclasses
@@ -71,7 +72,7 @@
7172
filters,
7273
print_formatted_text,
7374
)
74-
from prompt_toolkit.application import create_app_session, get_app
75+
from prompt_toolkit.application import Application, AppSession, create_app_session, get_app
7576
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
7677
from prompt_toolkit.completion import Completer, DummyCompleter
7778
from prompt_toolkit.formatted_text import ANSI, AnyFormattedText
@@ -82,9 +83,9 @@
8283
from prompt_toolkit.keys import Keys
8384
from prompt_toolkit.layout import Window, walk
8485
from prompt_toolkit.layout.containers import ConditionalContainer, FloatContainer
85-
from prompt_toolkit.output import DummyOutput, create_output
86+
from prompt_toolkit.output import DummyOutput, Output, create_output
8687
from prompt_toolkit.patch_stdout import patch_stdout
87-
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title
88+
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice
8889
from prompt_toolkit.styles import DynamicStyle
8990
from rich.console import (
9091
Group,
@@ -220,6 +221,11 @@ def __init__(self, msg: str = "") -> None:
220221
from prompt_toolkit.buffer import Buffer
221222

222223

224+
# Errors raised when prompt-toolkit can't use a terminal. For example, GitHub Actions on Windows reports
225+
# isatty() as True but has no real console.
226+
_TERMINAL_INIT_ERRORS = (NoConsoleScreenBufferError, AttributeError, ValueError)
227+
228+
223229
class _SavedCmd2Env:
224230
"""cmd2 environment settings that are backed up when entering an interactive Python shell."""
225231

@@ -689,6 +695,26 @@ def __init__(
689695
self._alert_thread: threading.Thread | None = None
690696
self._alert_prompt_timestamp: float = 0.0 # Uses time.monotonic()
691697

698+
# Members related to setting the window title. The lock is reentrant so a signal handler which sets the title
699+
# while the main thread is writing one doesn't deadlock. Such a nested call only records its title, and the
700+
# outer call writes it next.
701+
self._window_title_lock = threading.RLock()
702+
self._pending_window_title: str | None = None
703+
self._writing_window_title = False
704+
705+
# The prompt loop on which a title callback is queued but hasn't run yet. Later titles set while it's queued
706+
# only replace the pending title, which that callback writes.
707+
self._window_title_callback_loop: asyncio.AbstractEventLoop | None = None
708+
709+
# The stdout this app was created with. When the main session uses a DummyOutput, titles are written to it if
710+
# it's a terminal, including while a command's output is redirected. See _get_window_title_fallback_output().
711+
self._window_title_stdout: TextIO = self.stdout
712+
self._window_title_fallback_checked = False
713+
self._window_title_fallback_output: Output | None = None
714+
715+
# The AppSession of a select() menu while it is displayed, so window titles can find its event loop.
716+
self._select_app_session: AppSession | None = None
717+
692718
# Add functions decorated to be subcommands
693719
self._register_subcommands(self)
694720

@@ -838,7 +864,7 @@ def _create_main_session(
838864
kwargs["output"] = create_output(stdout=self.stdout)
839865
return PromptSession(**kwargs)
840866

841-
except (NoConsoleScreenBufferError, AttributeError, ValueError):
867+
except _TERMINAL_INIT_ERRORS:
842868
# Fallback to dummy input/output if PromptSession initialization fails.
843869
# This can happen in some CI environments (like GitHub Actions on Windows)
844870
# where isatty() is True but there is no real console.
@@ -3659,14 +3685,19 @@ def _read_raw_input(
36593685
if not callable(prompt):
36603686
prompt = pt_filter_style(prompt)
36613687

3662-
with patch_stdout():
3663-
try:
3664-
# Set this session as the active one for UI/completion logic.
3665-
self.active_session = session
3666-
return session.prompt(prompt, **prompt_kwargs)
3667-
finally:
3668-
# Revert back to the main session.
3669-
self.active_session = self.main_session
3688+
try:
3689+
with patch_stdout():
3690+
try:
3691+
# Set this session as the active one for UI/completion logic.
3692+
self.active_session = session
3693+
return session.prompt(prompt, **prompt_kwargs)
3694+
finally:
3695+
# Revert back to the main session.
3696+
self.active_session = self.main_session
3697+
finally:
3698+
# Write any title whose callback the prompt's event loop didn't run. Do this after patch_stdout()
3699+
# exits so the write can't race with its writer thread on the same output.
3700+
self._flush_pending_window_title_quietly()
36703701

36713702
# We're not at a terminal, so we're likely reading from a file or a pipe.
36723703
prompt_obj = prompt if isinstance(prompt, (ANSI, str)) else prompt()
@@ -4794,8 +4825,13 @@ def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], p
47944825
if self._is_tty_session(self.main_session):
47954826
try:
47964827
while True:
4797-
with create_app_session(input=self.main_session.input, output=self.main_session.output):
4798-
result = choice(message=prompt, options=fulloptions)
4828+
with create_app_session(input=self.main_session.input, output=self.main_session.output) as app_session:
4829+
self._select_app_session = app_session
4830+
try:
4831+
result = choice(message=prompt, options=fulloptions)
4832+
finally:
4833+
self._select_app_session = None
4834+
self._flush_pending_window_title_quietly()
47994835
if result is not None:
48004836
return result
48014837
except KeyboardInterrupt:
@@ -5883,13 +5919,125 @@ def add_alert(
58835919
self._alert_queue.append(alert)
58845920
self._alert_condition.notify_all()
58855921

5886-
@staticmethod
5887-
def set_window_title(title: str) -> None: # pragma: no cover
5888-
"""Set the terminal window title.
5922+
def set_window_title(self, title: str) -> None:
5923+
"""Set the title of this application's terminal.
5924+
5925+
The title is written to the output used by the main prompt, custom input prompts, and
5926+
select() menus. That is a dummy output when stdin or stdout isn't a terminal, or when the
5927+
terminal couldn't be set up. In that case, the title is written to the stdout this app was
5928+
created with instead, if that is a terminal. Otherwise, no title is written.
5929+
5930+
This can be called from any thread. If no prompt is running, then the title is written and
5931+
flushed before this returns, and output errors propagate to the caller. Such a direct write
5932+
from another thread isn't synchronized with other writers to the terminal, such as a prompt
5933+
which is just starting or finishing.
5934+
5935+
While a prompt is running, the title is written on the prompt's event loop, even when this is
5936+
called on that loop, so it doesn't interleave with rendering. It appears once the loop runs its
5937+
next callback, which can be delayed while the loop is busy, such as in a slow completer. If
5938+
several titles are set before then, only the latest is written. Output errors are ignored so
5939+
they can't disrupt the prompt.
58895940
58905941
:param title: the new window title
58915942
"""
5892-
set_title(title)
5943+
with self._window_title_lock:
5944+
self._pending_window_title = title
5945+
loop = self._get_prompt_loop()
5946+
if loop is not None:
5947+
if self._window_title_callback_loop is loop:
5948+
# A callback queued on this loop hasn't run yet, and it will write this title.
5949+
return
5950+
5951+
try:
5952+
loop.call_soon_threadsafe(self._flush_pending_window_title_quietly)
5953+
except RuntimeError:
5954+
# The loop closed after we found it, so the prompt is no longer rendering.
5955+
pass
5956+
else:
5957+
# If the prompt finishes before the loop runs the callback, then the prompt's
5958+
# cleanup writes the pending title.
5959+
self._window_title_callback_loop = loop
5960+
return
5961+
5962+
# Keep ownership until the direct write finishes, so another caller or prompt cleanup cannot consume
5963+
# this request and receive or suppress its output error.
5964+
self._flush_pending_window_title()
5965+
5966+
def _get_prompt_loop(self) -> asyncio.AbstractEventLoop | None:
5967+
"""Return the event loop of the prompt-toolkit application drawing on this terminal, if one is running.
5968+
5969+
An application's loop is set for its whole run, including startup and teardown, when its is_running flag
5970+
is already cleared but the loop can still write to the terminal.
5971+
"""
5972+
apps: list[Application[Any]] = [self.active_session.app]
5973+
5974+
# The main thread clears these when select() finishes, so read each one only once.
5975+
select_app_session = self._select_app_session
5976+
if select_app_session is not None:
5977+
select_app = select_app_session.app
5978+
if select_app is not None:
5979+
apps.append(select_app)
5980+
5981+
for app in apps:
5982+
loop = app.loop
5983+
if loop is not None:
5984+
return loop
5985+
return None
5986+
5987+
def _get_window_title_fallback_output(self) -> Output | None:
5988+
"""Return an output for the stdout this app was created with, or None if that isn't a terminal.
5989+
5990+
Window titles use it when the main session has a DummyOutput. It's created the first time a title needs it.
5991+
This isn't a cached_property because inspect.getmembers() evaluates properties while this app is constructed.
5992+
"""
5993+
if not self._window_title_fallback_checked:
5994+
self._window_title_fallback_checked = True
5995+
5996+
# A stream without isatty(), or a closed one, raises AttributeError or ValueError here.
5997+
with contextlib.suppress(*_TERMINAL_INIT_ERRORS):
5998+
if self._window_title_stdout.isatty():
5999+
self._window_title_fallback_output = create_output(stdout=self._window_title_stdout)
6000+
6001+
return self._window_title_fallback_output
6002+
6003+
def _get_window_title_output(self) -> Output:
6004+
"""Return the output which window titles are written to."""
6005+
output = self.main_session.output
6006+
if isinstance(output, DummyOutput) and (fallback := self._get_window_title_fallback_output()) is not None:
6007+
return fallback
6008+
return output
6009+
6010+
def _flush_pending_window_title(self) -> None:
6011+
"""Write and flush the most recent title passed to set_window_title(), if it hasn't been written yet."""
6012+
with self._window_title_lock:
6013+
if self._writing_window_title:
6014+
# This thread is already writing a title, such as when a signal handler calls set_window_title().
6015+
# Writing now would nest inside that write, so leave the title pending for the outer call.
6016+
return
6017+
6018+
self._writing_window_title = True
6019+
try:
6020+
# Keep going until no newer title arrived during the last write, so the latest title always wins.
6021+
while (title := self._pending_window_title) is not None:
6022+
self._pending_window_title = None
6023+
output = self._get_window_title_output()
6024+
output.set_title(title)
6025+
output.flush()
6026+
finally:
6027+
self._writing_window_title = False
6028+
6029+
def _flush_pending_window_title_quietly(self) -> None:
6030+
"""Flush a pending window title on a prompt's event loop or during its cleanup, ignoring output errors.
6031+
6032+
An error here would otherwise disrupt the prompt or replace its result. Writing to a closed stream raises
6033+
ValueError rather than OSError.
6034+
"""
6035+
with self._window_title_lock:
6036+
# Either this is the queued callback, or the prompt finished and its loop won't run that callback.
6037+
# Either way, the next title set during a prompt needs a new callback.
6038+
self._window_title_callback_loop = None
6039+
with contextlib.suppress(OSError, ValueError):
6040+
self._flush_pending_window_title()
58936041

58946042
def enable_command(self, command: str) -> None:
58956043
"""Enable a command by restoring its functions.

‎docs/examples/embedding_in_textual.md‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,6 @@ The example handles several details which are easy to miss when building somethi
9696
- The postparsing hooks stop running as soon as one of them sets `stop` or raises an exception, but
9797
the finalization hooks still run. If your application has other postparsing hooks, register the
9898
one which counts commands first so that it is never skipped.
99-
- `prompt-toolkit` buffers its output, so flush it after setting the window title when a command
100-
starts. Otherwise the "running" state only reaches the Textual application after the command is
101-
done.
10299
- Any program run in the terminal, for example by the `shell` command, can set the window title.
103100
Validate a status document before using it. Besides `json.JSONDecodeError`, `json.loads` raises
104101
`ValueError` for integers with too many digits and `RecursionError` for documents which are nested

‎docs/features/prompt.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@ Alerts can interact with the CLI in two ways:
5454

5555
- [cmd2.Cmd.set_window_title][]
5656

57+
Call this method on your application instance, for example `app.set_window_title("Working…")`. The
58+
title is sent to the same terminal as that instance's main prompt, custom input prompts, and
59+
`select()` menus, including while a command is running.
60+
61+
The method can be called from any thread. If no prompt is running, then the title is flushed before
62+
the method returns, and output errors such as a closed terminal propagate to the caller. Such a
63+
direct write from another thread isn't synchronized with other writers to the terminal, such as a
64+
prompt which is just starting or finishing.
65+
66+
While a prompt or `select()` menu is running, the title is written on that prompt's event loop, even
67+
when the method is called on that loop, so it doesn't interleave with rendering. The title appears
68+
once the loop runs its next callback, which can be delayed while the loop is busy, for example in a
69+
slow completer. If several titles are set before then, only the latest is written. Output errors are
70+
ignored in that case so they can't disrupt the prompt.
71+
72+
Prompts use a dummy output when stdin or stdout isn't a terminal, or when the terminal couldn't be
73+
set up. In that case, titles are written to the stdout the application was created with instead, if
74+
that is a terminal, including while a command's output is redirected. Otherwise, no title is
75+
emitted.
76+
5777
The easiest way to understand these functions is to see the
5878
[async_printing.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_printing.py)
5979
example for a demonstration.

‎examples/textual_tty_embed.py‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@
4040
from datetime import datetime
4141
from typing import Any, ClassVar
4242

43-
from prompt_toolkit.application import get_app_session
44-
4543
import cmd2
4644

4745
# Prefix which marks a window title as a status update from the cmd2 application
@@ -92,8 +90,6 @@ def _report_status(self, state: str) -> None:
9290
# hook reports the end of the command. Writing to it then fails, which doesn't matter because nobody can see it.
9391
with contextlib.suppress(OSError):
9492
self.set_window_title(STATUS_PREFIX + json.dumps(status))
95-
# prompt-toolkit buffers its output, so flush it or the "running" state would only appear after the command ends
96-
get_app_session().output.flush()
9793

9894
def preloop(self) -> None:
9995
"""Report the initial status before the first prompt."""

0 commit comments

Comments
 (0)