UNPKG

@tastekim/chat-cli

Version:

πŸ’¬Connect with developers worldwide through an interactive terminal chat experience while you code!πŸ’»

922 lines β€’ 49.2 kB
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime"; import { useState, useEffect, useCallback } from 'react'; import { render, Box, Text, useInput, useApp } from 'ink'; import { WebSocketClient } from '../core/client.js'; // Custom hook for terminal dimensions const useTerminalDimensions = () => { const [dimensions, setDimensions] = useState({ width: process.stdout.columns || 80, height: process.stdout.rows || 24 }); useEffect(() => { const handleResize = () => { setDimensions({ width: process.stdout.columns || 80, height: process.stdout.rows || 24 }); }; process.stdout.on('resize', handleResize); return () => { process.stdout.off('resize', handleResize); }; }, []); return dimensions; }; // Dynamic layout calculator const getDynamicLayout = (terminalWidth) => { if (terminalWidth < 60) { return { roomList: 0, chat: 100, userList: 0 }; // Hide sidebars in small terminals } else if (terminalWidth < 80) { return { roomList: 25, chat: 75, userList: 0 }; // Hide user list only } else { return { roomList: 25, chat: 60, userList: 15 }; // Full layout } }; // Parse message content for code blocks and markdown const parseMessageContent = (content) => { const parts = []; // Split by code blocks (```language\ncode\n```) const codeBlockRegex = /```(\w+)?\n([\s\S]*?)\n```/g; let lastIndex = 0; let match; while ((match = codeBlockRegex.exec(content)) !== null) { // Add text before code block if (match.index > lastIndex) { const textBefore = content.slice(lastIndex, match.index); if (textBefore.trim()) { parts.push({ type: 'text', content: textBefore }); } } // Add code block const language = match[1] || 'text'; const codeContent = match[2]; parts.push({ type: 'code', content: codeContent, language }); lastIndex = match.index + match[0].length; } // Add remaining text if (lastIndex < content.length) { const remainingText = content.slice(lastIndex); if (remainingText.trim()) { parts.push({ type: 'text', content: remainingText }); } } // If no code blocks found, treat as plain text or markdown if (parts.length === 0) { // Check if it has markdown syntax const hasMarkdown = /(\*\*.*?\*\*|\*.*?\*|`.*?`|#+ |>+ |\[.*?\]\(.*?\))/.test(content); parts.push({ type: hasMarkdown ? 'markdown' : 'text', content }); } return parts; }; // Code block component with syntax highlighting const CodeBlock = ({ content, language }) => { return (_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, paddingY: 0, marginY: 0, flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { color: "gray", dimColor: true, children: ["\uD83D\uDCC4 ", language || 'code'] }) }), content.split('\n').map((line, index) => (_jsx(Text, { color: "green", inverse: true, children: line }, index)))] })); }; // Message bubble component with markdown and code block support const MessageBubble = ({ message, isOwn, terminalWidth }) => { const layout = getDynamicLayout(terminalWidth); const maxMessageWidth = Math.max(20, Math.floor((layout.chat / 100) * terminalWidth - 10)); // Parse message content const parsedContent = parseMessageContent(message.content); const hasCodeBlocks = parsedContent.some(part => part.type === 'code'); return (_jsx(Box, { flexDirection: "row", justifyContent: isOwn ? 'flex-end' : 'flex-start', marginY: 0, paddingX: 1, children: _jsx(Box, { borderStyle: "round", borderColor: isOwn ? "white" : "cyan", paddingX: 1, paddingY: hasCodeBlocks ? 1 : 0, width: maxMessageWidth > 80 ? "80%" : "auto", flexDirection: "column", children: parsedContent.map((part, index) => { switch (part.type) { case 'code': return (_jsx(CodeBlock, { content: part.content, language: part.language }, index)); case 'markdown': // Simple markdown rendering without external dependency const renderSimpleMarkdown = (text) => { // Bold **text** const boldRegex = /\*\*(.*?)\*\*/g; // Italic *text* const italicRegex = /\*(.*?)\*/g; // Inline code `code` const codeRegex = /`([^`]+)`/g; const parts = []; let lastIndex = 0; let match; // Process bold text while ((match = boldRegex.exec(text)) !== null) { if (match.index > lastIndex) { parts.push({ type: 'text', content: text.slice(lastIndex, match.index) }); } parts.push({ type: 'bold', content: match[1] }); lastIndex = match.index + match[0].length; } if (lastIndex < text.length) { parts.push({ type: 'text', content: text.slice(lastIndex) }); } return parts.map((part, i) => { switch (part.type) { case 'bold': return _jsx(Text, { bold: true, color: isOwn ? "white" : "cyan", children: part.content }, i); default: return _jsx(Text, { color: isOwn ? "white" : "cyan", children: part.content }, i); } }); }; return (_jsx(Box, { children: renderSimpleMarkdown(part.content) }, index)); case 'text': default: // Handle multiline text const lines = part.content.split('\n'); return lines.length > 1 ? (_jsx(Box, { flexDirection: "column", children: lines.map((line, lineIndex) => (_jsx(Text, { color: isOwn ? "white" : "cyan", wrap: "wrap", children: line }, lineIndex))) }, index)) : (_jsx(Text, { color: isOwn ? "white" : "cyan", wrap: "wrap", children: part.content }, index)); } }) }) })); }; // System message component const SystemMessage = ({ message }) => { const color = message.type === 'error' ? 'red' : 'yellow'; return (_jsx(Box, { justifyContent: "center", marginY: 0, children: _jsx(Text, { color: color, dimColor: true, italic: true, children: message.content }) })); }; // Responsive sidebar component const ResponsiveSidebar = ({ type, terminalWidth, children }) => { const layout = getDynamicLayout(terminalWidth); const width = type === 'room' ? layout.roomList : layout.userList; if (width === 0) return null; // Hide sidebar if no space return (_jsx(Box, { width: `${width}%`, borderStyle: "round", borderColor: "gray", children: children })); }; // Room list component const RoomList = ({ rooms, currentRoom, title, unreadRooms, onSelect }) => { return (_jsxs(Box, { flexDirection: "column", height: "100%", overflow: "hidden", children: [_jsx(Box, { paddingY: 0, paddingX: 1, justifyContent: "flex-start", height: 1, flexShrink: 0, children: _jsx(Text, { color: "cyan", dimColor: true, children: title }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, paddingX: 1, overflow: "hidden", children: rooms.map((room, index) => (_jsx(Box, { paddingY: 0, flexShrink: 0, children: _jsxs(Text, { color: room.name === currentRoom ? 'green' : 'white', bold: room.name === currentRoom, children: [index + 1, ". ", room.isPrivate ? 'πŸ”’' : '#', " ", room.name, " (", room.userCount, ")", unreadRooms.has(room.name) && room.name !== currentRoom && (_jsx(Text, { color: "green", bold: true, children: " \u2022" }))] }) }, room.name))) })] })); }; // User list component const UserList = ({ users, currentUser }) => { return (_jsxs(Box, { flexDirection: "column", height: "100%", overflow: "hidden", children: [_jsx(Box, { paddingY: 0, paddingX: 1, justifyContent: "flex-start", height: 1, flexShrink: 0, children: _jsxs(Text, { color: "cyan", dimColor: true, children: ["Users (", users.length, ")"] }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, paddingX: 1, overflow: "hidden", children: users.map((user, index) => (_jsx(Box, { paddingY: 0, flexShrink: 0, children: _jsxs(Text, { color: user === currentUser ? 'green' : 'white', bold: user === currentUser, children: [user === currentUser ? 'πŸ‘€' : 'πŸ‘₯', " ", user] }) }, user))) })] })); }; // Chat panel component const ChatPanel = ({ terminalWidth, panelHeight, currentRoomId, messages, isTyping, currentInput, nickname }) => { const layout = getDynamicLayout(terminalWidth); const messageAreaHeight = panelHeight / 3.5; const maxMessages = Math.max(1, messageAreaHeight); return (_jsxs(Box, { flexDirection: "column", width: `${layout.chat}%`, borderStyle: "round", borderColor: "cyan", minWidth: "40", height: "100%", children: [_jsx(Box, { paddingY: 0, paddingX: 1, justifyContent: "flex-start", height: 1, flexShrink: 0, children: _jsxs(Text, { color: "cyan", dimColor: true, children: ["Chat - ", currentRoomId] }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, paddingX: 1, overflow: "hidden", children: messages.slice(-maxMessages).map((message, index) => { if (message.type === 'system' || message.type === 'error') { return _jsx(SystemMessage, { message: message }, index); } return (_jsx(MessageBubble, { message: message, isOwn: message.type === 'own', terminalWidth: terminalWidth }, message.id)); }) }), _jsx(Box, { height: 1, paddingX: 1, flexShrink: 0, children: isTyping && currentInput ? (_jsxs(Text, { color: "gray", dimColor: true, children: ["\uD83D\uDCAD ", nickname, " is typing..."] })) : (_jsx(Text, { children: " " })) })] })); }; // Simple Input component const ChatInput = ({ value, placeholder, isFocused = true }) => { return (_jsxs(Box, { flexDirection: "row", alignItems: "center", width: "100%", paddingX: 1, paddingY: 0, height: "100%", children: [_jsx(Box, { marginRight: 1, children: _jsx(Text, { color: "cyan", children: "\uD83D\uDCAC" }) }), _jsxs(Text, { wrap: "wrap", children: [value || (placeholder && _jsx(Text, { color: "gray", dimColor: true, children: placeholder })), isFocused && _jsx(Text, { color: "cyan", children: "|" })] })] })); }; const ChatInterface = ({ nickname, room, location }) => { const { exit } = useApp(); const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions(); // State management const [currentViewingRoom, setCurrentViewingRoom] = useState(room); // ν˜„μž¬ UIμ—μ„œ 보고 μžˆλŠ” λ°© const [joinedRooms, setJoinedRooms] = useState([]); // μ‹€μ œλ‘œ μ°Έμ—¬ν•œ λ°©λ“€ const [availableRooms, setAvailableRooms] = useState([]); const [messages, setMessages] = useState([]); // ν˜„μž¬ 보고 μžˆλŠ” 방의 λ©”μ‹œμ§€ const [allRoomMessages, setAllRoomMessages] = useState({}); // λͺ¨λ“  방의 λ©”μ‹œμ§€ const [unreadRooms, setUnreadRooms] = useState(new Set()); // 읽지 μ•Šμ€ λ©”μ‹œμ§€κ°€ μžˆλŠ” λ°©λ“€ const [currentInput, setCurrentInput] = useState(''); const [showJoinedRooms, setShowJoinedRooms] = useState(true); const [isConnected, setIsConnected] = useState(false); const [isTyping, setIsTyping] = useState(false); const [currentRoomUsers, setCurrentRoomUsers] = useState([]); const [isCreatingRoom, setIsCreatingRoom] = useState(false); const [roomCreationStep, setRoomCreationStep] = useState(null); const [pendingRoomData, setPendingRoomData] = useState({}); const [isJoiningRoom, setIsJoiningRoom] = useState(false); const [pendingJoinRoom, setPendingJoinRoom] = useState(null); // WebSocket client const [client] = useState(() => new WebSocketClient()); // Handle terminal resize with immediate responsive behavior useEffect(() => { const handleResize = () => { // Clear screen on resize to prevent UI duplication process.stdout.write('\x1b[2J\x1b[H'); }; process.stdout.on('resize', handleResize); return () => { process.stdout.off('resize', handleResize); }; }, []); // Prevent scrolling beyond terminal bounds useEffect(() => { // Disable line wrapping and hide cursor process.stdout.write('\x1b[?7l'); // Disable line wrapping process.stdout.write('\x1b[?25l'); // Hide cursor return () => { process.stdout.write('\x1b[?25h'); // Show cursor process.stdout.write('\x1b[?7h'); // Enable line wrapping }; }, []); // Add message to specific room (or current viewing room if no room specified) const addMessageToRoom = useCallback((roomId, type, content, sender) => { const newMessage = { id: `${Date.now()}-${Math.random()}`, type, content, sender }; // Store in all room messages setAllRoomMessages(prev => { const roomMessages = prev[roomId] || []; // Check for duplicate messages (same content, same sender, within 1 second) const isDuplicate = roomMessages.some(msg => msg.content === content && msg.sender === sender && msg.type === type); if (isDuplicate && sender === nickname) { // Skip adding duplicate own messages (optimistic update already added it) return prev; } return { ...prev, [roomId]: [...roomMessages, newMessage] }; }); // If this message is for the currently viewing room, also update the messages state if (roomId === currentViewingRoom) { setMessages(prev => { // Check for duplicates in current messages too const isDuplicate = prev.some(msg => msg.content === content && msg.sender === sender && msg.type === type); if (isDuplicate && sender === nickname) { return prev; // Don't add duplicate own messages } return [...prev, newMessage]; }); } else { // If message is for a different room and not from self, mark as unread if (sender && sender !== nickname && type === 'user') { setUnreadRooms(prev => new Set([...prev, roomId])); } } }, [currentViewingRoom, nickname]); // Add message to current viewing room (for system messages, etc.) const addMessage = useCallback((type, content, sender) => { addMessageToRoom(currentViewingRoom, type, content, sender); }, [currentViewingRoom, addMessageToRoom]); // Handle WebSocket events useEffect(() => { const setupClient = async () => { try { addMessage('system', 'Connecting to server...'); await client.connectWithParams(nickname, room, location); setIsConnected(true); // Set the initial viewing room and add to joined rooms setCurrentViewingRoom(room); const initialRoom = { name: room, isPrivate: false, userCount: 1 }; setJoinedRooms([initialRoom]); // Add self to user list setCurrentRoomUsers([nickname]); addMessage('system', `Connected! Welcome to ${room}.`); addMessage('system', 'Type /help to see available commands'); // Setup event handlers client.on('message', (data) => { try { const message = JSON.parse(data); switch (message.type) { case 'CHAT_MESSAGE': handleChatMessage(message.payload); break; case 'ROOM_LIST': handleRoomList(message.payload); break; case 'ROOM_CREATED': handleRoomCreated(message.payload); break; case 'ROOM_DELETED': handleRoomDeleted(message.payload); break; case 'USER_COUNT_UPDATE': handleUserCountUpdate(message.payload); break; case 'JOIN_ROOM_SUCCESS': handleJoinRoomSuccess(message.payload); break; case 'JOIN_ROOM_ERROR': handleJoinRoomError(message.payload); break; case 'ERROR': addMessage('error', `Server Error: ${message.payload.message}`); break; default: addMessage('system', `Unknown message type: ${message.type}`); } } catch (err) { addMessage('error', `Failed to parse message: ${err instanceof Error ? err.message : 'Unknown error'}`); } }); client.on('disconnected', (data) => { setIsConnected(false); addMessage('system', `Disconnected: ${data.reason || 'Connection lost'}`); }); client.on('error', (error) => { addMessage('error', `Connection error: ${error.message || 'Unknown error'}`); }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); addMessage('error', `Failed to connect: ${errorMessage}`); } }; setupClient(); return () => { client.disconnect(); }; }, [client, nickname, location]); // Removed room and addMessage to prevent reconnections // Message handlers const handleChatMessage = useCallback((payload) => { const { roomId, sender, content } = payload; const messageType = sender === nickname ? 'own' : (sender === 'System' ? 'system' : 'user'); // Store message in the appropriate room addMessageToRoom(roomId, messageType, content, sender); // Update user list only for the currently viewing room if (roomId === currentViewingRoom && sender !== 'System' && sender !== nickname && !currentRoomUsers.includes(sender)) { setCurrentRoomUsers(prev => [...prev, sender]); } // Auto-add room to joined rooms if we receive a message from a room we're not tracking if (sender === 'System' && (content.includes('Welcome to') || content.includes('You have joined'))) { setJoinedRooms(prev => { const roomExists = prev.some(r => r.name === roomId); if (!roomExists) { const roomToAdd = availableRooms.find(r => r.name === roomId); if (roomToAdd) { return [...prev, roomToAdd]; } else { // Create a basic room info if not found in available rooms const newRoom = { name: roomId, isPrivate: false, userCount: 1 }; return [...prev, newRoom]; } } return prev; }); // Auto-switch to the new room if this is a room creation welcome message if (content.includes('Welcome to') && roomId !== currentViewingRoom) { // Switch to the new room immediately setTimeout(() => { const roomHistory = allRoomMessages[roomId] || []; setCurrentViewingRoom(roomId); setMessages(roomHistory); setCurrentRoomUsers([nickname]); }, 100); // Small delay to ensure state updates } } }, [nickname, currentViewingRoom, addMessageToRoom, availableRooms, currentRoomUsers]); const handleRoomList = useCallback((payload) => { setAvailableRooms(payload.rooms || []); // Update joined rooms info with current room counts setJoinedRooms(prev => prev.map(joinedRoom => { const updatedRoom = (payload.rooms || []).find((r) => r.name === joinedRoom.name); return updatedRoom || joinedRoom; })); }, []); const handleRoomCreated = useCallback((payload) => { setAvailableRooms(prev => { if (!prev.some(r => r.name === payload.name)) { addMessage('system', `🏠 New room '${payload.name}' has been created!`); return [...prev, payload]; } return prev; }); }, [addMessage]); const handleRoomDeleted = useCallback((payload) => { setAvailableRooms(prev => prev.filter(r => r.name !== payload.name)); setJoinedRooms(prev => { const filtered = prev.filter(r => r.name !== payload.name); const hasLobby = filtered.some(r => r.name === 'Lobby'); if (!hasLobby) { // Always ensure Lobby is in the joined rooms filtered.unshift({ name: 'Lobby', isPrivate: false, userCount: 1 }); } return filtered; }); // If we're currently viewing the deleted room, switch to Lobby if (currentViewingRoom === payload.name) { setCurrentViewingRoom('Lobby'); setMessages(allRoomMessages['Lobby'] || []); addMessage('system', `Room '${payload.name}' was deleted. Switched to Lobby.`); } }, [currentViewingRoom, allRoomMessages, addMessage]); const handleUserCountUpdate = useCallback((payload) => { setAvailableRooms(prev => prev.map(r => r.name === payload.name ? { ...r, userCount: payload.userCount } : r)); setJoinedRooms(prev => prev.map(r => r.name === payload.name ? { ...r, userCount: payload.userCount } : r)); // Update current room users if it's the currently viewing room if (payload.name === currentViewingRoom) { const userCount = payload.userCount; if (userCount > 0) { // Create a user list with self and placeholder for others const users = [nickname]; for (let i = 1; i < userCount; i++) { users.push(`User${i}`); } setCurrentRoomUsers(users); } else { setCurrentRoomUsers([]); } } }, [currentViewingRoom, nickname]); // Handle join room success const handleJoinRoomSuccess = useCallback((payload) => { const { roomName } = payload; addMessage('system', `βœ… Successfully joined room: ${roomName}`); // Find the room in available rooms and add to joined rooms const targetRoom = availableRooms.find(r => r.name === roomName); if (targetRoom) { setJoinedRooms(prev => { if (!prev.some(r => r.name === targetRoom.name)) { return [...prev, targetRoom]; } return prev; }); // Switch to the new room setTimeout(() => { const roomHistory = allRoomMessages[roomName] || []; setCurrentViewingRoom(roomName); setMessages(roomHistory); setCurrentRoomUsers([nickname]); }, 100); } }, [availableRooms, allRoomMessages, nickname]); // Handle join room error const handleJoinRoomError = useCallback((payload) => { const { message } = payload; if (message.includes('password') || message.includes('incorrect')) { addMessage('error', 'πŸ”’ Incorrect password. Please try again.'); } else { addMessage('error', `❌ Failed to join room: ${message}`); } }, [addMessage]); // Graceful exit handler const handleGracefulExit = useCallback(() => { addMessage('system', 'πŸ‘‹ Goodbye! Disconnecting from chat...'); // Disconnect from WebSocket try { client.disconnect(); } catch (error) { // Ignore disconnect errors during exit } // Show goodbye message and exit after a short delay setTimeout(() => { // Clear screen and show final message process.stdout.write('\x1b[?1049l'); // Disable alternative screen buffer process.stdout.write('\x1b[2J\x1b[H'); // Clear screen console.log('\nπŸ‘‹ Thanks for using Chat CLI! See you next time!\n'); exit(); }, 1000); }, [client, addMessage, exit]); // Handle system signals for graceful shutdown useEffect(() => { const handleSignal = () => { handleGracefulExit(); }; process.on('SIGINT', handleSignal); process.on('SIGTERM', handleSignal); return () => { process.off('SIGINT', handleSignal); process.off('SIGTERM', handleSignal); }; }, [handleGracefulExit]); // Global key handling for manual input useInput((input, key) => { if (key.escape || (key.ctrl && input === 'c')) { handleGracefulExit(); return; } // Handle Enter for submit if (key.return) { if (currentInput.trim()) { handleInputSubmit(currentInput); } setCurrentInput(''); setIsTyping(false); return; } // Handle regular character input if (input && !key.ctrl && !key.meta && !key.escape && !key.tab && !key.return) { setCurrentInput(prev => prev + input); setIsTyping(true); return; } // Handle backspace if (key.backspace || key.delete) { setCurrentInput(prev => { const newValue = prev.slice(0, -1); if (newValue.length === 0) { setIsTyping(false); } return newValue; }); return; } if (key.tab) { // If input starts with '/', show command hints if (currentInput.startsWith('/')) { addMessage('system', 'πŸ“‹ Available commands:'); [ { command: '/help', description: 'Show available commands' }, { command: '/create-room', description: 'Create a new chat room' }, { command: '/join <room>', description: 'Join a specific room' }, { command: '/leave', description: 'Leave current room' }, { command: '/users', description: 'List users in current room' }, { command: '/rooms', description: 'List available rooms' }, { command: '/clear', description: 'Clear chat history' }, { command: '/quit', description: 'Exit the application' }, { command: '/1, /2, /3...', description: 'Switch to joined room by number' }, ].forEach(cmd => { addMessage('system', `${cmd.command} - ${cmd.description}`); }); return; } // Otherwise toggle room view setShowJoinedRooms(prev => !prev); return; } }); // Switch room function (defined before usage) - now just changes the viewing room const switchToRoom = useCallback((roomName) => { // Check if we're actually joined to this room const isJoined = joinedRooms.some(r => r.name === roomName); if (!isJoined) { addMessage('error', `You are not in room '${roomName}'. Use /join ${roomName} to join first.`); return; } // Ensure we're switching to a different room if (currentViewingRoom === roomName) { return; } // Load chat history for the room const roomHistory = allRoomMessages[roomName] || []; // Update state synchronously to ensure consistency setCurrentViewingRoom(roomName); setMessages(roomHistory); // Clear unread status for this room setUnreadRooms(prev => { const newSet = new Set(prev); newSet.delete(roomName); return newSet; }); // Reset user list for the new room setCurrentRoomUsers([nickname]); // Add message to the NEW room (use addMessageToRoom to ensure it goes to the right room) addMessageToRoom(roomName, 'system', `Now viewing room: ${roomName}`); }, [allRoomMessages, joinedRooms, nickname, currentViewingRoom, addMessageToRoom]); // Handle message submission const handleInputSubmit = useCallback((value) => { if (!value.trim()) { setCurrentInput(''); setIsTyping(false); return; } const trimmedValue = value.trim(); // Handle commands if (trimmedValue.startsWith('/')) { const command = trimmedValue.toLowerCase(); switch (command) { case '/help': addMessage('system', 'πŸ“‹ Available commands:'); [ { command: '/help', description: 'Show available commands' }, { command: '/create-room', description: 'Create a new chat room' }, { command: '/join <room>', description: 'Join a specific room' }, { command: '/leave', description: 'Leave current room' }, { command: '/users', description: 'List users in current room' }, { command: '/rooms', description: 'List available rooms' }, { command: '/clear', description: 'Clear chat history' }, { command: '/quit', description: 'Exit the application' }, { command: '/1, /2, /3...', description: 'Switch to joined room by number' }, ].forEach(cmd => { addMessage('system', `${cmd.command} - ${cmd.description}`); }); break; case '/create-room': addMessage('system', '🏠 Starting room creation process...'); addMessage('system', 'Enter room name (or type "cancel" to abort):'); setIsCreatingRoom(true); setRoomCreationStep('name'); setPendingRoomData({}); break; case '/users': const userList = currentRoomUsers.length > 0 ? currentRoomUsers.join(', ') : 'No other users'; addMessage('system', `πŸ‘₯ Users in ${currentViewingRoom}: ${userList}`); break; case '/rooms': if (availableRooms.length > 0) { addMessage('system', '🏠 Available rooms:'); availableRooms.forEach(room => { addMessage('system', `${room.name} (${room.userCount} users) ${room.isPrivate ? 'πŸ”’' : '🌐'}`); }); } else { addMessage('system', 'No rooms available'); } break; case '/clear': setMessages([]); addMessage('system', '🧹 Chat history cleared'); break; case '/quit': case '/exit': handleGracefulExit(); break; case '/leave': if (currentViewingRoom === 'Lobby') { addMessage('error', 'Cannot leave the Lobby room'); } else { const roomToLeave = currentViewingRoom; addMessage('system', `πŸšͺ Leaving room: ${roomToLeave}`); // Send leave room message client.sendWebSocketMessage({ type: 'LEAVE_ROOM', payload: { name: roomToLeave } }); // Remove from joined rooms (but ensure Lobby is always present) setJoinedRooms(prev => { const filtered = prev.filter(r => r.name !== roomToLeave); const hasLobby = filtered.some(r => r.name === 'Lobby'); if (!hasLobby) { // Always ensure Lobby is in the joined rooms filtered.unshift({ name: 'Lobby', isPrivate: false, userCount: 1 }); } return filtered; }); // Switch to Lobby view switchToRoom('Lobby'); } break; default: if (command.startsWith('/join ')) { const roomName = trimmedValue.substring(6).trim(); if (roomName) { // Check if room exists in available rooms const targetRoom = availableRooms.find(r => r.name.toLowerCase() === roomName.toLowerCase()); if (targetRoom) { // Check if already joined const alreadyJoined = joinedRooms.some(r => r.name === targetRoom.name); if (alreadyJoined) { addMessage('system', `You are already in room '${targetRoom.name}'. Switching view...`); switchToRoom(targetRoom.name); } else { // Check if room is private if (targetRoom.isPrivate) { addMessage('system', `πŸ”’ Room '${targetRoom.name}' is private. Please enter the password:`); setIsJoiningRoom(true); setPendingJoinRoom({ name: targetRoom.name, isPrivate: true }); } else { // Public room - join directly addMessage('system', `πŸšͺ Joining room: ${targetRoom.name}`); attemptJoinRoom(targetRoom.name, ''); } } } else { addMessage('error', `Room '${roomName}' not found. Use /rooms to see available rooms.`); } } else { addMessage('error', 'Usage: /join <room-name>'); } } else if (command.match(/^\/[1-9]$/)) { // Handle room switching by number (/1, /2, /3, etc.) const roomNumber = parseInt(command.substring(1)); const roomIndex = roomNumber - 1; if (roomIndex < joinedRooms.length) { const targetRoom = joinedRooms[roomIndex]; if (targetRoom && targetRoom.name !== currentViewingRoom) { // Use a more immediate approach for room switching const roomHistory = allRoomMessages[targetRoom.name] || []; setCurrentViewingRoom(targetRoom.name); setMessages(roomHistory); setCurrentRoomUsers([nickname]); // Clear unread status for this room setUnreadRooms(prev => { const newSet = new Set(prev); newSet.delete(targetRoom.name); return newSet; }); // Add confirmation message to the new room addMessageToRoom(targetRoom.name, 'system', `πŸ”„ Switched to room: ${targetRoom.name}`); } else if (targetRoom && targetRoom.name === currentViewingRoom) { addMessage('system', `You are already viewing ${targetRoom.name}`); } } else { addMessage('error', `Room ${roomNumber} not found. You have ${joinedRooms.length} joined rooms.`); } } else { addMessage('error', `Unknown command: ${command}. Type /help for available commands.`); } break; } } else { // Check if we're in room creation mode if (isCreatingRoom && roomCreationStep) { handleRoomCreationInput(trimmedValue); } else if (isJoiningRoom && pendingJoinRoom) { // Handle room joining password input handleRoomJoinInput(trimmedValue); } else { // Regular message - ensure we're sending to the correct room if (!currentViewingRoom) { addMessage('error', 'No room selected. Cannot send message.'); setCurrentInput(''); setIsTyping(false); return; } // Double-check that we're actually joined to this room const isJoinedToCurrentRoom = joinedRooms.some(r => r.name === currentViewingRoom); if (!isJoinedToCurrentRoom) { addMessage('error', `You are not joined to room '${currentViewingRoom}'. Use /join to join a room first.`); setCurrentInput(''); setIsTyping(false); return; } const messageData = { type: 'SEND_MESSAGE', payload: { roomId: currentViewingRoom, content: trimmedValue, }, }; const sent = client.sendWebSocketMessage(messageData); if (!sent) { addMessage('error', 'Failed to send message - connection not available'); } else { // Optimistic update: immediately show the message in the UI addMessageToRoom(currentViewingRoom, 'own', trimmedValue, nickname); } } } setCurrentInput(''); setIsTyping(false); }, [client, currentViewingRoom, addMessage, currentRoomUsers, availableRooms, exit, switchToRoom, isCreatingRoom, roomCreationStep]); // Handle room creation input const handleRoomCreationInput = useCallback((input) => { if (input.toLowerCase() === 'cancel') { addMessage('system', '❌ Room creation cancelled.'); setIsCreatingRoom(false); setRoomCreationStep(null); setPendingRoomData({}); return; } switch (roomCreationStep) { case 'name': if (input.length < 1 || input.length > 15) { addMessage('error', 'Room name must be 1-15 characters long. Try again:'); return; } if (availableRooms.some(room => room.name.toLowerCase() === input.toLowerCase())) { addMessage('error', `Room '${input}' already exists. Choose a different name:`); return; } setPendingRoomData(prev => ({ ...prev, name: input })); addMessage('system', `Room name set to: ${input}`); addMessage('system', 'Make room private? (y/n):'); setRoomCreationStep('privacy'); break; case 'privacy': const isPrivate = input.toLowerCase() === 'y' || input.toLowerCase() === 'yes'; setPendingRoomData(prev => ({ ...prev, isPrivate })); if (isPrivate) { addMessage('system', 'Room will be private.'); addMessage('system', 'Enter password for the room:'); setRoomCreationStep('password'); } else { addMessage('system', 'Room will be public.'); // Create the room immediately const roomData = { name: pendingRoomData.name, isPrivate: false, password: '' }; createRoom(roomData); } break; case 'password': if (input.length < 1) { addMessage('error', 'Password cannot be empty. Try again:'); return; } // Create the room with password const roomData = { name: pendingRoomData.name, isPrivate: true, password: input }; createRoom(roomData); break; } }, [roomCreationStep, pendingRoomData, availableRooms, addMessage]); // Attempt to join room function const attemptJoinRoom = useCallback((roomName, password) => { // Send join room message const sent = client.sendWebSocketMessage({ type: 'JOIN_ROOM', payload: { name: roomName, password: password } }); if (sent) { addMessage('system', `πŸ”„ Attempting to join room: ${roomName}`); } else { addMessage('error', 'Failed to send join request - connection not available'); // Reset joining state setIsJoiningRoom(false); setPendingJoinRoom(null); } }, [client, addMessage]); // Handle room join input const handleRoomJoinInput = useCallback((input) => { if (input.toLowerCase() === 'cancel') { addMessage('system', '❌ Room join cancelled.'); setIsJoiningRoom(false); setPendingJoinRoom(null); return; } if (pendingJoinRoom) { const password = input.trim(); if (password.length === 0) { addMessage('error', 'Password cannot be empty. Please enter the password (or type "cancel" to abort):'); return; } // Attempt to join with password attemptJoinRoom(pendingJoinRoom.name, password); // Reset joining state setIsJoiningRoom(false); setPendingJoinRoom(null); } }, [pendingJoinRoom, addMessage, attemptJoinRoom]); // Create room function const createRoom = useCallback((roomData) => { addMessage('system', `πŸš€ Creating room '${roomData.name}'...`); // Check if connection is open if (!client.isConnectionOpen()) { addMessage('error', '❌ WebSocket connection is not open. Cannot send room creation request.'); // Reset creation state setIsCreatingRoom(false); setRoomCreationStep(null); setPendingRoomData({}); return; } const messageData = { type: 'CREATE_ROOM', payload: { name: roomData.name, isPrivate: roomData.isPrivate, password: roomData.password } }; try { const success = client.sendWebSocketMessage(messageData); if (success) { addMessage('system', 'βœ… Room creation request sent to server.'); } else { addMessage('error', '❌ Failed to send room creation request.'); } } catch (error) { addMessage('error', `❌ Error sending room creation request: ${error}`); } // Reset creation state setIsCreatingRoom(false); setRoomCreationStep(null); setPendingRoomData({}); }, [client, addMessage]); // Calculate available height for main content (total - header - input) // Ensure we don't exceed terminal bounds const headerHeight = 3; const inputHeight = 3; const availableHeight = Math.max(5, terminalHeight - headerHeight - inputHeight); const safeHeight = Math.min(terminalHeight, terminalHeight); return (_jsxs(Box, { flexDirection: "column", width: "100%", height: safeHeight, children: [_jsxs(Box, { justifyContent: "space-between", paddingX: 1, paddingY: 0, borderStyle: "round", borderColor: "cyan", height: headerHeight, flexShrink: 0, children: [_jsxs(Box, { flexDirection: "row", alignItems: "center", children: [_jsx(Text, { bold: true, color: "cyan", children: currentViewingRoom }), _jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: "gray", children: ["\u2014 ", nickname] }) })] }), _jsxs(Box, { flexDirection: "row", alignItems: "center", children: [_jsx(Text, { color: isConnected ? 'green' : 'red', children: isConnected ? '● ' : '● ' }), _jsx(Box, { marginRight: 1, children: _jsx(Text, { color: "gray", children: showJoinedRooms ? 'Joined' : 'Available' }) }), terminalWidth > 60 && (_jsx(Text, { color: "gray", dimColor: true, children: "Tab: Toggle/Commands | Ctrl+C: Exit" }))] })] }), _jsxs(Box, { flexDirection: "row", height: availableHeight, flexShrink: 0, children: [_jsx(ResponsiveSidebar, { type: "room", terminalWidth: terminalWidth, children: _jsx(RoomList, { rooms: showJoinedRooms ? joinedRooms : availableRooms, currentRoom: currentViewingRoom, title: showJoinedRooms ? 'Joined Rooms' : 'Available Rooms', unreadRooms: unreadRooms, onSelect: switchToRoom }) }), _jsx(ChatPanel, { terminalWidth: terminalWidth, panelHeight: availableHeight, currentRoomId: currentViewingRoom, messages: messages, isTyping: isTyping, currentInput: currentInput, nickname: nickname }), _jsx(ResponsiveSidebar, { type: "user", terminalWidth: terminalWidth, children: _jsx(UserList, { users: currentRoomUsers, currentUser: nickname }) })] }), _jsx(Box, { height: inputHeight, borderStyle: "round", borderColor: isCreatingRoom ? "yellow" : isJoiningRoom ? "magenta" : "cyan", flexShrink: 0, children: _jsx(ChatInput, { value: currentInput, placeholder: isCreatingRoom ? (roomCreationStep === 'name' ? 'Enter room name...' : roomCreationStep === 'privacy' ? 'Make private? (y/n)...' : roomCreationStep === 'password' ? 'Enter password...' : 'Type a message...') : isJoiningRoom ? 'Enter room password (or type "cancel")...' : 'Type a message...' }) })] })); }; export const startInkChatInterface = (nickname, room, location) => { // Clear entire screen and reset cursor position process.stdout.write('\x1b[2J\x1b[H'); // Force alternative screen buffer to prevent header clipping process.stdout.write('\x1b[?1049h'); // Enable alternative screen buffer const cleanup = () => { process.stdout.write('\x1b[?1049l'); // Disable alternative screen buffer }; // Cleanup on exit process.on('exit', cleanup); render(_jsx(ChatInterface, { nickname: nickname, room: room, location: location }), { exitOnCtrlC: false, patchConsole: false }); }; //# sourceMappingURL=ink-chat-interface.js.map