@tastekim/chat-cli
Version:
π¬Connect with developers worldwide through an interactive terminal chat experience while you code!π»
922 lines β’ 49.2 kB
JavaScript
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