Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
- **complete_in_thread**: (boolean) if `True`, then completion will run in a separate
thread. If `False` then completion runs in the main thread and causes it to block if slow.
Defaults to `True`.
- **refresh_interval**: (float) How often, in seconds, to automatically refresh the UI.
Defaults to 0.0. This is used for bottom toolbars and right prompts which have dynamic
content needing to be refreshed at regular intervals and not just when a key is pressed.

- Bug Fixes
- Fixed type hinting so that methods decorated with `with_annotated` no longer trigger spurious
Expand Down Expand Up @@ -53,6 +56,14 @@
- A command can share an argument block with its subcommands via `cmd2_base_args` /
`cmd2_parent_args` parameters, passing parent-level options down without redeclaring them.

- Breaking Changes
- Renamed the `bottom_toolbar` argument in `Cmd.__init__()` to `enable_bottom_toolbar`. It is
also now strictly an `__init__` parameter and not an instance attribute.
- `complete_in_thread` is now strictly an `__init__` parameter and not an instance attribute of
`Cmd`.
- `get_rprompt()` is now only called if the `enable_rprompt` argument in `Cmd.__init__()` is set
to `True`.

## 4.0.0 (June 5, 2026)

### Summary
Expand Down
104 changes: 53 additions & 51 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import contextlib
import copy
import dataclasses
import datetime
import functools
import glob
import inspect
Expand Down Expand Up @@ -73,7 +74,7 @@
from prompt_toolkit.application import create_app_session, get_app
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import Completer, DummyCompleter
from prompt_toolkit.formatted_text import ANSI, FormattedText
from prompt_toolkit.formatted_text import ANSI, AnyFormattedText
from prompt_toolkit.history import InMemoryHistory
from prompt_toolkit.input import DummyInput, create_input
from prompt_toolkit.key_binding import KeyBindings
Expand Down Expand Up @@ -367,16 +368,17 @@ def __init__(
allow_redirection: bool = True,
auto_load_commands: bool = False,
auto_suggest: bool = True,
bottom_toolbar: bool = False,
complete_in_thread: bool = True,
command_sets: Iterable[CommandSet[Any]] | None = None,
enable_bottom_toolbar: bool = False,
enable_rprompt: bool = False,
include_ipy: bool = False,
include_py: bool = False,
intro: RenderableType = "",
multiline_commands: Iterable[str] | None = None,
persistent_history_file: str = "",
persistent_history_length: int = 1000,
refresh_interval: float = 0,
refresh_interval: float = 0.0,
shortcuts: Mapping[str, str] | None = None,
silence_startup_script: bool = False,
startup_script: str = "",
Expand Down Expand Up @@ -405,20 +407,23 @@ def __init__(
:param auto_suggest: If True, cmd2 will provide fish shell style auto-suggestions
based on history. User can press right-arrow key to accept the
provided suggestion.
:param bottom_toolbar: if ``True``, then a bottom toolbar will be displayed.
:param complete_in_thread: if ``True``, then completion will run in a separate thread.
:param command_sets: Provide CommandSet instances to load during cmd2 initialization.
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.
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.
:param include_ipy: should the "ipy" command be included for an embedded IPython shell
:param include_py: should the "py" command be included for an embedded Python shell
:param intro: introduction to display at startup
:param multiline_commands: Iterable of commands allowed to accept multi-line input
:param persistent_history_file: file path to load a persistent cmd2 command history from
:param persistent_history_length: max number of history items to write
to the persistent history file
:param refresh_interval: How often, in seconds, to refresh the UI. Defaults to 0.
:param refresh_interval: How often, in seconds, to refresh the UI. Defaults to 0.0.
prompt-toolkit already refreshes the UI every time a key is pressed.
Set this value if you need the UI to update automatically without
user input (e.g., for displaying a clock or background status
Expand Down Expand Up @@ -535,10 +540,14 @@ def __init__(
self._initialize_history(persistent_history_file)

# Create the main PromptSession
self.bottom_toolbar = bottom_toolbar
self.complete_in_thread = complete_in_thread
Comment thread
kmvanbrunt marked this conversation as resolved.
self.refresh_interval = refresh_interval
self.main_session = self._create_main_session(auto_suggest, completekey)
self.main_session = self._create_main_session(
auto_suggest=auto_suggest,
complete_in_thread=complete_in_thread,
completekey=completekey,
enable_bottom_toolbar=enable_bottom_toolbar,
enable_rprompt=enable_rprompt,
refresh_interval=refresh_interval,
)

# The session currently holding focus (either the main REPL or a command's
# custom prompt). Completion and UI logic should reference this variable
Expand Down Expand Up @@ -729,7 +738,16 @@ def _should_continue_multiline(self) -> bool:
# No macro found or already processed. The statement is complete.
return False

def _create_main_session(self, auto_suggest: bool, completekey: str) -> PromptSession[str]:
def _create_main_session(
self,
*,
auto_suggest: bool,
complete_in_thread: bool,
completekey: str,
enable_bottom_toolbar: bool,
enable_rprompt: bool,
refresh_interval: float,
) -> PromptSession[str]:
"""Create and return the main PromptSession for the application.

Builds an interactive session if self.stdin and self.stdout are TTYs.
Expand Down Expand Up @@ -759,19 +777,19 @@ def _(event: Any) -> None: # pragma: no cover
# Base configuration
kwargs: dict[str, Any] = {
"auto_suggest": AutoSuggestFromHistory() if auto_suggest else None,
"bottom_toolbar": self.get_bottom_toolbar if self.bottom_toolbar else None,
"bottom_toolbar": self.get_bottom_toolbar if enable_bottom_toolbar else None,
"color_depth": ColorDepth.TRUE_COLOR,
"complete_style": CompleteStyle.MULTI_COLUMN,
"complete_in_thread": self.complete_in_thread,
"complete_in_thread": complete_in_thread,
"complete_while_typing": False,
"completer": Cmd2Completer(self),
"history": Cmd2History(item.raw for item in self.history),
"key_bindings": key_bindings,
"lexer": Cmd2Lexer(self),
"multiline": filters.Condition(self._should_continue_multiline),
"prompt_continuation": self.continuation_prompt,
"refresh_interval": self.refresh_interval,
"rprompt": self.get_rprompt,
"refresh_interval": refresh_interval,
"rprompt": self.get_rprompt if enable_rprompt else None,
"style": DynamicStyle(get_pt_theme),
}

Expand Down Expand Up @@ -1983,49 +2001,35 @@ def ppretty(
end=end,
)

def get_bottom_toolbar(self) -> list[str | tuple[str, str]] | None:
def get_bottom_toolbar(self) -> AnyFormattedText:
"""Get the bottom toolbar content.

Returns None if `self.bottom_toolbar` is False. Otherwise, returns a
list of tokens to populate the toolbar (which can span multiple lines).

NOTE: prompt-toolkit calls this method on every UI refresh (e.g., on every keypress
and at scheduled refresh intervals). To ensure the CLI remains responsive, keep
this function highly optimized.
"""
if not self.bottom_toolbar:
return None

import datetime
import shutil
This method is called by prompt-toolkit while at the main prompt 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.

# Get the current time in ISO format with 0.01s precision
dt = datetime.datetime.now(datetime.timezone.utc).astimezone()
now = dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-4] + dt.strftime("%z")
left_text = sys.argv[0]
Override this if you want a bottom toolbar displaying contextual information useful for
your application. This could be information like the application name, current state,
or even a real-time clock.

# Get terminal width to calculate padding for right-alignment
cols, _ = shutil.get_terminal_size()
padding_size = cols - len(left_text) - len(now) - 1
if padding_size < 1:
padding_size = 1
padding = " " * padding_size
:return: Content to populate the bottom toolbar.
"""
return None

# Return formatted text for prompt-toolkit
return [
("ansigreen", left_text),
("", padding),
("ansicyan", now),
]
def get_rprompt(self) -> AnyFormattedText:
"""Provide text to populate the prompt-toolkit right prompt.

def get_rprompt(self) -> str | FormattedText | None:
"""Provide text to populate prompt-toolkit right prompt with.
This method is called by prompt-toolkit while at the main prompt if ``enable_rprompt``
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.

Override this if you want a right-prompt displaying contetual information useful for your application.
This could be information like current Git branch, time, current working directory, etc that is displayed
without cluttering the main input area.
Override this if you want a right prompt displaying contextual information useful for
your application. This could be information like the current Git branch, time, or current
working directory that is displayed without cluttering the main input area.

:return: any type of formatted text to display as the right prompt
:return: Content to populate the right prompt.
"""
return None

Expand Down Expand Up @@ -2932,8 +2936,6 @@ def onecmd_plus_hooks(
command's stdout.
:return: True if running of commands should stop
"""
import datetime

stop = False
statement = None

Expand Down
2 changes: 0 additions & 2 deletions docs/features/initialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ The `cmd2.Cmd` class provides a large number of public instance attributes which

Here are instance attributes of `cmd2.Cmd` which developers might wish to override:

- **bottom_toolbar**: if `True`, then a bottom toolbar will be displayed (Default: `False`)
- **broken_pipe_warning**: if non-empty, this string will be displayed if a broken pipe error occurs
- **complete_in_thread**: if `True`, then completion will run in a separate thread (Default: `True`)
- **continuation_prompt**: used for multiline commands on 2nd+ line of input
- **debug**: if `True`, show full stack trace on error (Default: `False`)
- **default_error**: the error that prints when a non-existent command is run
Expand Down
20 changes: 14 additions & 6 deletions docs/features/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,22 +65,23 @@ terminal window while the application is idle and waiting for input.

### Enabling the Toolbar

To enable the toolbar, set `bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor:
To enable the toolbar, set `enable_bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor:

```py
class App(cmd2.Cmd):
def __init__(self):
super().__init__(bottom_toolbar=True)
super().__init__(enable_bottom_toolbar=True)
```

### Customizing Toolbar Content

You can customize the content of the toolbar by overriding the [cmd2.Cmd.get_bottom_toolbar][]
method. This method should return either a string or a list of `(style, text)` tuples for formatted
text.
method.

```py
def get_bottom_toolbar(self) -> list[str | tuple[str, str]] | None:
from prompt_toolkit.formatted_text import AnyFormattedText

def get_bottom_toolbar(self) -> AnyFormattedText:
Comment thread
kmvanbrunt marked this conversation as resolved.
return [
('ansigreen', 'My Application Name'),
('', ' - '),
Expand All @@ -92,7 +93,14 @@ text.

Since the toolbar is rendered by `prompt-toolkit` as part of the prompt, it is naturally redrawn
whenever the prompt is refreshed. If you want the toolbar to update automatically (for example, to
display a clock), you can use a background thread to call `app.invalidate()` periodically.
display a clock), you can set `refresh_interval` in the [cmd2.Cmd.__init__][] constructor to a value
greater than 0.0.

```py
class App(cmd2.Cmd):
def __init__(self):
super().__init__(refresh_interval=0.5)
```

See the
[getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py)
Expand Down
5 changes: 2 additions & 3 deletions docs/upgrades.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,9 @@ While we have strived to maintain compatibility, there are some differences:
`cmd2` now supports an optional, persistent bottom toolbar. This can be used to display information
such as the application name, current state, or even a real-time clock.

- **Enablement**: Set `bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor.
- **Enablement**: Set `enable_bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor.
- **Customization**: Override the [cmd2.Cmd.get_bottom_toolbar][] method to return the content you
wish to display. The content can be a simple string or a list of `(style, text)` tuples for
formatted text with colors.
wish to display.

See the
[getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py)
Expand Down
37 changes: 31 additions & 6 deletions examples/getting_started.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
10) How to make custom attributes settable at runtime.
11) Shortcuts for commands
12) Persistent bottom toolbar with realtime status updates
13) Right prompt which displays contextual information
"""

import datetime
import pathlib
import sys

from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.application import get_app
from prompt_toolkit.formatted_text import AnyFormattedText
from rich.style import Style

import cmd2
Expand All @@ -44,7 +48,8 @@ def __init__(self) -> None:

super().__init__(
auto_suggest=True,
bottom_toolbar=True,
enable_bottom_toolbar=True,
enable_rprompt=True,
include_ipy=True,
multiline_commands=["echo"],
persistent_history_file="cmd2_history.dat",
Expand Down Expand Up @@ -87,11 +92,33 @@ def __init__(self) -> None:
)
)

def get_rprompt(self) -> str | FormattedText | None:
def get_bottom_toolbar(self) -> AnyFormattedText:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation for get_bottom_toolbar correctly warns that this method is called on every UI refresh. However, the implementation in this example performs relatively heavy operations inside this callback and as such I'm worried we are creating somewhat of a bad example for others to follow.

get_app().output.get_size() and datetime.datetime.now() are called every single time the user presses a key. In a high-frequency typing scenario, this might introduce micro-stutter if the terminal resize logic or time fetching becomes a bottleneck.

I think this is acceptable as an example for now, but we should improve this in a future PR. We should probably have something like a background thread that updates variables with this information (protected by a lock). Then the get_bottom_toolbar should just format that info for display.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created Issue #1702 to address this.

# Get the current time in ISO format with 0.01s precision
dt = datetime.datetime.now(datetime.timezone.utc).astimezone()
now = dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-4] + dt.strftime("%z")
left_text = sys.argv[0]
Comment thread
kmvanbrunt marked this conversation as resolved.

# Fetch the terminal width to calculate padding for right-alignment.
# If called outside a running app loop (e.g., in unit tests), get_app()
# safely returns a dummy app with an 80-column fallback.
cols = get_app().output.get_size().columns
padding_size = cols - len(left_text) - len(now) - 1
if padding_size < 1:
padding_size = 1
padding = " " * padding_size

# Return formatted text for prompt-toolkit
return [
("ansigreen", left_text),
("", padding),
("ansicyan", now),
]

def get_rprompt(self) -> AnyFormattedText:
current_working_directory = pathlib.Path.cwd()
style = "bg:ansired fg:ansiwhite"
text = f"cwd={current_working_directory}"
return FormattedText([(style, text)])
return [(style, text)]

def do_intro(self, _: cmd2.Statement) -> None:
"""Display the intro banner."""
Expand All @@ -108,7 +135,5 @@ def do_echo(self, arg: cmd2.Statement) -> None:


if __name__ == "__main__":
import sys

app = BasicApp()
sys.exit(app.cmdloop())
Loading
Loading