fix(theme): auto-adapt palette for light terminals (#931)

Closes #899.

Detects light terminal profiles from COLORFGBG, keeps the existing dark theme as the fallback, and maps DeepSeek dark-palette cells to readable light surfaces in the existing ColorCompatBackend.

Local verification:
- cargo fmt --all -- --check
- cargo test -p deepseek-tui palette --all-features
- cargo test -p deepseek-tui color_compat --all-features
- cargo build

CI: all required checks passed on #931.
This commit is contained in:
Hunter Bown
2026-05-06 20:55:30 -05:00
committed by GitHub
parent ebcffaadf9
commit 0ee4c81ac0
4 changed files with 285 additions and 12 deletions
+253 -2
View File
@@ -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<Self> {
let bg = value
.split(';')
.rev()
.find_map(|part| part.parse::<u16>().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);
+1 -1
View File
@@ -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 {
+24 -7
View File
@@ -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<W: Write> {
inner: CrosstermBackend<W>,
depth: ColorDepth,
palette_mode: PaletteMode,
}
impl<W: Write> ColorCompatBackend<W> {
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<W: Write> Backend for ColorCompatBackend<W> {
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::<Vec<_>>();
@@ -98,7 +100,10 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
}
}
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);
}
}
+7 -2
View File
@@ -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();