Skip to content

Themes

Color theming in a terminal AI agent spans several layers: the raw terminal’s color support (16 colors, 256 colors, 24-bit truecolor), the application’s semantic color palette (primary, error, muted, syntax tokens), and the markdown-specific rendering styles (heading color, code block color, link underline). Getting all three right — and adapting to what the user’s terminal can actually display — is where most tools make mistakes. Aider takes the minimal approach: per-element color flags with two preset modes. Codex builds a runtime color adaptation system using terminal capability detection, perceptual distance, and algorithmic blending. OpenCode ships a full JSON-based theme engine with 27+ built-in themes, dark/light variants per color, and system palette querying via OSC escape sequences.


A theme system for a terminal AI coding agent must address:

  • Terminal capability negotiation: Not all terminals support truecolor. A TUI should detect the terminal’s color level (16-color, 256-color, 24-bit) and use the best representation available.
  • Semantic color palette: Named colors for primary, error, textMuted, background, success, etc. Components should reference semantic names, not hardcoded hex values.
  • Dark/light mode detection: The application should determine whether the terminal background is light or dark, either through explicit user flags or by querying the terminal.
  • Code syntax highlighting: Separate from the UI palette, the syntax highlighting system needs its own color tokens mapped to language grammar scopes.
  • Markdown rendering colors: Headings, inline code, blockquotes, and links each need separate colors that integrate with the active theme.
  • User customization: Power users should be able to override any color without forking the codebase.

Source: aider/args.py, aider/main.py, aider/io.py, aider/mdstream.py

Aider’s theming is entirely CLI-driven. There is no config file for themes. Users pass flags, and those flags set per-element colors.

Two preset modes exist (--dark-mode and --light-mode), both implemented in main.py:532-544:

# main.py:532-544
if args.dark_mode:
args.user_input_color = "#32FF32"
args.tool_error_color = "#FF3333"
args.tool_warning_color = "#FFFF00"
args.assistant_output_color = "#00FFFF"
args.code_theme = "monokai"
if args.light_mode:
args.user_input_color = "green"
args.tool_error_color = "red"
args.tool_warning_color = "#FFA500"
args.assistant_output_color = "blue"
args.code_theme = "default"

The presets are not applied at startup by default — the user must pass --dark-mode or --light-mode explicitly. There is no auto-detection of terminal background color.

Every individual color element is overridable. From args.py:316-394:

FlagDefaultPurpose
--user-input-color#00cc00User input text color
--tool-output-colorNoneTool/command output color
--tool-error-color#FF2222Error message color
--tool-warning-color#FFA500Warning message color
--assistant-output-color#0088ffLLM response color
--completion-menu-colorNoneCompletion menu text
--completion-menu-bg-colorNoneCompletion menu background
--completion-menu-current-colorNoneSelected item text
--completion-menu-current-bg-colorNoneSelected item background

Colors accept hex format with or without # prefix, or named Rich colors like green, red, blue. The ensure_hash_prefix() function in io.py:46-54 normalizes bare hex strings:

def ensure_hash_prefix(color):
if not color:
return color
if isinstance(color, str) and color.strip() and not color.startswith("#"):
if all(c in "0123456789ABCDEFabcdef" for c in color) and len(color) in (3, 6):
return f"#{color}"
return color

All colors are validated at startup via Rich’s RichStyle:

# io.py:374-398
def _validate_color_settings(self):
color_attributes = [
"user_input_color", "tool_output_color", "tool_error_color",
"tool_warning_color", "assistant_output_color",
"completion_menu_color", "completion_menu_bg_color",
"completion_menu_current_color", "completion_menu_current_bg_color",
]
for attr_name in color_attributes:
color_value = getattr(self, attr_name, None)
if color_value:
try:
RichStyle(color=color_value)
except ColorParseError:
setattr(self, attr_name, None) # silently reset invalid colors

The --code-theme flag controls syntax highlighting inside fenced code blocks. It maps directly to a Pygments theme name:

# args.py:387-393
group.add_argument(
"--code-theme",
default="default",
help=(
"Set the markdown code theme (default: default, other options include monokai,"
" solarized-dark, solarized-light, or a Pygments builtin style,"
" see https://pygments.org/styles for available themes)"
),
)

The theme name is passed to NoInsetCodeBlock.create() → Rich’s Syntax(code, lexer_name, theme=theme). Any valid Pygments theme name is accepted, giving access to the full Pygments theme library.

All color flags accept AIDER_* environment variable equivalents because Aider uses configargparse with auto_env_var_prefix="AIDER_". AIDER_CODE_THEME=monokai is equivalent to --code-theme monokai.


Source: codex-rs/tui/src/color.rs, codex-rs/tui/src/terminal_palette.rs, codex-rs/tui/src/style.rs, codex-rs/tui/src/markdown_render.rs

Codex has no external theme files and no user-facing color configuration. Instead, it detects the terminal’s capabilities and native colors at startup and adapts its colors algorithmically.

terminal_palette.rs queries the terminal’s color support via the supports-color crate and detects the native foreground/background colors via escape sequences:

// terminal_palette.rs:13-34
pub fn best_color(target: (u8, u8, u8)) -> Color {
let Some(color_level) = supports_color::on_cached(supports_color::Stream::Stdout) else {
return Color::default();
};
if color_level.has_16m {
// Truecolor: use exact RGB
Color::Rgb(target.0, target.1, target.2)
} else if color_level.has_256 {
// 256-color: find nearest xterm color by perceptual distance
Color::Indexed(find_nearest_xterm_color(target))
} else {
// Basic 16-color: fall back to terminal defaults
Color::default()
}
}

On Unix, terminal_palette.rs:67-138 uses crossterm’s query_foreground_color() and query_background_color() to query the terminal’s actual foreground and background via OSC 10/11 escape sequences. The result is cached with an attempt counter to avoid repeated failed queries on terminals that do not support this.

The file contains a complete mapping of all 256 xterm color indices to RGB tuples (XTERM_COLORS: [(u8, u8, u8); 256], lines 158–418). The first 16 entries are the system ANSI colors (terminal-dependent); entries 16–255 are the standardized 6×6×6 color cube plus a 24-step grayscale ramp. This table is used for CIE76 distance comparison when choosing the nearest 256-color index.

color.rs provides three fundamental operations:

Brightness detection:

// color.rs:1-5
pub(crate) fn is_light(bg: (u8, u8, u8)) -> bool {
let (r, g, b) = bg;
// Rec. 601 luminance
let y = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
y > 128.0
}

Alpha compositing:

// color.rs:7-12
pub(crate) fn blend(fg: (u8, u8, u8), bg: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) {
let r = (fg.0 as f32 * alpha + bg.0 as f32 * (1.0 - alpha)) as u8;
let g = (fg.1 as f32 * alpha + bg.1 as f32 * (1.0 - alpha)) as u8;
let b = (fg.2 as f32 * alpha + bg.2 as f32 * (1.0 - alpha)) as u8;
(r, g, b)
}

Perceptual distance (CIE76):

// color.rs:14-75
pub(crate) fn perceptual_distance(a: (u8, u8, u8), b: (u8, u8, u8)) -> f32 {
// Converts both colors to CIELAB, returns Euclidean distance in L*a*b* space
// Used by find_nearest_xterm_color() to select the visually closest index
}

style.rs:8-44 uses the detected terminal background to derive contextual colors. Rather than choosing between a dark palette and a light palette, it blends with the actual terminal background:

pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color {
let (top, alpha) = if is_light(terminal_bg) {
((0, 0, 0), 0.04) // Subtle dark overlay on light background
} else {
((255, 255, 255), 0.12) // Subtle light overlay on dark background
};
best_color(blend(top, terminal_bg, alpha))
}
pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
match terminal_bg {
Some(bg) => Style::default().bg(user_message_bg(bg)),
None => Style::default(),
}
}

This approach is elegant: instead of hardcoding a background color for user message bubbles, Codex derives a color that is slightly different from the terminal’s own background, regardless of what that background is.

Markdown rendering styles (markdown_render.rs:17-55) are all hardcoded ratatui Stylize values:

struct MarkdownStyles {
h1: Style::new().bold().underlined(),
h2: Style::new().bold(),
h3: Style::new().bold().italic(),
h4: Style::new().italic(),
code: Style::new().cyan(),
emphasis: Style::new().italic(),
strong: Style::new().bold(),
strikethrough: Style::new().crossed_out(),
ordered_list_marker: Style::new().light_blue(),
link: Style::new().cyan().underlined(),
blockquote: Style::new().green(),
}

These are ratatui symbolic colors (Color::Cyan, Color::Green, Color::LightBlue), which map to the terminal’s ANSI color palette. On a dark terminal, Color::Green is typically a bright lime green; on a light terminal it may be unreadable. Codex does not adapt these colors to the detected background — they are always the ratatui defaults.

Codex has no --theme, --dark-mode, or --light-mode flags. Color behavior is entirely automatic based on terminal detection. Users cannot override individual colors.


Source: opencode/packages/opencode/src/cli/cmd/tui/context/theme.tsx (1152 lines), opentui/packages/core/src/lib/terminal-palette.ts, opentui/packages/core/src/zig/ansi.zig, opentui/packages/core/src/syntax-style.ts

OpenCode has the most sophisticated theming of the three tools: a JSON-based theme format, 27+ built-in themes, dark/light variants per color, OSC-4-based system palette detection, and user-defined theme discovery.

Theme JSON files live in packages/opencode/src/cli/cmd/tui/context/theme/. The following are imported at build time:

// theme.tsx (excerpt)
import aura from "./theme/aura.json"
import ayu from "./theme/ayu.json"
import catppuccin from "./theme/catppuccin.json"
import dracula from "./theme/dracula.json"
import github from "./theme/github.json"
import gruvbox from "./theme/gruvbox.json"
import monokai from "./theme/monokai.json"
import nord from "./theme/nord.json"
import tokyonight from "./theme/tokyonight.json"
// ... 20+ more

All themes are available immediately without filesystem reads.

The semantic color palette (theme.tsx:45-98) has 50+ named slots, categorized by use:

type ThemeColors = {
// Core UI
primary: RGBA; secondary: RGBA; accent: RGBA
error: RGBA; warning: RGBA; success: RGBA; info: RGBA
text: RGBA; textMuted: RGBA; selectedListItemText: RGBA
background: RGBA; backgroundPanel: RGBA; backgroundElement: RGBA; backgroundMenu: RGBA
border: RGBA; borderActive: RGBA; borderSubtle: RGBA
// Diff view colors
diffAdded: RGBA; diffRemoved: RGBA; diffContext: RGBA
diffHunkHeader: RGBA; diffHighlightAdded: RGBA; diffHighlightRemoved: RGBA
diffAddedBg: RGBA; diffRemovedBg: RGBA; diffContextBg: RGBA
diffLineNumber: RGBA; diffAddedLineNumberBg: RGBA; diffRemovedLineNumberBg: RGBA
// Markdown rendering (14 slots)
markdownText: RGBA; markdownHeading: RGBA; markdownLink: RGBA
markdownLinkText: RGBA; markdownCode: RGBA; markdownBlockQuote: RGBA
markdownEmph: RGBA; markdownStrong: RGBA; markdownHorizontalRule: RGBA
markdownListItem: RGBA; markdownListEnumeration: RGBA
markdownImage: RGBA; markdownImageText: RGBA; markdownCodeBlock: RGBA
// Syntax highlighting (9 slots)
syntaxComment: RGBA; syntaxKeyword: RGBA; syntaxFunction: RGBA
syntaxVariable: RGBA; syntaxString: RGBA; syntaxNumber: RGBA
syntaxType: RGBA; syntaxOperator: RGBA; syntaxPunctuation: RGBA
}

Each theme file defines a defs object (named color aliases) and a theme object (the semantic mappings):

{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"darkStep1": "#0a0a0a",
"darkStep2": "#141414",
"darkStep3": "#1e1e1e",
"darkAccent": "#7aa2f7",
"darkRed": "#e06c75",
"darkGreen": "#9ece6a",
"lightStep1": "#fafafa",
"lightAccent": "#0078d4"
},
"theme": {
"primary": {
"dark": "darkStep9",
"light": "lightStep9"
},
"error": {
"dark": "darkRed",
"light": "#d32f2f"
},
"markdownHeading": {
"dark": "darkAccent",
"light": "lightAccent"
},
"syntaxKeyword": {
"dark": "#9d7cd8",
"light": "#0000ff"
}
}
}

Every color value is either a hex string, a reference to a defs name, a reference to another theme key, or an ANSI color number. The dark/light object form selects the variant based on the active mode.

resolveTheme(theme, mode) (theme.tsx:176-232) recursively resolves all references:

function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA {
if (c instanceof RGBA) return c
if (typeof c === "string") {
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
if (c.startsWith("#")) return RGBA.fromHex(c)
if (defs[c] != null) return resolveColor(defs[c]) // Named def reference
if (theme.theme[c as keyof ThemeColors] !== undefined)
return resolveColor(theme.theme[c as keyof ThemeColors]!) // Cross-key reference
throw new Error(`Color reference "${c}" not found`)
}
if (typeof c === "number") return ansiToRgba(c) // ANSI index support
return resolveColor(c[mode]) // Dark/light variant
}
return Object.fromEntries(
Object.entries(theme.theme).map(([key, value]) => [key, resolveColor(value)])
) as Theme
}

If the user selects the "system" theme, OpenCode queries the terminal’s actual color palette using the OSC 4 escape protocol, via OpenTUI’s renderer.getPalette():

// theme.tsx:318-346
function resolveSystemTheme() {
renderer
.getPalette({ size: 16 })
.then((colors) => {
if (!colors.palette[0]) {
// Terminal does not support OSC 4; fall back to "opencode" theme
setStore("active", "opencode")
return
}
setStore(produce((draft) => {
draft.themes.system = generateSystem(colors, store.mode)
if (store.active === "system") draft.ready = true
}))
})
}

generateSystem(colors, mode) derives a full semantic palette from the 16-color ANSI palette using color theory heuristics (lightest color = background, darkest = foreground, accent = most saturated, etc.).

OpenTUI’s terminal-palette.ts sends OSC 4 queries and parses the xterm-format responses:

terminal-palette.ts
async detect(options?: GetPaletteOptions): Promise<TerminalColors>

For tmux users, OSC sequences are double-escaped:

function wrapForTmux(osc: string): string {
const escaped = osc.replace(/\x1b/g, "\x1b\x1b")
return `\x1bPtmux;${escaped}\x1b\\`
}

OpenCode searches for user-defined theme files in a glob scan across multiple directories:

// theme.tsx:394-418
const CUSTOM_THEME_GLOB = new Bun.Glob("themes/*.json")
async function getCustomThemes() {
const directories = [
Global.Path.config, // ~/.config/opencode/
...(await Array.fromAsync( // .opencode/ in every parent dir
Filesystem.up({ targets: [".opencode"], start: process.cwd() })
)),
]
const result: Record<string, ThemeJson> = {}
for (const dir of directories) {
for await (const item of CUSTOM_THEME_GLOB.scan({
absolute: true, followSymlinks: true, dot: true, cwd: dir
})) {
const name = path.basename(item, ".json")
result[name] = await Bun.file(item).json()
}
}
return result
}

User themes are merged with built-in themes. If a user places ~/.config/opencode/themes/mytheme.json, it appears as "mytheme" in the theme selector.

The theme is translated into a SyntaxStyle object via getSyntaxRules() (theme.tsx:622-1152), which maps 100+ TextMate-compatible scope names to theme colors:

function getSyntaxRules(theme: Theme) {
return [
{ scope: ["comment", "punctuation.definition.comment"],
style: { foreground: theme.syntaxComment } },
{ scope: ["keyword", "storage.type", "storage.modifier"],
style: { foreground: theme.syntaxKeyword } },
{ scope: ["entity.name.function", "meta.function-call"],
style: { foreground: theme.syntaxFunction } },
{ scope: ["string", "string.quoted"],
style: { foreground: theme.syntaxString } },
// markup scopes for markdown
{ scope: ["markup.heading"],
style: { foreground: theme.markdownHeading, bold: true } },
{ scope: ["markup.bold", "markup.strong"],
style: { foreground: theme.markdownStrong, bold: true } },
// ... 90+ more entries
]
}

The SyntaxStyle.fromTheme(rules) factory (syntax-style.ts:87-96) registers each scope with the Zig native library, compiling scope names to numeric IDs. During rendering, a scope string resolves to an ID via O(1) hash lookup. Dot-separated fallback ("keyword.control.rust""keyword") is handled by checking for . in the scope name and retrying with the base name.

At the lowest level, OpenTUI’s Zig code (ansi.zig) emits 24-bit ANSI sequences:

// ansi.zig:27-33
pub fn fgColorOutput(writer: anytype, r: u8, g: u8, b: u8) AnsiError!void {
writer.print("\x1b[38;2;{d};{d};{d}m", .{ r, g, b }) catch return AnsiError.WriteFailed;
}
pub fn bgColorOutput(writer: anytype, r: u8, g: u8, b: u8) AnsiError!void {
writer.print("\x1b[48;2;{d};{d};{d}m", .{ r, g, b }) catch return AnsiError.WriteFailed;
}

Text attributes are packed into a u8 bitmask:

// ansi.zig:186-218
pub const BOLD = 1 << 0;
pub const DIM = 1 << 1;
pub const ITALIC = 1 << 2;
pub const UNDERLINE = 1 << 3;
pub const BLINK = 1 << 4;
pub const INVERSE = 1 << 5;
pub const HIDDEN = 1 << 6;
pub const STRIKETHROUGH = 1 << 7;
// bits 8-31 are reserved for LINK_ID (24-bit hyperlink reference)

RGBA colors flow from TypeScript (as RGBA objects) through the FFI boundary into Zig as 4-float arrays [4]f32.


The OSC 10/11 protocol for querying terminal foreground/background colors is not universally supported. Common failure cases: ssh without $COLORTERM forwarding, multiplexers (tmux/screen) that do not proxy the query, and terminals that acknowledge the query but return incorrect values. Any system that relies on this must have a fallback (Codex caches the failure and disables the feature; OpenCode falls back to the default theme).

$COLORTERM=truecolor is the standard env var, but many terminal emulators do not set it even when they support truecolor. The supports-color crate used by Codex checks multiple signals: $COLORTERM, $TERM, $TERM_PROGRAM, and Kitty’s $KITTY_WINDOW_ID. Even so, some environments (certain CI runners, legacy SSH sessions) will falsely report no color support.

ANSI Named Colors Break in Unexpected Themes

Section titled “ANSI Named Colors Break in Unexpected Themes”

Codex uses Color::Cyan and Color::Green for inline code and blockquotes respectively. On a terminal with a Solarized Light palette, the ANSI cyan might render as a barely-visible near-white. Using ANSI symbolic colors that look good on one terminal can look terrible on another. The correct approach is always to use the terminal’s detected background and derive the actual RGB value.

OpenCode’s $schema field in theme JSON points to https://opencode.ai/theme.json. If the ThemeColors type gains new fields, existing themes will use the default colors for those fields. The resolveTheme() function must handle missing keys gracefully or new theme additions will break all user-defined themes.

When OpenCode adds a new semantic color slot (e.g., markdownCodeBlock was presumably added later), built-in themes can be updated, but user themes from the filesystem cannot. A "version" field in the theme JSON and a migration layer would prevent silent regressions.

Codex’s terminal_palette.rs:59-65 exports a palette_version() -> u64 counter backed by an atomic. When the terminal palette changes (detected via OS signals on macOS), the version increments and widgets that cached derived colors can detect the invalidation. This is an underused pattern — most implementations query the palette once at startup and never update it.


┌─────────────────────────────────────────────┐
│ Layer 3: Semantic ThemeColors struct │
│ (primary, error, markdownHeading, ...) │
└──────────────────────┬──────────────────────┘
│ resolveTheme(json, mode)
┌──────────────────────▼──────────────────────┐
│ Layer 2: Theme JSON with dark/light │
│ variants, defs, ANSI color support │
└──────────────────────┬──────────────────────┘
│ terminal capability detection
┌──────────────────────▼──────────────────────┐
│ Layer 1: Terminal Color API │
│ supports-color, OSC 10/11 query, fallback │
└─────────────────────────────────────────────┘
openoxide-tui/src/theme/mod.rs
pub struct ThemeColors {
// Core UI (12 fields)
pub primary: Color,
pub error: Color,
pub warning: Color,
pub success: Color,
pub text: Color,
pub text_muted: Color,
pub background: Color,
pub background_panel: Color,
pub border: Color,
pub border_active: Color,
// Markdown (8 fields)
pub markdown_heading: Color,
pub markdown_code: Color,
pub markdown_link: Color,
pub markdown_strong: Color,
pub markdown_emph: Color,
pub markdown_blockquote: Color,
// Syntax (9 fields)
pub syntax_comment: Color,
pub syntax_keyword: Color,
pub syntax_function: Color,
pub syntax_string: Color,
pub syntax_number: Color,
pub syntax_type: Color,
pub syntax_variable: Color,
pub syntax_operator: Color,
pub syntax_punctuation: Color,
}
pub struct MarkdownTheme {
pub heading: [Style; 6], // H1-H6
pub code_block: Style,
pub code_inline: Style,
pub emphasis: Style,
pub strong: Style,
pub strikethrough: Style,
pub link: Style,
pub blockquote: Style,
pub list_marker_ordered: Style,
pub list_marker_unordered: Style,
}

Use the same JSON schema as OpenCode (dark/light variant objects, defs for named aliases). This lets OpenCode theme files be used directly with OpenOxide with minimal modification.

openoxide-tui/src/theme/loader.rs
pub struct ThemeJson {
pub defs: HashMap<String, ColorValue>,
pub theme: HashMap<String, ColorValue>,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum ColorValue {
Hex(String), // "#7aa2f7"
Named(String), // "darkAccent" or other def/key ref
AnsiIndex(u8), // ANSI color number
Variant { dark: Box<ColorValue>, light: Box<ColorValue> },
}
pub fn resolve_theme(json: &ThemeJson, mode: ColorMode) -> Result<ThemeColors>;
openoxide-tui/src/theme/terminal.rs
pub enum ColorMode { Dark, Light }
pub fn detect_color_mode() -> ColorMode {
// 1. Check $OPENOXIDE_COLOR_MODE env var
// 2. Query OSC 11 for background color
// 3. Use Rec.601 luminance threshold
// 4. Default to Dark
}
pub fn best_color(rgb: (u8, u8, u8)) -> ratatui::style::Color {
// supports-color → truecolor → 256-color (CIE76) → default
}
openoxide-tui/src/theme/discovery.rs
pub fn discover_themes() -> HashMap<String, PathBuf> {
// 1. ~/.config/openoxide/themes/*.toml
// 2. .openoxide/themes/*.toml walking up from CWD
}

Use TOML instead of JSON for user-editable files (comments, no trailing comma restrictions).

CratePurpose
serde + serde_jsonTheme JSON deserialization
ratatuiColor, Style, Stylize traits
supports-colorTerminal color level detection
crosstermOSC color query (terminal background detection)