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

@@ -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());
}