3939import sys
4040import tempfile
4141import threading
42+ import time
4243from code import InteractiveConsole
43- from collections import namedtuple
44+ from collections import (
45+ deque ,
46+ namedtuple ,
47+ )
4448from collections .abc import (
4549 Callable ,
4650 Iterable ,
4751 Mapping ,
4852 MutableSequence ,
4953 Sequence ,
5054)
55+ from dataclasses import (
56+ dataclass ,
57+ field ,
58+ )
5159from types import FrameType
5260from typing import (
5361 IO ,
6068)
6169
6270import rich .box
71+ from prompt_toolkit import print_formatted_text
6372from prompt_toolkit .application import get_app
6473from rich .console import (
6574 Group ,
@@ -177,6 +186,7 @@ def __init__(self, msg: str = '') -> None:
177186 Cmd2Completer ,
178187 Cmd2History ,
179188 Cmd2Lexer ,
189+ pt_filter_style ,
180190)
181191from .utils import (
182192 Settable ,
@@ -273,6 +283,23 @@ def remove(self, command_method: CommandFunc) -> None:
273283 del self ._parsers [full_method_name ]
274284
275285
286+ @dataclass (kw_only = True )
287+ class AsyncAlert :
288+ """Contents of an asynchonous alert which display while user is at prompt.
289+
290+ :param msg: an optional message to be printed above the prompt.
291+ :param prompt: an optional string to dynamically replace the current prompt.
292+
293+ :ivar timestamp: monotonic creation time of the alert. If an alert was created
294+ before the current prompt was rendered, the prompt update is ignored
295+ to avoid a stale display but the msg will still be displayed.
296+ """
297+
298+ msg : str | None = None
299+ prompt : str | None = None
300+ timestamp : float = field (default_factory = time .monotonic , init = False )
301+
302+
276303class Cmd :
277304 """An easy but powerful framework for writing line-oriented command interpreters.
278305
@@ -370,7 +397,7 @@ def __init__(
370397 self ._initialize_plugin_system ()
371398
372399 # Configure a few defaults
373- self .prompt = Cmd .DEFAULT_PROMPT
400+ self .prompt : str = Cmd .DEFAULT_PROMPT
374401 self .intro = intro
375402
376403 # What to use for standard input
@@ -587,6 +614,14 @@ def __init__(
587614 # Command parsers for this Cmd instance.
588615 self ._command_parsers : _CommandParsers = _CommandParsers (self )
589616
617+ # Members related to printing asychronous alerts
618+ self ._alert_queue : deque [AsyncAlert ] = deque ()
619+ self ._alert_condition = threading .Condition ()
620+ self ._alert_allowed = False
621+ self ._alert_shutdown = False
622+ self ._alert_thread : threading .Thread | None = None
623+ self ._alert_prompt_timestamp : float = 0.0 # Uses time.monotonic()
624+
590625 # Add functions decorated to be subcommands
591626 self ._register_subcommands (self )
592627
@@ -2588,7 +2623,7 @@ def pre_prompt(self) -> None:
25882623 """Ran just before the prompt is displayed (and after the event loop has started)."""
25892624
25902625 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).
2626+ """Ran just before the command is executed by [cmd2.Cmd.onecmd][] and after adding it to history (cmd Hook method).
25922627
25932628 :param statement: subclass of str which also contains the parsed input
25942629 :return: a potentially modified version of the input Statement object
@@ -3200,9 +3235,9 @@ def _read_raw_input(
32003235 ) -> str :
32013236 """Execute the low-level input read from either a terminal or a redirected stream.
32023237
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`.
3238+ If input is coming from a TTY, it uses `prompt_toolkit` to render a
3239+ UI with completion and `patch_stdout` protection. Otherwise it performs
3240+ a direct line read from `stdin`.
32063241
32073242 :param prompt: the prompt text or a callable that returns the prompt.
32083243 :param session: the PromptSession instance to use for reading.
@@ -3214,6 +3249,8 @@ def _read_raw_input(
32143249 # Check if the session is configured for interactive terminal use.
32153250 if not isinstance (session .input , DummyInput ):
32163251 with patch_stdout ():
3252+ if not callable (prompt ):
3253+ prompt = pt_filter_style (prompt )
32173254 return session .prompt (prompt , completer = completer , ** prompt_kwargs )
32183255
32193256 # We're not at a terminal, so we're likely reading from a file or a pipe.
@@ -3321,6 +3358,60 @@ def read_input(
33213358
33223359 return self ._read_raw_input (prompt , temp_session , completer_to_use )
33233360
3361+ def _process_alerts (self ) -> None :
3362+ """Background worker that processes queued alerts and dynamic prompt updates."""
3363+ while True :
3364+ with self ._alert_condition :
3365+ # Wait until we have alerts and are allowed to display them, or shutdown is signaled.
3366+ self ._alert_condition .wait_for (
3367+ lambda : (len (self ._alert_queue ) > 0 and self ._alert_allowed ) or self ._alert_shutdown
3368+ )
3369+
3370+ # Shutdown immediately even if we have alerts.
3371+ if self ._alert_shutdown :
3372+ break
3373+
3374+ # Hold the condition lock while printing to block command execution. This
3375+ # prevents async alerts from printing once a command starts.
3376+
3377+ # Print all alerts at once to reduce flicker.
3378+ alert_text = "\n " .join (alert .msg for alert in self ._alert_queue if alert .msg )
3379+
3380+ # Find the latest prompt update among all pending alerts.
3381+ latest_prompt = None
3382+ for alert in reversed (self ._alert_queue ):
3383+ if (
3384+ alert .prompt is not None
3385+ and alert .prompt != self .prompt
3386+ and alert .timestamp > self ._alert_prompt_timestamp
3387+ ):
3388+ latest_prompt = alert .prompt
3389+ self ._alert_prompt_timestamp = alert .timestamp
3390+ break
3391+
3392+ # Clear the alerts
3393+ self ._alert_queue .clear ()
3394+
3395+ if alert_text :
3396+ if not self ._at_continuation_prompt and latest_prompt is not None :
3397+ # Update prompt now so patch_stdout can redraw it immediately.
3398+ self .prompt = latest_prompt
3399+
3400+ # Print the alert messages above the prompt.
3401+ with patch_stdout ():
3402+ print_formatted_text (pt_filter_style (alert_text ))
3403+
3404+ if self ._at_continuation_prompt and latest_prompt is not None :
3405+ # Update state only. The onscreen prompt won't change until the next prompt starts.
3406+ self .prompt = latest_prompt
3407+
3408+ elif latest_prompt is not None :
3409+ self .prompt = latest_prompt
3410+
3411+ # Refresh UI immediately unless at a continuation prompt.
3412+ if not self ._at_continuation_prompt :
3413+ get_app ().invalidate ()
3414+
33243415 def _read_command_line (self , prompt : str ) -> str :
33253416 """Read the next command line from the input stream.
33263417
@@ -3331,19 +3422,43 @@ def _read_command_line(self, prompt: str) -> str:
33313422 """
33323423
33333424 # Use dynamic prompt if the prompt matches self.prompt
3334- def get_prompt () -> ANSI | str :
3335- return ANSI (self .prompt )
3425+ def get_prompt () -> str | ANSI :
3426+ return pt_filter_style (self .prompt )
33363427
33373428 prompt_to_use : Callable [[], ANSI | str ] | ANSI | str = ANSI (prompt )
33383429 if prompt == self .prompt :
33393430 prompt_to_use = get_prompt
33403431
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- )
3432+ def _pre_prompt () -> None :
3433+ """Run standard pre-prompt processing and activate the background alerter."""
3434+ self .pre_prompt ()
3435+
3436+ # Record when this prompt was rendered.
3437+ self ._alert_prompt_timestamp = time .monotonic ()
3438+
3439+ # Start alerter thread if it's not already running.
3440+ if self ._alert_thread is None or not self ._alert_thread .is_alive ():
3441+ self ._alert_allowed = False
3442+ self ._alert_shutdown = False
3443+ self ._alert_thread = threading .Thread (target = self ._process_alerts , daemon = True )
3444+ self ._alert_thread .start ()
3445+
3446+ # Allow alerts to be printed now that we are at a prompt.
3447+ with self ._alert_condition :
3448+ self ._alert_allowed = True
3449+ self ._alert_condition .notify_all ()
3450+
3451+ try :
3452+ return self ._read_raw_input (
3453+ prompt = prompt_to_use ,
3454+ session = self .session ,
3455+ completer = self .completer ,
3456+ pre_run = _pre_prompt ,
3457+ )
3458+ finally :
3459+ # Ensure no alerts print while not at a prompt.
3460+ with self ._alert_condition :
3461+ self ._alert_allowed = False
33473462
33483463 def _cmdloop (self ) -> None :
33493464 """Repeatedly issue a prompt, accept input, parse it, and dispatch to apporpriate commands.
@@ -3371,7 +3486,18 @@ def _cmdloop(self) -> None:
33713486 # Run the command along with all associated pre and post hooks
33723487 stop = self .onecmd_plus_hooks (line )
33733488 finally :
3374- pass
3489+ with self .sigint_protection :
3490+ # Shut down the alert thread.
3491+ if self ._alert_thread is not None :
3492+ with self ._alert_condition :
3493+ self ._alert_shutdown = True
3494+ self ._alert_condition .notify_all ()
3495+
3496+ # The thread is event-driven and stays suspended until notified.
3497+ # We join with a 1 second timeout as a safety measure. If it hangs,
3498+ # the daemon status allows the OS to reap it on exit.
3499+ self ._alert_thread .join (timeout = 1.0 )
3500+ self ._alert_thread = None
33753501
33763502 #############################################################
33773503 # Parsers and functions for alias command and subcommands
@@ -5207,66 +5333,25 @@ def do__relative_run_script(self, args: argparse.Namespace) -> bool | None:
52075333 # self.last_result will be set by do_run_script()
52085334 return self .do_run_script (su .quote (relative_path ))
52095335
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.
5336+ def add_alert (self , * , msg : str | None = None , prompt : str | None = None ) -> None :
5337+ """Queue an asynchronous alert to be displayed when the prompt is active.
52155338
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.
5339+ Examples:
5340+ add_alert(msg="System error!") # Print message only
5341+ add_alert(prompt="user@host> ") # Update prompt only
5342+ add_alert(msg="Done", prompt="> ") # Update both
52185343
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.
5344+ :param msg: an optional message to be printed above the prompt.
5345+ :param prompt: an optional string to dynamically replace the current prompt.
52225346
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.
52285347 """
5348+ if msg is None and prompt is None :
5349+ return
52295350
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.
5268- """
5269- self .async_alert ('' , new_prompt )
5351+ with self ._alert_condition :
5352+ alert = AsyncAlert (msg = msg , prompt = prompt )
5353+ self ._alert_queue .append (alert )
5354+ self ._alert_condition .notify_all ()
52705355
52715356 @staticmethod
52725357 def set_window_title (title : str ) -> None : # pragma: no cover
0 commit comments