use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::Arc; use std::thread; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter}; use tempfile::NamedTempFile; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; use crate::commands::record_cost; use crate::config::ClaudeStartOptions; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, WorkingDirectoryEvent, }; use parking_lot::RwLock; 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>, conversation_id: Option, } 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())), conversation_id: None, } } pub fn new_with_conversation_id(conversation_id: String) -> Self { WslBridge { process: None, stdin: None, working_directory: String::new(), session_id: None, mcp_config_file: None, stats: Arc::new(RwLock::new(UsageStats::new())), conversation_id: Some(conversation_id), } } 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 and stats 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() ); println!("Loading saved stats..."); let persisted_stats = crate::stats::load_stats(&app_clone).await; let mut stats_guard = stats.write(); stats_guard.achievements = achievements; if let Some(persisted) = persisted_stats { println!("Applying persisted lifetime stats"); stats_guard.apply_persisted(persisted); } }); let working_dir = &options.working_dir; self.working_directory = working_dir.clone(); emit_connection_status( &app, ConnectionStatus::Connecting, self.conversation_id.clone(), ); // 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", "--debug", "hooks", ]); // 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]); } // Add resume flag if session ID provided if let Some(ref session_id) = options.resume_session_id { if !session_id.is_empty() { cmd.args(["--resume", session_id]); } } 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)); } // Add resume flag if session ID provided if let Some(ref session_id) = options.resume_session_id { if !session_id.is_empty() { claude_cmd.push_str(&format!(" --resume '{}'", session_id)); } } // 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); // Note: We no longer reset stats here - stats persist across reconnects // Stats are only reset when explicitly disconnecting via stop() // 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(); let conv_id = self.conversation_id.clone(); thread::spawn(move || { handle_stdout(stdout, app_clone, stats_clone, conv_id); }); } if let Some(stderr) = stderr { let app_clone = app.clone(); let conv_id = self.conversation_id.clone(); thread::spawn(move || { handle_stderr(stderr, app_clone, conv_id); }); } emit_connection_status( &app, ConnectionStatus::Connected, self.conversation_id.clone(), ); Ok(()) } pub fn send_message(&mut self, message: &str) -> Result<(), String> { let stdin = self.stdin.as_mut().ok_or("Process not running")?; // Track input for cost estimation on interrupt { let mut stats = self.stats.write(); stats.current_request_input = Some(message.to_string()); stats.current_request_output_chars = 0; stats.current_request_thinking_chars = 0; stats.current_request_tools.clear(); } 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 send_tool_result( &mut self, tool_use_id: &str, result: serde_json::Value, ) -> Result<(), String> { let stdin = self.stdin.as_mut().ok_or("Process not running")?; // The content should be a JSON string representation of the result let content_str = serde_json::to_string(&result).map_err(|e| e.to_string())?; let input = serde_json::json!({ "type": "user", "message": { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": tool_use_id, "content": content_str }] } }); 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() { // Estimate cost for interrupted request before killing self.estimate_interrupted_request_cost(app); // Kill the process immediately let _ = process.kill(); let _ = process.wait(); // Clear stdin self.stdin = None; // Clear tracking fields { let mut stats = self.stats.write(); stats.current_request_input = None; stats.current_request_output_chars = 0; stats.current_request_thinking_chars = 0; stats.current_request_tools.clear(); } // 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, self.conversation_id.clone(), ); Ok(()) } else { Err("No active process to interrupt".to_string()) } } fn estimate_interrupted_request_cost(&mut self, app: &AppHandle) { // Read tracking data from stats let (input_chars, output_chars, thinking_chars, tools, model) = { let stats = self.stats.read(); // Only estimate if we have tracked content if stats.current_request_input.is_none() && stats.current_request_output_chars == 0 && stats.current_request_thinking_chars == 0 && stats.current_request_tools.is_empty() { return; } let input_chars = stats.current_request_input.as_ref().map(|s| s.len() as u64).unwrap_or(0); let model = stats.model.clone().unwrap_or_else(|| "claude-sonnet-4-5-20250929".to_string()); (input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model) }; println!("[COST ESTIMATION] Estimating cost for interrupted request"); // Use conservative 3.5 chars/token for estimation (vs standard 4) let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64; let estimated_output_tokens = ((output_chars as f64 / 3.5).ceil() as u64) + ((thinking_chars as f64 / 3.5).ceil() as u64); // Add tool overhead based on session averages let mut tool_overhead_tokens = 0u64; { let stats = self.stats.read(); for tool_name in &tools { if let Some(tool_stats) = stats.session_tools_usage.get(tool_name) { if tool_stats.call_count > 0 { // Use session average tokens per call for this tool let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens) / tool_stats.call_count; tool_overhead_tokens += avg_tokens; println!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens); } } } } let total_estimated_input = estimated_input_tokens + tool_overhead_tokens; let total_estimated_output = estimated_output_tokens; // Add 20% safety margin to overestimate let safety_margin = 1.2; let conservative_input = (total_estimated_input as f64 * safety_margin).ceil() as u64; let conservative_output = (total_estimated_output as f64 * safety_margin).ceil() as u64; println!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens", input_chars, estimated_input_tokens, tool_overhead_tokens, conservative_input); println!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens", output_chars + thinking_chars, estimated_output_tokens, conservative_output); // Calculate cost (no cache tokens for interrupted requests) let estimated_cost = calculate_cost( conservative_input, conservative_output, &model, None, None, ); println!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost); // Add to stats with estimated flag { let mut stats_guard = self.stats.write(); stats_guard.add_usage( conservative_input, conservative_output, &model, None, None, ); } // Emit stats update let stats_update_event = StatsUpdateEvent { stats: self.stats.read().clone(), }; let _ = app.emit("claude:stats", stats_update_event); // Record to historical cost tracking (mark as estimated) let app_clone = app.clone(); tauri::async_runtime::spawn(async move { record_cost(&app_clone, conservative_input, conservative_output, estimated_cost).await; }); } 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 // Save lifetime stats before resetting session let stats_snapshot = self.stats.read().clone(); let app_clone = app.clone(); tauri::async_runtime::spawn(async move { println!("Saving stats on session stop..."); if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await { eprintln!("Failed to save stats: {}", e); } else { println!("Stats saved successfully on session stop"); } }); // Reset session stats on explicit disconnect self.stats.write().reset_session(); emit_connection_status( app, ConnectionStatus::Disconnected, self.conversation_id.clone(), ); } 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() } } impl Default for WslBridge { fn default() -> Self { Self::new() } } fn handle_stdout( stdout: std::process::ChildStdout, app: AppHandle, stats: Arc>, conversation_id: Option, ) { 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, &conversation_id) { eprintln!("Error processing line: {}", e); } } Err(e) => { eprintln!("Error reading stdout: {}", e); break; } _ => {} } } emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id); } fn handle_stderr( stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option, ) { let reader = BufReader::new(stderr); for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { // Check if this is a SubagentStart hook message if line.contains("[SubagentStart Hook]") { if let Some(agent_data) = parse_subagent_start_hook(&line) { eprintln!("[DEBUG] Parsed SubagentStart hook: agent_id={}, parent={:?}", agent_data.agent_id, agent_data.parent_tool_use_id); // Emit an agent-update event with the agent_id let _ = app.emit( "claude:agent-update", serde_json::json!({ "conversationId": conversation_id.clone(), "toolUseId": agent_data.parent_tool_use_id, "agentId": agent_data.agent_id, }), ); } } // Still emit the stderr line as output let _ = app.emit( "claude:output", OutputEvent { line_type: "error".to_string(), content: line, tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } Err(_) => break, _ => {} } } } #[derive(Debug)] struct SubagentStartData { agent_id: String, parent_tool_use_id: Option, } fn parse_subagent_start_hook(line: &str) -> Option { // Parse: [SubagentStart Hook] agent_id=agent-xxx, parent_tool_use_id=Some("toolu_xxx"), ... // Extract agent_id let agent_id = line .split("agent_id=") .nth(1)? .split(',') .next()? .trim() .to_string(); // Extract parent_tool_use_id if present let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") { line.split("parent_tool_use_id=Some(\"") .nth(1)? .split('"') .next() .map(|s| s.to_string()) } else { None }; Some(SubagentStartData { agent_id, parent_tool_use_id, }) } fn process_json_line( line: &str, app: &AppHandle, stats: &Arc>, conversation_id: &Option, ) -> 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", SessionEvent { session_id: id.clone(), conversation_id: conversation_id.clone(), }, ); } if let Some(dir) = cwd { let _ = app.emit( "claude:cwd", WorkingDirectoryEvent { directory: dir.clone(), conversation_id: conversation_id.clone(), }, ); } emit_state_change(app, CharacterState::Idle, None, conversation_id.clone()); } } ClaudeMessage::Assistant { message, parent_tool_use_id } => { let mut state = CharacterState::Typing; let mut tool_name = None; // Collect all tool names in this message for token attribution let tools_in_message: Vec = message .content .iter() .filter_map(|block| match block { ContentBlock::ToolUse { name, .. } => Some(name.clone()), _ => None, }) .collect(); // Track message cost for display let mut message_cost: Option = None; // Only update stats if we have usage information if let Some(usage) = &message.usage { // Get model from message, or fall back to last known model from stats let model = message.model.clone().or_else(|| { let stats_guard = stats.read(); stats_guard.model.clone() }).unwrap_or_else(|| { println!("[WARNING] No model info available for cost calculation, using default"); "claude-sonnet-4-5-20250929".to_string() }); // Calculate cost for historical tracking (including cache tokens) let cost_usd = calculate_cost( usage.input_tokens, usage.output_tokens, &model, usage.cache_creation_input_tokens, usage.cache_read_input_tokens, ); println!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}", usage.input_tokens, usage.output_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens, cost_usd ); // Store cost for later use in output events message_cost = Some(MessageCost { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, cost_usd, }); // 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, usage.cache_creation_input_tokens, usage.cache_read_input_tokens, ); stats_guard.get_session_duration(); // Attribute tokens to tools if any tools were used in this message if !tools_in_message.is_empty() { let per_tool_input = usage.input_tokens / tools_in_message.len() as u64; let per_tool_output = usage.output_tokens / tools_in_message.len() as u64; for tool in &tools_in_message { stats_guard.add_tool_tokens(tool, per_tool_input, per_tool_output); } } } // Record to historical cost tracking let app_clone = app.clone(); let input = usage.input_tokens; let output = usage.output_tokens; tauri::async_runtime::spawn(async move { record_cost(&app_clone, input, output, cost_usd).await; }); // 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(); } for block in &message.content { match block { ContentBlock::ToolUse { id, 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(), _ => {} } } // Emit agent-start event for Task tool invocations if name == "Task" { let description = input .get("description") .and_then(|v| v.as_str()) .unwrap_or("Subagent") .to_string(); let subagent_type = input .get("subagent_type") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; eprintln!( "[DEBUG] Emitting agent-start: id={}, desc={}, type={}, parent={:?}", id, description, subagent_type, parent_tool_use_id ); let _ = app.emit( "claude:agent-start", AgentStartEvent { tool_use_id: id.clone(), agent_id: None, // Will be updated when SubagentStart hook is received description, subagent_type, started_at: now, conversation_id: conversation_id.clone(), parent_tool_use_id: parent_tool_use_id.clone(), }, ); } 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()), conversation_id: conversation_id.clone(), cost: None, // Tool use doesn't have separate cost parent_tool_use_id: parent_tool_use_id.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, conversation_id: conversation_id.clone(), cost: message_cost.clone(), // Include cost with assistant text parent_tool_use_id: parent_tool_use_id.clone(), }, ); } ContentBlock::Thinking { thinking } => { state = CharacterState::Thinking; let _ = app.emit( "claude:output", OutputEvent { line_type: "system".to_string(), content: format!("[Thinking] {}", thinking), tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: parent_tool_use_id.clone(), }, ); } ContentBlock::ToolResult { tool_use_id, is_error, .. } => { // Emit agent-end for all tool results // The frontend will ignore IDs that don't match known agents let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; let _ = app.emit( "claude:agent-end", AgentEndEvent { tool_use_id: tool_use_id.clone(), ended_at: now, is_error: is_error.unwrap_or(false), conversation_id: conversation_id.clone(), duration_ms: None, num_turns: None, }, ); } } } emit_state_change(app, state, tool_name, conversation_id.clone()); } ClaudeMessage::StreamEvent { event } => { if event.event_type == "content_block_start" { if let Some(block) = &event.content_block { // Track tool calls for cost estimation if block.block_type == "tool_use" { if let Some(name) = &block.name { let mut stats_guard = stats.write(); stats_guard.current_request_tools.push(name.clone()); } } 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(), conversation_id.clone()); } } else if event.event_type == "content_block_delta" { if let Some(delta) = &event.delta { if let Some(text) = &delta.text { // Track output characters for cost estimation { let mut stats_guard = stats.write(); stats_guard.current_request_output_chars += text.len() as u64; } let _ = app.emit("claude:stream", text.clone()); } else if let Some(thinking) = &delta.thinking { // Track thinking characters for cost estimation let mut stats_guard = stats.write(); stats_guard.current_request_thinking_chars += thinking.len() as u64; } } } } ClaudeMessage::Result { subtype, result, permission_denials, usage, duration_ms, num_turns, } => { let state = if subtype == "success" { CharacterState::Success } else { CharacterState::Error }; // Log turn metrics if available if let Some(duration) = duration_ms { println!("Turn completed in {}ms", duration); } if let Some(turns) = num_turns { println!("Turn count: {}", turns); } // Track token usage from Result messages if available // This captures tokens from tool outputs and other operations if let Some(usage_info) = usage { // We need the model info to calculate cost properly // For now, use the last known model from stats let model = { let stats_guard = stats.read(); stats_guard.model.clone().unwrap_or_else(|| "claude-opus-4-20250514".to_string()) }; // Calculate cost for historical tracking (including cache tokens) let cost_usd = calculate_cost( usage_info.input_tokens, usage_info.output_tokens, &model, usage_info.cache_creation_input_tokens, usage_info.cache_read_input_tokens, ); let mut stats_guard = stats.write(); stats_guard.add_usage( usage_info.input_tokens, usage_info.output_tokens, &model, usage_info.cache_creation_input_tokens, usage_info.cache_read_input_tokens, ); println!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}", usage_info.input_tokens, usage_info.output_tokens, usage_info.cache_creation_input_tokens, usage_info.cache_read_input_tokens ); // Record to historical cost tracking let app_clone = app.clone(); let input = usage_info.input_tokens; let output = usage_info.output_tokens; tauri::async_runtime::spawn(async move { record_cost(&app_clone, input, output, cost_usd).await; }); } // Clear tracking fields since request completed successfully { let mut stats_guard = stats.write(); stats_guard.current_request_input = None; stats_guard.current_request_output_chars = 0; stats_guard.current_request_thinking_chars = 0; stats_guard.current_request_tools.clear(); } // 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.clone(), }; let _ = app.emit("claude:stats", stats_event); // Save stats periodically (every 10 messages to avoid excessive disk writes) if current_stats.session_messages_exchanged.is_multiple_of(10) && current_stats.session_messages_exchanged > 0 { let app_handle = app.clone(); tauri::async_runtime::spawn(async move { println!("Periodic stats save (every 10 messages)..."); if let Err(e) = crate::stats::save_stats(&app_handle, ¤t_stats).await { eprintln!("Failed to save stats: {}", e); } }); } // 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, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } } // Check for permission denials and emit prompts for each if let Some(denials) = permission_denials { let mut has_regular_denials = false; for denial in denials { // Special handling for AskUserQuestion tool if denial.tool_name == "AskUserQuestion" { if let Some(questions) = denial .tool_input .get("questions") .and_then(|q| q.as_array()) { // For now, handle the first question (most common case) if let Some(first_question) = questions.first() { let question_text = first_question .get("question") .and_then(|q| q.as_str()) .unwrap_or("Claude has a question for you") .to_string(); let header = first_question .get("header") .and_then(|h| h.as_str()) .map(|s| s.to_string()); let multi_select = first_question .get("multiSelect") .and_then(|m| m.as_bool()) .unwrap_or(false); let options: Vec = first_question .get("options") .and_then(|opts| opts.as_array()) .map(|opts| { opts.iter() .filter_map(|opt| { let label = opt.get("label").and_then(|l| l.as_str())?; let description = opt .get("description") .and_then(|d| d.as_str()) .map(|s| s.to_string()); Some(QuestionOption { label: label.to_string(), description, }) }) .collect() }) .unwrap_or_default(); let _ = app.emit( "claude:question", UserQuestionEvent { id: denial.tool_use_id.clone(), question: question_text, header, options, multi_select, conversation_id: conversation_id.clone(), }, ); } } } else { has_regular_denials = true; 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, conversation_id: conversation_id.clone(), }, ); } } // Show permission state if there were any denials (questions or regular) if has_regular_denials || !denials.is_empty() { emit_state_change( app, CharacterState::Permission, None, conversation_id.clone(), ); return Ok(()); } } emit_state_change(app, state, None, conversation_id.clone()); } ClaudeMessage::User { message } => { // Increment message count for user messages stats.write().increment_messages(); // Process content blocks for tool results (e.g., background Task agent completions) for block in &message.content { if let ContentBlock::ToolResult { tool_use_id, is_error, .. } = block { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; let _ = app.emit( "claude:agent-end", AgentEndEvent { tool_use_id: tool_use_id.clone(), ended_at: now, is_error: is_error.unwrap_or(false), conversation_id: conversation_id.clone(), duration_ms: None, num_turns: None, }, ); } } // 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, conversation_id.clone()); } } 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, conversation_id: Option, ) { let _ = app.emit( "claude:state", StateChangeEvent { state, tool_name, conversation_id, }, ); } fn emit_connection_status( app: &AppHandle, status: ConnectionStatus, conversation_id: Option, ) { let _ = app.emit( "claude:connection", ConnectionEvent { status, conversation_id, }, ); } #[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_manager() { use crate::bridge_manager::create_shared_bridge_manager; let shared = create_shared_bridge_manager(); let manager = shared.lock(); assert!(manager.get_active_conversations().is_empty()); } }