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'; import { useTranslation } from 'react-i18next'; import { IS_PLATFORM } from '../constants/config'; 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 fallbackCopyToClipboard(text) { if (!text || typeof document === 'undefined') return false; const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', ''); textarea.style.position = 'fixed'; textarea.style.opacity = '0'; textarea.style.pointerEvents = 'none'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); let copied = false; try { copied = document.execCommand('copy'); } catch { copied = false; } finally { document.body.removeChild(textarea); } return copied; } const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device'; function isCodexLoginCommand(command) { return typeof command === 'string' && /\bcodex\s+login\b/i.test(command); } function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) { const { t } = useTranslation('chat'); 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 [authUrl, setAuthUrl] = useState(''); const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle'); const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false); const selectedProjectRef = useRef(selectedProject); const selectedSessionRef = useRef(selectedSession); const initialCommandRef = useRef(initialCommand); const isPlainShellRef = useRef(isPlainShell); const onProcessCompleteRef = useRef(onProcessComplete); const authUrlRef = useRef(''); useEffect(() => { selectedProjectRef.current = selectedProject; selectedSessionRef.current = selectedSession; initialCommandRef.current = initialCommand; isPlainShellRef.current = isPlainShell; onProcessCompleteRef.current = onProcessComplete; }); const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => { if (!url) return false; const popup = window.open(url, '_blank', 'noopener,noreferrer'); if (popup) { try { popup.opener = null; } catch { // Ignore cross-origin restrictions when trying to null opener } return true; } return false; }, []); const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => { if (!url) return false; let copied = false; try { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(url); copied = true; } } catch { copied = false; } if (!copied) { copied = fallbackCopyToClipboard(url); } return copied; }, []); const connectWebSocket = useCallback(async () => { if (isConnecting || isConnected) return; try { let wsUrl; if (IS_PLATFORM) { 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); authUrlRef.current = ''; setAuthUrl(''); setAuthUrlCopyStatus('idle'); setIsAuthPanelHidden(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 === 'auth_url' && data.url) { authUrlRef.current = data.url; setAuthUrl(data.url); setAuthUrlCopyStatus('idle'); setIsAuthPanelHidden(false); } else if (data.type === 'url_open') { if (data.url) { authUrlRef.current = data.url; setAuthUrl(data.url); setAuthUrlCopyStatus('idle'); setIsAuthPanelHidden(false); } } } catch (error) { console.error('[Shell] Error handling WebSocket message:', error, event.data); } }; ws.current.onclose = (event) => { setIsConnected(false); setIsConnecting(false); setAuthUrlCopyStatus('idle'); setIsAuthPanelHidden(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, openAuthUrlInBrowser]); 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); authUrlRef.current = ''; setAuthUrl(''); setAuthUrlCopyStatus('idle'); setIsAuthPanelHidden(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); authUrlRef.current = ''; setAuthUrl(''); setAuthUrlCopyStatus('idle'); setIsAuthPanelHidden(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: true, 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); // Disable xterm link auto-detection in minimal (login) mode to avoid partial wrapped URL links. if (!minimal) { 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) => { const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current) ? CODEX_DEVICE_AUTH_URL : authUrlRef.current; if ( event.type === 'keydown' && minimal && isPlainShellRef.current && activeAuthUrl && !event.ctrlKey && !event.metaKey && !event.altKey && event.key?.toLowerCase() === 'c' ) { copyAuthUrlToClipboard(activeAuthUrl).catch(() => {}); } if ( event.type === 'keydown' && (event.ctrlKey || event.metaKey) && event.key?.toLowerCase() === 'c' && terminal.current.hasSelection() ) { event.preventDefault(); event.stopPropagation(); document.execCommand('copy'); return false; } if ( event.type === 'keydown' && (event.ctrlKey || event.metaKey) && event.key?.toLowerCase() === 'v' ) { // Block native browser/xterm paste so clipboard data is only sent after // the explicit clipboard-read flow resolves (avoids duplicate pastes). event.preventDefault(); event.stopPropagation(); 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, minimal, copyAuthUrlToClipboard]); useEffect(() => { if (!autoConnect || !isInitialized || isConnecting || isConnected) return; connectToShell(); }, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]); if (!selectedProject) { return (

{t('shell.selectProject.title')}

{t('shell.selectProject.description')}

); } if (minimal) { const displayAuthUrl = isCodexLoginCommand(initialCommand) ? CODEX_DEVICE_AUTH_URL : authUrl; const hasAuthUrl = Boolean(displayAuthUrl); const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden; const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden; return (
{showMobileAuthPanel && (

Open or copy the login URL:

event.currentTarget.select()} className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500" aria-label="Authentication URL" />
)} {showMobileAuthPanelToggle && (
)}
); } return (
{selectedSession && ( ({sessionDisplayNameShort}...) )} {!selectedSession && ( {t('shell.status.newSession')} )} {!isInitialized && ( {t('shell.status.initializing')} )} {isRestarting && ( {t('shell.status.restarting')} )}
{isConnected && ( )}
{!isInitialized && (
{t('shell.loading')}
)} {isInitialized && !isConnected && !isConnecting && (

{isPlainShell ? t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) : selectedSession ? t('shell.resumeSession', { displayName: sessionDisplayNameLong }) : t('shell.startSession') }

)} {isConnecting && (
{t('shell.connecting')}

{isPlainShell ? t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) : t('shell.startCli', { projectName: selectedProject.displayName }) }

)}
); } export default Shell;