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
This commit is contained in:
lzt404 committed 2026-08-30 18:48:01 +08:00
1 parent 270ad2869c
commit 79b50b7a2b
10 files changed
+288 -26

No files matched your search

+208 -25
View File
@@ -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<PathBuf> {
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<AgentTerminalOption> {
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;
}
let mut command = Command::new(terminal);
if program_on_path(program).is_none() {
continue;
}
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"))
+5
View File
@@ -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) => {
+9
View File
@@ -1237,6 +1237,9 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
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<bool, St
config.locale = normalized_locale.to_string();
changed = true;
}
let default_terminal = normalize_agent_terminal(&config.default_terminal);
if config.default_terminal != default_terminal {
config.default_terminal = default_terminal;
changed = true;
}
let host = config.host.trim();
let host = if host.is_empty() {
if config.allow_lan {
@@ -1642,6 +1650,7 @@ pub(crate) fn write_gui_config_to_path(
("start-core-on-launch", value(config.start_core_on_launch)),
("silent-start", value(config.silent_start)),
("close-behavior", value(config.close_behavior.as_str())),
("default-terminal", value(config.default_terminal.as_str())),
("auth-dir", value(config.auth_dir.as_str())),
(
"management-secret-key",
+9
View File
@@ -131,6 +131,7 @@ const LEGACY_DEFAULT_MAIN_WINDOW_HEIGHT: u32 = 891;
const OAUTH_DIR_NAME: &str = "oauth";
const DEFAULT_AUTH_DIR: &str = "../oauth";
const DEFAULT_API_KEY: &str = "123456";
const DEFAULT_AGENT_TERMINAL: &str = "auto";
const DEFAULT_API_KEY_INITIAL_REMARK: &str = "默认密钥";
const DEFAULT_REQUEST_RETRY: u32 = 3;
const DEFAULT_MAX_RETRY_CREDENTIALS: u32 = 0;
@@ -584,6 +585,7 @@ struct GuiConfigFile {
start_core_on_launch: bool,
silent_start: bool,
close_behavior: WindowsCloseBehavior,
default_terminal: String,
window_width: Option<u32>,
window_height: Option<u32>,
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<Vec<GuiApiAccessRemark>>,
management_secret_key: Option<String>,
close_behavior: Option<WindowsCloseBehavior>,
default_terminal: Option<String>,
start_core_on_launch: Option<bool>,
silent_start: Option<bool>,
usage_statistics_enabled: Option<bool>,
@@ -949,6 +953,8 @@ struct SoftwareSettings {
autostart_enabled: bool,
start_core_on_launch: bool,
silent_start_enabled: bool,
default_terminal: String,
available_terminals: Vec<AgentTerminalOption>,
}
#[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<GuiConfigFile, String> {
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(())
})
}
+2
View File
@@ -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\""));
+1
View File
@@ -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()),
+1
View File
@@ -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(),
+3
View File
@@ -372,6 +372,9 @@ export const en: Record<MessageKey, string> = {
'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',
+3
View File
@@ -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': '每次询问',
+47 -1
View File
@@ -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<NetworkDraftField, boolean>;
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<CoreTlsSettings | null>(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() {
<span className="switch-track" />
</label>
</div>
<div className="config-software-setting-row config-software-close-row">
<div className="config-software-setting-copy">
<span className="config-software-setting-icon" aria-hidden="true">
<Terminal size={18} />
</span>
<div>
<strong>{t('config.software.defaultTerminal')}</strong>
<small>{t('config.software.defaultTerminalDescription')}</small>
</div>
</div>
<label className="config-software-select">
<span className="sr-only">{t('config.software.defaultTerminal')}</span>
<select
className="config-network-input"
value={softwareDefaultTerminalDraft}
disabled={softwareSettingsLoading || softwareSettings === null || busyAction !== null}
onChange={(event) => {
setSoftwareSavedStatusVisible(false);
setSoftwareDefaultTerminalDraft(event.currentTarget.value);
}}
>
{(softwareSettings?.availableTerminals ?? []).map((terminal) => (
<option value={terminal.id} key={terminal.id}>
{terminal.id === 'auto' ? t('config.software.terminal.auto') : terminal.label}
</option>
))}
</select>
</label>
</div>
<div className="config-software-setting-row config-software-close-row">
<div className="config-software-setting-copy">
<span className="config-software-setting-icon" aria-hidden="true">