diff --git a/rust/crates/rusty-claude-cli/src/command_palette.rs b/rust/crates/rusty-claude-cli/src/command_palette.rs index 9673b759..692eef59 100644 --- a/rust/crates/rusty-claude-cli/src/command_palette.rs +++ b/rust/crates/rusty-claude-cli/src/command_palette.rs @@ -183,9 +183,18 @@ mod tests { fn test_filter_by_label() { let mut cp = CommandPalette::new(); 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'); - // Should filter to entries containing 'e' + cp.input('l'); + cp.input('p'); assert!(cp.filtered.len() < cp.entries.len()); + assert!(cp.filtered.len() >= 1); } #[test] diff --git a/rust/crates/rusty-claude-cli/src/markdown.rs b/rust/crates/rusty-claude-cli/src/markdown.rs index 74d4b8b5..006a5bf3 100644 --- a/rust/crates/rusty-claude-cli/src/markdown.rs +++ b/rust/crates/rusty-claude-cli/src/markdown.rs @@ -16,12 +16,15 @@ pub static THEME_SET: Lazy = Lazy::new(ThemeSet::load_defaults); #[derive(Clone)] pub struct MarkdownRenderer { code_theme_name: String, + theme: crate::theme::TuiTheme, } impl MarkdownRenderer { - pub fn new() -> Self { + pub fn new(theme: crate::theme::TuiTheme) -> Self { + let code_theme_name = theme.syntax_theme.clone(); 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(); } + 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. pub fn render(&self, markdown: &str, width: u16) -> Vec> { let parser = Parser::new(markdown); @@ -52,7 +60,7 @@ impl MarkdownRenderer { flush_line(&mut lines, &mut current_spans); style_stack.push( Style::default() - .fg(Color::Cyan) + .fg(self.theme.conversation_user.to_color()) .add_modifier(Modifier::BOLD), ); } @@ -84,8 +92,8 @@ impl MarkdownRenderer { } Tag::BlockQuote(_) => { current_spans - .push(Span::styled("│ ", Style::default().fg(Color::DarkGray))); - style_stack.push(Style::default().fg(Color::DarkGray)); + .push(Span::styled("│ ", Style::default().fg(self.theme.conversation_dim.to_color()))); + style_stack.push(Style::default().fg(self.theme.conversation_dim.to_color())); } Tag::Emphasis => { style_stack.push(Style::default().add_modifier(Modifier::ITALIC)); @@ -96,7 +104,7 @@ impl MarkdownRenderer { Tag::Link { .. } => { style_stack.push( Style::default() - .fg(Color::Blue) + .fg(self.theme.conversation_user.to_color()) .add_modifier(Modifier::UNDERLINED), ); } @@ -144,7 +152,7 @@ impl MarkdownRenderer { } } 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)); } Event::SoftBreak | Event::HardBreak => { @@ -158,7 +166,7 @@ impl MarkdownRenderer { flush_line(&mut lines, &mut current_spans); lines.push(Line::from(Span::styled( "─".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"); lines.push(Line::from(Span::styled( 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) @@ -197,7 +205,7 @@ impl MarkdownRenderer { Ok(ranges) => { let mut line_spans: Vec> = vec![Span::styled( "│ ", - Style::default().fg(Color::DarkGray), + Style::default().fg(self.theme.code_border.to_color()), )]; for (style, text) in ranges { let fg = style.foreground; @@ -210,7 +218,7 @@ impl MarkdownRenderer { } Err(_) => { 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()), ])); } @@ -219,7 +227,7 @@ impl MarkdownRenderer { lines.push(Line::from(Span::styled( "╰─", - Style::default().fg(Color::DarkGray), + Style::default().fg(self.theme.code_border.to_color()), ))); lines } @@ -249,19 +257,19 @@ pub fn looks_like_markdown(text: &str) -> bool { } /// Render a unified diff with color-coded lines. -pub fn render_diff(diff: &str) -> Vec> { +pub fn render_diff(diff: &str, theme: &crate::theme::TuiTheme) -> Vec> { diff.lines() .map(|raw_line| { 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("@@") { - (raw_line.to_string(), Color::Cyan) + (raw_line.to_string(), theme.conversation_user.to_color()) } 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('-') { - (raw_line.to_string(), Color::Red) + (raw_line.to_string(), theme.conversation_error.to_color()) } 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))) }) @@ -272,18 +280,22 @@ pub fn render_diff(diff: &str) -> Vec> { mod tests { use super::*; + fn test_theme() -> crate::theme::TuiTheme { + crate::theme::TuiTheme::builtin("default").unwrap() + } + // --- Markdown rendering --- #[test] fn test_plain_paragraph() { - let r = MarkdownRenderer::new(); + let r = MarkdownRenderer::new(test_theme()); let lines = r.render("Just a paragraph.", 80); assert_eq!(lines.len(), 2); // text + blank } #[test] fn test_h1_rendering() { - let r = MarkdownRenderer::new(); + let r = MarkdownRenderer::new(test_theme()); let lines = r.render("# Hello", 80); assert!(lines[0] .spans @@ -293,7 +305,7 @@ mod tests { #[test] 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); // At minimum: top border + at least 1 code line + bottom border assert!(lines.len() >= 3); @@ -306,7 +318,7 @@ mod tests { #[test] 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); assert!(lines.len() >= 3); let text: String = lines.iter().map(|l| { @@ -317,21 +329,21 @@ mod tests { #[test] fn test_inline_code() { - let r = MarkdownRenderer::new(); + let r = MarkdownRenderer::new(test_theme()); let lines = r.render("Use `git status` to check", 80); assert_eq!(lines.len(), 2); } #[test] fn test_bold_and_italic() { - let r = MarkdownRenderer::new(); + let r = MarkdownRenderer::new(test_theme()); let lines = r.render("**bold** and *italic*", 80); assert_eq!(lines.len(), 2); } #[test] 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); // At least 2 list items rendered (nested may be part of item 2) assert!(lines.len() >= 2); @@ -339,7 +351,7 @@ mod tests { #[test] fn test_blockquote() { - let r = MarkdownRenderer::new(); + let r = MarkdownRenderer::new(test_theme()); let lines = r.render("> quoted\n> text", 80); // 2 blockquote lines + possible blank lines assert!(lines.len() >= 2); @@ -347,21 +359,21 @@ mod tests { #[test] fn test_horizontal_rule() { - let r = MarkdownRenderer::new(); + let r = MarkdownRenderer::new(test_theme()); let lines = r.render("---", 80); assert_eq!(lines.len(), 1); } #[test] fn test_empty_input() { - let r = MarkdownRenderer::new(); + let r = MarkdownRenderer::new(test_theme()); let lines = r.render("", 80); assert!(lines.is_empty()); } #[test] 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 lines = r.render(md, 80); // Header + blank + text + blank + code block (3+) + blank + list item + blanks @@ -370,7 +382,7 @@ mod tests { #[test] fn test_theme_fallback() { - let mut r = MarkdownRenderer::new(); + let mut r = MarkdownRenderer::new(test_theme()); r.set_code_theme("nonexistent_theme"); // Should not panic — falls back gracefully let lines = r.render("```rust\nfn main() {}\n```", 80); @@ -416,26 +428,30 @@ mod tests { #[test] fn test_diff_additions_green() { - let lines = render_diff("+new line"); - assert_eq!(lines[0].spans[0].style.fg, Some(Color::Green)); + let theme = test_theme(); + let lines = render_diff("+new line", &theme); + assert_eq!(lines[0].spans[0].style.fg, Some(theme.agent_done.to_color())); } #[test] fn test_diff_deletions_red() { - let lines = render_diff("-old line"); - assert_eq!(lines[0].spans[0].style.fg, Some(Color::Red)); + let theme = test_theme(); + let lines = render_diff("-old line", &theme); + assert_eq!(lines[0].spans[0].style.fg, Some(theme.conversation_error.to_color())); } #[test] fn test_diff_hunk_header() { - let lines = render_diff("@@ -1,3 +1,4 @@"); - assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan)); + let theme = test_theme(); + let lines = render_diff("@@ -1,3 +1,4 @@", &theme); + assert_eq!(lines[0].spans[0].style.fg, Some(theme.conversation_user.to_color())); } #[test] fn test_diff_file_headers() { - let lines = render_diff("--- a/file.rs\n+++ b/file.rs"); - assert_eq!(lines[0].spans[0].style.fg, Some(Color::White)); - assert_eq!(lines[1].spans[0].style.fg, Some(Color::White)); + let theme = test_theme(); + let lines = render_diff("--- a/file.rs\n+++ b/file.rs", &theme); + 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())); } } diff --git a/rust/crates/rusty-claude-cli/src/tui.rs b/rust/crates/rusty-claude-cli/src/tui.rs index ca6839e9..bcf3ab5a 100644 --- a/rust/crates/rusty-claude-cli/src/tui.rs +++ b/rust/crates/rusty-claude-cli/src/tui.rs @@ -238,8 +238,8 @@ impl TuiApp { completion_index: 0, showing_completions: false, spinner_frame: 0, - markdown_renderer: crate::markdown::MarkdownRenderer::new(), - theme: crate::theme::TuiTheme::builtin("default").unwrap(), + markdown_renderer: crate::markdown::MarkdownRenderer::new(theme.clone()), + theme, keymap: crate::keybindings::KeyMap::new(crate::keybindings::KeyPreset::Emacs), command_palette: crate::command_palette::CommandPalette::new(), chat_mode: crate::chat_mode::ChatMode::Code, @@ -318,7 +318,7 @@ impl TuiApp { /// Set a new theme and trigger redraw. pub fn set_theme(&mut self, theme: crate::theme::TuiTheme) { - self.markdown_renderer.set_code_theme(&theme.syntax_theme); + self.markdown_renderer.set_theme(theme.clone()); self.theme = theme; self.needs_redraw = true; } @@ -393,17 +393,19 @@ impl TuiApp { } pub fn push_user_input(&mut self, text: &str) { + let color = self.theme.conversation_user.to_color(); for raw_line in text.lines() { self.conversation - .push(ConversationLine::plain(raw_line.to_string(), Color::Cyan, true)); + .push(ConversationLine::plain(raw_line.to_string(), color, true)); } self.auto_scroll(); } pub fn push_system_message(&mut self, text: &str) { + let color = self.theme.conversation_system.to_color(); for raw_line in text.lines() { self.conversation - .push(ConversationLine::plain(raw_line.to_string(), Color::Yellow, false)); + .push(ConversationLine::plain(raw_line.to_string(), color, false)); } self.auto_scroll(); } @@ -414,16 +416,18 @@ impl TuiApp { } let clean = crate::tui_update::strip_ansi(text); if is_error { + let color = self.theme.conversation_error.to_color(); for raw_line in clean.lines() { 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) { self.conversation.push(ConversationLine::markdown(clean)); } else { + let color = self.theme.conversation_text.to_color(); for raw_line in clean.lines() { self.conversation - .push(ConversationLine::plain(raw_line.to_string(), Color::White, false)); + .push(ConversationLine::plain(raw_line.to_string(), color, false)); } } self.auto_scroll(); @@ -465,7 +469,7 @@ impl TuiApp { // Insert trim notice self.conversation.insert(0, ConversationLine::plain( "... (earlier messages trimmed)".to_string(), - Color::DarkGray, + self.theme.conversation_dim.to_color(), false, )); } @@ -867,6 +871,7 @@ fn draw_frame( completion_index, showing_completions, markdown_renderer, + theme, ); draw_right_pane(f, main[1], dashboard, spinner_frame, theme); } @@ -937,6 +942,7 @@ fn build_wrapped_conversation<'a>( conversation: &[ConversationLine], content_width: usize, markdown_renderer: &crate::markdown::MarkdownRenderer, + theme: &crate::theme::TuiTheme, ) -> (Vec>, Vec) { let mut all_lines: Vec> = Vec::new(); let mut expand_counts: Vec = Vec::new(); @@ -966,7 +972,7 @@ fn build_wrapped_conversation<'a>( })); } ConversationContent::CodeDiff { diff } => { - let rendered = crate::markdown::render_diff(diff); + let rendered = crate::markdown::render_diff(diff, theme); let count = rendered.len().max(1); expand_counts.push(count); all_lines.extend(rendered.into_iter().map(|l: Line<'static>| { @@ -992,6 +998,7 @@ fn draw_left_pane( completion_index: usize, showing_completions: bool, markdown_renderer: &crate::markdown::MarkdownRenderer, + theme: &crate::theme::TuiTheme, ) { let left = Layout::default() .direction(Direction::Vertical) @@ -1001,7 +1008,7 @@ fn draw_left_pane( // --- conversation with word-wrapping --- // Subtract 1 for the top border, 2 for left/right block padding 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 total_visual = wrapped.len(); @@ -1017,10 +1024,10 @@ fn draw_left_pane( let conversation_widget = Paragraph::new(visible).block( Block::default() .borders(Borders::TOP) - .border_style(Style::default().fg(Color::DarkGray)) + .border_style(Style::default().fg(theme.border.to_color())) .title(Span::styled( " Conversation ", - Style::default().fg(Color::DarkGray), + Style::default().fg(theme.border.to_color()), )), ); f.render_widget(conversation_widget, left[0]); @@ -1056,7 +1063,7 @@ fn draw_left_pane( f.render_widget( Paragraph::new(Span::styled( scroll_label, - Style::default().fg(Color::DarkGray), + Style::default().fg(theme.conversation_dim.to_color()), )), scroll_area, ); @@ -1079,9 +1086,9 @@ fn draw_left_pane( .enumerate() .map(|(i, m)| { 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 { - Style::default().fg(Color::Gray) + Style::default().fg(theme.completion_fg.to_color()) }; ListItem::new(Line::from(Span::styled(m.as_str(), style))) }) @@ -1089,7 +1096,7 @@ fn draw_left_pane( let list = List::new(items).block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), + .border_style(Style::default().fg(theme.border.to_color())), ); let popup = Rect { x: left[1].x, @@ -1114,34 +1121,37 @@ fn draw_right_pane( #[allow(unused_assignments)] let mut gauge_row: Option = None; - lines.push(section("Connection")); - lines.push(kv("Model", &state.model, theme.dashboard_value.to_color())); - lines.push(kv("Provider", &state.provider, theme.dashboard_key.to_color())); - lines.push(kv("URL", &state.provider_url, theme.conversation_dim.to_color())); - lines.push(kv("Mode", &state.permission_mode, theme.conversation_system.to_color())); + lines.push(section("Connection", theme)); + lines.push(kv("Model", &state.model, theme.dashboard_value.to_color(), theme)); + lines.push(kv("Provider", &state.provider, theme.dashboard_key.to_color(), theme)); + 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(), theme)); 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(section("Tokens")); - lines.push(kv("Turns", &state.turn_count.to_string(), theme.dashboard_value.to_color())); - lines.push(kv("Input", &state.input_tokens.to_string(), theme.dashboard_value.to_color())); - lines.push(kv("Output", &state.output_tokens.to_string(), theme.dashboard_value.to_color())); + lines.push(section("Tokens", theme)); + 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(), theme)); + lines.push(kv("Output", &state.output_tokens.to_string(), theme.dashboard_value.to_color(), theme)); lines.push(kv( "Cache R", &state.cache_read_tokens.to_string(), theme.dashboard_key.to_color(), + theme, )); lines.push(kv( "Cache W", &state.cache_creation_tokens.to_string(), theme.dashboard_key.to_color(), + theme, )); lines.push(kv( "Cost", &format!("${:.4}", state.cost_usd), theme.conversation_system.to_color(), + theme, )); lines.push(Line::from("")); @@ -1153,11 +1163,12 @@ fn draw_right_pane( } else { theme.gauge_fill_green.to_color() }; - lines.push(section("Context")); + lines.push(section("Context", theme)); lines.push(kv( "Used", &format!("{:.1}% of {}", pct, state.context_window), theme.dashboard_value.to_color(), + theme, )); gauge_row = Some(lines.len()); lines.push(Line::from("")); @@ -1165,30 +1176,31 @@ fn draw_right_pane( "Compactions", &state.compaction_count.to_string(), theme.dashboard_key.to_color(), + theme, )); lines.push(Line::from("")); if !state.lsp_servers.is_empty() { - lines.push(section("LSP")); + lines.push(section("LSP", theme)); for lsp in &state.lsp_servers { let c = match lsp.status.as_str() { "connected" => theme.agent_done.to_color(), "starting" => theme.agent_waiting.to_color(), _ => theme.agent_failed.to_color(), }; - lines.push(kv(&lsp.language, &lsp.status, c)); + lines.push(kv(&lsp.language, &lsp.status, c, theme)); } lines.push(Line::from("")); } if let Some(ref team) = state.team { - lines.push(section("Team")); - lines.push(kv("Name", &team.team_name, theme.dashboard_value.to_color())); + lines.push(section("Team", theme)); + lines.push(kv("Name", &team.team_name, theme.dashboard_value.to_color(), theme)); let progress = format!( "{}/{} done, {} fail, {} run", 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 { let c = match agent.status.as_str() { "completed" => theme.agent_done.to_color(), @@ -1208,11 +1220,12 @@ fn draw_right_pane( lines.push(Line::from("")); } - lines.push(section("Session")); + lines.push(section("Session", theme)); lines.push(kv( "ID", state.session_id.as_deref().unwrap_or("-"), theme.dashboard_key.to_color(), + theme, )); if !state.status_message.is_empty() { @@ -1242,11 +1255,11 @@ fn draw_right_pane( .block( Block::default() .borders(Borders::LEFT) - .border_style(Style::default().fg(Color::DarkGray)) + .border_style(Style::default().fg(theme.border.to_color())) .title(Span::styled( " Dashboard ", Style::default() - .fg(Color::Cyan) + .fg(theme.dashboard_header.to_color()) .add_modifier(Modifier::BOLD), )), ) @@ -1263,7 +1276,7 @@ fn draw_right_pane( height: 1, }; 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 { (pct / 100.0).min(1.0) } 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( format!("─ {label} ─"), Style::default() - .fg(Color::Cyan) + .fg(theme.dashboard_header.to_color()) .add_modifier(Modifier::BOLD), )) } /// Key-value row with fixed-width key column so values align vertically. /// 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![ Span::styled( format!(" {: