diff --git a/bun.lock b/bun.lock index abf4563..086e187 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "cpa-gui", "dependencies": { "@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", @@ -199,6 +200,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=="], diff --git a/package.json b/package.json index 0558eb7..fa0b320 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@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" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b6ec629..522377c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -605,6 +605,7 @@ dependencies = [ "tar", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tokio", "tokio-util", @@ -2487,6 +2488,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -3168,6 +3170,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" @@ -4004,6 +4030,48 @@ dependencies = [ "walkdir", ] +[[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" @@ -5151,6 +5219,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" @@ -5184,13 +5261,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" @@ -5221,6 +5315,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" @@ -5233,6 +5333,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" @@ -5245,12 +5351,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" @@ -5263,6 +5381,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" @@ -5275,6 +5399,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" @@ -5287,6 +5417,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" @@ -5299,6 +5435,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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d8378c9..b2ffe46 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ serde_yaml = "0.9" sha2 = "0.10" tar = "0.4" tauri = { version = "~2.11", features = ["tray-icon"] } +tauri-plugin-dialog = "2.7.1" tauri-plugin-opener = "2" tokio = { version = "1", features = ["macros", "sync", "time"] } tokio-util = "0.7" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0a29d28..e06f87d 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" ] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 069ec4a..be58f19 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -479,6 +479,8 @@ struct GuiConfigFile { routing_session_affinity: bool, routing_session_affinity_ttl: String, codex_session_repair_on_launch: bool, + claude_code_working_directory: String, + claude_code_working_directory_prompt_disabled: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] @@ -566,6 +568,8 @@ impl Default for GuiConfigFile { routing_session_affinity: false, routing_session_affinity_ttl: String::new(), codex_session_repair_on_launch: false, + claude_code_working_directory: String::new(), + claude_code_working_directory_prompt_disabled: false, } } } @@ -588,6 +592,8 @@ struct GuiConfigPresence { proxy_url: Option, routing_session_affinity: Option, routing_session_affinity_ttl: Option, + claude_code_working_directory: Option, + claude_code_working_directory_prompt_disabled: Option, } #[derive(Clone, Serialize)] @@ -618,7 +624,10 @@ struct AgentConfigStatus { modification_state: String, backup_available: bool, applied_model: Option, + claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, + claude_code_working_directory: Option, + claude_code_working_directory_prompt_disabled: bool, warnings: Vec, error: Option, } @@ -874,6 +883,7 @@ struct AgentConfigurationOptions<'a> { models: &'a [AgentModelOption], codex_catalog: Option<&'a str>, oauth_configuration: bool, + claude_code_model_mappings: Option<&'a ClaudeDesktopModelMappings>, claude_desktop_model_mappings: Option<&'a ClaudeDesktopModelMappings>, } @@ -1330,6 +1340,20 @@ impl GuiConfigState { }) } + fn set_claude_code_launch_preferences( + &self, + working_directory: Option<&Path>, + prompt_disabled: bool, + ) -> Result { + self.update(|config| { + if let Some(working_directory) = working_directory { + config.claude_code_working_directory = path_to_string(working_directory); + } + config.claude_code_working_directory_prompt_disabled = prompt_disabled; + Ok(()) + }) + } + fn set_locale(&self, locale: String) -> Result { self.update(|config| { config.locale = normalize_app_locale(&locale).to_string(); @@ -1612,14 +1636,14 @@ fn resolve_windows_close_request( fn inspect_agent_config_statuses( app: &tauri::AppHandle, - port: u16, - api_key: &str, + config: &GuiConfigFile, ) -> Result, String> { let home = app .path() .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; - Ok([ + let api_key = effective_agent_api_key(config); + let mut statuses = [ AgentClient::ClaudeCode, AgentClient::ClaudeDesktop, AgentClient::Codex, @@ -1628,8 +1652,19 @@ fn inspect_agent_config_statuses( AgentClient::Hermes, ] .into_iter() - .map(|client| inspect_agent_config(client, &home, port, api_key)) - .collect()) + .map(|client| inspect_agent_config(client, &home, config.port, api_key)) + .collect::>(); + if let Some(status) = statuses + .iter_mut() + .find(|status| status.id == AgentClient::ClaudeCode.id()) + { + status.claude_code_working_directory = + (!config.claude_code_working_directory.trim().is_empty()) + .then(|| config.claude_code_working_directory.clone()); + status.claude_code_working_directory_prompt_disabled = + config.claude_code_working_directory_prompt_disabled; + } + Ok(statuses) } fn refresh_agent_config_status_cache( @@ -1644,7 +1679,7 @@ fn refresh_agent_config_status_cache( let config = gui_config_state.snapshot()?; let port = config.port; let api_key = effective_agent_api_key(&config); - let statuses = inspect_agent_config_statuses(app, port, api_key)?; + let statuses = inspect_agent_config_statuses(app, &config)?; cache.replace(port, api_key, statuses.clone())?; Ok(statuses) } @@ -1688,6 +1723,29 @@ async fn get_agent_models( Ok(fetch_prepared_agent_models(client, &config).await?.models) } +fn validate_claude_code_working_directory(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err( + "CLAUDE_CODE_WORKING_DIRECTORY_REQUIRED: Please select a working directory".to_string(), + ); + } + if value.chars().any(char::is_control) { + return Err("The Claude Code working directory contains invalid characters".to_string()); + } + let path = PathBuf::from(value); + if !path.is_absolute() { + return Err("The Claude Code working directory must be an absolute path".to_string()); + } + if !path.is_dir() { + return Err(format!( + "CLAUDE_CODE_WORKING_DIRECTORY_INVALID: The working directory does not exist: {}", + path_to_string(&path) + )); + } + Ok(path) +} + #[tauri::command] fn check_codex_oauth_login(app: tauri::AppHandle) -> Result<(), String> { let home = app @@ -2111,6 +2169,25 @@ fn resolve_claude_desktop_model_mappings( })) } +fn resolve_claude_code_model_mappings( + client: AgentClient, + models: &[AgentModelOption], + selected_model: &str, + requested: Option, +) -> Result, String> { + if client != AgentClient::ClaudeCode { + return Ok(None); + } + let requested = requested.unwrap_or_else(|| ClaudeDesktopModelMappings::all(selected_model)); + let resolve = + |model: &str| resolve_available_agent_model(models, &validate_agent_model(model)?); + Ok(Some(ClaudeDesktopModelMappings { + opus: resolve(&requested.opus)?, + sonnet: resolve(&requested.sonnet)?, + haiku: resolve(&requested.haiku)?, + })) +} + fn prepare_codex_agent_models( runtime_models: &[codex_catalog::CodexRuntimeModel], ) -> Result { @@ -2136,6 +2213,7 @@ async fn apply_agent_config( client: String, model: String, oauth_configuration: bool, + claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, ) -> Result { let client = AgentClient::parse(&client)?; @@ -2151,6 +2229,12 @@ async fn apply_agent_config( validate_agent_can_enable(client, &home, config.port, api_key)?; let prepared = fetch_prepared_agent_models(client, &config).await?; let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?; + let claude_code_model_mappings = resolve_claude_code_model_mappings( + client, + &prepared.models, + &model, + claude_code_model_mappings, + )?; let claude_desktop_model_mappings = resolve_claude_desktop_model_mappings( client, &prepared.models, @@ -2173,6 +2257,7 @@ async fn apply_agent_config( models: &prepared.models, codex_catalog: prepared.codex_catalog.as_deref(), oauth_configuration, + claude_code_model_mappings: claude_code_model_mappings.as_ref(), claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), }, ) @@ -2202,6 +2287,7 @@ async fn reset_agent_config_to_default( client: String, model: String, oauth_configuration: bool, + claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, ) -> Result { let client = AgentClient::parse(&client)?; @@ -2217,6 +2303,12 @@ async fn reset_agent_config_to_default( validate_agent_can_enable(client, &home, config.port, api_key)?; let prepared = fetch_prepared_agent_models(client, &config).await?; let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?; + let claude_code_model_mappings = resolve_claude_code_model_mappings( + client, + &prepared.models, + &model, + claude_code_model_mappings, + )?; let claude_desktop_model_mappings = resolve_claude_desktop_model_mappings( client, &prepared.models, @@ -2238,6 +2330,7 @@ async fn reset_agent_config_to_default( models: &prepared.models, codex_catalog: prepared.codex_catalog.as_deref(), oauth_configuration, + claude_code_model_mappings: claude_code_model_mappings.as_ref(), claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), }) } @@ -2262,6 +2355,7 @@ async fn set_agent_config_enabled( model: String, enabled: bool, force_restore: bool, + claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, ) -> Result { let client = AgentClient::parse(&client)?; @@ -2278,6 +2372,12 @@ async fn set_agent_config_enabled( let prepared = fetch_prepared_agent_models(client, &config).await?; let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?; + let claude_code_model_mappings = resolve_claude_code_model_mappings( + client, + &prepared.models, + &model, + claude_code_model_mappings, + )?; let claude_desktop_model_mappings = resolve_claude_desktop_model_mappings( client, &prepared.models, @@ -2300,6 +2400,7 @@ async fn set_agent_config_enabled( models: &prepared.models, codex_catalog: prepared.codex_catalog.as_deref(), oauth_configuration: false, + claude_code_model_mappings: claude_code_model_mappings.as_ref(), claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), }, ) @@ -2318,6 +2419,7 @@ async fn update_agent_config( gui_config_state: tauri::State<'_, GuiConfigState>, client: String, model: String, + claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, ) -> Result { let client = AgentClient::parse(&client)?; @@ -2330,6 +2432,12 @@ async fn update_agent_config( let api_key = effective_agent_api_key(&config); let prepared = fetch_prepared_agent_models(client, &config).await?; let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?; + let claude_code_model_mappings = resolve_claude_code_model_mappings( + client, + &prepared.models, + &model, + claude_code_model_mappings, + )?; let claude_desktop_model_mappings = resolve_claude_desktop_model_mappings( client, &prepared.models, @@ -2352,6 +2460,7 @@ async fn update_agent_config( models: &prepared.models, codex_catalog: prepared.codex_catalog.as_deref(), oauth_configuration: false, + claude_code_model_mappings: claude_code_model_mappings.as_ref(), claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), }, ) @@ -2361,8 +2470,11 @@ async fn update_agent_config( async fn launch_agent( app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, + cache: tauri::State<'_, AgentConfigStatusCache>, client: String, target: Option, + working_directory: Option, + suppress_working_directory_prompt: Option, ) -> Result<(), String> { let client = AgentClient::parse(&client)?; if !client.supported_platform() { @@ -2430,7 +2542,59 @@ async fn launch_agent( } let executable = find_agent_executable(client, &home) .ok_or_else(|| format!("未找到 {} 的可执行文件", client.name()))?; - launch_cli_agent(&executable, client.name(), &home) + if client == AgentClient::ClaudeCode { + let explicit_working_directory = working_directory + .as_deref() + .map(validate_claude_code_working_directory) + .transpose()?; + let selected_working_directory = match explicit_working_directory { + Some(path) => path, + None if config.claude_code_working_directory_prompt_disabled => { + validate_claude_code_working_directory(&config.claude_code_working_directory)? + } + None => { + return Err( + "CLAUDE_CODE_WORKING_DIRECTORY_REQUIRED: Please select a working directory" + .to_string(), + ) + } + }; + if working_directory.is_some() { + gui_config_state.set_claude_code_launch_preferences( + Some(&selected_working_directory), + suppress_working_directory_prompt.unwrap_or(false), + )?; + cache.clear()?; + } + let claude_config_path = agent_config_paths(client, &home)[0].clone(); + { + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "Claude Code configuration lock is poisoned".to_string())?; + remove_claude_code_conflicting_api_key(&claude_config_path)?; + } + let mappings = inspect_claude_code_model_mappings(&claude_config_path)? + .or_else(|| { + status + .current_model + .as_deref() + .map(ClaudeDesktopModelMappings::all) + }) + .ok_or_else(|| "Claude Code model environment is not configured".to_string())?; + let environment = claude_code_launch_environment( + &format!("http://127.0.0.1:{port}"), + effective_agent_api_key(&config), + &mappings, + ); + return launch_cli_agent( + &executable, + client.name(), + &selected_working_directory, + &environment, + &["ANTHROPIC_API_KEY"], + ); + } + launch_cli_agent(&executable, client.name(), &home, &[], &[]) } fn validate_agent_launch_modification( @@ -2994,7 +3158,12 @@ fn inspect_agent_config( modification_state: modification.state, backup_available: modification.backup_available, applied_model: modification.applied_model, + claude_code_model_mappings: (client == AgentClient::ClaudeCode) + .then(|| inspect_claude_code_model_mappings(&paths[0]).ok().flatten()) + .flatten(), claude_desktop_model_mappings: modification.claude_desktop_model_mappings, + claude_code_working_directory: None, + claude_code_working_directory_prompt_disabled: false, warnings, error, } @@ -4005,10 +4174,24 @@ fn launch_cli_agent( executable: &Path, label: &str, working_directory: &Path, + environment: &[(String, String)], + environment_to_remove: &[&str], ) -> Result<(), String> { + let environment_removals = environment_to_remove + .iter() + .map(|key| format!("-u {}", shell_single_quote(key))) + .collect::>() + .join(" "); + let environment = environment + .iter() + .map(|(key, value)| format!("{key}={}", shell_single_quote(value))) + .collect::>() + .join(" "); let command_line = format!( - "cd {} && exec {}", + "cd {} && exec env {} {} {}", shell_single_quote(&path_to_string(working_directory)), + environment_removals, + environment, shell_single_quote(&path_to_string(executable)) ); let apple_script_command = command_line.replace('\\', "\\\\").replace('"', "\\\""); @@ -4039,6 +4222,8 @@ fn launch_cli_agent( executable: &Path, label: &str, working_directory: &Path, + environment: &[(String, String)], + environment_to_remove: &[&str], ) -> Result<(), String> { let terminals: &[(&str, &[&str])] = &[ ("x-terminal-emulator", &["-e"]), @@ -4058,15 +4243,19 @@ fn launch_cli_agent( if !available { continue; } - match Command::new(terminal) + let mut command = Command::new(terminal); + command .args(*arguments) .arg(executable) + .envs(environment.iter().map(|(key, value)| (key, value))) .current_dir(working_directory) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - { + .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()), } @@ -4082,13 +4271,21 @@ fn launch_cli_agent( executable: &Path, label: &str, working_directory: &Path, + environment: &[(String, String)], + environment_to_remove: &[&str], ) -> Result<(), String> { use std::os::windows::process::CommandExt; const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; - windows_command_for_executable(executable, true) + let mut command = windows_command_for_executable(executable, true); + command + .envs(environment.iter().map(|(key, value)| (key, value))) .current_dir(working_directory) - .creation_flags(CREATE_NEW_CONSOLE) + .creation_flags(CREATE_NEW_CONSOLE); + for key in environment_to_remove { + command.env_remove(key); + } + command .spawn() .map(|_| ()) .map_err(|error| format!("启动 {label} 失败: {error}")) @@ -4099,6 +4296,8 @@ fn launch_cli_agent( _executable: &Path, label: &str, _working_directory: &Path, + _environment: &[(String, String)], + _environment_to_remove: &[&str], ) -> Result<(), String> { Err(format!("当前平台不支持启动 {label}")) } @@ -4136,6 +4335,68 @@ fn inspect_claude_agent_config( Ok((configured, model)) } +fn inspect_claude_code_model_mappings( + path: &Path, +) -> Result, String> { + if !path.is_file() { + return Ok(None); + } + let root: serde_json::Value = serde_json::from_str( + &fs::read_to_string(path) + .map_err(|error| format!("Failed to read Claude Code configuration: {error}"))?, + ) + .map_err(|error| format!("Failed to parse Claude Code configuration: {error}"))?; + let Some(env) = root.get("env").and_then(serde_json::Value::as_object) else { + return Ok(None); + }; + let fallback = env + .get("ANTHROPIC_MODEL") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let read_model = |key: &str| { + env.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or(fallback) + .map(str::to_string) + }; + let Some(opus) = read_model("ANTHROPIC_DEFAULT_OPUS_MODEL") else { + return Ok(None); + }; + let Some(sonnet) = read_model("ANTHROPIC_DEFAULT_SONNET_MODEL") else { + return Ok(None); + }; + let Some(haiku) = read_model("ANTHROPIC_DEFAULT_HAIKU_MODEL") else { + return Ok(None); + }; + Ok(Some(ClaudeDesktopModelMappings { + opus, + sonnet, + haiku, + })) +} + +fn claude_code_launch_environment( + base_url: &str, + api_key: &str, + mappings: &ClaudeDesktopModelMappings, +) -> Vec<(String, String)> { + [ + ("ANTHROPIC_BASE_URL", base_url), + ("ANTHROPIC_AUTH_TOKEN", api_key), + ("ANTHROPIC_MODEL", mappings.sonnet.as_str()), + ("ANTHROPIC_DEFAULT_HAIKU_MODEL", mappings.haiku.as_str()), + ("ANTHROPIC_DEFAULT_SONNET_MODEL", mappings.sonnet.as_str()), + ("ANTHROPIC_DEFAULT_OPUS_MODEL", mappings.opus.as_str()), + ("ANTHROPIC_DEFAULT_FABLE_MODEL", mappings.sonnet.as_str()), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() +} + fn inspect_claude_desktop_agent_config( paths: &[PathBuf], port: u16, @@ -4412,6 +4673,7 @@ fn build_agent_updates( models, codex_catalog, oauth_configuration: false, + claude_code_model_mappings: None, claude_desktop_model_mappings: None, }, ) @@ -4429,6 +4691,7 @@ fn build_agent_updates_with_oauth( models, codex_catalog, oauth_configuration, + claude_code_model_mappings, claude_desktop_model_mappings, } = options; let paths = agent_config_paths(client, home); @@ -4437,8 +4700,22 @@ fn build_agent_updates_with_oauth( match client { AgentClient::ClaudeCode => { let before = read_optional_text(&paths[0])?; - let after = build_claude_agent_config(before.as_deref(), &root_base, api_key, model) - .or_else(|_| build_claude_agent_config(None, &root_base, api_key, model))?; + let after = build_claude_agent_config( + before.as_deref(), + &root_base, + api_key, + model, + claude_code_model_mappings, + ) + .or_else(|_| { + build_claude_agent_config( + None, + &root_base, + api_key, + model, + claude_code_model_mappings, + ) + })?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, @@ -4580,6 +4857,7 @@ fn build_claude_agent_config( base_url: &str, api_key: &str, model: &str, + mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) { Some(value) => serde_json::from_str::(value) @@ -4589,16 +4867,19 @@ fn build_claude_agent_config( let root = root .as_object_mut() .ok_or_else(|| "Claude Code settings.json 根节点必须是对象".to_string())?; + let mappings = mappings + .cloned() + .unwrap_or_else(|| ClaudeDesktopModelMappings::all(model)); let env = ensure_json_object_entry(root, "env"); + env.remove("ANTHROPIC_API_KEY"); for (key, value) in [ ("ANTHROPIC_BASE_URL", base_url), - ("ANTHROPIC_API_KEY", api_key), ("ANTHROPIC_AUTH_TOKEN", api_key), - ("ANTHROPIC_MODEL", model), - ("ANTHROPIC_DEFAULT_HAIKU_MODEL", model), - ("ANTHROPIC_DEFAULT_SONNET_MODEL", model), - ("ANTHROPIC_DEFAULT_OPUS_MODEL", model), - ("ANTHROPIC_DEFAULT_FABLE_MODEL", model), + ("ANTHROPIC_MODEL", mappings.sonnet.as_str()), + ("ANTHROPIC_DEFAULT_HAIKU_MODEL", mappings.haiku.as_str()), + ("ANTHROPIC_DEFAULT_SONNET_MODEL", mappings.sonnet.as_str()), + ("ANTHROPIC_DEFAULT_OPUS_MODEL", mappings.opus.as_str()), + ("ANTHROPIC_DEFAULT_FABLE_MODEL", mappings.sonnet.as_str()), ] { env.insert( key.to_string(), @@ -4607,7 +4888,7 @@ fn build_claude_agent_config( } root.insert( "model".to_string(), - serde_json::Value::String(model.to_string()), + serde_json::Value::String(mappings.sonnet), ); let mut rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone())) .map_err(|error| format!("生成 Claude Code 配置失败: {error}"))?; @@ -4615,6 +4896,23 @@ fn build_claude_agent_config( Ok(rendered) } +fn remove_claude_code_conflicting_api_key(path: &Path) -> Result { + update_agent_json_file(path, "Claude Code configuration", |root| { + let managed = root + .get("env") + .and_then(serde_json::Value::as_object) + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(serde_json::Value::as_str) + .is_some_and(is_managed_agent_base_url); + if !managed { + return false; + } + root.get_mut("env") + .and_then(serde_json::Value::as_object_mut) + .is_some_and(|env| env.remove("ANTHROPIC_API_KEY").is_some()) + }) +} + fn parse_agent_json_object( existing: Option<&str>, label: &str, @@ -6963,7 +7261,7 @@ fn fresh_agent_contents( is_alias: false, context_window: None, }]; - fresh_agent_contents_with_oauth(client, port, api_key, model, &models, false, None) + fresh_agent_contents_with_oauth(client, port, api_key, model, &models, false, None, None) } fn fresh_agent_contents_with_oauth( @@ -6973,13 +7271,18 @@ fn fresh_agent_contents_with_oauth( model: &str, models: &[AgentModelOption], oauth_configuration: bool, + claude_code_model_mappings: Option<&ClaudeDesktopModelMappings>, claude_desktop_model_mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result, String> { let root_base = format!("http://127.0.0.1:{port}"); let openai_base = format!("{root_base}/v1"); match client { AgentClient::ClaudeCode => Ok(vec![build_claude_agent_config( - None, &root_base, api_key, model, + None, + &root_base, + api_key, + model, + claude_code_model_mappings, )?]), AgentClient::ClaudeDesktop => Ok(vec![ build_claude_desktop_deployment_config(None)?, @@ -7757,6 +8060,7 @@ fn apply_agent_configuration( models, codex_catalog, oauth_configuration: false, + claude_code_model_mappings: None, claude_desktop_model_mappings: None, }, ) @@ -7805,6 +8109,7 @@ fn reset_agent_configuration_to_default( }], codex_catalog, oauth_configuration: false, + claude_code_model_mappings: None, claude_desktop_model_mappings: None, }) } @@ -7818,6 +8123,7 @@ struct AgentDefaultConfiguration<'a> { models: &'a [AgentModelOption], codex_catalog: Option<&'a str>, oauth_configuration: bool, + claude_code_model_mappings: Option<&'a ClaudeDesktopModelMappings>, claude_desktop_model_mappings: Option<&'a ClaudeDesktopModelMappings>, } @@ -7833,6 +8139,7 @@ fn reset_agent_configuration_to_default_with_oauth( models, codex_catalog, oauth_configuration, + claude_code_model_mappings, claude_desktop_model_mappings, } = request; let paths = agent_config_paths(client, home); @@ -7843,6 +8150,7 @@ fn reset_agent_configuration_to_default_with_oauth( model, models, oauth_configuration, + claude_code_model_mappings, claude_desktop_model_mappings, )?; if paths.len() != contents.len() { @@ -14075,6 +14383,13 @@ fn load_or_create_gui_config() -> Result { if presence.codex_session_repair_on_launch.is_none() { changed = true; } + if presence.claude_code_working_directory.is_none() + || presence + .claude_code_working_directory_prompt_disabled + .is_none() + { + changed = true; + } let management_secret_rotated = ensure_strong_management_secret(&mut config)?; changed |= management_secret_rotated; changed |= sanitize_gui_config(&mut config)?; @@ -14355,6 +14670,11 @@ fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result { config.routing_session_affinity_ttl = routing_session_affinity_ttl; changed = true; } + let claude_code_working_directory = config.claude_code_working_directory.trim().to_string(); + if config.claude_code_working_directory != claude_code_working_directory { + config.claude_code_working_directory = claude_code_working_directory; + changed = true; + } let window_size = configured_window_size(config); let normalized_width = window_size.map(|size| size.width); let normalized_height = window_size.map(|size| size.height); @@ -14420,6 +14740,14 @@ fn write_gui_config_to_path(config: &GuiConfigFile, config_path: &Path) -> Resul "codex-session-repair-on-launch", value(config.codex_session_repair_on_launch), ), + ( + "claude-code-working-directory", + value(config.claude_code_working_directory.as_str()), + ), + ( + "claude-code-working-directory-prompt-disabled", + value(config.claude_code_working_directory_prompt_disabled), + ), ] { set_codex_table_item(root, key, item); } @@ -14505,6 +14833,13 @@ fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> { { return Err("会话粘性 TTL 不能包含控制字符".to_string()); } + if config + .claude_code_working_directory + .chars() + .any(char::is_control) + { + return Err("Claude Code working directory cannot contain control characters".to_string()); + } Ok(()) } @@ -16706,6 +17041,7 @@ fn main() { let initial_window_size = configured_window_size(&gui_config); let app = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .plugin( tauri_plugin_opener::Builder::new() .open_js_links_on_click(false) @@ -17254,6 +17590,7 @@ data: {"delta":{"type":"text_delta","text":"H"}} "http://127.0.0.1:8317", DEFAULT_API_KEY, "gpt-test", + None, ) .unwrap(); let mut claude_current: serde_json::Value = serde_json::from_str(&claude_managed).unwrap(); @@ -18159,6 +18496,7 @@ model: "http://127.0.0.1:8317", DEFAULT_API_KEY, "model-a", + None, ) .unwrap(); let claude = serde_json::from_str::(&claude).unwrap(); @@ -18255,6 +18593,8 @@ model: management_secret_key: "custom-secret".to_string(), usage_statistics_enabled: false, codex_session_repair_on_launch: true, + claude_code_working_directory: path_to_string(&home), + claude_code_working_directory_prompt_disabled: true, ..GuiConfigFile::default() }; @@ -18268,6 +18608,8 @@ model: assert!(content.contains("management-secret-key = \"custom-secret\"")); assert!(content.contains("usage-statistics-enabled = false")); assert!(content.contains("codex-session-repair-on-launch = true")); + assert!(content.contains("claude-code-working-directory = ")); + assert!(content.contains("claude-code-working-directory-prompt-disabled = true")); fs::remove_dir_all(home).unwrap(); } @@ -18395,22 +18737,94 @@ model: #[test] fn claude_agent_config_preserves_existing_fields() { let rendered = build_claude_agent_config( - Some(r#"{"theme":"dark","env":{"KEEP":"yes"}}"#), + Some(r#"{"theme":"dark","env":{"KEEP":"yes","ANTHROPIC_API_KEY":"legacy-key"}}"#), "http://127.0.0.1:8317", DEFAULT_API_KEY, "claude-test", + None, ) .unwrap(); let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); assert_eq!(value["theme"], "dark"); assert_eq!(value["env"]["KEEP"], "yes"); + assert!(value["env"].get("ANTHROPIC_API_KEY").is_none()); assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "http://127.0.0.1:8317"); assert_eq!(value["env"]["ANTHROPIC_AUTH_TOKEN"], DEFAULT_API_KEY); assert_eq!(value["env"]["ANTHROPIC_MODEL"], "claude-test"); assert_eq!(value["model"], "claude-test"); } + #[test] + fn claude_code_role_mappings_drive_settings_and_launch_environment() { + let mappings = ClaudeDesktopModelMappings { + opus: "gpt-opus".to_string(), + sonnet: "gpt-sonnet".to_string(), + haiku: "gpt-haiku".to_string(), + }; + let rendered = build_claude_agent_config( + None, + "http://127.0.0.1:8317", + "test-key", + "gpt-sonnet", + Some(&mappings), + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(value["env"]["ANTHROPIC_MODEL"], "gpt-sonnet"); + assert_eq!(value["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"], "gpt-opus"); + assert_eq!(value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], "gpt-sonnet"); + assert_eq!(value["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"], "gpt-haiku"); + + let environment = + claude_code_launch_environment("http://127.0.0.1:8317", "test-key", &mappings); + let read = |key: &str| { + environment + .iter() + .find(|(candidate, _)| candidate == key) + .map(|(_, value)| value.as_str()) + }; + assert_eq!(read("ANTHROPIC_BASE_URL"), Some("http://127.0.0.1:8317")); + assert_eq!(read("ANTHROPIC_AUTH_TOKEN"), Some("test-key")); + assert_eq!(read("ANTHROPIC_API_KEY"), None); + assert_eq!(read("ANTHROPIC_DEFAULT_OPUS_MODEL"), Some("gpt-opus")); + assert_eq!(read("ANTHROPIC_DEFAULT_SONNET_MODEL"), Some("gpt-sonnet")); + assert_eq!(read("ANTHROPIC_DEFAULT_HAIKU_MODEL"), Some("gpt-haiku")); + } + + #[test] + fn claude_code_launch_migrates_legacy_dual_auth_configuration() { + let directory = agent_test_home("claude-code-dual-auth-migration"); + let path = directory.join("settings.json"); + fs::write( + &path, + r#"{"env":{"ANTHROPIC_BASE_URL":"http://127.0.0.1:8317","ANTHROPIC_API_KEY":"legacy","ANTHROPIC_AUTH_TOKEN":"token"}}"#, + ) + .unwrap(); + + assert!(remove_claude_code_conflicting_api_key(&path).unwrap()); + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert!(value["env"].get("ANTHROPIC_API_KEY").is_none()); + assert_eq!(value["env"]["ANTHROPIC_AUTH_TOKEN"], "token"); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn claude_code_working_directory_must_be_absolute_and_exist() { + let directory = agent_test_home("claude-code-working-directory"); + assert_eq!( + validate_claude_code_working_directory(&path_to_string(&directory)).unwrap(), + directory + ); + assert!(validate_claude_code_working_directory("relative/path").is_err()); + assert!(validate_claude_code_working_directory(&path_to_string( + &directory.join("missing") + )) + .is_err()); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn codex_agent_config_uses_managed_provider_without_losing_comments() { let rendered = build_codex_agent_config( @@ -18500,6 +18914,7 @@ model: models: &models, codex_catalog: Some(&catalog), oauth_configuration, + claude_code_model_mappings: None, claude_desktop_model_mappings: None, }, ) @@ -20759,6 +21174,8 @@ custom_option = "keep-original" routing_session_affinity: true, routing_session_affinity_ttl: "1h".to_string(), codex_session_repair_on_launch: false, + claude_code_working_directory: String::new(), + claude_code_working_directory_prompt_disabled: false, }; let merged = merge_core_config_yaml(template, Some(current), &config).unwrap(); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 48d3565..2681133 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -2206,6 +2206,8 @@ mod tests { routing_session_affinity: false, routing_session_affinity_ttl: String::new(), codex_session_repair_on_launch: false, + claude_code_working_directory: String::new(), + claude_code_working_directory_prompt_disabled: false, }; let record = normalize_usage_record( serde_json::json!({ diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 20dfad6..f471438 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -552,11 +552,31 @@ export const jaOverrides = { 'agents.modify.oauthConfiguration': 'OAuth 設定方式', 'agents.claudeDesktopMapping.title': 'Claude Desktop モデル → 上流モデル', 'agents.claudeDesktopMapping.description': '各 Claude Desktop モデルが実際に呼び出す上流モデルを選択します。', + 'agents.claudeCodeMapping.title': 'Claude Code モデル → 上流モデル', + 'agents.claudeCodeMapping.description': '各タスク役割に対して、起動時に設定する上流モデルを選択します。', + 'agents.claudeCodeMapping.aliasOnly': 'エイリアスモデルのみ表示', + 'agents.claudeCodeMapping.opus': 'Opus', + 'agents.claudeCodeMapping.opusHint': '複雑な推論と高難度タスク', + 'agents.claudeCodeMapping.sonnet': 'Sonnet', + 'agents.claudeCodeMapping.sonnetHint': '既定のメインモデル', + 'agents.claudeCodeMapping.haiku': 'Haiku', + 'agents.claudeCodeMapping.haikuHint': '高速で軽量なタスク', 'agents.claudeDesktopMapping.customMapping': 'カスタムマッピング', 'agents.claudeDesktopMapping.customMappingHint': '有効にするとエイリアスモデルのみ、無効にするとエイリアス未設定の元モデルのみ表示します。', 'agents.claudeDesktopMapping.opus': 'Claude Opus 5 のマッピング先', 'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 のマッピング先', 'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 のマッピング先', + 'agents.claudeCodeLaunch.noDirectory': '未選択', + 'agents.claudeCodeLaunch.chooseDirectory': 'ディレクトリを選択', + 'agents.claudeCodeLaunch.selectedDirectory': '選択した作業ディレクトリ', + 'agents.claudeCodeLaunch.choosing': '選択中', + 'agents.claudeCodeLaunch.dialogTitle': 'Claude Code の作業ディレクトリを選択', + 'agents.claudeCodeLaunch.dialogDescription': '環境変数を設定して Claude Code を起動する前に、作業ディレクトリを選択してください。', + 'agents.claudeCodeLaunch.workingDirectory': '作業ディレクトリ', + 'agents.claudeCodeLaunch.browse': '参照', + 'agents.claudeCodeLaunch.neverAskAgain': '今後この確認を表示しない', + 'agents.claudeCodeLaunch.directoryRequired': '先に作業ディレクトリを選択してください', + 'agents.claudeCodeLaunch.launch': 'Claude Code を起動', 'agents.oauthLoginRequired.title': 'Codex へのログインが必要です', 'agents.oauthLoginRequired.enableDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定方式を有効にできません。Codex アプリで ChatGPT アカウントにログインしてください。Codex アプリでログインを開始できない場合は、先に', 'agents.oauthLoginRequired.enableClearConfiguration': '設定を消去', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 0b7fa2f..997cac3 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -550,11 +550,31 @@ export const zhCN = { 'agents.pending': '尚未应用', 'agents.claudeDesktopMapping.title': 'Claude Desktop 模型 → 上游模型', 'agents.claudeDesktopMapping.description': '选择每个 Claude Desktop 模型实际调用的上游模型。', + 'agents.claudeCodeMapping.title': 'Claude Code 模型 → 上游模型', + 'agents.claudeCodeMapping.description': '为不同任务角色选择启动时注入的上游模型。', + 'agents.claudeCodeMapping.aliasOnly': '仅显示别名模型', + 'agents.claudeCodeMapping.opus': 'Opus', + 'agents.claudeCodeMapping.opusHint': '复杂推理与高难度任务', + 'agents.claudeCodeMapping.sonnet': 'Sonnet', + 'agents.claudeCodeMapping.sonnetHint': '默认主模型', + 'agents.claudeCodeMapping.haiku': 'Haiku', + 'agents.claudeCodeMapping.haikuHint': '快速轻量任务', 'agents.claudeDesktopMapping.customMapping': '自定义映射', 'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。', 'agents.claudeDesktopMapping.opus': 'Claude Opus 5 映射到', 'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 映射到', 'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 映射到', + 'agents.claudeCodeLaunch.noDirectory': '尚未选择', + 'agents.claudeCodeLaunch.chooseDirectory': '选择目录', + 'agents.claudeCodeLaunch.selectedDirectory': '已选择的工作目录', + 'agents.claudeCodeLaunch.choosing': '选择中', + 'agents.claudeCodeLaunch.dialogTitle': '选择 Claude Code 工作目录', + 'agents.claudeCodeLaunch.dialogDescription': '需要先选择工作目录,然后才能设置环境变量并启动 Claude Code。', + 'agents.claudeCodeLaunch.workingDirectory': '工作目录', + 'agents.claudeCodeLaunch.browse': '浏览', + 'agents.claudeCodeLaunch.neverAskAgain': '永久关闭此提示', + 'agents.claudeCodeLaunch.directoryRequired': '请先选择工作目录', + 'agents.claudeCodeLaunch.launch': '启动 Claude Code', 'agents.model.readingAvailable': '正在读取可用模型', 'agents.model.current': '当前已应用:{model}', 'agents.model.firstSelection': '{count} 个可用模型,首次默认选择第一项', @@ -1465,11 +1485,31 @@ export const en: Record = { 'agents.pending': 'Not Applied', 'agents.claudeDesktopMapping.title': 'Claude Desktop Model → Upstream Model', 'agents.claudeDesktopMapping.description': 'Choose the upstream model each Claude Desktop model actually calls.', + 'agents.claudeCodeMapping.title': 'Claude Code Model → Upstream Model', + 'agents.claudeCodeMapping.description': 'Choose the upstream model injected at launch for each task role.', + 'agents.claudeCodeMapping.aliasOnly': 'Show aliases only', + 'agents.claudeCodeMapping.opus': 'Opus', + 'agents.claudeCodeMapping.opusHint': 'Complex reasoning and difficult tasks', + 'agents.claudeCodeMapping.sonnet': 'Sonnet', + 'agents.claudeCodeMapping.sonnetHint': 'Default primary model', + 'agents.claudeCodeMapping.haiku': 'Haiku', + 'agents.claudeCodeMapping.haikuHint': 'Fast lightweight tasks', 'agents.claudeDesktopMapping.customMapping': 'Custom Mapping', 'agents.claudeDesktopMapping.customMappingHint': 'When enabled, only aliased models are shown; when disabled, only original models without aliases are shown.', 'agents.claudeDesktopMapping.opus': 'Map Claude Opus 5 to', 'agents.claudeDesktopMapping.sonnet': 'Map Claude Sonnet 4.6 to', 'agents.claudeDesktopMapping.haiku': 'Map Claude Haiku 4.5 to', + 'agents.claudeCodeLaunch.noDirectory': 'Not selected', + 'agents.claudeCodeLaunch.chooseDirectory': 'Choose Directory', + 'agents.claudeCodeLaunch.selectedDirectory': 'Selected working directory', + 'agents.claudeCodeLaunch.choosing': 'Choosing', + 'agents.claudeCodeLaunch.dialogTitle': 'Choose a Claude Code Working Directory', + 'agents.claudeCodeLaunch.dialogDescription': 'Select a working directory before setting the environment variables and starting Claude Code.', + 'agents.claudeCodeLaunch.workingDirectory': 'Working directory', + 'agents.claudeCodeLaunch.browse': 'Browse', + 'agents.claudeCodeLaunch.neverAskAgain': 'Never show this prompt again', + 'agents.claudeCodeLaunch.directoryRequired': 'Select a working directory first', + 'agents.claudeCodeLaunch.launch': 'Start Claude Code', 'agents.model.readingAvailable': 'Loading available models', 'agents.model.current': 'Currently applied: {model}', 'agents.model.firstSelection': '{count} models available; the first is selected by default', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 6ffa1c4..0e0a64d 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -10,6 +10,7 @@ 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, @@ -70,7 +71,10 @@ type AgentConfigStatus = { modificationState: AgentModificationState; backupAvailable: boolean; appliedModel: string | null; - claudeDesktopModelMappings: ClaudeDesktopModelMappings | null; + claudeCodeModelMappings: ClaudeModelMappings | null; + claudeDesktopModelMappings: ClaudeModelMappings | null; + claudeCodeWorkingDirectory: string | null; + claudeCodeWorkingDirectoryPromptDisabled: boolean; warnings: string[]; error: string | null; }; @@ -96,7 +100,7 @@ type ChatGptCloseResult = { type OAuthLoginRequiredAction = 'enable' | 'apply' | 'launch'; -type ClaudeDesktopModelMappings = { +type ClaudeModelMappings = { opus: string; sonnet: string; haiku: string; @@ -104,21 +108,36 @@ type ClaudeDesktopModelMappings = { const CODEX_OAUTH_LOGIN_REQUIRED_ERROR = 'CODEX_OAUTH_LOGIN_REQUIRED'; -const createClaudeDesktopModelMappings = (model: string): ClaudeDesktopModelMappings => ({ +const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({ opus: model, sonnet: model, haiku: model, }); -const sameClaudeDesktopModelMappings = ( - left: ClaudeDesktopModelMappings, - right: ClaudeDesktopModelMappings, +const sameClaudeModelMappings = ( + left: ClaudeModelMappings, + right: ClaudeModelMappings, ) => left.opus === right.opus && left.sonnet === right.sonnet && left.haiku === right.haiku; -const claudeDesktopMappingRoles = [ - { key: 'opus', labelKey: 'agents.claudeDesktopMapping.opus' }, - { key: 'sonnet', labelKey: 'agents.claudeDesktopMapping.sonnet' }, - { key: 'haiku', labelKey: 'agents.claudeDesktopMapping.haiku' }, +const claudeMappingRoles = [ + { + key: 'opus', + labelKey: 'agents.claudeDesktopMapping.opus', + codeLabelKey: 'agents.claudeCodeMapping.opus', + codeHintKey: 'agents.claudeCodeMapping.opusHint', + }, + { + key: 'sonnet', + labelKey: 'agents.claudeDesktopMapping.sonnet', + codeLabelKey: 'agents.claudeCodeMapping.sonnet', + codeHintKey: 'agents.claudeCodeMapping.sonnetHint', + }, + { + key: 'haiku', + labelKey: 'agents.claudeDesktopMapping.haiku', + codeLabelKey: 'agents.claudeCodeMapping.haiku', + codeHintKey: 'agents.claudeCodeMapping.haikuHint', + }, ] as const; type AgentDefinition = { @@ -514,14 +533,18 @@ export function AgentsPage() { const [modelByClient, setModelByClient] = useState>>( readAgentModelSelections, ); - const [claudeDesktopModelMappingsDraft, setClaudeDesktopModelMappingsDraft] = useState( - createClaudeDesktopModelMappings(''), + const [claudeModelMappingsDraft, setClaudeModelMappingsDraft] = useState( + createClaudeModelMappings(''), ); - const [claudeDesktopCustomMapping, setClaudeDesktopCustomMapping] = useState(false); + const [claudeCustomMapping, setClaudeCustomMapping] = useState(false); + const [claudeCodeLaunchDialogOpen, setClaudeCodeLaunchDialogOpen] = useState(false); + const [claudeCodeLaunchDirectory, setClaudeCodeLaunchDirectory] = useState(''); + const [claudeCodeSuppressDirectoryPrompt, setClaudeCodeSuppressDirectoryPrompt] = useState(false); + const [claudeCodeDirectoryError, setClaudeCodeDirectoryError] = useState(''); const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< - 'apply' | 'close-config' | 'default' | 'clear' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | null + 'apply' | 'close-config' | 'default' | 'clear' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | 'directory' | null >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); @@ -540,7 +563,7 @@ export function AgentsPage() { const [oauthLoginRequiredAction, setOauthLoginRequiredAction] = useState(null); const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState(null); const modelRequestRef = useRef(0); - const claudeDesktopModelMappingsDirtyRef = useRef(false); + const claudeModelMappingsDirtyRef = useRef(false); const loadStatuses = useCallback(async (forceRefresh = false) => { const command = forceRefresh @@ -637,6 +660,9 @@ export function AgentsPage() { setCloseAppNotice(''); setCloseAppConfirmOpen(false); setOauthLoginRequiredAction(null); + setClaudeCodeLaunchDialogOpen(false); + setClaudeCodeDirectoryError(''); + claudeModelMappingsDirtyRef.current = false; }, [selected]); const activeDefinition = agentDefinitions.find((agent) => agent.id === selected) @@ -648,66 +674,78 @@ export function AgentsPage() { const savedSelectedModel = modelByClient[selected] ?? ''; const selectedModelOption = findAgentModel(models, savedSelectedModel); const selectedModel = selectedModelOption?.name ?? ''; - const claudeDesktopModels = useMemo( - () => filterAgentModelsByAlias(models, claudeDesktopCustomMapping), - [claudeDesktopCustomMapping, models], + const isClaudeModelMappingClient = selected === 'claude-code' || selected === 'claude-desktop'; + const claudeMappingModels = useMemo( + () => filterAgentModelsByAlias(models, claudeCustomMapping), + [claudeCustomMapping, models], ); useEffect(() => { - if (selected !== 'claude-desktop' || !selectedModel) return; - const appliedMappings = activeStatus?.claudeDesktopModelMappings; - if (!claudeDesktopModelMappingsDirtyRef.current && appliedMappings) { - const appliedModels = claudeDesktopMappingRoles + if (!isClaudeModelMappingClient || !selectedModel) return; + const appliedMappings = selected === 'claude-code' + ? activeStatus?.claudeCodeModelMappings + : activeStatus?.claudeDesktopModelMappings; + if (!claudeModelMappingsDirtyRef.current && appliedMappings) { + const appliedModels = claudeMappingRoles .map((role) => findAgentModel(models, appliedMappings[role.key])) .filter((model): model is ModelOption => model !== null); - if (appliedModels.length === claudeDesktopMappingRoles.length) { - setClaudeDesktopCustomMapping(appliedModels.every((model) => Boolean(model.isAlias))); + if (appliedModels.length === claudeMappingRoles.length) { + setClaudeCustomMapping(appliedModels.every((model) => Boolean(model.isAlias))); } } - setClaudeDesktopModelMappingsDraft((current) => { - const source = claudeDesktopModelMappingsDirtyRef.current + setClaudeModelMappingsDraft((current) => { + const source = claudeModelMappingsDirtyRef.current ? current : appliedMappings ?? current; - const next: ClaudeDesktopModelMappings = { + const next: ClaudeModelMappings = { opus: findAgentModel(models, source.opus)?.name ?? selectedModel, sonnet: findAgentModel(models, source.sonnet)?.name ?? selectedModel, haiku: findAgentModel(models, source.haiku)?.name ?? selectedModel, }; - return sameClaudeDesktopModelMappings(current, next) ? current : next; + return sameClaudeModelMappings(current, next) ? current : next; }); - }, [activeStatus?.claudeDesktopModelMappings, models, selected, selectedModel]); + }, [ + activeStatus?.claudeCodeModelMappings, + activeStatus?.claudeDesktopModelMappings, + isClaudeModelMappingClient, + models, + selected, + selectedModel, + ]); 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 appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? ''; - const modelDraftChanged = selected !== 'claude-desktop' && Boolean( + const modelDraftChanged = !isClaudeModelMappingClient && Boolean( selectedModel.trim() && appliedModel.trim() && selectedModel.trim() !== appliedModel.trim(), ); - const appliedClaudeDesktopModelMappings = activeStatus?.claudeDesktopModelMappings - ?? createClaudeDesktopModelMappings(appliedModel); - const claudeDesktopMappingsReady = selected !== 'claude-desktop' - || claudeDesktopMappingRoles.every((role) => - Boolean(findAgentModel(models, claudeDesktopModelMappingsDraft[role.key])), + const appliedClaudeModelMappings = (selected === 'claude-code' + ? activeStatus?.claudeCodeModelMappings + : activeStatus?.claudeDesktopModelMappings) + ?? createClaudeModelMappings(appliedModel); + const claudeMappingsReady = !isClaudeModelMappingClient + || claudeMappingRoles.every((role) => + Boolean(findAgentModel(models, claudeModelMappingsDraft[role.key])), ); - const claudeDesktopMappingDraftChanged = selected === 'claude-desktop' + const claudeMappingDraftChanged = isClaudeModelMappingClient && activeStatus?.modificationState === 'applied' - && !sameClaudeDesktopModelMappings( - claudeDesktopModelMappingsDraft, - appliedClaudeDesktopModelMappings, + && !sameClaudeModelMappings( + claudeModelMappingsDraft, + appliedClaudeModelMappings, ); const oauthConfigurationChanged = selected === 'codex' && oauthConfiguration !== Boolean(activeStatus?.oauthConfiguration); - const draftChanged = modelDraftChanged || claudeDesktopMappingDraftChanged || oauthConfigurationChanged; + const draftChanged = modelDraftChanged || claudeMappingDraftChanged || oauthConfigurationChanged; const canEnable = Boolean( activeStatus?.supportedPlatform && activeStatus.installed && !modelLoading - && (selected === 'claude-desktop' - ? claudeDesktopMappingsReady + && (isClaudeModelMappingClient + ? claudeMappingsReady : selectedModelOption), ); const launchEnabled = Boolean( @@ -753,28 +791,28 @@ export function AgentsPage() { }); }; - const selectClaudeDesktopModelMapping = ( - role: keyof ClaudeDesktopModelMappings, + const selectClaudeModelMapping = ( + role: keyof ClaudeModelMappings, value: string, ) => { const model = findAgentModel(models, value); if (!model) return; - claudeDesktopModelMappingsDirtyRef.current = true; + claudeModelMappingsDirtyRef.current = true; setModelSelectionError(''); - setClaudeDesktopModelMappingsDraft((current) => ({ ...current, [role]: model.name })); + setClaudeModelMappingsDraft((current) => ({ ...current, [role]: model.name })); }; - const changeClaudeDesktopCustomMapping = (enabled: boolean) => { - setClaudeDesktopCustomMapping(enabled); + const changeClaudeCustomMapping = (enabled: boolean) => { + setClaudeCustomMapping(enabled); setModelSelectionError(''); - setClaudeDesktopModelMappingsDraft((current) => { - const next: ClaudeDesktopModelMappings = { + setClaudeModelMappingsDraft((current) => { + const next: ClaudeModelMappings = { opus: resolveAgentModelForAliasMode(models, current.opus, enabled), sonnet: resolveAgentModelForAliasMode(models, current.sonnet, enabled), haiku: resolveAgentModelForAliasMode(models, current.haiku, enabled), }; - if (!sameClaudeDesktopModelMappings(current, next)) { - claudeDesktopModelMappingsDirtyRef.current = true; + if (!sameClaudeModelMappings(current, next)) { + claudeModelMappingsDirtyRef.current = true; } return next; }); @@ -798,11 +836,11 @@ export function AgentsPage() { return model.name; }; - const requireClaudeDesktopModelMappings = (): ClaudeDesktopModelMappings | null => { - if (selected !== 'claude-desktop') return null; - const resolved = {} as ClaudeDesktopModelMappings; - for (const role of claudeDesktopMappingRoles) { - const model = findAgentModel(models, claudeDesktopModelMappingsDraft[role.key]); + const requireClaudeModelMappings = (): ClaudeModelMappings | null => { + if (!isClaudeModelMappingClient) return null; + const resolved = {} as ClaudeModelMappings; + for (const role of claudeMappingRoles) { + const model = findAgentModel(models, claudeModelMappingsDraft[role.key]); if (!model) { setModelSelectionError(t('agents.error.mappingSelectionGone')); return null; @@ -842,10 +880,10 @@ export function AgentsPage() { const applyConfigurationChanges = async () => { setConfigurationError(''); - const claudeDesktopModelMappings = requireClaudeDesktopModelMappings(); - if (selected === 'claude-desktop' && !claudeDesktopModelMappings) return; - const model = selected === 'claude-desktop' - ? claudeDesktopModelMappings?.sonnet ?? null + const claudeModelMappings = requireClaudeModelMappings(); + if (isClaudeModelMappingClient && !claudeModelMappings) return; + const model = isClaudeModelMappingClient + ? claudeModelMappings?.sonnet ?? null : requireSelectedModel(); if (!model) return; setBusyAction('apply'); @@ -854,11 +892,12 @@ export function AgentsPage() { client: selected, model, oauthConfiguration, - claudeDesktopModelMappings, + claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null, + claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null, }); await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); - claudeDesktopModelMappingsDirtyRef.current = false; + claudeModelMappingsDirtyRef.current = false; } catch (requestError) { if (!handleOAuthLoginError(requestError, 'apply')) { setConfigurationError(String(requestError)); @@ -883,10 +922,10 @@ export function AgentsPage() { }; const resetConfigurationToDefault = async () => { - const claudeDesktopModelMappings = requireClaudeDesktopModelMappings(); - if (selected === 'claude-desktop' && !claudeDesktopModelMappings) return; - const model = selected === 'claude-desktop' - ? claudeDesktopModelMappings?.sonnet ?? null + const claudeModelMappings = requireClaudeModelMappings(); + if (isClaudeModelMappingClient && !claudeModelMappings) return; + const model = isClaudeModelMappingClient + ? claudeModelMappings?.sonnet ?? null : requireSelectedModel(); if (!model) return; setBusyAction('default'); @@ -896,12 +935,13 @@ export function AgentsPage() { client: selected, model, oauthConfiguration, - claudeDesktopModelMappings, + claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null, + claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null, }); setDefaultConfirmOpen(false); await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); - claudeDesktopModelMappingsDirtyRef.current = false; + claudeModelMappingsDirtyRef.current = false; } catch (requestError) { if (!handleOAuthLoginError(requestError, 'apply')) { setDefaultError(String(requestError)); @@ -928,7 +968,38 @@ export function AgentsPage() { } }; - const launchAgent = async (target: AgentLaunchTarget | null) => { + const openClaudeCodeLaunchDialog = () => { + setClaudeCodeLaunchDirectory(activeStatus?.claudeCodeWorkingDirectory ?? ''); + setClaudeCodeSuppressDirectoryPrompt(false); + setClaudeCodeDirectoryError(''); + setClaudeCodeLaunchDialogOpen(true); + }; + + const chooseClaudeCodeWorkingDirectory = async () => { + setBusyAction('directory'); + setClaudeCodeDirectoryError(''); + try { + const selectedDirectory = await open({ + directory: true, + multiple: false, + defaultPath: claudeCodeLaunchDirectory || activeStatus?.claudeCodeWorkingDirectory || undefined, + title: t('agents.claudeCodeLaunch.dialogTitle'), + }); + if (typeof selectedDirectory === 'string') { + setClaudeCodeLaunchDirectory(selectedDirectory); + } + } catch (requestError) { + setClaudeCodeDirectoryError(String(requestError)); + } finally { + setBusyAction(null); + } + }; + + const invokeAgentLaunch = async ( + target: AgentLaunchTarget, + workingDirectory: string | null = null, + suppressWorkingDirectoryPrompt: boolean | null = null, + ) => { if (!target) return; const launchAction = selected === 'codex' ? target.id === 'cli' ? 'launch-cli' : 'launch-app' @@ -939,14 +1010,62 @@ export function AgentsPage() { if (draftChanged) { throw new Error(t('agents.error.applyFirst')); } - await invoke('launch_agent', { client: selected, target: target.id }); + await invoke('launch_agent', { + client: selected, + target: target.id, + workingDirectory, + suppressWorkingDirectoryPrompt, + }); + if (selected === 'claude-code' && workingDirectory) { + setClaudeCodeLaunchDialogOpen(false); + await reloadStatusesAfterAction(); + } } catch (requestError) { - if (!handleOAuthLoginError(requestError, 'launch')) setLaunchError(String(requestError)); + const message = String(requestError); + if ( + selected === 'claude-code' + && (message.includes('CLAUDE_CODE_WORKING_DIRECTORY_REQUIRED') + || message.includes('CLAUDE_CODE_WORKING_DIRECTORY_INVALID')) + ) { + openClaudeCodeLaunchDialog(); + setClaudeCodeDirectoryError( + message.includes('CLAUDE_CODE_WORKING_DIRECTORY_INVALID') ? message : '', + ); + } else if (selected === 'claude-code' && workingDirectory) { + setClaudeCodeDirectoryError(message); + } else if (!handleOAuthLoginError(requestError, 'launch')) { + setLaunchError(message); + } } finally { setBusyAction(null); } }; + const launchAgent = async (target: AgentLaunchTarget | null) => { + if (!target) return; + if (selected === 'claude-code' && !activeStatus?.claudeCodeWorkingDirectoryPromptDisabled) { + openClaudeCodeLaunchDialog(); + return; + } + await invokeAgentLaunch(target); + }; + + const launchClaudeCodeFromDialog = async () => { + const target = defaultLaunchTarget; + if (!target) return; + const workingDirectory = claudeCodeLaunchDirectory.trim(); + if (!workingDirectory) { + setClaudeCodeDirectoryError(t('agents.claudeCodeLaunch.directoryRequired')); + return; + } + setClaudeCodeDirectoryError(''); + await invokeAgentLaunch( + target, + workingDirectory, + claudeCodeSuppressDirectoryPrompt, + ); + }; + const closeChatGptApp = async () => { setBusyAction('close-app'); setCloseAppError(''); @@ -1058,30 +1177,33 @@ export function AgentsPage() {
-
- {availableSubpages.map((subpage) => ( - - ))} -
+ {selected !== 'claude-code' ? ( +
+ {availableSubpages.map((subpage) => ( + + ))} +
+ ) : null} {activeSubpage === 'core' ? (
@@ -1104,7 +1226,7 @@ export function AgentsPage() {
) : null} - {selected !== 'claude-desktop' ? ( + {!isClaudeModelMappingClient ? (
{t('agents.useModel')}
@@ -1131,43 +1253,52 @@ export function AgentsPage() {
) : null} - {selected === 'claude-desktop' ? ( -
+ {isClaudeModelMappingClient ? ( +
- {t('agents.claudeDesktopMapping.title')} - {t('agents.claudeDesktopMapping.description')} + {t(selected === 'claude-code' + ? 'agents.claudeCodeMapping.title' + : 'agents.claudeDesktopMapping.title')} + {t(selected === 'claude-code' + ? 'agents.claudeCodeMapping.description' + : 'agents.claudeDesktopMapping.description')}
- {claudeDesktopMappingDraftChanged ? {t('agents.pending')} : null} + {claudeMappingDraftChanged ? {t('agents.pending')} : null}
- {claudeDesktopMappingRoles.map((role) => ( -
- {t(role.labelKey)} + {claudeMappingRoles.map((role) => ( +
+
+ {t(selected === 'claude-code' ? role.codeLabelKey : role.labelKey)} + {selected === 'claude-code' ? {t(role.codeHintKey)} : null} +
selectClaudeDesktopModelMapping(role.key, value)} + onChange={(value) => selectClaudeModelMapping(role.key, value)} onRefresh={refreshModels} />
@@ -1231,7 +1362,9 @@ export function AgentsPage() { onClick={openDefaultConfirmation} disabled={busy || !canEnable} > - {busyAction === 'default' ? : } + {busyAction === 'default' + ? + : selected === 'claude-code' ? null : } {t('agents.modify.default')} {selected === 'codex' ? ( @@ -1301,7 +1434,9 @@ export function AgentsPage() { ? defaultLaunchTarget?.detail : t('agents.launch.enableFirst')} > - {busyAction === 'launch' ? : } + {busyAction === 'launch' + ? + : selected === 'claude-code' ? null : } {busyAction === 'launch' ? t('agents.launch.starting') : defaultLaunchTarget ? t('agents.launch.start', { target: defaultLaunchTarget.label }) : t('agents.launch.unavailable')} )} @@ -1345,6 +1480,85 @@ export function AgentsPage() {
+ {claudeCodeLaunchDialogOpen ? ( +
{ + if (event.currentTarget === event.target && !busy) { + setClaudeCodeLaunchDialogOpen(false); + } + }}> +
+
+
+

+ {t('agents.claudeCodeLaunch.dialogTitle')} +

+
+
+

{t('agents.claudeCodeLaunch.dialogDescription')}

+
+ {t('agents.claudeCodeLaunch.workingDirectory')} + +
+ + {claudeCodeDirectoryError ? ( + + {claudeCodeDirectoryError} + + ) : null} +
+ + +
+
+
+ ) : null} + {defaultConfirmOpen ? (
diff --git a/src/styles.css b/src/styles.css index b6c5867..bbc6eb8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5310,6 +5310,127 @@ textarea:focus-visible { font-size: var(--font-size-label); } +.agent-claude-mapping-role { + display: grid; + min-width: 0; + gap: 3px; +} + +.agent-claude-mapping-role > strong { + color: var(--theme-5f5a53); + font-size: var(--font-size-label); +} + +.agent-claude-mapping-role > span { + overflow: hidden; + color: var(--theme-8a847c); + font-size: var(--font-size-meta); + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-claude-code-config { + gap: 12px; +} + +.agent-claude-code-config .agent-status-grid { + min-height: 0; + gap: 0; + padding: 10px 6px; + border: 1px solid var(--theme-e3e1db); + border-radius: 8px; + background: var(--theme-fffdf8); +} + +.agent-claude-code-config .agent-status-grid > div { + gap: 3px; + padding: 2px 12px; + border: 0; + border-radius: 0; + background: transparent; +} + +.agent-claude-code-config .agent-status-grid > div + div { + border-left: 1px solid var(--theme-e3e1db); +} + +.agent-claude-code-mapping { + gap: 14px; + padding: 18px; + border-color: var(--theme-d8d2c8); + background: var(--theme-fffdf8); +} + +.agent-claude-code-mapping > .agent-section-heading { + padding-bottom: 13px; + border-bottom: 1px solid var(--theme-e3e1db); +} + +.agent-claude-code-mapping .agent-claude-desktop-mapping-grid { + gap: 12px; +} + +.agent-claude-code-mapping .agent-claude-desktop-mapping-row { + position: relative; + gap: 10px; + padding: 13px; + overflow: hidden; + border: 1px solid var(--theme-e3e1db); + border-radius: 8px; + background: var(--theme-fff); +} + +.agent-claude-code-mapping .agent-claude-desktop-mapping-row::before { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 3px; + background: var(--theme-bdb5a9); + content: ''; +} + +.agent-claude-code-mapping .agent-claude-desktop-mapping-row.sonnet { + border-color: var(--theme-d8d1eb); + background: var(--theme-f8f5ff); +} + +.agent-claude-code-mapping .agent-claude-desktop-mapping-row.sonnet::before { + background: var(--theme-8069ad); +} + +.agent-claude-code-mapping .agent-claude-desktop-mapping-row.opus::before { + background: var(--theme-bf8a43); +} + +.agent-claude-code-mapping .agent-claude-desktop-mapping-row.haiku::before { + background: var(--theme-4f825c); +} + +.agent-claude-code-mapping .agent-claude-desktop-mapping-filter { + padding: 7px 9px; + border-radius: 8px; +} + +.agent-claude-code-config .agent-modification-actions { + grid-column: 1 / -1; + background: var(--theme-fffdf8); +} + +.agent-claude-code-config .agent-modification-control > .agent-inline-message { + width: 100%; +} + +.agent-claude-code-config .agent-config-footer { + padding-top: 0; + border-top: 0; +} + +.agent-claude-code-config .agent-launch-button { + min-width: 190px; + min-height: 42px; +} + .agent-section-heading-actions { display: flex !important; flex: none; @@ -5343,6 +5464,81 @@ textarea:focus-visible { opacity: 0.62; } +.agent-claude-code-launch-dialog { + grid-template-rows: auto; + width: min(620px, calc(100vw - 32px)); + height: auto; + min-height: 0; +} + +.agent-claude-code-launch-dialog > p { + margin: 0; + color: var(--theme-5f5a53); + font-size: var(--font-size-label); + line-height: var(--line-height-relaxed); +} + +.agent-claude-code-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; + transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; +} + +.agent-claude-code-directory-picker:hover:not(:disabled) { + border-color: var(--theme-bdb5a9); + background: var(--theme-faf8f4); +} + +.agent-claude-code-directory-picker:focus-visible { + border-color: var(--theme-8d8377); + outline: 0; + box-shadow: 0 0 0 3px rgba(45, 42, 38, 0.1); +} + +.agent-claude-code-directory-picker > span { + display: grid; + min-width: 0; + gap: 4px; +} + +.agent-claude-code-directory-picker small { + color: var(--theme-8a847c); + font-size: var(--font-size-meta); +} + +.agent-claude-code-directory-picker strong { + overflow: hidden; + font-size: var(--font-size-label); + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-claude-code-directory-picker > b { + flex: none; + color: var(--theme-8069ad); + font-size: var(--font-size-label); +} + +.agent-claude-code-suppress-prompt { + display: flex; + align-items: center; + gap: 8px; + color: var(--theme-5f5a53); + font-size: var(--font-size-label); + cursor: pointer; +} + .agent-section-heading { display: flex; align-items: center; @@ -7097,6 +7293,28 @@ textarea:focus-visible { grid-column: 2; height: 100%; } + + .agent-claude-code-config .agent-modification-actions { + grid-column: 1 / -1; + grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); + align-items: center; + height: auto; + } + + .agent-claude-code-config .agent-modification-control { + justify-items: end; + } + + .agent-claude-code-config .agent-modification-buttons { + display: flex; + width: auto; + justify-content: flex-end; + } + + .agent-claude-code-config .agent-modification-buttons button { + width: auto; + min-width: 152px; + } } @container agent-panel (max-width: 760px) { @@ -7127,6 +7345,19 @@ textarea:focus-visible { } @container agent-panel (max-width: 640px) { + .agent-claude-code-config .agent-status-grid { + padding: 6px 12px; + } + + .agent-claude-code-config .agent-status-grid > div { + padding: 8px 0; + } + + .agent-claude-code-config .agent-status-grid > div + div { + border-top: 1px solid var(--theme-e3e1db); + border-left: 0; + } + .agent-claude-desktop-mapping > .agent-section-heading { align-items: stretch; flex-direction: column;