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:
2025-12-02 17:24:14 +01:00
parent 09c8c9d83e
commit 10c8e2baae
67 changed files with 11444 additions and 626 deletions

View 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);
}
}