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

@@ -13,6 +13,8 @@ serde_json = "1"
color-eyre = "0.6"
url = "2.5"
async-trait = "0.1"
scraper = "0.18"
urlencoding = "2.1"
[dev-dependencies]
tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] }

View File

@@ -1,5 +1,6 @@
use color_eyre::eyre::{Result, eyre};
use reqwest::redirect::Policy;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use url::Url;
@@ -173,6 +174,91 @@ impl SearchProvider for StubSearchProvider {
}
}
/// DuckDuckGo HTML search provider
pub struct DuckDuckGoSearchProvider {
client: reqwest::Client,
max_results: usize,
}
impl DuckDuckGoSearchProvider {
/// Create a new DuckDuckGo search provider with default max results (10)
pub fn new() -> Self {
Self::with_max_results(10)
}
/// Create a new DuckDuckGo search provider with custom max results
pub fn with_max_results(max_results: usize) -> Self {
let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (compatible; Owlen/1.0)")
.build()
.unwrap();
Self { client, max_results }
}
/// Parse DuckDuckGo HTML results
fn parse_results(html: &str, max_results: usize) -> Result<Vec<SearchResult>> {
let document = Html::parse_document(html);
// DuckDuckGo HTML selectors
let result_selector = Selector::parse(".result").map_err(|e| eyre!("Invalid selector: {:?}", e))?;
let title_selector = Selector::parse(".result__title a").map_err(|e| eyre!("Invalid selector: {:?}", e))?;
let snippet_selector = Selector::parse(".result__snippet").map_err(|e| eyre!("Invalid selector: {:?}", e))?;
let mut results = Vec::new();
for result in document.select(&result_selector).take(max_results) {
let title = result
.select(&title_selector)
.next()
.map(|e| e.text().collect::<String>().trim().to_string())
.unwrap_or_default();
let url = result
.select(&title_selector)
.next()
.and_then(|e| e.value().attr("href"))
.unwrap_or_default()
.to_string();
let snippet = result
.select(&snippet_selector)
.next()
.map(|e| e.text().collect::<String>().trim().to_string())
.unwrap_or_default();
if !title.is_empty() && !url.is_empty() {
results.push(SearchResult { title, url, snippet });
}
}
Ok(results)
}
}
impl Default for DuckDuckGoSearchProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl SearchProvider for DuckDuckGoSearchProvider {
fn name(&self) -> &str {
"duckduckgo"
}
async fn search(&self, query: &str) -> Result<Vec<SearchResult>> {
let encoded_query = urlencoding::encode(query);
let url = format!("https://html.duckduckgo.com/html/?q={}", encoded_query);
let response = self.client.get(&url).send().await?;
let html = response.text().await?;
Self::parse_results(&html, self.max_results)
}
}
/// WebSearch client with pluggable providers
pub struct WebSearchClient {
provider: Box<dyn SearchProvider>,
@@ -192,6 +278,20 @@ impl WebSearchClient {
}
}
/// Format search results for LLM consumption (markdown format)
pub fn format_search_results(results: &[SearchResult]) -> String {
if results.is_empty() {
return "No results found.".to_string();
}
results
.iter()
.enumerate()
.map(|(i, r)| format!("{}. [{}]({})\n {}", i + 1, r.title, r.url, r.snippet))
.collect::<Vec<_>>()
.join("\n\n")
}
#[cfg(test)]
mod tests {
use super::*;