diff --git a/package.json b/package.json index c072d265a..8599f7246 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,11 @@ "@tailwindcss/cli": "^4.1.8", "@tailwindcss/vite": "^4.1.8", "@tanstack/react-virtual": "^3.13.10", - "@tauri-apps/api": "^2.1.1", - "@tauri-apps/plugin-dialog": "^2.0.2", - "@tauri-apps/plugin-global-shortcut": "^2.0.0", - "@tauri-apps/plugin-opener": "^2", - "@tauri-apps/plugin-shell": "^2.0.1", + "@tauri-apps/api": "~2.8.0", + "@tauri-apps/plugin-dialog": "~2.4.0", + "@tauri-apps/plugin-global-shortcut": "~2.3.0", + "@tauri-apps/plugin-opener": "~2.2.0", + "@tauri-apps/plugin-shell": "~2.3.0", "@types/diff": "^8.0.0", "@types/react-syntax-highlighter": "^15.5.13", "@uiw/react-md-editor": "^4.0.7", diff --git a/src-tauri/src/claude_binary.rs b/src-tauri/src/claude_binary.rs index 0ce4ce48e..bf93d420f 100644 --- a/src-tauri/src/claude_binary.rs +++ b/src-tauri/src/claude_binary.rs @@ -8,6 +8,17 @@ use std::path::PathBuf; use std::process::Command; use tauri::Manager; +/// Creates a std::process::Command that won't show a console window on Windows +pub fn hidden_command(program: &str) -> Command { + let mut cmd = Command::new(program); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + cmd +} + /// Type of Claude installation #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum InstallationType { @@ -214,7 +225,7 @@ fn try_which_command() -> Option { fn try_which_command() -> Option { debug!("Trying 'where claude' to find binary..."); - match Command::new("where").arg("claude").output() { + match hidden_command("where").arg("claude").output() { Ok(output) if output.status.success() => { let output_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -481,7 +492,7 @@ fn find_standard_installations() -> Vec { } // Also check if claude is available in PATH (without full path) - if let Ok(output) = Command::new("claude.exe").arg("--version").output() { + if let Ok(output) = hidden_command("claude.exe").arg("--version").output() { if output.status.success() { debug!("claude.exe is available in PATH"); let version = extract_version_from_output(&output.stdout); @@ -500,7 +511,7 @@ fn find_standard_installations() -> Vec { /// Get Claude version by running --version command fn get_claude_version(path: &str) -> Result, String> { - match Command::new(path).arg("--version").output() { + match hidden_command(path).arg("--version").output() { Ok(output) => { if output.status.success() { Ok(extract_version_from_output(&output.stdout)) @@ -689,5 +700,12 @@ pub fn create_command_with_env(program: &str) -> Command { } } + // On Windows, prevent spawning a visible console window for subprocesses + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + cmd } diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index 36513a7a4..5278093d9 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -1038,7 +1038,7 @@ async fn spawn_agent_system( "🔍 Process likely stuck waiting for input, attempting to kill PID: {}", pid ); - let kill_result = std::process::Command::new("kill") + let kill_result = crate::claude_binary::hidden_command("kill") .arg("-TERM") .arg(pid.to_string()) .output(); @@ -1049,7 +1049,7 @@ async fn spawn_agent_system( } Ok(_) => { warn!("🔍 Failed to kill process with TERM, trying KILL"); - let _ = std::process::Command::new("kill") + let _ = crate::claude_binary::hidden_command("kill") .arg("-KILL") .arg(pid.to_string()) .output(); @@ -1295,7 +1295,7 @@ pub async fn cleanup_finished_processes(db: State<'_, AgentDb>) -> Result) -> Result Command { tokio_cmd.env("PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); } + // On Windows, prevent spawning a visible console window for subprocesses + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + tokio_cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + tokio_cmd } diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 529eb1a2a..3c58e4505 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -286,9 +286,70 @@ fn create_command_with_env(program: &str) -> Command { } } + // On Windows, prevent spawning a visible console window for subprocesses + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + tokio_cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + tokio_cmd } +/// Checks if a prompt contains image references (@"data:image/..." or @"path/to/image") +fn prompt_has_images(prompt: &str) -> bool { + prompt.contains("@\"data:image/") +} + +/// Parses a prompt with image references into Claude stream-json content blocks. +/// Returns (content_blocks_json, cleaned_text_without_refs) +fn build_image_content_blocks(prompt: &str) -> String { + let mut content_blocks: Vec = Vec::new(); + let mut remaining_text = prompt.to_string(); + + // Extract @"data:image/TYPE;base64,DATA" references + let re = regex::Regex::new(r#"@"(data:image/([^;]+);base64,([^"]+))""#).unwrap(); + + for caps in re.captures_iter(prompt) { + let full_match = caps.get(0).unwrap().as_str(); + let media_type = format!("image/{}", &caps[2]); + let base64_data = &caps[3]; + + // Add image content block + content_blocks.push(serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": base64_data + } + })); + + // Remove the reference from the text + remaining_text = remaining_text.replace(full_match, ""); + } + + // Add remaining text as text block + let text = remaining_text.trim(); + if !text.is_empty() { + content_blocks.push(serde_json::json!({ + "type": "text", + "text": text + })); + } + + // Build the stream-json user message + let msg = serde_json::json!({ + "type": "user", + "message": { + "role": "user", + "content": content_blocks + } + }); + + msg.to_string() +} + /// Creates a system binary command with the given arguments fn create_system_command(claude_path: &str, args: Vec, project_path: &str) -> Command { let mut cmd = create_command_with_env(claude_path); @@ -299,6 +360,7 @@ fn create_system_command(claude_path: &str, args: Vec, project_path: &st } cmd.current_dir(project_path) + .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -600,7 +662,7 @@ pub async fn open_new_session(app: AppHandle, path: Option) -> Result Result = vec![]; + spawn_with_image_support(app, &claude_path, extra_args, prompt, model, project_path).await } /// Continue an existing Claude Code conversation with streaming output @@ -962,21 +1012,8 @@ pub async fn continue_claude_code( ); let claude_path = find_claude_binary(&app)?; - - let args = vec![ - "-c".to_string(), // Continue flag - "-p".to_string(), - prompt.clone(), - "--model".to_string(), - model.clone(), - "--output-format".to_string(), - "stream-json".to_string(), - "--verbose".to_string(), - "--dangerously-skip-permissions".to_string(), - ]; - - let cmd = create_system_command(&claude_path, args, &project_path); - spawn_claude_process(app, cmd, prompt, model, project_path).await + let extra_args = vec!["-c".to_string()]; // Continue flag + spawn_with_image_support(app, &claude_path, extra_args, prompt, model, project_path).await } /// Resume an existing Claude Code session by ID with streaming output @@ -996,22 +1033,8 @@ pub async fn resume_claude_code( ); let claude_path = find_claude_binary(&app)?; - - let args = vec![ - "--resume".to_string(), - session_id.clone(), - "-p".to_string(), - prompt.clone(), - "--model".to_string(), - model.clone(), - "--output-format".to_string(), - "stream-json".to_string(), - "--verbose".to_string(), - "--dangerously-skip-permissions".to_string(), - ]; - - let cmd = create_system_command(&claude_path, args, &project_path); - spawn_claude_process(app, cmd, prompt, model, project_path).await + let extra_args = vec!["--resume".to_string(), session_id.clone()]; + spawn_with_image_support(app, &claude_path, extra_args, prompt, model, project_path).await } /// Cancel the currently running Claude Code execution @@ -1092,11 +1115,11 @@ pub async fn cancel_claude_execution( if let Some(pid) = pid { log::info!("Attempting system kill as last resort for PID: {}", pid); let kill_result = if cfg!(target_os = "windows") { - std::process::Command::new("taskkill") + crate::claude_binary::hidden_command("taskkill") .args(["/F", "/PID", &pid.to_string()]) .output() } else { - std::process::Command::new("kill") + crate::claude_binary::hidden_command("kill") .args(["-KILL", &pid.to_string()]) .output() }; @@ -1170,6 +1193,87 @@ pub async fn get_claude_session_output( } } +/// Unified spawn helper that handles both text and image prompts +async fn spawn_with_image_support( + app: AppHandle, + claude_path: &str, + extra_args: Vec, + prompt: String, + model: String, + project_path: String, +) -> Result<(), String> { + if prompt_has_images(&prompt) { + let stdin_payload = build_image_content_blocks(&prompt); + let mut args = extra_args; + args.extend([ + "-p".to_string(), + "--input-format".to_string(), + "stream-json".to_string(), + "--model".to_string(), + model.clone(), + "--output-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + "--dangerously-skip-permissions".to_string(), + ]); + let mut cmd = create_command_with_env(claude_path); + for arg in args { cmd.arg(arg); } + cmd.current_dir(&project_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + spawn_claude_process_with_stdin(app, cmd, stdin_payload, prompt, model, project_path).await + } else { + let mut args = extra_args; + args.extend([ + "-p".to_string(), + prompt.clone(), + "--model".to_string(), + model.clone(), + "--output-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + "--dangerously-skip-permissions".to_string(), + ]); + let cmd = create_system_command(claude_path, args, &project_path); + spawn_claude_process(app, cmd, prompt, model, project_path).await + } +} + +/// Helper to spawn Claude process with stdin data (for image support) +async fn spawn_claude_process_with_stdin( + app: AppHandle, + mut cmd: Command, + stdin_data: String, + prompt: String, + model: String, + project_path: String, +) -> Result<(), String> { + use tokio::io::AsyncWriteExt; + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn Claude: {}", e))?; + + // Write the image payload to stdin, then close it + if let Some(mut stdin) = child.stdin.take() { + let data = stdin_data.clone(); + tokio::spawn(async move { + let _ = stdin.write_all(data.as_bytes()).await; + let _ = stdin.write_all(b"\n").await; + let _ = stdin.shutdown().await; + }); + } + + // Re-wrap as a command-less child and delegate to spawn_claude_process_from_child + // Actually, we need stdout/stderr from the child. Let's inline the remaining logic. + let stdout = child.stdout.take().ok_or("Failed to get stdout")?; + let stderr = child.stderr.take().ok_or("Failed to get stderr")?; + + // Delegate to the shared streaming handler + handle_claude_streaming(app, child, stdout, stderr, prompt, model, project_path).await +} + /// Helper function to spawn Claude process and handle streaming async fn spawn_claude_process( app: AppHandle, @@ -1178,9 +1282,6 @@ async fn spawn_claude_process( model: String, project_path: String, ) -> Result<(), String> { - use std::sync::Mutex; - use tokio::io::{AsyncBufReadExt, BufReader}; - // Spawn the process let mut child = cmd .spawn() @@ -1190,6 +1291,22 @@ async fn spawn_claude_process( let stdout = child.stdout.take().ok_or("Failed to get stdout")?; let stderr = child.stderr.take().ok_or("Failed to get stderr")?; + handle_claude_streaming(app, child, stdout, stderr, prompt, model, project_path).await +} + +/// Shared streaming handler for Claude process output +async fn handle_claude_streaming( + app: AppHandle, + mut child: tokio::process::Child, + stdout: tokio::process::ChildStdout, + stderr: tokio::process::ChildStderr, + prompt: String, + model: String, + project_path: String, +) -> Result<(), String> { + use std::sync::Mutex; + use tokio::io::{AsyncBufReadExt, BufReader}; + // Get the child PID for logging let pid = child.id().unwrap_or(0); log::info!("Spawned Claude process with PID: {:?}", pid); @@ -2164,7 +2281,7 @@ pub async fn validate_hook_command(command: String) -> Result { // SIGTERM failed, try SIGKILL directly warn!("SIGTERM failed for PID {}, trying SIGKILL", pid); - std::process::Command::new("kill") + crate::claude_binary::hidden_command("kill") .args(["-KILL", &pid.to_string()]) .output() } diff --git a/src-tauri/src/web_server.rs b/src-tauri/src/web_server.rs index 01a28436f..4349241b3 100644 --- a/src-tauri/src/web_server.rs +++ b/src-tauri/src/web_server.rs @@ -1,3 +1,11 @@ +macro_rules! trace_log { + ($($arg:tt)*) => { + if cfg!(debug_assertions) { + println!($($arg)*); + } + }; +} + use axum::extract::ws::{Message, WebSocket}; use axum::http::Method; use axum::{ @@ -25,7 +33,7 @@ fn find_claude_binary_web() -> Result { // First try the bundled binary (same location as Tauri app uses) let bundled_binary = "src-tauri/binaries/claude-code-x86_64-unknown-linux-gnu"; if std::path::Path::new(bundled_binary).exists() { - println!( + trace_log!( "[find_claude_binary_web] Using bundled binary: {}", bundled_binary ); @@ -48,7 +56,7 @@ fn find_claude_binary_web() -> Result { for candidate in candidates { if which::which(candidate).is_ok() { - println!( + trace_log!( "[find_claude_binary_web] Using system binary: {}", candidate ); @@ -236,14 +244,14 @@ async fn resume_claude_code() -> Json> { async fn cancel_claude_execution(Path(sessionId): Path) -> Json> { // In web mode, we don't have a way to cancel the subprocess cleanly // The WebSocket closing should handle cleanup - println!("[TRACE] Cancel request for session: {}", sessionId); + trace_log!("[TRACE] Cancel request for session: {}", sessionId); Json(ApiResponse::success(())) } /// Get Claude session output async fn get_claude_session_output(Path(sessionId): Path) -> Json> { // In web mode, output is streamed via WebSocket, not stored - println!("[TRACE] Output request for session: {}", sessionId); + trace_log!("[TRACE] Output request for session: {}", sessionId); Json(ApiResponse::success( "Output available via WebSocket only".to_string(), )) @@ -258,7 +266,7 @@ async fn claude_websocket_handler(socket: WebSocket, state: AppState) { let (mut sender, mut receiver) = socket.split(); let session_id = uuid::Uuid::new_v4().to_string(); - println!( + trace_log!( "[TRACE] WebSocket handler started - session_id: {}", session_id ); @@ -270,7 +278,7 @@ async fn claude_websocket_handler(socket: WebSocket, state: AppState) { { let mut sessions = state.active_sessions.lock().await; sessions.insert(session_id.clone(), tx); - println!( + trace_log!( "[TRACE] Session stored in state - active sessions count: {}", sessions.len() ); @@ -279,54 +287,54 @@ async fn claude_websocket_handler(socket: WebSocket, state: AppState) { // Task to forward channel messages to WebSocket let session_id_for_forward = session_id.clone(); let forward_task = tokio::spawn(async move { - println!( + trace_log!( "[TRACE] Forward task started for session {}", session_id_for_forward ); while let Some(message) = rx.recv().await { - println!("[TRACE] Forwarding message to WebSocket: {}", message); + trace_log!("[TRACE] Forwarding message to WebSocket: {}", message); if sender.send(Message::Text(message.into())).await.is_err() { - println!("[TRACE] Failed to send message to WebSocket - connection closed"); + trace_log!("[TRACE] Failed to send message to WebSocket - connection closed"); break; } } - println!( + trace_log!( "[TRACE] Forward task ended for session {}", session_id_for_forward ); }); // Handle incoming messages from WebSocket - println!("[TRACE] Starting to listen for WebSocket messages"); + trace_log!("[TRACE] Starting to listen for WebSocket messages"); while let Some(msg) = receiver.next().await { - println!("[TRACE] Received WebSocket message: {:?}", msg); + trace_log!("[TRACE] Received WebSocket message: {:?}", msg); if let Ok(msg) = msg { if let Message::Text(text) = msg { - println!( + trace_log!( "[TRACE] WebSocket text message received - length: {} chars", text.len() ); - println!("[TRACE] WebSocket message content: {}", text); + trace_log!("[TRACE] WebSocket message content: {}", text); match serde_json::from_str::(&text) { Ok(request) => { - println!("[TRACE] Successfully parsed request: {:?}", request); - println!("[TRACE] Command type: {}", request.command_type); - println!("[TRACE] Project path: {}", request.project_path); - println!("[TRACE] Prompt length: {} chars", request.prompt.len()); + trace_log!("[TRACE] Successfully parsed request: {:?}", request); + trace_log!("[TRACE] Command type: {}", request.command_type); + trace_log!("[TRACE] Project path: {}", request.project_path); + trace_log!("[TRACE] Prompt length: {} chars", request.prompt.len()); // Execute Claude command based on request type let session_id_clone = session_id.clone(); let state_clone = state.clone(); - println!( + trace_log!( "[TRACE] Spawning task to execute command: {}", request.command_type ); tokio::spawn(async move { - println!("[TRACE] Task started for command execution"); + trace_log!("[TRACE] Task started for command execution"); let result = match request.command_type.as_str() { "execute" => { - println!("[TRACE] Calling execute_claude_command"); + trace_log!("[TRACE] Calling execute_claude_command"); execute_claude_command( request.project_path, request.prompt, @@ -337,7 +345,7 @@ async fn claude_websocket_handler(socket: WebSocket, state: AppState) { .await } "continue" => { - println!("[TRACE] Calling continue_claude_command"); + trace_log!("[TRACE] Calling continue_claude_command"); continue_claude_command( request.project_path, request.prompt, @@ -348,7 +356,7 @@ async fn claude_websocket_handler(socket: WebSocket, state: AppState) { .await } "resume" => { - println!("[TRACE] Calling resume_claude_command"); + trace_log!("[TRACE] Calling resume_claude_command"); resume_claude_command( request.project_path, request.session_id.unwrap_or_default(), @@ -360,7 +368,7 @@ async fn claude_websocket_handler(socket: WebSocket, state: AppState) { .await } _ => { - println!( + trace_log!( "[TRACE] Unknown command type: {}", request.command_type ); @@ -368,7 +376,7 @@ async fn claude_websocket_handler(socket: WebSocket, state: AppState) { } }; - println!( + trace_log!( "[TRACE] Command execution finished with result: {:?}", result ); @@ -391,16 +399,16 @@ async fn claude_websocket_handler(socket: WebSocket, state: AppState) { "error": e }), }; - println!("[TRACE] Sending completion message: {}", completion_msg); + trace_log!("[TRACE] Sending completion message: {}", completion_msg); let _ = sender.send(completion_msg.to_string()).await; } else { - println!("[TRACE] Session not found in active sessions when sending completion"); + trace_log!("[TRACE] Session not found in active sessions when sending completion"); } }); } Err(e) => { - println!("[TRACE] Failed to parse WebSocket request: {}", e); - println!("[TRACE] Raw message that failed to parse: {}", text); + trace_log!("[TRACE] Failed to parse WebSocket request: {}", e); + trace_log!("[TRACE] Raw message that failed to parse: {}", text); // Send error back to client let error_msg = json!({ @@ -414,23 +422,23 @@ async fn claude_websocket_handler(socket: WebSocket, state: AppState) { } } } else if let Message::Close(_) = msg { - println!("[TRACE] WebSocket close message received"); + trace_log!("[TRACE] WebSocket close message received"); break; } else { - println!("[TRACE] Non-text WebSocket message received: {:?}", msg); + trace_log!("[TRACE] Non-text WebSocket message received: {:?}", msg); } } else { - println!("[TRACE] Error receiving WebSocket message"); + trace_log!("[TRACE] Error receiving WebSocket message"); } } - println!("[TRACE] WebSocket message loop ended"); + trace_log!("[TRACE] WebSocket message loop ended"); // Clean up session { let mut sessions = state.active_sessions.lock().await; sessions.remove(&session_id); - println!( + trace_log!( "[TRACE] Session {} removed from state - remaining sessions: {}", session_id, sessions.len() @@ -438,7 +446,7 @@ async fn claude_websocket_handler(socket: WebSocket, state: AppState) { } forward_task.abort(); - println!("[TRACE] WebSocket handler ended for session {}", session_id); + trace_log!("[TRACE] WebSocket handler ended for session {}", session_id); } // Claude command execution functions for WebSocket streaming @@ -452,14 +460,14 @@ async fn execute_claude_command( use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; - println!("[TRACE] execute_claude_command called:"); - println!("[TRACE] project_path: {}", project_path); - println!("[TRACE] prompt length: {} chars", prompt.len()); - println!("[TRACE] model: {}", model); - println!("[TRACE] session_id: {}", session_id); + trace_log!("[TRACE] execute_claude_command called:"); + trace_log!("[TRACE] project_path: {}", project_path); + trace_log!("[TRACE] prompt length: {} chars", prompt.len()); + trace_log!("[TRACE] model: {}", model); + trace_log!("[TRACE] session_id: {}", session_id); // Send initial message - println!("[TRACE] Sending initial start message"); + trace_log!("[TRACE] Sending initial start message"); send_to_session( &state, &session_id, @@ -472,16 +480,16 @@ async fn execute_claude_command( .await; // Find Claude binary (simplified for web mode) - println!("[TRACE] Finding Claude binary..."); + trace_log!("[TRACE] Finding Claude binary..."); let claude_path = find_claude_binary_web().map_err(|e| { let error = format!("Claude binary not found: {}", e); - println!("[TRACE] Error finding Claude binary: {}", error); + trace_log!("[TRACE] Error finding Claude binary: {}", error); error })?; - println!("[TRACE] Found Claude binary: {}", claude_path); + trace_log!("[TRACE] Found Claude binary: {}", claude_path); // Create Claude command - println!("[TRACE] Creating Claude command..."); + trace_log!("[TRACE] Creating Claude command..."); let mut cmd = Command::new(&claude_path); let args = [ "-p", @@ -495,37 +503,43 @@ async fn execute_claude_command( ]; cmd.args(args); cmd.current_dir(&project_path); + cmd.stdin(std::process::Stdio::null()); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } - println!( + trace_log!( "[TRACE] Command: {} {:?} (in dir: {})", claude_path, args, project_path ); // Spawn Claude process - println!("[TRACE] Spawning Claude process..."); + trace_log!("[TRACE] Spawning Claude process..."); let mut child = cmd.spawn().map_err(|e| { let error = format!("Failed to spawn Claude: {}", e); - println!("[TRACE] Spawn error: {}", error); + trace_log!("[TRACE] Spawn error: {}", error); error })?; - println!("[TRACE] Claude process spawned successfully"); + trace_log!("[TRACE] Claude process spawned successfully"); // Get stdout for streaming let stdout = child.stdout.take().ok_or_else(|| { - println!("[TRACE] Failed to get stdout from child process"); + trace_log!("[TRACE] Failed to get stdout from child process"); "Failed to get stdout".to_string() })?; let stdout_reader = BufReader::new(stdout); - println!("[TRACE] Starting to read Claude output..."); + trace_log!("[TRACE] Starting to read Claude output..."); // Stream output line by line let mut lines = stdout_reader.lines(); let mut line_count = 0; while let Ok(Some(line)) = lines.next_line().await { line_count += 1; - println!("[TRACE] Claude output line {}: {}", line_count, line); + trace_log!("[TRACE] Claude output line {}: {}", line_count, line); // Send each line to WebSocket let message = json!({ @@ -533,24 +547,24 @@ async fn execute_claude_command( "content": line }) .to_string(); - println!("[TRACE] Sending output message to session: {}", message); + trace_log!("[TRACE] Sending output message to session: {}", message); send_to_session(&state, &session_id, message).await; } - println!( + trace_log!( "[TRACE] Finished reading Claude output ({} lines total)", line_count ); // Wait for process to complete - println!("[TRACE] Waiting for Claude process to complete..."); + trace_log!("[TRACE] Waiting for Claude process to complete..."); let exit_status = child.wait().await.map_err(|e| { let error = format!("Failed to wait for Claude: {}", e); - println!("[TRACE] Wait error: {}", error); + trace_log!("[TRACE] Wait error: {}", error); error })?; - println!( + trace_log!( "[TRACE] Claude process completed with status: {:?}", exit_status ); @@ -560,11 +574,11 @@ async fn execute_claude_command( "Claude execution failed with exit code: {:?}", exit_status.code() ); - println!("[TRACE] Claude execution failed: {}", error); + trace_log!("[TRACE] Claude execution failed: {}", error); return Err(error); } - println!("[TRACE] execute_claude_command completed successfully"); + trace_log!("[TRACE] execute_claude_command completed successfully"); Ok(()) } @@ -607,8 +621,14 @@ async fn continue_claude_command( "--dangerously-skip-permissions", ]); cmd.current_dir(&project_path); + cmd.stdin(std::process::Stdio::null()); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } // Spawn and stream output let mut child = cmd @@ -656,7 +676,7 @@ async fn resume_claude_command( use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; - println!("[resume_claude_command] Starting with project_path: {}, claude_session_id: {}, prompt: {}, model: {}", + trace_log!("[resume_claude_command] Starting with project_path: {}, claude_session_id: {}, prompt: {}, model: {}", project_path, claude_session_id, prompt, model); send_to_session( @@ -671,16 +691,16 @@ async fn resume_claude_command( .await; // Find Claude binary - println!("[resume_claude_command] Finding Claude binary..."); + trace_log!("[resume_claude_command] Finding Claude binary..."); let claude_path = find_claude_binary_web().map_err(|e| format!("Claude binary not found: {}", e))?; - println!( + trace_log!( "[resume_claude_command] Found Claude binary: {}", claude_path ); // Create resume command - println!("[resume_claude_command] Creating command..."); + trace_log!("[resume_claude_command] Creating command..."); let mut cmd = Command::new(&claude_path); let args = [ "--resume", @@ -696,22 +716,28 @@ async fn resume_claude_command( ]; cmd.args(args); cmd.current_dir(&project_path); + cmd.stdin(std::process::Stdio::null()); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } - println!( + trace_log!( "[resume_claude_command] Command: {} {:?} (in dir: {})", claude_path, args, project_path ); // Spawn and stream output - println!("[resume_claude_command] Spawning process..."); + trace_log!("[resume_claude_command] Spawning process..."); let mut child = cmd.spawn().map_err(|e| { let error = format!("Failed to spawn Claude: {}", e); - println!("[resume_claude_command] Spawn error: {}", error); + trace_log!("[resume_claude_command] Spawn error: {}", error); error })?; - println!("[resume_claude_command] Process spawned successfully"); + trace_log!("[resume_claude_command] Process spawned successfully"); let stdout = child.stdout.take().ok_or("Failed to get stdout")?; let stdout_reader = BufReader::new(stdout); @@ -744,22 +770,22 @@ async fn resume_claude_command( } async fn send_to_session(state: &AppState, session_id: &str, message: String) { - println!("[TRACE] send_to_session called for session: {}", session_id); - println!("[TRACE] Message: {}", message); + trace_log!("[TRACE] send_to_session called for session: {}", session_id); + trace_log!("[TRACE] Message: {}", message); let sessions = state.active_sessions.lock().await; if let Some(sender) = sessions.get(session_id) { - println!("[TRACE] Found session in active sessions, sending message..."); + trace_log!("[TRACE] Found session in active sessions, sending message..."); match sender.send(message).await { - Ok(_) => println!("[TRACE] Message sent successfully"), - Err(e) => println!("[TRACE] Failed to send message: {}", e), + Ok(_) => trace_log!("[TRACE] Message sent successfully"), + Err(e) => trace_log!("[TRACE] Failed to send message: {}", e), } } else { - println!( + trace_log!( "[TRACE] Session {} not found in active sessions", session_id ); - println!( + trace_log!( "[TRACE] Active sessions: {:?}", sessions.keys().collect::>() ); @@ -829,8 +855,8 @@ pub async fn create_web_server(port: u16) -> Result<(), Box Result<(), Box) -> Result<(), Box> { let port = port.unwrap_or(8080); - println!("🚀 Starting Opcode in web server mode..."); + trace_log!("🚀 Starting Opcode in web server mode..."); create_web_server(port).await } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f82be8a04..2c18c0ecd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -5,10 +5,11 @@ "identifier": "opcode.asterisk.so", "build": { "beforeDevCommand": "", - "beforeBuildCommand": "bun run build", + "beforeBuildCommand": "npm run build", "frontendDist": "../dist" }, "app": { + "withGlobalTauri": true, "macOSPrivateApi": true, "windows": [ { @@ -16,8 +17,8 @@ "width": 800, "height": 600, "decorations": false, - "transparent": true, - "shadow": true, + "transparent": false, + "shadow": false, "center": true, "resizable": true, "alwaysOnTop": false @@ -57,11 +58,8 @@ "bundle": { "active": true, "targets": [ - "deb", - "rpm", - "appimage", - "app", - "dmg" + "nsis", + "msi" ], "icon": [ "icons/32x32.png", diff --git a/src/App.tsx b/src/App.tsx index 1eb89e8b1..c2e8dffc2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,13 @@ import { useState, useEffect } from "react"; + +// Suppress console output in production builds to avoid overhead +if (import.meta.env.PROD) { + console.log = () => {}; + console.debug = () => {}; + console.time = () => {}; + console.timeEnd = () => {}; + console.timeLog = () => {}; +} import { motion } from "framer-motion"; import { Bot, FolderCode } from "lucide-react"; import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api"; diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index f0f164f21..9e244d341 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -16,37 +16,13 @@ import { Popover } from "@/components/ui/popover"; import { api, type Session } from "@/lib/api"; import { cn } from "@/lib/utils"; -// Conditional imports for Tauri APIs -let tauriListen: any; +// Import Tauri event API directly (works in both Vite ESM and Tauri builds) +import { listen as tauriListenImport } from "@tauri-apps/api/event"; type UnlistenFn = () => void; -try { - if (typeof window !== 'undefined' && window.__TAURI__) { - tauriListen = require("@tauri-apps/api/event").listen; - } -} catch (e) { - console.log('[ClaudeCodeSession] Tauri APIs not available, using web mode'); -} +// Use the direct import; it will work in Tauri builds and throw at runtime in pure web +const listen = tauriListenImport; -// Web-compatible replacements -const listen = tauriListen || ((eventName: string, callback: (event: any) => void) => { - console.log('[ClaudeCodeSession] Setting up DOM event listener for:', eventName); - - // In web mode, listen for DOM events - const domEventHandler = (event: any) => { - console.log('[ClaudeCodeSession] DOM event received:', eventName, event.detail); - // Simulate Tauri event structure - callback({ payload: event.detail }); - }; - - window.addEventListener(eventName, domEventHandler); - - // Return unlisten function - return Promise.resolve(() => { - console.log('[ClaudeCodeSession] Removing DOM event listener for:', eventName); - window.removeEventListener(eventName, domEventHandler); - }); -}); import { StreamMessage } from "./StreamMessage"; import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput"; import { ErrorBoundary } from "./ErrorBoundary"; diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index 1f042b2c6..a766623f7 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -24,18 +24,7 @@ import { SlashCommandPicker } from "./SlashCommandPicker"; import { ImagePreview } from "./ImagePreview"; import { type FileEntry, type SlashCommand } from "@/lib/api"; -// Conditional import for Tauri webview window -let tauriGetCurrentWebviewWindow: any; -try { - if (typeof window !== 'undefined' && window.__TAURI__) { - tauriGetCurrentWebviewWindow = require("@tauri-apps/api/webviewWindow").getCurrentWebviewWindow; - } -} catch (e) { - console.log('[FloatingPromptInput] Tauri webview API not available, using web mode'); -} - -// Web-compatible replacement -const getCurrentWebviewWindow = tauriGetCurrentWebviewWindow || (() => ({ listen: () => Promise.resolve(() => {}) })); +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; interface FloatingPromptInputProps { /** @@ -238,6 +227,10 @@ const FloatingPromptInputInner = ( const [embeddedImages, setEmbeddedImages] = useState([]); const [dragActive, setDragActive] = useState(false); + // Store base64 image data separately, keyed by image number + const imageDataMapRef = useRef>(new Map()); + const imageCounterRef = useRef(0); + const textareaRef = useRef(null); const expandedTextareaRef = useRef(null); const unlistenDragDropRef = useRef<(() => void) | null>(null); @@ -706,9 +699,16 @@ const FloatingPromptInputInner = ( finalPrompt = `${finalPrompt}.\n\n${thinkingMode.phrase}.`; } + // Replace [Image #N] placeholders with actual @"data:image/..." references + for (const [imgNum, dataUrl] of imageDataMapRef.current.entries()) { + finalPrompt = finalPrompt.replace(`[Image #${imgNum}]`, `@"${dataUrl}"`); + } + onSend(finalPrompt, selectedModel); setPrompt(""); setEmbeddedImages([]); + imageDataMapRef.current.clear(); + imageCounterRef.current = 0; setTextareaHeight(48); // Reset height after sending } }; @@ -767,13 +767,17 @@ const FloatingPromptInputInner = ( const reader = new FileReader(); reader.onload = () => { const base64Data = reader.result as string; - - // Add the base64 data URL directly to the prompt + + // Assign an image number and store the data separately + imageCounterRef.current += 1; + const imgNum = imageCounterRef.current; + imageDataMapRef.current.set(imgNum, base64Data); + + // Insert a short placeholder in the textarea setPrompt(currentPrompt => { - // Use the data URL directly as the image reference - const mention = `@"${base64Data}"`; - const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' '; - + const placeholder = `[Image #${imgNum}]`; + const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + placeholder + ' '; + // Focus the textarea and move cursor to end setTimeout(() => { const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; @@ -783,8 +787,11 @@ const FloatingPromptInputInner = ( return newPrompt; }); + + // Update embeddedImages for the ImagePreview component + setEmbeddedImages(prev => [...prev, base64Data]); }; - + reader.readAsDataURL(blob); } catch (error) { console.error('Failed to paste image:', error); @@ -810,13 +817,25 @@ const FloatingPromptInputInner = ( const handleRemoveImage = (index: number) => { // Remove the corresponding @mention from the prompt const imagePath = embeddedImages[index]; - - // For data URLs, we need to handle them specially since they're always quoted + + // For data URLs stored via placeholder, find and remove the [Image #N] placeholder if (imagePath.startsWith('data:')) { - // Simply remove the exact quoted data URL + // Find which image number this data URL corresponds to + for (const [imgNum, dataUrl] of imageDataMapRef.current.entries()) { + if (dataUrl === imagePath) { + const placeholder = `[Image #${imgNum}]`; + const newPrompt = prompt.replace(placeholder, '').replace(/\s{2,}/g, ' ').trim(); + setPrompt(newPrompt); + imageDataMapRef.current.delete(imgNum); + break; + } + } + // Also try the old format in case it exists const quotedPath = `@"${imagePath}"`; - const newPrompt = prompt.replace(quotedPath, '').trim(); - setPrompt(newPrompt); + if (prompt.includes(quotedPath)) { + const newPrompt = prompt.replace(quotedPath, '').trim(); + setPrompt(newPrompt); + } return; } diff --git a/src/components/StreamMessage.tsx b/src/components/StreamMessage.tsx index ae43a5934..9b70bf900 100644 --- a/src/components/StreamMessage.tsx +++ b/src/components/StreamMessage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Terminal, User, @@ -14,6 +14,87 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { getClaudeSyntaxTheme } from "@/lib/claudeSyntaxTheme"; import { useTheme } from "@/hooks"; import type { ClaudeStreamMessage } from "./AgentExecution"; + +/** Renders user text with collapsed images and long pasted text */ +function UserTextDisplay({ text, imageCounter }: { text: string; imageCounter: React.MutableRefObject }) { + const LINE_THRESHOLD = 10; + + // Split text into segments: image refs and regular text + const segments = useMemo(() => { + const parts: { type: 'text' | 'image'; content: string; dataUrl?: string; imageNum?: number }[] = []; + const regex = /@"(data:image\/[^;]+;base64,[^"]+)"/g; + let lastIndex = 0; + let match; + + while ((match = regex.exec(text)) !== null) { + // Text before this image ref + if (match.index > lastIndex) { + parts.push({ type: 'text', content: text.slice(lastIndex, match.index) }); + } + imageCounter.current += 1; + parts.push({ type: 'image', content: '', dataUrl: match[1], imageNum: imageCounter.current }); + lastIndex = match.index + match[0].length; + } + // Remaining text + if (lastIndex < text.length) { + parts.push({ type: 'text', content: text.slice(lastIndex) }); + } + return parts; + }, [text]); + + return ( + <> + {segments.map((seg, i) => { + if (seg.type === 'image') { + return ( + + [Image #{seg.imageNum}] + {seg.dataUrl && ( + {`Image + )} + + ); + } + // Text segment — check if long + const trimmed = seg.content.trim(); + if (!trimmed) return null; + const lines = trimmed.split('\n'); + if (lines.length > LINE_THRESHOLD) { + return ; + } + return {seg.content}; + })} + + ); +} + +function CollapsedText({ text, lineCount }: { text: string; lineCount: number }) { + const [expanded, setExpanded] = useState(false); + const preview = text.split('\n').slice(0, 3).join('\n'); + return ( +
+ + {expanded ? ( +
+          {text}
+        
+ ) : ( +
+          {preview}...
+        
+ )} +
+ ); +} import { TodoWidget, TodoReadWidget, @@ -52,6 +133,9 @@ interface StreamMessageProps { * Component to render a single Claude Code stream message */ const StreamMessageComponent: React.FC = ({ message, className, streamMessages, onLinkDetected }) => { + // Counter for image numbering within this message + const imageCounterRef = React.useRef(0); + // State to track tool results mapped by tool call ID const [toolResults, setToolResults] = useState>(new Map()); @@ -358,10 +442,10 @@ const StreamMessageComponent: React.FC = ({ message, classNa return ; } - // Otherwise render as plain text + // Render with image/long-text support return (
- {contentStr} +
); })() @@ -612,14 +696,14 @@ const StreamMessageComponent: React.FC = ({ message, classNa // Text content if (content.type === "text") { // Handle both string and object formats - const textContent = typeof content.text === 'string' - ? content.text + const textContent = typeof content.text === 'string' + ? content.text : (content.text?.text || JSON.stringify(content.text)); - + renderedSomething = true; return (
- {textContent} +
); } @@ -656,30 +740,10 @@ const StreamMessageComponent: React.FC = ({ message, classNa {isError ? "Execution Failed" : "Execution Complete"} - {message.result && ( + {/* Result text is already shown in the assistant message above; only show if it's an error */} + {isError && message.result && (
- - {String(children).replace(/\n$/, '')} - - ) : ( - - {children} - - ); - } - }} - > + {message.result}
diff --git a/src/components/claude-code-session/useClaudeMessages.ts b/src/components/claude-code-session/useClaudeMessages.ts index 5a2e819e6..393580259 100644 --- a/src/components/claude-code-session/useClaudeMessages.ts +++ b/src/components/claude-code-session/useClaudeMessages.ts @@ -3,15 +3,7 @@ import { api } from '@/lib/api'; import { getEnvironmentInfo } from '@/lib/apiAdapter'; import type { ClaudeStreamMessage } from '../AgentExecution'; -// Conditional import for Tauri -let tauriListen: any; -try { - if (typeof window !== 'undefined' && window.__TAURI__) { - tauriListen = require('@tauri-apps/api/event').listen; - } -} catch (e) { - console.log('[useClaudeMessages] Tauri event API not available, using web mode'); -} +import { listen as tauriListen } from '@tauri-apps/api/event'; interface UseClaudeMessagesOptions { onSessionInfo?: (info: { sessionId: string; projectId: string }) => void;