Skip to content
Next Next commit
Implemented: enable_bottom_toolbar=True now keeps the toolbar visible…
… and refreshing during command execution. Prompts, pagers, and shell commands temporarily suspend it while using the terminal.

Try the work command examples/getting_started.py for a demonstration.

The feature uses two prompt-toolkit displays at different times: the existing PromptSession while waiting for input, and a dedicated CommandToolbar while commands execute.

- Command execution stays on the main thread. The command loop starts the toolbar’s UI in a background thread, using the same content callback, styling, and refresh interval.
- Output appears above the toolbar. Stable stream wrappers route Python and piped subprocess output through prompt-toolkit’s output proxy. Their identities remain consistent across cmd2 redirection and toolbar suspension.
- Input remains coordinated. The toolbar handles terminal position reports, saves typed-ahead keys for the next prompt, and forwards Ctrl-C to the main thread.
- Other terminal interfaces get exclusive access. Nested prompts, pagers, and shell commands temporarily suspend the toolbar. Custom commands can do the same with suspend_bottom_toolbar().
- Cleanup restores normal terminal operation. When execution ends, buffered output is flushed, workers are stopped, and the original streams are restored.

Most supporting code lives in cmd2/command_toolbar.py, with lifecycle integration in cmd2/cmd2.py. Because get_bottom_toolbar() runs in the UI thread during commands, shared mutable state should be protected with a lock.
  • Loading branch information
tleonhardt committed Sep 5, 2026
commit 76dcc78417874ea27db31682ac8f5a6f5d544d31
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 4.3.0 (TBD)

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

## 4.2.3 (September 2, 2026)

- Bug Fixes
Expand Down
74 changes: 67 additions & 7 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from collections.abc import (
Callable,
Iterable,
Iterator,
Mapping,
Sequence,
)
Expand Down Expand Up @@ -106,6 +107,7 @@
from . import (
argparse_completer,
argparse_utils,
command_toolbar,
constants,
plugin,
utils,
Expand Down Expand Up @@ -416,7 +418,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 @@ -557,6 +559,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 @@ -1868,6 +1871,7 @@ def pfeedback(
rich_print_kwargs=rich_print_kwargs,
)

@command_toolbar.suspend_toolbar
def ppaged(
self,
*objects: Any,
Expand Down Expand Up @@ -2042,7 +2046,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 @@ -2051,10 +2055,50 @@ 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) -> Iterator[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) -> Iterator[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:
toolbar.stop()
self._command_toolbar = None

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

Expand Down Expand Up @@ -3079,6 +3123,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 self._initial_termios_settings is not None and self.stdin.isatty(): # type: ignore[unreachable]
Expand Down Expand Up @@ -3314,12 +3359,17 @@ 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
# Capture subprocess output when it must pass through a Python stream,
# including the toolbar proxy which prints above the running display.
proc = subprocess.Popen( # noqa: S602
statement.redirect_to,
stdin=subproc_stdin,
stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable]
stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
stdout=subprocess.PIPE
if isinstance(self.stdout, (utils.StdSim, command_toolbar.ToolbarStream)) # type: ignore[unreachable]
else self.stdout,
stderr=subprocess.PIPE
if isinstance(sys.stderr, (utils.StdSim, command_toolbar.ToolbarStream))
else sys.stderr,
shell=True,
**kwargs,
)
Expand Down Expand Up @@ -3515,6 +3565,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 @@ -3796,7 +3847,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 @@ -3810,7 +3865,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 @@ -4640,6 +4696,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 @@ -4848,6 +4905,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 @@ -4966,6 +5024,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 @@ -5177,6 +5236,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