Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 4.4.0 (TBD)

- Enhancements
- `enable_bottom_toolbar=True` now keeps the toolbar visible and refreshing during command
execution

## 4.3.0 (October 13, 2026)

- Stabilized features
Expand Down
91 changes: 82 additions & 9 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from collections import deque
from collections.abc import (
Callable,
Generator,
Iterable,
Mapping,
Sequence,
Expand Down Expand Up @@ -108,6 +109,7 @@
from . import (
argparse_completer,
argparse_utils,
command_toolbar,
constants,
plugin,
utils,
Expand Down Expand Up @@ -418,7 +420,7 @@ def __init__(
This allows CommandSets with custom constructor parameters to be
loaded. This also allows the a set of CommandSets to be provided
when `auto_load_commands` is set to False
:param enable_bottom_toolbar: if ``True``, enables a bottom toolbar while at the main prompt.
:param enable_bottom_toolbar: if ``True``, enables a bottom toolbar at the main prompt and during commands.
Override ``get_bottom_toolbar()`` to define its content.
:param enable_rprompt: if ``True``, enables a right prompt while at the main prompt.
Override ``get_rprompt()`` to define its content.
Expand Down Expand Up @@ -561,6 +563,7 @@ def __init__(
# custom prompt). Completion and UI logic should reference this variable
# to ensure they modify the correct session state.
self.active_session = self.main_session
self._command_toolbar: command_toolbar.CommandToolbar | None = None

# Commands to exclude from the history command
self.exclude_from_history = ["_eof", "history"]
Expand Down Expand Up @@ -1896,6 +1899,7 @@ def pfeedback(
rich_print_kwargs=rich_print_kwargs,
)

@command_toolbar.suspend_toolbar
def ppaged(
self,
*objects: Any,
Expand Down Expand Up @@ -2066,7 +2070,7 @@ def ppretty(
def get_bottom_toolbar(self) -> AnyFormattedText:
"""Get the bottom toolbar content.

This method is called by prompt-toolkit while at the main prompt if ``enable_bottom_toolbar``
This method is called by prompt-toolkit at the main prompt and during commands if ``enable_bottom_toolbar``
was set to ``True`` during initialization. Because prompt-toolkit executes this callback
on every UI refresh (such as on every keypress or at scheduled refresh intervals), keeping
this function highly optimized is critical to ensuring the CLI remains responsive.
Expand All @@ -2075,10 +2079,54 @@ def get_bottom_toolbar(self) -> AnyFormattedText:
your application. This could be information like the application name, current state,
or even a real-time clock.

During command execution this callback runs in a background UI thread. Protect shared
state with a lock when necessary. The toolbar is suspended while another prompt, pager,
or interactive shell owns the terminal.

:return: Content to populate the bottom toolbar.
"""
return None

@contextlib.contextmanager
def suspend_bottom_toolbar(self) -> Generator[None]:
"""Temporarily hide the command toolbar and give exclusive access to the terminal.

Use this context manager around application-specific calls to ``input()``, other
terminal UIs, or subprocesses that inherit the terminal. cmd2 automatically suspends
its toolbar for its own input prompts, pagers, and shell commands.
"""
if self._command_toolbar is None:
yield
else:
with self._command_toolbar.suspend():
yield

@contextlib.contextmanager
def _command_toolbar_context(self) -> Generator[None]:
"""Display the toolbar around commands launched by the interactive command loop."""
if (
self._command_toolbar is not None
or self.main_session.bottom_toolbar is None
or not self._is_tty_session(self.main_session)
):
yield
return

toolbar = command_toolbar.CommandToolbar(self)
try:
with self.sigint_protection:
toolbar.start()
self._command_toolbar = toolbar
yield
finally:
with self.sigint_protection:
try:
toolbar.stop()
finally:
# Always forget a toolbar that has been torn down. Keeping a failed
# one would disable the toolbar for the rest of the session.
self._command_toolbar = None

def get_rprompt(self) -> AnyFormattedText:
"""Provide text to populate the prompt-toolkit right prompt.

Expand Down Expand Up @@ -3103,6 +3151,7 @@ def onecmd_plus_hooks(

return stop

@command_toolbar.suspend_toolbar
def _run_cmdfinalization_hooks(self, stop: bool, statement: Statement | None) -> bool:
"""Run the command finalization hooks."""
if not sys.platform.startswith("win") and self._initial_termios_settings is not None and self.stdin.isatty():
Expand Down Expand Up @@ -3328,9 +3377,15 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
if shell:
kwargs["executable"] = shell

# For any stream that is a StdSim, we will use a pipe so we can capture its output
pipe_stdout = None if isinstance(self.stdout, utils.StdSim) else self.stdout # type: ignore[unreachable]
pipe_stderr = None if isinstance(sys.stderr, utils.StdSim) else sys.stderr
# Hand the pipe process the real terminal when there is one to inherit, since it
# may be interactive. Otherwise capture its output so it can pass through a Python
# stream, including the toolbar proxy which prints above the running display.
pipe_stdout = (
None
if isinstance(self.stdout, utils.StdSim) # type: ignore[unreachable]
else command_toolbar.pipe_target(self.stdout)
)
pipe_stderr = None if isinstance(sys.stderr, utils.StdSim) else command_toolbar.pipe_target(sys.stderr)

terminal_fd: int | None = None
popen_command = statement.redirect_to
Expand Down Expand Up @@ -3363,6 +3418,12 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
kwargs["executable"] = posix_shell

with contextlib.ExitStack() as pipe_stack, contextlib.ExitStack() as setup_stack:
# The toolbar can neither draw nor hold the keyboard while a pipe process owns the terminal, so step aside
# until _restore_output() has reaped it. Entered first, the suspension ends last, once cmd2 has the terminal
# back.
if pipe_stdout is not None or pipe_stderr is not None:
pipe_stack.enter_context(self.suspend_bottom_toolbar())

# The console stays UTF-8 from before the pipe process starts, since a program may read the code page once as
# it starts, until _restore_output() has reaped it.
pipe_stack.enter_context(utils._utf8_console())
Expand Down Expand Up @@ -3434,7 +3495,8 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
self.stdout = new_stdout
setup_stack.pop_all()

# Keep the UTF-8 console and the pipeline's job control until _restore_output() reaps the pipe process.
# Keep the toolbar suspended, the UTF-8 console and the pipeline's job control until _restore_output() reaps
# the pipe process.
redir_saved_state.pipe_context = pipe_stack.pop_all()

elif statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND):
Expand Down Expand Up @@ -3494,7 +3556,8 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec
:param statement: Statement object which contains the parsed input from the user
:param saved_redir_state: contains information needed to restore state data
"""
# The UTF-8 console and the pipeline's job control end once its pipe process has been reaped.
# The UTF-8 console and the pipeline's job control end once its pipe process has been reaped, and then the toolbar
# gets the terminal back.
with contextlib.ExitStack() as pipe_stack:
if saved_redir_state.pipe_context is not None:
pipe_stack.callback(saved_redir_state.pipe_context.close)
Expand Down Expand Up @@ -3632,6 +3695,7 @@ def _is_tty_session(session: PromptSession[str]) -> bool:
# a DummyOutput.
return not isinstance(session.input, DummyInput)

@command_toolbar.suspend_toolbar
def _read_raw_input(
self,
prompt: Callable[[], ANSI | str] | ANSI | str,
Expand Down Expand Up @@ -3913,7 +3977,11 @@ def _cmdloop(self) -> None:
"""
try:
# Run startup commands
stop = self.runcmds_plus_hooks(self._startup_commands)
if self._startup_commands:
with self._command_toolbar_context():
stop = self.runcmds_plus_hooks(self._startup_commands)
else:
stop = False
self._startup_commands.clear()

while not stop:
Expand All @@ -3927,7 +3995,8 @@ def _cmdloop(self) -> None:
line = "_eof"

# Run the command along with all associated pre and post hooks
stop = self.onecmd_plus_hooks(line)
with self._command_toolbar_context():
stop = self.onecmd_plus_hooks(line)
finally:
with self.sigint_protection:
# Shut down the alert thread.
Expand Down Expand Up @@ -4757,6 +4826,7 @@ def do_quit(self, _: argparse.Namespace) -> bool | None:
self.last_result = True
return True

@command_toolbar.suspend_toolbar
def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], prompt: str = "Your choice? ") -> Any:
"""Present a menu to the user.

Expand Down Expand Up @@ -4965,6 +5035,7 @@ def _build_shell_parser(cls) -> Cmd2ArgumentParser:

# Preserve quotes since we are passing these strings to the shell
@with_argparser(_build_shell_parser, preserve_quotes=True)
@command_toolbar.suspend_toolbar
def do_shell(self, args: argparse.Namespace) -> None:
"""Execute a command as if at the OS prompt."""
import signal
Expand Down Expand Up @@ -5134,6 +5205,7 @@ def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None:

readline.set_completer(cmd2_env.completer)

@command_toolbar.suspend_toolbar
def _run_python(self, *, pyscript: str | None = None) -> bool | None:
"""Run an interactive Python shell or execute a pyscript file.

Expand Down Expand Up @@ -5345,6 +5417,7 @@ def _build_ipython_parser() -> Cmd2ArgumentParser:
return argparse_utils.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell.")

@with_argparser(_build_ipython_parser)
@command_toolbar.suspend_toolbar
def do_ipy(self, _: argparse.Namespace) -> bool | None: # pragma: no cover
"""Run an interactive IPython shell.

Expand Down
Loading
Loading