Themes

Overview

This document covers the theme system in Lilush Shell: how themes are structured, how they're discovered and applied, and how live theme switching works.

Related: TSS for the styling engine that consumes theme data.

The theme module (src/theme/theme.lua) provides a centralized palette for all styled output: shell prompt, builtins (ls, history, dig, etc.), completion, pager, wiki mode, markdown rendering, and LILPACK-provided sections (e.g. agent mode).

Theme data is structured as a set of RSS tables (raw style sheets) organized into sections. The core sections are shell and markdown; additional sections can be registered by LILPACKs at runtime. Each section feeds into term.tss to produce styled terminal output.

At the core of the theme system is a semantic palette — a table of 26 named color roles (e.g. success, accent3, text). All built-in style rules reference these roles. Bundled and custom themes override the palette to change the entire look, optionally adding per-element overrides for fine-tuning.

Themes can be switched at runtime — no restart required.

Architecture

Data flow

┌─────────────────┐   ┌───────────────┐
│ default palette │ → │ theme palette │
│ (26 roles)      │   │ (overrides)   │
└─────────────────┘   └───────────────┘
         │                    │
         └── merged palette ──┘
                    │
          build_defaults(palette)
             + LILPACK section builders
                    │
                    ▼
          palette-derived RSS tables
                    │
         ┌──────────┼──────────┐
         ▼          ▼          ▼
  ┌──────────┐ ┌────────┐ ┌────────┐
  │ theme    │ │ extra  │ │ merged │
  │ element  │ │ over-  │ │ RSS    │
  │ overrides│ │ rides  │ │ table  │
  │ (opt.)   │ │ (param)│ │        │
  └──────────┘ └────────┘ └────────┘
                    │
                    ▼
             tss = style.new(rss)

Each layer is deep-merged on top of the previous one. Later layers override earlier ones at the leaf level.

Palette

The default palette defines 26 semantic color roles used by all style rules:

GroupRolesPurpose
Backgroundbg_deep, bg_mid, bg_surface, bg_raised4-tier depth gradient
Foregroundfg1 .. fg88-level text gradient (dim → bright)
Accentaccent1 .. accent4UI accent colors (highlight → deep)
Statussuccess, error, warning, cautionSemantic status colors
Auxiliaryspecial1, special2, warm1, warm2, warm3Purple, amber, earth brown, sage tones
TexttextCanonical body text color (default: same as fg7)

The text role is the conventional main content color. All sections that display body text should reference p.text rather than picking an fg level directly. This ensures a consistent text color across LEM, markdown rendering, and any LILPACK-provided sections.

Themes override this palette to change the entire color scheme. Style rules are built from the palette via factory functions (build_builtin(p), build_prompt(p), build_markdown(p), etc.), so a palette override is sufficient to retheme the entire shell.

Sections

SectionWhat it stylesSource
shellPrompt, builtins, completion, pager, REPL separators, wiki mode, widgetsBuilt-in
markdownMarkdown renderer (headings, code blocks, borders, divs, tables, etc.)Built-in
agentCoding Agent display, prompts, tool outputAgent LILPACK

Additional sections can be registered by LILPACKs via theme.register_section().

Theme Sources

Themes are discovered from two sources:

PrioritySourceDescription
1LILPACKThemes registered from installed LILPACK packages
2Bundledtheme.catalog module (compiled into binary)

If a theme name exists in both sources, the LILPACK theme takes priority.

Bundled themes

The theme.catalog module (src/theme/theme/catalog.lua) contains curated themes compiled directly into the binary. Each catalog theme defines a palette table mapping the 26 semantic roles to theme-specific colors, plus optional per-element overrides for styles that can't be derived from the palette alone.

Current bundled themes:

Merge Order

When theme.set(name) is called, the palette is resolved first:

  1. Default palette — 26 built-in roles from src/theme/theme.lua

  2. Theme palettepalette table from the active theme (if any)

The merged palette is then passed to factory functions to build the default RSS tables. When theme.get(section) is called, additional layers are merged:

  1. Palette-derived defaults — Built from the merged palette

  2. Theme element overrides — Per-element overrides from the active theme

  3. Extra overrides — Optional parameter passed to get()

API

The theme module (require("theme")) exports the following functions:

get(section, extra_overrides)

Returns a merged RSS table for the given section.

local theme = require("theme")
local shell_rss = theme.get("shell")
local tss = require("term.tss").new(shell_rss)

set(name)

Activates a named theme. Pass "default" to return to the built-in palette. Returns true on success, or nil, error_message if the theme is not found.

theme.set("gruvbox")       -- activate gruvbox
theme.set("default")       -- back to built-in palette

current()

Returns the active theme name as a string ("default" when no named theme is active).

list()

Returns a sorted array of { name = string, source = string } entries for all discovered themes. Source is one of "bundled" or "lilpack".

palette()

Returns a copy of the currently active palette table (26 color roles). Useful for debugging or building palette-aware tools.

local pal = theme.palette()
-- pal.success, pal.accent3, pal.text, etc.

subscribe(section, sub_key)

Returns a closure that tracks theme changes and lazily rebuilds a TSS instance. Calling the closure returns the cached TSS, rebuilding it only when the theme generation has changed (i.e., after a set() call).

local theme = require("theme")
local ensure_tss = theme.subscribe("shell")           -- full section
local ensure_completion_tss = theme.subscribe("shell", "completion")  -- sub-key

The optional sub_key parameter extracts a nested table from the section RSS before passing it to style.new(). This is useful for modules that only need a subset of a section (e.g., completion styles within the shell section).

Each subscriber gets its own independent TSS instance. The invalidation check is a single integer comparison — negligible cost per call. Internally, subscribe uses a generation counter that set() increments on every theme change.

register_section(name, builder_fn)

Registers a LILPACK-provided theme section. The builder_fn receives the active palette table and must return an RSS table for the section. The builder is called immediately with the current palette and again whenever the theme changes.

local theme = require("theme")
theme.register_section("agent", function(p)
    return {
        agent = { error = { fg = p.error }, ... },
        prompts = { ... },
    }
end)

Called automatically during shell startup for LILPACKs that declare theme sections in their manifest.

register_theme(name, data)

Registers a LILPACK-provided theme. The data table follows the same format as bundled catalog entries: a palette table plus optional per-section override tables.

theme.register_theme("solarized", {
    palette = { bg_deep = {0, 43, 54}, fg8 = {253, 246, 227}, ... },
    markdown = { heading = { fg = {181, 137, 0} } },
})

Called automatically during shell startup for LILPACKs that declare theme definitions in their manifest.

LILPACK Theme Extensions

LILPACKs can extend the theme system in two ways:

Section builders

A LILPACK can provide a theme section (e.g., agent mode styles). The manifest declares a builder module that receives the palette and returns section-specific RSS data:

{
    "theme": {
        "sections": [
            {"name": "agent", "builder": "agent.theme"}
        ]
    }
}

The builder module must return a function:

-- agent/theme.lua
return function(p)
    return {
        agent = { error = { fg = p.error }, ... },
        prompts = { ... },
    }
end

Section builders are called with the active palette each time the theme changes, so palette-derived colors update automatically.

Theme definitions

A LILPACK can provide a complete theme (palette + optional section overrides):

{
    "theme": {
        "definition": {
            "name": "solarized",
            "module": "solarized.theme"
        }
    }
}

The module must return a table in the same format as bundled catalog entries:

-- solarized/theme.lua
return {
    palette = {
        bg_deep = {0, 43, 54},
        fg8 = {253, 246, 227},
        text = {238, 232, 213},
        -- ... remaining roles ...
    },
    -- Optional per-section overrides:
    markdown = {
        heading = { fg = {181, 137, 0} },
    },
}

A single LILPACK can provide both section builders and theme definitions.

Shell Builtin

The theme command is a shell builtin with two forms:

theme                   # List available themes, highlighting the active one
theme <name>            # Switch to a named theme

Tab completion is supported for theme names.

Live Reloading

Theme changes take effect immediately for all shell and agent components. This works through two mechanisms:

Subscribe-based TSS rebuild

Long-lived modules use theme.subscribe() to obtain a self-invalidating TSS accessor. The returned closure rebuilds the TSS only when the theme generation changes (after a set() call):

local theme_mod = require("theme")
local ensure_tss = theme_mod.subscribe("shell")

-- later, in any function:
local tss = ensure_tss()  -- cheap: returns cached TSS unless theme changed

For modules that need a sub-key of a section:

local ensure_tss = theme_mod.subscribe("shell", "completion")

Modules using this pattern:

Per-invocation fetch

Builtins that create a fresh TSS for each execution fetch theme data at call time rather than caching it at module level:

local theme_mod = require("theme")

local some_builtin = function(cmd, args)
    local tss = style.new(theme_mod.get("shell"))
    -- ... use tss ...
end

Since theme_mod.get() always returns data reflecting the active theme (and results are cached internally per generation), these builtins automatically pick up changes with minimal overhead.

Modules with this pattern:

Persisting Themes

Theme selection is not automatically persisted. To start the shell with a specific theme, add the theme <name> command to your init script:

# ~/.config/lilush/init.lsh
theme gruvbox

Creating Custom Themes

Adding a bundled theme

Add the theme data as a new key in src/theme/theme/catalog.lua and rebuild with ./build.bash. A minimal bundled theme only needs a palette table:

["mytheme"] = {
    palette = {
        bg_deep = {30, 25, 20}, bg_mid = {45, 40, 35},
        bg_surface = {60, 55, 50}, bg_raised = {80, 75, 70},
        fg1 = {100, 95, 90}, fg2 = {110, 105, 100},
        -- ... remaining roles ...
        text = {210, 200, 190},
        success = {140, 190, 80}, error = {210, 90, 80},
        warning = {220, 180, 70}, caution = {200, 140, 70},
    },
    -- Optional per-element overrides for styles that differ from palette:
    markdown = {
        heading = { fg = {220, 180, 100} },
    },
},

Creating a theme LILPACK

Create a LILPACK package with a theme definition in its manifest. See the LILPACK documentation for the full convention, and the "LILPACK Theme Extensions" section above for the manifest format.

Colors can be specified as: