Skip to content

Themes

tmnl’s chrome — the strip background, the tab pills, the search chip, the text colors — is a single Palette struct. The struct is small (a handful of straight-sRGB [r, g, b, a] quads), it’s read by every render-path call site, and it’s process-global by default (with thread-local override hooks for per-window themes; see Multi-window).

Pin a theme by name in ~/.config/tmnl/config.toml:

theme = "kanagawa"

Six themes are bundled into the binary — no extra files needed:

  • onedark (default)
  • kanagawa
  • gruvbox
  • tokyonight
  • catppuccin
  • nord

Save the file and the next tmnl launch picks it up. To swap live without editing the config, cycle with ⌘⌥] / ⌘⌥[ (see Cycling themes — chord + menu).

tmnl can follow the macOS system appearance and swap between a paired dark + light theme on every flip. Two knobs in ~/.config/tmnl/config.toml:

follow_system_appearance = true
theme = "dark:catppuccin,light:onedark"

The pair syntax matches Ghostty’s. When follow_system_appearance is on and theme carries a dark:.../light:... pair, tmnl picks the right side based on the current OS appearance and re-runs theme adoption on every system flip. If theme is unset, tmnl falls back to mnml’s [ui] theme (the existing single-theme behavior).

Without follow_system_appearance (the default), theme is treated as a single pinned name and the OS appearance is ignored. On non-macOS platforms follow_system_appearance has no effect — pin a single name.

Close-up of tmnl's chrome strip — the three-tier gradient (strip background → arrow button pill → active tab pill → search chip) that every theme paints

This page is about where the palette comes from. Specifically:

  • The six themes bundled into the binary for standalone use.
  • The on-disk precedence chain that lets mnml-installed themes (and Ghostty community themes) win over bundled ones.
  • How mnml’s [base_30] schema maps onto tmnl’s chrome roles.

If you want to pick a theme, that lives in the Settings overlay’s “Window theme” row — see Multi-window. If you want to understand what your pick is doing, you’re on the right page.

tmnl previously resolved theme files only from mnml’s install directory (~/.config/mnml/themes/). That works when mnml is also installed; the standalone tmnl-only install had no themes at all.

The binary now bakes in six starter themes via include_str!:

| Name | Origin | | --- | --- | | onedark | mnml’s default theme (and tmnl’s curated default, eyedropped from this same theme rendered in Apple Terminal). | | kanagawa | Calm warm-blue palette. | | gruvbox | High-contrast retro warm palette. | | tokyonight | Cool blue / purple palette. | | catppuccin | Pastel warm palette. | | nord | Cool blue-grey palette. |

These are mnml-format .toml files copied into tmnl/themes/ and pulled in at compile time. A fresh cargo install tmnl-rs ships them in the binary itself — no install steps, no extra files on disk.

The Settings overlay’s “Window theme” row reads the merged set (bundled + on-disk) so the picker shows every available theme even on a machine where mnml has never been installed.

Power users with a curated ~/.config/mnml/themes/ may not want the bundled set polluting their picker. The Settings overlay row “Include bundled themes” (the row sitting between “Show welcome” and “Window theme”) flips a config-level toggle:

~/.config/tmnl/config.toml
include_bundled_themes = false

When off, theme::list_theme_names skips the bundled merge and the “Window theme” picker shows only what’s actually on disk. The on-disk side of the resolver is untouched — a name like kanagawa still resolves through the seven-step precedence chain below; flipping this off only hides bundled names from the picker list.

User-installed themes always win on a name collision regardless of the toggle. The flag affects discovery (what shows up), not resolution (what wins on lookup).

The implementation pattern is worth knowing if you’re writing similar process-wide toggles. theme::list_theme_names is a free function called from several call sites; rather than thread a &Config through every caller, the toggle lives on a process-wide AtomicBool (theme::INCLUDE_BUNDLED_THEMES). App::resumed mirrors the cfg value at startup via theme::set_include_bundled_themes(on), and the Settings overlay re-mirrors on every commit so user toggles take effect without an app restart.

load_theme_palette(name) walks candidate locations in order and returns the first hit:

| # | Path | Format | | --- | --- | --- | | 1 | ~/.config/mnml/themes/<name>.toml | mnml | | 2 | ~/Library/Application Support/mnml/themes/<name>.toml (mac data dir) | mnml | | 3 | ~/Projects/mnml/themes/<name>.toml (dev fallback) | mnml | | 4 | ~/.config/mnml/themes/<name>.ghostty | Ghostty | | 5 | ~/Library/Application Support/mnml/themes/<name>.ghostty | Ghostty | | 6 | ~/Projects/mnml/themes/<name>.ghostty | Ghostty | | 7 | Bundled themes/<name>.toml (include_str!-baked) | mnml |

The first stop that contains a parseable theme wins. User installs always beat bundled themes on the same name: drop your own kanagawa.toml into ~/.config/mnml/themes/ and tmnl picks it up over the bundled copy without further configuration.

The dev fallback at ~/Projects/mnml/themes/ exists so the tmnl repo checkout can drive a working theme picker for contributors before they’ve shipped a release. It’s intentionally last in the on-disk list.

If every candidate misses, load_theme_palette returns None and the caller (Palette::from_mnml / Palette::from_theme_name) falls back to Palette::defaults — the curated onedark eyedrop.

Native format. The relevant block is [base_30], mnml’s chrome palette schema. tmnl parses a small subset of fields:

[base_30]
darker_black = "#1A1D22" # strip / clear_bg
black = "#1E2228" # arrow button pill
black2 = "#24272D" # active tab pill
one_bg = "#292D35" # search chip body
white = "#E6E8EC" # bright text (active tab labels, palette title)
grey_fg = "#9FA7B4" # tab label foreground
grey_fg2 = "#7A828F" # dim_fg (URL hints, "waiting for client" text)
yellow = "#F0B96A" # accent (keymap hints)
blue = "#61AFEF" # primary blue chip

Each field is parsed via hex(s) (#rrggbb or rrggbb) and projected onto a Palette field by map_mnml_to_palette. Missing optional fields (black2, grey_fg2, yellow, blue) fall back to Palette::defaults per-field, so older theme files without black2 still load cleanly.

Ghostty’s permissive key = value config file. The parser is forgiving: blank lines, #-comments, whitespace around = are all fine, and unknown keys (e.g. font-family, cursor-style) are silently dropped.

Recognised keys:

background = #1a1b26
foreground = #c0caf5
cursor-color = #c0caf5
selection-background = #283457
selection-foreground = #c0caf5
palette = 0=#15161e
palette = 4=#7aa2f7
palette = 8=#414868
palette = 11=#e0af68
palette = 12=#7aa2f7

Ghostty themes don’t carry chrome slots — there’s no “tab pill background” field in the format. map_ghostty_to_palette auto-derives the three-tier chrome gradient by lifting the terminal background by 8% / 16% / 24% toward white. The proportions match the onedark default (#1A1D22 → #1E2228 → #24272D → #292D35).

Text colors lean on the ANSI palette:

| tmnl role | Ghostty palette slot | | --- | --- | | tab_fg (inactive tab labels) | index 7 (white / light grey) | | dim_fg (URL hints, secondary text) | index 8 (bright black) — falls back to a 60% blend of fg→bg if missing | | accent_fg (keymap hints) | index 11 (bright yellow), then index 3 (yellow) | | blue (primary chip) | index 12 (bright blue), then index 4 (blue) |

The point of supporting Ghostty’s format is leverage: the community ships hundreds of .ghostty theme files. Drop any of them into ~/.config/mnml/themes/ and tmnl loads them with no migration step and no second config surface.

How mnml’s schema maps onto tmnl’s chrome

Section titled “How mnml’s schema maps onto tmnl’s chrome”

tmnl’s Palette struct is intentionally small — chrome only, not the 40-slot terminal palette a shell renders into. The mapping looks like:

| Palette field | mnml base_30 field | What it paints | | --- | --- | --- | | clear_bg, strip_bg | darker_black | Top-of-window letterbox + tab strip background | | btn_bg | black | Arrow button pill (back / forward) — tier 1 of the 3-tier chrome gradient | | active_chip_bg | black2 | Active tab pill — tier 2 | | chip_bg | one_bg | Search chip body — tier 3 (the brightest, primary affordance) | | text_fg | white | Active tab labels, palette title — the bright text role | | tab_fg | grey_fg | Inactive tab labels — brighter than dim_fg because tabs are nav | | dim_fg | grey_fg2 (aliases: light_grey) | URL hints, “waiting for client” text, secondary chrome | | accent_fg | yellow (alias: orange) | Keymap hints, highlight glyphs | | blue | blue | Primary blue chip (TREE pill style in mnml’s statusline; exported as MNML_PROMPT_BLUE so a shell prompt can match) |

The three-tier gradient inside the bufferline cluster (btn_bg < active_chip_bg < chip_bg) is the key visual cue — each tier sits visibly above the strip background, so tab pills and search chips read as raised surfaces over the chrome row.

mnml’s MnmlBase30 accepts light_grey as an alias for grey_fg2, and orange as an alias for yellow. The serde deserializer errors on “duplicate field” when both members of a pair appear in the same TOML block.

Most on-disk theme files in mnml’s repo include both aliases. The filesystem probe used to swallow the parse error silently and fall through to the next candidate; the bundled path doesn’t have that fallback — if a bundled theme can’t parse, the standalone install has no theme.

The six bundled themes are mnml’s theme files with the light_grey / orange duplicate-alias lines stripped, so they parse cleanly through include_str! + toml::from_str. The on-disk equivalents in mnml’s install retain both aliases; the read_mnml_theme_file probe logs the parse error and falls through. Same theme, two valid forms. Don’t strip the aliases from your own ~/.config/mnml/themes/ files unless they’re already breaking — mnml itself needs them.

⌘⌥] cycling tmnl's chrome through the bundled themes — onedark → kanagawa → gruvbox → tokyonight → catppuccin → nord

⌘⌥] / ⌘⌥[ cycle the focused window through the discovered theme list (bundled + on-disk, in the precedence-chain order resolved per name). The chord paths to the palette commands theme.cycle_next / theme.cycle_prev, which are also discoverable via the View menu:

| Menu entry | Accelerator | Palette command | | --- | --- | --- | | View → Next Theme | ⌘⌥] | theme.cycle_next | | View → Previous Theme | ⌘⌥[ | theme.cycle_prev |

The menu mirrors the chord — same accelerator displayed next to the entry — so users who don’t know the chord can find the action through the standard macOS menu path or macOS’s Help-menu search (typing “Next Theme” lands on the menu entry directly).

There’s no top-strip chip for the cycle. A custom chip would need a new GPU rect, a paint routine, click dispatch, a tooltip arm, and layout math touching the palette/launcher cluster — a substantial surface for one polish item. The menu surface delivers the same discoverability without that footprint and pairs cleanly with the chord for users who do memorize it. If a strip chip ever ships, the menu entry stays — chord + menu + chip are complementary surfaces, each catching a different user habit (keyboard / mouse without a memorized chord / mouse with a discoverable affordance).

Picking a theme — via Settings → “Window theme” or by editing ~/.config/tmnl/config.toml’s theme knob — retints every paint path that reads through palette():

  • Cell colors (text, default backgrounds for shell tabs without an explicit bg).
  • The chrome strip (background, arrow button pill, active / inactive tab pills, search chip).
  • The cursor color.
  • The blue accent (TREE-style pills, MNML_PROMPT_BLUE exported to the shell so prompt chips match).

palette() is read on every render frame, so the swap is immediate — no relaunch.

Cycling themes retints the help overlay too — section headers, text, and chord pills all pick up the new palette on the next paint frame

Per-window theme overrides are the same machinery, scoped through a thread-local push. See Multi-window — Phase 6b for the override layer, and the Apply-to scope toggle for picking “this window only” vs “all windows.”

When mnml is also installed, tmnl can adopt mnml’s currently-selected theme automatically on startup. The integration:

  1. Read ~/.config/mnml/config.toml’s [ui] theme = "<name>".
  2. Resolve <name> through the precedence chain above.
  3. Apply the result to tmnl’s chrome palette.

A once-per-tick mtime poll on mnml’s config file fires theme::refresh when the mtime moves. Edit mnml’s theme name, save the file, and tmnl’s chrome retints within one tick — no relaunch. There’s also a manual theme.refresh command in the palette for the case where the mtime check misses (rare; some editors rewrite the file in a way that leaves mtime unchanged).

  • Multi-window — per-window theme overrides; the same Palette struct, scoped through a thread-local push so each window can paint with its own theme.
  • Native tabs — hosted apps receive tmnl’s active palette via the Palette { bg, fg, accent } message so they can theme their chrome to match.
  • FEATURES.md — the shipped-feature inventory, including the bundled-themes line.
  • The implementation lives in src/theme.rs (the resolver, the MnmlTheme / GhosttyTheme parsers, Palette struct, bundled-theme include_str! table). The six theme files live under themes/.