diff --git a/website/matrix-client.js b/website/matrix-client.js index bc9ef84..8684c53 100644 --- a/website/matrix-client.js +++ b/website/matrix-client.js @@ -929,6 +929,7 @@ export function updateQuickCommands(mode) { container.innerHTML = ` + `; } else { @@ -958,6 +959,15 @@ export function runChatCommand(command) { return; } + if (command === '/samsay') { + // For /samsay, just fill in the command prefix + inlineInput.value = '/samsay '; + chatMode.inputLine = inlineInput.value; + resetMentionAutocomplete(); + inlineInput.focus(); + return; + } + // Execute the command inlineInput.value = command; submitInlineInput(); @@ -1017,6 +1027,14 @@ async function syncChatMessages(onlyNew = false) { recent.forEach(msg => { const color = getUserColor(msg.sender); term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); + + // If message starts with /samsay, speak it + if (msg.text.startsWith('/samsay ')) { + const samMessage = msg.text.substring(8).trim(); + if (samMessage && typeof window.samSpeak === 'function') { + window.samSpeak(samMessage); + } + } }); } } @@ -1037,6 +1055,14 @@ function renderChatMessage(msg) { // Write the new message term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); + // If message starts with /samsay, speak it + if (msg.text.startsWith('/samsay ')) { + const samMessage = msg.text.substring(8).trim(); + if (samMessage && typeof window.samSpeak === 'function') { + window.samSpeak(samMessage); + } + } + // Redraw separator term.writeln('─'.repeat(term.cols || 60)); diff --git a/website/scripts/commands/samsay.js b/website/scripts/commands/samsay.js new file mode 100644 index 0000000..c587a6c --- /dev/null +++ b/website/scripts/commands/samsay.js @@ -0,0 +1,56 @@ +/** + * SAM Say command - Use SAM (Software Automatic Mouth) to speak text + */ + +/** + * SAM (Software Automatic Mouth) speech synthesizer instance + * @type {object|null} + */ +let sam = null; + +/** + * Initialize SAM speech synthesizer with SAM voice + * @returns {object|null} SAM instance or null if unavailable + */ +function initSam() { + if (sam) return sam; + if (typeof SamJs !== 'undefined') { + // SAM preset: speed=72, pitch=64, mouth=128, throat=128 + sam = new SamJs({ speed: 72, pitch: 64, mouth: 128, throat: 128 }); + } + return sam; +} + +/** + * Speak text using SAM + * @param {string} text - Text to speak + * @returns {void} + */ +function samSpeak(text) { + const samInstance = initSam(); + if (samInstance) { + try { + samInstance.speak(text); + } catch (_err) { + // Silently fail if speech doesn't work + } + } +} + +export default { + description: 'Use SAM speech synthesizer to speak text', + execute: (term, writeClickable, VERSION, args) => { + const message = args.join(' '); + + if (!message) { + return 'Usage: samsay '; + } + + if (typeof SamJs === 'undefined') { + return 'SAM speech synthesizer is not available.'; + } + + samSpeak(message); + return `SAM says: ${message}`; + } +}; diff --git a/website/terminal.js b/website/terminal.js index 3116028..bd2924b 100644 --- a/website/terminal.js +++ b/website/terminal.js @@ -13,6 +13,7 @@ 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 samsayCmd from './scripts/commands/samsay.js'; // Import Matrix client import { @@ -36,6 +37,44 @@ import { sendChatMessage } from './matrix-client.js'; +/** + * SAM (Software Automatic Mouth) speech synthesizer instance + * @type {object|null} + */ +let sam = null; + +/** + * Initialize SAM speech synthesizer with SAM voice + * @returns {object|null} SAM instance or null if unavailable + */ +function initSam() { + if (sam) return sam; + if (typeof SamJs !== 'undefined') { + // SAM preset: speed=72, pitch=64, mouth=128, throat=128 + sam = new SamJs({ speed: 72, pitch: 64, mouth: 128, throat: 128 }); + } + return sam; +} + +/** + * Speak text using SAM + * @param {string} text - Text to speak + * @returns {void} + */ +function samSpeak(text) { + const samInstance = initSam(); + if (samInstance) { + try { + samInstance.speak(text); + } catch (_err) { + // Silently fail if speech doesn't work + } + } +} + +// Expose samSpeak globally for matrix-client +window.samSpeak = samSpeak; + // Version const VERSION = '1.0.0'; @@ -483,6 +522,10 @@ const commands = { description: numbermatchCmd.description, execute: (args) => numbermatchCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, + samsay: { + description: samsayCmd.description, + execute: (args) => samsayCmd.execute(term, writeClickable, VERSION, args, commandHistory) + }, chat: { description: 'Connect to chat room', execute: async (args) => { @@ -829,7 +872,12 @@ function positionInlineInput() { if (mentionSuggestions.style.display !== 'none') { mentionSuggestions.style.left = inlineInput.style.left; - mentionSuggestions.style.top = ((cursorY * charHeight) - charHeight - 6) + 'px'; + + // Get the actual height of the suggestions box + const suggestionsHeight = mentionSuggestions.offsetHeight || 0; + + // Position it above the cursor line + mentionSuggestions.style.top = ((cursorY * charHeight) - suggestionsHeight - 6) + 'px'; mentionSuggestions.style.fontSize = term.options.fontSize + 'px'; mentionSuggestions.style.lineHeight = charHeight + 'px'; } @@ -864,6 +912,192 @@ function hideInlineInput() { mentionSuggestions.style.display = 'none'; } +/** + * Command autocomplete state + */ +const commandAutocomplete = { + matches: [], + index: -1, + tokenStart: 0, + tokenEnd: 0 +}; + +/** + * Get command matches for autocomplete + * @param {string} query - Command prefix to match + * @param {boolean} isChatMode - Whether in chat mode + * @returns {Array<{name: string, description: string}>} Matching commands + */ +function getCommandMatches(query, isChatMode) { + const lowerQuery = query.toLowerCase(); + + if (isChatMode) { + // Chat commands + const chatCommands = [ + { name: '/help', description: 'Show chat commands' }, + { name: '/nick', description: 'Change display name' }, + { name: '/samsay', description: 'Send message with SAM speech' }, + { name: '/quit', description: 'Exit chat mode' } + ]; + return chatCommands.filter(cmd => cmd.name.startsWith(lowerQuery)); + } else { + // Terminal commands + const terminalCommands = Object.keys(commands).map(name => ({ + name, + description: commands[name].description || '' + })); + return terminalCommands.filter(cmd => cmd.name.toLowerCase().startsWith(lowerQuery)); + } +} + +/** + * Check if command suggestions are visible + * @returns {boolean} True if suggestions are visible + */ +function hasVisibleCommandSuggestions() { + return mentionSuggestions.style.display !== 'none' + && commandAutocomplete.matches.length > 0; +} + +/** + * Reset command autocomplete state + * @returns {void} + */ +function resetCommandAutocomplete() { + commandAutocomplete.matches = []; + commandAutocomplete.index = -1; + commandAutocomplete.tokenStart = 0; + commandAutocomplete.tokenEnd = 0; + mentionSuggestions.style.display = 'none'; + mentionSuggestions.innerHTML = ''; +} + +/** + * Render command suggestions + * @returns {void} + */ +function renderCommandSuggestions() { + const { matches, index } = commandAutocomplete; + + if (matches.length === 0) { + mentionSuggestions.style.display = 'none'; + return; + } + + const limitedMatches = matches.slice(0, 5); + mentionSuggestions.innerHTML = limitedMatches.map((cmd, i) => { + const selected = i === index ? ' selected' : ''; + return `
${cmd.name} - ${cmd.description}
`; + }).join(''); + + mentionSuggestions.style.display = 'block'; + positionInlineInput(); +} + +/** + * Refresh command suggestions based on current input + * @returns {void} + */ +function refreshCommandSuggestions() { + const value = inlineInput.value; + const cursorPos = inlineInput.selectionStart; + + // Check if we're at the start with a "/" or just typing a command + const isChatMode = chatMode.active; + let query = ''; + let tokenStart = 0; + let tokenEnd = cursorPos; + + if (isChatMode) { + // In chat mode, look for /command at start + if (value.startsWith('/')) { + const match = value.match(/^(\/\w*)/); + if (match && cursorPos <= match[1].length) { + query = match[1]; + tokenStart = 0; + tokenEnd = match[1].length; + } else { + resetCommandAutocomplete(); + return; + } + } else { + resetCommandAutocomplete(); + return; + } + } else { + // In terminal mode, look for command at start (no slash) + if (cursorPos === value.length) { + const match = value.match(/^(\w*)/); + if (match && match[1].length > 0) { + query = match[1]; + tokenStart = 0; + tokenEnd = match[1].length; + } else { + resetCommandAutocomplete(); + return; + } + } else { + resetCommandAutocomplete(); + return; + } + } + + const matches = getCommandMatches(query, isChatMode); + + if (matches.length === 0) { + resetCommandAutocomplete(); + return; + } + + commandAutocomplete.matches = matches; + commandAutocomplete.index = -1; + commandAutocomplete.tokenStart = tokenStart; + commandAutocomplete.tokenEnd = tokenEnd; + + renderCommandSuggestions(); +} + +/** + * Apply the selected command autocomplete + * @returns {boolean} True if a command was applied + */ +function applyCommandAutocomplete() { + if (!hasVisibleCommandSuggestions()) { + return false; + } + + // Cycle through commands or select first + if (commandAutocomplete.index < commandAutocomplete.matches.length - 1) { + commandAutocomplete.index++; + } else { + commandAutocomplete.index = 0; + } + + const selected = commandAutocomplete.matches[commandAutocomplete.index]; + const fullValue = inlineInput.value; + const valueAfter = fullValue.slice(commandAutocomplete.tokenEnd); + const isChatMode = chatMode.active; + + // For chat commands, include the slash; for terminal commands, don't + const commandText = selected.name; + const needsSpace = !valueAfter.startsWith(' ') && valueAfter.length > 0; + const newValue = commandText + (needsSpace ? ' ' : '') + valueAfter; + const newCursor = commandText.length + (needsSpace ? 1 : 0); + + inlineInput.value = newValue; + inlineInput.setSelectionRange(newCursor, newCursor); + + if (isChatMode) { + chatMode.inputLine = newValue; + } + + // Update token end for continued cycling + commandAutocomplete.tokenEnd = commandText.length; + + renderCommandSuggestions(); + return true; +} + /** * Submit current input */ @@ -887,9 +1121,10 @@ async function submitInlineInput() { 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(' /help - Show this help message'); + term.writeln(' /nick [name] - Change your display name'); + term.writeln(' /samsay [text] - Send message with SAM speech'); + term.writeln(' /quit - Exit chat mode'); term.writeln('─'.repeat(term.cols || 60)); term.write('\x1b[1;32m>\x1b[0m '); setTimeout(() => positionInlineInput(), 10); @@ -939,6 +1174,24 @@ async function submitInlineInput() { return; } + if (cmd.startsWith('/samsay ')) { + const message = cmd.substring(8).trim(); + + if (!message) { + // Move cursor up to separator, clear it + term.write('\x1b[1A\x1b[2K\r'); + term.writeln('\x1b[31mError: /samsay [text]\x1b[0m'); + term.writeln('─'.repeat(term.cols || 60)); + term.write('\x1b[1;32m>\x1b[0m '); + setTimeout(() => positionInlineInput(), 10); + return; + } + + // Send to chat (will play when message comes back) + await sendChatMessage(cmd); + return; + } + if (cmd && !cmd.startsWith('/')) { // Don't write the message here - let sync handle it await sendChatMessage(cmd); @@ -1084,24 +1337,62 @@ async function init() { // Handle inline input events inlineInput.addEventListener('keydown', async (e) => { playTerminalKeySound(e); - if (e.key === 'Escape' && hasVisibleMentionSuggestions()) { + + // Check if we have visible suggestions (mentions OR commands) + const hasMentions = hasVisibleMentionSuggestions(); + const hasCommands = hasVisibleCommandSuggestions(); + const hasSuggestions = hasMentions || hasCommands; + + if (e.key === 'Escape' && hasSuggestions) { e.preventDefault(); - resetMentionAutocomplete(); - } else if (e.key === ' ' && hasVisibleMentionSuggestions()) { + if (hasMentions) resetMentionAutocomplete(); + if (hasCommands) resetCommandAutocomplete(); + } else if (e.key === ' ' && hasSuggestions) { e.preventDefault(); - commitSelectedMentionSuggestion(); - } else if (e.key === 'Enter' && hasVisibleMentionSuggestions()) { + if (hasMentions) commitSelectedMentionSuggestion(); + if (hasCommands) { + resetCommandAutocomplete(); + inlineInput.value += ' '; + if (chatMode.active) chatMode.inputLine = inlineInput.value; + } + } else if (e.key === 'Enter' && hasSuggestions) { e.preventDefault(); - commitSelectedMentionSuggestion(); + if (hasMentions) { + commitSelectedMentionSuggestion(); + } else if (hasCommands) { + // Just accept the current command and submit + resetCommandAutocomplete(); + submitInlineInput(); + } } else if (e.key === 'Enter') { e.preventDefault(); submitInlineInput(); - } else if (e.key === 'Tab' && chatMode.active) { + } else if (e.key === 'Tab') { e.preventDefault(); - await applyMentionAutocomplete(); + if (chatMode.active && hasMentions) { + await applyMentionAutocomplete(); + } else if (hasCommands) { + applyCommandAutocomplete(); + } else { + // Trigger command suggestions on Tab + refreshCommandSuggestions(); + if (hasVisibleCommandSuggestions()) { + applyCommandAutocomplete(); + } else if (chatMode.active) { + // Try mention autocomplete in chat + await applyMentionAutocomplete(); + } + } } else if (e.key === 'ArrowUp') { e.preventDefault(); - if (commandHistory.length > 0) { + if (hasCommands) { + // Navigate command suggestions up + if (commandAutocomplete.index > 0) { + commandAutocomplete.index--; + renderCommandSuggestions(); + } + } else if (commandHistory.length > 0) { + // Navigate command history if (historyIndex === -1 || historyIndex >= commandHistory.length) { historyIndex = commandHistory.length - 1; } else if (historyIndex > 0) { @@ -1111,7 +1402,13 @@ async function init() { } } else if (e.key === 'ArrowDown') { e.preventDefault(); - if (historyIndex < commandHistory.length - 1) { + if (hasCommands) { + // Navigate command suggestions down + if (commandAutocomplete.index < commandAutocomplete.matches.length - 1) { + commandAutocomplete.index++; + renderCommandSuggestions(); + } + } else if (historyIndex < commandHistory.length - 1) { historyIndex++; inlineInput.value = commandHistory[historyIndex] || ''; } else { @@ -1126,6 +1423,9 @@ async function init() { chatMode.inputLine = inlineInput.value; await refreshMentionSuggestionsFromInput(); } + + // Refresh command suggestions if typing at the start + refreshCommandSuggestions(); }); // Click on terminal focuses input