From 628fb3c4a85deb242d00fc757a8a075f3d1483f8 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Thu, 7 May 2026 06:15:58 -0500 Subject: [PATCH] feat(tui): support custom background color (#1034) --- README.md | 2 +- crates/tui/src/commands/config.rs | 17 ++++++++ crates/tui/src/config_ui.rs | 55 ++++++++++++++++++++++++++ crates/tui/src/palette.rs | 65 ++++++++++++++++++++++++++++++- crates/tui/src/settings.rs | 63 +++++++++++++++++++++++++++++- crates/tui/src/tui/app.rs | 9 ++++- crates/tui/src/tui/ui.rs | 10 +++-- crates/tui/src/tui/views/mod.rs | 12 ++++++ crates/tui/src/tui/widgets/mod.rs | 41 ++++++++++++++++--- docs/CONFIGURATION.md | 8 +++- docs/LOCALIZATION.md | 4 +- 11 files changed, 269 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7250ea8d..8ae8bbf0 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,7 @@ Key environment variables: | `NO_ANIMATIONS=1` | Force accessibility mode at startup | | `SSL_CERT_FILE` | Custom CA bundle for corporate proxies | -UI locale is separate from model language — set `locale` in `settings.toml`, use `/config locale zh-Hans`, or rely on `LC_ALL`/`LANG`. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) and [docs/MCP.md](docs/MCP.md). +Set `locale` in `settings.toml`, use `/config locale zh-Hans`, or rely on `LC_ALL`/`LANG` to choose UI chrome and the default natural language sent to V4 models. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) and [docs/MCP.md](docs/MCP.md). --- diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 7d70992f..32612515 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -119,6 +119,10 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { } "approval_mode" | "approval" => Some(app.approval_mode.label().to_string()), "locale" | "language" => Some(locale_display(app.ui_locale).to_string()), + "background_color" | "background" | "bg" => { + crate::palette::hex_rgb_string(app.ui_theme.surface_bg) + .or_else(|| Some("(default)".to_string())) + } "auto_compact" | "compact" => { Some(if app.auto_compact { "true" } else { "false" }.to_string()) } @@ -383,6 +387,15 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.ui_locale = resolve_locale(&settings.locale); app.needs_redraw = true; } + "background_color" | "background" | "bg" => { + let base_theme = crate::palette::UiTheme::detect(); + app.ui_theme = settings + .background_color + .as_deref() + .and_then(crate::palette::parse_hex_rgb_color) + .map_or(base_theme, |color| base_theme.with_background_color(color)); + app.needs_redraw = true; + } "cost_currency" | "currency" => { app.cost_currency = crate::pricing::CostCurrency::from_setting(&settings.cost_currency) .unwrap_or(crate::pricing::CostCurrency::Usd); @@ -443,6 +456,10 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> let display_value = match key.as_str() { "default_mode" | "mode" => settings.default_mode.clone(), "cost_currency" | "currency" => settings.cost_currency.clone(), + "background_color" | "background" | "bg" => settings + .background_color + .clone() + .unwrap_or_else(|| "default".to_string()), _ => value.to_string(), }; diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index acc90d67..bafa7e5d 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -58,6 +58,11 @@ pub struct SettingsSection { pub show_thinking: bool, pub show_tool_details: bool, pub locale: UiLocale, + #[schemars( + title = "Background color", + description = "Main TUI background color as #RRGGBB" + )] + pub background_color: Option, pub composer_density: ComposerDensityValue, pub composer_border: bool, pub transcript_spacing: TranscriptSpacingValue, @@ -268,6 +273,7 @@ pub fn build_document(app: &App, config: &Config) -> Result { show_thinking: settings.show_thinking, show_tool_details: settings.show_tool_details, locale: UiLocale::from_setting(&settings.locale)?, + background_color: settings.background_color.clone(), composer_density: settings.composer_density.as_str().into(), composer_border: settings.composer_border, transcript_spacing: settings.transcript_spacing.as_str().into(), @@ -424,6 +430,13 @@ pub fn apply_document( bool_str(doc.settings.show_tool_details), ), ("locale", doc.settings.locale.as_setting()), + ( + "background_color", + doc.settings + .background_color + .as_deref() + .unwrap_or("default"), + ), ( "composer_density", doc.settings.composer_density.as_setting(), @@ -932,6 +945,48 @@ cost_currency = "cny" } } + #[test] + fn build_document_reflects_background_color_from_settings() { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let temp_root = std::env::temp_dir().join(format!( + "deepseek-config-ui-background-color-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(temp_root.join(".deepseek")).expect("config dir"); + let config_path = temp_root.join(".deepseek").join("config.toml"); + fs::write(&config_path, "").expect("seed config"); + fs::write( + temp_root.join(".deepseek").join("settings.toml"), + r##" +background_color = "#1A1B26" +"##, + ) + .expect("seed settings"); + + let old_config_path = std::env::var_os("DEEPSEEK_CONFIG_PATH"); + unsafe { + std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path); + } + + let app = app(); + let config = Config::default(); + let doc = build_document(&app, &config).expect("document"); + + assert_eq!(doc.settings.background_color.as_deref(), Some("#1a1b26")); + unsafe { + if let Some(value) = old_config_path { + std::env::set_var("DEEPSEEK_CONFIG_PATH", value); + } else { + std::env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + } + } + #[test] fn schema_contains_typed_enums() { let schema = build_schema(); diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index c3724b12..2b30987e 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -287,6 +287,40 @@ impl UiTheme { pub fn detect() -> Self { Self::for_mode(PaletteMode::detect()) } + + #[must_use] + pub fn with_background_color(mut self, color: Color) -> Self { + self.surface_bg = color; + self.header_bg = color; + self.footer_bg = color; + self + } +} + +#[must_use] +pub fn parse_hex_rgb_color(value: &str) -> Option { + let hex = value.trim().strip_prefix('#').unwrap_or(value.trim()); + if hex.len() != 6 || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) { + return None; + } + + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Color::Rgb(r, g, b)) +} + +#[must_use] +pub fn normalize_hex_rgb_color(value: &str) -> Option { + hex_rgb_string(parse_hex_rgb_color(value)?) +} + +#[must_use] +pub fn hex_rgb_string(color: Color) -> Option { + let Color::Rgb(r, g, b) = color else { + return None; + }; + Some(format!("#{r:02x}{g:02x}{b:02x}")) } #[must_use] @@ -612,9 +646,10 @@ mod tests { use super::{ ACCENT_REASONING_LIVE, ColorDepth, DEEPSEEK_INK, DEEPSEEK_RED, DEEPSEEK_SKY, DEEPSEEK_SLATE, LIGHT_PANEL, LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT, - LIGHT_UI_THEME, PaletteMode, SURFACE_REASONING, TEXT_HINT, adapt_bg, + LIGHT_UI_THEME, PaletteMode, SURFACE_REASONING, TEXT_HINT, UI_THEME, 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, + normalize_hex_rgb_color, parse_hex_rgb_color, pulse_brightness, reasoning_surface_tint, + rgb_to_ansi256, }; use ratatui::style::Color; @@ -640,6 +675,32 @@ mod tests { assert_eq!(theme.text_body, LIGHT_TEXT_BODY); } + #[test] + fn ui_theme_applies_custom_background_to_base_surfaces() { + let custom = Color::Rgb(26, 27, 38); + let theme = super::UiTheme::for_mode(PaletteMode::Dark).with_background_color(custom); + + assert_eq!(theme.surface_bg, custom); + assert_eq!(theme.header_bg, custom); + assert_eq!(theme.footer_bg, custom); + assert_eq!( + theme.composer_bg, UI_THEME.composer_bg, + "custom background must not erase panel contrast" + ); + } + + #[test] + fn hex_rgb_color_parser_accepts_hashless_and_normalizes() { + assert_eq!(parse_hex_rgb_color("#1a1B26"), Some(Color::Rgb(26, 27, 38))); + assert_eq!(parse_hex_rgb_color("1a1b26"), Some(Color::Rgb(26, 27, 38))); + assert_eq!( + normalize_hex_rgb_color("#1A1B26").as_deref(), + Some("#1a1b26") + ); + assert_eq!(parse_hex_rgb_color("#123"), None); + assert_eq!(parse_hex_rgb_color("#zzzzzz"), None); + } + #[test] fn light_palette_maps_dark_surfaces_and_text() { assert_eq!( diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index caa46af6..c4d843d8 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::config::{expand_path, normalize_model_name}; use crate::localization::normalize_configured_locale; +use crate::palette::normalize_hex_rgb_color; // ============================================================================ // TuiPrefs — ~/.deepseek/tui.toml @@ -185,6 +186,8 @@ pub struct Settings { pub show_tool_details: bool, /// UI locale: auto, en, ja, zh-Hans, pt-BR pub locale: String, + /// Optional main TUI background color as a 6-digit hex RGB value. + pub background_color: Option, /// Composer layout density: compact, comfortable, spacious pub composer_density: String, /// Show a border around the composer input area @@ -233,6 +236,7 @@ impl Default for Settings { show_thinking: true, show_tool_details: true, locale: "auto".to_string(), + background_color: None, composer_density: "comfortable".to_string(), composer_border: true, composer_vim_mode: "normal".to_string(), @@ -287,6 +291,7 @@ impl Settings { s.locale = normalize_configured_locale(&s.locale) .unwrap_or("en") .to_string(); + s.background_color = normalize_optional_background_color(s.background_color.as_deref()); s.default_model = s.default_model.as_deref().and_then(normalize_default_model); s }; @@ -358,6 +363,9 @@ impl Settings { }; self.locale = locale.to_string(); } + "background_color" | "background" | "bg" => { + self.background_color = normalize_background_color_setting(value)?; + } "composer_density" | "composer" => { let normalized = normalize_composer_density(value); if !["compact", "comfortable", "spacious"].contains(&normalized) { @@ -491,6 +499,10 @@ impl Settings { lines.push(format!(" show_thinking: {}", self.show_thinking)); lines.push(format!(" show_tool_details: {}", self.show_tool_details)); lines.push(format!(" locale: {}", self.locale)); + lines.push(format!( + " background_color: {}", + self.background_color.as_deref().unwrap_or("(default)") + )); lines.push(format!(" composer_density: {}", self.composer_density)); lines.push(format!(" composer_border: {}", self.composer_border)); lines.push(format!(" composer_vim_mode: {}", self.composer_vim_mode)); @@ -542,7 +554,11 @@ impl Settings { ("show_tool_details", "Show detailed tool output: on/off"), ( "locale", - "UI locale: auto, en, ja, zh-Hans, pt-BR (model output is unchanged)", + "UI locale and default model language: auto, en, ja, zh-Hans, pt-BR", + ), + ( + "background_color", + "Main TUI background color: #RRGGBB or default", ), ( "composer_density", @@ -621,6 +637,28 @@ fn normalize_transcript_spacing(value: &str) -> &str { } } +fn normalize_optional_background_color(value: Option<&str>) -> Option { + value.and_then(|raw| normalize_background_color_setting(raw).ok().flatten()) +} + +fn normalize_background_color_setting(value: &str) -> Result> { + let trimmed = value.trim(); + if trimmed.is_empty() + || matches!( + trimmed.to_ascii_lowercase().as_str(), + "default" | "none" | "reset" | "off" + ) + { + return Ok(None); + } + + normalize_hex_rgb_color(trimmed).map(Some).ok_or_else(|| { + anyhow::anyhow!( + "Failed to update setting: invalid background_color '{value}'. Expected #RRGGBB, RRGGBB, or default." + ) + }) +} + fn normalize_sidebar_focus(value: &str) -> &str { match value.trim().to_ascii_lowercase().as_str() { "plan" => "plan", @@ -705,6 +743,29 @@ mod tests { assert!(err.to_string().contains("invalid locale")); } + #[test] + fn background_color_normalizes_hex_and_accepts_default() { + let mut settings = Settings::default(); + settings + .set("background_color", "#1A1b26") + .expect("set custom background"); + assert_eq!(settings.background_color.as_deref(), Some("#1a1b26")); + + settings + .set("background", "default") + .expect("reset custom background"); + assert_eq!(settings.background_color, None); + } + + #[test] + fn background_color_rejects_invalid_hex() { + let mut settings = Settings::default(); + let err = settings + .set("background_color", "#123") + .expect_err("short hex should fail"); + assert!(err.to_string().contains("invalid background_color")); + } + #[test] fn cost_currency_normalizes_yuan_aliases_and_rejects_unknowns() { let mut settings = Settings::default(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index f66b44de..dd699190 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1148,7 +1148,14 @@ 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::UiTheme::detect(); + let mut ui_theme = palette::UiTheme::detect(); + if let Some(background) = settings + .background_color + .as_deref() + .and_then(palette::parse_hex_rgb_color) + { + ui_theme = ui_theme.with_background_color(background); + } 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/ui.rs b/crates/tui/src/tui/ui.rs index 28bc1e67..db9eb28d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -20,7 +20,7 @@ use ratatui::{ Frame, Terminal, layout::{Constraint, Direction, Layout, Rect}, prelude::Widget, - style::{Color, Style}, + style::Style, text::Span, widgets::Block, }; @@ -5107,8 +5107,8 @@ fn build_pending_input_preview(app: &App) -> PendingInputPreview { fn render(f: &mut Frame, app: &mut App) { let size = f.area(); - // Clear entire area with terminal default background - let background = Block::default().style(Style::default().bg(Color::Reset)); + // Clear entire area with the configured app background. + let background = Block::default().style(Style::default().bg(app.ui_theme.surface_bg)); f.render_widget(background, size); // Show onboarding screen if needed @@ -5213,7 +5213,9 @@ fn render(f: &mut Frame, app: &mut App) { // background before any sub-widgets render, so cells that end up // uncovered by layout splits (e.g. after file-tree toggle or // resize) don't retain stale content from a previous frame. - Block::default().render(chunks[1], f.buffer_mut()); + Block::default() + .style(Style::default().bg(app.ui_theme.surface_bg)) + .render(chunks[1], f.buffer_mut()); let mut sidebar_area = None; diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 58baf297..c6058948 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -599,6 +599,16 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Display, + key: "background_color".to_string(), + value: settings + .background_color + .clone() + .unwrap_or_else(|| "(default)".to_string()), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Display, key: "calm_mode".to_string(), @@ -1005,6 +1015,7 @@ fn config_hint_for_key(key: &str) -> &'static str { | "paste_burst_detection" => "on/off, true/false, yes/no, 1/0", "composer_density" | "transcript_spacing" => "compact | comfortable | spacious", "locale" => "auto | en | ja | zh-Hans | pt-BR", + "background_color" => "#RRGGBB | default", "default_mode" => "agent | plan | yolo", "sidebar_width" => "10..=50", "sidebar_focus" => "auto | plan | todos | tasks | agents", @@ -2017,6 +2028,7 @@ mod tests { assert!(keys.contains(&"model")); assert!(keys.contains(&"approval_mode")); assert!(keys.contains(&"locale")); + assert!(keys.contains(&"background_color")); assert!(keys.contains(&"auto_compact")); assert!(keys.contains(&"composer_border")); assert!(keys.contains(&"mcp_config_path")); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index fc15d94a..3dd1633a 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -54,6 +54,7 @@ pub struct ChatWidget { lines: Vec>, scrollbar: Option, jump_to_latest_button: Option, + background: Color, } #[derive(Debug, Clone, Copy)] @@ -66,6 +67,7 @@ struct TranscriptScrollbar { impl ChatWidget { pub fn new(app: &mut App, area: Rect) -> Self { let content_area = area; + let background = app.ui_theme.surface_bg; let visible_lines = content_area.height as usize; let render_options = app.transcript_render_options(); @@ -82,6 +84,7 @@ impl ChatWidget { lines, scrollbar: None, jump_to_latest_button: None, + background, }; } @@ -290,6 +293,7 @@ impl ChatWidget { lines, scrollbar, jump_to_latest_button, + background, } } } @@ -319,11 +323,11 @@ impl Renderable for ChatWidget { // gray on most user setups; an explicit ink fill keeps the chat // area on-brand. Block::default() - .style(Style::default().bg(palette::DEEPSEEK_INK)) + .style(Style::default().bg(self.background)) .render(area, buf); let paragraph = - Paragraph::new(self.lines.clone()).style(Style::default().bg(palette::DEEPSEEK_INK)); + Paragraph::new(self.lines.clone()).style(Style::default().bg(self.background)); paragraph.render(area, buf); if let Some(scrollbar) = self.scrollbar { @@ -342,7 +346,7 @@ impl Renderable for ChatWidget { } if let Some(button_area) = self.jump_to_latest_button { - render_jump_to_latest_button(button_area, buf); + render_jump_to_latest_button(button_area, buf, self.background); } } @@ -374,12 +378,12 @@ fn jump_to_latest_button_rect(area: Rect, has_scrollbar: bool) -> Option { }) } -fn render_jump_to_latest_button(area: Rect, buf: &mut Buffer) { +fn render_jump_to_latest_button(area: Rect, buf: &mut Buffer, background: Color) { Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(palette::BORDER_COLOR)) - .style(Style::default().bg(palette::DEEPSEEK_INK)) + .style(Style::default().bg(background)) .render(area, buf); let arrow_x = area.x.saturating_add(1); @@ -2613,6 +2617,33 @@ mod tests { } } + #[test] + fn chat_widget_uses_configured_surface_background() { + let mut app = create_test_app(); + let custom = ratatui::style::Color::Rgb(26, 27, 38); + app.ui_theme = app.ui_theme.with_background_color(custom); + app.add_message(HistoryCell::Assistant { + content: "ready".to_string(), + streaming: false, + }); + + let area = Rect { + x: 0, + y: 0, + width: 30, + height: 5, + }; + let mut buf = Buffer::empty(area); + let widget = ChatWidget::new(&mut app, area); + widget.render(area, &mut buf); + + assert_eq!(buf[(area.x, area.y)].bg, custom); + assert_eq!( + buf[(area.x + area.width - 1, area.y + area.height - 1)].bg, + custom + ); + } + /// Regression: when the transcript scrollbar is visible, the rightmost /// content column must remain readable (the scrollbar gets its own /// 1-column gutter rather than overdrawing chat content). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5ad3f27f..10660fa9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -287,8 +287,12 @@ Common settings keys: - `show_tool_details` (on/off) - `locale` (`auto`, `en`, `ja`, `zh-Hans`, `pt-BR`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`, `LC_MESSAGES`, then `LANG`; unsupported or - missing locales fall back to English. This does not force model output - language. + missing locales fall back to English. The runtime also exposes the resolved + locale in the system prompt so V4 models use it as the default natural + language for reasoning and replies. +- `background_color` (`#RRGGBB`, `RRGGBB`, or `default`): optional main TUI + background color applied to the root, header, transcript, and footer + surfaces while preserving panel contrast. - `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer, context panel, `/cost`, `/tokens`, and long-turn notification summaries. The aliases `rmb` and `yuan` normalize to `cny`. diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index c3f683a9..9c04a2ad 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -37,7 +37,9 @@ Fallback: - Missing or unsupported configured locales fall back to English. - `auto` falls back to English when no supported environment locale is detected. -- UI locale is separate from model prompt language. Users still ask the model for a response language in the prompt. +- The resolved locale is included in the system prompt and used as the default + natural language for V4 reasoning and replies. Users can still switch + languages mid-session by writing in a different language. ## Planned Global South QA Matrix