From bd15ea11936cd6b322f23c6a6e1706146ebf5bfb Mon Sep 17 00:00:00 2001 From: yovinchen Date: Wed, 26 Aug 2026 15:55:13 +0800 Subject: [PATCH] feat(terminal): add Otty support with tab-aware launching (#6620) --- .../en/1-getting-started/1.5-settings.md | 2 +- .../en/3-extensions/3.4-sessions.md | 2 +- .../ja/1-getting-started/1.5-settings.md | 2 +- .../ja/3-extensions/3.4-sessions.md | 2 +- .../zh/1-getting-started/1.5-settings.md | 2 +- .../zh/3-extensions/3.4-sessions.md | 2 +- src-tauri/src/commands/misc.rs | 94 ++++++++++++ src-tauri/src/session_manager/terminal/mod.rs | 145 ++++++++++++++++++ src-tauri/src/settings.rs | 2 +- src/components/settings/TerminalSettings.tsx | 1 + src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh-TW.json | 1 + src/i18n/locales/zh.json | 1 + src/types.ts | 2 +- 15 files changed, 252 insertions(+), 8 deletions(-) diff --git a/docs/user-manual/en/1-getting-started/1.5-settings.md b/docs/user-manual/en/1-getting-started/1.5-settings.md index a59af793f..72460fa15 100644 --- a/docs/user-manual/en/1-getting-started/1.5-settings.md +++ b/docs/user-manual/en/1-getting-started/1.5-settings.md @@ -106,7 +106,7 @@ Supported terminals (by platform): | Platform | Terminal Options | |----------|-----------------| -| macOS | Terminal, iTerm2, Alacritty, Kitty, Ghostty, WezTerm | +| macOS | Terminal, iTerm2, Alacritty, Kitty, Ghostty, Otty, WezTerm, Kaku, Warp | | Windows | CMD, PowerShell, Windows Terminal | | Linux | GNOME Terminal, Konsole, Xfce4 Terminal, Alacritty, Kitty, Ghostty | diff --git a/docs/user-manual/en/3-extensions/3.4-sessions.md b/docs/user-manual/en/3-extensions/3.4-sessions.md index b7f03abf4..920dd6cd3 100644 --- a/docs/user-manual/en/3-extensions/3.4-sessions.md +++ b/docs/user-manual/en/3-extensions/3.4-sessions.md @@ -83,7 +83,7 @@ Click the **Resume** button (play icon) on a selected session to continue the co - The terminal opens in the session's project directory - If terminal launch fails, the command is copied to your clipboard instead -**Supported terminals (macOS):** Terminal.app, iTerm2, Ghostty, Kitty, WezTerm, Alacritty, Warp +**Supported terminals (macOS):** Terminal.app, iTerm2, Ghostty, Otty, Kitty, WezTerm, Kaku, Alacritty, Warp **On other platforms:** - The resume command is copied to your clipboard diff --git a/docs/user-manual/ja/1-getting-started/1.5-settings.md b/docs/user-manual/ja/1-getting-started/1.5-settings.md index 46106bc04..b733dd3df 100644 --- a/docs/user-manual/ja/1-getting-started/1.5-settings.md +++ b/docs/user-manual/ja/1-getting-started/1.5-settings.md @@ -106,7 +106,7 @@ CC Switch がターミナルを開く際に使用するターミナルアプリ | プラットフォーム | ターミナル選択肢 | | ------- | ------------------------------------------------------------------ | -| macOS | Terminal、iTerm2、Alacritty、Kitty、Ghostty、WezTerm | +| macOS | Terminal、iTerm2、Alacritty、Kitty、Ghostty、Otty、WezTerm、Kaku、Warp | | Windows | CMD、PowerShell、Windows Terminal | | Linux | GNOME Terminal、Konsole、Xfce4 Terminal、Alacritty、Kitty、Ghostty | diff --git a/docs/user-manual/ja/3-extensions/3.4-sessions.md b/docs/user-manual/ja/3-extensions/3.4-sessions.md index 2e1b4e80a..678d37040 100644 --- a/docs/user-manual/ja/3-extensions/3.4-sessions.md +++ b/docs/user-manual/ja/3-extensions/3.4-sessions.md @@ -83,7 +83,7 @@ - ターミナルはセッションのプロジェクトディレクトリで開きます - ターミナルの起動に失敗した場合、コマンドがクリップボードにコピーされます -**対応ターミナル(macOS):** Terminal.app、iTerm2、Ghostty、Kitty、WezTerm、Alacritty、Warp +**対応ターミナル(macOS):** Terminal.app、iTerm2、Ghostty、Otty、Kitty、WezTerm、Kaku、Alacritty、Warp **その他のプラットフォーム:** - 再開コマンドがクリップボードにコピーされます diff --git a/docs/user-manual/zh/1-getting-started/1.5-settings.md b/docs/user-manual/zh/1-getting-started/1.5-settings.md index db66fe1a9..d8e20bcce 100644 --- a/docs/user-manual/zh/1-getting-started/1.5-settings.md +++ b/docs/user-manual/zh/1-getting-started/1.5-settings.md @@ -106,7 +106,7 @@ v3.13.0 起新增「轻量模式」——一种**仅托盘运行**的状态, | 平台 | 终端选项 | | ------- | ------------------------------------------------------------------ | -| macOS | Terminal、iTerm2、Alacritty、Kitty、Ghostty、WezTerm | +| macOS | Terminal、iTerm2、Alacritty、Kitty、Ghostty、Otty、WezTerm、Kaku、Warp | | Windows | CMD、PowerShell、Windows Terminal | | Linux | GNOME Terminal、Konsole、Xfce4 Terminal、Alacritty、Kitty、Ghostty | diff --git a/docs/user-manual/zh/3-extensions/3.4-sessions.md b/docs/user-manual/zh/3-extensions/3.4-sessions.md index 35c1d2d57..ea3557b64 100644 --- a/docs/user-manual/zh/3-extensions/3.4-sessions.md +++ b/docs/user-manual/zh/3-extensions/3.4-sessions.md @@ -83,7 +83,7 @@ - 终端会在会话的项目目录中打开 - 如果终端启动失败,命令会被复制到剪贴板 -**支持的终端(macOS)**:Terminal.app、iTerm2、Ghostty、Kitty、WezTerm、Alacritty、Warp +**支持的终端(macOS)**:Terminal.app、iTerm2、Ghostty、Otty、Kitty、WezTerm、Kaku、Alacritty、Warp **其他平台**: - 恢复命令会被复制到剪贴板 diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 6b22ce3c5..5c5ea4219 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -3899,6 +3899,7 @@ echo "{config_path}" "alacritty" => launch_macos_open_app("Alacritty", &script_file, true), "kitty" => launch_macos_open_app("kitty", &script_file, false), "ghostty" => launch_macos_ghostty(&script_file), + "otty" => launch_macos_otty(&script_file), "wezterm" => launch_macos_open_app("WezTerm", &script_file, true), "kaku" => launch_macos_open_app("Kaku", &script_file, true), _ => launch_macos_terminal_app(&script_file), @@ -3995,6 +3996,77 @@ fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String ) } +#[cfg(target_os = "macos")] +fn launch_macos_otty(script_file: &std::path::Path) -> Result<(), String> { + use std::process::Command; + + let otty_cli = find_macos_otty_cli().ok_or_else(|| { + "未找到 Otty CLI。请将 Otty 安装到 /Applications 或 ~/Applications。".to_string() + })?; + + let command = build_macos_dash_c_command(script_file); + let tab_result = Command::new(&otty_cli) + .args(["tab", "new", "--window", "0", "--command", &command]) + .output() + .map_err(|e| format!("启动 Otty CLI 失败: {e}"))?; + + if tab_result.status.success() { + return Ok(()); + } + + log::debug!( + "Otty 新建 Tab 失败,改为新建窗口: {}", + decode_command_output(&tab_result.stderr) + ); + + let window_result = Command::new(&otty_cli) + .args(["open", "--command", &command]) + .output() + .map_err(|e| format!("启动 Otty CLI 失败: {e}"))?; + + if window_result.status.success() { + Ok(()) + } else { + Err(format!( + "Otty 新建窗口失败 (exit code: {:?}): {}", + window_result.status.code(), + decode_command_output(&window_result.stderr) + )) + } +} + +#[cfg(target_os = "macos")] +fn find_macos_otty_cli() -> Option { + macos_otty_cli_candidates() + .into_iter() + .find(|path| path.is_file() && is_executable_file(path)) +} + +#[cfg(target_os = "macos")] +fn macos_otty_cli_candidates() -> Vec { + let mut candidates = vec![std::path::PathBuf::from( + "/Applications/Otty.app/Contents/MacOS/otty-cli", + )]; + + if let Some(home) = std::env::var_os("HOME") { + candidates.push( + std::path::PathBuf::from(home).join("Applications/Otty.app/Contents/MacOS/otty-cli"), + ); + } + + candidates.push(std::path::PathBuf::from("/usr/local/bin/otty")); + candidates.push(std::path::PathBuf::from("/opt/homebrew/bin/otty")); + + if let Some(path) = std::env::var_os("PATH") { + for directory in std::env::split_paths(&path) { + candidates.push(directory.join("otty")); + candidates.push(directory.join("otty-cli")); + } + } + + candidates +} + /// macOS: iTerm2 #[cfg(target_os = "macos")] fn build_macos_iterm2_applescript(script_file: &std::path::Path) -> String { @@ -4459,6 +4531,7 @@ read -r _ "alacritty" => launch_macos_open_app("Alacritty", &script_file, true), "kitty" => launch_macos_open_app("kitty", &script_file, false), "ghostty" => launch_macos_ghostty(&script_file), + "otty" => launch_macos_otty(&script_file), "wezterm" => launch_macos_open_app("WezTerm", &script_file, true), "kaku" => launch_macos_open_app("Kaku", &script_file, true), _ => launch_macos_terminal_app(&script_file), @@ -6830,6 +6903,27 @@ mod tests { ); } + #[cfg(target_os = "macos")] + #[test] + fn otty_launcher_command_executes_the_temporary_script() { + assert_eq!( + build_macos_dash_c_command(Path::new("/tmp/cc_switch_launcher.sh")), + "exec sh '/tmp/cc_switch_launcher.sh'" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn otty_cli_candidates_include_bundle_and_installed_cli_locations() { + let candidates = macos_otty_cli_candidates(); + + assert!(candidates.contains(&PathBuf::from( + "/Applications/Otty.app/Contents/MacOS/otty-cli" + ))); + assert!(candidates.contains(&PathBuf::from("/usr/local/bin/otty"))); + assert!(candidates.contains(&PathBuf::from("/opt/homebrew/bin/otty"))); + } + /// Restored windows should not receive the launcher command. #[cfg(target_os = "macos")] #[test] diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs index aaacb1a48..4abffe997 100644 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -19,6 +19,8 @@ pub fn launch_terminal( "iTerm" | "iterm" => launch_iterm(command, cwd), "ghostty" => launch_ghostty(command, cwd), "kitty" => launch_kitty(command, cwd), + #[cfg(target_os = "macos")] + "otty" => launch_otty(command, cwd), "wezterm" => launch_wezterm(command, cwd), "kaku" => launch_kaku(command, cwd), "alacritty" => launch_alacritty(command, cwd), @@ -52,6 +54,105 @@ end tell"# } } +#[cfg(target_os = "macos")] +fn build_otty_tab_args(command: &str, cwd: Option<&str>) -> Vec { + let mut args = vec![ + "tab".to_string(), + "new".to_string(), + "--window".to_string(), + "0".to_string(), + ]; + if let Some(cwd) = cwd.filter(|value| !value.trim().is_empty()) { + args.push("--cwd".to_string()); + args.push(cwd.to_string()); + } + args.push("--command".to_string()); + args.push(command.to_string()); + args +} + +#[cfg(target_os = "macos")] +fn build_otty_window_args(command: &str, cwd: Option<&str>) -> Vec { + let mut args = vec![ + "open".to_string(), + "--command".to_string(), + command.to_string(), + ]; + if let Some(cwd) = cwd.filter(|value| !value.trim().is_empty()) { + args.push(cwd.to_string()); + } + args +} + +#[cfg(target_os = "macos")] +fn launch_otty(command: &str, cwd: Option<&str>) -> Result<(), String> { + let otty_cli = find_otty_cli().ok_or_else(|| { + "Otty CLI not found. Install Otty to /Applications or ~/Applications.".to_string() + })?; + + let tab_result = Command::new(&otty_cli) + .args(build_otty_tab_args(command, cwd)) + .output() + .map_err(|e| format!("Failed to launch Otty CLI: {e}"))?; + if tab_result.status.success() { + return Ok(()); + } + + let window_result = Command::new(&otty_cli) + .args(build_otty_window_args(command, cwd)) + .output() + .map_err(|e| format!("Failed to launch Otty CLI: {e}"))?; + if window_result.status.success() { + return Ok(()); + } + + Err(format!( + "Failed to launch Otty: {}", + String::from_utf8_lossy(&window_result.stderr).trim() + )) +} + +#[cfg(target_os = "macos")] +fn find_otty_cli() -> Option { + otty_cli_candidates() + .into_iter() + .find(|path| path.is_file() && is_executable_file(path)) +} + +#[cfg(target_os = "macos")] +fn otty_cli_candidates() -> Vec { + let mut candidates = vec![std::path::PathBuf::from( + "/Applications/Otty.app/Contents/MacOS/otty-cli", + )]; + + if let Some(home) = std::env::var_os("HOME") { + candidates.push( + std::path::PathBuf::from(home).join("Applications/Otty.app/Contents/MacOS/otty-cli"), + ); + } + + candidates.push(std::path::PathBuf::from("/usr/local/bin/otty")); + candidates.push(std::path::PathBuf::from("/opt/homebrew/bin/otty")); + + if let Some(path) = std::env::var_os("PATH") { + for directory in std::env::split_paths(&path) { + candidates.push(directory.join("otty")); + candidates.push(directory.join("otty-cli")); + } + } + + candidates +} + +#[cfg(target_os = "macos")] +fn is_executable_file(path: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + path.metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> { let full_command = build_shell_command(command, cwd); let escaped = escape_osascript(&full_command); @@ -353,6 +454,50 @@ mod tests { ); } + #[cfg(target_os = "macos")] + #[test] + fn otty_launches_a_new_tab_without_injecting_into_the_current_session() { + assert_eq!( + build_otty_tab_args("claude --resume abc-123", Some("/tmp/project-$(id -un)")), + vec![ + "tab", + "new", + "--window", + "0", + "--cwd", + "/tmp/project-$(id -un)", + "--command", + "claude --resume abc-123", + ] + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn otty_falls_back_to_a_new_window_with_the_same_command_and_cwd() { + assert_eq!( + build_otty_window_args("claude --resume abc-123", Some("/tmp/project dir")), + vec![ + "open", + "--command", + "claude --resume abc-123", + "/tmp/project dir", + ] + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn otty_cli_candidates_include_bundle_and_installed_cli_locations() { + let candidates = otty_cli_candidates(); + + assert!(candidates.contains(&std::path::PathBuf::from( + "/Applications/Otty.app/Contents/MacOS/otty-cli" + ))); + assert!(candidates.contains(&std::path::PathBuf::from("/usr/local/bin/otty"))); + assert!(candidates.contains(&std::path::PathBuf::from("/opt/homebrew/bin/otty"))); + } + #[test] fn wezterm_compatible_terminals_use_start_and_cwd_arguments() { let args = build_wezterm_compatible_args_with_shell( diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index bbf6b5766..e24e6630f 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -485,7 +485,7 @@ pub struct AppSettings { // ===== 终端设置 ===== /// 首选终端应用(可选,默认使用系统默认终端) - /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "kaku" + /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "otty" | "wezterm" | "kaku" /// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal) /// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/components/settings/TerminalSettings.tsx b/src/components/settings/TerminalSettings.tsx index 916b5fbea..2a364331e 100644 --- a/src/components/settings/TerminalSettings.tsx +++ b/src/components/settings/TerminalSettings.tsx @@ -15,6 +15,7 @@ const MACOS_TERMINALS = [ { value: "alacritty", labelKey: "settings.terminal.options.macos.alacritty" }, { value: "kitty", labelKey: "settings.terminal.options.macos.kitty" }, { value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" }, + { value: "otty", labelKey: "settings.terminal.options.macos.otty" }, { value: "wezterm", labelKey: "settings.terminal.options.macos.wezterm" }, { value: "kaku", labelKey: "settings.terminal.options.macos.kaku" }, { value: "warp", labelKey: "settings.terminal.options.macos.warp" }, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0c1de7603..0b3c00387 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -751,6 +751,7 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", + "otty": "Otty", "wezterm": "WezTerm", "kaku": "Kaku", "warp": "Warp" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a12c388c5..c4443edf2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -751,6 +751,7 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", + "otty": "Otty", "wezterm": "WezTerm", "kaku": "Kaku", "warp": "Warp" diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 8929c8919..64457f3b3 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -751,6 +751,7 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", + "otty": "Otty", "wezterm": "WezTerm", "kaku": "Kaku", "warp": "Warp" diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index bb2f8d5b6..68ae5c70f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -751,6 +751,7 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", + "otty": "Otty", "wezterm": "WezTerm", "kaku": "Kaku", "warp": "Warp" diff --git a/src/types.ts b/src/types.ts index a04af690f..12a15a603 100644 --- a/src/types.ts +++ b/src/types.ts @@ -450,7 +450,7 @@ export interface Settings { // ===== 终端设置 ===== // 首选终端应用(可选,默认使用系统默认终端) - // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "kaku" + // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "otty" | "wezterm" | "kaku" // Windows: "cmd" | "powershell" | "wt" // Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" preferredTerminal?: string;