refactor(agent-mode): redesign agent binary configuration UI (#2517)

Replace the per-backend install/settings panels with a shared Configure
dialog layout. Introduces ConfigDialogShell + ConfigSection, InstallCommandRow,
and an installStatus badge/status-line module (with tests), and reworks the
Claude, Codex, and opencode Configure dialogs onto it. The settings page now
shows each backend's icon, an install-status badge, and a Configure button,
dropping the old BinaryInstallContent and SimpleBackendSettingsPanel helpers.
Copy is simplified for non-technical users and dialog spacing/dividers are
made consistent.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-05-28 15:15:52 -07:00 committed by GitHub
parent 0114cdbd12
commit d72e3ee05a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 591 additions and 691 deletions

View file

@ -1,46 +1,92 @@
import { App, Modal, Setting } from "obsidian";
import { CLAUDE_INSTALL_COMMAND } from "./descriptor";
import { BinaryPathSetting } from "@/agentMode/backends/shared/BinaryPathSetting";
import { ConfigDialogShell, ConfigSection } from "@/agentMode/backends/shared/ConfigDialogShell";
import { InstallCommandRow } from "@/agentMode/backends/shared/InstallCommandRow";
import { InstallStatusLine } from "@/agentMode/backends/shared/installStatus";
import type { InstallState } from "@/agentMode/session/types";
import { ReactModal } from "@/components/modals/ReactModal";
import { Button } from "@/components/ui/button";
import { setSettings, useSettingsValue } from "@/settings/model";
import { validateExecutableFile } from "@/utils/detectBinary";
import { App, Notice } from "obsidian";
import React from "react";
import { CLAUDE_INSTALL_COMMAND, detectClaudeCliPath, resolveClaudeCliPath } from "./descriptor";
/**
* Onboarding modal shown when the `claude` CLI cannot be located. Tells
* the user the exact install command and offers a "Re-detect" button (the
* resolver runs each time the descriptor's `getInstallState` is queried).
* Configure dialog for the Claude (Agent SDK) backend. The SDK auto-detects the
* `claude` CLI; this dialog surfaces the resolved path, the install command, an
* optional custom path override, and auth guidance. There is no managed
* install the user installs the `claude` CLI themselves.
*/
export class ClaudeInstallModal extends Modal {
const ClaudeConfigBody: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const settings = useSettingsValue();
const overridePath = settings.agentMode?.claudeCli?.path ?? "";
const resolvedPath = resolveClaudeCliPath(settings);
const isCustom = Boolean(overridePath);
const sessionState: InstallState = resolvedPath
? { kind: "ready", source: isCustom ? "custom" : "managed" }
: { kind: "absent" };
const onSaveCustomPath = React.useCallback(async (path: string): Promise<string | null> => {
const err = await validateExecutableFile(path);
if (err) return err;
setSettings((cur) => ({ agentMode: { ...cur.agentMode, claudeCli: { path } } }));
new Notice("Claude CLI path saved.");
return null;
}, []);
const clearCustomPath = React.useCallback((): void => {
setSettings((cur) => ({ agentMode: { ...cur.agentMode, claudeCli: undefined } }));
new Notice("Claude CLI override cleared. Auto-detection will be used.");
}, []);
return (
<ConfigDialogShell status={<InstallStatusLine state={sessionState} />} onClose={onClose}>
<ConfigSection title="Install Claude Code">
<InstallCommandRow command={CLAUDE_INSTALL_COMMAND} />
</ConfigSection>
<ConfigSection title="Use a custom claude path">
<p className="tw-my-0 tw-text-sm tw-text-muted">
Use an existing <code>claude</code> binary you have on disk.
</p>
<BinaryPathSetting
binaryName="claude"
placeholder="/absolute/path/to/claude"
initialPath={overridePath}
notFoundHint={`claude not found on disk. Install with \`${CLAUDE_INSTALL_COMMAND}\` and try again, or paste a custom path manually.`}
onSave={onSaveCustomPath}
persistOnAutoDetect
detect={() => Promise.resolve(detectClaudeCliPath())}
/>
{isCustom && (
<div className="tw-flex tw-justify-end">
<Button variant="destructive" size="default" onClick={clearCustomPath}>
Clear path
</Button>
</div>
)}
</ConfigSection>
<ConfigSection title="Authentication">
<p className="tw-my-0 tw-text-sm tw-text-muted">
Credentials inherit from the <code>claude</code> CLI login state run <code>claude</code>{" "}
once to sign in or set <code>ANTHROPIC_API_KEY</code> (or Bedrock / Vertex env) in your
shell.
</p>
</ConfigSection>
</ConfigDialogShell>
);
};
/** Configure dialog for the Claude backend. Opened via `descriptor.openInstallUI`. */
export class ClaudeInstallModal extends ReactModal {
constructor(app: App) {
super(app);
super(app, "Configure Claude");
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl("h2", { text: "Install Claude CLI" });
contentEl.createEl("p", {
text:
"The Claude (SDK) backend requires the official `claude` CLI installed on your system. " +
"Run this command in a terminal:",
});
const code = contentEl.createEl("pre");
code.createEl("code", { text: CLAUDE_INSTALL_COMMAND });
contentEl.createEl("p", {
text:
"The Claude Agent SDK is bundled with this plugin; the `claude` CLI provides " +
"authentication and runs the model — that's why you install `@anthropic-ai/claude-code`.",
});
contentEl.createEl("p", {
text:
"Then return to Obsidian and click 'Re-detect' in Agent Mode advanced settings. " +
"Authentication is inherited from the CLI's login state — run `claude` once to sign in if " +
"you haven't already, or set ANTHROPIC_API_KEY in your shell environment.",
});
new Setting(contentEl).addButton((btn) =>
btn
.setButtonText("Close")
.setCta()
.onClick(() => this.close())
);
}
onClose(): void {
this.contentEl.empty();
protected renderContent(close: () => void): React.ReactElement {
return <ClaudeConfigBody onClose={close} />;
}
}

View file

@ -1,148 +1,26 @@
import { BinaryPathSetting } from "@/agentMode/backends/shared/BinaryPathSetting";
import { EnvOverridesSetting } from "@/agentMode/backends/shared/EnvOverridesSetting";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import type CopilotPlugin from "@/main";
import { setSettings, useSettingsValue } from "@/settings/model";
import { validateExecutableFile } from "@/utils/detectBinary";
import { useSettingsValue } from "@/settings/model";
import type { App } from "obsidian";
import { Notice } from "obsidian";
import React from "react";
import {
CLAUDE_INSTALL_COMMAND,
detectClaudeCliPath,
resolveClaudeCliPath,
updateClaudeFields,
} from "./descriptor";
import { updateClaudeFields } from "./descriptor";
interface Props {
plugin: CopilotPlugin;
app: App;
}
interface AuthEnvSummary {
bedrock: boolean;
vertex: boolean;
apiKey: boolean;
}
/**
* Read Claude SDK auth-related env vars once. The SDK resolves credentials
* through the spawned `claude` CLI, so any of these may be unset and the
* agent still works (the CLI's saved login covers it).
*/
function readAuthEnv(): AuthEnvSummary {
return {
apiKey: Boolean(process.env.ANTHROPIC_API_KEY),
bedrock: Boolean(process.env.CLAUDE_CODE_USE_BEDROCK),
vertex: Boolean(process.env.CLAUDE_CODE_USE_VERTEX),
};
}
function renderAuthDescription(env: AuthEnvSummary): React.ReactNode {
const parts: string[] = [];
if (env.apiKey) parts.push("Anthropic API key set");
if (env.bedrock) parts.push("Bedrock configured");
if (env.vertex) parts.push("Vertex configured");
if (parts.length === 0) {
return (
<span className="tw-text-muted">
No auth env vars set credentials inherit from the <code>claude</code> CLI login state.
</span>
);
}
return <div>{parts.join(" · ")}</div>;
}
/**
* Settings panel for the Claude (Agent SDK) backend. Shows the resolved
* `claude` CLI status, lets users force a re-detect, override the path, and
* inspects auth-relevant env vars. There is no managed install for this
* backend the user must install the `claude` CLI themselves.
* Claude card extras. CLI detection / path / auth configuration lives in the
* Configure dialog (`ClaudeInstallModal`, opened via
* `descriptor.openInstallUI`); this panel hosts the model-behavior toggle and
* spawn-time environment overrides that remain on the settings card.
*/
export const ClaudeSettingsPanel: React.FC<Props> = () => {
const settings = useSettingsValue();
const [, force] = React.useReducer((x: number) => x + 1, 0);
const overridePath = settings.agentMode?.claudeCli?.path ?? "";
// Each render re-walks fs.existsSync via the resolver — pressing
// "Re-detect" simply forces a new render via `force`.
const resolvedPath = resolveClaudeCliPath(settings);
const isCustom = Boolean(overridePath);
// Env doesn't change without an Obsidian restart; read once on mount.
const authEnv = React.useMemo(readAuthEnv, []);
const statusDescription = resolvedPath ? (
<>
<div>
Ready <code>claude</code>
{isCustom && <span className="tw-text-muted"> (custom)</span>}
</div>
<div className="tw-break-all tw-font-mono tw-text-xs">{resolvedPath}</div>
</>
) : (
<span className="tw-text-warning">
Setup required Claude CLI not found. Install with <code>{CLAUDE_INSTALL_COMMAND}</code>.
</span>
);
const onSaveCustomPath = React.useCallback(async (path: string): Promise<string | null> => {
const err = await validateExecutableFile(path);
if (err) return err;
setSettings((cur) => ({
agentMode: { ...cur.agentMode, claudeCli: { path } },
}));
new Notice("Claude CLI path saved.");
return null;
}, []);
const clearCustomPath = (): void => {
setSettings((cur) => ({
agentMode: { ...cur.agentMode, claudeCli: undefined },
}));
new Notice("Claude CLI override cleared. Auto-detection will be used.");
};
return (
<>
<SettingItem type="custom" title="Claude CLI" description={statusDescription}>
<div className="tw-flex tw-flex-wrap tw-justify-end tw-gap-2">
<Button variant="secondary" onClick={force}>
Re-detect
</Button>
{isCustom && (
<Button variant="destructive" onClick={clearCustomPath}>
Clear path
</Button>
)}
</div>
</SettingItem>
<SettingItem
type="custom"
title="Use custom Claude CLI path"
description="Skip auto-detection and point Agent Mode at a `claude` binary you already have on disk. Useful for non-standard prefixes (Volta, asdf, NVM, etc.)."
>
<BinaryPathSetting
binaryName="claude"
placeholder="/absolute/path/to/claude"
initialPath={overridePath}
notFoundHint={`claude not found on disk. Install with \`${CLAUDE_INSTALL_COMMAND}\` and try again, or paste a custom path manually.`}
onSave={onSaveCustomPath}
persistOnAutoDetect
detect={() => Promise.resolve(detectClaudeCliPath())}
/>
</SettingItem>
<SettingItem
type="custom"
title="Authentication"
description={renderAuthDescription(authEnv)}
>
<span />
</SettingItem>
<SettingItem
type="switch"
title="Show extended thinking"

View file

@ -1,27 +1,73 @@
import { getSettings } from "@/settings/model";
import { App } from "obsidian";
import { BinaryPathSetting } from "@/agentMode/backends/shared/BinaryPathSetting";
import { ConfigDialogShell, ConfigSection } from "@/agentMode/backends/shared/ConfigDialogShell";
import { InstallCommandRow } from "@/agentMode/backends/shared/InstallCommandRow";
import { InstallStatusLine } from "@/agentMode/backends/shared/installStatus";
import type { InstallState } from "@/agentMode/session/types";
import { ReactModal } from "@/components/modals/ReactModal";
import { useSettingsValue } from "@/settings/model";
import { validateExecutableFile } from "@/utils/detectBinary";
import { App, Notice } from "obsidian";
import React from "react";
import { BinaryInstallModal } from "@/agentMode/backends/shared/BinaryInstallContent";
import { CODEX_BINARY_NAME, CODEX_INSTALL_COMMAND, updateCodexFields } from "./descriptor";
export class CodexInstallModal extends BinaryInstallModal {
/**
* Configure dialog for the Codex backend. Codex spawns the self-contained
* `codex-acp` binary (it bundles the Codex engine as Rust crates no separate
* `codex` CLI is needed at runtime). The dialog configures the codex-acp path
* and gives auth guidance; the `codex` CLI is only relevant for `codex login`.
*/
const CodexConfigBody: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const settings = useSettingsValue();
const binaryPath = settings.agentMode?.backends?.codex?.binaryPath ?? "";
const sessionState: InstallState = binaryPath
? { kind: "ready", source: "custom" }
: { kind: "absent" };
const onSavePath = React.useCallback(async (path: string): Promise<string | null> => {
const err = await validateExecutableFile(path);
if (err) return err;
updateCodexFields({ binaryPath: path });
new Notice("Codex binary path saved.");
return null;
}, []);
return (
<ConfigDialogShell status={<InstallStatusLine state={sessionState} />} onClose={onClose}>
<ConfigSection title="Install codex-acp">
<InstallCommandRow command={CODEX_INSTALL_COMMAND} />
</ConfigSection>
<ConfigSection title="codex-acp path">
<p className="tw-my-0 tw-text-sm tw-text-muted">
Use an existing <code>{CODEX_BINARY_NAME}</code> binary you have on disk.
</p>
<BinaryPathSetting
binaryName={CODEX_BINARY_NAME}
placeholder="/absolute/path/to/codex-acp"
initialPath={binaryPath}
notFoundHint={`${CODEX_BINARY_NAME} not found on PATH. Run the install command above, then click Auto-detect again.`}
onSave={onSavePath}
persistOnAutoDetect
/>
</ConfigSection>
<ConfigSection title="Authentication">
<p className="tw-my-0 tw-text-sm tw-text-muted">
Codex inherits auth from your local <code>codex login</code> credentials, or from{" "}
<code>OPENAI_API_KEY</code> / <code>CODEX_API_KEY</code> exported in your shell.
</p>
</ConfigSection>
</ConfigDialogShell>
);
};
/** Configure dialog for the Codex backend. Opened via `descriptor.openInstallUI`. */
export class CodexInstallModal extends ReactModal {
constructor(app: App) {
super(app, {
modalTitle: "Configure Codex (Agent backend)",
binaryDisplayName: "Codex",
binaryName: CODEX_BINARY_NAME,
installCommand: CODEX_INSTALL_COMMAND,
pathPlaceholder: "/absolute/path/to/codex-acp",
initialPath: getSettings().agentMode?.backends?.codex?.binaryPath ?? "",
description: (
<>
Codex uses the official <code>@zed-industries/codex-acp</code> adapter, which wraps the
local <code>codex</code> CLI. It inherits your existing <code>codex login</code>{" "}
credentials or set <code>OPENAI_API_KEY</code> / <code>CODEX_API_KEY</code> in your
shell if you prefer API-key auth.
</>
),
onPersist: (path) => updateCodexFields({ binaryPath: path }),
});
super(app, "Configure Codex");
}
protected renderContent(close: () => void): React.ReactElement {
return <CodexConfigBody onClose={close} />;
}
}

View file

@ -1,39 +1,29 @@
import { EnvOverridesSetting } from "@/agentMode/backends/shared/EnvOverridesSetting";
import type CopilotPlugin from "@/main";
import { getSettings, useSettingsValue } from "@/settings/model";
import { useSettingsValue } from "@/settings/model";
import type { App } from "obsidian";
import React from "react";
import { SimpleBackendSettingsPanel } from "@/agentMode/backends/shared/SimpleBackendSettingsPanel";
import { CodexInstallModal } from "./CodexInstallModal";
import { CODEX_BINARY_NAME, CODEX_INSTALL_COMMAND, updateCodexFields } from "./descriptor";
import { updateCodexFields } from "./descriptor";
interface Props {
plugin: CopilotPlugin;
app: App;
}
export const CodexSettingsPanel: React.FC<Props> = ({ app }) => {
/**
* Codex card extras. The codex-acp install / path / auth configuration lives
* in the Configure dialog (`CodexInstallModal`, opened via
* `descriptor.openInstallUI`); this panel only hosts the spawn-time
* environment-variable overrides that remain on the settings card.
*/
export const CodexSettingsPanel: React.FC<Props> = () => {
const settings = useSettingsValue();
return (
<>
<SimpleBackendSettingsPanel
displayName="Codex"
binaryName={CODEX_BINARY_NAME}
installCommand={CODEX_INSTALL_COMMAND}
pathPlaceholder="/absolute/path/to/codex-acp"
customPathTitle="Custom codex-acp path"
customPathDescription="Point Agent Mode at the codex-acp binary. Codex inherits auth from your local `codex login` credentials, or from `OPENAI_API_KEY` / `CODEX_API_KEY` exported in your shell."
readStoredPath={() => getSettings().agentMode?.backends?.codex?.binaryPath ?? ""}
persistPath={(path) => updateCodexFields({ binaryPath: path })}
openInstallModal={() => new CodexInstallModal(app).open()}
/>
<EnvOverridesSetting
backendDisplayName="Codex"
value={settings.agentMode?.backends?.codex?.envOverrides}
onChange={(next) => updateCodexFields({ envOverrides: next })}
hintExamples={["CODEX_HOME", "OPENAI_BASE_URL"]}
/>
</>
<EnvOverridesSetting
backendDisplayName="Codex"
value={settings.agentMode?.backends?.codex?.envOverrides}
onChange={(next) => updateCodexFields({ envOverrides: next })}
hintExamples={["CODEX_HOME", "OPENAI_BASE_URL"]}
/>
);
};

View file

@ -1,31 +1,24 @@
import { ReactModal } from "@/components/modals/ReactModal";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { BinaryPathSetting } from "@/agentMode/backends/shared/BinaryPathSetting";
import { ConfigDialogShell, ConfigSection } from "@/agentMode/backends/shared/ConfigDialogShell";
import { InstallStatusLine } from "@/agentMode/backends/shared/installStatus";
import {
AbortError,
computeInstallState,
InstallOptions,
ProgressEvent,
} from "@/agentMode/backends/opencode/OpencodeBinaryManager";
import type { OpencodeBinaryManager } from "@/agentMode/backends/opencode/OpencodeBinaryManager";
import type { InstallState } from "@/agentMode/session/types";
import { ReactModal } from "@/components/modals/ReactModal";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { OPENCODE_PINNED_VERSION } from "@/constants";
import { App } from "obsidian";
import { logError } from "@/logger";
import { useSettingsValue } from "@/settings/model";
import { App, Notice } from "obsidian";
import React from "react";
type ModalState =
| { kind: "confirm" }
| { kind: "running"; progress: ProgressEvent | null }
| { kind: "success"; version: string; path: string }
| { kind: "error"; message: string };
interface ContentProps {
manager: OpencodeBinaryManager;
hostPlatform: string;
hostArch: string;
pinnedVersion: string;
destinationDir: string;
onClose: () => void;
}
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@ -60,89 +53,82 @@ const phaseProgress = (e: ProgressEvent | null): number | undefined => {
return undefined;
};
const OpencodeInstallContent: React.FC<ContentProps> = ({
manager,
hostPlatform,
hostArch,
pinnedVersion,
destinationDir,
onClose,
}) => {
const [state, setState] = React.useState<ModalState>({ kind: "confirm" });
type RunState =
| { kind: "idle" }
| { kind: "running"; progress: ProgressEvent | null }
| { kind: "error"; message: string };
type LocalInstallState = ReturnType<typeof computeInstallState>;
/**
* The managed-download section of the opencode dialog. Owns the
* download/extract progress sub-state; on success the settings change
* re-renders the parent (which reads `computeInstallState` via
* `useSettingsValue`), so it doesn't need to report completion upward.
*/
const OpencodeManagedInstall: React.FC<{
manager: OpencodeBinaryManager;
hostPlatform: string;
hostArch: string;
local: LocalInstallState;
app: App;
}> = ({ manager, hostPlatform, hostArch, local, app }) => {
const [run, setRun] = React.useState<RunState>({ kind: "idle" });
const abortRef = React.useRef<AbortController | null>(null);
const startInstall = React.useCallback(() => {
const controller = new AbortController();
abortRef.current = controller;
setState({ kind: "running", progress: null });
setRun({ kind: "running", progress: null });
const opts: InstallOptions = {
signal: controller.signal,
onProgress: (e) => setState({ kind: "running", progress: e }),
onProgress: (e) => setRun({ kind: "running", progress: e }),
};
manager
.install(opts)
.then(({ version, path }) => setState({ kind: "success", version, path }))
.then(({ version }) => {
setRun({ kind: "idle" });
new Notice(`opencode v${version} installed.`);
})
.catch((err: unknown) => {
if (err instanceof AbortError || (err as Error)?.name === "AbortError") {
// User cancelled — go back to confirm so they can retry.
setState({ kind: "confirm" });
setRun({ kind: "idle" });
return;
}
const message = err instanceof Error ? err.message : String(err);
setState({ kind: "error", message });
setRun({ kind: "error", message: err instanceof Error ? err.message : String(err) });
});
}, [manager]);
const cancelInstall = React.useCallback(() => {
abortRef.current?.abort();
}, []);
const cancelInstall = React.useCallback(() => abortRef.current?.abort(), []);
React.useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
React.useEffect(() => () => abortRef.current?.abort(), []);
if (state.kind === "confirm") {
const handleUninstall = React.useCallback(() => {
new ConfirmModal(
app,
async () => {
try {
await manager.uninstall();
new Notice("opencode uninstalled.");
} catch (e) {
logError("[AgentMode] uninstall failed", e);
new Notice(`Uninstall failed: ${e instanceof Error ? e.message : String(e)}`);
}
},
"Remove the installed opencode binary? Your BYOK keys and MCP config will be kept.",
"Uninstall opencode",
"Uninstall"
).open();
}, [app, manager]);
if (run.kind === "running") {
const pct = phaseProgress(run.progress);
return (
<div className="tw-flex tw-flex-col tw-gap-4">
<p>
opencode runs locally on your machine. The official binary will be downloaded from{" "}
<code>github.com/sst/opencode/releases</code> over HTTPS and smoke-tested with{" "}
<code>--version</code> before being activated.
</p>
<dl className="tw-grid tw-grid-cols-[max-content_1fr] tw-gap-x-4 tw-gap-y-1 tw-text-sm">
<dt className="tw-text-muted">Platform</dt>
<dd className="tw-font-mono">
{hostPlatform}-{hostArch}
</dd>
<dt className="tw-text-muted">Version</dt>
<dd className="tw-font-mono">v{pinnedVersion} (pinned)</dd>
<dt className="tw-text-muted">Destination</dt>
<dd className="tw-break-all tw-font-mono tw-text-xs">{destinationDir}</dd>
</dl>
<div className="tw-flex tw-justify-end tw-gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="default" onClick={startInstall}>
Download & install
</Button>
</div>
</div>
);
}
if (state.kind === "running") {
const pct = phaseProgress(state.progress);
return (
<div className="tw-flex tw-flex-col tw-gap-4">
<p className="tw-text-sm">{phaseLabel(state.progress)}</p>
<div className="tw-flex tw-flex-col tw-gap-2">
<p className="tw-text-sm">{phaseLabel(run.progress)}</p>
<Progress value={pct ?? 0} />
<div className="tw-flex tw-justify-end">
<Button variant="ghost" onClick={cancelInstall}>
<Button variant="ghost" size="default" onClick={cancelInstall}>
Cancel
</Button>
</div>
@ -150,58 +136,151 @@ const OpencodeInstallContent: React.FC<ContentProps> = ({
);
}
if (state.kind === "success") {
return (
<div className="tw-flex tw-flex-col tw-gap-4">
<p>
opencode <code>v{state.version}</code> installed successfully.
</p>
<p className="tw-break-all tw-font-mono tw-text-xs tw-text-muted">{state.path}</p>
<div className="tw-flex tw-justify-end">
<Button variant="default" onClick={onClose}>
Done
</Button>
</div>
</div>
);
}
const isManaged = local.kind === "installed" && local.source === "managed";
// error
return (
<div className="tw-flex tw-flex-col tw-gap-4">
<p className="tw-text-error">Install failed.</p>
<pre className="tw-max-h-32 tw-overflow-auto tw-whitespace-pre-wrap tw-rounded tw-bg-secondary tw-p-2 tw-text-xs">
{state.message}
</pre>
<div className="tw-flex tw-flex-col tw-gap-2">
<dl className="tw-grid tw-grid-cols-[max-content_1fr] tw-gap-x-4 tw-gap-y-1 tw-text-sm">
<dt className="tw-text-muted">Platform</dt>
<dd className="tw-font-mono">
{hostPlatform}-{hostArch}
</dd>
<dt className="tw-text-muted">Version</dt>
<dd className="tw-font-mono">v{OPENCODE_PINNED_VERSION} (pinned)</dd>
<dt className="tw-text-muted">Destination</dt>
<dd className="tw-break-all tw-font-mono tw-text-xs">{manager.getDataDir()}</dd>
</dl>
{run.kind === "error" && (
<pre className="tw-max-h-32 tw-overflow-auto tw-whitespace-pre-wrap tw-rounded tw-bg-secondary tw-p-2 tw-text-xs tw-text-error">
{run.message}
</pre>
)}
<div className="tw-flex tw-justify-end tw-gap-2">
<Button variant="ghost" onClick={onClose}>
Close
</Button>
<Button variant="default" onClick={startInstall}>
Retry
</Button>
{isManaged ? (
<>
<Button variant="secondary" size="default" onClick={startInstall}>
Reinstall
</Button>
<Button variant="destructive" size="default" onClick={handleUninstall}>
Uninstall
</Button>
</>
) : (
<Button variant="default" size="default" onClick={startInstall}>
Download &amp; install
</Button>
)}
</div>
</div>
);
};
/**
* Configure dialog for the opencode backend. Presents the two clearly-separated
* setup paths: download the managed binary, or point at your own binary on
* disk. Always accessible so users can switch between them.
*/
const OpencodeConfigBody: React.FC<{
manager: OpencodeBinaryManager;
hostPlatform: string;
hostArch: string;
app: App;
onClose: () => void;
}> = ({ manager, hostPlatform, hostArch, app, onClose }) => {
const settings = useSettingsValue();
const local = computeInstallState(settings.agentMode?.backends?.opencode);
// Narrow once so `.path` is reachable; `isCustom`/`customPath` derive from it.
const customInstall = local.kind === "installed" && local.source === "custom" ? local : null;
const isCustom = customInstall !== null;
const customPath = customInstall?.path ?? "";
const sessionState: InstallState =
local.kind === "installed" ? { kind: "ready", source: local.source } : { kind: "absent" };
const detail = local.kind === "installed" ? `opencode v${local.version}` : undefined;
const onSaveCustomPath = React.useCallback(
async (path: string): Promise<string | null> => {
try {
await manager.setCustomBinaryPath(path);
} catch (e) {
return e instanceof Error ? e.message : String(e);
}
new Notice("Custom opencode binary path saved.");
return null;
},
[manager]
);
const clearCustomPath = React.useCallback(async (): Promise<void> => {
await manager.setCustomBinaryPath(null);
new Notice("Custom opencode path cleared.");
}, [manager]);
return (
<ConfigDialogShell
status={<InstallStatusLine state={sessionState} detail={detail} />}
onClose={onClose}
>
<ConfigSection title="Download managed binary">
<p className="tw-my-0 tw-text-sm tw-text-muted">
Let Copilot download and manage the official opencode binary from its GitHub repo.
</p>
{isCustom && (
<p className="tw-my-0 tw-text-sm tw-text-muted">
A custom binary is active (below). Download a managed copy to switch.
</p>
)}
<OpencodeManagedInstall
manager={manager}
hostPlatform={hostPlatform}
hostArch={hostArch}
local={local}
app={app}
/>
</ConfigSection>
<ConfigSection title="Use your own binary">
<p className="tw-my-0 tw-text-sm tw-text-muted">
Point Agent Mode at a binary you already have on disk. Useful for self-builders or
air-gapped machines.
</p>
<BinaryPathSetting
binaryName="opencode"
placeholder="/absolute/path/to/opencode"
initialPath={customPath}
notFoundHint="opencode not found on PATH. Install it or paste a custom path manually."
onSave={onSaveCustomPath}
persistOnAutoDetect
/>
{isCustom && (
<div className="tw-flex tw-justify-end">
<Button variant="destructive" size="default" onClick={clearCustomPath}>
Clear custom path
</Button>
</div>
)}
</ConfigSection>
</ConfigDialogShell>
);
};
/** Configure dialog for the opencode backend. Opened via `descriptor.openInstallUI`. */
export class OpencodeInstallModal extends ReactModal {
constructor(
app: App,
private readonly manager: OpencodeBinaryManager,
private readonly hostInfo: { platform: string; arch: string }
) {
super(app, "Install opencode (BYOK Agent backend)");
super(app, "Configure opencode");
}
protected renderContent(close: () => void): React.ReactElement {
return (
<OpencodeInstallContent
<OpencodeConfigBody
manager={this.manager}
hostPlatform={this.hostInfo.platform}
hostArch={this.hostInfo.arch}
pinnedVersion={OPENCODE_PINNED_VERSION}
destinationDir={this.manager.getDataDir()}
app={this.app}
onClose={close}
/>
);

View file

@ -1,159 +1,28 @@
import { OpencodeInstallModal } from "@/agentMode/backends/opencode/OpencodeInstallModal";
import { BinaryPathSetting } from "@/agentMode/backends/shared/BinaryPathSetting";
import { EnvOverridesSetting } from "@/agentMode/backends/shared/EnvOverridesSetting";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import { logError } from "@/logger";
import type CopilotPlugin from "@/main";
import { updateAgentModeBackendFields, useSettingsValue } from "@/settings/model";
import type { App } from "obsidian";
import { Notice } from "obsidian";
import React from "react";
import { computeInstallState, type InstallState } from "./OpencodeBinaryManager";
import { getOpencodeBinaryManager } from "./descriptor";
import { mapNodeArch, mapNodePlatform } from "./platformResolver";
interface Props {
plugin: CopilotPlugin;
app: App;
}
function renderStatusDescription(state: InstallState): React.ReactNode {
if (state.kind === "absent") {
return <span className="tw-text-warning">Setup required opencode is not installed.</span>;
}
return (
<>
<div>
Ready opencode <code>v{state.version}</code>
{state.source === "custom" && <span className="tw-text-muted"> (custom)</span>}
</div>
<div className="tw-break-all tw-font-mono tw-text-xs">{state.path}</div>
</>
);
}
/**
* OpenCode-specific settings panel. Lives in the OpenCode backend folder so
* the generic Agent Mode settings tab stays backend-agnostic it just
* renders `descriptor.SettingsPanel`.
* OpenCode card extras. Binary install / detection / path configuration lives
* in the Configure dialog (`OpencodeInstallModal`, opened via
* `descriptor.openInstallUI`); this panel only hosts the spawn-time
* environment-variable overrides that remain on the settings card.
*/
export const OpencodeSettingsPanel: React.FC<Props> = ({ plugin, app }) => {
export const OpencodeSettingsPanel: React.FC<Props> = () => {
const settings = useSettingsValue();
const manager = getOpencodeBinaryManager(plugin);
const [showAdvanced, setShowAdvanced] = React.useState(false);
const installState = computeInstallState(settings.agentMode?.backends?.opencode);
const openInstallModal = (): void => {
new OpencodeInstallModal(app, manager, {
platform: mapNodePlatform(process.platform) ?? process.platform,
arch: mapNodeArch(process.arch) ?? process.arch,
}).open();
};
const handleUninstall = (): void => {
new ConfirmModal(
app,
async () => {
try {
await manager.uninstall();
new Notice("opencode uninstalled.");
} catch (e) {
logError("[AgentMode] uninstall failed", e);
new Notice(`Uninstall failed: ${e instanceof Error ? e.message : String(e)}`);
}
},
"Remove the installed opencode binary? Your BYOK keys and MCP config will be kept.",
"Uninstall opencode",
"Uninstall"
).open();
};
const onSaveCustomPath = React.useCallback(
async (path: string): Promise<string | null> => {
try {
await manager.setCustomBinaryPath(path);
} catch (e) {
return e instanceof Error ? e.message : String(e);
}
new Notice("Custom opencode binary path saved.");
return null;
},
[manager]
);
const clearCustomPath = async (): Promise<void> => {
await manager.setCustomBinaryPath(null);
};
return (
<>
<SettingItem
type="custom"
title="opencode binary"
description={renderStatusDescription(installState)}
>
<div className="tw-flex tw-flex-wrap tw-justify-end tw-gap-2">
{installState.kind === "absent" && (
<Button variant="default" onClick={openInstallModal}>
Install opencode
</Button>
)}
{installState.kind === "installed" && installState.source === "managed" && (
<>
<Button variant="secondary" onClick={openInstallModal}>
Reinstall
</Button>
<Button variant="destructive" onClick={handleUninstall}>
Uninstall
</Button>
</>
)}
{installState.kind === "installed" && installState.source === "custom" && (
<Button variant="secondary" onClick={clearCustomPath}>
Clear custom path
</Button>
)}
</div>
</SettingItem>
{!(installState.kind === "installed" && installState.source === "custom") && (
<div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdvanced((s) => !s)}
aria-expanded={showAdvanced}
>
{showAdvanced ? "▾ Advanced" : "▸ Advanced"}
</Button>
{showAdvanced && (
<SettingItem
type="custom"
title="Use custom opencode binary path"
description="Skip the managed install and point Agent Mode at a binary you already have on disk. Useful for self-builders or air-gapped machines."
>
<BinaryPathSetting
binaryName="opencode"
placeholder="/absolute/path/to/opencode"
initialPath=""
notFoundHint="opencode not found on PATH. Install it or paste a custom path manually."
onSave={onSaveCustomPath}
persistOnAutoDetect
/>
</SettingItem>
)}
</div>
)}
<EnvOverridesSetting
backendDisplayName="opencode"
value={settings.agentMode?.backends?.opencode?.envOverrides}
onChange={(next) => updateAgentModeBackendFields("opencode", { envOverrides: next })}
hintExamples={["XDG_CONFIG_HOME", "HTTPS_PROXY"]}
/>
</>
<EnvOverridesSetting
backendDisplayName="opencode"
value={settings.agentMode?.backends?.opencode?.envOverrides}
onChange={(next) => updateAgentModeBackendFields("opencode", { envOverrides: next })}
hintExamples={["XDG_CONFIG_HOME", "HTTPS_PROXY"]}
/>
);
};

View file

@ -1,106 +0,0 @@
import { ReactModal } from "@/components/modals/ReactModal";
import { BinaryPathSetting } from "./BinaryPathSetting";
import { Button } from "@/components/ui/button";
import { logError } from "@/logger";
import { validateExecutableFile } from "@/utils/detectBinary";
import { App, Notice } from "obsidian";
import React from "react";
export interface BinaryInstallContentProps {
/** Modal title (e.g. "Configure Claude Code (Agent backend)"). */
modalTitle: string;
/** Lower-case display label for save toast (e.g. "Claude Code"). */
binaryDisplayName: string;
/** Binary lookup name (e.g. "claude-agent-acp"). */
binaryName: string;
/** Shell command to install the binary. */
installCommand: string;
/** Placeholder shown in the binary-path input. */
pathPlaceholder: string;
/** Initial path read from settings. */
initialPath: string;
/** Body paragraph above the install command. */
description: React.ReactNode;
/** Persist the validated path back to settings. */
onPersist: (path: string) => void;
onClose: () => void;
}
const BinaryInstallContent: React.FC<BinaryInstallContentProps> = ({
binaryDisplayName,
binaryName,
installCommand,
pathPlaceholder,
initialPath,
description,
onPersist,
onClose,
}) => {
const onSave = React.useCallback(
async (path: string): Promise<string | null> => {
const err = await validateExecutableFile(path);
if (err) return err;
onPersist(path);
new Notice(`${binaryDisplayName} binary path saved.`);
onClose();
return null;
},
[binaryDisplayName, onPersist, onClose]
);
const copy = React.useCallback((): void => {
navigator.clipboard.writeText(installCommand).catch((e) => {
logError("[AgentMode] copy install command failed", e);
});
new Notice("Copied to clipboard.");
}, [installCommand]);
return (
<div className="tw-flex tw-flex-col tw-gap-4">
<p>{description}</p>
<div className="tw-flex tw-flex-col tw-gap-1">
<span className="tw-text-xs tw-text-muted">Install command</span>
<div className="tw-flex tw-items-center tw-gap-2">
<code className="tw-flex-1 tw-break-all tw-rounded tw-bg-secondary tw-p-2 tw-text-xs">
{installCommand}
</code>
<Button variant="ghost" size="sm" onClick={copy}>
Copy
</Button>
</div>
</div>
<div className="tw-flex tw-flex-col tw-gap-2">
<span className="tw-text-xs tw-text-muted">Binary path</span>
<BinaryPathSetting
binaryName={binaryName}
placeholder={pathPlaceholder}
initialPath={initialPath}
notFoundHint={`${binaryName} not found on PATH. Run the install command above, then click Auto-detect again.`}
onSave={onSave}
/>
</div>
<div className="tw-flex tw-justify-end">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
</div>
</div>
);
};
/**
* Generic install modal for backends whose configuration is "point us at a
* binary." Backends provide identifying strings + a persist callback.
*/
export class BinaryInstallModal extends ReactModal {
constructor(
app: App,
private readonly props: Omit<BinaryInstallContentProps, "onClose">
) {
super(app, props.modalTitle);
}
protected renderContent(close: () => void): React.ReactElement {
return <BinaryInstallContent {...this.props} onClose={close} />;
}
}

View file

@ -93,24 +93,23 @@ export const BinaryPathSetting: React.FC<Props> = ({
}, [binaryName, busy, notFoundHint, onSave, persistOnAutoDetect, detect]);
return (
<div className="tw-flex tw-w-full tw-flex-col tw-gap-2 sm:tw-w-[360px]">
<div className="tw-flex tw-w-full tw-flex-col tw-gap-2">
<div className="tw-flex tw-items-center tw-gap-2">
<Input
type="text"
placeholder={placeholder}
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
className="tw-flex-1"
/>
<Button variant="secondary" size="sm" onClick={autoDetect} disabled={busy}>
<Button variant="secondary" size="default" onClick={autoDetect} disabled={busy}>
Auto-detect
</Button>
</div>
<div className="tw-flex tw-justify-end">
<Button variant="default" onClick={apply} disabled={busy}>
<Button variant="default" size="default" onClick={apply} disabled={busy}>
Apply
</Button>
</div>
{error && <div className="tw-text-xs tw-text-error">{error}</div>}
{error && <div className="tw-text-sm tw-text-error">{error}</div>}
</div>
);
};

View file

@ -0,0 +1,52 @@
import { Button } from "@/components/ui/button";
import React from "react";
interface ConfigDialogShellProps {
/** One-line current-state summary rendered at the top (e.g. <InstallStatusLine/>). */
status: React.ReactNode;
/** Ordered body sections — compose <ConfigSection> children. */
children: React.ReactNode;
/** Footer-right content. Defaults to a single "Done" button. */
footer?: React.ReactNode;
onClose: () => void;
}
/**
* Presentational layout shared by every agent's Configure dialog so the status
* line, sections, and footer stay visually consistent across the three
* (intentionally bespoke) bodies. Rendered inside a per-agent `ReactModal`
* subclass it is not itself a modal; the modal owns the title chrome.
*/
export const ConfigDialogShell: React.FC<ConfigDialogShellProps> = ({
status,
children,
footer,
onClose,
}) => (
<div className="tw-flex tw-flex-col tw-gap-4">
{status}
<div className="tw-flex tw-flex-col tw-gap-4">{children}</div>
<div className="tw-flex tw-justify-end tw-gap-2 tw-border-[0px] tw-border-t tw-border-solid tw-border-border tw-pt-4">
{footer ?? (
<Button variant="default" size="default" onClick={onClose}>
Done
</Button>
)}
</div>
</div>
);
/**
* One labeled section inside a {@link ConfigDialogShell}. The optional title
* renders a subtle header above the body; a hairline divider separates each
* section from the content above it.
*/
export const ConfigSection: React.FC<{ title?: string; children: React.ReactNode }> = ({
title,
children,
}) => (
<div className="tw-flex tw-flex-col tw-gap-2 tw-border-[0px] tw-border-t tw-border-solid tw-border-border tw-pt-4">
{title && <div className="tw-text-sm tw-font-medium">{title}</div>}
{children}
</div>
);

View file

@ -168,7 +168,7 @@ export const EnvOverridesSetting: React.FC<Props> = ({
);
})}
<div className="tw-flex tw-justify-end">
<Button variant="secondary" size="sm" onClick={addRow}>
<Button variant="secondary" size="default" onClick={addRow}>
<Plus className="tw-size-icon-xs" />
Add variable
</Button>

View file

@ -0,0 +1,38 @@
import { Button } from "@/components/ui/button";
import { logError } from "@/logger";
import { Notice } from "obsidian";
import React from "react";
interface Props {
/** Shell command to display and copy. */
command: string;
/** Label above the command block. Defaults to "Install command". */
label?: string;
}
/**
* A copy-able shell command block (install instructions) with a Copy button.
* Shared by the Claude and Codex Configure dialogs.
*/
export const InstallCommandRow: React.FC<Props> = ({ command, label = "Install command" }) => {
const copy = React.useCallback((): void => {
navigator.clipboard.writeText(command).catch((e) => {
logError("[AgentMode] copy install command failed", e);
});
new Notice("Copied to clipboard.");
}, [command]);
return (
<div className="tw-flex tw-flex-col tw-gap-1">
<span className="tw-text-xs tw-text-muted">{label}</span>
<div className="tw-flex tw-items-center tw-gap-2">
<code className="tw-flex-1 tw-break-all tw-rounded tw-bg-secondary tw-p-2 tw-text-xs">
{command}
</code>
<Button variant="ghost" size="default" onClick={copy}>
Copy
</Button>
</div>
</div>
);
};

View file

@ -1,107 +0,0 @@
import { BinaryPathSetting } from "./BinaryPathSetting";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import { useSettingsValue } from "@/settings/model";
import { validateExecutableFile } from "@/utils/detectBinary";
import { Notice } from "obsidian";
import React from "react";
export interface SimpleBackendSettingsPanelProps {
/** Lower-case display name (e.g. "Claude Code"). */
displayName: string;
/** Binary lookup name (e.g. "claude-agent-acp"). */
binaryName: string;
/** Shell command users can run to install the binary. */
installCommand: string;
/** Placeholder for the binary-path input. */
pathPlaceholder: string;
/** Title of the custom-path SettingItem. */
customPathTitle: string;
/** Description of the custom-path SettingItem. */
customPathDescription: string;
/** Read the configured binary path from current settings. */
readStoredPath: () => string;
/** Persist `path` (or clear when `undefined`) back to settings. */
persistPath: (path: string | undefined) => void;
/** Open the matching install modal. */
openInstallModal: () => void;
}
/**
* Settings panel for backends whose configuration is a single binary path
* (Claude Code, Codex). Manages the "Configure / Clear path" SettingItem
* plus a custom-path entry.
*/
export const SimpleBackendSettingsPanel: React.FC<SimpleBackendSettingsPanelProps> = ({
displayName,
binaryName,
installCommand,
pathPlaceholder,
customPathTitle,
customPathDescription,
readStoredPath,
persistPath,
openInstallModal,
}) => {
// Re-render whenever settings change.
useSettingsValue();
const stored = readStoredPath();
const onSave = React.useCallback(
async (path: string): Promise<string | null> => {
const err = await validateExecutableFile(path);
if (err) return err;
persistPath(path);
new Notice(`${displayName} binary path saved.`);
return null;
},
[displayName, persistPath]
);
const clear = React.useCallback((): void => {
persistPath(undefined);
}, [persistPath]);
const description = stored ? (
<>
<div>
Ready <code>{binaryName}</code> (custom path)
</div>
<div className="tw-break-all tw-font-mono tw-text-xs">{stored}</div>
</>
) : (
<span className="tw-text-warning">
Setup required {displayName} binary path not configured.
</span>
);
return (
<>
<SettingItem type="custom" title={`${displayName} binary`} description={description}>
<div className="tw-flex tw-flex-wrap tw-justify-end tw-gap-2">
{!stored && (
<Button variant="default" onClick={openInstallModal}>
Configure
</Button>
)}
{stored && (
<Button variant="destructive" onClick={clear}>
Clear path
</Button>
)}
</div>
</SettingItem>
<SettingItem type="custom" title={customPathTitle} description={customPathDescription}>
<BinaryPathSetting
binaryName={binaryName}
placeholder={pathPlaceholder}
initialPath={stored}
notFoundHint={`${binaryName} not found on PATH. Install with \`${installCommand}\` and try again.`}
onSave={onSave}
persistOnAutoDetect
/>
</SettingItem>
</>
);
};

View file

@ -0,0 +1,32 @@
import type { InstallState } from "@/agentMode/session/types";
import { installBadge } from "./installStatus";
describe("installBadge", () => {
it("returns a green 'Ready' badge with a check for ready state", () => {
const spec = installBadge({ kind: "ready", source: "managed" });
expect(spec).toEqual({
label: "Ready",
variant: "outline",
className: "tw-text-success",
showCheck: true,
});
});
it("ignores source — custom and managed both read 'Ready' (no path/source on the card)", () => {
expect(installBadge({ kind: "ready", source: "custom" })?.label).toBe("Ready");
expect(installBadge({ kind: "ready", source: "managed" })?.label).toBe("Ready");
});
it("returns null for absent state — the missing badge is the 'not configured' signal", () => {
expect(installBadge({ kind: "absent" })).toBeNull();
});
it("returns a destructive 'Error' badge carrying the message as a tooltip", () => {
const state: InstallState = { kind: "error", message: "boom" };
expect(installBadge(state)).toEqual({
label: "Error",
variant: "destructive",
title: "boom",
});
});
});

View file

@ -0,0 +1,69 @@
import type { InstallState } from "@/agentMode/session/types";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import React from "react";
interface InstallBadgeSpec {
label: string;
variant: "outline" | "destructive";
/** Extra text color class layered on the badge. */
className?: string;
/** Render a leading check glyph (ready state). */
showCheck?: boolean;
/** Tooltip text (error message). */
title?: string;
}
/**
* Derive the card status badge from an {@link InstallState}.
*
* Returns `null` when the agent is not configured the *absence* of a badge
* is the "not configured" signal; the CTA-styled Configure button carries the
* call to action, so no "Setup required" pill is shown on the card.
*/
export function installBadge(state: InstallState): InstallBadgeSpec | null {
if (state.kind === "ready") {
return { label: "Ready", variant: "outline", className: "tw-text-success", showCheck: true };
}
if (state.kind === "error") {
return { label: "Error", variant: "destructive", title: state.message };
}
// absent → no badge.
return null;
}
/**
* Card status badge. Renders nothing when the agent is not configured.
*/
export const InstallBadge: React.FC<{ state: InstallState }> = ({ state }) => {
const spec = installBadge(state);
if (!spec) return null;
return (
<Badge variant={spec.variant} className={cn("tw-gap-1", spec.className)} title={spec.title}>
{spec.showCheck && <Check className="tw-size-icon-xs" />}
{spec.label}
</Badge>
);
};
/**
* Status line shown at the top of a Configure dialog: the badge plus an
* optional verbose detail line (resolved path / version) the caller supplies,
* since {@link InstallState} does not carry the path. Unlike the card, the
* unconfigured state reads explicitly here ("Not configured") because the
* dialog is where setup happens.
*/
export const InstallStatusLine: React.FC<{
state: InstallState;
detail?: React.ReactNode;
}> = ({ state, detail }) => (
<div className="tw-flex tw-flex-col tw-items-start tw-gap-1">
{state.kind === "absent" ? (
<span className="tw-text-sm tw-text-muted">Not configured.</span>
) : (
<InstallBadge state={state} />
)}
{detail && <div className="tw-break-all tw-font-mono tw-text-xs tw-text-muted">{detail}</div>}
</div>
);

View file

@ -23,6 +23,7 @@ export { useAgentModePicker } from "./ui/useAgentModePicker";
export type { AgentModePickerOverride } from "./ui/useAgentModePicker";
export type { AgentSessionManager } from "./session/AgentSessionManager";
export type { AgentBrand, BackendDescriptor, BackendId, InstallState } from "./session/types";
export { installBadge, InstallBadge, InstallStatusLine } from "./backends/shared/installStatus";
export { isAgentModelEnabled, writeAgentModelOverride } from "./session/modelEnable";
export { getBackendModelOverrides } from "./session/backendSettingsAccess";
export type {

View file

@ -526,9 +526,7 @@ export class SkillManager {
const patchResult = await this.runInternalMutation(
() => runUpdateProperties({ skill: activeSkill, patch: req.patch, fs }),
(r) =>
r.ok && vaultRoot !== null
? buildUpdatePropertiesExpectations(activeSkill, vaultRoot)
: []
r.ok && vaultRoot !== null ? buildUpdatePropertiesExpectations(activeSkill, vaultRoot) : []
);
if (!patchResult.ok) {
if (newName !== undefined) {

View file

@ -1,5 +1,6 @@
import {
getBackendModelOverrides,
InstallBadge,
isAgentModelEnabled,
listBackendDescriptors,
McpServersPanel,
@ -9,6 +10,7 @@ import {
type BackendState,
type ModelEntry,
} from "@/agentMode";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import { usePlugin } from "@/contexts/PluginContext";
import { logError } from "@/logger";
@ -125,12 +127,24 @@ const BackendSection: React.FC<{
}, [manager, descriptor.id, installState.kind, cachedModel]);
const overrides = getBackendModelOverrides(settings, descriptor.id);
const Icon = descriptor.Icon;
return (
<div className="tw-space-y-3 tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-3">
<div className="tw-text-base tw-font-semibold">{descriptor.displayName}</div>
{Panel && <Panel plugin={plugin} app={plugin.app} />}
<div className="tw-flex tw-items-center tw-justify-between tw-gap-2">
<div className="tw-flex tw-items-center tw-gap-2">
<Icon className="tw-size-4" />
<span className="tw-text-base tw-font-semibold">{descriptor.displayName}</span>
<InstallBadge state={installState} />
</div>
<Button
size="default"
variant={installState.kind === "ready" ? "secondary" : "default"}
onClick={() => descriptor.openInstallUI(plugin)}
>
Configure
</Button>
</div>
{installState.kind === "ready" && (
<ModelCurationBlock
@ -139,6 +153,8 @@ const BackendSection: React.FC<{
overrides={overrides}
/>
)}
{Panel && <Panel plugin={plugin} app={plugin.app} />}
</div>
);
};