mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 16:56:16 +08:00
e4b58c7206
Kaku is a WezTerm-derived macOS terminal, so reusing the existing WezTerm-compatible launch path keeps the change small while making it selectable in settings and session resume flows. Constraint: Kaku support should stay macOS-only and avoid introducing a separate launcher model Rejected: Treat Kaku as a silent WezTerm fallback | users could not explicitly choose it in settings Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Kaku on the shared WezTerm-compatible launch path unless upstream drops the start-compatible CLI Tested: pnpm typecheck; pnpm format:check; cargo check --manifest-path src-tauri/Cargo.toml; cargo fmt --manifest-path src-tauri/Cargo.toml --check; cargo test --manifest-path src-tauri/Cargo.toml --lib session_manager::terminal::tests Not-tested: End-to-end launch against a locally installed Kaku.app Related: #1954
369 lines
10 KiB
Rust
369 lines
10 KiB
Rust
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 args = build_ghostty_args(command, cwd);
|
|
|
|
let status = Command::new("open")
|
|
.args(args.iter().map(String::as_str))
|
|
.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 build_ghostty_args(command: &str, cwd: Option<&str>) -> Vec<String> {
|
|
let input = ghostty_raw_input(command);
|
|
|
|
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(format!("--input={input}"));
|
|
args
|
|
}
|
|
|
|
fn ghostty_raw_input(command: &str) -> String {
|
|
let mut escaped = String::from("raw:");
|
|
for ch in command.chars() {
|
|
match ch {
|
|
'\\' => escaped.push_str("\\\\"),
|
|
'\n' => escaped.push_str("\\n"),
|
|
'\r' => escaped.push_str("\\r"),
|
|
_ => escaped.push(ch),
|
|
}
|
|
}
|
|
escaped.push_str("\\n");
|
|
escaped
|
|
}
|
|
|
|
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<String> {
|
|
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<String> {
|
|
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 ghostty_uses_shell_mode_for_resume_commands() {
|
|
let args = build_ghostty_args("claude --resume abc-123", Some("/tmp/project dir"));
|
|
|
|
assert_eq!(
|
|
args,
|
|
vec![
|
|
"-na",
|
|
"Ghostty",
|
|
"--args",
|
|
"--quit-after-last-window-closed=true",
|
|
"--working-directory=/tmp/project dir",
|
|
"--input=raw:claude --resume abc-123\\n",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ghostty_keeps_command_without_cwd_prefix_when_not_provided() {
|
|
let args = build_ghostty_args("claude --resume abc-123", None);
|
|
|
|
assert_eq!(
|
|
args,
|
|
vec![
|
|
"-na",
|
|
"Ghostty",
|
|
"--args",
|
|
"--quit-after-last-window-closed=true",
|
|
"--input=raw:claude --resume abc-123\\n",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ghostty_escapes_newlines_and_backslashes_in_input() {
|
|
assert_eq!(
|
|
ghostty_raw_input("echo foo\\\\bar\npwd"),
|
|
"raw:echo foo\\\\\\\\bar\\npwd\\n"
|
|
);
|
|
}
|
|
|
|
#[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(),
|
|
]
|
|
);
|
|
}
|
|
}
|