fix(tui): test fix, theme migration, zero hardcoded colors in rendering

- Fix failing test_filter_by_label: use 'Z' (matches nothing) and 'Help'
  (matches specific entries) instead of 'e' which matched everything
- Replace all 36 hardcoded Color:: in tui.rs and markdown.rs with theme
  lookups (theme.conversation_user, theme.dashboard_key, etc.)
- MarkdownRenderer now stores a TuiTheme, render_diff() accepts &TuiTheme
- section() and kv() helpers take theme param for key/color lookups
- draw_left_pane/draw_right_pane use theme for all colors
- push_user_input/push_system_message/push_output read from self.theme
- Only remaining Color:: in production code: Color::Reset (unstyled bg)
  and Color::Rgb (dynamic syntect output) — both acceptable

283 tests pass, 0 failures. Clippy clean. Workspace builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
TheArchitectit 2026-06-12 10:25:11 -05:00
parent d4ed89e61c
commit 9ed9df543b
3 changed files with 118 additions and 80 deletions

View File

@ -183,9 +183,18 @@ mod tests {
fn test_filter_by_label() { fn test_filter_by_label() {
let mut cp = CommandPalette::new(); let mut cp = CommandPalette::new();
cp.open(); cp.open();
// 'Z' doesn't appear in any default entry label/description/category,
// so it should filter down to zero results.
cp.input('Z');
assert!(cp.filtered.is_empty());
// A more specific query that matches only some entries
cp.backspace();
cp.input('H');
cp.input('e'); cp.input('e');
// Should filter to entries containing 'e' cp.input('l');
cp.input('p');
assert!(cp.filtered.len() < cp.entries.len()); assert!(cp.filtered.len() < cp.entries.len());
assert!(cp.filtered.len() >= 1);
} }
#[test] #[test]

View File

@ -16,12 +16,15 @@ pub static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
#[derive(Clone)] #[derive(Clone)]
pub struct MarkdownRenderer { pub struct MarkdownRenderer {
code_theme_name: String, code_theme_name: String,
theme: crate::theme::TuiTheme,
} }
impl MarkdownRenderer { impl MarkdownRenderer {
pub fn new() -> Self { pub fn new(theme: crate::theme::TuiTheme) -> Self {
let code_theme_name = theme.syntax_theme.clone();
Self { Self {
code_theme_name: "base16-ocean.dark".to_string(), code_theme_name,
theme,
} }
} }
@ -29,6 +32,11 @@ impl MarkdownRenderer {
self.code_theme_name = name.to_string(); self.code_theme_name = name.to_string();
} }
pub fn set_theme(&mut self, theme: crate::theme::TuiTheme) {
self.code_theme_name = theme.syntax_theme.clone();
self.theme = theme;
}
/// Render a markdown string into ratatui Lines at the given terminal width. /// Render a markdown string into ratatui Lines at the given terminal width.
pub fn render(&self, markdown: &str, width: u16) -> Vec<Line<'static>> { pub fn render(&self, markdown: &str, width: u16) -> Vec<Line<'static>> {
let parser = Parser::new(markdown); let parser = Parser::new(markdown);
@ -52,7 +60,7 @@ impl MarkdownRenderer {
flush_line(&mut lines, &mut current_spans); flush_line(&mut lines, &mut current_spans);
style_stack.push( style_stack.push(
Style::default() Style::default()
.fg(Color::Cyan) .fg(self.theme.conversation_user.to_color())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
); );
} }
@ -84,8 +92,8 @@ impl MarkdownRenderer {
} }
Tag::BlockQuote(_) => { Tag::BlockQuote(_) => {
current_spans current_spans
.push(Span::styled("", Style::default().fg(Color::DarkGray))); .push(Span::styled("", Style::default().fg(self.theme.conversation_dim.to_color())));
style_stack.push(Style::default().fg(Color::DarkGray)); style_stack.push(Style::default().fg(self.theme.conversation_dim.to_color()));
} }
Tag::Emphasis => { Tag::Emphasis => {
style_stack.push(Style::default().add_modifier(Modifier::ITALIC)); style_stack.push(Style::default().add_modifier(Modifier::ITALIC));
@ -96,7 +104,7 @@ impl MarkdownRenderer {
Tag::Link { .. } => { Tag::Link { .. } => {
style_stack.push( style_stack.push(
Style::default() Style::default()
.fg(Color::Blue) .fg(self.theme.conversation_user.to_color())
.add_modifier(Modifier::UNDERLINED), .add_modifier(Modifier::UNDERLINED),
); );
} }
@ -144,7 +152,7 @@ impl MarkdownRenderer {
} }
} }
Event::Code(code) => { Event::Code(code) => {
let style = Style::default().fg(Color::Yellow).bg(Color::DarkGray); let style = Style::default().fg(self.theme.conversation_system.to_color()).bg(self.theme.code_bg.to_color());
current_spans.push(Span::styled(format!(" {code} "), style)); current_spans.push(Span::styled(format!(" {code} "), style));
} }
Event::SoftBreak | Event::HardBreak => { Event::SoftBreak | Event::HardBreak => {
@ -158,7 +166,7 @@ impl MarkdownRenderer {
flush_line(&mut lines, &mut current_spans); flush_line(&mut lines, &mut current_spans);
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
"".repeat(width as usize), "".repeat(width as usize),
Style::default().fg(Color::DarkGray), Style::default().fg(self.theme.conversation_dim.to_color()),
))); )));
} }
_ => {} _ => {}
@ -186,7 +194,7 @@ impl MarkdownRenderer {
let lang_label = language.unwrap_or("text"); let lang_label = language.unwrap_or("text");
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
format!("╭─ {lang_label}"), format!("╭─ {lang_label}"),
Style::default().fg(Color::DarkGray), Style::default().fg(self.theme.code_language_label.to_color()),
))); )));
// syntect 5.x: HighlightLines::new() returns directly (not Result) // syntect 5.x: HighlightLines::new() returns directly (not Result)
@ -197,7 +205,7 @@ impl MarkdownRenderer {
Ok(ranges) => { Ok(ranges) => {
let mut line_spans: Vec<Span<'static>> = vec![Span::styled( let mut line_spans: Vec<Span<'static>> = vec![Span::styled(
"", "",
Style::default().fg(Color::DarkGray), Style::default().fg(self.theme.code_border.to_color()),
)]; )];
for (style, text) in ranges { for (style, text) in ranges {
let fg = style.foreground; let fg = style.foreground;
@ -210,7 +218,7 @@ impl MarkdownRenderer {
} }
Err(_) => { Err(_) => {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled("", Style::default().fg(Color::DarkGray)), Span::styled("", Style::default().fg(self.theme.code_border.to_color())),
Span::raw(line.to_string()), Span::raw(line.to_string()),
])); ]));
} }
@ -219,7 +227,7 @@ impl MarkdownRenderer {
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
"╰─", "╰─",
Style::default().fg(Color::DarkGray), Style::default().fg(self.theme.code_border.to_color()),
))); )));
lines lines
} }
@ -249,19 +257,19 @@ pub fn looks_like_markdown(text: &str) -> bool {
} }
/// Render a unified diff with color-coded lines. /// Render a unified diff with color-coded lines.
pub fn render_diff(diff: &str) -> Vec<Line<'static>> { pub fn render_diff(diff: &str, theme: &crate::theme::TuiTheme) -> Vec<Line<'static>> {
diff.lines() diff.lines()
.map(|raw_line| { .map(|raw_line| {
let (text, color) = if raw_line.starts_with("+++") || raw_line.starts_with("---") { let (text, color) = if raw_line.starts_with("+++") || raw_line.starts_with("---") {
(raw_line.to_string(), Color::White) (raw_line.to_string(), theme.conversation_text.to_color())
} else if raw_line.starts_with("@@") { } else if raw_line.starts_with("@@") {
(raw_line.to_string(), Color::Cyan) (raw_line.to_string(), theme.conversation_user.to_color())
} else if raw_line.starts_with('+') { } else if raw_line.starts_with('+') {
(raw_line.to_string(), Color::Green) (raw_line.to_string(), theme.agent_done.to_color())
} else if raw_line.starts_with('-') { } else if raw_line.starts_with('-') {
(raw_line.to_string(), Color::Red) (raw_line.to_string(), theme.conversation_error.to_color())
} else { } else {
(raw_line.to_string(), Color::DarkGray) (raw_line.to_string(), theme.conversation_dim.to_color())
}; };
Line::from(Span::styled(text, Style::default().fg(color))) Line::from(Span::styled(text, Style::default().fg(color)))
}) })
@ -272,18 +280,22 @@ pub fn render_diff(diff: &str) -> Vec<Line<'static>> {
mod tests { mod tests {
use super::*; use super::*;
fn test_theme() -> crate::theme::TuiTheme {
crate::theme::TuiTheme::builtin("default").unwrap()
}
// --- Markdown rendering --- // --- Markdown rendering ---
#[test] #[test]
fn test_plain_paragraph() { fn test_plain_paragraph() {
let r = MarkdownRenderer::new(); let r = MarkdownRenderer::new(test_theme());
let lines = r.render("Just a paragraph.", 80); let lines = r.render("Just a paragraph.", 80);
assert_eq!(lines.len(), 2); // text + blank assert_eq!(lines.len(), 2); // text + blank
} }
#[test] #[test]
fn test_h1_rendering() { fn test_h1_rendering() {
let r = MarkdownRenderer::new(); let r = MarkdownRenderer::new(test_theme());
let lines = r.render("# Hello", 80); let lines = r.render("# Hello", 80);
assert!(lines[0] assert!(lines[0]
.spans .spans
@ -293,7 +305,7 @@ mod tests {
#[test] #[test]
fn test_rust_code_block() { fn test_rust_code_block() {
let r = MarkdownRenderer::new(); let r = MarkdownRenderer::new(test_theme());
let lines = r.render("```rust\nfn main() {\n println!(\"hi\");\n}\n```", 80); let lines = r.render("```rust\nfn main() {\n println!(\"hi\");\n}\n```", 80);
// At minimum: top border + at least 1 code line + bottom border // At minimum: top border + at least 1 code line + bottom border
assert!(lines.len() >= 3); assert!(lines.len() >= 3);
@ -306,7 +318,7 @@ mod tests {
#[test] #[test]
fn test_python_code_block() { fn test_python_code_block() {
let r = MarkdownRenderer::new(); let r = MarkdownRenderer::new(test_theme());
let lines = r.render("```python\ndef hello():\n pass\n```", 80); let lines = r.render("```python\ndef hello():\n pass\n```", 80);
assert!(lines.len() >= 3); assert!(lines.len() >= 3);
let text: String = lines.iter().map(|l| { let text: String = lines.iter().map(|l| {
@ -317,21 +329,21 @@ mod tests {
#[test] #[test]
fn test_inline_code() { fn test_inline_code() {
let r = MarkdownRenderer::new(); let r = MarkdownRenderer::new(test_theme());
let lines = r.render("Use `git status` to check", 80); let lines = r.render("Use `git status` to check", 80);
assert_eq!(lines.len(), 2); assert_eq!(lines.len(), 2);
} }
#[test] #[test]
fn test_bold_and_italic() { fn test_bold_and_italic() {
let r = MarkdownRenderer::new(); let r = MarkdownRenderer::new(test_theme());
let lines = r.render("**bold** and *italic*", 80); let lines = r.render("**bold** and *italic*", 80);
assert_eq!(lines.len(), 2); assert_eq!(lines.len(), 2);
} }
#[test] #[test]
fn test_nested_list() { fn test_nested_list() {
let r = MarkdownRenderer::new(); let r = MarkdownRenderer::new(test_theme());
let lines = r.render("- item 1\n- item 2\n - nested", 80); let lines = r.render("- item 1\n- item 2\n - nested", 80);
// At least 2 list items rendered (nested may be part of item 2) // At least 2 list items rendered (nested may be part of item 2)
assert!(lines.len() >= 2); assert!(lines.len() >= 2);
@ -339,7 +351,7 @@ mod tests {
#[test] #[test]
fn test_blockquote() { fn test_blockquote() {
let r = MarkdownRenderer::new(); let r = MarkdownRenderer::new(test_theme());
let lines = r.render("> quoted\n> text", 80); let lines = r.render("> quoted\n> text", 80);
// 2 blockquote lines + possible blank lines // 2 blockquote lines + possible blank lines
assert!(lines.len() >= 2); assert!(lines.len() >= 2);
@ -347,21 +359,21 @@ mod tests {
#[test] #[test]
fn test_horizontal_rule() { fn test_horizontal_rule() {
let r = MarkdownRenderer::new(); let r = MarkdownRenderer::new(test_theme());
let lines = r.render("---", 80); let lines = r.render("---", 80);
assert_eq!(lines.len(), 1); assert_eq!(lines.len(), 1);
} }
#[test] #[test]
fn test_empty_input() { fn test_empty_input() {
let r = MarkdownRenderer::new(); let r = MarkdownRenderer::new(test_theme());
let lines = r.render("", 80); let lines = r.render("", 80);
assert!(lines.is_empty()); assert!(lines.is_empty());
} }
#[test] #[test]
fn test_mixed_content() { fn test_mixed_content() {
let r = MarkdownRenderer::new(); let r = MarkdownRenderer::new(test_theme());
let md = "# Title\n\nSome text with `code`.\n\n```rust\nfn main() {}\n```\n\n- list item"; let md = "# Title\n\nSome text with `code`.\n\n```rust\nfn main() {}\n```\n\n- list item";
let lines = r.render(md, 80); let lines = r.render(md, 80);
// Header + blank + text + blank + code block (3+) + blank + list item + blanks // Header + blank + text + blank + code block (3+) + blank + list item + blanks
@ -370,7 +382,7 @@ mod tests {
#[test] #[test]
fn test_theme_fallback() { fn test_theme_fallback() {
let mut r = MarkdownRenderer::new(); let mut r = MarkdownRenderer::new(test_theme());
r.set_code_theme("nonexistent_theme"); r.set_code_theme("nonexistent_theme");
// Should not panic — falls back gracefully // Should not panic — falls back gracefully
let lines = r.render("```rust\nfn main() {}\n```", 80); let lines = r.render("```rust\nfn main() {}\n```", 80);
@ -416,26 +428,30 @@ mod tests {
#[test] #[test]
fn test_diff_additions_green() { fn test_diff_additions_green() {
let lines = render_diff("+new line"); let theme = test_theme();
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Green)); let lines = render_diff("+new line", &theme);
assert_eq!(lines[0].spans[0].style.fg, Some(theme.agent_done.to_color()));
} }
#[test] #[test]
fn test_diff_deletions_red() { fn test_diff_deletions_red() {
let lines = render_diff("-old line"); let theme = test_theme();
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Red)); let lines = render_diff("-old line", &theme);
assert_eq!(lines[0].spans[0].style.fg, Some(theme.conversation_error.to_color()));
} }
#[test] #[test]
fn test_diff_hunk_header() { fn test_diff_hunk_header() {
let lines = render_diff("@@ -1,3 +1,4 @@"); let theme = test_theme();
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan)); let lines = render_diff("@@ -1,3 +1,4 @@", &theme);
assert_eq!(lines[0].spans[0].style.fg, Some(theme.conversation_user.to_color()));
} }
#[test] #[test]
fn test_diff_file_headers() { fn test_diff_file_headers() {
let lines = render_diff("--- a/file.rs\n+++ b/file.rs"); let theme = test_theme();
assert_eq!(lines[0].spans[0].style.fg, Some(Color::White)); let lines = render_diff("--- a/file.rs\n+++ b/file.rs", &theme);
assert_eq!(lines[1].spans[0].style.fg, Some(Color::White)); assert_eq!(lines[0].spans[0].style.fg, Some(theme.conversation_text.to_color()));
assert_eq!(lines[1].spans[0].style.fg, Some(theme.conversation_text.to_color()));
} }
} }

View File

@ -238,8 +238,8 @@ impl TuiApp {
completion_index: 0, completion_index: 0,
showing_completions: false, showing_completions: false,
spinner_frame: 0, spinner_frame: 0,
markdown_renderer: crate::markdown::MarkdownRenderer::new(), markdown_renderer: crate::markdown::MarkdownRenderer::new(theme.clone()),
theme: crate::theme::TuiTheme::builtin("default").unwrap(), theme,
keymap: crate::keybindings::KeyMap::new(crate::keybindings::KeyPreset::Emacs), keymap: crate::keybindings::KeyMap::new(crate::keybindings::KeyPreset::Emacs),
command_palette: crate::command_palette::CommandPalette::new(), command_palette: crate::command_palette::CommandPalette::new(),
chat_mode: crate::chat_mode::ChatMode::Code, chat_mode: crate::chat_mode::ChatMode::Code,
@ -318,7 +318,7 @@ impl TuiApp {
/// Set a new theme and trigger redraw. /// Set a new theme and trigger redraw.
pub fn set_theme(&mut self, theme: crate::theme::TuiTheme) { pub fn set_theme(&mut self, theme: crate::theme::TuiTheme) {
self.markdown_renderer.set_code_theme(&theme.syntax_theme); self.markdown_renderer.set_theme(theme.clone());
self.theme = theme; self.theme = theme;
self.needs_redraw = true; self.needs_redraw = true;
} }
@ -393,17 +393,19 @@ impl TuiApp {
} }
pub fn push_user_input(&mut self, text: &str) { pub fn push_user_input(&mut self, text: &str) {
let color = self.theme.conversation_user.to_color();
for raw_line in text.lines() { for raw_line in text.lines() {
self.conversation self.conversation
.push(ConversationLine::plain(raw_line.to_string(), Color::Cyan, true)); .push(ConversationLine::plain(raw_line.to_string(), color, true));
} }
self.auto_scroll(); self.auto_scroll();
} }
pub fn push_system_message(&mut self, text: &str) { pub fn push_system_message(&mut self, text: &str) {
let color = self.theme.conversation_system.to_color();
for raw_line in text.lines() { for raw_line in text.lines() {
self.conversation self.conversation
.push(ConversationLine::plain(raw_line.to_string(), Color::Yellow, false)); .push(ConversationLine::plain(raw_line.to_string(), color, false));
} }
self.auto_scroll(); self.auto_scroll();
} }
@ -414,16 +416,18 @@ impl TuiApp {
} }
let clean = crate::tui_update::strip_ansi(text); let clean = crate::tui_update::strip_ansi(text);
if is_error { if is_error {
let color = self.theme.conversation_error.to_color();
for raw_line in clean.lines() { for raw_line in clean.lines() {
self.conversation self.conversation
.push(ConversationLine::plain(raw_line.to_string(), Color::Red, false)); .push(ConversationLine::plain(raw_line.to_string(), color, false));
} }
} else if crate::markdown::looks_like_markdown(&clean) { } else if crate::markdown::looks_like_markdown(&clean) {
self.conversation.push(ConversationLine::markdown(clean)); self.conversation.push(ConversationLine::markdown(clean));
} else { } else {
let color = self.theme.conversation_text.to_color();
for raw_line in clean.lines() { for raw_line in clean.lines() {
self.conversation self.conversation
.push(ConversationLine::plain(raw_line.to_string(), Color::White, false)); .push(ConversationLine::plain(raw_line.to_string(), color, false));
} }
} }
self.auto_scroll(); self.auto_scroll();
@ -465,7 +469,7 @@ impl TuiApp {
// Insert trim notice // Insert trim notice
self.conversation.insert(0, ConversationLine::plain( self.conversation.insert(0, ConversationLine::plain(
"... (earlier messages trimmed)".to_string(), "... (earlier messages trimmed)".to_string(),
Color::DarkGray, self.theme.conversation_dim.to_color(),
false, false,
)); ));
} }
@ -867,6 +871,7 @@ fn draw_frame(
completion_index, completion_index,
showing_completions, showing_completions,
markdown_renderer, markdown_renderer,
theme,
); );
draw_right_pane(f, main[1], dashboard, spinner_frame, theme); draw_right_pane(f, main[1], dashboard, spinner_frame, theme);
} }
@ -937,6 +942,7 @@ fn build_wrapped_conversation<'a>(
conversation: &[ConversationLine], conversation: &[ConversationLine],
content_width: usize, content_width: usize,
markdown_renderer: &crate::markdown::MarkdownRenderer, markdown_renderer: &crate::markdown::MarkdownRenderer,
theme: &crate::theme::TuiTheme,
) -> (Vec<Line<'a>>, Vec<usize>) { ) -> (Vec<Line<'a>>, Vec<usize>) {
let mut all_lines: Vec<Line<'a>> = Vec::new(); let mut all_lines: Vec<Line<'a>> = Vec::new();
let mut expand_counts: Vec<usize> = Vec::new(); let mut expand_counts: Vec<usize> = Vec::new();
@ -966,7 +972,7 @@ fn build_wrapped_conversation<'a>(
})); }));
} }
ConversationContent::CodeDiff { diff } => { ConversationContent::CodeDiff { diff } => {
let rendered = crate::markdown::render_diff(diff); let rendered = crate::markdown::render_diff(diff, theme);
let count = rendered.len().max(1); let count = rendered.len().max(1);
expand_counts.push(count); expand_counts.push(count);
all_lines.extend(rendered.into_iter().map(|l: Line<'static>| { all_lines.extend(rendered.into_iter().map(|l: Line<'static>| {
@ -992,6 +998,7 @@ fn draw_left_pane(
completion_index: usize, completion_index: usize,
showing_completions: bool, showing_completions: bool,
markdown_renderer: &crate::markdown::MarkdownRenderer, markdown_renderer: &crate::markdown::MarkdownRenderer,
theme: &crate::theme::TuiTheme,
) { ) {
let left = Layout::default() let left = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
@ -1001,7 +1008,7 @@ fn draw_left_pane(
// --- conversation with word-wrapping --- // --- conversation with word-wrapping ---
// Subtract 1 for the top border, 2 for left/right block padding // Subtract 1 for the top border, 2 for left/right block padding
let content_width = (left[0].width as usize).saturating_sub(2); let content_width = (left[0].width as usize).saturating_sub(2);
let (wrapped, expand_counts) = build_wrapped_conversation(conversation, content_width, markdown_renderer); let (wrapped, expand_counts) = build_wrapped_conversation(conversation, content_width, markdown_renderer, theme);
let pane_rows = (left[0].height.saturating_sub(1) as usize).max(1); let pane_rows = (left[0].height.saturating_sub(1) as usize).max(1);
let total_visual = wrapped.len(); let total_visual = wrapped.len();
@ -1017,10 +1024,10 @@ fn draw_left_pane(
let conversation_widget = Paragraph::new(visible).block( let conversation_widget = Paragraph::new(visible).block(
Block::default() Block::default()
.borders(Borders::TOP) .borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray)) .border_style(Style::default().fg(theme.border.to_color()))
.title(Span::styled( .title(Span::styled(
" Conversation ", " Conversation ",
Style::default().fg(Color::DarkGray), Style::default().fg(theme.border.to_color()),
)), )),
); );
f.render_widget(conversation_widget, left[0]); f.render_widget(conversation_widget, left[0]);
@ -1056,7 +1063,7 @@ fn draw_left_pane(
f.render_widget( f.render_widget(
Paragraph::new(Span::styled( Paragraph::new(Span::styled(
scroll_label, scroll_label,
Style::default().fg(Color::DarkGray), Style::default().fg(theme.conversation_dim.to_color()),
)), )),
scroll_area, scroll_area,
); );
@ -1079,9 +1086,9 @@ fn draw_left_pane(
.enumerate() .enumerate()
.map(|(i, m)| { .map(|(i, m)| {
let style = if i == completion_index % matches.len() { let style = if i == completion_index % matches.len() {
Style::default().bg(Color::DarkGray).fg(Color::White) Style::default().bg(theme.completion_selected_bg.to_color()).fg(theme.completion_selected_fg.to_color())
} else { } else {
Style::default().fg(Color::Gray) Style::default().fg(theme.completion_fg.to_color())
}; };
ListItem::new(Line::from(Span::styled(m.as_str(), style))) ListItem::new(Line::from(Span::styled(m.as_str(), style)))
}) })
@ -1089,7 +1096,7 @@ fn draw_left_pane(
let list = List::new(items).block( let list = List::new(items).block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)), .border_style(Style::default().fg(theme.border.to_color())),
); );
let popup = Rect { let popup = Rect {
x: left[1].x, x: left[1].x,
@ -1114,34 +1121,37 @@ fn draw_right_pane(
#[allow(unused_assignments)] #[allow(unused_assignments)]
let mut gauge_row: Option<usize> = None; let mut gauge_row: Option<usize> = None;
lines.push(section("Connection")); lines.push(section("Connection", theme));
lines.push(kv("Model", &state.model, theme.dashboard_value.to_color())); lines.push(kv("Model", &state.model, theme.dashboard_value.to_color(), theme));
lines.push(kv("Provider", &state.provider, theme.dashboard_key.to_color())); lines.push(kv("Provider", &state.provider, theme.dashboard_key.to_color(), theme));
lines.push(kv("URL", &state.provider_url, theme.conversation_dim.to_color())); lines.push(kv("URL", &state.provider_url, theme.conversation_dim.to_color(), theme));
lines.push(kv("Mode", &state.permission_mode, theme.conversation_system.to_color())); lines.push(kv("Mode", &state.permission_mode, theme.conversation_system.to_color(), theme));
if let Some(ref branch) = state.git_branch { if let Some(ref branch) = state.git_branch {
lines.push(kv("Branch", branch, theme.agent_done.to_color())); lines.push(kv("Branch", branch, theme.agent_done.to_color(), theme));
} }
lines.push(Line::from("")); lines.push(Line::from(""));
lines.push(section("Tokens")); lines.push(section("Tokens", theme));
lines.push(kv("Turns", &state.turn_count.to_string(), theme.dashboard_value.to_color())); lines.push(kv("Turns", &state.turn_count.to_string(), theme.dashboard_value.to_color(), theme));
lines.push(kv("Input", &state.input_tokens.to_string(), theme.dashboard_value.to_color())); lines.push(kv("Input", &state.input_tokens.to_string(), theme.dashboard_value.to_color(), theme));
lines.push(kv("Output", &state.output_tokens.to_string(), theme.dashboard_value.to_color())); lines.push(kv("Output", &state.output_tokens.to_string(), theme.dashboard_value.to_color(), theme));
lines.push(kv( lines.push(kv(
"Cache R", "Cache R",
&state.cache_read_tokens.to_string(), &state.cache_read_tokens.to_string(),
theme.dashboard_key.to_color(), theme.dashboard_key.to_color(),
theme,
)); ));
lines.push(kv( lines.push(kv(
"Cache W", "Cache W",
&state.cache_creation_tokens.to_string(), &state.cache_creation_tokens.to_string(),
theme.dashboard_key.to_color(), theme.dashboard_key.to_color(),
theme,
)); ));
lines.push(kv( lines.push(kv(
"Cost", "Cost",
&format!("${:.4}", state.cost_usd), &format!("${:.4}", state.cost_usd),
theme.conversation_system.to_color(), theme.conversation_system.to_color(),
theme,
)); ));
lines.push(Line::from("")); lines.push(Line::from(""));
@ -1153,11 +1163,12 @@ fn draw_right_pane(
} else { } else {
theme.gauge_fill_green.to_color() theme.gauge_fill_green.to_color()
}; };
lines.push(section("Context")); lines.push(section("Context", theme));
lines.push(kv( lines.push(kv(
"Used", "Used",
&format!("{:.1}% of {}", pct, state.context_window), &format!("{:.1}% of {}", pct, state.context_window),
theme.dashboard_value.to_color(), theme.dashboard_value.to_color(),
theme,
)); ));
gauge_row = Some(lines.len()); gauge_row = Some(lines.len());
lines.push(Line::from("")); lines.push(Line::from(""));
@ -1165,30 +1176,31 @@ fn draw_right_pane(
"Compactions", "Compactions",
&state.compaction_count.to_string(), &state.compaction_count.to_string(),
theme.dashboard_key.to_color(), theme.dashboard_key.to_color(),
theme,
)); ));
lines.push(Line::from("")); lines.push(Line::from(""));
if !state.lsp_servers.is_empty() { if !state.lsp_servers.is_empty() {
lines.push(section("LSP")); lines.push(section("LSP", theme));
for lsp in &state.lsp_servers { for lsp in &state.lsp_servers {
let c = match lsp.status.as_str() { let c = match lsp.status.as_str() {
"connected" => theme.agent_done.to_color(), "connected" => theme.agent_done.to_color(),
"starting" => theme.agent_waiting.to_color(), "starting" => theme.agent_waiting.to_color(),
_ => theme.agent_failed.to_color(), _ => theme.agent_failed.to_color(),
}; };
lines.push(kv(&lsp.language, &lsp.status, c)); lines.push(kv(&lsp.language, &lsp.status, c, theme));
} }
lines.push(Line::from("")); lines.push(Line::from(""));
} }
if let Some(ref team) = state.team { if let Some(ref team) = state.team {
lines.push(section("Team")); lines.push(section("Team", theme));
lines.push(kv("Name", &team.team_name, theme.dashboard_value.to_color())); lines.push(kv("Name", &team.team_name, theme.dashboard_value.to_color(), theme));
let progress = format!( let progress = format!(
"{}/{} done, {} fail, {} run", "{}/{} done, {} fail, {} run",
team.completed_agents, team.total_agents, team.failed_agents, team.running_agents team.completed_agents, team.total_agents, team.failed_agents, team.running_agents
); );
lines.push(kv("Status", &progress, theme.agent_done.to_color())); lines.push(kv("Status", &progress, theme.agent_done.to_color(), theme));
for agent in &team.agents { for agent in &team.agents {
let c = match agent.status.as_str() { let c = match agent.status.as_str() {
"completed" => theme.agent_done.to_color(), "completed" => theme.agent_done.to_color(),
@ -1208,11 +1220,12 @@ fn draw_right_pane(
lines.push(Line::from("")); lines.push(Line::from(""));
} }
lines.push(section("Session")); lines.push(section("Session", theme));
lines.push(kv( lines.push(kv(
"ID", "ID",
state.session_id.as_deref().unwrap_or("-"), state.session_id.as_deref().unwrap_or("-"),
theme.dashboard_key.to_color(), theme.dashboard_key.to_color(),
theme,
)); ));
if !state.status_message.is_empty() { if !state.status_message.is_empty() {
@ -1242,11 +1255,11 @@ fn draw_right_pane(
.block( .block(
Block::default() Block::default()
.borders(Borders::LEFT) .borders(Borders::LEFT)
.border_style(Style::default().fg(Color::DarkGray)) .border_style(Style::default().fg(theme.border.to_color()))
.title(Span::styled( .title(Span::styled(
" Dashboard ", " Dashboard ",
Style::default() Style::default()
.fg(Color::Cyan) .fg(theme.dashboard_header.to_color())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)), )),
) )
@ -1263,7 +1276,7 @@ fn draw_right_pane(
height: 1, height: 1,
}; };
let gauge = Gauge::default() let gauge = Gauge::default()
.gauge_style(Style::default().fg(gauge_color).bg(Color::DarkGray)) .gauge_style(Style::default().fg(gauge_color).bg(theme.gauge_bg.to_color()))
.ratio(if pct > 0.0 { .ratio(if pct > 0.0 {
(pct / 100.0).min(1.0) (pct / 100.0).min(1.0)
} else { } else {
@ -1273,22 +1286,22 @@ fn draw_right_pane(
} }
} }
fn section<'a>(label: &str) -> Line<'a> { fn section<'a>(label: &str, theme: &crate::theme::TuiTheme) -> Line<'a> {
Line::from(Span::styled( Line::from(Span::styled(
format!("{label}"), format!("{label}"),
Style::default() Style::default()
.fg(Color::Cyan) .fg(theme.dashboard_header.to_color())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)) ))
} }
/// Key-value row with fixed-width key column so values align vertically. /// Key-value row with fixed-width key column so values align vertically.
/// Key is right-padded to `KV_KEY_WIDTH` columns: `" Model value"`. /// Key is right-padded to `KV_KEY_WIDTH` columns: `" Model value"`.
fn kv<'a>(key: &str, val: &str, val_color: Color) -> Line<'a> { fn kv<'a>(key: &str, val: &str, val_color: Color, theme: &crate::theme::TuiTheme) -> Line<'a> {
Line::from(vec![ Line::from(vec![
Span::styled( Span::styled(
format!(" {:<KV_KEY_WIDTH$}", key), format!(" {:<KV_KEY_WIDTH$}", key),
Style::default().fg(Color::DarkGray), Style::default().fg(theme.dashboard_key.to_color()),
), ),
Span::styled(val.to_string(), Style::default().fg(val_color)), Span::styled(val.to_string(), Style::default().fg(val_color)),
]) ])