//! Terminal-native palette for minimal mode. //! //! Any RGB theme is designed for one background polarity, so composited on //! the terminal's own canvas it can land dark-on-dark or light-on-light //! (e.g. macOS in Light Mode + a dark terminal profile). Polarity detection //! is not reliable either: OS appearance and OSC 11 both disagree with the //! actual canvas in edge cases and can differ mid-session. Terminal //! profiles, however, tune their **default** fg/bg to be legible against //! their own background — this is how `git` and `ls` stay readable on any //! terminal — so a palette built from `Reset` (body) + sparse named ANSI-16 //! accents is polarity-safe without detection. //! //! ## Grays / secondary text //! //! Do **not** paint body or status text as `DarkGray` (ANSI bright black). //! Many dark profiles deliberately set that slot very dark for subtle //! chrome, which washes out tool stdout and the prompt info bar. Instead: //! //! - **Primary content** (`text_primary`, `gray_bright`, …) → `Color::Reset` //! (terminal default foreground). //! - **Secondary chrome** (`gray`, `gray_dim`, `text_secondary`) → also //! `Color::Reset`; [`Theme::muted`] / [`Theme::dim`] apply `Modifier::DIM` //! so de-emphasis tracks the terminal's own fg (polarity-safe), unlike //! hard-coding bright black. use ratatui::style::{Color, Modifier}; use super::Theme; impl Theme { /// The fixed terminal-native palette used by minimal mode: every field /// is `Color::Reset` or a named ANSI-16 color (see the module docs). pub const fn terminal_default() -> Self { // Secondary roles store Reset; Theme::muted / Theme::dim apply SGR dim. const MUTED: Color = Color::Reset; Self { bg_base: Color::Reset, bg_light: Color::Reset, bg_dark: Color::Reset, bg_highlight: Color::Reset, bg_hover: Color::Reset, bg_terminal: Color::Reset, accent_user: Color::Reset, accent_assistant: Color::Magenta, accent_thinking: MUTED, accent_tool: MUTED, accent_system: Color::Blue, accent_error: Color::Red, accent_success: Color::Green, accent_running: Color::Magenta, accent_skill: Color::Blue, text_primary: Color::Reset, text_secondary: MUTED, gray_dim: MUTED, gray: MUTED, gray_bright: Color::Reset, command: Color::Yellow, path: Color::Cyan, running: Color::Cyan, warning: Color::Yellow, fuzzy_accent: Color::Cyan, accent_plan: Color::Yellow, accent_verify: Color::Magenta, accent_feedback: Color::Cyan, accent_remember: Color::Green, selection_border: MUTED, hover_border: MUTED, prompt_border: MUTED, prompt_border_active: Color::Reset, accent_model: Color::Cyan, scrollbar_bg: Color::Reset, scrollbar_fg: MUTED, diff_delete_bg: Color::Reset, diff_delete_fg: Color::Red, diff_insert_bg: Color::Reset, diff_insert_fg: Color::Green, diff_equal_fg: MUTED, diff_gutter_fg: MUTED, bg_visual: Color::Reset, paste_bg: Color::Reset, paste_fg: MUTED, paste_dim: MUTED, md_heading_h1: Color::Reset, md_heading_h1_mod: Modifier::BOLD.union(Modifier::UNDERLINED), md_heading_h2: Color::Reset, md_heading_h2_mod: Modifier::BOLD, md_heading_h3: Color::Reset, md_heading_h3_mod: Modifier::BOLD, md_heading_h4: Color::Reset, md_heading_h4_mod: Modifier::BOLD, md_heading_h5: Color::Reset, md_heading_h5_mod: Modifier::BOLD, md_heading_h6: MUTED, md_heading_h6_mod: Modifier::BOLD, md_code: Color::Cyan, md_task_checked: Color::Green, md_task_unchecked: MUTED, md_muted: MUTED, md_code_bg: Color::Reset, md_text: Color::Reset, link_fg: Color::Blue, } } } #[cfg(test)] mod tests { use super::*; fn all_colors(theme: &Theme) -> Vec<(&'static str, Color)> { vec![ ("bg_base", theme.bg_base), ("bg_light", theme.bg_light), ("bg_dark", theme.bg_dark), ("bg_highlight", theme.bg_highlight), ("bg_hover", theme.bg_hover), ("bg_terminal", theme.bg_terminal), ("accent_user", theme.accent_user), ("accent_assistant", theme.accent_assistant), ("accent_thinking", theme.accent_thinking), ("accent_tool", theme.accent_tool), ("accent_system", theme.accent_system), ("accent_error", theme.accent_error), ("accent_success", theme.accent_success), ("accent_running", theme.accent_running), ("accent_skill", theme.accent_skill), ("text_primary", theme.text_primary), ("text_secondary", theme.text_secondary), ("gray_dim", theme.gray_dim), ("gray", theme.gray), ("gray_bright", theme.gray_bright), ("command", theme.command), ("path", theme.path), ("running", theme.running), ("warning", theme.warning), ("fuzzy_accent", theme.fuzzy_accent), ("accent_plan", theme.accent_plan), ("accent_verify", theme.accent_verify), ("accent_feedback", theme.accent_feedback), ("accent_remember", theme.accent_remember), ("selection_border", theme.selection_border), ("hover_border", theme.hover_border), ("prompt_border", theme.prompt_border), ("prompt_border_active", theme.prompt_border_active), ("accent_model", theme.accent_model), ("scrollbar_bg", theme.scrollbar_bg), ("scrollbar_fg", theme.scrollbar_fg), ("diff_delete_bg", theme.diff_delete_bg), ("diff_delete_fg", theme.diff_delete_fg), ("diff_insert_bg", theme.diff_insert_bg), ("diff_insert_fg", theme.diff_insert_fg), ("diff_equal_fg", theme.diff_equal_fg), ("diff_gutter_fg", theme.diff_gutter_fg), ("bg_visual", theme.bg_visual), ("paste_bg", theme.paste_bg), ("paste_fg", theme.paste_fg), ("paste_dim", theme.paste_dim), ("md_heading_h1", theme.md_heading_h1), ("md_heading_h2", theme.md_heading_h2), ("md_heading_h3", theme.md_heading_h3), ("md_heading_h4", theme.md_heading_h4), ("md_heading_h5", theme.md_heading_h5), ("md_heading_h6", theme.md_heading_h6), ("md_code", theme.md_code), ("md_task_checked", theme.md_task_checked), ("md_task_unchecked", theme.md_task_unchecked), ("md_muted", theme.md_muted), ("md_code_bg", theme.md_code_bg), ("md_text", theme.md_text), ("link_fg", theme.link_fg), ] } #[test] fn terminal_default_uses_only_reset_and_named_ansi() { let theme = Theme::terminal_default(); for (name, color) in all_colors(&theme) { assert!( !matches!(color, Color::Rgb(..) | Color::Indexed(_)), "{name} must be Reset or a named ANSI color, got {color:?}" ); } } #[test] fn terminal_default_backgrounds_are_transparent() { let theme = Theme::terminal_default(); for (name, color) in [ ("bg_base", theme.bg_base), ("bg_light", theme.bg_light), ("bg_dark", theme.bg_dark), ("bg_terminal", theme.bg_terminal), ("md_code_bg", theme.md_code_bg), ("diff_delete_bg", theme.diff_delete_bg), ("diff_insert_bg", theme.diff_insert_bg), ("paste_bg", theme.paste_bg), ] { assert_eq!(color, Color::Reset, "{name} must defer to the canvas"); } } #[test] fn terminal_default_leaves_cursor_color_alone() { let theme = Theme::terminal_default(); assert_eq!(theme.accent_user, Color::Reset); assert_eq!( crate::render::color::resolve_to_rgb(theme.accent_user), None ); } #[test] fn terminal_default_survives_quantization() { use crate::theme::color_support::ColorLevel; let theme = Theme::terminal_default(); for level in [ ColorLevel::Basic, ColorLevel::Ansi256, ColorLevel::TrueColor, ] { let quantized = theme.quantized(level); for ((name, before), (_, after)) in all_colors(&theme).into_iter().zip(all_colors(&quantized)) { assert_eq!(before, after, "{name} must survive {level:?}"); } } let stripped = theme.quantized(ColorLevel::None); for (name, color) in all_colors(&stripped) { assert_eq!(color, Color::Reset, "{name} must strip under NO_COLOR"); } } #[test] fn terminal_default_primary_is_reset_not_dark_gray() { let theme = Theme::terminal_default(); assert_eq!(theme.text_primary, Color::Reset); assert_eq!(theme.gray, Color::Reset); assert_eq!(theme.gray_dim, Color::Reset); // Must not hard-code bright black for body/secondary roles. assert_ne!(theme.text_primary, Color::DarkGray); assert_ne!(theme.gray, Color::DarkGray); assert_ne!(theme.gray_dim, Color::DarkGray); } #[test] fn terminal_default_muted_and_dim_use_sgr_dim_not_bright_black() { use ratatui::style::Modifier; let theme = Theme::terminal_default(); let muted = theme.muted(); let dim = theme.dim(); assert!( muted.add_modifier.contains(Modifier::DIM), "muted should DIM the terminal default fg: {muted:?}" ); assert!( dim.add_modifier.contains(Modifier::DIM), "dim should DIM the terminal default fg: {dim:?}" ); // No explicit DarkGray paint — dim tracks the host palette. assert!( muted.fg.is_none() || muted.fg == Some(Color::Reset), "muted must not set a hard gray: {muted:?}" ); assert!( dim.fg.is_none() || dim.fg == Some(Color::Reset), "dim must not set a hard gray: {dim:?}" ); } #[test] fn rgb_theme_muted_keeps_explicit_gray_without_forced_dim() { use ratatui::style::Modifier; // KigiNight paints real RGB grays; muted/dim must not invent DIM. let theme = Theme::kiginight(); assert!(!matches!(theme.gray, Color::Reset)); let muted = theme.muted(); assert_eq!(muted.fg, Some(theme.gray)); assert!( !muted.add_modifier.contains(Modifier::DIM), "RGB muted should not force SGR dim: {muted:?}" ); } }