From 40c5492903b580e2368095979290b5650dfdd84a Mon Sep 17 00:00:00 2001 From: liushiao Date: Wed, 20 May 2026 16:39:25 +0800 Subject: [PATCH 1/3] 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 b3a5a367f..7de9d730c 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 85da1d41a..fca7254a4 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()) From fce32bfb69c06b338d882aff3829acbf862f6a23 Mon Sep 17 00:00:00 2001 From: liushiao Date: Wed, 20 May 2026 17:10:47 +0800 Subject: [PATCH 2/3] refactor(tui): use Magenta + DarkGray for Terminal theme accents Adopt Gemini code-assist review on PR #1831: - mode_plan: Yellow -> Magenta. Plan chip now contrasts with status_warning (still Yellow) so the two never visually collide in the status row. - status_ready: Reset -> DarkGray. Ready chip now reads as a distinct subdued accent instead of blending into body text (which also resolves to Reset on this theme). No surface change otherwise -- backgrounds and body text still use Color::Reset to inherit the host terminal's color scheme. --- crates/tui/src/palette.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index 7de9d730c..d0d8e93b8 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -846,8 +846,13 @@ pub const TERMINAL_UI_THEME: UiTheme = UiTheme { footer_bg: Color::Reset, mode_agent: Color::Blue, mode_yolo: Color::Red, - mode_plan: Color::Yellow, - status_ready: Color::Reset, + // Magenta keeps Plan visually distinct from `status_warning` (yellow) + // so the mode indicator and warning chip don't collide on themes that + // render both in the status row. + mode_plan: Color::Magenta, + // DarkGray gives "Ready" a low-contrast but still distinguishable hue + // versus default body text (which is `Color::Reset` on this theme). + status_ready: Color::DarkGray, status_working: Color::Cyan, status_warning: Color::Yellow, text_dim: Color::Reset, From e72bf3827d150c5e8bf2e1f7185431378951e894 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 27 May 2026 06:06:22 -0500 Subject: [PATCH 3/3] test(tui): cover terminal theme palette remap --- crates/tui/src/palette.rs | 79 ++++++++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index d0d8e93b8..fb1c66e82 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -844,23 +844,42 @@ pub const TERMINAL_UI_THEME: UiTheme = UiTheme { selection_bg: Color::Reset, header_bg: Color::Reset, footer_bg: Color::Reset, + text_dim: Color::Reset, + text_hint: Color::Reset, + text_muted: Color::Reset, + text_body: Color::Reset, + text_soft: Color::Reset, + border: Color::Reset, + accent_primary: Color::Blue, + accent_secondary: Color::Cyan, + accent_action: Color::Yellow, + error_fg: Color::Red, + error_hover: Color::Red, + error_surface: Color::Reset, + error_border: Color::Red, + error_text: Color::Red, + warning: Color::Yellow, + success: Color::Green, + info: Color::Cyan, mode_agent: Color::Blue, mode_yolo: Color::Red, // Magenta keeps Plan visually distinct from `status_warning` (yellow) // so the mode indicator and warning chip don't collide on themes that // render both in the status row. mode_plan: Color::Magenta, + mode_goal: Color::Green, // DarkGray gives "Ready" a low-contrast but still distinguishable hue // versus default body text (which is `Color::Reset` on this theme). status_ready: Color::DarkGray, 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, + diff_added_fg: Color::Green, + diff_deleted_fg: Color::Red, + diff_added_bg: Color::Reset, + diff_deleted_bg: Color::Reset, + tool_running: Color::Cyan, + tool_success: Color::Green, + tool_failed: Color::Red, }; pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme { @@ -980,9 +999,7 @@ 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::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", @@ -1729,14 +1746,15 @@ fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 { mod tests { use super::{ ACCENT_REASONING_LIVE, ColorDepth, DEEPSEEK_INK, DEEPSEEK_RED, DEEPSEEK_SKY, - DEEPSEEK_SLATE, GRAYSCALE_BORDER, GRAYSCALE_ELEVATED, GRAYSCALE_PANEL, GRAYSCALE_REASONING, - GRAYSCALE_SURFACE, GRAYSCALE_TEXT_BODY, GRAYSCALE_TEXT_HINT, GRAYSCALE_TEXT_SOFT, - GRAYSCALE_UI_THEME, LIGHT_BORDER, LIGHT_ELEVATED, LIGHT_PANEL, LIGHT_REASONING, - LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT, LIGHT_UI_THEME, PaletteMode, - SURFACE_REASONING, SURFACE_REASONING_TINT, TEXT_BODY, TEXT_HINT, TEXT_REASONING, - TEXT_TOOL_OUTPUT, UI_THEME, WHALE_REASONING_TEXT_RGB, WHALE_REASONING_TINT_RGB, - WHALE_TEXT_BODY_RGB, adapt_bg, adapt_bg_for_palette_mode, adapt_color, - adapt_fg_for_palette_mode, blend, luma, nearest_ansi16, normalize_hex_rgb_color, + DEEPSEEK_SLATE, DIFF_ADDED, DIFF_ADDED_BG, GRAYSCALE_BORDER, GRAYSCALE_ELEVATED, + GRAYSCALE_PANEL, GRAYSCALE_REASONING, GRAYSCALE_SURFACE, GRAYSCALE_TEXT_BODY, + GRAYSCALE_TEXT_HINT, GRAYSCALE_TEXT_SOFT, GRAYSCALE_UI_THEME, LIGHT_BORDER, LIGHT_ELEVATED, + LIGHT_PANEL, LIGHT_REASONING, LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT, + LIGHT_UI_THEME, PaletteMode, SURFACE_REASONING, SURFACE_REASONING_TINT, TERMINAL_UI_THEME, + TEXT_BODY, TEXT_HINT, TEXT_REASONING, TEXT_TOOL_OUTPUT, ThemeId, UI_THEME, + WHALE_REASONING_TEXT_RGB, WHALE_REASONING_TINT_RGB, WHALE_TEXT_BODY_RGB, adapt_bg, + adapt_bg_for_palette_mode, adapt_bg_for_theme, adapt_color, adapt_fg_for_palette_mode, + adapt_fg_for_theme, blend, luma, nearest_ansi16, normalize_hex_rgb_color, normalize_theme_name, parse_hex_rgb_color, pulse_brightness, reasoning_surface_tint, rgb_to_ansi256, theme_label_for_mode, ui_theme_from_settings, }; @@ -1822,12 +1840,39 @@ mod tests { assert_eq!(normalize_theme_name("system"), Some("system")); assert_eq!(normalize_theme_name("default"), Some("system")); assert_eq!(normalize_theme_name("whale"), Some("dark")); + assert_eq!(normalize_theme_name("transparent"), Some("terminal")); + assert_eq!(normalize_theme_name("inherit"), Some("terminal")); assert_eq!(normalize_theme_name("black-white"), Some("grayscale")); assert_eq!(normalize_theme_name("mono"), Some("grayscale")); assert_eq!(normalize_theme_name("solarized"), None); assert_eq!(theme_label_for_mode(PaletteMode::Grayscale), "grayscale"); } + #[test] + fn terminal_theme_resets_surfaces_and_remaps_direct_palette_constants() { + assert_eq!(ThemeId::from_name("terminal"), Some(ThemeId::Terminal)); + assert_eq!(TERMINAL_UI_THEME.surface_bg, Color::Reset); + assert_eq!(TERMINAL_UI_THEME.footer_bg, Color::Reset); + assert_eq!(TERMINAL_UI_THEME.text_body, Color::Reset); + + assert_eq!( + adapt_bg_for_theme(DEEPSEEK_INK, ThemeId::Terminal, &TERMINAL_UI_THEME), + Color::Reset + ); + assert_eq!( + adapt_bg_for_theme(DIFF_ADDED_BG, ThemeId::Terminal, &TERMINAL_UI_THEME), + Color::Reset + ); + assert_eq!( + adapt_fg_for_theme(TEXT_BODY, ThemeId::Terminal, &TERMINAL_UI_THEME), + Color::Reset + ); + assert_eq!( + adapt_fg_for_theme(DIFF_ADDED, ThemeId::Terminal, &TERMINAL_UI_THEME), + Color::Green + ); + } + #[test] fn light_palette_has_quiet_layer_separation() { assert_eq!(LIGHT_SURFACE, Color::Rgb(246, 248, 251));