From 810676ff216dbd39d43f40454ea3d2ee3819ed67 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Tue, 19 May 2026 21:18:50 +0200 Subject: [PATCH] feat: add Ollama icon and modern chat UI styling - src/chat-view.ts: Add getIcon() returning 'bot' for the view tab icon. Improve render() with role-specific CSS classes (user vs assistant) and message header structure for better styling hooks. - src/main.ts: Add ribbon icon ('bot') in the left sidebar that opens the chat view with a single click. - styles.css (new): Modern chat UI with message bubbles, distinct user and assistant themes using Obsidian CSS variables, sticky input bar, styled send button with accent color, and emoji role indicators. - install.sh: Copy styles.css into the plugin directory and verify its presence during installation. - README.md: Include styles.css in manual install instructions. - __mocks__/obsidian.ts: Add addRibbonIcon() mock for test compatibility. - tests/chat-view.test.ts: Add getIcon() assertion. --- README.md | 1 + __mocks__/obsidian.ts | 4 ++ install.sh | 6 +- main.js | 18 ++++- src/chat-view.ts | 39 +++++++---- src/main.ts | 5 ++ styles.css | 151 ++++++++++++++++++++++++++++++++++++++++ tests/chat-view.test.ts | 6 ++ 8 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 styles.css diff --git a/README.md b/README.md index 7e35d85..89de3d0 100755 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Then copy the plugin into your vault: mkdir -p /path/to/vault/.obsidian/plugins/ollama-plugin cp manifest.json /path/to/vault/.obsidian/plugins/ollama-plugin/ cp main.js /path/to/vault/.obsidian/plugins/ollama-plugin/ +cp styles.css /path/to/vault/.obsidian/plugins/ollama-plugin/ # Remove old dist/ from previous installs (no longer needed with bundling) rm -rf /path/to/vault/.obsidian/plugins/ollama-plugin/dist ``` diff --git a/__mocks__/obsidian.ts b/__mocks__/obsidian.ts index 5426045..a8e1711 100755 --- a/__mocks__/obsidian.ts +++ b/__mocks__/obsidian.ts @@ -110,4 +110,8 @@ export class Plugin { constructor() { this.app = new App(); } + + addRibbonIcon(_icon: string, _title: string, _callback: () => void): HTMLElement { + return document.createElement('div'); + } } diff --git a/install.sh b/install.sh index 973b70c..14d6f5e 100755 --- a/install.sh +++ b/install.sh @@ -95,9 +95,10 @@ mkdir -p "$PLUGIN_DIR" # Remove old dist/ directory from previous installations (no longer needed with bundling) rm -rf "$PLUGIN_DIR/dist" -# Copy manifest and bundled entry point +# Copy manifest, bundled entry point, and styles cp "$SCRIPT_DIR/manifest.json" "$PLUGIN_DIR/" cp "$SCRIPT_DIR/main.js" "$PLUGIN_DIR/" +cp "$SCRIPT_DIR/styles.css" "$PLUGIN_DIR/" # All dependencies are now bundled into main.js; no runtime node_modules needed. @@ -107,9 +108,10 @@ info "Plugin installed to $PLUGIN_DIR" step "Verifying installation" -if [ -f "$PLUGIN_DIR/manifest.json" ] && [ -f "$PLUGIN_DIR/main.js" ]; then +if [ -f "$PLUGIN_DIR/manifest.json" ] && [ -f "$PLUGIN_DIR/main.js" ] && [ -f "$PLUGIN_DIR/styles.css" ]; then info "manifest.json ✓" info "main.js ✓" + info "styles.css ✓" else error "Installation verification failed — missing files in $PLUGIN_DIR" exit 1 diff --git a/main.js b/main.js index cadff1a..6eb8e82 100644 --- a/main.js +++ b/main.js @@ -9005,6 +9005,9 @@ var ChatView = class extends import_obsidian3.ItemView { getDisplayText() { return "Ollama Chat"; } + getIcon() { + return "bot"; + } async onOpen() { try { await this.ollamaClient.initializeCache(); @@ -9059,9 +9062,12 @@ var ChatView = class extends import_obsidian3.ItemView { contentEl.textContent = msg.content; } } else { - const messageEl = container.createEl("div", { cls: "ollama-message" }); + const messageEl = container.createEl("div", { + cls: `ollama-message ollama-message-${msg.role}` + }); messageEl.setAttribute("data-msg-id", msg.id); - messageEl.createEl("div", { cls: "ollama-message-role", text: msg.role }); + const headerEl = messageEl.createEl("div", { cls: "ollama-message-header" }); + headerEl.createEl("span", { cls: "ollama-message-role", text: msg.role }); const contentEl = messageEl.createEl("div", { cls: "ollama-message-content" }); contentEl.textContent = msg.content; } @@ -9307,7 +9313,10 @@ var ChatView = class extends import_obsidian3.ItemView { this.lastMessageEl = previousStreamingEl; } try { - const entries = await this.vaultIndexer.searchVault(userMessage, this.settings.vaultSearchLimit); + const entries = await this.vaultIndexer.searchVault( + userMessage, + this.settings.vaultSearchLimit + ); const context = entries.map((entry) => `${entry.title} ${entry.content}`).join("\n\n").slice(0, MAX_CONTEXT_LENGTH); const userMessageWithContext = context ? `Relevant vault context: @@ -9402,6 +9411,9 @@ var OllamaPlugin = class extends import_obsidian4.Plugin { "ollama-chat-view", (leaf) => new ChatView(leaf, this.settings) ); + this.addRibbonIcon("bot", "Open Ollama Chat", async () => { + await this.activateChatView(); + }); this.addCommand({ id: "open-ollama-chat", name: "Open Ollama Chat", diff --git a/src/chat-view.ts b/src/chat-view.ts index ea6407e..c3d9898 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -76,6 +76,10 @@ export class ChatView extends ItemView { return 'Ollama Chat'; } + getIcon(): string { + return 'bot'; + } + async onOpen(): Promise { try { await this.ollamaClient.initializeCache(); @@ -145,9 +149,14 @@ export class ChatView extends ItemView { contentEl.textContent = msg.content; } } else { - const messageEl = container.createEl('div', { cls: 'ollama-message' }); + const messageEl = container.createEl('div', { + cls: `ollama-message ollama-message-${msg.role}`, + }); messageEl.setAttribute('data-msg-id', msg.id); - messageEl.createEl('div', { cls: 'ollama-message-role', text: msg.role }); + + const headerEl = messageEl.createEl('div', { cls: 'ollama-message-header' }); + headerEl.createEl('span', { cls: 'ollama-message-role', text: msg.role }); + const contentEl = messageEl.createEl('div', { cls: 'ollama-message-content' }); contentEl.textContent = msg.content; } @@ -366,15 +375,15 @@ export class ChatView extends ItemView { ): Promise { const toolResults = ( await Promise.all( - toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => { - try { - const toolResult = await this.toolExecutor.handleToolCall(toolCall); - return { ...toolResult, id: toolCall.id }; - } catch (error) { - ErrorHandler.handleError(error, 'ChatView.handleUserInput'); - return null; - } - }) + toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => { + try { + const toolResult = await this.toolExecutor.handleToolCall(toolCall); + return { ...toolResult, id: toolCall.id }; + } catch (error) { + ErrorHandler.handleError(error, 'ChatView.handleUserInput'); + return null; + } + }) ) ).filter((result): result is NonNullable => result !== null); @@ -449,7 +458,10 @@ export class ChatView extends ItemView { } try { - const entries = await this.vaultIndexer.searchVault(userMessage, this.settings.vaultSearchLimit); + const entries = await this.vaultIndexer.searchVault( + userMessage, + this.settings.vaultSearchLimit + ); const context = entries .map((entry) => `${entry.title}\n${entry.content}`) .join('\n\n') @@ -459,7 +471,8 @@ export class ChatView extends ItemView { : userMessage; // Get the complete messages array for the LLM with all context layers - const completeMessages = this.conversationStateManager.getCompleteMessages(userMessageWithContext); + const completeMessages = + this.conversationStateManager.getCompleteMessages(userMessageWithContext); const stream = this.ollamaClient.streamChat(completeMessages, tools); diff --git a/src/main.ts b/src/main.ts index b03ad3e..0910fc0 100755 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,11 @@ export default class OllamaPlugin extends Plugin { (leaf: WorkspaceLeaf) => new ChatView(leaf, this.settings) ); + // Add a ribbon icon in the left sidebar + this.addRibbonIcon('bot', 'Open Ollama Chat', async () => { + await this.activateChatView(); + }); + // Add a command to open the chat view this.addCommand({ id: 'open-ollama-chat', diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..50f0444 --- /dev/null +++ b/styles.css @@ -0,0 +1,151 @@ +:root { + --ollama-user-bg: var(--background-modifier-form-field); + --ollama-assistant-bg: var(--background-primary-alt); + --ollama-border: var(--background-modifier-border); + --ollama-radius: var(--radius-m); + --ollama-gap: var(--size-4-2); +} + +.ollama-chat-container { + display: flex; + flex-direction: column; + gap: var(--ollama-gap); + padding: var(--size-4-2); + overflow-y: auto; + flex: 1; +} + +.ollama-message { + display: flex; + flex-direction: column; + gap: var(--size-4-1); + padding: var(--size-4-2); + border-radius: var(--ollama-radius); + border: 1px solid var(--ollama-border); + max-width: 90%; +} + +.ollama-message-user { + align-self: flex-end; + background-color: var(--ollama-user-bg); +} + +.ollama-message-assistant { + align-self: flex-start; + background-color: var(--ollama-assistant-bg); +} + +.ollama-message-header { + display: flex; + align-items: center; + gap: var(--size-4-1); + font-size: var(--font-smallest); + font-weight: var(--font-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.ollama-message-role::before { + display: inline-block; + width: 1em; + height: 1em; + margin-right: var(--size-4-1); + vertical-align: middle; +} + +.ollama-message-user .ollama-message-role::before { + content: '👤'; +} + +.ollama-message-assistant .ollama-message-role::before { + content: '🤖'; +} + +.ollama-message-content { + line-height: var(--line-height-normal); + white-space: pre-wrap; + word-break: break-word; +} + +.ollama-new-chat-container { + display: flex; + justify-content: flex-end; + padding: var(--size-4-1) var(--size-4-2); +} + +.ollama-new-chat-button { + display: inline-flex; + align-items: center; + gap: var(--size-4-1); + padding: var(--size-4-1) var(--size-4-2); + font-size: var(--font-smallest); + border-radius: var(--ollama-radius); + border: 1px solid var(--ollama-border); + background-color: var(--background-modifier-form-field); + color: var(--text-normal); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.ollama-new-chat-button:hover { + background-color: var(--background-modifier-hover); +} + +.ollama-new-chat-button::before { + content: '➕'; +} + +.ollama-input-container { + display: flex; + gap: var(--size-4-1); + padding: var(--size-4-2); + border-top: 1px solid var(--ollama-border); + background-color: var(--background-primary); + align-items: flex-end; +} + +.ollama-input { + flex: 1; + min-height: 2.5rem; + max-height: 8rem; + padding: var(--size-4-1) var(--size-4-2); + border-radius: var(--ollama-radius); + border: 1px solid var(--ollama-border); + background-color: var(--background-modifier-form-field); + color: var(--text-normal); + resize: vertical; + font-family: inherit; + font-size: var(--font-ui-small); + line-height: var(--line-height-normal); +} + +.ollama-input:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--interactive-accent-translucent); +} + +.ollama-send-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--size-4-1) var(--size-4-3); + border-radius: var(--ollama-radius); + border: none; + background-color: var(--interactive-accent); + color: var(--text-on-accent); + font-weight: var(--font-semibold); + font-size: var(--font-ui-small); + cursor: pointer; + transition: filter 0.15s ease; + white-space: nowrap; +} + +.ollama-send-button:hover { + filter: brightness(1.1); +} + +.ollama-send-button:active { + filter: brightness(0.95); +} diff --git a/tests/chat-view.test.ts b/tests/chat-view.test.ts index b3e4a91..fbe670c 100755 --- a/tests/chat-view.test.ts +++ b/tests/chat-view.test.ts @@ -100,6 +100,12 @@ describe('ChatView', () => { }); }); + describe('getIcon', () => { + it('should return the bot icon', () => { + expect(view.getIcon()).toBe('bot'); + }); + }); + describe('onOpen', () => { it('should call render and setup event listeners', async () => { const renderSpy = jest.spyOn(view, 'render');