fix(tui): keep light theme readable after toggle

This commit is contained in:
Hunter Bown
2026-05-07 16:06:26 -05:00
parent aa9e32bf0e
commit 2eddff473e
8 changed files with 143 additions and 38 deletions
+6 -2
View File
@@ -39,8 +39,9 @@ Feishu/Lark/mobile companion work remain out of scope for this release.
sharing to avoid the `.cargo-ok File exists` unpack race in release checks.
- **Long-session palette is easier to read** (#1070, #936 partial) - default
body text is slightly softer, reasoning/thinking text uses a warmer accent,
and the light-theme adaptation keeps those contrasts coherent. Thanks
@bevis-wong and @oooyuy92 for the readability reports.
and `/theme` now updates the terminal color adapter so light mode keeps those
contrasts coherent after an in-session toggle. Thanks @bevis-wong and
@oooyuy92 for the readability reports.
- **Install docs add a second rustup mirror fallback** (#1011) - `rsproxy.cn`
is documented as an alternate rustup mirror, and old Debian/Ubuntu Cargo
`edition2024` failures now point users to rustup stable. Thanks @wuwuzhijing.
@@ -54,6 +55,9 @@ Feishu/Lark/mobile companion work remain out of scope for this release.
scroll margins/origin mode before key repaints after resume, resize, and turn
completion, preventing alt-screen content from drifting downward and leaving
blank rows at the top.
- **Light theme reasoning blocks stay light** (#1070, #936 partial) -
thinking/reasoning background tints now map to the light reasoning surface
instead of keeping the dark-mode tint after `/theme light`.
- **FreeBSD can compile the secrets crate** (#1089) - platforms without a native
`keyring` dependency now fail the OS-keyring probe cleanly and fall back to
the file-backed secret store instead of referencing a missing crate. Thanks
+2 -2
View File
@@ -247,8 +247,8 @@ A focused follow-up release for TUI/runtime/install polish.
are recoverable at postinstall time, while checksum, platform, glibc, and
runtime failures remain fatal.
- **Plus**: FreeBSD secrets-crate compile fallback, Docker Buildx cache-race
fix, softer long-session text colors, Windows sandbox guarantee cleanup, and
rustup mirror/install troubleshooting updates.
fix, readable light-theme toggles, softer long-session text colors, Windows
sandbox guarantee cleanup, and rustup mirror/install troubleshooting updates.
---
+47 -3
View File
@@ -15,12 +15,14 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{BorderType, Borders, Padding};
use crate::palette;
use crate::palette::PaletteMode;
use crate::tui::history::ToolStatus;
/// Visual variant exposed by the theme.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Variant {
Dark,
Light,
}
/// Centralized visual tokens for sidebar, plan, and tool rendering.
@@ -85,6 +87,40 @@ impl Theme {
}
}
/// Light theme tokens for sidebar and tool chrome.
#[must_use]
pub const fn light() -> Self {
Self {
variant: Variant::Light,
section_borders: Borders::ALL,
section_border_type: BorderType::Plain,
section_border_color: palette::LIGHT_BORDER,
section_bg: palette::LIGHT_PANEL,
section_title_color: palette::DEEPSEEK_BLUE,
section_padding: Padding::horizontal(1),
tool_title_color: palette::LIGHT_TEXT_SOFT,
tool_value_color: palette::LIGHT_TEXT_MUTED,
tool_label_color: palette::LIGHT_TEXT_HINT,
tool_running_accent: palette::DEEPSEEK_BLUE,
tool_success_accent: palette::LIGHT_TEXT_HINT,
tool_failed_accent: palette::DEEPSEEK_RED,
plan_progress_color: palette::DEEPSEEK_BLUE,
plan_summary_color: palette::LIGHT_TEXT_MUTED,
plan_explanation_color: palette::LIGHT_TEXT_HINT,
plan_pending_color: palette::LIGHT_TEXT_MUTED,
plan_in_progress_color: Color::Rgb(180, 83, 9),
plan_completed_color: palette::DEEPSEEK_BLUE,
}
}
#[must_use]
pub const fn for_palette_mode(mode: PaletteMode) -> Self {
match mode {
PaletteMode::Dark => Self::dark(),
PaletteMode::Light => Self::light(),
}
}
/// Pick the right tool accent for a given [`ToolStatus`].
#[must_use]
pub const fn tool_status_color(self, status: ToolStatus) -> Color {
@@ -123,9 +159,6 @@ impl Theme {
}
/// Returns the active theme used by the TUI today.
///
/// Today this is always `Theme::dark()`. A future PR can wire this to an
/// `App` field or a config setting in five lines.
#[must_use]
pub const fn active_theme() -> Theme {
Theme::dark()
@@ -157,6 +190,17 @@ mod tests {
assert_eq!(theme.tool_failed_accent, palette::ACCENT_TOOL_ISSUE);
}
#[test]
fn light_theme_uses_light_panel_tokens() {
let theme = Theme::for_palette_mode(crate::palette::PaletteMode::Light);
assert_eq!(theme.variant, Variant::Light);
assert_eq!(theme.section_bg, palette::LIGHT_PANEL);
assert_eq!(theme.section_border_color, palette::LIGHT_BORDER);
assert_eq!(theme.tool_title_color, palette::LIGHT_TEXT_SOFT);
assert_eq!(theme.tool_value_color, palette::LIGHT_TEXT_MUTED);
assert_eq!(theme.plan_summary_color, palette::LIGHT_TEXT_MUTED);
}
#[test]
fn tool_status_color_maps_each_status() {
let theme = Theme::dark();
+36 -14
View File
@@ -19,10 +19,10 @@ 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_TEXT_MUTED_RGB: (u8, u8, u8) = (51, 65, 85); // #334155
pub const LIGHT_TEXT_HINT_RGB: (u8, u8, u8) = (71, 85, 105); // #475569
pub const LIGHT_TEXT_SOFT_RGB: (u8, u8, u8) = (30, 41, 59); // #1E293B
pub const LIGHT_BORDER_RGB: (u8, u8, u8) = (71, 85, 105); // #475569
pub const LIGHT_SELECTION_RGB: (u8, u8, u8) = (219, 234, 254); // #DBEAFE
// New semantic colors
@@ -136,6 +136,7 @@ pub const SURFACE_PANEL: Color = Color::Rgb(21, 33, 52); // #152134
#[allow(dead_code)]
pub const SURFACE_ELEVATED: Color = Color::Rgb(28, 42, 64); // #1C2A40
pub const SURFACE_REASONING: Color = Color::Rgb(54, 44, 26); // #362C1A
pub const SURFACE_REASONING_TINT: Color = Color::Rgb(16, 24, 37); // #101825
#[allow(dead_code)]
pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(68, 53, 28); // #44351C
#[allow(dead_code)]
@@ -369,7 +370,10 @@ pub fn adapt_bg_for_palette_mode(color: Color, mode: PaletteMode) -> Color {
LIGHT_PANEL
} else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE {
LIGHT_ELEVATED
} else if color == SURFACE_REASONING || color == SURFACE_REASONING_ACTIVE {
} else if color == SURFACE_REASONING
|| color == SURFACE_REASONING_TINT
|| color == SURFACE_REASONING_ACTIVE
{
LIGHT_REASONING
} else if color == SURFACE_SUCCESS {
LIGHT_SUCCESS
@@ -477,6 +481,7 @@ pub fn adapt_bg(color: Color, depth: ColorDepth) -> Color {
/// Mix two RGB colors at `alpha` (0.0 = `bg`, 1.0 = `fg`). Anything that's not
/// RGB falls back to `fg` — there's no meaningful alpha blend on a named
/// palette entry.
#[allow(dead_code)]
#[must_use]
pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color {
let alpha = alpha.clamp(0.0, 1.0);
@@ -501,10 +506,7 @@ pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color {
pub fn reasoning_surface_tint(depth: ColorDepth) -> Option<Color> {
match depth {
ColorDepth::Ansi16 => None,
_ => Some(adapt_bg(
blend(SURFACE_REASONING, DEEPSEEK_INK, 0.12),
depth,
)),
_ => Some(adapt_bg(SURFACE_REASONING_TINT, depth)),
}
}
@@ -646,11 +648,12 @@ 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,
DEEPSEEK_SLATE, LIGHT_PANEL, LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT,
LIGHT_UI_THEME, PaletteMode, SURFACE_REASONING, TEXT_BODY, TEXT_HINT, TEXT_REASONING,
TEXT_TOOL_OUTPUT, UI_THEME, adapt_bg, adapt_bg_for_palette_mode, adapt_color,
adapt_fg_for_palette_mode, blend, nearest_ansi16, normalize_hex_rgb_color,
parse_hex_rgb_color, pulse_brightness, reasoning_surface_tint, rgb_to_ansi256,
DEEPSEEK_SLATE, LIGHT_PANEL, LIGHT_REASONING, LIGHT_SURFACE, LIGHT_TEXT_BODY,
LIGHT_TEXT_HINT, LIGHT_UI_THEME, PaletteMode, SURFACE_REASONING, SURFACE_REASONING_TINT,
TEXT_BODY, TEXT_HINT, TEXT_REASONING, TEXT_TOOL_OUTPUT, UI_THEME, adapt_bg,
adapt_bg_for_palette_mode, adapt_color, adapt_fg_for_palette_mode, blend, nearest_ansi16,
normalize_hex_rgb_color, parse_hex_rgb_color, pulse_brightness, reasoning_surface_tint,
rgb_to_ansi256,
};
use ratatui::style::Color;
@@ -787,6 +790,25 @@ mod tests {
));
}
#[test]
fn light_palette_maps_reasoning_tint_to_light_surface() {
assert_eq!(
blend(SURFACE_REASONING, DEEPSEEK_INK, 0.12),
SURFACE_REASONING_TINT
);
assert_eq!(
adapt_bg_for_palette_mode(SURFACE_REASONING_TINT, PaletteMode::Light),
LIGHT_REASONING
);
assert_eq!(
adapt_bg_for_palette_mode(
reasoning_surface_tint(ColorDepth::TrueColor).expect("truecolor tint"),
PaletteMode::Light,
),
LIGHT_REASONING
);
}
#[test]
fn blend_at_zero_returns_bg_at_one_returns_fg() {
let fg = Color::Rgb(200, 100, 50);
+14
View File
@@ -45,6 +45,10 @@ impl<W: Write> ColorCompatBackend<W> {
pub(crate) fn clear_forced_size(&mut self) {
self.forced_size = None;
}
pub(crate) fn set_palette_mode(&mut self, palette_mode: PaletteMode) {
self.palette_mode = palette_mode;
}
}
impl<W: Write> Write for ColorCompatBackend<W> {
@@ -200,4 +204,14 @@ mod tests {
assert_eq!(cell.fg, palette::LIGHT_TEXT_BODY);
assert_eq!(cell.bg, palette::LIGHT_SURFACE);
}
#[test]
fn backend_palette_mode_can_follow_runtime_theme_changes() {
let writer = SharedWriter::default();
let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
assert_eq!(backend.palette_mode, PaletteMode::Dark);
backend.set_palette_mode(PaletteMode::Light);
assert_eq!(backend.palette_mode, PaletteMode::Light);
}
}
+8 -3
View File
@@ -16,7 +16,7 @@ use ratatui::{
widgets::{Block, Paragraph, Wrap},
};
use crate::deepseek_theme::active_theme;
use crate::deepseek_theme::Theme;
use crate::palette;
use crate::tui::ui::truncate_line_to_width;
@@ -287,7 +287,12 @@ const FILE_TREE_MIN_WIDTH: u16 = 20;
/// Render the file tree inside `area`.
/// Polls async loading state before rendering (#399 S3).
pub fn render_file_tree(f: &mut Frame, area: Rect, state: &mut FileTreeState) {
pub fn render_file_tree(
f: &mut Frame,
area: Rect,
state: &mut FileTreeState,
mode: palette::PaletteMode,
) {
state.poll_loading();
if area.width < FILE_TREE_MIN_WIDTH || area.height < 3 {
return;
@@ -351,7 +356,7 @@ pub fn render_file_tree(f: &mut Frame, area: Rect, state: &mut FileTreeState) {
}
// Use the same theme as the sidebar for consistent styling.
let theme = active_theme();
let theme = Theme::for_palette_mode(mode);
let section = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
Block::default()
.title(Line::from(Span::styled(
+21 -11
View File
@@ -15,7 +15,7 @@ use ratatui::{
widgets::{Block, Paragraph, Wrap},
};
use crate::deepseek_theme::active_theme;
use crate::deepseek_theme::Theme;
use crate::palette;
use crate::tools::plan::StepStatus;
use crate::tools::subagent::SubAgentStatus;
@@ -30,7 +30,9 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, app: &App) {
if area.width < 24 || area.height < 8 {
// Paint a styled block over the area so stale cells from a previous
// (wider) frame don't persist as bleed-through artifacts (#400).
Block::default().render(area, f.buffer_mut());
Block::default()
.style(Style::default().bg(app.ui_theme.surface_bg))
.render(area, f.buffer_mut());
return;
}
@@ -144,7 +146,7 @@ fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) {
return;
}
let theme = active_theme();
let theme = Theme::for_palette_mode(app.ui_theme.mode);
let content_width = area.width.saturating_sub(4) as usize;
let mut lines: Vec<Line<'static>> = Vec::with_capacity(usize::from(area.height).max(4));
@@ -274,7 +276,7 @@ fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) {
}
}
render_sidebar_section(f, area, "Plan", lines);
render_sidebar_section(f, area, "Plan", lines, app);
}
/// One-line hint shown when the Plan section has nothing to display
@@ -354,7 +356,7 @@ fn render_sidebar_todos(f: &mut Frame, area: Rect, app: &App) {
}
}
render_sidebar_section(f, area, "Todos", lines);
render_sidebar_section(f, area, "Todos", lines, app);
}
fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &App) {
@@ -448,7 +450,7 @@ fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &App) {
}
}
render_sidebar_section(f, area, "Tasks", lines);
render_sidebar_section(f, area, "Tasks", lines, app);
}
fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) {
@@ -501,7 +503,7 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) {
};
let lines = subagent_navigator_lines(&summary, content_width);
render_sidebar_section(f, area, "Agents", lines);
render_sidebar_section(f, area, "Agents", lines, app);
}
/// Minimal projection of the data the sub-agent sidebar needs. Lifted out
@@ -737,17 +739,25 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &App) {
)));
}
render_sidebar_section(f, area, "Session", lines);
render_sidebar_section(f, area, "Session", lines, app);
}
fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec<Line<'static>>) {
fn render_sidebar_section(
f: &mut Frame,
area: Rect,
title: &str,
lines: Vec<Line<'static>>,
app: &App,
) {
if area.width < 4 || area.height < 3 {
// Clear stale cells before bailing out (#400).
Block::default().render(area, f.buffer_mut());
Block::default()
.style(Style::default().bg(app.ui_theme.surface_bg))
.render(area, f.buffer_mut());
return;
}
let theme = active_theme();
let theme = Theme::for_palette_mode(app.ui_theme.mode);
// Truncate the panel title so it always fits within the section width
// even after a resize. The title occupies up to 4 chars of border chrome
// (two spaces + one space on each side), so the max title length is
+9 -3
View File
@@ -1519,7 +1519,7 @@ async fn run_event_loop(
reset_terminal_viewport(terminal)?;
force_terminal_repaint = false;
}
terminal.draw(|f| render(f, app))?; // app is &mut
draw_app_frame(terminal, app)?;
frame_rate_limiter.mark_emitted(Instant::now());
app.needs_redraw = false;
}
@@ -1658,7 +1658,7 @@ async fn run_event_loop(
// any other events can interleave. Without this, the next
// iteration's draw can race against fast follow-up input and
// leave the user staring at a blank/partial frame.
terminal.draw(|f| render(f, app))?;
draw_app_frame(terminal, app)?;
{
let backend = terminal.backend_mut();
backend.clear_forced_size();
@@ -5297,7 +5297,7 @@ fn render(f: &mut Frame, app: &mut App) {
// Render the file-tree pane.
if let Some(ref mut state) = app.file_tree {
super::file_tree::render_file_tree(f, tree_area, state);
super::file_tree::render_file_tree(f, tree_area, state, app.ui_theme.mode);
}
remaining
@@ -5372,6 +5372,12 @@ fn render(f: &mut Frame, app: &mut App) {
}
}
fn draw_app_frame(terminal: &mut AppTerminal, app: &mut App) -> Result<()> {
terminal.backend_mut().set_palette_mode(app.ui_theme.mode);
terminal.draw(|f| render(f, app))?;
Ok(())
}
/// Pull the latest snapshot of cells / revisions / render options into the
/// live transcript overlay sitting on top of the view stack. No-op if the
/// top view isn't a `LiveTranscriptOverlay`.