From 9a095cc30b94d59ca5558188810eae2ffbd8dd10 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 30 Aug 2026 16:57:02 +0800 Subject: [PATCH 1/7] fix(gitignore): add .codex to ignored files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c5b9f6cf..2f9babfb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ cpa-core/ /portable-app.json agent_docs/ +.codex/ *.log *.tmp From 0f6559646147c5ca07b7a59d92869771b49057a1 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 30 Aug 2026 18:47:05 +0800 Subject: [PATCH 2/7] feat(settings): add configurable default terminal for CLI agents - Add default terminal selector under Advanced Settings > Software Settings - Detect available terminals across macOS, Windows, and Linux - Persist and migrate the default-terminal configuration - Apply the selected terminal when launching CLI agents - Add localized labels and regression tests Fixes #179 --- src-tauri/src/agents/launch.rs | 233 +++++++++++++++++++++++--- src-tauri/src/app_settings.rs | 5 + src-tauri/src/core_config/settings.rs | 9 + src-tauri/src/main.rs | 9 + src-tauri/src/tests/app_settings.rs | 2 + src-tauri/src/tests/core_config.rs | 1 + src-tauri/src/usage.rs | 1 + src/i18n/locales/en.ts | 3 + src/i18n/locales/zh-CN.ts | 3 + src/pages/ConfigPanel.tsx | 48 +++++- 10 files changed, 288 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index 8bdb87e9..b6f09577 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -1,5 +1,111 @@ use super::*; +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentTerminalOption { + pub(crate) id: String, + pub(crate) label: String, +} + +fn terminal_option(id: &str, label: &str) -> AgentTerminalOption { + AgentTerminalOption { + id: id.to_string(), + label: label.to_string(), + } +} + +fn program_on_path(program: &str) -> Option { + let path = env::var_os("PATH")?; + env::split_paths(&path).find_map(|directory| { + let candidate = directory.join(program); + candidate.is_file().then_some(candidate) + }) +} + +#[cfg(target_os = "macos")] +fn macos_iterm2_installed() -> bool { + Path::new("/Applications/iTerm.app").is_dir() + || env::var_os("HOME") + .map(PathBuf::from) + .is_some_and(|home| home.join("Applications/iTerm.app").is_dir()) +} + +#[cfg(target_os = "linux")] +fn linux_terminal_definitions() -> &'static [( + &'static str, + &'static str, + &'static [&'static str], + &'static str, +)] { + &[ + ( + "x-terminal-emulator", + "x-terminal-emulator", + &["-e"], + "System terminal", + ), + ( + "gnome-terminal", + "gnome-terminal", + &["--"], + "GNOME Terminal", + ), + ("konsole", "konsole", &["-e"], "Konsole"), + ("xfce4-terminal", "xfce4-terminal", &["-e"], "Xfce Terminal"), + ("mate-terminal", "mate-terminal", &["--"], "MATE Terminal"), + ("kitty", "kitty", &["-e"], "Kitty"), + ("alacritty", "alacritty", &["-e"], "Alacritty"), + ("ghostty", "ghostty", &["-e"], "Ghostty"), + ("xterm", "xterm", &["-e"], "XTerm"), + ] +} + +pub(crate) fn available_agent_terminals() -> Vec { + let mut options = vec![terminal_option("auto", "Automatic")]; + #[cfg(target_os = "macos")] + { + options.push(terminal_option("terminal", "Terminal")); + if macos_iterm2_installed() { + options.push(terminal_option("iterm2", "iTerm2")); + } + } + #[cfg(target_os = "windows")] + { + if program_on_path("wt.exe").is_some() { + options.push(terminal_option("windows-terminal", "Windows Terminal")); + } + let powershell = windows_powershell_executable(); + if powershell.is_file() { + options.push(terminal_option("powershell", "PowerShell")); + } + let command_prompt = windows_command_processor(); + if command_prompt.is_file() { + options.push(terminal_option("cmd", "Command Prompt")); + } + } + #[cfg(target_os = "linux")] + { + for (id, program, _, label) in linux_terminal_definitions() { + if program_on_path(program).is_some() { + options.push(terminal_option(id, label)); + } + } + } + options +} + +pub(crate) fn normalize_agent_terminal(value: &str) -> String { + let value = value.trim(); + if available_agent_terminals() + .iter() + .any(|option| option.id == value) + { + value.to_string() + } else { + DEFAULT_AGENT_TERMINAL.to_string() + } +} + #[tauri::command] pub(crate) fn launch_agent( app: tauri::AppHandle, @@ -13,6 +119,7 @@ pub(crate) fn launch_agent( .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; let config = gui_config_state.snapshot()?; + let terminal = normalize_agent_terminal(&config.default_terminal); let requested_target = target .as_deref() .map(str::trim) @@ -30,7 +137,13 @@ pub(crate) fn launch_agent( let executable = find_pi_executable(&home).ok_or_else(|| "未找到 Pi CLI 可执行文件".to_string())?; let launch_directory = resolve_launch_directory(working_directory.as_deref(), &home)?; - return launch_cli_agent(&executable, PI_AGENT_NAME, &launch_directory, &[]); + return launch_cli_agent( + &executable, + PI_AGENT_NAME, + &launch_directory, + &[], + &terminal, + ); } let client = AgentClient::parse(&client)?; @@ -73,6 +186,7 @@ pub(crate) fn launch_agent( client.name(), &launch_directory, environment_to_remove, + &terminal, ) } (_, "app") => Err(format!("{} 不支持桌面 App 启动方式", client.name())), @@ -602,6 +716,7 @@ fn launch_cli_agent( label: &str, working_directory: &Path, environment_to_remove: &[&str], + terminal: &str, ) -> Result<(), String> { let removals = environment_to_remove .iter() @@ -614,10 +729,19 @@ fn launch_cli_agent( removals, shell_single_quote(&path_to_string(executable)), ); - let script = format!( - "tell application \"Terminal\"\nactivate\ndo script \"{}\"\nend tell", - command_line.replace('\\', "\\\\").replace('"', "\\\"") - ); + let script = if terminal == "iterm2" { + format!( + "tell application \"iTerm2\"\nactivate\nset newWindow to (create window with default profile)\ntell current session of newWindow\nwrite text \"{}\"\nend tell\nend tell", + command_line.replace('\\', "\\\\").replace('"', "\\\"") + ) + } else if matches!(terminal, "auto" | "terminal") { + format!( + "tell application \"Terminal\"\nactivate\ndo script \"{}\"\nend tell", + command_line.replace('\\', "\\\\").replace('"', "\\\"") + ) + } else { + return Err(format!("启动 {label} 失败:不支持所选终端")); + }; let output = Command::new("osascript") .arg("-e") .arg(script) @@ -637,27 +761,21 @@ fn launch_cli_agent( label: &str, working_directory: &Path, environment_to_remove: &[&str], + terminal: &str, ) -> Result<(), String> { - let terminals: &[(&str, &[&str])] = &[ - ("x-terminal-emulator", &["-e"]), - ("gnome-terminal", &["--"]), - ("konsole", &["-e"]), - ("xfce4-terminal", &["-e"]), - ("mate-terminal", &["--"]), - ("kitty", &["-e"]), - ("alacritty", &["-e"]), - ("ghostty", &["-e"]), - ("xterm", &["-e"]), - ]; - let path = env::var_os("PATH").unwrap_or_default(); + let definitions = linux_terminal_definitions(); let mut last_error = None; - for (terminal, arguments) in terminals { - if !env::split_paths(&path).any(|directory| directory.join(terminal).is_file()) { + for definition in definitions { + let (id, program, arguments, _) = *definition; + if terminal != "auto" && id != terminal { + continue; + } + if program_on_path(program).is_none() { continue; } - let mut command = Command::new(terminal); + let mut command = Command::new(program); command - .args(*arguments) + .args(arguments) .arg(executable) .current_dir(working_directory) .stdin(Stdio::null()) @@ -671,6 +789,9 @@ fn launch_cli_agent( Err(error) => last_error = Some(error.to_string()), } } + if terminal != "auto" && last_error.is_none() { + return Err(format!("启动 {label} 失败:未找到所选终端")); + } Err(match last_error { Some(error) => format!("启动 {label} 失败: {error}"), None => format!("启动 {label} 失败:未找到可用的终端程序"), @@ -683,14 +804,66 @@ fn launch_cli_agent( label: &str, working_directory: &Path, environment_to_remove: &[&str], + terminal: &str, ) -> Result<(), String> { use std::os::windows::process::CommandExt; const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; - let mut command = windows_command_for_executable(executable, true); - command - .current_dir(working_directory) - .creation_flags(CREATE_NEW_CONSOLE); + let is_batch_script = executable + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat") + }); + + let mut command = match terminal { + "auto" => { + let mut command = windows_command_for_executable(executable, true); + command + .current_dir(working_directory) + .creation_flags(CREATE_NEW_CONSOLE); + command + } + "windows-terminal" => { + let terminal_executable = program_on_path("wt.exe") + .ok_or_else(|| format!("启动 {label} 失败:未找到 Windows Terminal"))?; + let directory = path_to_string(working_directory); + let mut command = Command::new(terminal_executable); + command.args(["-d", &directory, "--"]); + if is_batch_script { + command + .arg(windows_command_processor()) + .args(["/D", "/K", "call"]) + .arg(windows_batch_executable_argument(executable)); + } else { + command.arg(executable); + } + command + } + "powershell" => { + let mut command = Command::new(windows_powershell_executable()); + let script = format!( + "Set-Location -LiteralPath {}; & {}", + windows_powershell_single_quoted_literal(&path_to_string(working_directory)), + windows_powershell_single_quoted_literal(&path_to_string(executable)), + ); + command.args(["-NoLogo", "-NoProfile", "-NoExit", "-Command", &script]); + command.creation_flags(CREATE_NEW_CONSOLE); + command + } + "cmd" => { + let mut command = Command::new(windows_command_processor()); + let command_line = format!( + "cd /d \"{}\" && call \"{}\"", + path_to_string(working_directory).replace('"', "\"\""), + path_to_string(executable).replace('"', "\"\"") + ); + command.args(["/D", "/K", &command_line]); + command.creation_flags(CREATE_NEW_CONSOLE); + command + } + _ => return Err(format!("启动 {label} 失败:不支持所选终端")), + }; for key in environment_to_remove { command.env_remove(key); } @@ -706,6 +879,7 @@ fn launch_cli_agent( label: &str, _working_directory: &Path, _environment_to_remove: &[&str], + _terminal: &str, ) -> Result<(), String> { Err(format!("当前平台不支持启动 {label}")) } @@ -714,6 +888,15 @@ fn launch_cli_agent( mod tests { use super::*; + #[test] + fn unknown_terminal_defaults_to_automatic() { + assert_eq!( + normalize_agent_terminal("missing-terminal"), + DEFAULT_AGENT_TERMINAL + ); + assert_eq!(normalize_agent_terminal(" auto "), "auto"); + } + #[test] fn relative_launch_directory_is_rejected() { let error = resolve_launch_directory(Some("relative/project"), Path::new("/fallback")) diff --git a/src-tauri/src/app_settings.rs b/src-tauri/src/app_settings.rs index 54edabed..0ce9e36b 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -180,6 +180,8 @@ pub(crate) fn software_settings( autostart_enabled: app_autostart_enabled(app)?, start_core_on_launch: config.start_core_on_launch, silent_start_enabled: config.silent_start, + default_terminal: normalize_agent_terminal(&config.default_terminal), + available_terminals: available_agent_terminals(), }) } @@ -201,6 +203,7 @@ pub(crate) fn save_software_settings( let previous_config = gui_config_state.snapshot()?; let previous_autostart_enabled = app_autostart_enabled(&app)?; let autostart_changed = previous_autostart_enabled != settings.autostart_enabled; + let default_terminal = normalize_agent_terminal(&settings.default_terminal); if autostart_changed { set_app_autostart_enabled(&app, settings.autostart_enabled)?; @@ -209,6 +212,7 @@ pub(crate) fn save_software_settings( let config = if previous_config.close_behavior == settings.close_behavior && previous_config.start_core_on_launch == settings.start_core_on_launch && previous_config.silent_start == settings.silent_start_enabled + && previous_config.default_terminal == default_terminal { previous_config } else { @@ -216,6 +220,7 @@ pub(crate) fn save_software_settings( settings.close_behavior, settings.start_core_on_launch, settings.silent_start_enabled, + default_terminal.clone(), ) { Ok(config) => config, Err(error) => { diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 2fcf2ad6..52272b11 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -1237,6 +1237,9 @@ pub(crate) fn load_or_create_gui_config() -> Result { if presence.silent_start.is_none() { changed = true; } + if presence.default_terminal.is_none() { + changed = true; + } if presence.download_source.is_none() { config.download_source = if config.prefer_gitcode_downloads { VersionDownloadSource::Gitcode @@ -1493,6 +1496,11 @@ pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result, window_height: Option, auth_dir: String, @@ -868,6 +870,7 @@ impl Default for GuiConfigFile { start_core_on_launch: true, silent_start: false, close_behavior: WindowsCloseBehavior::Ask, + default_terminal: DEFAULT_AGENT_TERMINAL.to_string(), window_width: Some(DEFAULT_MAIN_WINDOW_WIDTH), window_height: Some(DEFAULT_MAIN_WINDOW_HEIGHT), auth_dir: DEFAULT_AUTH_DIR.to_string(), @@ -906,6 +909,7 @@ struct GuiConfigPresence { api_access_remarks: Option>, management_secret_key: Option, close_behavior: Option, + default_terminal: Option, start_core_on_launch: Option, silent_start: Option, usage_statistics_enabled: Option, @@ -949,6 +953,8 @@ struct SoftwareSettings { autostart_enabled: bool, start_core_on_launch: bool, silent_start_enabled: bool, + default_terminal: String, + available_terminals: Vec, } #[derive(Deserialize)] @@ -958,6 +964,7 @@ struct SoftwareSettingsInput { autostart_enabled: bool, start_core_on_launch: bool, silent_start_enabled: bool, + default_terminal: String, } #[derive(Clone, Serialize)] @@ -1911,11 +1918,13 @@ impl GuiConfigState { close_behavior: WindowsCloseBehavior, start_core_on_launch: bool, silent_start: bool, + default_terminal: String, ) -> Result { self.update(|config| { config.close_behavior = close_behavior; config.start_core_on_launch = start_core_on_launch; config.silent_start = silent_start; + config.default_terminal = normalize_agent_terminal(&default_terminal); Ok(()) }) } diff --git a/src-tauri/src/tests/app_settings.rs b/src-tauri/src/tests/app_settings.rs index 745b8cd4..6adba21e 100644 --- a/src-tauri/src/tests/app_settings.rs +++ b/src-tauri/src/tests/app_settings.rs @@ -84,6 +84,8 @@ fn gui_config_defaults_are_stable() { assert!(content.contains("start-core-on-launch = true")); assert!(content.contains("silent-start = false")); assert!(content.contains("close-behavior = \"ask\"")); + assert!(content.contains("default-terminal = \"auto\"")); + assert_eq!(config.default_terminal, DEFAULT_AGENT_TERMINAL); assert!(content.contains("window-width = 1280")); assert!(content.contains("window-height = 800")); assert!(content.contains("auth-dir = \"../oauth\"")); diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index b8e98f70..cefc7aef 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -1023,6 +1023,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { start_core_on_launch: true, silent_start: false, close_behavior: WindowsCloseBehavior::Ask, + default_terminal: DEFAULT_AGENT_TERMINAL.to_string(), window_width: None, window_height: None, auth_dir: path_to_string(&fixed_oauth_dir().unwrap()), diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 40e43799..0dc1a5d1 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -3763,6 +3763,7 @@ mod tests { start_core_on_launch: true, silent_start: false, close_behavior: crate::WindowsCloseBehavior::Ask, + default_terminal: crate::DEFAULT_AGENT_TERMINAL.to_string(), window_width: None, window_height: None, auth_dir: String::new(), diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a6c0ba1a..02f102ae 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -372,6 +372,9 @@ export const en: Record = { 'config.software.startCoreOnLaunchDescription': 'Automatically start the CPA core whenever EasyCLIProxyAPI launches', 'config.software.silentStart': 'Silent Launch', 'config.software.silentStartDescription': 'Start without showing the main window and run directly in the system tray', + 'config.software.defaultTerminal': 'Default terminal', + 'config.software.defaultTerminalDescription': 'Terminal program used when launching CLI agents', + 'config.software.terminal.auto': 'Automatic', 'config.software.closeBehavior': 'Close behavior', 'config.software.closeBehaviorDescription': 'Choose whether closing the window exits the app or keeps it running in the system tray', 'config.software.behavior.ask': 'Ask every time', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 12e6e90a..cdc97b93 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -370,6 +370,9 @@ export const zhCN = { 'config.software.startCoreOnLaunchDescription': '每次启动 EasyCLIProxyAPI 时自动启动 CPA 内核', 'config.software.silentStart': '静默启动', 'config.software.silentStartDescription': '启动应用时不显示主窗口,直接在系统托盘运行', + 'config.software.defaultTerminal': '默认终端', + 'config.software.defaultTerminalDescription': '启动 CLI 智能体时使用的终端程序', + 'config.software.terminal.auto': '自动选择', 'config.software.closeBehavior': '关闭行为', 'config.software.closeBehaviorDescription': '决定关闭窗口时是退出程序还是继续在系统托盘运行', 'config.software.behavior.ask': '每次询问', diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index cf258df0..2dd5dad6 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -22,6 +22,7 @@ import { ShieldCheck, Sparkles, Power, + Terminal, Trash2, X, } from 'lucide-react'; @@ -79,11 +80,18 @@ type DraftRefreshMode = 'replace' | 'preserve'; type NetworkDraftDirty = Record; +type AgentTerminalOption = { + id: string; + label: string; +}; + type SoftwareSettings = { closeBehavior: CloseBehavior; autostartEnabled: boolean; startCoreOnLaunch: boolean; silentStartEnabled: boolean; + defaultTerminal: string; + availableTerminals: AgentTerminalOption[]; }; type CoreTlsSettings = { @@ -119,6 +127,7 @@ export function ConfigPanelPage() { const [softwareAutostartDraft, setSoftwareAutostartDraft] = useState(false); const [softwareStartCoreDraft, setSoftwareStartCoreDraft] = useState(true); const [softwareSilentStartDraft, setSoftwareSilentStartDraft] = useState(false); + const [softwareDefaultTerminalDraft, setSoftwareDefaultTerminalDraft] = useState('auto'); const [softwareSavedStatusVisible, setSoftwareSavedStatusVisible] = useState(false); const [tlsSettings, setTlsSettings] = useState(null); const [tlsSettingsLoading, setTlsSettingsLoading] = useState(true); @@ -264,6 +273,7 @@ export function ConfigPanelPage() { setSoftwareAutostartDraft(result.autostartEnabled); setSoftwareStartCoreDraft(result.startCoreOnLaunch); setSoftwareSilentStartDraft(result.silentStartEnabled); + setSoftwareDefaultTerminalDraft(result.defaultTerminal); } catch (error) { setSoftwareSettings(null); showNotice(t('config.error.saveFailed', { error: String(error) }), 'error'); @@ -690,6 +700,7 @@ export function ConfigPanelPage() { && softwareAutostartDraft === softwareSettings.autostartEnabled && softwareStartCoreDraft === softwareSettings.startCoreOnLaunch && softwareSilentStartDraft === softwareSettings.silentStartEnabled + && softwareDefaultTerminalDraft === softwareSettings.defaultTerminal ) return; setBusyAction('software'); @@ -700,6 +711,7 @@ export function ConfigPanelPage() { autostartEnabled: softwareAutostartDraft, startCoreOnLaunch: softwareStartCoreDraft, silentStartEnabled: softwareSilentStartDraft, + defaultTerminal: softwareDefaultTerminalDraft, }, }); setSoftwareSettings(result); @@ -707,6 +719,7 @@ export function ConfigPanelPage() { setSoftwareAutostartDraft(result.autostartEnabled); setSoftwareStartCoreDraft(result.startCoreOnLaunch); setSoftwareSilentStartDraft(result.silentStartEnabled); + setSoftwareDefaultTerminalDraft(result.defaultTerminal); setSoftwareSavedStatusVisible(true); showNotice(t('config.notice.softwareUpdated'), 'success'); } catch (error) { @@ -714,6 +727,7 @@ export function ConfigPanelPage() { setSoftwareAutostartDraft(softwareSettings.autostartEnabled); setSoftwareStartCoreDraft(softwareSettings.startCoreOnLaunch); setSoftwareSilentStartDraft(softwareSettings.silentStartEnabled); + setSoftwareDefaultTerminalDraft(softwareSettings.defaultTerminal); setSoftwareSavedStatusVisible(false); showNotice(t('config.error.saveFailed', { error: String(error) }), 'error'); void loadSoftwareSettings(); @@ -746,10 +760,13 @@ export function ConfigPanelPage() { && softwareSilentStartDraft !== softwareSettings.silentStartEnabled; const softwareStartCoreDirty = softwareSettings !== null && softwareStartCoreDraft !== softwareSettings.startCoreOnLaunch; + const softwareDefaultTerminalDirty = softwareSettings !== null + && softwareDefaultTerminalDraft !== softwareSettings.defaultTerminal; const softwareSettingsDirty = softwareCloseBehaviorDirty || softwareAutostartDirty || softwareStartCoreDirty - || softwareSilentStartDirty; + || softwareSilentStartDirty + || softwareDefaultTerminalDirty; const softwareStatusLabel = softwareSettingsLoading ? t('common.loading') : softwareSettings === null @@ -1660,6 +1677,35 @@ export function ConfigPanelPage() { +
+
+ +
+ {t('config.software.defaultTerminal')} + {t('config.software.defaultTerminalDescription')} +
+
+ +