diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 9a10b786..974505e1 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -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. diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index a3616450..262a67ea 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -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), diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index c2513c8a..9d566093 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -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 { + 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 diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 18fec2ed..98174090 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -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, /// 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", diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 880eba2c..027da61d 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -862,6 +862,11 @@ pub struct App { /// Animation anchor for status-strip active sub-agent spinner. pub agent_activity_started_at: Option, 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, diff --git a/crates/tui/src/tui/color_compat.rs b/crates/tui/src/tui/color_compat.rs index 5ea13874..95df7fc6 100644 --- a/crates/tui/src/tui/color_compat.rs +++ b/crates/tui/src/tui/color_compat.rs @@ -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 { inner: CrosstermBackend, 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 ColorCompatBackend { inner: CrosstermBackend::new(writer), depth, palette_mode, + theme_id: ThemeId::System, forced_size: None, } } @@ -49,6 +55,10 @@ impl ColorCompatBackend { 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 Write for ColorCompatBackend { @@ -71,7 +81,7 @@ impl Backend for ColorCompatBackend { 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::>(); @@ -123,10 +133,24 @@ impl Backend for ColorCompatBackend { } } -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); diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 0bebd9dd..1e5d7699 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -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; diff --git a/crates/tui/src/tui/theme_picker.rs b/crates/tui/src/tui/theme_picker.rs new file mode 100644 index 00000000..be7f788b --- /dev/null +++ b/crates/tui/src/tui/theme_picker.rs @@ -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 = 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 = 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()) + ); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2f01ed9e..dac36151 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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 diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 845572ac..e5a94612 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -35,6 +35,7 @@ pub enum ModalKind { FilePicker, StatusPicker, FeedbackPicker, + ThemePicker, ContextMenu, ShellControl, } diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 7cc507f6..be1d0ccf 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -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 }