Files
lit.ruv.wtf/website/terminal.js

1253 lines
47 KiB
JavaScript

// 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<boolean>} 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<void>}
*/
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();
}