rait-09_obsidian-agent-client/src/plugin.ts

1216 lines
35 KiB
TypeScript

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<string, string>;
// 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<string, AcpAdapter> = 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown>)
: {};
const codexFromRaw =
typeof rawSettings.codex === "object" && rawSettings.codex !== null
? (rawSettings.codex as Record<string, unknown>)
: {};
const geminiFromRaw =
typeof rawSettings.gemini === "object" &&
rawSettings.gemini !== null
? (rawSettings.gemini as Record<string, unknown>)
: {};
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<string, unknown>)
: {};
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<string, unknown>
| 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<string, unknown>
| 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<string, string> = {};
for (const [key, value] of Object.entries(
raw as Record<string, unknown>,
)) {
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<string | null> {
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<string | null> {
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<boolean> {
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<string>();
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);
}
}