import { ItemView, WorkspaceLeaf, MarkdownView } from "obsidian"; import type { Message, ChatState, ChatCompletionResponse, SlashCommand } from "./types"; import { ContextBridge } from "./context-bridge"; // ApiClient interface — the concrete implementation (api-client.ts) is // instantiated with GatewayConfig and passed to ChatView via setApiClient(). export interface ApiClient { chat( messages: { role: string; content: string }[], stream?: boolean ): Promise; streamChat( messages: { role: string; content: string }[], onDelta: (text: string) => void ): Promise; } export const VIEW_TYPE = "hermes-chat-view"; export class ChatView extends ItemView { private apiClient: ApiClient | null = null; private contextBridge: ContextBridge | null = null; private chatState: ChatState; private messagesContainer: HTMLElement | null = null; private statusBar: HTMLElement | null = null; private loadingEl: HTMLElement | null = null; private textarea: HTMLTextAreaElement | null = null; private sendButton: HTMLButtonElement | null = null; /** Context captured on chat focus — avoids the timing bug where * activeEditor is null by the time handleSend() runs. */ private capturedNote: { vault: string; path: string; title: string; selection: string | null; } | null = null; // --- Slash command state --- private slashCommands: SlashCommand[] = []; private suggestEl: HTMLElement | null = null; private suggestIndex: number = -1; private filteredCommands: SlashCommand[] = []; constructor(leaf: WorkspaceLeaf, apiClient?: ApiClient, contextBridge?: ContextBridge) { super(leaf); this.apiClient = apiClient ?? null; this.contextBridge = contextBridge ?? null; this.chatState = { sessionId: null, messages: [], isLoading: false, model: "hermes-agent", contextActive: false, }; } getViewType(): string { return VIEW_TYPE; } getDisplayText(): string { return "Hermes"; } async onOpen(): Promise { const container = this.containerEl.children[1]; container.empty(); container.addClass("hermes-chat-container"); // --- Message list area --- this.messagesContainer = container.createDiv("chat-messages"); this.messagesContainer.id = "chat-messages"; // Capture note context on focus — this runs when the user clicks // into chat from a markdown note, before activeEditor becomes null. this.containerEl.addEventListener("focusin", () => { if (this.contextBridge) { this.capturedNote = this.contextBridge.snapshot(); } }); // Also capture when user switches to a different note while chat is // already open (no focus change on chat itself, but we want the // new note's context). this.registerEvent( this.app.workspace.on("active-leaf-change", (leaf) => { if (leaf?.view instanceof MarkdownView && this.contextBridge) { this.capturedNote = this.contextBridge.snapshot(); } }) ); // Loading indicator (hidden by default) this.loadingEl = this.messagesContainer.createDiv("chat-loading"); this.loadingEl.setText("Thinking\u2026"); this.loadingEl.style.display = "none"; // --- Input area --- const inputArea = container.createDiv("chat-input-area"); this.textarea = inputArea.createEl("textarea"); this.textarea.placeholder = "Type your message\u2026"; this.textarea.setAttribute("rows", "1"); // Keydown: handle Enter (send or autocomplete select), arrow keys, // Escape (close suggest) this.textarea.addEventListener("keydown", (event: KeyboardEvent) => { if (this.suggestEl && this.suggestEl.style.display !== "none") { // Autocomplete is open — intercept navigation keys if (event.key === "ArrowDown") { event.preventDefault(); this.suggestMove(1); return; } if (event.key === "ArrowUp") { event.preventDefault(); this.suggestMove(-1); return; } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); if (this.suggestIndex >= 0 && this.suggestIndex < this.filteredCommands.length) { this.suggestSelect(this.filteredCommands[this.suggestIndex]); } else { this.suggestClose(); } return; } if (event.key === "Escape") { event.preventDefault(); this.suggestClose(); return; } if (event.key === "Tab") { event.preventDefault(); if (this.suggestIndex >= 0 && this.suggestIndex < this.filteredCommands.length) { this.suggestSelect(this.filteredCommands[this.suggestIndex]); } return; } } // Normal Enter = send if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); this.handleSend(); } }); // Input event: detect '/' for slash command autocomplete this.textarea.addEventListener("input", () => { this.checkSlashTrigger(); }); this.sendButton = inputArea.createEl("button"); this.sendButton.setText("Send"); this.sendButton.addEventListener("click", () => this.handleSend()); // --- Slash command suggestion dropdown --- this.suggestEl = container.createDiv("hermes-slash-suggest"); this.suggestEl.style.display = "none"; this.suggestEl.style.position = "absolute"; this.suggestEl.style.zIndex = "1000"; // --- Status bar --- this.statusBar = container.createDiv("chat-status-bar"); this.updateStatus(this.chatState.model, null); } async onClose(): Promise { // No persistent cleanup needed } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** Set the API client for real streaming calls (called by main.ts). */ setApiClient(client: ApiClient): void { this.apiClient = client; } /** Toggle whether context (e.g. note content) should be sent with requests. */ setContextActive(active: boolean): void { this.chatState.contextActive = active; } /** Set available slash commands (built-in + custom from settings). */ setSlashCommands(commands: SlashCommand[]): void { this.slashCommands = commands; } /** Append a message bubble to the chat display and scroll to bottom. */ appendMessage(role: "user" | "assistant" | "system", content: string): void { if (!this.messagesContainer) return; const wrapper = this.messagesContainer.createDiv("chat-message"); wrapper.addClass(`chat-message-${role}`); const roleLabel = wrapper.createDiv("chat-message-role"); roleLabel.setText( role === "user" ? "You" : role === "assistant" ? "Hermes" : "System" ); const contentEl = wrapper.createDiv("chat-message-content"); contentEl.setText(content); this.scrollToBottom(); } /** Show or hide the "Thinking\u2026" loading indicator and lock input. */ setLoading(isLoading: boolean): void { this.chatState.isLoading = isLoading; if (this.loadingEl) { this.loadingEl.style.display = isLoading ? "block" : "none"; } if (this.textarea) { this.textarea.disabled = isLoading; } if (this.sendButton) { this.sendButton.disabled = isLoading; } } /** Update the bottom status bar with current model and session info. */ updateStatus(model: string, sessionId: string | null): void { this.chatState.model = model; this.chatState.sessionId = sessionId; if (this.statusBar) { const sessionPart = sessionId ? `Session: ${sessionId}` : "Session: \u2014"; this.statusBar.setText(`Model: ${model} | ${sessionPart}`); } } /** Clear all messages from the display. */ clearChat(): void { if (!this.messagesContainer) return; const children = Array.from(this.messagesContainer.children); for (const child of children) { if (child !== this.loadingEl) { child.remove(); } } this.chatState.messages = []; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- private scrollToBottom(): void { if (this.messagesContainer) { this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight; } } // --------------------------------------------------------------------------- // Slash command autocomplete // --------------------------------------------------------------------------- /** Check if the textarea currently has a '/' trigger worth suggesting for. */ private checkSlashTrigger(): void { if (!this.textarea) return; const value = this.textarea.value; const cursorPos = this.textarea.selectionStart; // Find the start of the current "word" — the text from the last // whitespace/newline/start to the cursor. let wordStart = cursorPos - 1; while (wordStart >= 0 && value[wordStart] !== " " && value[wordStart] !== "\n") { wordStart--; } wordStart++; // first char of the current word const currentWord = value.slice(wordStart, cursorPos); // Only trigger if current word starts with '/' and we're at the beginning // of a word (after whitespace/newline/start of input). if (currentWord.startsWith("/") && (wordStart === 0 || value[wordStart - 1] === " " || value[wordStart - 1] === "\n")) { const query = currentWord.slice(1).toLowerCase(); this.filteredCommands = this.slashCommands.filter( (cmd) => cmd.trigger.toLowerCase().includes(query) || cmd.description.toLowerCase().includes(query) ); if (this.filteredCommands.length > 0) { this.suggestShow(); return; } } this.suggestClose(); } private suggestShow(): void { if (!this.suggestEl || !this.textarea) return; this.suggestIndex = -1; this.suggestEl.empty(); for (let i = 0; i < this.filteredCommands.length; i++) { const cmd = this.filteredCommands[i]; const item = this.suggestEl.createDiv("hermes-slash-item"); const triggerEl = item.createSpan("hermes-slash-trigger"); triggerEl.setText(cmd.trigger); const descEl = item.createSpan("hermes-slash-desc"); descEl.setText(cmd.description); item.addEventListener("click", () => this.suggestSelect(cmd)); item.addEventListener("mouseenter", () => { this.suggestIndex = i; this.suggestHighlight(); }); } // Position below textarea const rect = this.textarea.getBoundingClientRect(); const containerRect = this.containerEl.getBoundingClientRect(); this.suggestEl.style.left = `${rect.left - containerRect.left}px`; this.suggestEl.style.top = `${rect.bottom - containerRect.top}px`; this.suggestEl.style.width = `${rect.width}px`; this.suggestEl.style.display = "block"; } private suggestClose(): void { if (this.suggestEl) { this.suggestEl.style.display = "none"; this.suggestEl.empty(); } this.suggestIndex = -1; this.filteredCommands = []; } private suggestMove(delta: number): void { if (this.filteredCommands.length === 0) return; this.suggestIndex += delta; if (this.suggestIndex < 0) this.suggestIndex = this.filteredCommands.length - 1; if (this.suggestIndex >= this.filteredCommands.length) this.suggestIndex = 0; this.suggestHighlight(); } private suggestHighlight(): void { if (!this.suggestEl) return; const items = this.suggestEl.querySelectorAll(".hermes-slash-item"); items.forEach((item, i) => { if (i === this.suggestIndex) { item.addClass("hermes-slash-item-active"); } else { item.removeClass("hermes-slash-item-active"); } }); } /** Select a slash command: fill the textarea with the full prompt. */ private suggestSelect(cmd: SlashCommand): void { if (!this.textarea) return; const value = this.textarea.value; const cursorPos = this.textarea.selectionStart; // Find the word boundaries of the '/' trigger let wordStart = cursorPos - 1; while (wordStart >= 0 && value[wordStart] !== " " && value[wordStart] !== "\n") { wordStart--; } wordStart++; // Replace the /trigger with the full prompt const before = value.slice(0, wordStart); const after = value.slice(cursorPos); this.textarea.value = before + cmd.prompt + after; this.suggestClose(); this.textarea.focus(); } /** Resolve slash commands in user input. If input starts with a known * trigger, returns the full prompt. Otherwise returns input unchanged. */ private resolveSlashCommand(input: string): string { const trimmed = input.trim(); for (const cmd of this.slashCommands) { if (trimmed.startsWith(cmd.trigger)) { return cmd.prompt + trimmed.slice(cmd.trigger.length); } } return input; } // --------------------------------------------------------------------------- // Send // --------------------------------------------------------------------------- private async handleSend(): Promise { if (!this.textarea) return; const rawInput = this.textarea.value.trim(); if (!rawInput) return; this.textarea.value = ""; // Resolve slash commands to full prompts before sending const userInput = this.resolveSlashCommand(rawInput); // Display user message (show the resolved prompt, not the /trigger) this.appendMessage("user", userInput); // Check API client availability if (!this.apiClient) { this.appendMessage( "assistant", "[API client not configured. Check plugin settings.]" ); return; } this.setLoading(true); // Create a placeholder div for the streaming assistant response const assistantMsgDiv = this.messagesContainer?.createDiv( "chat-message chat-message-assistant" ); const roleLabel = assistantMsgDiv?.createDiv("chat-message-role"); if (roleLabel) roleLabel.setText("Hermes"); const contentDiv = assistantMsgDiv?.createDiv("chat-message-content"); if (contentDiv) contentDiv.setText(""); let fullContent = ""; try { // Build messages using the captured note context (grabbed on focus, // before activeEditor became null). Falls back to no context if // nothing was captured. let messages: { role: string; content: string }[]; if (this.chatState.contextActive && this.capturedNote) { const cn = this.capturedNote; if (cn.selection) { // Selection embedded directly — small, intentional, works anywhere messages = [{ role: "system", content: `[Selection from ${cn.vault}/${cn.path}]\n\n${cn.selection}`, }, { role: "user", content: userInput }]; } else { // Path reference — Hermes reads the file via MCP on Helm messages = [{ role: "system", content: [ `[Current note: ${cn.vault}/${cn.path}]`, `Use mcp_obsidian_get_vault_document(vault_name="${cn.vault}", doc_path="${cn.path}") to read the full contents.`, ].join("\n"), }, { role: "user", content: userInput }]; } } else { // No context captured (or injection disabled) — plain user message messages = this.contextBridge ? this.contextBridge.buildMessages(userInput, this.chatState.contextActive) : [{ role: "user", content: userInput }]; } const response = await this.apiClient!.streamChat( messages, (delta: string) => { fullContent += delta; if (contentDiv) contentDiv.setText(fullContent); this.scrollToBottom(); } ); if (response.sessionId) { this.chatState.sessionId = response.sessionId; this.updateStatus(response.model, response.sessionId); } } catch (error: any) { if (contentDiv) { contentDiv.setText(`Error: ${error.message || "Unknown error"}`); } } finally { this.setLoading(false); } } }