diff --git a/src/tui/mod.rs b/src/tui/mod.rs index ce2eb5a..0a01e17 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod search_state; pub(crate) mod search_worker; pub(crate) mod share_state; pub(crate) mod text_layout; +pub(crate) mod theme; pub(crate) mod ui; pub(crate) mod usage_state; pub(crate) mod viewing_state; diff --git a/src/tui/theme.rs b/src/tui/theme.rs new file mode 100644 index 0000000..e452101 --- /dev/null +++ b/src/tui/theme.rs @@ -0,0 +1,78 @@ +use ratatui::style::Color; + +pub(crate) struct Theme { + pub(crate) text: Color, + pub(crate) text_muted: Color, + pub(crate) accent: Color, + pub(crate) highlight: Color, + pub(crate) success: Color, + pub(crate) error: Color, + pub(crate) info: Color, + + pub(crate) border_focus: Color, + pub(crate) border_idle: Color, + pub(crate) background: Color, + pub(crate) popup_bg: Color, + + pub(crate) selected_fg: Color, + pub(crate) selected_bg: Color, + // Dimmer band behind the selected message in the preview/viewing panes; distinct + // from the strong list/filter row selection above. + pub(crate) message_highlight: Color, + + pub(crate) match_fg: Color, + pub(crate) match_bg: Color, + + pub(crate) source: Color, + pub(crate) user: Color, + pub(crate) assistant: Color, + pub(crate) summary: Color, + + pub(crate) scrollbar_thumb: Color, + pub(crate) scrollbar_track: Color, + + // Usage dashboard: skill-audit accent and the categorical token-mix series + pub(crate) skill: Color, + pub(crate) token_input: Color, + pub(crate) token_output: Color, + pub(crate) token_cache_read: Color, + pub(crate) token_cache_write: Color, + pub(crate) token_reasoning: Color, +} + +pub(crate) const THEME: Theme = Theme { + text: Color::White, + text_muted: Color::DarkGray, + accent: Color::Yellow, + highlight: Color::Cyan, + success: Color::Green, + error: Color::Red, + info: Color::Blue, + + border_focus: Color::Cyan, + border_idle: Color::DarkGray, + background: Color::Reset, + popup_bg: Color::Black, + + selected_fg: Color::Black, + selected_bg: Color::Cyan, + message_highlight: Color::DarkGray, + + match_fg: Color::Black, + match_bg: Color::Yellow, + + source: Color::Green, + user: Color::Cyan, + assistant: Color::Green, + summary: Color::Green, + + scrollbar_thumb: Color::Cyan, + scrollbar_track: Color::DarkGray, + + skill: Color::Magenta, + token_input: Color::Cyan, + token_output: Color::Green, + token_cache_read: Color::Blue, + token_cache_write: Color::Magenta, + token_reasoning: Color::Yellow, +}; diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index fd82a85..269dada 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -12,6 +12,7 @@ use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState}; use crate::tui::app::App; use crate::tui::share_state::{AppMode, ResumeOrigin}; +use crate::tui::theme::THEME; pub(super) fn highlight_spans( text: &str, @@ -28,7 +29,7 @@ pub(super) fn highlight_spans( let mut spans: Vec> = Vec::new(); let mut cursor = 0usize; let match_style = - Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD); + Style::default().fg(THEME.match_fg).bg(THEME.match_bg).add_modifier(Modifier::BOLD); while cursor < text.len() { let hit = needles .iter() @@ -99,8 +100,8 @@ pub(super) fn render_vertical_scrollbar( .end_symbol(None) .thumb_symbol("▌") .track_symbol(Some("▌")) - .thumb_style(Style::default().fg(Color::Cyan)) - .track_style(Style::default().fg(Color::DarkGray)), + .thumb_style(Style::default().fg(THEME.scrollbar_thumb)) + .track_style(Style::default().fg(THEME.scrollbar_track)), area.inner(Margin { vertical: 1, horizontal: 0 }), &mut state, ); @@ -239,7 +240,7 @@ mod tests { }) .unwrap(); - assert_eq!(frame.buffer[(1, 8)].style().fg, Some(Color::Cyan)); + assert_eq!(frame.buffer[(1, 8)].style().fg, Some(THEME.scrollbar_thumb)); } #[test] @@ -253,9 +254,9 @@ mod tests { let texts: Vec<&str> = spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(texts, vec!["Alpha", " beta ", "Gamma"]); - assert_eq!(spans[0].style.bg, Some(Color::Yellow)); + assert_eq!(spans[0].style.bg, Some(THEME.match_bg)); assert_eq!(spans[1].style.bg, None); - assert_eq!(spans[2].style.bg, Some(Color::Yellow)); + assert_eq!(spans[2].style.bg, Some(THEME.match_bg)); } #[test] @@ -269,7 +270,7 @@ mod tests { let texts: Vec<&str> = spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(texts, vec!["foobar", " baz"]); - assert_eq!(spans[0].style.bg, Some(Color::Yellow)); + assert_eq!(spans[0].style.bg, Some(THEME.match_bg)); } #[test] @@ -333,13 +334,13 @@ mod tests { for x in inner.x..inner.x + inner.width { assert_eq!( buffer[(x, selected_y)].style().bg, - Some(Color::Cyan), - "selected row x={x} should have cyan background" + Some(THEME.selected_bg), + "selected row x={x} should have the selected background" ); } assert!( (inner.x..inner.x + inner.width) - .all(|x| buffer[(x, unselected_y)].style().bg != Some(Color::Cyan)) + .all(|x| buffer[(x, unselected_y)].style().bg != Some(THEME.selected_bg)) ); } @@ -405,7 +406,7 @@ mod tests { assert!(summary.contains( "tokens 31 input 10 output 9 cache r/w 6/4 reasoning 2 | time 2m | user msgs 2/3" )); - assert_eq!(terminal.backend().buffer()[(2, 1)].fg, Color::Green); + assert_eq!(terminal.backend().buffer()[(2, 1)].fg, THEME.summary); } #[test] diff --git a/src/tui/ui/popups.rs b/src/tui/ui/popups.rs index a31353b..f2f6415 100644 --- a/src/tui/ui/popups.rs +++ b/src/tui/ui/popups.rs @@ -1,6 +1,6 @@ use ratatui::Frame; use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use unicode_width::UnicodeWidthStr; @@ -9,6 +9,7 @@ use crate::handoff; use crate::tui::app::App; use crate::tui::search_state::{PanelFocus, ProjectPickerRow, SourcePickerRow}; use crate::tui::share_state::PendingCommandAction; +use crate::tui::theme::THEME; use super::truncate_start; @@ -23,8 +24,8 @@ pub(super) fn render_handoff_target_picker(f: &mut Frame, app: &App) { let block = Block::default() .title(" Handoff target ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)) - .style(Style::default().bg(Color::Black)); + .border_style(Style::default().fg(THEME.accent)) + .style(Style::default().bg(THEME.popup_bg)); let mut lines = Vec::new(); lines.push(Line::from("")); @@ -32,9 +33,9 @@ pub(super) fn render_handoff_target_picker(f: &mut Frame, app: &App) { let selected = index == app.handoff_target_selected; let marker = if selected { ">" } else { " " }; let style = if selected { - Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default().fg(THEME.selected_fg).bg(THEME.accent).add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::White) + Style::default().fg(THEME.text) }; lines.push(Line::from(vec![ Span::styled(format!(" {marker} "), style), @@ -44,10 +45,10 @@ pub(super) fn render_handoff_target_picker(f: &mut Frame, app: &App) { } lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled(" [Enter] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Span::styled("select ", Style::default().fg(Color::White)), - Span::styled("[Esc] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Span::styled("cancel", Style::default().fg(Color::White)), + Span::styled(" [Enter] ", Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD)), + Span::styled("select ", Style::default().fg(THEME.text)), + Span::styled("[Esc] ", Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD)), + Span::styled("cancel", Style::default().fg(THEME.text)), ])); let widget = Paragraph::new(lines).block(block).wrap(Wrap { trim: false }); @@ -68,11 +69,11 @@ pub(super) fn render_source_picker(f: &mut Frame, app: &App) { let block = Block::default() .title(" Filters > Source ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(THEME.border_focus)); - let selected_style = Style::default().bg(Color::Cyan).fg(Color::Black); - let normal_style = Style::default().fg(Color::White); - let muted_style = Style::default().fg(Color::DarkGray); + let selected_style = Style::default().bg(THEME.selected_bg).fg(THEME.selected_fg); + let normal_style = Style::default().fg(THEME.text); + let muted_style = Style::default().fg(THEME.text_muted); let visible_rows = height.saturating_sub(7) as usize; let start = if visible_rows == 0 || app.source_picker.selected < visible_rows { @@ -89,7 +90,10 @@ pub(super) fn render_source_picker(f: &mut Frame, app: &App) { Span::styled(app.source_picker.query.clone(), normal_style) }; lines.push(Line::from(vec![ - Span::styled(" Filter: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled( + " Filter: ", + Style::default().fg(THEME.highlight).add_modifier(Modifier::BOLD), + ), filter_value, ])); lines.push(Line::from("")); @@ -122,19 +126,19 @@ pub(super) fn render_source_picker(f: &mut Frame, app: &App) { lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled(" Space", Style::default().fg(Color::Yellow)), + Span::styled(" Space", Style::default().fg(THEME.accent)), Span::styled(" select/clear ", muted_style), - Span::styled("/", Style::default().fg(Color::Yellow)), + Span::styled("/", Style::default().fg(THEME.accent)), Span::styled(" filter ", muted_style), - Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::styled("Enter", Style::default().fg(THEME.accent)), Span::styled(" apply ", muted_style), - Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::styled("Esc", Style::default().fg(THEME.accent)), Span::styled(" back", muted_style), ])); lines.push(Line::from(vec![ - Span::styled("Ctrl+A", Style::default().fg(Color::Yellow)), + Span::styled("Ctrl+A", Style::default().fg(THEME.accent)), Span::styled(" all ", muted_style), - Span::styled("Ctrl+U", Style::default().fg(Color::Yellow)), + Span::styled("Ctrl+U", Style::default().fg(THEME.accent)), Span::styled(" clear input", muted_style), ])); @@ -163,11 +167,11 @@ pub(super) fn render_project_picker(f: &mut Frame, app: &App) { let block = Block::default() .title(" Filters > Project ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(THEME.border_focus)); - let selected_style = Style::default().bg(Color::Cyan).fg(Color::Black); - let normal_style = Style::default().fg(Color::White); - let muted_style = Style::default().fg(Color::DarkGray); + let selected_style = Style::default().bg(THEME.selected_bg).fg(THEME.selected_fg); + let normal_style = Style::default().fg(THEME.text); + let muted_style = Style::default().fg(THEME.text_muted); let visible_rows = height.saturating_sub(7) as usize; let start = if visible_rows == 0 || app.project_picker.selected < visible_rows { @@ -184,7 +188,10 @@ pub(super) fn render_project_picker(f: &mut Frame, app: &App) { Span::styled(app.project_picker.query.clone(), normal_style) }; lines.push(Line::from(vec![ - Span::styled(" Filter: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled( + " Filter: ", + Style::default().fg(THEME.highlight).add_modifier(Modifier::BOLD), + ), filter_value, ])); lines.push(Line::from("")); @@ -227,19 +234,19 @@ pub(super) fn render_project_picker(f: &mut Frame, app: &App) { lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled(" Space", Style::default().fg(Color::Yellow)), + Span::styled(" Space", Style::default().fg(THEME.accent)), Span::styled(" select/clear ", muted_style), - Span::styled("/", Style::default().fg(Color::Yellow)), + Span::styled("/", Style::default().fg(THEME.accent)), Span::styled(" filter ", muted_style), - Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::styled("Enter", Style::default().fg(THEME.accent)), Span::styled(" apply ", muted_style), - Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::styled("Esc", Style::default().fg(THEME.accent)), Span::styled(" back", muted_style), ])); lines.push(Line::from(vec![ - Span::styled("Ctrl+A", Style::default().fg(Color::Yellow)), + Span::styled("Ctrl+A", Style::default().fg(THEME.accent)), Span::styled(" all ", muted_style), - Span::styled("Ctrl+U", Style::default().fg(Color::Yellow)), + Span::styled("Ctrl+U", Style::default().fg(THEME.accent)), Span::styled(" clear input", muted_style), ])); @@ -264,11 +271,11 @@ pub(super) fn render_export_input(f: &mut Frame, app: &App) { let block = Block::default() .title(" Export to (Enter confirm, Esc cancel) ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)) - .style(Style::default().bg(Color::Black)); + .border_style(Style::default().fg(THEME.accent)) + .style(Style::default().bg(THEME.popup_bg)); let input = Paragraph::new(app.export_path.as_str()) - .style(Style::default().fg(Color::White).bg(Color::Black)) + .style(Style::default().fg(THEME.text).bg(THEME.popup_bg)) .block(block); f.render_widget(Clear, popup_area); @@ -290,15 +297,15 @@ pub(super) fn render_settings(f: &mut Frame, app: &App) { let block = Block::default() .title(" Settings (Enter/Space toggle, Esc close) ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)) - .style(Style::default().bg(Color::Black)); + .border_style(Style::default().fg(THEME.accent)) + .style(Style::default().bg(THEME.popup_bg)); let mut lines = Vec::new(); - let selected_style = Style::default().bg(Color::Yellow).fg(Color::Black); - let normal_style = Style::default().fg(Color::White); + let selected_style = Style::default().bg(THEME.accent).fg(THEME.selected_fg); + let normal_style = Style::default().fg(THEME.text); lines.push(Line::from(vec![ - Span::styled(" Time Scope: ", Style::default().fg(Color::DarkGray)), + Span::styled(" Time Scope: ", Style::default().fg(THEME.text_muted)), Span::styled( app.config.sync_window.label(), if app.settings_selected == 0 { selected_style } else { normal_style }, @@ -333,54 +340,54 @@ pub(super) fn render_share_result(f: &mut Frame, app: &App) { let y = area.y + (area.height.saturating_sub(height)) / 2; let rect = Rect::new(x, y, width, height); - let border = if popup.is_error { Color::Red } else { Color::Green }; + let border = if popup.is_error { THEME.error } else { THEME.success }; let title = if popup.is_error { " Share failed " } else { " Session shared " }; let block = Block::default() .title(title) .borders(Borders::ALL) .border_style(Style::default().fg(border)) - .style(Style::default().bg(Color::Black)); + .style(Style::default().bg(THEME.popup_bg)); let mut lines = vec![ Line::from(""), Line::from(Span::styled( popup.message.clone(), if popup.is_error { - Style::default().fg(Color::Red) + Style::default().fg(THEME.error) } else { - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + Style::default().fg(THEME.success).add_modifier(Modifier::BOLD) }, )), ]; if let Some(url) = popup.url.as_ref() { lines.push(Line::from("")); - lines.push(Line::from(Span::styled(url.clone(), Style::default().fg(Color::Cyan)))); + lines.push(Line::from(Span::styled(url.clone(), Style::default().fg(THEME.highlight)))); lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled(" [O] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Span::styled("open ", Style::default().fg(Color::White)), - Span::styled(" [C] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Span::styled("copy URL ", Style::default().fg(Color::White)), + Span::styled(" [O] ", Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD)), + Span::styled("open ", Style::default().fg(THEME.text)), + Span::styled(" [C] ", Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD)), + Span::styled("copy URL ", Style::default().fg(THEME.text)), Span::styled( "[Enter/Esc] ", - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD), ), - Span::styled("close", Style::default().fg(Color::White)), + Span::styled("close", Style::default().fg(THEME.text)), ])); } else if popup.is_error { lines.push(Line::from("")); lines.push(Line::from(vec![ Span::styled( " [Enter/Esc] ", - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD), ), - Span::styled("close", Style::default().fg(Color::White)), + Span::styled("close", Style::default().fg(THEME.text)), ])); } else { lines.push(Line::from("")); lines.push(Line::from(Span::styled( " Publishing with Wrangler...", - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.text_muted), ))); } @@ -415,8 +422,8 @@ pub(super) fn render_confirm_resume(f: &mut Frame, app: &App) { let block = Block::default() .title(block_title) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)) - .style(Style::default().bg(Color::Black)); + .border_style(Style::default().fg(THEME.accent)) + .style(Style::default().bg(THEME.popup_bg)); let title: String = pending.session_title.chars().take(width as usize - 10).collect(); let command_text: String = @@ -441,32 +448,32 @@ pub(super) fn render_confirm_resume(f: &mut Frame, app: &App) { PendingCommandAction::Handoff => " Target: ", _ => " Source: ", }, - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.text_muted), ), Span::styled( pending.source_label.clone(), - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.source).add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::styled(title, Style::default().fg(Color::White)), + Span::styled(title, Style::default().fg(THEME.text)), ]), Line::from(vec![ - Span::styled(" Cwd: ", Style::default().fg(Color::DarkGray)), - Span::styled(cwd_text, Style::default().fg(Color::White)), + Span::styled(" Cwd: ", Style::default().fg(THEME.text_muted)), + Span::styled(cwd_text, Style::default().fg(THEME.text)), ]), Line::from(vec![ - Span::styled(" Command: ", Style::default().fg(Color::DarkGray)), + Span::styled(" Command: ", Style::default().fg(THEME.text_muted)), Span::styled( command_text, - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.highlight).add_modifier(Modifier::BOLD), ), ]), Line::from(""), Line::from(vec![ - Span::styled(" [Y] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Span::styled(confirm_label, Style::default().fg(Color::White)), - Span::styled("[N] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Span::styled("cancel", Style::default().fg(Color::White)), + Span::styled(" [Y] ", Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD)), + Span::styled(confirm_label, Style::default().fg(THEME.text)), + Span::styled("[N] ", Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD)), + Span::styled("cancel", Style::default().fg(THEME.text)), ]), ]; @@ -489,18 +496,18 @@ pub(super) fn render_status_bar(f: &mut Frame, app: &App, area: Rect) { app.semantic_progress.failed_sessions ); } - Some(Span::styled(text, Style::default().fg(Color::Blue))) + Some(Span::styled(text, Style::default().fg(THEME.info))) } else { None }; let stats_span = Span::styled( format!(" [{} sessions, {} messages]", app.total_sessions, app.total_messages), - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.text_muted), ); let line = if let Some(ref msg) = app.status_message { - let mut spans = vec![Span::styled(format!(" {msg}"), Style::default().fg(Color::Green))]; + let mut spans = vec![Span::styled(format!(" {msg}"), Style::default().fg(THEME.success))]; if let Some(span) = semantic_span.clone() { spans.push(span); } @@ -510,26 +517,26 @@ pub(super) fn render_status_bar(f: &mut Frame, app: &App, area: Rect) { match app.panel_focus { PanelFocus::SessionList => { let mut spans = vec![ - Span::styled(" ↑/↓", Style::default().fg(Color::Yellow)), - Span::styled(" sessions ", Style::default().fg(Color::DarkGray)), - Span::styled("→", Style::default().fg(Color::Yellow)), - Span::styled(" preview ", Style::default().fg(Color::DarkGray)), - Span::styled("Enter", Style::default().fg(Color::Yellow)), - Span::styled(" detail ", Style::default().fg(Color::DarkGray)), - Span::styled("Ctrl+F", Style::default().fg(Color::Yellow)), - Span::styled(" filter ", Style::default().fg(Color::DarkGray)), - Span::styled("Ctrl+R", Style::default().fg(Color::Yellow)), - Span::styled(" resume ", Style::default().fg(Color::DarkGray)), - Span::styled("Ctrl+O", Style::default().fg(Color::Yellow)), - Span::styled(" app ", Style::default().fg(Color::DarkGray)), - Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)), - Span::styled(" settings ", Style::default().fg(Color::DarkGray)), - Span::styled("Esc", Style::default().fg(Color::Yellow)), - Span::styled(" clear ", Style::default().fg(Color::DarkGray)), + Span::styled(" ↑/↓", Style::default().fg(THEME.accent)), + Span::styled(" sessions ", Style::default().fg(THEME.text_muted)), + Span::styled("→", Style::default().fg(THEME.accent)), + Span::styled(" preview ", Style::default().fg(THEME.text_muted)), + Span::styled("Enter", Style::default().fg(THEME.accent)), + Span::styled(" detail ", Style::default().fg(THEME.text_muted)), + Span::styled("Ctrl+F", Style::default().fg(THEME.accent)), + Span::styled(" filter ", Style::default().fg(THEME.text_muted)), + Span::styled("Ctrl+R", Style::default().fg(THEME.accent)), + Span::styled(" resume ", Style::default().fg(THEME.text_muted)), + Span::styled("Ctrl+O", Style::default().fg(THEME.accent)), + Span::styled(" app ", Style::default().fg(THEME.text_muted)), + Span::styled("Ctrl+S", Style::default().fg(THEME.accent)), + Span::styled(" settings ", Style::default().fg(THEME.text_muted)), + Span::styled("Esc", Style::default().fg(THEME.accent)), + Span::styled(" clear ", Style::default().fg(THEME.text_muted)), ]; if app.query.is_empty() { - spans.push(Span::styled("q", Style::default().fg(Color::Yellow))); - spans.push(Span::styled(" quit", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled("q", Style::default().fg(THEME.accent))); + spans.push(Span::styled(" quit", Style::default().fg(THEME.text_muted))); } if let Some(span) = semantic_span.clone() { spans.push(span); @@ -539,14 +546,14 @@ pub(super) fn render_status_bar(f: &mut Frame, app: &App, area: Rect) { } PanelFocus::Preview => { let mut spans = vec![ - Span::styled(" ↑/↓", Style::default().fg(Color::Yellow)), - Span::styled(" messages ", Style::default().fg(Color::DarkGray)), - Span::styled("←", Style::default().fg(Color::Yellow)), - Span::styled(" sessions ", Style::default().fg(Color::DarkGray)), - Span::styled("Enter", Style::default().fg(Color::Yellow)), - Span::styled(" detail ", Style::default().fg(Color::DarkGray)), - Span::styled("Esc", Style::default().fg(Color::Yellow)), - Span::styled(" back", Style::default().fg(Color::DarkGray)), + Span::styled(" ↑/↓", Style::default().fg(THEME.accent)), + Span::styled(" messages ", Style::default().fg(THEME.text_muted)), + Span::styled("←", Style::default().fg(THEME.accent)), + Span::styled(" sessions ", Style::default().fg(THEME.text_muted)), + Span::styled("Enter", Style::default().fg(THEME.accent)), + Span::styled(" detail ", Style::default().fg(THEME.text_muted)), + Span::styled("Esc", Style::default().fg(THEME.accent)), + Span::styled(" back", Style::default().fg(THEME.text_muted)), ]; if let Some(span) = semantic_span { spans.push(span); diff --git a/src/tui/ui/search.rs b/src/tui/ui/search.rs index 63c82df..d164548 100644 --- a/src/tui/ui/search.rs +++ b/src/tui/ui/search.rs @@ -1,6 +1,6 @@ use ratatui::Frame; use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; use unicode_width::UnicodeWidthStr; @@ -9,6 +9,7 @@ use crate::tui::app::App; use crate::tui::layout::search_layout; use crate::tui::search_state::{FilterFocus, PanelFocus}; use crate::tui::text_layout::wrap_visual_rows; +use crate::tui::theme::THEME; use crate::types::{MatchSource, Role}; use super::popups::render_status_bar; @@ -28,21 +29,21 @@ pub(super) fn render_search_box(f: &mut Frame, app: &App, area: Rect) { let block = Block::default() .title(" Recall ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(THEME.border_focus)); let line = if app.query.is_empty() { if let Some(feedback) = app.search_feedback.as_deref() { - Line::from(Span::styled(feedback.to_string(), Style::default().fg(Color::Yellow))) + Line::from(Span::styled(feedback.to_string(), Style::default().fg(THEME.accent))) } else { - Line::from(Span::styled("Type to search...", Style::default().fg(Color::DarkGray))) + Line::from(Span::styled("Type to search...", Style::default().fg(THEME.text_muted))) } } else if let Some(feedback) = app.search_feedback.as_deref() { Line::from(vec![ - Span::styled(app.query.clone(), Style::default().fg(Color::White)), - Span::styled(format!(" {feedback}"), Style::default().fg(Color::Yellow)), + Span::styled(app.query.clone(), Style::default().fg(THEME.text)), + Span::styled(format!(" {feedback}"), Style::default().fg(THEME.accent)), ]) } else { - Line::from(Span::styled(app.query.clone(), Style::default().fg(Color::White))) + Line::from(Span::styled(app.query.clone(), Style::default().fg(THEME.text))) }; let input = Paragraph::new(line).block(block); @@ -61,27 +62,27 @@ pub(super) fn render_filters(f: &mut Frame, app: &App, area: Rect) { let sort_label = app.sort_label(); let line = Line::from(vec![ - Span::styled(" S:", Style::default().fg(Color::DarkGray)), + Span::styled(" S:", Style::default().fg(THEME.text_muted)), Span::styled( format!("[{source_label}]"), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD), ), - Span::styled(" P:", Style::default().fg(Color::DarkGray)), + Span::styled(" P:", Style::default().fg(THEME.text_muted)), Span::styled( format!("[{project_label}]"), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD), ), - Span::styled(" T:", Style::default().fg(Color::DarkGray)), + Span::styled(" T:", Style::default().fg(THEME.text_muted)), Span::styled( format!("[{time_label}]"), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD), ), - Span::styled(" Sort:", Style::default().fg(Color::DarkGray)), + Span::styled(" Sort:", Style::default().fg(THEME.text_muted)), Span::styled( format!("[{sort_label}]"), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD), ), - Span::styled(" Ctrl+F", Style::default().fg(Color::DarkGray)), + Span::styled(" Ctrl+F", Style::default().fg(THEME.text_muted)), ]); f.render_widget(Paragraph::new(line), area); @@ -109,7 +110,7 @@ pub(super) fn render_filter_overview(f: &mut Frame, app: &App) { let block = Block::default() .title(" Filters ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(THEME.border_focus)); let mut lines = vec![Line::from("")]; lines.push(filter_overview_line( @@ -138,16 +139,16 @@ pub(super) fn render_filter_overview(f: &mut Frame, app: &App) { )); lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled(" ↑/↓", Style::default().fg(Color::Yellow)), - Span::styled(" nav ", Style::default().fg(Color::DarkGray)), - Span::styled("←/→", Style::default().fg(Color::Yellow)), - Span::styled(" adjust ", Style::default().fg(Color::DarkGray)), - Span::styled("Enter", Style::default().fg(Color::Yellow)), - Span::styled(" edit ", Style::default().fg(Color::DarkGray)), - Span::styled("c", Style::default().fg(Color::Yellow)), - Span::styled(" clear ", Style::default().fg(Color::DarkGray)), - Span::styled("Esc", Style::default().fg(Color::Yellow)), - Span::styled(" apply", Style::default().fg(Color::DarkGray)), + Span::styled(" ↑/↓", Style::default().fg(THEME.accent)), + Span::styled(" nav ", Style::default().fg(THEME.text_muted)), + Span::styled("←/→", Style::default().fg(THEME.accent)), + Span::styled(" adjust ", Style::default().fg(THEME.text_muted)), + Span::styled("Enter", Style::default().fg(THEME.accent)), + Span::styled(" edit ", Style::default().fg(THEME.text_muted)), + Span::styled("c", Style::default().fg(THEME.accent)), + Span::styled(" clear ", Style::default().fg(THEME.text_muted)), + Span::styled("Esc", Style::default().fg(THEME.accent)), + Span::styled(" apply", Style::default().fg(THEME.text_muted)), ])); let widget = Paragraph::new(lines).block(block).wrap(Wrap { trim: false }); @@ -162,9 +163,9 @@ fn filter_overview_line( selected: bool, ) -> Line<'static> { let style = if selected { - Style::default().bg(Color::Cyan).fg(Color::Black).add_modifier(Modifier::BOLD) + Style::default().bg(THEME.selected_bg).fg(THEME.selected_fg).add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::White) + Style::default().fg(THEME.text) }; Line::from(Span::styled( format!(" {label:<12} {:<22} {hint}", truncate_label(value, 22)), @@ -173,7 +174,7 @@ fn filter_overview_line( } pub(super) fn render_result_list(f: &mut Frame, app: &App, area: Rect) { let focused = app.panel_focus == PanelFocus::SessionList; - let border_color = if focused { Color::Cyan } else { Color::DarkGray }; + let border_color = if focused { THEME.border_focus } else { THEME.border_idle }; let title = if app.results.is_empty() { " Sessions (0) ".to_string() } else { @@ -186,7 +187,7 @@ pub(super) fn render_result_list(f: &mut Frame, app: &App, area: Rect) { if app.results.is_empty() { let msg = "No results"; - let p = Paragraph::new(msg).style(Style::default().fg(Color::DarkGray)).block(block); + let p = Paragraph::new(msg).style(Style::default().fg(THEME.text_muted)).block(block); f.render_widget(p, area); return; } @@ -213,9 +214,9 @@ pub(super) fn render_result_list(f: &mut Frame, app: &App, area: Rect) { let active_selected = focused && selected; let passive_selected = selected && !focused; let selected_text_style = if active_selected { - Style::default().fg(Color::Black) + Style::default().fg(THEME.selected_fg) } else if passive_selected { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default().fg(THEME.border_focus).add_modifier(Modifier::BOLD) } else { Style::default() }; @@ -223,7 +224,7 @@ pub(super) fn render_result_list(f: &mut Frame, app: &App, area: Rect) { let line = Line::from(vec![ Span::styled( source_label.to_string(), - if selected { selected_text_style } else { Style::default().fg(Color::Green) }, + if selected { selected_text_style } else { Style::default().fg(THEME.source) }, ), Span::raw(" "), Span::styled( @@ -231,26 +232,26 @@ pub(super) fn render_result_list(f: &mut Frame, app: &App, area: Rect) { if selected { selected_text_style } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(THEME.text_muted) }, ), Span::raw(" "), Span::styled( title, - if selected { selected_text_style } else { Style::default().fg(Color::White) }, + if selected { selected_text_style } else { Style::default().fg(THEME.text) }, ), Span::styled( format!(" {age}"), if selected { selected_text_style } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(THEME.text_muted) }, ), ]); ListItem::new(line).style(if active_selected { - Style::default().bg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default().bg(THEME.selected_bg).add_modifier(Modifier::BOLD) } else { Style::default() }) @@ -264,7 +265,7 @@ pub(super) fn render_result_list(f: &mut Frame, app: &App, area: Rect) { let row_y = list_inner.y + (app.selected_index - start) as u16; f.buffer_mut().set_style( Rect::new(list_inner.x, row_y, list_inner.width, 1), - Style::default().bg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default().bg(THEME.selected_bg).add_modifier(Modifier::BOLD), ); } render_vertical_scrollbar(f, area, app.results.len(), visible_rows, start); @@ -272,7 +273,7 @@ pub(super) fn render_result_list(f: &mut Frame, app: &App, area: Rect) { pub(super) fn render_preview(f: &mut Frame, app: &App, area: Rect) { let focused = app.panel_focus == PanelFocus::Preview; - let border_color = if focused { Color::Cyan } else { Color::DarkGray }; + let border_color = if focused { THEME.border_focus } else { THEME.border_idle }; let title = if let Some(result) = app.results.get(app.selected_index) { let dir = result.session.directory.as_deref().unwrap_or("-"); @@ -296,7 +297,7 @@ pub(super) fn render_preview(f: &mut Frame, app: &App, area: Rect) { if app.preview_messages.is_empty() { let p = - Paragraph::new("No messages").style(Style::default().fg(Color::DarkGray)).block(block); + Paragraph::new("No messages").style(Style::default().fg(THEME.text_muted)).block(block); f.render_widget(p, area); return; } @@ -315,22 +316,23 @@ pub(super) fn render_preview(f: &mut Frame, app: &App, area: Rect) { for (i, msg) in app.preview_messages.iter().enumerate() { let selected = focused && i == app.preview_selected_msg; let (prefix, color) = match msg.role { - Role::User => ("User: ", Color::Cyan), - Role::Assistant => ("Asst: ", Color::Green), + Role::User => ("User: ", THEME.user), + Role::Assistant => ("Asst: ", THEME.assistant), }; let time_str = crate::utils::format_message_time(msg.timestamp); let header_bg = if selected && row_visible(visual_row, viewport_start, viewport_end) { - Color::DarkGray + THEME.message_highlight } else { - Color::Reset + THEME.background }; let mut header = vec![Span::styled( prefix, Style::default().fg(color).bg(header_bg).add_modifier(Modifier::BOLD), )]; if !time_str.is_empty() { - header.push(Span::styled(time_str, Style::default().fg(Color::DarkGray).bg(header_bg))); + header + .push(Span::styled(time_str, Style::default().fg(THEME.text_muted).bg(header_bg))); } lines.push(Line::from(header)); visual_row += 1; @@ -341,13 +343,13 @@ pub(super) fn render_preview(f: &mut Frame, app: &App, area: Rect) { let line = format!(" {line}"); for row in wrap_visual_rows(&line, inner_width) { let body_bg = if selected && row_visible(visual_row, viewport_start, viewport_end) { - Color::DarkGray + THEME.message_highlight } else { - Color::Reset + THEME.background }; lines.push(Line::from(Span::styled( row, - Style::default().fg(Color::White).bg(body_bg), + Style::default().fg(THEME.text).bg(body_bg), ))); visual_row += 1; } diff --git a/src/tui/ui/usage.rs b/src/tui/ui/usage.rs index 2836831..d878112 100644 --- a/src/tui/ui/usage.rs +++ b/src/tui/ui/usage.rs @@ -10,6 +10,7 @@ use ratatui::widgets::{Block, Borders, Paragraph}; use crate::db::search::TimeRange; use crate::skill_audit::{SkillTier, SkillUsageEntry, format_last_used, format_signals}; use crate::tui::app::App; +use crate::tui::theme::THEME; use crate::tui::usage_state::UsageTab; use crate::usage::{TokenTotals, UsageReport}; @@ -50,10 +51,10 @@ pub(super) fn render_usage_header(f: &mut Frame, app: &App, area: Rect) { let block = Block::default() .title(" Recall Usage Dashboard ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(THEME.border_focus)); - let chip = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let muted = Style::default().fg(Color::DarkGray); + let chip = Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD); + let muted = Style::default().fg(THEME.text_muted); let control = vec![ Span::styled(" range ", muted), @@ -69,7 +70,7 @@ pub(super) fn render_usage_header(f: &mut Frame, app: &App, area: Rect) { if app.usage_is_loading() { lines.push(Line::from(Span::styled( " Loading usage data...", - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); } else if let Some(report) = app.usage_report.as_ref() { @@ -86,29 +87,29 @@ pub(super) fn render_usage_header(f: &mut Frame, app: &App, area: Rect) { Span::styled(" tokens ", muted), Span::styled( format_compact(report.summary.tokens.total_tokens), - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.success).add_modifier(Modifier::BOLD), ), Span::styled(" sessions ", muted), Span::styled( format_count(report.summary.sessions), - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.text).add_modifier(Modifier::BOLD), ), Span::styled(" events ", muted), - Span::styled(format_count(report.summary.events), Style::default().fg(Color::White)), + Span::styled(format_count(report.summary.events), Style::default().fg(THEME.text)), Span::styled(" active-days ", muted), - Span::styled(format_count(active_days), Style::default().fg(Color::White)), + Span::styled(format_count(active_days), Style::default().fg(THEME.text)), ])); lines.push(Line::from(vec![ Span::styled(" top-source ", muted), Span::styled( truncate_label(top_source, 18), - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.highlight).add_modifier(Modifier::BOLD), ), Span::styled(" top-model ", muted), - Span::styled(truncate_label(top_model, 32), Style::default().fg(Color::White)), + Span::styled(truncate_label(top_model, 32), Style::default().fg(THEME.text)), ])); } else if let Some(error) = app.usage_error.as_ref() { - lines.push(Line::from(Span::styled(error.clone(), Style::default().fg(Color::Red)))); + lines.push(Line::from(Span::styled(error.clone(), Style::default().fg(THEME.error)))); lines.push(Line::from("")); } else { lines.push(Line::from(Span::styled("No usage data loaded", muted))); @@ -122,12 +123,12 @@ pub(super) fn render_activity_map(f: &mut Frame, app: &App, area: Rect) { let block = Block::default() .title(" Token Activity Map ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Green)); + .border_style(Style::default().fg(THEME.success)); if app.usage_is_loading() { f.render_widget( Paragraph::new("Loading usage data...") - .style(Style::default().fg(Color::Yellow)) + .style(Style::default().fg(THEME.accent)) .block(block), area, ); @@ -138,7 +139,7 @@ pub(super) fn render_activity_map(f: &mut Frame, app: &App, area: Rect) { let Some(report) = report else { f.render_widget( Paragraph::new("No usage events") - .style(Style::default().fg(Color::DarkGray)) + .style(Style::default().fg(THEME.text_muted)) .block(block), area, ); @@ -177,8 +178,8 @@ pub(super) fn render_activity_map(f: &mut Frame, app: &App, area: Rect) { cells.push_str(&heatmap_cell(value, max_value).to_string().repeat(cell_width)); } lines.push(Line::from(vec![ - Span::styled(format!(" {label} "), Style::default().fg(Color::DarkGray)), - Span::styled(cells, Style::default().fg(Color::Green)), + Span::styled(format!(" {label} "), Style::default().fg(THEME.text_muted)), + Span::styled(cells, Style::default().fg(THEME.success)), ])); } @@ -189,12 +190,12 @@ pub(super) fn render_daily_token_chart(f: &mut Frame, app: &App, area: Rect) { let block = Block::default() .title(" Daily Token Usage ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(THEME.border_focus)); if app.usage_is_loading() { f.render_widget( Paragraph::new("Loading usage data...") - .style(Style::default().fg(Color::Yellow)) + .style(Style::default().fg(THEME.accent)) .block(block), area, ); @@ -204,7 +205,7 @@ pub(super) fn render_daily_token_chart(f: &mut Frame, app: &App, area: Rect) { let Some(report) = app.usage_report.as_ref() else { f.render_widget( Paragraph::new("No token usage") - .style(Style::default().fg(Color::DarkGray)) + .style(Style::default().fg(THEME.text_muted)) .block(block), area, ); @@ -219,7 +220,7 @@ pub(super) fn render_daily_token_chart(f: &mut Frame, app: &App, area: Rect) { if points.is_empty() || max_tokens == 0 { f.render_widget( Paragraph::new("No token usage in this range") - .style(Style::default().fg(Color::DarkGray)) + .style(Style::default().fg(THEME.text_muted)) .block(block), area, ); @@ -246,17 +247,17 @@ pub(super) fn render_daily_token_chart(f: &mut Frame, app: &App, area: Rect) { } } lines.push(Line::from(vec![ - Span::styled(format!("{label} "), Style::default().fg(Color::DarkGray)), - Span::styled(bars, Style::default().fg(Color::Cyan)), + Span::styled(format!("{label} "), Style::default().fg(THEME.text_muted)), + Span::styled(bars, Style::default().fg(THEME.highlight)), ])); } if let (Some((first, _)), Some((last, _))) = (points.first(), points.last()) { lines.push(Line::from(vec![ - Span::styled(" ", Style::default().fg(Color::DarkGray)), + Span::styled(" ", Style::default().fg(THEME.text_muted)), Span::styled( endpoint_labels(first, last, plot_width), - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.text_muted), ), ])); } @@ -268,12 +269,12 @@ pub(super) fn render_usage_breakdown(f: &mut Frame, app: &App, area: Rect) { let block = Block::default() .title(" Breakdown ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)); + .border_style(Style::default().fg(THEME.accent)); if app.usage_is_loading() { f.render_widget( Paragraph::new("Loading usage data...") - .style(Style::default().fg(Color::Yellow)) + .style(Style::default().fg(THEME.accent)) .block(block), area, ); @@ -283,7 +284,7 @@ pub(super) fn render_usage_breakdown(f: &mut Frame, app: &App, area: Rect) { let Some(report) = app.usage_report.as_ref() else { f.render_widget( Paragraph::new("No usage data") - .style(Style::default().fg(Color::DarkGray)) + .style(Style::default().fg(THEME.text_muted)) .block(block), area, ); @@ -327,7 +328,7 @@ fn build_usage_source_lines( report.by_source.iter().map(|source| source.tokens.total_tokens).max().unwrap_or(0); let mut lines = vec![Line::from(Span::styled( format!(" Sources ({})", report.by_source.len()), - Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.text_muted).add_modifier(Modifier::BOLD), ))]; for source in &report.by_source { @@ -336,11 +337,11 @@ fn build_usage_source_lines( source.tokens.total_tokens, source_max, inner_width, - Color::Cyan, + THEME.highlight, )); } if report.by_source.is_empty() { - lines.push(Line::from(Span::styled(" -", Style::default().fg(Color::DarkGray)))); + lines.push(Line::from(Span::styled(" -", Style::default().fg(THEME.text_muted)))); } lines.push(Line::from("")); lines @@ -349,7 +350,7 @@ fn build_usage_source_lines( fn build_usage_token_mix_lines(report: &UsageReport, inner_width: usize) -> Vec> { let mut lines = vec![Line::from(Span::styled( " Token Mix", - Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.text_muted).add_modifier(Modifier::BOLD), ))]; lines.extend(token_mix_lines(&report.summary.tokens, inner_width)); lines.push(Line::from("")); @@ -365,7 +366,7 @@ fn build_usage_model_lines( report.by_model.iter().map(|model| model.tokens.total_tokens).max().unwrap_or(0); let mut lines = vec![Line::from(Span::styled( format!(" Models ({})", report.by_model.len()), - Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.text_muted).add_modifier(Modifier::BOLD), ))]; for model in &report.by_model { @@ -375,11 +376,11 @@ fn build_usage_model_lines( model.tokens.total_tokens, model_max, inner_width, - Color::Green, + THEME.success, )); } if report.by_model.is_empty() { - lines.push(Line::from(Span::styled(" -", Style::default().fg(Color::DarkGray)))); + lines.push(Line::from(Span::styled(" -", Style::default().fg(THEME.text_muted)))); } lines } @@ -387,34 +388,34 @@ fn build_usage_model_lines( pub(super) fn render_usage_status(f: &mut Frame, app: &App, area: Rect) { let line = match app.usage_tab { UsageTab::Tokens => Line::from(vec![ - Span::styled("m", Style::default().fg(Color::Yellow)), - Span::styled(" tab ", Style::default().fg(Color::DarkGray)), - Span::styled("t", Style::default().fg(Color::Yellow)), - Span::styled(" time ", Style::default().fg(Color::DarkGray)), - Span::styled("s", Style::default().fg(Color::Yellow)), - Span::styled(" source ", Style::default().fg(Color::DarkGray)), - Span::styled("↑↓", Style::default().fg(Color::Yellow)), - Span::styled(" breakdown ", Style::default().fg(Color::DarkGray)), - Span::styled("r", Style::default().fg(Color::Yellow)), - Span::styled(" reset ", Style::default().fg(Color::DarkGray)), - Span::styled("Esc/q", Style::default().fg(Color::Yellow)), - Span::styled(" quit", Style::default().fg(Color::DarkGray)), + Span::styled("m", Style::default().fg(THEME.accent)), + Span::styled(" tab ", Style::default().fg(THEME.text_muted)), + Span::styled("t", Style::default().fg(THEME.accent)), + Span::styled(" time ", Style::default().fg(THEME.text_muted)), + Span::styled("s", Style::default().fg(THEME.accent)), + Span::styled(" source ", Style::default().fg(THEME.text_muted)), + Span::styled("↑↓", Style::default().fg(THEME.accent)), + Span::styled(" breakdown ", Style::default().fg(THEME.text_muted)), + Span::styled("r", Style::default().fg(THEME.accent)), + Span::styled(" reset ", Style::default().fg(THEME.text_muted)), + Span::styled("Esc/q", Style::default().fg(THEME.accent)), + Span::styled(" quit", Style::default().fg(THEME.text_muted)), ]), UsageTab::Skills => Line::from(vec![ - Span::styled("m", Style::default().fg(Color::Yellow)), - Span::styled(" tab ", Style::default().fg(Color::DarkGray)), - Span::styled("t", Style::default().fg(Color::Yellow)), - Span::styled(" time ", Style::default().fg(Color::DarkGray)), - Span::styled("s", Style::default().fg(Color::Yellow)), - Span::styled(" source ", Style::default().fg(Color::DarkGray)), - Span::styled("↑↓", Style::default().fg(Color::Yellow)), - Span::styled(" select ", Style::default().fg(Color::DarkGray)), - Span::styled("Enter", Style::default().fg(Color::Yellow)), - Span::styled(" sessions ", Style::default().fg(Color::DarkGray)), - Span::styled("r", Style::default().fg(Color::Yellow)), - Span::styled(" reset ", Style::default().fg(Color::DarkGray)), - Span::styled("Esc/q", Style::default().fg(Color::Yellow)), - Span::styled(" quit", Style::default().fg(Color::DarkGray)), + Span::styled("m", Style::default().fg(THEME.accent)), + Span::styled(" tab ", Style::default().fg(THEME.text_muted)), + Span::styled("t", Style::default().fg(THEME.accent)), + Span::styled(" time ", Style::default().fg(THEME.text_muted)), + Span::styled("s", Style::default().fg(THEME.accent)), + Span::styled(" source ", Style::default().fg(THEME.text_muted)), + Span::styled("↑↓", Style::default().fg(THEME.accent)), + Span::styled(" select ", Style::default().fg(THEME.text_muted)), + Span::styled("Enter", Style::default().fg(THEME.accent)), + Span::styled(" sessions ", Style::default().fg(THEME.text_muted)), + Span::styled("r", Style::default().fg(THEME.accent)), + Span::styled(" reset ", Style::default().fg(THEME.text_muted)), + Span::styled("Esc/q", Style::default().fg(THEME.accent)), + Span::styled(" quit", Style::default().fg(THEME.text_muted)), ]), }; f.render_widget(Paragraph::new(line), area); @@ -544,10 +545,10 @@ fn usage_bar_line( Line::from(vec![ Span::styled( format!(" {: Vec> { .unwrap_or(0); [ - ("input", tokens.input_tokens, Color::Cyan), - ("output", tokens.output_tokens, Color::Green), - ("cache read", tokens.cache_read_tokens, Color::Blue), - ("cache write", tokens.cache_write_tokens, Color::Magenta), - ("reasoning", tokens.reasoning_tokens, Color::Yellow), + ("input", tokens.input_tokens, THEME.token_input), + ("output", tokens.output_tokens, THEME.token_output), + ("cache read", tokens.cache_read_tokens, THEME.token_cache_read), + ("cache write", tokens.cache_write_tokens, THEME.token_cache_write), + ("reasoning", tokens.reasoning_tokens, THEME.token_reasoning), ] .into_iter() .map(|(label, value, color)| usage_bar_line(label, value, max_value, width, color)) @@ -591,10 +592,10 @@ pub(super) fn render_skill_audit_header(f: &mut Frame, app: &App, area: Rect) { let block = Block::default() .title(" Recall Skill Audit ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Magenta)); + .border_style(Style::default().fg(THEME.skill)); - let chip = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let muted = Style::default().fg(Color::DarkGray); + let chip = Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD); + let muted = Style::default().fg(THEME.text_muted); let control = vec![ Span::styled(" range ", muted), @@ -610,36 +611,39 @@ pub(super) fn render_skill_audit_header(f: &mut Frame, app: &App, area: Rect) { if app.usage_is_loading() { lines.push(Line::from(Span::styled( " Loading skill audit...", - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD), ))); } else if let Some(error) = app.skill_audit_error.as_ref() { - lines.push(Line::from(Span::styled(error.clone(), Style::default().fg(Color::Red)))); + lines.push(Line::from(Span::styled(error.clone(), Style::default().fg(THEME.error)))); } else if let Some(report) = app.skill_audit_report.as_ref() { lines.push(Line::from(vec![ Span::styled(" installed ", muted), Span::styled( format_count(report.summary.installed), - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.text).add_modifier(Modifier::BOLD), ), Span::styled(" core ", muted), Span::styled( format_count(report.summary.core), - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.success).add_modifier(Modifier::BOLD), ), Span::styled(" occasional ", muted), - Span::styled(format_count(report.summary.occasional), Style::default().fg(Color::Cyan)), + Span::styled( + format_count(report.summary.occasional), + Style::default().fg(THEME.highlight), + ), Span::styled(" dormant ", muted), Span::styled( format_count(report.summary.dormant), - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.text_muted), ), ])); lines.push(Line::from(Span::styled( " core ≥10 calls · occasional 1-9 · dormant installed but unused in range", - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.text_muted), ))); if let Some(note) = report.coverage_note.as_ref() { - lines.push(Line::from(Span::styled(note.clone(), Style::default().fg(Color::Yellow)))); + lines.push(Line::from(Span::styled(note.clone(), Style::default().fg(THEME.accent)))); } } else { lines.push(Line::from(Span::styled("No skill audit loaded", muted))); @@ -652,12 +656,12 @@ pub(super) fn render_skill_audit_list(f: &mut Frame, app: &App, area: Rect) { let block = Block::default() .title(" Skills ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Magenta)); + .border_style(Style::default().fg(THEME.skill)); if app.usage_is_loading() { f.render_widget( Paragraph::new("Loading skill audit...") - .style(Style::default().fg(Color::Yellow)) + .style(Style::default().fg(THEME.accent)) .block(block), area, ); @@ -667,7 +671,7 @@ pub(super) fn render_skill_audit_list(f: &mut Frame, app: &App, area: Rect) { let Some(report) = app.skill_audit_report.as_ref() else { f.render_widget( Paragraph::new("No skill audit data") - .style(Style::default().fg(Color::DarkGray)) + .style(Style::default().fg(THEME.text_muted)) .block(block), area, ); @@ -713,7 +717,7 @@ fn build_skill_audit_lines( } lines.push(Line::from(Span::styled( format!(" {title} ({})", entries.len()), - Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD), + Style::default().fg(THEME.text_muted).add_modifier(Modifier::BOLD), ))); for entry in entries { if entry_index == selected { @@ -728,7 +732,7 @@ fn build_skill_audit_lines( if lines.is_empty() { lines.push(Line::from(Span::styled( " No personal skills found in ~/.claude/skills, ~/.codex/skills, or ~/.agents/skills", - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.text_muted), ))); } @@ -752,7 +756,7 @@ fn skill_audit_columns(width: usize) -> SkillAuditColumns { } fn skill_audit_table_header(cols: &SkillAuditColumns) -> Line<'static> { - let style = Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD); + let style = Style::default().fg(THEME.text_muted).add_modifier(Modifier::BOLD); Line::from(vec![ Span::styled(format!(" {:calls_w$}", "CALLS", calls_w = cols.calls), style), @@ -764,7 +768,7 @@ fn skill_audit_table_header(cols: &SkillAuditColumns) -> Line<'static> { fn skill_audit_metrics_legend() -> Line<'static> { Line::from(Span::styled( " CALLS session uses · LAST last use · VIA invoke/read/both", - Style::default().fg(Color::DarkGray), + Style::default().fg(THEME.text_muted), )) } @@ -775,14 +779,14 @@ fn skill_audit_entry_line( cols: &SkillAuditColumns, ) -> Line<'static> { let base = if selected { - Style::default().fg(Color::Black).bg(Color::Magenta).add_modifier(Modifier::BOLD) + Style::default().fg(THEME.selected_fg).bg(THEME.skill).add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::White) + Style::default().fg(THEME.text) }; let muted = if selected { - Style::default().fg(Color::Black).bg(Color::Magenta) + Style::default().fg(THEME.selected_fg).bg(THEME.skill) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(THEME.text_muted) }; let skill = truncate_label(&entry.id, cols.skill); diff --git a/src/tui/ui/viewing.rs b/src/tui/ui/viewing.rs index 0aba7ab..1deafae 100644 --- a/src/tui/ui/viewing.rs +++ b/src/tui/ui/viewing.rs @@ -1,6 +1,6 @@ use ratatui::Frame; use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use unicode_width::UnicodeWidthStr; @@ -8,6 +8,7 @@ use unicode_width::UnicodeWidthStr; use crate::tui::app::App; use crate::tui::layout::viewing_layout; use crate::tui::text_layout::wrap_spans_to_lines; +use crate::tui::theme::THEME; use crate::tui::viewing_state::SanitizedLine; use crate::types::Role; @@ -34,7 +35,7 @@ pub(super) fn render_viewing(f: &mut Frame, app: &App) { let block = Block::default() .title(session_info) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(THEME.border_focus)); f.render_widget(block, layout.content); let inner_width = layout.messages.width as usize; @@ -52,15 +53,15 @@ pub(super) fn render_viewing(f: &mut Frame, app: &App) { for (i, msg) in app.viewing_messages.iter().enumerate() { let selected = i == app.viewing_selected_msg; let (prefix, color) = match msg.role { - Role::User => ("User", Color::Cyan), - Role::Assistant => ("Assistant", Color::Green), + Role::User => ("User", THEME.user), + Role::Assistant => ("Assistant", THEME.assistant), }; let time_str = crate::utils::format_message_time(msg.timestamp); let header_bg = if selected && row_visible(visual_row, viewport_start, viewport_end) { - Color::DarkGray + THEME.message_highlight } else { - Color::Reset + THEME.background }; let mut header = vec![Span::styled( format!("── {prefix} ──"), @@ -69,7 +70,7 @@ pub(super) fn render_viewing(f: &mut Frame, app: &App) { if !time_str.is_empty() { header.push(Span::styled( format!(" {time_str}"), - Style::default().fg(Color::DarkGray).bg(header_bg), + Style::default().fg(THEME.text_muted).bg(header_bg), )); } lines.push(Line::from(header)); @@ -78,13 +79,13 @@ pub(super) fn render_viewing(f: &mut Frame, app: &App) { let empty: Vec = Vec::new(); let cached_lines = app.viewing_sanitized_lines.get(i).unwrap_or(&empty); for sl in cached_lines { - let body_style = Style::default().fg(Color::White); + let body_style = Style::default().fg(THEME.text); let spans = highlight_spans(&sl.text, &sl.lower, &needles, body_style); for line in wrap_spans_to_lines(spans, inner_width) { let body_bg = if selected && row_visible(visual_row, viewport_start, viewport_end) { - Color::DarkGray + THEME.message_highlight } else { - Color::Reset + THEME.background }; lines.push(line_with_background(line, body_bg)); visual_row += 1; @@ -106,39 +107,39 @@ pub(super) fn render_viewing(f: &mut Frame, app: &App) { ); let help_spans = vec![ - Span::styled(" ↑/↓", Style::default().fg(Color::Yellow)), - Span::styled(" messages ", Style::default().fg(Color::DarkGray)), - Span::styled("/", Style::default().fg(Color::Yellow)), - Span::styled(" find ", Style::default().fg(Color::DarkGray)), - Span::styled("n/N", Style::default().fg(Color::Yellow)), - Span::styled(" next/prev ", Style::default().fg(Color::DarkGray)), - Span::styled("c", Style::default().fg(Color::Yellow)), - Span::styled(" copy ", Style::default().fg(Color::DarkGray)), - Span::styled("e", Style::default().fg(Color::Yellow)), - Span::styled(" export ", Style::default().fg(Color::DarkGray)), - Span::styled("s", Style::default().fg(Color::Yellow)), - Span::styled(" share ", Style::default().fg(Color::DarkGray)), - Span::styled("h", Style::default().fg(Color::Yellow)), - Span::styled(" handoff ", Style::default().fg(Color::DarkGray)), - Span::styled("Ctrl+R", Style::default().fg(Color::Yellow)), - Span::styled(" resume ", Style::default().fg(Color::DarkGray)), - Span::styled("Ctrl+O", Style::default().fg(Color::Yellow)), - Span::styled(" app ", Style::default().fg(Color::DarkGray)), - Span::styled("Esc/q", Style::default().fg(Color::Yellow)), - Span::styled(" back", Style::default().fg(Color::DarkGray)), + Span::styled(" ↑/↓", Style::default().fg(THEME.accent)), + Span::styled(" messages ", Style::default().fg(THEME.text_muted)), + Span::styled("/", Style::default().fg(THEME.accent)), + Span::styled(" find ", Style::default().fg(THEME.text_muted)), + Span::styled("n/N", Style::default().fg(THEME.accent)), + Span::styled(" next/prev ", Style::default().fg(THEME.text_muted)), + Span::styled("c", Style::default().fg(THEME.accent)), + Span::styled(" copy ", Style::default().fg(THEME.text_muted)), + Span::styled("e", Style::default().fg(THEME.accent)), + Span::styled(" export ", Style::default().fg(THEME.text_muted)), + Span::styled("s", Style::default().fg(THEME.accent)), + Span::styled(" share ", Style::default().fg(THEME.text_muted)), + Span::styled("h", Style::default().fg(THEME.accent)), + Span::styled(" handoff ", Style::default().fg(THEME.text_muted)), + Span::styled("Ctrl+R", Style::default().fg(THEME.accent)), + Span::styled(" resume ", Style::default().fg(THEME.text_muted)), + Span::styled("Ctrl+O", Style::default().fg(THEME.accent)), + Span::styled(" app ", Style::default().fg(THEME.text_muted)), + Span::styled("Esc/q", Style::default().fg(THEME.accent)), + Span::styled(" back", Style::default().fg(THEME.text_muted)), ]; let status_line = if let Some(ref input) = app.viewing_search_input { Line::from(vec![ - Span::styled(" /", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Span::styled(input.clone(), Style::default().fg(Color::White)), + Span::styled(" /", Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD)), + Span::styled(input.clone(), Style::default().fg(THEME.text)), ]) } else if let Some(ref msg) = app.status_message { - Line::from(vec![Span::styled(format!(" {msg}"), Style::default().fg(Color::Green))]) + Line::from(vec![Span::styled(format!(" {msg}"), Style::default().fg(THEME.success))]) } else if let Some(ref note) = app.viewing_search_status { Line::from(vec![Span::styled( format!(" {note}: \"{}\"", app.viewing_search_query), - Style::default().fg(Color::Red), + Style::default().fg(THEME.error), )]) } else if !app.viewing_search_query.is_empty() { let matches = app.viewing_match_indices(); @@ -148,7 +149,7 @@ pub(super) fn render_viewing(f: &mut Frame, app: &App) { let mut spans = help_spans.clone(); spans.push(Span::styled( format!(" [{current_pos}/{total} \"{}\"]", app.viewing_search_query), - Style::default().fg(Color::Yellow), + Style::default().fg(THEME.accent), )); Line::from(spans) } else { @@ -165,7 +166,7 @@ pub(super) fn render_viewing(f: &mut Frame, app: &App) { } pub(super) fn render_viewing_summary(f: &mut Frame, app: &App, area: Rect) { let text = viewing_summary_text(app, area.width as usize); - let line = Line::from(Span::styled(text, Style::default().fg(Color::Green))); + let line = Line::from(Span::styled(text, Style::default().fg(THEME.summary))); f.render_widget(Paragraph::new(line), area); }