Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2997f51
Flush window title updates on the prompt event loop
KennyMcSimpson Oct 24, 2026
5028406
Merge remote-tracking branch 'origin/main' into fix/window-title-flush
KennyMcSimpson Oct 24, 2026
e49ec13
Fix window title routing during select
KennyMcSimpson Oct 25, 2026
c78994f
Explain select session traversal for title updates
KennyMcSimpson Oct 25, 2026
001e0ea
Merge branch 'main' into fix/window-title-flush
tleonhardt Oct 27, 2026
f12523d
Merge branch 'main' into fix/window-title-flush
tleonhardt Oct 28, 2026
a227158
Route synchronous title updates through each Cmd output
KennyMcSimpson Oct 28, 2026
d9caa63
Merge branch 'main' into fix/window-title-flush
tleonhardt Oct 29, 2026
460b0c8
Merge PR #1783 (Flush window title updates without waiting for a prompt)
tleonhardt Oct 29, 2026
70da13a
Write window titles on the running prompt's event loop instead of loc…
tleonhardt Oct 29, 2026
b0e8996
Don't assert when a worker's title becomes visible during a prompt
tleonhardt Oct 29, 2026
5f46a34
Harden window title writes found in review
tleonhardt Oct 29, 2026
a33ccdd
Fix window title edge cases found in second review
tleonhardt Oct 29, 2026
dcdfdc5
Schedule every title on a running prompt's loop and check stdout lazily
tleonhardt Oct 29, 2026
57081a2
Coalesce queued title callbacks and clarify when titles use the stdou…
tleonhardt Oct 29, 2026
e72ceb5
Give the prompt cleanup test a VT100 output for patch_stdout()
tleonhardt Oct 29, 2026
4472a0a
Keep synchronous window title errors with their caller (#1793)
KennyMcSimpson Oct 29, 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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
## 4.4.0 (TBD)

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

## 4.3.1 (October 24, 2026)

Expand Down
184 changes: 166 additions & 18 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
# import this module, many of these imports are lazy-loaded
# i.e. we only import the module when we use it.
import argparse
import asyncio
import contextlib
import copy
import dataclasses
Expand Down Expand Up @@ -71,7 +72,7 @@
filters,
print_formatted_text,
)
from prompt_toolkit.application import create_app_session, get_app
from prompt_toolkit.application import Application, AppSession, create_app_session, get_app
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import Completer, DummyCompleter
from prompt_toolkit.formatted_text import ANSI, AnyFormattedText
Expand All @@ -82,9 +83,9 @@
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import Window, walk
from prompt_toolkit.layout.containers import ConditionalContainer, FloatContainer
from prompt_toolkit.output import DummyOutput, create_output
from prompt_toolkit.output import DummyOutput, Output, create_output
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice
from prompt_toolkit.styles import DynamicStyle
from rich.console import (
Group,
Expand Down Expand Up @@ -220,6 +221,11 @@ def __init__(self, msg: str = "") -> None:
from prompt_toolkit.buffer import Buffer


# Errors raised when prompt-toolkit can't use a terminal. For example, GitHub Actions on Windows reports
# isatty() as True but has no real console.
_TERMINAL_INIT_ERRORS = (NoConsoleScreenBufferError, AttributeError, ValueError)


class _SavedCmd2Env:
"""cmd2 environment settings that are backed up when entering an interactive Python shell."""

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

# Members related to setting the window title. The lock is reentrant so a signal handler which sets the title
# while the main thread is writing one doesn't deadlock. Such a nested call only records its title, and the
# outer call writes it next.
self._window_title_lock = threading.RLock()
self._pending_window_title: str | None = None
self._writing_window_title = False

# The prompt loop on which a title callback is queued but hasn't run yet. Later titles set while it's queued
# only replace the pending title, which that callback writes.
self._window_title_callback_loop: asyncio.AbstractEventLoop | None = None

# The stdout this app was created with. When the main session uses a DummyOutput, titles are written to it if
# it's a terminal, including while a command's output is redirected. See _get_window_title_fallback_output().
self._window_title_stdout: TextIO = self.stdout
self._window_title_fallback_checked = False
self._window_title_fallback_output: Output | None = None

# The AppSession of a select() menu while it is displayed, so window titles can find its event loop.
self._select_app_session: AppSession | None = None

# Add functions decorated to be subcommands
self._register_subcommands(self)

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

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

with patch_stdout():
try:
# Set this session as the active one for UI/completion logic.
self.active_session = session
return session.prompt(prompt, **prompt_kwargs)
finally:
# Revert back to the main session.
self.active_session = self.main_session
try:
with patch_stdout():
try:
# Set this session as the active one for UI/completion logic.
self.active_session = session
return session.prompt(prompt, **prompt_kwargs)
finally:
# Revert back to the main session.
self.active_session = self.main_session
finally:
# Write any title whose callback the prompt's event loop didn't run. Do this after patch_stdout()
# exits so the write can't race with its writer thread on the same output.
self._flush_pending_window_title_quietly()

# We're not at a terminal, so we're likely reading from a file or a pipe.
prompt_obj = prompt if isinstance(prompt, (ANSI, str)) else prompt()
Expand Down Expand Up @@ -4794,8 +4825,13 @@ def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], p
if self._is_tty_session(self.main_session):
try:
while True:
with create_app_session(input=self.main_session.input, output=self.main_session.output):
result = choice(message=prompt, options=fulloptions)
with create_app_session(input=self.main_session.input, output=self.main_session.output) as app_session:
self._select_app_session = app_session
try:
result = choice(message=prompt, options=fulloptions)
finally:
self._select_app_session = None
self._flush_pending_window_title_quietly()
if result is not None:
return result
except KeyboardInterrupt:
Expand Down Expand Up @@ -5883,13 +5919,125 @@ def add_alert(
self._alert_queue.append(alert)
self._alert_condition.notify_all()

@staticmethod
def set_window_title(title: str) -> None: # pragma: no cover
"""Set the terminal window title.
def set_window_title(self, title: str) -> None:
"""Set the title of this application's terminal.

The title is written to the output used by the main prompt, custom input prompts, and
select() menus. That is a dummy output when stdin or stdout isn't a terminal, or when the
terminal couldn't be set up. In that case, the title is written to the stdout this app was
created with instead, if that is a terminal. Otherwise, no title is written.

This can be called from any thread. If no prompt is running, then the title is written and
flushed before this returns, and output errors propagate to the caller. Such a direct write
from another thread isn't synchronized with other writers to the terminal, such as a prompt
which is just starting or finishing.

While a prompt is running, the title is written on the prompt's event loop, even when this is
called on that loop, so it doesn't interleave with rendering. It appears once the loop runs its
next callback, which can be delayed while the loop is busy, such as in a slow completer. If
several titles are set before then, only the latest is written. Output errors are ignored so
they can't disrupt the prompt.

:param title: the new window title
"""
set_title(title)
with self._window_title_lock:
self._pending_window_title = title
loop = self._get_prompt_loop()
if loop is not None:
if self._window_title_callback_loop is loop:
# A callback queued on this loop hasn't run yet, and it will write this title.
return

try:
loop.call_soon_threadsafe(self._flush_pending_window_title_quietly)
except RuntimeError:
# The loop closed after we found it, so the prompt is no longer rendering.
pass
else:
# If the prompt finishes before the loop runs the callback, then the prompt's
# cleanup writes the pending title.
self._window_title_callback_loop = loop
return

# Keep ownership until the direct write finishes, so another caller or prompt cleanup cannot consume
# this request and receive or suppress its output error.
self._flush_pending_window_title()

def _get_prompt_loop(self) -> asyncio.AbstractEventLoop | None:
"""Return the event loop of the prompt-toolkit application drawing on this terminal, if one is running.

An application's loop is set for its whole run, including startup and teardown, when its is_running flag
is already cleared but the loop can still write to the terminal.
"""
apps: list[Application[Any]] = [self.active_session.app]

# The main thread clears these when select() finishes, so read each one only once.
select_app_session = self._select_app_session
if select_app_session is not None:
select_app = select_app_session.app
if select_app is not None:
apps.append(select_app)

for app in apps:
loop = app.loop
if loop is not None:
return loop
return None

def _get_window_title_fallback_output(self) -> Output | None:
"""Return an output for the stdout this app was created with, or None if that isn't a terminal.

Window titles use it when the main session has a DummyOutput. It's created the first time a title needs it.
This isn't a cached_property because inspect.getmembers() evaluates properties while this app is constructed.
"""
if not self._window_title_fallback_checked:
self._window_title_fallback_checked = True

# A stream without isatty(), or a closed one, raises AttributeError or ValueError here.
with contextlib.suppress(*_TERMINAL_INIT_ERRORS):
if self._window_title_stdout.isatty():
self._window_title_fallback_output = create_output(stdout=self._window_title_stdout)

return self._window_title_fallback_output

def _get_window_title_output(self) -> Output:
"""Return the output which window titles are written to."""
output = self.main_session.output
if isinstance(output, DummyOutput) and (fallback := self._get_window_title_fallback_output()) is not None:
return fallback
return output

def _flush_pending_window_title(self) -> None:
"""Write and flush the most recent title passed to set_window_title(), if it hasn't been written yet."""
with self._window_title_lock:
if self._writing_window_title:
# This thread is already writing a title, such as when a signal handler calls set_window_title().
# Writing now would nest inside that write, so leave the title pending for the outer call.
return

self._writing_window_title = True
try:
# Keep going until no newer title arrived during the last write, so the latest title always wins.
while (title := self._pending_window_title) is not None:
self._pending_window_title = None
output = self._get_window_title_output()
output.set_title(title)
output.flush()
finally:
self._writing_window_title = False

def _flush_pending_window_title_quietly(self) -> None:
"""Flush a pending window title on a prompt's event loop or during its cleanup, ignoring output errors.

An error here would otherwise disrupt the prompt or replace its result. Writing to a closed stream raises
ValueError rather than OSError.
"""
with self._window_title_lock:
# Either this is the queued callback, or the prompt finished and its loop won't run that callback.
# Either way, the next title set during a prompt needs a new callback.
self._window_title_callback_loop = None
with contextlib.suppress(OSError, ValueError):
self._flush_pending_window_title()

def enable_command(self, command: str) -> None:
"""Enable a command by restoring its functions.
Expand Down
3 changes: 0 additions & 3 deletions docs/examples/embedding_in_textual.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,6 @@ The example handles several details which are easy to miss when building somethi
- The postparsing hooks stop running as soon as one of them sets `stop` or raises an exception, but
the finalization hooks still run. If your application has other postparsing hooks, register the
one which counts commands first so that it is never skipped.
- `prompt-toolkit` buffers its output, so flush it after setting the window title when a command
starts. Otherwise the "running" state only reaches the Textual application after the command is
done.
- Any program run in the terminal, for example by the `shell` command, can set the window title.
Validate a status document before using it. Besides `json.JSONDecodeError`, `json.loads` raises
`ValueError` for integers with too many digits and `RecursionError` for documents which are nested
Expand Down
20 changes: 20 additions & 0 deletions docs/features/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ Alerts can interact with the CLI in two ways:

- [cmd2.Cmd.set_window_title][]

Call this method on your application instance, for example `app.set_window_title("Working…")`. The
title is sent to the same terminal as that instance's main prompt, custom input prompts, and
`select()` menus, including while a command is running.

The method can be called from any thread. If no prompt is running, then the title is flushed before
the method returns, and output errors such as a closed terminal propagate to the caller. Such a
direct write from another thread isn't synchronized with other writers to the terminal, such as a
prompt which is just starting or finishing.

While a prompt or `select()` menu is running, the title is written on that prompt's event loop, even
when the method is called on that loop, so it doesn't interleave with rendering. The title appears
once the loop runs its next callback, which can be delayed while the loop is busy, for example in a
slow completer. If several titles are set before then, only the latest is written. Output errors are
ignored in that case so they can't disrupt the prompt.

Prompts use a dummy output when stdin or stdout isn't a terminal, or when the terminal couldn't be
set up. In that case, titles are written to the stdout the application was created with instead, if
that is a terminal, including while a command's output is redirected. Otherwise, no title is
emitted.

The easiest way to understand these functions is to see the
[async_printing.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_printing.py)
example for a demonstration.
Expand Down
4 changes: 0 additions & 4 deletions examples/textual_tty_embed.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@
from datetime import datetime
from typing import Any, ClassVar

from prompt_toolkit.application import get_app_session

import cmd2

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

def preloop(self) -> None:
"""Report the initial status before the first prompt."""
Expand Down
Loading
Loading