use parking_lot::Mutex; use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::Arc; use std::thread; use tauri::{AppHandle, Emitter}; use tempfile::NamedTempFile; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use crate::config::ClaudeStartOptions; use crate::stats::{UsageStats, StatsUpdateEvent}; use parking_lot::RwLock; use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent}; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; fn detect_wsl() -> bool { // Check /proc/version for WSL indicators if let Ok(version) = std::fs::read_to_string("/proc/version") { let version_lower = version.to_lowercase(); if version_lower.contains("microsoft") || version_lower.contains("wsl") { return true; } } // Fallback: check for WSLInterop if std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() { return true; } // Check for WSL environment variable if std::env::var("WSL_DISTRO_NAME").is_ok() { return true; } false } fn find_claude_binary() -> Option { // Check common installation locations for claude let home = std::env::var("HOME").ok()?; let paths_to_check = [ format!("{}/.local/bin/claude", home), format!("{}/.claude/local/claude", home), "/usr/local/bin/claude".to_string(), "/usr/bin/claude".to_string(), ]; for path in &paths_to_check { if std::path::Path::new(path).exists() { return Some(path.clone()); } } // Fall back to checking PATH via which if let Ok(output) = Command::new("which").arg("claude").output() { if output.status.success() { let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !path.is_empty() { return Some(path); } } } None } pub struct WslBridge { process: Option, stdin: Option, working_directory: String, session_id: Option, mcp_config_file: Option, stats: Arc>, } impl WslBridge { pub fn new() -> Self { WslBridge { process: None, stdin: None, working_directory: String::new(), session_id: None, mcp_config_file: None, stats: Arc::new(RwLock::new(UsageStats::new())), } } #[allow(dead_code)] pub async fn new_with_loaded_achievements(app: &tauri::AppHandle) -> Self { let bridge = Self::new(); // Load saved achievements into the stats let achievements = crate::achievements::load_achievements(app).await; println!("Loaded achievements into bridge: {} unlocked", achievements.unlocked.len()); bridge.stats.write().achievements = achievements; bridge } pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { if self.process.is_some() { return Err("Process already running".to_string()); } // Load saved achievements when starting a new session let app_clone = app.clone(); let stats = self.stats.clone(); tauri::async_runtime::spawn(async move { println!("Loading saved achievements..."); let achievements = crate::achievements::load_achievements(&app_clone).await; println!("Loaded {} unlocked achievements", achievements.unlocked.len()); stats.write().achievements = achievements; }); let working_dir = &options.working_dir; self.working_directory = working_dir.clone(); emit_connection_status(&app, ConnectionStatus::Connecting); // Create temp file for MCP config if provided let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json { if !mcp_json.trim().is_empty() { // Validate JSON before writing serde_json::from_str::(mcp_json) .map_err(|e| format!("Invalid MCP servers JSON: {}", e))?; let mut temp_file = NamedTempFile::new() .map_err(|e| format!("Failed to create temp file for MCP config: {}", e))?; temp_file .write_all(mcp_json.as_bytes()) .map_err(|e| format!("Failed to write MCP config: {}", e))?; temp_file .flush() .map_err(|e| format!("Failed to flush MCP config: {}", e))?; let path = temp_file.path().to_string_lossy().to_string(); self.mcp_config_file = Some(temp_file); Some(path) } else { None } } else { None }; // Detect if we're running inside WSL or on Windows let is_wsl = detect_wsl(); eprintln!("[DEBUG] is_wsl: {}", is_wsl); eprintln!("[DEBUG] options: {:?}", options); let mut command = if is_wsl { // Running inside WSL - call claude directly // Try to find claude in common locations since GUI apps may not inherit shell PATH let claude_path = find_claude_binary() .ok_or_else(|| "Could not find claude binary. Is Claude Code installed?".to_string())?; eprintln!("[DEBUG] Found claude at: {}", claude_path); eprintln!("[DEBUG] Working dir: {}", working_dir); let mut cmd = Command::new(&claude_path); cmd.args([ "--output-format", "stream-json", "--input-format", "stream-json", "--verbose", ]); // Add model if specified if let Some(ref model) = options.model { if !model.is_empty() { cmd.args(["--model", model]); } } // Add allowed tools if any for tool in &options.allowed_tools { cmd.args(["--allowedTools", tool]); } // Add custom instructions as system prompt if specified if let Some(ref instructions) = options.custom_instructions { if !instructions.is_empty() { cmd.args(["--system-prompt", instructions]); } } // Add MCP config if provided if let Some(ref mcp_path) = mcp_config_path { cmd.args(["--mcp-config", mcp_path]); } cmd.current_dir(working_dir); // Set API key as environment variable if specified if let Some(ref api_key) = options.api_key { if !api_key.is_empty() { cmd.env("ANTHROPIC_API_KEY", api_key); } } cmd } else { // Running on Windows - use wsl with bash login shell to ensure PATH is loaded eprintln!("[DEBUG] Windows path - using wsl"); let mut cmd = Command::new("wsl"); // Build the claude command with all arguments let mut claude_cmd = format!( "cd '{}' && ", working_dir ); // Set API key as environment variable if specified if let Some(ref api_key) = options.api_key { if !api_key.is_empty() { claude_cmd.push_str(&format!("ANTHROPIC_API_KEY='{}' ", api_key)); } } claude_cmd.push_str("claude --output-format stream-json --input-format stream-json --verbose"); // Add model if specified if let Some(ref model) = options.model { if !model.is_empty() { claude_cmd.push_str(&format!(" --model '{}'", model)); } } // Add allowed tools if any for tool in &options.allowed_tools { claude_cmd.push_str(&format!(" --allowedTools '{}'", tool)); } // Add custom instructions as system prompt if specified if let Some(ref instructions) = options.custom_instructions { if !instructions.is_empty() { // Escape single quotes in instructions let escaped = instructions.replace('\'', "'\\''"); claude_cmd.push_str(&format!(" --system-prompt '{}'", escaped)); } } // Add MCP config if provided if let Some(ref mcp_path) = mcp_config_path { claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path)); } // Use bash -lc to load login profile (ensures PATH includes claude) cmd.args(["-e", "bash", "-lc", &claude_cmd]); // Hide the console window on Windows #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW cmd }; command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = command.spawn().map_err(|e| { eprintln!("[DEBUG] Spawn error: {:?}", e); format!("Failed to spawn process: {}", e) })?; let stdin = child.stdin.take(); let stdout = child.stdout.take(); let stderr = child.stderr.take(); self.stdin = stdin; self.process = Some(child); // Reset session stats when starting new session self.stats.write().reset_session(); // Load saved achievements let app_handle = app.clone(); let stats_clone = self.stats.clone(); tokio::spawn(async move { let saved_progress = crate::achievements::load_achievements(&app_handle).await; stats_clone.write().achievements = saved_progress; }); if let Some(stdout) = stdout { let app_clone = app.clone(); let stats_clone = self.stats.clone(); thread::spawn(move || { handle_stdout(stdout, app_clone, stats_clone); }); } if let Some(stderr) = stderr { let app_clone = app.clone(); thread::spawn(move || { handle_stderr(stderr, app_clone); }); } emit_connection_status(&app, ConnectionStatus::Connected); Ok(()) } pub fn send_message(&mut self, message: &str) -> Result<(), String> { let stdin = self.stdin.as_mut().ok_or("Process not running")?; let input = serde_json::json!({ "type": "user", "message": { "role": "user", "content": [{ "type": "text", "text": message }] } }); let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?; stdin .write_all(format!("{}\n", json_line).as_bytes()) .map_err(|e| format!("Failed to write to stdin: {}", e))?; stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?; Ok(()) } pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> { // Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work, // we have to kill the process. This is the only reliable way to stop it. // See: https://github.com/anthropics/claude-code/issues/3455 if let Some(mut process) = self.process.take() { // Kill the process immediately let _ = process.kill(); let _ = process.wait(); // Clear stdin self.stdin = None; // Keep session_id and working directory for user reference // The user will see what session was interrupted // Emit disconnected status emit_connection_status(app, ConnectionStatus::Disconnected); Ok(()) } else { Err("No active process to interrupt".to_string()) } } pub fn stop(&mut self, app: &AppHandle) { if let Some(mut process) = self.process.take() { let _ = process.kill(); let _ = process.wait(); } self.stdin = None; self.session_id = None; self.mcp_config_file = None; // Temp file is automatically deleted when dropped emit_connection_status(app, ConnectionStatus::Disconnected); } pub fn is_running(&self) -> bool { self.process.is_some() } pub fn get_working_directory(&self) -> &str { &self.working_directory } pub fn get_stats(&self) -> UsageStats { self.stats.read().clone() } #[allow(dead_code)] pub fn update_stats(&mut self, input_tokens: u64, output_tokens: u64, model: &str) { self.stats.write().add_usage(input_tokens, output_tokens, model); } #[allow(dead_code)] pub fn reset_session_stats(&mut self) { self.stats.write().reset_session(); } } impl Default for WslBridge { fn default() -> Self { Self::new() } } fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc>) { let reader = BufReader::new(stdout); for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { if let Err(e) = process_json_line(&line, &app, &stats) { eprintln!("Error processing line: {}", e); } } Err(e) => { eprintln!("Error reading stdout: {}", e); break; } _ => {} } } emit_connection_status(&app, ConnectionStatus::Disconnected); } fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) { let reader = BufReader::new(stderr); for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { let _ = app.emit("claude:output", OutputEvent { line_type: "error".to_string(), content: line, tool_name: None, }); } Err(_) => break, _ => {} } } } fn process_json_line(line: &str, app: &AppHandle, stats: &Arc>) -> Result<(), String> { let message: ClaudeMessage = serde_json::from_str(line) .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; match &message { ClaudeMessage::System { subtype, session_id, cwd, .. } => { if subtype == "init" { if let Some(id) = session_id { let _ = app.emit("claude:session", id.clone()); } if let Some(dir) = cwd { let _ = app.emit("claude:cwd", dir.clone()); } emit_state_change(app, CharacterState::Idle, None); } } ClaudeMessage::Assistant { message, .. } => { let mut state = CharacterState::Typing; let mut tool_name = None; // Only update stats if we have usage information if let Some(usage) = &message.usage { if let Some(model) = &message.model { // Batch all stats updates in a single write lock { let mut stats_guard = stats.write(); stats_guard.increment_messages(); stats_guard.add_usage(usage.input_tokens, usage.output_tokens, model); stats_guard.get_session_duration(); } // Don't emit here - we'll emit on Result message instead // This reduces the frequency of updates } else { // Just increment message count if no usage info stats.write().increment_messages(); } } else { // Just increment message count if no usage info stats.write().increment_messages(); } for block in &message.content { match block { ContentBlock::ToolUse { name, input, .. } => { tool_name = Some(name.clone()); state = get_tool_state(name); // Batch tool tracking updates { let mut stats_guard = stats.write(); stats_guard.increment_tool_usage(name); // Track file operations match name.as_str() { "Edit" => stats_guard.increment_files_edited(), "Write" => stats_guard.increment_files_created(), _ => {} } } let desc = format_tool_description(name, input); let _ = app.emit("claude:output", OutputEvent { line_type: "tool".to_string(), content: desc, tool_name: Some(name.clone()), }); } ContentBlock::Text { text } => { // Count code blocks in the text let code_blocks = text.matches("```").count() / 2; for _ in 0..code_blocks { stats.write().increment_code_blocks(); } let _ = app.emit("claude:output", OutputEvent { line_type: "assistant".to_string(), content: text.clone(), tool_name: None, }); } ContentBlock::Thinking { thinking } => { state = CharacterState::Thinking; let _ = app.emit("claude:output", OutputEvent { line_type: "system".to_string(), content: format!("[Thinking] {}", thinking), tool_name: None, }); } _ => {} } } emit_state_change(app, state, tool_name); } ClaudeMessage::StreamEvent { event } => { if event.event_type == "content_block_start" { if let Some(block) = &event.content_block { let state = match block.block_type.as_str() { "thinking" => CharacterState::Thinking, "text" => CharacterState::Typing, "tool_use" => { if let Some(name) = &block.name { get_tool_state(name) } else { CharacterState::Typing } } _ => CharacterState::Typing, }; emit_state_change(app, state, block.name.clone()); } } else if event.event_type == "content_block_delta" { if let Some(delta) = &event.delta { if let Some(text) = &delta.text { let _ = app.emit("claude:stream", text.clone()); } } } } ClaudeMessage::Result { subtype, result, permission_denials, usage: _, .. } => { let state = if subtype == "success" { CharacterState::Success } else { CharacterState::Error }; // Always emit updated stats on result message (less frequent) // This includes the latest session duration let newly_unlocked = { let mut stats_guard = stats.write(); stats_guard.get_session_duration(); println!("Checking achievements after result message..."); let unlocked = stats_guard.check_achievements(); println!("Newly unlocked achievements: {:?}", unlocked); unlocked }; // Emit achievement events for any newly unlocked achievements for achievement_id in &newly_unlocked { let info = get_achievement_info(achievement_id); let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { achievement: info, }); } // Save achievements after unlocking new ones if !newly_unlocked.is_empty() { println!("Saving newly unlocked achievements: {:?}", newly_unlocked); let app_handle = app.clone(); let achievements_progress = stats.read().achievements.clone(); // Use Tauri's async runtime instead of tokio::spawn tauri::async_runtime::spawn(async move { println!("Spawned save task for achievements"); if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { eprintln!("Failed to save achievements: {}", e); } else { println!("Achievement save task completed successfully"); } }); } let current_stats = stats.read().clone(); let stats_event = StatsUpdateEvent { stats: current_stats, }; let _ = app.emit("claude:stats", stats_event); // Only emit error results - success content is already sent via Assistant message if subtype != "success" { if let Some(text) = result { let _ = app.emit("claude:output", OutputEvent { line_type: "error".to_string(), content: text.clone(), tool_name: None, }); } } // Check for permission denials and emit prompts for each if let Some(denials) = permission_denials { for denial in denials { let description = format_tool_description(&denial.tool_name, &denial.tool_input); let _ = app.emit("claude:permission", PermissionPromptEvent { id: denial.tool_use_id.clone(), tool_name: denial.tool_name.clone(), tool_input: denial.tool_input.clone(), description, }); } // Show permission state if there were denials if !denials.is_empty() { emit_state_change(app, CharacterState::Permission, None); return Ok(()); } } emit_state_change(app, state, None); } ClaudeMessage::User { message } => { // Increment message count for user messages stats.write().increment_messages(); // Extract text content from the message let message_text = message.content.iter() .filter_map(|block| match block { crate::types::ContentBlock::Text { text } => Some(text.clone()), _ => None, }) .collect::>() .join(" "); // Check achievements after user message let newly_unlocked = { let mut stats_guard = stats.write(); println!("User sent message, checking achievements..."); // Check message-based achievements let mut unlocked = crate::achievements::check_message_achievements( &message_text, &mut stats_guard.achievements, ); // Check stats-based achievements let stats_unlocked = stats_guard.check_achievements(); unlocked.extend(stats_unlocked); unlocked }; // Emit achievement events for any newly unlocked achievements for achievement_id in &newly_unlocked { println!("User message unlocked achievement: {:?}", achievement_id); let info = get_achievement_info(achievement_id); let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { achievement: info, }); } // Save achievements after unlocking new ones if !newly_unlocked.is_empty() { println!("Saving newly unlocked achievements from user message"); let app_handle = app.clone(); let achievements_progress = stats.read().achievements.clone(); tauri::async_runtime::spawn(async move { if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { eprintln!("Failed to save achievements: {}", e); } else { println!("Achievements saved after user message"); } }); } emit_state_change(app, CharacterState::Thinking, None); } } Ok(()) } fn get_tool_state(tool_name: &str) -> CharacterState { if SEARCH_TOOLS.contains(&tool_name) { CharacterState::Searching } else if CODING_TOOLS.contains(&tool_name) { CharacterState::Coding } else if tool_name.starts_with("mcp__") { CharacterState::Mcp } else if tool_name == "Task" { CharacterState::Thinking } else { CharacterState::Typing } } fn format_tool_description(name: &str, input: &serde_json::Value) -> String { match name { "Read" => { if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) { format!("Reading file: {}", path) } else { "Reading file...".to_string() } } "Glob" => { if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) { format!("Searching for files: {}", pattern) } else { "Searching for files...".to_string() } } "Grep" => { if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) { format!("Searching for: {}", pattern) } else { "Searching in files...".to_string() } } "Edit" | "Write" => { if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) { format!("Editing: {}", path) } else { "Editing file...".to_string() } } "Bash" => { if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) { let truncated = if cmd.len() > 50 { format!("{}...", &cmd[..50]) } else { cmd.to_string() }; format!("Running: {}", truncated) } else { "Running command...".to_string() } } _ => format!("Using tool: {}", name), } } fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option) { let _ = app.emit("claude:state", StateChangeEvent { state, tool_name }); } fn emit_connection_status(app: &AppHandle, status: ConnectionStatus) { let _ = app.emit("claude:connection", status); } pub type SharedBridge = Arc>; pub fn create_shared_bridge() -> SharedBridge { Arc::new(Mutex::new(WslBridge::new())) } #[cfg(test)] mod tests { use super::*; #[test] fn test_get_tool_state_search_tools() { assert!(matches!(get_tool_state("Read"), CharacterState::Searching)); assert!(matches!(get_tool_state("Glob"), CharacterState::Searching)); assert!(matches!(get_tool_state("Grep"), CharacterState::Searching)); assert!(matches!(get_tool_state("WebSearch"), CharacterState::Searching)); assert!(matches!(get_tool_state("WebFetch"), CharacterState::Searching)); } #[test] fn test_get_tool_state_coding_tools() { assert!(matches!(get_tool_state("Edit"), CharacterState::Coding)); assert!(matches!(get_tool_state("Write"), CharacterState::Coding)); assert!(matches!(get_tool_state("NotebookEdit"), CharacterState::Coding)); } #[test] fn test_get_tool_state_mcp_tools() { assert!(matches!(get_tool_state("mcp__github__create_issue"), CharacterState::Mcp)); assert!(matches!(get_tool_state("mcp__notion__search"), CharacterState::Mcp)); } #[test] fn test_get_tool_state_task() { assert!(matches!(get_tool_state("Task"), CharacterState::Thinking)); } #[test] fn test_get_tool_state_unknown() { assert!(matches!(get_tool_state("SomeUnknownTool"), CharacterState::Typing)); assert!(matches!(get_tool_state("Bash"), CharacterState::Typing)); } #[test] fn test_format_tool_description_read() { let input = serde_json::json!({"file_path": "/home/test/file.txt"}); let desc = format_tool_description("Read", &input); assert_eq!(desc, "Reading file: /home/test/file.txt"); } #[test] fn test_format_tool_description_read_no_path() { let input = serde_json::json!({}); let desc = format_tool_description("Read", &input); assert_eq!(desc, "Reading file..."); } #[test] fn test_format_tool_description_glob() { let input = serde_json::json!({"pattern": "**/*.rs"}); let desc = format_tool_description("Glob", &input); assert_eq!(desc, "Searching for files: **/*.rs"); } #[test] fn test_format_tool_description_grep() { let input = serde_json::json!({"pattern": "TODO"}); let desc = format_tool_description("Grep", &input); assert_eq!(desc, "Searching for: TODO"); } #[test] fn test_format_tool_description_edit() { let input = serde_json::json!({"file_path": "/home/test/main.rs"}); let desc = format_tool_description("Edit", &input); assert_eq!(desc, "Editing: /home/test/main.rs"); } #[test] fn test_format_tool_description_write() { let input = serde_json::json!({"file_path": "/home/test/new.txt"}); let desc = format_tool_description("Write", &input); assert_eq!(desc, "Editing: /home/test/new.txt"); } #[test] fn test_format_tool_description_bash_short() { let input = serde_json::json!({"command": "ls -la"}); let desc = format_tool_description("Bash", &input); assert_eq!(desc, "Running: ls -la"); } #[test] fn test_format_tool_description_bash_long() { let long_cmd = "a".repeat(100); let input = serde_json::json!({"command": long_cmd}); let desc = format_tool_description("Bash", &input); assert!(desc.starts_with("Running: ")); assert!(desc.ends_with("...")); assert!(desc.len() < 70); } #[test] fn test_format_tool_description_unknown() { let input = serde_json::json!({"some": "data"}); let desc = format_tool_description("CustomTool", &input); assert_eq!(desc, "Using tool: CustomTool"); } #[test] fn test_wsl_bridge_new() { let bridge = WslBridge::new(); assert!(!bridge.is_running()); assert_eq!(bridge.get_working_directory(), ""); } #[test] fn test_wsl_bridge_default() { let bridge = WslBridge::default(); assert!(!bridge.is_running()); } #[test] fn test_create_shared_bridge() { let shared = create_shared_bridge(); let bridge = shared.lock(); assert!(!bridge.is_running()); } }