import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { WebglAddon } from '@xterm/addon-webgl'; import { WebLinksAddon } from '@xterm/addon-web-links'; import '@xterm/xterm/css/xterm.css'; const xtermStyles = ` .xterm .xterm-screen { outline: none !important; } .xterm:focus .xterm-screen { outline: none !important; } .xterm-screen:focus { outline: none !important; } `; if (typeof document !== 'undefined') { const styleSheet = document.createElement('style'); styleSheet.type = 'text/css'; styleSheet.innerText = xtermStyles; document.head.appendChild(styleSheet); } function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) { 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); const selectedProjectRef = useRef(selectedProject); const selectedSessionRef = useRef(selectedSession); const initialCommandRef = useRef(initialCommand); const isPlainShellRef = useRef(isPlainShell); const onProcessCompleteRef = useRef(onProcessComplete); useEffect(() => { selectedProjectRef.current = selectedProject; selectedSessionRef.current = selectedSession; initialCommandRef.current = initialCommand; isPlainShellRef.current = isPlainShell; onProcessCompleteRef.current = onProcessComplete; }); const connectWebSocket = useCallback(async () => { if (isConnecting || isConnected) return; try { const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true'; let wsUrl; if (isPlatform) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; wsUrl = `${protocol}//${window.location.host}/shell`; } else { const token = localStorage.getItem('auth-token'); if (!token) { console.error('No authentication token found for Shell WebSocket connection'); return; } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`; } ws.current = new WebSocket(wsUrl); ws.current.onopen = () => { setIsConnected(true); setIsConnecting(false); setTimeout(() => { if (fitAddon.current && terminal.current) { fitAddon.current.fit(); ws.current.send(JSON.stringify({ type: 'init', projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path, sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id, hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current, provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || 'claude'), cols: terminal.current.cols, rows: terminal.current.rows, initialCommand: initialCommandRef.current, isPlainShell: isPlainShellRef.current })); } }, 100); }; ws.current.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'output') { let output = data.data; if (isPlainShellRef.current && onProcessCompleteRef.current) { const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); if (cleanOutput.includes('Process exited with code 0')) { onProcessCompleteRef.current(0); } else if (cleanOutput.match(/Process exited with code (\d+)/)) { const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]); if (exitCode !== 0) { onProcessCompleteRef.current(exitCode); } } } if (terminal.current) { terminal.current.write(output); } } else if (data.type === 'url_open') { window.open(data.url, '_blank'); } } catch (error) { console.error('[Shell] Error handling WebSocket message:', error, event.data); } }; ws.current.onclose = (event) => { setIsConnected(false); setIsConnecting(false); if (terminal.current) { terminal.current.clear(); terminal.current.write('\x1b[2J\x1b[H'); } }; ws.current.onerror = (error) => { setIsConnected(false); setIsConnecting(false); }; } catch (error) { setIsConnected(false); setIsConnecting(false); } }, [isConnecting, isConnected]); const connectToShell = useCallback(() => { if (!isInitialized || isConnected || isConnecting) return; setIsConnecting(true); connectWebSocket(); }, [isInitialized, isConnected, isConnecting, connectWebSocket]); const disconnectFromShell = useCallback(() => { if (ws.current) { ws.current.close(); ws.current = null; } if (terminal.current) { terminal.current.clear(); terminal.current.write('\x1b[2J\x1b[H'); } setIsConnected(false); setIsConnecting(false); }, []); const sessionDisplayName = useMemo(() => { if (!selectedSession) return null; return selectedSession.__provider === 'cursor' ? (selectedSession.name || 'Untitled Session') : (selectedSession.summary || 'New Session'); }, [selectedSession]); const sessionDisplayNameShort = useMemo(() => { if (!sessionDisplayName) return null; return sessionDisplayName.slice(0, 30); }, [sessionDisplayName]); const sessionDisplayNameLong = useMemo(() => { if (!sessionDisplayName) return null; return sessionDisplayName.slice(0, 50); }, [sessionDisplayName]); const restartShell = () => { setIsRestarting(true); if (ws.current) { ws.current.close(); ws.current = null; } if (terminal.current) { terminal.current.dispose(); terminal.current = null; fitAddon.current = null; } setIsConnected(false); setIsInitialized(false); setTimeout(() => { setIsRestarting(false); }, 200); }; useEffect(() => { const currentSessionId = selectedSession?.id || null; if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) { disconnectFromShell(); } setLastSessionId(currentSessionId); }, [selectedSession?.id, isInitialized, disconnectFromShell]); useEffect(() => { if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) { return; } terminal.current = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: 'Menlo, Monaco, "Courier New", monospace', allowProposedApi: true, allowTransparency: false, convertEol: true, scrollback: 10000, tabStopWidth: 4, windowsMode: false, macOptionIsMeta: true, macOptionClickForcesSelection: false, theme: { background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#ffffff', cursorAccent: '#1e1e1e', selection: '#264f78', selectionForeground: '#ffffff', black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510', blue: '#2472c8', magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5', brightBlack: '#666666', brightRed: '#f14c4c', brightGreen: '#23d18b', brightYellow: '#f5f543', brightBlue: '#3b8eea', brightMagenta: '#d670d6', brightCyan: '#29b8db', brightWhite: '#ffffff', extendedAnsi: [ '#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0', '#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff' ] } }); fitAddon.current = new FitAddon(); const webglAddon = new WebglAddon(); const webLinksAddon = new WebLinksAddon(); terminal.current.loadAddon(fitAddon.current); terminal.current.loadAddon(webLinksAddon); // Note: ClipboardAddon removed - we handle clipboard operations manually in attachCustomKeyEventHandler try { terminal.current.loadAddon(webglAddon); } catch (error) { console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback'); } terminal.current.open(terminalRef.current); terminal.current.attachCustomKeyEventHandler((event) => { if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) { document.execCommand('copy'); return false; } 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(() => {}); return false; } return true; }); setTimeout(() => { if (fitAddon.current) { fitAddon.current.fit(); 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); terminal.current.onData((data) => { if (ws.current && ws.current.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify({ type: 'input', data: data })); } }); const resizeObserver = new ResizeObserver(() => { if (fitAddon.current && terminal.current) { setTimeout(() => { fitAddon.current.fit(); 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(); if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) { ws.current.close(); } ws.current = null; if (terminal.current) { terminal.current.dispose(); terminal.current = null; } }; }, [selectedProject?.path || selectedProject?.fullPath, isRestarting]); useEffect(() => { if (!autoConnect || !isInitialized || isConnecting || isConnected) return; connectToShell(); }, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]); if (!selectedProject) { return (
Choose a project to open an interactive shell in that directory
{isPlainShell ? `Run ${initialCommand || 'command'} in ${selectedProject.displayName}` : selectedSession ? `Resume session: ${sessionDisplayNameLong}...` : 'Start a new Claude session' }
{isPlainShell ? `Running ${initialCommand || 'command'} in ${selectedProject.displayName}` : `Starting Claude CLI in ${selectedProject.displayName}` }