feat(terminal): add Otty support with tab-aware launching (#6620)

This commit is contained in:
yovinchen
2026-08-26 15:55:13 +08:00
committed by GitHub
parent c2ec78dd47
commit bd15ea1193
15 changed files with 252 additions and 8 deletions
@@ -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 |
@@ -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
@@ -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 |
@@ -83,7 +83,7 @@
- ターミナルはセッションのプロジェクトディレクトリで開きます
- ターミナルの起動に失敗した場合、コマンドがクリップボードにコピーされます
**対応ターミナル(macOS):** Terminal.app、iTerm2、Ghostty、Kitty、WezTerm、Alacritty、Warp
**対応ターミナル(macOS):** Terminal.app、iTerm2、Ghostty、Otty、Kitty、WezTerm、Kaku、Alacritty、Warp
**その他のプラットフォーム:**
- 再開コマンドがクリップボードにコピーされます
@@ -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 |
@@ -83,7 +83,7 @@
- 终端会在会话的项目目录中打开
- 如果终端启动失败,命令会被复制到剪贴板
**支持的终端(macOS**Terminal.app、iTerm2、Ghostty、Kitty、WezTerm、Alacritty、Warp
**支持的终端(macOS**Terminal.app、iTerm2、Ghostty、Otty、Kitty、WezTerm、Kaku、Alacritty、Warp
**其他平台**
- 恢复命令会被复制到剪贴板
+94
View File
@@ -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<std::path::PathBuf> {
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<std::path::PathBuf> {
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]
@@ -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<String> {
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<String> {
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<std::path::PathBuf> {
otty_cli_candidates()
.into_iter()
.find(|path| path.is_file() && is_executable_file(path))
}
#[cfg(target_os = "macos")]
fn otty_cli_candidates() -> Vec<std::path::PathBuf> {
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(
+1 -1
View File
@@ -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")]
@@ -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" },
+1
View File
@@ -751,6 +751,7 @@
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty",
"otty": "Otty",
"wezterm": "WezTerm",
"kaku": "Kaku",
"warp": "Warp"
+1
View File
@@ -751,6 +751,7 @@
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty",
"otty": "Otty",
"wezterm": "WezTerm",
"kaku": "Kaku",
"warp": "Warp"
+1
View File
@@ -751,6 +751,7 @@
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty",
"otty": "Otty",
"wezterm": "WezTerm",
"kaku": "Kaku",
"warp": "Warp"
+1
View File
@@ -751,6 +751,7 @@
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty",
"otty": "Otty",
"wezterm": "WezTerm",
"kaku": "Kaku",
"warp": "Warp"
+1 -1
View File
@@ -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;