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.
This commit is contained in:
Helm 2026-07-10 11:40:40 -04:00
parent 3b35f59ec8
commit 16fb2238dd
8 changed files with 448 additions and 35 deletions

View file

@ -106,6 +106,14 @@ The plugin maintains a persistent session with Hermes. Your conversation history
| **Show model info** | Display the current model name in the status bar | Enabled | | **Show model info** | Display the current model name in the status bar | Enabled |
| **Enable context injection** | Automatically send the active note as context with each message | Enabled | | **Enable context injection** | Automatically send the active note as context with each message | Enabled |
## Roadmap
- ~~**Multi-tab chat support**~~ — open multiple Hermes chat tabs simultaneously, each with its own isolated session, message history, and note context. *(shipped: each tab gets its own API client + session; use Ctrl+P → "Open Hermes Chat in New Tab")*
- ~~**Slash commands**~~`/prep-session`, `/sync-content`, `/lint-wiki`, and user-defined custom commands in plugin settings. *(shipped: built-in commands + JSON-configurable custom commands)*
- ~~**Slash command autocomplete**~~ — on `/` trigger in the chat input, show a suggestion dropdown with arrow-key navigation and click-to-select. *(shipped)*
- **Quick Actions button bar** — one-click buttons above the chat input for common workflows (Sync Content, Prep Session, Lint Wiki). Configurable in settings.
- **Hermes-side `/commands` endpoint** — server publishes a manifest of available slash commands; plugin fetches and merges with local commands on load.
## Development ## Development
```bash ```bash

18
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
import { ItemView, WorkspaceLeaf } from "obsidian"; import { ItemView, WorkspaceLeaf, MarkdownView } from "obsidian";
import type { Message, ChatState, ChatCompletionResponse } from "./types"; import type { Message, ChatState, ChatCompletionResponse, SlashCommand } from "./types";
import { ContextBridge } from "./context-bridge"; import { ContextBridge } from "./context-bridge";
// ApiClient interface — the concrete implementation (api-client.ts) is // ApiClient interface — the concrete implementation (api-client.ts) is
@ -28,6 +28,21 @@ export class ChatView extends ItemView {
private textarea: HTMLTextAreaElement | null = null; private textarea: HTMLTextAreaElement | null = null;
private sendButton: HTMLButtonElement | 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) { constructor(leaf: WorkspaceLeaf, apiClient?: ApiClient, contextBridge?: ContextBridge) {
super(leaf); super(leaf);
this.apiClient = apiClient ?? null; this.apiClient = apiClient ?? null;
@ -58,6 +73,25 @@ export class ChatView extends ItemView {
this.messagesContainer = container.createDiv("chat-messages"); this.messagesContainer = container.createDiv("chat-messages");
this.messagesContainer.id = "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) // Loading indicator (hidden by default)
this.loadingEl = this.messagesContainer.createDiv("chat-loading"); this.loadingEl = this.messagesContainer.createDiv("chat-loading");
this.loadingEl.setText("Thinking\u2026"); this.loadingEl.setText("Thinking\u2026");
@ -69,17 +103,67 @@ export class ChatView extends ItemView {
this.textarea = inputArea.createEl("textarea"); this.textarea = inputArea.createEl("textarea");
this.textarea.placeholder = "Type your message\u2026"; this.textarea.placeholder = "Type your message\u2026";
this.textarea.setAttribute("rows", "1"); this.textarea.setAttribute("rows", "1");
// Keydown: handle Enter (send or autocomplete select), arrow keys,
// Escape (close suggest)
this.textarea.addEventListener("keydown", (event: KeyboardEvent) => { 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) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
this.handleSend(); this.handleSend();
} }
}); });
// Input event: detect '/' for slash command autocomplete
this.textarea.addEventListener("input", () => {
this.checkSlashTrigger();
});
this.sendButton = inputArea.createEl("button"); this.sendButton = inputArea.createEl("button");
this.sendButton.setText("Send"); this.sendButton.setText("Send");
this.sendButton.addEventListener("click", () => this.handleSend()); 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 --- // --- Status bar ---
this.statusBar = container.createDiv("chat-status-bar"); this.statusBar = container.createDiv("chat-status-bar");
this.updateStatus(this.chatState.model, null); this.updateStatus(this.chatState.model, null);
@ -103,6 +187,11 @@ export class ChatView extends ItemView {
this.chatState.contextActive = active; 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. */ /** Append a message bubble to the chat display and scroll to bottom. */
appendMessage(role: "user" | "assistant" | "system", content: string): void { appendMessage(role: "user" | "assistant" | "system", content: string): void {
if (!this.messagesContainer) return; if (!this.messagesContainer) return;
@ -167,13 +256,149 @@ export class ChatView extends ItemView {
} }
} }
// ---------------------------------------------------------------------------
// 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> { private async handleSend(): Promise<void> {
if (!this.textarea) return; if (!this.textarea) return;
const userInput = this.textarea.value.trim(); const rawInput = this.textarea.value.trim();
if (!userInput) return; if (!rawInput) return;
this.textarea.value = ""; this.textarea.value = "";
// Display user message // 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); this.appendMessage("user", userInput);
// Check API client availability // Check API client availability
@ -199,10 +424,34 @@ export class ChatView extends ItemView {
let fullContent = ""; let fullContent = "";
try { try {
// Build messages with optional note context // Build messages using the captured note context (grabbed on focus,
const messages = this.contextBridge // before activeEditor became null). Falls back to no context if
? this.contextBridge.buildMessages(userInput, this.chatState.contextActive) // nothing was captured.
: [{ role: "user", content: userInput }]; 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( const response = await this.apiClient!.streamChat(
messages, messages,
(delta: string) => { (delta: string) => {

View file

@ -43,6 +43,27 @@ export class ContextBridge {
}; };
} }
/**
* Snapshot the active note context AND selection in one atomic read.
* Call this when the chat view gains focus captures the note state
* before the user's focus move clears activeEditor / deselects text.
*
* Returns null if no markdown editor is active.
*/
snapshot(): {
vault: string;
path: string;
title: string;
selection: string | null;
} | null {
const noteRef = this.getActiveNoteContext();
if (!noteRef) return null;
return {
...noteRef,
selection: this.getSelectionContext(),
};
}
/** /**
* Get the currently selected text in the active editor. * Get the currently selected text in the active editor.
* Returns null if no text is selected. * Returns null if no text is selected.

View file

@ -1,25 +1,27 @@
import { Plugin, WorkspaceLeaf } from "obsidian"; import { Plugin, WorkspaceLeaf } from "obsidian";
import { HermesChatSettingTab, HermesChatSettings, DEFAULT_SETTINGS } from "./settings"; import { HermesChatSettingTab, HermesChatSettings, DEFAULT_SETTINGS, BUILTIN_COMMANDS } from "./settings";
import { ChatView, VIEW_TYPE } from "./chat-view"; import { ChatView, VIEW_TYPE } from "./chat-view";
import { ApiClient } from "./api-client"; import { ApiClient } from "./api-client";
import { ContextBridge } from "./context-bridge"; import { ContextBridge } from "./context-bridge";
import type { GatewayConfig } from "./types"; import type { GatewayConfig, SlashCommand } from "./types";
export default class HermesChatPlugin extends Plugin { export default class HermesChatPlugin extends Plugin {
settings: HermesChatSettings; settings: HermesChatSettings;
private statusBarItem: HTMLElement | null = null; private statusBarItem: HTMLElement | null = null;
private apiClient: ApiClient | null = null;
private contextBridge: ContextBridge | null = null; private contextBridge: ContextBridge | null = null;
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
// Register the chat view // Register the chat view — each instance gets its own ApiClient so
// multiple tabs maintain independent sessions with Hermes.
this.registerView( this.registerView(
VIEW_TYPE, VIEW_TYPE,
(leaf: WorkspaceLeaf) => { (leaf: WorkspaceLeaf) => {
const view = new ChatView(leaf, this.apiClient!, this.contextBridge!); const client = new ApiClient(this.getGatewayConfig());
const view = new ChatView(leaf, client, this.contextBridge!);
view.setContextActive(this.settings.enableContextInjection); view.setContextActive(this.settings.enableContextInjection);
view.setSlashCommands(this.getAllCommands());
return view; return view;
} }
); );
@ -27,7 +29,7 @@ export default class HermesChatPlugin extends Plugin {
// Add settings tab // Add settings tab
this.addSettingTab(new HermesChatSettingTab(this.app, this)); this.addSettingTab(new HermesChatSettingTab(this.app, this));
// Ribbon icon to activate chat view // Ribbon icon to activate chat view (reveals existing or opens new)
this.addRibbonIcon("message-square", "Open Hermes Chat", () => { this.addRibbonIcon("message-square", "Open Hermes Chat", () => {
this.activateView(); this.activateView();
}); });
@ -38,16 +40,10 @@ export default class HermesChatPlugin extends Plugin {
this.statusBarItem.setText("Hermes: connecting..."); this.statusBarItem.setText("Hermes: connecting...");
} }
// Initialize API client from settings // Initialize shared context bridge (stateless — reads app.workspace)
const gatewayConfig: GatewayConfig = {
baseUrl: this.settings.gatewayUrl,
apiKey: this.settings.apiKey,
model: "hermes-agent",
};
this.apiClient = new ApiClient(gatewayConfig);
this.contextBridge = new ContextBridge(this.app); this.contextBridge = new ContextBridge(this.app);
// Command: Open Hermes Chat // Command: Open Hermes Chat (reveal existing or open new)
this.addCommand({ this.addCommand({
id: "open-hermes-chat", id: "open-hermes-chat",
name: "Open Hermes Chat", name: "Open Hermes Chat",
@ -56,6 +52,15 @@ export default class HermesChatPlugin extends Plugin {
}, },
}); });
// Command: Open Hermes Chat in New Tab (always fresh session)
this.addCommand({
id: "open-hermes-chat-new-tab",
name: "Open Hermes Chat in New Tab",
callback: () => {
this.openNewTab();
},
});
// Command: Send Current Note as Context // Command: Send Current Note as Context
this.addCommand({ this.addCommand({
id: "send-note-as-context", id: "send-note-as-context",
@ -72,10 +77,11 @@ export default class HermesChatPlugin extends Plugin {
}, },
}); });
// Check gateway connectivity // Check gateway connectivity (one-off probe — use a throwaway client)
if (this.statusBarItem && this.apiClient) { if (this.statusBarItem) {
this.statusBarItem.setText("Hermes: checking..."); this.statusBarItem.setText("Hermes: checking...");
this.apiClient.healthCheck().then((healthy) => { const probe = new ApiClient(this.getGatewayConfig());
probe.healthCheck().then((healthy) => {
if (this.statusBarItem) { if (this.statusBarItem) {
this.statusBarItem.setText(healthy ? "Hermes: connected" : "Hermes: unreachable"); this.statusBarItem.setText(healthy ? "Hermes: connected" : "Hermes: unreachable");
} }
@ -106,22 +112,47 @@ export default class HermesChatPlugin extends Plugin {
} }
} }
/** Always open a fresh ChatView in a new leaf — separate session. */
async openNewTab(): Promise<void> {
const leaf = this.app.workspace.getRightLeaf(false);
if (leaf) {
await leaf.setViewState({ type: VIEW_TYPE, active: true });
this.app.workspace.revealLeaf(leaf);
}
}
/** Build a GatewayConfig from current plugin settings. */
private getGatewayConfig(): GatewayConfig {
return {
baseUrl: this.settings.gatewayUrl,
apiKey: this.settings.apiKey,
model: "hermes-agent",
};
}
async loadSettings() { async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
} }
async saveSettings() { async saveSettings() {
await this.saveData(this.settings); await this.saveData(this.settings);
this.syncContextToViews(); this.syncSettingsToViews();
} }
/** Push enableContextInjection setting to any open ChatViews. */ /** Push settings changes (context toggle, slash commands) to all open ChatViews. */
private syncContextToViews(): void { private syncSettingsToViews(): void {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE); const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE);
const commands = this.getAllCommands();
for (const leaf of leaves) { for (const leaf of leaves) {
if (leaf.view instanceof ChatView) { if (leaf.view instanceof ChatView) {
leaf.view.setContextActive(this.settings.enableContextInjection); leaf.view.setContextActive(this.settings.enableContextInjection);
leaf.view.setSlashCommands(commands);
} }
} }
} }
/** Merge built-in commands with user-defined custom commands. */
private getAllCommands(): SlashCommand[] {
return [...BUILTIN_COMMANDS, ...this.settings.customCommands];
}
} }

View file

@ -6,13 +6,34 @@ export interface HermesChatSettings {
apiKey: string; apiKey: string;
showModelInfo: boolean; showModelInfo: boolean;
enableContextInjection: boolean; enableContextInjection: boolean;
customCommands: { trigger: string; description: string; prompt: string }[];
} }
/** Built-in slash commands shipped with the plugin. */
export const BUILTIN_COMMANDS: { trigger: string; description: string; prompt: string }[] = [
{
trigger: "/prep-session",
description: "Prep the next D&D session",
prompt: "Run the full session-prep pipeline for the next SKT session: scan recent session notes, identify loose threads, generate a session guide with modular flow-chart sections.",
},
{
trigger: "/sync-content",
description: "Sync Content Sink notes to vault",
prompt: "/run content-sink-sync",
},
{
trigger: "/lint-wiki",
description: "Lint the campaign wiki",
prompt: "Scan the campaign wiki for broken links, orphaned entities, missing templates, and formatting inconsistencies. Report a prioritized list of issues.",
},
];
export const DEFAULT_SETTINGS: HermesChatSettings = { export const DEFAULT_SETTINGS: HermesChatSettings = {
gatewayUrl: "http://192.168.1.12:8642/v1", gatewayUrl: "http://192.168.1.12:8642/v1",
apiKey: "0f224b188b52e8728d135ec339f8db8dfbf82d89a60021dcfe20d91c6174cf9b", apiKey: "0f224b188b52e8728d135ec339f8db8dfbf82d89a60021dcfe20d91c6174cf9b",
showModelInfo: true, showModelInfo: true,
enableContextInjection: true, enableContextInjection: true,
customCommands: [],
}; };
export class HermesChatSettingTab extends PluginSettingTab { export class HermesChatSettingTab extends PluginSettingTab {
@ -79,5 +100,34 @@ export class HermesChatSettingTab extends PluginSettingTab {
await this.plugin.saveData(this.plugin.settings); await this.plugin.saveData(this.plugin.settings);
}) })
); );
// --- Slash commands ---
containerEl.createEl("h3", { text: "Slash Commands" });
containerEl.createEl("p", {
text: "Type / in the chat input to see available commands. Built-in: /prep-session, /sync-content, /lint-wiki. Add custom commands below as JSON.",
});
const commandsDesc = containerEl.createDiv("setting-item-description");
commandsDesc.setText(
'Format: [{"trigger":"/my-cmd","description":"What it does","prompt":"Full prompt text..."}]'
);
const textarea = containerEl.createEl("textarea");
textarea.setAttribute("rows", "6");
textarea.style.width = "100%";
textarea.style.fontFamily = "monospace";
textarea.style.fontSize = "12px";
textarea.value = JSON.stringify(this.plugin.settings.customCommands, null, 2);
textarea.addEventListener("change", async () => {
try {
const parsed = JSON.parse(textarea.value);
if (Array.isArray(parsed)) {
this.plugin.settings.customCommands = parsed;
await this.plugin.saveData(this.plugin.settings);
}
} catch {
// Invalid JSON — don't save, user is still typing
}
});
} }
} }

View file

@ -19,6 +19,12 @@ export interface ChatState {
contextActive: boolean; contextActive: boolean;
} }
export interface SlashCommand {
trigger: string; // e.g. "/prep-session"
description: string; // e.g. "Prep the next D&D session"
prompt: string; // the full prompt sent to Hermes
}
export interface ChatCompletionResponse { export interface ChatCompletionResponse {
content: string; content: string;
sessionId: string; sessionId: string;

View file

@ -137,3 +137,45 @@
.chat-messages::-webkit-scrollbar-thumb:hover { .chat-messages::-webkit-scrollbar-thumb:hover {
background: var(--text-muted); background: var(--text-muted);
} }
/* --- Slash command autocomplete --- */
.hermes-slash-suggest {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
max-height: 200px;
overflow-y: auto;
min-width: 200px;
}
.hermes-slash-item {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
cursor: pointer;
transition: background 0.1s;
}
.hermes-slash-item:hover,
.hermes-slash-item-active {
background: var(--background-modifier-hover);
}
.hermes-slash-trigger {
font-family: var(--font-monospace);
font-size: 0.8rem;
font-weight: 600;
color: var(--interactive-accent);
flex-shrink: 0;
min-width: 80px;
}
.hermes-slash-desc {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}