This document describes how theming works in the coding-agent today: schema, loading, runtime behavior, and failure modes.
The theme system drives:
- foreground/background color tokens used across the TUI
- markdown styling adapters (
getMarkdownTheme()) - selector/editor/settings list adapters (
getSelectListTheme(),getEditorTheme(),getSettingsListTheme()) - symbol preset + symbol overrides (
unicode,nerd,ascii) - syntax highlighting colors used by native highlighter (
@gajae-code/natives) - status line segment colors
Primary implementation: src/modes/theme/theme.ts.
Theme files are JSON objects validated against the runtime schema in theme.ts (ThemeJsonSchema) and mirrored by src/modes/theme/theme-schema.json.
Top-level fields:
name(required)colors(required; all color tokens required)vars(optional; reusable color variables)export(optional; HTML export colors)symbols(optional)preset(optional:unicode | nerd | ascii)overrides(optional: key/value overrides forSymbolKey)
Color values accept:
- hex string (
"#RRGGBB") - 256-color index (
0..255) - variable reference string (resolved through
vars) - empty string (
"") meaning terminal default (\x1b[39mfg,\x1b[49mbg)
All tokens below are required in colors.
accent, border, borderAccent, borderMuted, success, error, warning, muted, dim, text, thinkingText
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg, statusLineBg
userMessageText, customMessageText, customMessageLabel, toolTitle, toolOutput
mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
toolDiffAdded, toolDiffRemoved, toolDiffContext,
syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, bashMode, pythonMode
statusLineSep, statusLineModel, statusLinePath, statusLineGitClean, statusLineGitDirty, statusLineContext, statusLineSpend, statusLineStaged, statusLineDirty, statusLineUntracked, statusLineOutput, statusLineCost, statusLineSubagents
Used for HTML export theming helpers:
export.pageBgexport.cardBgexport.infoBg
If omitted, export code derives defaults from resolved theme colors.
symbols.presetsets a theme-level default symbol set.symbols.overridescan override individualSymbolKeyvalues.
Runtime precedence:
- settings
symbolPresetoverride (if set) - theme JSON
symbols.preset - fallback
"unicode"
Invalid override keys are ignored and logged (logger.debug).
Theme lookup order (loadThemeJson):
- built-in embedded themes (
red-claw.json,blue-crab.json,claude-code.json,codex.json, andopencode.jsoncompiled intodefaultThemes) - custom theme file:
<customThemesDir>/<name>.json
Custom themes directory comes from getCustomThemesDir():
- default:
~/.gjc/agent/themes - overridden by
GJC_CODING_AGENT_DIR($GJC_CODING_AGENT_DIR/themes)
getAvailableThemes() returns merged built-in + custom names, sorted, with built-ins taking precedence on name collision.
For custom theme files:
- read JSON
- parse JSON
- validate against
ThemeJsonSchema - resolve
varsreferences recursively - convert resolved values to ANSI by terminal capability mode
Validation behavior:
- missing required color tokens: explicit grouped error message
- bad token types/values: validation errors with JSON path
- unknown theme file:
Theme not found: <name>
Var reference behavior:
- supports nested references
- throws on missing variable reference
- throws on circular references
Color mode detection (detectColorMode):
COLORTERM=truecolor|24bit=> truecolorWT_SESSION=> truecolorTERMindumb,linux, or empty => 256color- otherwise => truecolor
Conversion behavior:
- hex ->
Bun.color(..., "ansi-16m" | "ansi-256") - numeric ->
38;5/48;5ANSI ""-> default fg/bg reset
main.ts initializes theme with settings:
symbolPresetcolorBlindModetheme.darktheme.light
Auto theme slot selection uses terminal appearance in this order:
- terminal-reported OSC 11 background luminance, unless the macOS/Zellij fallback path is active
COLORFGBGbackground index (< 8=> dark,>= 8=> light)- macOS appearance fallback only for the known-broken macOS/Zellij OSC 11 path
- dark slot fallback
Built-in theme note: red-claw is the default dark GJC theme, and blue-crab is the default light-slot theme. Both are crustacean brand themes with separate semantic error/warning/diff-removal tokens and crab-oriented symbol overrides. Three additional bundled migration themes — claude-code, codex, and opencode — mirror the look of those tools for easy eye-migration. All three are dark-classified and recommended for theme.dark, but are selectable in either slot; they keep GJC's default symbol identity (no crab-symbol overrides).
Current defaults from settings schema:
theme.dark = "red-claw"theme.light = "blue-crab"symbolPreset = "unicode"colorBlindMode = false
/themewith no arguments opens the interactive theme selector with live preview./theme <name>switches immediately: the name is validated against built-in and custom themes, persisted to the detected slot (theme.darkortheme.light), and applied to the running session (status line, editor border, and chat re-render at once). An unknown name is rejected with the list of available themes and changes nothing.
- loads selected theme
- updates global
themesingleton - optionally starts watcher
- triggers
onThemeChangecallback
On failure:
- falls back to built-in
dark - returns
{ success: false, error }
- applies temporary preview theme to global
theme - does not change persisted settings by itself
- returns success/error without fallback replacement
The settings theme picker is confirm-only; arrow-key browsing does not call previewTheme, so the rendered theme and displayed/persisted theme name stay aligned until Enter confirms a new selection.
When watcher is enabled (setTheme(..., true) / interactive init):
- watches
<customThemesDir>/<currentTheme>.jsononly when that file exists - built-ins are effectively not watched; built-in theme lookup also takes precedence over same-name custom files
- matching file changes schedule a debounced reload; reload errors or temporary file absence keep the last successfully loaded theme
- the watcher does not perform a delete/rename fallback; it waits for a future successful reload or explicit theme switch
Auto mode also reevaluates dark/light slot mapping from terminal appearance changes, SIGWINCH, and the macOS fallback observer when active.
colorBlindMode changes only one token at runtime:
toolDiffAddedis HSV-adjusted (green shifted toward blue)- adjustment is applied only when resolved value is a hex string
Other tokens are unchanged.
Theme-related settings are persisted by Settings to global config YAML:
- path:
<agentDir>/config.yml - default agent dir:
~/.gjc/agent - effective default file:
~/.gjc/agent/config.yml
Persisted keys:
theme.darktheme.lightsymbolPresetcolorBlindMode
Legacy migration exists: old flat theme: "name" is migrated to nested theme.dark or theme.light based on luminance detection; legacy built-in names dark/light map to red-claw/blue-crab unless matching custom theme files exist.
- Create file in custom themes dir, e.g.
~/.gjc/agent/themes/my-theme.json. - Include
name, optionalvars, and all requiredcolorstokens. - Optionally include
symbolsandexport. - Select the theme in Settings (
Display -> Dark themeorDisplay -> Light theme) depending on which auto slot you want. All bundled themes are selectable: the crustacean defaultsred-clawandblue-crab, plus the migration themesclaude-code,codex, andopencode(dark-classified, recommended for the dark slot but selectable in either).
Minimal skeleton:
{
"name": "my-theme",
"vars": {
"accent": "#7aa2f7",
"muted": 244
},
"colors": {
"accent": "accent",
"border": "#4c566a",
"borderAccent": "accent",
"borderMuted": "muted",
"success": "#9ece6a",
"error": "#f7768e",
"warning": "#e0af68",
"muted": "muted",
"dim": 240,
"text": "",
"thinkingText": "muted",
"selectedBg": "#2a2f45",
"userMessageBg": "#1f2335",
"userMessageText": "",
"customMessageBg": "#24283b",
"customMessageText": "",
"customMessageLabel": "accent",
"toolPendingBg": "#1f2335",
"toolSuccessBg": "#1f2d2a",
"toolErrorBg": "#2d1f2a",
"toolTitle": "",
"toolOutput": "muted",
"mdHeading": "accent",
"mdLink": "accent",
"mdLinkUrl": "muted",
"mdCode": "#c0caf5",
"mdCodeBlock": "#c0caf5",
"mdCodeBlockBorder": "muted",
"mdQuote": "muted",
"mdQuoteBorder": "muted",
"mdHr": "muted",
"mdListBullet": "accent",
"toolDiffAdded": "#9ece6a",
"toolDiffRemoved": "#f7768e",
"toolDiffContext": "muted",
"syntaxComment": "#565f89",
"syntaxKeyword": "#bb9af7",
"syntaxFunction": "#7aa2f7",
"syntaxVariable": "#c0caf5",
"syntaxString": "#9ece6a",
"syntaxNumber": "#ff9e64",
"syntaxType": "#2ac3de",
"syntaxOperator": "#89ddff",
"syntaxPunctuation": "#9aa5ce",
"thinkingOff": 240,
"thinkingMinimal": 244,
"thinkingLow": "#7aa2f7",
"thinkingMedium": "#2ac3de",
"thinkingHigh": "#bb9af7",
"thinkingXhigh": "#f7768e",
"bashMode": "#2ac3de",
"pythonMode": "#bb9af7",
"statusLineBg": "#16161e",
"statusLineSep": 240,
"statusLineModel": "#bb9af7",
"statusLinePath": "#7aa2f7",
"statusLineGitClean": "#9ece6a",
"statusLineGitDirty": "#e0af68",
"statusLineContext": "#2ac3de",
"statusLineSpend": "#7dcfff",
"statusLineStaged": "#9ece6a",
"statusLineDirty": "#e0af68",
"statusLineUntracked": "#f7768e",
"statusLineOutput": "#c0caf5",
"statusLineCost": "#ff9e64",
"statusLineSubagents": "#bb9af7"
}
}Use this workflow:
- Start interactive mode (watcher enabled from startup).
- Open settings and confirm the custom theme in the dark/light theme picker; arrow-key browsing is intentionally non-mutating.
- For custom theme files, edit the JSON while running and confirm auto-reload on save.
- Exercise critical surfaces:
- markdown rendering
- tool blocks (pending/success/error)
- diff rendering (added/removed/context)
- status line readability
- thinking level border changes
- bash/python mode border colors
- Validate both symbol presets if your theme depends on glyph width/appearance.
- All
colorstokens are required for custom themes. exportandsymbolsare optional.$schemain theme JSON is informational; runtime validation is enforced by a Zod schema in code.setThemefailure falls back todark;previewThemefailure does not replace current theme.- File watcher reload errors or temporary missing files keep the current loaded theme until a successful reload or explicit theme switch.