From d8908c71f38d2417182f08838a6bf92526a11792 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Thu, 2 Apr 2026 12:59:17 +0800 Subject: [PATCH] fix: comprehensive Windows support and UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Windows Platform Fixes - Fix Tauri event system: replace `require()` with ES `import` for `@tauri-apps/api/event` — `require()` fails silently in Vite ESM builds, causing all Tauri IPC events to fall back to DOM listeners that never receive backend messages. This was the root cause of the chat "spinning forever" bug on all platforms using production builds. - Add `withGlobalTauri: true` to tauri.conf.json for reliable `window.__TAURI__` availability. - Add `CREATE_NO_WINDOW` (0x08000000) flag to all subprocess spawns on Windows to prevent console windows from flashing when launching claude, `where.exe`, or other CLI tools. Introduces `hidden_command()` helper in claude_binary.rs. - Set `stdin(Stdio::null())` on Claude subprocess to prevent 3-second stdin wait timeout. - Disable transparent window rendering (`transparent: false`) which causes severe performance issues with WebView2 on Windows. - Add `nsis` and `msi` to bundle targets for Windows installer generation. ## Image Support - Add image sending support via `--input-format stream-json` with stdin pipe. When prompts contain `@"data:image/..."` references, automatically switch from `-p` flag to stdin-based content blocks with proper base64 image data. - Works across all entry points: execute, continue, and resume commands. ## UX Improvements - Show `[Image #N]` placeholder + thumbnail in input box instead of raw base64. - Collapse long pasted text (>10 lines) as `[Pasted text +XX lines]` with expandable preview in message display. - Remove duplicate content in result messages — "Execution Complete" card now only shows metadata (cost, duration, tokens) since the assistant message above already displays the response text. - Suppress console.log/debug output in production builds to reduce overhead. - Wrap all Rust `println!` in web_server.rs with `trace_log!` macro that only prints in debug builds. Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 10 +- src-tauri/src/claude_binary.rs | 24 +- src-tauri/src/commands/agents.rs | 15 +- src-tauri/src/commands/claude.rs | 223 +++++++++++++----- src-tauri/src/process/registry.rs | 10 +- src-tauri/src/web_server.rs | 180 ++++++++------ src-tauri/tauri.conf.json | 14 +- src/App.tsx | 9 + src/components/ClaudeCodeSession.tsx | 32 +-- src/components/FloatingPromptInput.tsx | 67 ++++-- src/components/StreamMessage.tsx | 124 +++++++--- .../claude-code-session/useClaudeMessages.ts | 10 +- 12 files changed, 472 insertions(+), 246 deletions(-) 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;