feat(agents): configurable executable for every built-in agent (#682)

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 <config>/agents/<id>.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>
This commit is contained in:
arookieofc
2026-11-03 21:55:51 +08:00
committed by GitHub
co-authored by a-rookie-of-C-language
parent e4b8b76452
commit 9dbae1d189
22 changed files with 1086 additions and 57 deletions
+14 -11
View File
@@ -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::<Vec<_>>()
.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<String>) -> 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());
+18
View File
@@ -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<String>,
pub default_program: Option<String>,
pub default_args: Vec<String>,
pub login_hint: Option<String>,
pub command_overridden: bool,
pub command_editable: bool,
pub command_read_only_reason: Option<AgentCommandReadOnlyReason>,
/// 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")]
+213 -1
View File
@@ -2,7 +2,8 @@
//! `<config>/agents/<id>.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<ProviderSpec> {
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::<Vec<_>>()
.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, `<config>/agents/<id>.toml`.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -182,6 +259,9 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
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::<Vec<_>>();
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();
+2 -18
View File
@@ -826,15 +826,7 @@ mod tests {
let mut providers: Vec<AgentProvider> = 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,
+8
View File
@@ -23,6 +23,13 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
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<String>) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.set_builtin_command(&id, command)
}
/// Where ACP Registry agents are saved (`<config>/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
+1 -1
View File
@@ -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(|_| {})),
+13
View File
@@ -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<String>,
) -> 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]
+1
View File
@@ -97,6 +97,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+17
View File
@@ -86,6 +86,11 @@ export const commands = {
/** Streams the project's agent events to this window; returns every task. */
agentSubscribe: (onEvent: Channel<AgentEvent>) => typedError<AgentTask[], IdeError>(__TAURI_INVOKE("agent_subscribe", { onEvent })),
agentProviders: () => typedError<AgentProvider[], IdeError>(__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<null, IdeError>(__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. */
+6 -1
View File
@@ -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,
+33 -4
View File
@@ -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 (
<div className={styles.body}>
<Tabs
variant="compact"
className={styles.tabs}
items={[
{ id: "builtin", label: "Built-in" },
{ id: "registry", label: "ACP Registry" },
]}
activeId={tab}
onSelect={setTab}
aria-label={tx("Agents")}
/>
{tab === "builtin" ? (
<BuiltinAgents backend={commandBackend} onChanged={onChanged} />
) : (
<RegistryList backend={backend} onChanged={onChanged} />
)}
</div>
);
}
function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChanged?: () => void }) {
const [registry, setRegistry] = useState<AgentRegistry | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [query, setQuery] = useState("");
@@ -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;
}
+182
View File
@@ -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 (
<Badge tone={agent.commandOverridden ? "accent" : "warning"}>
{agent.commandOverridden ? translate("Custom") : translate("Not found")}
</Badge>
);
}
export function BuiltinAgents({ backend, onChanged }: { backend: CommandsBackend; onChanged?: () => void }) {
const [agents, setAgents] = useState<AgentProvider[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<p className={styles.error} role="alert">
{error}
</p>
);
if (!agents) return <Spinner size={14} />;
const agent = agents[selected] ?? agents[0];
return (
<div className={styles.split}>
<List
className={styles.master}
items={agents}
getKey={(p) => p.id}
activeIndex={selected}
onActiveChange={setSelected}
rowHeight={32}
aria-label={tx("Built-in agents")}
renderItem={(p) => (
<div className={styles.agentRow}>
<Icon name={providerIcon(p.id)} size={16} />
<span className={styles.agentName}>{p.name}</span> <Status agent={p} />
</div>
)}
/>
{agent && <Details key={agent.id} agent={agent} backend={backend} refresh={refresh} onChanged={onChanged} />}
</div>
);
}
function Details({
agent,
backend,
refresh,
onChanged,
}: {
agent: AgentProvider;
backend: CommandsBackend;
refresh: () => Promise<void>;
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<string | null>(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 (
<section className={styles.details} aria-label={tx`${agent.name} executable`}>
<div className={styles.title}>
<Icon name={providerIcon(agent.id)} size={20} />
<h3>{agent.name}</h3>
</div>
<code className={styles.launch}>{launch}</code>
<p className={styles.status}>
{agent.commandOverridden ? (
<T>Custom executable</T>
) : agent.installed ? (
<T>Found on PATH</T>
) : (
<T>Not found on PATH</T>
)}
</p>
{agent.commandEditable ? (
<>
<label className={styles.label} htmlFor={`agent-executable-${agent.id}`}>
<T>Executable</T>
</label>
<div className={styles.controls}>
<Input
id={`agent-executable-${agent.id}`}
mono
value={value}
disabled={saving}
placeholder={tx`Auto-detect (${agent.defaultProgram})`}
onChange={(e) => set(e.target.value)}
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
disabled={saving}
onClick={async () => {
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));
}
}}
/>
<Button size="small" disabled={saving || !value} onClick={() => set("")}>
<T>Reset</T>
</Button>
</div>
<p className={styles.hint}>
<T>Leave empty to find the executable on PATH. Changes apply to new sessions.</T>
</p>
</>
) : (
<p className={styles.hint}>
{agent.commandReadOnlyReason === "packageRunner" ? (
<T>This agent runs through a package runner (npm or uv) and has no executable to choose.</T>
) : agent.commandReadOnlyReason === "registry" ? (
<T>This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab.</T>
) : (
<T>This agent is configured in its agent file.</T>
)}
</p>
)}
{agent.loginHint && (
<p className={styles.hint}>
<LoginHint text={agent.loginHint} />
</p>
)}
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
</section>
);
}
/** 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 ? <code key={i}>{part.slice(1, -1)}</code> : part));
}
+344 -13
View File
@@ -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<typeof RegistryPage>) {
const form = useSettingsFormFor(REGISTRY_PAGE, "global");
return (
<SettingsFormContext.Provider value={form}>
<RegistryPage {...props} />
</SettingsFormContext.Provider>
);
}
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<RegistryAgent> & Pick<RegistryAgent, "id" | "name">): RegistryAgent => ({
version: "1.0.0",
@@ -114,6 +136,14 @@ describe("agent picker", () => {
const provider = (p: Partial<AgentProvider> & Pick<AgentProvider, "id" | "source">): 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<typeof render>) => {
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(<AgentRegistryPage backend={backend} />);
renderRegistry(<AgentRegistryPage backend={backend} />);
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(<AgentRegistryPage backend={backend} />);
renderRegistry(<AgentRegistryPage backend={backend} />);
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(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
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(
<>
<AgentRegistryPage backend={backend} />
<MessagesHost />
@@ -218,7 +253,7 @@ describe("Agents settings page", () => {
it("installs an agent with a checksum without asking", async () => {
const backend = new FakeBackend();
render(
renderRegistry(
<>
<AgentRegistryPage backend={backend} />
<MessagesHost />
@@ -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(<AgentRegistryPage backend={backend} />);
const { unmount } = renderRegistry(<AgentRegistryPage backend={backend} />);
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(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
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(<AgentRegistryPage backend={backend} />);
const { unmount } = renderRegistry(<AgentRegistryPage backend={backend} />);
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Could not reach the ACP Registry"));
unmount();
backend.list.mockRejectedValueOnce(new IpcError({ kind: "io", message: "offline" }));
render(<AgentRegistryPage backend={backend} />);
renderRegistry(<AgentRegistryPage backend={backend} />);
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: () => <RegistryPage backend={backend} commandBackend={commands} onChanged={onChanged} />,
});
render(<SettingsDialog />);
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(<AgentRegistryPage backend={new FakeBackend()} commandBackend={commands} />);
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(<AgentRegistryPage backend={new FakeBackend()} commandBackend={commands} />);
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"]]);
});
});
@@ -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")],
};
+27 -1
View File
@@ -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<AgentProvider[]>;
set(id: string, command: string | null): Promise<void>;
chooseExecutable(title: string, current?: string): Promise<string | null>;
}
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<RegistryDistribution, string> = {
binary: "Binary",
npx: "npm",
+16 -1
View File
@@ -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: <T>(key: string, base: T, apply: (value: T) => Promise<T>) =>
useExternalValue(pageId, key, base, apply),
}),
[pageId, scope],
);
}
function useExternalValue<T>(page: string, key: string, base: T, apply: (value: T) => Promise<T>) {
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);
}
@@ -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<TreeHandle>(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(),
},
];
+54 -2
View File
@@ -18,6 +18,12 @@ export interface StagedChange {
page: string;
}
interface ExternalChange {
page: string;
value: unknown;
apply(): Promise<unknown>;
}
export interface SettingsDialogState {
open: boolean;
pageId: string;
@@ -33,6 +39,9 @@ export interface SettingsDialogState {
staged: Record<string, StagedChange>;
/** SSH credentials stay outside settings JSON until Apply / OK. */
stagedSecrets: Record<string, string>;
stagedExternal: Record<string, ExternalChange>;
/** Values written outside settings JSON since opening the dialog. */
externalApplied: Record<string, unknown>;
/** Scope chosen per page (Global / This project). */
scopes: Record<string, Scope>;
saving: boolean;
@@ -52,6 +61,8 @@ export const useSettingsDialog = create<SettingsDialogState>(() => ({
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<SettingsDialogState, "base" | "globalBase">;
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<T>(page: string, key: string, value: T, base: T, apply: (value: T) => Promise<T>) {
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;
}
@@ -44,6 +44,9 @@ export function TestSettingsForm({
const form = useMemo<SettingsForm>(
() => ({
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]] })),
+26
View File
@@ -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",
+11
View File
@@ -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<T>(
key: string,
base: T,
apply: (value: T) => Promise<T>,
): {
value: T;
current: T;
set(value: T): void;
saving: boolean;
};
}
export const SettingsFormContext = createContext<SettingsForm | null>(null);