Merge remote-tracking branch 'origin/pr/1534' into work/v0.8.34

# Conflicts:
#	crates/tui/src/commands/config.rs
#	crates/tui/src/commands/mod.rs
#	crates/tui/src/palette.rs
#	crates/tui/src/settings.rs
#	crates/tui/src/tui/app.rs
#	crates/tui/src/tui/color_compat.rs
#	crates/tui/src/tui/widgets/footer.rs
This commit is contained in:
Hunter Bown
2026-05-12 23:58:39 -05:00
15 changed files with 977 additions and 68 deletions
+3 -2
View File
@@ -264,8 +264,9 @@ cleaner "Work" tab. [Full changelog](CHANGELOG.md).
mouse filter drops inert move events but allows transcript and
scrollbar drags to continue — the known issue from v0.8.32 is
resolved.
- **Grayscale theme.** Use `/theme grayscale` for a quiet black/white
palette, or `/set theme grayscale --save` to make it the saved default.
- **Theme presets.** Use `/theme` for a live picker, or `/theme grayscale`,
`/theme catppuccin-mocha`, `/theme tokyo-night`, `/theme dracula`, and
`/theme gruvbox-dark` to save a theme directly.
- **Session history picker.** `/sessions` and `Ctrl+R` now put full
session history on the left, the session list on the right, number keys
`1`-`9` open visible histories, and `PgUp` / `PgDn` scroll history.
+3 -2
View File
@@ -262,8 +262,9 @@ RLM 工作,`agent_open` / `agent_eval` / `agent_close` 用于命名子
- **流式输出期间文本选择正常工作。** 加载状态的鼠标过滤器丢弃
无关移动事件,但允许对话记录和滚动条拖动继续——
v0.8.32 的已知问题已解决。
- **灰度主题。** 使用 `/theme grayscale` 可切换到更克制的黑白
调色板;使用 `/set theme grayscale --save` 可保存为默认主题。
- **主题预设。** 使用 `/theme` 打开实时预览选择器,或通过
`/theme grayscale``/theme catppuccin-mocha``/theme tokyo-night`
`/theme dracula``/theme gruvbox-dark` 直接保存主题。
- **会话历史选择器。** `/sessions``Ctrl+R` 现在左侧显示完整
会话历史,右侧显示会话列表;按 `1`-`9` 打开可见会话历史,
`PgUp` / `PgDn` 翻页查看历史。
+11 -27
View File
@@ -424,6 +424,8 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.needs_redraw = true;
}
"theme" | "ui_theme" | "background_color" | "background" | "bg" => {
app.theme_id = crate::palette::ThemeId::from_name(&settings.theme)
.unwrap_or(crate::palette::ThemeId::System);
app.ui_theme = crate::palette::ui_theme_from_settings(
&settings.theme,
settings.background_color.as_deref(),
@@ -584,33 +586,14 @@ fn mode_display_name(mode: AppMode) -> &'static str {
}
}
/// Switch the runtime theme. `/set theme <value> --save` persists it.
/// `/theme [name]` — with no argument, open the interactive picker (arrow
/// keys, live preview, Enter to persist, Esc to revert). With an argument,
/// route through `set_config_value("theme", ...)` so the apply + save flow is
/// shared with `/config`.
pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult {
let requested = match arg.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => {
let Some(theme) = crate::palette::normalize_theme_name(value) else {
return CommandResult::error("Usage: /theme [dark|light|grayscale|system]");
};
theme
}
None => match app.ui_theme.mode {
crate::palette::PaletteMode::Dark => "light",
crate::palette::PaletteMode::Light => "grayscale",
crate::palette::PaletteMode::Grayscale => "dark",
},
};
let background = Settings::load()
.ok()
.and_then(|settings| settings.background_color);
app.ui_theme = crate::palette::ui_theme_from_settings(requested, background.as_deref());
app.needs_redraw = true;
let label = crate::palette::theme_label_for_mode(app.ui_theme.mode);
if requested == "system" {
CommandResult::message(format!("Theme switched to system ({label})."))
} else {
CommandResult::message(format!("Theme switched to {label}."))
match arg.map(str::trim).filter(|s| !s.is_empty()) {
None => CommandResult::action(AppAction::OpenThemePicker),
Some(name) => set_config_value(app, "theme", name, true),
}
}
@@ -1602,7 +1585,8 @@ mod tests {
let mut app = create_test_app();
let result = theme(&mut app, Some("grayscale"));
assert_eq!(result.message.unwrap(), "Theme switched to grayscale.");
assert_eq!(result.message.unwrap(), "theme = grayscale (saved)");
assert_eq!(app.theme_id, crate::palette::ThemeId::Grayscale);
assert_eq!(app.ui_theme.mode, crate::palette::PaletteMode::Grayscale);
assert!(app.needs_redraw);
}
+1 -1
View File
@@ -355,7 +355,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "theme",
aliases: &[],
usage: "/theme [dark|light|grayscale|system]",
usage: "/theme [name]",
description_id: MessageId::CmdThemeDescription,
},
CommandInfo {
+1 -1
View File
@@ -947,7 +947,7 @@ fn english(id: MessageId) -> &'static str {
MessageId::CmdModelsDescription => "List available models from API",
MessageId::CmdNetworkDescription => "Manage network allow and deny rules",
MessageId::CmdNoteDescription => "Add, list, edit, or remove workspace notes",
MessageId::CmdThemeDescription => "Switch theme: dark, light, grayscale, or system",
MessageId::CmdThemeDescription => "Switch theme or open the theme picker",
MessageId::CmdProviderDescription => {
"Switch or view the active LLM backend (deepseek | nvidia-nim | ollama)"
}
+372 -8
View File
@@ -375,6 +375,213 @@ pub const GRAYSCALE_UI_THEME: UiTheme = UiTheme {
border: GRAYSCALE_BORDER,
};
pub const CATPPUCCIN_MOCHA_UI_THEME: UiTheme = UiTheme {
name: "catppuccin-mocha",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x1e, 0x1e, 0x2e), // base
panel_bg: Color::Rgb(0x18, 0x18, 0x25), // mantle
elevated_bg: Color::Rgb(0x31, 0x32, 0x44), // surface0
composer_bg: Color::Rgb(0x18, 0x18, 0x25),
selection_bg: Color::Rgb(0x45, 0x47, 0x5a), // surface1
header_bg: Color::Rgb(0x11, 0x11, 0x1b), // crust
footer_bg: Color::Rgb(0x11, 0x11, 0x1b),
mode_agent: Color::Rgb(0x89, 0xb4, 0xfa), // blue
mode_yolo: Color::Rgb(0xf3, 0x8b, 0xa8), // red
mode_plan: Color::Rgb(0xfa, 0xb3, 0x87), // peach
status_ready: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
status_working: Color::Rgb(0x74, 0xc7, 0xec), // sapphire
status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow
text_dim: Color::Rgb(0x6c, 0x70, 0x86), // overlay0
text_hint: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
text_muted: Color::Rgb(0xa6, 0xad, 0xc8), // subtext0
text_body: Color::Rgb(0xcd, 0xd6, 0xf4), // text
text_soft: Color::Rgb(0xba, 0xc2, 0xde), // subtext1
border: Color::Rgb(0x45, 0x47, 0x5a), // surface1
};
pub const TOKYO_NIGHT_UI_THEME: UiTheme = UiTheme {
name: "tokyo-night",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x1a, 0x1b, 0x26), // bg
panel_bg: Color::Rgb(0x16, 0x16, 0x1e), // bg_dark
elevated_bg: Color::Rgb(0x29, 0x2e, 0x42), // bg_highlight
composer_bg: Color::Rgb(0x16, 0x16, 0x1e),
selection_bg: Color::Rgb(0x28, 0x34, 0x57), // visual selection
header_bg: Color::Rgb(0x16, 0x16, 0x1e),
footer_bg: Color::Rgb(0x16, 0x16, 0x1e),
mode_agent: Color::Rgb(0x7a, 0xa2, 0xf7), // blue
mode_yolo: Color::Rgb(0xf7, 0x76, 0x8e), // red
mode_plan: Color::Rgb(0xff, 0x9e, 0x64), // orange
status_ready: Color::Rgb(0x56, 0x5f, 0x89), // comment
status_working: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
status_warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow
text_dim: Color::Rgb(0x56, 0x5f, 0x89), // comment
text_hint: Color::Rgb(0x73, 0x7a, 0xa2), // dark5
text_muted: Color::Rgb(0xa9, 0xb1, 0xd6), // fg_dark
text_body: Color::Rgb(0xc0, 0xca, 0xf5), // fg
text_soft: Color::Rgb(0xbb, 0xc2, 0xe0),
border: Color::Rgb(0x41, 0x48, 0x68), // terminal_black
};
pub const DRACULA_UI_THEME: UiTheme = UiTheme {
name: "dracula",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x28, 0x2a, 0x36), // background
panel_bg: Color::Rgb(0x21, 0x22, 0x2c),
elevated_bg: Color::Rgb(0x34, 0x37, 0x46),
composer_bg: Color::Rgb(0x21, 0x22, 0x2c),
selection_bg: Color::Rgb(0x44, 0x47, 0x5a), // current line
header_bg: Color::Rgb(0x21, 0x22, 0x2c),
footer_bg: Color::Rgb(0x21, 0x22, 0x2c),
mode_agent: Color::Rgb(0xbd, 0x93, 0xf9), // purple
mode_yolo: Color::Rgb(0xff, 0x55, 0x55), // red
mode_plan: Color::Rgb(0xff, 0xb8, 0x6c), // orange
status_ready: Color::Rgb(0x62, 0x72, 0xa4), // comment
status_working: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow
text_dim: Color::Rgb(0x62, 0x72, 0xa4),
text_hint: Color::Rgb(0x8a, 0x8e, 0xaa),
text_muted: Color::Rgb(0xc0, 0xc4, 0xd6),
text_body: Color::Rgb(0xf8, 0xf8, 0xf2), // foreground
text_soft: Color::Rgb(0xe2, 0xe2, 0xdc),
border: Color::Rgb(0x44, 0x47, 0x5a),
};
pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme {
name: "gruvbox-dark",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x28, 0x28, 0x28), // bg0
panel_bg: Color::Rgb(0x3c, 0x38, 0x36), // bg1
elevated_bg: Color::Rgb(0x50, 0x49, 0x45), // bg2
composer_bg: Color::Rgb(0x3c, 0x38, 0x36),
selection_bg: Color::Rgb(0x66, 0x5c, 0x54), // bg3
header_bg: Color::Rgb(0x1d, 0x20, 0x21), // bg0_h
footer_bg: Color::Rgb(0x1d, 0x20, 0x21),
mode_agent: Color::Rgb(0x83, 0xa5, 0x98), // blue
mode_yolo: Color::Rgb(0xfb, 0x49, 0x34), // red
mode_plan: Color::Rgb(0xfe, 0x80, 0x19), // orange
status_ready: Color::Rgb(0x92, 0x83, 0x74), // gray
status_working: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua
status_warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow
text_dim: Color::Rgb(0x92, 0x83, 0x74), // gray
text_hint: Color::Rgb(0xa8, 0x99, 0x84), // fg4
text_muted: Color::Rgb(0xbd, 0xae, 0x93), // fg3
text_body: Color::Rgb(0xeb, 0xdb, 0xb2), // fg1
text_soft: Color::Rgb(0xd5, 0xc4, 0xa1), // fg2
border: Color::Rgb(0x66, 0x5c, 0x54), // bg3
};
/// Stable identifiers for the named themes the user can select. `System`
/// defers to `PaletteMode::detect()` (terminal-driven dark/light). Each
/// dark/light id resolves to a single fixed `UiTheme`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeId {
System,
Whale,
WhaleLight,
Grayscale,
CatppuccinMocha,
TokyoNight,
Dracula,
GruvboxDark,
}
impl ThemeId {
/// Parse a settings string (`"system"`, `"dark"`, `"catppuccin-mocha"`, …).
/// Accepts a few aliases (`"whale"` for dark, `"light"` for whale-light)
/// so existing config files keep working. Case-insensitive.
#[must_use]
pub fn from_name(value: &str) -> Option<Self> {
match normalize_theme_name(value)? {
"system" => Some(Self::System),
"dark" => Some(Self::Whale),
"light" => Some(Self::WhaleLight),
"grayscale" => Some(Self::Grayscale),
"catppuccin-mocha" => Some(Self::CatppuccinMocha),
"tokyo-night" => Some(Self::TokyoNight),
"dracula" => Some(Self::Dracula),
"gruvbox-dark" => Some(Self::GruvboxDark),
_ => None,
}
}
/// Canonical settings string (lowercase, dash-separated). Round-trips
/// through `from_name`.
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::System => "system",
Self::Whale => "dark",
Self::WhaleLight => "light",
Self::Grayscale => "grayscale",
Self::CatppuccinMocha => "catppuccin-mocha",
Self::TokyoNight => "tokyo-night",
Self::Dracula => "dracula",
Self::GruvboxDark => "gruvbox-dark",
}
}
/// Human-readable label for picker rows.
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::System => "System",
Self::Whale => "Whale (Dark)",
Self::WhaleLight => "Whale Light",
Self::Grayscale => "Grayscale",
Self::CatppuccinMocha => "Catppuccin Mocha",
Self::TokyoNight => "Tokyo Night",
Self::Dracula => "Dracula",
Self::GruvboxDark => "Gruvbox Dark",
}
}
/// Short tagline for picker rows.
#[must_use]
pub const fn tagline(self) -> &'static str {
match self {
Self::System => "Follow terminal background (COLORFGBG)",
Self::Whale => "Default DeepSeek dark blue",
Self::WhaleLight => "DeepSeek light, paper-ish",
Self::Grayscale => "Color-minimal high contrast",
Self::CatppuccinMocha => "Soft pastels on warm dark",
Self::TokyoNight => "Deep blue/violet night palette",
Self::Dracula => "Classic high-contrast purple",
Self::GruvboxDark => "Vintage warm earth tones",
}
}
/// Resolve to a concrete `UiTheme`. For `System` this consults
/// `PaletteMode::detect()` exactly once and returns the corresponding
/// dark/light theme — callers that want to live-track terminal background
/// changes need to re-invoke this.
#[must_use]
pub fn ui_theme(self) -> UiTheme {
match self {
Self::System => UiTheme::detect(),
Self::Whale => UI_THEME,
Self::WhaleLight => LIGHT_UI_THEME,
Self::Grayscale => GRAYSCALE_UI_THEME,
Self::CatppuccinMocha => CATPPUCCIN_MOCHA_UI_THEME,
Self::TokyoNight => TOKYO_NIGHT_UI_THEME,
Self::Dracula => DRACULA_UI_THEME,
Self::GruvboxDark => GRUVBOX_DARK_UI_THEME,
}
}
}
/// Themes shown in the `/theme` picker, in display order.
pub const SELECTABLE_THEMES: &[ThemeId] = &[
ThemeId::System,
ThemeId::Whale,
ThemeId::WhaleLight,
ThemeId::Grayscale,
ThemeId::CatppuccinMocha,
ThemeId::TokyoNight,
ThemeId::Dracula,
ThemeId::GruvboxDark,
];
impl UiTheme {
#[must_use]
pub fn for_mode(mode: PaletteMode) -> Self {
@@ -392,13 +599,7 @@ impl UiTheme {
#[must_use]
pub fn from_setting(value: &str) -> Option<Self> {
match normalize_theme_name(value)? {
"system" => Some(Self::detect()),
"dark" => Some(Self::for_mode(PaletteMode::Dark)),
"light" => Some(Self::for_mode(PaletteMode::Light)),
"grayscale" => Some(Self::for_mode(PaletteMode::Grayscale)),
_ => None,
}
ThemeId::from_name(value).map(ThemeId::ui_theme)
}
#[must_use]
@@ -418,6 +619,10 @@ pub fn normalize_theme_name(value: &str) -> Option<&'static str> {
"light" | "whale-light" => Some("light"),
"grayscale" | "greyscale" | "gray" | "grey" | "mono" | "monochrome" | "black-white"
| "black_and_white" | "blackwhite" | "bw" | "b&w" => Some("grayscale"),
"catppuccin-mocha" | "catppuccin" | "mocha" => Some("catppuccin-mocha"),
"tokyo-night" | "tokyonight" | "tokyo" => Some("tokyo-night"),
"dracula" => Some("dracula"),
"gruvbox-dark" | "gruvbox" => Some("gruvbox-dark"),
_ => None,
}
}
@@ -541,6 +746,166 @@ fn adapt_bg_for_light_palette(color: Color) -> Color {
}
}
// === Community-theme remap ===
//
// The vast majority of render sites in this crate reach for `palette::TEXT_*`,
// `palette::DEEPSEEK_INK`, `palette::BORDER_COLOR`, etc. directly rather than
// looking up `app.ui_theme`. To make community theme presets (Catppuccin,
// Tokyo Night, …) actually move the needle visually we intercept colors at
// the backend layer (see `tui::color_compat::ColorCompatBackend`) and remap
// every well-known dark-palette constant to the equivalent UiTheme slot for
// the active preset. For `System`, `Whale`, and `WhaleLight` the remap is a
// no-op — the existing dark/light pipeline handles those.
/// Per-preset green accent used for things that semantically *should* stay
/// green even after theming (diff "+" lines, user-input body). Mapping these
/// to `ui.status_working` would lose the green/cyan distinction the UI
/// relies on, so we keep a small dedicated table.
#[must_use]
const fn theme_green(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0xa6, 0xe3, 0xa1),
ThemeId::TokyoNight => Color::Rgb(0x9e, 0xce, 0x6a),
ThemeId::Dracula => Color::Rgb(0x50, 0xfa, 0x7b),
ThemeId::GruvboxDark => Color::Rgb(0xb8, 0xbb, 0x26),
_ => USER_BODY,
}
}
/// Per-preset red accent, used for diff "" line foreground when present.
#[must_use]
const fn theme_red(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0xf3, 0x8b, 0xa8),
ThemeId::TokyoNight => Color::Rgb(0xf7, 0x76, 0x8e),
ThemeId::Dracula => Color::Rgb(0xff, 0x55, 0x55),
ThemeId::GruvboxDark => Color::Rgb(0xfb, 0x49, 0x34),
_ => DEEPSEEK_RED,
}
}
/// Per-preset dark-green diff-added background tint.
#[must_use]
const fn theme_diff_added_bg(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0x1f, 0x33, 0x29),
ThemeId::TokyoNight => Color::Rgb(0x1b, 0x2b, 0x1f),
ThemeId::Dracula => Color::Rgb(0x21, 0x3a, 0x2a),
ThemeId::GruvboxDark => Color::Rgb(0x29, 0x32, 0x16),
_ => DIFF_ADDED_BG,
}
}
/// Per-preset dark-red diff-deleted background tint.
#[must_use]
const fn theme_diff_deleted_bg(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0x3a, 0x1f, 0x2a),
ThemeId::TokyoNight => Color::Rgb(0x33, 0x1c, 0x24),
ThemeId::Dracula => Color::Rgb(0x3a, 0x1f, 0x22),
ThemeId::GruvboxDark => Color::Rgb(0x35, 0x1c, 0x18),
_ => DIFF_DELETED_BG,
}
}
/// Returns `true` if the preset participates in the cell-level remap. The
/// default Whale and System themes pass through unchanged so this whole
/// stage compiles down to a single load+compare on the hot path.
#[inline]
#[must_use]
pub const fn theme_remap_active(theme: ThemeId) -> bool {
matches!(
theme,
ThemeId::CatppuccinMocha | ThemeId::TokyoNight | ThemeId::Dracula | ThemeId::GruvboxDark
)
}
/// Remap a foreground color for a community theme preset. Mirrors the
/// structure of [`adapt_fg_for_palette_mode`] — same source set, different
/// destinations sourced from the preset's [`UiTheme`].
///
/// The `ui` argument is the *active* UiTheme as carried on `App` —
/// `ThemeId.ui_theme()` with the user's `background_color` override
/// already applied. Passing it through (rather than re-resolving from
/// `theme` inside this function) preserves that override; otherwise a
/// user combining `background_color = "#..."` with a community theme
/// would see their override silently overwritten by the preset's
/// surface_bg on every cell remap.
#[must_use]
pub fn adapt_fg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color {
if !theme_remap_active(theme) {
return color;
}
if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White {
ui.text_body
} else if color == TEXT_SECONDARY || color == TEXT_MUTED {
ui.text_muted
} else if color == TEXT_HINT || color == TEXT_DIM {
ui.text_hint
} else if color == TEXT_SOFT || color == TEXT_TOOL_OUTPUT {
ui.text_soft
} else if color == BORDER_COLOR {
ui.border
} else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE {
ui.status_working
} else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE {
ui.mode_plan
} else if color == ACCENT_TOOL_ISSUE {
ui.mode_yolo
} else if color == STATUS_WARNING {
ui.status_warning
} else if color == DEEPSEEK_RED {
theme_red(theme)
} else if color == DIFF_ADDED || color == USER_BODY {
theme_green(theme)
} else if color == DEEPSEEK_BLUE {
// The default mode_agent accent — keep it in the preset's blue family.
ui.mode_agent
} else {
color
}
}
/// Remap a background color for a community theme preset. See the
/// `ui` note on [`adapt_fg_for_theme`] — same contract here.
#[must_use]
pub fn adapt_bg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color {
if !theme_remap_active(theme) {
return color;
}
if color == DEEPSEEK_INK || color == BACKGROUND_DARK {
ui.surface_bg
} else if color == DEEPSEEK_SLATE
|| color == COMPOSER_BG
|| color == SURFACE_PANEL
|| color == SURFACE_TOOL
{
ui.panel_bg
} else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE {
ui.elevated_bg
} else if color == SURFACE_REASONING
|| color == SURFACE_REASONING_TINT
|| color == SURFACE_REASONING_ACTIVE
|| color == SURFACE_SUCCESS
|| color == SURFACE_ERROR
{
// Reasoning/success/error backgrounds are subtle tints that don't have
// a dedicated theme slot. Collapse them onto the panel surface so they
// read as recessed rather than a stray default-blue tint.
ui.panel_bg
} else if color == SELECTION_BG {
ui.selection_bg
} else if color == DIFF_ADDED_BG {
theme_diff_added_bg(theme)
} else if color == DIFF_DELETED_BG {
theme_diff_deleted_bg(theme)
} else {
color
}
}
fn adapt_fg_for_grayscale_palette(color: Color) -> Color {
if color == Color::Reset {
return color;
@@ -684,7 +1049,6 @@ fn grayscale_bg_from_luma(luma: u8) -> Color {
fn luma(r: u8, g: u8, b: u8) -> u8 {
(((u16::from(r) * 299) + (u16::from(g) * 587) + (u16::from(b) * 114)) / 1000) as u8
}
// === Color depth + brightness helpers (v0.6.6 UI redesign) ===
/// Terminal color depth, used to gate truecolor surfaces (e.g. reasoning bg
+42 -13
View File
@@ -28,7 +28,7 @@ use crate::palette::{normalize_hex_rgb_color, normalize_theme_name};
/// # Example `~/.deepseek/tui.toml`
///
/// ```toml
/// theme = "dark" # "system" | "dark" | "light" | "grayscale"
/// theme = "dark" # "system" | "dark" | "light" | "grayscale" | "catppuccin-mocha" | ...
/// font_size = 14
///
/// [keybinds]
@@ -43,7 +43,7 @@ use crate::palette::{normalize_hex_rgb_color, normalize_theme_name};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TuiPrefs {
/// UI colour theme: `"dark"` | `"light"` | `"grayscale"` | `"system"`.
/// UI colour theme.
/// Default `"dark"`.
pub theme: String,
/// Terminal font size hint forwarded to supporting front-ends (e.g. the
@@ -152,7 +152,7 @@ impl TuiPrefs {
let theme = self.theme.trim().to_ascii_lowercase();
let Some(theme) = normalize_theme_name(&theme) else {
anyhow::bail!(
"Invalid tui.toml theme '{}': expected system, dark, light, or grayscale.",
"Invalid tui.toml theme '{}': expected system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, or gruvbox-dark.",
self.theme
);
};
@@ -195,7 +195,11 @@ pub struct Settings {
pub show_tool_details: bool,
/// UI locale: auto, en, ja, zh-Hans, pt-BR, es-419
pub locale: String,
/// UI theme: system, dark, light, grayscale
/// Named UI theme. Accepts `"system"` (follow terminal background),
/// `"dark"`, `"light"`, `"grayscale"`, or one of the community
/// presets: `"catppuccin-mocha"`, `"tokyo-night"`, `"dracula"`,
/// `"gruvbox-dark"`. The `background_color` setting still overrides the
/// surface color on top of the resolved theme.
pub theme: String,
/// Optional main TUI background color as a 6-digit hex RGB value.
pub background_color: Option<String>,
@@ -353,8 +357,8 @@ impl Settings {
s.locale = normalize_configured_locale(&s.locale)
.unwrap_or("en")
.to_string();
s.theme = normalize_settings_theme(&s.theme).to_string();
s.background_color = normalize_optional_background_color(s.background_color.as_deref());
s.theme = normalize_settings_theme(&s.theme).to_string();
s.default_model = s.default_model.as_deref().and_then(normalize_default_model);
s
};
@@ -480,13 +484,21 @@ impl Settings {
};
self.locale = locale.to_string();
}
"theme" | "ui_theme" => {
let Some(theme) = normalize_theme_name(value) else {
"theme" => {
let Some(id) = crate::palette::ThemeId::from_name(value) else {
anyhow::bail!(
"Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, grayscale."
"Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark."
);
};
self.theme = theme.to_string();
self.theme = id.name().to_string();
}
"ui_theme" => {
let Some(id) = crate::palette::ThemeId::from_name(value) else {
anyhow::bail!(
"Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark."
);
};
self.theme = id.name().to_string();
}
"background_color" | "background" | "bg" => {
self.background_color = normalize_background_color_setting(value)?;
@@ -645,7 +657,7 @@ 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!(" theme: {}", self.theme));
lines.push(format!(" theme: {}", self.theme));
lines.push(format!(
" background_color: {}",
self.background_color.as_deref().unwrap_or("(default)")
@@ -715,7 +727,10 @@ impl Settings {
"locale",
"UI locale and default model language: auto, en, ja, zh-Hans, pt-BR, es-419",
),
("theme", "UI theme: system, dark, light, grayscale"),
(
"theme",
"UI theme: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark",
),
(
"background_color",
"Main TUI background color: #RRGGBB or default",
@@ -1001,6 +1016,11 @@ mod tests {
settings.set("theme", "whale").expect("set dark alias");
assert_eq!(settings.theme, "dark");
settings
.set("theme", "tokyonight")
.expect("set community theme alias");
assert_eq!(settings.theme, "tokyo-night");
let err = settings
.set("theme", "solarized")
.expect_err("unknown theme should fail");
@@ -1655,7 +1675,16 @@ mod tests {
#[test]
fn tui_prefs_validate_accepts_known_themes() {
for theme in ["dark", "light", "system", "grayscale"] {
for theme in [
"dark",
"light",
"system",
"grayscale",
"catppuccin-mocha",
"tokyo-night",
"dracula",
"gruvbox-dark",
] {
let mut prefs = TuiPrefs {
theme: theme.to_string(),
..TuiPrefs::default()
@@ -1691,7 +1720,7 @@ mod tests {
assert!(err.to_string().contains("Invalid tui.toml theme"));
assert!(
err.to_string()
.contains("expected system, dark, light, or grayscale")
.contains("expected system, dark, light, grayscale")
);
}
+21 -2
View File
@@ -859,6 +859,11 @@ pub struct App {
/// Animation anchor for status-strip active sub-agent spinner.
pub agent_activity_started_at: Option<Instant>,
pub ui_theme: UiTheme,
/// Active named theme. Drives the cell-level color remap in
/// `tui::color_compat::ColorCompatBackend` so community presets
/// (Catppuccin, Tokyo Night, Dracula, Gruvbox) propagate to every
/// render site, not just the handful that read `app.ui_theme`.
pub theme_id: palette::ThemeId,
// Onboarding
pub onboarding: OnboardingState,
pub onboarding_needs_api_key: bool,
@@ -1264,8 +1269,19 @@ 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::ui_theme_from_settings(&settings.theme, settings.background_color.as_deref());
// Resolve the named theme from settings; unknown values were already
// normalised to "system" in Settings::load. The background_color
// setting still overlays on top.
let theme_id =
palette::ThemeId::from_name(&settings.theme).unwrap_or(palette::ThemeId::System);
let mut ui_theme = theme_id.ui_theme();
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
.provider_models
.as_ref()
@@ -1459,6 +1475,7 @@ impl App {
pending_subagent_dispatch: None,
agent_activity_started_at: None,
ui_theme,
theme_id,
onboarding,
onboarding_needs_api_key: needs_api_key,
onboarding_workspace_trust_gate,
@@ -4005,6 +4022,8 @@ pub enum AppAction {
OpenStatusPicker,
/// Open the `/feedback` picker for GitHub issue/security destinations.
OpenFeedbackPicker,
/// Open the `/theme` picker modal with live preview of every preset.
OpenThemePicker,
/// Open an external URL in the system browser.
OpenExternalUrl {
url: String,
+95 -7
View File
@@ -14,13 +14,25 @@ use ratatui::{
layout::{Position, Size},
};
use crate::palette::{self, ColorDepth, PaletteMode};
use crate::palette::{self, ColorDepth, PaletteMode, ThemeId, UiTheme};
#[derive(Debug)]
pub(crate) struct ColorCompatBackend<W: Write> {
inner: CrosstermBackend<W>,
depth: ColorDepth,
palette_mode: PaletteMode,
/// Currently active named theme. `System`/`Whale`/`WhaleLight` make the
/// theme remap a no-op (those rely on the dark/light pipeline); the
/// community presets (Catppuccin, Tokyo Night, Dracula, Gruvbox) trigger
/// a per-cell rewrite of dark-palette constants → preset slots.
theme_id: ThemeId,
/// Resolved active `UiTheme`, *including* any user `background_color`
/// override (`UiTheme::with_background_color`). The cell remap reads
/// target slots from this struct, not from `theme_id.ui_theme()`, so
/// `theme = "tokyo-night"` + `background_color = "#000000"` lands as a
/// pure-black surface instead of being overwritten back to
/// tokyo-night's `#16161e` by the remap.
active_ui_theme: UiTheme,
/// During a resize event the terminal emulator may report stale dimensions
/// for a brief window (observed on macOS Terminal.app and Windows ConHost).
/// Forcing the expected size prevents ratatui's internal `autoresize` from
@@ -34,6 +46,12 @@ impl<W: Write> ColorCompatBackend<W> {
inner: CrosstermBackend::new(writer),
depth,
palette_mode,
theme_id: ThemeId::System,
// Default to whatever System resolves to right now — it stays a
// no-op for the remap since `theme_id` is also System, so this
// initial value only matters once `set_theme` flips both fields
// to a community preset.
active_ui_theme: UiTheme::detect(),
forced_size: None,
}
}
@@ -49,6 +67,11 @@ impl<W: Write> ColorCompatBackend<W> {
pub(crate) fn set_palette_mode(&mut self, palette_mode: PaletteMode) {
self.palette_mode = palette_mode;
}
pub(crate) fn set_theme(&mut self, theme_id: ThemeId, ui_theme: UiTheme) {
self.theme_id = theme_id;
self.active_ui_theme = ui_theme;
}
}
impl<W: Write> Write for ColorCompatBackend<W> {
@@ -71,7 +94,13 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
let adapted = content
.map(|(x, y, cell)| {
let mut cell = cell.clone();
adapt_cell_colors(&mut cell, self.depth, self.palette_mode);
adapt_cell_colors(
&mut cell,
self.depth,
self.palette_mode,
self.theme_id,
&self.active_ui_theme,
);
(x, y, cell)
})
.collect::<Vec<_>>();
@@ -123,10 +152,25 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
}
}
fn adapt_cell_colors(cell: &mut Cell, depth: ColorDepth, palette_mode: PaletteMode) {
fn adapt_cell_colors(
cell: &mut Cell,
depth: ColorDepth,
palette_mode: PaletteMode,
theme_id: ThemeId,
ui_theme: &UiTheme,
) {
// Stage 1: community-theme remap (dark palette → preset slots). No-op
// for System / Whale / WhaleLight so legacy dark/light flows are
// untouched. Runs *before* the palette-mode remap so a light terminal
// running e.g. Catppuccin still routes the preset colors through the
// light adaptation below (rare combo, but the sequencing is the same).
cell.fg = palette::adapt_fg_for_theme(cell.fg, theme_id, ui_theme);
cell.bg = palette::adapt_bg_for_theme(cell.bg, theme_id, ui_theme);
// Stage 2: legacy dark↔light remap.
let original_bg = cell.bg;
cell.fg = palette::adapt_fg_for_palette_mode(cell.fg, original_bg, palette_mode);
cell.bg = palette::adapt_bg_for_palette_mode(cell.bg, palette_mode);
// Stage 3: depth (truecolor / 256 / 16) downsampling.
cell.fg = palette::adapt_color(cell.fg, depth);
cell.bg = palette::adapt_bg(cell.bg, depth);
}
@@ -160,7 +204,13 @@ mod tests {
cell.set_fg(Color::Rgb(53, 120, 229));
cell.set_bg(Color::Rgb(11, 21, 38));
adapt_cell_colors(&mut cell, ColorDepth::Ansi256, PaletteMode::Dark);
adapt_cell_colors(
&mut cell,
ColorDepth::Ansi256,
PaletteMode::Dark,
ThemeId::System,
&palette::UI_THEME,
);
assert!(matches!(cell.fg, Color::Indexed(_)));
assert!(matches!(cell.bg, Color::Indexed(_)));
@@ -172,7 +222,13 @@ mod tests {
cell.set_fg(Color::Rgb(53, 120, 229));
cell.set_bg(Color::Rgb(11, 21, 38));
adapt_cell_colors(&mut cell, ColorDepth::TrueColor, PaletteMode::Dark);
adapt_cell_colors(
&mut cell,
ColorDepth::TrueColor,
PaletteMode::Dark,
ThemeId::System,
&palette::UI_THEME,
);
assert_eq!(cell.fg, Color::Rgb(53, 120, 229));
assert_eq!(cell.bg, Color::Rgb(11, 21, 38));
@@ -201,7 +257,13 @@ mod tests {
cell.set_fg(Color::White);
cell.set_bg(Color::Rgb(11, 21, 38));
adapt_cell_colors(&mut cell, ColorDepth::TrueColor, PaletteMode::Light);
adapt_cell_colors(
&mut cell,
ColorDepth::TrueColor,
PaletteMode::Light,
ThemeId::WhaleLight,
&palette::LIGHT_UI_THEME,
);
assert_eq!(cell.fg, palette::LIGHT_TEXT_BODY);
assert_eq!(cell.bg, palette::LIGHT_SURFACE);
@@ -213,12 +275,38 @@ mod tests {
cell.set_fg(palette::DEEPSEEK_SKY);
cell.set_bg(palette::DEEPSEEK_INK);
adapt_cell_colors(&mut cell, ColorDepth::TrueColor, PaletteMode::Grayscale);
adapt_cell_colors(
&mut cell,
ColorDepth::TrueColor,
PaletteMode::Grayscale,
ThemeId::Grayscale,
&palette::GRAYSCALE_UI_THEME,
);
assert_eq!(cell.fg, palette::GRAYSCALE_TEXT_SOFT);
assert_eq!(cell.bg, palette::GRAYSCALE_SURFACE);
}
#[test]
fn community_theme_remap_honors_background_color_override() {
// Tokyo Night + a custom black surface: the remap must rewrite
// `palette::DEEPSEEK_INK` to the *active* UiTheme's overridden
// surface, not to tokyo-night's default surface.
let active = palette::TOKYO_NIGHT_UI_THEME.with_background_color(Color::Rgb(0, 0, 0));
let mut cell = Cell::default();
cell.set_bg(palette::DEEPSEEK_INK);
adapt_cell_colors(
&mut cell,
ColorDepth::TrueColor,
PaletteMode::Dark,
ThemeId::TokyoNight,
&active,
);
assert_eq!(cell.bg, Color::Rgb(0, 0, 0));
}
#[test]
fn backend_palette_mode_can_follow_runtime_theme_changes() {
let writer = SharedWriter::default();
+1
View File
@@ -52,6 +52,7 @@ pub mod sidebar;
pub mod slash_menu;
pub mod streaming;
mod subagent_routing;
pub mod theme_picker;
mod tool_routing;
pub mod transcript;
pub mod transcript_cache;
+398
View File
@@ -0,0 +1,398 @@
//! `/theme` picker with live preview.
//!
//! Modeled after `feedback_picker`. Differences:
//! - The option list comes from `palette::SELECTABLE_THEMES`.
//! - Up/Down emit a `ConfigUpdated{persist:false}` so the host swaps
//! `app.ui_theme` immediately and the whole TUI re-paints under the
//! modal — the user sees the candidate theme before committing.
//! - Enter persists (`persist:true`); Esc emits one more
//! `ConfigUpdated{persist:false}` to restore the original theme name
//! that was active when the picker opened.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
};
use crate::palette::{SELECTABLE_THEMES, ThemeId, UiTheme};
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
pub struct ThemePickerView {
selected: usize,
/// Settings name of the theme that was active when the picker opened.
/// Used to revert on Esc.
original_name: String,
/// Cached UiTheme for `ThemeId::System`, captured once at construction
/// so the per-frame render doesn't re-invoke `UiTheme::detect()` (which
/// reads `COLORFGBG`) on every keystroke.
system_ui_theme: UiTheme,
}
impl ThemePickerView {
#[must_use]
pub fn new(original_name: String) -> Self {
// If the persisted name matches one of the entries, start there;
// otherwise fall back to "System" so the cursor lands on a valid row.
let selected = SELECTABLE_THEMES
.iter()
.position(|id| id.name() == original_name.trim().to_ascii_lowercase())
.unwrap_or(0);
Self {
selected,
original_name,
system_ui_theme: UiTheme::detect(),
}
}
fn current(&self) -> ThemeId {
SELECTABLE_THEMES
.get(self.selected)
.copied()
.unwrap_or(ThemeId::System)
}
/// Resolve a theme to a `UiTheme`, returning the cached `System`
/// resolution to avoid repeated env-var reads inside `render`.
fn ui_theme_for(&self, id: ThemeId) -> UiTheme {
if matches!(id, ThemeId::System) {
self.system_ui_theme
} else {
id.ui_theme()
}
}
fn preview_event(&self) -> ViewAction {
ViewAction::Emit(ViewEvent::ConfigUpdated {
key: "theme".to_string(),
value: self.current().name().to_string(),
persist: false,
})
}
fn commit_event(&self) -> ViewAction {
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
key: "theme".to_string(),
value: self.current().name().to_string(),
persist: true,
})
}
fn revert_event(&self) -> ViewAction {
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
key: "theme".to_string(),
value: self.original_name.clone(),
persist: false,
})
}
fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
fn move_down(&mut self) {
let max = SELECTABLE_THEMES.len().saturating_sub(1);
if self.selected < max {
self.selected += 1;
}
}
}
impl ModalView for ThemePickerView {
fn kind(&self) -> ModalKind {
ModalKind::ThemePicker
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc => self.revert_event(),
KeyCode::Enter => self.commit_event(),
KeyCode::Up | KeyCode::Char('k') => {
self.move_up();
self.preview_event()
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_down();
self.preview_event()
}
KeyCode::Home => {
self.selected = 0;
self.preview_event()
}
KeyCode::End => {
self.selected = SELECTABLE_THEMES.len().saturating_sub(1);
self.preview_event()
}
// Number shortcuts: '1'..='9' jump to that row (1-indexed).
// '0' is rejected explicitly — saturating_sub would otherwise
// collapse it onto row 0, which is unintuitive.
KeyCode::Char(c)
if matches!(c, '1'..='9')
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
let idx = (c as usize) - ('1' as usize);
if idx < SELECTABLE_THEMES.len() {
self.selected = idx;
self.preview_event()
} else {
ViewAction::None
}
}
_ => ViewAction::None,
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
// Modal must always fit inside `area`. The old `.max(52) / .max(10)`
// floors could produce dimensions larger than the available area on
// very small terminals (or split-pane setups), which then made the
// centering arithmetic underflow and ratatui assert. Take a
// soft-preferred size and clamp it strictly to `area`.
let popup_width = 78u16.min(area.width.saturating_sub(4));
// 1 title + 1 spacer + N rows + spacer + bottom hint
let needed_height = (SELECTABLE_THEMES.len() as u16).saturating_add(9);
let popup_height = needed_height.min(area.height.saturating_sub(4));
if popup_width == 0 || popup_height == 0 {
// Nothing sensible to draw — the host's caller has already
// cleared the area, so we just return.
return;
}
let popup_area = Rect {
x: area.x + (area.width.saturating_sub(popup_width)) / 2,
y: area.y + (area.height.saturating_sub(popup_height)) / 2,
width: popup_width,
height: popup_height,
};
// The live theme has already been swapped under us via ConfigUpdated,
// so we pull the *current* preview's UiTheme from the cursor row to
// skin the modal chrome. That way the popup itself shifts color as
// the cursor moves, matching what the background will look like
// after Enter.
let live = self.ui_theme_for(self.current());
Clear.render(popup_area, buf);
let block = Block::default()
.title(Line::from(Span::styled(
" Theme ",
Style::default()
.fg(live.status_working)
.add_modifier(Modifier::BOLD),
)))
.title_bottom(Line::from(vec![
Span::styled(" ↑/↓ ", Style::default().fg(live.text_muted)),
Span::raw("preview "),
Span::styled(" Enter ", Style::default().fg(live.text_muted)),
Span::raw("save "),
Span::styled(" Esc ", Style::default().fg(live.text_muted)),
Span::raw("revert "),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(live.border))
.style(Style::default().bg(live.surface_bg))
.padding(Padding::uniform(1));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut lines: Vec<Line> = Vec::with_capacity(SELECTABLE_THEMES.len() + 5);
lines.push(Line::from(Span::styled(
"Pick a theme — preview is live; Enter saves to settings.toml.",
Style::default().fg(live.text_muted),
)));
lines.push(Line::from(""));
for (idx, id) in SELECTABLE_THEMES.iter().enumerate() {
let id = *id;
let is_selected = idx == self.selected;
let row_style = if is_selected {
Style::default()
.fg(live.text_body)
.bg(live.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(live.text_body)
};
let tagline_style = if is_selected {
Style::default().fg(live.text_muted).bg(live.selection_bg)
} else {
Style::default().fg(live.text_dim)
};
let number_style = if is_selected {
Style::default()
.fg(live.status_working)
.bg(live.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(live.text_hint)
};
let pointer = if is_selected { "" } else { " " };
// 3-cell color swatch per row using the candidate theme's own
// accent + panel + border colors so the picker doubles as a
// legend. Use the cached resolver so `System` doesn't repeat
// `UiTheme::detect()`.
let row_theme = self.ui_theme_for(id);
let swatch = vec![
Span::styled(" ", Style::default().bg(row_theme.surface_bg)),
Span::styled(" ", Style::default().bg(row_theme.panel_bg)),
Span::styled(" ", Style::default().bg(row_theme.status_working)),
Span::styled(" ", Style::default().bg(row_theme.mode_yolo)),
Span::styled(" ", Style::default().bg(row_theme.mode_plan)),
];
let mut spans: Vec<Span> = Vec::with_capacity(8);
spans.push(Span::styled(format!(" {pointer} "), row_style));
spans.push(Span::styled(format!("{}. ", idx + 1), number_style));
spans.push(Span::styled(
format!("{:<22}", id.display_name()),
row_style,
));
spans.extend(swatch);
spans.push(Span::raw(" "));
spans.push(Span::styled(id.tagline(), tagline_style));
lines.push(Line::from(spans));
}
Paragraph::new(lines).render(inner, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn selected_name(action: &ViewAction) -> Option<&str> {
match action {
ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, .. })
| ViewAction::EmitAndClose(ViewEvent::ConfigUpdated { key, value, .. })
if key == "theme" =>
{
Some(value.as_str())
}
_ => None,
}
}
#[test]
fn opens_at_persisted_theme() {
let v = ThemePickerView::new("tokyo-night".to_string());
assert_eq!(v.current(), ThemeId::TokyoNight);
}
#[test]
fn unknown_persisted_name_falls_back_to_first_row() {
let v = ThemePickerView::new("not-a-real-theme".to_string());
assert_eq!(v.selected, 0);
assert_eq!(v.current(), ThemeId::System);
}
#[test]
fn arrow_down_previews_next_theme() {
let mut v = ThemePickerView::new("system".to_string());
let action = v.handle_key(key(KeyCode::Down));
assert!(matches!(action, ViewAction::Emit(_)));
assert_eq!(selected_name(&action), Some(ThemeId::Whale.name()));
}
#[test]
fn enter_commits_with_persist_true() {
let mut v = ThemePickerView::new("system".to_string());
v.handle_key(key(KeyCode::Down));
v.handle_key(key(KeyCode::Down));
v.handle_key(key(KeyCode::Down));
v.handle_key(key(KeyCode::Down)); // -> CatppuccinMocha
let action = v.handle_key(key(KeyCode::Enter));
match action {
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
key,
value,
persist,
}) => {
assert_eq!(key, "theme");
assert_eq!(value, ThemeId::CatppuccinMocha.name());
assert!(persist);
}
other => panic!("expected commit, got {other:?}"),
}
}
#[test]
fn esc_reverts_to_original() {
let mut v = ThemePickerView::new("dracula".to_string());
v.handle_key(key(KeyCode::Up));
v.handle_key(key(KeyCode::Up));
let action = v.handle_key(key(KeyCode::Esc));
match action {
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
key,
value,
persist,
}) => {
assert_eq!(key, "theme");
assert_eq!(value, "dracula");
assert!(!persist);
}
other => panic!("expected revert, got {other:?}"),
}
}
#[test]
fn digit_jumps_to_row() {
let mut v = ThemePickerView::new("system".to_string());
let action = v.handle_key(key(KeyCode::Char('5')));
// Row 5 (1-indexed) -> index 4 -> CatppuccinMocha
assert_eq!(
selected_name(&action),
Some(ThemeId::CatppuccinMocha.name())
);
}
#[test]
fn digit_zero_is_rejected_not_remapped_to_row_zero() {
let mut v = ThemePickerView::new("dracula".to_string());
let before = v.selected;
let action = v.handle_key(key(KeyCode::Char('0')));
assert!(matches!(action, ViewAction::None));
assert_eq!(v.selected, before, "'0' should not move the cursor");
}
#[test]
fn render_does_not_panic_on_zero_sized_area() {
// The picker historically panicked here via .max(W).max(H) floors
// that produced dimensions larger than the available area, then
// underflowed the centering arithmetic.
let v = ThemePickerView::new("system".to_string());
let outer = ratatui::layout::Rect::new(0, 0, 10, 10);
let area = ratatui::layout::Rect::new(0, 0, 0, 0);
let mut buf = ratatui::buffer::Buffer::empty(outer);
v.render(area, &mut buf);
}
#[test]
fn render_does_not_panic_on_tiny_area() {
// 20×6 is smaller than every soft floor the picker prefers.
let v = ThemePickerView::new("system".to_string());
let area = ratatui::layout::Rect::new(0, 0, 20, 6);
let mut buf = ratatui::buffer::Buffer::empty(area);
v.render(area, &mut buf);
}
}
+18 -1
View File
@@ -5367,6 +5367,17 @@ async fn apply_command_result(
.push(crate::tui::feedback_picker::FeedbackPickerView::new());
}
}
AppAction::OpenThemePicker => {
if app.view_stack.top_kind() != Some(ModalKind::ThemePicker) {
// Capture the active theme name straight from `app` so
// Esc can revert through the same ConfigUpdated channel.
// Avoids re-reading settings.toml from disk on every
// `/theme` invocation.
let original = app.theme_id.name().to_string();
app.view_stack
.push(crate::tui::theme_picker::ThemePickerView::new(original));
}
}
AppAction::OpenExternalUrl { url, label } => match open_external_url(&url) {
Ok(()) => {
app.status_message = Some(format!("Opened {label} in your browser"));
@@ -6287,6 +6298,7 @@ fn draw_app_frame_inner(
full_repaint: bool,
) -> Result<()> {
terminal.backend_mut().set_palette_mode(app.ui_theme.mode);
terminal.backend_mut().set_theme(app.theme_id, app.ui_theme);
// DEC 2026 wrapping is on by default but can be turned off for
// terminals that mishandle it (Ptyxis 50.x + VTE 0.84.x flashes the
// whole viewport on every wrapped frame instead of deferring as the
@@ -6566,7 +6578,12 @@ async fn handle_view_events(
persist,
} => {
let result = commands::set_config_value(app, &key, &value, persist);
if let Some(msg) = result.message {
// Only surface the "key = value" confirmation when the
// change is being persisted. Live-preview events
// (`persist: false`, e.g. arrow keys in the theme picker)
// fire on every navigation tick and would otherwise spam
// a `System` cell into the transcript per row visited.
if persist && let Some(msg) = result.message {
app.add_message(HistoryCell::System { content: msg });
}
+1
View File
@@ -35,6 +35,7 @@ pub enum ModalKind {
FilePicker,
StatusPicker,
FeedbackPicker,
ThemePicker,
ContextMenu,
ShellControl,
}
+4
View File
@@ -673,6 +673,10 @@ mod tests {
app.model = "deepseek-v4-flash".to_string();
app.auto_model = false;
app.api_provider = crate::config::ApiProvider::Deepseek;
// Same for theme: tests below assert against the default dark palette,
// but App::new honors saved settings.toml values on the host machine.
app.theme_id = crate::palette::ThemeId::Whale;
app.ui_theme = crate::palette::UI_THEME;
app
}
+6 -4
View File
@@ -327,10 +327,12 @@ replacement compaction. You can inspect or update these from the TUI with
Common settings keys:
- `theme` (`system`, `dark`, `light`, `grayscale`; default `system`):
`system` follows terminal background detection, `dark`/`light` use the
DeepSeek palettes, and `grayscale` is the low-opinion black/white theme.
Aliases such as `whale`, `mono`, and `black-white` are accepted.
- `theme` (`system`, `dark`, `light`, `grayscale`, `catppuccin-mocha`,
`tokyo-night`, `dracula`, `gruvbox-dark`; default `system`): `system`
follows terminal background detection, `dark`/`light` use the DeepSeek
palettes, `grayscale` is the low-opinion black/white theme, and the named
community presets apply across the TUI. Aliases such as `whale`, `mono`,
`black-white`, `tokyonight`, and `gruvbox` are accepted.
- `auto_compact` (on/off, default off)
- `paste_burst_detection` (on/off, default on): fallback rapid-key paste
detection for terminals that do not emit bracketed-paste events. This is