mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
0114cdbd12
commit
d72e3ee05a
17 changed files with 591 additions and 691 deletions
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 & 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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
52
src/agentMode/backends/shared/ConfigDialogShell.tsx
Normal file
52
src/agentMode/backends/shared/ConfigDialogShell.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
38
src/agentMode/backends/shared/InstallCommandRow.tsx
Normal file
38
src/agentMode/backends/shared/InstallCommandRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
32
src/agentMode/backends/shared/installStatus.test.ts
Normal file
32
src/agentMode/backends/shared/installStatus.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
69
src/agentMode/backends/shared/installStatus.tsx
Normal file
69
src/agentMode/backends/shared/installStatus.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue