Help Customization

Cyclopts provides extensive customization options for help screen appearance and formatting through the help_formatter parameter available on both App and Group. These parameters accept formatters that follow the HelpFormatter protocol.

Setting Help Formatters

App-Level Formatting

The App class accepts a help_formatter parameter that controls the default formatting for all help output:

from cyclopts import App
from cyclopts.help import DefaultFormatter, PlainFormatter

# Use a built-in formatter by name: {"default", "plain"}
app = App(help_formatter="plain")

# Or pass a formatter instance with custom configuration
app = App(
    help_formatter=DefaultFormatter(
        # Custom configuration options
    )
)

# Or use a completely custom formatter; see HelpFormatter protocol.
app = App(help_formatter=MyCustomFormatter())

Group-Level Formatting

Individual Group instances can have their own help_formatter that overrides the app-level default:

from cyclopts import App, Group
from cyclopts.help import DefaultFormatter, PanelSpec
from rich.box import DOUBLE

# Create a group with custom formatting
advanced_group = Group(
    "Advanced Options",
    help_formatter=DefaultFormatter(
        panel_spec=PanelSpec(
            border_style="red",
            box=DOUBLE,
        )
    )
)

# The app can have a different default formatter
app = App(help_formatter="plain")

# Parameters in advanced_group will use the group's formatter,
# while other parameters use the app's formatter

Built-in Formatters

DefaultFormatter

The DefaultFormatter is the default help formatter that uses Rich for beautiful terminal output with colors, borders, and structured layouts.

from cyclopts import App

# Explicitly use the default formatter (same as not specifying)
app = App(help_formatter="default")

@app.default
def main(name: str, count: int = 1):
    """A simple greeting application.

    Parameters
    ----------
    name : str
        Person to greet.
    count : int
        Number of times to greet.
    """
    for _ in range(count):
        print(f"Hello, {name}!")

if __name__ == "__main__":
    app()

Output:

Usage: my-app [ARGS] [OPTIONS]

A simple greeting application.

╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ --help -h  Display this message and exit.                                    │
│ --version  Display application version.                                      │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Parameters ─────────────────────────────────────────────────────────────────╮
│ *  NAME --name    Person to greet. [required]                                │
│    COUNT --count  Number of times to greet. [default: 1]                     │
╰──────────────────────────────────────────────────────────────────────────────╯

PlainFormatter

The PlainFormatter provides accessibility-focused plain text output without colors or special characters, ideal for screen readers and simpler terminals.

from cyclopts import App

# Use plain text formatter for accessibility
app = App(help_formatter="plain")

@app.default
def main(name: str, count: int = 1):
    """A simple greeting application.

    Parameters
    ----------
    name : str
        Person to greet.
    count : int
        Number of times to greet.
    """
    for _ in range(count):
        print(f"Hello, {name}!")

if __name__ == "__main__":
    app()

Output:

Usage: demo.py [ARGS] [OPTIONS]

A simple greeting application.

Commands:
--help, -h: Display this message and exit.
--version: Display application version.

Parameters:
NAME, --name: Person to greet.
COUNT, --count: Number of times to greet.

Basic Customization

The DefaultFormatter accepts several customization options through its initialization parameters.

Panel Customization

The PanelSpec controls the outer panel appearance:

from cyclopts import App
from cyclopts.help import DefaultFormatter, PanelSpec
from rich.box import DOUBLE

app = App(
    help_formatter=DefaultFormatter(
        panel_spec=PanelSpec(
            box=DOUBLE,              # Use double-line borders
            border_style="blue",     # Blue border color
            padding=(1, 2),         # (vertical, horizontal) padding
            expand=True,            # Expand to full terminal width
        )
    )
)

@app.default
def main(path: str, verbose: bool = False):
    """Process a file with custom panel styling."""
    print(f"Processing {path}")

if __name__ == "__main__":
    app()

Output:

Usage: demo.py [ARGS] [OPTIONS]

Process a file with custom panel styling.

╔═ Commands ═══════════════════════════════════════════════════════════╗
║                                                                      ║
║  --help -h  Display this message and exit.                           ║
║  --version  Display application version.                             ║
║                                                                      ║
╚══════════════════════════════════════════════════════════════════════╝
╔═ Parameters ═════════════════════════════════════════════════════════╗
║                                                                      ║
║  *  PATH --path                     [required]                       ║
║     VERBOSE --verbose  [default: False]                              ║
║       --no-verbose                                                   ║
║                                                                      ║
╚══════════════════════════════════════════════════════════════════════╝

Table Customization

The TableSpec controls the table styling within panels:

from cyclopts import App
from cyclopts.help import DefaultFormatter, TableSpec

app = App(
    help_formatter=DefaultFormatter(
        table_spec=TableSpec(
            show_header=True,  # Show column headers
            show_lines=True,  # Show lines between rows
            show_edge=False,  # Remove outer table border
            border_style="green",  # Green table elements
            padding=(0, 2, 0, 0),  # Extra right padding
            box=SQUARE,  # otherwise we won't see the lines
        )
    )
)

@app.default
def main(path: str, verbose: bool = False):
    """Process a file with custom table styling."""
    print(f"Processing {path}")

if __name__ == "__main__":
    app()

Output:

Usage: test_table_custom.py [ARGS] [OPTIONS]

Process a file with custom table styling.

╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ Command    │Description                                                      │
│ ───────────┼──────────────────────────────────────────────────────────────── │
│ --help -h  │Display this message and exit.                                   │
│ ───────────┼──────────────────────────────────────────────────────────────── │
│ --version  │Display application version.                                     │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Parameters ─────────────────────────────────────────────────────────────────╮
│    │Option             │Description                                          │
│ ───┼───────────────────┼──────────────────────────────────────────────────── │
│ *  │PATH --path        │[required]                                           │
│ ───┼───────────────────┼──────────────────────────────────────────────────── │
│    │VERBOSE --verbose  │[default: False]                                     │
│    │  --no-verbose     │                                                     │
╰──────────────────────────────────────────────────────────────────────────────╯

Color Styles

Every color Cyclopts renders in help output is routed through a named Rich style. Define any of these keys in your Theme and pass the themed Console to your App to restyle that element; keys you don't define fall back to the built-in default.

Style name

Applies to

Default

"cyclopts.name"

parameter and command names

"cyan"

"cyclopts.required_marker"

the asterisk marker for required parameters

"red bold"

"cyclopts.required"

the "[required]" annotation

"red"

"cyclopts.default"

the "[default: ...]" annotation

"gray58"

"cyclopts.choices"

the "[choices: ...]" annotation

"gray58"

"cyclopts.env_var"

the "[env var: ...]" annotation

"gray58"

"cyclopts.border"

the help panel border

"none"

"cyclopts.usage"

the "Usage:" line

"bold"

Override any key to match your own palette:

from pathlib import Path

from rich.console import Console
from rich.theme import Theme

from cyclopts import App

theme = Theme(
    {
        "cyclopts.default": "blue",
        "cyclopts.required": "bright_red",
        "cyclopts.name": "bright_cyan",
    }
)
app = App(console=Console(theme=theme))

@app.default
def compress(src: Path, dst: Path = Path("out.zip")):
    """Compress a file."""

if __name__ == "__main__":
    app()

Output:

Usage: demo.py SRC [ARGS]

Compress a file.

╭─ Commands ───────────────────────────────────────────────────────────╮
│ --help (-h)  Display this message and exit.                          │
│ --version    Display application version.                            │
╰──────────────────────────────────────────────────────────────────────╯
╭─ Parameters ─────────────────────────────────────────────────────────╮
│ *  SRC --src  [required]                                             │
│    DST --dst  [default: out.zip]                                     │
╰──────────────────────────────────────────────────────────────────────╯

Because the console is resolved through the app hierarchy, a theme set on a parent App is inherited by its subcommands' help output.

Per-Group Styles

To restyle a single panel, give its Group a theme. It accepts the same "cyclopts."-prefixed keys as the app-wide styles (as a mapping or a Theme) and layers them on top for just that group's panel, leaving every other panel on the app's styles:

from cyclopts import App, Group

app = App()

danger = Group("Danger Zone", theme={"cyclopts.border": "red", "cyclopts.name": "bright_red"})

@app.command
def build():
    """Build the project."""

@app.command(group=danger)
def destroy():
    """Tear down all infrastructure."""

if __name__ == "__main__":
    app()

Output:

Usage: demo.py COMMAND

╭─ Commands ───────────────────────────────────────────────────────────╮
│ build        Build the project.                                      │
│ --help (-h)  Display this message and exit.                          │
│ --version    Display application version.                            │
╰──────────────────────────────────────────────────────────────────────╯
╭─ Danger Zone ────────────────────────────────────────────────────────╮
│ destroy  Tear down all infrastructure.                               │
╰──────────────────────────────────────────────────────────────────────╯

Keys the group's theme doesn't define fall through to the app's styles, so a group can override just its border while inheriting every other color.

Combining Customizations

You can combine both panel and table specifications:

from cyclopts import App
from cyclopts.help import DefaultFormatter, PanelSpec, TableSpec
from rich.box import ROUNDED

app = App(
    help_formatter=DefaultFormatter(
        panel_spec=PanelSpec(
            box=ROUNDED,
            border_style="cyan",
            padding=(0, 1),
        ),
        table_spec=TableSpec(
            show_header=False,
            show_lines=False,
            padding=(0, 1),
        )
    )
)

@app.default
def main(path: str, verbose: bool = False):
    """Process a file with combined customizations."""
    print(f"Processing {path}")

if __name__ == "__main__":
    app()

Output:

Usage: my-app [ARGS] [OPTIONS]

Process a file with combined customizations.

╭─ Commands ──────────────────────────────────────────────────────────╮
│ --help -h  Display this message and exit.                           │
│ --version  Display application version.                             │
╰─────────────────────────────────────────────────────────────────────╯
╭─ Parameters ────────────────────────────────────────────────────────╮
│ *  PATH --path       [required]                                     │
│    VERBOSE --verbose [default: False]                               │
╰─────────────────────────────────────────────────────────────────────╯

Group-Level Formatting

Different parameter groups can have different formatting styles, allowing you to visually distinguish between different types of options:

from cyclopts import App, Group, Parameter
from cyclopts.help import DefaultFormatter, PanelSpec
from rich.box import DOUBLE, MINIMAL
from typing import Annotated

# Create groups with different styles
required_group = Group(
    "Required Options",
    help_formatter=DefaultFormatter(
        panel_spec=PanelSpec(
            box=DOUBLE,
            border_style="red bold",
        )
    )
)

optional_group = Group(
    "Optional Settings",
    help_formatter=DefaultFormatter(
        panel_spec=PanelSpec(
            box=MINIMAL,
            border_style="green",
        )
    )
)

app = App()

@app.default
def main(
    # Required parameters with red double border
    input_file: Annotated[str, Parameter(group=required_group)],
    output_dir: Annotated[str, Parameter(group=required_group)],

    # Optional parameters with green minimal border
    verbose: Annotated[bool, Parameter(group=optional_group)] = False,
    threads: Annotated[int, Parameter(group=optional_group)] = 4,
):
    """Process files with styled help groups."""
    print(f"Processing {input_file} -> {output_dir}")
    if verbose:
        print(f"Using {threads} threads")

if __name__ == "__main__":
    app()

Output:

Usage: test_group_formatting.py [ARGS] [OPTIONS]

Process files with styled help groups.

╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ --help -h  Display this message and exit.                                    │
│ --version  Display application version.                                      │
╰──────────────────────────────────────────────────────────────────────────────╯
   Optional Settings                                                            
  VERBOSE --verbose  [default: False]                                           
    --no-verbose                                                                
  THREADS --threads  [default: 4]                                               
                                                                                
╔═ Required Options ═══════════════════════════════════════════════════════════╗
║ *  INPUT-FILE --input-file  [required]                                       ║
║ *  OUTPUT-DIR --output-dir  [required]                                       ║
╚══════════════════════════════════════════════════════════════════════════════╝

Custom Column Layout

For complete control over the help table layout, you can define custom columns using ColumnSpec:

from cyclopts import App, Group, Parameter
from cyclopts.help import DefaultFormatter, ColumnSpec, TableSpec
from typing import Annotated

# Define custom column renderers
def names_renderer(entry):
    """Combine the positional label (if any) with the option names."""
    return " ".join(entry.display_labels)

def type_renderer(entry):
    """Show the parameter type."""
    from cyclopts.annotations import get_hint_name
    return get_hint_name(entry.type) if entry.type else ""

# Create custom columns
custom_group = Group(
    "Custom Layout",
    help_formatter=DefaultFormatter(
        table_spec=TableSpec(show_header=True),
        column_specs=(
            ColumnSpec(
                renderer=lambda e: "★" if e.required else " ",
                header="",
                width=2,
                style="yellow bold",
            ),
            ColumnSpec(
                renderer=names_renderer,
                header="Option",
                style="cyan",
                max_width=30,
            ),
            ColumnSpec(
                renderer=type_renderer,
                header="Type",
                style="magenta",
                justify="center",
            ),
            ColumnSpec(
                renderer="description",  # Use attribute name
                header="Description",
                overflow="fold",
            ),
        )
    )
)

app = App()

@app.default
def main(
    input_path: Annotated[str, Parameter(group=custom_group, help="Input file path")],
    output_path: Annotated[str, Parameter(group=custom_group, help="Output file path")],
    count: Annotated[int, Parameter(group=custom_group, help="Number of iterations")] = 1,
):
    """Demo custom column layout."""
    print(f"Processing {input_path} -> {output_path} ({count} times)")

if __name__ == "__main__":
    app()

Output:

Usage: test_custom_column.py [ARGS] [OPTIONS]

Demo custom column layout.

╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ --help -h  Display this message and exit.                                    │
│ --version  Display application version.                                      │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Custom Layout ──────────────────────────────────────────────────────────────╮
│     Option                     Type  Description                             │
│ ★   INPUT-PATH --input-path    str   Input file path                         │
│ ★   OUTPUT-PATH --output-path  str   Output file path                        │
│     COUNT --count              int   Number of iterations                    │
╰──────────────────────────────────────────────────────────────────────────────╯

Dynamic Column Builders

For even more flexibility, you can create columns dynamically based on runtime conditions:

from cyclopts import App, Parameter
from cyclopts.help import DefaultFormatter, ColumnSpec
from typing import Annotated

def dynamic_columns(console, options, entries):
    """Build columns based on console width and entries."""
    columns = []

    # Only show required indicator if there are required params
    if any(e.required for e in entries):
        columns.append(ColumnSpec(
            renderer=lambda e: "*" if e.required else "",
            width=2,
            style="red",
        ))

    # Adjust name column width based on console size
    max_width = min(40, int(console.width * 0.3))
    columns.append(ColumnSpec(
        renderer=lambda e: " ".join(e.display_labels),
        header="Option",
        max_width=max_width,
        style="cyan",
    ))

    # Always include description
    columns.append(ColumnSpec(
        renderer="description",
        header="Description",
        overflow="fold",
    ))

    return tuple(columns)

app = App(
    help_formatter=DefaultFormatter(
        column_specs=dynamic_columns
    )
)

@app.default
def main(
    input_file: str,
    output_file: str,
    verbose: bool = False,
):
    """Process files with dynamic columns."""
    print(f"Processing {input_file} -> {output_file}")

if __name__ == "__main__":
    app()

Output (adjusts based on terminal width):

Usage: test_dynamic_columns.py [ARGS] [OPTIONS]

Process files with dynamic columns.

╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ Option     Description                                                       │
│ --help -h  Display this message and exit.                                    │
│ --version  Display application version.                                      │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Parameters ─────────────────────────────────────────────────────────────────╮
│     Option                    Description                                    │
│ *   INPUT-FILE --input-file                                                  │
│ *   OUTPUT-FILE                                                              │
│     --output-file                                                            │
│     VERBOSE --verbose                                                        │
│     --no-verbose                                                             │
╰──────────────────────────────────────────────────────────────────────────────╯

Metavars

A metavar is the placeholder standing in for a parameter's value, e.g. the PATH in --config PATH. HelpEntry.metavar carries it for every parameter that consumes a value; it is None for boolean flags, count parameters, and dict parameters (populated only through --name.KEY VALUE), which consume none. The default derives from the parameter's type (Path → PATH, str → STR, Literal/Enum → CHOICE); an explicit Parameter.choices also yields CHOICE regardless of the underlying type, since the choice list describes the value. Parameter.metavar overrides it. For an optional type the None is stripped, so Path | None yields PATH rather than PATH|NONE (the value you provide is always a Path; its absence is conveyed by the parameter being optional). Tuples render one placeholder per token, the way the values are typed on the command line: tuple[int, int] → INT INT and tuple[int, ...] → INT.... Variadic collections (list, set, Sequence, ...) consume one element per token, so a single-element type renders the same way: list[str] → STR..., set[int] → INT....

This is distinct from a positional parameter's identifier — the SRC in SRC --src — which is its HelpEntry.positional_label, derived from the parameter's name (change it via Parameter.name). metavar never affects the identifier.

Every builtin formatter (rich, plain, markdown, rst, html) appends the metavar to keyword-only parameters (--config PATH), and the usage line shows it for required keyword parameters. Positional-capable rows show their identifier instead (the CONFIG in CONFIG --config, since it already stands in for the value). Parameters that consume no value (boolean flags, counters) never show a metavar, explicit or not. Disable metavars entirely with DefaultFormatter.show_metavar, which drops them from the usage line and clears HelpEntry.metavar on every entry before the columns render, so it also applies to custom column_specs:

from cyclopts import App
from cyclopts.help import DefaultFormatter

app = App(help_formatter=DefaultFormatter(show_metavar=False))

A custom column can instead render the metavar next to every option, positional-capable rows included:

from pathlib import Path
from typing import Annotated

from cyclopts import App, Group, Parameter
from cyclopts.help import ColumnSpec, DefaultFormatter


def names_renderer(entry):
    """Render ``LABEL --option -o METAVAR``."""
    return " ".join(entry.display_labels_with_metavar)


options = Group(
    "Options",
    help_formatter=DefaultFormatter(
        column_specs=(
            ColumnSpec(renderer=names_renderer, style="cyan"),
            ColumnSpec(renderer="description", overflow="fold"),
        )
    ),
)

app = App(name="my-script")


@app.default
def main(
    *,
    config: Annotated[
        Path | None,
        Parameter(name=["--config", "-c"], metavar="FILE", group=options, help="Configuration file."),
    ] = None,
    output_directory: Annotated[Path, Parameter(group=options, help="Where to write results.")] = Path(),
    verbose: Annotated[bool, Parameter(name=["--verbose", "-v"], group=options, help="Increase output.")] = False,
):
    pass


if __name__ == "__main__":
    app()

Output:

$ my-script --help
Usage: my-script [OPTIONS]

╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ --help (-h)  Display this message and exit.                                  │
│ --version    Display application version.                                    │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --config -c FILE           Configuration file.                               │
│ --output-directory PATH    Where to write results.                           │
│ --verbose -v --no-verbose  Increase output.                                  │
╰──────────────────────────────────────────────────────────────────────────────╯

--config uses an explicit Parameter.metavar, --output-directory falls back to its type name (PATH), and --verbose has no metavar at all because a flag consumes no value.

Note

all_options (and names) contain option names only. Use display_labels to reproduce the builtin layout, which prefixes the positional_label for positional parameters, or display_labels_with_metavar to also include the metavar exactly as the builtin formatters do.

Creating Custom Formatters

For complete control, you can implement your own formatter by following the HelpFormatter protocol. The formatter methods receive the console and options first, followed by the content to render:

from cyclopts import App
from cyclopts.help import HelpPanel
from rich.console import Console, ConsoleOptions
from rich.table import Table
from rich.panel import Panel

class MyCustomFormatter:
    """A custom formatter with unique styling."""

    def __call__(self, console: Console, options: ConsoleOptions, panel: HelpPanel) -> None:
        """Render a help panel with custom styling."""
        if not panel.entries:
            return

        # Create a custom table
        table = Table(show_header=True, header_style="bold magenta")
        table.add_column("Option", style="cyan", no_wrap=True)
        table.add_column("Description", style="white")

        for entry in panel.entries:
            name = " ".join(entry.display_labels)
            # Extract plain text from description (handles InlineText, etc)
            desc = ""
            if entry.description:
                if hasattr(entry.description, 'plain'):
                    desc = entry.description.plain
                elif hasattr(entry.description, '__rich_console__'):
                    # Render to plain text without styles
                    with console.capture() as capture:
                        console.print(entry.description, end="")
                    desc = capture.get()
                else:
                    desc = str(entry.description)
            table.add_row(name, desc)

        # Wrap in a custom panel
        panel_title = panel.title or "Options"
        styled_panel = Panel(
            table,
            title=f"[bold blue]{panel_title}[/bold blue]",
            border_style="blue",
        )

        console.print(styled_panel)

    def render_usage(self, console: Console, options: ConsoleOptions, usage) -> None:
        """Render the usage line."""
        if usage:
            console.print(f"[bold green]Usage:[/bold green] {usage}")

    def render_description(self, console: Console, options: ConsoleOptions, description) -> None:
        """Render the description."""
        if description:
            console.print(f"\n[italic]{description}[/italic]\n")

# Use the custom formatter
app = App(help_formatter=MyCustomFormatter())

@app.default
def main(input_file: str, output_file: str, verbose: bool = False):
    """Process files with custom formatter."""
    print(f"Processing {input_file} -> {output_file}")

if __name__ == "__main__":
    app()

Output:

Usage: test_custom_formatter.py [ARGS] [OPTIONS]

Process files with custom formatter.

╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓                               │
│ ┃ Option    ┃ Description                    ┃                               │
│ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩                               │
│ │ --help -h │ Display this message and exit. │                               │
│ │ --version │ Display application version.   │                               │
│ └───────────┴────────────────────────────────┘                               │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Parameters ─────────────────────────────────────────────────────────────────╮
│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓                             │
│ ┃ Option                         ┃ Description ┃                             │
│ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩                             │
│ │ INPUT-FILE --input-file        │             │                             │
│ │ OUTPUT-FILE --output-file      │             │                             │
│ │ VERBOSE --verbose --no-verbose │             │                             │
│ └────────────────────────────────┴─────────────┘                             │
╰──────────────────────────────────────────────────────────────────────────────╯

Reference

For complete API documentation of help formatting components, see:

See also:

  • Help - General help system documentation

  • Groups - Organizing parameters into groups