UNPKG

melq

Version:

Quantum-secure chat network with ML-KEM-768 encryption and host-based architecture

1,434 lines (1,180 loc) 56.5 kB
import readline from 'readline'; import chalk from 'chalk'; import logger from '../utils/async-logger.js'; const CHAT_MODES = { DIRECTORY: 'directory', CHAT: 'chat', ERROR: 'error', LOADING: 'loading' }; const CONNECTION_STATUS = { CONNECTED: 'connected', CONNECTING: 'connecting', DISCONNECTED: 'disconnected', RECONNECTING: 'reconnecting', ERROR: 'error' }; const UI_THEMES = { SUCCESS: { icon: '✅', color: chalk.green }, ERROR: { icon: '❌', color: chalk.red }, WARNING: { icon: '⚠️', color: chalk.yellow }, INFO: { icon: 'ℹ️', color: chalk.blue }, LOADING: { icon: '⏳', color: chalk.cyan }, NETWORK: { icon: '🌐', color: chalk.magenta } }; export class CLIInterface { constructor(node) { this.node = node; this.node.cliInterface = this; // Set reference for prompt restoration this.currentPath = '/'; this.currentChat = null; this.mode = CHAT_MODES.DIRECTORY; this.messages = new Map(); // chatId -> messages[] this.chatColorAssignments = new Map(); // chatId -> { username -> colorName } this.customNames = new Map(); // chatId -> Map(nodeId -> custom name) this.maxMessagesPerChat = 100; // Maximum messages to keep per chat this.chatHeight = Math.max(10, process.stdout.rows - 6); // Reserve space for input area // Enhanced connection management this.connectionStatus = CONNECTION_STATUS.DISCONNECTED; this.connectionAttempts = 0; this.maxRetries = 3; this.lastError = null; this.isShuttingDown = false; // UI state management this.isConnecting = false; this.lastActivity = Date.now(); this.statusInterval = null; this.spinnerFrame = 0; this.spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; this.connectionInfo = null; this.refreshTimeout = null; // Error handling and recovery this.errorHistory = []; this.maxErrorHistory = 10; this.lastSuccessfulCommand = null; // Professional UI enhancements this.terminalWidth = process.stdout.columns || 80; this.notifications = []; this.maxNotifications = 5; // Enhanced readline with better error handling this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: this.getPrompt(), completer: this.completer.bind(this), history: [] }); // Set up comprehensive error handling this.setupErrorHandling(); this.setupEventHandlers(); this.setupNodeHandlers(); this.setupScreenResize(); } // =============================== // ERROR HANDLING & RECOVERY // =============================== setupErrorHandling() { // Handle uncaught exceptions gracefully process.on('uncaughtException', (error) => { this.handleCriticalError('System Error', error); }); process.on('unhandledRejection', (reason, promise) => { this.handleCriticalError('Promise Rejection', new Error(reason)); }); // Handle readline errors this.rl.on('error', (error) => { this.logError('Input Error', error); this.showError('Input error occurred. Please try again.'); }); } handleCriticalError(type, error) { try { console.clear(); this.showCriticalErrorScreen(type, error); // Attempt graceful shutdown setTimeout(() => { this.performEmergencyShutdown(); }, 3000); } catch (shutdownError) { // Last resort console.error('CRITICAL ERROR:', error.message); process.exit(1); } } logError(type, error) { const errorEntry = { type, message: error.message, stack: error.stack, timestamp: new Date().toISOString(), context: { mode: this.mode, currentChat: this.currentChat?.name, connectionStatus: this.connectionStatus } }; this.errorHistory.push(errorEntry); if (this.errorHistory.length > this.maxErrorHistory) { this.errorHistory.shift(); } this.lastError = errorEntry; } showCriticalErrorScreen(type, error) { const width = this.terminalWidth; const border = '═'.repeat(width - 2); console.log(chalk.red('╔' + border + '╗')); console.log(chalk.red('║') + chalk.bold.white(' '.repeat(Math.floor((width - 16) / 2)) + 'CRITICAL ERROR' + ' '.repeat(Math.ceil((width - 16) / 2))) + chalk.red('║')); console.log(chalk.red('╠' + border + '╣')); console.log(chalk.red('║') + ' '.repeat(width - 2) + chalk.red('║')); console.log(chalk.red('║') + chalk.yellow(` Type: ${type}`.padEnd(width - 3)) + chalk.red('║')); console.log(chalk.red('║') + chalk.white(` Error: ${error.message}`.padEnd(width - 3)) + chalk.red('║')); console.log(chalk.red('║') + ' '.repeat(width - 2) + chalk.red('║')); console.log(chalk.red('║') + chalk.dim(` Attempting graceful shutdown...`.padEnd(width - 3)) + chalk.red('║')); console.log(chalk.red('║') + chalk.dim(` Please wait 3 seconds...`.padEnd(width - 3)) + chalk.red('║')); console.log(chalk.red('║') + ' '.repeat(width - 2) + chalk.red('║')); console.log(chalk.red('╚' + border + '╝')); } performEmergencyShutdown() { try { // Exit alternative screen if in chat if (this.mode === CHAT_MODES.CHAT) { process.stdout.write('\x1b[?1049l'); } // Disconnect node if (this.node) { this.node.disconnect(); } console.log(chalk.red('\n🚫 Emergency shutdown completed.')); process.exit(1); } catch { process.exit(1); } } // =============================== // INPUT VALIDATION & COMPLETION // =============================== completer(line) { const commands = { [CHAT_MODES.DIRECTORY]: ['ls', 'cd', 'mkdir', 'discover', 'nodes', 'help', 'clear', 'connect', 'status'], [CHAT_MODES.CHAT]: ['/exit', '/help', '/clear', '/colors', '/name', '/status', '/reconnect'] }; const availableCommands = commands[this.mode] || []; const hits = availableCommands.filter(cmd => cmd.startsWith(line)); return [hits.length ? hits : availableCommands, line]; } validateInput(input, mode = this.mode) { try { if (!input || typeof input !== 'string') { return { valid: false, error: 'Invalid input type' }; } const trimmed = input.trim(); if (trimmed.length === 0) { return { valid: true, input: trimmed }; } // Check for dangerous patterns const dangerousPatterns = [ /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/, // Control characters /\x1b\[[0-9;]*[mGKH]/, // ANSI escape sequences /__[A-Z_]+__:/, // System message patterns ]; for (const pattern of dangerousPatterns) { if (pattern.test(trimmed)) { return { valid: false, error: 'Input contains invalid characters' }; } } // Mode-specific validation if (mode === CHAT_MODES.CHAT) { if (trimmed.length > 1000) { return { valid: false, error: 'Message too long (max 1000 characters)' }; } if (trimmed.startsWith('/')) { const validChatCommands = ['/exit', '/help', '/clear', '/colors', '/name', '/status', '/reconnect']; const command = trimmed.split(' ')[0]; if (!validChatCommands.includes(command)) { return { valid: false, error: `Unknown command: ${command}` }; } } } return { valid: true, input: trimmed }; } catch (error) { this.logError('Input Validation', error); return { valid: false, error: 'Input validation failed' }; } } showError(message, details = null) { const theme = UI_THEMES.ERROR; const errorMessage = `${theme.icon} ${message}`; if (this.mode === CHAT_MODES.CHAT) { this.displaySystemMessage(theme.color(errorMessage), false); if (details) { this.displaySystemMessage(chalk.dim(` Details: ${details}`), false); } } else { logger.log(theme.color(errorMessage)); if (details) { logger.log(chalk.dim(` Details: ${details}`)); } } } showSuccess(message) { const theme = UI_THEMES.SUCCESS; const successMessage = `${theme.icon} ${message}`; if (this.mode === CHAT_MODES.CHAT) { this.displaySystemMessage(theme.color(successMessage)); } else { logger.log(theme.color(successMessage)); } } showWarning(message) { const theme = UI_THEMES.WARNING; const warningMessage = `${theme.icon} ${message}`; if (this.mode === CHAT_MODES.CHAT) { this.displaySystemMessage(theme.color(warningMessage)); } else { logger.log(theme.color(warningMessage)); } } setupEventHandlers() { this.rl.on('line', (input) => { try { if (this.mode === CHAT_MODES.CHAT) { // Clear the input line before processing process.stdout.write('\r\x1b[K'); } // Validate input before processing const validation = this.validateInput(input); if (!validation.valid) { // If in chat mode with no peers, silently ignore invalid input to prevent spam if (this.mode === CHAT_MODES.CHAT && this.node.peerKeys && this.node.peerKeys.size === 0) { this.rl.prompt(); return; } this.showError(validation.error); this.rl.prompt(); return; } this.lastSuccessfulCommand = validation.input; this.handleCommand(validation.input); } catch (error) { this.logError('Command Processing', error); this.showError('An error occurred processing your command'); this.rl.prompt(); } }); this.rl.on('close', () => { this.performGracefulShutdown(); }); process.on('SIGINT', () => { this.performGracefulShutdown(); }); // Handle terminal resize process.stdout.on('resize', () => { this.terminalWidth = process.stdout.columns || 80; this.chatHeight = Math.max(10, process.stdout.rows - 6); if (this.mode === CHAT_MODES.CHAT && this.currentChat) { this.debouncedRefresh(); } }); } performGracefulShutdown() { try { this.isShuttingDown = true; // Exit alternative screen buffer if we're in a chat if (this.mode === CHAT_MODES.CHAT) { process.stdout.write('\x1b[?1049l'); } // Clear any intervals if (this.statusInterval) { clearInterval(this.statusInterval); } if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); } // Show professional goodbye message console.clear(); const width = Math.min(process.stdout.columns || 80, 100); const border = '═'.repeat(Math.max(0, width - 2)); // Title line const title = 'GOODBYE!'; const titlePadding = Math.max(0, Math.floor((width - title.length - 2) / 2)); const titleLeft = ' '.repeat(titlePadding); const titleRight = ' '.repeat(Math.max(0, width - title.length - titlePadding - 2)); // Message lines with proper padding const msg1 = ' Thank you for using MELQ - Quantum-Secure Chat'; const msg2 = ' Your connection has been securely closed.'; // Ensure messages fit and pad properly const msg1Padded = msg1.length < width - 2 ? msg1 + ' '.repeat(width - msg1.length - 2) : msg1.substring(0, width - 2); const msg2Padded = msg2.length < width - 2 ? msg2 + ' '.repeat(width - msg2.length - 2) : msg2.substring(0, width - 2); console.log(chalk.cyan('╔' + border + '╗')); console.log(chalk.cyan('║') + titleLeft + chalk.bold.white(title) + titleRight + chalk.cyan('║')); console.log(chalk.cyan('║') + ' '.repeat(Math.max(0, width - 2)) + chalk.cyan('║')); console.log(chalk.cyan('║') + chalk.dim(msg1Padded) + chalk.cyan('║')); console.log(chalk.cyan('║') + chalk.dim(msg2Padded) + chalk.cyan('║')); console.log(chalk.cyan('║') + ' '.repeat(Math.max(0, width - 2)) + chalk.cyan('║')); console.log(chalk.cyan('╚' + border + '╝')); // Disconnect node gracefully if (this.node && !this.isShuttingDown) { this.node.disconnect(); } setTimeout(() => process.exit(0), 500); } catch (error) { console.log(chalk.yellow('\n👋 Goodbye!')); process.exit(0); } } setupScreenResize() { process.stdout.on('resize', () => { this.chatHeight = Math.max(10, process.stdout.rows - 6); if (this.mode === CHAT_MODES.CHAT && this.currentChat) { this.debouncedRefresh(); } }); } startSpinner(message = 'Connecting') { if (this.statusInterval) { this.stopSpinner(); } this.isConnecting = true; this.statusInterval = setInterval(() => { const spinner = this.spinnerChars[this.spinnerFrame % this.spinnerChars.length]; this.spinnerFrame++; // Clear current line and show spinner process.stdout.write(`\r${chalk.yellow(spinner + ' ' + message + '...')}`); }, 100); } stopSpinner(successMessage = null) { if (this.statusInterval) { clearInterval(this.statusInterval); this.statusInterval = null; } this.isConnecting = false; // Clear the spinner line process.stdout.write('\r' + ' '.repeat(50) + '\r'); if (successMessage) { logger.log(chalk.green('✅ ' + successMessage)); } } setupNodeHandlers() { this.node.onMessage((messageData) => { this.handleIncomingMessage(messageData); }); } // Method called by UnifiedNode.safeLog() for synchronous/asynchronous logging log(message, color = null) { // Use synchronous logging when in home menu mode to prevent prompt disruption if (this.mode === CHAT_MODES.DIRECTORY) { const output = color ? color(message) : message; console.log(output); // Re-prompt in home menu to keep interface clean if (this.rl) { this.rl.prompt(); } } else { // Use async logger in chat mode to prevent display corruption const outputMessage = color ? color(message) : message; logger.log(outputMessage); } } getPrompt() { const nodeInfo = chalk.green(`[${this.node.nodeId.slice(-8)}]`); if (this.mode === CHAT_MODES.CHAT && this.currentChat) { const peerCount = this.node.peerKeys ? this.node.peerKeys.size : 0; const status = peerCount > 0 ? chalk.green('●') : chalk.red('●'); return `${status} > `; } else { const path = chalk.blue(this.currentPath); const peerCount = this.node.peerKeys ? this.node.peerKeys.size : 0; const peerInfo = peerCount > 0 ? chalk.gray(`(${peerCount})`) : ''; return `${nodeInfo}${peerInfo} ${path}$ `; } } updatePrompt() { this.rl.setPrompt(this.getPrompt()); } handleCommand(input) { if (this.mode === CHAT_MODES.CHAT) { this.handleChatInput(input); } else { this.handleDirectoryCommand(input); } } handleChatInput(input) { const trimmedInput = input.trim(); // Handle special commands in chat mode if (trimmedInput === '/exit' || trimmedInput === '/quit') { this.exitChat(); return; } if (trimmedInput === '/help') { this.showChatHelp(); this.rl.prompt(); return; } if (trimmedInput === '/clear') { this.clearChatScreen(); this.rl.prompt(); return; } if (trimmedInput === '/colors') { this.showColorAssignments(); this.rl.prompt(); return; } if (trimmedInput.startsWith('/name ')) { this.setCustomName(trimmedInput.substring(6).trim()); this.rl.prompt(); return; } if (trimmedInput === '/name') { this.displaySystemMessage('Usage: /name <your_name>'); this.displaySystemMessage('Set a custom name for this chat (max 25 characters)'); this.rl.prompt(); return; } if (trimmedInput === '') { this.rl.prompt(); return; } // Send the message const messageSent = this.sendMessage(trimmedInput); // If no message was sent (no peers), refresh display to keep chat clean if (!messageSent && this.node.peerKeys && this.node.peerKeys.size === 0) { this.refreshChatDisplay(); } this.rl.prompt(); } handleDirectoryCommand(input) { const args = input.split(' '); const command = args[0]; switch (command) { case 'ls': this.listContents(); break; case 'cd': this.changeDirectory(args[1]); break; case 'mkdir': this.createChat(args.slice(1).join(' ')); break; case 'discover': this.startSpinner('Discovering peers'); this.node.discoverNodes(); setTimeout(() => { this.stopSpinner('Discovery request sent'); this.rl.prompt(); }, 2000); return; case 'nodes': this.showNodes(); break; case 'help': this.showHelp(); break; case 'clear': console.clear(); break; case '': break; default: if (command) { logger.log(chalk.red(`❌ Command "${command}" not found.`)); logger.log(chalk.dim.gray('💡 Type "help" to see available commands.')); } } this.rl.prompt(); } listContents() { const terminalWidth = Math.min(process.stdout.columns || 80, 80); // Beautiful header for chat list console.log(chalk.bold.yellow('📁 Available Chats:')); console.log(chalk.dim('─'.repeat(terminalWidth - 2))); if (this.node.chats.size === 0) { const emptyIcon = '📭'; console.log(chalk.dim.gray(` ${emptyIcon} No chats available`)); console.log(chalk.dim.gray(' 💡 Use "mkdir <chat_name>" to create a new chat')); } else { for (const [chatId, chat] of this.node.chats.entries()) { const messageCount = this.messages.get(chatId)?.length || 0; const chatIcon = messageCount > 0 ? '💬' : '📁'; const lastActivity = this.getLastActivity(chatId); console.log(` ${chatIcon} ${chalk.cyan.bold(chat.name)} ${chalk.dim.gray(lastActivity)}`); } } console.log(); } getLastActivity(chatId) { const messages = this.messages.get(chatId); if (!messages || messages.length === 0) return '(empty)'; const lastMessage = messages[messages.length - 1]; const timeDiff = Date.now() - lastMessage.timestamp; if (timeDiff < 60000) return 'just now'; if (timeDiff < 3600000) return `${Math.floor(timeDiff / 60000)}m ago`; if (timeDiff < 86400000) return `${Math.floor(timeDiff / 3600000)}h ago`; return `${Math.floor(timeDiff / 86400000)}d ago`; } changeDirectory(path) { if (!path) { console.log('Usage: cd <directory>'); return; } if (path === '..' || path === '/') { this.exitChat(); } else { // Remove trailing slash if present const cleanPath = path.replace(/\/$/, ''); const chat = Array.from(this.node.chats.values()).find(c => c.name === cleanPath); if (chat) { this.enterChat(chat); } else { console.log(chalk.red(`❌ Chat "${path}" not found.`)); console.log(chalk.yellow('📁 Available chats:')); if (this.node.chats.size === 0) { console.log(chalk.dim.gray(' 📭 No chats available')); console.log(chalk.dim.gray(' 💡 Use "mkdir <name>" to create a new chat')); } else { for (const [chatId, chat] of this.node.chats.entries()) { console.log(chalk.cyan(` 📁 ${chat.name}`)); } console.log(chalk.dim.gray(' 💡 Use "cd <chat_name>" to enter a chat')); } } } } createChat(chatName) { if (!chatName) { console.log(chalk.red('❌ Usage: mkdir <chat_name>')); console.log(chalk.dim.gray('💡 Example: mkdir general')); return; } // Validate chat name if (chatName.length > 20) { console.log(chalk.red('❌ Chat name too long (max 20 characters)')); return; } if (!/^[a-zA-Z0-9_-]+$/.test(chatName)) { console.log(chalk.red('❌ Chat name can only contain letters, numbers, hyphens, and underscores')); return; } // Check if chat already exists const existingChat = Array.from(this.node.chats.values()).find(c => c.name === chatName); if (existingChat) { console.log(chalk.blue(`📁 Chat "${chatName}" already exists. Entering...`)); this.enterChat(existingChat); return; } console.log(chalk.yellow(`🔨 Creating chat: "${chatName}"...`)); try { this.node.createChat(chatName); console.log(chalk.green(`✅ Successfully created chat "${chatName}"`)); console.log(chalk.dim.gray('💡 Use "cd ' + chatName + '" to enter the chat')); } catch (error) { console.log(chalk.red(`❌ Failed to create chat: ${error.message}`)); } } sendMessage(message) { if (!this.currentChat) { this.displaySystemMessage('❌ You must be in a chat to send messages.'); this.displaySystemMessage('💡 Use "cd <chat_name>" to enter a chat.'); return false; } if (!message || message.trim().length === 0) { return false; } // Check message length if (message.length > 500) { this.displaySystemMessage('❌ Message too long (max 500 characters)'); return false; } const targets = Array.from(this.node.peerKeys.keys()); if (targets.length === 0) { // Don't add to chat history - just show temporary notification // The message was not sent, so don't store it anywhere return false; } try { // Send to peers targets.forEach(nodeId => { this.node.sendMessage(this.currentChat.id, message, nodeId); }); // Add to local message history if (!this.messages.has(this.currentChat.id)) { this.messages.set(this.currentChat.id, []); } this.messages.get(this.currentChat.id).push({ from: 'You', text: message.trim(), timestamp: Date.now() }); // Enforce message limit for this chat this.enforceMessageLimit(this.currentChat.id); // Ensure "You" gets a color assignment in this chat this.getColorForUser(this.currentChat.id, 'You'); // Refresh the chat display to show the new message this.refreshChatDisplay(); return true; // Message was successfully sent } catch (error) { this.displaySystemMessage(`❌ Failed to send message: ${error.message}`); return false; } } handleIncomingMessage(messageData) { const fromNode = messageData.fromNodeId.slice(-8); // Check if this is a name change notification if (messageData.text && messageData.text.startsWith('__NAME_CHANGE__:')) { const customName = messageData.text.substring('__NAME_CHANGE__:'.length).trim(); this.handleNameChange(messageData.chatId, fromNode, customName); return; // Don't add name change messages to chat history } if (!this.messages.has(messageData.chatId)) { this.messages.set(messageData.chatId, []); } this.messages.get(messageData.chatId).push({ from: fromNode, text: messageData.text, timestamp: messageData.timestamp }); // Enforce message limit for this chat this.enforceMessageLimit(messageData.chatId); // Ensure sender gets a color assignment in this chat this.getColorForUser(messageData.chatId, fromNode); // Only refresh display if we're in the same chat in chat mode if (this.mode === CHAT_MODES.CHAT && this.currentChat && this.currentChat.id === messageData.chatId) { // Preserve current input line and cursor position const currentInput = this.rl.line; const currentCursor = this.rl.cursor; // Use debounced refresh to prevent display corruption from rapid messages this.debouncedRefresh(); // Small delay to let display refresh complete before restoring input setTimeout(() => { // Restore the input line and cursor position properly this.rl.line = currentInput; this.rl.cursor = currentCursor; // Clear the current line and rewrite with proper cursor position process.stdout.write('\r\x1b[K'); // Clear current line this.rl._refreshLine(); // Use readline's internal refresh method }, 60); // Slightly longer than debounce delay } // In directory mode, don't show any message notifications } showNodes() { const peerCount = this.node.peerKeys.size; const terminalWidth = Math.min(process.stdout.columns || 80, 80); console.log(chalk.bold.blue(`🌐 Network Status - Connected to ${peerCount} peer(s):`)); console.log(chalk.dim('─'.repeat(terminalWidth - 2))); // Show connection info if available if (this.connectionInfo) { if (this.connectionInfo.isHost) { console.log(chalk.dim.gray(` 🏠 Role: ${chalk.green('Hosting')} this network`)); } if (this.connectionInfo.connectionCode) { const displayCode = this.connectionInfo.connectionCode.length > 50 ? this.connectionInfo.connectionCode.substring(0, 47) + '...' : this.connectionInfo.connectionCode; console.log(chalk.dim.gray(` 📍 Network: ${chalk.white(displayCode)}`)); } } if (peerCount === 0) { console.log(chalk.dim.gray(' 🔍 No peers connected. Use "discover" to find other nodes.')); console.log(chalk.dim.gray(' 🚀 Or share your connection code with others!')); } else { console.log(); let index = 1; for (const nodeId of this.node.peerKeys.keys()) { const nodeShort = nodeId.slice(-8); const statusIcon = '🟢'; console.log(` ${statusIcon} ${chalk.cyan.bold(`Peer ${index}`)}: ${chalk.dim(nodeShort)}`); index++; } } console.log(); } enterChat(chat) { this.currentPath = `/${chat.name}`; this.currentChat = chat; this.mode = CHAT_MODES.CHAT; // Enter alternative screen buffer to prevent scroll-up access to previous content process.stdout.write('\x1b[?1049h'); this.refreshChatDisplay(); this.updatePrompt(); } // Debounced refresh to prevent rapid updates debouncedRefresh() { if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); } this.refreshTimeout = setTimeout(() => { this.refreshChatDisplay(); }, 50); // 50ms debounce } refreshChatDisplay() { if (!this.currentChat) return; // Get actual terminal dimensions const terminalWidth = process.stdout.columns || 80; const terminalHeight = process.stdout.rows || 30; // Clear screen completely and ensure cursor is at top-left process.stdout.write('\x1b[2J'); // Clear entire screen process.stdout.write('\x1b[1;1H'); // Move cursor to row 1, column 1 // Add a small buffer line to ensure header is visible at top console.log(''); // Empty line to push content down slightly // Create responsive chat header with commands this.drawChatHeader(terminalWidth); // Calculate available space for messages (header + footer) const headerHeight = 6; // Header has 6 lines: buffer line + ╔═══╗, ║title║, ║empty║, ║commands║, ╚═══╝ const footerHeight = 1; // Input line const availableHeight = Math.max(5, terminalHeight - headerHeight - footerHeight); // Get messages to display (limit by actual rendered lines, not message count) const chatMessages = this.messages.get(this.currentChat.id) || []; const displayMessages = this.selectMessagesToFit(chatMessages, terminalWidth, availableHeight); // Display messages with proper formatting const renderedLines = this.drawChatMessages(displayMessages, terminalWidth, availableHeight); // Fill remaining space to prevent scrolling const remainingLines = availableHeight - renderedLines; if (remainingLines > 0) { console.log('\n'.repeat(remainingLines)); } } drawChatHeader(terminalWidth) { const chatName = this.currentChat.name.toUpperCase(); const peerCount = this.node.peerKeys ? this.node.peerKeys.size : 0; const statusIcon = peerCount > 0 ? '🟢' : '🔴'; const headerTitle = `${statusIcon} ${chatName} (${peerCount} peers)`; const commands = '/exit /help /clear /colors /name'; const commandsHint = `Commands: ${commands}`; // Use the same pattern as showWelcomeBanner for consistent rendering const banner = '═'.repeat(terminalWidth - 2); const titlePadding = Math.max(0, Math.floor((terminalWidth - headerTitle.length - 2) / 2)); const cmdPadding = Math.max(0, Math.floor((terminalWidth - commandsHint.length - 2) / 2)); console.log(chalk.cyan('╔' + banner + '╗')); console.log(chalk.cyan('║') + ' '.repeat(titlePadding) + chalk.bold.white(headerTitle) + ' '.repeat(terminalWidth - headerTitle.length - titlePadding - 2) + chalk.cyan('║')); console.log(chalk.cyan('║') + ' '.repeat(terminalWidth - 2) + chalk.cyan('║')); console.log(chalk.cyan('║') + ' '.repeat(cmdPadding) + chalk.dim.gray(commandsHint) + ' '.repeat(terminalWidth - commandsHint.length - cmdPadding - 2) + chalk.cyan('║')); console.log(chalk.cyan('╚' + banner + '╝')); } // Select messages that will fit in the available height without scrolling selectMessagesToFit(messages, terminalWidth, availableHeight) { if (messages.length === 0) return []; const timeStampWidth = 8; // [HH:MM] const nameMaxWidth = 15; // Reasonable max for display names const prefixWidth = timeStampWidth + nameMaxWidth + 6; // spacing and colons const maxTextWidth = Math.max(40, terminalWidth - prefixWidth); let totalLines = 1; // Start with 1 for the space after header const selectedMessages = []; // Go backwards through messages to fit as many as possible for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; // Calculate lines this message will take const wrappedText = this.wrapText(msg.text, maxTextWidth); const messageLines = wrappedText.split('\n').length; // Add spacing between message groups let spacingLines = 0; if (selectedMessages.length > 0) { const nextMsg = selectedMessages[0]; // Most recent selected message const timeDiff = nextMsg.timestamp - msg.timestamp; const differentUser = nextMsg.from !== msg.from; if (differentUser || timeDiff > 300000) { // 5 minutes spacingLines = 1; } } const neededLines = messageLines + spacingLines; // Check if this message will fit if (totalLines + neededLines <= availableHeight) { totalLines += neededLines; selectedMessages.unshift(msg); // Add to beginning to maintain order } else { break; // Can't fit more messages } } return selectedMessages; } // Helper function to get plain text length without ANSI codes and emojis getPlainTextLength(text) { // Remove ANSI escape sequences and emojis for accurate length calculation return text.replace(/\u001b\[[0-9;]*[mGKH]/g, '').replace(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, 'E').length; } // Legacy method (keeping for compatibility) getTextLength(text) { return this.getPlainTextLength(text); } drawChatMessages(displayMessages, terminalWidth, availableHeight) { if (displayMessages.length === 0) { // Show different message based on whether peers are connected const peerCount = this.node.peerKeys ? this.node.peerKeys.size : 0; let emptyMsg, subMsg; if (peerCount === 0) { emptyMsg = 'No messages yet. Waiting for peers to connect... 🔍'; subMsg = 'Messages can\'t be sent until a peer connects to the chat.'; } else { emptyMsg = 'No messages yet. Start the conversation! 💬'; subMsg = null; } const emptyPadding = Math.max(0, Math.floor((terminalWidth - emptyMsg.length) / 2)); let verticalPadding = Math.floor(availableHeight / 2) - 1; // Adjust vertical padding if we have a subtitle if (subMsg) { verticalPadding = Math.floor(availableHeight / 2) - 2; } console.log('\n'.repeat(Math.max(0, verticalPadding))); console.log(chalk.dim.gray(' '.repeat(emptyPadding) + emptyMsg)); if (subMsg) { const subPadding = Math.max(0, Math.floor((terminalWidth - subMsg.length) / 2)); console.log(chalk.dim.gray(' '.repeat(subPadding) + subMsg)); console.log('\n'.repeat(Math.max(0, availableHeight - verticalPadding - 3))); } else { console.log('\n'.repeat(Math.max(0, availableHeight - verticalPadding - 2))); } return availableHeight; // Return total lines used } // Display messages with smart text wrapping console.log(); // Space after header let linesRendered = 1; // Count the space after header displayMessages.forEach((msg, index) => { const timestamp = new Date(msg.timestamp).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' }); // Calculate available width for message text const timeStampWidth = 8; // [HH:MM] const nameMaxWidth = 15; // Reasonable max for display names const prefixWidth = timeStampWidth + nameMaxWidth + 6; // spacing and colons const maxTextWidth = Math.max(40, terminalWidth - prefixWidth); const timeColor = chalk.dim.gray; let msgText; if (msg.from === 'System') { msgText = this.wrapText(msg.text, maxTextWidth); console.log(chalk.yellow(` ${timeColor(`[${timestamp}]`)} ${chalk.bold('System')}: ${msgText}`)); } else if (msg.from === 'You') { const displayName = this.getDisplayName(this.currentChat.id, 'You'); msgText = this.wrapText(msg.text, maxTextWidth); console.log(chalk.green(` ${timeColor(`[${timestamp}]`)} ${chalk.bold(displayName)}: ${msgText}`)); } else { const displayName = this.getDisplayName(this.currentChat.id, msg.from); const userColor = this.getColorForUser(this.currentChat.id, displayName); msgText = this.wrapText(msg.text, maxTextWidth); console.log(` ${timeColor(`[${timestamp}]`)} ${userColor(chalk.bold(displayName))}: ${msgText}`); } // Count lines for this message (wrapped text can be multiple lines) const messageLines = msgText.split('\n').length; linesRendered += messageLines; // Add spacing between message groups (different users or time gaps) if (index < displayMessages.length - 1) { const nextMsg = displayMessages[index + 1]; const timeDiff = nextMsg.timestamp - msg.timestamp; const differentUser = nextMsg.from !== msg.from; if (timeDiff > 300000 || (differentUser && timeDiff > 60000)) { // 5 min gap or 1 min + different user console.log(); linesRendered += 1; // Count the spacing line } } }); return linesRendered; } wrapText(text, maxWidth) { if (text.length <= maxWidth) return text; const words = text.split(' '); const lines = []; let currentLine = ''; words.forEach(word => { if ((currentLine + word).length <= maxWidth) { currentLine += (currentLine ? ' ' : '') + word; } else { if (currentLine) lines.push(currentLine); currentLine = word; } }); if (currentLine) lines.push(currentLine); return lines.join('\n '); // Add indent for wrapped lines } getColorForUser(chatId, username) { // Get or assign a unique color for this user in this specific chat if (!this.chatColorAssignments.has(chatId)) { this.chatColorAssignments.set(chatId, new Map()); } const chatAssignments = this.chatColorAssignments.get(chatId); if (chatAssignments.has(username)) { return chatAssignments.get(username); } // Available colors const availableColors = [ chalk.blue, chalk.red, chalk.green, chalk.yellow, chalk.magenta, chalk.cyan, chalk.hex('#FFA500'), // Orange chalk.hex('#9370DB'), // Purple chalk.hex('#FF69B4'), // Pink chalk.hex('#32CD32'), // Lime chalk.hex('#008080'), // Teal chalk.hex('#FFD700') // Gold ]; // Assign colors in order to ensure each person gets a different color const colorIndex = chatAssignments.size % availableColors.length; const selectedColor = availableColors[colorIndex]; // Assign this color to the user in this chat chatAssignments.set(username, selectedColor); return selectedColor; } getUserColor(username) { // Use per-chat color assignment if we're in a chat if (this.currentChat) { return this.getColorForUser(this.currentChat.id, username); } // Original color system as fallback const colors = [ chalk.blue, chalk.magenta, chalk.yellow, chalk.cyan, chalk.red, chalk.green ]; let hash = 0; for (let i = 0; i < username.length; i++) { hash = username.charCodeAt(i) + ((hash << 5) - hash); } return colors[Math.abs(hash) % colors.length]; } // Helper method to check if we should store persistent messages shouldStorePersistentMessages() { return this.mode === CHAT_MODES.CHAT && this.node.peerKeys && this.node.peerKeys.size > 0; } displaySystemMessage(message, refresh = true) { if (this.mode === CHAT_MODES.CHAT) { // Only store messages if we have peers connected if (this.shouldStorePersistentMessages()) { // Add system message to chat and refresh display if (!this.messages.has(this.currentChat.id)) { this.messages.set(this.currentChat.id, []); } this.messages.get(this.currentChat.id).push({ from: 'System', text: message, timestamp: Date.now() }); if (refresh) { this.refreshChatDisplay(); } } // If no peers, the message is ignored (not stored in history) // This keeps the chat clean when no one is connected } else { logger.log(chalk.yellow(message)); } } exitChat() { const chatName = this.currentChat ? this.currentChat.name : 'chat'; // Exit alternative screen buffer to return to main terminal process.stdout.write('\x1b[?1049l'); this.currentPath = '/'; this.currentChat = null; this.mode = CHAT_MODES.DIRECTORY; console.clear(); // Show the full welcome banner when returning from a chat this.showWelcomeBanner(); // Force synchronous display of exit message logger.sync('log', chalk.green(`✅ Left chat "${chatName}"`)); logger.sync('log', ''); this.updatePrompt(); this.rl.prompt(); } clearChatScreen() { if (this.currentChat) { this.refreshChatDisplay(); } } setCustomName(name) { if (!this.currentChat) { this.displaySystemMessage('❌ You must be in a chat to set a name.'); return; } if (!name || name.length === 0) { this.displaySystemMessage('❌ Name cannot be empty.'); return; } if (name.length > 25) { this.displaySystemMessage('❌ Name too long (max 25 characters).'); return; } // Basic validation - only allow letters, numbers, spaces, and common punctuation if (!/^[a-zA-Z0-9\s\-_.!?]+$/.test(name)) { this.displaySystemMessage('❌ Name contains invalid characters. Use letters, numbers, spaces, and basic punctuation only.'); return; } // Store the name locally if (!this.customNames.has(this.currentChat.id)) { this.customNames.set(this.currentChat.id, new Map()); } this.customNames.get(this.currentChat.id).set('You', name); // Broadcast name change to all participants this.broadcastNameChange(name); this.displaySystemMessage(`✅ Name set to "${name}" for this chat.`); } handleNameChange(chatId, fromNode, customName) { // Store the custom name for this user in this chat if (!this.customNames.has(chatId)) { this.customNames.set(chatId, new Map()); } const chatNames = this.customNames.get(chatId); const previousName = chatNames.get(fromNode); chatNames.set(fromNode, customName); // Show notification if we're currently in this chat if (this.currentChat && this.currentChat.id === chatId) { if (previousName) { this.displaySystemMessage(`✨ ${previousName} is now known as ${customName}`); } else { this.displaySystemMessage(`✨ ${fromNode} is now known as ${customName}`); } } } getDisplayName(chatId, nodeId) { if (!this.customNames.has(chatId)) { return nodeId === 'You' ? 'You' : nodeId; } const chatNames = this.customNames.get(chatId); return chatNames.get(nodeId) || (nodeId === 'You' ? 'You' : nodeId); } enforceMessageLimit(chatId) { if (!this.messages.has(chatId)) return; const messages = this.messages.get(chatId); if (messages.length > this.maxMessagesPerChat) { // Remove oldest messages to stay within limit const messagesToRemove = messages.length - this.maxMessagesPerChat; messages.splice(0, messagesToRemove); } } broadcastNameChange(name) { if (!this.currentChat) return; const targets = Array.from(this.node.peerKeys.keys()); if (targets.length === 0) return; try { // Send name change notification to all peers const nameChangeMessage = `__NAME_CHANGE__:${name}`; targets.forEach(nodeId => { this.node.sendMessage(this.currentChat.id, nameChangeMessage, nodeId); }); } catch (error) { console.error('Failed to broadcast name change:', error); } } showColorAssignments() { if (!this.currentChat || !this.chatColorAssignments.has(this.currentChat.id)) { this.displaySystemMessage('No color assignments yet in this chat.'); return; } const assignments = this.chatColorAssignments.get(this.currentChat.id); if (assignments.size === 0) { this.displaySystemMessage('No participants with assigned colors yet.'); return; } this.displaySystemMessage('Current participants with their assigned colors:', false); for (const [username, colorFunc] of assignments.entries()) { const displayName = this.getDisplayName(this.currentChat.id, username); // Get color for display name to match message display const displayColor = this.getColorForUser(this.currentChat.id, displayName); const coloredName = displayColor(displayName); this.displaySystemMessage(` ${coloredName}`, false); } // Refresh display to show all color assignments if (this.mode === CHAT_MODES.CHAT) { this.refreshChatDisplay(); } } showChatHelp() { this.displaySystemMessage('Chat commands: /exit (leave chat), /help (this help), /clear (refresh screen)', false); this.displaySystemMessage('/colors (show participants), /name <name> (set custom name for this chat)', false); this.displaySystemMessage('Just type your message and press Enter to send it!', false); if (this.mode === CHAT_MODES.CHAT) { this.refreshChatDisplay(); } this.rl.prompt(); } showHelp() { const terminalWidth = process.stdout.columns || 80; console.log(chalk.bold.yellow('📖 MELQ Help & Commands')); console.log(chalk.dim('═'.repeat(terminalWidth - 2))); console.log(chalk.bold.cyan('\n🗂️ Navigation:')); console.log(chalk.cyan(' ls') + ' - ' + chalk.gray('List available chats with activity')); console.log(chalk.cyan(' cd <chat>') + ' - ' + chalk.gray('Enter a chat room')); console.log(chalk.bold.cyan('\n💬 Chat Management:')); console.log(chalk.cyan(' mkdir <name>') + ' - ' + chalk.gray('Create a new chat room')); console.log(chalk.cyan(' /exit') + ' - ' + chalk.gray('Leave current chat (when in chat mode)')); console.log(chalk.cyan(' /clear') + ' - ' + chalk.gray('Clear chat screen (when in chat mode)')); console.log(chalk.bold.cyan('\n🌐 Network:')); console.log(chalk.cyan(' discover') + ' - ' + chalk.gray('Find other MELQ nodes on network')); console.log(chalk.cyan(' nodes') + ' - ' + chalk.gray('Show connected peers and status')); console.log(chalk.bold.cyan('\n🔧 Utilities:')); console.log(chalk.cyan(' clear') + ' - ' + chalk.gray('Clear terminal screen')); console.log(chalk.cyan(' help') + ' - ' + chalk.gray('Show this help message')); console.log(chalk.cyan(' Ctrl+C') + ' - ' + chalk.gray('Exit MELQ')); console.log(chalk.dim('\n💡 Pro tip: Use filesystem-like commands to navigate chats!')); console.log(); } setConnectionInfo(connectionInfo) { this.connectionInfo = connectionInfo; } showConnectionInfo() { if (!this.connectionInfo) return; const terminalWidth = Math.min(process.stdout.columns || 80, 80); console.log(chalk.blue('\n📡 Connection Details:')); console.log(chalk.dim('─'.repeat(terminalWidth - 2))); // Show hosting status if applicable if (this.connectionInfo.isHost) { console.log(chalk.dim.gray(` 🏠 Role: ${chalk.green('Hosting')} (you started this network)`)); if (this.connectionInfo.hasInternet) { console.log(chalk.dim.gray(` 🌍 Access: ${chalk.white('Local + Internet')} (both connection codes active)`)); } else { console.log(chalk.dim.gray(` 🏠 Access: ${chalk.white('Local Network Only')}`)); } } // Show connection codes (local and internet if available) if (this.connectionInfo.localConnectionCode) { const displayCode = this.connectionInfo.localConnectionCode.length > 40 ? this.connectionInfo.localConnectionCode.substring(0, 37) + '...' : this.connectionInfo.localConnectionCode; console.log(chalk.dim.gray(` 🏠 Local: ${chalk.white(displayCode)}`)); } if (this.connectionInfo.internetConnectionCode) { const displayCode = this.connectionInfo.internetConnectionCode.length > 40 ? this.connectionInfo.internetConnectionCode.substring(0, 37) + '...' : this.connectionInfo.internetConnectionCode; console.log(chalk.dim.gray(` 🌐 Internet: ${chalk.white(displayCode)}`)); } // Fallback for older single connectionCode format if (this.connectionInfo.connectionCode && !this.connectionInfo.localConnectionCode && !this.connectionInfo.internetConnectionCode) { const displayCode = this.connectionInfo.connectionCode.length > 40 ? this.connectionInfo.connectionCode.substring(0, 37) + '...' : this.connectionInfo.connectionCode; console.log(chalk.dim.gray(` 📍 Connected to: ${chalk.white(displayCode)}`)); } // Only show method for non-host connections (clients) if (this.connectionInfo.method && !this.connectionInfo.isHost) { const methodIcon = this.connectionInfo.method === 'local' ? '🏠' : '🌐'; const methodText = this.connectionInfo.method === 'local' ? 'Local Network' : this.connectionInfo.method === 'internet' ? 'Internet' : this.connectionInfo.method; console.log(chalk.dim.gray(` ${methodIcon} Access: ${chalk.white(methodText)}`)); } if (this.connectionInfo.tunnelMethod && this.connectionInfo.tunnelMethod !== 'local') { const tunnelIcon = '🚇'; console.log(chalk.dim.gray(` ${tunnelIcon} Tunnel: ${chalk.white(this.connectionInfo.tunnelMethod)}`)); } const peerCount = this.node.peerKeys ? this.node.peerKeys.size : 0; const peerStatus = peerCount > 0 ? chalk.green(`${peerCount} peers`) : chalk.yellow('No peers yet'); console.log(chalk.dim.gray(` 👥 Network: ${peerStatus}`)); console.log(); } showWelcomeBanner() { const terminalWidth = process.stdout.columns || 80; const title = '🔐 MELQ - Quantum-Secure P2P Chat'; const subtitle = 'Connected as: ' + this.node.nodeId.slice(-8); const peerCount = this.node.peerKeys ? this.node.peerKeys.size : 0; const status = `Status: ${peerCount > 0 ? '🟢 Connected' : '🔴 Waiting for peers'}`; // Create beautiful welcome banner const banner = '═'.repeat(terminalWidth - 2); const titlePadding = Math.max(0, Math.floor((terminalWidth - title.length - 2) / 2)); const subtitlePadding = Math.max(0, Math.floor((terminalWidth - subtitle.length - 2) / 2)); const statusPadding = Math.max(0, Math.floor((terminalWidth - status.length - 2) / 2)); console.log(chalk.cyan('╔' + banner + '╗')); console.log(chalk.cyan('║') + ' '.repeat(titlePadding) + chalk.bold.white(title) + ' '.repeat(terminalWidth - title.length - titlePadding - 2) + chalk.cyan('║')); console.log(chalk.cyan('║') + ' '.repeat(terminalWidth - 2) + chalk.cyan('║')); console.log(chalk.cyan('║') + ' '.repeat(subtitlePadding) + chalk.dim.gray(subtitle)