3535import inspect
3636import os
3737import pydoc
38+ import queue
3839import re
3940import sys
4041import tempfile
4142import threading
43+ import time
4244from code import InteractiveConsole
4345from collections import namedtuple
4446from collections .abc import (
4850 MutableSequence ,
4951 Sequence ,
5052)
53+ from dataclasses import (
54+ dataclass ,
55+ field ,
56+ )
5157from types import FrameType
5258from typing import (
5359 IO ,
6066)
6167
6268import rich .box
63- from prompt_toolkit .application import get_app
6469from rich .console import (
6570 Group ,
6671 RenderableType ,
@@ -273,6 +278,19 @@ def remove(self, command_method: CommandFunc) -> None:
273278 del self ._parsers [full_method_name ]
274279
275280
281+ @dataclass (kw_only = True )
282+ class AsyncAlert :
283+ """Contents of an asynchonous alert which display while user is at prompt.
284+
285+ :param msg: an optional message to be printed above the prompt.
286+ :param prompt: an optional string to dynamically replace the current prompt.
287+ """
288+
289+ msg : str | None = None
290+ prompt : str | None = None
291+ timestamp : float = field (default_factory = time .monotonic , init = False )
292+
293+
276294class Cmd :
277295 """An easy but powerful framework for writing line-oriented command interpreters.
278296
@@ -587,6 +605,13 @@ def __init__(
587605 # Command parsers for this Cmd instance.
588606 self ._command_parsers : _CommandParsers = _CommandParsers (self )
589607
608+ # Members related to printing asychronous alerts
609+ self .alert_queue : queue .Queue [AsyncAlert ] = queue .Queue ()
610+ self ._alerter_gate = threading .Event ()
611+ self ._alerter_shutdown = threading .Event ()
612+ self ._process_alerts_thread : threading .Thread | None = None
613+ self ._prompt_drawn_at : float = 0.0 # Uses time.monotonic()
614+
590615 # Add functions decorated to be subcommands
591616 self ._register_subcommands (self )
592617
@@ -2588,7 +2613,7 @@ def pre_prompt(self) -> None:
25882613 """Ran just before the prompt is displayed (and after the event loop has started)."""
25892614
25902615 def precmd (self , statement : Statement | str ) -> Statement :
2591- """Ran just before the command is executed by [cmd2.Cmd.onecmd][] and after adding it to history (cmd Hook method).
2616+ """Ran just before the command is executed by [cmd2.Cmd.onecmd][] and after adding it to history (cmd Hook method).
25922617
25932618 :param statement: subclass of str which also contains the parsed input
25942619 :return: a potentially modified version of the input Statement object
@@ -3200,9 +3225,9 @@ def _read_raw_input(
32003225 ) -> str :
32013226 """Execute the low-level input read from either a terminal or a redirected stream.
32023227
3203- If the session is interactive ( TTY) , it uses `prompt_toolkit` to render a
3204- rich UI with completion and `patch_stdout` protection. If non-interactive
3205- (Pipe/File), it performs a direct line read from `stdin`.
3228+ If input is coming from a TTY, it uses `prompt_toolkit` to render a
3229+ UI with completion and `patch_stdout` protection. Otherwise it performs
3230+ a direct line read from `stdin`.
32063231
32073232 :param prompt: the prompt text or a callable that returns the prompt.
32083233 :param session: the PromptSession instance to use for reading.
@@ -3321,6 +3346,42 @@ def read_input(
33213346
33223347 return self ._read_raw_input (prompt , temp_session , completer_to_use )
33233348
3349+ def _process_alerts (self ) -> None :
3350+ """Background worker that processes queued alerts and prompt updates.
3351+
3352+ This loop waits for the prompt gate to open, ensuring that background
3353+ messages and UI refreshes only occur while the user is at an
3354+ interactive prompt, avoiding interference with active commands.
3355+ """
3356+ while not self ._alerter_shutdown .is_set ():
3357+ try :
3358+ # Wait for an alert
3359+ alert = self .alert_queue .get (timeout = 0.5 )
3360+
3361+ # Block if not at a prompt
3362+ while not self ._alerter_gate .is_set ():
3363+ if self ._alerter_shutdown .is_set ():
3364+ return
3365+ self ._alerter_gate .wait (timeout = 0.1 )
3366+
3367+ # Print and update
3368+ with patch_stdout ():
3369+ if alert .msg :
3370+ print (alert .msg )
3371+
3372+ # Only update if the alert was generated after the current prompt was drawn on the screen.
3373+ if (alert .prompt is not None and
3374+ alert .prompt != self .prompt and
3375+ alert .timestamp > self ._prompt_drawn_at ): # fmt: skip
3376+ self .prompt = alert .prompt
3377+
3378+ # Don't update the UI if we are at a continuation prompt.
3379+ if not self ._at_continuation_prompt :
3380+ self .session .app .invalidate ()
3381+
3382+ except queue .Empty : # noqa: PERF203
3383+ continue
3384+
33243385 def _read_command_line (self , prompt : str ) -> str :
33253386 """Read the next command line from the input stream.
33263387
@@ -3331,19 +3392,39 @@ def _read_command_line(self, prompt: str) -> str:
33313392 """
33323393
33333394 # Use dynamic prompt if the prompt matches self.prompt
3334- def get_prompt () -> ANSI | str :
3395+ def get_prompt () -> ANSI :
33353396 return ANSI (self .prompt )
33363397
33373398 prompt_to_use : Callable [[], ANSI | str ] | ANSI | str = ANSI (prompt )
33383399 if prompt == self .prompt :
33393400 prompt_to_use = get_prompt
33403401
3341- return self ._read_raw_input (
3342- prompt = prompt_to_use ,
3343- session = self .session ,
3344- completer = self .completer ,
3345- pre_run = self .pre_prompt ,
3346- )
3402+ def _pre_prompt () -> None :
3403+ """Run standard pre-prompt processing and activate the background alerter."""
3404+ self .pre_prompt ()
3405+
3406+ # Record exactly when the user was presented with this prompt
3407+ self ._prompt_drawn_at = time .monotonic ()
3408+
3409+ # Start alerter thread if it's not already running
3410+ if self ._process_alerts_thread is None or not self ._process_alerts_thread .is_alive ():
3411+ self ._alerter_shutdown .clear ()
3412+ self ._process_alerts_thread = threading .Thread (target = self ._process_alerts , daemon = True )
3413+ self ._process_alerts_thread .start ()
3414+
3415+ # Allow alerts to be printed
3416+ self ._alerter_gate .set ()
3417+
3418+ try :
3419+ return self ._read_raw_input (
3420+ prompt = prompt_to_use ,
3421+ session = self .session ,
3422+ completer = self .completer ,
3423+ pre_run = _pre_prompt ,
3424+ )
3425+ finally :
3426+ # Ensure no alerts print while the command is processing
3427+ self ._alerter_gate .clear ()
33473428
33483429 def _cmdloop (self ) -> None :
33493430 """Repeatedly issue a prompt, accept input, parse it, and dispatch to apporpriate commands.
@@ -5207,66 +5288,19 @@ def do__relative_run_script(self, args: argparse.Namespace) -> bool | None:
52075288 # self.last_result will be set by do_run_script()
52085289 return self .do_run_script (su .quote (relative_path ))
52095290
5210- def async_alert (self , alert_msg : str , new_prompt : str | None = None ) -> None :
5211- """Display an important message to the user while they are at a command line prompt.
5212-
5213- To the user it appears as if an alert message is printed above the prompt and their
5214- current input text and cursor location is left alone.
5215-
5216- This function checks self._in_prompt to ensure a prompt is on screen.
5217- If the main thread is not at the prompt, a RuntimeError is raised.
5291+ def add_alert (self , * , msg : str | None = None , prompt : str | None = None ) -> None :
5292+ """Thread-safe method to request UI updates.
52185293
5219- This function is only needed when you need to print an alert or update the prompt while the
5220- main thread is blocking at the prompt. Therefore, this should never be called from the main
5221- thread. Doing so will raise a RuntimeError.
5294+ :param msg: an optional message to be printed above the prompt.
5295+ :param prompt: an optional string to dynamically replace the current prompt.
52225296
5223- :param alert_msg: the message to display to the user
5224- :param new_prompt: If you also want to change the prompt that is displayed, then include it here.
5225- See async_update_prompt() docstring for guidance on updating a prompt.
5226- :raises RuntimeError: if called from the main thread.
5227- :raises RuntimeError: if main thread is not currently at the prompt.
5228- """
5229-
5230- # Check if prompt is currently displayed and waiting for user input
5231- def _alert () -> None :
5232- if new_prompt is not None :
5233- self .prompt = new_prompt
5234-
5235- if alert_msg :
5236- # Since we are running in the loop, patch_stdout context manager from read_input
5237- # should be active (if tty), or at least we are in the main thread.
5238- print (alert_msg )
5239-
5240- if hasattr (self , 'session' ):
5241- # Invalidate to force prompt update
5242- get_app ().invalidate ()
5243-
5244- # Schedule the alert to run on the main thread's event loop
5245- try :
5246- get_app ().loop .call_soon_threadsafe (_alert ) # type: ignore[union-attr]
5247- except AttributeError :
5248- # Fallback if loop is not accessible (e.g. prompt not running or session not initialized)
5249- # This shouldn't happen if _in_prompt is True, unless prompt exited concurrently.
5250- raise RuntimeError ("Event loop not available" ) from None
5251-
5252- def async_update_prompt (self , new_prompt : str ) -> None : # pragma: no cover
5253- """Update the command line prompt while the user is still typing at it.
5254-
5255- This is good for alerting the user to system changes dynamically in between commands.
5256- For instance you could alter the color of the prompt to indicate a system status or increase a
5257- counter to report an event. If you do alter the actual text of the prompt, it is best to keep
5258- the prompt the same width as what's on screen. Otherwise the user's input text will be shifted
5259- and the update will not be seamless.
5260-
5261- If user is at a continuation prompt while entering a multiline command, the onscreen prompt will
5262- not change. However, self.prompt will still be updated and display immediately after the multiline
5263- line command completes.
5264-
5265- :param new_prompt: what to change the prompt to
5266- :raises RuntimeError: if called from the main thread.
5267- :raises RuntimeError: if main thread is not currently at the prompt.
5297+ 1. print an alert: add_alert(msg="System error!")
5298+ 2. print and update prompt: add_alert(msg="Logged in", prompt="user@host> ")
5299+ 3. update prompt only: add_alert(prompt="waiting> ")
52685300 """
5269- self .async_alert ('' , new_prompt )
5301+ if msg is not None or prompt is not None :
5302+ alert = AsyncAlert (msg = msg , prompt = prompt )
5303+ self .alert_queue .put (alert )
52705304
52715305 @staticmethod
52725306 def set_window_title (title : str ) -> None : # pragma: no cover
0 commit comments