fix(tui): keep light theme readable after toggle
This commit is contained in:
+6
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user