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:
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.
The palette
Each role is a full Style — foreground, background, and attributes. Roles are the vocabulary the rest of the system speaks:
| Role | Meant for | Default |
|---|---|---|
| success | operations that succeeded | green, bold |
| err | failures (error is reserved in Zig) | coral, bold |
| warning | caution, deprecation | amber, bold |
| info | general information | blue, bold |
| muted | less important text, hints | gray, dim |
| command | command names in help | turquoise |
| flag | flags and options | orchid |
| path | file paths | light cyan |
| value | argument names, user values | lawn green |
| code | inline code | purple |
| header | section headers | bold (no color — readable on light & dark) |
| link | URLs | sky blue, italic |
| accent | your brand color — the role component tokens reference by default | cyan |
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:
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.
| Token | Styles | Default |
|---|---|---|
| border | the color of a box or panel border (the glyph shape — rounded, single — stays a per-node choice) | the accent role — recolor your brand and every border follows |
| panel | the opaque fill behind a panel or overlay's content | a 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.
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 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");
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:
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:
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:
// 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):
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);