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:
@@ -13,6 +13,8 @@ grep-regex = "0.1"
|
||||
grep-searcher = "0.1"
|
||||
color-eyre = "0.6"
|
||||
similar = "2.7"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
humantime = "2.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.23.0"
|
||||
@@ -3,6 +3,7 @@ use ignore::WalkBuilder;
|
||||
use grep_regex::RegexMatcher;
|
||||
use grep_searcher::{sinks::UTF8, SearcherBuilder};
|
||||
use globset::Glob;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn read_file(path: &str) -> Result<String> {
|
||||
@@ -127,4 +128,81 @@ pub fn grep(root: &str, pattern: &str) -> Result<Vec<(String, usize, String)>> {
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Edit operation for MultiEdit
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditOperation {
|
||||
pub old_string: String,
|
||||
pub new_string: String,
|
||||
}
|
||||
|
||||
/// Perform multiple edits on a file atomically
|
||||
pub fn multi_edit_file(path: &str, edits: Vec<EditOperation>) -> Result<String> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let mut new_content = content.clone();
|
||||
|
||||
// Apply edits in order
|
||||
for edit in &edits {
|
||||
if !new_content.contains(&edit.old_string) {
|
||||
return Err(eyre!("String not found: '{}'", edit.old_string));
|
||||
}
|
||||
new_content = new_content.replacen(&edit.old_string, &edit.new_string, 1);
|
||||
}
|
||||
|
||||
// Create backup and write
|
||||
let backup_path = format!("{}.bak", path);
|
||||
std::fs::copy(path, &backup_path)?;
|
||||
std::fs::write(path, &new_content)?;
|
||||
|
||||
Ok(format!("Applied {} edits to {}", edits.len(), path))
|
||||
}
|
||||
|
||||
/// Entry in directory listing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DirEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: Option<u64>,
|
||||
pub modified: Option<String>,
|
||||
}
|
||||
|
||||
/// List contents of a directory
|
||||
pub fn list_directory(path: &str, show_hidden: bool) -> Result<Vec<DirEntry>> {
|
||||
let entries = std::fs::read_dir(path)?;
|
||||
let mut result = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
// Skip hidden files unless requested
|
||||
if !show_hidden && name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = entry.metadata()?;
|
||||
let modified = metadata.modified().ok().map(|t| {
|
||||
// Format as ISO 8601
|
||||
humantime::format_rfc3339(t).to_string()
|
||||
});
|
||||
|
||||
result.push(DirEntry {
|
||||
name,
|
||||
is_dir: metadata.is_dir(),
|
||||
size: if metadata.is_file() { Some(metadata.len()) } else { None },
|
||||
modified,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort directories first, then alphabetically
|
||||
result.sort_by(|a, b| {
|
||||
match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use tools_fs::{read_file, glob_list, grep, write_file, edit_file};
|
||||
use tools_fs::{read_file, glob_list, grep, write_file, edit_file, multi_edit_file, list_directory, EditOperation};
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -101,4 +101,124 @@ fn edit_file_fails_on_no_match() {
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("not found") || err_msg.contains("String to replace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_edit_file_applies_multiple_edits() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
let original = "line 1\nline 2\nline 3\n";
|
||||
fs::write(&file_path, original).unwrap();
|
||||
|
||||
let edits = vec![
|
||||
EditOperation {
|
||||
old_string: "line 1".to_string(),
|
||||
new_string: "modified 1".to_string(),
|
||||
},
|
||||
EditOperation {
|
||||
old_string: "line 2".to_string(),
|
||||
new_string: "modified 2".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = multi_edit_file(file_path.to_str().unwrap(), edits).unwrap();
|
||||
assert!(result.contains("Applied 2 edits"));
|
||||
|
||||
let content = read_file(file_path.to_str().unwrap()).unwrap();
|
||||
assert_eq!(content, "modified 1\nmodified 2\nline 3\n");
|
||||
|
||||
// Backup file should exist
|
||||
let backup_path = format!("{}.bak", file_path.display());
|
||||
assert!(std::path::Path::new(&backup_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_edit_file_fails_on_missing_string() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
let original = "line 1\nline 2\n";
|
||||
fs::write(&file_path, original).unwrap();
|
||||
|
||||
let edits = vec![
|
||||
EditOperation {
|
||||
old_string: "line 1".to_string(),
|
||||
new_string: "modified 1".to_string(),
|
||||
},
|
||||
EditOperation {
|
||||
old_string: "nonexistent".to_string(),
|
||||
new_string: "modified".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = multi_edit_file(file_path.to_str().unwrap(), edits);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("String not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_directory_shows_files_and_dirs() {
|
||||
let dir = tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
|
||||
// Create test structure
|
||||
fs::write(root.join("file1.txt"), "content").unwrap();
|
||||
fs::write(root.join("file2.txt"), "content").unwrap();
|
||||
fs::create_dir(root.join("subdir")).unwrap();
|
||||
fs::write(root.join(".hidden"), "hidden content").unwrap();
|
||||
|
||||
let entries = list_directory(root.to_str().unwrap(), false).unwrap();
|
||||
|
||||
// Should find 2 files and 1 directory (hidden file excluded)
|
||||
assert_eq!(entries.len(), 3);
|
||||
|
||||
// Verify directory appears first (sorted)
|
||||
assert_eq!(entries[0].name, "subdir");
|
||||
assert!(entries[0].is_dir);
|
||||
|
||||
// Verify files
|
||||
let file_names: Vec<_> = entries.iter().skip(1).map(|e| e.name.as_str()).collect();
|
||||
assert!(file_names.contains(&"file1.txt"));
|
||||
assert!(file_names.contains(&"file2.txt"));
|
||||
|
||||
// Hidden file should not be present
|
||||
assert!(!entries.iter().any(|e| e.name == ".hidden"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_directory_shows_hidden_when_requested() {
|
||||
let dir = tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
|
||||
fs::write(root.join("visible.txt"), "content").unwrap();
|
||||
fs::write(root.join(".hidden"), "hidden content").unwrap();
|
||||
|
||||
let entries = list_directory(root.to_str().unwrap(), true).unwrap();
|
||||
|
||||
// Should find both files
|
||||
assert!(entries.iter().any(|e| e.name == "visible.txt"));
|
||||
assert!(entries.iter().any(|e| e.name == ".hidden"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_directory_includes_metadata() {
|
||||
let dir = tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
|
||||
fs::write(root.join("test.txt"), "hello world").unwrap();
|
||||
fs::create_dir(root.join("testdir")).unwrap();
|
||||
|
||||
let entries = list_directory(root.to_str().unwrap(), false).unwrap();
|
||||
|
||||
// Directory entry should have no size
|
||||
let dir_entry = entries.iter().find(|e| e.name == "testdir").unwrap();
|
||||
assert!(dir_entry.is_dir);
|
||||
assert!(dir_entry.size.is_none());
|
||||
assert!(dir_entry.modified.is_some());
|
||||
|
||||
// File entry should have size
|
||||
let file_entry = entries.iter().find(|e| e.name == "test.txt").unwrap();
|
||||
assert!(!file_entry.is_dir);
|
||||
assert_eq!(file_entry.size, Some(11)); // "hello world" is 11 bytes
|
||||
assert!(file_entry.modified.is_some());
|
||||
}
|
||||
Reference in New Issue
Block a user