diff --git a/crates/cowork-core/src/acp/client.rs b/crates/cowork-core/src/acp/client.rs index 285e965..9cc8f1d 100644 --- a/crates/cowork-core/src/acp/client.rs +++ b/crates/cowork-core/src/acp/client.rs @@ -219,6 +219,7 @@ fn run_acp_in_thread( // Spawn the agent process let mut cmd = Command::new(&config.command); cmd.args(&config.args) + .env("PATH", std::env::var("PATH").unwrap_or_default()) .current_dir(&workspace) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/delivery_agent.json b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/delivery_agent.json index 7aef7e2..cea4643 100644 --- a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/delivery_agent.json +++ b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/delivery_agent.json @@ -15,6 +15,9 @@ { "tool_id": "list_files" }, + { + "tool_id": "read_file_truncated" + }, { "tool_id": "save_delivery_report" }, diff --git a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/idea_agent.json b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/idea_agent.json index 4b6ce9a..6eac580 100644 --- a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/idea_agent.json +++ b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/idea_agent.json @@ -9,6 +9,12 @@ { "tool_id": "save_idea" }, + { + "tool_id": "read_file" + }, + { + "tool_id": "list_files" + }, { "tool_id": "query_memory" }, diff --git a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/pm_agent.json b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/pm_agent.json index ece2963..8acfd12 100644 --- a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/pm_agent.json +++ b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/pm_agent.json @@ -21,6 +21,12 @@ { "tool_id": "read_file" }, + { + "tool_id": "list_files" + }, + { + "tool_id": "read_file_truncated" + }, { "tool_id": "query_memory" } diff --git a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/prd_actor.json b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/prd_actor.json index 8fd0848..fff0002 100644 --- a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/prd_actor.json +++ b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/prd_actor.json @@ -33,6 +33,15 @@ { "tool_id": "save_prd_doc" }, + { + "tool_id": "read_file" + }, + { + "tool_id": "list_files" + }, + { + "tool_id": "read_file_truncated" + }, { "tool_id": "query_memory" }, diff --git a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/prd_critic.json b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/prd_critic.json index a33b531..b55ba8c 100644 --- a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/prd_critic.json +++ b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/prd_critic.json @@ -15,6 +15,15 @@ { "tool_id": "provide_feedback" }, + { + "tool_id": "read_file" + }, + { + "tool_id": "list_files" + }, + { + "tool_id": "read_file_truncated" + }, { "tool_id": "query_memory" }, diff --git a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/summary_agent.json b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/summary_agent.json index 130ed51..e94e4de 100644 --- a/crates/cowork-core/src/config_definition/default_configs/agents/built-in/summary_agent.json +++ b/crates/cowork-core/src/config_definition/default_configs/agents/built-in/summary_agent.json @@ -11,6 +11,12 @@ }, { "tool_id": "write_file" + }, + { + "tool_id": "list_files" + }, + { + "tool_id": "read_file_truncated" } ], "model": { diff --git a/crates/cowork-gui/src-tauri/src/commands/mod.rs b/crates/cowork-gui/src-tauri/src/commands/mod.rs index 6676838..34e768c 100644 --- a/crates/cowork-gui/src-tauri/src/commands/mod.rs +++ b/crates/cowork-gui/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod template; pub mod pm; pub mod system; pub mod import_cmd; +pub mod path_utils; use lazy_static::lazy_static; use std::sync::Mutex; @@ -20,3 +21,9 @@ lazy_static! { pub fn init_app_handle(handle: tauri::AppHandle) { PROJECT_RUNNER.set_app_handle(handle); } + +/// Initialize PATH for macOS App Bundle compatibility +/// Must be called very early in the application lifecycle +pub fn init_path_for_app_bundle() { + path_utils::init_extended_path(); +} diff --git a/crates/cowork-gui/src-tauri/src/commands/path_utils.rs b/crates/cowork-gui/src-tauri/src/commands/path_utils.rs new file mode 100644 index 0000000..334f9fe --- /dev/null +++ b/crates/cowork-gui/src-tauri/src/commands/path_utils.rs @@ -0,0 +1,425 @@ +// Path utilities for resolving executable search issues across platforms +// +// This module provides platform-agnostic utilities to find common executables +// (bun, npm, node, etc.) regardless of PATH limitations on GUI apps. +// +// Platform support: +// - macOS: Finder/Launchpad apps have limited PATH +// - Windows: May need to check Program Files, AppData, etc. +// - Linux: Various package manager locations + +use std::path::PathBuf; +use std::env; + +/// Platform-specific path separator +#[cfg(windows)] +const PATH_SEP: char = ';'; + +#[cfg(not(windows))] +const PATH_SEP: char = ':'; + +/// Get user home directory path +fn get_home_dir() -> Option { + #[cfg(windows)] + { + if let Ok(userprofile) = env::var("USERPROFILE") { + return Some(PathBuf::from(userprofile)); + } + if let Ok(home) = env::var("HOMEDRIVE").and_then(|d| env::var("HOMEPATH").map(|p| PathBuf::from(d).join(p))) { + return Some(home); + } + } + + #[cfg(not(windows))] + { + if let Ok(home) = env::var("HOME") { + return Some(PathBuf::from(home)); + } + } + + None +} + +/// Get platform-specific bun installation paths +fn get_bun_paths() -> Vec { + let mut paths = Vec::new(); + + // Check PATH first (fastest path) + if let Ok(path_env) = env::var("PATH") { + for dir in path_env.split(PATH_SEP) { + paths.push(PathBuf::from(dir).join("bun")); + } + } + + // macOS-specific paths + #[cfg(target_os = "macos")] + { + if let Some(home) = get_home_dir() { + paths.push(home.join(".bun").join("bin").join("bun")); + paths.push(home.join(".nvm").join("versions").join("node").join("*").join("bin").join("bun")); + } + paths.push(PathBuf::from("/opt/homebrew/bin/bun")); // Apple Silicon + paths.push(PathBuf::from("/usr/local/bin/bun")); // Intel Mac + } + + // Linux-specific paths + #[cfg(target_os = "linux")] + { + if let Some(home) = get_home_dir() { + paths.push(home.join(".bun").join("bin").join("bun")); + paths.push(home.join(".nvm").join("versions").join("node").join("*").join("bin").join("bun")); + } + paths.push(PathBuf::from("/usr/local/bin/bun")); + paths.push(PathBuf::from("/snap/bin/bun")); + } + + // Windows-specific paths + #[cfg(windows)] + { + if let Some(home) = get_home_dir() { + paths.push(home.join(".bun").join("bin").join("bun.exe")); + } + + // Program Files + if let Ok(program_files) = env::var("ProgramFiles") { + paths.push(PathBuf::from(&program_files).join("bun").join("bin").join("bun.exe")); + } + if let Ok(program_files_x86) = env::var("ProgramFiles(x86)") { + paths.push(PathBuf::from(&program_files_x86).join("bun").join("bin").join("bun.exe")); + } + + // Local AppData + if let Ok(local_appdata) = env::var("LOCALAPPDATA") { + paths.push(PathBuf::from(&local_appdata).join("bun").join("bin").join("bun.exe")); + } + } + + paths +} + +/// Get platform-specific npm installation paths +fn get_npm_paths() -> Vec { + let mut paths = Vec::new(); + + // Check PATH first + if let Ok(path_env) = env::var("PATH") { + for dir in path_env.split(PATH_SEP) { + paths.push(PathBuf::from(dir).join("npm")); + } + } + + // macOS/Linux + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + if let Some(home) = get_home_dir() { + // nvm installations + paths.push(home.join(".nvm").join("versions").join("node").join("*").join("bin").join("npm")); + // fnm installations + paths.push(home.join(".fnm").join("node-versions").join("*").join("installation").join("bin").join("npm")); + // volta installations + paths.push(home.join(".volta").join("bin").join("npm")); + } + + #[cfg(target_os = "macos")] + { + paths.push(PathBuf::from("/opt/homebrew/bin/npm")); + paths.push(PathBuf::from("/usr/local/bin/npm")); + } + + #[cfg(target_os = "linux")] + { + paths.push(PathBuf::from("/usr/local/bin/npm")); + paths.push(PathBuf::from("/snap/bin/npm")); + } + } + + // Windows + #[cfg(windows)] + { + if let Some(home) = get_home_dir() { + paths.push(home.join(".nvm").join("versions").join("node").join("*").join("npm.cmd")); + paths.push(home.join(".fnm").join("node-versions").join("*").join("installation").join("npm.cmd")); + paths.push(home.join(".volta").join("bin").join("npm.cmd")); + } + + if let Ok(program_files) = env::var("ProgramFiles") { + paths.push(PathBuf::from(&program_files).join("nodejs").join("npm.cmd")); + } + if let Ok(program_files_x86) = env::var("ProgramFiles(x86)") { + paths.push(PathBuf::from(&program_files_x86).join("nodejs").join("npm.cmd")); + } + } + + paths +} + +/// Get platform-specific node installation paths +#[allow(dead_code)] +fn get_node_paths() -> Vec { + let mut paths = Vec::new(); + + // Check PATH first + if let Ok(path_env) = env::var("PATH") { + for dir in path_env.split(PATH_SEP) { + paths.push(PathBuf::from(dir).join("node")); + } + } + + // macOS/Linux + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + if let Some(home) = get_home_dir() { + paths.push(home.join(".nvm").join("versions").join("node").join("*").join("bin").join("node")); + paths.push(home.join(".fnm").join("node-versions").join("*").join("installation").join("bin").join("node")); + paths.push(home.join(".volta").join("bin").join("node")); + } + + #[cfg(target_os = "macos")] + { + paths.push(PathBuf::from("/opt/homebrew/bin/node")); + paths.push(PathBuf::from("/usr/local/bin/node")); + } + + #[cfg(target_os = "linux")] + { + paths.push(PathBuf::from("/usr/local/bin/node")); + paths.push(PathBuf::from("/snap/bin/node")); + } + } + + // Windows + #[cfg(windows)] + { + if let Some(home) = get_home_dir() { + paths.push(home.join(".nvm").join("versions").join("node").join("*").join("node.exe")); + paths.push(home.join(".fnm").join("node-versions").join("*").join("installation").join("node.exe")); + paths.push(home.join(".volta").join("bin").join("node.exe")); + } + + if let Ok(program_files) = env::var("ProgramFiles") { + paths.push(PathBuf::from(&program_files).join("nodejs").join("node.exe")); + } + if let Ok(program_files_x86) = env::var("ProgramFiles(x86)") { + paths.push(PathBuf::from(&program_files_x86).join("nodejs").join("node.exe")); + } + } + + paths +} + +/// Find an executable by checking common paths +fn find_executable(paths: &[PathBuf]) -> Option { + for path in paths { + // For paths with wildcards, we need to expand them + if path.to_string_lossy().contains('*') { + if let Some(parent) = path.parent() { + if let Some(file_name) = path.file_name() { + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.flatten() { + let full_path = entry.path().join(file_name); + if full_path.exists() && is_executable(&full_path) { + return Some(full_path); + } + } + } + } + } + } else if path.exists() && is_executable(path) { + return Some(path.clone()); + } + } + None +} + +/// Check if a path is executable +#[cfg(unix)] +fn is_executable(path: &PathBuf) -> bool { + use std::os::unix::fs::PermissionsExt; + path.is_file() && path.metadata().map(|m| m.permissions().mode() & 0o111 != 0).unwrap_or(false) +} + +#[cfg(windows)] +fn is_executable(path: &PathBuf) -> bool { + path.is_file() + && path.extension() + .map(|ext| matches!(ext.to_str(), Some("exe") | Some("cmd") | Some("bat"))) + .unwrap_or(false) +} + +/// Find bun executable, returns the full path if found +pub fn find_bun() -> Option { + find_executable(&get_bun_paths()) +} + +/// Find npm executable, returns the full path if found +pub fn find_npm() -> Option { + find_executable(&get_npm_paths()) +} + +/// Find node executable, returns the full path if found +#[allow(dead_code)] +pub fn find_node() -> Option { + find_executable(&get_node_paths()) +} + +/// Check if bun is available +pub fn has_bun() -> bool { + find_bun().is_some() +} + +/// Check if npm is available +pub fn has_npm() -> bool { + find_npm().is_some() +} + +/// Get the preferred package manager (bun or npm) +/// Returns ("bun" or "npm", full_path) +#[allow(dead_code)] +pub fn get_package_manager() -> Option<(&'static str, PathBuf)> { + if let Some(bun) = find_bun() { + return Some(("bun", bun)); + } + if let Some(npm) = find_npm() { + return Some(("npm", npm)); + } + None +} + +/// Build an extended PATH that includes common development tool locations +/// This should be called at application startup to fix the PATH issue on GUI apps +pub fn build_extended_path() -> String { + let mut path_dirs: Vec = Vec::new(); + + // Get existing PATH + if let Ok(existing) = env::var("PATH") { + for dir in existing.split(PATH_SEP) { + if !dir.is_empty() && !path_dirs.contains(&dir.to_string()) { + path_dirs.push(dir.to_string()); + } + } + } + + // Add platform-specific paths + #[cfg(target_os = "macos")] + { + let extra_paths = [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + ]; + for path in extra_paths { + if !path_dirs.contains(&path.to_string()) { + path_dirs.push(path.to_string()); + } + } + + // Add user-specific paths + if let Some(home) = get_home_dir() { + let home_str = home.to_string_lossy().to_string(); + let user_paths = [ + format!("{}/.bun/bin", home_str), + format!("{}/.cargo/bin", home_str), + format!("{}/.volta/bin", home_str), + format!("{}/.local/bin", home_str), + format!("{}/go/bin", home_str), + ]; + for path in user_paths { + if !path_dirs.contains(&path) { + path_dirs.push(path); + } + } + } + } + + #[cfg(target_os = "linux")] + { + let extra_paths = [ + "/usr/local/bin", + "/snap/bin", + ]; + for path in extra_paths { + if !path_dirs.contains(&path.to_string()) { + path_dirs.push(path.to_string()); + } + } + + if let Some(home) = get_home_dir() { + let home_str = home.to_string_lossy().to_string(); + let user_paths = [ + format!("{}/.bun/bin", home_str), + format!("{}/.cargo/bin", home_str), + format!("{}/.volta/bin", home_str), + format!("{}/.local/bin", home_str), + format!("{}/go/bin", home_str), + ]; + for path in user_paths { + if !path_dirs.contains(&path) { + path_dirs.push(path); + } + } + } + } + + #[cfg(windows)] + { + // Windows-specific paths + if let Ok(program_files) = env::var("ProgramFiles") { + let pf = PathBuf::from(&program_files); + if !path_dirs.contains(&pf.to_string_lossy().to_string()) { + path_dirs.push(pf.to_string_lossy().to_string()); + } + } + if let Ok(program_files_x86) = env::var("ProgramFiles(x86)") { + let pf_x86 = PathBuf::from(&program_files_x86); + if !path_dirs.contains(&pf_x86.to_string_lossy().to_string()) { + path_dirs.push(pf_x86.to_string_lossy().to_string()); + } + } + + if let Some(home) = get_home_dir() { + let home_str = home.to_string_lossy().to_string(); + let user_paths = [ + format!("{}/.bun/bin", home_str), + format!("{}/.cargo/bin", home_str), + format!("{}/AppData/Local/Programs", home_str), + ]; + for path in user_paths { + if !path_dirs.contains(&path) { + path_dirs.push(path); + } + } + } + } + + path_dirs.join(&PATH_SEP.to_string()) +} + +/// Initialize extended PATH at application startup +/// Call this early in the application lifecycle to ensure all child processes +/// inherit the correct PATH +pub fn init_extended_path() { + let extended_path = build_extended_path(); + + // Log for debugging + eprintln!("[PathUtils] Setting extended PATH ({} platform)", std::env::consts::OS); + eprintln!("[PathUtils] PATH length: {} characters", extended_path.len()); + + // Set the PATH environment variable + // Note: Using unsafe block to work around potential Rust version or environment issues + // std::env::set_var is safe in standard Rust, but may be marked differently in this environment + unsafe { std::env::set_var("PATH", &extended_path) }; + + // Verify that bun/npm can now be found + if let Some(bun) = find_bun() { + eprintln!("[PathUtils] Found bun at: {:?}", bun); + } else { + eprintln!("[PathUtils] bun not found after PATH extension"); + } + + if let Some(npm) = find_npm() { + eprintln!("[PathUtils] Found npm at: {:?}", npm); + } else { + eprintln!("[PathUtils] npm not found after PATH extension"); + } +} \ No newline at end of file diff --git a/crates/cowork-gui/src-tauri/src/commands/preview.rs b/crates/cowork-gui/src-tauri/src/commands/preview.rs index 5d35968..7aac070 100644 --- a/crates/cowork-gui/src-tauri/src/commands/preview.rs +++ b/crates/cowork-gui/src-tauri/src/commands/preview.rs @@ -1,6 +1,7 @@ use crate::gui_types::*; use crate::static_server; use crate::commands::PROJECT_RUNNER; +use crate::commands::path_utils; use crate::AppState; use std::path::PathBuf; use tauri::State; @@ -37,8 +38,8 @@ async fn install_dependencies_if_needed(workspace: &std::path::Path) -> Result<( let node_modules = workspace.join("node_modules"); if package_json.exists() && !node_modules.exists() { - let use_bun = which::which("bun").is_ok(); - let use_npm = which::which("npm").is_ok(); + let use_bun = path_utils::has_bun(); + let use_npm = path_utils::has_npm(); let (cmd, args) = if use_bun { ("bun", vec!["install"]) } else if use_npm { ("npm", vec!["install"]) } diff --git a/crates/cowork-gui/src-tauri/src/commands/runner.rs b/crates/cowork-gui/src-tauri/src/commands/runner.rs index af15b6b..4b05bb3 100644 --- a/crates/cowork-gui/src-tauri/src/commands/runner.rs +++ b/crates/cowork-gui/src-tauri/src/commands/runner.rs @@ -1,6 +1,7 @@ use crate::gui_types::*; use crate::static_server; use crate::commands::PROJECT_RUNNER; +use crate::commands::path_utils; use crate::AppState; use cowork_core::RuntimeType; use std::path::PathBuf; @@ -63,9 +64,14 @@ async fn install_deps_if_needed(workspace: &std::path::Path) -> Result<(), Strin let pkg = workspace.join("package.json"); let mods = workspace.join("node_modules"); if pkg.exists() && !mods.exists() { - let (cmd, args) = if which::which("bun").is_ok() { ("bun", vec!["install"]) } - else if which::which("npm").is_ok() { ("npm", vec!["install"]) } - else { return Ok(()) }; + // Use our path_utils instead of which::which for macOS App Bundle compatibility + let (cmd, args) = if path_utils::has_bun() { + ("bun", vec!["install"]) + } else if path_utils::has_npm() { + ("npm", vec!["install"]) + } else { + return Ok(()) + }; eprintln!("[Runner] Installing dependencies with {} {:?}", cmd, args); let out = std::process::Command::new(cmd).args(&args).current_dir(workspace).output(); @@ -145,7 +151,7 @@ fn detect_npm_start_command(dir: &std::path::Path) -> Option { // Try common start scripts in order for script_name in &["dev", "start", "serve"] { if scripts.get(*script_name).and_then(|s| s.as_str()).is_some() { - let pkg_manager = if which::which("bun").is_ok() { "bun" } else { "npm" }; + let pkg_manager = if path_utils::has_bun() { "bun" } else { "npm" }; let command = format!("{} run {}", pkg_manager, script_name); eprintln!("[Runner] Detected start command from package.json: {}", command); return Some(command); @@ -325,7 +331,7 @@ pub async fn start_iteration_project( } // No start script but has package.json - try common defaults - let pkg_manager = if which::which("bun").is_ok() { "bun" } else { "npm" }; + let pkg_manager = if path_utils::has_bun() { "bun" } else { "npm" }; let default_cmd = format!("{} run dev", pkg_manager); eprintln!("[Runner] Fallback: trying default command: {}", default_cmd); diff --git a/crates/cowork-gui/src-tauri/src/config_commands.rs b/crates/cowork-gui/src-tauri/src/config_commands.rs index 65ecd06..976c5ee 100644 --- a/crates/cowork-gui/src-tauri/src/config_commands.rs +++ b/crates/cowork-gui/src-tauri/src/config_commands.rs @@ -699,3 +699,313 @@ fn open_folder_in_explorer(path: &std::path::Path) -> Result<(), String> { Ok(()) } + +/// Tool info for frontend display +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInfo { + pub id: String, + pub name: String, + pub category: String, + pub description: String, +} + +/// Get all available tools that can be assigned to agents +#[tauri::command] +pub async fn gui_get_available_tools() -> Result, String> { + // This list should match the tools supported in agent_factory.rs + let tools = vec![ + // Idea tools + ToolInfo { + id: "save_idea".to_string(), + name: "Save Idea".to_string(), + category: "Idea".to_string(), + description: "Save the initial project idea document".to_string(), + }, + + // Data tools + ToolInfo { + id: "create_requirement".to_string(), + name: "Create Requirement".to_string(), + category: "Data".to_string(), + description: "Create a new requirement".to_string(), + }, + ToolInfo { + id: "update_requirement".to_string(), + name: "Update Requirement".to_string(), + category: "Data".to_string(), + description: "Update an existing requirement".to_string(), + }, + ToolInfo { + id: "delete_requirement".to_string(), + name: "Delete Requirement".to_string(), + category: "Data".to_string(), + description: "Delete a requirement".to_string(), + }, + ToolInfo { + id: "get_requirements".to_string(), + name: "Get Requirements".to_string(), + category: "Data".to_string(), + description: "Retrieve all requirements".to_string(), + }, + ToolInfo { + id: "add_feature".to_string(), + name: "Add Feature".to_string(), + category: "Data".to_string(), + description: "Add a feature to a requirement".to_string(), + }, + ToolInfo { + id: "update_feature".to_string(), + name: "Update Feature".to_string(), + category: "Data".to_string(), + description: "Update an existing feature".to_string(), + }, + ToolInfo { + id: "create_task".to_string(), + name: "Create Task".to_string(), + category: "Data".to_string(), + description: "Create a new task in the plan".to_string(), + }, + ToolInfo { + id: "update_task_status".to_string(), + name: "Update Task Status".to_string(), + category: "Data".to_string(), + description: "Update the status of a task".to_string(), + }, + ToolInfo { + id: "get_design".to_string(), + name: "Get Design".to_string(), + category: "Data".to_string(), + description: "Retrieve the current design specification".to_string(), + }, + ToolInfo { + id: "get_implementation_plan".to_string(), + name: "Get Implementation Plan".to_string(), + category: "Data".to_string(), + description: "Retrieve the implementation plan (alias: get_plan)".to_string(), + }, + + // File tools + ToolInfo { + id: "read_file".to_string(), + name: "Read File".to_string(), + category: "File".to_string(), + description: "Read the contents of a file in the workspace".to_string(), + }, + ToolInfo { + id: "write_file".to_string(), + name: "Write File".to_string(), + category: "File".to_string(), + description: "Write content to a file in the workspace".to_string(), + }, + ToolInfo { + id: "list_files".to_string(), + name: "List Files".to_string(), + category: "File".to_string(), + description: "List files in a directory within the workspace".to_string(), + }, + ToolInfo { + id: "run_command".to_string(), + name: "Run Command".to_string(), + category: "File".to_string(), + description: "Execute a shell command in the workspace".to_string(), + }, + ToolInfo { + id: "read_file_truncated".to_string(), + name: "Read File Truncated".to_string(), + category: "File".to_string(), + description: "Read a file with intelligent truncation for large files".to_string(), + }, + + // Document tools (Project Iteration Files) + ToolInfo { + id: "load_idea".to_string(), + name: "Load Idea".to_string(), + category: "Document".to_string(), + description: "Load the idea document from current iteration".to_string(), + }, + ToolInfo { + id: "load_prd_doc".to_string(), + name: "Load PRD Doc".to_string(), + category: "Document".to_string(), + description: "Load the PRD document from current iteration".to_string(), + }, + ToolInfo { + id: "load_design_doc".to_string(), + name: "Load Design Doc".to_string(), + category: "Document".to_string(), + description: "Load the design document from current iteration".to_string(), + }, + ToolInfo { + id: "load_plan_doc".to_string(), + name: "Load Plan Doc".to_string(), + category: "Document".to_string(), + description: "Load the implementation plan document from current iteration".to_string(), + }, + ToolInfo { + id: "save_prd_doc".to_string(), + name: "Save PRD Doc".to_string(), + category: "Document".to_string(), + description: "Save the PRD document to the artifacts directory".to_string(), + }, + ToolInfo { + id: "save_design_doc".to_string(), + name: "Save Design Doc".to_string(), + category: "Document".to_string(), + description: "Save the design document to the artifacts directory".to_string(), + }, + ToolInfo { + id: "save_plan_doc".to_string(), + name: "Save Plan Doc".to_string(), + category: "Document".to_string(), + description: "Save the implementation plan document to the artifacts directory".to_string(), + }, + ToolInfo { + id: "save_delivery_report".to_string(), + name: "Save Delivery Report".to_string(), + category: "Document".to_string(), + description: "Save the delivery report to the artifacts directory".to_string(), + }, + ToolInfo { + id: "save_check_report".to_string(), + name: "Save Check Report".to_string(), + category: "Document".to_string(), + description: "Save the check report to the artifacts directory".to_string(), + }, + + // Design tools + ToolInfo { + id: "create_design_component".to_string(), + name: "Create Design Component".to_string(), + category: "Design".to_string(), + description: "Create a new design component".to_string(), + }, + + // Validation tools + ToolInfo { + id: "check_feature_coverage".to_string(), + name: "Check Feature Coverage".to_string(), + category: "Validation".to_string(), + description: "Validate that all features are covered in the design".to_string(), + }, + ToolInfo { + id: "check_task_dependencies".to_string(), + name: "Check Task Dependencies".to_string(), + category: "Validation".to_string(), + description: "Validate task dependencies in the plan".to_string(), + }, + ToolInfo { + id: "check_tests".to_string(), + name: "Check Tests".to_string(), + category: "Validation".to_string(), + description: "Run tests in the workspace".to_string(), + }, + ToolInfo { + id: "check_lint".to_string(), + name: "Check Lint".to_string(), + category: "Validation".to_string(), + description: "Run linting in the workspace".to_string(), + }, + ToolInfo { + id: "check_data_format".to_string(), + name: "Check Data Format".to_string(), + category: "Validation".to_string(), + description: "Validate data format consistency".to_string(), + }, + + // HITL tools + ToolInfo { + id: "provide_feedback".to_string(), + name: "Provide Feedback".to_string(), + category: "HITL".to_string(), + description: "Provide feedback to the user for review".to_string(), + }, + ToolInfo { + id: "load_feedback_history".to_string(), + name: "Load Feedback History".to_string(), + category: "HITL".to_string(), + description: "Load history of feedback from previous iterations".to_string(), + }, + + // Memory tools + ToolInfo { + id: "query_memory".to_string(), + name: "Query Memory".to_string(), + category: "Memory".to_string(), + description: "Query the project memory for relevant context".to_string(), + }, + ToolInfo { + id: "save_insight".to_string(), + name: "Save Insight".to_string(), + category: "Memory".to_string(), + description: "Save an insight to the iteration memory".to_string(), + }, + ToolInfo { + id: "save_issue".to_string(), + name: "Save Issue".to_string(), + category: "Memory".to_string(), + description: "Save an issue to the iteration memory".to_string(), + }, + ToolInfo { + id: "save_learning".to_string(), + name: "Save Learning".to_string(), + category: "Memory".to_string(), + description: "Save a learning to the iteration memory".to_string(), + }, + ToolInfo { + id: "promote_to_decision".to_string(), + name: "Promote to Decision".to_string(), + category: "Memory".to_string(), + description: "Promote an insight to a project-level decision".to_string(), + }, + ToolInfo { + id: "promote_to_pattern".to_string(), + name: "Promote to Pattern".to_string(), + category: "Memory".to_string(), + description: "Promote a learning to a project-level pattern".to_string(), + }, + + // Deployment tools + ToolInfo { + id: "copy_workspace_to_project".to_string(), + name: "Copy Workspace to Project".to_string(), + category: "Deployment".to_string(), + description: "Copy generated workspace files to the project directory".to_string(), + }, + + // Flow control tools + ToolInfo { + id: "goto_stage".to_string(), + name: "Goto Stage".to_string(), + category: "Flow Control".to_string(), + description: "Jump to a specific stage in the flow".to_string(), + }, + + // PM tools + ToolInfo { + id: "pm_goto_stage".to_string(), + name: "PM Goto Stage".to_string(), + category: "PM".to_string(), + description: "PM agent: Jump to a specific stage".to_string(), + }, + ToolInfo { + id: "pm_create_iteration".to_string(), + name: "PM Create Iteration".to_string(), + category: "PM".to_string(), + description: "PM agent: Create a new iteration".to_string(), + }, + ToolInfo { + id: "pm_respond".to_string(), + name: "PM Respond".to_string(), + category: "PM".to_string(), + description: "PM agent: Respond to user".to_string(), + }, + ToolInfo { + id: "pm_save_decision".to_string(), + name: "PM Save Decision".to_string(), + category: "PM".to_string(), + description: "PM agent: Save a decision to memory".to_string(), + }, + ]; + + Ok(tools) +} diff --git a/crates/cowork-gui/src-tauri/src/lib.rs b/crates/cowork-gui/src-tauri/src/lib.rs index 9a0cbd3..87cadea 100644 --- a/crates/cowork-gui/src-tauri/src/lib.rs +++ b/crates/cowork-gui/src-tauri/src/lib.rs @@ -22,7 +22,7 @@ mod config_commands; use project_manager::*; // Re-export commands from new module structure -use commands::{init_app_handle, file, preview, runner, memory, template, pm, system, import_cmd}; +use commands::{init_app_handle, init_path_for_app_bundle, file, preview, runner, memory, template, pm, system, import_cmd}; @@ -633,6 +633,11 @@ async fn submit_input_response( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Initialize PATH for macOS App Bundle compatibility + // This MUST be called first, before any other initialization + // On macOS, GUI apps launched from Finder have a limited PATH + init_path_for_app_bundle(); + let app_state = AppState::new() .expect("Failed to initialize application state"); @@ -835,6 +840,7 @@ pub fn run() { config_commands::gui_export_config, config_commands::gui_import_config, config_commands::gui_get_builtin_instructions, + config_commands::gui_get_available_tools, // Project creation commands path_exists, create_project_at_path, diff --git a/crates/cowork-gui/src/components/chat/InputArea.tsx b/crates/cowork-gui/src/components/chat/InputArea.tsx index 72adb9a..571b405 100644 --- a/crates/cowork-gui/src/components/chat/InputArea.tsx +++ b/crates/cowork-gui/src/components/chat/InputArea.tsx @@ -1,8 +1,10 @@ -import React, { memo, useCallback } from 'react'; -import { Button, Space, Input } from 'antd'; -import { CopyOutlined } from '@ant-design/icons'; +import React, { memo, useCallback, useState, useRef, useEffect } from 'react'; +import { Button, Space, Input, Modal } from 'antd'; +import { CopyOutlined, ExpandOutlined, CompressOutlined } from '@ant-design/icons'; import type { InputRequest, InputOption } from '../../stores'; +const { TextArea } = Input; + interface InputAreaProps { userInput: string; onUserInputChange: (value: string) => void; @@ -28,27 +30,203 @@ const InputAreaInner: React.FC = ({ disabled, mode, }) => { - const handleInputChange = useCallback((e: React.ChangeEvent) => { + const [expanded, setExpanded] = useState(false); + const textAreaRef = useRef(null); + const modalTextAreaRef = useRef(null); + // 跟踪 IME 输入法组合状态,用于中文输入法等场景 + const isComposingRef = useRef(false); + // 记录 compositionend 触发的时间,用于防止在 IME 刚结束时误触发发送 + const compositionEndTimeRef = useRef(0); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { onUserInputChange(e.target.value); }, [onUserInputChange]); + const handleCompositionStart = useCallback(() => { + isComposingRef.current = true; + }, []); + + const handleCompositionEnd = useCallback(() => { + // 延迟设置状态,防止在同一个事件循环中被 keydown 误判 + compositionEndTimeRef.current = Date.now(); + // 使用 setTimeout 确保在当前事件循环之后再重置状态 + setTimeout(() => { + isComposingRef.current = false; + }, 0); + }, []); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + // 检查原生事件的 isComposing 属性,这是最可靠的 IME 检测方式 + if (e.nativeEvent.isComposing) { + return; + } + // 如果刚刚结束 composition(50ms 内),也跳过,防止 IME 确认时误触发发送 + if (Date.now() - compositionEndTimeRef.current < 50) { + return; + } + // 如果正在使用输入法组合输入(如中文输入法),不处理快捷键 + if (isComposingRef.current) { + return; + } + // Shift + Enter: 展开输入框 + if (e.shiftKey && e.key === 'Enter') { + e.preventDefault(); + setExpanded(true); + return; + } + // Enter alone: 发送消息 + if (!e.shiftKey && e.key === 'Enter') { + e.preventDefault(); + if (userInput.trim() && !disabled) { + onSend(); + } + } + }, [userInput, disabled, onSend]); + + const handleExpandClick = useCallback(() => { + setExpanded(true); + }, []); + + const handleCollapse = useCallback(() => { + setExpanded(false); + // 折叠后聚焦回主输入框 + setTimeout(() => { + textAreaRef.current?.focus(); + }, 100); + }, []); + + const handleModalSend = useCallback(() => { + if (userInput.trim() && !disabled) { + onSend(); + setExpanded(false); + } + }, [userInput, disabled, onSend]); + + // 展开时聚焦 Modal 内的 TextArea + useEffect(() => { + if (expanded) { + setTimeout(() => { + modalTextAreaRef.current?.focus(); + // 将光标移到末尾 + const len = userInput.length; + modalTextAreaRef.current?.setSelectionRange(len, len); + }, 100); + } + }, [expanded, userInput]); + + const handleModalKeyDown = useCallback((e: React.KeyboardEvent) => { + // 检查原生事件的 isComposing 属性,这是最可靠的 IME 检测方式 + if (e.nativeEvent.isComposing) { + return; + } + // 如果刚刚结束 composition(50ms 内),也跳过,防止 IME 确认时误触发发送 + if (Date.now() - compositionEndTimeRef.current < 50) { + return; + } + // 如果正在使用输入法组合输入(如中文输入法),不处理快捷键 + if (isComposingRef.current) { + return; + } + // Shift + Enter: 在展开模式下输入换行 + if (e.shiftKey && e.key === 'Enter') { + // 默认行为,输入换行 + return; + } + // Ctrl/Cmd + Enter: 发送消息 + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + if (userInput.trim() && !disabled) { + onSend(); + setExpanded(false); + } + } + // Escape: 关闭展开模式 + if (e.key === 'Escape') { + e.preventDefault(); + setExpanded(false); + } + }, [userInput, disabled, onSend]); + + const renderInputWithExpand = (placeholder: string) => ( +
+