Merge pull request #139 from router-for-me/dev

Dev
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-16 02:05:05 +08:00
commit fbd5e169f9
16 files changed
+1570 -13

No files matched your search

+3
View File
@@ -9,6 +9,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"lucide-react": "^1.24.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -210,6 +211,8 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
+1
View File
@@ -15,6 +15,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"lucide-react": "^1.24.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
+143 -1
View File
@@ -617,6 +617,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tokio",
"tokio-util",
@@ -2519,6 +2520,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -3211,6 +3213,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -4061,6 +4087,48 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.3+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -5208,6 +5276,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -5241,13 +5318,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -5278,6 +5372,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -5290,6 +5390,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -5302,12 +5408,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -5320,6 +5438,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -5332,6 +5456,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -5344,6 +5474,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -5356,6 +5492,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
+1
View File
@@ -28,6 +28,7 @@ sha2 = "0.10"
tar = "0.4"
tauri = { version = "~2.11", features = ["tray-icon"] }
tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-opener = "2"
tokio = { version = "1", features = ["io-util", "macros", "net", "sync", "time"] }
tokio-util = "0.7"
+2 -1
View File
@@ -8,6 +8,7 @@
"core:event:allow-unlisten",
"core:window:allow-set-theme",
"core:window:allow-set-background-color",
"core:webview:allow-set-webview-background-color"
"core:webview:allow-set-webview-background-color",
"dialog:allow-open"
]
}
+2
View File
@@ -5,8 +5,10 @@ use super::*;
mod commands;
mod configuration;
mod discovery;
mod launch;
mod state;
pub(crate) use commands::*;
pub(crate) use configuration::*;
pub(crate) use discovery::*;
pub(crate) use launch::*;
pub(crate) use state::*;
+89 -8
View File
@@ -360,6 +360,16 @@ pub(crate) fn inspect_pi_provider_status(
supported_platform: true,
installed: executable.is_some(),
plugin_installed,
launch_targets: executable
.as_ref()
.map(|path| {
vec![AgentLaunchTarget {
id: "cli".to_string(),
label: PI_AGENT_NAME.to_string(),
detail: path_to_string(path),
}]
})
.unwrap_or_default(),
version: cli_version.clone(),
cli_version,
app_version: None,
@@ -831,15 +841,28 @@ pub(crate) fn inspect_agent_config(
} else {
executable.as_deref().and_then(read_agent_version)
};
let codex_app_installation = (client == AgentClient::Codex)
.then(|| find_codex_app_installation(home))
.flatten();
let app_version = match client {
AgentClient::ClaudeDesktop => read_claude_desktop_version(home),
AgentClient::Codex => read_codex_app_version(home),
AgentClient::Codex => codex_app_installation
.as_ref()
.and_then(read_codex_app_installation_version),
AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home),
AgentClient::ZCode => read_zcode_app_version(home),
_ => None,
};
let version = cli_version.clone().or_else(|| app_version.clone());
let installed = version.is_some() || (client == AgentClient::ZCode && executable.is_some());
let installed = version.is_some()
|| (client == AgentClient::ZCode && executable.is_some())
|| codex_app_installation.is_some();
let launch_targets = agent_launch_targets(
client,
executable.as_deref(),
app_version.as_deref(),
codex_app_installation.is_some(),
);
let mut warnings = Vec::new();
if !client.supported_platform() {
warnings.push("当前平台不支持 Claude Desktop 3P 配置".to_string());
@@ -859,6 +882,7 @@ pub(crate) fn inspect_agent_config(
supported_platform: client.supported_platform(),
installed,
plugin_installed: true,
launch_targets,
version,
cli_version,
app_version,
@@ -883,6 +907,63 @@ pub(crate) fn inspect_agent_config(
}
}
pub(crate) fn agent_launch_targets(
client: AgentClient,
executable: Option<&Path>,
app_version: Option<&str>,
app_installed: bool,
) -> Vec<AgentLaunchTarget> {
let mut targets = Vec::new();
match client {
AgentClient::ClaudeDesktop => {
if executable.is_some() || app_version.is_some() {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "Claude Desktop".to_string(),
detail: executable
.map(path_to_string)
.unwrap_or_else(|| "Claude Desktop application".to_string()),
});
}
}
AgentClient::Codex => {
if app_installed {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "Codex App".to_string(),
detail: "Codex desktop application".to_string(),
});
}
if let Some(executable) = executable {
targets.push(AgentLaunchTarget {
id: "cli".to_string(),
label: "Codex CLI".to_string(),
detail: path_to_string(executable),
});
}
}
AgentClient::ZCode => {
if let Some(executable) = executable {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "ZCode".to_string(),
detail: path_to_string(executable),
});
}
}
_ => {
if let Some(executable) = executable {
targets.push(AgentLaunchTarget {
id: "cli".to_string(),
label: client.name().to_string(),
detail: path_to_string(executable),
});
}
}
}
targets
}
pub(crate) fn agent_configuration_is_synchronized(
client: AgentClient,
home: &Path,
@@ -1260,27 +1341,27 @@ pub(crate) fn read_claude_desktop_version(home: &Path) -> Option<String> {
find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path))
}
pub(crate) fn read_codex_app_version(home: &Path) -> Option<String> {
pub(crate) fn read_codex_app_installation_version(installation: &CodexAppTarget) -> Option<String> {
#[cfg(target_os = "windows")]
{
match find_codex_app_installation(home)? {
match installation {
CodexAppTarget::WindowsAppId(app_id) => {
let _ = app_id;
read_windows_codex_store_version()
}
CodexAppTarget::Application(path) => {
read_windows_executable_version(&path).or_else(|| read_agent_version(&path))
read_windows_executable_version(path).or_else(|| read_agent_version(path))
}
}
}
#[cfg(target_os = "macos")]
{
let CodexAppTarget::Application(path) = find_codex_app_installation(home)?;
read_macos_app_version(&path)
let CodexAppTarget::Application(path) = installation;
read_macos_app_version(path)
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let _ = home;
let _ = installation;
None
}
}
+584
View File
@@ -0,0 +1,584 @@
use super::*;
#[tauri::command]
pub(crate) fn launch_agent(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
client: String,
target: Option<String>,
working_directory: Option<String>,
) -> Result<(), String> {
let home = app
.path()
.home_dir()
.map_err(|error| format!("无法获取用户目录: {error}"))?;
let config = gui_config_state.snapshot()?;
let requested_target = target
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if client.trim().eq_ignore_ascii_case(PI_AGENT_ID) {
if requested_target.is_some_and(|value| value != "cli") {
return Err("Pi 只支持 CLI 启动方式".to_string());
}
let status =
inspect_pi_provider_status(&home, config.port, effective_agent_api_key(&config));
if !status.installed {
return Err("未检测到 Pi CLI,请先安装 Pi 并重新检测".to_string());
}
if !status.configured {
return Err("请先安装并配置 Pi CLIProxyAPI provider 插件".to_string());
}
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, &[]);
}
let client = AgentClient::parse(&client)?;
if !client.supported_platform() {
return Err(format!("当前平台不支持启动 {}", client.name()));
}
let status = inspect_agent_config(client, &home, config.port, effective_agent_api_key(&config));
if !status.installed {
return Err(format!("未检测到 {},请先安装并重新检测", client.name()));
}
validate_agent_launch_configuration(client, &status)?;
if client == AgentClient::Codex && status.oauth_configuration {
validate_codex_oauth_login(&home)?;
}
let default_target = match client {
AgentClient::ClaudeDesktop | AgentClient::ZCode => "app",
AgentClient::Codex if find_codex_app_installation(&home).is_some() => "app",
_ => "cli",
};
let requested_target = requested_target.unwrap_or(default_target);
match (client, requested_target) {
(AgentClient::ClaudeDesktop, "app") => launch_claude_desktop(&home),
(AgentClient::ZCode, "app") => {
let executable = find_zcode_desktop_executable(&home)
.ok_or_else(|| "未找到 ZCode 应用程序".to_string())?;
launch_desktop_agent(&executable, client.name())
}
(AgentClient::Codex, "app") => launch_codex_desktop(&home),
(AgentClient::ClaudeDesktop | AgentClient::ZCode, "cli") => {
Err(format!("{} 不支持 CLI 启动方式", client.name()))
}
(_, "cli") => {
let executable = find_agent_executable(client, &home)
.ok_or_else(|| format!("未找到 {} 的可执行文件", client.name()))?;
let launch_directory = resolve_launch_directory(working_directory.as_deref(), &home)?;
let environment_to_remove = if client == AgentClient::ClaudeCode {
&["ANTHROPIC_API_KEY"][..]
} else {
&[]
};
launch_cli_agent(
&executable,
client.name(),
&launch_directory,
environment_to_remove,
)
}
(_, "app") => Err(format!("{} 不支持桌面 App 启动方式", client.name())),
_ => Err("不支持的智能体启动方式".to_string()),
}
}
#[tauri::command]
pub(crate) async fn restart_codex_app(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<(), String> {
let home = app
.path()
.home_dir()
.map_err(|error| format!("无法获取用户目录: {error}"))?;
let config = gui_config_state.snapshot()?;
let status = inspect_agent_config(
AgentClient::Codex,
&home,
config.port,
effective_agent_api_key(&config),
);
if !status.installed {
return Err("未检测到 Codex,请先安装并重新检测".to_string());
}
if status.oauth_configuration {
validate_codex_oauth_login(&home)?;
}
let target = find_codex_app_installation(&home)
.ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?;
tauri::async_runtime::spawn_blocking(move || {
stop_codex_desktop(&target)?;
launch_codex_target(&target)
})
.await
.map_err(|error| format!("重启 Codex App 任务失败: {error}"))?
}
fn validate_agent_launch_configuration(
client: AgentClient,
status: &AgentConfigStatus,
) -> Result<(), String> {
if client == AgentClient::Codex
|| (status.modification_enabled && status.modification_state == "applied")
{
return Ok(());
}
Err(format!(
"请先为 {} 应用配置修改,确保 CPA 配置生效后再启动",
client.name()
))
}
fn resolve_launch_directory(value: Option<&str>, fallback: &Path) -> Result<PathBuf, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(fallback.to_path_buf());
};
if value.chars().any(char::is_control) {
return Err("工作目录包含无效字符".to_string());
}
let path = PathBuf::from(value);
if !path.is_absolute() {
return Err("工作目录必须是绝对路径".to_string());
}
if !path.is_dir() {
return Err(format!("工作目录不存在: {}", path_to_string(&path)));
}
Ok(path)
}
fn launch_codex_desktop(home: &Path) -> Result<(), String> {
let target = find_codex_app_installation(home)
.ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?;
launch_codex_target(&target)
}
fn launch_codex_target(target: &CodexAppTarget) -> Result<(), String> {
match target {
#[cfg(target_os = "windows")]
CodexAppTarget::WindowsAppId(app_id) => launch_windows_store_app(app_id, "Codex App"),
CodexAppTarget::Application(path) => launch_desktop_agent(path, "Codex App"),
}
}
#[cfg(target_os = "windows")]
fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> {
let script = windows_codex_stop_script(target);
let encoded = windows_powershell_encoded_command(&script);
let mut command = Command::new(windows_powershell_executable());
command.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encoded,
]);
configure_background_command(&mut command);
let output = command
.output()
.map_err(|error| format!("关闭 Codex App 失败: {error}"))?;
if output.status.success() {
return Ok(());
}
let detail = String::from_utf8_lossy(if output.stderr.is_empty() {
&output.stdout
} else {
&output.stderr
})
.trim()
.to_string();
Err(if detail.is_empty() {
"Codex App 未能完全关闭".to_string()
} else {
format!("关闭 Codex App 失败: {detail}")
})
}
#[cfg(target_os = "windows")]
fn windows_codex_stop_script(target: &CodexAppTarget) -> String {
let selector = match target {
CodexAppTarget::Application(path) => format!(
"$targetExecutable = {}\n$targetRoot = $null",
windows_powershell_single_quoted_literal(&path_to_string(path))
),
CodexAppTarget::WindowsAppId(app_id) => {
let package_family = app_id.split('!').next().unwrap_or(app_id);
format!(
concat!(
"$targetExecutable = $null\n",
"$packageFamily = {}\n",
"$package = @(Get-AppxPackage | Where-Object {{ $_.PackageFamilyName -eq $packageFamily }}) | Select-Object -First 1\n",
"$targetRoot = if ($package) {{ $package.InstallLocation }} else {{ $null }}\n",
"if (-not $targetRoot) {{ throw 'Codex App package directory was not found' }}"
),
windows_powershell_single_quoted_literal(package_family)
)
}
};
format!(
r#"$ErrorActionPreference = 'Stop'
{selector}
function Get-CodexAppProcesses {{
@(Get-CimInstance Win32_Process | Where-Object {{
$_.Name -in @('ChatGPT.exe', 'Codex.exe') -and
$_.ExecutablePath -and
(($targetExecutable -and [string]::Equals($_.ExecutablePath, $targetExecutable, [System.StringComparison]::OrdinalIgnoreCase)) -or
($targetRoot -and $_.ExecutablePath.StartsWith(($targetRoot.TrimEnd('\') + '\'), [System.StringComparison]::OrdinalIgnoreCase)))
}})
}}
$processes = @(Get-CodexAppProcesses)
foreach ($process in $processes) {{
Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue
}}
$deadline = [DateTime]::UtcNow.AddSeconds(5)
do {{
$remaining = @(Get-CodexAppProcesses)
if ($remaining.Count -eq 0) {{ exit 0 }}
Start-Sleep -Milliseconds 100
}} while ([DateTime]::UtcNow -lt $deadline)
throw "Codex App did not exit; remaining process IDs: $($remaining.ProcessId -join ', ')"
"#
)
}
#[cfg(target_os = "macos")]
fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> {
let CodexAppTarget::Application(executable) = target;
let process_name = executable
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.ok_or_else(|| "无法识别 Codex App 进程名称".to_string())?;
let application_name = executable
.ancestors()
.find(|path| path.extension().and_then(|value| value.to_str()) == Some("app"))
.and_then(Path::file_stem)
.and_then(|name| name.to_str())
.unwrap_or(process_name);
let escaped_name = application_name.replace('\\', "\\\\").replace('"', "\\\"");
let _ = Command::new("osascript")
.args([
"-e",
&format!("tell application \"{escaped_name}\" to quit"),
])
.status();
wait_for_unix_process_exit(process_name)
}
#[cfg(target_os = "macos")]
fn wait_for_unix_process_exit(process_name: &str) -> Result<(), String> {
for _ in 0..50 {
let status = Command::new("pgrep").args(["-x", process_name]).status();
if status.is_ok_and(|status| !status.success()) {
return Ok(());
}
thread::sleep(Duration::from_millis(100));
}
let _ = Command::new("pkill")
.args(["-KILL", "-x", process_name])
.status();
thread::sleep(Duration::from_millis(100));
let status = Command::new("pgrep")
.args(["-x", process_name])
.status()
.map_err(|error| format!("检查 Codex App 进程失败: {error}"))?;
if status.success() {
Err("Codex App 未能完全关闭".to_string())
} else {
Ok(())
}
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn stop_codex_desktop(_target: &CodexAppTarget) -> Result<(), String> {
Err("当前平台不支持重启 Codex App".to_string())
}
fn launch_claude_desktop(home: &Path) -> Result<(), String> {
if let Some(executable) = find_claude_desktop_executable(home) {
return launch_desktop_agent(&executable, "Claude Desktop");
}
#[cfg(target_os = "windows")]
{
launch_windows_claude_store_app()
}
#[cfg(not(target_os = "windows"))]
Err("未检测到 Claude Desktop 应用,请先安装或重新检测".to_string())
}
fn launch_desktop_agent(executable: &Path, label: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
let mut command = {
let application = executable
.ancestors()
.find(|path| path.extension().and_then(|value| value.to_str()) == Some("app"))
.unwrap_or(executable);
let mut command = Command::new("open");
command.arg(application);
command
};
#[cfg(any(target_os = "windows", target_os = "linux"))]
let mut command = Command::new(executable);
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = (executable, label);
return Err("当前平台不支持桌面智能体".to_string());
}
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
command
.spawn()
.map(|_| ())
.map_err(|error| format!("启动 {label} 失败: {error}"))
}
}
#[cfg(target_os = "windows")]
fn launch_windows_store_app(app_id: &str, label: &str) -> Result<(), String> {
let mut command = Command::new(windows_explorer_executable());
command
.arg(format!("shell:AppsFolder\\{app_id}"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
command
.spawn()
.map(|_| ())
.map_err(|error| format!("启动 {label} 失败: {error}"))
}
#[cfg(target_os = "windows")]
fn launch_windows_claude_store_app() -> Result<(), String> {
const SCRIPT: &str = r#"
$ErrorActionPreference = 'Stop'
$appId = @(Get-StartApps) |
Where-Object {
$_.AppID -like 'Claude_*!*' -or
$_.AppID -like 'Anthropic.Claude_*!*'
} |
Select-Object -First 1 -ExpandProperty AppID
if (-not $appId) { throw 'Claude Desktop app entry was not found' }
Start-Process "shell:AppsFolder\$appId"
"#;
let encoded = windows_powershell_encoded_command(SCRIPT);
let mut command = Command::new(windows_powershell_executable());
command.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encoded,
]);
configure_background_command(&mut command);
let output = command
.output()
.map_err(|error| format!("启动 Claude Desktop 失败: {error}"))?;
if output.status.success() {
Ok(())
} else {
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if detail.is_empty() {
"未找到可启动的 Claude Desktop 应用".to_string()
} else {
format!("启动 Claude Desktop 失败: {detail}")
})
}
}
#[cfg(target_os = "macos")]
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg(target_os = "macos")]
fn launch_cli_agent(
executable: &Path,
label: &str,
working_directory: &Path,
environment_to_remove: &[&str],
) -> Result<(), String> {
let removals = environment_to_remove
.iter()
.map(|key| format!("-u {}", shell_single_quote(key)))
.collect::<Vec<_>>()
.join(" ");
let command_line = format!(
"cd {} && exec env {} {}",
shell_single_quote(&path_to_string(working_directory)),
removals,
shell_single_quote(&path_to_string(executable)),
);
let script = format!(
"tell application \"Terminal\"\nactivate\ndo script \"{}\"\nend tell",
command_line.replace('\\', "\\\\").replace('"', "\\\"")
);
let output = Command::new("osascript")
.arg("-e")
.arg(script)
.output()
.map_err(|error| format!("启动 {label} 终端失败: {error}"))?;
if output.status.success() {
Ok(())
} else {
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(format!("启动 {label} 终端失败: {detail}"))
}
}
#[cfg(target_os = "linux")]
fn launch_cli_agent(
executable: &Path,
label: &str,
working_directory: &Path,
environment_to_remove: &[&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 mut last_error = None;
for (terminal, arguments) in terminals {
if !env::split_paths(&path).any(|directory| directory.join(terminal).is_file()) {
continue;
}
let mut command = Command::new(terminal);
command
.args(*arguments)
.arg(executable)
.current_dir(working_directory)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
for key in environment_to_remove {
command.env_remove(key);
}
match command.spawn() {
Ok(_) => return Ok(()),
Err(error) => last_error = Some(error.to_string()),
}
}
Err(match last_error {
Some(error) => format!("启动 {label} 失败: {error}"),
None => format!("启动 {label} 失败:未找到可用的终端程序"),
})
}
#[cfg(target_os = "windows")]
fn launch_cli_agent(
executable: &Path,
label: &str,
working_directory: &Path,
environment_to_remove: &[&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);
for key in environment_to_remove {
command.env_remove(key);
}
command
.spawn()
.map(|_| ())
.map_err(|error| format!("启动 {label} 失败: {error}"))
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn launch_cli_agent(
_executable: &Path,
label: &str,
_working_directory: &Path,
_environment_to_remove: &[&str],
) -> Result<(), String> {
Err(format!("当前平台不支持启动 {label}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_launch_directory_is_rejected() {
let error = resolve_launch_directory(Some("relative/project"), Path::new("/fallback"))
.expect_err("relative path should be rejected");
assert!(error.contains("绝对路径"));
}
#[test]
fn omitted_launch_directory_uses_fallback() {
let fallback = if cfg!(target_os = "windows") {
Path::new(r"C:\Users\tester")
} else {
Path::new("/home/tester")
};
assert_eq!(resolve_launch_directory(None, fallback).unwrap(), fallback);
}
#[test]
fn codex_exposes_independent_app_and_cli_targets() {
let executable = Path::new("codex");
let targets =
agent_launch_targets(AgentClient::Codex, Some(executable), Some("1.0.0"), true);
assert_eq!(
targets
.iter()
.map(|target| target.id.as_str())
.collect::<Vec<_>>(),
["app", "cli"]
);
}
#[test]
fn desktop_and_cli_clients_get_the_correct_target_kind() {
let executable = Path::new("agent");
let zcode =
agent_launch_targets(AgentClient::ZCode, Some(executable), Some("1.0.0"), false);
let kimi = agent_launch_targets(AgentClient::KimiCode, Some(executable), None, false);
assert_eq!(zcode[0].id, "app");
assert_eq!(kimi[0].id, "cli");
}
#[cfg(target_os = "windows")]
#[test]
fn codex_restart_script_is_scoped_to_the_detected_installation() {
let executable = CodexAppTarget::Application(PathBuf::from(r"C:\Apps\Codex\Codex.exe"));
let executable_script = windows_codex_stop_script(&executable);
assert!(executable_script.contains(r"C:\Apps\Codex\Codex.exe"));
assert!(executable_script.contains("Get-CimInstance Win32_Process"));
assert!(!executable_script.contains("taskkill"));
let store = CodexAppTarget::WindowsAppId("OpenAI.Codex_123!App".to_string());
let store_script = windows_codex_stop_script(&store);
assert!(store_script.contains("OpenAI.Codex_123"));
assert!(store_script.contains("PackageFamilyName"));
}
}
+12
View File
@@ -763,6 +763,7 @@ struct AgentConfigStatus {
supported_platform: bool,
installed: bool,
plugin_installed: bool,
launch_targets: Vec<AgentLaunchTarget>,
version: Option<String>,
cli_version: Option<String>,
app_version: Option<String>,
@@ -784,6 +785,14 @@ struct AgentConfigStatus {
error: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentLaunchTarget {
id: String,
label: String,
detail: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConfigActionResult {
@@ -1770,6 +1779,7 @@ fn main() {
let start_hidden = should_start_hidden(&gui_config);
let app = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
@@ -1974,6 +1984,8 @@ fn main() {
clear_codex_config,
set_agent_config_enabled,
update_agent_config,
launch_agent,
restart_codex_app,
get_lan_ipv4,
save_gui_settings,
save_network_routing_settings,
+25
View File
@@ -500,6 +500,30 @@ export const jaOverrides = {
'agents.error.mappingSelectionGone': 'Claude Desktop のモデルマッピングに利用できないモデルがあります。モデルを更新して選び直してください',
'agents.error.claudeCodeRuntimeSettingsInvalid': '最大コンテキストは 100000~1000000、自動圧縮率は 1~100 にしてください',
'agents.error.applyFirst': '選択したモデルはまだ適用されていません。先に「設定変更を適用」をクリックしてください',
'agents.launch.enableFirst': '先に設定変更を適用してください',
'agents.launch.applyFirst': '先に選択中のモデルを適用してください',
'agents.launch.starting': '起動中',
'agents.launch.startCli': 'CLI を起動',
'agents.launch.startApp': 'App を起動',
'agents.launch.restartApp': 'App を再起動',
'agents.launch.restartingApp': '再起動中',
'agents.launch.start': '{target} を起動',
'agents.launch.unavailable': '起動できません',
'agents.launchDirectory.noDirectory': '未選択',
'agents.launchDirectory.chooseDirectory': 'ディレクトリを選択',
'agents.launchDirectory.selectedDirectory': '選択した作業ディレクトリ',
'agents.launchDirectory.choosing': '選択中',
'agents.launchDirectory.dialogTitle': '{client} の起動ディレクトリを選択',
'agents.launchDirectory.dialogDescription': 'プロジェクトの作業ディレクトリを選択してから、{client} を起動します。',
'agents.launchDirectory.workingDirectory': '作業ディレクトリ',
'agents.launchDirectory.browse': '変更',
'agents.launchDirectory.directoryRequired': '先に作業ディレクトリを選択してください',
'agents.launchDirectory.launch': '{client} を起動',
'agents.launchDirectory.historyTitle': '最近使用した項目',
'agents.launchDirectory.lastUsed': '前回使用',
'agents.launchDirectory.recentItem': '履歴ディレクトリ',
'agents.launchDirectory.historyUse': '選択',
'agents.launchDirectory.historySelected': '選択済み',
'agents.status.detecting': '検出中',
'agents.status.unsupported': 'プラットフォーム非対応',
'agents.status.notInstalled': '未インストール',
@@ -613,6 +637,7 @@ export const jaOverrides = {
'agents.oauthLoginRequired.enableClearConfiguration': '設定を消去',
'agents.oauthLoginRequired.enableDescriptionSuffix': 'を使用してください。',
'agents.oauthLoginRequired.applyDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定を適用できません。Codex で ChatGPT アカウントにログインしてから、設定を適用してください。',
'agents.oauthLoginRequired.launchDescription': '有効なログイン認証情報が見つからないため Codex を起動できません。先に Codex アプリで ChatGPT アカウントにログインしてください。',
'agents.oauthLoginRequired.confirm': '了解しました',
'agents.modify.apply': '設定変更を適用',
'agents.modify.update': '設定を更新',
+25
View File
@@ -500,6 +500,30 @@ export const en: Record<MessageKey, string> = {
'agents.error.mappingSelectionGone': 'A Claude Desktop mapping is no longer available. Refresh the models and select it again.',
'agents.error.claudeCodeRuntimeSettingsInvalid': 'Maximum context must be 100000–1000000 and the auto-compact percentage must be 1–100.',
'agents.error.applyFirst': 'The selected model has not been applied. Click “Apply Configuration Changes” first.',
'agents.launch.enableFirst': 'Apply the configuration changes first',
'agents.launch.applyFirst': 'Apply the selected model first',
'agents.launch.starting': 'Starting',
'agents.launch.startCli': 'Start CLI',
'agents.launch.startApp': 'Start App',
'agents.launch.restartApp': 'Restart App',
'agents.launch.restartingApp': 'Restarting',
'agents.launch.start': 'Start {target}',
'agents.launch.unavailable': 'Cannot Start',
'agents.launchDirectory.noDirectory': 'Not selected',
'agents.launchDirectory.chooseDirectory': 'Choose Directory',
'agents.launchDirectory.selectedDirectory': 'Selected working directory',
'agents.launchDirectory.choosing': 'Choosing',
'agents.launchDirectory.dialogTitle': 'Choose a Launch Directory for {client}',
'agents.launchDirectory.dialogDescription': 'Choose a project working directory, then start {client}.',
'agents.launchDirectory.workingDirectory': 'Working directory',
'agents.launchDirectory.browse': 'Change',
'agents.launchDirectory.directoryRequired': 'Select a working directory first',
'agents.launchDirectory.launch': 'Start {client}',
'agents.launchDirectory.historyTitle': 'Recently Used',
'agents.launchDirectory.lastUsed': 'Last used',
'agents.launchDirectory.recentItem': 'Recent directory',
'agents.launchDirectory.historyUse': 'Select',
'agents.launchDirectory.historySelected': 'Selected',
'agents.status.detecting': 'Detecting',
'agents.status.unsupported': 'Unsupported Platform',
'agents.status.notInstalled': 'Not Installed',
@@ -613,6 +637,7 @@ export const en: Record<MessageKey, string> = {
'agents.oauthLoginRequired.enableClearConfiguration': 'Clear Configuration',
'agents.oauthLoginRequired.enableDescriptionSuffix': 'first.',
'agents.oauthLoginRequired.applyDescription': 'OAuth configuration cannot be applied because no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex, then apply the configuration.',
'agents.oauthLoginRequired.launchDescription': 'Codex cannot be started because no valid sign-in credentials were found. Sign in to your ChatGPT account in the Codex app first.',
'agents.oauthLoginRequired.confirm': 'Got it',
'agents.modify.apply': 'Apply Configuration Changes',
'agents.modify.update': 'Update Configuration',
+25
View File
@@ -498,6 +498,30 @@ export const zhCN = {
'agents.error.mappingSelectionGone': 'Claude Desktop 模型映射包含不可用模型,请刷新模型后重新选择',
'agents.error.claudeCodeRuntimeSettingsInvalid': '最大窗口必须为 100000–1000000,触发压缩百分比必须为 1–100',
'agents.error.applyFirst': '模型选择尚未应用,请先点击“应用配置修改”',
'agents.launch.enableFirst': '请先应用配置修改',
'agents.launch.applyFirst': '请先应用当前模型选择',
'agents.launch.starting': '启动中',
'agents.launch.startCli': '启动 CLI',
'agents.launch.startApp': '启动 App',
'agents.launch.restartApp': '重启 App',
'agents.launch.restartingApp': '重启中',
'agents.launch.start': '启动 {target}',
'agents.launch.unavailable': '无法启动',
'agents.launchDirectory.noDirectory': '尚未选择',
'agents.launchDirectory.chooseDirectory': '选择目录',
'agents.launchDirectory.selectedDirectory': '已选择的工作目录',
'agents.launchDirectory.choosing': '选择中',
'agents.launchDirectory.dialogTitle': '选择 {client} 启动目录',
'agents.launchDirectory.dialogDescription': '选择项目工作目录,然后启动 {client}。',
'agents.launchDirectory.workingDirectory': '工作目录',
'agents.launchDirectory.browse': '修改',
'agents.launchDirectory.directoryRequired': '请先选择工作目录',
'agents.launchDirectory.launch': '启动 {client}',
'agents.launchDirectory.historyTitle': '最近使用',
'agents.launchDirectory.lastUsed': '上次使用',
'agents.launchDirectory.recentItem': '历史目录',
'agents.launchDirectory.historyUse': '选择',
'agents.launchDirectory.historySelected': '已选择',
'agents.status.detecting': '检测中',
'agents.status.unsupported': '平台不支持',
'agents.status.notInstalled': '未安装',
@@ -611,6 +635,7 @@ export const zhCN = {
'agents.oauthLoginRequired.enableClearConfiguration': '清空配置',
'agents.oauthLoginRequired.enableDescriptionSuffix': '功能。',
'agents.oauthLoginRequired.applyDescription': '无法应用 OAuth 配置:未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再应用配置。',
'agents.oauthLoginRequired.launchDescription': '无法启动 Codex:未检测到有效的登录凭据。请先在 Codex 应用中登录 ChatGPT 账号。',
'agents.oauthLoginRequired.confirm': '我知道了',
'agents.modify.apply': '应用配置修改',
'agents.modify.update': '更新配置',
+365 -2
View File
@@ -10,16 +10,20 @@ import {
} from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
import {
AlertTriangle,
AppWindow,
BadgeCheck,
Bot,
Check,
ChevronDown,
LoaderCircle,
Play,
RefreshCw,
Search,
Trash2,
Terminal,
Wrench,
X,
} from 'lucide-react';
@@ -47,6 +51,11 @@ import {
sameAgentModel,
sameAgentModelMappings,
} from '../services/agentConfigurationDraft';
import {
parseAgentLaunchDirectoryHistory,
rememberAgentLaunchDirectory,
type AgentLaunchDirectoryHistory,
} from '../services/agentLaunchDirectoryHistory';
import type { ModelOption } from '../services/modelService';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import { CodexSessionsPanel } from './CodexSessionsPanel';
@@ -74,6 +83,7 @@ type AgentConfigStatus = {
supportedPlatform: boolean;
installed: boolean;
pluginInstalled: boolean;
launchTargets: AgentLaunchTarget[];
version: string | null;
cliVersion: string | null;
appVersion: string | null;
@@ -93,6 +103,12 @@ type AgentConfigStatus = {
error: string | null;
};
type AgentLaunchTarget = {
id: 'app' | 'cli';
label: string;
detail: string;
};
type AgentConfigActionResult = {
outcome: 'applied' | 'default';
enabled: boolean;
@@ -107,7 +123,7 @@ type PiProviderUpdateStatus = {
updateAvailable: boolean;
};
type OAuthLoginRequiredAction = 'enable' | 'apply';
type OAuthLoginRequiredAction = 'enable' | 'apply' | 'launch';
type ClaudeModelMappings = {
opus: string;
@@ -273,6 +289,7 @@ const DEFAULT_AGENT_SUBPAGE: AgentSubpageId = 'core';
const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1';
const AGENT_SELECTED_CLIENT_KEY = 'cpa-gui.agent-selected-client.v1';
const AGENT_LAUNCH_DIRECTORY_HISTORY_KEY = 'cpa-gui.agent-launch-directory-history.v1';
const readSelectedAgentClient = (): AgentClientId => {
const fallback = agentDefinitions[0].id;
@@ -323,6 +340,27 @@ const writeAgentModelSelections = (
}
};
const readAgentLaunchDirectoryHistory = (): AgentLaunchDirectoryHistory => {
if (typeof window === 'undefined') return {};
try {
return parseAgentLaunchDirectoryHistory(
window.localStorage.getItem(AGENT_LAUNCH_DIRECTORY_HISTORY_KEY),
agentDefinitions.map((agent) => agent.id),
);
} catch {
return {};
}
};
const writeAgentLaunchDirectoryHistory = (history: AgentLaunchDirectoryHistory) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(AGENT_LAUNCH_DIRECTORY_HISTORY_KEY, JSON.stringify(history));
} catch {
// Keep the current in-memory history when persistent storage is unavailable.
}
};
function AgentMark({ definition, size = 26 }: { definition: AgentDefinition; size?: number }) {
if (definition.icon) {
return <img src={definition.icon} alt="" className="provider-logo" />;
@@ -612,7 +650,7 @@ export function AgentsPage() {
const [loading, setLoading] = useState(true);
const [modelLoading, setModelLoading] = useState(false);
const [busyAction, setBusyAction] = useState<
'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | null
'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | null
>(null);
const busy = busyAction !== null;
const [detectionError, setDetectionError] = useState('');
@@ -624,6 +662,14 @@ export function AgentsPage() {
const [clearError, setClearError] = useState('');
const [clearNotice, setClearNotice] = useState('');
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [launchError, setLaunchError] = useState('');
const [launchDirectoryDialogOpen, setLaunchDirectoryDialogOpen] = useState(false);
const [launchDirectory, setLaunchDirectory] = useState('');
const [launchDirectoryTarget, setLaunchDirectoryTarget] = useState<AgentLaunchTarget | null>(null);
const [launchDirectoryError, setLaunchDirectoryError] = useState('');
const [launchDirectoryHistory, setLaunchDirectoryHistory] = useState(
readAgentLaunchDirectoryHistory,
);
const [oauthLoginRequiredAction, setOauthLoginRequiredAction] = useState<OAuthLoginRequiredAction | null>(null);
const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState<boolean | null>(null);
const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState<PiProviderUpdateStatus | null>(null);
@@ -747,6 +793,10 @@ export function AgentsPage() {
setClearError('');
setClearNotice('');
setClearConfirmOpen(false);
setLaunchError('');
setLaunchDirectoryDialogOpen(false);
setLaunchDirectoryTarget(null);
setLaunchDirectoryError('');
setOauthLoginRequiredAction(null);
setOauthConfigurationDraft(null);
// Preserve each Claude client's unsaved draft while navigating between clients.
@@ -903,6 +953,20 @@ export function AgentsPage() {
? claudeMappingsReady && claudeCodeRuntimeSettingsReady
: selectedModelOption),
);
const activeLaunchTargets = activeStatus?.launchTargets ?? [];
const defaultLaunchTarget = activeLaunchTargets[0] ?? null;
const cliLaunchTarget = activeLaunchTargets.find((target) => target.id === 'cli') ?? null;
const appLaunchTarget = activeLaunchTargets.find((target) => target.id === 'app') ?? null;
const launchEnabled = Boolean(
activeStatus?.supportedPlatform
&& activeStatus.installed
&& (selected === 'codex'
|| (selected === 'pi'
? activeStatus.configured
: activeStatus.modificationEnabled && activeStatus.modificationState === 'applied')),
);
const canLaunchTarget = (target: AgentLaunchTarget | null) => launchEnabled && Boolean(target);
const activeLaunchDirectoryHistory = launchDirectoryHistory[selected] ?? [];
const modelHint = modelSelectionError
|| modelError
|| (modelLoading
@@ -1249,6 +1313,106 @@ export function AgentsPage() {
}
};
const openLaunchDirectoryDialog = (target: AgentLaunchTarget) => {
setLaunchDirectory(activeLaunchDirectoryHistory[0] ?? '');
setLaunchDirectoryTarget(target);
setLaunchDirectoryError('');
setLaunchDirectoryDialogOpen(true);
};
const chooseLaunchDirectory = async () => {
setBusyAction('directory');
setLaunchDirectoryError('');
try {
const selectedDirectory = await open({
directory: true,
multiple: false,
defaultPath: launchDirectory || undefined,
title: t('agents.launchDirectory.dialogTitle', { client: activeDefinition.name }),
});
if (typeof selectedDirectory === 'string') setLaunchDirectory(selectedDirectory);
} catch (requestError) {
setLaunchDirectoryError(String(requestError));
} finally {
setBusyAction(null);
}
};
const rememberLaunchDirectory = (client: AgentClientId, directory: string) => {
setLaunchDirectoryHistory((current) => {
const next = rememberAgentLaunchDirectory(current, client, directory);
writeAgentLaunchDirectoryHistory(next);
return next;
});
};
const invokeAgentLaunch = async (
target: AgentLaunchTarget,
workingDirectory: string | null = null,
) => {
const action = selected === 'codex'
? target.id === 'cli' ? 'launch-cli' : 'launch-app'
: 'launch';
setBusyAction(action);
setLaunchError('');
try {
if (draftChanged) throw new Error(t('agents.error.applyFirst'));
await invoke('launch_agent', {
client: selected,
target: target.id,
workingDirectory,
});
if (workingDirectory) {
rememberLaunchDirectory(selected, workingDirectory);
setLaunchDirectoryDialogOpen(false);
setLaunchDirectoryTarget(null);
}
} catch (requestError) {
if (workingDirectory) {
setLaunchDirectoryError(String(requestError));
} else if (!handleOAuthLoginError(requestError, 'launch')) {
setLaunchError(String(requestError));
}
} finally {
setBusyAction(null);
}
};
const launchAgent = async (target: AgentLaunchTarget | null) => {
if (!target) return;
if (target.id === 'cli') {
openLaunchDirectoryDialog(target);
return;
}
await invokeAgentLaunch(target);
};
const restartCodexApp = async () => {
setBusyAction('restart-app');
setLaunchError('');
try {
if (draftChanged) throw new Error(t('agents.error.applyFirst'));
await invoke('restart_codex_app');
} catch (requestError) {
if (!handleOAuthLoginError(requestError, 'launch')) {
setLaunchError(String(requestError));
}
} finally {
setBusyAction(null);
}
};
const launchFromDirectoryDialog = async () => {
if (!launchDirectoryTarget) return;
const workingDirectory = launchDirectory.trim();
if (!workingDirectory) {
setLaunchDirectoryError(t('agents.launchDirectory.directoryRequired'));
return;
}
setLaunchDirectoryError('');
await invokeAgentLaunch(launchDirectoryTarget, workingDirectory);
};
const openDefaultConfirmation = () => {
setDefaultError('');
setDefaultConfirmOpen(true);
@@ -1714,6 +1878,93 @@ export function AgentsPage() {
</section>
)}
<div className="agent-config-footer">
<div className="agent-launch-control">
<div className="agent-launch-actions">
{selected === 'codex' ? (
<>
<button
type="button"
className="secondary-button agent-launch-button"
onClick={() => void launchAgent(cliLaunchTarget)}
disabled={busy || !canLaunchTarget(cliLaunchTarget) || draftChanged}
title={draftChanged
? t('agents.launch.applyFirst')
: cliLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'launch-cli'
? <LoaderCircle size={16} className="spin" />
: <Terminal size={16} />}
{busyAction === 'launch-cli'
? t('agents.launch.starting')
: t('agents.launch.startCli')}
</button>
<button
type="button"
className="primary-button agent-launch-button"
onClick={() => void launchAgent(appLaunchTarget)}
disabled={busy || !canLaunchTarget(appLaunchTarget) || draftChanged}
title={draftChanged
? t('agents.launch.applyFirst')
: appLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'launch-app'
? <LoaderCircle size={16} className="spin" />
: <AppWindow size={16} />}
{busyAction === 'launch-app'
? t('agents.launch.starting')
: t('agents.launch.startApp')}
</button>
<button
type="button"
className="secondary-button agent-launch-button"
onClick={() => void restartCodexApp()}
disabled={busy || !canLaunchTarget(appLaunchTarget) || draftChanged}
title={draftChanged
? t('agents.launch.applyFirst')
: appLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'restart-app'
? <LoaderCircle size={16} className="spin" />
: <RefreshCw size={16} />}
{busyAction === 'restart-app'
? t('agents.launch.restartingApp')
: t('agents.launch.restartApp')}
</button>
</>
) : (
<button
type="button"
className="primary-button agent-launch-button"
onClick={() => void launchAgent(defaultLaunchTarget)}
disabled={busy || !canLaunchTarget(defaultLaunchTarget) || draftChanged}
title={draftChanged
? t('agents.launch.applyFirst')
: defaultLaunchTarget?.detail ?? (launchEnabled
? t('agents.launch.unavailable')
: t('agents.launch.enableFirst'))}
>
{busyAction === 'launch'
? <LoaderCircle size={16} className="spin" />
: defaultLaunchTarget?.id === 'cli'
? <Terminal size={16} />
: <Play size={16} />}
{busyAction === 'launch'
? t('agents.launch.starting')
: defaultLaunchTarget
? t('agents.launch.start', { target: defaultLaunchTarget.label })
: t('agents.launch.unavailable')}
</button>
)}
</div>
{launchError ? (
<span className="agent-inline-message error" role="alert" aria-live="polite">
{launchError}
</span>
) : null}
</div>
</div>
</div>
) : null}
@@ -1730,6 +1981,118 @@ export function AgentsPage() {
</section>
</div>
{launchDirectoryDialogOpen ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => {
if (event.currentTarget === event.target && !busy) {
setLaunchDirectoryDialogOpen(false);
setLaunchDirectoryTarget(null);
}
}}>
<section
className="config-dialog agent-launch-directory-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="agent-launch-directory-title"
>
<div className="config-dialog-heading">
<div>
<h2 id="agent-launch-directory-title">
{t('agents.launchDirectory.dialogTitle', { client: activeDefinition.name })}
</h2>
</div>
</div>
<p>{t('agents.launchDirectory.dialogDescription', { client: activeDefinition.name })}</p>
<div className="config-dialog-field">
<span>{t('agents.launchDirectory.workingDirectory')}</span>
<button
type="button"
className="agent-launch-directory-picker"
onClick={() => void chooseLaunchDirectory()}
disabled={busy}
autoFocus
>
<span>
<small>{launchDirectory
? t('agents.launchDirectory.selectedDirectory')
: t('agents.launchDirectory.noDirectory')}</small>
<strong title={launchDirectory || undefined}>
{launchDirectory || t('agents.launchDirectory.chooseDirectory')}
</strong>
</span>
<b>{busyAction === 'directory'
? t('agents.launchDirectory.choosing')
: t('agents.launchDirectory.browse')}</b>
</button>
</div>
{activeLaunchDirectoryHistory.length ? (
<div className="agent-launch-directory-history">
<strong>{t('agents.launchDirectory.historyTitle')}</strong>
<div className="agent-launch-directory-history-list">
{activeLaunchDirectoryHistory.map((directory, index) => {
const active = directory === launchDirectory;
return (
<button
type="button"
className={active ? 'active' : ''}
key={directory}
onClick={() => {
setLaunchDirectory(directory);
setLaunchDirectoryError('');
}}
disabled={busy}
title={directory}
>
<span>
<strong>{directory}</strong>
<small>{index === 0
? t('agents.launchDirectory.lastUsed')
: t('agents.launchDirectory.recentItem')}</small>
</span>
<b>
{active ? <Check size={15} aria-hidden /> : null}
{active
? t('agents.launchDirectory.historySelected')
: t('agents.launchDirectory.historyUse')}
</b>
</button>
);
})}
</div>
</div>
) : null}
{launchDirectoryError ? (
<span className="agent-inline-message error" role="alert" aria-live="polite">
{launchDirectoryError}
</span>
) : null}
<div className="config-dialog-actions two-actions">
<button
type="button"
className="secondary-button"
onClick={() => {
setLaunchDirectoryDialogOpen(false);
setLaunchDirectoryTarget(null);
}}
disabled={busy}
>
{t('common.cancel')}
</button>
<button
type="button"
className="primary-button"
onClick={() => void launchFromDirectoryDialog()}
disabled={busy || !launchDirectory.trim() || !launchDirectoryTarget}
>
{busyAction === 'launch' || busyAction === 'launch-cli'
? <LoaderCircle size={16} className="spin" />
: null}
{t('agents.launchDirectory.launch', { client: activeDefinition.name })}
</button>
</div>
</section>
</div>
) : null}
{defaultConfirmOpen ? (
<div className="config-dialog-backdrop">
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-default-title">
@@ -0,0 +1,50 @@
export type AgentLaunchDirectoryHistory = Record<string, string[]>;
export const MAX_AGENT_LAUNCH_DIRECTORY_HISTORY = 8;
const validDirectory = (value: unknown): value is string => (
typeof value === 'string'
&& Boolean(value.trim())
&& value.length <= 4096
&& !Array.from(value).some((character) => /[\u0000-\u001f\u007f]/u.test(character))
);
export const parseAgentLaunchDirectoryHistory = (
payload: string | null,
clients: readonly string[],
): AgentLaunchDirectoryHistory => {
if (!payload) return {};
try {
const parsed = JSON.parse(payload) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const source = parsed as Record<string, unknown>;
return clients.reduce<AgentLaunchDirectoryHistory>((history, client) => {
const entries = source[client];
if (!Array.isArray(entries)) return history;
const normalized = entries
.filter(validDirectory)
.map((directory) => directory.trim())
.filter((directory, index, directories) => directories.indexOf(directory) === index)
.slice(0, MAX_AGENT_LAUNCH_DIRECTORY_HISTORY);
if (normalized.length) history[client] = normalized;
return history;
}, {});
} catch {
return {};
}
};
export const rememberAgentLaunchDirectory = (
history: AgentLaunchDirectoryHistory,
client: string,
directory: string,
): AgentLaunchDirectoryHistory => {
const normalized = directory.trim();
if (!validDirectory(normalized)) return history;
const previous = history[client] ?? [];
return {
...history,
[client]: [normalized, ...previous.filter((item) => item !== normalized)]
.slice(0, MAX_AGENT_LAUNCH_DIRECTORY_HISTORY),
};
};
+209 -1
View File
@@ -5523,8 +5523,191 @@ textarea:focus-visible {
}
.agent-config-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
min-height: 48px;
padding-top: 14px;
border-top: 1px solid var(--theme-e3e1db);
}
.agent-launch-control {
display: grid;
min-width: 0;
justify-items: end;
gap: 5px;
}
.agent-launch-actions {
display: flex;
min-width: 0;
max-width: 100%;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.agent-launch-actions > button {
min-width: 120px;
}
.agent-launch-control > .agent-inline-message {
max-width: 420px;
text-align: right;
}
.agent-launch-directory-dialog {
grid-template-rows: auto;
align-content: start;
width: min(620px, 100%);
max-height: calc(100vh - 40px);
height: auto;
min-height: 0;
overflow-y: auto;
}
.agent-launch-directory-dialog > p {
margin: 0;
color: var(--theme-5f5a53);
font-size: var(--font-size-label);
line-height: var(--line-height-relaxed);
}
.agent-launch-directory-dialog .config-dialog-field {
grid-template-rows: auto auto;
min-height: 0;
}
.agent-launch-directory-picker {
display: flex;
width: 100%;
min-width: 0;
min-height: 64px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 11px 13px;
border: 1px solid var(--theme-d8d2c8);
border-radius: 9px;
background: var(--theme-fff);
color: var(--theme-2d2a26);
text-align: left;
}
.agent-launch-directory-picker:hover:not(:disabled) {
border-color: var(--theme-bdb5a9);
background: var(--theme-faf8f4);
}
.agent-launch-directory-picker:focus-visible {
border-color: var(--theme-8d8377);
outline: 0;
box-shadow: 0 0 0 3px rgba(45, 42, 38, 0.1);
}
.agent-launch-directory-picker > span {
display: grid;
min-width: 0;
gap: 4px;
}
.agent-launch-directory-picker small {
color: var(--theme-8a847c);
font-size: var(--font-size-meta);
}
.agent-launch-directory-picker strong {
overflow: hidden;
font-size: var(--font-size-label);
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-launch-directory-picker > b {
flex: none;
color: var(--theme-8069ad);
font-size: var(--font-size-label);
}
.agent-launch-directory-history {
display: grid;
min-height: 0;
gap: 7px;
}
.agent-launch-directory-history > strong {
color: var(--theme-5f5a53);
font-size: var(--font-size-body);
}
.agent-launch-directory-history-list {
display: grid;
min-height: 64px;
max-height: 176px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
border: 1px solid var(--theme-d8d2c8);
border-radius: 9px;
background: var(--theme-fff);
}
.agent-launch-directory-history-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: 100%;
min-height: 58px;
padding: 8px 11px;
border: 0;
border-bottom: 1px solid var(--theme-eeeae2);
border-radius: 0;
background: transparent;
color: var(--theme-2d2a26);
text-align: left;
}
.agent-launch-directory-history-list > button:last-child {
border-bottom: 0;
}
.agent-launch-directory-history-list > button:hover:not(:disabled) {
background: var(--theme-faf8f4);
}
.agent-launch-directory-history-list > button.active {
background: var(--theme-f1ecfb);
box-shadow: inset 3px 0 0 var(--theme-8069ad);
}
.agent-launch-directory-history-list > button > span {
display: grid;
min-width: 0;
gap: 3px;
}
.agent-launch-directory-history-list > button strong {
overflow: hidden;
font-size: var(--font-size-label);
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-launch-directory-history-list > button small {
color: var(--theme-8a847c);
font-size: var(--font-size-meta);
}
.agent-launch-directory-history-list > button > b {
display: inline-flex;
flex: none;
align-items: center;
gap: 5px;
color: var(--theme-8069ad);
font-size: var(--font-size-label);
}
.agent-status-grid {
@@ -7423,7 +7606,8 @@ textarea:focus-visible {
}
.agent-status-grid,
.agent-status-messages {
.agent-status-messages,
.agent-config-footer {
grid-column: 1 / -1;
}
@@ -8052,6 +8236,30 @@ textarea:focus-visible {
text-align: left;
}
.agent-config-footer,
.agent-launch-actions {
width: 100%;
}
.agent-launch-control {
width: 100%;
justify-items: stretch;
}
.agent-launch-actions {
display: grid;
grid-template-columns: 1fr;
}
.agent-launch-actions > button {
width: 100%;
}
.agent-launch-control > .agent-inline-message {
max-width: none;
text-align: left;
}
.oauth-card {
grid-template-rows: auto auto auto;
min-height: 0;
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'bun:test';
import {
MAX_AGENT_LAUNCH_DIRECTORY_HISTORY,
parseAgentLaunchDirectoryHistory,
rememberAgentLaunchDirectory,
} from '../src/services/agentLaunchDirectoryHistory';
describe('agent launch directory history', () => {
it('keeps only valid known-client entries', () => {
const history = parseAgentLaunchDirectoryHistory(JSON.stringify({
codex: [' C:\\work\\one ', '', 'C:\\work\\one', 42],
unknown: ['C:\\private'],
}), ['codex', 'claude-code']);
expect(history).toEqual({ codex: ['C:\\work\\one'] });
});
it('moves the successful directory to the front and limits history size', () => {
const existing = Array.from(
{ length: MAX_AGENT_LAUNCH_DIRECTORY_HISTORY },
(_, index) => `/work/${index}`,
);
const added = rememberAgentLaunchDirectory({ codex: existing }, 'codex', '/work/new');
expect(added.codex[0]).toBe('/work/new');
expect(added.codex).toHaveLength(MAX_AGENT_LAUNCH_DIRECTORY_HISTORY);
const reused = rememberAgentLaunchDirectory(added, 'codex', '/work/2');
expect(reused.codex[0]).toBe('/work/2');
expect(reused.codex.filter((directory) => directory === '/work/2')).toHaveLength(1);
});
it('falls back to an empty history for malformed storage', () => {
expect(parseAgentLaunchDirectoryHistory('{broken', ['codex'])).toEqual({});
});
});