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}; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent}; 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, } impl WslBridge { pub fn new() -> Self { WslBridge { process: None, stdin: None, working_directory: String::new(), session_id: None, } } pub fn start(&mut self, app: AppHandle, working_dir: &str, allowed_tools: Vec) -> Result<(), String> { if self.process.is_some() { return Err("Process already running".to_string()); } self.working_directory = working_dir.to_string(); emit_connection_status(&app, ConnectionStatus::Connecting); // Detect if we're running inside WSL or on Windows let is_wsl = detect_wsl(); eprintln!("[DEBUG] is_wsl: {}", is_wsl); eprintln!("[DEBUG] allowed_tools: {:?}", allowed_tools); 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 allowed tools if any for tool in &allowed_tools { cmd.args(["--allowedTools", tool]); } cmd.current_dir(working_dir); 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 '{}' && claude --output-format stream-json --input-format stream-json --verbose", working_dir ); // Add allowed tools if any for tool in &allowed_tools { claude_cmd.push_str(&format!(" --allowedTools '{}'", tool)); } // 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); if let Some(stdout) = stdout { let app_clone = app.clone(); thread::spawn(move || { handle_stdout(stdout, app_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 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; 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 } } impl Default for WslBridge { fn default() -> Self { Self::new() } } fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle) { 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) { 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) -> 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; for block in &message.content { match block { ContentBlock::ToolUse { name, input, .. } => { tool_name = Some(name.clone()); state = get_tool_state(name); 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 } => { 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, .. } => { let state = if subtype == "success" { CharacterState::Success } else { CharacterState::Error }; // 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 { .. } => { 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())) }