diff --git a/CHANGELOG.md b/CHANGELOG.md index 59ea53f5..af669fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 0374e0bc..ce242d4c 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/crates/tui/src/deepseek_theme.rs b/crates/tui/src/deepseek_theme.rs index 46f4069f..13f96cd0 100644 --- a/crates/tui/src/deepseek_theme.rs +++ b/crates/tui/src/deepseek_theme.rs @@ -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(); diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index d670fcab..981918d6 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -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 { 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); diff --git a/crates/tui/src/tui/color_compat.rs b/crates/tui/src/tui/color_compat.rs index c8d0644f..0bee93d4 100644 --- a/crates/tui/src/tui/color_compat.rs +++ b/crates/tui/src/tui/color_compat.rs @@ -45,6 +45,10 @@ impl ColorCompatBackend { 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 Write for ColorCompatBackend { @@ -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); + } } diff --git a/crates/tui/src/tui/file_tree.rs b/crates/tui/src/tui/file_tree.rs index c15c36d4..69069730 100644 --- a/crates/tui/src/tui/file_tree.rs +++ b/crates/tui/src/tui/file_tree.rs @@ -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( diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 2ac86029..daf28436 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -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> = 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>) { +fn render_sidebar_section( + f: &mut Frame, + area: Rect, + title: &str, + lines: Vec>, + 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 diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index eb5c4d0f..af3024a0 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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`.