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.
┌─────────────────┐ ┌───────────────┐
│ 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.
The default palette defines 26 semantic color roles used by all style rules:
| Group | Roles | Purpose |
|---|---|---|
| Background | bg_deep, bg_mid, bg_surface, bg_raised | 4-tier depth gradient |
| Foreground | fg1 .. fg8 | 8-level text gradient (dim → bright) |
| Accent | accent1 .. accent4 | UI accent colors (highlight → deep) |
| Status | success, error, warning, caution | Semantic status colors |
| Auxiliary | special1, special2, warm1, warm2, warm3 | Purple, amber, earth brown, sage tones |
| Text | text | Canonical 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.
| Section | What it styles | Source |
|---|---|---|
shell | Prompt, builtins, completion, pager, REPL separators, wiki mode, widgets | Built-in |
markdown | Markdown renderer (headings, code blocks, borders, divs, tables, etc.) | Built-in |
agent | Coding Agent display, prompts, tool output | Agent LILPACK |
Additional sections can be registered by LILPACKs via theme.register_section().
Themes are discovered from two sources:
| Priority | Source | Description |
|---|---|---|
| 1 | LILPACK | Themes registered from installed LILPACK packages |
| 2 | Bundled | theme.catalog module (compiled into binary) |
If a theme name exists in both sources, the LILPACK theme takes priority.
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:
default — Somewhat based on Alabaster Dark palette (defined in src/theme/theme.lua)
gruvbox — Gruvbox Dark palette
kanagawa — Kanagawa Wave palette (inspired by Hokusai's "The Great Wave")
1984 — Orwellian dystopian palette
When theme.set(name) is called, the palette is resolved first:
Default palette — 26 built-in roles from src/theme/theme.lua
Theme palette — palette 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:
Palette-derived defaults — Built from the merged palette
Theme element overrides — Per-element overrides from the active theme
Extra overrides — Optional parameter passed to get()
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.
LILPACKs can extend the theme system in two ways:
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.
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.
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.
Theme changes take effect immediately for all shell and agent components. This works through two mechanisms:
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:
shell/mode/shell.lua — shell mode TLBs
shell/mode/shell.prompt.lua — prompt rendering
shell/shell.lua — shell input and completion TSS subscriptions
shell/mode/lua.lua — Lua REPL separator
shell/mode/lua.prompt.lua — Lua REPL prompt
shell/mode/wiki.lua — wiki mode display
shell/mode/wiki.prompt.lua — wiki mode prompt
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:
shell/builtins/fs.lua — ls, cat
shell/builtins/history.lua
shell/builtins/dns.lua — dig
shell/builtins/net.lua — netstat
shell/builtins/env.lua
shell/messages.lua
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
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} },
},
},
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:
Named ANSI colors: "red", "green", "blue", etc.
256-color index: 42
RGB array: {100, 200, 255} (Lua) or [100, 200, 255] (JSON)