// Import commands import helpCmd from './scripts/commands/help.js'; import aboutCmd from './scripts/commands/about.js'; import clearCmd from './scripts/commands/clear.js'; import echoCmd from './scripts/commands/echo.js'; import dateCmd from './scripts/commands/date.js'; import whoamiCmd from './scripts/commands/whoami.js'; import historyCmd from './scripts/commands/history.js'; import colorCmd from './scripts/commands/color.js'; import bannerCmd from './scripts/commands/banner.js'; import githubCmd from './scripts/commands/github.js'; import contactCmd from './scripts/commands/contact.js'; import privacyCmd from './scripts/commands/privacy.js'; import blueskyCmd from './scripts/commands/bluesky.js'; // Version const VERSION = '1.0.0'; // Create inline input element const inlineInput = document.createElement('input'); inlineInput.type = 'text'; inlineInput.className = 'terminal-inline-input'; inlineInput.autocomplete = 'off'; inlineInput.autocorrect = 'on'; inlineInput.autocapitalize = 'off'; inlineInput.spellcheck = false; const mentionSuggestions = document.createElement('div'); mentionSuggestions.className = 'mention-suggestions'; mentionSuggestions.style.display = 'none'; const TERMINAL_SOUND_FILES = window.location.hostname.endsWith('neocities.org') ? { startup: 'https://lit.ruv.wtf/sounds/poweron.mp3', commandLaunch: 'https://lit.ruv.wtf/sounds/floppyreadshort.wav', typing: [ 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_01.wav', 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_02.wav', 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_03.wav', 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_04.wav', 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_05.wav', 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_06.wav' ], enter: [ 'https://lit.ruv.wtf/sounds/ui_hacking_charenter_01.wav', 'https://lit.ruv.wtf/sounds/ui_hacking_charenter_02.wav', 'https://lit.ruv.wtf/sounds/ui_hacking_charenter_03.wav' ], scroll: 'https://lit.ruv.wtf/sounds/ui_hacking_charscroll.wav' } : { startup: 'sounds/poweron.mp3', commandLaunch: 'sounds/floppyreadshort.wav', typing: [ 'sounds/ui_hacking_charsingle_01.wav', 'sounds/ui_hacking_charsingle_02.wav', 'sounds/ui_hacking_charsingle_03.wav', 'sounds/ui_hacking_charsingle_04.wav', 'sounds/ui_hacking_charsingle_05.wav', 'sounds/ui_hacking_charsingle_06.wav' ], enter: [ 'sounds/ui_hacking_charenter_01.wav', 'sounds/ui_hacking_charenter_02.wav', 'sounds/ui_hacking_charenter_03.wav' ], scroll: 'sounds/ui_hacking_charscroll.wav' }; const terminalSoundState = { startupPlayed: false, startupUnlockBound: false, disabled: false }; /** * Select a random item from an array. * @param {string[]} values - Candidate values * @returns {string} Randomly selected value */ function pickRandomValue(values) { return values[Math.floor(Math.random() * values.length)]; } /** * Play a single sound file at the provided volume. * @param {string} filePath - Relative sound file path * @param {number} volume - Playback volume between 0 and 1 * @returns {Promise} True when playback starts */ async function playSoundFile(filePath, volume) { if (terminalSoundState.disabled) { return false; } try { const sound = new Audio(filePath); sound.preload = 'auto'; sound.volume = volume; await sound.play(); return true; } catch (error) { if (!error || error.name !== 'NotAllowedError') { terminalSoundState.disabled = true; } return false; } } /** * Attempt startup sound playback and defer until user interaction if blocked. * @returns {Promise} */ async function attemptStartupSoundPlayback() { if (terminalSoundState.startupPlayed) { return; } const didPlay = await playSoundFile(TERMINAL_SOUND_FILES.startup, 0.5); if (didPlay) { terminalSoundState.startupPlayed = true; terminalSoundState.startupUnlockBound = false; return; } if (terminalSoundState.startupUnlockBound) { return; } terminalSoundState.startupUnlockBound = true; const unlockAndPlay = async () => { if (terminalSoundState.startupPlayed) { return; } const unlocked = await playSoundFile(TERMINAL_SOUND_FILES.startup, 0.5); if (unlocked) { terminalSoundState.startupPlayed = true; } }; document.addEventListener('pointerdown', unlockAndPlay, { once: true }); document.addEventListener('keydown', unlockAndPlay, { once: true }); } /** * Play keypress sound effects for terminal typing/navigation. * @param {KeyboardEvent} event - Keyboard event * @returns {void} */ function playTerminalKeySound(event) { const key = event.key; if (event.ctrlKey || event.altKey || event.metaKey) { return; } if (key === 'ArrowUp' || key === 'ArrowDown') { void playSoundFile(TERMINAL_SOUND_FILES.scroll, 0.22); return; } if (key === 'Enter') { void playSoundFile(pickRandomValue(TERMINAL_SOUND_FILES.enter), 0.26); return; } const isTypingKey = key.length === 1 || key === 'Backspace' || key === 'Delete'; if (isTypingKey) { void playSoundFile(pickRandomValue(TERMINAL_SOUND_FILES.typing), 0.2); } } // Initialize xterm.js terminal const term = new Terminal({ cursorBlink: false, cursorStyle: 'underline', cursorInactiveStyle: 'none', fontFamily: '"Courier New", Courier, monospace', fontSize: 14, theme: { background: '#001800', foreground: '#00ff00', cursor: 'transparent', cursorAccent: 'transparent', selection: 'rgba(0, 255, 0, 0.3)', black: '#000000', red: '#ff0000', green: '#00ff00', yellow: '#ffff00', blue: '#0066ff', magenta: '#ff00ff', cyan: '#00ffff', white: '#ffffff', brightBlack: '#666666', brightRed: '#ff6666', brightGreen: '#66ff66', brightYellow: '#ffff66', brightBlue: '#6666ff', brightMagenta: '#ff66ff', brightCyan: '#66ffff', brightWhite: '#ffffff' }, allowTransparency: true, scrollback: 1000, disableStdin: true }); // Add addons const fitAddon = new FitAddon.FitAddon(); const webLinksAddon = new WebLinksAddon.WebLinksAddon(); term.loadAddon(fitAddon); term.loadAddon(webLinksAddon); // Open terminal term.open(document.getElementById('terminal')); fitAddon.fit(); // Register custom link provider for clickable commands term.registerLinkProvider({ provideLinks: (bufferLineNumber, callback) => { const line = term.buffer.active.getLine(bufferLineNumber - 1); if (!line) { callback(undefined); return; } const lineText = line.translateToString(); const links = []; // Find all command names that match our known commands const commandNames = ['help', 'about', 'clear', 'echo', 'date', 'whoami', 'history', 'color', 'banner', 'bluesky', 'chat', 'github', 'contact', 'privacy']; commandNames.forEach(cmd => { let startIndex = 0; while (true) { const index = lineText.indexOf(cmd, startIndex); if (index === -1) break; // Check if it's a standalone command word (surrounded by spaces, brackets, or at start/end) const charBefore = index > 0 ? lineText[index - 1] : ' '; const charAfter = index + cmd.length < lineText.length ? lineText[index + cmd.length] : ' '; const validBefore = /[\s\[\]]/.test(charBefore); const validAfter = /[\s\[\]\-]/.test(charAfter); if (validBefore && validAfter) { links.push({ range: { start: { x: index + 1, y: bufferLineNumber }, end: { x: index + cmd.length + 1, y: bufferLineNumber } }, text: cmd, activate: () => { runQuickCommand(cmd); } }); } startIndex = index + 1; } }); callback(links.length > 0 ? links : undefined); } }); // Adjust font size based on screen width function adjustFontSize() { const width = window.innerWidth; if (width < 350) { term.options.fontSize = 8; } else if (width < 480) { term.options.fontSize = 10; } else if (width < 768) { term.options.fontSize = 12; } else { term.options.fontSize = 14; } fitAddon.fit(); } adjustFontSize(); // Check viewport size for border display function checkViewportSize() { const width = window.innerWidth; const height = window.innerHeight; if (width < 1280 || height < 720) { document.body.classList.add('small-viewport'); } else { document.body.classList.remove('small-viewport'); } } checkViewportSize(); // Make terminal responsive window.addEventListener('resize', () => { adjustFontSize(); checkViewportSize(); }); // Mobile keyboard detection using Visual Viewport API let initialViewportHeight = window.innerHeight; /** * Detect if mobile keyboard is open based on viewport height reduction */ function checkKeyboardState() { if (window.visualViewport) { const viewportHeight = window.visualViewport.height; const heightReduction = initialViewportHeight - viewportHeight; // If viewport shrank by more than 150px, keyboard is likely open if (heightReduction > 150) { document.body.classList.add('keyboard-open'); } else { document.body.classList.remove('keyboard-open'); } } } if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { checkKeyboardState(); checkViewportSize(); adjustFontSize(); setTimeout(positionInlineInput, 50); }); } // Update initial height on orientation change window.addEventListener('orientationchange', () => { setTimeout(() => { initialViewportHeight = window.innerHeight; checkViewportSize(); }, 300); }); // Update system time function updateTime() { const now = new Date(); const timeStr = now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); document.getElementById('systemTime').textContent = timeStr; } updateTime(); setInterval(updateTime, 1000); // Command history let commandHistory = []; let historyIndex = -1; let currentLine = ''; let cursorPosition = 0; // Chat mode state let chatMode = { active: false, messages: [], lastSync: null, pollInterval: null, inputLine: '', displayNames: {}, // Cache for display names mentionDirectory: [], mentionLoadedAt: 0, mentionAutocomplete: { tokenStart: -1, tokenEnd: -1, matches: [], index: 0 } }; const MATRIX_PUBLIC_READ_TOKEN = 'syt_Z2VuZXJhbGNoYXQtcmVhZG9ubHk_sikLltUtfbHlztnanEVm_2icJ1o'; const IS_NEOCITIES_HOST = window.location.hostname.endsWith('neocities.org'); const MATRIX_BRIDGE_URL = 'https://lit.ruv.wtf/matrix-bridge.html'; /** * Matrix Bridge - Iframe-based communication for CSP-restricted hosts */ const matrixBridge = { iframe: null, ready: false, pendingRequests: new Map(), requestCounter: 0 }; /** * Initialize the Matrix iframe bridge * @returns {Promise} True if bridge loaded successfully */ async function initMatrixBridge() { if (matrixBridge.iframe) { return matrixBridge.ready; } return new Promise((resolve) => { const iframe = document.createElement('iframe'); iframe.src = MATRIX_BRIDGE_URL; iframe.style.display = 'none'; iframe.style.position = 'absolute'; iframe.style.width = '0'; iframe.style.height = '0'; iframe.style.border = 'none'; const timeout = setTimeout(() => { console.error('[Matrix Bridge] Failed to load iframe bridge'); matrixBridge.ready = false; resolve(false); }, 10000); window.addEventListener('message', (event) => { if (event.data && event.data.type === 'matrix:bridge:ready') { clearTimeout(timeout); matrixBridge.ready = true; console.log('[Matrix Bridge] Iframe bridge ready'); resolve(true); } }); document.body.appendChild(iframe); matrixBridge.iframe = iframe; }); } /** * Send a request to the Matrix bridge via postMessage * @param {string} type - Request type (e.g. 'matrix:auth', 'matrix:sendMessage') * @param {object} payload - Request payload * @returns {Promise} Response from bridge */ function matrixBridgeRequest(type, payload) { return new Promise((resolve, reject) => { if (!matrixBridge.iframe || !matrixBridge.ready) { reject(new Error('Matrix bridge not initialized')); return; } const requestId = `req_${++matrixBridge.requestCounter}`; const timeout = setTimeout(() => { matrixBridge.pendingRequests.delete(requestId); reject(new Error('Matrix bridge request timeout')); }, 30000); matrixBridge.pendingRequests.set(requestId, { resolve, reject, timeout }); matrixBridge.iframe.contentWindow.postMessage({ type, requestId, payload }, MATRIX_BRIDGE_URL); }); } /** * Handle responses from the Matrix bridge */ window.addEventListener('message', (event) => { if (event.origin !== new URL(MATRIX_BRIDGE_URL).origin) { return; } const { type, requestId, payload } = event.data; if (!type || !requestId || !type.includes(':response')) { return; } const pending = matrixBridge.pendingRequests.get(requestId); if (pending) { clearTimeout(pending.timeout); matrixBridge.pendingRequests.delete(requestId); pending.resolve(payload); } }); // Matrix API helper const matrixApi = async (endpoint, method = 'GET', body = null) => { if (!window.matrixSession) return null; // Use iframe bridge on Neocities if (IS_NEOCITIES_HOST) { if (!matrixBridge.ready) { const initialized = await initMatrixBridge(); if (!initialized) { return { errcode: 'M_BRIDGE_UNAVAILABLE', error: 'Matrix bridge failed to initialize' }; } } try { const result = await matrixBridgeRequest('matrix:api', { endpoint, method, body, accessToken: window.matrixSession.accessToken }); return result; } catch (error) { return { errcode: 'M_BRIDGE_ERROR', error: error.message }; } } // Direct API call for non-CSP-restricted hosts const homeserver = 'https://chat.ruv.wtf'; const url = `${homeserver}/_matrix/client/r0${endpoint}`; const headers = { 'Content-Type': 'application/json' }; if (window.matrixSession.accessToken) { headers['Authorization'] = `Bearer ${window.matrixSession.accessToken}`; } const options = { method, headers }; if (body) options.body = JSON.stringify(body); const response = await fetch(url, options); return response.json(); }; /** * Fetch the latest public chat message using the read-only public token. * @param {string} homeserver - Matrix homeserver URL * @param {string} roomAlias - Room alias (e.g. #generalchat:example.org) * @returns {Promise<{time: string, sender: string, text: string, timestamp: number} | null>} Last message summary or null */ async function fetchPublicLastMessage(homeserver, roomAlias) { // Use iframe bridge on Neocities if (IS_NEOCITIES_HOST) { if (!matrixBridge.ready) { await initMatrixBridge(); } if (!matrixBridge.ready) { return null; } try { // Resolve room alias first let roomId = null; try { const resolveResult = await matrixBridgeRequest('matrix:resolveAlias', { roomAlias }); if (resolveResult && !resolveResult.error && resolveResult.room_id) { roomId = resolveResult.room_id; } } catch (error) { // Continue with fallback } if (!roomId) { roomId = '!RkOwQGTlDJwZbNxGeS:b.ruv.wtf'; } // Fetch last message const result = await matrixBridgeRequest('matrix:fetchLastMessage', { roomId }); if (!result || result.error || !Array.isArray(result.chunk)) { return null; } const lastTextEvent = result.chunk.find((event) => { return event && event.type === 'm.room.message' && event.content && event.content.msgtype === 'm.text'; }); if (!lastTextEvent) { return null; } const timestamp = typeof lastTextEvent.origin_server_ts === 'number' ? new Date(lastTextEvent.origin_server_ts) : new Date(); const time = timestamp.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const sender = typeof lastTextEvent.sender === 'string' ? lastTextEvent.sender.split(':')[0].replace(/^@/, '') : 'unknown'; const text = lastTextEvent.content.body || ''; const timestampMs = typeof lastTextEvent.origin_server_ts === 'number' ? lastTextEvent.origin_server_ts : Date.now(); return { time, sender, text, timestamp: timestampMs }; } catch (error) { return null; } } // Direct API call for non-CSP-restricted hosts const resolvedAlias = encodeURIComponent(roomAlias); const authHeaders = { Authorization: `Bearer ${MATRIX_PUBLIC_READ_TOKEN}` }; let roomId = null; try { const roomResponse = await fetch(`${homeserver}/_matrix/client/r0/directory/room/${resolvedAlias}`, { headers: authHeaders }); if (roomResponse.ok) { const roomData = await roomResponse.json(); roomId = roomData && roomData.room_id ? roomData.room_id : null; } } catch (error) { roomId = null; } if (!roomId) { roomId = '!RkOwQGTlDJwZbNxGeS:b.ruv.wtf'; } const resolvedRoomId = encodeURIComponent(roomId); const messagesResponse = await fetch(`${homeserver}/_matrix/client/r0/rooms/${resolvedRoomId}/messages?dir=b&limit=30`, { headers: authHeaders }); if (!messagesResponse.ok) { return null; } const messagesData = await messagesResponse.json(); if (!messagesData || !Array.isArray(messagesData.chunk)) { return null; } const lastTextEvent = messagesData.chunk.find((event) => { return event && event.type === 'm.room.message' && event.content && event.content.msgtype === 'm.text'; }); if (!lastTextEvent) { return null; } const timestamp = typeof lastTextEvent.origin_server_ts === 'number' ? new Date(lastTextEvent.origin_server_ts) : new Date(); const time = timestamp.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const sender = typeof lastTextEvent.sender === 'string' ? lastTextEvent.sender.split(':')[0].replace(/^@/, '') : 'unknown'; const text = lastTextEvent.content.body || ''; const timestampMs = typeof lastTextEvent.origin_server_ts === 'number' ? lastTextEvent.origin_server_ts : Date.now(); return { time, sender, text, timestamp: timestampMs }; } /** * Fetch Matrix presence for a user using the read-only public token. * @param {string} homeserver - Matrix homeserver URL * @param {string} userId - Full Matrix user ID * @returns {Promise} Presence state (online/offline/unavailable) or null when unavailable */ async function fetchPublicPresence(homeserver, userId) { // Use iframe bridge on Neocities if (IS_NEOCITIES_HOST) { if (!matrixBridge.ready) { await initMatrixBridge(); } if (!matrixBridge.ready) { return null; } try { const result = await matrixBridgeRequest('matrix:fetchPresence', { userId }); if (result && !result.error && typeof result.presence === 'string') { return result.presence; } return null; } catch (error) { return null; } } // Direct API call for non-CSP-restricted hosts const authHeaders = { Authorization: `Bearer ${MATRIX_PUBLIC_READ_TOKEN}` }; const encodedUserId = encodeURIComponent(userId); const response = await fetch(`${homeserver}/_matrix/client/r0/presence/${encodedUserId}/status`, { headers: authHeaders }); if (!response.ok) { return null; } const data = await response.json(); return data && typeof data.presence === 'string' ? data.presence : null; } /** * Format a unix-millisecond timestamp as relative age text. * @param {number} timestampMs - Epoch timestamp in milliseconds * @returns {string} Relative time label */ 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`; } /** * Write a startup MOTD line with the latest public chat message. * Uses a short timeout so startup stays responsive. * @returns {Promise} */ async function writeStartupChatMotd() { try { const result = await Promise.race([ (async () => { const [latest, litruvPresence] = await Promise.all([ fetchPublicLastMessage('https://chat.ruv.wtf', '#generalchat:b.ruv.wtf'), fetchPublicPresence('https://chat.ruv.wtf', '@litruv:b.ruv.wtf') ]); return { latest, litruvPresence }; })(), new Promise((resolve) => setTimeout(() => resolve({ latest: null, litruvPresence: null }), 2500)) ]); if (!result.latest && !result.litruvPresence) { return; } const lastMessageAge = result.latest ? formatTimeAgo(result.latest.timestamp) : null; const onlineText = result.litruvPresence === 'online' ? 'online' : 'offline'; if (lastMessageAge) { term.writeln(` #generalchat · last message ${lastMessageAge} · @litruv:b.ruv.wtf ${onlineText}`); } else { term.writeln(` #generalchat · @litruv:b.ruv.wtf ${onlineText}`); } term.writeln(''); } catch (error) { // Ignore MOTD fetch issues to avoid blocking terminal startup. } } // Get display name for a user (with caching) async function getDisplayName(userId) { // Check cache first if (chatMode.displayNames[userId]) { return chatMode.displayNames[userId]; } try { const data = await matrixApi(`/profile/${encodeURIComponent(userId)}/displayname`, 'GET'); const displayName = data.displayname || userId.split(':')[0].substring(1); chatMode.displayNames[userId] = displayName; return displayName; } catch (error) { // Fallback to username part const fallback = userId.split(':')[0].substring(1); chatMode.displayNames[userId] = fallback; return fallback; } } // Get color for user based on their ID (consistent hashing) function getUserColor(username) { // Color palette that works well on dark green background 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 ]; // Simple hash function let hash = 0; for (let i = 0; i < username.length; i++) { hash = ((hash << 5) - hash) + username.charCodeAt(i); hash = hash & hash; // Convert to 32bit integer } return colors[Math.abs(hash) % colors.length]; } // Check if display name is already taken async function isDisplayNameTaken(newName) { try { const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); if (members && members.joined) { for (const [userId, member] of Object.entries(members.joined)) { // Skip our own user if (userId === window.matrixSession.userId) continue; const displayName = member.display_name || userId.split(':')[0].substring(1); if (displayName.toLowerCase() === newName.toLowerCase()) { return true; } } } return false; } catch (error) { console.error('Error checking display names:', error); return false; // Allow on error } } /** * 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 (!window.matrixSession || !window.matrixSession.userId) { return false; } try { const encodedUserId = encodeURIComponent(window.matrixSession.userId); const profileData = await matrixApi(`/profile/${encodedUserId}/displayname`, 'GET'); const displayName = profileData && typeof profileData.displayname === 'string' ? profileData.displayname : ''; return isDefaultDisplayName(displayName); } catch (error) { return false; } } /** * Sync mention directory for autocomplete and mention rendering. * @param {boolean} force - When true, bypass cache time * @returns {Promise} */ async function syncMentionDirectory(force = false) { if (!window.matrixSession || !window.matrixSession.roomId) { return; } const now = Date.now(); if (!force && chatMode.mentionDirectory.length > 0 && (now - chatMode.mentionLoadedAt) < 30000) { return; } try { const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); if (!members || !members.joined) { return; } const directory = []; Object.entries(members.joined).forEach(([userId, member]) => { const fallbackName = userId.split(':')[0].substring(1); const displayName = member && member.display_name ? member.display_name : fallbackName; chatMode.displayNames[userId] = displayName; directory.push({ userId, displayName }); }); chatMode.mentionDirectory = directory; chatMode.mentionLoadedAt = now; } catch (error) { // Ignore mention directory refresh failures. } } /** * 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 */ function hasVisibleMentionSuggestions() { return chatMode.active && mentionSuggestions.style.display !== 'none' && chatMode.mentionAutocomplete.matches.length > 0; } /** * Hide mention suggestion UI and clear state. * @returns {void} */ 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} */ 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 */ 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}`; }); } /** * Build a plain Matrix text payload with canonical mention IDs. * @param {string} message - Outgoing raw message * @returns {{msgtype: string, body: string}} Matrix content payload */ function buildPlainMentionMessageContent(message) { return { msgtype: 'm.text', body: transformOutgoingMentions(message) }; } /** * 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 rich mention payload without m.mentions for compatibility fallback. * @param {string} message - Outgoing raw message * @returns {{msgtype: string, body: string, format?: string, formatted_body?: string}} Matrix content payload */ function buildRichMentionFallbackContent(message) { const resolvedBody = transformOutgoingMentions(message); if (!hasCanonicalMentions(resolvedBody)) { return { msgtype: 'm.text', body: resolvedBody }; } return { msgtype: 'm.text', body: resolvedBody, format: 'org.matrix.custom.html', formatted_body: buildFormattedMentionBody(resolvedBody) }; } /** * Build Matrix message content with mention metadata and formatted HTML. * @param {string} message - Outgoing raw message * @returns {{msgtype: string, body: string, "m.mentions"?: object, format?: string, formatted_body?: string}} 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} */ 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 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 channel presence checker for litruv account const presenceTime = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const litruvPresence = await fetchPublicPresence('https://chat.ruv.wtf', '@litruv:b.ruv.wtf'); const litruvOnlineText = litruvPresence === 'online' ? 'online' : 'offline'; term.writeln(`\x1b[90m[${presenceTime}]\x1b[0m \x1b[93mSystem:\x1b[0m @litruv:b.ruv.wtf is ${litruvOnlineText}`); // 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 polling for new messages chatMode.pollInterval = setInterval(async () => { await syncChatMessages(true); }, 3000); // 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'); } // Exit chat mode function exitChatMode() { chatMode.active = false; if (chatMode.pollInterval) { clearInterval(chatMode.pollInterval); chatMode.pollInterval = null; } chatMode.displayNames = {}; // Clear display name cache 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' */ 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 (statusLeft) statusLeft.textContent = 'Ready'; container.innerHTML = ` `; } } /** * Run a chat command from button click * @param {string} command - The chat command to run */ function runChatCommand(command) { if (!chatMode.active) return; if (command === '/nick') { // For /nick, just fill in the command prefix inlineInput.value = '/nick '; chatMode.inputLine = inlineInput.value; resetMentionAutocomplete(); inlineInput.focus(); return; } // Execute the command inlineInput.value = command; submitInlineInput(); } window.runChatCommand = runChatCommand; // Sync messages from Matrix async function syncChatMessages(onlyNew = false) { try { let endpoint = `/rooms/${window.matrixSession.roomId}/messages?dir=b&limit=50`; const data = await matrixApi(endpoint); if (!data || !data.chunk) return; const newMessages = []; for (const event of data.chunk.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' }); // Get display name for sender 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); // Only keep last 100 messages in memory if (chatMode.messages.length > 100) { chatMode.messages = chatMode.messages.slice(-100); } // Render new messages if in chat mode and only updating if (onlyNew && chatMode.active) { newMessages.forEach(msg => { renderChatMessage(msg); }); } else if (!onlyNew && chatMode.active) { // Initial load - show last 20 messages (simple print, no cursor manipulation) 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) function renderChatMessage(msg) { const color = getUserColor(msg.sender); // Move up to separator line and clear it and the prompt line term.write('\x1b[1A\x1b[2K\r'); // Move up to separator, clear it // Write the new message term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); // Redraw separator term.writeln('─'.repeat(term.cols || 60)); // Redraw prompt with current input term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); // Scroll to bottom to show new message term.scrollToBottom(); // Reposition the inline input after terminal updates setTimeout(() => positionInlineInput(), 10); } // Render chat input prompt (efficiently) function renderChatPrompt() { // Move to beginning of line, redraw prompt and input term.write('\r\x1b[K'); // CR + clear rest of line term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); } // Send chat message async function sendChatMessage(message) { if (!message.trim()) return; try { await syncMentionDirectory(); const content = buildMentionMessageContent(message); const hasMentions = hasCanonicalMentions(content.body); const txnId = Date.now().toString(); let sendResult = await matrixApi( `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}`, 'PUT', content ); if (sendResult && sendResult.errcode && hasMentions) { const richFallbackContent = buildRichMentionFallbackContent(message); sendResult = await matrixApi( `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-richfallback`, 'PUT', richFallbackContent ); } if (sendResult && sendResult.errcode && !hasMentions) { const fallbackContent = buildPlainMentionMessageContent(message); sendResult = await matrixApi( `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-fallback`, 'PUT', fallbackContent ); } if (sendResult && sendResult.errcode) { throw new Error(sendResult.error || sendResult.errcode || 'Failed to send message'); } // Clear the input chatMode.inputLine = ''; resetMentionAutocomplete(); renderChatPrompt(); // Reposition input after render setTimeout(() => positionInlineInput(), 10); // Immediately sync to show our message setTimeout(() => syncChatMessages(true), 500); } catch (error) { // Show error above separator 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); } } // Available commands const commands = { help: { description: helpCmd.description, execute: (args) => helpCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, about: { description: aboutCmd.description, execute: (args) => aboutCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, clear: { description: clearCmd.description, execute: (args) => clearCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, echo: { description: echoCmd.description, execute: (args) => echoCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, date: { description: dateCmd.description, execute: (args) => dateCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, whoami: { description: whoamiCmd.description, execute: (args) => whoamiCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, history: { description: historyCmd.description, execute: (args) => historyCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, color: { description: colorCmd.description, execute: (args) => colorCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, banner: { description: bannerCmd.description, execute: (args) => bannerCmd.execute(term, writeClickable, VERSION, args, commandHistory, welcomeBannerFull, welcomeBannerCompact, welcomeBannerMinimal) }, github: { description: githubCmd.description, execute: (args) => githubCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, contact: { description: contactCmd.description, execute: (args) => contactCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, privacy: { description: privacyCmd.description, execute: (args) => privacyCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, bluesky: { description: blueskyCmd.description, execute: async (args) => await blueskyCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, chat: { description: 'Connect to chat room', execute: async (args) => { const homeserver = 'https://chat.ruv.wtf'; const roomAlias = '#generalchat:b.ruv.wtf'; // Generate UUID const generateUUID = () => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); }; // Check if user is providing credentials let username = null; let password = null; let subcommand = args[0]; let subcommandArgs = args.slice(1); // Fetch the latest message without logging in if (subcommand === 'last') { try { const latest = await fetchPublicLastMessage(homeserver, roomAlias); if (!latest) { return '\r\n Could not read latest message without login (room may not be public).\r\n'; } return `\r\n #generalchat latest:\r\n [${latest.time}] ${latest.sender}: ${latest.text}\r\n`; } catch (error) { return '\r\n Error: Failed to fetch latest message without login.\r\n'; } } // If first arg is not a known subcommand and second arg exists, treat as credentials if (args[0] && args[1] && args[0] !== 'send' && args[0] !== 'disconnect' && args[0] !== 'last' && isNaN(parseInt(args[0]))) { username = args[0]; password = args[1]; subcommand = args[2]; subcommandArgs = args.slice(3); } // Initialize Matrix session if (!window.matrixSession) { window.matrixSession = { accessToken: localStorage.getItem('matrix_access_token'), userId: localStorage.getItem('matrix_user_id'), deviceId: localStorage.getItem('matrix_device_id'), roomId: localStorage.getItem('matrix_room_id'), username: localStorage.getItem('matrix_username'), password: localStorage.getItem('matrix_password') }; } // Register/login user if not logged in if (!window.matrixSession.accessToken) { try { // Use provided credentials or generate new ones if (!username && window.matrixSession.username && window.matrixSession.password) { username = window.matrixSession.username; password = window.matrixSession.password; } else if (!username) { username = generateUUID(); password = generateUUID(); } term.writeln('\r\n Connecting to chat server...\r\n'); // Try to register - first attempt to get auth flows let regData = await matrixApi('/register', 'POST', { username: username, password: password }); // If we need to complete auth flow (dummy auth) if (regData.flows && !regData.access_token) { term.writeln(' Completing registration...\r\n'); regData = await matrixApi('/register', 'POST', { auth: { type: 'm.login.dummy', session: regData.session }, username: username, password: password }); } if (regData.access_token) { window.matrixSession.accessToken = regData.access_token; window.matrixSession.userId = regData.user_id; window.matrixSession.deviceId = regData.device_id; window.matrixSession.username = username; window.matrixSession.password = password; localStorage.setItem('matrix_access_token', regData.access_token); localStorage.setItem('matrix_user_id', regData.user_id); localStorage.setItem('matrix_device_id', regData.device_id); localStorage.setItem('matrix_username', username); localStorage.setItem('matrix_password', password); term.writeln(` Registered as: ${regData.user_id}\r\n`); } else if (regData.errcode === 'M_USER_IN_USE') { // Username exists, try to login term.writeln(' Username exists, logging in...\r\n'); const loginData = await matrixApi('/login', 'POST', { type: 'm.login.password', identifier: { type: 'm.id.user', user: username }, password: password }); if (loginData.access_token) { window.matrixSession.accessToken = loginData.access_token; window.matrixSession.userId = loginData.user_id; window.matrixSession.deviceId = loginData.device_id; window.matrixSession.username = username; window.matrixSession.password = password; localStorage.setItem('matrix_access_token', loginData.access_token); localStorage.setItem('matrix_user_id', loginData.user_id); localStorage.setItem('matrix_device_id', loginData.device_id); localStorage.setItem('matrix_username', username); localStorage.setItem('matrix_password', password); term.writeln(` Logged in as: ${loginData.user_id}\r\n`); } else { return ` Error: Failed to login - ${loginData.error || loginData.errcode || 'Unknown error'}`; } } else { return ` Error: Failed to register account - ${regData.error || regData.errcode || 'Unknown error'}`; } } catch (error) { console.error('Chat error:', error); return ` Error: Could not connect to chat server - ${error.message}`; } } // Join room if not already joined if (!window.matrixSession.roomId) { try { term.writeln(' Joining #generalchat...\r\n'); // Try joining directly with the room alias const joinData = await matrixApi(`/join/${encodeURIComponent(roomAlias)}`, 'POST', {}); if (joinData && joinData.room_id) { window.matrixSession.roomId = joinData.room_id; localStorage.setItem('matrix_room_id', joinData.room_id); } else if (joinData && joinData.errcode) { // If direct join fails, try resolving alias first then joining by room ID term.writeln(' Trying alternate join method...\r\n'); const resolveData = await matrixApi(`/directory/room/${encodeURIComponent(roomAlias)}`, 'GET'); if (!resolveData || !resolveData.room_id) { return ` Error: Could not find room - ${resolveData?.error || resolveData?.errcode || 'Room not found'}`; } const roomId = resolveData.room_id; // Try POST to /join/{roomId} const joinData2 = await matrixApi(`/join/${encodeURIComponent(roomId)}`, 'POST', {}); if (joinData2 && joinData2.room_id) { window.matrixSession.roomId = joinData2.room_id; localStorage.setItem('matrix_room_id', joinData2.room_id); } else if (joinData2 && joinData2.errcode) { return ` Error: Failed to join room - ${joinData2.error || joinData2.errcode}`; } else { window.matrixSession.roomId = roomId; localStorage.setItem('matrix_room_id', roomId); } } else { // Empty response might still be success - try to get room ID from alias const resolveData = await matrixApi(`/directory/room/${encodeURIComponent(roomAlias)}`, 'GET'); if (resolveData && resolveData.room_id) { window.matrixSession.roomId = resolveData.room_id; localStorage.setItem('matrix_room_id', resolveData.room_id); } else { return ' Error: Failed to join room - Could not determine room ID'; } } } catch (error) { console.error('Room join error:', error); return ` Error: Could not join chat room - ${error.message}`; } } // Handle subcommands if (subcommand === 'disconnect') { // Exit chat mode if active if (chatMode.active) { if (chatMode.pollInterval) { clearInterval(chatMode.pollInterval); } chatMode.active = false; chatMode.displayNames = {}; // Clear display name cache term.clear(); } localStorage.removeItem('matrix_access_token'); localStorage.removeItem('matrix_user_id'); localStorage.removeItem('matrix_device_id'); localStorage.removeItem('matrix_room_id'); localStorage.removeItem('matrix_username'); localStorage.removeItem('matrix_password'); window.matrixSession = null; return '\r\n Disconnected from chat\r\n'; } else { // Enter interactive chat mode (default) await enterChatMode(); return null; } } } }; // Welcome banner - full size - NFO style const welcomeBannerFull = [ '', ' ........ ', ' ...++++++++... ......... ', ' ...++++++++++++.................... ...+++++++.... ', ' ...++++++----++++...+++++++++++++++....+++++++++++... ', ' ..+++++######--++++++++++++++++++++++++++++-----+++.. ', ' ..+++++#######-++++++++++++++++++++++++++++######++.. ', ' ..-++++#######++++++++++++++++++++++++++++++#####+... ', ' ..-+++++##+++++++++++++++++++++++++++++++++++##+... ', ' ...+++++++++++++++++++++++++++++++++++++++++++... ', ' ...++++++++++++++++----------+++++++-----++++.. ', ' ...+++++++++++++++---.....----+####+---..---++-.. ', ' ...+++++++++++++++++++.......-#########....+++++... ', ' ...++++++++++++++++++++++++++############+----++++.. ', ' ..++++++++++++++++++++++++++###############----+++.. ', ' ..++++++++++++++++++++++++++########........----+++-.. ', ' ..+++++++++++++++++++++++++########..........#------.. ', ' ..+++++++++++++++++++++++++##########........##------.. ', ' ..++++++-++++++++++++++++++############....-###------.. ', ' ..++++++--+++++++++++++++++#############+.#####---.--.. ', ' ..+++++++--+++++++++++++++++#########.......##----.... ', ' ..++++++++--+-+++++++++++++++#####+##########----..... ', ' ..+.+++++++----++--++++++++++++############-----... ', ' .....+++++++-----+----+++++++++----------------.. ', ' .....++++++++---------------------------------.. ', ' ..-+++++++++------------------------------.. ', ' ..+++++++++++---------------------------.. ', ' ..++++++++++++------------------------.. ', ' ..+++++++++--------------------------.. ', ' ...+++.-----------------------------.. ', ' ....-...---------------------------.. ', ' .......---------------------------.. ', ' . ....----.-------------------.. ', ' .....-.....----------------.. ', ' ..... .....-------------.. ', ' LIT.RUV.WTF TERMINAL v' + VERSION + ' ', '', ' Type "help" for available commands.', ' Use ↑/↓ arrows to navigate command history.', '' ].join('\r\n'); // Welcome banner - compact (40 chars wide) const welcomeBannerCompact = [ '', ' ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄', ' ▄█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█▄', ' ██░ LIT.RUV.WTF TERMINAL ░██', ' ██░ ═══════════════════════ ░██', ' ██░ v' + VERSION + ' · Litruv ░██', ' ▀█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█▀', ' ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀', '', ' Welcome! Type "help" for commands.', '' ].join('\r\n'); // Welcome banner - minimal (25 chars wide) const welcomeBannerMinimal = [ '', ' ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄', ' █░ LIT.RUV.WTF ░█', ' █░ TERMINAL v' + VERSION + ' ░█', ' ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀', '', ' Type "help"', '' ].join('\r\n'); // Get appropriate banner based on terminal width function getWelcomeBanner() { const cols = term.cols; const width = window.innerWidth; // Use minimal mode for very narrow screens if (width < 350 || cols < 30) { return welcomeBannerMinimal; } else if (cols >= 78) { return welcomeBannerFull; } else if (cols >= 40) { return welcomeBannerCompact; } else { return welcomeBannerMinimal; } } // Prompt (plain version for display, we handle colors separately) const promptText = 'user@lit.ruv.wtf $ '; const promptColored = '\x1b[1;32muser@lit.ruv.wtf\x1b[0m $ '; /** * Write the shell prompt at the current cursor position. * @returns {void} */ function writePrompt() { term.write(promptColored); } /** * Position the inline input at the cursor location */ function positionInlineInput() { const terminalEl = document.getElementById('terminal'); const xtermEl = terminalEl.querySelector('.xterm-screen'); if (!xtermEl) return; // Get terminal dimensions const charWidth = term._core._renderService.dimensions.css.cell.width; const charHeight = term._core._renderService.dimensions.css.cell.height; // Get cursor position const cursorY = term.buffer.active.cursorY; const cursorX = term.buffer.active.cursorX; // Position input at cursor (xterm-screen handles positioning) inlineInput.style.left = (cursorX * charWidth) + 'px'; inlineInput.style.top = (cursorY * charHeight) + 'px'; inlineInput.style.height = charHeight + 'px'; inlineInput.style.fontSize = term.options.fontSize + 'px'; inlineInput.style.lineHeight = charHeight + 'px'; if (mentionSuggestions.style.display !== 'none') { mentionSuggestions.style.left = inlineInput.style.left; mentionSuggestions.style.top = ((cursorY * charHeight) - charHeight - 6) + 'px'; mentionSuggestions.style.fontSize = term.options.fontSize + 'px'; mentionSuggestions.style.lineHeight = charHeight + 'px'; } } /** * Show the inline input and position it */ function showInlineInput() { const terminalEl = document.getElementById('terminal'); const xtermEl = terminalEl.querySelector('.xterm-screen'); if (xtermEl && !xtermEl.contains(inlineInput)) { xtermEl.appendChild(inlineInput); } if (xtermEl && !xtermEl.contains(mentionSuggestions)) { xtermEl.appendChild(mentionSuggestions); } inlineInput.style.display = 'block'; positionInlineInput(); inlineInput.focus(); } /** * Hide the inline input */ function hideInlineInput() { inlineInput.style.display = 'none'; inlineInput.value = ''; mentionSuggestions.style.display = 'none'; } /** * Submit current input */ async function submitInlineInput() { const cmd = inlineInput.value.trim(); const rawValue = inlineInput.value; inlineInput.value = ''; resetMentionAutocomplete(); // Handle chat mode if (chatMode.active) { chatMode.inputLine = rawValue; if (cmd === '/quit' || cmd === '/exit') { hideInlineInput(); exitChatMode(); return; } if (cmd === '/help') { // Move cursor up to separator, clear it and the prompt term.write('\x1b[1A\x1b[2K\r'); term.writeln('\x1b[33mChat Commands:\x1b[0m'); term.writeln(' /help - Show this help message'); term.writeln(' /nick [name] - Change your display name'); term.writeln(' /quit - Exit chat mode'); term.writeln('─'.repeat(term.cols || 60)); term.write('\x1b[1;32m>\x1b[0m '); setTimeout(() => positionInlineInput(), 10); return; } if (cmd.startsWith('/nick ')) { const newNick = cmd.substring(6).trim(); // Move cursor up to separator, clear it term.write('\x1b[1A\x1b[2K\r'); if (!newNick) { term.writeln('\x1b[31mError: /nick [name]\x1b[0m'); } else { try { const isTaken = await isDisplayNameTaken(newNick); if (isTaken) { term.writeln('\x1b[31mError: Nickname already in use\x1b[0m'); } else { const encodedUserId = encodeURIComponent(window.matrixSession.userId); const putResponse = await matrixApi(`/profile/${encodedUserId}/displayname`, 'PUT', { displayname: newNick }); if (putResponse && putResponse.errcode) { term.writeln(`\x1b[31mError: ${putResponse.error || 'Nickname rejected by server'}\x1b[0m`); } else { const verifyData = await matrixApi(`/profile/${encodedUserId}/displayname`, 'GET'); const actualName = verifyData && verifyData.displayname; if (actualName === newNick) { chatMode.displayNames[window.matrixSession.userId] = newNick; term.writeln(`\x1b[32mNickname changed to: ${newNick}\x1b[0m`); } else { term.writeln(`\x1b[31mError: Nickname rejected by server\x1b[0m`); } } } } catch (error) { term.writeln(`\x1b[31mError: Failed to change nickname\x1b[0m`); } } term.writeln('─'.repeat(term.cols || 60)); term.write('\x1b[1;32m>\x1b[0m '); setTimeout(() => positionInlineInput(), 10); return; } if (cmd && !cmd.startsWith('/')) { // Don't write the message here - let sync handle it await sendChatMessage(cmd); return; } else if (cmd.startsWith('/')) { // Move cursor up to separator, clear it term.write('\x1b[1A\x1b[2K\r'); term.writeln(`\x1b[31mUnknown command: ${cmd.split(' ')[0]}. Type /help\x1b[0m`); term.writeln('─'.repeat(term.cols || 60)); term.write('\x1b[1;32m>\x1b[0m '); setTimeout(() => positionInlineInput(), 10); } else { // Empty message, just reposition the prompt setTimeout(() => positionInlineInput(), 10); } return; } // Normal command mode term.write(rawValue + '\r\n'); if (cmd) { await executeCommand(cmd); } // Show prompt if not in chat mode if (!chatMode.active) { writePrompt(); // Delay to ensure terminal has rendered before positioning setTimeout(() => { positionInlineInput(); }, 10); } term.scrollToBottom(); } // Initialize terminal async function init() { // Display welcome banner with clickable commands const cols = term.cols; if (cols >= 78) { term.writeln(welcomeBannerFull.split('\r\n').slice(0, -3).join('\r\n')); writeClickable(' Type [command=help] for available commands.'); term.writeln(' Use ↑/↓ arrows to navigate command history.'); term.writeln(''); } else if (cols >= 40) { term.writeln(welcomeBannerCompact.split('\r\n').slice(0, -2).join('\r\n')); writeClickable(' Welcome! Type [command=help] for commands.'); term.writeln(''); } else { term.writeln(welcomeBannerMinimal.split('\r\n').slice(0, -2).join('\r\n')); writeClickable(' Type [command=help]'); term.writeln(''); } await writeStartupChatMotd(); await attemptStartupSoundPlayback(); writePrompt(); // Add inline input after a short delay to ensure terminal is rendered setTimeout(() => { showInlineInput(); }, 100); // Handle inline input events inlineInput.addEventListener('keydown', async (e) => { playTerminalKeySound(e); if (e.key === 'Escape' && hasVisibleMentionSuggestions()) { e.preventDefault(); resetMentionAutocomplete(); } else if (e.key === ' ' && hasVisibleMentionSuggestions()) { e.preventDefault(); commitSelectedMentionSuggestion(); } else if (e.key === 'Enter' && hasVisibleMentionSuggestions()) { e.preventDefault(); commitSelectedMentionSuggestion(); } else if (e.key === 'Enter') { e.preventDefault(); submitInlineInput(); } else if (e.key === 'Tab' && chatMode.active) { e.preventDefault(); await applyMentionAutocomplete(); } else if (e.key === 'ArrowUp') { e.preventDefault(); if (commandHistory.length > 0) { if (historyIndex === -1 || historyIndex >= commandHistory.length) { historyIndex = commandHistory.length - 1; } else if (historyIndex > 0) { historyIndex--; } inlineInput.value = commandHistory[historyIndex] || ''; } } else if (e.key === 'ArrowDown') { e.preventDefault(); if (historyIndex < commandHistory.length - 1) { historyIndex++; inlineInput.value = commandHistory[historyIndex] || ''; } else { historyIndex = commandHistory.length; inlineInput.value = ''; } } }); inlineInput.addEventListener('input', async () => { if (chatMode.active) { chatMode.inputLine = inlineInput.value; await refreshMentionSuggestionsFromInput(); } }); // Click on terminal focuses input document.getElementById('terminal').addEventListener('click', () => { if (inlineInput.style.display !== 'none') { inlineInput.focus(); } }); // Reposition on resize window.addEventListener('resize', () => { setTimeout(positionInlineInput, 50); }); // Handle scroll to keep input positioned term.onScroll(() => { positionInlineInput(); }); } /** * Run a quick command from a button click * @param {string} command - The command to execute */ async function runQuickCommand(command) { if (!command) return; // Write command to terminal term.writeln(`\x1b[1;32m${promptText}\x1b[0m ${command}`); // Execute the command await executeCommand(command.trim()); // Show prompt if not in chat mode if (!chatMode.active) { writePrompt(); // Delay to ensure terminal has rendered before positioning setTimeout(() => { positionInlineInput(); }, 10); } term.scrollToBottom(); } // Make function available globally for onclick handlers window.runQuickCommand = runQuickCommand; /** * Write text to terminal with clickable commands * Parses [command=X] syntax to create clickable command links * The link provider makes these clickable automatically * @param {string} text - Text to write (can include [command=X] syntax) * @param {boolean} newline - Whether to add newline after text */ function writeClickable(text, newline = true) { // Parse for [command=X] syntax const regex = /\[command=([^\]]+)\]/g; let lastIndex = 0; let match; while ((match = regex.exec(text)) !== null) { // Write text before the match if (match.index > lastIndex) { term.write(text.substring(lastIndex, match.index)); } // Write command with underline styling (link provider makes it clickable) const command = match[1]; term.write(`\x1b[4;32m${command}\x1b[0m`); lastIndex = regex.lastIndex; } // Write remaining text if (lastIndex < text.length) { term.write(text.substring(lastIndex)); } if (newline) { term.write('\r\n'); } } // Execute command async function executeCommand(input) { if (!input) return; void playSoundFile(TERMINAL_SOUND_FILES.commandLaunch, 0.28); // Add to history commandHistory.push(input); historyIndex = commandHistory.length; // Parse command const parts = input.split(/\s+/); const cmd = parts[0].toLowerCase(); const args = parts.slice(1); // Execute command if (commands[cmd]) { const output = await commands[cmd].execute(args); if (output !== null && output !== undefined) { term.writeln(output); } } else { term.writeln(`\r\n Command not found: ${cmd}`); writeClickable(' Type [command=help] for available commands.\r\n'); } } // Initialize when loaded - wait for boot animation first if (window.runBootAnimation) { runBootAnimation().then(init); } else { init(); }