obsidian-hermes-plugin/src/chat-view.ts
Helm 16fb2238dd feat: context capture fix, multi-tab support, slash commands with autocomplete
- Fix context capture timing bug: snapshot active note on chat focus
  instead of at send time (when activeEditor is already null).
  Added workspace active-leaf-change listener to keep context fresh.
- Multi-tab: each ChatView now gets its own ApiClient instance,
  enabling independent sessions per tab. Added 'Open in New Tab'
  command. Removed shared ApiClient from plugin.
- Slash commands: three built-in commands (/prep-session,
  /sync-content, /lint-wiki) plus JSON-configurable custom commands
  in settings. On '/' trigger, shows suggestion dropdown with
  arrow-key navigation, click-to-select, and Escape to close.
- Slash command resolution at send time replaces /trigger with
  full prompt text.
2026-07-10 11:40:40 -04:00

476 lines
No EOL
16 KiB
TypeScript

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<ChatCompletionResponse>;
streamChat(
messages: { role: string; content: string }[],
onDelta: (text: string) => void
): Promise<ChatCompletionResponse>;
}
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<void> {
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<void> {
// 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<void> {
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);
}
}
}