// 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'; import numbermatchCmd, { gameMode, processGameInput, handleTileClick } from './scripts/commands/numbermatch.js'; // Import Matrix client import { initMatrixClient, chatMode, matrixApi, fetchPublicLastMessage, fetchPublicPresence, formatTimeAgo, isDisplayNameTaken, hasVisibleMentionSuggestions, resetMentionAutocomplete, refreshMentionSuggestionsFromInput, commitSelectedMentionSuggestion, applyMentionAutocomplete, enterChatMode, exitChatMode, updateQuickCommands, runChatCommand, renderChatPrompt, sendChatMessage } from './matrix-client.js'; // Version const VERSION = '1.0.0'; // Matrix configuration const IS_NEOCITIES_HOST = window.location.hostname.endsWith('neocities.org'); const MATRIX_CONFIG = { homeserver: 'https://chat.ruv.wtf', bridgeUrl: 'https://lit.ruv.wtf/matrix-bridge.html', useBridge: IS_NEOCITIES_HOST, publicReadToken: 'syt_Z2VuZXJhbGNoYXQtcmVhZG9ubHk_sikLltUtfbHlztnanEVm_2icJ1o' }; // 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: 20, 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', 'numbermatch']; // When in game mode, detect clickable tile numbers on board lines if (gameMode.active && lineText.includes('│')) { // Board line format: " A │ 5 3 7 ...│" // Detect row letter at start const rowMatch = lineText.match(/^\s*([A-Z])\s*│/); if (rowMatch) { const rowLetter = rowMatch[1]; // Find all digits within the board area (between │ characters) const boardStart = lineText.indexOf('│') + 1; const boardEnd = lineText.lastIndexOf('│'); if (boardStart > 0 && boardEnd > boardStart) { let col = 0; for (let i = boardStart; i < boardEnd; i++) { const char = lineText[i]; // Each cell is 3 chars wide: " X " if ((i - boardStart) % 3 === 1 && /[1-9]/.test(char)) { col = Math.floor((i - boardStart) / 3) + 1; const coord = `${rowLetter}${col}`; links.push({ range: { start: { x: i + 1, y: bufferLineNumber }, end: { x: i + 2, y: bufferLineNumber } }, text: char, activate: () => { handleTileClick(coord); } }); } } } } } 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 = 11; } else if (width < 480) { term.options.fontSize = 14; } else if (width < 768) { term.options.fontSize = 17; } else { term.options.fontSize = 20; } 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; // Matrix client - imported from matrix-client.js // 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) }, numbermatch: { description: numbermatchCmd.description, execute: (args) => numbermatchCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, chat: { description: 'Connect to chat room', execute: async (args) => { const homeserver = 'https://chat.ruv.wtf'; const roomAlias = '#publicchat: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; } // Handle game mode if (gameMode.active) { term.write(rawValue + '\r\n'); const continueGame = processGameInput(term, cmd); if (!continueGame) { writePrompt(); } setTimeout(() => positionInlineInput(), 10); return; } // Normal command mode term.write(rawValue + '\r\n'); if (cmd) { await executeCommand(cmd); } // Show prompt if not in chat mode or game mode // Game mode handles its own prompt in renderBoard if (!chatMode.active && !gameMode.active) { writePrompt(); // Delay to ensure terminal has rendered before positioning setTimeout(() => { positionInlineInput(); }, 10); } else if (gameMode.active) { setTimeout(() => positionInlineInput(), 10); } term.scrollToBottom(); } /** * Write startup chat MOTD with last message and presence */ async function writeStartupChatMotd() { try { const result = await Promise.race([ (async () => { const [latest, presence] = await Promise.all([ fetchPublicLastMessage('#publicchat:ruv.wtf'), fetchPublicPresence('@litruv:b.ruv.wtf') ]); return { latest, presence }; })(), new Promise((resolve) => setTimeout(() => resolve({ latest: null, presence: null }), 2500)) ]); if (!result.latest && !result.presence) { return; } if (result.latest) { const sender = result.latest.sender.split(':')[0].substring(1); const age = formatTimeAgo(result.latest.timestamp); term.write(` \x1b[36m${sender}\x1b[0m: ${result.latest.body.substring(0, 50)}${result.latest.body.length > 50 ? '...' : ''} \x1b[90m(${age})\x1b[0m\r\n`); } if (result.presence) { const statusColor = result.presence.presence === 'online' ? '\x1b[32m' : '\x1b[90m'; const lastActive = result.presence.lastActive > 0 ? formatTimeAgo(Date.now() - result.presence.lastActive) : null; term.write(` \x1b[36mlitruv\x1b[0m ${statusColor}${result.presence.presence}\x1b[0m`); if (result.presence.presence !== 'online' && lastActive) { term.write(` \x1b[90m(${lastActive})\x1b[0m`); } term.writeln(''); } term.write(' '); writeClickable('[command=chat]', 'Run \x1b[32mchat\x1b[0m to join in on the conversation!'); term.writeln('\r\n'); } catch (error) { // Silently fail - not critical for startup } } // Initialize terminal async function init() { // Initialize Matrix client with config and dependencies initMatrixClient(MATRIX_CONFIG, { term, inlineInput, mentionSuggestions, writeClickable, writePrompt, showInlineInput, positionInlineInput, submitInlineInput, welcomeBannerFull, welcomeBannerCompact, welcomeBannerMinimal }); // Expose chat command for onclick handlers window.runChatCommand = runChatCommand; // 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; // Expose sound functions for game modules window.terminalSounds = { files: TERMINAL_SOUND_FILES, play: playSoundFile, pickRandom: pickRandomValue }; /** * 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(); }