Multi-LLM Provider Support: - Add llm-core crate with LlmProvider trait abstraction - Implement Anthropic Claude API client with streaming - Implement OpenAI API client with streaming - Add token counting with SimpleTokenCounter and ClaudeTokenCounter - Add retry logic with exponential backoff and jitter Borderless TUI Redesign: - Rewrite theme system with terminal capability detection (Full/Unicode256/Basic) - Add provider tabs component with keybind switching [1]/[2]/[3] - Implement vim-modal input (Normal/Insert/Visual/Command modes) - Redesign chat panel with timestamps and streaming indicators - Add multi-provider status bar with cost tracking - Add Nerd Font icons with graceful ASCII fallbacks - Add syntax highlighting (syntect) and markdown rendering (pulldown-cmark) Advanced Agent Features: - Add system prompt builder with configurable components - Enhance subagent orchestration with parallel execution - Add git integration module for safe command detection - Add streaming tool results via channels - Expand tool set: AskUserQuestion, TodoWrite, LS, MultiEdit, BashOutput, KillShell - Add WebSearch with provider abstraction Plugin System Enhancement: - Add full agent definition parsing from YAML frontmatter - Add skill system with progressive disclosure - Wire plugin hooks into HookManager 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
190 lines
5.2 KiB
Rust
190 lines
5.2 KiB
Rust
//! 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);
|
|
}
|
|
}
|