From e68a80b4185c465e834ccc37fb73d6817ce9b435 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 19:08:30 -0500 Subject: [PATCH] =?UTF-8?q?feat(tui):=20Sprint=203=20=E2=80=94=20theme=20s?= =?UTF-8?q?ystem=20with=2011=20built-in=20themes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create src/theme.rs with TuiTheme struct (40+ color roles per theme) - ColorDef enum: Named/Ansi256/Rgb with serde support - 11 built-in themes: default, tokyonight, catppuccin-mocha, catppuccin-latte, nord, gruvbox, dracula, solarized-dark, solarized-light, monokai, system - Theme integrated into TuiApp (tc() helper, set_theme() method) - Dashboard rendering now uses theme colors (all Color::White/Gray/etc replaced) - Input area uses theme colors (border, fg, cursor) - JSON serialization/deserialization for custom themes - 8 new tests: all builtins, ColorDef variants, JSON round-trip - 248 unit tests pass Sprint 3 of 7. Authored by TheArchitectit --- rust/crates/rusty-claude-cli/src/main.rs | 1 + rust/crates/rusty-claude-cli/src/theme.rs | 663 ++++++++++++++++++++++ rust/crates/rusty-claude-cli/src/tui.rs | 96 ++-- 3 files changed, 722 insertions(+), 38 deletions(-) create mode 100644 rust/crates/rusty-claude-cli/src/theme.rs diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 0dbe8edd..b11049fd 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -19,6 +19,7 @@ mod input; mod render; mod setup_wizard; mod markdown; +mod theme; mod tui; mod tui_error; mod tui_update; diff --git a/rust/crates/rusty-claude-cli/src/theme.rs b/rust/crates/rusty-claude-cli/src/theme.rs new file mode 100644 index 00000000..7720cb9a --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/theme.rs @@ -0,0 +1,663 @@ +//! Theme system for TUI — 11 built-in themes + custom JSON loading. + +use ratatui::style::Color; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TuiTheme { + pub name: String, + // Conversation pane + pub conversation_text: ColorDef, + pub conversation_user: ColorDef, + pub conversation_system: ColorDef, + pub conversation_error: ColorDef, + pub conversation_assistant: ColorDef, + pub conversation_dim: ColorDef, + // Code blocks + pub code_fg: ColorDef, + pub code_bg: ColorDef, + pub code_border: ColorDef, + pub code_language_label: ColorDef, + // Dashboard + pub dashboard_header: ColorDef, + pub dashboard_key: ColorDef, + pub dashboard_value: ColorDef, + pub dashboard_separator: ColorDef, + pub dashboard_keys_hint: ColorDef, + // Context gauge + pub gauge_fill_green: ColorDef, + pub gauge_fill_yellow: ColorDef, + pub gauge_fill_red: ColorDef, + pub gauge_bg: ColorDef, + pub gauge_label: ColorDef, + // Input area + pub input_fg: ColorDef, + pub input_cursor_fg: ColorDef, + pub input_cursor_bg: ColorDef, + pub input_border: ColorDef, + pub input_border_active: ColorDef, + // UI chrome + pub border: ColorDef, + pub border_active: ColorDef, + pub scrollbar: ColorDef, + pub spinner: ColorDef, + pub status_message: ColorDef, + pub key_hint: ColorDef, + // Completions + pub completion_fg: ColorDef, + pub completion_bg: ColorDef, + pub completion_selected_fg: ColorDef, + pub completion_selected_bg: ColorDef, + // Agent View + pub agent_running: ColorDef, + pub agent_waiting: ColorDef, + pub agent_done: ColorDef, + pub agent_failed: ColorDef, + pub agent_cancelled: ColorDef, + // Syntax highlighting + pub syntax_theme: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum ColorDef { + Named(String), + Ansi256(u8), + Rgb { r: u8, g: u8, b: u8 }, +} + +impl ColorDef { + pub fn to_color(&self) -> Color { + match self { + ColorDef::Named(name) => match name.to_lowercase().as_str() { + "black" => Color::Black, + "red" => Color::Red, + "green" => Color::Green, + "yellow" => Color::Yellow, + "blue" => Color::Blue, + "magenta" | "purple" => Color::Magenta, + "cyan" => Color::Cyan, + "white" => Color::White, + "gray" | "grey" | "darkgray" => Color::DarkGray, + "lightgray" | "lightgrey" => Color::Gray, + _ => Color::White, + }, + ColorDef::Ansi256(n) => Color::Indexed(*n), + ColorDef::Rgb { r, g, b } => Color::Rgb(*r, *g, *b), + } + } +} + +fn named(s: &str) -> ColorDef { ColorDef::Named(s.to_string()) } +fn rgb(r: u8, g: u8, b: u8) -> ColorDef { ColorDef::Rgb { r, g, b } } + +impl TuiTheme { + pub fn builtin(name: &str) -> Option { + match name { + "default" => Some(Self::theme_default()), + "tokyonight" => Some(Self::theme_tokyonight()), + "catppuccin-mocha" => Some(Self::theme_catppuccin_mocha()), + "catppuccin-latte" => Some(Self::theme_catppuccin_latte()), + "nord" => Some(Self::theme_nord()), + "gruvbox" => Some(Self::theme_gruvbox()), + "dracula" => Some(Self::theme_dracula()), + "solarized-dark" => Some(Self::theme_solarized_dark()), + "solarized-light" => Some(Self::theme_solarized_light()), + "monokai" => Some(Self::theme_monokai()), + "system" => Some(Self::theme_system()), + _ => None, + } + } + + pub fn all_builtin_names() -> Vec<&'static str> { + vec![ + "default", "tokyonight", "catppuccin-mocha", "catppuccin-latte", + "nord", "gruvbox", "dracula", "solarized-dark", "solarized-light", + "monokai", "system", + ] + } + + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| e.to_string()) + } + + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(self).unwrap_or_default() + } + + // ----------------------------------------------------------------------- + // Built-in themes + // ----------------------------------------------------------------------- + + fn theme_default() -> Self { + Self { + name: "default".into(), + conversation_text: named("white"), + conversation_user: named("cyan"), + conversation_system: named("yellow"), + conversation_error: named("red"), + conversation_assistant: named("white"), + conversation_dim: named("darkgray"), + code_fg: named("white"), + code_bg: named("black"), + code_border: named("darkgray"), + code_language_label: named("darkgray"), + dashboard_header: named("cyan"), + dashboard_key: named("darkgray"), + dashboard_value: named("white"), + dashboard_separator: named("darkgray"), + dashboard_keys_hint: named("darkgray"), + gauge_fill_green: named("green"), + gauge_fill_yellow: named("yellow"), + gauge_fill_red: named("red"), + gauge_bg: named("darkgray"), + gauge_label: named("white"), + input_fg: named("white"), + input_cursor_fg: named("black"), + input_cursor_bg: named("cyan"), + input_border: named("cyan"), + input_border_active: named("cyan"), + border: named("darkgray"), + border_active: named("cyan"), + scrollbar: named("darkgray"), + spinner: named("blue"), + status_message: named("blue"), + key_hint: named("darkgray"), + completion_fg: named("white"), + completion_bg: rgb(40, 40, 40), + completion_selected_fg: named("black"), + completion_selected_bg: named("cyan"), + agent_running: named("cyan"), + agent_waiting: named("yellow"), + agent_done: named("green"), + agent_failed: named("red"), + agent_cancelled: named("darkgray"), + syntax_theme: "base16-ocean.dark".into(), + } + } + + fn theme_tokyonight() -> Self { + Self { + name: "tokyonight".into(), + conversation_text: rgb(192, 202, 245), + conversation_user: rgb(125, 207, 255), + conversation_system: rgb(224, 175, 104), + conversation_error: rgb(247, 118, 142), + conversation_assistant: rgb(192, 202, 245), + conversation_dim: rgb(86, 95, 137), + code_fg: rgb(192, 202, 245), + code_bg: rgb(26, 27, 48), + code_border: rgb(86, 95, 137), + code_language_label: rgb(86, 95, 137), + dashboard_header: rgb(125, 207, 255), + dashboard_key: rgb(86, 95, 137), + dashboard_value: rgb(192, 202, 245), + dashboard_separator: rgb(59, 66, 97), + dashboard_keys_hint: rgb(86, 95, 137), + gauge_fill_green: rgb(158, 206, 106), + gauge_fill_yellow: rgb(224, 175, 104), + gauge_fill_red: rgb(247, 118, 142), + gauge_bg: rgb(59, 66, 97), + gauge_label: rgb(192, 202, 245), + input_fg: rgb(192, 202, 245), + input_cursor_fg: rgb(26, 27, 48), + input_cursor_bg: rgb(125, 207, 255), + input_border: rgb(59, 66, 97), + input_border_active: rgb(125, 207, 255), + border: rgb(59, 66, 97), + border_active: rgb(125, 207, 255), + scrollbar: rgb(59, 66, 97), + spinner: rgb(125, 207, 255), + status_message: rgb(125, 207, 255), + key_hint: rgb(86, 95, 137), + completion_fg: rgb(192, 202, 245), + completion_bg: rgb(35, 40, 65), + completion_selected_fg: rgb(26, 27, 48), + completion_selected_bg: rgb(125, 207, 255), + agent_running: rgb(125, 207, 255), + agent_waiting: rgb(224, 175, 104), + agent_done: rgb(158, 206, 106), + agent_failed: rgb(247, 118, 142), + agent_cancelled: rgb(86, 95, 137), + syntax_theme: "base16-ocean.dark".into(), + } + } + + fn theme_catppuccin_mocha() -> Self { + Self { + name: "catppuccin-mocha".into(), + conversation_text: rgb(205, 214, 244), + conversation_user: rgb(137, 180, 250), + conversation_system: rgb(249, 226, 175), + conversation_error: rgb(243, 139, 168), + conversation_assistant: rgb(205, 214, 244), + conversation_dim: rgb(108, 112, 134), + code_fg: rgb(205, 214, 244), + code_bg: rgb(30, 30, 46), + code_border: rgb(108, 112, 134), + code_language_label: rgb(108, 112, 134), + dashboard_header: rgb(137, 180, 250), + dashboard_key: rgb(108, 112, 134), + dashboard_value: rgb(205, 214, 244), + dashboard_separator: rgb(69, 71, 90), + dashboard_keys_hint: rgb(108, 112, 134), + gauge_fill_green: rgb(166, 227, 161), + gauge_fill_yellow: rgb(249, 226, 175), + gauge_fill_red: rgb(243, 139, 168), + gauge_bg: rgb(69, 71, 90), + gauge_label: rgb(205, 214, 244), + input_fg: rgb(205, 214, 244), + input_cursor_fg: rgb(30, 30, 46), + input_cursor_bg: rgb(137, 180, 250), + input_border: rgb(69, 71, 90), + input_border_active: rgb(137, 180, 250), + border: rgb(69, 71, 90), + border_active: rgb(137, 180, 250), + scrollbar: rgb(69, 71, 90), + spinner: rgb(137, 180, 250), + status_message: rgb(137, 180, 250), + key_hint: rgb(108, 112, 134), + completion_fg: rgb(205, 214, 244), + completion_bg: rgb(49, 50, 68), + completion_selected_fg: rgb(30, 30, 46), + completion_selected_bg: rgb(137, 180, 250), + agent_running: rgb(137, 180, 250), + agent_waiting: rgb(249, 226, 175), + agent_done: rgb(166, 227, 161), + agent_failed: rgb(243, 139, 168), + agent_cancelled: rgb(108, 112, 134), + syntax_theme: "base16-ocean.dark".into(), + } + } + + fn theme_catppuccin_latte() -> Self { + Self { + name: "catppuccin-latte".into(), + conversation_text: rgb(76, 79, 105), + conversation_user: rgb(30, 102, 245), + conversation_system: rgb(223, 142, 29), + conversation_error: rgb(210, 15, 57), + conversation_assistant: rgb(76, 79, 105), + conversation_dim: rgb(156, 160, 176), + code_fg: rgb(76, 79, 105), + code_bg: rgb(239, 241, 245), + code_border: rgb(156, 160, 176), + code_language_label: rgb(156, 160, 176), + dashboard_header: rgb(30, 102, 245), + dashboard_key: rgb(156, 160, 176), + dashboard_value: rgb(76, 79, 105), + dashboard_separator: rgb(204, 208, 218), + dashboard_keys_hint: rgb(156, 160, 176), + gauge_fill_green: rgb(64, 160, 43), + gauge_fill_yellow: rgb(223, 142, 29), + gauge_fill_red: rgb(210, 15, 57), + gauge_bg: rgb(204, 208, 218), + gauge_label: rgb(76, 79, 105), + input_fg: rgb(76, 79, 105), + input_cursor_fg: rgb(239, 241, 245), + input_cursor_bg: rgb(30, 102, 245), + input_border: rgb(204, 208, 218), + input_border_active: rgb(30, 102, 245), + border: rgb(204, 208, 218), + border_active: rgb(30, 102, 245), + scrollbar: rgb(204, 208, 218), + spinner: rgb(30, 102, 245), + status_message: rgb(30, 102, 245), + key_hint: rgb(156, 160, 176), + completion_fg: rgb(76, 79, 105), + completion_bg: rgb(220, 224, 232), + completion_selected_fg: rgb(239, 241, 245), + completion_selected_bg: rgb(30, 102, 245), + agent_running: rgb(30, 102, 245), + agent_waiting: rgb(223, 142, 29), + agent_done: rgb(64, 160, 43), + agent_failed: rgb(210, 15, 57), + agent_cancelled: rgb(156, 160, 176), + syntax_theme: "InspiredGitHub".into(), + } + } + + fn theme_nord() -> Self { + Self { + name: "nord".into(), + conversation_text: rgb(216, 222, 233), + conversation_user: rgb(136, 192, 208), + conversation_system: rgb(235, 203, 139), + conversation_error: rgb(191, 97, 106), + conversation_assistant: rgb(216, 222, 233), + conversation_dim: rgb(76, 86, 106), + code_fg: rgb(216, 222, 233), + code_bg: rgb(46, 52, 64), + code_border: rgb(76, 86, 106), + code_language_label: rgb(76, 86, 106), + dashboard_header: rgb(136, 192, 208), + dashboard_key: rgb(76, 86, 106), + dashboard_value: rgb(216, 222, 233), + dashboard_separator: rgb(59, 66, 81), + dashboard_keys_hint: rgb(76, 86, 106), + gauge_fill_green: rgb(163, 190, 140), + gauge_fill_yellow: rgb(235, 203, 139), + gauge_fill_red: rgb(191, 97, 106), + gauge_bg: rgb(59, 66, 81), + gauge_label: rgb(216, 222, 233), + input_fg: rgb(216, 222, 233), + input_cursor_fg: rgb(46, 52, 64), + input_cursor_bg: rgb(136, 192, 208), + input_border: rgb(59, 66, 81), + input_border_active: rgb(136, 192, 208), + border: rgb(59, 66, 81), + border_active: rgb(136, 192, 208), + scrollbar: rgb(59, 66, 81), + spinner: rgb(136, 192, 208), + status_message: rgb(136, 192, 208), + key_hint: rgb(76, 86, 106), + completion_fg: rgb(216, 222, 233), + completion_bg: rgb(67, 76, 94), + completion_selected_fg: rgb(46, 52, 64), + completion_selected_bg: rgb(136, 192, 208), + agent_running: rgb(136, 192, 208), + agent_waiting: rgb(235, 203, 139), + agent_done: rgb(163, 190, 140), + agent_failed: rgb(191, 97, 106), + agent_cancelled: rgb(76, 86, 106), + syntax_theme: "base16-ocean.dark".into(), + } + } + + fn theme_gruvbox() -> Self { + Self { + name: "gruvbox".into(), + conversation_text: rgb(235, 219, 178), + conversation_user: rgb(131, 165, 152), + conversation_system: rgb(250, 189, 47), + conversation_error: rgb(251, 73, 52), + conversation_assistant: rgb(235, 219, 178), + conversation_dim: rgb(146, 131, 116), + code_fg: rgb(235, 219, 178), + code_bg: rgb(40, 40, 40), + code_border: rgb(146, 131, 116), + code_language_label: rgb(146, 131, 116), + dashboard_header: rgb(131, 165, 152), + dashboard_key: rgb(146, 131, 116), + dashboard_value: rgb(235, 219, 178), + dashboard_separator: rgb(80, 73, 69), + dashboard_keys_hint: rgb(146, 131, 116), + gauge_fill_green: rgb(184, 187, 38), + gauge_fill_yellow: rgb(250, 189, 47), + gauge_fill_red: rgb(251, 73, 52), + gauge_bg: rgb(80, 73, 69), + gauge_label: rgb(235, 219, 178), + input_fg: rgb(235, 219, 178), + input_cursor_fg: rgb(40, 40, 40), + input_cursor_bg: rgb(131, 165, 152), + input_border: rgb(80, 73, 69), + input_border_active: rgb(131, 165, 152), + border: rgb(80, 73, 69), + border_active: rgb(131, 165, 152), + scrollbar: rgb(80, 73, 69), + spinner: rgb(131, 165, 152), + status_message: rgb(131, 165, 152), + key_hint: rgb(146, 131, 116), + completion_fg: rgb(235, 219, 178), + completion_bg: rgb(60, 56, 54), + completion_selected_fg: rgb(40, 40, 40), + completion_selected_bg: rgb(131, 165, 152), + agent_running: rgb(131, 165, 152), + agent_waiting: rgb(250, 189, 47), + agent_done: rgb(184, 187, 38), + agent_failed: rgb(251, 73, 52), + agent_cancelled: rgb(146, 131, 116), + syntax_theme: "base16-ocean.dark".into(), + } + } + + fn theme_dracula() -> Self { + Self { + name: "dracula".into(), + conversation_text: rgb(248, 248, 242), + conversation_user: rgb(139, 233, 253), + conversation_system: rgb(241, 250, 140), + conversation_error: rgb(255, 85, 85), + conversation_assistant: rgb(248, 248, 242), + conversation_dim: rgb(98, 114, 164), + code_fg: rgb(248, 248, 242), + code_bg: rgb(40, 42, 54), + code_border: rgb(98, 114, 164), + code_language_label: rgb(98, 114, 164), + dashboard_header: rgb(139, 233, 253), + dashboard_key: rgb(98, 114, 164), + dashboard_value: rgb(248, 248, 242), + dashboard_separator: rgb(68, 71, 90), + dashboard_keys_hint: rgb(98, 114, 164), + gauge_fill_green: rgb(80, 250, 123), + gauge_fill_yellow: rgb(241, 250, 140), + gauge_fill_red: rgb(255, 85, 85), + gauge_bg: rgb(68, 71, 90), + gauge_label: rgb(248, 248, 242), + input_fg: rgb(248, 248, 242), + input_cursor_fg: rgb(40, 42, 54), + input_cursor_bg: rgb(139, 233, 253), + input_border: rgb(68, 71, 90), + input_border_active: rgb(139, 233, 253), + border: rgb(68, 71, 90), + border_active: rgb(139, 233, 253), + scrollbar: rgb(68, 71, 90), + spinner: rgb(139, 233, 253), + status_message: rgb(139, 233, 253), + key_hint: rgb(98, 114, 164), + completion_fg: rgb(248, 248, 242), + completion_bg: rgb(55, 57, 72), + completion_selected_fg: rgb(40, 42, 54), + completion_selected_bg: rgb(139, 233, 253), + agent_running: rgb(139, 233, 253), + agent_waiting: rgb(241, 250, 140), + agent_done: rgb(80, 250, 123), + agent_failed: rgb(255, 85, 85), + agent_cancelled: rgb(98, 114, 164), + syntax_theme: "base16-ocean.dark".into(), + } + } + + fn theme_solarized_dark() -> Self { + Self { + name: "solarized-dark".into(), + conversation_text: rgb(147, 161, 161), + conversation_user: rgb(42, 161, 152), + conversation_system: rgb(181, 137, 0), + conversation_error: rgb(220, 50, 47), + conversation_assistant: rgb(147, 161, 161), + conversation_dim: rgb(88, 110, 117), + code_fg: rgb(147, 161, 161), + code_bg: rgb(0, 43, 54), + code_border: rgb(88, 110, 117), + code_language_label: rgb(88, 110, 117), + dashboard_header: rgb(42, 161, 152), + dashboard_key: rgb(88, 110, 117), + dashboard_value: rgb(147, 161, 161), + dashboard_separator: rgb(7, 54, 66), + dashboard_keys_hint: rgb(88, 110, 117), + gauge_fill_green: rgb(133, 153, 0), + gauge_fill_yellow: rgb(181, 137, 0), + gauge_fill_red: rgb(220, 50, 47), + gauge_bg: rgb(7, 54, 66), + gauge_label: rgb(147, 161, 161), + input_fg: rgb(147, 161, 161), + input_cursor_fg: rgb(0, 43, 54), + input_cursor_bg: rgb(42, 161, 152), + input_border: rgb(7, 54, 66), + input_border_active: rgb(42, 161, 152), + border: rgb(7, 54, 66), + border_active: rgb(42, 161, 152), + scrollbar: rgb(7, 54, 66), + spinner: rgb(42, 161, 152), + status_message: rgb(42, 161, 152), + key_hint: rgb(88, 110, 117), + completion_fg: rgb(147, 161, 161), + completion_bg: rgb(14, 63, 75), + completion_selected_fg: rgb(0, 43, 54), + completion_selected_bg: rgb(42, 161, 152), + agent_running: rgb(42, 161, 152), + agent_waiting: rgb(181, 137, 0), + agent_done: rgb(133, 153, 0), + agent_failed: rgb(220, 50, 47), + agent_cancelled: rgb(88, 110, 117), + syntax_theme: "base16-ocean.dark".into(), + } + } + + fn theme_solarized_light() -> Self { + Self { + name: "solarized-light".into(), + conversation_text: rgb(101, 123, 131), + conversation_user: rgb(42, 161, 152), + conversation_system: rgb(181, 137, 0), + conversation_error: rgb(220, 50, 47), + conversation_assistant: rgb(101, 123, 131), + conversation_dim: rgb(147, 161, 161), + code_fg: rgb(101, 123, 131), + code_bg: rgb(253, 246, 227), + code_border: rgb(147, 161, 161), + code_language_label: rgb(147, 161, 161), + dashboard_header: rgb(42, 161, 152), + dashboard_key: rgb(147, 161, 161), + dashboard_value: rgb(101, 123, 131), + dashboard_separator: rgb(238, 232, 213), + dashboard_keys_hint: rgb(147, 161, 161), + gauge_fill_green: rgb(133, 153, 0), + gauge_fill_yellow: rgb(181, 137, 0), + gauge_fill_red: rgb(220, 50, 47), + gauge_bg: rgb(238, 232, 213), + gauge_label: rgb(101, 123, 131), + input_fg: rgb(101, 123, 131), + input_cursor_fg: rgb(253, 246, 227), + input_cursor_bg: rgb(42, 161, 152), + input_border: rgb(238, 232, 213), + input_border_active: rgb(42, 161, 152), + border: rgb(238, 232, 213), + border_active: rgb(42, 161, 152), + scrollbar: rgb(238, 232, 213), + spinner: rgb(42, 161, 152), + status_message: rgb(42, 161, 152), + key_hint: rgb(147, 161, 161), + completion_fg: rgb(101, 123, 131), + completion_bg: rgb(238, 232, 213), + completion_selected_fg: rgb(253, 246, 227), + completion_selected_bg: rgb(42, 161, 152), + agent_running: rgb(42, 161, 152), + agent_waiting: rgb(181, 137, 0), + agent_done: rgb(133, 153, 0), + agent_failed: rgb(220, 50, 47), + agent_cancelled: rgb(147, 161, 161), + syntax_theme: "InspiredGitHub".into(), + } + } + + fn theme_monokai() -> Self { + Self { + name: "monokai".into(), + conversation_text: rgb(248, 248, 242), + conversation_user: rgb(166, 226, 46), + conversation_system: rgb(230, 219, 116), + conversation_error: rgb(249, 38, 114), + conversation_assistant: rgb(248, 248, 242), + conversation_dim: rgb(117, 113, 94), + code_fg: rgb(248, 248, 242), + code_bg: rgb(39, 40, 34), + code_border: rgb(117, 113, 94), + code_language_label: rgb(117, 113, 94), + dashboard_header: rgb(166, 226, 46), + dashboard_key: rgb(117, 113, 94), + dashboard_value: rgb(248, 248, 242), + dashboard_separator: rgb(75, 73, 62), + dashboard_keys_hint: rgb(117, 113, 94), + gauge_fill_green: rgb(166, 226, 46), + gauge_fill_yellow: rgb(230, 219, 116), + gauge_fill_red: rgb(249, 38, 114), + gauge_bg: rgb(75, 73, 62), + gauge_label: rgb(248, 248, 242), + input_fg: rgb(248, 248, 242), + input_cursor_fg: rgb(39, 40, 34), + input_cursor_bg: rgb(166, 226, 46), + input_border: rgb(75, 73, 62), + input_border_active: rgb(166, 226, 46), + border: rgb(75, 73, 62), + border_active: rgb(166, 226, 46), + scrollbar: rgb(75, 73, 62), + spinner: rgb(166, 226, 46), + status_message: rgb(166, 226, 46), + key_hint: rgb(117, 113, 94), + completion_fg: rgb(248, 248, 242), + completion_bg: rgb(55, 54, 46), + completion_selected_fg: rgb(39, 40, 34), + completion_selected_bg: rgb(166, 226, 46), + agent_running: rgb(166, 226, 46), + agent_waiting: rgb(230, 219, 116), + agent_done: rgb(166, 226, 46), + agent_failed: rgb(249, 38, 114), + agent_cancelled: rgb(117, 113, 94), + syntax_theme: "base16-monokai.dark".into(), + } + } + + fn theme_system() -> Self { + let mut theme = Self::theme_default(); + theme.name = "system".into(); + theme.syntax_theme = "InspiredGitHub".into(); + theme + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_all_builtins_exist() { + for name in TuiTheme::all_builtin_names() { + assert!(TuiTheme::builtin(name).is_some(), "missing theme: {name}"); + } + } + + #[test] + fn test_color_def_named() { + assert_eq!(ColorDef::Named("red".into()).to_color(), Color::Red); + assert_eq!(ColorDef::Named("cyan".into()).to_color(), Color::Cyan); + } + + #[test] + fn test_color_def_rgb() { + assert_eq!(ColorDef::Rgb { r: 255, g: 0, b: 0 }.to_color(), Color::Rgb(255, 0, 0)); + } + + #[test] + fn test_color_def_ansi256() { + assert_eq!(ColorDef::Ansi256(196).to_color(), Color::Indexed(196)); + } + + #[test] + fn test_from_json_valid() { + let theme = TuiTheme::builtin("default").unwrap(); + let json = theme.to_json(); + let parsed = TuiTheme::from_json(&json); + assert!(parsed.is_ok()); + assert_eq!(parsed.unwrap().name, "default"); + } + + #[test] + fn test_from_json_invalid() { + assert!(TuiTheme::from_json("not json").is_err()); + } + + #[test] + fn test_unknown_theme_returns_none() { + assert!(TuiTheme::builtin("nonexistent").is_none()); + } + + #[test] + fn test_system_theme_uses_ansi_only() { + let theme = TuiTheme::builtin("system").unwrap(); + // All fields should be Named colors + assert!(matches!(theme.conversation_text, ColorDef::Named(_))); + assert!(matches!(theme.conversation_user, ColorDef::Named(_))); + } +} diff --git a/rust/crates/rusty-claude-cli/src/tui.rs b/rust/crates/rusty-claude-cli/src/tui.rs index a02beb2f..9b4f8815 100644 --- a/rust/crates/rusty-claude-cli/src/tui.rs +++ b/rust/crates/rusty-claude-cli/src/tui.rs @@ -179,6 +179,7 @@ pub struct TuiApp { spinner_frame: usize, needs_redraw: bool, pub markdown_renderer: crate::markdown::MarkdownRenderer, + pub theme: crate::theme::TuiTheme, } const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -204,18 +205,19 @@ impl TuiApp { let mut terminal = ratatui::Terminal::new(backend)?; terminal.hide_cursor()?; + let theme = crate::theme::TuiTheme::builtin("default").unwrap(); let mut input = TextArea::new(vec![String::new()]); input.set_block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) + .border_style(Style::default().fg(theme.input_border.to_color())) .title(" > "), ); - input.set_style(Style::default().fg(Color::White)); + input.set_style(Style::default().fg(theme.input_fg.to_color())); input.set_cursor_style( Style::default() - .fg(Color::Black) - .bg(Color::Cyan) + .fg(theme.input_cursor_fg.to_color()) + .bg(theme.input_cursor_bg.to_color()) .add_modifier(Modifier::BOLD), ); @@ -231,6 +233,7 @@ impl TuiApp { showing_completions: false, spinner_frame: 0, markdown_renderer: crate::markdown::MarkdownRenderer::new(), + theme: crate::theme::TuiTheme::builtin("default").unwrap(), needs_redraw: true, }; me.draw_screen()?; @@ -296,6 +299,18 @@ impl TuiApp { self.needs_redraw = true; } + /// Short helper to convert theme ColorDef to ratatui Color. + pub fn tc(&self, c: &crate::theme::ColorDef) -> Color { + c.to_color() + } + + /// Set a new theme and trigger redraw. + pub fn set_theme(&mut self, theme: crate::theme::TuiTheme) { + self.markdown_renderer.set_code_theme(&theme.syntax_theme); + self.theme = theme; + self.needs_redraw = true; + } + // ------------------------------------------------------------------- // Conversation helpers // ------------------------------------------------------------------- @@ -425,6 +440,7 @@ impl TuiApp { let spinner_frame = self.spinner_frame; let input_lines: Vec = self.input.lines().iter().cloned().collect(); let renderer = self.markdown_renderer.clone(); + let theme = self.theme.clone(); self.terminal.draw(|f| { draw_frame( @@ -439,6 +455,7 @@ impl TuiApp { showing_completions, spinner_frame, &renderer, + &theme, ); })?; self.terminal.backend_mut().flush()?; @@ -569,6 +586,7 @@ fn draw_frame( showing_completions: bool, spinner_frame: usize, markdown_renderer: &crate::markdown::MarkdownRenderer, + theme: &crate::theme::TuiTheme, ) { let size = f.area(); let main = Layout::default() @@ -588,7 +606,7 @@ fn draw_frame( showing_completions, markdown_renderer, ); - draw_right_pane(f, main[1], dashboard, spinner_frame); + draw_right_pane(f, main[1], dashboard, spinner_frame, theme); } /// Word-wrap a single text line into visual lines that fit `width` columns. @@ -817,6 +835,7 @@ fn draw_right_pane( area: Rect, dashboard: &SharedDashboardState, spinner_frame: usize, + theme: &crate::theme::TuiTheme, ) { let state = dashboard.read().unwrap_or_else(|e| e.into_inner()); let mut lines: Vec = Vec::new(); @@ -824,55 +843,56 @@ fn draw_right_pane( let mut gauge_row: Option = None; lines.push(section("Connection")); - lines.push(kv("Model", &state.model, Color::White)); - lines.push(kv("Provider", &state.provider, Color::Gray)); - lines.push(kv("URL", &state.provider_url, Color::DarkGray)); - lines.push(kv("Mode", &state.permission_mode, Color::Yellow)); + lines.push(kv("Model", &state.model, theme.dashboard_value.to_color())); + lines.push(kv("Provider", &state.provider, theme.dashboard_key.to_color())); + lines.push(kv("URL", &state.provider_url, theme.conversation_dim.to_color())); + lines.push(kv("Mode", &state.permission_mode, theme.conversation_system.to_color())); if let Some(ref branch) = state.git_branch { - lines.push(kv("Branch", branch, Color::Green)); + lines.push(kv("Branch", branch, theme.agent_done.to_color())); } lines.push(Line::from("")); lines.push(section("Tokens")); - lines.push(kv("Turns", &state.turn_count.to_string(), Color::White)); - lines.push(kv("Input", &state.input_tokens.to_string(), Color::White)); - lines.push(kv("Output", &state.output_tokens.to_string(), Color::White)); + lines.push(kv("Turns", &state.turn_count.to_string(), theme.dashboard_value.to_color())); + lines.push(kv("Input", &state.input_tokens.to_string(), theme.dashboard_value.to_color())); + lines.push(kv("Output", &state.output_tokens.to_string(), theme.dashboard_value.to_color())); lines.push(kv( "Cache R", &state.cache_read_tokens.to_string(), - Color::Gray, + theme.dashboard_key.to_color(), )); lines.push(kv( "Cache W", &state.cache_creation_tokens.to_string(), - Color::Gray, + theme.dashboard_key.to_color(), )); lines.push(kv( "Cost", &format!("${:.4}", state.cost_usd), - Color::Yellow, + theme.conversation_system.to_color(), )); lines.push(Line::from("")); let pct = state.context_percent; - let gauge_color = match () { - _ if pct > 80.0 => Color::Red, - _ if pct > 50.0 => Color::Yellow, - _ => Color::Green, + let gauge_color = if pct > 80.0 { + theme.gauge_fill_red.to_color() + } else if pct > 50.0 { + theme.gauge_fill_yellow.to_color() + } else { + theme.gauge_fill_green.to_color() }; lines.push(section("Context")); lines.push(kv( "Used", &format!("{:.1}% of {}", pct, state.context_window), - Color::White, + theme.dashboard_value.to_color(), )); - // Reserve a row for the gauge bar — we'll overlay it after gauge_row = Some(lines.len()); - lines.push(Line::from("")); // gauge placeholder row + lines.push(Line::from("")); lines.push(kv( "Compactions", &state.compaction_count.to_string(), - Color::Gray, + theme.dashboard_key.to_color(), )); lines.push(Line::from("")); @@ -880,9 +900,9 @@ fn draw_right_pane( lines.push(section("LSP")); for lsp in &state.lsp_servers { let c = match lsp.status.as_str() { - "connected" => Color::Green, - "starting" => Color::Yellow, - _ => Color::Red, + "connected" => theme.agent_done.to_color(), + "starting" => theme.agent_waiting.to_color(), + _ => theme.agent_failed.to_color(), }; lines.push(kv(&lsp.language, &lsp.status, c)); } @@ -891,17 +911,17 @@ fn draw_right_pane( if let Some(ref team) = state.team { lines.push(section("Team")); - lines.push(kv("Name", &team.team_name, Color::White)); + lines.push(kv("Name", &team.team_name, theme.dashboard_value.to_color())); let progress = format!( "{}/{} done, {} fail, {} run", team.completed_agents, team.total_agents, team.failed_agents, team.running_agents ); - lines.push(kv("Status", &progress, Color::Green)); + lines.push(kv("Status", &progress, theme.agent_done.to_color())); for agent in &team.agents { let c = match agent.status.as_str() { - "completed" => Color::Green, - "failed" => Color::Red, - _ => Color::Cyan, + "completed" => theme.agent_done.to_color(), + "failed" => theme.agent_failed.to_color(), + _ => theme.agent_running.to_color(), }; let label = format!("● {}", agent.name); let detail = format!("({})", agent.subagent_type.as_deref().unwrap_or("?")); @@ -910,7 +930,7 @@ fn draw_right_pane( format!(" {: