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 <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-11-03 21:55:51 +08:00
1 джерело e4b8b76452
коміт 9dbae1d189
22 files changed
+1086 -57

No files matched your search

+6 -1
Переглянути файл
@@ -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
Переглянути файл
@@ -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("");
+84
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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"]]);
});
});
+8
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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);
}
+5 -4
Переглянути файл
@@ -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
Переглянути файл
@@ -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;
}
+3
Переглянути файл
@@ -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]] })),