1use once_cell::sync::Lazy;
8use ratatui::style::{Color, Modifier, Style};
9use serde::{Deserialize, Deserializer, Serialize};
10use std::collections::HashMap;
11use std::fmt;
12use syntect::highlighting::ThemeSet;
13use thiserror::Error;
14use tracing::debug;
15
16mod loader;
17
18pub use loader::ThemeLoader;
19
20static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
22
23#[derive(Debug, Error)]
25pub enum ThemeError {
26 #[error("IO error: {0}")]
27 Io(#[from] std::io::Error),
28
29 #[error("Parse error: {0}")]
30 Parse(#[from] toml::de::Error),
31
32 #[error("Validation error: {0}")]
33 Validation(String),
34
35 #[error("Color not found in palette: {0}")]
36 ColorNotFound(String),
37
38 #[error("Invalid color value: {0}")]
39 InvalidColor(String),
40}
41
42#[derive(Debug, Clone, Deserialize)]
44#[serde(untagged)]
45pub enum ColorValue {
46 Palette(String),
48 Direct(String),
50}
51
52#[derive(Debug, Clone, Deserialize)]
54pub struct ComponentStyle {
55 pub fg: Option<ColorValue>,
56 pub bg: Option<ColorValue>,
57 #[serde(default)]
58 pub bold: bool,
59 #[serde(default)]
60 pub italic: bool,
61 #[serde(default)]
62 pub underlined: bool,
63}
64
65#[derive(Debug, Clone, Deserialize)]
67pub struct RawTheme {
68 pub name: String,
69 pub palette: HashMap<String, RgbColor>,
70 pub components: HashMap<Component, ComponentStyle>,
71 pub syntax: Option<SyntaxConfig>,
72}
73
74#[derive(Debug, Clone, Deserialize)]
76pub struct SyntaxConfig {
77 pub syntect_theme: Option<String>,
79}
80
81pub type Theme = CompiledTheme;
82
83impl Theme {
84 pub fn message_spacing(&self) -> u16 {
86 1 }
88}
89
90#[derive(Debug, Clone, Copy)]
92pub struct RgbColor(pub u8, pub u8, pub u8);
93
94impl<'de> Deserialize<'de> for RgbColor {
95 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
96 where
97 D: Deserializer<'de>,
98 {
99 let s = String::deserialize(deserializer)?;
100
101 if let Some(hex) = s.strip_prefix('#') {
103 if hex.len() == 6 {
104 let r = u8::from_str_radix(&hex[0..2], 16)
105 .map_err(|_| serde::de::Error::custom(format!("Invalid hex color: {s}")))?;
106 let g = u8::from_str_radix(&hex[2..4], 16)
107 .map_err(|_| serde::de::Error::custom(format!("Invalid hex color: {s}")))?;
108 let b = u8::from_str_radix(&hex[4..6], 16)
109 .map_err(|_| serde::de::Error::custom(format!("Invalid hex color: {s}")))?;
110 return Ok(RgbColor(r, g, b));
111 }
112 }
113
114 match s.to_lowercase().as_str() {
116 "black" => Ok(RgbColor(0, 0, 0)),
117 "red" => Ok(RgbColor(255, 0, 0)),
118 "green" => Ok(RgbColor(0, 255, 0)),
119 "yellow" => Ok(RgbColor(255, 255, 0)),
120 "blue" => Ok(RgbColor(0, 0, 255)),
121 "magenta" => Ok(RgbColor(255, 0, 255)),
122 "cyan" => Ok(RgbColor(0, 255, 255)),
123 "white" => Ok(RgbColor(255, 255, 255)),
124 "gray" | "grey" => Ok(RgbColor(128, 128, 128)),
125 "darkgray" | "darkgrey" | "dark_gray" | "dark_grey" => Ok(RgbColor(64, 64, 64)),
126 _ => Err(serde::de::Error::custom(format!("Unknown color: {s}"))),
127 }
128 }
129}
130
131impl From<RgbColor> for Color {
132 fn from(rgb: RgbColor) -> Self {
133 Color::Rgb(rgb.0, rgb.1, rgb.2)
134 }
135}
136
137#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Deserialize, Serialize)]
139#[serde(rename_all = "snake_case")]
140pub enum Component {
141 StatusBar,
143
144 InputPanelBorder,
146 InputPanelBorderActive,
147 InputPanelBorderCommand,
148 InputPanelBorderApproval,
149 InputPanelBorderError,
150 InputPanelLabel,
151 InputPanelLabelActive,
152 InputPanelLabelCommand,
153 InputPanelLabelConfirmExit,
154
155 ChatListBorder,
157 ChatListBackground,
158 UserMessage,
159 UserMessageRole,
160 AssistantMessage,
161 AssistantMessageRole,
162 SystemMessage,
163 SystemMessageRole,
164
165 ToolCall,
167 ToolCallBorder,
168 ToolCallHeader,
169 ToolCallId,
170 ToolOutput,
171 ToolSuccess,
172 ToolError,
173
174 ThoughtBox,
176 ThoughtHeader,
177 ThoughtBorder,
178 ThoughtText,
179
180 CommandPrompt,
182 CommandText,
183 CommandSuccess,
184 CommandError,
185
186 ErrorText,
188 ErrorBold,
189 DimText,
190 SelectionHighlight,
191 PlaceholderText,
192
193 ModelInfo,
195
196 NoticeInfo,
198 NoticeWarn,
199 NoticeError,
200
201 TodoHigh,
203 TodoMedium,
204 TodoLow,
205 TodoPending,
206 TodoInProgress,
207 TodoCompleted,
208
209 CodeAddition,
211 CodeDeletion,
212 CodeFilePath,
213
214 PopupBorder,
216 PopupSelection,
217
218 MarkdownH1,
220 MarkdownH2,
221 MarkdownH3,
222 MarkdownH4,
223 MarkdownH5,
224 MarkdownH6,
225 MarkdownParagraph,
226 MarkdownBold,
227 MarkdownItalic,
228 MarkdownStrikethrough,
229 MarkdownCode,
230 MarkdownCodeBlock,
231 MarkdownLink,
232 MarkdownBlockquote,
233 MarkdownListBullet,
234 MarkdownListNumber,
235 MarkdownTaskChecked,
236 MarkdownTaskUnchecked,
237
238 MarkdownTableBorder,
240 MarkdownTableHeader,
241 MarkdownTableCell,
242
243 SetupTitle,
245 SetupBorder,
246 SetupBorderActive,
247 SetupHeader,
248 SetupText,
249 SetupHighlight,
250 SetupKeyBinding,
251 SetupProviderName,
252 SetupProviderSelected,
253 SetupStatusActive,
254 SetupStatusInactive,
255 SetupStatusInProgress,
256 SetupSuccessIcon,
257 SetupErrorMessage,
258 SetupHint,
259 SetupUrl,
260 SetupInputLabel,
261 SetupInputValue,
262 SetupBigText,
263}
264
265impl fmt::Display for Component {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 write!(f, "{self:?}")
268 }
269}
270
271#[derive(Debug, Clone)]
273pub struct CompiledTheme {
274 pub name: String,
275 pub styles: HashMap<Component, Style>,
276 pub background_color: Option<Color>,
277 pub syntax_theme: Option<syntect::highlighting::Theme>,
278}
279
280impl RawTheme {
281 pub fn into_theme(self) -> Result<Theme, ThemeError> {
283 let mut styles = HashMap::new();
284
285 let background_color = self.palette.get("background").map(|&rgb| rgb.into());
287
288 let syntax_theme = if let Some(syntax_config) = &self.syntax {
290 debug!("Loading syntect theme from config: {:?}", syntax_config);
291 Some(load_syntect_theme(syntax_config)?)
292 } else {
293 debug!("No syntax config found in theme");
294 None
295 };
296
297 for (component, style_def) in &self.components {
299 let mut style = Style::default();
300
301 if let Some(fg) = &style_def.fg {
303 let color = self.resolve_color(fg.clone())?;
304 style = style.fg(color);
305 }
306
307 if let Some(bg) = &style_def.bg {
309 let color = self.resolve_color(bg.clone())?;
310 style = style.bg(color);
311 }
312
313 if style_def.bold {
315 style = style.add_modifier(Modifier::BOLD);
316 }
317 if style_def.italic {
318 style = style.add_modifier(Modifier::ITALIC);
319 }
320 if style_def.underlined {
321 style = style.add_modifier(Modifier::UNDERLINED);
322 }
323
324 styles.insert(*component, style);
325 }
326
327 Ok(Theme {
328 name: self.name,
329 styles,
330 background_color,
331 syntax_theme,
332 })
333 }
334
335 fn resolve_color(&self, color_value: ColorValue) -> Result<Color, ThemeError> {
337 match color_value {
338 ColorValue::Palette(name) => {
339 self.palette
341 .get(&name)
342 .map(|&rgb| rgb.into())
343 .ok_or(ThemeError::ColorNotFound(name))
344 }
345 ColorValue::Direct(color_str) => {
346 parse_direct_color(&color_str)
348 }
349 }
350 }
351}
352
353fn load_syntect_theme(config: &SyntaxConfig) -> Result<syntect::highlighting::Theme, ThemeError> {
355 if let Some(theme_name) = &config.syntect_theme {
356 THEME_SET.themes.get(theme_name).cloned().ok_or_else(|| {
358 ThemeError::Validation(format!("Syntect theme '{theme_name}' not found"))
359 })
360 } else {
361 THEME_SET
363 .themes
364 .get("base16-ocean.dark")
365 .cloned()
366 .ok_or_else(|| ThemeError::Validation("Default syntect theme not found".to_string()))
367 }
368}
369
370fn parse_direct_color(color_str: &str) -> Result<Color, ThemeError> {
371 if let Some(hex) = color_str.strip_prefix('#') {
373 if hex.len() == 6 {
374 let r = u8::from_str_radix(&hex[0..2], 16)
375 .map_err(|_| ThemeError::InvalidColor(color_str.to_string()))?;
376 let g = u8::from_str_radix(&hex[2..4], 16)
377 .map_err(|_| ThemeError::InvalidColor(color_str.to_string()))?;
378 let b = u8::from_str_radix(&hex[4..6], 16)
379 .map_err(|_| ThemeError::InvalidColor(color_str.to_string()))?;
380 return Ok(Color::Rgb(r, g, b));
381 }
382 }
383
384 match color_str.to_lowercase().as_str() {
386 "black" => Ok(Color::Black),
387 "red" => Ok(Color::Red),
388 "green" => Ok(Color::Green),
389 "yellow" => Ok(Color::Yellow),
390 "blue" => Ok(Color::Blue),
391 "magenta" => Ok(Color::Magenta),
392 "cyan" => Ok(Color::Cyan),
393 "white" => Ok(Color::White),
394 "gray" | "grey" => Ok(Color::Gray),
395 "darkgray" | "darkgrey" | "dark_gray" | "dark_grey" => Ok(Color::DarkGray),
396 "lightred" | "light_red" => Ok(Color::LightRed),
397 "lightgreen" | "light_green" => Ok(Color::LightGreen),
398 "lightyellow" | "light_yellow" => Ok(Color::LightYellow),
399 "lightblue" | "light_blue" => Ok(Color::LightBlue),
400 "lightmagenta" | "light_magenta" => Ok(Color::LightMagenta),
401 "lightcyan" | "light_cyan" => Ok(Color::LightCyan),
402 "reset" => Ok(Color::Reset),
403 _ => Err(ThemeError::InvalidColor(color_str.to_string())),
404 }
405}
406
407impl CompiledTheme {
408 pub fn style(&self, component: Component) -> Style {
410 self.styles.get(&component).copied().unwrap_or_default()
411 }
412
413 pub fn get_background_color(&self) -> Option<Color> {
415 self.background_color
416 }
417
418 pub fn error_text(&self) -> Style {
420 self.style(Component::ErrorText)
421 }
422
423 pub fn dim_text(&self) -> Style {
424 self.style(Component::DimText)
425 }
426
427 pub fn subtle_text(&self) -> Style {
428 self.style(Component::DimText)
429 }
430
431 pub fn text(&self) -> Style {
432 Style::default()
433 }
434}
435
436impl Default for CompiledTheme {
437 fn default() -> Self {
438 create_default_theme()
439 }
440}
441
442fn create_default_theme() -> CompiledTheme {
444 let mut styles = HashMap::new();
445
446 styles.insert(Component::StatusBar, Style::default().fg(Color::LightCyan));
448
449 styles.insert(
451 Component::InputPanelBorder,
452 Style::default().fg(Color::DarkGray),
453 );
454 styles.insert(
455 Component::InputPanelBorderActive,
456 Style::default().fg(Color::Yellow),
457 );
458 styles.insert(
459 Component::InputPanelBorderCommand,
460 Style::default().fg(Color::Cyan),
461 );
462 styles.insert(
463 Component::InputPanelBorderApproval,
464 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
465 );
466 styles.insert(
467 Component::InputPanelBorderError,
468 Style::default()
469 .fg(Color::LightRed)
470 .add_modifier(Modifier::BOLD),
471 );
472
473 styles.insert(
475 Component::ChatListBorder,
476 Style::default().fg(Color::DarkGray),
477 );
478 styles.insert(Component::ChatListBackground, Style::default());
479 styles.insert(Component::UserMessage, Style::default());
480 styles.insert(
481 Component::UserMessageRole,
482 Style::default()
483 .fg(Color::Green)
484 .add_modifier(Modifier::BOLD),
485 );
486 styles.insert(Component::AssistantMessage, Style::default());
487 styles.insert(
488 Component::AssistantMessageRole,
489 Style::default()
490 .fg(Color::Blue)
491 .add_modifier(Modifier::BOLD),
492 );
493 styles.insert(Component::SystemMessage, Style::default());
494 styles.insert(
495 Component::SystemMessageRole,
496 Style::default().fg(Color::Yellow),
497 );
498
499 styles.insert(Component::ToolCall, Style::default().fg(Color::Cyan));
501 styles.insert(Component::ToolCallBorder, Style::default().fg(Color::Cyan));
502 styles.insert(Component::ToolCallHeader, Style::default().fg(Color::Cyan));
503 styles.insert(Component::ToolCallId, Style::default().fg(Color::DarkGray));
504 styles.insert(Component::ToolOutput, Style::default());
505 styles.insert(Component::ToolSuccess, Style::default().fg(Color::Green));
506 styles.insert(Component::ToolError, Style::default().fg(Color::Red));
507
508 styles.insert(Component::ThoughtBox, Style::default().fg(Color::DarkGray));
510 styles.insert(Component::ThoughtHeader, Style::default().fg(Color::Gray));
511 styles.insert(
512 Component::ThoughtBorder,
513 Style::default().fg(Color::DarkGray),
514 );
515 styles.insert(
516 Component::ThoughtText,
517 Style::default()
518 .fg(Color::DarkGray)
519 .add_modifier(Modifier::ITALIC),
520 );
521
522 styles.insert(
524 Component::CommandPrompt,
525 Style::default()
526 .fg(Color::Green)
527 .add_modifier(Modifier::BOLD),
528 );
529 styles.insert(Component::CommandText, Style::default().fg(Color::Cyan));
530 styles.insert(Component::CommandSuccess, Style::default().fg(Color::Green));
531 styles.insert(Component::CommandError, Style::default().fg(Color::Red));
532
533 styles.insert(Component::ErrorText, Style::default().fg(Color::Red));
535 styles.insert(
536 Component::ErrorBold,
537 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
538 );
539 styles.insert(Component::DimText, Style::default().fg(Color::DarkGray));
540 styles.insert(
541 Component::SelectionHighlight,
542 Style::default()
543 .fg(Color::Yellow)
544 .add_modifier(Modifier::BOLD),
545 );
546 styles.insert(
547 Component::PlaceholderText,
548 Style::default()
549 .fg(Color::DarkGray)
550 .add_modifier(Modifier::ITALIC),
551 );
552
553 styles.insert(
555 Component::ModelInfo,
556 Style::default().fg(Color::LightMagenta),
557 );
558
559 styles.insert(Component::NoticeInfo, Style::default().fg(Color::Blue));
561 styles.insert(Component::NoticeWarn, Style::default().fg(Color::Yellow));
562 styles.insert(Component::NoticeError, Style::default().fg(Color::Red));
563
564 styles.insert(Component::TodoHigh, Style::default().fg(Color::Red));
566 styles.insert(Component::TodoMedium, Style::default().fg(Color::Yellow));
567 styles.insert(Component::TodoLow, Style::default().fg(Color::Green));
568 styles.insert(Component::TodoPending, Style::default().fg(Color::Blue));
569 styles.insert(
570 Component::TodoInProgress,
571 Style::default().fg(Color::Yellow),
572 );
573 styles.insert(Component::TodoCompleted, Style::default().fg(Color::Green));
574
575 styles.insert(Component::CodeAddition, Style::default().fg(Color::Green));
577 styles.insert(Component::CodeDeletion, Style::default().fg(Color::Red));
578 styles.insert(Component::CodeFilePath, Style::default().fg(Color::Yellow));
579
580 styles.insert(Component::PopupBorder, Style::default().fg(Color::White));
582 styles.insert(
583 Component::PopupSelection,
584 Style::default().fg(Color::Yellow).bg(Color::DarkGray),
585 );
586
587 styles.insert(Component::MarkdownH1, Style::default().fg(Color::Cyan));
589 styles.insert(Component::MarkdownH2, Style::default().fg(Color::Cyan));
590 styles.insert(Component::MarkdownH3, Style::default().fg(Color::Cyan));
591 styles.insert(Component::MarkdownH4, Style::default().fg(Color::LightCyan));
592 styles.insert(Component::MarkdownH5, Style::default().fg(Color::LightCyan));
593 styles.insert(Component::MarkdownH6, Style::default().fg(Color::Gray));
594 styles.insert(Component::MarkdownParagraph, Style::default());
595 styles.insert(Component::MarkdownBold, Style::default());
596 styles.insert(Component::MarkdownItalic, Style::default());
597 styles.insert(Component::MarkdownStrikethrough, Style::default());
598 styles.insert(
599 Component::MarkdownCode,
600 Style::default().fg(Color::White).bg(Color::Black),
601 );
602 styles.insert(
603 Component::MarkdownCodeBlock,
604 Style::default().bg(Color::Black),
605 );
606 styles.insert(Component::MarkdownLink, Style::default().fg(Color::Blue));
607 styles.insert(
608 Component::MarkdownBlockquote,
609 Style::default().fg(Color::Green),
610 );
611 styles.insert(
612 Component::MarkdownListBullet,
613 Style::default().fg(Color::Gray),
614 );
615 styles.insert(
616 Component::MarkdownListNumber,
617 Style::default().fg(Color::LightBlue),
618 );
619
620 styles.insert(
622 Component::MarkdownTableBorder,
623 Style::default().fg(Color::DarkGray),
624 );
625 styles.insert(
626 Component::MarkdownTableHeader,
627 Style::default()
628 .fg(Color::Cyan)
629 .add_modifier(Modifier::BOLD),
630 );
631 styles.insert(Component::MarkdownTableCell, Style::default());
632
633 styles.insert(
635 Component::MarkdownTaskChecked,
636 Style::default().fg(Color::Green),
637 );
638 styles.insert(
639 Component::MarkdownTaskUnchecked,
640 Style::default().fg(Color::Gray),
641 );
642
643 styles.insert(
645 Component::SetupTitle,
646 Style::default()
647 .fg(Color::Cyan)
648 .add_modifier(Modifier::BOLD),
649 );
650 styles.insert(Component::SetupBorder, Style::default().fg(Color::DarkGray));
651 styles.insert(
652 Component::SetupBorderActive,
653 Style::default().fg(Color::Yellow),
654 );
655 styles.insert(
656 Component::SetupHeader,
657 Style::default()
658 .fg(Color::Cyan)
659 .add_modifier(Modifier::BOLD),
660 );
661 styles.insert(Component::SetupText, Style::default());
662 styles.insert(
663 Component::SetupHighlight,
664 Style::default()
665 .bg(Color::DarkGray)
666 .add_modifier(Modifier::BOLD),
667 );
668 styles.insert(
669 Component::SetupKeyBinding,
670 Style::default()
671 .fg(Color::Green)
672 .add_modifier(Modifier::BOLD),
673 );
674 styles.insert(Component::SetupProviderName, Style::default());
675 styles.insert(
676 Component::SetupProviderSelected,
677 Style::default()
678 .bg(Color::DarkGray)
679 .add_modifier(Modifier::BOLD),
680 );
681 styles.insert(
682 Component::SetupStatusActive,
683 Style::default().fg(Color::Green),
684 );
685 styles.insert(
686 Component::SetupStatusInactive,
687 Style::default().fg(Color::Red),
688 );
689 styles.insert(
690 Component::SetupStatusInProgress,
691 Style::default().fg(Color::Yellow),
692 );
693 styles.insert(
694 Component::SetupSuccessIcon,
695 Style::default()
696 .fg(Color::Green)
697 .add_modifier(Modifier::BOLD),
698 );
699 styles.insert(
700 Component::SetupErrorMessage,
701 Style::default().fg(Color::Red),
702 );
703 styles.insert(Component::SetupHint, Style::default().fg(Color::DarkGray));
704 styles.insert(
705 Component::SetupUrl,
706 Style::default()
707 .fg(Color::Blue)
708 .add_modifier(Modifier::UNDERLINED),
709 );
710 styles.insert(Component::SetupInputLabel, Style::default());
711 styles.insert(
712 Component::SetupInputValue,
713 Style::default().fg(Color::Yellow),
714 );
715 styles.insert(
716 Component::SetupBigText,
717 Style::default()
718 .fg(Color::Cyan)
719 .add_modifier(Modifier::BOLD),
720 );
721
722 CompiledTheme {
723 name: "Default".to_string(),
724 styles,
725 background_color: None, syntax_theme: None,
727 }
728}