use std::process::Command; pub fn launch_terminal( target: &str, command: &str, cwd: Option<&str>, custom_config: Option<&str>, ) -> Result<(), String> { if command.trim().is_empty() { return Err("Resume command is empty".to_string()); } if !cfg!(target_os = "macos") { return Err("Terminal resume is only supported on macOS".to_string()); } match target { "terminal" => launch_macos_terminal(command, cwd), "iTerm" | "iterm" => launch_iterm(command, cwd), "ghostty" => launch_ghostty(command, cwd), "kitty" => launch_kitty(command, cwd), "wezterm" => launch_wezterm(command, cwd), "kaku" => launch_kaku(command, cwd), "alacritty" => launch_alacritty(command, cwd), "custom" => launch_custom(command, cwd, custom_config), _ => Err(format!("Unsupported terminal target: {target}")), } } fn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String> { let full_command = build_shell_command(command, cwd); let escaped = escape_osascript(&full_command); let script = format!( r#"tell application "Terminal" activate do script "{escaped}" end tell"# ); let status = Command::new("osascript") .arg("-e") .arg(script) .status() .map_err(|e| format!("Failed to launch Terminal: {e}"))?; if status.success() { Ok(()) } else { Err("Terminal command execution failed".to_string()) } } fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> { let full_command = build_shell_command(command, cwd); let escaped = escape_osascript(&full_command); // iTerm2 AppleScript to create a new window and execute command let script = format!( r#"tell application "iTerm" activate create window with default profile tell current session of current window write text "{escaped}" end tell end tell"# ); let status = Command::new("osascript") .arg("-e") .arg(script) .status() .map_err(|e| format!("Failed to launch iTerm: {e}"))?; if status.success() { Ok(()) } else { Err("iTerm command execution failed".to_string()) } } fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> { let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); let mut args = vec![ "-na".to_string(), "Ghostty".to_string(), "--args".to_string(), "--quit-after-last-window-closed=true".to_string(), ]; if let Some(dir) = cwd { if !dir.trim().is_empty() { args.push(format!("--working-directory={dir}")); } } args.push("-e".to_string()); args.push(shell); args.push("-l".to_string()); args.push("-c".to_string()); args.push(command.to_string()); let status = Command::new("open") .args(&args) .status() .map_err(|e| format!("Failed to launch Ghostty: {e}"))?; if status.success() { Ok(()) } else { Err("Failed to launch Ghostty. Make sure it is installed.".to_string()) } } fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> { let full_command = build_shell_command(command, cwd); // 获取用户默认 shell let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); let status = Command::new("open") .arg("-na") .arg("kitty") .arg("--args") .arg("-e") .arg(&shell) .arg("-l") .arg("-c") .arg(&full_command) .status() .map_err(|e| format!("Failed to launch Kitty: {e}"))?; if status.success() { Ok(()) } else { Err("Failed to launch Kitty. Make sure it is installed.".to_string()) } } fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> { // wezterm start --cwd ... -- command // To invoke via `open`, we use `open -na "WezTerm" --args start ...` let args = build_wezterm_compatible_args("WezTerm", command, cwd); let status = Command::new("open") .args(args.iter().map(String::as_str)) .status() .map_err(|e| format!("Failed to launch WezTerm: {e}"))?; if status.success() { Ok(()) } else { Err("Failed to launch WezTerm.".to_string()) } } fn launch_kaku(command: &str, cwd: Option<&str>) -> Result<(), String> { // Kaku is a WezTerm-derived terminal and keeps a compatible `start` entrypoint. let args = build_wezterm_compatible_args("Kaku", command, cwd); let status = Command::new("open") .args(args.iter().map(String::as_str)) .status() .map_err(|e| format!("Failed to launch Kaku: {e}"))?; if status.success() { Ok(()) } else { Err("Failed to launch Kaku.".to_string()) } } fn build_wezterm_compatible_args(app_name: &str, command: &str, cwd: Option<&str>) -> Vec { let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); build_wezterm_compatible_args_with_shell(app_name, command, cwd, &shell) } fn build_wezterm_compatible_args_with_shell( app_name: &str, command: &str, cwd: Option<&str>, shell: &str, ) -> Vec { let full_command = build_shell_command(command, None); let mut args = vec![ "-na".to_string(), app_name.to_string(), "--args".to_string(), "start".to_string(), ]; if let Some(dir) = cwd { args.push("--cwd".to_string()); args.push(dir.to_string()); } // Invoke shell to run the command string (to handle pipes, etc) args.push("--".to_string()); args.push(shell.to_string()); args.push("-c".to_string()); args.push(full_command); args } fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> { // Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command let full_command = build_shell_command(command, None); let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); let mut args = vec!["-na", "Alacritty", "--args"]; if let Some(dir) = cwd { args.push("--working-directory"); args.push(dir); } args.push("-e"); args.push(&shell); args.push("-c"); args.push(&full_command); let status = Command::new("open") .args(&args) .status() .map_err(|e| format!("Failed to launch Alacritty: {e}"))?; if status.success() { Ok(()) } else { Err("Failed to launch Alacritty.".to_string()) } } fn launch_custom( command: &str, cwd: Option<&str>, custom_config: Option<&str>, ) -> Result<(), String> { let template = custom_config.ok_or("No custom terminal config provided")?; if template.trim().is_empty() { return Err("Custom terminal command template is empty".to_string()); } let cmd_str = command; let dir_str = cwd.unwrap_or("."); let final_cmd_line = template .replace("{command}", cmd_str) .replace("{cwd}", dir_str); // Execute via sh -c let status = Command::new("sh") .arg("-c") .arg(&final_cmd_line) .status() .map_err(|e| format!("Failed to execute custom terminal launcher: {e}"))?; if status.success() { Ok(()) } else { Err("Custom terminal execution returned error code".to_string()) } } fn build_shell_command(command: &str, cwd: Option<&str>) -> String { match cwd { Some(dir) if !dir.trim().is_empty() => { format!("cd {} && {}", shell_escape(dir), command) } _ => command.to_string(), } } fn shell_escape(value: &str) -> String { let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); format!("\"{escaped}\"") } fn escape_osascript(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } #[cfg(test)] mod tests { use super::*; #[test] fn build_shell_command_keeps_command_without_cwd_prefix_when_not_provided() { assert_eq!( build_shell_command("claude --resume abc-123", None), "claude --resume abc-123" ); } #[test] fn wezterm_compatible_terminals_use_start_and_cwd_arguments() { let args = build_wezterm_compatible_args_with_shell( "Kaku", "claude --resume abc-123", Some("/tmp/project dir"), "/bin/zsh", ); assert_eq!( args, vec![ "-na".to_string(), "Kaku".to_string(), "--args".to_string(), "start".to_string(), "--cwd".to_string(), "/tmp/project dir".to_string(), "--".to_string(), "/bin/zsh".to_string(), "-c".to_string(), "claude --resume abc-123".to_string(), ] ); } #[test] fn ghostty_uses_working_directory_arg_for_cwd() { // cwd should be passed as --working-directory, not embedded in the shell command string // This avoids shell expansion of special characters in directory paths let cwd = "/tmp/project dir"; let command = "claude --resume abc-123"; // Verify build_shell_command does NOT include cwd when used in ghostty context // (ghostty passes cwd via --working-directory flag instead) assert_eq!( build_shell_command(command, None), "claude --resume abc-123" ); // Verify shell_escape works correctly for paths with spaces assert_eq!(shell_escape(cwd), "\"/tmp/project dir\""); } }