diff --git a/server/index.js b/server/index.js index 4b70cd3..b671dcb 100755 --- a/server/index.js +++ b/server/index.js @@ -1138,7 +1138,7 @@ function handleShellConnection(ws) { if (isPlainShell) { welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`; } else { - const providerName = provider === 'cursor' ? 'Cursor' : 'Claude'; + const providerName = provider === 'cursor' ? 'Cursor' : provider === 'codex' ? 'Codex' : 'Claude'; welcomeMsg = hasSession ? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` : `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`; @@ -1174,6 +1174,23 @@ function handleShellConnection(ws) { shellCommand = `cd "${projectPath}" && cursor-agent`; } } + } else if (provider === 'codex') { + // Use codex command + if (os.platform() === 'win32') { + if (hasSession && sessionId) { + // Try to resume session, but with fallback to a new session if it fails + shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`; + } else { + shellCommand = `Set-Location -Path "${projectPath}"; codex`; + } + } else { + if (hasSession && sessionId) { + // Try to resume session, but with fallback to a new session if it fails + shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`; + } else { + shellCommand = `cd "${projectPath}" && codex`; + } + } } else { // Use claude command (default) or initialCommand if provided const command = initialCommand || 'claude'; diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx new file mode 100644 index 0000000..44115b9 --- /dev/null +++ b/src/components/Shell.jsx @@ -0,0 +1,692 @@ +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 || localStorage.getItem('selected-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.description')}
+Open or copy the login URL:
+ ++ {isPlainShell ? + t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) : + selectedSession ? + t('shell.resumeSession', { displayName: sessionDisplayNameLong }) : + t('shell.startSession') + } +
++ {isPlainShell ? + t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) : + t('shell.startCli', { projectName: selectedProject.displayName }) + } +
+