//! Provider tabs component for multi-LLM support //! //! Displays horizontal tabs for switching between providers (Claude, Ollama, OpenAI) //! with icons and keybind hints. use crate::theme::{Provider, Theme}; use ratatui::{ layout::Rect, style::Style, text::{Line, Span}, widgets::Paragraph, Frame, }; /// Provider tab state and rendering pub struct ProviderTabs { active: Provider, theme: Theme, } impl ProviderTabs { /// Create new provider tabs with default provider pub fn new(theme: Theme) -> Self { Self { active: Provider::Ollama, // Default to Ollama (local) theme, } } /// Create with specific active provider pub fn with_provider(provider: Provider, theme: Theme) -> Self { Self { active: provider, theme, } } /// Get the currently active provider pub fn active(&self) -> Provider { self.active } /// Set the active provider pub fn set_active(&mut self, provider: Provider) { self.active = provider; } /// Cycle to the next provider pub fn next(&mut self) { self.active = match self.active { Provider::Claude => Provider::Ollama, Provider::Ollama => Provider::OpenAI, Provider::OpenAI => Provider::Claude, }; } /// Cycle to the previous provider pub fn previous(&mut self) { self.active = match self.active { Provider::Claude => Provider::OpenAI, Provider::Ollama => Provider::Claude, Provider::OpenAI => Provider::Ollama, }; } /// Select provider by number (1, 2, 3) pub fn select_by_number(&mut self, num: u8) { self.active = match num { 1 => Provider::Claude, 2 => Provider::Ollama, 3 => Provider::OpenAI, _ => self.active, }; } /// Update the theme pub fn set_theme(&mut self, theme: Theme) { self.theme = theme; } /// Render the provider tabs (borderless) pub fn render(&self, frame: &mut Frame, area: Rect) { let mut spans = Vec::new(); // Add spacing at start spans.push(Span::raw(" ")); for (i, provider) in Provider::all().iter().enumerate() { let is_active = *provider == self.active; let icon = self.theme.provider_icon(*provider); let name = provider.name(); let number = (i + 1).to_string(); // Keybind hint spans.push(Span::styled( format!("[{}] ", number), self.theme.status_dim, )); // Icon and name let style = if is_active { Style::default() .fg(self.theme.provider_color(*provider)) .add_modifier(ratatui::style::Modifier::BOLD) } else { self.theme.tab_inactive }; spans.push(Span::styled(format!("{} ", icon), style)); spans.push(Span::styled(name.to_string(), style)); // Separator between tabs (not after last) if i < Provider::all().len() - 1 { spans.push(Span::styled( format!(" {} ", self.theme.symbols.vertical_separator), self.theme.status_dim, )); } } // Tab cycling hint on the right spans.push(Span::raw(" ")); spans.push(Span::styled("[Tab] cycle", self.theme.status_dim)); let line = Line::from(spans); let paragraph = Paragraph::new(line); frame.render_widget(paragraph, area); } /// Render a compact version (just active provider) pub fn render_compact(&self, frame: &mut Frame, area: Rect) { let icon = self.theme.provider_icon(self.active); let name = self.active.name(); let line = Line::from(vec![ Span::raw(" "), Span::styled( format!("{} {}", icon, name), Style::default() .fg(self.theme.provider_color(self.active)) .add_modifier(ratatui::style::Modifier::BOLD), ), ]); let paragraph = Paragraph::new(line); frame.render_widget(paragraph, area); } } #[cfg(test)] mod tests { use super::*; #[test] fn test_provider_cycling() { let theme = Theme::default(); let mut tabs = ProviderTabs::new(theme); assert_eq!(tabs.active(), Provider::Ollama); tabs.next(); assert_eq!(tabs.active(), Provider::OpenAI); tabs.next(); assert_eq!(tabs.active(), Provider::Claude); tabs.next(); assert_eq!(tabs.active(), Provider::Ollama); } #[test] fn test_select_by_number() { let theme = Theme::default(); let mut tabs = ProviderTabs::new(theme); tabs.select_by_number(1); assert_eq!(tabs.active(), Provider::Claude); tabs.select_by_number(2); assert_eq!(tabs.active(), Provider::Ollama); tabs.select_by_number(3); assert_eq!(tabs.active(), Provider::OpenAI); // Invalid number should not change tabs.select_by_number(4); assert_eq!(tabs.active(), Provider::OpenAI); } }