import React, { useEffect, useRef, useState } from 'react'; import { Terminal } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import { ClipboardAddon } from '@xterm/addon-clipboard'; import { WebglAddon } from '@xterm/addon-webgl'; import 'xterm/css/xterm.css'; // CSS to remove xterm focus outline const xtermStyles = ` .xterm .xterm-screen { outline: none !important; } .xterm:focus .xterm-screen { outline: none !important; } .xterm-screen:focus { outline: none !important; } `; // Inject styles if (typeof document !== 'undefined') { const styleSheet = document.createElement('style'); styleSheet.type = 'text/css'; styleSheet.innerText = xtermStyles; document.head.appendChild(styleSheet); } // Global store for shell sessions to persist across tab switches const shellSessions = new Map(); function Shell({ selectedProject, selectedSession, isActive }) { const terminalRef = useRef(null); const terminal = useRef(null); const fitAddon = useRef(null); const ws = useRef(null); const [isConnected, setIsConnected] = useState(false); const [isInitialized, setIsInitialized] = useState(false); const [isRestarting, setIsRestarting] = useState(false); const [lastSessionId, setLastSessionId] = useState(null); const [isConnecting, setIsConnecting] = useState(false); // Connect to shell function const connectToShell = () => { if (!isInitialized || isConnected || isConnecting) return; setIsConnecting(true); // Start the WebSocket connection connectWebSocket(); }; // Disconnect from shell function const disconnectFromShell = () => { if (ws.current) { ws.current.close(); ws.current = null; } // Clear terminal content completely if (terminal.current) { terminal.current.clear(); terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home } setIsConnected(false); setIsConnecting(false); }; // Restart shell function const restartShell = () => { setIsRestarting(true); // Clear ALL session storage for this project to force fresh start const sessionKeys = Array.from(shellSessions.keys()).filter(key => key.includes(selectedProject.name) ); sessionKeys.forEach(key => shellSessions.delete(key)); // Close existing WebSocket if (ws.current) { ws.current.close(); ws.current = null; } // Clear and dispose existing terminal if (terminal.current) { // Dispose terminal immediately without writing text terminal.current.dispose(); terminal.current = null; fitAddon.current = null; } // Reset states setIsConnected(false); setIsInitialized(false); // Force re-initialization after cleanup setTimeout(() => { setIsRestarting(false); }, 200); }; // Watch for session changes and restart shell useEffect(() => { const currentSessionId = selectedSession?.id || null; // Disconnect when session changes (user will need to manually reconnect) if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) { // Disconnect from current shell disconnectFromShell(); // Clear stored sessions for this project const allKeys = Array.from(shellSessions.keys()); allKeys.forEach(key => { if (key.includes(selectedProject.name)) { shellSessions.delete(key); } }); } setLastSessionId(currentSessionId); }, [selectedSession?.id, isInitialized]); // Initialize terminal when component mounts useEffect(() => { if (!terminalRef.current || !selectedProject || isRestarting) { return; } // Create session key for this project/session combination const sessionKey = selectedSession?.id || `project-${selectedProject.name}`; // Check if we have an existing session const existingSession = shellSessions.get(sessionKey); if (existingSession && !terminal.current) { try { // Reuse existing terminal terminal.current = existingSession.terminal; fitAddon.current = existingSession.fitAddon; ws.current = existingSession.ws; setIsConnected(existingSession.isConnected); // Reattach to DOM - dispose existing element first if needed if (terminal.current.element && terminal.current.element.parentNode) { terminal.current.element.parentNode.removeChild(terminal.current.element); } terminal.current.open(terminalRef.current); setTimeout(() => { if (fitAddon.current) { fitAddon.current.fit(); // Send terminal size to backend after reattaching if (ws.current && ws.current.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify({ type: 'resize', cols: terminal.current.cols, rows: terminal.current.rows })); } } }, 100); setIsInitialized(true); return; } catch (error) { // Clear the broken session and continue to create a new one shellSessions.delete(sessionKey); terminal.current = null; fitAddon.current = null; ws.current = null; } } if (terminal.current) { return; } // Initialize new terminal terminal.current = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: 'Menlo, Monaco, "Courier New", monospace', allowProposedApi: true, // Required for clipboard addon allowTransparency: false, convertEol: true, scrollback: 10000, tabStopWidth: 4, // Enable full color support windowsMode: false, macOptionIsMeta: true, macOptionClickForcesSelection: false, // Enhanced theme with full 16-color ANSI support + true colors theme: { // Basic colors background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#ffffff', cursorAccent: '#1e1e1e', selection: '#264f78', selectionForeground: '#ffffff', // Standard ANSI colors (0-7) black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510', blue: '#2472c8', magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5', // Bright ANSI colors (8-15) brightBlack: '#666666', brightRed: '#f14c4c', brightGreen: '#23d18b', brightYellow: '#f5f543', brightBlue: '#3b8eea', brightMagenta: '#d670d6', brightCyan: '#29b8db', brightWhite: '#ffffff', // Extended colors for better Claude output extendedAnsi: [ // 16-color palette extension for 256-color support '#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0', '#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff' ] } }); fitAddon.current = new FitAddon(); const clipboardAddon = new ClipboardAddon(); const webglAddon = new WebglAddon(); terminal.current.loadAddon(fitAddon.current); terminal.current.loadAddon(clipboardAddon); try { terminal.current.loadAddon(webglAddon); } catch (error) { } terminal.current.open(terminalRef.current); // Wait for terminal to be fully rendered, then fit setTimeout(() => { if (fitAddon.current) { fitAddon.current.fit(); } }, 50); // Add keyboard shortcuts for copy/paste terminal.current.attachCustomKeyEventHandler((event) => { // Ctrl+C or Cmd+C for copy (when text is selected) if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) { document.execCommand('copy'); return false; } // Ctrl+V or Cmd+V for paste if ((event.ctrlKey || event.metaKey) && event.key === 'v') { navigator.clipboard.readText().then(text => { if (ws.current && ws.current.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify({ type: 'input', data: text })); } }).catch(err => { // Failed to read clipboard }); return false; } return true; }); // Ensure terminal takes full space and notify backend of size setTimeout(() => { if (fitAddon.current) { fitAddon.current.fit(); // Send terminal size to backend after fitting if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify({ type: 'resize', cols: terminal.current.cols, rows: terminal.current.rows })); } } }, 100); setIsInitialized(true); // Handle terminal input terminal.current.onData((data) => { if (ws.current && ws.current.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify({ type: 'input', data: data })); } }); // Add resize observer to handle container size changes const resizeObserver = new ResizeObserver(() => { if (fitAddon.current && terminal.current) { setTimeout(() => { fitAddon.current.fit(); // Send updated terminal size to backend after resize if (ws.current && ws.current.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify({ type: 'resize', cols: terminal.current.cols, rows: terminal.current.rows })); } }, 50); } }); if (terminalRef.current) { resizeObserver.observe(terminalRef.current); } return () => { resizeObserver.disconnect(); // Store session for reuse instead of disposing if (terminal.current && selectedProject) { const sessionKey = selectedSession?.id || `project-${selectedProject.name}`; try { shellSessions.set(sessionKey, { terminal: terminal.current, fitAddon: fitAddon.current, ws: ws.current, isConnected: isConnected }); } catch (error) { } } }; }, [terminalRef.current, selectedProject, selectedSession, isRestarting]); // Fit terminal when tab becomes active useEffect(() => { if (!isActive || !isInitialized) return; // Fit terminal when tab becomes active and notify backend setTimeout(() => { if (fitAddon.current) { fitAddon.current.fit(); // Send terminal size to backend after tab activation if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify({ type: 'resize', cols: terminal.current.cols, rows: terminal.current.rows })); } } }, 100); }, [isActive, isInitialized]); // WebSocket connection function (called manually) const connectWebSocket = async () => { if (isConnecting || isConnected) return; try { // Get authentication token const token = localStorage.getItem('auth-token'); if (!token) { console.error('No authentication token found for Shell WebSocket connection'); return; } // Fetch server configuration to get the correct WebSocket URL let wsBaseUrl; try { const configResponse = await fetch('/api/config', { headers: { 'Authorization': `Bearer ${token}` } }); const config = await configResponse.json(); wsBaseUrl = config.wsUrl; // If the config returns localhost but we're not on localhost, use current host but with API server port if (wsBaseUrl.includes('localhost') && !window.location.hostname.includes('localhost')) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // For development, API server is typically on port 3002 when Vite is on 3001 const apiPort = window.location.port === '3001' ? '3002' : window.location.port; wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`; } } catch (error) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // For development, API server is typically on port 3002 when Vite is on 3001 const apiPort = window.location.port === '3001' ? '3002' : window.location.port; wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`; } // Include token in WebSocket URL as query parameter const wsUrl = `${wsBaseUrl}/shell?token=${encodeURIComponent(token)}`; ws.current = new WebSocket(wsUrl); ws.current.onopen = () => { setIsConnected(true); setIsConnecting(false); // Wait for terminal to be ready, then fit and send dimensions setTimeout(() => { if (fitAddon.current && terminal.current) { // Force a fit to ensure proper dimensions fitAddon.current.fit(); // Wait a bit more for fit to complete, then send dimensions setTimeout(() => { const initPayload = { type: 'init', projectPath: selectedProject.fullPath || selectedProject.path, sessionId: selectedSession?.id, hasSession: !!selectedSession, cols: terminal.current.cols, rows: terminal.current.rows }; ws.current.send(JSON.stringify(initPayload)); // Also send resize message immediately after init setTimeout(() => { if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify({ type: 'resize', cols: terminal.current.cols, rows: terminal.current.rows })); } }, 100); }, 50); } }, 200); }; ws.current.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'output') { // Check for URLs in the output and make them clickable const urlRegex = /(https?:\/\/[^\s\x1b\x07]+)/g; let output = data.data; // Find URLs in the text (excluding ANSI escape sequences) const urls = []; let match; while ((match = urlRegex.exec(output.replace(/\x1b\[[0-9;]*m/g, ''))) !== null) { urls.push(match[1]); } // If URLs found, log them for potential opening terminal.current.write(output); } else if (data.type === 'url_open') { // Handle explicit URL opening requests from server window.open(data.url, '_blank'); } } catch (error) { } }; ws.current.onclose = (event) => { setIsConnected(false); setIsConnecting(false); // Clear terminal content when connection closes if (terminal.current) { terminal.current.clear(); terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home } // Don't auto-reconnect anymore - user must manually connect }; ws.current.onerror = (error) => { setIsConnected(false); setIsConnecting(false); }; } catch (error) { setIsConnected(false); setIsConnecting(false); } }; if (!selectedProject) { return (

Select a Project

Choose a project to open an interactive shell in that directory

); } return (
{/* Header */}
{selectedSession && ( ({selectedSession.summary.slice(0, 30)}...) )} {!selectedSession && ( (New Session) )} {!isInitialized && ( (Initializing...) )} {isRestarting && ( (Restarting...) )}
{isConnected && ( )}
{/* Terminal */}
{/* Loading state */} {!isInitialized && (
Loading terminal...
)} {/* Connect button when not connected */} {isInitialized && !isConnected && !isConnecting && (

{selectedSession ? `Resume session: ${selectedSession.summary.slice(0, 50)}...` : 'Start a new Claude session' }

)} {/* Connecting state */} {isConnecting && (
Connecting to shell...

Starting Claude CLI in {selectedProject.displayName}

)}
); } export default Shell;