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.
This commit is contained in:
2026-05-19 21:18:50 +02:00
parent 138890b9d2
commit 810676ff21
8 changed files with 212 additions and 18 deletions
+1
View File
@@ -66,6 +66,7 @@ Then copy the plugin into your vault:
mkdir -p /path/to/vault/.obsidian/plugins/ollama-plugin mkdir -p /path/to/vault/.obsidian/plugins/ollama-plugin
cp manifest.json /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 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) # Remove old dist/ from previous installs (no longer needed with bundling)
rm -rf /path/to/vault/.obsidian/plugins/ollama-plugin/dist rm -rf /path/to/vault/.obsidian/plugins/ollama-plugin/dist
``` ```
+4
View File
@@ -110,4 +110,8 @@ export class Plugin {
constructor() { constructor() {
this.app = new App(); this.app = new App();
} }
addRibbonIcon(_icon: string, _title: string, _callback: () => void): HTMLElement {
return document.createElement('div');
}
} }
+4 -2
View File
@@ -95,9 +95,10 @@ mkdir -p "$PLUGIN_DIR"
# Remove old dist/ directory from previous installations (no longer needed with bundling) # Remove old dist/ directory from previous installations (no longer needed with bundling)
rm -rf "$PLUGIN_DIR/dist" 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/manifest.json" "$PLUGIN_DIR/"
cp "$SCRIPT_DIR/main.js" "$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. # 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" 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 "manifest.json ✓"
info "main.js ✓" info "main.js ✓"
info "styles.css ✓"
else else
error "Installation verification failed — missing files in $PLUGIN_DIR" error "Installation verification failed — missing files in $PLUGIN_DIR"
exit 1 exit 1
+15 -3
View File
@@ -9005,6 +9005,9 @@ var ChatView = class extends import_obsidian3.ItemView {
getDisplayText() { getDisplayText() {
return "Ollama Chat"; return "Ollama Chat";
} }
getIcon() {
return "bot";
}
async onOpen() { async onOpen() {
try { try {
await this.ollamaClient.initializeCache(); await this.ollamaClient.initializeCache();
@@ -9059,9 +9062,12 @@ var ChatView = class extends import_obsidian3.ItemView {
contentEl.textContent = msg.content; contentEl.textContent = msg.content;
} }
} else { } 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.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" }); const contentEl = messageEl.createEl("div", { cls: "ollama-message-content" });
contentEl.textContent = msg.content; contentEl.textContent = msg.content;
} }
@@ -9307,7 +9313,10 @@ var ChatView = class extends import_obsidian3.ItemView {
this.lastMessageEl = previousStreamingEl; this.lastMessageEl = previousStreamingEl;
} }
try { 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} const context = entries.map((entry) => `${entry.title}
${entry.content}`).join("\n\n").slice(0, MAX_CONTEXT_LENGTH); ${entry.content}`).join("\n\n").slice(0, MAX_CONTEXT_LENGTH);
const userMessageWithContext = context ? `Relevant vault context: const userMessageWithContext = context ? `Relevant vault context:
@@ -9402,6 +9411,9 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
"ollama-chat-view", "ollama-chat-view",
(leaf) => new ChatView(leaf, this.settings) (leaf) => new ChatView(leaf, this.settings)
); );
this.addRibbonIcon("bot", "Open Ollama Chat", async () => {
await this.activateChatView();
});
this.addCommand({ this.addCommand({
id: "open-ollama-chat", id: "open-ollama-chat",
name: "Open Ollama Chat", name: "Open Ollama Chat",
+26 -13
View File
@@ -76,6 +76,10 @@ export class ChatView extends ItemView {
return 'Ollama Chat'; return 'Ollama Chat';
} }
getIcon(): string {
return 'bot';
}
async onOpen(): Promise<void> { async onOpen(): Promise<void> {
try { try {
await this.ollamaClient.initializeCache(); await this.ollamaClient.initializeCache();
@@ -145,9 +149,14 @@ export class ChatView extends ItemView {
contentEl.textContent = msg.content; contentEl.textContent = msg.content;
} }
} else { } 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.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' }); const contentEl = messageEl.createEl('div', { cls: 'ollama-message-content' });
contentEl.textContent = msg.content; contentEl.textContent = msg.content;
} }
@@ -366,15 +375,15 @@ export class ChatView extends ItemView {
): Promise<void> { ): Promise<void> {
const toolResults = ( const toolResults = (
await Promise.all( await Promise.all(
toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => { toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => {
try { try {
const toolResult = await this.toolExecutor.handleToolCall(toolCall); const toolResult = await this.toolExecutor.handleToolCall(toolCall);
return { ...toolResult, id: toolCall.id }; return { ...toolResult, id: toolCall.id };
} catch (error) { } catch (error) {
ErrorHandler.handleError(error, 'ChatView.handleUserInput'); ErrorHandler.handleError(error, 'ChatView.handleUserInput');
return null; return null;
} }
}) })
) )
).filter((result): result is NonNullable<typeof result> => result !== null); ).filter((result): result is NonNullable<typeof result> => result !== null);
@@ -449,7 +458,10 @@ export class ChatView extends ItemView {
} }
try { try {
const entries = await this.vaultIndexer.searchVault(userMessage, this.settings.vaultSearchLimit); const entries = await this.vaultIndexer.searchVault(
userMessage,
this.settings.vaultSearchLimit
);
const context = entries const context = entries
.map((entry) => `${entry.title}\n${entry.content}`) .map((entry) => `${entry.title}\n${entry.content}`)
.join('\n\n') .join('\n\n')
@@ -459,7 +471,8 @@ export class ChatView extends ItemView {
: userMessage; : userMessage;
// Get the complete messages array for the LLM with all context layers // 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); const stream = this.ollamaClient.streamChat(completeMessages, tools);
+5
View File
@@ -17,6 +17,11 @@ export default class OllamaPlugin extends Plugin {
(leaf: WorkspaceLeaf) => new ChatView(leaf, this.settings) (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 // Add a command to open the chat view
this.addCommand({ this.addCommand({
id: 'open-ollama-chat', id: 'open-ollama-chat',
+151
View File
@@ -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);
}
+6
View File
@@ -100,6 +100,12 @@ describe('ChatView', () => {
}); });
}); });
describe('getIcon', () => {
it('should return the bot icon', () => {
expect(view.getIcon()).toBe('bot');
});
});
describe('onOpen', () => { describe('onOpen', () => {
it('should call render and setup event listeners', async () => { it('should call render and setup event listeners', async () => {
const renderSpy = jest.spyOn(view, 'render'); const renderSpy = jest.spyOn(view, 'render');