feat(v2): complete multi-LLM providers, TUI redesign, and advanced agent features
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>
This commit is contained in:
189
crates/app/ui/src/components/provider_tabs.rs
Normal file
189
crates/app/ui/src/components/provider_tabs.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user