Skip to content

Commit 2997f51

Browse files
Flush window title updates on the prompt event loop
1 parent 07495f8 commit 2997f51

4 files changed

Lines changed: 190 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
- Bug Fixes
44
- Fixed `@with_annotated` command `--help` output bypassing the application's output stream,
55
leaving redirected help files empty and preventing help from being captured by embedded apps.
6+
- Fixed `set_window_title()` leaving title changes buffered until the next prompt render.
7+
Updates during a running prompt are sent through its event loop to avoid concurrent writes
8+
from background threads.
69

710
## 4.3.0 (October 13, 2026)
811

‎cmd2/cmd2.py‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
filters,
7272
print_formatted_text,
7373
)
74-
from prompt_toolkit.application import create_app_session, get_app
74+
from prompt_toolkit.application import create_app_session, get_app, get_app_or_none, get_app_session
7575
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
7676
from prompt_toolkit.completion import Completer, DummyCompleter
7777
from prompt_toolkit.formatted_text import ANSI, AnyFormattedText
@@ -84,7 +84,7 @@
8484
from prompt_toolkit.layout.containers import ConditionalContainer, FloatContainer
8585
from prompt_toolkit.output import DummyOutput, create_output
8686
from prompt_toolkit.patch_stdout import patch_stdout
87-
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title
87+
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice
8888
from prompt_toolkit.styles import DynamicStyle
8989
from rich.console import (
9090
Group,
@@ -5862,12 +5862,35 @@ def add_alert(
58625862
self._alert_condition.notify_all()
58635863

58645864
@staticmethod
5865-
def set_window_title(title: str) -> None: # pragma: no cover
5865+
def set_window_title(title: str) -> None:
58665866
"""Set the terminal window title.
58675867
5868+
When a prompt-toolkit application is running, update the title on its event loop
5869+
to avoid writing to its output buffer from a background thread.
5870+
Calls use the current prompt-toolkit app session. Worker threads serving a custom
5871+
app session must inherit that session's context.
5872+
58685873
:param title: the new window title
58695874
"""
5870-
set_title(title)
5875+
output = get_app_session().output
5876+
5877+
def set_title_and_flush() -> None:
5878+
output.set_title(title)
5879+
output.flush()
5880+
5881+
app = get_app_or_none()
5882+
loop = app.loop if app is not None else None
5883+
if loop is not None and loop.is_running():
5884+
try:
5885+
loop.call_soon_threadsafe(set_title_and_flush)
5886+
except RuntimeError:
5887+
# The application can finish and close its loop between the check and scheduling.
5888+
if not loop.is_closed():
5889+
raise
5890+
else:
5891+
return
5892+
5893+
set_title_and_flush()
58715894

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

‎docs/features/prompt.md‎

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

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

57+
Title changes are flushed without waiting for the next prompt. When a prompt-toolkit application is
58+
running, the update is scheduled on its event loop so background threads can change the title
59+
without writing to the prompt's output buffer directly.
60+
61+
Calls use the current prompt-toolkit app session. If you create a custom session, pass its context
62+
to worker threads, for example with `contextvars.copy_context().run`.
63+
5764
The easiest way to understand these functions is to see the
5865
[async_printing.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_printing.py)
5966
example for a demonstration.

‎tests/test_cmd2.py‎

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Cmd2 unit/functional testing"""
22

3+
import asyncio
34
import contextlib
5+
import contextvars
46
import io
57
import os
68
import signal
@@ -17,11 +19,14 @@
1719
from unittest import mock
1820

1921
import pytest
20-
from prompt_toolkit.application import create_app_session, get_app
22+
from prompt_toolkit.application import Application, create_app_session, get_app
23+
from prompt_toolkit.application.current import set_app
2124
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
2225
from prompt_toolkit.completion import DummyCompleter
26+
from prompt_toolkit.data_structures import Size
2327
from prompt_toolkit.input import DummyInput, create_pipe_input
2428
from prompt_toolkit.output import DummyOutput
29+
from prompt_toolkit.output.vt100 import Vt100_Output
2530
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession
2631
from rich.style import Style
2732
from rich.text import Text
@@ -4965,6 +4970,153 @@ def get_rprompt(self):
49654970
assert True not in drawn_while_done, "the right prompt was drawn into the committed final frame"
49664971

49674972

4973+
def test_set_window_title_is_flushed_during_a_command() -> None:
4974+
stream = io.StringIO()
4975+
output = Vt100_Output(stream, lambda: Size(rows=24, columns=80), enable_cpr=False)
4976+
observed = []
4977+
4978+
class TitleApp(cmd2.Cmd):
4979+
def do_work(self, _statement):
4980+
self.set_window_title("Working…")
4981+
observed.append(stream.getvalue())
4982+
4983+
with create_app_session(input=DummyInput(), output=output):
4984+
app = TitleApp(allow_cli_args=False)
4985+
app.onecmd_plus_hooks("work")
4986+
4987+
assert observed == ["\x1b]2;Working…\x07"]
4988+
4989+
4990+
@pytest.mark.parametrize("from_worker", [False, True])
4991+
def test_set_window_title_while_a_prompt_is_running(from_worker) -> None:
4992+
stream = io.StringIO()
4993+
title_threads = []
4994+
observed = []
4995+
workers = []
4996+
loop_thread = threading.get_ident()
4997+
4998+
class TitleOutput(Vt100_Output):
4999+
def set_title(self, title):
5000+
title_threads.append(threading.get_ident())
5001+
super().set_title(title)
5002+
5003+
output = TitleOutput(stream, lambda: Size(rows=24, columns=80), enable_cpr=False)
5004+
with create_pipe_input() as pipe_input, create_app_session(input=pipe_input, output=output):
5005+
app = Application(input=pipe_input, output=output)
5006+
sent = False
5007+
5008+
def check_title():
5009+
# Observe before exiting, because the final render also flushes output.
5010+
observed.append("\x1b]2;Working…\x07" in stream.getvalue())
5011+
app.exit()
5012+
5013+
def update_title():
5014+
cmd2.Cmd.set_window_title("Working…")
5015+
app.loop.call_soon_threadsafe(check_title)
5016+
5017+
def after_render(_sender):
5018+
nonlocal sent
5019+
if sent:
5020+
return
5021+
sent = True
5022+
# Start after the first frame, so its flush cannot hide a buffered title.
5023+
if from_worker:
5024+
context = contextvars.copy_context()
5025+
worker = threading.Thread(target=context.run, args=(update_title,), daemon=True)
5026+
workers.append(worker)
5027+
worker.start()
5028+
else:
5029+
update_title()
5030+
5031+
app.after_render += after_render
5032+
app.run(pre_run=lambda: app.loop.call_later(5, app.exit), set_exception_handler=False)
5033+
5034+
for worker in workers:
5035+
worker.join(timeout=5)
5036+
assert not worker.is_alive()
5037+
5038+
assert observed == [True]
5039+
assert title_threads == [loop_thread]
5040+
5041+
5042+
def test_set_window_title_without_a_terminal() -> None:
5043+
with create_app_session(input=DummyInput(), output=DummyOutput()):
5044+
cmd2.Cmd.set_window_title("Working…")
5045+
5046+
5047+
def test_set_window_title_after_the_application_loop_has_closed() -> None:
5048+
stream = io.StringIO()
5049+
output = Vt100_Output(stream, lambda: Size(rows=24, columns=80), enable_cpr=False)
5050+
app = Application(input=DummyInput(), output=output)
5051+
output.flush()
5052+
stream.seek(0)
5053+
stream.truncate()
5054+
loop = asyncio.new_event_loop()
5055+
loop.close()
5056+
app.loop = loop
5057+
5058+
with (
5059+
create_app_session(input=DummyInput(), output=output),
5060+
set_app(app),
5061+
mock.patch.object(output, "flush", wraps=output.flush) as flush,
5062+
):
5063+
cmd2.Cmd.set_window_title("Finished")
5064+
flush.assert_called_once_with()
5065+
5066+
assert stream.getvalue() == "\x1b]2;Finished\x07"
5067+
5068+
5069+
@pytest.mark.parametrize("close_during_scheduling", [True, False])
5070+
def test_set_window_title_when_scheduling_fails(monkeypatch, close_during_scheduling) -> None:
5071+
stream = io.StringIO()
5072+
output = Vt100_Output(stream, lambda: Size(rows=24, columns=80), enable_cpr=False)
5073+
app = Application(input=DummyInput(), output=output)
5074+
output.flush()
5075+
stream.seek(0)
5076+
stream.truncate()
5077+
loop = asyncio.new_event_loop()
5078+
app.loop = loop
5079+
schedule = loop.call_soon_threadsafe
5080+
ready = threading.Event()
5081+
loop.call_soon(ready.set)
5082+
worker = threading.Thread(target=loop.run_forever, daemon=True)
5083+
5084+
def fail_to_schedule(callback):
5085+
if not close_during_scheduling:
5086+
raise RuntimeError("scheduling failed")
5087+
# Close the real loop after set_window_title has observed it running.
5088+
schedule(loop.stop)
5089+
worker.join(timeout=5)
5090+
assert not worker.is_alive()
5091+
loop.close()
5092+
schedule(callback)
5093+
5094+
worker.start()
5095+
try:
5096+
assert ready.wait(timeout=5)
5097+
monkeypatch.setattr(loop, "call_soon_threadsafe", fail_to_schedule)
5098+
with (
5099+
create_app_session(input=DummyInput(), output=output),
5100+
set_app(app),
5101+
mock.patch.object(output, "flush", wraps=output.flush) as flush,
5102+
):
5103+
if close_during_scheduling:
5104+
cmd2.Cmd.set_window_title("Finished")
5105+
flush.assert_called_once_with()
5106+
assert stream.getvalue() == "\x1b]2;Finished\x07"
5107+
else:
5108+
with pytest.raises(RuntimeError, match="scheduling failed"):
5109+
cmd2.Cmd.set_window_title("Finished")
5110+
flush.assert_not_called()
5111+
assert stream.getvalue() == ""
5112+
finally:
5113+
if loop.is_running():
5114+
schedule(loop.stop)
5115+
worker.join(timeout=5)
5116+
assert not worker.is_alive()
5117+
loop.close()
5118+
5119+
49685120
def test_get_bottom_toolbar(base_app: cmd2.Cmd) -> None:
49695121
assert base_app.get_bottom_toolbar() is None
49705122

0 commit comments

Comments
 (0)