/** * Matrix Client - Using mxjs-lite library */ import { MxjsClient } from './mxjs-lite.js'; /** * @type {MxjsClient|null} */ let mxClient = null; /** * @type {string|null} */ let syncToken = null; // Dependencies that will be injected let term, inlineInput, mentionSuggestions, writeClickable, writePrompt, showInlineInput, positionInlineInput, submitInlineInput; /** * Initialize Matrix client with configuration and dependencies * @param {Object} userConfig - Configuration options * @param {string} userConfig.homeserver - Matrix homeserver URL * @param {string} [userConfig.publicReadToken] - Public read token for unauthenticated requests * @param {Object} deps - UI dependencies */ export function initMatrixClient(userConfig, deps) { // Create new mxjs-lite client mxClient = new MxjsClient({ homeserver: userConfig.homeserver || 'https://matrix.org', publicReadToken: userConfig.publicReadToken || null }); // Inject UI dependencies if (deps) { term = deps.term; inlineInput = deps.inlineInput; mentionSuggestions = deps.mentionSuggestions; writeClickable = deps.writeClickable; writePrompt = deps.writePrompt; showInlineInput = deps.showInlineInput; positionInlineInput = deps.positionInlineInput; submitInlineInput = deps.submitInlineInput; } // Set up event handlers setupEventHandlers(); } /** * Update mxClient with new session credentials * @param {Object} session - Session data with accessToken and userId */ export function updateClientSession(session) { if (!mxClient) return; mxClient.accessToken = session.accessToken; mxClient.userId = session.userId; } /** * Setup mxjs-lite event handlers */ function setupEventHandlers() { if (!mxClient) return; mxClient.on('message', ({ roomId, event }) => { if (!chatMode.active || roomId !== window.matrixSession?.roomId) return; handleNewMessage(event); }); mxClient.on('edit', ({ roomId, edits, newBody, event }) => { if (!chatMode.active || roomId !== window.matrixSession?.roomId) return; handleMessageEdit(edits, newBody); }); mxClient.on('redaction', ({ roomId, redacts, event }) => { if (!chatMode.active || roomId !== window.matrixSession?.roomId) return; handleMessageRedaction(redacts); }); mxClient.on('typing', ({ roomId, userIds }) => { // Can add typing indicators here if needed }); } /** * Handle incoming message event * @param {Object} event - Matrix message event */ function handleNewMessage(event) { if (event.content?.msgtype !== 'm.text') return; const msgId = event.event_id; const exists = chatMode.messages.find(m => m.id === msgId); if (exists) return; const timestamp = new Date(event.origin_server_ts); const time = timestamp.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const userId = event.sender; const displayName = chatMode.displayNames[userId] || mxClient.extractLocalpart(userId); const newMessage = { id: msgId, time: time, sender: displayName, userId: userId, text: event.content.body }; chatMode.messages.push(newMessage); // Only keep last 100 messages in memory if (chatMode.messages.length > 100) { chatMode.messages = chatMode.messages.slice(-100); } renderChatMessage(newMessage); } /** * Handle message edit event * @param {string} originalEventId - ID of the original message being edited * @param {string} newBody - New message text */ function handleMessageEdit(originalEventId, newBody) { const message = chatMode.messages.find(m => m.id === originalEventId); if (!message) return; message.text = newBody + ' \x1b[90m(edited)\x1b[0m'; rerenderChatView(); } /** * Handle message redaction event * @param {string} redactedEventId - ID of the message being redacted */ function handleMessageRedaction(redactedEventId) { const messageIndex = chatMode.messages.findIndex(m => m.id === redactedEventId); if (messageIndex === -1) return; // Remove the message from the array chatMode.messages.splice(messageIndex, 1); rerenderChatView(); } /** * Re-render the entire chat view */ function rerenderChatView() { if (!chatMode.active) return; // Clear terminal and redraw header term.clear(); term.writeln('╔════════════════════════════════════════════════════════════╗'); term.writeln('║ CHAT - #generalchat ║'); term.writeln('║ Type /help for commands ║'); term.writeln('╚════════════════════════════════════════════════════════════╝'); // Render the last 20 messages const recent = chatMode.messages.slice(-20); recent.forEach(msg => { const color = getUserColor(msg.sender); term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); }); // Render separator and prompt term.writeln(''); term.writeln('─'.repeat(term.cols || 60)); term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); term.scrollToBottom(); setTimeout(() => positionInlineInput(), 10); } // Chat mode state export const chatMode = { active: false, messages: [], lastSync: null, pollInterval: null, inputLine: '', displayNames: {}, mentionDirectory: [], mentionLoadedAt: 0, mentionAutocomplete: { tokenStart: -1, tokenEnd: -1, matches: [], index: 0 } }; /** * Matrix API helper for backward compatibility * @param {string} endpoint - API endpoint path * @param {string} [method='GET'] - HTTP method * @param {Object|null} [body=null] - Request body * @returns {Promise} API response */ export const matrixApi = async (endpoint, method = 'GET', body = null) => { if (!mxClient) return null; const accessToken = window.matrixSession?.accessToken || null; return await mxClient.api(endpoint, method, body, accessToken); }; /** * Fetch the latest public message from a room * @param {string} roomAlias - Room alias (e.g. #room:server.com) * @returns {Promise<{sender: string, body: string, timestamp: number} | null>} */ export async function fetchPublicLastMessage(roomAlias) { if (!mxClient) return null; return await mxClient.fetchPublicLastMessage(roomAlias); } /** * Fetch user presence * @param {string} userId - Full Matrix user ID * @returns {Promise<{presence: string, lastActive: number} | null>} */ export async function fetchPublicPresence(userId) { if (!mxClient) return null; const result = await mxClient.fetchPublicPresence(userId); return result ? { presence: result.presence, lastActive: result.lastActive } : null; } /** * Format a unix-millisecond timestamp as relative age text. * @param {number} timestampMs - Epoch timestamp in milliseconds * @returns {string} Relative time label */ export function formatTimeAgo(timestampMs) { if (typeof timestampMs !== 'number') { return 'unknown'; } const elapsedSeconds = Math.max(0, Math.floor((Date.now() - timestampMs) / 1000)); if (elapsedSeconds < 60) { return 'just now'; } if (elapsedSeconds < 3600) { return `${Math.floor(elapsedSeconds / 60)}m ago`; } if (elapsedSeconds < 86400) { return `${Math.floor(elapsedSeconds / 3600)}h ago`; } return `${Math.floor(elapsedSeconds / 86400)}d ago`; } /** * Get display name for a user (with caching) * @param {string} userId - Matrix user ID * @returns {Promise} Display name */ async function getDisplayName(userId) { if (chatMode.displayNames[userId]) { return chatMode.displayNames[userId]; } if (!mxClient) { const fallback = userId.split(':')[0].substring(1); chatMode.displayNames[userId] = fallback; return fallback; } try { const profile = await mxClient.getProfile(userId); const displayName = profile?.displayName || mxClient.extractLocalpart(userId); chatMode.displayNames[userId] = displayName; return displayName; } catch (error) { const fallback = mxClient.extractLocalpart(userId); chatMode.displayNames[userId] = fallback; return fallback; } } /** * Get color for user based on their ID (consistent hashing) * @param {string} username - Username string * @returns {string} ANSI color code */ function getUserColor(username) { const colors = [ '\x1b[91m', // bright red '\x1b[92m', // bright green '\x1b[93m', // bright yellow '\x1b[94m', // bright blue '\x1b[95m', // bright magenta '\x1b[96m', // bright cyan '\x1b[33m', // yellow '\x1b[35m', // magenta '\x1b[36m', // cyan ]; let hash = 0; for (let i = 0; i < username.length; i++) { hash = ((hash << 5) - hash) + username.charCodeAt(i); hash = hash & hash; } return colors[Math.abs(hash) % colors.length]; } /** * Check if display name is already taken * @param {string} newName - Proposed display name * @returns {Promise} True if taken */ export async function isDisplayNameTaken(newName) { if (!mxClient || !window.matrixSession?.roomId) return false; try { const members = await mxClient.getRoomMembers(window.matrixSession.roomId); if (!members) return false; for (const member of members) { if (member.userId === window.matrixSession.userId) continue; if (member.displayName.toLowerCase() === newName.toLowerCase()) { return true; } } return false; } catch (error) { console.error('Error checking display names:', error); return false; } } /** * Determine if a Matrix display name appears unchanged/default. * @param {string} displayName - Current display name * @returns {boolean} True when name looks like an auto-generated default */ function isDefaultDisplayName(displayName) { if (!displayName) return true; const trimmedName = displayName.trim(); if (!trimmedName) return true; const uuidV4Pattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidV4Pattern.test(trimmedName); } /** * Check whether to show a nickname setup hint for the current user. * @returns {Promise} True when the user should be prompted to change nickname */ async function shouldShowNicknameHint() { if (!mxClient || !window.matrixSession?.userId) return false; try { const profile = await mxClient.getProfile(window.matrixSession.userId); return isDefaultDisplayName(profile?.displayName || ''); } catch (error) { return false; } } /** * Sync mention directory for autocomplete and mention rendering. * @param {boolean} [force=false] - When true, bypass cache time * @returns {Promise} */ async function syncMentionDirectory(force = false) { if (!mxClient || !window.matrixSession?.roomId) return; const now = Date.now(); if (!force && chatMode.mentionDirectory.length > 0 && (now - chatMode.mentionLoadedAt) < 30000) { return; } try { const members = await mxClient.getRoomMembers(window.matrixSession.roomId); if (!members) return; const directory = []; for (const member of members) { chatMode.displayNames[member.userId] = member.displayName; directory.push({ userId: member.userId, displayName: member.displayName }); } chatMode.mentionDirectory = directory; chatMode.mentionLoadedAt = now; } catch (error) { console.warn('Failed to sync mention directory:', error); } } /** * Get mention autocomplete matches for query. * @param {string} query - Partial display name text without @ * @returns {Array<{userId: string, displayName: string}>} Sorted matches */ function getMentionMatches(query) { const queryLower = query.toLowerCase(); const matches = chatMode.mentionDirectory.filter((entry) => { const display = entry.displayName.toLowerCase(); const localPart = entry.userId.split(':')[0].substring(1).toLowerCase(); return display.startsWith(queryLower) || localPart.startsWith(queryLower); }); matches.sort((left, right) => left.displayName.localeCompare(right.displayName)); return matches; } /** * Escape text for HTML output. * @param {string} value - Raw text * @returns {string} HTML-escaped text */ function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Extract current mention token context from the input cursor. * @returns {{atIndex: number, cursorPosition: number, tokenText: string} | null} Token info or null */ function getMentionTokenContext() { const cursorPosition = inlineInput.selectionStart; const fullValue = inlineInput.value; const textBeforeCursor = fullValue.slice(0, cursorPosition); const tokenStart = textBeforeCursor.search(/(?:^|\s)@[^\s]*$/); if (tokenStart === -1) return null; const atIndex = textBeforeCursor.indexOf('@', tokenStart); if (atIndex === -1) return null; const tokenText = textBeforeCursor.slice(atIndex + 1); if (!tokenText || tokenText.includes(':')) return null; return { atIndex, cursorPosition, tokenText }; } /** * Render mention suggestions above the inline input. * @returns {void} */ function renderMentionSuggestions() { const { matches, index } = chatMode.mentionAutocomplete; if (!chatMode.active || matches.length === 0) { mentionSuggestions.style.display = 'none'; return; } const limitedMatches = matches.slice(0, 5); mentionSuggestions.innerHTML = limitedMatches.map((entry, entryIndex) => { const selectedClass = index >= 0 && entryIndex === (index % limitedMatches.length) ? ' selected' : ''; return `@${escapeHtml(entry.displayName)}`; }).join(''); mentionSuggestions.style.display = 'block'; positionInlineInput(); } /** * Check whether mention suggestions are currently visible. * @returns {boolean} True when suggestion strip is active */ export function hasVisibleMentionSuggestions() { return chatMode.active && mentionSuggestions.style.display !== 'none' && chatMode.mentionAutocomplete.matches.length > 0; } /** * Hide mention suggestion UI and clear state. * @returns {void} */ export function resetMentionAutocomplete() { chatMode.mentionAutocomplete = { tokenStart: -1, tokenEnd: -1, matches: [], index: 0 }; mentionSuggestions.style.display = 'none'; mentionSuggestions.innerHTML = ''; } /** * Refresh mention suggestions based on current cursor token. * @returns {Promise} */ export async function refreshMentionSuggestionsFromInput() { if (!chatMode.active) { resetMentionAutocomplete(); return; } const context = getMentionTokenContext(); if (!context) { resetMentionAutocomplete(); return; } await syncMentionDirectory(); const matches = getMentionMatches(context.tokenText); if (matches.length === 0) { resetMentionAutocomplete(); return; } chatMode.mentionAutocomplete = { tokenStart: context.atIndex, tokenEnd: context.cursorPosition, matches, index: -1 }; renderMentionSuggestions(); } /** * Replace selected mention token in input with selected display name. * @param {{userId: string, displayName: string}} selectedEntry - Selected mention entry * @returns {void} */ function applyMentionReplacement(selectedEntry) { const rangeStart = chatMode.mentionAutocomplete.tokenStart; const rangeEnd = chatMode.mentionAutocomplete.tokenEnd; if (rangeStart < 0 || rangeEnd < 0) return; const fullValue = inlineInput.value; const valueBeforeMention = fullValue.slice(0, rangeStart); const valueAfterMention = fullValue.slice(rangeEnd); const mentionText = `@${selectedEntry.displayName}`; const hasTrailingSpace = valueAfterMention.startsWith(' '); const nextValue = `${valueBeforeMention}${mentionText}${hasTrailingSpace ? '' : ' '}${valueAfterMention}`; const nextCursor = valueBeforeMention.length + mentionText.length + (hasTrailingSpace ? 0 : 1); inlineInput.value = nextValue; inlineInput.setSelectionRange(nextCursor, nextCursor); chatMode.inputLine = nextValue; chatMode.mentionAutocomplete.tokenEnd = nextCursor; } /** * Commit the currently selected mention suggestion into input text. * @returns {boolean} True when a suggestion was applied */ export function commitSelectedMentionSuggestion() { if (!hasVisibleMentionSuggestions()) return false; if (chatMode.mentionAutocomplete.index < 0) { chatMode.mentionAutocomplete.index = 0; } const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; applyMentionReplacement(selectedEntry); resetMentionAutocomplete(); return true; } /** * Resolve typed @displayname mentions to Matrix user IDs before sending. * @param {string} message - Outgoing message text * @returns {string} Message with mentions converted to @user:server */ function transformOutgoingMentions(message) { if (!message || !chatMode.mentionDirectory.length) return message; return message.replace(/(^|\s)@([^\s:]+)\b/g, (fullMatch, leadingWhitespace, mentionValue) => { const matchedEntry = chatMode.mentionDirectory.find((entry) => { return entry.displayName.toLowerCase() === mentionValue.toLowerCase(); }); if (!matchedEntry) return fullMatch; return `${leadingWhitespace}${matchedEntry.userId}`; }); } /** * Check whether canonical Matrix mentions exist in text. * @param {string} text - Message text * @returns {boolean} True when one or more @user:server mentions are present */ function hasCanonicalMentions(text) { return /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/.test(text); } /** * Build formatted HTML body with matrix.to links for mentions. * @param {string} resolvedBody - Message body with canonical mention IDs * @returns {string} HTML formatted body */ function buildFormattedMentionBody(resolvedBody) { const mentionRegex = /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g; return escapeHtml(resolvedBody).replace(mentionRegex, (matchedValue, userBody) => { const userId = `@${userBody}`; const knownDisplayName = chatMode.displayNames[userId]; const fallbackDisplayName = userId.split(':')[0].substring(1); const displayName = knownDisplayName || fallbackDisplayName; const matrixToUrl = `https://matrix.to/#/${userId}`; return `@${escapeHtml(displayName)}`; }); } /** * Build Matrix message content with mention metadata and formatted HTML. * @param {string} message - Outgoing raw message * @returns {Object} Matrix content payload */ function buildMentionMessageContent(message) { const resolvedBody = transformOutgoingMentions(message); const hasMentions = hasCanonicalMentions(resolvedBody); if (!hasMentions) { return { msgtype: 'm.text', body: resolvedBody }; } return { msgtype: 'm.text', body: resolvedBody, 'm.mentions': {}, format: 'org.matrix.custom.html', formatted_body: buildFormattedMentionBody(resolvedBody) }; } /** * Render Matrix user mentions as highlighted @displayname values. * @param {string} text - Chat message body * @returns {string} Formatted message text for terminal output */ function formatChatTextWithMentions(text) { if (!text) return ''; return text.replace(/@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g, (matchValue, userBody) => { const userId = `@${userBody}`; const knownDisplayName = chatMode.displayNames[userId]; const fallbackDisplayName = userId.split(':')[0].substring(1); const displayName = knownDisplayName || fallbackDisplayName; return `\x1b[1;96m@${displayName}\x1b[0m`; }); } /** * Cycle mention suggestions and apply selected display name. * @returns {Promise} */ export async function applyMentionAutocomplete() { if (!chatMode.active) return; if (chatMode.mentionAutocomplete.matches.length === 0) { await refreshMentionSuggestionsFromInput(); if (chatMode.mentionAutocomplete.matches.length === 0) return; chatMode.mentionAutocomplete.index = 0; } else { chatMode.mentionAutocomplete.index = (chatMode.mentionAutocomplete.index + 1) % chatMode.mentionAutocomplete.matches.length; } const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; applyMentionReplacement(selectedEntry); renderMentionSuggestions(); } /** * Enter chat mode * @returns {Promise} */ export async function enterChatMode() { if (!window.matrixSession || !window.matrixSession.roomId) { term.writeln('\r\n Error: Not connected to chat. Use "chat" command first.\r\n'); return; } chatMode.active = true; chatMode.messages = []; chatMode.lastSync = null; chatMode.inputLine = ''; chatMode.mentionAutocomplete = { tokenStart: -1, tokenEnd: -1, matches: [], index: 0 }; resetMentionAutocomplete(); await syncMentionDirectory(true); term.clear(); term.writeln('╔════════════════════════════════════════════════════════════╗'); term.writeln('║ CHAT - #generalchat ║'); term.writeln('║ Type /help for commands ║'); term.writeln('╚════════════════════════════════════════════════════════════╝'); // Fetch initial messages await syncChatMessages(); // Show nickname setup hint in message history when display name is still default const showNicknameHint = await shouldShowNicknameHint(); if (showNicknameHint) { const hintTime = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); term.writeln(`\x1b[90m[${hintTime}]\x1b[0m \x1b[93mSystem:\x1b[0m You are using a default name. Use /nick [name] to change it.`); } // Start sync loop startSyncLoop(); // Add separator and initial prompt term.writeln(''); term.writeln('─'.repeat(term.cols || 60)); term.write('\x1b[1;32m>\x1b[0m '); // Delay showing input to ensure terminal has rendered and cursor is positioned setTimeout(() => { showInlineInput(); }, 50); updateQuickCommands('chat'); } /** * Start Matrix sync loop */ function startSyncLoop() { if (!mxClient) return; chatMode.pollInterval = setInterval(async () => { try { const data = await mxClient.sync(syncToken, 10000); if (data) { syncToken = data.next_batch; mxClient.processSyncData(data); } } catch (error) { console.error('Sync error:', error); } }, 3000); } /** * Sync messages from Matrix (initial load) * @param {boolean} [onlyNew=false] - Only fetch new messages * @returns {Promise} */ async function syncChatMessages(onlyNew = false) { if (!mxClient || !window.matrixSession?.roomId) return; try { const result = await mxClient.getMessages(window.matrixSession.roomId, { limit: 50, dir: 'b' }); if (!result || !result.messages) return; const newMessages = []; for (const event of result.messages.reverse()) { if (event.type === 'm.room.message' && event.content?.msgtype === 'm.text') { const msgId = event.event_id; const exists = chatMode.messages.find(m => m.id === msgId); if (!exists) { const timestamp = new Date(event.origin_server_ts); const time = timestamp.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const displayName = await getDisplayName(event.sender); newMessages.push({ id: msgId, time: time, sender: displayName, userId: event.sender, text: event.content.body }); } } } if (newMessages.length > 0) { chatMode.messages.push(...newMessages); if (chatMode.messages.length > 100) { chatMode.messages = chatMode.messages.slice(-100); } if (!onlyNew && chatMode.active) { const recent = chatMode.messages.slice(-20); recent.forEach(msg => { const color = getUserColor(msg.sender); term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); }); } } chatMode.lastSync = Date.now(); } catch (error) { console.error('Sync error:', error); } } /** * Render a chat message (insert above the prompt area) * @param {Object} msg - Message object */ function renderChatMessage(msg) { const color = getUserColor(msg.sender); term.write('\x1b[1A\x1b[2K\r'); term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); if (msg.text.startsWith('/samsay ')) { const samMessage = msg.text.substring(8).trim(); if (samMessage && typeof window.samSpeak === 'function') { window.samSpeak(samMessage); } } term.writeln('─'.repeat(term.cols || 60)); term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); term.scrollToBottom(); setTimeout(() => positionInlineInput(), 10); } /** * Exit chat mode */ export function exitChatMode() { chatMode.active = false; if (chatMode.pollInterval) { clearInterval(chatMode.pollInterval); chatMode.pollInterval = null; } chatMode.displayNames = {}; chatMode.mentionDirectory = []; chatMode.mentionLoadedAt = 0; resetMentionAutocomplete(); term.clear(); term.writeln(' Exited chat mode.'); writePrompt(); showInlineInput(); updateQuickCommands('terminal'); } /** * Update quick command buttons based on context * @param {string} mode - 'terminal' or 'chat' */ export function updateQuickCommands(mode) { const container = document.querySelector('.quick-commands'); const statusLeft = document.querySelector('.status-left'); if (!container) return; if (mode === 'chat') { if (statusLeft) statusLeft.textContent = 'Chat Mode'; container.innerHTML = ` `; } else if (mode === 'game') { if (statusLeft) statusLeft.textContent = 'Number Match'; container.innerHTML = ''; } else { if (statusLeft) statusLeft.textContent = 'Ready'; container.innerHTML = ` `; } } /** * Run a game command from a button click * @param {string} command - The game command to submit * @returns {void} */ export function runGameCommand(command) { if (!inlineInput || !submitInlineInput) return; inlineInput.value = command; submitInlineInput(); } /** * Run a chat command from button click * @param {string} command - The chat command to run */ export function runChatCommand(command) { if (!chatMode.active) return; if (command === '/nick') { inlineInput.value = '/nick '; chatMode.inputLine = inlineInput.value; resetMentionAutocomplete(); inlineInput.focus(); return; } if (command === '/samsay') { inlineInput.value = '/samsay '; chatMode.inputLine = inlineInput.value; resetMentionAutocomplete(); inlineInput.focus(); return; } inlineInput.value = command; submitInlineInput(); } /** * Render chat input prompt (efficiently) */ export function renderChatPrompt() { term.write('\r\x1b[K'); term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); } /** * Send chat message * @param {string} message - Message to send * @returns {Promise} */ export async function sendChatMessage(message) { if (!message.trim()) return; if (!mxClient || !window.matrixSession?.roomId) return; try { await syncMentionDirectory(); const content = buildMentionMessageContent(message); const result = await mxClient.sendMessage( window.matrixSession.roomId, content.body, content.formatted_body || null ); if (!result) { throw new Error('Failed to send message'); } chatMode.inputLine = ''; resetMentionAutocomplete(); renderChatPrompt(); setTimeout(() => positionInlineInput(), 10); } catch (error) { term.write('\x1b[1A\x1b[2K\r'); term.writeln(`\x1b[31mError: ${error.message}\x1b[0m`); term.writeln('─'.repeat(term.cols || 60)); term.write(`\x1b[1;32m>\x1b[0m `); setTimeout(() => positionInlineInput(), 10); } }