diff --git a/src/agentMode/backends/claude/ClaudeInstallModal.tsx b/src/agentMode/backends/claude/ClaudeInstallModal.tsx index 36e0e750..39206941 100644 --- a/src/agentMode/backends/claude/ClaudeInstallModal.tsx +++ b/src/agentMode/backends/claude/ClaudeInstallModal.tsx @@ -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 => { + 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 ( + } onClose={onClose}> + + + + + +

+ Use an existing claude binary you have on disk. +

+ Promise.resolve(detectClaudeCliPath())} + /> + {isCustom && ( +
+ +
+ )} +
+ + +

+ Credentials inherit from the claude CLI login state — run claude{" "} + once to sign in — or set ANTHROPIC_API_KEY (or Bedrock / Vertex env) in your + shell. +

+
+
+ ); +}; + +/** 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 ; } } diff --git a/src/agentMode/backends/claude/ClaudeSettingsPanel.tsx b/src/agentMode/backends/claude/ClaudeSettingsPanel.tsx index c90e87ef..e34dc203 100644 --- a/src/agentMode/backends/claude/ClaudeSettingsPanel.tsx +++ b/src/agentMode/backends/claude/ClaudeSettingsPanel.tsx @@ -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 ( - - No auth env vars set — credentials inherit from the claude CLI login state. - - ); - } - return
{parts.join(" · ")}
; -} - -/** - * 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 = () => { 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 ? ( - <> -
- Ready — claude - {isCustom && (custom)} -
-
{resolvedPath}
- - ) : ( - - Setup required — Claude CLI not found. Install with {CLAUDE_INSTALL_COMMAND}. - - ); - - const onSaveCustomPath = React.useCallback(async (path: string): Promise => { - 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 ( <> - -
- - {isCustom && ( - - )} -
-
- - - Promise.resolve(detectClaudeCliPath())} - /> - - - - - - 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 => { + const err = await validateExecutableFile(path); + if (err) return err; + updateCodexFields({ binaryPath: path }); + new Notice("Codex binary path saved."); + return null; + }, []); + + return ( + } onClose={onClose}> + + + + + +

+ Use an existing {CODEX_BINARY_NAME} binary you have on disk. +

+ +
+ + +

+ Codex inherits auth from your local codex login credentials, or from{" "} + OPENAI_API_KEY / CODEX_API_KEY exported in your shell. +

+
+
+ ); +}; + +/** 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 @zed-industries/codex-acp adapter, which wraps the - local codex CLI. It inherits your existing codex login{" "} - credentials — or set OPENAI_API_KEY / CODEX_API_KEY 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 ; } } diff --git a/src/agentMode/backends/codex/CodexSettingsPanel.tsx b/src/agentMode/backends/codex/CodexSettingsPanel.tsx index 17cfb606..59a4d9ba 100644 --- a/src/agentMode/backends/codex/CodexSettingsPanel.tsx +++ b/src/agentMode/backends/codex/CodexSettingsPanel.tsx @@ -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 = ({ 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 = () => { const settings = useSettingsValue(); return ( - <> - getSettings().agentMode?.backends?.codex?.binaryPath ?? ""} - persistPath={(path) => updateCodexFields({ binaryPath: path })} - openInstallModal={() => new CodexInstallModal(app).open()} - /> - - updateCodexFields({ envOverrides: next })} - hintExamples={["CODEX_HOME", "OPENAI_BASE_URL"]} - /> - + updateCodexFields({ envOverrides: next })} + hintExamples={["CODEX_HOME", "OPENAI_BASE_URL"]} + /> ); }; diff --git a/src/agentMode/backends/opencode/OpencodeInstallModal.tsx b/src/agentMode/backends/opencode/OpencodeInstallModal.tsx index fa235632..8b716bd4 100644 --- a/src/agentMode/backends/opencode/OpencodeInstallModal.tsx +++ b/src/agentMode/backends/opencode/OpencodeInstallModal.tsx @@ -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 = ({ - manager, - hostPlatform, - hostArch, - pinnedVersion, - destinationDir, - onClose, -}) => { - const [state, setState] = React.useState({ kind: "confirm" }); +type RunState = + | { kind: "idle" } + | { kind: "running"; progress: ProgressEvent | null } + | { kind: "error"; message: string }; + +type LocalInstallState = ReturnType; + +/** + * 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({ kind: "idle" }); const abortRef = React.useRef(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 ( -
-

- opencode runs locally on your machine. The official binary will be downloaded from{" "} - github.com/sst/opencode/releases over HTTPS and smoke-tested with{" "} - --version before being activated. -

-
-
Platform
-
- {hostPlatform}-{hostArch} -
-
Version
-
v{pinnedVersion} (pinned)
-
Destination
-
{destinationDir}
-
-
- - -
-
- ); - } - - if (state.kind === "running") { - const pct = phaseProgress(state.progress); - return ( -
-

{phaseLabel(state.progress)}

+
+

{phaseLabel(run.progress)}

-
@@ -150,58 +136,151 @@ const OpencodeInstallContent: React.FC = ({ ); } - if (state.kind === "success") { - return ( -
-

- opencode v{state.version} installed successfully. -

-

{state.path}

-
- -
-
- ); - } + const isManaged = local.kind === "installed" && local.source === "managed"; - // error return ( -
-

Install failed.

-
-        {state.message}
-      
+
+
+
Platform
+
+ {hostPlatform}-{hostArch} +
+
Version
+
v{OPENCODE_PINNED_VERSION} (pinned)
+
Destination
+
{manager.getDataDir()}
+
+ {run.kind === "error" && ( +
+          {run.message}
+        
+ )}
- - + {isManaged ? ( + <> + + + + ) : ( + + )}
); }; +/** + * 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 => { + 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 => { + await manager.setCustomBinaryPath(null); + new Notice("Custom opencode path cleared."); + }, [manager]); + + return ( + } + onClose={onClose} + > + +

+ Let Copilot download and manage the official opencode binary from its GitHub repo. +

+ {isCustom && ( +

+ A custom binary is active (below). Download a managed copy to switch. +

+ )} + +
+ + +

+ Point Agent Mode at a binary you already have on disk. Useful for self-builders or + air-gapped machines. +

+ + {isCustom && ( +
+ +
+ )} +
+
+ ); +}; + +/** 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 ( - ); diff --git a/src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx b/src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx index 32bc4710..a1d41802 100644 --- a/src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx +++ b/src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx @@ -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 Setup required — opencode is not installed.; - } - return ( - <> -
- Ready — opencode v{state.version} - {state.source === "custom" && (custom)} -
-
{state.path}
- - ); -} - /** - * 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 = ({ plugin, app }) => { +export const OpencodeSettingsPanel: React.FC = () => { 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 => { - 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 => { - await manager.setCustomBinaryPath(null); - }; - return ( - <> - -
- {installState.kind === "absent" && ( - - )} - {installState.kind === "installed" && installState.source === "managed" && ( - <> - - - - )} - {installState.kind === "installed" && installState.source === "custom" && ( - - )} -
-
- - {!(installState.kind === "installed" && installState.source === "custom") && ( -
- - {showAdvanced && ( - - - - )} -
- )} - - updateAgentModeBackendFields("opencode", { envOverrides: next })} - hintExamples={["XDG_CONFIG_HOME", "HTTPS_PROXY"]} - /> - + updateAgentModeBackendFields("opencode", { envOverrides: next })} + hintExamples={["XDG_CONFIG_HOME", "HTTPS_PROXY"]} + /> ); }; diff --git a/src/agentMode/backends/shared/BinaryInstallContent.tsx b/src/agentMode/backends/shared/BinaryInstallContent.tsx deleted file mode 100644 index e6ad4aa5..00000000 --- a/src/agentMode/backends/shared/BinaryInstallContent.tsx +++ /dev/null @@ -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 = ({ - binaryDisplayName, - binaryName, - installCommand, - pathPlaceholder, - initialPath, - description, - onPersist, - onClose, -}) => { - const onSave = React.useCallback( - async (path: string): Promise => { - 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 ( -
-

{description}

-
- Install command -
- - {installCommand} - - -
-
-
- Binary path - -
-
- -
-
- ); -}; - -/** - * 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 - ) { - super(app, props.modalTitle); - } - - protected renderContent(close: () => void): React.ReactElement { - return ; - } -} diff --git a/src/agentMode/backends/shared/BinaryPathSetting.tsx b/src/agentMode/backends/shared/BinaryPathSetting.tsx index 4e0c9500..a88708bb 100644 --- a/src/agentMode/backends/shared/BinaryPathSetting.tsx +++ b/src/agentMode/backends/shared/BinaryPathSetting.tsx @@ -93,24 +93,23 @@ export const BinaryPathSetting: React.FC = ({ }, [binaryName, busy, notFoundHint, onSave, persistOnAutoDetect, detect]); return ( -
+
setPathInput(e.target.value)} + className="tw-flex-1" /> - -
-
-
- {error &&
{error}
} + {error &&
{error}
}
); }; diff --git a/src/agentMode/backends/shared/ConfigDialogShell.tsx b/src/agentMode/backends/shared/ConfigDialogShell.tsx new file mode 100644 index 00000000..82b618f2 --- /dev/null +++ b/src/agentMode/backends/shared/ConfigDialogShell.tsx @@ -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. ). */ + status: React.ReactNode; + /** Ordered body sections — compose 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 = ({ + status, + children, + footer, + onClose, +}) => ( +
+ {status} +
{children}
+
+ {footer ?? ( + + )} +
+
+); + +/** + * 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, +}) => ( +
+ {title &&
{title}
} + {children} +
+); diff --git a/src/agentMode/backends/shared/EnvOverridesSetting.tsx b/src/agentMode/backends/shared/EnvOverridesSetting.tsx index 2ec2b3cf..71949961 100644 --- a/src/agentMode/backends/shared/EnvOverridesSetting.tsx +++ b/src/agentMode/backends/shared/EnvOverridesSetting.tsx @@ -168,7 +168,7 @@ export const EnvOverridesSetting: React.FC = ({ ); })}
- diff --git a/src/agentMode/backends/shared/InstallCommandRow.tsx b/src/agentMode/backends/shared/InstallCommandRow.tsx new file mode 100644 index 00000000..6c963263 --- /dev/null +++ b/src/agentMode/backends/shared/InstallCommandRow.tsx @@ -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 = ({ 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 ( +
+ {label} +
+ + {command} + + +
+
+ ); +}; diff --git a/src/agentMode/backends/shared/SimpleBackendSettingsPanel.tsx b/src/agentMode/backends/shared/SimpleBackendSettingsPanel.tsx deleted file mode 100644 index c990da47..00000000 --- a/src/agentMode/backends/shared/SimpleBackendSettingsPanel.tsx +++ /dev/null @@ -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 = ({ - 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 => { - 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 ? ( - <> -
- Ready — {binaryName} (custom path) -
-
{stored}
- - ) : ( - - Setup required — {displayName} binary path not configured. - - ); - - return ( - <> - -
- {!stored && ( - - )} - {stored && ( - - )} -
-
- - - - - - ); -}; diff --git a/src/agentMode/backends/shared/installStatus.test.ts b/src/agentMode/backends/shared/installStatus.test.ts new file mode 100644 index 00000000..9f69a9b5 --- /dev/null +++ b/src/agentMode/backends/shared/installStatus.test.ts @@ -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", + }); + }); +}); diff --git a/src/agentMode/backends/shared/installStatus.tsx b/src/agentMode/backends/shared/installStatus.tsx new file mode 100644 index 00000000..6c8d1747 --- /dev/null +++ b/src/agentMode/backends/shared/installStatus.tsx @@ -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 ( + + {spec.showCheck && } + {spec.label} + + ); +}; + +/** + * 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 }) => ( +
+ {state.kind === "absent" ? ( + Not configured. + ) : ( + + )} + {detail &&
{detail}
} +
+); diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts index 508bd989..ef8476f7 100644 --- a/src/agentMode/index.ts +++ b/src/agentMode/index.ts @@ -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 { diff --git a/src/agentMode/skills/SkillManager.ts b/src/agentMode/skills/SkillManager.ts index e8df6d3f..270270e5 100644 --- a/src/agentMode/skills/SkillManager.ts +++ b/src/agentMode/skills/SkillManager.ts @@ -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) { diff --git a/src/settings/v2/components/AgentSettings.tsx b/src/settings/v2/components/AgentSettings.tsx index 3ef05338..07a37ac2 100644 --- a/src/settings/v2/components/AgentSettings.tsx +++ b/src/settings/v2/components/AgentSettings.tsx @@ -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 (
-
{descriptor.displayName}
- - {Panel && } +
+
+ + {descriptor.displayName} + +
+ +
{installState.kind === "ready" && ( )} + + {Panel && }
); };