mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
co-authored by
a-rookie-of-C-language
parent
e4b8b76452
commit
9dbae1d189
+14
-11
@@ -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());
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(|_| {})),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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")],
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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]] })),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user