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

@@ -44,6 +44,109 @@ pub struct McpServerConfig {
pub env: HashMap<String, String>,
}
/// Plugin hook configuration from hooks/hooks.json
#[derive(Debug, Clone, Deserialize)]
pub struct PluginHooksConfig {
pub description: Option<String>,
pub hooks: HashMap<String, Vec<HookMatcher>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HookMatcher {
pub matcher: Option<String>, // Regex pattern for tool names
pub hooks: Vec<HookDefinition>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HookDefinition {
#[serde(rename = "type")]
pub hook_type: String, // "command" or "prompt"
pub command: Option<String>,
pub prompt: Option<String>,
pub timeout: Option<u64>,
}
/// Parsed slash command from markdown file
#[derive(Debug, Clone)]
pub struct SlashCommand {
pub name: String,
pub description: Option<String>,
pub argument_hint: Option<String>,
pub allowed_tools: Option<Vec<String>>,
pub body: String, // Markdown content after frontmatter
pub source_path: PathBuf,
}
/// Parsed agent definition from markdown file
#[derive(Debug, Clone)]
pub struct AgentDefinition {
pub name: String,
pub description: String,
pub tools: Vec<String>, // Tool whitelist
pub model: Option<String>, // haiku, sonnet, opus
pub color: Option<String>,
pub system_prompt: String, // Markdown body
pub source_path: PathBuf,
}
/// Parsed skill definition
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub version: Option<String>,
pub content: String, // Core SKILL.md content
pub references: Vec<PathBuf>, // Reference files
pub examples: Vec<PathBuf>, // Example files
pub source_path: PathBuf,
}
/// YAML frontmatter for command files
#[derive(Deserialize)]
struct CommandFrontmatter {
description: Option<String>,
#[serde(rename = "argument-hint")]
argument_hint: Option<String>,
#[serde(rename = "allowed-tools")]
allowed_tools: Option<String>,
}
/// YAML frontmatter for agent files
#[derive(Deserialize)]
struct AgentFrontmatter {
name: String,
description: String,
#[serde(default)]
tools: Vec<String>,
model: Option<String>,
color: Option<String>,
}
/// YAML frontmatter for skill files
#[derive(Deserialize)]
struct SkillFrontmatter {
name: String,
description: String,
version: Option<String>,
}
/// Parse YAML frontmatter from markdown content
fn parse_frontmatter<T: serde::de::DeserializeOwned>(content: &str) -> Result<(T, String)> {
if !content.starts_with("---") {
return Err(eyre!("No frontmatter found"));
}
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Err(eyre!("Invalid frontmatter format"));
}
let frontmatter: T = serde_yaml::from_str(parts[1].trim())?;
let body = parts[2].trim().to_string();
Ok((frontmatter, body))
}
/// A loaded plugin with its manifest and base path
#[derive(Debug, Clone)]
pub struct Plugin {
@@ -64,7 +167,7 @@ impl Plugin {
/// Get the path to a skill file
pub fn skill_path(&self, skill_name: &str) -> PathBuf {
self.base_path.join("skills").join(format!("{}.md", skill_name))
self.base_path.join("skills").join(skill_name).join("SKILL.md")
}
/// Get the path to a hook script
@@ -73,6 +176,210 @@ impl Plugin {
self.base_path.join("hooks").join(path)
})
}
/// Parse a command file
pub fn parse_command(&self, name: &str) -> Result<SlashCommand> {
let path = self.command_path(name);
let content = fs::read_to_string(&path)?;
let (fm, body): (CommandFrontmatter, String) = parse_frontmatter(&content)?;
let allowed_tools = fm.allowed_tools.map(|s| {
s.split(',').map(|t| t.trim().to_string()).collect()
});
Ok(SlashCommand {
name: name.to_string(),
description: fm.description,
argument_hint: fm.argument_hint,
allowed_tools,
body,
source_path: path,
})
}
/// Parse an agent file
pub fn parse_agent(&self, name: &str) -> Result<AgentDefinition> {
let path = self.agent_path(name);
let content = fs::read_to_string(&path)?;
let (fm, body): (AgentFrontmatter, String) = parse_frontmatter(&content)?;
Ok(AgentDefinition {
name: fm.name,
description: fm.description,
tools: fm.tools,
model: fm.model,
color: fm.color,
system_prompt: body,
source_path: path,
})
}
/// Parse a skill file
pub fn parse_skill(&self, name: &str) -> Result<Skill> {
let path = self.skill_path(name);
let content = fs::read_to_string(&path)?;
let (fm, body): (SkillFrontmatter, String) = parse_frontmatter(&content)?;
// Discover reference and example files in the skill directory
let skill_dir = self.base_path.join("skills").join(name);
let references_dir = skill_dir.join("references");
let examples_dir = skill_dir.join("examples");
let references = if references_dir.exists() {
fs::read_dir(&references_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect()
} else {
Vec::new()
};
let examples = if examples_dir.exists() {
fs::read_dir(&examples_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect()
} else {
Vec::new()
};
Ok(Skill {
name: fm.name,
description: fm.description,
version: fm.version,
content: body,
references,
examples,
source_path: path,
})
}
/// Auto-discover commands in the commands/ directory
pub fn discover_commands(&self) -> Vec<String> {
let commands_dir = self.base_path.join("commands");
if !commands_dir.exists() {
return Vec::new();
}
std::fs::read_dir(&commands_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
.filter_map(|e| {
e.path().file_stem()
.map(|s| s.to_string_lossy().to_string())
})
.collect()
}
/// Auto-discover agents in the agents/ directory
pub fn discover_agents(&self) -> Vec<String> {
let agents_dir = self.base_path.join("agents");
if !agents_dir.exists() {
return Vec::new();
}
std::fs::read_dir(&agents_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
.filter_map(|e| {
e.path().file_stem()
.map(|s| s.to_string_lossy().to_string())
})
.collect()
}
/// Auto-discover skills in skills/*/SKILL.md
pub fn discover_skills(&self) -> Vec<String> {
let skills_dir = self.base_path.join("skills");
if !skills_dir.exists() {
return Vec::new();
}
std::fs::read_dir(&skills_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter(|e| e.path().join("SKILL.md").exists())
.filter_map(|e| {
e.path().file_name()
.map(|s| s.to_string_lossy().to_string())
})
.collect()
}
/// Get all commands (manifest + discovered)
pub fn all_command_names(&self) -> Vec<String> {
let mut names: std::collections::HashSet<String> =
self.manifest.commands.iter().cloned().collect();
names.extend(self.discover_commands());
names.into_iter().collect()
}
/// Get all agent names (manifest + discovered)
pub fn all_agent_names(&self) -> Vec<String> {
let mut names: std::collections::HashSet<String> =
self.manifest.agents.iter().cloned().collect();
names.extend(self.discover_agents());
names.into_iter().collect()
}
/// Get all skill names (manifest + discovered)
pub fn all_skill_names(&self) -> Vec<String> {
let mut names: std::collections::HashSet<String> =
self.manifest.skills.iter().cloned().collect();
names.extend(self.discover_skills());
names.into_iter().collect()
}
/// Load hooks configuration from hooks/hooks.json
pub fn load_hooks_config(&self) -> Result<Option<PluginHooksConfig>> {
let hooks_path = self.base_path.join("hooks").join("hooks.json");
if !hooks_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&hooks_path)?;
let config: PluginHooksConfig = serde_json::from_str(&content)?;
Ok(Some(config))
}
/// Register hooks from this plugin's config into a hook manager
/// This requires the hooks crate to be available where this is called
pub fn register_hooks_with_manager(&self, config: &PluginHooksConfig) -> Vec<(String, String, Option<String>, Option<u64>)> {
let mut hooks_to_register = Vec::new();
for (event_name, matchers) in &config.hooks {
for matcher in matchers {
for hook_def in &matcher.hooks {
if let Some(command) = &hook_def.command {
// Substitute ${CLAUDE_PLUGIN_ROOT}
let resolved = command.replace(
"${CLAUDE_PLUGIN_ROOT}",
&self.base_path.to_string_lossy()
);
hooks_to_register.push((
event_name.clone(),
resolved,
matcher.matcher.clone(),
hook_def.timeout,
));
}
}
}
}
hooks_to_register
}
}
/// Plugin loader and registry
@@ -232,6 +539,45 @@ impl PluginManager {
servers
}
/// Get all parsed commands
pub fn load_all_commands(&self) -> Vec<SlashCommand> {
let mut commands = Vec::new();
for plugin in &self.plugins {
for cmd_name in &plugin.manifest.commands {
if let Ok(cmd) = plugin.parse_command(cmd_name) {
commands.push(cmd);
}
}
}
commands
}
/// Get all parsed agents
pub fn load_all_agents(&self) -> Vec<AgentDefinition> {
let mut agents = Vec::new();
for plugin in &self.plugins {
for agent_name in &plugin.manifest.agents {
if let Ok(agent) = plugin.parse_agent(agent_name) {
agents.push(agent);
}
}
}
agents
}
/// Get all parsed skills
pub fn load_all_skills(&self) -> Vec<Skill> {
let mut skills = Vec::new();
for plugin in &self.plugins {
for skill_name in &plugin.manifest.skills {
if let Ok(skill) = plugin.parse_skill(skill_name) {
skills.push(skill);
}
}
}
skills
}
}
impl Default for PluginManager {
@@ -274,12 +620,12 @@ mod tests {
fs::write(
dir.join("commands/test-cmd.md"),
"# Test Command\nThis is a test command.",
"---\ndescription: A test command\nargument-hint: <file>\nallowed-tools: read,write\n---\n\nThis is a test command body.",
)?;
fs::write(
dir.join("agents/test-agent.md"),
"# Test Agent\nThis is a test agent.",
"---\nname: test-agent\ndescription: A test agent\ntools:\n - read\n - write\nmodel: sonnet\ncolor: blue\n---\n\nYou are a helpful test agent.",
)?;
Ok(())
@@ -351,4 +697,77 @@ mod tests {
Ok(())
}
#[test]
fn test_parse_command() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let plugin_dir = temp_dir.path().join("test-plugin");
create_test_plugin(&plugin_dir)?;
let manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
let plugin = manager.load_plugin(&plugin_dir)?;
let cmd = plugin.parse_command("test-cmd")?;
assert_eq!(cmd.name, "test-cmd");
assert_eq!(cmd.description, Some("A test command".to_string()));
assert_eq!(cmd.argument_hint, Some("<file>".to_string()));
assert_eq!(cmd.allowed_tools, Some(vec!["read".to_string(), "write".to_string()]));
assert_eq!(cmd.body, "This is a test command body.");
Ok(())
}
#[test]
fn test_parse_agent() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let plugin_dir = temp_dir.path().join("test-plugin");
create_test_plugin(&plugin_dir)?;
let manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
let plugin = manager.load_plugin(&plugin_dir)?;
let agent = plugin.parse_agent("test-agent")?;
assert_eq!(agent.name, "test-agent");
assert_eq!(agent.description, "A test agent");
assert_eq!(agent.tools, vec!["read", "write"]);
assert_eq!(agent.model, Some("sonnet".to_string()));
assert_eq!(agent.color, Some("blue".to_string()));
assert_eq!(agent.system_prompt, "You are a helpful test agent.");
Ok(())
}
#[test]
fn test_load_all_commands() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let plugin_dir = temp_dir.path().join("test-plugin");
create_test_plugin(&plugin_dir)?;
let mut manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
manager.load_all()?;
let commands = manager.load_all_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].name, "test-cmd");
assert_eq!(commands[0].description, Some("A test command".to_string()));
Ok(())
}
#[test]
fn test_load_all_agents() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let plugin_dir = temp_dir.path().join("test-plugin");
create_test_plugin(&plugin_dir)?;
let mut manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
manager.load_all()?;
let agents = manager.load_all_agents();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].name, "test-agent");
assert_eq!(agents[0].description, "A test agent");
Ok(())
}
}