Add Catppuccin/Tokyo Night/Dracula/Gruvbox themes + /theme picker

Wires the previously-dormant `theme` setting (#657 follow-up) into the
live Settings struct so the choice survives restart. `/theme` opens an
interactive picker with live preview; `/theme <name>` switches and
persists non-interactively.

Most render sites use bare `palette::TEXT_BODY` / `DEEPSEEK_INK` /
`BORDER_COLOR` constants rather than reading `app.ui_theme`, so simply
adding new UiTheme variants only repaints ~20% of surfaces. The fix is
a third stage in ColorCompatBackend (alongside the existing dark<->light
and truecolor<->256 stages) that rewrites every well-known dark-palette
constant to the corresponding UiTheme slot for the active preset. The
remap is a no-op for System / Whale / WhaleLight, so legacy dark/light
flows stay byte-identical.

Settings: theme = system | dark | light | catppuccin-mocha |
tokyo-night | dracula | gruvbox-dark. Unknown values normalise to
system. background_color still overlays on top.

Tests: new coverage in theme_picker and palette; pinned make_app() in
footer tests to ThemeId::System after App::new (matching the existing
default_model pin) since App::new now honors settings.theme.
This commit is contained in:
laoye_tchs
2026-05-12 22:24:52 +08:00
parent 33822424d8
commit 7bf63ea654
11 changed files with 840 additions and 26 deletions
+24 -17
View File
@@ -419,7 +419,21 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.needs_redraw = true;
}
"background_color" | "background" | "bg" => {
let base_theme = crate::palette::UiTheme::detect();
let base_theme = crate::palette::ThemeId::from_name(&settings.theme)
.unwrap_or(crate::palette::ThemeId::System)
.ui_theme();
app.ui_theme = settings
.background_color
.as_deref()
.and_then(crate::palette::parse_hex_rgb_color)
.map_or(base_theme, |color| base_theme.with_background_color(color));
app.needs_redraw = true;
}
"theme" => {
let theme_id = crate::palette::ThemeId::from_name(&settings.theme)
.unwrap_or(crate::palette::ThemeId::System);
let base_theme = theme_id.ui_theme();
app.theme_id = theme_id;
app.ui_theme = settings
.background_color
.as_deref()
@@ -580,22 +594,15 @@ fn mode_display_name(mode: AppMode) -> &'static str {
}
}
/// Toggle between dark and light theme.
pub fn theme(app: &mut App) -> CommandResult {
let new_theme = match app.ui_theme.mode {
crate::palette::PaletteMode::Dark => {
crate::palette::UiTheme::for_mode(crate::palette::PaletteMode::Light)
}
crate::palette::PaletteMode::Light => {
crate::palette::UiTheme::for_mode(crate::palette::PaletteMode::Dark)
}
};
app.ui_theme = new_theme;
let label = match new_theme.mode {
crate::palette::PaletteMode::Dark => "dark",
crate::palette::PaletteMode::Light => "light",
};
CommandResult::message(format!("Theme switched to {label}."))
/// `/theme [name]` — with no argument, open the interactive picker (arrow
/// keys, live preview, Enter to persist, Esc to revert). With an argument,
/// route through `set_config_value("theme", ...)` so the apply + save flow is
/// shared with `/config`.
pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult {
match arg.map(str::trim).filter(|s| !s.is_empty()) {
None => CommandResult::action(AppAction::OpenThemePicker),
Some(name) => set_config_value(app, "theme", name, true),
}
}
/// Manage workspace-level trust and the per-path allowlist.
+2 -2
View File
@@ -341,7 +341,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "theme",
aliases: &[],
usage: "/theme",
usage: "/theme [name]",
description_id: MessageId::CmdThemeDescription,
},
CommandInfo {
@@ -562,7 +562,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"status" => status::status(app),
"statusline" => config::status_line(app),
"mode" => config::mode(app, arg),
"theme" => config::theme(app),
"theme" => config::theme(app, arg),
"verbose" => config::verbose(app, arg),
"trust" => config::trust(app, arg),
"logout" => config::logout(app),
+360
View File
@@ -278,6 +278,213 @@ pub const LIGHT_UI_THEME: UiTheme = UiTheme {
border: LIGHT_BORDER,
};
// ---------------------------------------------------------------------------
// Community theme presets. All four are dark; selecting any of them implies
// `PaletteMode::Dark` so the existing dark/light adapt_*_for_palette_mode
// helpers continue to apply without surprise. Hex values come from each
// project's published spec.
// ---------------------------------------------------------------------------
pub const CATPPUCCIN_MOCHA_UI_THEME: UiTheme = UiTheme {
name: "catppuccin-mocha",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x1e, 0x1e, 0x2e), // base
panel_bg: Color::Rgb(0x18, 0x18, 0x25), // mantle
elevated_bg: Color::Rgb(0x31, 0x32, 0x44), // surface0
composer_bg: Color::Rgb(0x18, 0x18, 0x25),
selection_bg: Color::Rgb(0x45, 0x47, 0x5a), // surface1
header_bg: Color::Rgb(0x11, 0x11, 0x1b), // crust
footer_bg: Color::Rgb(0x11, 0x11, 0x1b),
mode_agent: Color::Rgb(0x89, 0xb4, 0xfa), // blue
mode_yolo: Color::Rgb(0xf3, 0x8b, 0xa8), // red
mode_plan: Color::Rgb(0xfa, 0xb3, 0x87), // peach
status_ready: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
status_working: Color::Rgb(0x74, 0xc7, 0xec), // sapphire
status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow
text_dim: Color::Rgb(0x6c, 0x70, 0x86), // overlay0
text_hint: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
text_muted: Color::Rgb(0xa6, 0xad, 0xc8), // subtext0
text_body: Color::Rgb(0xcd, 0xd6, 0xf4), // text
text_soft: Color::Rgb(0xba, 0xc2, 0xde), // subtext1
border: Color::Rgb(0x45, 0x47, 0x5a), // surface1
};
pub const TOKYO_NIGHT_UI_THEME: UiTheme = UiTheme {
name: "tokyo-night",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x1a, 0x1b, 0x26), // bg
panel_bg: Color::Rgb(0x16, 0x16, 0x1e), // bg_dark
elevated_bg: Color::Rgb(0x29, 0x2e, 0x42), // bg_highlight
composer_bg: Color::Rgb(0x16, 0x16, 0x1e),
selection_bg: Color::Rgb(0x28, 0x34, 0x57), // visual selection
header_bg: Color::Rgb(0x16, 0x16, 0x1e),
footer_bg: Color::Rgb(0x16, 0x16, 0x1e),
mode_agent: Color::Rgb(0x7a, 0xa2, 0xf7), // blue
mode_yolo: Color::Rgb(0xf7, 0x76, 0x8e), // red
mode_plan: Color::Rgb(0xff, 0x9e, 0x64), // orange
status_ready: Color::Rgb(0x56, 0x5f, 0x89), // comment
status_working: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
status_warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow
text_dim: Color::Rgb(0x56, 0x5f, 0x89), // comment
text_hint: Color::Rgb(0x73, 0x7a, 0xa2), // dark5
text_muted: Color::Rgb(0xa9, 0xb1, 0xd6), // fg_dark
text_body: Color::Rgb(0xc0, 0xca, 0xf5), // fg
text_soft: Color::Rgb(0xbb, 0xc2, 0xe0),
border: Color::Rgb(0x41, 0x48, 0x68), // terminal_black
};
pub const DRACULA_UI_THEME: UiTheme = UiTheme {
name: "dracula",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x28, 0x2a, 0x36), // background
panel_bg: Color::Rgb(0x21, 0x22, 0x2c),
elevated_bg: Color::Rgb(0x34, 0x37, 0x46),
composer_bg: Color::Rgb(0x21, 0x22, 0x2c),
selection_bg: Color::Rgb(0x44, 0x47, 0x5a), // current line
header_bg: Color::Rgb(0x21, 0x22, 0x2c),
footer_bg: Color::Rgb(0x21, 0x22, 0x2c),
mode_agent: Color::Rgb(0xbd, 0x93, 0xf9), // purple
mode_yolo: Color::Rgb(0xff, 0x55, 0x55), // red
mode_plan: Color::Rgb(0xff, 0xb8, 0x6c), // orange
status_ready: Color::Rgb(0x62, 0x72, 0xa4), // comment
status_working: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow
text_dim: Color::Rgb(0x62, 0x72, 0xa4),
text_hint: Color::Rgb(0x8a, 0x8e, 0xaa),
text_muted: Color::Rgb(0xc0, 0xc4, 0xd6),
text_body: Color::Rgb(0xf8, 0xf8, 0xf2), // foreground
text_soft: Color::Rgb(0xe2, 0xe2, 0xdc),
border: Color::Rgb(0x44, 0x47, 0x5a),
};
pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme {
name: "gruvbox-dark",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x28, 0x28, 0x28), // bg0
panel_bg: Color::Rgb(0x3c, 0x38, 0x36), // bg1
elevated_bg: Color::Rgb(0x50, 0x49, 0x45), // bg2
composer_bg: Color::Rgb(0x3c, 0x38, 0x36),
selection_bg: Color::Rgb(0x66, 0x5c, 0x54), // bg3
header_bg: Color::Rgb(0x1d, 0x20, 0x21), // bg0_h
footer_bg: Color::Rgb(0x1d, 0x20, 0x21),
mode_agent: Color::Rgb(0x83, 0xa5, 0x98), // blue
mode_yolo: Color::Rgb(0xfb, 0x49, 0x34), // red
mode_plan: Color::Rgb(0xfe, 0x80, 0x19), // orange
status_ready: Color::Rgb(0x92, 0x83, 0x74), // gray
status_working: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua
status_warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow
text_dim: Color::Rgb(0x92, 0x83, 0x74), // gray
text_hint: Color::Rgb(0xa8, 0x99, 0x84), // fg4
text_muted: Color::Rgb(0xbd, 0xae, 0x93), // fg3
text_body: Color::Rgb(0xeb, 0xdb, 0xb2), // fg1
text_soft: Color::Rgb(0xd5, 0xc4, 0xa1), // fg2
border: Color::Rgb(0x66, 0x5c, 0x54), // bg3
};
/// Stable identifiers for the named themes the user can select. `System`
/// defers to `PaletteMode::detect()` (terminal-driven dark/light). Each
/// dark/light id resolves to a single fixed `UiTheme`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeId {
System,
Whale,
WhaleLight,
CatppuccinMocha,
TokyoNight,
Dracula,
GruvboxDark,
}
impl ThemeId {
/// Parse a settings string (`"system"`, `"dark"`, `"catppuccin-mocha"`, …).
/// Accepts a few aliases (`"whale"` for dark, `"light"` for whale-light)
/// so existing config files keep working. Case-insensitive.
#[must_use]
pub fn from_name(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"system" | "auto" => Some(Self::System),
"dark" | "whale" => Some(Self::Whale),
"light" | "whale-light" => Some(Self::WhaleLight),
"catppuccin-mocha" | "catppuccin" | "mocha" => Some(Self::CatppuccinMocha),
"tokyo-night" | "tokyonight" => Some(Self::TokyoNight),
"dracula" => Some(Self::Dracula),
"gruvbox-dark" | "gruvbox" => Some(Self::GruvboxDark),
_ => None,
}
}
/// Canonical settings string (lowercase, dash-separated). Round-trips
/// through `from_name`.
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::System => "system",
Self::Whale => "dark",
Self::WhaleLight => "light",
Self::CatppuccinMocha => "catppuccin-mocha",
Self::TokyoNight => "tokyo-night",
Self::Dracula => "dracula",
Self::GruvboxDark => "gruvbox-dark",
}
}
/// Human-readable label for picker rows.
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::System => "System",
Self::Whale => "Whale (Dark)",
Self::WhaleLight => "Whale Light",
Self::CatppuccinMocha => "Catppuccin Mocha",
Self::TokyoNight => "Tokyo Night",
Self::Dracula => "Dracula",
Self::GruvboxDark => "Gruvbox Dark",
}
}
/// Short tagline for picker rows.
#[must_use]
pub const fn tagline(self) -> &'static str {
match self {
Self::System => "Follow terminal background (COLORFGBG)",
Self::Whale => "Default DeepSeek dark blue",
Self::WhaleLight => "DeepSeek light, paper-ish",
Self::CatppuccinMocha => "Soft pastels on warm dark",
Self::TokyoNight => "Deep blue/violet night palette",
Self::Dracula => "Classic high-contrast purple",
Self::GruvboxDark => "Vintage warm earth tones",
}
}
/// Resolve to a concrete `UiTheme`. For `System` this consults
/// `PaletteMode::detect()` exactly once and returns the corresponding
/// dark/light theme — callers that want to live-track terminal background
/// changes need to re-invoke this.
#[must_use]
pub fn ui_theme(self) -> UiTheme {
match self {
Self::System => UiTheme::detect(),
Self::Whale => UI_THEME,
Self::WhaleLight => LIGHT_UI_THEME,
Self::CatppuccinMocha => CATPPUCCIN_MOCHA_UI_THEME,
Self::TokyoNight => TOKYO_NIGHT_UI_THEME,
Self::Dracula => DRACULA_UI_THEME,
Self::GruvboxDark => GRUVBOX_DARK_UI_THEME,
}
}
}
/// Themes shown in the `/theme` picker, in display order.
pub const SELECTABLE_THEMES: &[ThemeId] = &[
ThemeId::System,
ThemeId::Whale,
ThemeId::WhaleLight,
ThemeId::CatppuccinMocha,
ThemeId::TokyoNight,
ThemeId::Dracula,
ThemeId::GruvboxDark,
];
impl UiTheme {
#[must_use]
pub fn for_mode(mode: PaletteMode) -> Self {
@@ -394,6 +601,159 @@ pub fn adapt_bg_for_palette_mode(color: Color, mode: PaletteMode) -> Color {
}
}
// === Community-theme remap ===
//
// The vast majority of render sites in this crate reach for `palette::TEXT_*`,
// `palette::DEEPSEEK_INK`, `palette::BORDER_COLOR`, etc. directly rather than
// looking up `app.ui_theme`. To make community theme presets (Catppuccin,
// Tokyo Night, …) actually move the needle visually we intercept colors at
// the backend layer (see `tui::color_compat::ColorCompatBackend`) and remap
// every well-known dark-palette constant to the equivalent UiTheme slot for
// the active preset. For `System`, `Whale`, and `WhaleLight` the remap is a
// no-op — the existing dark/light pipeline handles those.
/// Per-preset green accent used for things that semantically *should* stay
/// green even after theming (diff "+" lines, user-input body). Mapping these
/// to `ui.status_working` would lose the green/cyan distinction the UI
/// relies on, so we keep a small dedicated table.
#[must_use]
const fn theme_green(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0xa6, 0xe3, 0xa1),
ThemeId::TokyoNight => Color::Rgb(0x9e, 0xce, 0x6a),
ThemeId::Dracula => Color::Rgb(0x50, 0xfa, 0x7b),
ThemeId::GruvboxDark => Color::Rgb(0xb8, 0xbb, 0x26),
_ => USER_BODY,
}
}
/// Per-preset red accent, used for diff "" line foreground when present.
#[must_use]
const fn theme_red(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0xf3, 0x8b, 0xa8),
ThemeId::TokyoNight => Color::Rgb(0xf7, 0x76, 0x8e),
ThemeId::Dracula => Color::Rgb(0xff, 0x55, 0x55),
ThemeId::GruvboxDark => Color::Rgb(0xfb, 0x49, 0x34),
_ => DEEPSEEK_RED,
}
}
/// Per-preset dark-green diff-added background tint.
#[must_use]
const fn theme_diff_added_bg(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0x1f, 0x33, 0x29),
ThemeId::TokyoNight => Color::Rgb(0x1b, 0x2b, 0x1f),
ThemeId::Dracula => Color::Rgb(0x21, 0x3a, 0x2a),
ThemeId::GruvboxDark => Color::Rgb(0x29, 0x32, 0x16),
_ => DIFF_ADDED_BG,
}
}
/// Per-preset dark-red diff-deleted background tint.
#[must_use]
const fn theme_diff_deleted_bg(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0x3a, 0x1f, 0x2a),
ThemeId::TokyoNight => Color::Rgb(0x33, 0x1c, 0x24),
ThemeId::Dracula => Color::Rgb(0x3a, 0x1f, 0x22),
ThemeId::GruvboxDark => Color::Rgb(0x35, 0x1c, 0x18),
_ => DIFF_DELETED_BG,
}
}
/// Returns `true` if the preset participates in the cell-level remap. The
/// default Whale and System themes pass through unchanged so this whole
/// stage compiles down to a single load+compare on the hot path.
#[inline]
#[must_use]
pub const fn theme_remap_active(theme: ThemeId) -> bool {
matches!(
theme,
ThemeId::CatppuccinMocha | ThemeId::TokyoNight | ThemeId::Dracula | ThemeId::GruvboxDark
)
}
/// Remap a foreground color for a community theme preset. Mirrors the
/// structure of [`adapt_fg_for_palette_mode`] — same source set, different
/// destinations sourced from the preset's [`UiTheme`].
#[must_use]
pub fn adapt_fg_for_theme(color: Color, theme: ThemeId) -> Color {
if !theme_remap_active(theme) {
return color;
}
let ui = theme.ui_theme();
if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White {
ui.text_body
} else if color == TEXT_SECONDARY || color == TEXT_MUTED {
ui.text_muted
} else if color == TEXT_HINT || color == TEXT_DIM {
ui.text_hint
} else if color == TEXT_SOFT || color == TEXT_TOOL_OUTPUT {
ui.text_soft
} else if color == BORDER_COLOR {
ui.border
} else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE {
ui.status_working
} else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE {
ui.mode_plan
} else if color == ACCENT_TOOL_ISSUE {
ui.mode_yolo
} else if color == STATUS_WARNING {
ui.status_warning
} else if color == DEEPSEEK_RED {
theme_red(theme)
} else if color == DIFF_ADDED || color == USER_BODY {
theme_green(theme)
} else if color == DEEPSEEK_BLUE {
// The default mode_agent accent — keep it in the preset's blue family.
ui.mode_agent
} else {
color
}
}
/// Remap a background color for a community theme preset.
#[must_use]
pub fn adapt_bg_for_theme(color: Color, theme: ThemeId) -> Color {
if !theme_remap_active(theme) {
return color;
}
let ui = theme.ui_theme();
if color == DEEPSEEK_INK || color == BACKGROUND_DARK {
ui.surface_bg
} else if color == DEEPSEEK_SLATE
|| color == COMPOSER_BG
|| color == SURFACE_PANEL
|| color == SURFACE_TOOL
{
ui.panel_bg
} else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE {
ui.elevated_bg
} else if color == SURFACE_REASONING
|| color == SURFACE_REASONING_TINT
|| color == SURFACE_REASONING_ACTIVE
|| color == SURFACE_SUCCESS
|| color == SURFACE_ERROR
{
// Reasoning/success/error backgrounds are subtle tints that don't have
// a dedicated theme slot — collapse them onto `panel_bg` so they
// read as a recessed surface rather than a stray DeepSeek-blue tint.
ui.panel_bg
} else if color == SELECTION_BG {
ui.selection_bg
} else if color == DIFF_ADDED_BG {
theme_diff_added_bg(theme)
} else if color == DIFF_DELETED_BG {
theme_diff_deleted_bg(theme)
} else {
color
}
}
// === Color depth + brightness helpers (v0.6.6 UI redesign) ===
/// Terminal color depth, used to gate truecolor surfaces (e.g. reasoning bg
+35
View File
@@ -195,6 +195,14 @@ pub struct Settings {
pub show_tool_details: bool,
/// UI locale: auto, en, ja, zh-Hans, pt-BR
pub locale: String,
/// Named UI theme. Accepts `"system"` (follow terminal background),
/// the legacy `"dark"` / `"light"` aliases, or one of the community
/// presets: `"catppuccin-mocha"`, `"tokyo-night"`, `"dracula"`,
/// `"gruvbox-dark"`. Resolved at startup via
/// `palette::ThemeId::from_name`; unknown values fall back to `"system"`
/// with a warning. The `background_color` setting still overrides the
/// surface color *on top of* the resolved theme.
pub theme: String,
/// Optional main TUI background color as a 6-digit hex RGB value.
pub background_color: Option<String>,
/// Composer layout density: compact, comfortable, spacious
@@ -287,6 +295,7 @@ impl Default for Settings {
show_thinking: true,
show_tool_details: true,
locale: "auto".to_string(),
theme: "system".to_string(),
background_color: None,
composer_density: "comfortable".to_string(),
composer_border: true,
@@ -351,6 +360,19 @@ impl Settings {
.unwrap_or("en")
.to_string();
s.background_color = normalize_optional_background_color(s.background_color.as_deref());
// Drop unknown theme names so a stale settings file with a typo
// doesn't pin the app to whatever the default happens to be.
// `from_name` is intentionally permissive about case + aliases.
if crate::palette::ThemeId::from_name(&s.theme).is_none() {
s.theme = "system".to_string();
} else {
s.theme = s.theme.trim().to_ascii_lowercase();
// Canonicalize aliases (e.g. "whale" -> "dark") so the saved
// form matches the picker's stored value.
if let Some(id) = crate::palette::ThemeId::from_name(&s.theme) {
s.theme = id.name().to_string();
}
}
s.default_model = s.default_model.as_deref().and_then(normalize_default_model);
s
};
@@ -477,6 +499,14 @@ impl Settings {
"background_color" | "background" | "bg" => {
self.background_color = normalize_background_color_setting(value)?;
}
"theme" => {
let Some(id) = crate::palette::ThemeId::from_name(value) else {
anyhow::bail!(
"Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark."
);
};
self.theme = id.name().to_string();
}
"composer_density" | "composer" => {
let normalized = normalize_composer_density(value);
if !["compact", "comfortable", "spacious"].contains(&normalized) {
@@ -631,6 +661,7 @@ impl Settings {
lines.push(format!(" show_thinking: {}", self.show_thinking));
lines.push(format!(" show_tool_details: {}", self.show_tool_details));
lines.push(format!(" locale: {}", self.locale));
lines.push(format!(" theme: {}", self.theme));
lines.push(format!(
" background_color: {}",
self.background_color.as_deref().unwrap_or("(default)")
@@ -700,6 +731,10 @@ impl Settings {
"locale",
"UI locale and default model language: auto, en, ja, zh-Hans, pt-BR",
),
(
"theme",
"Named UI theme: system, dark, light, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark",
),
(
"background_color",
"Main TUI background color: #RRGGBB or default",
+14 -1
View File
@@ -862,6 +862,11 @@ pub struct App {
/// Animation anchor for status-strip active sub-agent spinner.
pub agent_activity_started_at: Option<Instant>,
pub ui_theme: UiTheme,
/// Active named theme. Drives the cell-level color remap in
/// `tui::color_compat::ColorCompatBackend` so community presets
/// (Catppuccin, Tokyo Night, Dracula, Gruvbox) propagate to every
/// render site, not just the handful that read `app.ui_theme`.
pub theme_id: palette::ThemeId,
// Onboarding
pub onboarding: OnboardingState,
pub onboarding_needs_api_key: bool,
@@ -1262,7 +1267,12 @@ impl App {
let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus);
let max_input_history = settings.max_input_history;
let use_paste_burst_detection = settings.paste_burst_detection;
let mut ui_theme = palette::UiTheme::detect();
// Resolve the named theme from settings; unknown values were already
// normalised to "system" in Settings::load. The background_color
// setting still overlays on top.
let theme_id =
palette::ThemeId::from_name(&settings.theme).unwrap_or(palette::ThemeId::System);
let mut ui_theme = theme_id.ui_theme();
if let Some(background) = settings
.background_color
.as_deref()
@@ -1463,6 +1473,7 @@ impl App {
pending_subagent_dispatch: None,
agent_activity_started_at: None,
ui_theme,
theme_id,
onboarding,
onboarding_needs_api_key: needs_api_key,
onboarding_workspace_trust_gate,
@@ -3995,6 +4006,8 @@ pub enum AppAction {
OpenStatusPicker,
/// Open the `/feedback` picker for GitHub issue/security destinations.
OpenFeedbackPicker,
/// Open the `/theme` picker modal with live preview of every preset.
OpenThemePicker,
/// Open an external URL in the system browser.
OpenExternalUrl {
url: String,
+45 -6
View File
@@ -14,13 +14,18 @@ use ratatui::{
layout::{Position, Size},
};
use crate::palette::{self, ColorDepth, PaletteMode};
use crate::palette::{self, ColorDepth, PaletteMode, ThemeId};
#[derive(Debug)]
pub(crate) struct ColorCompatBackend<W: Write> {
inner: CrosstermBackend<W>,
depth: ColorDepth,
palette_mode: PaletteMode,
/// Currently active named theme. `System`/`Whale`/`WhaleLight` make the
/// theme remap a no-op (those rely on the dark/light pipeline); the
/// community presets (Catppuccin, Tokyo Night, Dracula, Gruvbox) trigger
/// a per-cell rewrite of dark-palette constants → preset slots.
theme_id: ThemeId,
/// During a resize event the terminal emulator may report stale dimensions
/// for a brief window (observed on macOS Terminal.app and Windows ConHost).
/// Forcing the expected size prevents ratatui's internal `autoresize` from
@@ -34,6 +39,7 @@ impl<W: Write> ColorCompatBackend<W> {
inner: CrosstermBackend::new(writer),
depth,
palette_mode,
theme_id: ThemeId::System,
forced_size: None,
}
}
@@ -49,6 +55,10 @@ impl<W: Write> ColorCompatBackend<W> {
pub(crate) fn set_palette_mode(&mut self, palette_mode: PaletteMode) {
self.palette_mode = palette_mode;
}
pub(crate) fn set_theme(&mut self, theme_id: ThemeId) {
self.theme_id = theme_id;
}
}
impl<W: Write> Write for ColorCompatBackend<W> {
@@ -71,7 +81,7 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
let adapted = content
.map(|(x, y, cell)| {
let mut cell = cell.clone();
adapt_cell_colors(&mut cell, self.depth, self.palette_mode);
adapt_cell_colors(&mut cell, self.depth, self.palette_mode, self.theme_id);
(x, y, cell)
})
.collect::<Vec<_>>();
@@ -123,10 +133,24 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
}
}
fn adapt_cell_colors(cell: &mut Cell, depth: ColorDepth, palette_mode: PaletteMode) {
fn adapt_cell_colors(
cell: &mut Cell,
depth: ColorDepth,
palette_mode: PaletteMode,
theme_id: ThemeId,
) {
// Stage 1: community-theme remap (dark palette → preset slots). No-op
// for System / Whale / WhaleLight so legacy dark/light flows are
// untouched. Runs *before* the palette-mode remap so a light terminal
// running e.g. Catppuccin still routes the preset colors through the
// light adaptation below (rare combo, but the sequencing is the same).
cell.fg = palette::adapt_fg_for_theme(cell.fg, theme_id);
cell.bg = palette::adapt_bg_for_theme(cell.bg, theme_id);
// Stage 2: legacy dark↔light remap.
let original_bg = cell.bg;
cell.fg = palette::adapt_fg_for_palette_mode(cell.fg, original_bg, palette_mode);
cell.bg = palette::adapt_bg_for_palette_mode(cell.bg, palette_mode);
// Stage 3: depth (truecolor / 256 / 16) downsampling.
cell.fg = palette::adapt_color(cell.fg, depth);
cell.bg = palette::adapt_bg(cell.bg, depth);
}
@@ -160,7 +184,12 @@ mod tests {
cell.set_fg(Color::Rgb(53, 120, 229));
cell.set_bg(Color::Rgb(11, 21, 38));
adapt_cell_colors(&mut cell, ColorDepth::Ansi256, PaletteMode::Dark);
adapt_cell_colors(
&mut cell,
ColorDepth::Ansi256,
PaletteMode::Dark,
ThemeId::System,
);
assert!(matches!(cell.fg, Color::Indexed(_)));
assert!(matches!(cell.bg, Color::Indexed(_)));
@@ -172,7 +201,12 @@ mod tests {
cell.set_fg(Color::Rgb(53, 120, 229));
cell.set_bg(Color::Rgb(11, 21, 38));
adapt_cell_colors(&mut cell, ColorDepth::TrueColor, PaletteMode::Dark);
adapt_cell_colors(
&mut cell,
ColorDepth::TrueColor,
PaletteMode::Dark,
ThemeId::System,
);
assert_eq!(cell.fg, Color::Rgb(53, 120, 229));
assert_eq!(cell.bg, Color::Rgb(11, 21, 38));
@@ -201,7 +235,12 @@ mod tests {
cell.set_fg(Color::White);
cell.set_bg(Color::Rgb(11, 21, 38));
adapt_cell_colors(&mut cell, ColorDepth::TrueColor, PaletteMode::Light);
adapt_cell_colors(
&mut cell,
ColorDepth::TrueColor,
PaletteMode::Light,
ThemeId::WhaleLight,
);
assert_eq!(cell.fg, palette::LIGHT_TEXT_BODY);
assert_eq!(cell.bg, palette::LIGHT_SURFACE);
+1
View File
@@ -52,6 +52,7 @@ pub mod sidebar;
pub mod slash_menu;
pub mod streaming;
mod subagent_routing;
pub mod theme_picker;
mod tool_routing;
pub mod transcript;
pub mod transcript_cache;
+338
View File
@@ -0,0 +1,338 @@
//! `/theme` picker with live preview.
//!
//! Modeled after `feedback_picker`. Differences:
//! - The option list comes from `palette::SELECTABLE_THEMES`.
//! - Up/Down emit a `ConfigUpdated{persist:false}` so the host swaps
//! `app.ui_theme` immediately and the whole TUI re-paints under the
//! modal — the user sees the candidate theme before committing.
//! - Enter persists (`persist:true`); Esc emits one more
//! `ConfigUpdated{persist:false}` to restore the original theme name
//! that was active when the picker opened.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
};
use crate::palette::{SELECTABLE_THEMES, ThemeId};
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
pub struct ThemePickerView {
selected: usize,
/// Settings name of the theme that was active when the picker opened.
/// Used to revert on Esc.
original_name: String,
}
impl ThemePickerView {
#[must_use]
pub fn new(original_name: String) -> Self {
// If the persisted name matches one of the entries, start there;
// otherwise fall back to "System" so the cursor lands on a valid row.
let selected = SELECTABLE_THEMES
.iter()
.position(|id| id.name() == original_name.trim().to_ascii_lowercase())
.unwrap_or(0);
Self {
selected,
original_name,
}
}
fn current(&self) -> ThemeId {
SELECTABLE_THEMES
.get(self.selected)
.copied()
.unwrap_or(ThemeId::System)
}
fn preview_event(&self) -> ViewAction {
ViewAction::Emit(ViewEvent::ConfigUpdated {
key: "theme".to_string(),
value: self.current().name().to_string(),
persist: false,
})
}
fn commit_event(&self) -> ViewAction {
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
key: "theme".to_string(),
value: self.current().name().to_string(),
persist: true,
})
}
fn revert_event(&self) -> ViewAction {
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
key: "theme".to_string(),
value: self.original_name.clone(),
persist: false,
})
}
fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
fn move_down(&mut self) {
let max = SELECTABLE_THEMES.len().saturating_sub(1);
if self.selected < max {
self.selected += 1;
}
}
}
impl ModalView for ThemePickerView {
fn kind(&self) -> ModalKind {
ModalKind::ThemePicker
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc => self.revert_event(),
KeyCode::Enter => self.commit_event(),
KeyCode::Up | KeyCode::Char('k') => {
self.move_up();
self.preview_event()
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_down();
self.preview_event()
}
KeyCode::Home => {
self.selected = 0;
self.preview_event()
}
KeyCode::End => {
self.selected = SELECTABLE_THEMES.len().saturating_sub(1);
self.preview_event()
}
// Number shortcuts: 1..=9 → jump to that row (1-indexed).
KeyCode::Char(c)
if c.is_ascii_digit()
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
let idx = (c as usize).saturating_sub('1' as usize);
if idx < SELECTABLE_THEMES.len() {
self.selected = idx;
self.preview_event()
} else {
ViewAction::None
}
}
_ => ViewAction::None,
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let popup_width = 78.min(area.width.saturating_sub(4)).max(52);
// 1 title row + 1 spacer + N options + 2 spacer + 1 swatch row
let needed_height = (SELECTABLE_THEMES.len() as u16).saturating_add(9);
let popup_height = needed_height.min(area.height.saturating_sub(4)).max(10);
let popup_area = Rect {
x: area.x + (area.width.saturating_sub(popup_width)) / 2,
y: area.y + (area.height.saturating_sub(popup_height)) / 2,
width: popup_width,
height: popup_height,
};
// The live theme has already been swapped under us via ConfigUpdated,
// so we pull the *current* preview's UiTheme from the cursor row to
// skin the modal chrome. That way the popup itself shifts color as
// the cursor moves, matching what the background will look like
// after Enter.
let live = self.current().ui_theme();
Clear.render(popup_area, buf);
let block = Block::default()
.title(Line::from(Span::styled(
" Theme ",
Style::default()
.fg(live.status_working)
.add_modifier(Modifier::BOLD),
)))
.title_bottom(Line::from(vec![
Span::styled(" ↑/↓ ", Style::default().fg(live.text_muted)),
Span::raw("preview "),
Span::styled(" Enter ", Style::default().fg(live.text_muted)),
Span::raw("save "),
Span::styled(" Esc ", Style::default().fg(live.text_muted)),
Span::raw("revert "),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(live.border))
.style(Style::default().bg(live.surface_bg))
.padding(Padding::uniform(1));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut lines: Vec<Line> = Vec::with_capacity(SELECTABLE_THEMES.len() + 5);
lines.push(Line::from(Span::styled(
"Pick a theme — preview is live; Enter saves to settings.toml.",
Style::default().fg(live.text_muted),
)));
lines.push(Line::from(""));
for (idx, id) in SELECTABLE_THEMES.iter().enumerate() {
let id = *id;
let is_selected = idx == self.selected;
let row_style = if is_selected {
Style::default()
.fg(live.text_body)
.bg(live.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(live.text_body)
};
let tagline_style = if is_selected {
Style::default().fg(live.text_muted).bg(live.selection_bg)
} else {
Style::default().fg(live.text_dim)
};
let number_style = if is_selected {
Style::default()
.fg(live.status_working)
.bg(live.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(live.text_hint)
};
let pointer = if is_selected { "" } else { " " };
// 3-cell color swatch per row using the candidate theme's own
// accent + panel + border colors so the picker doubles as a
// legend.
let row_theme = id.ui_theme();
let swatch = vec![
Span::styled(" ", Style::default().bg(row_theme.surface_bg)),
Span::styled(" ", Style::default().bg(row_theme.panel_bg)),
Span::styled(" ", Style::default().bg(row_theme.status_working)),
Span::styled(" ", Style::default().bg(row_theme.mode_yolo)),
Span::styled(" ", Style::default().bg(row_theme.mode_plan)),
];
let mut spans: Vec<Span> = Vec::with_capacity(8);
spans.push(Span::styled(format!(" {pointer} "), row_style));
spans.push(Span::styled(format!("{}. ", idx + 1), number_style));
spans.push(Span::styled(
format!("{:<22}", id.display_name()),
row_style,
));
spans.extend(swatch);
spans.push(Span::raw(" "));
spans.push(Span::styled(id.tagline(), tagline_style));
lines.push(Line::from(spans));
}
Paragraph::new(lines).render(inner, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn selected_name(action: &ViewAction) -> Option<&str> {
match action {
ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, .. })
| ViewAction::EmitAndClose(ViewEvent::ConfigUpdated { key, value, .. })
if key == "theme" =>
{
Some(value.as_str())
}
_ => None,
}
}
#[test]
fn opens_at_persisted_theme() {
let v = ThemePickerView::new("tokyo-night".to_string());
assert_eq!(v.current(), ThemeId::TokyoNight);
}
#[test]
fn unknown_persisted_name_falls_back_to_first_row() {
let v = ThemePickerView::new("not-a-real-theme".to_string());
assert_eq!(v.selected, 0);
assert_eq!(v.current(), ThemeId::System);
}
#[test]
fn arrow_down_previews_next_theme() {
let mut v = ThemePickerView::new("system".to_string());
let action = v.handle_key(key(KeyCode::Down));
assert!(matches!(action, ViewAction::Emit(_)));
assert_eq!(selected_name(&action), Some(ThemeId::Whale.name()));
}
#[test]
fn enter_commits_with_persist_true() {
let mut v = ThemePickerView::new("system".to_string());
v.handle_key(key(KeyCode::Down));
v.handle_key(key(KeyCode::Down));
v.handle_key(key(KeyCode::Down)); // -> CatppuccinMocha
let action = v.handle_key(key(KeyCode::Enter));
match action {
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
key,
value,
persist,
}) => {
assert_eq!(key, "theme");
assert_eq!(value, ThemeId::CatppuccinMocha.name());
assert!(persist);
}
other => panic!("expected commit, got {other:?}"),
}
}
#[test]
fn esc_reverts_to_original() {
let mut v = ThemePickerView::new("dracula".to_string());
v.handle_key(key(KeyCode::Up));
v.handle_key(key(KeyCode::Up));
let action = v.handle_key(key(KeyCode::Esc));
match action {
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
key,
value,
persist,
}) => {
assert_eq!(key, "theme");
assert_eq!(value, "dracula");
assert!(!persist);
}
other => panic!("expected revert, got {other:?}"),
}
}
#[test]
fn digit_jumps_to_row() {
let mut v = ThemePickerView::new("system".to_string());
let action = v.handle_key(key(KeyCode::Char('4')));
// Row 4 (1-indexed) → index 3 → CatppuccinMocha
assert_eq!(
selected_name(&action),
Some(ThemeId::CatppuccinMocha.name())
);
}
}
+14
View File
@@ -5270,6 +5270,19 @@ async fn apply_command_result(
.push(crate::tui::feedback_picker::FeedbackPickerView::new());
}
}
AppAction::OpenThemePicker => {
if app.view_stack.top_kind() != Some(ModalKind::ThemePicker) {
// Capture the persisted theme name so Esc can revert
// through the same ConfigUpdated channel. Falling back
// to "system" when Settings::load fails matches the
// app-startup default.
let original = crate::settings::Settings::load()
.map(|s| s.theme)
.unwrap_or_else(|_| "system".to_string());
app.view_stack
.push(crate::tui::theme_picker::ThemePickerView::new(original));
}
}
AppAction::OpenExternalUrl { url, label } => match open_external_url(&url) {
Ok(()) => {
app.status_message = Some(format!("Opened {label} in your browser"));
@@ -6167,6 +6180,7 @@ fn draw_app_frame_inner(
full_repaint: bool,
) -> Result<()> {
terminal.backend_mut().set_palette_mode(app.ui_theme.mode);
terminal.backend_mut().set_theme(app.theme_id);
// DEC 2026 wrapping is on by default but can be turned off for
// terminals that mishandle it (Ptyxis 50.x + VTE 0.84.x flashes the
// whole viewport on every wrapped frame instead of deferring as the
+1
View File
@@ -35,6 +35,7 @@ pub enum ModalKind {
FilePicker,
StatusPicker,
FeedbackPicker,
ThemePicker,
ContextMenu,
ShellControl,
}
+6
View File
@@ -672,6 +672,12 @@ mod tests {
// file, which overrides the option above. Pin the model explicitly
// so these tests are independent of any host-side configuration.
app.model = "deepseek-v4-flash".to_string();
// Same for theme: tests below assert against the default `MODE_AGENT`
// / `TEXT_MUTED` constants, but a saved `theme = "tokyo-night"` in
// the user's settings.toml would override them through the
// theme-aware constructor. Pin to System (no remap) for isolation.
app.theme_id = crate::palette::ThemeId::System;
app.ui_theme = crate::palette::UiTheme::detect();
app
}