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,557 @@
//! Git integration module for detecting repository state and validating git commands.
//!
//! This module provides functionality to:
//! - Detect if the current directory is a git repository
//! - Capture git repository state (branch, status, uncommitted changes)
//! - Validate git commands for safety (read-only vs destructive operations)
use color_eyre::eyre::Result;
use std::path::Path;
use std::process::Command;
/// Status of a file in the git working tree
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitFileStatus {
/// File has been modified
Modified { path: String },
/// File has been added (staged)
Added { path: String },
/// File has been deleted
Deleted { path: String },
/// File has been renamed
Renamed { from: String, to: String },
/// File is untracked
Untracked { path: String },
}
impl GitFileStatus {
/// Get the primary path associated with this status
pub fn path(&self) -> &str {
match self {
Self::Modified { path } => path,
Self::Added { path } => path,
Self::Deleted { path } => path,
Self::Renamed { to, .. } => to,
Self::Untracked { path } => path,
}
}
}
/// Complete state of a git repository
#[derive(Debug, Clone)]
pub struct GitState {
/// Whether the current directory is in a git repository
pub is_git_repo: bool,
/// Current branch name (None if not in a repo or detached HEAD)
pub current_branch: Option<String>,
/// Main branch name (main/master, None if not detected)
pub main_branch: Option<String>,
/// Status of files in the working tree
pub status: Vec<GitFileStatus>,
/// Whether there are any uncommitted changes
pub has_uncommitted_changes: bool,
/// Remote URL for the repository (None if no remote configured)
pub remote_url: Option<String>,
}
impl GitState {
/// Create a default GitState for non-git directories
pub fn not_a_repo() -> Self {
Self {
is_git_repo: false,
current_branch: None,
main_branch: None,
status: Vec::new(),
has_uncommitted_changes: false,
remote_url: None,
}
}
}
/// Detect the current git repository state
///
/// This function runs various git commands to gather information about the repository.
/// If git is not available or the directory is not a git repo, returns a default state.
pub fn detect_git_state(working_dir: &Path) -> Result<GitState> {
// Check if this is a git repository
let is_repo = Command::new("git")
.arg("rev-parse")
.arg("--git-dir")
.current_dir(working_dir)
.output()
.map(|output| output.status.success())
.unwrap_or(false);
if !is_repo {
return Ok(GitState::not_a_repo());
}
// Get current branch
let current_branch = get_current_branch(working_dir)?;
// Detect main branch (try main first, then master)
let main_branch = detect_main_branch(working_dir)?;
// Get file status
let status = get_git_status(working_dir)?;
// Check if there are uncommitted changes
let has_uncommitted_changes = !status.is_empty();
// Get remote URL
let remote_url = get_remote_url(working_dir)?;
Ok(GitState {
is_git_repo: true,
current_branch,
main_branch,
status,
has_uncommitted_changes,
remote_url,
})
}
/// Get the current branch name
fn get_current_branch(working_dir: &Path) -> Result<Option<String>> {
let output = Command::new("git")
.arg("rev-parse")
.arg("--abbrev-ref")
.arg("HEAD")
.current_dir(working_dir)
.output()?;
if !output.status.success() {
return Ok(None);
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
// "HEAD" means detached HEAD state
if branch == "HEAD" {
Ok(None)
} else {
Ok(Some(branch))
}
}
/// Detect the main branch (main or master)
fn detect_main_branch(working_dir: &Path) -> Result<Option<String>> {
// Try to get all branches
let output = Command::new("git")
.arg("branch")
.arg("-a")
.current_dir(working_dir)
.output()?;
if !output.status.success() {
return Ok(None);
}
let branches = String::from_utf8_lossy(&output.stdout);
// Check for main branch first (modern convention)
if branches.lines().any(|line| {
let trimmed = line.trim_start_matches('*').trim();
trimmed == "main" || trimmed.ends_with("/main")
}) {
return Ok(Some("main".to_string()));
}
// Fall back to master
if branches.lines().any(|line| {
let trimmed = line.trim_start_matches('*').trim();
trimmed == "master" || trimmed.ends_with("/master")
}) {
return Ok(Some("master".to_string()));
}
Ok(None)
}
/// Get the git status for all files
fn get_git_status(working_dir: &Path) -> Result<Vec<GitFileStatus>> {
let output = Command::new("git")
.arg("status")
.arg("--porcelain")
.arg("-z") // Null-terminated for better parsing
.current_dir(working_dir)
.output()?;
if !output.status.success() {
return Ok(Vec::new());
}
let status_text = String::from_utf8_lossy(&output.stdout);
let mut statuses = Vec::new();
// Parse porcelain format with null termination
// Format: XY filename\0 (where X is staged status, Y is unstaged status)
for entry in status_text.split('\0').filter(|s| !s.is_empty()) {
if entry.len() < 3 {
continue;
}
let status_code = &entry[0..2];
let path = entry[3..].to_string();
// Parse status codes
match status_code {
"M " | " M" | "MM" => {
statuses.push(GitFileStatus::Modified { path });
}
"A " | " A" | "AM" => {
statuses.push(GitFileStatus::Added { path });
}
"D " | " D" | "AD" => {
statuses.push(GitFileStatus::Deleted { path });
}
"??" => {
statuses.push(GitFileStatus::Untracked { path });
}
s if s.starts_with('R') => {
// Renamed files have format "R old_name -> new_name"
if let Some((from, to)) = path.split_once(" -> ") {
statuses.push(GitFileStatus::Renamed {
from: from.to_string(),
to: to.to_string(),
});
} else {
// Fallback if parsing fails
statuses.push(GitFileStatus::Modified { path });
}
}
_ => {
// Unknown status code, treat as modified
statuses.push(GitFileStatus::Modified { path });
}
}
}
Ok(statuses)
}
/// Get the remote URL for the repository
fn get_remote_url(working_dir: &Path) -> Result<Option<String>> {
let output = Command::new("git")
.arg("remote")
.arg("get-url")
.arg("origin")
.current_dir(working_dir)
.output()?;
if !output.status.success() {
return Ok(None);
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
if url.is_empty() {
Ok(None)
} else {
Ok(Some(url))
}
}
/// Check if a git command is safe (read-only)
///
/// Safe commands include:
/// - status, log, show, diff, branch (without -D)
/// - remote (without add/remove)
/// - config --get
/// - rev-parse, ls-files, ls-tree
pub fn is_safe_git_command(command: &str) -> bool {
let parts: Vec<&str> = command.split_whitespace().collect();
if parts.is_empty() || parts[0] != "git" {
return false;
}
if parts.len() < 2 {
return false;
}
let subcommand = parts[1];
// List of read-only git commands
match subcommand {
"status" | "log" | "show" | "diff" | "blame" | "reflog" => true,
"ls-files" | "ls-tree" | "ls-remote" => true,
"rev-parse" | "rev-list" => true,
"describe" | "tag" if !command.contains("-d") && !command.contains("--delete") => true,
"branch" if !command.contains("-D") && !command.contains("-d") && !command.contains("-m") => true,
"remote" if command.contains("get-url") || command.contains("-v") || command.contains("show") => true,
"config" if command.contains("--get") || command.contains("--list") => true,
"grep" | "shortlog" | "whatchanged" => true,
"fetch" if !command.contains("--prune") => true,
_ => false,
}
}
/// Check if a git command is destructive
///
/// Returns (is_destructive, warning_message) tuple.
/// Destructive commands include:
/// - push --force, reset --hard, clean -fd
/// - rebase, amend, filter-branch
/// - branch -D, tag -d
pub fn is_destructive_git_command(command: &str) -> (bool, &'static str) {
let cmd_lower = command.to_lowercase();
// Check for force push
if cmd_lower.contains("push") && (cmd_lower.contains("--force") || cmd_lower.contains("-f")) {
return (true, "Force push can overwrite remote history and affect other collaborators");
}
// Check for hard reset
if cmd_lower.contains("reset") && cmd_lower.contains("--hard") {
return (true, "Hard reset will discard uncommitted changes permanently");
}
// Check for git clean
if cmd_lower.contains("clean") && (cmd_lower.contains("-f") || cmd_lower.contains("-d")) {
return (true, "Git clean will permanently delete untracked files");
}
// Check for rebase
if cmd_lower.contains("rebase") {
return (true, "Rebase rewrites commit history and can cause conflicts");
}
// Check for amend
if cmd_lower.contains("commit") && cmd_lower.contains("--amend") {
return (true, "Amending rewrites the last commit and changes its hash");
}
// Check for filter-branch or filter-repo
if cmd_lower.contains("filter-branch") || cmd_lower.contains("filter-repo") {
return (true, "Filter operations rewrite repository history");
}
// Check for branch/tag deletion
if (cmd_lower.contains("branch") && (cmd_lower.contains("-D") || cmd_lower.contains("-d")))
|| (cmd_lower.contains("tag") && (cmd_lower.contains("-d") || cmd_lower.contains("--delete")))
{
return (true, "This will delete a branch or tag");
}
// Check for reflog expire
if cmd_lower.contains("reflog") && cmd_lower.contains("expire") {
return (true, "Expiring reflog removes recovery points for lost commits");
}
// Check for gc with aggressive or prune
if cmd_lower.contains("gc") && (cmd_lower.contains("--aggressive") || cmd_lower.contains("--prune")) {
return (true, "Aggressive garbage collection can make recovery difficult");
}
(false, "")
}
/// Format git state for human-readable display
///
/// Example output:
/// ```text
/// Git Repository: yes
/// Current branch: feature-branch
/// Main branch: main
/// Status: 3 modified, 1 untracked
/// Remote: https://github.com/user/repo.git
/// ```
pub fn format_git_status(state: &GitState) -> String {
if !state.is_git_repo {
return "Not a git repository".to_string();
}
let mut lines = Vec::new();
lines.push("Git Repository: yes".to_string());
if let Some(branch) = &state.current_branch {
lines.push(format!("Current branch: {}", branch));
} else {
lines.push("Current branch: (detached HEAD)".to_string());
}
if let Some(main) = &state.main_branch {
lines.push(format!("Main branch: {}", main));
}
// Summarize status
if state.status.is_empty() {
lines.push("Status: clean working tree".to_string());
} else {
let mut modified = 0;
let mut added = 0;
let mut deleted = 0;
let mut renamed = 0;
let mut untracked = 0;
for status in &state.status {
match status {
GitFileStatus::Modified { .. } => modified += 1,
GitFileStatus::Added { .. } => added += 1,
GitFileStatus::Deleted { .. } => deleted += 1,
GitFileStatus::Renamed { .. } => renamed += 1,
GitFileStatus::Untracked { .. } => untracked += 1,
}
}
let mut status_parts = Vec::new();
if modified > 0 {
status_parts.push(format!("{} modified", modified));
}
if added > 0 {
status_parts.push(format!("{} added", added));
}
if deleted > 0 {
status_parts.push(format!("{} deleted", deleted));
}
if renamed > 0 {
status_parts.push(format!("{} renamed", renamed));
}
if untracked > 0 {
status_parts.push(format!("{} untracked", untracked));
}
lines.push(format!("Status: {}", status_parts.join(", ")));
}
if let Some(url) = &state.remote_url {
lines.push(format!("Remote: {}", url));
} else {
lines.push("Remote: (none)".to_string());
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_safe_git_command() {
// Safe commands
assert!(is_safe_git_command("git status"));
assert!(is_safe_git_command("git log --oneline"));
assert!(is_safe_git_command("git diff HEAD"));
assert!(is_safe_git_command("git branch -v"));
assert!(is_safe_git_command("git remote -v"));
assert!(is_safe_git_command("git config --get user.name"));
// Unsafe commands
assert!(!is_safe_git_command("git commit -m test"));
assert!(!is_safe_git_command("git push origin main"));
assert!(!is_safe_git_command("git branch -D feature"));
assert!(!is_safe_git_command("git remote add origin url"));
}
#[test]
fn test_is_destructive_git_command() {
// Destructive commands
let (is_dest, msg) = is_destructive_git_command("git push --force origin main");
assert!(is_dest);
assert!(msg.contains("Force push"));
let (is_dest, msg) = is_destructive_git_command("git reset --hard HEAD~1");
assert!(is_dest);
assert!(msg.contains("Hard reset"));
let (is_dest, msg) = is_destructive_git_command("git clean -fd");
assert!(is_dest);
assert!(msg.contains("clean"));
let (is_dest, msg) = is_destructive_git_command("git rebase main");
assert!(is_dest);
assert!(msg.contains("Rebase"));
let (is_dest, msg) = is_destructive_git_command("git commit --amend");
assert!(is_dest);
assert!(msg.contains("Amending"));
// Non-destructive commands
let (is_dest, _) = is_destructive_git_command("git status");
assert!(!is_dest);
let (is_dest, _) = is_destructive_git_command("git log");
assert!(!is_dest);
let (is_dest, _) = is_destructive_git_command("git diff");
assert!(!is_dest);
}
#[test]
fn test_git_state_not_a_repo() {
let state = GitState::not_a_repo();
assert!(!state.is_git_repo);
assert!(state.current_branch.is_none());
assert!(state.main_branch.is_none());
assert!(state.status.is_empty());
assert!(!state.has_uncommitted_changes);
assert!(state.remote_url.is_none());
}
#[test]
fn test_git_file_status_path() {
let status = GitFileStatus::Modified {
path: "test.rs".to_string(),
};
assert_eq!(status.path(), "test.rs");
let status = GitFileStatus::Renamed {
from: "old.rs".to_string(),
to: "new.rs".to_string(),
};
assert_eq!(status.path(), "new.rs");
}
#[test]
fn test_format_git_status_not_repo() {
let state = GitState::not_a_repo();
let formatted = format_git_status(&state);
assert_eq!(formatted, "Not a git repository");
}
#[test]
fn test_format_git_status_clean() {
let state = GitState {
is_git_repo: true,
current_branch: Some("main".to_string()),
main_branch: Some("main".to_string()),
status: Vec::new(),
has_uncommitted_changes: false,
remote_url: Some("https://github.com/user/repo.git".to_string()),
};
let formatted = format_git_status(&state);
assert!(formatted.contains("Git Repository: yes"));
assert!(formatted.contains("Current branch: main"));
assert!(formatted.contains("clean working tree"));
}
#[test]
fn test_format_git_status_with_changes() {
let state = GitState {
is_git_repo: true,
current_branch: Some("feature".to_string()),
main_branch: Some("main".to_string()),
status: vec![
GitFileStatus::Modified {
path: "file1.rs".to_string(),
},
GitFileStatus::Modified {
path: "file2.rs".to_string(),
},
GitFileStatus::Untracked {
path: "new.rs".to_string(),
},
],
has_uncommitted_changes: true,
remote_url: None,
};
let formatted = format_git_status(&state);
assert!(formatted.contains("2 modified"));
assert!(formatted.contains("1 untracked"));
}
}