feat(tui): Sprint 3 — theme system with 11 built-in themes
- 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
This commit is contained in:
parent
e8e8c72185
commit
e68a80b418
|
|
@ -19,6 +19,7 @@ mod input;
|
|||
mod render;
|
||||
mod setup_wizard;
|
||||
mod markdown;
|
||||
mod theme;
|
||||
mod tui;
|
||||
mod tui_error;
|
||||
mod tui_update;
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
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<Self, String> {
|
||||
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(_)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> = 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<Line> = Vec::new();
|
||||
|
|
@ -824,55 +843,56 @@ fn draw_right_pane(
|
|||
let mut gauge_row: Option<usize> = 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!(" {:<KV_KEY_WIDTH$}", label),
|
||||
Style::default().fg(c),
|
||||
),
|
||||
Span::styled(detail, Style::default().fg(Color::Gray)),
|
||||
Span::styled(detail, Style::default().fg(theme.dashboard_key.to_color())),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
|
@ -920,7 +940,7 @@ fn draw_right_pane(
|
|||
lines.push(kv(
|
||||
"ID",
|
||||
state.session_id.as_deref().unwrap_or("-"),
|
||||
Color::Gray,
|
||||
theme.dashboard_key.to_color(),
|
||||
));
|
||||
|
||||
if !state.status_message.is_empty() {
|
||||
|
|
@ -928,22 +948,22 @@ fn draw_right_pane(
|
|||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("{frame} {}", state.status_message),
|
||||
Style::default().fg(Color::Blue),
|
||||
Style::default().fg(theme.spinner.to_color()),
|
||||
)));
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"─ Keys ─",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
Style::default().fg(theme.key_hint.to_color()),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Enter Submit Shift+Enter ↵",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
Style::default().fg(theme.key_hint.to_color()),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" ^P Swap ^T Team ^C ⊘ ^D Exit",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
Style::default().fg(theme.key_hint.to_color()),
|
||||
)));
|
||||
|
||||
let widget = Paragraph::new(lines)
|
||||
|
|
|
|||
Loading…
Reference in New Issue