diff --git a/src/hooks/useAgentMessages.ts b/src/hooks/useAgentMessages.ts index 76cc9236..1767e487 100644 --- a/src/hooks/useAgentMessages.ts +++ b/src/hooks/useAgentMessages.ts @@ -108,7 +108,7 @@ export function useAgentMessages( const [isSending, setIsSending] = useState(false); const [lastUserMessage, setLastUserMessage] = useState(null); - // Tool call index: toolCallId → message index for O(1) lookup + // Tool call index: toolCallId to message index for O(1) lookup const toolCallIndexRef = useRef>(new Map()); // Ignore updates flag (used during session/load to skip history replay) @@ -256,7 +256,11 @@ export function useAgentMessages( // Wait for any in-flight send to settle (e.g. after cancel/stop) // before starting a new one to avoid interleaved state updates. if (sendPromiseRef.current) { - try { await sendPromiseRef.current; } catch { /* ignore */ } + try { + await sendPromiseRef.current; + } catch { + /* ignore */ + } } const currentSessionId = session.sessionId; diff --git a/src/hooks/useChatActions.ts b/src/hooks/useChatActions.ts index bda8a48d..df4f98cb 100644 --- a/src/hooks/useChatActions.ts +++ b/src/hooks/useChatActions.ts @@ -78,6 +78,7 @@ export function useChatActions( messages: ChatMessage[], settings: AgentClientPluginSettings, vaultPath: string, + persistentSourcePath?: string, ): UseChatActionsReturn { const logger = getLogger(); @@ -193,6 +194,7 @@ export function useChatActions( await sessionHistory.saveSessionLocally( session.sessionId, content, + persistentSourcePath, ); logger.log( `[ChatPanel] Session saved locally: ${session.sessionId}`, @@ -205,6 +207,7 @@ export function useChatActions( messages.length, session.sessionId, sessionHistory.saveSessionLocally, + persistentSourcePath, logger, suggestions.mentions.activeNote, suggestions.mentions.isAutoMentionDisabled, diff --git a/src/hooks/useSessionHistory.ts b/src/hooks/useSessionHistory.ts index 3aff8ea6..dba73afe 100644 --- a/src/hooks/useSessionHistory.ts +++ b/src/hooks/useSessionHistory.ts @@ -188,6 +188,7 @@ export interface UseSessionHistoryReturn { saveSessionLocally: ( sessionId: string, messageContent: string, + sourcePath?: string, ) => Promise; /** @@ -779,7 +780,11 @@ export function useSessionHistory( * Called when the first message is sent in a new session. */ const saveSessionLocally = useCallback( - async (sessionId: string, messageContent: string) => { + async ( + sessionId: string, + messageContent: string, + sourcePath?: string, + ) => { if (!session.agentId) return; const title = truncateTitle(messageContent); @@ -789,6 +794,7 @@ export function useSessionHistory( agentId: session.agentId, cwd: agentCwd, title, + sourcePath, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/src/hooks/useSuggestions.ts b/src/hooks/useSuggestions.ts index 05598ddb..2e8aa397 100644 --- a/src/hooks/useSuggestions.ts +++ b/src/hooks/useSuggestions.ts @@ -91,6 +91,7 @@ export function useSuggestions( plugin: AgentClientPlugin, availableCommands: SlashCommand[], autoMentionDefault: boolean, + pinnedActiveNote?: NoteMetadata | null, ): UseSuggestionsReturn { // ============================================================ // Mention State @@ -103,7 +104,9 @@ export function useSuggestions( const [mentionContext, setMentionContext] = useState( null, ); - const [activeNote, setActiveNote] = useState(null); + const [activeNote, setActiveNote] = useState( + pinnedActiveNote ?? null, + ); const [isAutoMentionDisabled, setIsAutoMentionDisabled] = useState( !autoMentionDefault, ); @@ -206,9 +209,13 @@ export function useSuggestions( }, []); const updateActiveNote = useCallback(async () => { + if (pinnedActiveNote) { + setActiveNote(pinnedActiveNote); + return; + } const note = await vaultAccess.getActiveNote(); setActiveNote(note); - }, [vaultAccess]); + }, [vaultAccess, pinnedActiveNote]); // ============================================================ // Command Callbacks diff --git a/src/plugin.ts b/src/plugin.ts index 9b1851c4..d51dd2a2 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,9 +3,17 @@ import { WorkspaceLeaf, Notice, requestUrl, + MarkdownRenderChild, + SuggestModal, + setIcon, + type App, + type MarkdownPostProcessorContext, } from "obsidian"; import * as semver from "semver"; import { ChatView, VIEW_TYPE_CHAT } from "./ui/ChatView"; +import { mountCodeBlockChat } from "./ui/CodeBlockChatView"; +import { mountAgentButtonBlock } from "./ui/AgentButtonBlock"; +import { parseAgentBlock } from "./utils/agent-block-parser"; import { SessionManagerView, VIEW_TYPE_SESSION_MANAGER, @@ -44,11 +52,31 @@ import { CustomAgentSettings, } from "./types/agent"; import type { SavedSessionInfo } from "./types/session"; +import { classifyIconRef, resolveImageSrc } from "./utils/resolve-image-src"; import { initializeLogger, getLogger } from "./utils/logger"; // Re-export for backward compatibility export type { AgentEnvVar, CustomAgentSettings }; +export interface QuickPrompt { + name: string; + prompt: string; + agentId?: string; + usageCount: number; + /** + * Icon shown on the quick-prompt chip. Either a Lucide icon id + * (e.g. "sparkles", "wand-2") or an image reference (http(s)/data URL + * or vault-relative path). Falls back to "sparkles" when unset. + */ + icon?: string; + /** + * When true, the quick-prompt chip hides itself after it is clicked, + * for the lifetime of the current chat view. Reopening the chat + * restores it. Non-persistent and non-destructive. + */ + hideAfterClick?: boolean; +} + /** * Send message shortcut configuration. * - 'enter': Enter to send, Shift+Enter for newline (default) @@ -62,12 +90,20 @@ export type SendMessageShortcut = "enter" | "cmd-enter"; * - 'right-split': Open in right pane with vertical split * - 'editor-tab': Open in editor area as tabs * - 'editor-split': Open in editor area with right split + * - 'floating': Open as a floating chat window */ export type ChatViewLocation = | "right-tab" | "right-split" | "editor-tab" - | "editor-split"; + | "editor-split" + | "floating"; + +export interface EmbeddedChatRegistration { + viewId: string; + sourcePath: string; + lineStart: number; +} export interface AgentClientPluginSettings { gemini: GeminiAgentSettings; @@ -109,6 +145,8 @@ export interface AgentClientPluginSettings { windowsWslDistribution?: string; // Input behavior sendMessageShortcut: SendMessageShortcut; + showQuickPromptsInChat: boolean; + quickPrompts: QuickPrompt[]; // View settings chatViewLocation: ChatViewLocation; // Display settings @@ -186,6 +224,8 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = { windowsWslMode: false, windowsWslDistribution: undefined, sendMessageShortcut: "enter", + showQuickPromptsInChat: true, + quickPrompts: [], chatViewLocation: "right-tab", displaySettings: { autoCollapseDiffs: false, @@ -218,6 +258,8 @@ export default class AgentClientPlugin extends Plugin { private floatingButton: FloatingButtonContainer | null = null; /** Counter for generating unique floating chat instance IDs */ private floatingChatCounter = 0; + /** Embedded chat instances mounted from markdown code blocks. */ + private embeddedChats = new Map(); async onload() { await this.loadSettings(); @@ -290,6 +332,18 @@ export default class AgentClientPlugin extends Plugin { }, }); + this.addCommand({ + id: "run-quick-prompt", + name: "Run quick prompt", + checkCallback: (checking) => { + if (this.settings.quickPrompts.length === 0) return false; + if (checking) return true; + new QuickPromptSuggestModal(this.app, this, (prompt) => { + void this.runQuickPrompt(prompt); + }).open(); + }, + }); + // Register agent-specific commands this.registerAgentCommands(); this.registerPermissionCommands(); @@ -356,6 +410,14 @@ export default class AgentClientPlugin extends Plugin { this.addSettingTab(new AgentClientSettingTab(this.app, this)); + this.registerMarkdownCodeBlockProcessor( + "agent-client", + (source, el, ctx) => this.renderAgentBlock(source, el, ctx), + ); + this.registerMarkdownCodeBlockProcessor("agent", (source, el, ctx) => + this.renderAgentBlock(source, el, ctx), + ); + // Mount floating button (always present; visibility controlled by settings inside component) this.floatingButton = new FloatingButtonContainer(this); this.floatingButton.mount(); @@ -469,6 +531,16 @@ export default class AgentClientPlugin extends Plugin { async activateView() { const { workspace } = this.app; + if (this.settings.chatViewLocation === "floating") { + const instances = this.getFloatingChatInstances(); + if (instances.length === 0) { + this.openNewFloatingChat(true); + } else { + this.expandFloatingChat(instances[instances.length - 1]); + } + return; + } + let leaf: WorkspaceLeaf | null = null; const leaves = workspace.getLeavesOfType(VIEW_TYPE_CHAT); @@ -560,11 +632,15 @@ export default class AgentClientPlugin extends Plugin { * 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 { + private createNewChatLeaf( + isAdditional: boolean, + location: ChatViewLocation = this.settings.chatViewLocation, + ): WorkspaceLeaf | null { const { workspace } = this.app; - const location = this.settings.chatViewLocation; switch (location) { + case "floating": + return null; case "right-tab": if (isAdditional) { return this.createSidebarTab("right"); @@ -616,11 +692,20 @@ export default class AgentClientPlugin extends Plugin { * 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); + async openNewChatViewWithAgent( + agentId: string, + location: ChatViewLocation = this.settings.chatViewLocation, + ): Promise { + if (location === "floating") { + const counterBefore = this.floatingChatCounter; + this.openNewFloatingChat(true); + return `floating-chat-${counterBefore}`; + } + + const leaf = this.createNewChatLeaf(true, location); if (!leaf) { getLogger().warn("[AgentClient] Failed to create new leaf"); - return; + return null; } await leaf.setViewState({ @@ -630,6 +715,8 @@ export default class AgentClientPlugin extends Plugin { }); await this.app.workspace.revealLeaf(leaf); + const view = leaf.view as ChatView | null; + const viewId = view?.viewId ?? null; // Focus textarea after revealing the leaf const viewContainerEl = leaf.view?.containerEl; @@ -643,6 +730,7 @@ export default class AgentClientPlugin extends Plugin { } }, 0); } + return viewId; } /** @@ -659,6 +747,35 @@ export default class AgentClientPlugin extends Plugin { createFloatingChat(this, instanceId, initialExpanded, initialPosition); } + registerEmbeddedChat(registration: EmbeddedChatRegistration): () => void { + this.embeddedChats.set(registration.viewId, registration); + return () => { + const current = this.embeddedChats.get(registration.viewId); + if (current === registration) { + this.embeddedChats.delete(registration.viewId); + } + }; + } + + findNearestEmbeddedChat( + sourcePath: string, + lineStart: number, + ): string | null { + let nearest: EmbeddedChatRegistration | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (const registration of this.embeddedChats.values()) { + if (registration.sourcePath !== sourcePath) continue; + const distance = Math.abs(registration.lineStart - lineStart); + if (distance < nearestDistance) { + nearest = registration; + nearestDistance = distance; + } + } + + return nearest?.viewId ?? null; + } + /** * Close a specific floating chat window. * @param viewId - The viewId in "floating-chat-{id}" format (from getFloatingChatInstances()) @@ -689,6 +806,170 @@ export default class AgentClientPlugin extends Plugin { } } + /** + * Render an `agent-client` code block. Dispatches to embedded chat or + * quick-action button based on the parsed `type` field. + */ + private renderAgentBlock( + source: string, + el: HTMLElement, + ctx: MarkdownPostProcessorContext, + ): void { + const child = new MarkdownRenderChild(el); + const parsed = parseAgentBlock(source); + + if (!parsed.ok) { + const errorEl = el.createDiv({ + cls: "agent-client-code-block-error", + }); + errorEl.createSpan({ + cls: "agent-client-code-block-error-label", + text: "agent-client block error: ", + }); + errorEl.createSpan({ text: parsed.error }); + const sourceEl = errorEl.createEl("pre", { + cls: "agent-client-code-block-error-source", + }); + sourceEl.setText(source); + ctx.addChild(child); + return; + } + + const sectionInfo = ctx.getSectionInfo(el); + const sourcePath = ctx.sourcePath || ""; + const lineStart = sectionInfo?.lineStart ?? 0; + const blockId = `${sourcePath || "untitled"}:${lineStart}`; + + if (parsed.config.type === "chat") { + const root = mountCodeBlockChat(this, el, parsed.config, { + sourcePath, + blockId, + lineStart, + }); + child.onunload = () => root.unmount(); + } else { + const root = mountAgentButtonBlock(this, el, parsed.config, { + sourcePath, + lineStart, + }); + child.onunload = () => root.unmount(); + } + ctx.addChild(child); + } + + /** + * Open a chat view and inject a prompt into it. Used by quick-action + * buttons (embedded code blocks, command palette entries, etc.). + * + * Fires `agent-client:run-prompt` shortly after the open call so the + * target ChatPanel can populate its input box, optionally auto-sending + * once the session is ready. + */ + async runPromptInChat(options: { + agentId: string; + prompt: string; + autoSend: boolean; + viewType: "right-pane" | "floating" | "editor-tab" | "embedded"; + sourcePath?: string; + lineStart?: number; + }): Promise { + const { agentId, prompt, autoSend, viewType, sourcePath, lineStart } = + options; + let targetViewId: string | null = null; + + if (viewType === "embedded") { + targetViewId = + sourcePath && typeof lineStart === "number" + ? this.findNearestEmbeddedChat(sourcePath, lineStart) + : null; + if (!targetViewId) { + new Notice("No embedded chat block found in this note."); + return; + } + } else if (viewType === "floating") { + const counterBefore = this.floatingChatCounter; + this.openNewFloatingChat(true); + targetViewId = `floating-chat-${counterBefore}`; + } else if (viewType === "editor-tab") { + targetViewId = await this.openNewChatViewWithAgent( + agentId, + "editor-tab", + ); + } else { + targetViewId = await this.openNewChatViewWithAgent( + agentId, + "right-tab", + ); + } + + if (!targetViewId) return; + + this.dispatchPromptToChat(targetViewId, prompt, autoSend); + } + + private dispatchPromptToChat( + targetViewId: string, + prompt: string, + autoSend: boolean, + ): void { + // Defer slightly so the React root has mounted and registered its + // `agent-client:run-prompt` listener. + window.setTimeout(() => { + this.app.workspace.trigger( + "agent-client:run-prompt", + targetViewId, + prompt, + autoSend, + ); + }, 100); + } + + getQuickPrompts(): QuickPrompt[] { + return this.settings.quickPrompts + .filter((prompt) => prompt.name.trim() && prompt.prompt.trim()) + .sort((a, b) => { + if (b.usageCount !== a.usageCount) { + return b.usageCount - a.usageCount; + } + return a.name.localeCompare(b.name); + }); + } + + findQuickPrompt(name: string | undefined): QuickPrompt | null { + const key = name?.trim(); + if (!key) return null; + const exact = this.settings.quickPrompts.find((p) => p.name === key); + if (exact) return exact; + const lower = key.toLowerCase(); + return ( + this.settings.quickPrompts.find( + (p) => p.name.toLowerCase() === lower, + ) ?? null + ); + } + + async incrementQuickPromptUsage(name: string): Promise { + const next = this.settings.quickPrompts.map((prompt) => + prompt.name === name + ? { ...prompt, usageCount: prompt.usageCount + 1 } + : prompt, + ); + await this.settingsService.updateSettings({ quickPrompts: next }); + } + + async runQuickPrompt(prompt: QuickPrompt): Promise { + const agentId = + prompt.agentId && + this.collectAvailableAgentIds().includes(prompt.agentId) + ? prompt.agentId + : this.settings.defaultAgentId; + await this.incrementQuickPromptUsage(prompt.name); + const targetViewId = await this.openNewChatViewWithAgent(agentId); + if (targetViewId) { + this.dispatchPromptToChat(targetViewId, prompt.prompt, false); + } + } + /** * Get all available agents (claude, codex, gemini, custom) */ @@ -938,6 +1219,12 @@ export default class AgentClientPlugin extends Plugin { ? rawDefaultId : availableAgentIds[0] || D.claude.id; + const pickAvatarImage = (value: unknown): string | undefined => { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + }; + this.settings = { claude: { id: D.claude.id, // Fixed — never from raw @@ -959,6 +1246,7 @@ export default class AgentClientPlugin extends Plugin { D.claude.command, args: sanitizeArgs(rc.args), env: normalizeEnvVars(rc.env), + avatarImage: pickAvatarImage(rc.avatarImage), }, codex: { id: D.codex.id, @@ -976,6 +1264,7 @@ export default class AgentClientPlugin extends Plugin { command: str(rk.command, "") || D.codex.command, args: sanitizeArgs(rk.args), env: normalizeEnvVars(rk.env), + avatarImage: pickAvatarImage(rk.avatarImage), }, gemini: { id: D.gemini.id, @@ -1000,6 +1289,7 @@ export default class AgentClientPlugin extends Plugin { ? sanitizeArgs(rg.args) : D.gemini.args, env: normalizeEnvVars(rg.env), + avatarImage: pickAvatarImage(rg.avatarImage), }, customAgents, defaultAgentId, @@ -1075,9 +1365,49 @@ export default class AgentClientPlugin extends Plugin { ["enter", "cmd-enter"], D.sendMessageShortcut, ), + showQuickPromptsInChat: bool( + raw.showQuickPromptsInChat, + D.showQuickPromptsInChat, + ), + quickPrompts: (() => { + if (!Array.isArray(raw.quickPrompts)) return D.quickPrompts; + const seen = new Set(); + const prompts: QuickPrompt[] = []; + for (const entry of raw.quickPrompts) { + const item = obj(entry); + if (!item) continue; + const name = str(item.name, "").trim(); + const prompt = str(item.prompt, "").trim(); + if (!name || !prompt || seen.has(name)) continue; + seen.add(name); + const agentId = str(item.agentId, "").trim(); + prompts.push({ + name, + prompt, + agentId: + agentId.length > 0 && + availableAgentIds.includes(agentId) + ? agentId + : undefined, + usageCount: num(item.usageCount, 0, 0), + icon: str(item.icon, "").trim() || undefined, + hideAfterClick: + typeof item.hideAfterClick === "boolean" + ? item.hideAfterClick + : undefined, + }); + } + return prompts; + })(), chatViewLocation: enumVal( raw.chatViewLocation, - ["right-tab", "right-split", "editor-tab", "editor-split"], + [ + "right-tab", + "right-split", + "editor-tab", + "editor-split", + "floating", + ], D.chatViewLocation, ), displaySettings: { @@ -1316,3 +1646,79 @@ export default class AgentClientPlugin extends Plugin { return Array.from(ids); } } + +class QuickPromptSuggestModal extends SuggestModal { + constructor( + app: App, + private plugin: AgentClientPlugin, + private onChoose: (prompt: QuickPrompt) => void, + ) { + super(app); + this.setPlaceholder("Choose a quick prompt"); + } + + getSuggestions(query: string): QuickPrompt[] { + const needle = query.trim().toLowerCase(); + const prompts = this.plugin.getQuickPrompts(); + if (!needle) return prompts; + return prompts.filter((prompt) => { + return ( + prompt.name.toLowerCase().includes(needle) || + prompt.prompt.toLowerCase().includes(needle) + ); + }); + } + + renderSuggestion(prompt: QuickPrompt, el: HTMLElement): void { + const row = el.createDiv({ + cls: "agent-client-quick-prompt-suggestion", + }); + this.renderPromptIcon(row, prompt); + + const text = row.createDiv({ + cls: "agent-client-quick-prompt-suggestion-text", + }); + text.createDiv({ + cls: "agent-client-quick-prompt-suggestion-name", + text: prompt.name, + }); + text.createDiv({ + cls: "agent-client-quick-prompt-suggestion-preview", + text: + prompt.prompt.length > 80 + ? prompt.prompt.slice(0, 80) + "\u2026" + : prompt.prompt, + }); + } + + /** + * Render the prompt icon (Lucide id or image) into the row, mirroring the + * chat-input chip. Falls back to "sparkles" when unset or unresolvable. + */ + private renderPromptIcon(parent: HTMLElement, prompt: QuickPrompt): void { + const classified = classifyIconRef(prompt.icon); + + if (classified?.kind === "image") { + const src = resolveImageSrc(this.plugin, classified.value); + if (src) { + parent.createEl("img", { + cls: "agent-client-quick-prompt-suggestion-image", + attr: { src, alt: "" }, + }); + return; + } + } + + const iconEl = parent.createSpan({ + cls: "agent-client-quick-prompt-suggestion-icon", + }); + setIcon( + iconEl, + classified?.kind === "lucide" ? classified.value : "sparkles", + ); + } + + onChooseSuggestion(prompt: QuickPrompt): void { + this.onChoose(prompt); + } +} diff --git a/src/services/settings-normalizer.ts b/src/services/settings-normalizer.ts index eefded95..64a3ba0f 100644 --- a/src/services/settings-normalizer.ts +++ b/src/services/settings-normalizer.ts @@ -128,6 +128,12 @@ export const normalizeCustomAgent = ( agent.displayName.trim().length > 0 ? agent.displayName.trim() : rawId; + const rawAvatar = + agent && + typeof agent.avatarImage === "string" && + agent.avatarImage.trim().length > 0 + ? agent.avatarImage.trim() + : undefined; return { id: rawId, displayName: rawDisplayName, @@ -139,6 +145,7 @@ export const normalizeCustomAgent = ( : "", args: sanitizeArgs(agent?.args), env: normalizeEnvVars(agent?.env), + avatarImage: rawAvatar, }; }; diff --git a/src/types/agent.ts b/src/types/agent.ts index 3064deb1..2a9592eb 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -51,6 +51,12 @@ export interface BaseAgentSettings { /** Environment variables for the agent process */ env: AgentEnvVar[]; + + /** + * Per-agent avatar image used by embedded chat and quick-prompt buttons. + * Accepts an http(s) URL, a data URL, or a vault-relative path. + */ + avatarImage?: string; } /** diff --git a/src/types/session.ts b/src/types/session.ts index 4b572af4..9ce5de50 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -593,6 +593,8 @@ export interface SavedSessionInfo { cwd: string; /** Human-readable session title (first 50 chars of first user message) */ title?: string; + /** Note path that owns a persistent embedded chat session. */ + sourcePath?: string; /** ISO 8601 timestamp of session creation */ createdAt: string; /** ISO 8601 timestamp of last activity */ diff --git a/src/ui/AgentButtonBlock.tsx b/src/ui/AgentButtonBlock.tsx new file mode 100644 index 00000000..a6c05369 --- /dev/null +++ b/src/ui/AgentButtonBlock.tsx @@ -0,0 +1,136 @@ +import * as React from "react"; +const { useCallback, useMemo, useState } = React; +import { createRoot, type Root } from "react-dom/client"; +import { Notice } from "obsidian"; + +import type AgentClientPlugin from "../plugin"; +import { + getAgentAvatarImage, + resolveImageSrc, +} from "../utils/resolve-image-src"; +import type { AgentButtonBlockConfig } from "../utils/agent-block-parser"; + +interface AgentButtonBlockProps { + plugin: AgentClientPlugin; + config: AgentButtonBlockConfig; + mountCtx: AgentButtonMountContext; +} + +export interface AgentButtonMountContext { + sourcePath: string; + lineStart: number; +} + +function resolveAgentId( + plugin: AgentClientPlugin, + preferred: string | undefined, +): string { + const available = plugin.getAvailableAgents().map((a) => a.id); + if (preferred && available.includes(preferred)) return preferred; + return plugin.settings.defaultAgentId; +} + +function AgentButtonBlockComponent({ + plugin, + config, + mountCtx, +}: AgentButtonBlockProps) { + // Whether this rendered button has been clicked. Used to hide it when + // hideAfterClick is set; resets when the note is re-rendered. + const [dismissed, setDismissed] = useState(false); + + const quickPrompt = useMemo(() => { + return plugin.findQuickPrompt(config.promptName); + }, [plugin, config.promptName]); + + const resolvedAgentId = useMemo(() => { + return resolveAgentId(plugin, config.agent ?? quickPrompt?.agentId); + }, [plugin, config.agent, quickPrompt?.agentId]); + + const avatarSrc = useMemo(() => { + return resolveImageSrc( + plugin, + getAgentAvatarImage(plugin, resolvedAgentId), + ); + }, [plugin, resolvedAgentId]); + + const handleClick = useCallback(async () => { + const promptText = config.prompt ?? quickPrompt?.prompt; + if (!promptText) { + new Notice( + config.promptName + ? `Quick prompt "${config.promptName}" was not found.` + : "Button block has no prompt.", + ); + return; + } + + try { + if (quickPrompt && !config.prompt) { + await plugin.incrementQuickPromptUsage(quickPrompt.name); + } + await plugin.runPromptInChat({ + agentId: resolvedAgentId, + prompt: promptText, + autoSend: config.autoSend ?? false, + viewType: config.viewType ?? "right-pane", + sourcePath: mountCtx.sourcePath, + lineStart: mountCtx.lineStart, + }); + + // Hide the button after a successful click when requested. The + // YAML field wins; otherwise fall back to the quick prompt's setting. + const shouldHide = + config.hideAfterClick ?? quickPrompt?.hideAfterClick ?? false; + if (shouldHide) { + setDismissed(true); + } + } catch (error) { + console.error("[Agent Client] runPromptInChat failed:", error); + new Notice("Failed to open chat with prompt."); + } + }, [plugin, resolvedAgentId, config, quickPrompt, mountCtx]); + + if (dismissed) return null; + + return ( +
+ +
+ ); +} + +export function mountAgentButtonBlock( + plugin: AgentClientPlugin, + el: HTMLElement, + config: AgentButtonBlockConfig, + mountCtx: AgentButtonMountContext, +): Root { + const container = el.createDiv(); + const root = createRoot(container); + root.render( + , + ); + return root; +} diff --git a/src/ui/ChatPanel.tsx b/src/ui/ChatPanel.tsx index e7eed3e7..49b0a2bb 100644 --- a/src/ui/ChatPanel.tsx +++ b/src/ui/ChatPanel.tsx @@ -6,10 +6,12 @@ import { Platform, Menu, setIcon, + TFile, type MenuItem, } from "obsidian"; import type { AttachedFile, ChatInputState } from "../types/chat"; +import type { NoteMetadata } from "../services/vault-service"; import { isSameDirectory } from "../utils/platform"; import { computeSessionTitle } from "../services/session-helpers"; import { useHistoryModal } from "../hooks/useHistoryModal"; @@ -79,13 +81,19 @@ export interface ChatPanelCallbacks { // ============================================================================ export interface ChatPanelProps { - variant: "sidebar" | "floating"; + variant: "sidebar" | "floating" | "embedded"; viewId: string; workingDirectory?: string; initialAgentId?: string; - config?: { agent?: string; model?: string }; + config?: { + agent?: string; + model?: string; + persist?: boolean; + noteContext?: "hosting"; + sourcePath?: string; + }; onRegisterCallbacks?: (callbacks: ChatPanelCallbacks) => void; - /** Called when agent ID changes (sidebar only — persists in Obsidian state) */ + /** Called when agent ID changes (sidebar only; persists in Obsidian state) */ onAgentIdChanged?: (agentId: string) => void; /** * Called when the derived session title may have changed (sidebar only — @@ -173,7 +181,7 @@ export function ChatPanel({ return process.cwd(); }, [plugin, workingDirectory]); - // Agent working directory — defaults to vault path. + // Agent working directory; defaults to vault path. // Can be changed independently via "New chat in directory..." action. const [agentCwd, setAgentCwd] = useState(vaultPath); @@ -198,11 +206,27 @@ export function ChatPanel({ errorInfo, } = agent; + const pinnedActiveNote = useMemo(() => { + if (config?.noteContext !== "hosting" || !config.sourcePath) { + return null; + } + const file = plugin.app.vault.getAbstractFileByPath(config.sourcePath); + if (!(file instanceof TFile)) return null; + return { + path: file.path, + name: file.basename, + extension: file.extension, + created: file.stat.ctime, + modified: file.stat.mtime, + }; + }, [plugin, config?.noteContext, config?.sourcePath]); + const suggestions = useSuggestions( vaultService, plugin, session.availableCommands || EMPTY_COMMANDS, settings.autoMentionActiveNote, + pinnedActiveNote, ); // Session history hook with callback for session load @@ -252,6 +276,10 @@ export function ChatPanel({ const [inputValue, setInputValue] = useState(""); const [attachedFiles, setAttachedFiles] = useState([]); + // Pending auto-send queued by `agent-client:run-prompt` (drained when ready) + const [pendingAutoSend, setPendingAutoSend] = useState(null); + const persistRestoreAttemptedRef = useRef(false); + // ============================================================ // Refs // ============================================================ @@ -299,6 +327,7 @@ export function ChatPanel({ messages, settings, vaultPath, + config?.persist ? config.sourcePath : undefined, ); const { @@ -360,6 +389,17 @@ export function ChatPanel({ [handleSendMessage], ); + // Switch the view to a quick-prompt's assigned agent (fresh chat) and then + // auto-send the prompt once the new session is ready, reusing the same + // pending-auto-send drain as `agent-client:run-prompt`. + const handleSwitchAgentAndRun = useCallback( + async (nextAgentId: string, prompt: string) => { + await handleSwitchAgent(nextAgentId); + setPendingAutoSend(prompt); + }, + [handleSwitchAgent], + ); + const { handleOpenHistory } = useHistoryModal( plugin, agent, @@ -636,14 +676,14 @@ export function ChatPanel({ // Floating: create a shim with listener tracking return { app: plugin.app, - registerDomEvent: (( + registerDomEvent: ( target: Window | Document | HTMLElement, type: string, callback: EventListenerOrEventListenerObject, ) => { target.addEventListener(type, callback); registeredListenersRef.current.push({ target, type, callback }); - }), + }, }; }, [viewHostProp, plugin.app]); @@ -670,6 +710,35 @@ export function ChatPanel({ void agent.createSession(config?.agent || initialAgentId); }, [agent.createSession, config?.agent, initialAgentId]); + useEffect(() => { + if (variant !== "embedded") return; + if (!config?.persist || !config.sourcePath) return; + if (!isSessionReady || !session.sessionId || !session.agentId) return; + if (!sessionHistory.canRestore) return; + if (persistRestoreAttemptedRef.current) return; + persistRestoreAttemptedRef.current = true; + + const savedSession = plugin.settingsService + .getSavedSessions(session.agentId, agentCwd) + .find((item) => item.sourcePath === config.sourcePath); + if (!savedSession || savedSession.sessionId === session.sessionId) { + return; + } + + void sessionHistory.restoreSession(savedSession.sessionId, agentCwd); + }, [ + variant, + config?.persist, + config?.sourcePath, + isSessionReady, + session.sessionId, + session.agentId, + sessionHistory.canRestore, + sessionHistory.restoreSession, + plugin.settingsService, + agentCwd, + ]); + // Apply configured model when session is ready useEffect(() => { if (!config?.model || !isSessionReady) return; @@ -793,7 +862,10 @@ export function ChatPanel({ ); // System notification on response completion - if (settings.enableSystemNotifications && !activeDocument.hasFocus()) { + if ( + settings.enableSystemNotifications && + !activeDocument.hasFocus() + ) { new Notification("Agent Client", { body: `${activeAgentLabel} has completed the response.`, }); @@ -990,6 +1062,27 @@ export function ChatPanel({ if (targetViewId && targetViewId !== viewId) return; void handleExportChatRef.current(); }), + + // Run prompt injected by quick-action button or code block. + // `targetViewId` filter: null/empty matches any view (used when the + // caller created the leaf via openNewChatViewWithAgent and cannot + // know the new viewId synchronously). + ws.on( + "agent-client:run-prompt", + ( + targetViewId: string | null, + prompt: string, + autoSend?: boolean, + ) => { + if (targetViewId && targetViewId !== viewId) return; + if (typeof prompt !== "string" || prompt.length === 0) + return; + setInputValue(prompt); + if (autoSend) { + setPendingAutoSend(prompt); + } + }, + ), ]; return () => { @@ -1005,6 +1098,27 @@ export function ChatPanel({ suggestions.mentions.toggleAutoMention, ]); + // ============================================================ + // Effects - Drain pending auto-send when session becomes ready + // ============================================================ + useEffect(() => { + if (!pendingAutoSend) return; + if (!isSessionReady) return; + if (isSending) return; + if (sessionHistory.loading) return; + + const prompt = pendingAutoSend; + setPendingAutoSend(null); + setInputValue(""); + void handleSendMessage(prompt); + }, [ + pendingAutoSend, + isSessionReady, + isSending, + sessionHistory.loading, + handleSendMessage, + ]); + // ============================================================ // Effects - Focus Tracking // ============================================================ @@ -1135,6 +1249,8 @@ export function ChatPanel({ } as React.CSSProperties) : undefined; + const shouldShowAgentSelector = variant !== "embedded" || !config?.agent; + const headerElement = variant === "sidebar" ? ( void handleSwitchAgent(agentId)} @@ -1214,6 +1330,7 @@ export function ChatPanel({ usage={session.usage} supportsImages={session.promptCapabilities?.image ?? false} agentId={session.agentId} + onSwitchAgentAndRun={handleSwitchAgentAndRun} // Controlled component props (for broadcast commands) inputValue={inputValue} onInputChange={setInputValue} @@ -1254,6 +1371,25 @@ export function ChatPanel({ ); } + if (variant === "embedded") { + return ( +
+
+ {headerElement} +
+ {cwdBanner} +
+ {messageListElement} +
+ {inputAreaElement} +
+ ); + } + // Sidebar layout return (
plugin.getOrCreateAcpClient(viewId), + [plugin, viewId], + ); + + const vaultService = useMemo(() => new VaultService(plugin), [plugin]); + + useEffect(() => { + const unregisterEmbeddedChat = plugin.registerEmbeddedChat({ + viewId, + sourcePath: mountCtx.sourcePath, + lineStart: mountCtx.lineStart, + }); + return () => { + unregisterEmbeddedChat(); + vaultService.destroy(); + void plugin.removeAcpClient(viewId); + }; + }, [plugin, viewId, vaultService, mountCtx.sourcePath, mountCtx.lineStart]); + + const contextValue = useMemo( + () => ({ + plugin, + acpClient, + vaultService, + settingsService: plugin.settingsService, + }), + [plugin, acpClient, vaultService], + ); + + const avatarSrc = useMemo(() => { + return ( + resolveImageSrc(plugin, config.image) ?? + resolveImageSrc( + plugin, + getAgentAvatarImage(plugin, config.agent), + ) ?? + resolveImageSrc(plugin, plugin.settings.floatingButtonImage) + ); + }, [plugin, config.image, config.agent]); + + const heightStyle = config.height + ? ({ "--ac-embedded-max-height": config.height } as React.CSSProperties) + : undefined; + + return ( +
+ {avatarSrc && ( +
+ +
+ )} + + + +
+ ); +} + +export function mountCodeBlockChat( + plugin: AgentClientPlugin, + el: HTMLElement, + config: AgentChatBlockConfig, + mountCtx: CodeBlockMountContext, +): Root { + const container = el.createDiv({ cls: "agent-client-code-block-host" }); + const root = createRoot(container); + root.render( + , + ); + return root; +} diff --git a/src/ui/InputArea.tsx b/src/ui/InputArea.tsx index f9706f4b..fa62606f 100644 --- a/src/ui/InputArea.tsx +++ b/src/ui/InputArea.tsx @@ -3,6 +3,7 @@ const { useRef, useState, useEffect, useCallback, useMemo } = React; import { setIcon, Notice } from "obsidian"; import type AgentClientPlugin from "../plugin"; +import type { QuickPrompt } from "../plugin"; import type { IChatViewHost } from "./view-host"; import type { NoteMetadata } from "../services/vault-service"; import type { @@ -19,6 +20,7 @@ import { ErrorBanner } from "./ErrorBanner"; import { AttachmentStrip } from "./shared/AttachmentStrip"; import { InputToolbar } from "./InputToolbar"; import { getLogger } from "../utils/logger"; +import { classifyIconRef, resolveImageSrc } from "../utils/resolve-image-src"; import type { ErrorInfo } from "../types/errors"; import type { AgentUpdateNotification } from "../services/update-checker"; import { useSettings } from "../hooks/useSettings"; @@ -174,6 +176,49 @@ function useInputHistory( return { handleHistoryKeyDown, resetHistory }; } +// ============================================================================ +// Quick Prompt Chip Icon +// ============================================================================ + +/** + * Renders a quick-prompt chip icon: an when the configured icon is an + * image reference, otherwise a Lucide icon. Falls back to "sparkles" when the + * icon is unset or an image reference cannot be resolved. + */ +function QuickPromptChipIcon({ + plugin, + icon, +}: { + plugin: AgentClientPlugin; + icon?: string; +}) { + const classified = useMemo(() => classifyIconRef(icon), [icon]); + + if (classified?.kind === "image") { + const src = resolveImageSrc(plugin, classified.value); + if (src) { + return ( + + ); + } + } + + const lucideName = + classified?.kind === "lucide" ? classified.value : "sparkles"; + return ( + { + if (el) setIcon(el, lucideName); + }} + /> + ); +} + // ============================================================================ // InputArea Component // ============================================================================ @@ -226,6 +271,12 @@ export interface InputAreaProps { supportsImages?: boolean; /** Current agent ID (used to clear images on agent switch) */ agentId: string; + /** + * Switch the view to `agentId` (starting a fresh chat on that agent) and + * then auto-send `prompt` once the new session is ready. Used by quick + * prompts that carry an assigned agent so clicking the chip honors it. + */ + onSwitchAgentAndRun?: (agentId: string, prompt: string) => Promise; // Controlled component props (for broadcast commands) /** Current input text value */ inputValue: string; @@ -286,6 +337,7 @@ export function InputArea({ usage, supportsImages = false, agentId, + onSwitchAgentAndRun, // Controlled component props inputValue, onInputChange, @@ -307,10 +359,27 @@ export function InputArea({ const logger = getLogger(); const settings = useSettings(plugin); const showEmojis = plugin.settings.displaySettings.showEmojis; + // Quick-prompt chips dismissed by clicking (when hideAfterClick is set). + // View-local and non-persistent: cleared on remount / chat reopen. + const [dismissedQuickPrompts, setDismissedQuickPrompts] = useState< + Set + >(() => new Set()); + const quickPrompts = useMemo(() => { + if (!settings.showQuickPromptsInChat) return []; + return plugin + .getQuickPrompts() + .filter((prompt) => !dismissedQuickPrompts.has(prompt.name)); + }, [ + plugin, + settings.showQuickPromptsInChat, + settings.quickPrompts, + dismissedQuickPrompts, + ]); // Unofficial Obsidian API (see src/types/obsidian-internals.d.ts) const obsidianSpellcheck = - (plugin.app.vault.getConfig("spellcheck") as boolean | undefined) ?? true; + (plugin.app.vault.getConfig("spellcheck") as boolean | undefined) ?? + true; // Local state (hint and command are still local - not needed for broadcast) const [hintText, setHintText] = useState(null); @@ -646,6 +715,64 @@ export function InputArea({ [mentions, inputValue, setTextAndFocus], ); + const handleSelectQuickPrompt = useCallback( + (prompt: QuickPrompt) => { + void (async () => { + if (prompt.hideAfterClick) { + setDismissedQuickPrompts((prev) => { + const next = new Set(prev); + next.add(prompt.name); + return next; + }); + } + await plugin.incrementQuickPromptUsage(prompt.name); + + // If the prompt is assigned to a different (valid) agent, switch + // the view to that agent first and let it auto-send once the + // fresh session is ready. Mirrors the code-block/command path. + const targetAgentId = + prompt.agentId && + prompt.agentId !== agentId && + plugin + .getAvailableAgents() + .some((a) => a.id === prompt.agentId) + ? prompt.agentId + : null; + if (targetAgentId && onSwitchAgentAndRun) { + onInputChange(""); + await onSwitchAgentAndRun(targetAgentId, prompt.prompt); + return; + } + + if (isSessionReady && !isSending && !isRestoringSession) { + onInputChange(""); + await onSendMessage(prompt.prompt); + return; + } + + onInputChange(prompt.prompt); + window.setTimeout(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.focus(); + textarea.selectionStart = prompt.prompt.length; + textarea.selectionEnd = prompt.prompt.length; + } + }, 0); + })(); + }, + [ + agentId, + isRestoringSession, + isSending, + isSessionReady, + onInputChange, + onSendMessage, + onSwitchAgentAndRun, + plugin, + ], + ); + /** * Handle slash command selection from dropdown. */ @@ -981,6 +1108,28 @@ export function InputArea({ /> )} + {quickPrompts.length > 0 && ( +
+ {quickPrompts.map((prompt) => ( + + ))} +
+ )} + {/* Mention Dropdown */} {mentions.isOpen && ( + toggle + .setValue(this.plugin.settings.showQuickPromptsInChat) + .onChange(async (value) => { + await this.plugin.settingsService.updateSettings({ + showQuickPromptsInChat: value, + }); + }), + ); + new Setting(containerEl).setName("Mentions").setHeading(); new Setting(containerEl) @@ -202,6 +219,7 @@ export class AgentClientSettingTab extends PluginSettingTab { .addOption("right-split", "Right pane (split)") .addOption("editor-tab", "Editor area (tabs)") .addOption("editor-split", "Editor area (split)") + .addOption("floating", "Floating chat") .setValue(this.plugin.settings.chatViewLocation) .onChange(async (value) => { await this.plugin.settingsService.updateSettings({ @@ -434,6 +452,12 @@ export class AgentClientSettingTab extends PluginSettingTab { // Permissions // ───────────────────────────────────────────────────────────────────── + new Setting(containerEl).setName("Quick prompts").setHeading(); + new Setting(containerEl).setDesc( + "Saved prompts can be shown in chat views, run from the command palette, or referenced from `agent-client` button blocks with `promptName:`.", + ); + this.renderQuickPrompts(containerEl); + new Setting(containerEl).setName("Permissions").setHeading(); new Setting(containerEl) @@ -1027,6 +1051,11 @@ export class AgentClientSettingTab extends PluginSettingTab { }); text.inputEl.rows = 3; }); + + this.renderAvatarSetting(sectionEl, gemini.avatarImage, async (v) => { + this.plugin.settings.gemini.avatarImage = v; + await this.plugin.saveSettings(); + }); } private renderClaudeSettings(sectionEl: HTMLElement) { @@ -1122,6 +1151,11 @@ export class AgentClientSettingTab extends PluginSettingTab { }); text.inputEl.rows = 3; }); + + this.renderAvatarSetting(sectionEl, claude.avatarImage, async (v) => { + this.plugin.settings.claude.avatarImage = v; + await this.plugin.saveSettings(); + }); } private renderCodexSettings(sectionEl: HTMLElement) { @@ -1217,6 +1251,11 @@ export class AgentClientSettingTab extends PluginSettingTab { }); text.inputEl.rows = 3; }); + + this.renderAvatarSetting(sectionEl, codex.avatarImage, async (v) => { + this.plugin.settings.codex.avatarImage = v; + await this.plugin.saveSettings(); + }); } private renderCustomAgents(containerEl: HTMLElement) { @@ -1363,6 +1402,11 @@ export class AgentClientSettingTab extends PluginSettingTab { }); text.inputEl.rows = 3; }); + + this.renderAvatarSetting(blockEl, agent.avatarImage, async (v) => { + this.plugin.settings.customAgents[index].avatarImage = v; + await this.plugin.saveSettings(); + }); } /** @@ -1539,4 +1583,210 @@ export class AgentClientSettingTab extends PluginSettingTab { return normalizeEnvVars(envVars); } + + private renderQuickPrompts(containerEl: HTMLElement): void { + const prompts = this.plugin.settings.quickPrompts; + if (prompts.length === 0) { + containerEl.createEl("p", { + cls: "setting-item-description", + text: "No quick prompts saved yet.", + }); + } else { + prompts.forEach((prompt, index) => { + this.renderQuickPrompt(containerEl, prompt, index); + }); + } + + new Setting(containerEl).addButton((button) => { + button + .setButtonText("Add quick prompt") + .setCta() + .onClick(async () => { + const next = [ + ...this.plugin.settings.quickPrompts, + { + name: this.generateQuickPromptName(), + prompt: "", + usageCount: 0, + }, + ]; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + this.display(); + }); + }); + } + + private renderQuickPrompt( + containerEl: HTMLElement, + prompt: QuickPrompt, + index: number, + ): void { + const blockEl = containerEl.createDiv({ + cls: "agent-client-quick-prompt-setting", + }); + + const nameSetting = new Setting(blockEl) + .setName("Name") + .setDesc("Use this value in button blocks as `promptName:`.") + .addText((text) => { + text.setPlaceholder("Summarize note") + .setValue(prompt.name) + .onChange(async (value) => { + const name = value.trim(); + if (!name) return; + const duplicate = + this.plugin.settings.quickPrompts.some( + (item, itemIndex) => + itemIndex !== index && item.name === name, + ); + if (duplicate) { + new Notice( + `[Agent Client] A quick prompt named "${name}" already exists.`, + ); + text.setValue(prompt.name); + return; + } + const next = [...this.plugin.settings.quickPrompts]; + next[index] = { ...next[index], name }; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + }); + }); + + nameSetting.addExtraButton((button) => { + button + .setIcon("trash") + .setTooltip("Delete quick prompt") + .onClick(async () => { + const next = [...this.plugin.settings.quickPrompts]; + next.splice(index, 1); + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + this.display(); + }); + }); + + new Setting(blockEl) + .setName("Prompt") + .setDesc("The text inserted into chat or sent by a button block.") + .addTextArea((text) => { + text.setPlaceholder("Summarize the active note in 3 bullets.") + .setValue(prompt.prompt) + .onChange(async (value) => { + const next = [...this.plugin.settings.quickPrompts]; + next[index] = { ...next[index], prompt: value }; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + }); + text.inputEl.rows = 4; + }); + + new Setting(blockEl) + .setName("Icon") + .setDesc( + "Lucide icon name (e.g. sparkles, wand-2) or an image URL / vault path. Leave blank for the default sparkles icon.", + ) + .addText((text) => { + // eslint-disable-next-line obsidianmd/ui/sentence-case -- Lucide icon id, not UI prose + text.setPlaceholder("sparkles") + .setValue(prompt.icon ?? "") + .onChange(async (value) => { + const icon = value.trim(); + const next = [...this.plugin.settings.quickPrompts]; + next[index] = { + ...next[index], + icon: icon.length > 0 ? icon : undefined, + }; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + }); + }); + + new Setting(blockEl) + .setName("Default agent") + .setDesc("Used when a button block does not specify `agent:`.") + .addDropdown((dropdown) => { + dropdown.addOption("", "(plugin default)"); + for (const agent of this.plugin.getAvailableAgents()) { + dropdown.addOption(agent.id, agent.displayName); + } + dropdown + .setValue(prompt.agentId ?? "") + .onChange(async (value) => { + const next = [...this.plugin.settings.quickPrompts]; + next[index] = { + ...next[index], + agentId: value.length > 0 ? value : undefined, + }; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + }); + }); + new Setting(blockEl) + .setName("Hide after click") + .setDesc( + "Hide this chip once it is used, for the current chat view. Reopening the chat restores it.", + ) + .addToggle((toggle) => { + toggle + .setValue(prompt.hideAfterClick ?? false) + .onChange(async (value) => { + const next = [...this.plugin.settings.quickPrompts]; + next[index] = { + ...next[index], + hideAfterClick: value || undefined, + }; + await this.plugin.settingsService.updateSettings({ + quickPrompts: next, + }); + }); + }); + } + + private generateQuickPromptName(): string { + const base = "New prompt"; + const existing = new Set( + this.plugin.settings.quickPrompts.map((prompt) => prompt.name), + ); + if (!existing.has(base)) return base; + let counter = 2; + let candidate = `${base} ${counter}`; + while (existing.has(candidate)) { + counter += 1; + candidate = `${base} ${counter}`; + } + return candidate; + } + + /** + * Shared avatar field rendered at the bottom of every agent section. + */ + private renderAvatarSetting( + sectionEl: HTMLElement, + currentValue: string | undefined, + onChange: (value: string | undefined) => Promise, + ): void { + new Setting(sectionEl) + .setName("Avatar image") + .setDesc( + "URL or vault path to an image. Used by embedded `agent-client` chat blocks and quick-action buttons.", + ) + .addText((text) => { + text.setPlaceholder("https://example.com/avatar.png") + .setValue(currentValue ?? "") + .onChange(async (value) => { + const trimmed = value.trim(); + await onChange( + trimmed.length > 0 ? trimmed : undefined, + ); + }); + }); + } } diff --git a/src/utils/agent-block-parser.ts b/src/utils/agent-block-parser.ts new file mode 100644 index 00000000..e0161387 --- /dev/null +++ b/src/utils/agent-block-parser.ts @@ -0,0 +1,229 @@ +/** + * Parser for the `agent-client` markdown code block. + * + * Pure function. No React, no Obsidian view APIs (parseYaml from obsidian + * is the only Obsidian import, used as a YAML utility). + * + * Fence body is parsed as YAML and dispatched by a `type` discriminator: + * - `chat` (default): embedded chat view + * - `button`: quick-action button that opens a chat with a prompt + */ + +import { parseYaml } from "obsidian"; + +export type AgentChatBlockConfig = { + type: "chat"; + agent?: string; + model?: string; + /** Max height of the messages area, e.g. "400px". */ + height?: string; + /** Restore the latest saved session for this note + agent. */ + persist?: boolean; + /** Pin auto-mention context to the note hosting this block. */ + noteContext?: "hosting"; + /** + * Per-block avatar override. + * Accepts http(s) URL, data URL, or vault-relative path. + * Falls back to the configured agent's avatarImage, then the + * global floatingButtonImage. + */ + image?: string; +}; + +export type AgentButtonBlockConfig = { + type: "button"; + text: string; + /** Prompt sent to the opened chat. */ + prompt?: string; + /** Name of a saved quick prompt to resolve at click time. */ + promptName?: string; + agent?: string; + /** Where to open the chat when clicked. */ + viewType?: "right-pane" | "floating" | "editor-tab" | "embedded"; + /** Send immediately on open. */ + autoSend?: boolean; + /** Hide the button after it is clicked (until the note is re-rendered). */ + hideAfterClick?: boolean; + /** Alignment of the rendered button block within the note. */ + align?: "left" | "center" | "right"; +}; + +export type AgentBlockConfig = AgentChatBlockConfig | AgentButtonBlockConfig; + +export type AgentBlockParseResult = + | { ok: true; config: AgentBlockConfig } + | { ok: false; error: string }; + +const VALID_TYPES = new Set(["chat", "button"]); +const VALID_VIEW_TYPES = new Set([ + "right-pane", + "floating", + "editor-tab", + "embedded", +]); +const VALID_NOTE_CONTEXTS = new Set(["hosting"]); +const VALID_ALIGNMENTS = new Set(["left", "center", "right"]); + +function normalizeViewType( + value: string | undefined, +): AgentButtonBlockConfig["viewType"] | undefined { + if (!value) return undefined; + if (value === "right" || value === "right-tab") return "right-pane"; + if (value === "float" || value === "floating-chat") return "floating"; + if (value === "tab") return "editor-tab"; + if (value === "embed" || value === "embeddable") return "embedded"; + return VALID_VIEW_TYPES.has(value) + ? (value as AgentButtonBlockConfig["viewType"]) + : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function dedent(source: string): string { + const normalized = source.replace(/\r\n?/g, "\n"); + const lines = normalized.split("\n"); + const indents = lines + .filter((line) => line.trim().length > 0) + .map((line) => line.match(/^[ \t]*/)?.[0].length ?? 0); + const minIndent = indents.length > 0 ? Math.min(...indents) : 0; + + if (minIndent === 0) return normalized.trim(); + + return lines + .map((line) => (line.trim().length > 0 ? line.slice(minIndent) : line)) + .join("\n") + .trim(); +} + +function normalizeCssLength(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.replace(/^(-?\d+(?:\.\d+)?)\s+(px|em|rem|vh|vw|%)$/i, "$1$2"); +} + +/** + * Parse the fence body. An empty body yields a default `chat` block. + * + * Returns a discriminated result. Callers should render an inline error + * (createDiv/createSpan, never innerHTML) when ok is false. + */ +export function parseAgentBlock(source: string): AgentBlockParseResult { + const trimmed = dedent(source); + + let raw: unknown; + if (trimmed.length === 0) { + raw = {}; + } else { + try { + raw = parseYaml(trimmed); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + return { ok: false, error: `Invalid YAML: ${message}` }; + } + } + + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return { + ok: false, + error: "Block body must be a YAML mapping (key: value pairs).", + }; + } + + const obj = raw as Record; + const typeValue = asString(obj.type) ?? "chat"; + + if (!VALID_TYPES.has(typeValue)) { + return { + ok: false, + error: `Unknown type: "${typeValue}". Expected "chat" or "button".`, + }; + } + + if (typeValue === "chat") { + const rawNoteContext = asString(obj.noteContext); + const noteContext = rawNoteContext + ? VALID_NOTE_CONTEXTS.has(rawNoteContext) + ? (rawNoteContext as AgentChatBlockConfig["noteContext"]) + : undefined + : undefined; + if (rawNoteContext && !noteContext) { + return { + ok: false, + error: `Unknown noteContext: "${rawNoteContext}". Expected "hosting".`, + }; + } + + const config: AgentChatBlockConfig = { + type: "chat", + agent: asString(obj.agent), + model: asString(obj.model), + height: normalizeCssLength(asString(obj.height)), + persist: asBoolean(obj.persist) ?? false, + noteContext, + image: asString(obj.image), + }; + return { ok: true, config }; + } + + const text = asString(obj.text); + if (!text) { + return { + ok: false, + error: 'Button block requires a non-empty "text" field.', + }; + } + + const prompt = asString(obj.prompt); + const promptName = asString(obj.promptName); + if (!prompt && !promptName) { + return { + ok: false, + error: 'Button block requires a non-empty "prompt" or "promptName" field.', + }; + } + + const rawViewType = asString(obj.viewType); + const viewType = normalizeViewType(rawViewType); + if (rawViewType && !viewType) { + return { + ok: false, + error: `Unknown viewType: "${rawViewType}". Expected "right-pane", "floating", "editor-tab", or "embedded".`, + }; + } + + const rawAlign = asString(obj.align); + const align = rawAlign + ? VALID_ALIGNMENTS.has(rawAlign) + ? (rawAlign as AgentButtonBlockConfig["align"]) + : undefined + : undefined; + if (rawAlign && !align) { + return { + ok: false, + error: `Unknown align: "${rawAlign}". Expected "left", "center", or "right".`, + }; + } + + const config: AgentButtonBlockConfig = { + type: "button", + text, + prompt, + promptName, + agent: asString(obj.agent), + viewType: viewType ?? "right-pane", + autoSend: asBoolean(obj.autoSend) ?? false, + // Left undefined when unset so it can fall back to the referenced + // quick prompt's hideAfterClick setting at click time. + hideAfterClick: asBoolean(obj.hideAfterClick), + align: align ?? "left", + }; + return { ok: true, config }; +} diff --git a/src/utils/resolve-image-src.ts b/src/utils/resolve-image-src.ts new file mode 100644 index 00000000..35105727 --- /dev/null +++ b/src/utils/resolve-image-src.ts @@ -0,0 +1,76 @@ +/** + * Resolve a user-supplied image reference to a value usable in an . + * + * Accepts: + * - http:// or https:// URLs (returned unchanged) + * - data: URLs (returned unchanged) + * - vault-relative paths (resolved via FileSystemAdapter.getResourcePath) + * + * Returns null for empty/missing input or non-FileSystemAdapter vaults. + */ + +import { FileSystemAdapter } from "obsidian"; +import type AgentClientPlugin from "../plugin"; + +export function resolveImageSrc( + plugin: AgentClientPlugin, + value: string | undefined | null, +): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + if (/^(https?:|data:)/i.test(trimmed)) { + return trimmed; + } + + const adapter = plugin.app.vault.adapter; + if (adapter instanceof FileSystemAdapter) { + return adapter.getResourcePath(trimmed); + } + return null; +} + +/** Image file extensions recognized when classifying an icon reference. */ +const IMAGE_EXTENSION_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i; + +/** + * Classify an icon reference as either a Lucide icon id or an image reference. + * + * Heuristic: a value that looks like an http(s)/data URL, contains a path + * separator, or ends in a known image extension is treated as an image; + * otherwise it is treated as a Lucide icon id (a kebab-case slug like + * "sparkles" or "wand-2"). Returns null for empty/missing input. + */ +export function classifyIconRef( + value: string | undefined | null, +): + | { kind: "image"; value: string } + | { kind: "lucide"; value: string } + | null { + if (!value) return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + if ( + /^(https?:|data:)/i.test(trimmed) || + /[/\\]/.test(trimmed) || + IMAGE_EXTENSION_RE.test(trimmed) + ) { + return { kind: "image", value: trimmed }; + } + return { kind: "lucide", value: trimmed }; +} + +export function getAgentAvatarImage( + plugin: AgentClientPlugin, + agentId: string | undefined, +): string | undefined { + if (!agentId) return undefined; + const settings = plugin.settings; + if (agentId === settings.claude.id) return settings.claude.avatarImage; + if (agentId === settings.codex.id) return settings.codex.avatarImage; + if (agentId === settings.gemini.id) return settings.gemini.avatarImage; + return settings.customAgents.find((agent) => agent.id === agentId) + ?.avatarImage; +} diff --git a/styles.css b/styles.css index a1100646..587f55a1 100644 --- a/styles.css +++ b/styles.css @@ -143,6 +143,14 @@ If your plugin does not need CSS, delete this file. border-radius: 8px; } +.agent-client-quick-prompt-setting { + padding: 12px 16px; + margin-bottom: 16px; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; +} + /* ===== Loading Indicator ===== */ .agent-client-loading-indicator { display: flex; @@ -811,6 +819,134 @@ If your plugin does not need CSS, delete this file. background-color: var(--background-secondary); } +.agent-client-quick-prompt-strip { + display: flex; + gap: 6px; + /* "safe" prevents the leftmost chip being clipped/unreachable when the + row overflows: centers when chips fit, falls back to start otherwise. */ + justify-content: safe center; + margin-bottom: 6px; + overflow-x: auto; + overflow-y: hidden; + padding: 2px 4px 4px; + /* Scrollable but with no visible scrollbar (still scrolls via wheel/trackpad). */ + scrollbar-width: none; + -ms-overflow-style: none; +} + +.agent-client-quick-prompt-strip::-webkit-scrollbar { + display: none; +} + +.agent-client-quick-prompt-chip { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + max-width: 260px; + min-height: 34px; + padding: 6px 12px !important; + border: 1px solid var(--background-modifier-border) !important; + border-radius: 6px !important; + background: var(--background-secondary) !important; + color: var(--text-normal) !important; + box-shadow: none !important; + font-size: var(--font-ui-medium); + font-weight: 500; + cursor: pointer; +} + +.agent-client-quick-prompt-chip:hover { + background: var(--background-modifier-hover) !important; +} + +.agent-client-quick-prompt-chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 16px; + width: 16px; + height: 16px; + color: var(--text-muted); +} + +.agent-client-quick-prompt-chip-icon svg { + width: 16px; + height: 16px; +} + +.agent-client-quick-prompt-chip-image { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 16px; + width: 16px; + height: 16px; + border-radius: 4px; + object-fit: cover; + color: var(--text-muted); +} + +.agent-client-quick-prompt-chip-image svg { + width: 14px; + height: 14px; +} + +.agent-client-quick-prompt-chip-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-client-quick-prompt-suggestion { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.agent-client-quick-prompt-suggestion-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 18px; + width: 18px; + height: 18px; + color: var(--text-muted); +} + +.agent-client-quick-prompt-suggestion-icon svg { + width: 18px; + height: 18px; +} + +.agent-client-quick-prompt-suggestion-image { + flex: 0 0 18px; + width: 18px; + height: 18px; + border-radius: 4px; + object-fit: cover; +} + +.agent-client-quick-prompt-suggestion-text { + min-width: 0; + flex: 1 1 auto; +} + +.agent-client-quick-prompt-suggestion-name { + font-weight: 600; + color: var(--text-normal); +} + +.agent-client-quick-prompt-suggestion-preview { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* Auto-mention inline display */ .agent-client-auto-mention-inline { display: flex; @@ -2199,48 +2335,6 @@ If your plugin does not need CSS, delete this file. color: var(--interactive-accent); } -/* ===== Code Block Chat View ===== */ -/* TODO(code-block): Styles for future code block chat view */ -.agent-client-code-block-container { - display: flex; - flex-direction: column; - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - background: var(--background-primary); - overflow: hidden; -} - -.agent-client-code-block-content { - display: flex; - flex-direction: column; - padding: 8px; -} - -.agent-client-code-block-status { - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 8px; - background: var(--background-secondary); - border-radius: 4px; - margin-bottom: 4px; - flex-wrap: wrap; - gap: 8px; -} - -.agent-client-code-block-status-actions { - display: flex; - align-items: center; - gap: 4px; - flex-wrap: wrap; -} - -.agent-client-code-block-messages { - max-height: 400px; - overflow-y: auto; - margin-bottom: 8px; -} - /* ===== Agent Selector (InlineHeader) ===== */ .agent-client-agent-selector { display: inline-flex; @@ -2260,7 +2354,6 @@ If your plugin does not need CSS, delete this file. .agent-client-agent-selector-icon { display: flex; align-items: center; - pointer-events: none; color: var(--text-faint); margin-left: 2px; order: 2; @@ -2305,3 +2398,172 @@ If your plugin does not need CSS, delete this file. color: var(--text-normal); font-size: 12px; } + +/* ============================================================ + Embedded agent-client code blocks + ============================================================ */ + +.agent-client-code-block-error { + border: 1px solid var(--background-modifier-error-border, var(--text-error)); + background: var(--background-modifier-error); + color: var(--text-on-accent, #fff); + padding: 10px 14px; + border-radius: 6px; + font-size: 0.95em; + margin: 4px 0; + font-family: var(--font-interface); +} + +.agent-client-code-block-error-label { + font-weight: 700; + margin-right: 4px; + color: var(--text-on-accent, #fff); +} + +.agent-client-code-block-error-source { + margin-top: 8px; + padding: 6px 10px; + background: rgba(0, 0, 0, 0.25); + border-radius: 4px; + font-family: var(--font-monospace); + font-size: 0.85em; + color: var(--text-on-accent, #fff); + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow: auto; +} + +.agent-client-code-block-host { + margin: 8px 0; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + overflow: hidden; + background: var(--background-primary); +} + +.agent-client-code-block-chat { + display: flex; + flex-direction: column; + height: var(--ac-embedded-max-height, 520px); + min-height: 320px; + max-height: min(var(--ac-embedded-max-height, 520px), 80vh); + overflow: hidden; +} + +.agent-client-code-block-chat-avatar-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 40px; + padding: 6px 10px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); +} + +.agent-client-code-block-chat-avatar { + display: block; + flex: 0 0 28px; + width: 28px !important; + height: 28px !important; + min-width: 28px; + min-height: 28px; + max-width: 28px; + max-height: 28px; + aspect-ratio: 1 / 1; + border-radius: 50%; + object-fit: cover; + overflow: hidden; +} + +.agent-client-embedded-chat-panel { + flex: 1 1 auto; + height: 100%; + min-height: 0; + overflow: hidden; + background: var(--background-primary); +} + +.agent-client-embedded-header { + flex: 0 0 auto; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-primary); +} + +.agent-client-embedded-header .agent-client-inline-header { + min-height: 34px; + padding: 6px 10px; +} + +.agent-client-embedded-messages-container { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.agent-client-embedded-messages-container .agent-client-chat-view-messages { + flex: 1 1 auto; + min-height: 0; + padding: 12px 10px; + overflow-y: auto; + overscroll-behavior: contain; +} + +.agent-client-embedded-chat-panel .agent-client-chat-input-container { + flex: 0 0 auto; + padding: 8px 10px 10px; + border-top: 1px solid var(--background-modifier-border); + background: var(--background-primary); +} + +/* ============================================================ + Quick-action button blocks + ============================================================ */ + +.agent-client-button-block { + display: flex; + margin: 4px 0; +} + +.agent-client-button-block-align-left { + justify-content: flex-start; +} + +.agent-client-button-block-align-center { + justify-content: center; +} + +.agent-client-button-block-align-right { + justify-content: flex-end; +} + +.agent-client-button-block-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-radius: 6px; + cursor: pointer; + font-weight: 500; +} + +.agent-client-button-block-avatar { + display: block; + flex: 0 0 18px; + width: 18px !important; + height: 18px !important; + min-width: 18px; + min-height: 18px; + max-width: 18px; + max-height: 18px; + aspect-ratio: 1 / 1; + border-radius: 50%; + object-fit: cover; + overflow: hidden; +} + +.agent-client-button-block-text { + white-space: nowrap; +}