From 3edf8ad4cebbe8093a1849a8f6bcd8c8b932e934 Mon Sep 17 00:00:00 2001
From: kavren
Date: Mon, 8 Dec 2025 19:15:10 -0500
Subject: [PATCH] Move tool notifications to status bar, add UI improvements
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Tool calls now display in status bar instead of chat messages
- Added "working" status style (blue, italic)
- Added floating draggable button as UI fallback
- Added Stop, Copy, Save, Clear buttons
- Added markdown rendering for assistant messages
- Improved session handling and abort support
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
module.json | 5 +-
scripts/main.js | 288 +++++++++++++++++++++++++++++++++++++---
styles/gm-assistant.css | 75 +++++++++++
3 files changed, 352 insertions(+), 16 deletions(-)
diff --git a/module.json b/module.json
index b68cc09..be0d9ec 100644
--- a/module.json
+++ b/module.json
@@ -17,5 +17,8 @@
],
"styles": [
"styles/gm-assistant.css"
- ]
+ ],
+ "url": "https://git.kavcorp.com/kavren/gm-assistant",
+ "manifest": "https://git.kavcorp.com/kavren/gm-assistant/raw/branch/main/module.json",
+ "download": "https://git.kavcorp.com/kavren/gm-assistant/releases/download/v0.1.0/gm-assistant.zip"
}
diff --git a/scripts/main.js b/scripts/main.js
index 27c0d8e..72cdeda 100644
--- a/scripts/main.js
+++ b/scripts/main.js
@@ -23,10 +23,11 @@ Hooks.once('ready', () => {
console.log('GM Assistant | Module ready');
});
-// Add button to scene controls
+// Add button to scene controls (v13 API)
Hooks.on('getSceneControlButtons', (controls) => {
if (!game.user?.isGM) return;
+ // v13 uses array of control groups
const tokenControls = controls.find(c => c.name === 'token');
if (tokenControls) {
tokenControls.tools.push({
@@ -39,6 +40,78 @@ Hooks.on('getSceneControlButtons', (controls) => {
}
});
+// Add floating button as fallback (always visible, draggable)
+Hooks.once('ready', () => {
+ if (!game.user?.isGM) return;
+
+ // Load saved position or use default
+ const savedPos = JSON.parse(localStorage.getItem('gm-assistant-btn-pos') || '{"top":8,"left":240}');
+
+ const btn = document.createElement('button');
+ btn.id = 'gm-assistant-fab';
+ btn.innerHTML = '';
+ btn.title = 'GM Assistant (drag to move)';
+ btn.style.cssText = `
+ position: fixed;
+ top: ${savedPos.top}px;
+ left: ${savedPos.left}px;
+ width: 36px;
+ height: 36px;
+ border-radius: 6px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border: none;
+ color: white;
+ font-size: 16px;
+ cursor: grab;
+ z-index: 1000;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3);
+ user-select: none;
+ `;
+
+ let isDragging = false;
+ let wasDragged = false;
+ let startX, startY, startLeft, startTop;
+
+ btn.onmousedown = (e) => {
+ isDragging = true;
+ wasDragged = false;
+ startX = e.clientX;
+ startY = e.clientY;
+ startLeft = btn.offsetLeft;
+ startTop = btn.offsetTop;
+ btn.style.cursor = 'grabbing';
+ e.preventDefault();
+ };
+
+ document.addEventListener('mousemove', (e) => {
+ if (!isDragging) return;
+ const dx = e.clientX - startX;
+ const dy = e.clientY - startY;
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) wasDragged = true;
+ btn.style.left = (startLeft + dx) + 'px';
+ btn.style.top = (startTop + dy) + 'px';
+ });
+
+ document.addEventListener('mouseup', () => {
+ if (isDragging) {
+ isDragging = false;
+ btn.style.cursor = 'grab';
+ // Save position
+ localStorage.setItem('gm-assistant-btn-pos', JSON.stringify({
+ top: btn.offsetTop,
+ left: btn.offsetLeft
+ }));
+ }
+ });
+
+ btn.onclick = (e) => {
+ if (!wasDragged) GMAssistantApp.show();
+ };
+
+ document.body.appendChild(btn);
+ console.log('GM Assistant | Floating button added');
+});
+
// GM Assistant Application Window
class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
static DEFAULT_OPTIONS = {
@@ -102,7 +175,11 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
-
+
+
+
+
+
Ready
@@ -110,6 +187,10 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
return html;
}
+ _replaceHTML(result, content, options) {
+ content.replaceChildren(result);
+ }
+
_onRender(context, options) {
const html = this.element;
@@ -117,13 +198,19 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
const messagesEl = html.querySelector('[data-messages]');
const inputEl = html.querySelector('[data-input]');
const sendBtn = html.querySelector('[data-send]');
+ const stopBtn = html.querySelector('[data-stop]');
const newChatBtn = html.querySelector('[data-new-chat]');
+ const copyBtn = html.querySelector('[data-copy]');
+ const saveBtn = html.querySelector('[data-save]');
+ const clearBtn = html.querySelector('[data-clear]');
const statusEl = html.querySelector('[data-status]');
// Store refs
this._messagesEl = messagesEl;
this._inputEl = inputEl;
this._statusEl = statusEl;
+ this._sendBtn = sendBtn;
+ this._stopBtn = stopBtn;
// Render existing messages
this._renderMessages();
@@ -131,6 +218,17 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
// Send button
sendBtn.addEventListener('click', () => this._sendMessage());
+ // Stop button
+ stopBtn.addEventListener('click', () => {
+ if (this._abortController) {
+ this._abortController.abort();
+ this._abortController = null;
+ this.isStreaming = false;
+ this._toggleButtons(false);
+ this._setStatus('Stopped', 'error');
+ }
+ });
+
// Enter to send (Shift+Enter for newline)
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -139,14 +237,35 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
}
});
- // New chat button
+ // New chat button (resets session and aborts any running request)
newChatBtn.addEventListener('click', () => {
+ if (this._abortController) {
+ this._abortController.abort();
+ this._abortController = null;
+ }
this.messages = [];
this.sessionId = null;
+ this.isStreaming = false;
this._renderMessages();
this._setStatus('New conversation started', 'connected');
});
+ // Copy button - copy chat log to clipboard
+ copyBtn.addEventListener('click', () => {
+ const log = this.messages.map(msg => `[${msg.type}] ${msg.content}`).join('\n\n');
+ this._fallbackCopy(log);
+ });
+
+ // Save button - save chat to Foundry journal
+ saveBtn.addEventListener('click', () => this._saveToJournal());
+
+ // Clear button - clear display only (keeps session)
+ clearBtn.addEventListener('click', () => {
+ this.messages = [];
+ this._renderMessages();
+ this._setStatus('Chat cleared', 'connected');
+ });
+
// Check bridge health
this._checkBridgeHealth();
}
@@ -171,12 +290,19 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
}
}
+ _toggleButtons(streaming) {
+ if (this._sendBtn) this._sendBtn.style.display = streaming ? 'none' : '';
+ if (this._stopBtn) this._stopBtn.style.display = streaming ? '' : 'none';
+ }
+
_renderMessages() {
if (!this._messagesEl) return;
this._messagesEl.innerHTML = this.messages.map(msg => {
const classes = ['gm-assistant-message', msg.type];
- return `${this._escapeHtml(msg.content)}
`;
+ // Render markdown for assistant messages, escape for user/system
+ const content = msg.type === 'assistant' ? this._renderMarkdown(msg.content) : this._escapeHtml(msg.content);
+ return `${content}
`;
}).join('');
// Scroll to bottom
@@ -189,17 +315,139 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
return div.innerHTML;
}
+ _renderMarkdown(text) {
+ if (!text) return '';
+ // Render markdown while preserving existing HTML from Claude
+ return text
+ // Headers (only if not already HTML)
+ .replace(/^### (.+)$/gm, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
')
+ // Bold and italic
+ .replace(/\*\*\*(.+?)\*\*\*/g, '$1')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*([^*]+?)\*/g, '$1')
+ // Code blocks (fenced)
+ .replace(/```(\w*)\n([\s\S]*?)```/g, '$2
')
+ // Inline code (but not if already in HTML tags)
+ .replace(/`([^`]+)`/g, '$1')
+ // Blockquotes
+ .replace(/^> (.+)$/gm, '$1
')
+ // Horizontal rules
+ .replace(/^---$/gm, '
')
+ // Unordered lists
+ .replace(/^\* (.+)$/gm, '$1')
+ .replace(/^- (.+)$/gm, '$1')
+ // Wrap consecutive li elements in ul
+ .replace(/([\s\S]*?<\/li>)(\s*)/g, '$1$2')
+ .replace(/([\s\S]*?<\/li>)(?!\s*)/g, '')
+ // Double newlines to paragraphs, single to br
+ .replace(/\n\n/g, '
')
+ .replace(/\n/g, '
');
+ }
+
+ _fallbackCopy(text) {
+ const textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.style.position = 'fixed';
+ textarea.style.opacity = '0';
+ document.body.appendChild(textarea);
+ textarea.select();
+ try {
+ document.execCommand('copy');
+ this._setStatus('Copied to clipboard', 'connected');
+ } catch (err) {
+ this._setStatus('Copy failed - select manually', 'error');
+ console.log('Chat log:\n', text);
+ }
+ document.body.removeChild(textarea);
+ }
+
+ async _saveToJournal() {
+ if (this.messages.length === 0) {
+ this._setStatus('No messages to save', 'error');
+ return;
+ }
+
+ this._setStatus('Saving to journal...', '');
+
+ try {
+ // Find or create the GM Assistant Logs folder
+ let folder = game.folders.find(f => f.name === 'GM Assistant Logs' && f.type === 'JournalEntry');
+ if (!folder) {
+ folder = await Folder.create({
+ name: 'GM Assistant Logs',
+ type: 'JournalEntry',
+ color: '#667eea'
+ });
+ }
+
+ // Generate timestamp for title
+ const now = new Date();
+ const timestamp = now.toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false
+ });
+
+ // Build HTML content from messages
+ let htmlContent = '
';
+ for (const msg of this.messages) {
+ const label = msg.type.charAt(0).toUpperCase() + msg.type.slice(1);
+ const cssClass = msg.type;
+
+ // For assistant messages, content may already have HTML from markdown rendering
+ const content = msg.type === 'assistant' ? this._renderMarkdown(msg.content) : this._escapeHtml(msg.content);
+
+ htmlContent += `
`;
+ htmlContent += `[${label}]
`;
+ htmlContent += content;
+ htmlContent += '
';
+ }
+ htmlContent += '
';
+
+ // Create journal entry
+ const journal = await JournalEntry.create({
+ name: `GM Assistant - ${timestamp}`,
+ folder: folder.id,
+ ownership: { default: 0 } // GM only
+ });
+
+ // Add content page
+ await journal.createEmbeddedDocuments('JournalEntryPage', [{
+ name: 'Chat Log',
+ type: 'text',
+ text: { content: htmlContent }
+ }]);
+
+ this._setStatus(`Saved to "${journal.name}"`, 'connected');
+ ui.notifications.info(`Chat saved to journal: ${journal.name}`);
+
+ } catch (err) {
+ console.error('GM Assistant save error:', err);
+ this._setStatus('Save failed - see console', 'error');
+ ui.notifications.error('Failed to save chat log');
+ }
+ }
+
_addMessage(type, content) {
this.messages.push({ type, content });
this._renderMessages();
}
_updateLastAssistantMessage(content) {
- const last = this.messages[this.messages.length - 1];
- if (last && last.type === 'assistant') {
- last.content = content;
- this._renderMessages();
+ // Find the last assistant message (may not be the very last message if tools were used)
+ for (let i = this.messages.length - 1; i >= 0; i--) {
+ if (this.messages[i].type === 'assistant') {
+ this.messages[i].content = content;
+ this._renderMessages();
+ return;
+ }
}
+ // No assistant message found, add one
+ this._addMessage('assistant', content);
}
async _sendMessage() {
@@ -216,6 +464,10 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
this.isStreaming = true;
this.currentResponse = '';
this._setStatus('Thinking...', '');
+ this._toggleButtons(true);
+
+ // Create abort controller for this request
+ this._abortController = new AbortController();
try {
const response = await fetch(`${this.bridgeUrl}/api/chat`, {
@@ -224,7 +476,8 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
body: JSON.stringify({
message: message,
sessionId: this.sessionId
- })
+ }),
+ signal: this._abortController.signal
});
if (!response.ok) {
@@ -262,9 +515,8 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
break;
case 'tool':
- this._addMessage('tool', `Using: ${data.name}`);
- // Re-add assistant placeholder after tool message
- this._addMessage('assistant', this.currentResponse);
+ // Show tool use in status line instead of cluttering chat
+ this._setStatus(`Using: ${data.name}`, 'working');
break;
case 'done':
@@ -283,11 +535,17 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
}
} catch (e) {
- console.error('GM Assistant error:', e);
- this._addMessage('system', `Error: ${e.message}`);
- this._setStatus('Error', 'error');
+ if (e.name === 'AbortError') {
+ this._addMessage('system', 'Request cancelled');
+ } else {
+ console.error('GM Assistant error:', e);
+ this._addMessage('system', `Error: ${e.message}`);
+ this._setStatus('Error', 'error');
+ }
} finally {
this.isStreaming = false;
+ this._abortController = null;
+ this._toggleButtons(false);
this._setStatus('Ready', 'connected');
}
}
diff --git a/styles/gm-assistant.css b/styles/gm-assistant.css
index 3e694d5..a981f2a 100644
--- a/styles/gm-assistant.css
+++ b/styles/gm-assistant.css
@@ -44,6 +44,72 @@
.gm-assistant-message.assistant {
background: var(--color-bg-btn);
margin-right: auto;
+ max-width: 100%;
+}
+
+/* Markdown styles for assistant messages */
+.gm-assistant-message.assistant h2 {
+ font-size: 1.2em;
+ margin: 0.5em 0 0.3em;
+ border-bottom: 1px solid var(--color-border-light);
+}
+
+.gm-assistant-message.assistant h3 {
+ font-size: 1.1em;
+ margin: 0.5em 0 0.3em;
+}
+
+.gm-assistant-message.assistant h4 {
+ font-size: 1em;
+ margin: 0.4em 0 0.2em;
+}
+
+.gm-assistant-message.assistant strong {
+ color: var(--color-text-light-highlight);
+}
+
+.gm-assistant-message.assistant code {
+ background: rgba(0, 0, 0, 0.2);
+ padding: 1px 4px;
+ border-radius: 3px;
+ font-family: monospace;
+ font-size: 0.9em;
+}
+
+.gm-assistant-message.assistant pre {
+ background: rgba(0, 0, 0, 0.2);
+ padding: 8px;
+ border-radius: 4px;
+ overflow-x: auto;
+ margin: 0.5em 0;
+}
+
+.gm-assistant-message.assistant pre code {
+ background: none;
+ padding: 0;
+}
+
+.gm-assistant-message.assistant blockquote {
+ border-left: 3px solid var(--color-warm-1);
+ margin: 0.5em 0;
+ padding-left: 10px;
+ font-style: italic;
+ color: var(--color-text-light-secondary);
+}
+
+.gm-assistant-message.assistant ul {
+ margin: 0.3em 0;
+ padding-left: 1.5em;
+}
+
+.gm-assistant-message.assistant li {
+ margin: 0.2em 0;
+}
+
+.gm-assistant-message.assistant hr {
+ border: none;
+ border-top: 1px solid var(--color-border-light);
+ margin: 0.5em 0;
}
.gm-assistant-message.system {
@@ -98,6 +164,10 @@
font-size: 0.85em;
}
+.gm-assistant-input-area button.save {
+ background: var(--color-cool-3);
+}
+
/* Status bar */
.gm-assistant-status {
padding: 5px 10px;
@@ -115,6 +185,11 @@
color: #f44336;
}
+.gm-assistant-status.working {
+ color: #64b5f6;
+ font-style: italic;
+}
+
/* Typing indicator */
.gm-assistant-typing {
display: flex;