From 9dbae1d189b60c82a991d238355e648018da8247 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 21:55:51 +0800 Subject: [PATCH] feat(agents): configurable executable for every built-in agent (#682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Tools › Agents is split into Built-in and ACP Registry tabs. The Built-in tab lists every built-in agent with its status (custom executable or not found on PATH) and, for the selected agent, its default launch line, an Executable field (empty = auto-detect on PATH) with Browse and Reset, and its sign-in hint. Edits are staged with the Settings dialog and written on Apply / OK; Cancel discards them. The override is written to /agents/.toml, keeping hand-written name, driver, args and env; resetting a command-only override removes the file. AgentProvider now exposes the program, args, built-in defaults, login hint and whether the command is editable: built-ins started through a package runner (npx) and registry installs are read-only, with the reason shown. Fixes #596 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-agent/src/lib.rs | 25 +- crates/ide-agent/src/model.rs | 18 + crates/ide-agent/src/provider.rs | 214 ++++++++++- crates/ide-agent/src/registry.rs | 20 +- crates/ide-host/src/ops/agent.rs | 8 + crates/ide-host/src/ops/dependencies.rs | 2 +- src-tauri/src/commands/agent.rs | 13 + src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 17 + src/features/agents/AgentRegistry.module.css | 7 +- src/features/agents/AgentRegistryPage.tsx | 37 +- src/features/agents/BuiltinAgents.module.css | 84 +++++ src/features/agents/BuiltinAgents.tsx | 182 +++++++++ src/features/agents/agentRegistry.test.tsx | 357 +++++++++++++++++- src/features/agents/composerCommands.test.tsx | 8 + src/features/agents/registry.ts | 28 +- src/features/settings/components/context.ts | 17 +- .../settings/dialog/SettingsDialog.tsx | 9 +- src/features/settings/model/staging.ts | 56 ++- src/features/tools/__tests__/settingsForm.tsx | 3 + src/platform/locales/text/zh.json | 26 ++ src/workbench/contributions.ts | 11 + 22 files changed, 1086 insertions(+), 57 deletions(-) create mode 100644 src/features/agents/BuiltinAgents.module.css create mode 100644 src/features/agents/BuiltinAgents.tsx diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index cabe079e..a11036b4 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -301,17 +301,11 @@ impl AgentManager { let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); let list = specs .iter() - .map(|p| AgentProvider { - id: p.id.clone(), - name: p.name.clone(), - command: std::iter::once(p.command.as_str()) - .chain(p.args.iter().map(String::as_str)) - .collect::>() - .join(" "), - installed: provider::installed(&p.command), - config: self.shared.known_config(&p.id), - commands: self.shared.known_commands(&p.id), - source: p.source.clone(), + .map(|p| { + let mut info = provider::describe(p); + info.config = self.shared.known_config(&p.id); + info.commands = self.shared.known_commands(&p.id); + info }) .collect(); let mut prev = self.shared.providers.write(); @@ -325,6 +319,15 @@ impl AgentManager { list } + /// Overrides built-in `id`'s command with `command` (the driver and arguments stay + /// the built-in's) through its agent file, or returns it to auto-detection with `None`. + pub fn set_builtin_command(&self, id: &str, command: Option) -> IdeResult<()> { + let Some(dir) = self.shared.paths.custom_agents_dir.clone() else { + return Err(IdeError::unavailable("there is no custom agents directory")); + }; + provider::set_builtin_command(&dir, id, command.as_deref()) + } + /// Re-reads configured/installed agents, drops cached probes, and notifies subscribers. pub fn reload_providers(&self) { let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 1ba50ae8..b3781cd9 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -322,6 +322,15 @@ pub struct AgentProvider { pub name: String, /// The command line that starts its ACP server. pub command: String, + /// Executable and arguments actually launched, without flattening paths containing spaces. + pub program: String, + pub args: Vec, + pub default_program: Option, + pub default_args: Vec, + pub login_hint: Option, + pub command_overridden: bool, + pub command_editable: bool, + pub command_read_only_reason: Option, /// Whether the command was found (on `PATH`). pub installed: bool, /// Settings (models, modes…) the agent offered last time it ran; empty before its first session. @@ -331,6 +340,15 @@ pub struct AgentProvider { pub source: AgentSource, } +/// Why the executable cannot be changed in built-in agent settings. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AgentCommandReadOnlyReason { + PackageRunner, + Registry, + CustomAgent, +} + /// Where an agent comes from. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 17608704..0b39eade 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -2,7 +2,8 @@ //! `/agents/.toml` (written by hand, or by installing one from the ACP //! Registry, see `registry`). -use crate::AgentSource; +use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource}; +use ide_core::{IdeError, IdeResult}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -92,6 +93,82 @@ pub(crate) fn builtin_name(id: &str) -> Option<&'static str> { BUILTIN.iter().find(|&&(bid, _, _, _, _)| bid == id).map(|&(_, name, _, _, _)| name) } +/// The built-in spec of `id`. +pub(crate) fn builtin_spec(id: &str) -> Option { + builtins().into_iter().find(|s| s.id == id) +} + +/// Public launch metadata, keeping the built-in defaults separate from agent-file overrides. +pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider { + let builtin = builtin_spec(&spec.id); + let reason = if matches!(spec.source, AgentSource::Registry { .. }) { + Some(AgentCommandReadOnlyReason::Registry) + } else if let Some(default) = &builtin { + matches!(default.command.as_str(), "npx" | "bunx" | "uvx").then_some(AgentCommandReadOnlyReason::PackageRunner) + } else { + Some(AgentCommandReadOnlyReason::CustomAgent) + }; + AgentProvider { + id: spec.id.clone(), + name: spec.name.clone(), + command: std::iter::once(spec.command.as_str()) + .chain(spec.args.iter().map(String::as_str)) + .collect::>() + .join(" "), + program: spec.command.clone(), + args: spec.args.clone(), + default_program: builtin.as_ref().map(|b| b.command.clone()), + default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(), + login_hint: Some(spec.login_hint.clone()), + command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command), + command_editable: reason.is_none(), + command_read_only_reason: reason, + installed: installed(&spec.command), + config: vec![], + commands: vec![], + source: spec.source.clone(), + } +} + +/// Overrides built-in `id`'s command through its agent file, preserving other +/// customizations. `None` restores the built-in command and removes a redundant file. +pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> { + let Some(builtin) = builtin_spec(id) else { + return Err(IdeError::invalid(format!("{id} is not a built-in agent"))); + }; + let file = custom_dir.join(format!("{id}.toml")); + let mut agent = if file.try_exists()? { + CustomAgent::read(&file).map_err(IdeError::invalid)? + } else { + CustomAgent { + name: Some(builtin.name.clone()), + driver: builtin.driver, + command: builtin.command.clone(), + args: builtin.args.clone(), + env: BTreeMap::new(), + registry: None, + } + }; + if agent.registry.is_some() { + return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry"))); + } + if command.is_none() + && agent.name.as_deref() == Some(builtin.name.as_str()) + && agent.driver == builtin.driver + && agent.args == builtin.args + && agent.env.is_empty() + { + match std::fs::remove_file(&file) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()), + _ => {} + } + return Ok(()); + } + agent.command = command.unwrap_or(&builtin.command).to_owned(); + agent.write(&file)?; + Ok(()) +} + /// An agent file, `/agents/.toml`. #[derive(Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -182,6 +259,9 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec { match specs.iter_mut().find(|s| s.id == spec.id) { Some(existing) => { spec.login_hint = existing.login_hint.clone(); + if spec.source == AgentSource::Custom { + spec.source = AgentSource::Builtin; + } *existing = spec; } None => specs.push(spec), @@ -221,6 +301,54 @@ fn is_executable(p: &Path) -> bool { mod tests { use super::*; + #[test] + fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() { + let dir = tempfile::tempdir().unwrap(); + let list = || load(Some(dir.path())).iter().map(describe).collect::>(); + let defaults = list(); + let codex = defaults.iter().find(|p| p.id == "codex").unwrap(); + assert_eq!(codex.program, "codex"); + assert_eq!(codex.default_program.as_deref(), Some("codex")); + assert_eq!(codex.args, ["app-server"]); + assert_eq!(codex.default_args, codex.args); + assert_eq!(codex.command, "codex app-server"); + assert_eq!(codex.login_hint.as_deref(), Some("Run `codex login` in a terminal.")); + assert!(!codex.command_overridden); + assert!(codex.command_editable); + for p in &defaults { + assert_eq!(p.source, AgentSource::Builtin); + assert!(p.default_program.is_some()); + assert_eq!(p.command_editable, !p.id.ends_with("-acp")); + assert_eq!( + p.command_read_only_reason, + p.id.ends_with("-acp").then_some(AgentCommandReadOnlyReason::PackageRunner) + ); + } + set_builtin_command(dir.path(), "codex", Some("/path with spaces/codex")).unwrap(); + let overridden = list().into_iter().find(|p| p.id == "codex").unwrap(); + assert_eq!(overridden.program, "/path with spaces/codex"); + assert_eq!(overridden.args, codex.args); + assert_eq!(overridden.default_program, codex.default_program); + assert!(overridden.command_overridden && overridden.command_editable); + assert_eq!(overridden.source, AgentSource::Builtin); + set_builtin_command(dir.path(), "codex", Some("codex")).unwrap(); + assert!(!list().iter().find(|p| p.id == "codex").unwrap().command_overridden); + std::fs::write( + dir.path().join("gemini.toml"), + "command = \"/registry/gemini\"\n[registry]\nid = \"gemini\"\nversion = \"1.0\"\n", + ) + .unwrap(); + let registry = list().into_iter().find(|p| p.id == "gemini").unwrap(); + assert_eq!(registry.default_program.as_deref(), Some("gemini")); + assert!(!registry.command_editable); + assert_eq!(registry.command_read_only_reason, Some(AgentCommandReadOnlyReason::Registry)); + assert!(matches!(registry.source, AgentSource::Registry { .. })); + std::fs::write(dir.path().join("mine.toml"), "command = \"mine\"\n").unwrap(); + let custom = list().into_iter().find(|p| p.id == "mine").unwrap(); + assert_eq!(custom.default_program, None); + assert_eq!(custom.command_read_only_reason, Some(AgentCommandReadOnlyReason::CustomAgent)); + } + #[test] fn custom_agents_extend_and_override_the_builtins() { let dir = tempfile::tempdir().unwrap(); @@ -240,6 +368,90 @@ mod tests { assert!(installed("sh")); } + #[test] + fn set_builtin_command_overrides_and_clears_a_builtin() { + let dir = tempfile::tempdir().unwrap(); + set_builtin_command(dir.path(), "not-a-builtin", Some("/x")).unwrap_err(); + set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap(); + let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap(); + assert_eq!((claude.command.as_str(), claude.driver), ("/opt/claude", Driver::Claude)); + assert!(claude.args.is_empty()); + // A registry install is neither overwritten nor removed by a command override. + let path = dir.path().join("codex.toml"); + CustomAgent { + name: None, + driver: Driver::Acp, + command: "amp".into(), + args: vec![], + env: BTreeMap::new(), + registry: Some(RegistryRef { id: "amp-acp".into(), version: "1.0.0".into() }), + } + .write(&path) + .unwrap(); + set_builtin_command(dir.path(), "codex", Some("/x")).unwrap_err(); + set_builtin_command(dir.path(), "codex", None).unwrap_err(); + assert!(path.exists()); + // Clearing (twice) returns the built-in to auto-detection. + set_builtin_command(dir.path(), "claude", None).unwrap(); + set_builtin_command(dir.path(), "claude", None).unwrap(); + assert!(!dir.path().join("claude.toml").exists()); + assert_eq!(load(Some(dir.path())).iter().find(|s| s.id == "claude").unwrap().command, "claude"); + } + + #[test] + fn set_builtin_command_preserves_other_customizations_on_save_and_reset() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("claude.toml"); + for field in ["name", "driver", "args", "env", "unnamed"] { + let mut agent = CustomAgent { + name: Some("Claude Code".into()), + driver: Driver::Claude, + command: "/old/claude".into(), + args: vec![], + env: BTreeMap::new(), + registry: None, + }; + match field { + "name" => agent.name = Some("My Claude".into()), + "driver" => agent.driver = Driver::Acp, + "args" => agent.args.push("--verbose".into()), + "env" => { + agent.env.insert("K".into(), "v".into()); + } + "unnamed" => agent.name = None, + _ => unreachable!(), + } + agent.write(&path).unwrap(); + + set_builtin_command(dir.path(), "claude", Some("/new/claude")).unwrap(); + agent.command = "/new/claude".into(); + assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}"); + + set_builtin_command(dir.path(), "claude", None).unwrap(); + agent.command = "claude".into(); + assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}"); + } + } + + #[test] + fn resetting_a_command_only_override_removes_the_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("codex.toml"); + std::fs::write( + &path, + "name = \"Codex\"\ndriver = \"codex\"\ncommand = \"/custom/codex\"\nargs = [\"app-server\"]\n", + ) + .unwrap(); + + set_builtin_command(dir.path(), "codex", None).unwrap(); + + assert!(!path.exists()); + assert_eq!( + load(Some(dir.path())).into_iter().find(|s| s.id == "codex").unwrap(), + builtin_spec("codex").unwrap() + ); + } + #[test] fn agent_files_round_trip_with_their_registry_table() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-agent/src/registry.rs b/crates/ide-agent/src/registry.rs index 1c22007d..602a4a1c 100644 --- a/crates/ide-agent/src/registry.rs +++ b/crates/ide-agent/src/registry.rs @@ -826,15 +826,7 @@ mod tests { let mut providers: Vec = provider::load(Some(&p.agents_dir)) .into_iter() .filter(|s| s.id == "amp-acp") - .map(|s| AgentProvider { - id: s.id, - name: s.name, - command: s.command, - installed: false, - config: vec![], - commands: vec![], - source: s.source, - }) + .map(|s| provider::describe(&s)) .collect(); mark_updates(&p, &mut providers); assert_eq!( @@ -1001,15 +993,7 @@ mod tests { assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in."); assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None }); - let mut providers = vec![AgentProvider { - id: spec.id, - name: spec.name, - command: spec.command, - installed: true, - config: vec![], - commands: vec![], - source: spec.source, - }]; + let mut providers = vec![provider::describe(&spec)]; mark_updates(&p, &mut providers); assert_eq!( providers[0].source, diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index 5c16f270..758cebb9 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -23,6 +23,13 @@ pub fn providers(host: &Host, window: &str) -> IdeResult> { Ok(providers) } +/// Overrides built-in agent `id`'s command with `command` (its agent file keeps the +/// built-in's driver and arguments), or returns it to auto-detection with `None`. +pub fn set_command(host: &Host, window: &str, id: String, command: Option) -> IdeResult<()> { + host.require_trust(window)?; + host.project(window)?.agents.set_builtin_command(&id, command) +} + /// Where ACP Registry agents are saved (`/agents`, like the user's own) and installed. pub(crate) fn registry_paths(host: &Host) -> RegistryPaths { RegistryPaths::new(&host.config_dir, &host.local_data_dir) @@ -187,6 +194,7 @@ pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) - pub(crate) fn register(d: &mut Dispatcher) { d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) }); d.add("agent_providers", |c| async move { providers(&c.host, &c.window) }); + d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) }); d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await }); d.add("agent_registry_install", |c| async move { registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs index 0dc62207..96709587 100644 --- a/crates/ide-host/src/ops/dependencies.rs +++ b/crates/ide-host/src/ops/dependencies.rs @@ -91,7 +91,7 @@ mod tests { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), - local_data_dir: base.join("data"), + local_data_dir: base.join("local"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(|_| {})), diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index 21b699c9..cf870663 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -36,6 +36,19 @@ pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult< agent::providers(&state.host, window.label()) } +/// Overrides built-in agent `id`'s command with `command` (its agent file keeps the +/// built-in's driver and arguments), or returns it to auto-detection with `None`. +#[tauri::command] +#[specta::specta] +pub fn agent_set_command( + window: Window, + state: State<'_, AppState>, + id: String, + command: Option, +) -> IdeResult<()> { + agent::set_command(&state.host, window.label(), id, command) +} + /// Agents of the ACP Registry this machine can run, with what is installed; the list /// saved earlier when the registry is unreachable. Cancel with `cancel_request(request)`. #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0a410b34..f0066101 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -97,6 +97,7 @@ pub fn builder() -> tauri_specta::Builder { app::log_frontend, agent::agent_subscribe, agent::agent_providers, + agent::agent_set_command, agent::agent_registry_list, agent::agent_registry_install, agent::agent_registry_remove, diff --git a/src/bindings.ts b/src/bindings.ts index c7bc9c42..31aa20b4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -86,6 +86,11 @@ export const commands = { /** Streams the project's agent events to this window; returns every task. */ agentSubscribe: (onEvent: Channel) => typedError(__TAURI_INVOKE("agent_subscribe", { onEvent })), agentProviders: () => typedError(__TAURI_INVOKE("agent_providers")), + /** + * Overrides built-in agent `id`'s command with `command` (its agent file keeps the + * built-in's driver and arguments), or returns it to auto-detection with `None`. + */ + agentSetCommand: (id: string, command: string | null) => typedError(__TAURI_INVOKE("agent_set_command", { id, command })), /** * Agents of the ACP Registry this machine can run, with what is installed; the list * saved earlier when the registry is unreachable. Cancel with `cancel_request(request)`. @@ -1142,6 +1147,9 @@ export type AgentCommand = { hint: string | null, }; +/** Why the executable cannot be changed in built-in agent settings. */ +export type AgentCommandReadOnlyReason = "packageRunner" | "registry" | "customAgent"; + /** A setting chosen for a new task, by [`AgentConfigOption::id`]. */ export type AgentConfigChoice = { id: string, @@ -1274,6 +1282,15 @@ export type AgentProvider = { name: string, /** The command line that starts its ACP server. */ command: string, + /** Executable and arguments actually launched, without flattening paths containing spaces. */ + program: string, + args: string[], + defaultProgram: string | null, + defaultArgs: string[], + loginHint: string | null, + commandOverridden: boolean, + commandEditable: boolean, + commandReadOnlyReason: AgentCommandReadOnlyReason | null, /** Whether the command was found (on `PATH`). */ installed: boolean, /** Settings (models, modes…) the agent offered last time it ran; empty before its first session. */ diff --git a/src/features/agents/AgentRegistry.module.css b/src/features/agents/AgentRegistry.module.css index 0743add7..b8591ede 100644 --- a/src/features/agents/AgentRegistry.module.css +++ b/src/features/agents/AgentRegistry.module.css @@ -1,11 +1,16 @@ .body { display: flex; flex-direction: column; - gap: 8px; + gap: 12px; height: 100%; min-height: 0; } +/* The compact variant may shrink in toolbars; here it keeps its height above the panes. */ +.tabs { + flex: none; +} + .notice, .warning, .error, diff --git a/src/features/agents/AgentRegistryPage.tsx b/src/features/agents/AgentRegistryPage.tsx index e0341790..a7644a83 100644 --- a/src/features/agents/AgentRegistryPage.tsx +++ b/src/features/agents/AgentRegistryPage.tsx @@ -1,14 +1,14 @@ /** - * Settings › Tools › Agents: agents of the ACP Registry this machine can run, with - * Install / Update / Remove. Installs run in the background with progress; leaving the - * page cancels one. + * Settings › Tools › Agents: the launch command of built-in agents (Claude Code…), and + * agents of the ACP Registry this machine can run, with Install / Update / Remove. + * Installs run in the background with progress; leaving the page cancels one. */ import { useEffect, useMemo, useRef, useState } from "react"; import type { AgentRegistry, RegistryAgent, RequestId } from "@/bindings"; import { isCancelled, nextRequestId } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; import { openExternalUrl } from "@/platform/openUrl"; -import { Badge, Button, Icon, ProgressBar, SearchField, Spinner, T } from "@/ui"; +import { Badge, Button, Icon, ProgressBar, SearchField, Spinner, T, Tabs } from "@/ui"; import { confirm } from "@/workbench/messages"; import { providerIcon } from "./model"; import { @@ -18,9 +18,12 @@ import { matchesQuery, rowActions, sortAgents, + tauriCommands, tauriRegistry, + type CommandsBackend, type RegistryBackend, } from "./registry"; +import { BuiltinAgents } from "./BuiltinAgents"; import styles from "./AgentRegistry.module.css"; const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); @@ -35,11 +38,37 @@ interface Job { export function AgentRegistryPage({ onChanged, backend = tauriRegistry, + commandBackend = tauriCommands, }: { /** An agent was installed, updated or removed (the agent list changed). */ onChanged?: () => void; backend?: RegistryBackend; + commandBackend?: CommandsBackend; }) { + const [tab, setTab] = useState("builtin"); + return ( +
+ + {tab === "builtin" ? ( + + ) : ( + + )} +
+ ); +} + +function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChanged?: () => void }) { const [registry, setRegistry] = useState(null); const [loadError, setLoadError] = useState(null); const [query, setQuery] = useState(""); diff --git a/src/features/agents/BuiltinAgents.module.css b/src/features/agents/BuiltinAgents.module.css new file mode 100644 index 00000000..709b9022 --- /dev/null +++ b/src/features/agents/BuiltinAgents.module.css @@ -0,0 +1,84 @@ +.split { + display: flex; + flex: 1; + min-height: 320px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-control); + overflow: hidden; +} +.master { + flex: 0 0 300px; + padding: 6px; + border-right: 1px solid var(--border); +} +.agentRow { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + padding: 0 10px; +} +.agentName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.details { + flex: 1; + min-width: 0; + padding: 20px; + overflow: auto; +} +.title { + display: flex; + align-items: center; + gap: 8px; + color: var(--fg-strong); +} +.title h3 { + margin: 0; + font-size: var(--fs-input-large); +} +.launch { + display: block; + margin: 8px 0 0; + color: var(--fg-muted); + font-family: var(--font-mono); + font-size: var(--fs-small); + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.status { + margin: 6px 0 24px; + color: var(--fg-muted); + font-size: var(--fs-small); +} +.hint code { + font-family: var(--font-mono); + color: var(--fg); +} +.label { + display: block; + margin-bottom: 6px; +} +.controls { + display: flex; + align-items: center; + gap: 6px; +} +.controls > :first-child { + flex: 1; + min-width: 0; +} +.hint { + margin: 8px 0 16px; + color: var(--fg-muted); + font-size: var(--fs-small); + overflow-wrap: anywhere; +} +.error { + color: var(--danger); + white-space: pre-wrap; +} diff --git a/src/features/agents/BuiltinAgents.tsx b/src/features/agents/BuiltinAgents.tsx new file mode 100644 index 00000000..89156bdb --- /dev/null +++ b/src/features/agents/BuiltinAgents.tsx @@ -0,0 +1,182 @@ +/** Built-in launch settings; each executable is staged independently until Apply. */ +import { useCallback, useEffect, useState } from "react"; +import type { AgentProvider } from "@/bindings"; +import { tx, useTx } from "@/platform/i18n"; +import { Badge, Button, Icon, IconButton, Input, List, Spinner, T } from "@/ui"; +import { useSettingsForm } from "@/workbench/contributions"; +import { providerIcon } from "./model"; +import type { CommandsBackend } from "./registry"; +import styles from "./BuiltinAgents.module.css"; + +function Status({ agent }: { agent: AgentProvider }) { + const translate = useTx(); + if (!agent.commandOverridden && agent.installed) return null; + return ( + + {agent.commandOverridden ? translate("Custom") : translate("Not found")} + + ); +} + +export function BuiltinAgents({ backend, onChanged }: { backend: CommandsBackend; onChanged?: () => void }) { + const [agents, setAgents] = useState(null); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(0); + useEffect(() => { + let live = true; + backend.providers().then( + (list) => live && setAgents(list.filter((p) => p.defaultProgram != null)), + (e: unknown) => live && setError(e instanceof Error ? e.message : String(e)), + ); + return () => { + live = false; + }; + }, [backend]); + const refresh = useCallback(async () => { + const list = await backend.providers(); + setAgents(list.filter((p) => p.defaultProgram != null)); + }, [backend]); + if (error) + return ( +

+ {error} +

+ ); + if (!agents) return ; + const agent = agents[selected] ?? agents[0]; + return ( +
+ p.id} + activeIndex={selected} + onActiveChange={setSelected} + rowHeight={32} + aria-label={tx("Built-in agents")} + renderItem={(p) => ( +
+ + {p.name} +
+ )} + /> + {agent &&
} +
+ ); +} + +function Details({ + agent, + backend, + refresh, + onChanged, +}: { + agent: AgentProvider; + backend: CommandsBackend; + refresh: () => Promise; + onChanged?: () => void; +}) { + const form = useSettingsForm(); + const base = agent.commandOverridden ? agent.program : ""; + const { value, current, set, saving } = form.useExternal(`agents.${agent.id}.command`, base, async (draft) => { + const command = draft.trim() || null; + await backend.set(agent.id, command); + await refresh(); + onChanged?.(); + return command === agent.defaultProgram ? "" : (command ?? ""); + }); + const [error, setError] = useState(null); + // An edit can have been staged by a previous page instance. Its Apply callback + // refreshes that instance; the mounted one follows the newly applied value too. + useEffect(() => { + if (!saving && current !== base) { + void refresh().catch((e: unknown) => setError(e instanceof Error ? e.message : String(e))); + } + }, [base, current, saving, refresh]); + const launch = [agent.defaultProgram, ...agent.defaultArgs].join(" "); + return ( +
+
+ +

{agent.name}

+
+ {launch} +

+ {agent.commandOverridden ? ( + Custom executable + ) : agent.installed ? ( + Found on PATH + ) : ( + Not found on PATH + )} +

+ {agent.commandEditable ? ( + <> + +
+ set(e.target.value)} + /> + { + try { + const path = await backend.chooseExecutable(tx`Select ${agent.name}`, value); + if (path) set(path); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }} + /> + +
+

+ Leave empty to find the executable on PATH. Changes apply to new sessions. +

+ + ) : ( +

+ {agent.commandReadOnlyReason === "packageRunner" ? ( + This agent runs through a package runner (npm or uv) and has no executable to choose. + ) : agent.commandReadOnlyReason === "registry" ? ( + This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab. + ) : ( + This agent is configured in its agent file. + )} +

+ )} + {agent.loginHint && ( +

+ +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} + +/** Login hints are plain text with optional backtick-delimited command spans. */ +function LoginHint({ text }: { text: string }) { + const translate = useTx(); + return translate(text) + .split(/(`[^`]+`)/g) + .map((part, i) => (i % 2 === 1 ? {part.slice(1, -1)} : part)); +} diff --git a/src/features/agents/agentRegistry.test.tsx b/src/features/agents/agentRegistry.test.tsx index f9c2bc8e..98624633 100644 --- a/src/features/agents/agentRegistry.test.tsx +++ b/src/features/agents/agentRegistry.test.tsx @@ -1,13 +1,35 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { AgentProvider, AgentRegistry, InstallEvent, RegistryAgent, RequestId } from "@/bindings"; import { IpcError } from "@/platform/ipc"; -import { tx } from "@/platform/i18n"; +import { setLocale, tx } from "@/platform/i18n"; import { iconMarkup } from "@/ui/icons"; import { MessagesHost } from "@/workbench/messages"; -import { AgentRegistryPage } from "./AgentRegistryPage"; +import { AgentRegistryPage as RegistryPage } from "./AgentRegistryPage"; +import { SettingsFormContext, registerSettingsPage } from "@/workbench/contributions"; +import { useSettingsFormFor } from "@/features/settings/components/context"; +import { closeSettings, openSettings, selectPage } from "@/features/settings/model/staging"; +import { SettingsDialog } from "@/features/settings/dialog/SettingsDialog"; +import { REGISTRY_PAGE } from "./model"; +import type { ComponentProps } from "react"; + +function AgentRegistryPage(props: ComponentProps) { + const form = useSettingsFormFor(REGISTRY_PAGE, "global"); + return ( + + + + ); +} import { ADD_AGENT, providerOptions } from "./providerOptions"; -import { afterRemove, matchesQuery, rowActions, sortAgents, type RegistryBackend } from "./registry"; +import { + afterRemove, + matchesQuery, + rowActions, + sortAgents, + type CommandsBackend, + type RegistryBackend, +} from "./registry"; const agent = (partial: Partial & Pick): RegistryAgent => ({ version: "1.0.0", @@ -114,6 +136,14 @@ describe("agent picker", () => { const provider = (p: Partial & Pick): AgentProvider => ({ name: p.id, command: p.id, + program: p.id, + args: [], + defaultProgram: p.id, + defaultArgs: [], + loginHint: null, + commandOverridden: false, + commandEditable: true, + commandReadOnlyReason: null, installed: true, config: [], commands: [], @@ -143,10 +173,15 @@ describe("agent picker", () => { }); describe("Agents settings page", () => { + const renderRegistry = (...args: Parameters) => { + const result = render(...args); + fireEvent.click(screen.getByRole("tab", { name: "ACP Registry" })); + return result; + }; it("shows the built-in Copilot name and icon in its registry row", async () => { const backend = new FakeBackend(); backend.registry = { agents: [agent({ id: "copilot", name: "GitHub Copilot" })], warning: null }; - render(); + renderRegistry(); const copilot = await screen.findByRole("listitem", { name: "GitHub Copilot" }); const expected = document.createElement("div"); expected.innerHTML = iconMarkup("agent-copilot")!; @@ -156,7 +191,7 @@ describe("Agents settings page", () => { it("lists registry agents, says they are third-party, and searches them", async () => { const backend = new FakeBackend(); - render(); + renderRegistry(); await waitFor(() => expect(row("Amp")).toBeInTheDocument()); expect(screen.getByText(/third-party software/)).toBeInTheDocument(); expect(within(row("Amp")).getByRole("button", { name: "Install" })).toBeEnabled(); @@ -175,7 +210,7 @@ describe("Agents settings page", () => { it("installs with progress, then reports the change", async () => { const backend = new FakeBackend(); const onChanged = vi.fn(); - render(); + renderRegistry(); await waitFor(() => expect(row("Amp")).toBeInTheDocument()); fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" })); expect(backend.installs).toEqual(["amp-acp"]); @@ -194,7 +229,7 @@ describe("Agents settings page", () => { it("asks before installing an agent whose download has no checksum", async () => { const backend = new FakeBackend(); backend.registry = { agents: [{ ...AMP, noChecksum: true }], warning: null }; - render( + renderRegistry( <> @@ -218,7 +253,7 @@ describe("Agents settings page", () => { it("installs an agent with a checksum without asking", async () => { const backend = new FakeBackend(); - render( + renderRegistry( <> @@ -233,7 +268,7 @@ describe("Agents settings page", () => { it("shows why an install failed, and cancels one when the page closes", async () => { const backend = new FakeBackend(); - const { unmount } = render(); + const { unmount } = renderRegistry(); await waitFor(() => expect(row("Cline")).toBeInTheDocument()); fireEvent.click(within(row("Cline")).getByRole("button", { name: "Update" })); await act(async () => backend.fail(new IpcError({ kind: "unavailable", message: "npm is required" }))); @@ -251,7 +286,7 @@ describe("Agents settings page", () => { it("removes an installed agent", async () => { const backend = new FakeBackend(); const onChanged = vi.fn(); - render(); + renderRegistry(); await waitFor(() => expect(row("fast-agent")).toBeInTheDocument()); fireEvent.click(within(row("fast-agent")).getByRole("button", { name: "Remove" })); await waitFor(() => expect(within(row("fast-agent")).getByRole("button", { name: "Install" })).toBeEnabled()); @@ -262,12 +297,308 @@ describe("Agents settings page", () => { it("falls back to the saved list with a warning, and reports a registry it cannot load", async () => { const backend = new FakeBackend(); backend.registry = { agents: AGENTS, warning: "Could not reach the ACP Registry" }; - const { unmount } = render(); + const { unmount } = renderRegistry(); await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Could not reach the ACP Registry")); unmount(); backend.list.mockRejectedValueOnce(new IpcError({ kind: "io", message: "offline" })); - render(); + renderRegistry(); await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Cannot load the ACP Registry: offline")); }); }); + +class FakeCommands implements CommandsBackend { + list: AgentProvider[] = []; + saved: [string, string | null][] = []; + providers = vi.fn(async () => this.list); + chooseExecutable = vi.fn(async () => "/chosen/agent"); + async set(id: string, command: string | null) { + this.saved.push([id, command]); + // What the agent list shows after the override takes effect. + this.list = this.list.map((p) => + p.id === id + ? { + ...p, + program: command ?? p.defaultProgram!, + command: command ?? p.defaultProgram!, + commandOverridden: command != null && command !== p.defaultProgram, + } + : p, + ); + } +} + +const claudeProvider = (command: string): AgentProvider => ({ + id: "claude", + name: "Claude Code", + command, + program: command, + args: [], + defaultProgram: "claude", + defaultArgs: [], + loginHint: "Run `claude` in a terminal and sign in.", + commandOverridden: command !== "claude", + commandEditable: true, + commandReadOnlyReason: null, + installed: true, + config: [], + commands: [], + source: { kind: "builtin" }, +}); + +describe("Built-in executable overrides", () => { + let dispose = () => {}; + beforeEach(() => openSettings(REGISTRY_PAGE)); + afterEach(() => { + closeSettings(); + dispose(); + }); + + const openCommands = async (commands: FakeCommands, onChanged = vi.fn()) => { + const backend = new FakeBackend(); + dispose = registerSettingsPage({ + id: REGISTRY_PAGE, + title: "Agents", + group: "Tools", + component: () => , + }); + render(); + return screen.findByRole("region", { name: "Claude Code executable" }); + }; + const apply = () => screen.getByRole("button", { name: "Apply" }); + const field = () => screen.getByRole("textbox", { name: "Executable" }); + + it("follows locale changes in tabs and renders login commands as inline code", async () => { + const commands = new FakeCommands(); + commands.list = [ + claudeProvider("claude"), + { + ...claudeProvider("codex"), + id: "codex", + name: "Codex", + defaultProgram: "codex", + commandOverridden: false, + installed: false, + }, + ]; + const section = await openCommands(commands); + expect(within(section).getByText("claude", { selector: "p code" })).toBeInTheDocument(); + expect(screen.getByText("Found on PATH")).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Claude Code" })).toBeInTheDocument(); + try { + await act(async () => { + await setLocale("zh"); + }); + expect(screen.getByRole("option", { name: "Codex 未找到" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "内置" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "ACP 注册表" })).toBeInTheDocument(); + await act(async () => { + await setLocale("en"); + }); + expect(screen.getByRole("option", { name: "Codex Not found" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Built-in" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "ACP Registry" })).toBeInTheDocument(); + } finally { + await act(async () => { + await setLocale("en"); + }); + } + }); + + it("reports when providers cannot be read (no trusted project)", async () => { + const commands = new FakeCommands(); + commands.providers.mockRejectedValue(new IpcError({ kind: "unavailable", message: "restricted" })); + render(); + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("restricted")); + expect(screen.queryByRole("textbox", { name: "Executable" })).toBeNull(); + }); + + it.each(["Apply", "OK"])("stages a binary path and writes it only on %s", async (button) => { + const commands = new FakeCommands(); + commands.list = [claudeProvider("claude")]; + const onChanged = vi.fn(); + const section = await openCommands(commands, onChanged); + expect(apply()).toBeDisabled(); + expect(field()).toHaveValue(""); + expect(within(section).queryByRole("button", { name: "Save" })).toBeNull(); + fireEvent.change(field(), { target: { value: "D:\\tools\\claude.exe" } }); + expect(commands.saved).toEqual([]); + expect(onChanged).not.toHaveBeenCalled(); + expect(apply()).toBeEnabled(); + expect(screen.queryByRole("option", { name: /Claude Code Custom/ })).toBeNull(); + + await act(async () => fireEvent.click(screen.getByRole("button", { name: button }))); + expect(commands.saved).toEqual([["claude", "D:\\tools\\claude.exe"]]); + expect(onChanged).toHaveBeenCalledOnce(); + if (button === "Apply") { + expect(screen.getByRole("option", { name: /Claude Code Custom/ })).toBeInTheDocument(); + expect(apply()).toBeDisabled(); + } else expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("Cancel discards command edits without writing", async () => { + const commands = new FakeCommands(); + commands.list = [ + claudeProvider("/saved/claude"), + { + ...claudeProvider("codex"), + id: "codex", + name: "Codex", + defaultProgram: "codex", + commandOverridden: false, + }, + ]; + await openCommands(commands); + fireEvent.change(field(), { target: { value: "/unsaved/claude" } }); + fireEvent.mouseDown(screen.getByRole("option", { name: "Codex" })); + fireEvent.change(field(), { target: { value: "/unsaved/codex" } }); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(commands.saved).toEqual([]); + act(() => openSettings(REGISTRY_PAGE)); + expect(await screen.findByRole("textbox", { name: "Executable" })).toHaveValue("/saved/claude"); + expect(apply()).toBeDisabled(); + }); + + it.each(["clear", "Reset"])("%s stages automatic detection until Apply", async (action) => { + const commands = new FakeCommands(); + commands.list = [claudeProvider("/saved/claude")]; + const section = await openCommands(commands); + if (action === "clear") fireEvent.change(field(), { target: { value: "" } }); + else fireEvent.click(within(section).getByRole("button", { name: "Reset" })); + expect(field()).toHaveValue(""); + expect(commands.saved).toEqual([]); + expect(screen.getByRole("option", { name: /Claude Code Custom/ })).toBeInTheDocument(); + expect(apply()).toBeEnabled(); + await act(async () => fireEvent.click(apply())); + expect(commands.saved).toEqual([["claude", null]]); + expect(field()).toHaveValue(""); + expect(screen.queryByRole("option", { name: /Claude Code Custom/ })).toBeNull(); + expect(apply()).toBeDisabled(); + }); + + it("page Reset discards edits and returning to the saved value clears the modified state", async () => { + const commands = new FakeCommands(); + commands.list = [claudeProvider("/saved/claude")]; + await openCommands(commands); + fireEvent.change(field(), { target: { value: "/edited/claude" } }); + fireEvent.click(screen.getAllByRole("button", { name: "Reset" })[0]!); + expect(field()).toHaveValue("/saved/claude"); + expect(apply()).toBeDisabled(); + fireEvent.change(field(), { target: { value: "/edited/claude" } }); + fireEvent.change(field(), { target: { value: "/saved/claude" } }); + expect(apply()).toBeDisabled(); + expect(commands.saved).toEqual([]); + }); + + it.each([false, true])("keeps edits across page navigation and applies with Agents mounted=%s", async (mounted) => { + const commands = new FakeCommands(); + commands.list = [claudeProvider("claude")]; + await openCommands(commands); + const off = registerSettingsPage({ id: "other", title: "Other" }); + try { + fireEvent.change(field(), { target: { value: "/custom/claude" } }); + act(() => selectPage("other")); + expect(apply()).toBeEnabled(); + if (mounted) { + act(() => selectPage(REGISTRY_PAGE)); + expect(await screen.findByRole("textbox", { name: "Executable" })).toHaveValue("/custom/claude"); + } + await act(async () => fireEvent.click(apply())); + act(() => selectPage(REGISTRY_PAGE)); + expect(await screen.findByRole("textbox", { name: "Executable" })).toHaveValue("/custom/claude"); + expect(screen.getByRole("option", { name: /Claude Code Custom/ })).toBeInTheDocument(); + expect(commands.saved).toEqual([["claude", "/custom/claude"]]); + expect(apply()).toBeDisabled(); + } finally { + off(); + } + }); + + it("retains a failed command for retry and shows only the applied badge", async () => { + const commands = new FakeCommands(); + commands.list = [claudeProvider("claude")]; + const set = vi.spyOn(commands, "set").mockRejectedValueOnce(new Error("Read-only configuration")); + await openCommands(commands); + fireEvent.change(field(), { target: { value: " /custom/claude " } }); + await act(async () => fireEvent.click(apply())); + expect(screen.getByRole("status")).toHaveTextContent("Read-only configuration"); + expect(field()).toHaveValue(" /custom/claude "); + expect(screen.queryByRole("option", { name: /Claude Code Custom/ })).toBeNull(); + expect(apply()).toBeEnabled(); + await act(async () => fireEvent.click(apply())); + expect(set).toHaveBeenCalledTimes(2); + expect(commands.saved).toEqual([["claude", "/custom/claude"]]); + expect(field()).toHaveValue("/custom/claude"); + expect(screen.getByRole("option", { name: /Claude Code Custom/ })).toBeInTheDocument(); + }); + it.each(["Apply", "OK"])("lists built-ins and writes two independent edits on %s", async (button) => { + const commands = new FakeCommands(); + commands.list = [ + claudeProvider("claude"), + { + ...claudeProvider("codex"), + id: "codex", + name: "Codex", + defaultProgram: "codex", + commandOverridden: false, + args: ["app-server"], + defaultArgs: ["app-server"], + installed: false, + }, + ]; + const changed = vi.fn(); + await openCommands(commands, changed); + expect(screen.getByRole("option", { name: "Claude Code" })).toBeInTheDocument(); + const codex = screen.getByRole("option", { name: "Codex Not found" }); + fireEvent.change(field(), { target: { value: "/custom/claude" } }); + fireEvent.mouseDown(codex); + expect(screen.getByText("codex app-server")).toBeInTheDocument(); + expect(field()).toHaveAttribute("placeholder", "Auto-detect (codex)"); + fireEvent.change(field(), { target: { value: "/custom/codex" } }); + expect(commands.saved).toEqual([]); + await act(async () => fireEvent.click(screen.getByRole("button", { name: button }))); + expect(commands.saved).toEqual([ + ["claude", "/custom/claude"], + ["codex", "/custom/codex"], + ]); + expect(changed).toHaveBeenCalledTimes(2); + if (button === "Apply") expect(screen.getByRole("option", { name: "Codex Custom" })).toBeInTheDocument(); + }); + + it.each(["packageRunner", "registry"] as const)("keeps %s built-ins visible and read-only", async (reason) => { + const commands = new FakeCommands(); + commands.list = [ + { + ...claudeProvider("npx"), + id: "claude-acp", + name: "Claude Code (ACP)", + defaultProgram: "npx", + defaultArgs: ["-y", "adapter"], + commandEditable: false, + commandReadOnlyReason: reason, + source: reason === "registry" ? { kind: "registry", version: "1", update: null } : { kind: "builtin" }, + }, + ]; + render(); + expect(await screen.findByText("npx -y adapter")).toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: "Executable" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Browse…" })).toBeNull(); + expect( + screen.getByText( + reason === "packageRunner" ? /runs through a package runner/ : /installed from the ACP Registry/, + ), + ).toBeInTheDocument(); + expect(commands.saved).toEqual([]); + }); + + it("stages a browsed executable", async () => { + const commands = new FakeCommands(); + commands.list = [claudeProvider("claude")]; + await openCommands(commands); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Browse…" }))); + expect(field()).toHaveValue("/chosen/agent"); + expect(commands.saved).toEqual([]); + await act(async () => fireEvent.click(apply())); + expect(commands.saved).toEqual([["claude", "/chosen/agent"]]); + }); +}); diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx index 6558b5ae..d4d38312 100644 --- a/src/features/agents/composerCommands.test.tsx +++ b/src/features/agents/composerCommands.test.tsx @@ -42,6 +42,14 @@ const claude: AgentProvider = { installed: true, config: [], source: { kind: "builtin" }, + program: "claude", + args: [], + defaultProgram: "claude", + defaultArgs: [], + loginHint: null, + commandOverridden: false, + commandEditable: true, + commandReadOnlyReason: null, // Offered by the agent's last session in this project. commands: [command("compact", "Free up context")], }; diff --git a/src/features/agents/registry.ts b/src/features/agents/registry.ts index f71772ea..60cb80b8 100644 --- a/src/features/agents/registry.ts +++ b/src/features/agents/registry.ts @@ -1,6 +1,14 @@ /** Agents of the ACP Registry: listing, install / update / remove (`agent_registry_*`). */ +import { open } from "@tauri-apps/plugin-dialog"; import { Channel } from "@tauri-apps/api/core"; -import type { AgentRegistry, InstallEvent, RegistryAgent, RegistryDistribution, RequestId } from "@/bindings"; +import type { + AgentProvider, + AgentRegistry, + InstallEvent, + RegistryAgent, + RegistryDistribution, + RequestId, +} from "@/bindings"; import { call, commands } from "@/platform/ipc"; export interface RegistryBackend { @@ -10,6 +18,13 @@ export interface RegistryBackend { cancel(request: RequestId): void; } +/** The built-in agents' launch commands: read, and override through an agent file. */ +export interface CommandsBackend { + providers(): Promise; + set(id: string, command: string | null): Promise; + chooseExecutable(title: string, current?: string): Promise; +} + export const tauriRegistry: RegistryBackend = { list: (request) => call(commands.agentRegistryList(request)), install: async (id, request, onEvent) => { @@ -23,6 +38,17 @@ export const tauriRegistry: RegistryBackend = { cancel: (request) => void commands.cancelRequest(request).catch(() => {}), }; +export const tauriCommands: CommandsBackend = { + providers: () => call(commands.agentProviders()), + chooseExecutable: async (title, current) => { + const picked = await open({ title, defaultPath: current || undefined, directory: false, multiple: false }); + return typeof picked === "string" ? picked : null; + }, + set: async (id, command) => { + await call(commands.agentSetCommand(id, command)); + }, +}; + export const DISTRIBUTION_LABEL: Record = { binary: "Binary", npx: "npm", diff --git a/src/features/settings/components/context.ts b/src/features/settings/components/context.ts index d0a3fc69..0d4dc653 100644 --- a/src/features/settings/components/context.ts +++ b/src/features/settings/components/context.ts @@ -1,7 +1,7 @@ /** Page context for the form components: which page / scope they edit and the search query. */ import { createContext, useCallback, useContext, useMemo } from "react"; import type { SettingsForm } from "@/workbench/contributions"; -import { stage, stagedValue, useSettingsDialog, type Scope } from "../model/staging"; +import { externalKey, stage, stageExternal, stagedValue, useSettingsDialog, type Scope } from "../model/staging"; import { labelMatches } from "../model/pages"; export interface PageContextValue { @@ -38,11 +38,26 @@ export function useSettingsFormFor(pageId: string, scope: Scope): SettingsForm { scope, useValue: (key: string) => useStagedValue(scope, key), set: (key: string, value: unknown) => stage(pageId, scope, key, value), + useExternal: (key: string, base: T, apply: (value: T) => Promise) => + useExternalValue(pageId, key, base, apply), }), [pageId, scope], ); } +function useExternalValue(page: string, key: string, base: T, apply: (value: T) => Promise) { + const id = externalKey(page, key); + const staged = useSettingsDialog((s) => s.stagedExternal[id]); + const current = useSettingsDialog((s) => (id in s.externalApplied ? (s.externalApplied[id] as T) : base)); + const saving = useSettingsDialog((s) => s.saving); + return { + value: staged ? (staged.value as T) : current, + current, + set: (value: T) => stageExternal(page, key, value, current, apply), + saving, + }; +} + export function useSearchQuery(): string { return useSettingsDialog((s) => s.query); } diff --git a/src/features/settings/dialog/SettingsDialog.tsx b/src/features/settings/dialog/SettingsDialog.tsx index b7c0b67a..9d6bbf74 100644 --- a/src/features/settings/dialog/SettingsDialog.tsx +++ b/src/features/settings/dialog/SettingsDialog.tsx @@ -220,7 +220,7 @@ function SettingsDialogInner() { /** Page tree + current page; reads the shared settings staging store. */ export function SettingsLayout() { const pages = useSettingsPages((s) => s.pages); - const { pageId, query, staged, stagedSecrets, saving } = useSettingsDialog(); + const { pageId, query, staged, stagedSecrets, stagedExternal, saving } = useSettingsDialog(); const treeRef = useRef(null); const tree = useMemo(() => buildPageTree(pages.values()), [pages]); @@ -235,8 +235,9 @@ export function SettingsLayout() { const dirtyPages = useMemo(() => { const pages = new Set(Object.values(staged).map((c) => c.page)); if (Object.keys(stagedSecrets).length) pages.add(SSH_SETTINGS_PAGE); + for (const c of Object.values(stagedExternal)) pages.add(c.page); return pages; - }, [staged, stagedSecrets]); + }, [staged, stagedSecrets, stagedExternal]); const pageDirty = !!current && dirtyPages.has(current.key); // Back / forward through pages visited while the dialog is open. @@ -338,7 +339,7 @@ export function SettingsLayout() { /** OK / Cancel / Apply, ordered per platform by the dialog primitive. */ function useSettingsButtons(): DialogButton[] { - const { staged, stagedSecrets, base, globalBase, saving } = useSettingsDialog(); + const { staged, stagedSecrets, stagedExternal, base, globalBase, saving } = useSettingsDialog(); const pending = useMemo(() => pendingChanges({ base, globalBase, staged }).length > 0, [base, globalBase, staged]); const ok = async () => { if (await applySettings()) closeSettings(); @@ -348,7 +349,7 @@ function useSettingsButtons(): DialogButton[] { { label: "Cancel", role: "cancel", onClick: closeSettings }, { label: "Apply", - disabled: (!pending && !Object.keys(stagedSecrets).length) || saving, + disabled: (!pending && !Object.keys(stagedSecrets).length && !Object.keys(stagedExternal).length) || saving, onClick: () => void applySettings(), }, ]; diff --git a/src/features/settings/model/staging.ts b/src/features/settings/model/staging.ts index 8cde51ea..6bc437ad 100644 --- a/src/features/settings/model/staging.ts +++ b/src/features/settings/model/staging.ts @@ -18,6 +18,12 @@ export interface StagedChange { page: string; } +interface ExternalChange { + page: string; + value: unknown; + apply(): Promise; +} + export interface SettingsDialogState { open: boolean; pageId: string; @@ -33,6 +39,9 @@ export interface SettingsDialogState { staged: Record; /** SSH credentials stay outside settings JSON until Apply / OK. */ stagedSecrets: Record; + stagedExternal: Record; + /** Values written outside settings JSON since opening the dialog. */ + externalApplied: Record; /** Scope chosen per page (Global / This project). */ scopes: Record; saving: boolean; @@ -52,6 +61,8 @@ export const useSettingsDialog = create(() => ({ newProjects: false, staged: {}, stagedSecrets: {}, + stagedExternal: {}, + externalApplied: {}, scopes: {}, saving: false, error: null, @@ -79,6 +90,8 @@ export function openSettings(pageId?: string, opts: { newProjects?: boolean } = newProjects: opts.newProjects ?? false, staged: {}, stagedSecrets: {}, + stagedExternal: {}, + externalApplied: {}, saving: false, error: null, }); @@ -108,7 +121,15 @@ type Bases = Pick; const baseFor = (s: Bases, scope: Scope) => (scope === "global" ? s.globalBase : s.base); export function closeSettings() { - useSettingsDialog.setState({ open: false, staged: {}, stagedSecrets: {}, error: null, saving: false }); + useSettingsDialog.setState({ + open: false, + staged: {}, + stagedSecrets: {}, + stagedExternal: {}, + externalApplied: {}, + error: null, + saving: false, + }); } /** Shows `pageId` (`page#focus` also selects `focus` on it). */ @@ -180,11 +201,25 @@ export function stageSshSecret(id: string, value: string) { }); } +export const externalKey = (page: string, key: string) => `${page}|${key}`; + +export function stageExternal(page: string, key: string, value: T, base: T, apply: (value: T) => Promise) { + useSettingsDialog.setState((s) => { + if (s.saving) return {}; + const stagedExternal = { ...s.stagedExternal }; + const id = externalKey(page, key); + if (deepEqual(value, base)) delete stagedExternal[id]; + else stagedExternal[id] = { page, value, apply: () => apply(value) }; + return { stagedExternal, error: null }; + }); +} + /** Discards staged edits of one page (the dialog's Reset button). */ export function resetPage(page: string) { useSettingsDialog.setState((s) => ({ staged: Object.fromEntries(Object.entries(s.staged).filter(([, c]) => c.page !== page)), stagedSecrets: page === SSH_SETTINGS_PAGE ? {} : s.stagedSecrets, + stagedExternal: Object.fromEntries(Object.entries(s.stagedExternal).filter(([, c]) => c.page !== page)), error: null, })); } @@ -254,7 +289,8 @@ export async function applySettings(deps: PersistDeps = defaultDeps()): Promise< if (initial.saving) return false; const changes = pendingChanges(initial); const secrets = Object.entries(initial.stagedSecrets); - if (!changes.length && !secrets.length) return true; + const external = Object.entries(initial.stagedExternal); + if (!changes.length && !secrets.length && !external.length) return true; useSettingsDialog.setState({ saving: true, error: null }); const result = await persistChanges(changes, deps); const { written } = result; @@ -293,6 +329,22 @@ export async function applySettings(deps: PersistDeps = defaultDeps()): Promise< } } } + if (!error) { + for (const [id, change] of external) { + if (useSettingsDialog.getState().stagedExternal[id] !== change) continue; + try { + const value = await change.apply(); + useSettingsDialog.setState((s) => { + const stagedExternal = { ...s.stagedExternal }; + if (stagedExternal[id] === change) delete stagedExternal[id]; + return { stagedExternal, externalApplied: { ...s.externalApplied, [id]: value } }; + }); + } catch (e) { + error = `${id}: ${e instanceof Error ? e.message : String(e)}`; + break; + } + } + } useSettingsDialog.setState({ saving: false, error }); return error === null; } diff --git a/src/features/tools/__tests__/settingsForm.tsx b/src/features/tools/__tests__/settingsForm.tsx index 5e46a6b5..e08370cb 100644 --- a/src/features/tools/__tests__/settingsForm.tsx +++ b/src/features/tools/__tests__/settingsForm.tsx @@ -44,6 +44,9 @@ export function TestSettingsForm({ const form = useMemo( () => ({ scope, + useExternal: () => { + throw new Error("External settings are not supported by this stand-in"); + }, useValue: (key) => useTestForm((s) => getAt(s.values, key)), set: (key, value) => useTestForm.setState((s) => ({ values: putAt(s.values, key, value), staged: [...s.staged, [key, value]] })), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c0edc2d5..affb952c 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,9 +1,34 @@ { "Edit {name}": "编辑 {name}", "The language server JDK ({path}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {version} or newer. Download JDK {version}, choose a full JDK in {setting}, or set it to Auto.": "语言服务器 JDK({path})必须是包含 jdk.compiler、jdk.jdi 和 jdk.unsupported 的完整 JDK,才能支持 Java 语言功能和调试。jdtls 需要 JDK {version} 或更高版本。请下载 JDK {version},在 {setting} 中选择完整 JDK,或设为自动。", + "ACP Registry": "ACP 注册表", "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple 的 clangd 不运行 clang-tidy,因此不会显示 .clang-tidy 中配置的检查。请安装 clangd(LLVM)以启用这些检查。", + "Auto-detect ({defaultProgram})": "自动检测({defaultProgram})", + "Built-in": "内置", + "Built-in agents": "内置智能体", + "Custom executable": "自定义可执行文件", + "Found on PATH": "已在 PATH 中找到", + "Leave empty to find the executable on PATH. Changes apply to new sessions.": "留空以从 PATH 查找可执行文件。更改对新会话生效。", "Newer dependency version available": "有更新的依赖版本可用", + "Not found": "未找到", + "Not found on PATH": "未在 PATH 中找到", "Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。", + "Run `auggie login` in a terminal.": "在终端中运行 `auggie login`。", + "Run `claude` in a terminal and sign in.": "在终端中运行 `claude` 并登录。", + "Run `codex login` in a terminal.": "在终端中运行 `codex login`。", + "Run `copilot` in a terminal and sign in.": "在终端中运行 `copilot` 并登录。", + "Run `cursor-agent login` in a terminal.": "在终端中运行 `cursor-agent login`。", + "Run `gemini` in a terminal and sign in.": "在终端中运行 `gemini` 并登录。", + "Run `goose configure` in a terminal.": "在终端中运行 `goose configure`。", + "Run `kimi` in a terminal and sign in.": "在终端中运行 `kimi` 并登录。", + "Run `kiro-cli login` in a terminal.": "在终端中运行 `kiro-cli login`。", + "Run `opencode auth login` in a terminal.": "在终端中运行 `opencode auth login`。", + "Run `qwen` in a terminal and sign in.": "在终端中运行 `qwen` 并登录。", + "Run `vibe` in a terminal and sign in.": "在终端中运行 `vibe` 并登录。", + "Sign in with the agent's own command line first.": "请先使用智能体自身的命令行登录。", + "This agent is configured in its agent file.": "此智能体在其智能体文件中配置。", + "This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab.": "此智能体从 ACP 注册表安装。请在 ACP 注册表标签页管理其安装。", + "This agent runs through a package runner (npm or uv) and has no executable to choose.": "此智能体通过包运行器(npm 或 uv)启动,无需选择可执行文件。", "Update to {latest}": "更新至 {latest}", "A newer version is available: {latest}": "有新版本可用:{latest}", "Kotlin bridge requires a Maven build root": "Kotlin 桥接需要 Maven 构建根目录", @@ -1624,6 +1649,7 @@ "oxfmt formatting timed out": "oxfmt 格式化超时", "{name} does not support selection formatting for this language; formatted the whole file": "{name} 不支持此语言的选区格式化,已格式化整个文件", "Formatting timed out": "格式化超时", + "{name} executable": "{name} 可执行文件", "{name}: Node.js is required": "{name}:需要 Node.js", "Formatter": "格式化工具", "JSON with Comments": "带注释的 JSON", diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 6331f20a..f53a2d48 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -813,6 +813,17 @@ export interface SettingsForm { useValue(key: string): unknown; /** Stages a value; `null` removes the key on Apply. */ set(key: string, value: unknown): void; + /** Values stored outside settings JSON; the writer runs on Apply / OK and returns the saved value. */ + useExternal( + key: string, + base: T, + apply: (value: T) => Promise, + ): { + value: T; + current: T; + set(value: T): void; + saving: boolean; + }; } export const SettingsFormContext = createContext(null);