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:
@@ -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
@@ -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` 翻页查看历史。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ pub enum ModalKind {
|
||||
FilePicker,
|
||||
StatusPicker,
|
||||
FeedbackPicker,
|
||||
ThemePicker,
|
||||
ContextMenu,
|
||||
ShellControl,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user