Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ prompt is displayed.
- Added `Cmd2ArgumentParser.output_to()` context manager to temporarily set the output stream
during `argparse` operations. This is helpful for directing output for functions like
`parse_args()`, which default to `sys.stdout` and lack a `file` argument.
- 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 @@ -80,6 +80,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 @@ -192,6 +194,7 @@ def __init__(self, msg: str = "") -> None:
Cmd2History,
Cmd2Lexer,
pt_filter_style,
rich_to_pt_style,
)
from .utils import (
Settable,
Expand Down Expand Up @@ -523,6 +526,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 @@ -716,6 +723,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 @@ -757,6 +788,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 @@ -3569,6 +3601,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 @@ -3587,6 +3620,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
87 changes: 72 additions & 15 deletions cmd2/pt_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,44 @@
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,
utils,
)
from . import rich_utils as ru
from . import string_utils as su
from .styles import Cmd2Style

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 +74,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 Expand Up @@ -196,28 +260,21 @@ class Cmd2Lexer(Lexer):
def __init__(
self,
cmd_app: "Cmd",
command_color: str = "ansigreen",
alias_color: str = "ansicyan",
macro_color: str = "ansimagenta",
flag_color: str = "ansired",
argument_color: str = "ansiyellow",
) -> None:
"""Initialize the Lexer.

:param cmd_app: cmd2.Cmd instance
:param command_color: color to use for commands, defaults to 'ansigreen'
:param alias_color: color to use for aliases, defaults to 'ansicyan'
:param macro_color: color to use for macros, defaults to 'ansimagenta'
:param flag_color: color to use for flags, defaults to 'ansired'
:param argument_color: color to use for arguments, defaults to 'ansiyellow'
"""
super().__init__()
self.cmd_app = cmd_app
self.command_color = command_color
self.alias_color = alias_color
self.macro_color = macro_color
self.flag_color = flag_color
self.argument_color = argument_color

# Retrieve styles dynamically from the current theme
theme = ru.get_theme()
self.command_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_COMMAND, ""))
self.alias_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ALIAS, ""))
self.macro_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_MACRO, ""))
self.flag_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_FLAG, ""))
self.argument_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ARGUMENT, ""))

def lex_document(self, document: Document) -> Callable[[int], Any]:
"""Lex the document."""
Expand Down
14 changes: 14 additions & 0 deletions cmd2/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,16 @@ 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
LEXER_COMMAND = "cmd2.lexer.command" # Lexer color for commands
LEXER_ALIAS = "cmd2.lexer.alias" # Lexer color for aliases
LEXER_MACRO = "cmd2.lexer.macro" # Lexer color for macros
LEXER_FLAG = "cmd2.lexer.flag" # Lexer color for flags
LEXER_ARGUMENT = "cmd2.lexer.argument" # Lexer color for arguments
SUCCESS = "cmd2.success" # Success text (used by psuccess())
TABLE_BORDER = "cmd2.table_border" # Applied to cmd2's table borders
WARNING = "cmd2.warning" # Warning text (used by pwarning())
Expand All @@ -63,9 +70,16 @@ 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),
Cmd2Style.LEXER_COMMAND: Style(color=Color.GREEN),
Cmd2Style.LEXER_ALIAS: Style(color=Color.CYAN),
Cmd2Style.LEXER_MACRO: Style(color=Color.MAGENTA),
Cmd2Style.LEXER_FLAG: Style(color=Color.RED),
Cmd2Style.LEXER_ARGUMENT: Style(color=Color.YELLOW),
Cmd2Style.SUCCESS: Style(color=Color.GREEN),
Cmd2Style.TABLE_BORDER: Style(color=Color.BRIGHT_GREEN),
Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW),
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
7 changes: 7 additions & 0 deletions examples/rich_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ def __init__(self, *args, **kwargs):
Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bgcolor="#44475a"),
Cmd2Style.HELP_LEADER: Style(color="#f8f8f2", bgcolor="#282a36"), # use RGB hex colors
Cmd2Style.TABLE_BORDER: Style(color="turquoise2"), # use a rich standard color
Cmd2Style.LEXER_COMMAND: Style(color=Color.LIGHT_GREEN),
Cmd2Style.LEXER_ALIAS: Style(color=Color.LIGHT_CYAN1),
Cmd2Style.LEXER_MACRO: Style(color=Color.LIGHT_CORAL),
Cmd2Style.LEXER_FLAG: Style(color=Color.LIGHT_PINK3),
Cmd2Style.LEXER_ARGUMENT: Style(color=Color.LIGHT_GOLDENROD1),
Cmd2Style.COMPLETION_MENU_ITEM: Style(color=Color.WHITE, bgcolor=Color.NAVY_BLUE),
Cmd2Style.COMPLETION_MENU_META: Style(color=Color.WHITE, bgcolor=Color.DARK_SLATE_GRAY2),
"traceback.exc_type": Style(color=Color.RED, bgcolor=Color.LIGHT_YELLOW3, bold=True),
"argparse.args": Style(color=Color.AQUAMARINE3, underline=True),
}
Expand Down
48 changes: 48 additions & 0 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1871,6 +1871,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