diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1e77d35..2aa2428 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,37 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-client-protocol" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea4b85f3bcd56ebe65f830321d34bc939af1b5a33b9dcb683195a3b72de0cdb" +dependencies = [ + "agent-client-protocol-schema", + "anyhow", + "async-broadcast", + "async-trait", + "derive_more 2.1.1", + "futures", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70829a300bd178abe42836ac779cd3eb3b0dd3881250c752b2621b5324735df1" +dependencies = [ + "anyhow", + "derive_more 2.1.1", + "schemars 1.1.0", + "serde", + "serde_json", + "strum", +] + [[package]] name = "ahash" version = "0.7.8" @@ -576,6 +607,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -792,13 +832,36 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.111", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.111", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -1256,6 +1319,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1263,6 +1341,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1330,6 +1409,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -4001,7 +4081,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -4028,6 +4108,7 @@ checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive 1.1.0", "serde", "serde_json", ] @@ -4044,6 +4125,18 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "schemars_derive" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.111", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4093,7 +4186,7 @@ checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", "cssparser", - "derive_more", + "derive_more 0.99.20", "fxhash", "log", "phf 0.8.0", @@ -4426,8 +4519,11 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "staged" version = "0.1.0" dependencies = [ + "agent-client-protocol", + "async-trait", "chrono", "dirs 5.0.1", + "futures", "git2", "ignore", "log", @@ -4448,6 +4544,7 @@ dependencies = [ "tempfile", "thiserror 2.0.17", "tokio", + "tokio-util", "uuid", ] @@ -4488,6 +4585,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5137,10 +5255,22 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "tracing", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5159,6 +5289,7 @@ checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -5451,6 +5582,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 405f426..811716a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,9 +44,15 @@ dirs = "5.0" tauri-plugin-clipboard-manager = "2.3.2" tauri-plugin-window-state = "2" reqwest = { version = "0.13.1", features = ["json"] } -tokio = { version = "1.49.0", features = ["sync"] } +tokio = { version = "1.49.0", features = ["sync", "process", "io-util", "macros", "rt-multi-thread"] } open = "5" +# Agent Client Protocol (ACP) for AI integration +agent-client-protocol = "0.9" +async-trait = "0.1" +tokio-util = { version = "0.7", features = ["compat"] } +futures = "0.3" + [[bin]] name = "debug_diff" path = "src/bin/debug_diff.rs" diff --git a/src-tauri/src/ai/acp_client.rs b/src-tauri/src/ai/acp_client.rs new file mode 100644 index 0000000..549f1dd --- /dev/null +++ b/src-tauri/src/ai/acp_client.rs @@ -0,0 +1,348 @@ +//! ACP Client - handles communication with AI agents via Agent Client Protocol +//! +//! This module spawns agent processes and communicates with them using ACP, +//! a JSON-RPC based protocol over stdio. Unlike builderbot which maintains +//! persistent sessions, Staged uses one-shot requests for diff analysis. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; + +use agent_client_protocol::{ + Agent, ClientSideConnection, ContentBlock as AcpContentBlock, Implementation, + InitializeRequest, NewSessionRequest, PermissionOptionId, PromptRequest, ProtocolVersion, + RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, + Result as AcpResult, SelectedPermissionOutcome, SessionNotification, TextContent, +}; +use async_trait::async_trait; +use tokio::process::Command; +use tokio::sync::Mutex; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +/// Supported ACP-compatible AI agents +#[derive(Debug, Clone)] +pub enum AcpAgent { + Goose(PathBuf), + Claude(PathBuf), +} + +impl AcpAgent { + pub fn name(&self) -> &'static str { + match self { + AcpAgent::Goose(_) => "goose", + AcpAgent::Claude(_) => "claude", + } + } + + pub fn path(&self) -> &Path { + match self { + AcpAgent::Goose(p) => p, + AcpAgent::Claude(p) => p, + } + } + + /// Get the arguments to start ACP mode + pub fn acp_args(&self) -> Vec<&str> { + match self { + AcpAgent::Goose(_) => vec!["acp"], + AcpAgent::Claude(_) => vec![], // claude-code-acp runs in ACP mode by default + } + } +} + +/// Common paths where CLIs might be installed (for GUI apps that don't inherit shell PATH) +const COMMON_PATHS: &[&str] = &[ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/home/linuxbrew/.linuxbrew/bin", +]; + +/// Find goose CLI using login shell (to get user's PATH) +fn find_via_login_shell(cmd: &str) -> Option { + let which_cmd = format!("which {}", cmd); + + // Try zsh first (default on macOS) + if let Ok(output) = std::process::Command::new("/bin/zsh") + .args(["-l", "-c", &which_cmd]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(path_str) = stdout.lines().rfind(|l| !l.is_empty()) { + let path_str = path_str.trim(); + if !path_str.is_empty() && path_str.starts_with('/') { + return Some(PathBuf::from(path_str)); + } + } + } + } + + // Fallback to bash + if let Ok(output) = std::process::Command::new("/bin/bash") + .args(["-l", "-c", &which_cmd]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(path_str) = stdout.lines().rfind(|l| !l.is_empty()) { + let path_str = path_str.trim(); + if !path_str.is_empty() && path_str.starts_with('/') { + return Some(PathBuf::from(path_str)); + } + } + } + } + + None +} + +/// Verify a command works by running it with --version +fn verify_command(path: &Path) -> bool { + std::process::Command::new(path) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) +} + +/// Find an ACP-compatible AI agent +/// Prefers Goose if available, falls back to Claude +pub fn find_acp_agent() -> Option { + // Try Goose first (default) + if let Some(agent) = find_agent("goose", AcpAgent::Goose) { + return Some(agent); + } + + // Fall back to Claude (claude-code-acp) + find_agent("claude-code-acp", AcpAgent::Claude) +} + +/// Find a specific agent by command name +fn find_agent(cmd: &str, constructor: F) -> Option +where + F: Fn(PathBuf) -> AcpAgent, +{ + // Strategy 1: Login shell which + if let Some(path) = find_via_login_shell(cmd) { + if verify_command(&path) { + return Some(constructor(path)); + } + } + + // Strategy 2: Direct command + let direct_path = PathBuf::from(cmd); + if verify_command(&direct_path) { + return Some(constructor(direct_path)); + } + + // Strategy 3: Common paths + for dir in COMMON_PATHS { + let path = PathBuf::from(dir).join(cmd); + if path.exists() && verify_command(&path) { + return Some(constructor(path)); + } + } + + None +} + +/// Shared state for collecting the response +struct ResponseCollector { + accumulated_content: Mutex, +} + +/// Client implementation for handling agent notifications +struct StagedAcpClient { + collector: Arc, +} + +#[async_trait(?Send)] +impl agent_client_protocol::Client for StagedAcpClient { + async fn request_permission( + &self, + args: RequestPermissionRequest, + ) -> AcpResult { + // Auto-approve permissions (Staged doesn't use tools that need approval) + log::debug!("Permission requested: {:?}", args); + + let option_id = args + .options + .first() + .map(|opt| opt.option_id.clone()) + .unwrap_or_else(|| PermissionOptionId::new("approve")); + + Ok(RequestPermissionResponse::new( + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)), + )) + } + + async fn session_notification(&self, notification: SessionNotification) -> AcpResult<()> { + use agent_client_protocol::SessionUpdate; + + match ¬ification.update { + SessionUpdate::AgentMessageChunk(chunk) => { + if let AcpContentBlock::Text(text) = &chunk.content { + let mut accumulated = self.collector.accumulated_content.lock().await; + accumulated.push_str(&text.text); + } + } + _ => { + log::debug!("Ignoring session update: {:?}", notification.update); + } + } + + Ok(()) + } +} + +/// Run a one-shot prompt through ACP and return the response +/// +/// This spawns the agent, initializes ACP, sends the prompt, collects the +/// response, and shuts down. Designed for Staged's single-request use case. +/// +/// Runs in a dedicated thread with its own LocalSet to handle !Send futures. +pub async fn run_acp_prompt( + agent: &AcpAgent, + working_dir: &Path, + prompt: &str, +) -> Result { + let agent_path = agent.path().to_path_buf(); + let agent_name = agent.name().to_string(); + let agent_args: Vec = agent.acp_args().iter().map(|s| s.to_string()).collect(); + let working_dir = working_dir.to_path_buf(); + let prompt = prompt.to_string(); + + // Run the ACP session in a blocking task with its own runtime + // This is needed because ACP uses !Send futures (LocalSet) + tokio::task::spawn_blocking(move || { + // Create a new runtime for this thread + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("Failed to create runtime: {}", e))?; + + // Run the ACP session on a LocalSet + let local = tokio::task::LocalSet::new(); + local.block_on(&rt, async move { + run_acp_session_inner(&agent_path, &agent_name, &agent_args, &working_dir, &prompt) + .await + }) + }) + .await + .map_err(|e| format!("Task join error: {}", e))? +} + +/// Internal function to run the ACP session (runs on LocalSet) +async fn run_acp_session_inner( + agent_path: &Path, + agent_name: &str, + agent_args: &[String], + working_dir: &Path, + prompt: &str, +) -> Result { + // Spawn the agent process with ACP mode + let mut cmd = Command::new(agent_path); + cmd.args(agent_args) + .current_dir(working_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); // Ensure child is killed if we exit early + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn {}: {}", agent_name, e))?; + + // Get stdin/stdout + let stdin = child + .stdin + .take() + .ok_or_else(|| "Failed to get stdin from agent process".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "Failed to get stdout from agent process".to_string())?; + + // Convert to futures-compatible async read/write + let stdin_compat = stdin.compat_write(); + let stdout_compat = stdout.compat(); + + // Create response collector + let collector = Arc::new(ResponseCollector { + accumulated_content: Mutex::new(String::new()), + }); + + // Create client handler + let client = StagedAcpClient { + collector: collector.clone(), + }; + + // Create the ACP connection + let (connection, io_future) = + ClientSideConnection::new(client, stdin_compat, stdout_compat, |fut| { + tokio::task::spawn_local(fut); + }); + + // Spawn the IO task + tokio::task::spawn_local(async move { + if let Err(e) = io_future.await { + log::error!("ACP IO error: {:?}", e); + } + }); + + // Initialize the connection + let client_info = Implementation::new("staged", env!("CARGO_PKG_VERSION")); + let init_request = InitializeRequest::new(ProtocolVersion::LATEST).client_info(client_info); + + let init_response = connection + .initialize(init_request) + .await + .map_err(|e| format!("Failed to initialize ACP connection: {:?}", e))?; + + if let Some(agent_info) = &init_response.agent_info { + log::info!( + "Connected to agent: {} v{}", + agent_info.name, + agent_info.version + ); + } + + // Create a new session + let session_response = connection + .new_session(NewSessionRequest::new(working_dir.to_path_buf())) + .await + .map_err(|e| format!("Failed to create ACP session: {:?}", e))?; + + let session_id = session_response.session_id; + + // Send the prompt + let prompt_request = PromptRequest::new( + session_id, + vec![AcpContentBlock::Text(TextContent::new(prompt.to_string()))], + ); + + connection + .prompt(prompt_request) + .await + .map_err(|e| format!("Failed to send prompt: {:?}", e))?; + + // Clean up the child process + let _ = child.kill().await; + + // Get the accumulated response + let response = collector.accumulated_content.lock().await.clone(); + + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_acp_agent() { + // This test just verifies the function doesn't panic + // Actual availability depends on the system + let _ = find_acp_agent(); + } +} diff --git a/src-tauri/src/ai/mod.rs b/src-tauri/src/ai/mod.rs index c7efbcb..0944660 100644 --- a/src-tauri/src/ai/mod.rs +++ b/src-tauri/src/ai/mod.rs @@ -1,11 +1,13 @@ -//! AI-powered diff analysis. +//! AI-powered diff analysis via ACP (Agent Client Protocol). //! -//! Shells out to AI CLI tools (goose or claude) to generate contextual +//! Communicates with ACP-compatible agents like Goose to generate contextual //! annotations for code changes. +mod acp_client; mod prompt; mod runner; mod types; -pub use runner::{analyze_diff, find_ai_tool, AiTool}; +pub use acp_client::{find_acp_agent, AcpAgent}; +pub use runner::analyze_diff; pub use types::{ChangesetAnalysis, ChangesetSummary, SmartDiffAnnotation, SmartDiffResult}; diff --git a/src-tauri/src/ai/runner.rs b/src-tauri/src/ai/runner.rs index 9884e8c..797335f 100644 --- a/src-tauri/src/ai/runner.rs +++ b/src-tauri/src/ai/runner.rs @@ -1,76 +1,34 @@ -//! AI tool discovery and execution. +//! AI tool discovery and execution via ACP (Agent Client Protocol). +//! +//! This module handles AI-powered diff analysis by communicating with +//! ACP-compatible agents like Goose. -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::path::Path; +use super::acp_client::{find_acp_agent, run_acp_prompt, AcpAgent}; use super::prompt::{build_prompt_with_strategy, FileAnalysisInput, LARGE_FILE_THRESHOLD}; use super::types::ChangesetAnalysis; use crate::git::{self, DiffSpec, FileContent}; -/// Supported AI CLI tools. -#[derive(Debug, Clone)] -pub enum AiTool { - Goose(PathBuf), - Claude(PathBuf), -} - -impl AiTool { - pub fn name(&self) -> &'static str { - match self { - AiTool::Goose(_) => "goose", - AiTool::Claude(_) => "claude", - } - } -} - -/// Find an available AI CLI tool. -pub fn find_ai_tool() -> Option { - if let Some(path) = find_in_path("goose") { - return Some(AiTool::Goose(path)); - } - if let Some(path) = find_in_path("claude") { - return Some(AiTool::Claude(path)); - } - None -} - -fn find_in_path(cmd: &str) -> Option { - let output = Command::new("which").arg(cmd).output().ok()?; - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !path.is_empty() { - return Some(PathBuf::from(path)); - } - } - None +/// Find an available AI agent. +/// +/// Currently supports Goose via ACP. +pub fn find_ai_tool() -> Option { + find_acp_agent() } /// Check if output contains a context window error. -/// -/// We need to be careful here - the AI's response might legitimately mention -/// "context window" when analyzing code that deals with context windows. -/// So we look for specific error phrases, not just any mention. -fn detect_context_error(output: &str, tool: &AiTool) -> Option { +fn detect_context_error(output: &str) -> Option { let output_lower = output.to_lowercase(); - // These patterns should be specific error messages, not general phrases - // that might appear in code or analysis - let error_patterns: &[&str] = match tool { - AiTool::Goose(_) => &[ - "context limit reached", - "context length exceeded", - "maximum context length exceeded", - "prompt is too long", - "input too long", - ], - AiTool::Claude(_) => &[ - "context length exceeded", - "prompt is too long", - "input too long", - "exceeds the maximum number of tokens", - "maximum context length", - ], - }; + let error_patterns = &[ + "context limit reached", + "context length exceeded", + "maximum context length exceeded", + "prompt is too long", + "input too long", + "exceeds the maximum number of tokens", + ]; for pattern in error_patterns { if output_lower.contains(pattern) { @@ -84,42 +42,6 @@ fn detect_context_error(output: &str, tool: &AiTool) -> Option { None } -fn run_tool(tool: &AiTool, prompt: &str) -> Result { - let output = match tool { - AiTool::Goose(path) => Command::new(path) - .args(["run", "-t", prompt]) - .output() - .map_err(|e| format!("Failed to run goose: {}", e))?, - - AiTool::Claude(path) => Command::new(path) - .args(["--dangerously-skip-permissions", "-p", prompt]) - .output() - .map_err(|e| format!("Failed to run claude: {}", e))?, - }; - - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - - // Check for context window errors in both stdout and stderr - if let Some(error_msg) = detect_context_error(&stdout, tool) { - return Err(error_msg); - } - if let Some(error_msg) = detect_context_error(&stderr, tool) { - return Err(error_msg); - } - - if !output.status.success() { - return Err(format!( - "{} failed (exit {}): {}", - tool.name(), - output.status.code().unwrap_or(-1), - stderr - )); - } - - Ok(stdout) -} - /// Parse AI response into ChangesetAnalysis fn parse_response(response: &str) -> Result { let response = response.trim(); @@ -161,23 +83,20 @@ fn load_after_content_if_small( Ok((content, line_count)) } -/// Analyze a diff using AI. +/// Analyze a diff using AI via ACP. /// /// This is the main entry point - it handles: /// 1. Listing files in the diff /// 2. Loading unified diffs and after content for each file /// 3. Building an appropriately-sized prompt (with automatic tier selection) -/// 4. Running AI analysis +/// 4. Running AI analysis via ACP /// 5. Returning the complete result /// /// The frontend just needs to provide the diff spec. -pub fn analyze_diff(repo_path: &Path, spec: &DiffSpec) -> Result { - // Find AI tool first (fail fast) - let tool = find_ai_tool().ok_or_else(|| { - "No AI CLI found. Install one of:\n\ - - goose: https://github.com/block/goose\n\ - - claude: npm install -g @anthropic-ai/claude-code" - .to_string() +pub async fn analyze_diff(repo_path: &Path, spec: &DiffSpec) -> Result { + // Find AI agent first (fail fast) + let agent = find_ai_tool().ok_or_else(|| { + "No AI agent found. Install Goose: https://github.com/block/goose".to_string() })?; // List files in the diff @@ -242,13 +161,19 @@ pub fn analyze_diff(repo_path: &Path, spec: &DiffSpec) -> Result = env::args().skip(1).collect(); println!("=== Smart Diff AI Test ===\n"); - // Check for AI tool - match ai::find_ai_tool() { - Some(tool) => println!("✓ Found AI tool: {}\n", tool.name()), + // Check for AI agent + match ai::find_acp_agent() { + Some(agent) => println!("✓ Found AI agent: {}\n", agent.name()), None => { - eprintln!("✗ No AI tool found. Install goose or claude."); + eprintln!("✗ No AI agent found. Install goose: https://github.com/block/goose"); std::process::exit(1); } } @@ -30,7 +31,7 @@ fn main() { None | Some("--help") | Some("-h") => print_help(), Some(range) => { let repo_path = args.get(1).map(|s| s.as_str()).unwrap_or("."); - test_real_diff(range, repo_path); + test_real_diff(range, repo_path).await; } } } @@ -50,7 +51,7 @@ Examples: ); } -fn test_real_diff(range: &str, repo_path: &str) { +async fn test_real_diff(range: &str, repo_path: &str) { // Parse base..head let parts: Vec<&str> = range.split("..").collect(); if parts.len() != 2 { @@ -73,9 +74,9 @@ fn test_real_diff(range: &str, repo_path: &str) { }; // Run analysis - the backend handles file listing and content loading - println!("Analyzing diff with AI (this may take a few seconds)...\n"); + println!("Analyzing diff with AI via ACP (this may take a few seconds)...\n"); - match ai::analyze_diff(repo, &spec) { + match ai::analyze_diff(repo, &spec).await { Ok(result) => { println!("═══════════════════════════════════════════════════════════════"); println!(" CHANGESET ANALYSIS"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6999515..4b5181a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -490,16 +490,16 @@ async fn sync_review_to_github( use ai::{ChangesetAnalysis, ChangesetSummary, SmartDiffResult}; -/// Check if an AI CLI tool is available. +/// Check if an AI agent is available (via ACP). #[tauri::command(rename_all = "camelCase")] fn check_ai_available() -> Result { - match ai::find_ai_tool() { - Some(tool) => Ok(tool.name().to_string()), - None => Err("No AI CLI found. Install goose or claude.".to_string()), + match ai::find_acp_agent() { + Some(agent) => Ok(agent.name().to_string()), + None => Err("No AI agent found. Install Goose: https://github.com/block/goose".to_string()), } } -/// Analyze a diff using AI. +/// Analyze a diff using AI via ACP. /// /// This is the main AI entry point - handles file listing, content loading, /// and AI analysis in one call. Frontend just provides the diff spec. @@ -510,10 +510,8 @@ async fn analyze_diff( ) -> Result { let path = get_repo_path(repo_path.as_deref()).to_path_buf(); - // Run on blocking thread pool since this does file I/O and spawns a subprocess - tokio::task::spawn_blocking(move || ai::analyze_diff(&path, &spec)) - .await - .map_err(|e| format!("Task join error: {}", e))? + // analyze_diff is now async (uses ACP) + ai::analyze_diff(&path, &spec).await } // =============================================================================