diff --git a/README.md b/README.md index 00737384..0245cb36 100644 --- a/README.md +++ b/README.md @@ -264,8 +264,9 @@ cleaner "Work" tab. [Full changelog](CHANGELOG.md). mouse filter drops inert move events but allows transcript and scrollbar drags to continue — the known issue from v0.8.32 is resolved. -- **Grayscale theme.** Use `/theme grayscale` for a quiet black/white - palette, or `/set theme grayscale --save` to make it the saved default. +- **Theme presets.** Use `/theme` for a live picker, or `/theme grayscale`, + `/theme catppuccin-mocha`, `/theme tokyo-night`, `/theme dracula`, and + `/theme gruvbox-dark` to save a theme directly. - **Session history picker.** `/sessions` and `Ctrl+R` now put full session history on the left, the session list on the right, number keys `1`-`9` open visible histories, and `PgUp` / `PgDn` scroll history. diff --git a/README.zh-CN.md b/README.zh-CN.md index d52262b5..83930f48 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -262,8 +262,9 @@ RLM 工作,`agent_open` / `agent_eval` / `agent_close` 用于命名子 - **流式输出期间文本选择正常工作。** 加载状态的鼠标过滤器丢弃 无关移动事件,但允许对话记录和滚动条拖动继续—— v0.8.32 的已知问题已解决。 -- **灰度主题。** 使用 `/theme grayscale` 可切换到更克制的黑白 - 调色板;使用 `/set theme grayscale --save` 可保存为默认主题。 +- **主题预设。** 使用 `/theme` 打开实时预览选择器,或通过 + `/theme grayscale`、`/theme catppuccin-mocha`、`/theme tokyo-night`、 + `/theme dracula`、`/theme gruvbox-dark` 直接保存主题。 - **会话历史选择器。** `/sessions` 和 `Ctrl+R` 现在左侧显示完整 会话历史,右侧显示会话列表;按 `1`-`9` 打开可见会话历史, `PgUp` / `PgDn` 翻页查看历史。 diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 82b4e5c4..8183d765 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -424,6 +424,8 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.needs_redraw = true; } "theme" | "ui_theme" | "background_color" | "background" | "bg" => { + app.theme_id = crate::palette::ThemeId::from_name(&settings.theme) + .unwrap_or(crate::palette::ThemeId::System); app.ui_theme = crate::palette::ui_theme_from_settings( &settings.theme, settings.background_color.as_deref(), @@ -584,33 +586,14 @@ fn mode_display_name(mode: AppMode) -> &'static str { } } -/// Switch the runtime theme. `/set theme --save` persists it. +/// `/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 { - let requested = match arg.map(str::trim).filter(|value| !value.is_empty()) { - Some(value) => { - let Some(theme) = crate::palette::normalize_theme_name(value) else { - return CommandResult::error("Usage: /theme [dark|light|grayscale|system]"); - }; - theme - } - None => match app.ui_theme.mode { - crate::palette::PaletteMode::Dark => "light", - crate::palette::PaletteMode::Light => "grayscale", - crate::palette::PaletteMode::Grayscale => "dark", - }, - }; - - let background = Settings::load() - .ok() - .and_then(|settings| settings.background_color); - app.ui_theme = crate::palette::ui_theme_from_settings(requested, background.as_deref()); - app.needs_redraw = true; - - let label = crate::palette::theme_label_for_mode(app.ui_theme.mode); - if requested == "system" { - CommandResult::message(format!("Theme switched to system ({label}).")) - } else { - CommandResult::message(format!("Theme switched to {label}.")) + match arg.map(str::trim).filter(|s| !s.is_empty()) { + None => CommandResult::action(AppAction::OpenThemePicker), + Some(name) => set_config_value(app, "theme", name, true), } } @@ -1602,7 +1585,8 @@ mod tests { let mut app = create_test_app(); let result = theme(&mut app, Some("grayscale")); - assert_eq!(result.message.unwrap(), "Theme switched to grayscale."); + assert_eq!(result.message.unwrap(), "theme = grayscale (saved)"); + assert_eq!(app.theme_id, crate::palette::ThemeId::Grayscale); assert_eq!(app.ui_theme.mode, crate::palette::PaletteMode::Grayscale); assert!(app.needs_redraw); } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 7342cd9f..aaa9d0fe 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -355,7 +355,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "theme", aliases: &[], - usage: "/theme [dark|light|grayscale|system]", + usage: "/theme [name]", description_id: MessageId::CmdThemeDescription, }, CommandInfo { diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 673ac543..111ba6fb 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -947,7 +947,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdModelsDescription => "List available models from API", MessageId::CmdNetworkDescription => "Manage network allow and deny rules", MessageId::CmdNoteDescription => "Add, list, edit, or remove workspace notes", - MessageId::CmdThemeDescription => "Switch theme: dark, light, grayscale, or system", + MessageId::CmdThemeDescription => "Switch theme or open the theme picker", MessageId::CmdProviderDescription => { "Switch or view the active LLM backend (deepseek | nvidia-nim | ollama)" } diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index 93e15380..054c1a86 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -375,6 +375,213 @@ pub const GRAYSCALE_UI_THEME: UiTheme = UiTheme { border: GRAYSCALE_BORDER, }; +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, + Grayscale, + 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 normalize_theme_name(value)? { + "system" => Some(Self::System), + "dark" => Some(Self::Whale), + "light" => Some(Self::WhaleLight), + "grayscale" => Some(Self::Grayscale), + "catppuccin-mocha" => Some(Self::CatppuccinMocha), + "tokyo-night" => Some(Self::TokyoNight), + "dracula" => Some(Self::Dracula), + "gruvbox-dark" => 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::Grayscale => "grayscale", + 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::Grayscale => "Grayscale", + 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::Grayscale => "Color-minimal high contrast", + 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::Grayscale => GRAYSCALE_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::Grayscale, + ThemeId::CatppuccinMocha, + ThemeId::TokyoNight, + ThemeId::Dracula, + ThemeId::GruvboxDark, +]; + impl UiTheme { #[must_use] pub fn for_mode(mode: PaletteMode) -> Self { @@ -392,13 +599,7 @@ impl UiTheme { #[must_use] pub fn from_setting(value: &str) -> Option { - match normalize_theme_name(value)? { - "system" => Some(Self::detect()), - "dark" => Some(Self::for_mode(PaletteMode::Dark)), - "light" => Some(Self::for_mode(PaletteMode::Light)), - "grayscale" => Some(Self::for_mode(PaletteMode::Grayscale)), - _ => None, - } + ThemeId::from_name(value).map(ThemeId::ui_theme) } #[must_use] @@ -418,6 +619,10 @@ pub fn normalize_theme_name(value: &str) -> Option<&'static str> { "light" | "whale-light" => Some("light"), "grayscale" | "greyscale" | "gray" | "grey" | "mono" | "monochrome" | "black-white" | "black_and_white" | "blackwhite" | "bw" | "b&w" => Some("grayscale"), + "catppuccin-mocha" | "catppuccin" | "mocha" => Some("catppuccin-mocha"), + "tokyo-night" | "tokyonight" | "tokyo" => Some("tokyo-night"), + "dracula" => Some("dracula"), + "gruvbox-dark" | "gruvbox" => Some("gruvbox-dark"), _ => None, } } @@ -541,6 +746,166 @@ fn adapt_bg_for_light_palette(color: Color) -> 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`]. +/// +/// The `ui` argument is the *active* UiTheme as carried on `App` — +/// `ThemeId.ui_theme()` with the user's `background_color` override +/// already applied. Passing it through (rather than re-resolving from +/// `theme` inside this function) preserves that override; otherwise a +/// user combining `background_color = "#..."` with a community theme +/// would see their override silently overwritten by the preset's +/// surface_bg on every cell remap. +#[must_use] +pub fn adapt_fg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color { + if !theme_remap_active(theme) { + return color; + } + + 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. See the +/// `ui` note on [`adapt_fg_for_theme`] — same contract here. +#[must_use] +pub fn adapt_bg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color { + if !theme_remap_active(theme) { + return color; + } + + 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 the panel surface so they + // read as recessed rather than a stray default-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 + } +} + fn adapt_fg_for_grayscale_palette(color: Color) -> Color { if color == Color::Reset { return color; @@ -684,7 +1049,6 @@ fn grayscale_bg_from_luma(luma: u8) -> Color { fn luma(r: u8, g: u8, b: u8) -> u8 { (((u16::from(r) * 299) + (u16::from(g) * 587) + (u16::from(b) * 114)) / 1000) as u8 } - // === 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 9dc879b2..0b65f932 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -28,7 +28,7 @@ use crate::palette::{normalize_hex_rgb_color, normalize_theme_name}; /// # Example `~/.deepseek/tui.toml` /// /// ```toml -/// theme = "dark" # "system" | "dark" | "light" | "grayscale" +/// theme = "dark" # "system" | "dark" | "light" | "grayscale" | "catppuccin-mocha" | ... /// font_size = 14 /// /// [keybinds] @@ -43,7 +43,7 @@ use crate::palette::{normalize_hex_rgb_color, normalize_theme_name}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct TuiPrefs { - /// UI colour theme: `"dark"` | `"light"` | `"grayscale"` | `"system"`. + /// UI colour theme. /// Default `"dark"`. pub theme: String, /// Terminal font size hint forwarded to supporting front-ends (e.g. the @@ -152,7 +152,7 @@ impl TuiPrefs { let theme = self.theme.trim().to_ascii_lowercase(); let Some(theme) = normalize_theme_name(&theme) else { anyhow::bail!( - "Invalid tui.toml theme '{}': expected system, dark, light, or grayscale.", + "Invalid tui.toml theme '{}': expected system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, or gruvbox-dark.", self.theme ); }; @@ -195,7 +195,11 @@ pub struct Settings { pub show_tool_details: bool, /// UI locale: auto, en, ja, zh-Hans, pt-BR, es-419 pub locale: String, - /// UI theme: system, dark, light, grayscale + /// Named UI theme. Accepts `"system"` (follow terminal background), + /// `"dark"`, `"light"`, `"grayscale"`, or one of the community + /// presets: `"catppuccin-mocha"`, `"tokyo-night"`, `"dracula"`, + /// `"gruvbox-dark"`. 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, @@ -353,8 +357,8 @@ impl Settings { s.locale = normalize_configured_locale(&s.locale) .unwrap_or("en") .to_string(); - s.theme = normalize_settings_theme(&s.theme).to_string(); s.background_color = normalize_optional_background_color(s.background_color.as_deref()); + s.theme = normalize_settings_theme(&s.theme).to_string(); s.default_model = s.default_model.as_deref().and_then(normalize_default_model); s }; @@ -480,13 +484,21 @@ impl Settings { }; self.locale = locale.to_string(); } - "theme" | "ui_theme" => { - let Some(theme) = normalize_theme_name(value) else { + "theme" => { + let Some(id) = crate::palette::ThemeId::from_name(value) else { anyhow::bail!( - "Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, grayscale." + "Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark." ); }; - self.theme = theme.to_string(); + self.theme = id.name().to_string(); + } + "ui_theme" => { + let Some(id) = crate::palette::ThemeId::from_name(value) else { + anyhow::bail!( + "Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark." + ); + }; + self.theme = id.name().to_string(); } "background_color" | "background" | "bg" => { self.background_color = normalize_background_color_setting(value)?; @@ -645,7 +657,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!(" theme: {}", self.theme)); lines.push(format!( " background_color: {}", self.background_color.as_deref().unwrap_or("(default)") @@ -715,7 +727,10 @@ impl Settings { "locale", "UI locale and default model language: auto, en, ja, zh-Hans, pt-BR, es-419", ), - ("theme", "UI theme: system, dark, light, grayscale"), + ( + "theme", + "UI theme: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark", + ), ( "background_color", "Main TUI background color: #RRGGBB or default", @@ -1001,6 +1016,11 @@ mod tests { settings.set("theme", "whale").expect("set dark alias"); assert_eq!(settings.theme, "dark"); + settings + .set("theme", "tokyonight") + .expect("set community theme alias"); + assert_eq!(settings.theme, "tokyo-night"); + let err = settings .set("theme", "solarized") .expect_err("unknown theme should fail"); @@ -1655,7 +1675,16 @@ mod tests { #[test] fn tui_prefs_validate_accepts_known_themes() { - for theme in ["dark", "light", "system", "grayscale"] { + for theme in [ + "dark", + "light", + "system", + "grayscale", + "catppuccin-mocha", + "tokyo-night", + "dracula", + "gruvbox-dark", + ] { let mut prefs = TuiPrefs { theme: theme.to_string(), ..TuiPrefs::default() @@ -1691,7 +1720,7 @@ mod tests { assert!(err.to_string().contains("Invalid tui.toml theme")); assert!( err.to_string() - .contains("expected system, dark, light, or grayscale") + .contains("expected system, dark, light, grayscale") ); } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 8a6de1d0..414ed1c7 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -859,6 +859,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, @@ -1264,8 +1269,19 @@ 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 ui_theme = - palette::ui_theme_from_settings(&settings.theme, settings.background_color.as_deref()); + // 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() + .and_then(palette::parse_hex_rgb_color) + { + ui_theme = ui_theme.with_background_color(background); + } let model = settings .provider_models .as_ref() @@ -1459,6 +1475,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, @@ -4005,6 +4022,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 6de30c0f..68c367f2 100644 --- a/crates/tui/src/tui/color_compat.rs +++ b/crates/tui/src/tui/color_compat.rs @@ -14,13 +14,25 @@ use ratatui::{ layout::{Position, Size}, }; -use crate::palette::{self, ColorDepth, PaletteMode}; +use crate::palette::{self, ColorDepth, PaletteMode, ThemeId, UiTheme}; #[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, + /// Resolved active `UiTheme`, *including* any user `background_color` + /// override (`UiTheme::with_background_color`). The cell remap reads + /// target slots from this struct, not from `theme_id.ui_theme()`, so + /// `theme = "tokyo-night"` + `background_color = "#000000"` lands as a + /// pure-black surface instead of being overwritten back to + /// tokyo-night's `#16161e` by the remap. + active_ui_theme: UiTheme, /// 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 +46,12 @@ impl ColorCompatBackend { inner: CrosstermBackend::new(writer), depth, palette_mode, + theme_id: ThemeId::System, + // Default to whatever System resolves to right now — it stays a + // no-op for the remap since `theme_id` is also System, so this + // initial value only matters once `set_theme` flips both fields + // to a community preset. + active_ui_theme: UiTheme::detect(), forced_size: None, } } @@ -49,6 +67,11 @@ 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, ui_theme: UiTheme) { + self.theme_id = theme_id; + self.active_ui_theme = ui_theme; + } } impl Write for ColorCompatBackend { @@ -71,7 +94,13 @@ 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, + &self.active_ui_theme, + ); (x, y, cell) }) .collect::>(); @@ -123,10 +152,25 @@ 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, + ui_theme: &UiTheme, +) { + // 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, ui_theme); + cell.bg = palette::adapt_bg_for_theme(cell.bg, theme_id, ui_theme); + // 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 +204,13 @@ 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, + &palette::UI_THEME, + ); assert!(matches!(cell.fg, Color::Indexed(_))); assert!(matches!(cell.bg, Color::Indexed(_))); @@ -172,7 +222,13 @@ 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, + &palette::UI_THEME, + ); assert_eq!(cell.fg, Color::Rgb(53, 120, 229)); assert_eq!(cell.bg, Color::Rgb(11, 21, 38)); @@ -201,7 +257,13 @@ 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, + &palette::LIGHT_UI_THEME, + ); assert_eq!(cell.fg, palette::LIGHT_TEXT_BODY); assert_eq!(cell.bg, palette::LIGHT_SURFACE); @@ -213,12 +275,38 @@ mod tests { cell.set_fg(palette::DEEPSEEK_SKY); cell.set_bg(palette::DEEPSEEK_INK); - adapt_cell_colors(&mut cell, ColorDepth::TrueColor, PaletteMode::Grayscale); + adapt_cell_colors( + &mut cell, + ColorDepth::TrueColor, + PaletteMode::Grayscale, + ThemeId::Grayscale, + &palette::GRAYSCALE_UI_THEME, + ); assert_eq!(cell.fg, palette::GRAYSCALE_TEXT_SOFT); assert_eq!(cell.bg, palette::GRAYSCALE_SURFACE); } + #[test] + fn community_theme_remap_honors_background_color_override() { + // Tokyo Night + a custom black surface: the remap must rewrite + // `palette::DEEPSEEK_INK` to the *active* UiTheme's overridden + // surface, not to tokyo-night's default surface. + let active = palette::TOKYO_NIGHT_UI_THEME.with_background_color(Color::Rgb(0, 0, 0)); + let mut cell = Cell::default(); + cell.set_bg(palette::DEEPSEEK_INK); + + adapt_cell_colors( + &mut cell, + ColorDepth::TrueColor, + PaletteMode::Dark, + ThemeId::TokyoNight, + &active, + ); + + assert_eq!(cell.bg, Color::Rgb(0, 0, 0)); + } + #[test] fn backend_palette_mode_can_follow_runtime_theme_changes() { let writer = SharedWriter::default(); 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..95dcd09d --- /dev/null +++ b/crates/tui/src/tui/theme_picker.rs @@ -0,0 +1,398 @@ +//! `/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, UiTheme}; +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, + /// Cached UiTheme for `ThemeId::System`, captured once at construction + /// so the per-frame render doesn't re-invoke `UiTheme::detect()` (which + /// reads `COLORFGBG`) on every keystroke. + system_ui_theme: UiTheme, +} + +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, + system_ui_theme: UiTheme::detect(), + } + } + + fn current(&self) -> ThemeId { + SELECTABLE_THEMES + .get(self.selected) + .copied() + .unwrap_or(ThemeId::System) + } + + /// Resolve a theme to a `UiTheme`, returning the cached `System` + /// resolution to avoid repeated env-var reads inside `render`. + fn ui_theme_for(&self, id: ThemeId) -> UiTheme { + if matches!(id, ThemeId::System) { + self.system_ui_theme + } else { + id.ui_theme() + } + } + + 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). + // '0' is rejected explicitly — saturating_sub would otherwise + // collapse it onto row 0, which is unintuitive. + KeyCode::Char(c) + if matches!(c, '1'..='9') + && !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + let idx = (c as usize) - ('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) { + // Modal must always fit inside `area`. The old `.max(52) / .max(10)` + // floors could produce dimensions larger than the available area on + // very small terminals (or split-pane setups), which then made the + // centering arithmetic underflow and ratatui assert. Take a + // soft-preferred size and clamp it strictly to `area`. + let popup_width = 78u16.min(area.width.saturating_sub(4)); + // 1 title + 1 spacer + N rows + spacer + bottom hint + let needed_height = (SELECTABLE_THEMES.len() as u16).saturating_add(9); + let popup_height = needed_height.min(area.height.saturating_sub(4)); + + if popup_width == 0 || popup_height == 0 { + // Nothing sensible to draw — the host's caller has already + // cleared the area, so we just return. + return; + } + + 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.ui_theme_for(self.current()); + + 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. Use the cached resolver so `System` doesn't repeat + // `UiTheme::detect()`. + let row_theme = self.ui_theme_for(id); + 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)); + 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('5'))); + // Row 5 (1-indexed) -> index 4 -> CatppuccinMocha + assert_eq!( + selected_name(&action), + Some(ThemeId::CatppuccinMocha.name()) + ); + } + + #[test] + fn digit_zero_is_rejected_not_remapped_to_row_zero() { + let mut v = ThemePickerView::new("dracula".to_string()); + let before = v.selected; + let action = v.handle_key(key(KeyCode::Char('0'))); + assert!(matches!(action, ViewAction::None)); + assert_eq!(v.selected, before, "'0' should not move the cursor"); + } + + #[test] + fn render_does_not_panic_on_zero_sized_area() { + // The picker historically panicked here via .max(W).max(H) floors + // that produced dimensions larger than the available area, then + // underflowed the centering arithmetic. + let v = ThemePickerView::new("system".to_string()); + let outer = ratatui::layout::Rect::new(0, 0, 10, 10); + let area = ratatui::layout::Rect::new(0, 0, 0, 0); + let mut buf = ratatui::buffer::Buffer::empty(outer); + v.render(area, &mut buf); + } + + #[test] + fn render_does_not_panic_on_tiny_area() { + // 20×6 is smaller than every soft floor the picker prefers. + let v = ThemePickerView::new("system".to_string()); + let area = ratatui::layout::Rect::new(0, 0, 20, 6); + let mut buf = ratatui::buffer::Buffer::empty(area); + v.render(area, &mut buf); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 23047b63..c10e0ba7 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5367,6 +5367,17 @@ 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 active theme name straight from `app` so + // Esc can revert through the same ConfigUpdated channel. + // Avoids re-reading settings.toml from disk on every + // `/theme` invocation. + let original = app.theme_id.name().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")); @@ -6287,6 +6298,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, app.ui_theme); // 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 @@ -6566,7 +6578,12 @@ async fn handle_view_events( persist, } => { let result = commands::set_config_value(app, &key, &value, persist); - if let Some(msg) = result.message { + // Only surface the "key = value" confirmation when the + // change is being persisted. Live-preview events + // (`persist: false`, e.g. arrow keys in the theme picker) + // fire on every navigation tick and would otherwise spam + // a `System` cell into the transcript per row visited. + if persist && let Some(msg) = result.message { app.add_message(HistoryCell::System { content: msg }); } diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index d25c6519..fe70c4bb 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 de8f7b70..01ac69f8 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -673,6 +673,10 @@ mod tests { app.model = "deepseek-v4-flash".to_string(); app.auto_model = false; app.api_provider = crate::config::ApiProvider::Deepseek; + // Same for theme: tests below assert against the default dark palette, + // but App::new honors saved settings.toml values on the host machine. + app.theme_id = crate::palette::ThemeId::Whale; + app.ui_theme = crate::palette::UI_THEME; app } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 95e99c45..bc8d2ed1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -327,10 +327,12 @@ replacement compaction. You can inspect or update these from the TUI with Common settings keys: -- `theme` (`system`, `dark`, `light`, `grayscale`; default `system`): - `system` follows terminal background detection, `dark`/`light` use the - DeepSeek palettes, and `grayscale` is the low-opinion black/white theme. - Aliases such as `whale`, `mono`, and `black-white` are accepted. +- `theme` (`system`, `dark`, `light`, `grayscale`, `catppuccin-mocha`, + `tokyo-night`, `dracula`, `gruvbox-dark`; default `system`): `system` + follows terminal background detection, `dark`/`light` use the DeepSeek + palettes, `grayscale` is the low-opinion black/white theme, and the named + community presets apply across the TUI. Aliases such as `whale`, `mono`, + `black-white`, `tokyonight`, and `gruvbox` are accepted. - `auto_compact` (on/off, default off) - `paste_burst_detection` (on/off, default on): fallback rapid-key paste detection for terminals that do not emit bracketed-paste events. This is