Skip to content
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ prompt is displayed.
full type hints and IDE autocompletion for `self._cmd` without needing to override and cast
the property.
- Added `traceback_kwargs` attribute to allow customization of Rich-based tracebacks.
- Added ability to customize `prompt-toolkit` completion menu colors by overriding
`Cmd2Style.COMPLETION_MENU_ITEM` and `Cmd2Style.COMPLETION_MENU_META` in the `cmd2` theme.

## 3.5.1 (April 24, 2026)

Expand Down
34 changes: 34 additions & 0 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@
from prompt_toolkit.output import DummyOutput, create_output
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title
from prompt_toolkit.styles import DynamicStyle
from prompt_toolkit.styles import Style as PtStyle
from rich.console import (
Group,
JustifyMethod,
Expand Down Expand Up @@ -190,6 +192,7 @@ def __init__(self, msg: str = "") -> None:
Cmd2History,
Cmd2Lexer,
pt_filter_style,
rich_to_pt_style,
)
from .utils import (
Settable,
Expand Down Expand Up @@ -521,6 +524,10 @@ def __init__(
self._persistent_history_length = persistent_history_length
self._initialize_history(persistent_history_file)

# Cache for prompt_toolkit completion menu styles
self._cached_pt_style: PtStyle | None = None
self._cached_pt_style_params: tuple[StyleType, StyleType] | None = None

# Create the main PromptSession
self.bottom_toolbar = bottom_toolbar
self.main_session = self._create_main_session(auto_suggest, completekey)
Expand Down Expand Up @@ -714,6 +721,30 @@ def _should_continue_multiline(self) -> bool:
# No macro found or already processed. The statement is complete.
return False

def _get_pt_style(self) -> "PtStyle":
"""Return the prompt_toolkit style for the completion menu."""
theme = ru.get_theme()
rich_item_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_ITEM, "")
rich_meta_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META, "")

current_params = (rich_item_style, rich_meta_style)
if self._cached_pt_style is not None and self._cached_pt_style_params == current_params:
return self._cached_pt_style

item_style = rich_to_pt_style(rich_item_style)
meta_style = rich_to_pt_style(rich_meta_style)

self._cached_pt_style_params = current_params
self._cached_pt_style = PtStyle.from_dict(
{
"completion-menu.completion.current": item_style,
"completion-menu.meta.completion.current": meta_style,
"completion-menu.multi-column-meta": meta_style,
}
)

return self._cached_pt_style

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

Expand Down Expand Up @@ -755,6 +786,7 @@ def _(event: Any) -> None: # pragma: no cover
"multiline": filters.Condition(self._should_continue_multiline),
"prompt_continuation": self.continuation_prompt,
"rprompt": self.get_rprompt,
"style": DynamicStyle(self._get_pt_style),
}

if self.stdin.isatty() and self.stdout.isatty():
Expand Down Expand Up @@ -3574,6 +3606,7 @@ def read_input(
key_bindings=self.main_session.key_bindings,
input=self.main_session.input,
output=self.main_session.output,
style=DynamicStyle(self._get_pt_style),
)

return self._read_raw_input(prompt, temp_session)
Expand All @@ -3592,6 +3625,7 @@ def read_secret(
temp_session: PromptSession[str] = PromptSession(
input=self.main_session.input,
output=self.main_session.output,
style=DynamicStyle(self._get_pt_style),
)

return self._read_raw_input(prompt, temp_session, is_password=True)
Expand Down
63 changes: 63 additions & 0 deletions cmd2/pt_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.history import History
from prompt_toolkit.lexers import Lexer
from rich.style import Style, StyleType

from . import (
constants,
Expand All @@ -29,11 +30,33 @@
from . import string_utils as su

if TYPE_CHECKING: # pragma: no cover
from rich.color import Color

from .cmd2 import Cmd


BASE_DELIMITERS = " \t\n" + "".join(constants.QUOTES) + "".join(constants.REDIRECTION_CHARS)

# prompt_toolkit accepts these standard ANSI color names directly
ANSI_NAMES = (
"ansiblack",
"ansired",
"ansigreen",
"ansiyellow",
"ansiblue",
"ansimagenta",
"ansicyan",
"ansiwhite",
"ansibrightblack",
"ansibrightred",
"ansibrightgreen",
"ansibrightyellow",
"ansibrightblue",
"ansibrightmagenta",
"ansibrightcyan",
"ansibrightwhite",
)


def pt_filter_style(text: str | ANSI) -> str | ANSI:
"""Strip styles if disallowed by ru.ALLOW_STYLE. Otherwise return an ANSI object.
Expand All @@ -50,6 +73,46 @@ def pt_filter_style(text: str | ANSI) -> str | ANSI:
return text if isinstance(text, ANSI) else ANSI(text)


def rich_to_pt_color(color: "Color | None") -> str:
"""Convert a rich Color object to a prompt_toolkit color string."""
if not color or color.is_default:
return "default"

# Use prompt_toolkit's 16 standard ansi color names if applicable.
# This prevents overriding terminal themes with absolute RGB values.
if color.number is not None and 0 <= color.number <= 15:
return ANSI_NAMES[color.number]

# For 8-bit and truecolor, we fallback to hex RGB strings which prompt-toolkit supports natively
c = color.get_truecolor()
return f"#{c.red:02x}{c.green:02x}{c.blue:02x}"


def rich_to_pt_style(rich_style: StyleType) -> str:
"""Convert a rich Style object to a prompt_toolkit style string."""
if not rich_style:
return ""

if isinstance(rich_style, str):
rich_style = Style.parse(rich_style)

parts = ["noreverse"]

fg_color = rich_to_pt_color(rich_style.color)
parts.append(f"fg:{fg_color}")

bg_color = rich_to_pt_color(rich_style.bgcolor)
parts.append(f"bg:{bg_color}")

if rich_style.bold is not None:
parts.append("bold" if rich_style.bold else "nobold")
if rich_style.italic is not None:
parts.append("italic" if rich_style.italic else "noitalic")
if rich_style.underline is not None:
parts.append("underline" if rich_style.underline else "nounderline")
return " ".join(parts)


class Cmd2Completer(Completer):
"""Completer that delegates to cmd2's completion logic."""

Expand Down
4 changes: 4 additions & 0 deletions cmd2/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ class Cmd2Style(StrEnum):
"""

COMMAND_LINE = "cmd2.example" # Command line examples in help text
COMPLETION_MENU_ITEM = "cmd2.completion_menu.item" # Selected completion item
COMPLETION_MENU_META = "cmd2.completion_menu.meta" # Selected completion help/meta text
ERROR = "cmd2.error" # Error text (used by perror())
HELP_HEADER = "cmd2.help.header" # Help table header text
HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed
Expand All @@ -63,6 +65,8 @@ class Cmd2Style(StrEnum):
# Tightly coupled with the Cmd2Style enum.
DEFAULT_CMD2_STYLES: dict[str, StyleType] = {
Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True),
Cmd2Style.COMPLETION_MENU_ITEM: Style(color=Color.BLACK, bgcolor=Color.GREEN),
Cmd2Style.COMPLETION_MENU_META: Style(color=Color.BLACK, bgcolor=Color.BRIGHT_GREEN),
Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED),
Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN),
Cmd2Style.HELP_LEADER: Style(color=Color.CYAN),
Expand Down
7 changes: 7 additions & 0 deletions docs/features/completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ demonstration of how this is used.
[read_input](https://github.com/python-cmd2/cmd2/blob/main/examples/read_input.py) example for a
demonstration.

## Custom Completion Menu Colors

`cmd2` provides the ability to customize the foreground and background colors of the completion menu
items and their associated help text. See
[Customizing Completion Menu Colors](./theme.md#customizing-completion-menu-colors) in the Theme
documentation for more details.

## For More Information

See [cmd2's argparse_utils API](../api/argparse_utils.md) for a more detailed discussion of argparse
Expand Down
14 changes: 14 additions & 0 deletions docs/features/theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,19 @@
information. You can use this to brand your application and set an overall consistent look and feel
that is appealing to your user base.

## Customizing Completion Menu Colors

`cmd2` leverages `prompt-toolkit` for its tab completion menu. You can customize the colors of the
completion menu by overriding the following styles in your `cmd2` theme:

- `Cmd2Style.COMPLETION_MENU_ITEM`: The background and foreground color of the selected completion
item.
- `Cmd2Style.COMPLETION_MENU_META`: The background and foreground color of the selected completion
item's help/meta text.

By default, these are styled with black text on a green background to provide contrast.

## Example

See [rich_theme.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_theme.py) for a
simple example of configuring a custom theme for your `cmd2` application.
10 changes: 10 additions & 0 deletions docs/upgrades.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ See the
example for a demonstration of how to implement a background thread that refreshes the toolbar
periodically.

### Custom Completion Menu Colors

`cmd2` now leverages `prompt-toolkit` for its tab completion menu and provides the ability to
customize its appearance using the `cmd2` theme.

- **Customization**: Override the `Cmd2Style.COMPLETION_MENU_ITEM` and
`Cmd2Style.COMPLETION_MENU_META` styles using `cmd2.rich_utils.set_theme()`. See
[Customizing Completion Menu Colors](features/theme.md#customizing-completion-menu-colors) for
more details.

### Deleted Modules

Removed `rl_utils.py` and `terminal_utils.py` since `prompt-toolkit` provides this functionality.
Expand Down
48 changes: 48 additions & 0 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1889,6 +1889,54 @@ def do_orate(self, opts, arg) -> None:
self.stdout.write(arg + "\n")


def test_get_pt_style_caching(base_app) -> None:
# Get the initial style (populates the cache)
style1 = base_app._get_pt_style()

# Getting it again should return the exact same object from the cache
style2 = base_app._get_pt_style()
assert style1 is style2

# Change the theme which should invalidate the cache
from rich.style import Style

import cmd2.rich_utils as ru
from cmd2.styles import Cmd2Style

# Save the original theme to restore later
orig_theme = ru.get_theme()

try:
ru.set_theme({Cmd2Style.COMPLETION_MENU_ITEM: Style(color="red")})

# Getting the style now should return a new object
style3 = base_app._get_pt_style()
assert style3 is not style1

# Getting it again should return the new cached object
style4 = base_app._get_pt_style()
assert style4 is style3

# Verify the style reflects the change
# In prompt_toolkit 3, styles are accessed differently
attrs = style3.class_names_and_attrs
found = False
for classes, attr in attrs:
if "completion-menu.completion.current" in classes and attr.color in (
"800000",
"darkred",
"ff0000",
"#800000",
"ansired",
):
found = True
break
assert found, "Color change not found in cached style"

finally:
ru._APP_THEME = orig_theme


@pytest.fixture
def multiline_app():
return MultilineApp()
Expand Down
Loading
Loading