generated from nhcarrigan/template
97a93c31c2
Also includes a fix to persist configuration across reconnects. Reviewed-on: #125 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
1595 lines
59 KiB
Rust
1595 lines
59 KiB
Rust
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<String> {
|
||
// 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<Child>,
|
||
stdin: Option<ChildStdin>,
|
||
working_directory: String,
|
||
session_id: Option<String>,
|
||
mcp_config_file: Option<NamedTempFile>,
|
||
stats: Arc<RwLock<UsageStats>>,
|
||
conversation_id: Option<String>,
|
||
}
|
||
|
||
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::<serde_json::Value>(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<RwLock<UsageStats>>,
|
||
conversation_id: Option<String>,
|
||
) {
|
||
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<String>,
|
||
) {
|
||
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<String>,
|
||
}
|
||
|
||
fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
|
||
// 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<RwLock<UsageStats>>,
|
||
conversation_id: &Option<String>,
|
||
) -> 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<String> = 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<MessageCost> = 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<QuestionOption> = 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::<Vec<String>>()
|
||
.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<String>,
|
||
conversation_id: Option<String>,
|
||
) {
|
||
let _ = app.emit(
|
||
"claude:state",
|
||
StateChangeEvent {
|
||
state,
|
||
tool_name,
|
||
conversation_id,
|
||
},
|
||
);
|
||
}
|
||
|
||
fn emit_connection_status(
|
||
app: &AppHandle,
|
||
status: ConnectionStatus,
|
||
conversation_id: Option<String>,
|
||
) {
|
||
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());
|
||
}
|
||
}
|