Move tool notifications to status bar, add UI improvements

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-08 19:15:10 -05:00
parent a1d32379c0
commit 3edf8ad4ce
3 changed files with 352 additions and 16 deletions

View File

@@ -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 = '<i class="fas fa-robot"></i>';
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 {
<textarea placeholder="Ask Claude anything..." data-input></textarea>
<div class="button-group">
<button type="button" class="send" data-send>Send</button>
<button type="button" class="new-chat" data-new-chat>New Chat</button>
<button type="button" class="stop" data-stop style="display:none;background:#d32f2f;">Stop</button>
<button type="button" class="new-chat" data-new-chat>New</button>
<button type="button" class="copy" data-copy>Copy</button>
<button type="button" class="save" data-save>Save</button>
<button type="button" class="clear" data-clear>Clear</button>
</div>
</div>
<div class="gm-assistant-status" data-status>Ready</div>
@@ -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 `<div class="${classes.join(' ')}">${this._escapeHtml(msg.content)}</div>`;
// Render markdown for assistant messages, escape for user/system
const content = msg.type === 'assistant' ? this._renderMarkdown(msg.content) : this._escapeHtml(msg.content);
return `<div class="${classes.join(' ')}">${content}</div>`;
}).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, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
// Bold and italic
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+?)\*/g, '<em>$1</em>')
// Code blocks (fenced)
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
// Inline code (but not if already in HTML tags)
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Blockquotes
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rules
.replace(/^---$/gm, '<hr>')
// Unordered lists
.replace(/^\* (.+)$/gm, '<li>$1</li>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
// Wrap consecutive li elements in ul
.replace(/(<li>[\s\S]*?<\/li>)(\s*<li>)/g, '$1$2')
.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, '<ul>$1</ul>')
// Double newlines to paragraphs, single to br
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
}
_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 = '<div class="gm-assistant-log">';
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 += `<div class="log-message ${cssClass}">`;
htmlContent += `<strong>[${label}]</strong><br>`;
htmlContent += content;
htmlContent += '</div><hr>';
}
htmlContent += '</div>';
// 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');
}
}