From 0ea84dce7d233960cb3e271206b2c6d6519ed2b5 Mon Sep 17 00:00:00 2001 From: liushiao Date: Wed, 20 May 2026 16:39:25 +0800 Subject: [PATCH] feat(tui): add "Terminal" theme that fully inherits the host terminal's colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new selectable theme `terminal` (alongside System / Whale / Whale Light / Grayscale / Catppuccin / Tokyo Night / Dracula / Gruvbox) that paints every surface with `Color::Reset` instead of any RGB so the host terminal's own background, foreground, and palette show through. The existing `system` theme only chose between two RGB themes (Whale dark or Whale Light) based on COLORFGBG / macOS appearance — useful, but it still painted brand-colored RGB surfaces. Users with custom terminal themes (Solarized, Nord, transparent backgrounds, custom Ghostty/iTerm schemes) had no way to make the TUI respect their terminal palette. Implementation: - New `TERMINAL_UI_THEME` const where every `*_bg` and most text slots are `Color::Reset`, and accents (mode_agent/yolo/plan, status_working, status_warning) use ANSI named colors so they also inherit the user's terminal palette rather than DeepSeek brand RGB. - `ThemeId::Terminal` plumbed through `from_name` / `name` / `display_name` / `tagline` / `ui_theme` / `SELECTABLE_THEMES`, and registered in `normalize_theme_name` with aliases `term`, `transparent`, `follow-terminal`, `inherit` so existing user-friendly config strings just work. - `theme_remap_active(Terminal) → true` so the existing per-cell remap in `ColorCompatBackend` rewrites every hard-coded palette constant (`DEEPSEEK_INK`, `DEEPSEEK_SLATE`, `BORDER_COLOR`, `TEXT_BODY`, …) to `Color::Reset`. Without this, the many render sites that reach for the named palette constants directly would still paint brand RGB. - `theme_green` / `theme_red` return `Color::Green` / `Color::Red` for Terminal so diff "+"/"−" stay green/red but follow the user's terminal palette. - `theme_diff_added_bg` / `theme_diff_deleted_bg` return `Color::Reset` for Terminal — diff highlight is conveyed by foreground color only. - The new theme is the second entry in `SELECTABLE_THEMES` (right after System) so it surfaces prominently in the `/theme` picker. theme_picker tests: the new theme is inserted in row 2 of `SELECTABLE_THEMES`, which shifts the indices three existing tests relied on — `arrow_down_previews_next_theme`, `enter_commits_with_persist_true`, and `digit_jumps_to_row` — so those expectations are updated to match the new ordering. No production behavior change in those tests, just index arithmetic. Default (`theme = "system"`) is unchanged; existing users see no difference. Users who want full terminal pass-through opt in via `/theme` or `theme = "terminal"` in settings.toml. --- crates/tui/src/palette.rs | 49 +++++++++++++++++++++++++++++- crates/tui/src/tui/theme_picker.rs | 7 +++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index b3a5a367..7de9d730 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -825,6 +825,39 @@ pub const DRACULA_UI_THEME: UiTheme = UiTheme { tool_failed: Color::Rgb(0xff, 0x55, 0x55), // red }; +/// "Terminal" theme: lets the host terminal's color scheme show through +/// instead of painting any RGB surface. Backgrounds use `Color::Reset` +/// (the terminal's own default bg) and most text uses `Color::Reset` +/// (terminal's own default fg). Accents are ANSI named colors so they +/// also inherit the user's terminal palette (Solarized, Nord, custom +/// schemes, etc.) rather than DeepSeek brand RGB. +pub const TERMINAL_UI_THEME: UiTheme = UiTheme { + name: "terminal", + // Mode is reported as Dark to avoid the dark→light cell remap kicking + // in; the terminal-theme cell remap already normalizes everything to + // `Color::Reset`, and we never want a second pass overwriting that. + mode: PaletteMode::Dark, + surface_bg: Color::Reset, + panel_bg: Color::Reset, + elevated_bg: Color::Reset, + composer_bg: Color::Reset, + selection_bg: Color::Reset, + header_bg: Color::Reset, + footer_bg: Color::Reset, + mode_agent: Color::Blue, + mode_yolo: Color::Red, + mode_plan: Color::Yellow, + status_ready: Color::Reset, + status_working: Color::Cyan, + status_warning: Color::Yellow, + text_dim: Color::Reset, + text_hint: Color::Reset, + text_muted: Color::Reset, + text_body: Color::Reset, + text_soft: Color::Reset, + border: Color::Reset, +}; + pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme { name: "gruvbox-dark", mode: PaletteMode::Dark, @@ -874,6 +907,7 @@ pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThemeId { System, + Terminal, Whale, WhaleLight, Grayscale, @@ -891,6 +925,7 @@ impl ThemeId { pub fn from_name(value: &str) -> Option { match normalize_theme_name(value)? { "system" => Some(Self::System), + "terminal" => Some(Self::Terminal), "dark" => Some(Self::Whale), "light" => Some(Self::WhaleLight), "grayscale" => Some(Self::Grayscale), @@ -908,6 +943,7 @@ impl ThemeId { pub const fn name(self) -> &'static str { match self { Self::System => "system", + Self::Terminal => "terminal", Self::Whale => "dark", Self::WhaleLight => "light", Self::Grayscale => "grayscale", @@ -923,6 +959,7 @@ impl ThemeId { pub const fn display_name(self) -> &'static str { match self { Self::System => "System", + Self::Terminal => "Terminal", Self::Whale => "Whale (Dark)", Self::WhaleLight => "Whale Light", Self::Grayscale => "Grayscale", @@ -938,6 +975,9 @@ impl ThemeId { pub const fn tagline(self) -> &'static str { match self { Self::System => "Follow terminal background (COLORFGBG / macOS appearance)", + Self::Terminal => { + "Inherit terminal colors fully (transparent surfaces, ANSI accents)" + } Self::Whale => "Whale dark — deep navy & gold", Self::WhaleLight => "DeepSeek light, paper-ish", Self::Grayscale => "Color-minimal high contrast", @@ -956,6 +996,7 @@ impl ThemeId { pub fn ui_theme(self) -> UiTheme { match self { Self::System => UiTheme::detect(), + Self::Terminal => TERMINAL_UI_THEME, Self::Whale => UI_THEME, Self::WhaleLight => LIGHT_UI_THEME, Self::Grayscale => GRAYSCALE_UI_THEME, @@ -970,6 +1011,7 @@ impl ThemeId { /// Themes shown in the `/theme` picker, in display order. pub const SELECTABLE_THEMES: &[ThemeId] = &[ ThemeId::System, + ThemeId::Terminal, ThemeId::Whale, ThemeId::WhaleLight, ThemeId::Grayscale, @@ -1012,6 +1054,7 @@ impl UiTheme { pub fn normalize_theme_name(value: &str) -> Option<&'static str> { match value.trim().to_ascii_lowercase().as_str() { "" | "auto" | "system" | "default" => Some("system"), + "terminal" | "term" | "transparent" | "follow-terminal" | "inherit" => Some("terminal"), "dark" | "whale" | "whale-dark" => Some("dark"), "light" | "whale-light" => Some("light"), "grayscale" | "greyscale" | "gray" | "grey" | "mono" | "monochrome" | "black-white" @@ -1189,7 +1232,11 @@ const fn theme_diff_deleted_bg(ui: &UiTheme) -> Color { pub const fn theme_remap_active(theme: ThemeId) -> bool { matches!( theme, - ThemeId::CatppuccinMocha | ThemeId::TokyoNight | ThemeId::Dracula | ThemeId::GruvboxDark + ThemeId::Terminal + | ThemeId::CatppuccinMocha + | ThemeId::TokyoNight + | ThemeId::Dracula + | ThemeId::GruvboxDark ) } diff --git a/crates/tui/src/tui/theme_picker.rs b/crates/tui/src/tui/theme_picker.rs index 85da1d41..fca7254a 100644 --- a/crates/tui/src/tui/theme_picker.rs +++ b/crates/tui/src/tui/theme_picker.rs @@ -317,7 +317,7 @@ mod tests { 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())); + assert_eq!(selected_name(&action), Some(ThemeId::Terminal.name())); } #[test] @@ -337,6 +337,7 @@ mod tests { v.handle_key(key(KeyCode::Down)); 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 { @@ -376,8 +377,8 @@ mod tests { #[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 + let action = v.handle_key(key(KeyCode::Char('6'))); + // Row 6 (1-indexed) -> index 5 -> CatppuccinMocha assert_eq!( selected_name(&action), Some(ThemeId::CatppuccinMocha.name())