import { Plugin, WorkspaceLeaf, Notice, requestUrl } from "obsidian"; import type { Root } from "react-dom/client"; import * as semver from "semver"; import { ChatView, VIEW_TYPE_CHAT } from "./components/chat/ChatView"; import { createFloatingChat, FloatingViewContainer, } from "./components/chat/FloatingChatView"; import { FloatingButtonContainer } from "./components/chat/FloatingButton"; import { ChatViewRegistry } from "./shared/chat-view-registry"; import { createSettingsStore, type SettingsStore, } from "./adapters/obsidian/settings-store.adapter"; import { AgentClientSettingTab } from "./components/settings/AgentClientSettingTab"; import { AcpAdapter } from "./adapters/acp/acp.adapter"; import { sanitizeArgs, normalizeEnvVars, normalizeCustomAgent, ensureUniqueCustomAgentIds, } from "./shared/settings-utils"; import { parseChatFontSize, } from "./shared/display-settings"; import { AgentEnvVar, GeminiAgentSettings, ClaudeAgentSettings, CodexAgentSettings, CustomAgentSettings, } from "./domain/models/agent-config"; import type { SavedSessionInfo } from "./domain/models/session-info"; import { initializeLogger } from "./shared/logger"; // Re-export for backward compatibility export type { AgentEnvVar, CustomAgentSettings }; /** * Send message shortcut configuration. * - 'enter': Enter to send, Shift+Enter for newline (default) * - 'cmd-enter': Cmd/Ctrl+Enter to send, Enter for newline */ export type SendMessageShortcut = "enter" | "cmd-enter"; /** * Chat view location configuration. * - 'right-tab': Open in right pane as tabs (default) * - 'editor-tab': Open in editor area as tabs * - 'editor-split': Open in editor area with right split */ export type ChatViewLocation = "right-tab" | "editor-tab" | "editor-split"; export interface AgentClientPluginSettings { gemini: GeminiAgentSettings; claude: ClaudeAgentSettings; codex: CodexAgentSettings; customAgents: CustomAgentSettings[]; /** Default agent ID for new views (renamed from activeAgentId for multi-session) */ defaultAgentId: string; autoAllowPermissions: boolean; autoMentionActiveNote: boolean; debugMode: boolean; nodePath: string; exportSettings: { defaultFolder: string; filenameTemplate: string; autoExportOnNewChat: boolean; autoExportOnCloseChat: boolean; openFileAfterExport: boolean; includeImages: boolean; imageLocation: "obsidian" | "custom" | "base64"; imageCustomFolder: string; frontmatterTag: string; }; // WSL settings (Windows only) windowsWslMode: boolean; windowsWslDistribution?: string; // Input behavior sendMessageShortcut: SendMessageShortcut; // View settings chatViewLocation: ChatViewLocation; // Display settings displaySettings: { autoCollapseDiffs: boolean; diffCollapseThreshold: number; maxNoteLength: number; maxSelectionLength: number; showEmojis: boolean; fontSize: number | null; }; // Locally saved session metadata (for agents without session/list support) savedSessions: SavedSessionInfo[]; // Last used model per agent (agentId → modelId) lastUsedModels: Record; // Floating chat button settings showFloatingButton: boolean; floatingButtonImage: string; floatingWindowSize: { width: number; height: number }; floatingWindowPosition: { x: number; y: number } | null; } const DEFAULT_SETTINGS: AgentClientPluginSettings = { claude: { id: "claude-code-acp", displayName: "Claude Code", apiKey: "", command: "", args: [], env: [], }, codex: { id: "codex-acp", displayName: "Codex", apiKey: "", command: "", args: [], env: [], }, gemini: { id: "gemini-cli", displayName: "Gemini CLI", apiKey: "", command: "", args: ["--experimental-acp"], env: [], }, customAgents: [], defaultAgentId: "claude-code-acp", autoAllowPermissions: false, autoMentionActiveNote: true, debugMode: false, nodePath: "", exportSettings: { defaultFolder: "Agent Client", filenameTemplate: "agent_client_{date}_{time}", autoExportOnNewChat: false, autoExportOnCloseChat: false, openFileAfterExport: true, includeImages: true, imageLocation: "obsidian", imageCustomFolder: "Agent Client", frontmatterTag: "agent-client", }, windowsWslMode: false, windowsWslDistribution: undefined, sendMessageShortcut: "enter", chatViewLocation: "right-tab", displaySettings: { autoCollapseDiffs: false, diffCollapseThreshold: 10, maxNoteLength: 10000, maxSelectionLength: 10000, showEmojis: true, fontSize: null, }, savedSessions: [], lastUsedModels: {}, showFloatingButton: false, floatingButtonImage: "", floatingWindowSize: { width: 400, height: 500 }, floatingWindowPosition: null, }; export default class AgentClientPlugin extends Plugin { settings: AgentClientPluginSettings; settingsStore!: SettingsStore; /** Registry for all chat view containers (sidebar + floating) */ viewRegistry = new ChatViewRegistry(); /** Map of viewId to AcpAdapter for multi-session support */ private _adapters: Map = new Map(); /** Floating button container (independent from chat view instances) */ private floatingButton: FloatingButtonContainer | null = null; /** Map of viewId to floating chat roots and containers (legacy, being migrated to viewRegistry) */ private floatingChatInstances: Map< string, { root: Root; container: HTMLElement } > = new Map(); /** Counter for generating unique floating chat instance IDs */ private floatingChatCounter = 0; async onload() { await this.loadSettings(); initializeLogger(this.settings); // Initialize settings store this.settingsStore = createSettingsStore(this.settings, this); this.registerView(VIEW_TYPE_CHAT, (leaf) => new ChatView(leaf, this)); const ribbonIconEl = this.addRibbonIcon( "bot-message-square", "Open agent client", (_evt: MouseEvent) => { void this.activateView(); }, ); ribbonIconEl.addClass("agent-client-ribbon-icon"); this.addCommand({ id: "open-chat-view", name: "Open agent chat", callback: () => { void this.activateView(); }, }); this.addCommand({ id: "focus-next-chat-view", name: "Focus next chat view", callback: () => { this.focusChatView("next"); }, }); this.addCommand({ id: "focus-previous-chat-view", name: "Focus previous chat view", callback: () => { this.focusChatView("previous"); }, }); this.addCommand({ id: "open-new-chat-view", name: "Open new chat view", callback: () => { void this.openNewChatViewWithAgent( this.settings.defaultAgentId, ); }, }); // Register agent-specific commands this.registerAgentCommands(); this.registerPermissionCommands(); this.registerBroadcastCommands(); // Floating chat window commands this.addCommand({ id: "open-floating-chat", name: "Open floating chat window", callback: () => { if (!this.settings.showFloatingButton) return; const instances = this.getFloatingChatInstances(); if (instances.length === 0) { this.openNewFloatingChat(true); } else if (instances.length === 1) { this.expandFloatingChat(instances[0]); } else { const focused = this.viewRegistry.getFocused(); if (focused && focused.viewType === "floating") { focused.expand(); } else { this.expandFloatingChat( instances[instances.length - 1], ); } } }, }); this.addCommand({ id: "open-new-floating-chat", name: "Open new floating chat window", callback: () => { if (!this.settings.showFloatingButton) return; this.openNewFloatingChat(true); }, }); this.addCommand({ id: "close-floating-chat", name: "Close floating chat window", callback: () => { const focused = this.viewRegistry.getFocused(); if (focused && focused.viewType === "floating") { focused.collapse(); } }, }); this.addSettingTab(new AgentClientSettingTab(this.app, this)); // Mount floating button (always present; visibility controlled by settings inside component) this.floatingButton = new FloatingButtonContainer(this); this.floatingButton.mount(); // Mount initial floating chat instance only if enabled if (this.settings.showFloatingButton) { this.openNewFloatingChat(); } // Clean up all ACP sessions when Obsidian quits // Note: We don't wait for disconnect to complete to avoid blocking quit this.registerEvent( this.app.workspace.on("quit", () => { // Fire and forget - don't block Obsidian from quitting for (const [viewId, adapter] of this._adapters) { adapter.disconnect().catch((error) => { console.warn( `[AgentClient] Quit cleanup error for view ${viewId}:`, error, ); }); } this._adapters.clear(); }), ); } onunload() { // Unmount floating button this.floatingButton?.unmount(); this.floatingButton = null; // Unmount all floating chat instances via registry for (const container of this.viewRegistry.getByType("floating")) { if (container instanceof FloatingViewContainer) { container.unmount(); } } // Clear registry (sidebar views are managed by Obsidian workspace) this.viewRegistry.clear(); // Clear legacy storage this.floatingChatInstances.clear(); } /** * Get or create an AcpAdapter for a specific view. * Each ChatView has its own adapter for independent sessions. */ getOrCreateAdapter(viewId: string): AcpAdapter { let adapter = this._adapters.get(viewId); if (!adapter) { adapter = new AcpAdapter(this); this._adapters.set(viewId, adapter); } return adapter; } /** * Remove and disconnect the adapter for a specific view. * Called when a ChatView is closed. */ async removeAdapter(viewId: string): Promise { const adapter = this._adapters.get(viewId); if (adapter) { try { await adapter.disconnect(); } catch (error) { console.warn( `[AgentClient] Failed to disconnect adapter for view ${viewId}:`, error, ); } this._adapters.delete(viewId); } // Note: lastActiveChatViewId is now managed by viewRegistry // Clearing happens automatically when view is unregistered } /** * Get the last active ChatView ID for keybind targeting. */ get lastActiveChatViewId(): string | null { return this.viewRegistry.getFocusedId(); } /** * Set the last active ChatView ID. * Called when a ChatView receives focus or interaction. */ setLastActiveChatViewId(viewId: string | null): void { if (viewId) { this.viewRegistry.setFocused(viewId); } } async activateView() { const { workspace } = this.app; let leaf: WorkspaceLeaf | null = null; const leaves = workspace.getLeavesOfType(VIEW_TYPE_CHAT); if (leaves.length > 0) { // Find the leaf matching lastActiveChatViewId, or fall back to first leaf const focusedId = this.lastActiveChatViewId; if (focusedId) { leaf = leaves.find( (l) => (l.view as ChatView)?.viewId === focusedId, ) || leaves[0]; } else { leaf = leaves[0]; } } else { leaf = this.createNewChatLeaf(false); if (leaf) { await leaf.setViewState({ type: VIEW_TYPE_CHAT, active: true, }); } } if (leaf) { await workspace.revealLeaf(leaf); this.focusTextarea(leaf); } } /** * Focus the textarea in a ChatView leaf. */ private focusTextarea(leaf: WorkspaceLeaf): void { const viewContainerEl = leaf.view?.containerEl; if (viewContainerEl) { window.setTimeout(() => { const textarea = viewContainerEl.querySelector( "textarea.agent-client-chat-input-textarea", ); if (textarea instanceof HTMLTextAreaElement) { textarea.focus(); } }, 50); } } /** * Focus the next or previous ChatView in the list. * Uses ChatViewRegistry which includes both sidebar and floating views. */ private focusChatView(direction: "next" | "previous"): void { if (direction === "next") { this.viewRegistry.focusNext(); } else { this.viewRegistry.focusPrevious(); } } /** * Create a new leaf for ChatView based on the configured location setting. * @param isAdditional - true when opening additional views (e.g., Open New View) */ private createNewChatLeaf(isAdditional: boolean): WorkspaceLeaf | null { const { workspace } = this.app; const location = this.settings.chatViewLocation; switch (location) { case "right-tab": return workspace.getRightLeaf(isAdditional); case "editor-tab": return workspace.getLeaf("tab"); case "editor-split": return workspace.getLeaf("split"); default: return workspace.getRightLeaf(isAdditional); } } /** * Open a new chat view with a specific agent. * Always creates a new view (doesn't reuse existing). */ async openNewChatViewWithAgent(agentId: string): Promise { const leaf = this.createNewChatLeaf(true); if (!leaf) { console.warn("[AgentClient] Failed to create new leaf"); return; } await leaf.setViewState({ type: VIEW_TYPE_CHAT, active: true, state: { initialAgentId: agentId }, }); await this.app.workspace.revealLeaf(leaf); // Focus textarea after revealing the leaf const viewContainerEl = leaf.view?.containerEl; if (viewContainerEl) { window.setTimeout(() => { const textarea = viewContainerEl.querySelector( "textarea.agent-client-chat-input-textarea", ); if (textarea instanceof HTMLTextAreaElement) { textarea.focus(); } }, 0); } } /** * Open a new floating chat window. * Each window is independent with its own session. */ openNewFloatingChat( initialExpanded = false, initialPosition?: { x: number; y: number }, ): void { // instanceId is just the counter (e.g., "0", "1", "2") // FloatingViewContainer will create viewId as "floating-chat-{instanceId}" const instanceId = String(this.floatingChatCounter++); const container = createFloatingChat( this, instanceId, initialExpanded, initialPosition, ); // Store by viewId for consistent lookup this.floatingChatInstances.set(container.viewId, { root: null as unknown as Root, container: container.getContainerEl(), }); } /** * Close a specific floating chat window. * @param viewId - The viewId in "floating-chat-{id}" format (from getFloatingChatInstances()) */ closeFloatingChat(viewId: string): void { const container = this.viewRegistry.get(viewId); if (container && container instanceof FloatingViewContainer) { container.unmount(); } // Also remove from legacy floatingChatInstances if present this.floatingChatInstances.delete(viewId); } /** * Get all floating chat instance viewIds. * @returns Array of viewIds in "floating-chat-{id}" format */ getFloatingChatInstances(): string[] { return this.viewRegistry.getByType("floating").map((v) => v.viewId); } /** * Expand a specific floating chat window by triggering a custom event. * @param viewId - The viewId in "floating-chat-{id}" format (from getFloatingChatInstances()) */ expandFloatingChat(viewId: string): void { window.dispatchEvent( new CustomEvent("agent-client:expand-floating-chat", { detail: { viewId }, }), ); } /** * Get all available agents (claude, codex, gemini, custom) */ getAvailableAgents(): Array<{ id: string; displayName: string }> { return [ { id: this.settings.claude.id, displayName: this.settings.claude.displayName || this.settings.claude.id, }, { id: this.settings.codex.id, displayName: this.settings.codex.displayName || this.settings.codex.id, }, { id: this.settings.gemini.id, displayName: this.settings.gemini.displayName || this.settings.gemini.id, }, ...this.settings.customAgents.map((agent) => ({ id: agent.id, displayName: agent.displayName || agent.id, })), ]; } /** * Open chat view and switch to specified agent */ private async openChatWithAgent(agentId: string): Promise { await this.activateView(); // Trigger new chat with specific agent // Pass agentId so ChatComponent knows to force new session even if empty this.app.workspace.trigger( "agent-client:new-chat-requested" as "quit", agentId, ); } /** * Register commands for each configured agent */ private registerAgentCommands(): void { const agents = this.getAvailableAgents(); for (const agent of agents) { this.addCommand({ id: `open-chat-with-${agent.id}`, name: `New chat with ${agent.displayName}`, callback: async () => { await this.openChatWithAgent(agent.id); }, }); } } private registerPermissionCommands(): void { this.addCommand({ id: "approve-active-permission", name: "Approve active permission", callback: async () => { // Only activate sidebar view if the focused view is a sidebar // (avoid stealing focus from floating views) const focusedId = this.lastActiveChatViewId; const isFloatingFocused = focusedId?.startsWith("floating-chat-"); if (!isFloatingFocused) { await this.activateView(); } this.app.workspace.trigger( "agent-client:approve-active-permission" as "quit", this.lastActiveChatViewId, ); }, }); this.addCommand({ id: "reject-active-permission", name: "Reject active permission", callback: async () => { // Only activate sidebar view if the focused view is a sidebar // (avoid stealing focus from floating views) const focusedId = this.lastActiveChatViewId; const isFloatingFocused = focusedId?.startsWith("floating-chat-"); if (!isFloatingFocused) { await this.activateView(); } this.app.workspace.trigger( "agent-client:reject-active-permission" as "quit", this.lastActiveChatViewId, ); }, }); this.addCommand({ id: "toggle-auto-mention", name: "Toggle auto-mention", callback: async () => { // Only activate sidebar view if the focused view is a sidebar // (avoid stealing focus from floating views) const focusedId = this.lastActiveChatViewId; const isFloatingFocused = focusedId?.startsWith("floating-chat-"); if (!isFloatingFocused) { await this.activateView(); } this.app.workspace.trigger( "agent-client:toggle-auto-mention" as "quit", this.lastActiveChatViewId, ); }, }); this.addCommand({ id: "cancel-current-message", name: "Cancel current message", callback: () => { this.app.workspace.trigger( "agent-client:cancel-message" as "quit", this.lastActiveChatViewId, ); }, }); } /** * Register broadcast commands for multi-view operations */ private registerBroadcastCommands(): void { // Broadcast prompt: Copy prompt from active view to all other views this.addCommand({ id: "broadcast-prompt", name: "Broadcast prompt", callback: () => { this.broadcastPrompt(); }, }); // Broadcast send: Send message in all views that can send this.addCommand({ id: "broadcast-send", name: "Broadcast send", callback: () => { void this.broadcastSend(); }, }); // Broadcast cancel: Cancel operation in all views this.addCommand({ id: "broadcast-cancel", name: "Broadcast cancel", callback: () => { void this.broadcastCancel(); }, }); } /** * Copy prompt from active view to all other views */ private broadcastPrompt(): void { const allViews = this.viewRegistry.getAll(); if (allViews.length === 0) { new Notice("[Agent Client] No chat views open"); return; } const inputState = this.viewRegistry.toFocused((v) => v.getInputState(), ); if ( !inputState || (inputState.text.trim() === "" && inputState.images.length === 0) ) { new Notice("[Agent Client] No prompt to broadcast"); return; } const focusedId = this.viewRegistry.getFocusedId(); const targetViews = allViews.filter((v) => v.viewId !== focusedId); if (targetViews.length === 0) { new Notice("[Agent Client] No other chat views to broadcast to"); return; } for (const view of targetViews) { view.setInputState(inputState); } } /** * Send message in all views that can send */ private async broadcastSend(): Promise { const allViews = this.viewRegistry.getAll(); if (allViews.length === 0) { new Notice("[Agent Client] No chat views open"); return; } const sendableViews = allViews.filter((v) => v.canSend()); if (sendableViews.length === 0) { new Notice("[Agent Client] No views ready to send"); return; } await Promise.allSettled(sendableViews.map((v) => v.sendMessage())); } /** * Cancel operation in all views */ private async broadcastCancel(): Promise { const allViews = this.viewRegistry.getAll(); if (allViews.length === 0) { new Notice("[Agent Client] No chat views open"); return; } await Promise.allSettled(allViews.map((v) => v.cancelOperation())); new Notice("[Agent Client] Cancel broadcast to all views"); } async loadSettings() { const rawSettings = ((await this.loadData()) ?? {}) as Record< string, unknown >; const claudeFromRaw = typeof rawSettings.claude === "object" && rawSettings.claude !== null ? (rawSettings.claude as Record) : {}; const codexFromRaw = typeof rawSettings.codex === "object" && rawSettings.codex !== null ? (rawSettings.codex as Record) : {}; const geminiFromRaw = typeof rawSettings.gemini === "object" && rawSettings.gemini !== null ? (rawSettings.gemini as Record) : {}; const resolvedClaudeArgs = sanitizeArgs(claudeFromRaw.args); const resolvedClaudeEnv = normalizeEnvVars(claudeFromRaw.env); const resolvedCodexArgs = sanitizeArgs(codexFromRaw.args); const resolvedCodexEnv = normalizeEnvVars(codexFromRaw.env); const resolvedGeminiArgs = sanitizeArgs(geminiFromRaw.args); const resolvedGeminiEnv = normalizeEnvVars(geminiFromRaw.env); const customAgents = Array.isArray(rawSettings.customAgents) ? ensureUniqueCustomAgentIds( rawSettings.customAgents.map((agent: unknown) => { const agentObj = typeof agent === "object" && agent !== null ? (agent as Record) : {}; return normalizeCustomAgent(agentObj); }), ) : []; const availableAgentIds = [ DEFAULT_SETTINGS.claude.id, DEFAULT_SETTINGS.codex.id, DEFAULT_SETTINGS.gemini.id, ...customAgents.map((agent) => agent.id), ]; // Migration: support both old activeAgentId and new defaultAgentId const rawDefaultId = typeof rawSettings.defaultAgentId === "string" ? rawSettings.defaultAgentId.trim() : typeof rawSettings.activeAgentId === "string" ? rawSettings.activeAgentId.trim() : ""; const fallbackDefaultId = availableAgentIds.find((id) => id.length > 0) || DEFAULT_SETTINGS.claude.id; const defaultAgentId = availableAgentIds.includes(rawDefaultId) && rawDefaultId.length > 0 ? rawDefaultId : fallbackDefaultId; this.settings = { claude: { id: DEFAULT_SETTINGS.claude.id, displayName: typeof claudeFromRaw.displayName === "string" && claudeFromRaw.displayName.trim().length > 0 ? claudeFromRaw.displayName.trim() : DEFAULT_SETTINGS.claude.displayName, apiKey: typeof claudeFromRaw.apiKey === "string" ? claudeFromRaw.apiKey : DEFAULT_SETTINGS.claude.apiKey, command: typeof claudeFromRaw.command === "string" && claudeFromRaw.command.trim().length > 0 ? claudeFromRaw.command.trim() : typeof rawSettings.claudeCodeAcpCommandPath === "string" && rawSettings.claudeCodeAcpCommandPath.trim() .length > 0 ? rawSettings.claudeCodeAcpCommandPath.trim() : DEFAULT_SETTINGS.claude.command, args: resolvedClaudeArgs.length > 0 ? resolvedClaudeArgs : [], env: resolvedClaudeEnv.length > 0 ? resolvedClaudeEnv : [], }, codex: { id: DEFAULT_SETTINGS.codex.id, displayName: typeof codexFromRaw.displayName === "string" && codexFromRaw.displayName.trim().length > 0 ? codexFromRaw.displayName.trim() : DEFAULT_SETTINGS.codex.displayName, apiKey: typeof codexFromRaw.apiKey === "string" ? codexFromRaw.apiKey : DEFAULT_SETTINGS.codex.apiKey, command: typeof codexFromRaw.command === "string" && codexFromRaw.command.trim().length > 0 ? codexFromRaw.command.trim() : DEFAULT_SETTINGS.codex.command, args: resolvedCodexArgs.length > 0 ? resolvedCodexArgs : [], env: resolvedCodexEnv.length > 0 ? resolvedCodexEnv : [], }, gemini: { id: DEFAULT_SETTINGS.gemini.id, displayName: typeof geminiFromRaw.displayName === "string" && geminiFromRaw.displayName.trim().length > 0 ? geminiFromRaw.displayName.trim() : DEFAULT_SETTINGS.gemini.displayName, apiKey: typeof geminiFromRaw.apiKey === "string" ? geminiFromRaw.apiKey : DEFAULT_SETTINGS.gemini.apiKey, command: typeof geminiFromRaw.command === "string" && geminiFromRaw.command.trim().length > 0 ? geminiFromRaw.command.trim() : typeof rawSettings.geminiCommandPath === "string" && rawSettings.geminiCommandPath.trim().length > 0 ? rawSettings.geminiCommandPath.trim() : DEFAULT_SETTINGS.gemini.command, args: resolvedGeminiArgs.length > 0 ? resolvedGeminiArgs : DEFAULT_SETTINGS.gemini.args, env: resolvedGeminiEnv.length > 0 ? resolvedGeminiEnv : [], }, customAgents: customAgents, defaultAgentId, autoAllowPermissions: typeof rawSettings.autoAllowPermissions === "boolean" ? rawSettings.autoAllowPermissions : DEFAULT_SETTINGS.autoAllowPermissions, autoMentionActiveNote: typeof rawSettings.autoMentionActiveNote === "boolean" ? rawSettings.autoMentionActiveNote : DEFAULT_SETTINGS.autoMentionActiveNote, debugMode: typeof rawSettings.debugMode === "boolean" ? rawSettings.debugMode : DEFAULT_SETTINGS.debugMode, nodePath: typeof rawSettings.nodePath === "string" ? rawSettings.nodePath.trim() : DEFAULT_SETTINGS.nodePath, exportSettings: (() => { const rawExport = rawSettings.exportSettings as | Record | null | undefined; if (rawExport && typeof rawExport === "object") { return { defaultFolder: typeof rawExport.defaultFolder === "string" ? rawExport.defaultFolder : DEFAULT_SETTINGS.exportSettings.defaultFolder, filenameTemplate: typeof rawExport.filenameTemplate === "string" ? rawExport.filenameTemplate : DEFAULT_SETTINGS.exportSettings .filenameTemplate, autoExportOnNewChat: typeof rawExport.autoExportOnNewChat === "boolean" ? rawExport.autoExportOnNewChat : DEFAULT_SETTINGS.exportSettings .autoExportOnNewChat, autoExportOnCloseChat: typeof rawExport.autoExportOnCloseChat === "boolean" ? rawExport.autoExportOnCloseChat : DEFAULT_SETTINGS.exportSettings .autoExportOnCloseChat, openFileAfterExport: typeof rawExport.openFileAfterExport === "boolean" ? rawExport.openFileAfterExport : DEFAULT_SETTINGS.exportSettings .openFileAfterExport, includeImages: typeof rawExport.includeImages === "boolean" ? rawExport.includeImages : DEFAULT_SETTINGS.exportSettings.includeImages, imageLocation: rawExport.imageLocation === "obsidian" || rawExport.imageLocation === "custom" || rawExport.imageLocation === "base64" ? rawExport.imageLocation : DEFAULT_SETTINGS.exportSettings.imageLocation, imageCustomFolder: typeof rawExport.imageCustomFolder === "string" ? rawExport.imageCustomFolder : DEFAULT_SETTINGS.exportSettings .imageCustomFolder, frontmatterTag: typeof rawExport.frontmatterTag === "string" ? rawExport.frontmatterTag : DEFAULT_SETTINGS.exportSettings .frontmatterTag, }; } return DEFAULT_SETTINGS.exportSettings; })(), windowsWslMode: typeof rawSettings.windowsWslMode === "boolean" ? rawSettings.windowsWslMode : DEFAULT_SETTINGS.windowsWslMode, windowsWslDistribution: typeof rawSettings.windowsWslDistribution === "string" ? rawSettings.windowsWslDistribution : DEFAULT_SETTINGS.windowsWslDistribution, sendMessageShortcut: rawSettings.sendMessageShortcut === "enter" || rawSettings.sendMessageShortcut === "cmd-enter" ? rawSettings.sendMessageShortcut : DEFAULT_SETTINGS.sendMessageShortcut, chatViewLocation: rawSettings.chatViewLocation === "right-tab" || rawSettings.chatViewLocation === "editor-tab" || rawSettings.chatViewLocation === "editor-split" ? rawSettings.chatViewLocation : DEFAULT_SETTINGS.chatViewLocation, displaySettings: (() => { const rawDisplay = rawSettings.displaySettings as | Record | null | undefined; if (rawDisplay && typeof rawDisplay === "object") { return { autoCollapseDiffs: typeof rawDisplay.autoCollapseDiffs === "boolean" ? rawDisplay.autoCollapseDiffs : DEFAULT_SETTINGS.displaySettings .autoCollapseDiffs, diffCollapseThreshold: typeof rawDisplay.diffCollapseThreshold === "number" && rawDisplay.diffCollapseThreshold > 0 ? rawDisplay.diffCollapseThreshold : DEFAULT_SETTINGS.displaySettings .diffCollapseThreshold, maxNoteLength: typeof rawDisplay.maxNoteLength === "number" && rawDisplay.maxNoteLength >= 1 ? rawDisplay.maxNoteLength : DEFAULT_SETTINGS.displaySettings .maxNoteLength, maxSelectionLength: typeof rawDisplay.maxSelectionLength === "number" && rawDisplay.maxSelectionLength >= 1 ? rawDisplay.maxSelectionLength : DEFAULT_SETTINGS.displaySettings .maxSelectionLength, showEmojis: typeof rawDisplay.showEmojis === "boolean" ? rawDisplay.showEmojis : DEFAULT_SETTINGS.displaySettings.showEmojis, fontSize: parseChatFontSize(rawDisplay.fontSize), }; } return DEFAULT_SETTINGS.displaySettings; })(), savedSessions: Array.isArray(rawSettings.savedSessions) ? (rawSettings.savedSessions as SavedSessionInfo[]) : DEFAULT_SETTINGS.savedSessions, lastUsedModels: (() => { const raw = rawSettings.lastUsedModels; if (raw && typeof raw === "object" && !Array.isArray(raw)) { const result: Record = {}; for (const [key, value] of Object.entries( raw as Record, )) { if ( typeof key === "string" && key.length > 0 && typeof value === "string" && value.length > 0 ) { result[key] = value; } } return result; } return DEFAULT_SETTINGS.lastUsedModels; })(), showFloatingButton: typeof rawSettings.showFloatingButton === "boolean" ? rawSettings.showFloatingButton : DEFAULT_SETTINGS.showFloatingButton, floatingButtonImage: typeof rawSettings.floatingButtonImage === "string" ? rawSettings.floatingButtonImage : DEFAULT_SETTINGS.floatingButtonImage, floatingWindowSize: (() => { const raw = rawSettings.floatingWindowSize as | { width?: number; height?: number } | null | undefined; if ( raw && typeof raw === "object" && typeof raw.width === "number" && typeof raw.height === "number" ) { return { width: raw.width, height: raw.height }; } return DEFAULT_SETTINGS.floatingWindowSize; })(), floatingWindowPosition: (() => { const raw = rawSettings.floatingWindowPosition as | { x?: number; y?: number } | null | undefined; if ( raw && typeof raw === "object" && typeof raw.x === "number" && typeof raw.y === "number" ) { return { x: raw.x, y: raw.y }; } return null; })(), }; this.ensureDefaultAgentId(); } async saveSettings() { await this.saveData(this.settings); } async saveSettingsAndNotify(nextSettings: AgentClientPluginSettings) { this.settings = nextSettings; await this.saveData(this.settings); this.settingsStore.set(this.settings); } /** * Fetch the latest stable release version from GitHub. */ private async fetchLatestStable(): Promise { const response = await requestUrl({ url: "https://api.github.com/repos/RAIT-09/obsidian-agent-client/releases/latest", }); const data = response.json as { tag_name?: string }; return data.tag_name ? semver.clean(data.tag_name) : null; } /** * Fetch the latest prerelease version from GitHub. */ private async fetchLatestPrerelease(): Promise { const response = await requestUrl({ url: "https://api.github.com/repos/RAIT-09/obsidian-agent-client/releases", }); const releases = response.json as Array<{ tag_name: string; prerelease: boolean; }>; // Find the first prerelease (releases are sorted by date descending) const latestPrerelease = releases.find((r) => r.prerelease); return latestPrerelease ? semver.clean(latestPrerelease.tag_name) : null; } /** * Check for plugin updates. * - Stable version users: compare with latest stable release * - Prerelease users: compare with both latest stable and latest prerelease */ async checkForUpdates(): Promise { const currentVersion = semver.clean(this.manifest.version) || this.manifest.version; const isCurrentPrerelease = semver.prerelease(currentVersion) !== null; if (isCurrentPrerelease) { // Prerelease user: check both stable and prerelease const [latestStable, latestPrerelease] = await Promise.all([ this.fetchLatestStable(), this.fetchLatestPrerelease(), ]); const hasNewerStable = latestStable && semver.gt(latestStable, currentVersion); const hasNewerPrerelease = latestPrerelease && semver.gt(latestPrerelease, currentVersion); if (hasNewerStable || hasNewerPrerelease) { // Prefer stable version notification if available const newestVersion = hasNewerStable ? latestStable : latestPrerelease; new Notice( `[Agent Client] Update available: v${newestVersion}`, ); return true; } } else { // Stable version user: check stable only const latestStable = await this.fetchLatestStable(); if (latestStable && semver.gt(latestStable, currentVersion)) { new Notice(`[Agent Client] Update available: v${latestStable}`); return true; } } return false; } ensureDefaultAgentId(): void { const availableIds = this.collectAvailableAgentIds(); if (availableIds.length === 0) { this.settings.defaultAgentId = DEFAULT_SETTINGS.claude.id; return; } if (!availableIds.includes(this.settings.defaultAgentId)) { this.settings.defaultAgentId = availableIds[0]; } } private collectAvailableAgentIds(): string[] { const ids = new Set(); ids.add(this.settings.claude.id); ids.add(this.settings.codex.id); ids.add(this.settings.gemini.id); for (const agent of this.settings.customAgents) { if (agent.id && agent.id.length > 0) { ids.add(agent.id); } } return Array.from(ids); } }