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:
Claude 2026-06-11 19:08:30 -05:00
parent e8e8c72185
commit e68a80b418
3 changed files with 722 additions and 38 deletions

View File

@ -19,6 +19,7 @@ mod input;
mod render;
mod setup_wizard;
mod markdown;
mod theme;
mod tui;
mod tui_error;
mod tui_update;

View File

@ -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(_)));
}
}

View File

@ -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)