feat(tui): support custom background color (#1034)

This commit is contained in:
Hunter Bown
2026-05-07 06:15:58 -05:00
committed by GitHub
parent dc3fef1346
commit 628fb3c4a8
11 changed files with 269 additions and 17 deletions
+1 -1
View File
@@ -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).
---
+17
View File
@@ -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(),
};
+55
View File
@@ -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<String>,
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<ConfigUiDocument> {
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();
+63 -2
View File
@@ -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<Color> {
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<String> {
hex_rgb_string(parse_hex_rgb_color(value)?)
}
#[must_use]
pub fn hex_rgb_string(color: Color) -> Option<String> {
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!(
+62 -1
View File
@@ -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<String>,
/// 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<String> {
value.and_then(|raw| normalize_background_color_setting(raw).ok().flatten())
}
fn normalize_background_color_setting(value: &str) -> Result<Option<String>> {
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();
+8 -1
View File
@@ -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 {
+6 -4
View File
@@ -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;
+12
View File
@@ -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"));
+36 -5
View File
@@ -54,6 +54,7 @@ pub struct ChatWidget {
lines: Vec<Line<'static>>,
scrollbar: Option<TranscriptScrollbar>,
jump_to_latest_button: Option<Rect>,
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<Rect> {
})
}
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).
+6 -2
View File
@@ -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`.
+3 -1
View File
@@ -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