diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index b3a5a367f..fb1c66e82 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -825,6 +825,63 @@ 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, + 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, + 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 { name: "gruvbox-dark", mode: PaletteMode::Dark, @@ -874,6 +931,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 +949,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 +967,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 +983,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 +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::Whale => "Whale dark — deep navy & gold", Self::WhaleLight => "DeepSeek light, paper-ish", Self::Grayscale => "Color-minimal high contrast", @@ -956,6 +1018,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 +1033,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 +1076,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 +1254,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 ) } @@ -1677,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, }; @@ -1770,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)); 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())