Theming

Declare a theme once and your whole CLI follows it — help output, styled text, interactive prompts, spinners, and progress bars. Style by meaning, not by color, and let the terminal decide how much color it can show.

zcli's theme system is built on three layers:

  • A palette maps 13 semantic roles — success, err, command, accent, … — to complete styles (color and attributes like bold or dim).
  • Component tokens style specific UI elements — the prompt cursor, the spinner, the progress-bar fill, the chrome behind a full-screen panel. Each token defaults to a palette role, so one palette change restyles everything; any token can also be overridden individually.
  • Capability detection renders every style at the terminal's level: true color, 256 color, 16 color, or plain text — and always respects NO_COLOR.

Declaring a theme

Add a public zcli_theme declaration to your root source file — the same pattern as Zig's std_options. Every field has a default, so you only write what you change:

src/main.zigzig
const zcli = @import("zcli");

pub const zcli_theme: zcli.Theme = .{
    .palette = .{
        // brand your CLI: one color, applied everywhere accent is referenced
        .accent = .{ .foreground = .{ .rgb = .{ .r = 255, .g = 179, .b = 71 } } },
        .command = .{ .foreground = .{ .rgb = .{ .r = 255, .g = 179, .b = 71 } } },
    },
};

That's the whole integration. The generated registry picks the declaration up at compile time and hands it to every command as part of context.theme; the help plugin compiles its output with your palette; prompts and progress read their tokens from it. No theme declared means the default theme — everything works out of the box.

Compile-time The theme is a comptime constant, so themed help text is still generated at compile time — four capability variants baked into the binary, selected at runtime. Custom palettes cost nothing at runtime.

The palette

Each role is a full Style — foreground, background, and attributes. Roles are the vocabulary the rest of the system speaks:

RoleMeant forDefault
successoperations that succeededgreen, bold
errfailures (error is reserved in Zig)coral, bold
warningcaution, deprecationamber, bold
infogeneral informationblue, bold
mutedless important text, hintsgray, dim
commandcommand names in helpturquoise
flagflags and optionsorchid
pathfile pathslight cyan
valueargument names, user valueslawn green
codeinline codepurple
headersection headersbold (no color — readable on light & dark)
linkURLssky blue, italic
accentyour brand color — the role component tokens reference by defaultcyan

Component tokens

Tokens style specific UI elements. Each is a StyleRef: either a reference to a palette role (the default) or a literal style. Change the palette and every referencing token follows; pin a token when one element should differ:

src/main.zigzig
pub const zcli_theme: zcli.Theme = .{
    .prompts = .{
        // defaults: cursor/selected → accent, marker → success, hint → muted
        .selected = .{ .role = .info },                 // point at a different role…
        .cursor = .{ .style = .{ .foreground = .red } }, // …or pin a literal style
    },
    .progress = .{
        // defaults: spinner/bar_fill → accent, bar_empty → muted
    },
};

Surface chrome

Prompts and progress are line-oriented. A full-screen UI also draws surfaces — panels, modals, dropdowns, the border around a box. Those get their own token group, surface: the one place you set "the panel look" instead of repeating styles at every call site.

TokenStylesDefault
borderthe color of a box or panel border (the glyph shaperounded, single — stays a per-node choice)the accent role — recolor your brand and every border follows
panelthe opaque fill behind a panel or overlay's contenta literal dark-gray background (no palette role owns a surface fill)

The point isn't just two more tokens — it's that styling defaults derive from the theme, resolved at compile time from your root zcli_theme. You rarely pass a Style at a call site at all. A bordered ui.box wears surface.border unless you override it; ui.panel wears both tokens, so a modal or dropdown reskins entirely from the theme with zero style mentions. Set the look once; every surface derives from it.

src/main.zigzig
pub const zcli_theme: zcli.Theme = .{
    .surface = .{
        // defaults: border → accent, panel → a dark-gray fill
        .border = .{ .role = .info }, // point borders at a different role…
        .panel = .{ .style = .{ .background = .{ .indexed = 234 } } }, // …or pin a fill
    },
};

At the call site, chrome and semantic text both come straight from the theme — no Style literals, no plumbing:

a full-screen framezig
// A modal whose border and fill derive from the theme's surface tokens.
try ui.panel(a, .{ .padding = .all(1) }, children);

// ui.role pulls a palette role into any text node — one word, themed.
ui.text(ui.role(.success), "done");
Why compile-time The app theme is a comptime constant reached through the root declaration (the same std_options idiom), so every default resolves where it's declared — the ui, prompts, and progress packages read your theme with no value threaded through their APIs. Custom chrome costs nothing at runtime, and a package compiled into your app picks up your theme automatically.

Help output

The help plugin writes semantic tags — <command>, <flag>, <value>, <header> — and your palette decides how they look. There is nothing to wire: declare zcli_theme and --help renders in your colors, compiled at build time for all four capability levels.

Command output

Style your own output with the fluent API. Semantic methods tag content with a role; the role resolves through the active palette when rendered:

src/commands/sync.zigzig
const styled = zcli.theme.styled;

// semantic — follows the app palette
try styled("Synced").success().render(writer, &context.theme);
try styled("deploy.zig").path().render(writer, &context.theme);

// direct — explicit settings override the role's style
try styled("Careful").warning().underline().render(writer, &context.theme);
try styled("raw").rgb(255, 100, 50).bold().render(writer, &context.theme);

Prompts

Interactive prompts style their cursor, selected row, multi-select marker, and hint text through theme.prompts tokens. context.prompts() returns an instance already carrying your theme and the detected capabilities — including NO_COLOR — so every prompt made through it is themed:

promptszig
const p = context.prompts();
const idx = try p.select(.{
    .message = "Pick a region:",
    .choices = &.{ "us-east", "eu-west", "ap-south" },
});

Progress

Spinners animate in the progress.spinner token (accent by default); result symbols — succeed, fail, warn, info — render through the matching palette roles; progress bars color their fill and track via bar_fill/bar_empty on TTYs and stay plain when piped:

progresszig
// context.progress() carries the app theme, so the spinner is themed.
var spinner = try context.progress().spinner(.{});
spinner.start("Deploying...");
spinner.succeed("Deployed");  // ✔ in your palette's success style

Terminal capabilities

Every style is resolved against what the terminal can actually display, detected once per run from the environment (COLORTERM, TERM, platform signals) and whether output is a TTY:

  • true color — exact RGB, as designed
  • 256 color — nearest palette index
  • 16 color — nearest ANSI color, bright variants preserved
  • no color — plain text; set by NO_COLOR, a dumb terminal, or piped output

The same themed call sites produce all four — no branching in your code.

Standalone use

Like every zcli package, theme, prompts, and progress work without the framework. Outside zcli there's no context.theme, so build a ThemeContext yourself — or let the default kick in (default theme at ANSI-16):

standalonezig
const theme = @import("theme");

const ctx = theme.ThemeContext{
    .caps = theme.Capabilities.init(env, io),  // detect from the environment
};
try theme.styled("done").success().render(writer, &ctx);