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:
parent
3b35f59ec8
commit
16fb2238dd
8 changed files with 448 additions and 35 deletions
|
|
@ -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 |
|
||||
| **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
|
||||
|
||||
```bash
|
||||
|
|
|
|||
18
main.js
18
main.js
File diff suppressed because one or more lines are too long
267
src/chat-view.ts
267
src/chat-view.ts
|
|
@ -1,5 +1,5 @@
|
|||
import { ItemView, WorkspaceLeaf } from "obsidian";
|
||||
import type { Message, ChatState, ChatCompletionResponse } from "./types";
|
||||
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
|
||||
|
|
@ -28,6 +28,21 @@ export class ChatView extends ItemView {
|
|||
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;
|
||||
|
|
@ -58,6 +73,25 @@ export class ChatView extends ItemView {
|
|||
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");
|
||||
|
|
@ -69,17 +103,67 @@ export class ChatView extends ItemView {
|
|||
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);
|
||||
|
|
@ -103,6 +187,11 @@ export class ChatView extends ItemView {
|
|||
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;
|
||||
|
|
@ -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> {
|
||||
if (!this.textarea) return;
|
||||
const userInput = this.textarea.value.trim();
|
||||
if (!userInput) return;
|
||||
const rawInput = this.textarea.value.trim();
|
||||
if (!rawInput) return;
|
||||
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);
|
||||
|
||||
// Check API client availability
|
||||
|
|
@ -199,10 +424,34 @@ export class ChatView extends ItemView {
|
|||
let fullContent = "";
|
||||
|
||||
try {
|
||||
// Build messages with optional note context
|
||||
const messages = this.contextBridge
|
||||
? this.contextBridge.buildMessages(userInput, this.chatState.contextActive)
|
||||
: [{ role: "user", content: userInput }];
|
||||
// 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) => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
* Returns null if no text is selected.
|
||||
|
|
|
|||
71
src/main.ts
71
src/main.ts
|
|
@ -1,25 +1,27 @@
|
|||
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 { ApiClient } from "./api-client";
|
||||
import { ContextBridge } from "./context-bridge";
|
||||
import type { GatewayConfig } from "./types";
|
||||
import type { GatewayConfig, SlashCommand } from "./types";
|
||||
|
||||
export default class HermesChatPlugin extends Plugin {
|
||||
settings: HermesChatSettings;
|
||||
private statusBarItem: HTMLElement | null = null;
|
||||
private apiClient: ApiClient | null = null;
|
||||
private contextBridge: ContextBridge | null = null;
|
||||
|
||||
async onload() {
|
||||
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(
|
||||
VIEW_TYPE,
|
||||
(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.setSlashCommands(this.getAllCommands());
|
||||
return view;
|
||||
}
|
||||
);
|
||||
|
|
@ -27,7 +29,7 @@ export default class HermesChatPlugin extends Plugin {
|
|||
// Add settings tab
|
||||
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.activateView();
|
||||
});
|
||||
|
|
@ -38,16 +40,10 @@ export default class HermesChatPlugin extends Plugin {
|
|||
this.statusBarItem.setText("Hermes: connecting...");
|
||||
}
|
||||
|
||||
// Initialize API client from settings
|
||||
const gatewayConfig: GatewayConfig = {
|
||||
baseUrl: this.settings.gatewayUrl,
|
||||
apiKey: this.settings.apiKey,
|
||||
model: "hermes-agent",
|
||||
};
|
||||
this.apiClient = new ApiClient(gatewayConfig);
|
||||
// Initialize shared context bridge (stateless — reads app.workspace)
|
||||
this.contextBridge = new ContextBridge(this.app);
|
||||
|
||||
// Command: Open Hermes Chat
|
||||
// Command: Open Hermes Chat (reveal existing or open new)
|
||||
this.addCommand({
|
||||
id: "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
|
||||
this.addCommand({
|
||||
id: "send-note-as-context",
|
||||
|
|
@ -72,10 +77,11 @@ export default class HermesChatPlugin extends Plugin {
|
|||
},
|
||||
});
|
||||
|
||||
// Check gateway connectivity
|
||||
if (this.statusBarItem && this.apiClient) {
|
||||
// Check gateway connectivity (one-off probe — use a throwaway client)
|
||||
if (this.statusBarItem) {
|
||||
this.statusBarItem.setText("Hermes: checking...");
|
||||
this.apiClient.healthCheck().then((healthy) => {
|
||||
const probe = new ApiClient(this.getGatewayConfig());
|
||||
probe.healthCheck().then((healthy) => {
|
||||
if (this.statusBarItem) {
|
||||
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() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
this.syncContextToViews();
|
||||
this.syncSettingsToViews();
|
||||
}
|
||||
|
||||
/** Push enableContextInjection setting to any open ChatViews. */
|
||||
private syncContextToViews(): void {
|
||||
/** Push settings changes (context toggle, slash commands) to all open ChatViews. */
|
||||
private syncSettingsToViews(): void {
|
||||
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE);
|
||||
const commands = this.getAllCommands();
|
||||
for (const leaf of leaves) {
|
||||
if (leaf.view instanceof ChatView) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,34 @@ export interface HermesChatSettings {
|
|||
apiKey: string;
|
||||
showModelInfo: 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 = {
|
||||
gatewayUrl: "http://192.168.1.12:8642/v1",
|
||||
apiKey: "0f224b188b52e8728d135ec339f8db8dfbf82d89a60021dcfe20d91c6174cf9b",
|
||||
showModelInfo: true,
|
||||
enableContextInjection: true,
|
||||
customCommands: [],
|
||||
};
|
||||
|
||||
export class HermesChatSettingTab extends PluginSettingTab {
|
||||
|
|
@ -79,5 +100,34 @@ export class HermesChatSettingTab extends PluginSettingTab {
|
|||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ export interface ChatState {
|
|||
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 {
|
||||
content: string;
|
||||
sessionId: string;
|
||||
|
|
|
|||
42
styles.css
42
styles.css
|
|
@ -136,4 +136,46 @@
|
|||
|
||||
.chat-messages::-webkit-scrollbar-thumb:hover {
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue