diff --git a/website/boot.js b/website/boot.js index b305998..33986bf 100644 --- a/website/boot.js +++ b/website/boot.js @@ -22,12 +22,63 @@ const BIOS_LINES = [ { text: 'Starting LIT.RUV.WTF Terminal...', class: 'highlight', delay: 400 } ]; +const BOOT_STARTUP_SOUND_PATH = 'sounds/551405__nakkivene66__old-pc-startup-idle-shutdown.wav'; + +let bootStartupSoundPlayed = false; +let bootStartupSoundUnlockBound = false; + +/** + * Play the boot startup sound as early as possible. + * Falls back to first user interaction when autoplay is blocked. + * @returns {Promise} + */ +async function playBootStartupSound() { + if (bootStartupSoundPlayed) { + return; + } + + try { + const startupSound = new Audio(BOOT_STARTUP_SOUND_PATH); + startupSound.preload = 'auto'; + startupSound.volume = 0.55; + await startupSound.play(); + bootStartupSoundPlayed = true; + bootStartupSoundUnlockBound = false; + return; + } catch (error) { + if (bootStartupSoundUnlockBound) { + return; + } + + bootStartupSoundUnlockBound = true; + const unlockAndPlay = async () => { + if (bootStartupSoundPlayed) { + return; + } + + try { + const deferredSound = new Audio(BOOT_STARTUP_SOUND_PATH); + deferredSound.preload = 'auto'; + deferredSound.volume = 0.55; + await deferredSound.play(); + bootStartupSoundPlayed = true; + } catch (retryError) { + // Keep boot sequence running even if sound cannot play. + } + }; + + document.addEventListener('pointerdown', unlockAndPlay, { once: true }); + document.addEventListener('keydown', unlockAndPlay, { once: true }); + } +} + /** * Run the BIOS boot animation * @returns {Promise} Resolves when animation is complete */ function runBootAnimation() { return new Promise((resolve) => { + void playBootStartupSound(); const bootScreen = document.getElementById('bootScreen'); const biosText = document.getElementById('biosText'); const pageFade = document.getElementById('pageFade'); diff --git a/website/sounds/551405__nakkivene66__old-pc-startup-idle-shutdown.wav b/website/sounds/551405__nakkivene66__old-pc-startup-idle-shutdown.wav new file mode 100644 index 0000000..5ce0e8c Binary files /dev/null and b/website/sounds/551405__nakkivene66__old-pc-startup-idle-shutdown.wav differ diff --git a/website/sounds/floppyreadshort.wav b/website/sounds/floppyreadshort.wav new file mode 100644 index 0000000..991e698 Binary files /dev/null and b/website/sounds/floppyreadshort.wav differ diff --git a/website/sounds/poweroff.mp3 b/website/sounds/poweroff.mp3 new file mode 100644 index 0000000..d5275b3 Binary files /dev/null and b/website/sounds/poweroff.mp3 differ diff --git a/website/sounds/poweron.mp3 b/website/sounds/poweron.mp3 new file mode 100644 index 0000000..5433278 Binary files /dev/null and b/website/sounds/poweron.mp3 differ diff --git a/website/sounds/ui_hacking_charenter_01.wav b/website/sounds/ui_hacking_charenter_01.wav new file mode 100644 index 0000000..3e11d7a Binary files /dev/null and b/website/sounds/ui_hacking_charenter_01.wav differ diff --git a/website/sounds/ui_hacking_charenter_02.wav b/website/sounds/ui_hacking_charenter_02.wav new file mode 100644 index 0000000..8a2bf7f Binary files /dev/null and b/website/sounds/ui_hacking_charenter_02.wav differ diff --git a/website/sounds/ui_hacking_charenter_03.wav b/website/sounds/ui_hacking_charenter_03.wav new file mode 100644 index 0000000..cc3b9f4 Binary files /dev/null and b/website/sounds/ui_hacking_charenter_03.wav differ diff --git a/website/sounds/ui_hacking_charscroll.wav b/website/sounds/ui_hacking_charscroll.wav new file mode 100644 index 0000000..4e57d75 Binary files /dev/null and b/website/sounds/ui_hacking_charscroll.wav differ diff --git a/website/sounds/ui_hacking_charscroll_lp.wav b/website/sounds/ui_hacking_charscroll_lp.wav new file mode 100644 index 0000000..f1ad62d Binary files /dev/null and b/website/sounds/ui_hacking_charscroll_lp.wav differ diff --git a/website/sounds/ui_hacking_charsingle_01.wav b/website/sounds/ui_hacking_charsingle_01.wav new file mode 100644 index 0000000..defadef Binary files /dev/null and b/website/sounds/ui_hacking_charsingle_01.wav differ diff --git a/website/sounds/ui_hacking_charsingle_02.wav b/website/sounds/ui_hacking_charsingle_02.wav new file mode 100644 index 0000000..c2ff37f Binary files /dev/null and b/website/sounds/ui_hacking_charsingle_02.wav differ diff --git a/website/sounds/ui_hacking_charsingle_03.wav b/website/sounds/ui_hacking_charsingle_03.wav new file mode 100644 index 0000000..248958b Binary files /dev/null and b/website/sounds/ui_hacking_charsingle_03.wav differ diff --git a/website/sounds/ui_hacking_charsingle_04.wav b/website/sounds/ui_hacking_charsingle_04.wav new file mode 100644 index 0000000..d3ce789 Binary files /dev/null and b/website/sounds/ui_hacking_charsingle_04.wav differ diff --git a/website/sounds/ui_hacking_charsingle_05.wav b/website/sounds/ui_hacking_charsingle_05.wav new file mode 100644 index 0000000..6b72c63 Binary files /dev/null and b/website/sounds/ui_hacking_charsingle_05.wav differ diff --git a/website/sounds/ui_hacking_charsingle_06.wav b/website/sounds/ui_hacking_charsingle_06.wav new file mode 100644 index 0000000..917526f Binary files /dev/null and b/website/sounds/ui_hacking_charsingle_06.wav differ diff --git a/website/styles.css b/website/styles.css index 57e284b..bdcc308 100644 --- a/website/styles.css +++ b/website/styles.css @@ -214,6 +214,32 @@ body { color: rgba(0, 255, 0, 0.3); } +.mention-suggestions { + position: absolute; + z-index: 11; + display: flex; + gap: 6px; + max-width: 85%; + padding: 1px 4px; + border: 1px solid rgba(0, 255, 0, 0.45); + background: rgba(0, 24, 0, 0.92); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} + +.mention-suggestion { + color: #66ff66; + opacity: 0.9; +} + +.mention-suggestion.selected { + color: #001800; + background: #66ff66; + padding: 0 2px; +} + .status-bar { background: transparent; border-top: 2px solid #0f0; diff --git a/website/terminal.js b/website/terminal.js index c9e0ec4..6094395 100644 --- a/website/terminal.js +++ b/website/terminal.js @@ -25,6 +25,124 @@ 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 = { + 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 +}; + +/** + * 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) { + try { + const sound = new Audio(filePath); + sound.preload = 'auto'; + sound.volume = volume; + await sound.play(); + return true; + } catch (error) { + 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, @@ -220,7 +338,15 @@ let chatMode = { lastSync: null, pollInterval: null, inputLine: '', - displayNames: {} // Cache for display names + 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'; @@ -501,6 +627,334 @@ async function shouldShowNicknameHint() { } } +/** + * 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 Matrix message content with mention metadata and formatted HTML. + * @param {string} message - Outgoing raw message + * @returns {{msgtype: string, body: string, "m.mentions"?: {user_ids: string[]}, format?: string, formatted_body?: string}} Matrix content payload + */ +function buildMentionMessageContent(message) { + const resolvedBody = transformOutgoingMentions(message); + const mentionRegex = /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g; + const mentionUserIds = []; + let mentionMatch; + + while ((mentionMatch = mentionRegex.exec(resolvedBody)) !== null) { + const userId = `@${mentionMatch[1]}`; + if (!mentionUserIds.includes(userId)) { + mentionUserIds.push(userId); + } + } + + if (mentionUserIds.length === 0) { + return { + msgtype: 'm.text', + body: resolvedBody + }; + } + + const formattedBody = 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)}`; + }); + + return { + msgtype: 'm.text', + body: resolvedBody, + 'm.mentions': { + user_ids: mentionUserIds + }, + format: 'org.matrix.custom.html', + formatted_body: formattedBody + }; +} + +/** + * 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) { @@ -512,6 +966,14 @@ async function enterChatMode() { chatMode.messages = []; chatMode.lastSync = null; chatMode.inputLine = ''; + chatMode.mentionAutocomplete = { + tokenStart: -1, + tokenEnd: -1, + matches: [], + index: 0 + }; + resetMentionAutocomplete(); + await syncMentionDirectory(true); term.clear(); term.writeln('╔════════════════════════════════════════════════════════════╗'); @@ -558,6 +1020,9 @@ function exitChatMode() { chatMode.pollInterval = null; } chatMode.displayNames = {}; // Clear display name cache + chatMode.mentionDirectory = []; + chatMode.mentionLoadedAt = 0; + resetMentionAutocomplete(); term.clear(); term.writeln(' Exited chat mode.\r\n'); term.write(promptColored); @@ -602,6 +1067,8 @@ function runChatCommand(command) { if (command === '/nick') { // For /nick, just fill in the command prefix inlineInput.value = '/nick '; + chatMode.inputLine = inlineInput.value; + resetMentionAutocomplete(); inlineInput.focus(); return; } @@ -666,7 +1133,7 @@ async function syncChatMessages(onlyNew = false) { 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 ${msg.text}`); + term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); }); } } @@ -685,7 +1152,7 @@ function renderChatMessage(msg) { 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 ${msg.text}`); + term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); // Redraw separator term.writeln('─'.repeat(term.cols || 60)); @@ -712,18 +1179,18 @@ async function sendChatMessage(message) { if (!message.trim()) return; try { + await syncMentionDirectory(); + const content = buildMentionMessageContent(message); const txnId = Date.now(); await matrixApi( `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}`, 'PUT', - { - msgtype: 'm.text', - body: message - } + content ); // Clear the input chatMode.inputLine = ''; + resetMentionAutocomplete(); renderChatPrompt(); // Reposition input after render @@ -1101,6 +1568,13 @@ function positionInlineInput() { 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'; + } } /** @@ -1113,6 +1587,10 @@ function showInlineInput() { if (xtermEl && !xtermEl.contains(inlineInput)) { xtermEl.appendChild(inlineInput); } + + if (xtermEl && !xtermEl.contains(mentionSuggestions)) { + xtermEl.appendChild(mentionSuggestions); + } inlineInput.style.display = 'block'; positionInlineInput(); @@ -1125,6 +1603,7 @@ function showInlineInput() { function hideInlineInput() { inlineInput.style.display = 'none'; inlineInput.value = ''; + mentionSuggestions.style.display = 'none'; } /** @@ -1134,9 +1613,11 @@ 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(); @@ -1257,6 +1738,7 @@ async function init() { } await writeStartupChatMotd(); + await attemptStartupSoundPlayback(); term.write(promptColored); @@ -1266,10 +1748,23 @@ async function init() { }, 100); // Handle inline input events - inlineInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { + 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) { @@ -1291,6 +1786,13 @@ async function init() { } } }); + + inlineInput.addEventListener('input', async () => { + if (chatMode.active) { + chatMode.inputLine = inlineInput.value; + await refreshMentionSuggestionsFromInput(); + } + }); // Click on terminal focuses input document.getElementById('terminal').addEventListener('click', () => { @@ -1377,6 +1879,8 @@ function writeClickable(text, newline = true) { // Execute command async function executeCommand(input) { if (!input) return; + + void playSoundFile(TERMINAL_SOUND_FILES.commandLaunch, 0.28); // Add to history commandHistory.push(input);