diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index b56a6393..c3724b12 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -12,6 +12,19 @@ pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38); pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46); pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96); +pub const LIGHT_SURFACE_RGB: (u8, u8, u8) = (248, 250, 252); // #F8FAFC +pub const LIGHT_PANEL_RGB: (u8, u8, u8) = (241, 245, 249); // #F1F5F9 +pub const LIGHT_ELEVATED_RGB: (u8, u8, u8) = (226, 232, 240); // #E2E8F0 +pub const LIGHT_REASONING_RGB: (u8, u8, u8) = (254, 243, 199); // #FEF3C7 +pub const LIGHT_SUCCESS_RGB: (u8, u8, u8) = (220, 252, 231); // #DCFCE7 +pub const LIGHT_ERROR_RGB: (u8, u8, u8) = (254, 226, 226); // #FEE2E2 +pub const LIGHT_TEXT_BODY_RGB: (u8, u8, u8) = (15, 23, 42); // #0F172A +pub const LIGHT_TEXT_MUTED_RGB: (u8, u8, u8) = (71, 85, 105); // #475569 +pub const LIGHT_TEXT_HINT_RGB: (u8, u8, u8) = (100, 116, 139); // #64748B +pub const LIGHT_TEXT_SOFT_RGB: (u8, u8, u8) = (51, 65, 85); // #334155 +pub const LIGHT_BORDER_RGB: (u8, u8, u8) = (148, 163, 184); // #94A3B8 +pub const LIGHT_SELECTION_RGB: (u8, u8, u8) = (219, 234, 254); // #DBEAFE + // New semantic colors pub const BORDER_COLOR_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F @@ -44,6 +57,56 @@ pub const DEEPSEEK_SLATE: Color = Color::Rgb( pub const DEEPSEEK_RED: Color = Color::Rgb(DEEPSEEK_RED_RGB.0, DEEPSEEK_RED_RGB.1, DEEPSEEK_RED_RGB.2); +pub const LIGHT_SURFACE: Color = Color::Rgb( + LIGHT_SURFACE_RGB.0, + LIGHT_SURFACE_RGB.1, + LIGHT_SURFACE_RGB.2, +); +pub const LIGHT_PANEL: Color = Color::Rgb(LIGHT_PANEL_RGB.0, LIGHT_PANEL_RGB.1, LIGHT_PANEL_RGB.2); +pub const LIGHT_ELEVATED: Color = Color::Rgb( + LIGHT_ELEVATED_RGB.0, + LIGHT_ELEVATED_RGB.1, + LIGHT_ELEVATED_RGB.2, +); +pub const LIGHT_REASONING: Color = Color::Rgb( + LIGHT_REASONING_RGB.0, + LIGHT_REASONING_RGB.1, + LIGHT_REASONING_RGB.2, +); +pub const LIGHT_SUCCESS: Color = Color::Rgb( + LIGHT_SUCCESS_RGB.0, + LIGHT_SUCCESS_RGB.1, + LIGHT_SUCCESS_RGB.2, +); +pub const LIGHT_ERROR: Color = Color::Rgb(LIGHT_ERROR_RGB.0, LIGHT_ERROR_RGB.1, LIGHT_ERROR_RGB.2); +pub const LIGHT_TEXT_BODY: Color = Color::Rgb( + LIGHT_TEXT_BODY_RGB.0, + LIGHT_TEXT_BODY_RGB.1, + LIGHT_TEXT_BODY_RGB.2, +); +pub const LIGHT_TEXT_MUTED: Color = Color::Rgb( + LIGHT_TEXT_MUTED_RGB.0, + LIGHT_TEXT_MUTED_RGB.1, + LIGHT_TEXT_MUTED_RGB.2, +); +pub const LIGHT_TEXT_HINT: Color = Color::Rgb( + LIGHT_TEXT_HINT_RGB.0, + LIGHT_TEXT_HINT_RGB.1, + LIGHT_TEXT_HINT_RGB.2, +); +pub const LIGHT_TEXT_SOFT: Color = Color::Rgb( + LIGHT_TEXT_SOFT_RGB.0, + LIGHT_TEXT_SOFT_RGB.1, + LIGHT_TEXT_SOFT_RGB.2, +); +pub const LIGHT_BORDER: Color = + Color::Rgb(LIGHT_BORDER_RGB.0, LIGHT_BORDER_RGB.1, LIGHT_BORDER_RGB.2); +pub const LIGHT_SELECTION_BG: Color = Color::Rgb( + LIGHT_SELECTION_RGB.0, + LIGHT_SELECTION_RGB.1, + LIGHT_SELECTION_RGB.2, +); + pub const TEXT_BODY: Color = Color::White; pub const TEXT_SECONDARY: Color = Color::Rgb(192, 192, 192); // #C0C0C0 pub const TEXT_HINT: Color = Color::Rgb(160, 160, 160); // #A0A0A0 @@ -106,9 +169,42 @@ pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74); #[allow(dead_code)] pub const COMPOSER_BG: Color = DEEPSEEK_SLATE; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaletteMode { + Dark, + Light, +} + +impl PaletteMode { + /// Parse `COLORFGBG`, whose last numeric segment is the terminal + /// background color. Values >= 8 conventionally indicate a light profile. + #[must_use] + pub fn from_colorfgbg(value: &str) -> Option { + let bg = value + .split(';') + .rev() + .find_map(|part| part.parse::().ok())?; + Some(if bg >= 8 { Self::Light } else { Self::Dark }) + } + + /// Detect whether the terminal profile is light. Missing or unparsable + /// values default to dark so existing terminal setups keep the tuned theme. + #[must_use] + pub fn detect() -> Self { + std::env::var("COLORFGBG") + .ok() + .and_then(|value| Self::from_colorfgbg(&value)) + .unwrap_or(Self::Dark) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UiTheme { pub name: &'static str, + pub mode: PaletteMode, + pub surface_bg: Color, + pub panel_bg: Color, + pub elevated_bg: Color, pub composer_bg: Color, pub selection_bg: Color, pub header_bg: Color, @@ -125,10 +221,17 @@ pub struct UiTheme { pub text_dim: Color, pub text_hint: Color, pub text_muted: Color, + pub text_body: Color, + pub text_soft: Color, + pub border: Color, } pub const UI_THEME: UiTheme = UiTheme { name: "whale", + mode: PaletteMode::Dark, + surface_bg: DEEPSEEK_INK, + panel_bg: DEEPSEEK_SLATE, + elevated_bg: SURFACE_ELEVATED, composer_bg: DEEPSEEK_SLATE, selection_bg: SELECTION_BG, header_bg: DEEPSEEK_INK, @@ -142,8 +245,112 @@ pub const UI_THEME: UiTheme = UiTheme { text_dim: TEXT_DIM, text_hint: TEXT_HINT, text_muted: TEXT_MUTED, + text_body: TEXT_BODY, + text_soft: TEXT_SOFT, + border: BORDER_COLOR, }; +pub const LIGHT_UI_THEME: UiTheme = UiTheme { + name: "whale-light", + mode: PaletteMode::Light, + surface_bg: LIGHT_SURFACE, + panel_bg: LIGHT_PANEL, + elevated_bg: LIGHT_ELEVATED, + composer_bg: LIGHT_PANEL, + selection_bg: LIGHT_SELECTION_BG, + header_bg: LIGHT_SURFACE, + footer_bg: LIGHT_SURFACE, + mode_agent: DEEPSEEK_BLUE, + mode_yolo: DEEPSEEK_RED, + mode_plan: Color::Rgb(180, 83, 9), + status_ready: LIGHT_TEXT_MUTED, + status_working: DEEPSEEK_BLUE, + status_warning: Color::Rgb(180, 83, 9), + text_dim: LIGHT_TEXT_HINT, + text_hint: LIGHT_TEXT_HINT, + text_muted: LIGHT_TEXT_MUTED, + text_body: LIGHT_TEXT_BODY, + text_soft: LIGHT_TEXT_SOFT, + border: LIGHT_BORDER, +}; + +impl UiTheme { + #[must_use] + pub fn for_mode(mode: PaletteMode) -> Self { + match mode { + PaletteMode::Dark => UI_THEME, + PaletteMode::Light => LIGHT_UI_THEME, + } + } + + #[must_use] + pub fn detect() -> Self { + Self::for_mode(PaletteMode::detect()) + } +} + +#[must_use] +pub fn adapt_fg_for_palette_mode(color: Color, _bg: Color, mode: PaletteMode) -> Color { + if mode == PaletteMode::Dark { + return color; + } + + if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White { + LIGHT_TEXT_BODY + } else if color == TEXT_SECONDARY || color == TEXT_MUTED { + LIGHT_TEXT_MUTED + } else if color == TEXT_HINT || color == TEXT_DIM { + LIGHT_TEXT_HINT + } else if color == TEXT_SOFT || color == TEXT_TOOL_OUTPUT { + LIGHT_TEXT_SOFT + } else if color == BORDER_COLOR { + LIGHT_BORDER + } else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE { + DEEPSEEK_BLUE + } else if color == ACCENT_REASONING_LIVE { + Color::Rgb(146, 64, 14) + } else if color == ACCENT_TOOL_ISSUE { + Color::Rgb(159, 18, 57) + } else if color == DIFF_ADDED { + Color::Rgb(22, 101, 52) + } else { + color + } +} + +#[must_use] +pub fn adapt_bg_for_palette_mode(color: Color, mode: PaletteMode) -> Color { + if mode == PaletteMode::Dark { + return color; + } + + if color == DEEPSEEK_INK || color == BACKGROUND_DARK { + LIGHT_SURFACE + } else if color == DEEPSEEK_SLATE + || color == COMPOSER_BG + || color == SURFACE_PANEL + || color == SURFACE_TOOL + { + LIGHT_PANEL + } else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE { + LIGHT_ELEVATED + } else if color == SURFACE_REASONING || color == SURFACE_REASONING_ACTIVE { + LIGHT_REASONING + } else if color == SURFACE_SUCCESS { + LIGHT_SUCCESS + } else if color == SURFACE_ERROR { + LIGHT_ERROR + } else if color == DIFF_ADDED_BG { + LIGHT_SUCCESS + } else if color == DIFF_DELETED_BG { + LIGHT_ERROR + } else if color == SELECTION_BG { + LIGHT_SELECTION_BG + } else { + color + } +} + // === Color depth + brightness helpers (v0.6.6 UI redesign) === /// Terminal color depth, used to gate truecolor surfaces (e.g. reasoning bg @@ -404,11 +611,55 @@ 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, - SURFACE_REASONING, adapt_bg, adapt_color, blend, nearest_ansi16, pulse_brightness, - reasoning_surface_tint, rgb_to_ansi256, + DEEPSEEK_SLATE, LIGHT_PANEL, LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT, + LIGHT_UI_THEME, PaletteMode, SURFACE_REASONING, TEXT_HINT, adapt_bg, + adapt_bg_for_palette_mode, adapt_color, adapt_fg_for_palette_mode, blend, nearest_ansi16, + pulse_brightness, reasoning_surface_tint, rgb_to_ansi256, }; use ratatui::style::Color; + #[test] + fn palette_mode_parses_colorfgbg_background_slot() { + assert_eq!( + PaletteMode::from_colorfgbg("0;15"), + Some(PaletteMode::Light) + ); + assert_eq!(PaletteMode::from_colorfgbg("15;0"), Some(PaletteMode::Dark)); + assert_eq!( + PaletteMode::from_colorfgbg("7;default;15"), + Some(PaletteMode::Light) + ); + assert_eq!(PaletteMode::from_colorfgbg("not-a-color"), None); + } + + #[test] + fn ui_theme_selects_light_variant() { + let theme = super::UiTheme::for_mode(PaletteMode::Light); + assert_eq!(theme, LIGHT_UI_THEME); + assert_eq!(theme.surface_bg, LIGHT_SURFACE); + assert_eq!(theme.text_body, LIGHT_TEXT_BODY); + } + + #[test] + fn light_palette_maps_dark_surfaces_and_text() { + assert_eq!( + adapt_bg_for_palette_mode(DEEPSEEK_INK, PaletteMode::Light), + LIGHT_SURFACE + ); + assert_eq!( + adapt_bg_for_palette_mode(DEEPSEEK_SLATE, PaletteMode::Light), + LIGHT_PANEL + ); + assert_eq!( + adapt_fg_for_palette_mode(Color::White, LIGHT_SURFACE, PaletteMode::Light), + LIGHT_TEXT_BODY + ); + assert_eq!( + adapt_fg_for_palette_mode(TEXT_HINT, LIGHT_SURFACE, PaletteMode::Light), + LIGHT_TEXT_HINT + ); + } + #[test] fn adapt_color_passes_through_truecolor() { let c = Color::Rgb(53, 120, 229); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 79ccfceb..eaf826f4 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1146,7 +1146,7 @@ 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; + let ui_theme = palette::UiTheme::detect(); let model = settings.default_model.clone().unwrap_or(model); let auto_model = model.trim().eq_ignore_ascii_case("auto"); let threshold_model = if auto_model { diff --git a/crates/tui/src/tui/color_compat.rs b/crates/tui/src/tui/color_compat.rs index 7e757613..c2e0ccc0 100644 --- a/crates/tui/src/tui/color_compat.rs +++ b/crates/tui/src/tui/color_compat.rs @@ -14,19 +14,21 @@ use ratatui::{ layout::{Position, Size}, }; -use crate::palette::{self, ColorDepth}; +use crate::palette::{self, ColorDepth, PaletteMode}; #[derive(Debug)] pub(crate) struct ColorCompatBackend { inner: CrosstermBackend, depth: ColorDepth, + palette_mode: PaletteMode, } impl ColorCompatBackend { - pub(crate) fn new(writer: W, depth: ColorDepth) -> Self { + pub(crate) fn new(writer: W, depth: ColorDepth, palette_mode: PaletteMode) -> Self { Self { inner: CrosstermBackend::new(writer), depth, + palette_mode, } } } @@ -49,7 +51,7 @@ impl Backend for ColorCompatBackend { let adapted = content .map(|(x, y, cell)| { let mut cell = cell.clone(); - adapt_cell_colors(&mut cell, self.depth); + adapt_cell_colors(&mut cell, self.depth, self.palette_mode); (x, y, cell) }) .collect::>(); @@ -98,7 +100,10 @@ impl Backend for ColorCompatBackend { } } -fn adapt_cell_colors(cell: &mut Cell, depth: ColorDepth) { +fn adapt_cell_colors(cell: &mut Cell, depth: ColorDepth, palette_mode: PaletteMode) { + 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); cell.fg = palette::adapt_color(cell.fg, depth); cell.bg = palette::adapt_bg(cell.bg, depth); } @@ -132,7 +137,7 @@ 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); + adapt_cell_colors(&mut cell, ColorDepth::Ansi256, PaletteMode::Dark); assert!(matches!(cell.fg, Color::Indexed(_))); assert!(matches!(cell.bg, Color::Indexed(_))); @@ -144,7 +149,7 @@ 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); + adapt_cell_colors(&mut cell, ColorDepth::TrueColor, PaletteMode::Dark); assert_eq!(cell.fg, Color::Rgb(53, 120, 229)); assert_eq!(cell.bg, Color::Rgb(11, 21, 38)); @@ -154,7 +159,7 @@ mod tests { fn ansi256_backend_output_does_not_emit_truecolor_sgr() { let writer = SharedWriter::default(); let capture = writer.0.clone(); - let mut backend = ColorCompatBackend::new(writer, ColorDepth::Ansi256); + let mut backend = ColorCompatBackend::new(writer, ColorDepth::Ansi256, PaletteMode::Dark); let mut cell = Cell::default(); cell.set_symbol("x") .set_fg(Color::Rgb(53, 120, 229)) @@ -166,4 +171,16 @@ mod tests { assert!(!output.contains("38;2;"), "{output:?}"); assert!(!output.contains("48;2;"), "{output:?}"); } + + #[test] + fn light_palette_maps_dark_cells_before_depth_adaptation() { + let mut cell = Cell::default(); + cell.set_fg(Color::White); + cell.set_bg(Color::Rgb(11, 21, 38)); + + adapt_cell_colors(&mut cell, ColorDepth::TrueColor, PaletteMode::Light); + + assert_eq!(cell.fg, palette::LIGHT_TEXT_BODY); + assert_eq!(cell.bg, palette::LIGHT_SURFACE); + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2bcd3b73..f5d936fe 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -233,8 +233,13 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { ); } let color_depth = palette::ColorDepth::detect(); - tracing::debug!(?color_depth, "terminal color depth detected"); - let backend = ColorCompatBackend::new(stdout, color_depth); + let palette_mode = palette::PaletteMode::detect(); + tracing::debug!( + ?color_depth, + ?palette_mode, + "terminal color profile detected" + ); + let backend = ColorCompatBackend::new(stdout, color_depth, palette_mode); let mut terminal = Terminal::new(backend)?; terminal.clear()?; let event_broker = EventBroker::new();