From 521fce32d0520604024229c2f314f798e18f8bd9 Mon Sep 17 00:00:00 2001 From: simos Date: Fri, 14 Nov 2025 23:44:29 +0000 Subject: [PATCH] refactor: improve shell performance, fix bugs on the git tab and promote login to a standalone component Implement PTY session persistence with 30-minute timeout for shell reconnection. Sessions are now keyed by project path and session ID, preserving terminal state across UI disconnections with buffered output replay. Refactor Shell component to use refs for stable prop access, removing unnecessary isActive prop and improving WebSocket connection lifecycle management. Replace conditional rendering with early returns in MainContent for better performance. Add directory handling in git operations: support discarding, diffing, and viewing directories in untracked files. Prevent errors when staging or generating commit messages for directories. Extract LoginModal into reusable component for Claude and Cursor CLI authentication. Add minimal mode to StandaloneShell for embedded use cases. Update Settings to use new LoginModal component. Improve terminal dimensions handling by passing client-provided cols and rows to PTY spawn. Add comprehensive logging for session lifecycle and API operations. --- server/index.js | 99 +++++- server/routes/git.js | 65 +++- src/components/LoginModal.jsx | 86 +++++ src/components/MainContent.jsx | 33 +- src/components/Settings.jsx | 37 +- src/components/Shell.jsx | 536 +++++++++++------------------ src/components/Sidebar.jsx | 10 +- src/components/StandaloneShell.jsx | 27 +- 8 files changed, 467 insertions(+), 426 deletions(-) create mode 100644 src/components/LoginModal.jsx diff --git a/server/index.js b/server/index.js index 833af56..ce798a4 100755 --- a/server/index.js +++ b/server/index.js @@ -164,6 +164,9 @@ async function setupProjectsWatcher() { const app = express(); const server = http.createServer(app); +const ptySessionsMap = new Map(); +const PTY_SESSION_TIMEOUT = 30 * 60 * 1000; + // Single WebSocket server that handles both paths const wss = new WebSocketServer({ server, @@ -397,9 +400,12 @@ app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => { try { const { projectName, sessionId } = req.params; + console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`); await deleteSession(projectName, sessionId); + console.log(`[API] Session ${sessionId} deleted successfully`); res.json({ success: true }); } catch (error) { + console.error(`[API] Error deleting session ${req.params.sessionId}:`, error); res.status(500).json({ error: error.message }); } }); @@ -797,6 +803,8 @@ function handleChatConnection(ws) { function handleShellConnection(ws) { console.log('🐚 Shell client connected'); let shellProcess = null; + let ptySessionKey = null; + let outputBuffer = []; ws.on('message', async (message) => { try { @@ -804,7 +812,6 @@ function handleShellConnection(ws) { console.log('📨 Shell message received:', data.type); if (data.type === 'init') { - // Initialize shell with project path and session info const projectPath = data.projectPath || process.cwd(); const sessionId = data.sessionId; const hasSession = data.hasSession; @@ -812,6 +819,35 @@ function handleShellConnection(ws) { const initialCommand = data.initialCommand; const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell'; + ptySessionKey = `${projectPath}_${sessionId || 'default'}`; + + const existingSession = ptySessionsMap.get(ptySessionKey); + if (existingSession) { + console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey); + shellProcess = existingSession.pty; + + clearTimeout(existingSession.timeoutId); + + ws.send(JSON.stringify({ + type: 'output', + data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n` + })); + + if (existingSession.buffer && existingSession.buffer.length > 0) { + console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`); + existingSession.buffer.forEach(bufferedData => { + ws.send(JSON.stringify({ + type: 'output', + data: bufferedData + })); + }); + } + + existingSession.ws = ws; + + return; + } + console.log('[INFO] Starting shell in:', projectPath); console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session')); console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider); @@ -885,10 +921,15 @@ function handleShellConnection(ws) { const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand]; + // Use terminal dimensions from client if provided, otherwise use defaults + const termCols = data.cols || 80; + const termRows = data.rows || 24; + console.log('📐 Using terminal dimensions:', termCols, 'x', termRows); + shellProcess = pty.spawn(shell, shellArgs, { name: 'xterm-256color', - cols: 80, - rows: 24, + cols: termCols, + rows: termRows, cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'), env: { ...process.env, @@ -902,9 +943,28 @@ function handleShellConnection(ws) { console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid); + ptySessionsMap.set(ptySessionKey, { + pty: shellProcess, + ws: ws, + buffer: [], + timeoutId: null, + projectPath, + sessionId + }); + // Handle data output shellProcess.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { + const session = ptySessionsMap.get(ptySessionKey); + if (!session) return; + + if (session.buffer.length < 5000) { + session.buffer.push(data); + } else { + session.buffer.shift(); + session.buffer.push(data); + } + + if (session.ws && session.ws.readyState === WebSocket.OPEN) { let outputData = data; // Check for various URL opening patterns @@ -928,7 +988,7 @@ function handleShellConnection(ws) { console.log('[DEBUG] Detected URL for opening:', url); // Send URL opening message to client - ws.send(JSON.stringify({ + session.ws.send(JSON.stringify({ type: 'url_open', url: url })); @@ -941,7 +1001,7 @@ function handleShellConnection(ws) { }); // Send regular output - ws.send(JSON.stringify({ + session.ws.send(JSON.stringify({ type: 'output', data: outputData })); @@ -951,12 +1011,17 @@ function handleShellConnection(ws) { // Handle process exit shellProcess.onExit((exitCode) => { console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal); - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ + const session = ptySessionsMap.get(ptySessionKey); + if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { + session.ws.send(JSON.stringify({ type: 'output', data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n` })); } + if (session && session.timeoutId) { + clearTimeout(session.timeoutId); + } + ptySessionsMap.delete(ptySessionKey); shellProcess = null; }); @@ -999,9 +1064,21 @@ function handleShellConnection(ws) { ws.on('close', () => { console.log('🔌 Shell client disconnected'); - if (shellProcess && shellProcess.kill) { - console.log('🔴 Killing shell process:', shellProcess.pid); - shellProcess.kill(); + + if (ptySessionKey) { + const session = ptySessionsMap.get(ptySessionKey); + if (session) { + console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey); + session.ws = null; + + session.timeoutId = setTimeout(() => { + console.log('⏰ PTY session timeout, killing process:', ptySessionKey); + if (session.pty && session.pty.kill) { + session.pty.kill(); + } + ptySessionsMap.delete(ptySessionKey); + }, PTY_SESSION_TIMEOUT); + } } }); diff --git a/server/routes/git.js b/server/routes/git.js index 0f4f10d..6fc6a0a 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -161,10 +161,18 @@ router.get('/diff', async (req, res) => { let diff; if (isUntracked) { // For untracked files, show the entire file content as additions - const fileContent = await fs.readFile(path.join(projectPath, file), 'utf-8'); - const lines = fileContent.split('\n'); - diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` + - lines.map(line => `+${line}`).join('\n'); + const filePath = path.join(projectPath, file); + const stats = await fs.stat(filePath); + + if (stats.isDirectory()) { + // For directories, show a simple message + diff = `Directory: ${file}\n(Cannot show diff for directories)`; + } else { + const fileContent = await fs.readFile(filePath, 'utf-8'); + const lines = fileContent.split('\n'); + diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` + + lines.map(line => `+${line}`).join('\n'); + } } else if (isDeleted) { // For deleted files, show the entire file content from HEAD as deletions const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); @@ -222,7 +230,15 @@ router.get('/file-with-diff', async (req, res) => { currentContent = headContent; // Show the deleted content in editor } else { // Get current file content - currentContent = await fs.readFile(path.join(projectPath, file), 'utf-8'); + const filePath = path.join(projectPath, file); + const stats = await fs.stat(filePath); + + if (stats.isDirectory()) { + // Cannot show content for directories + return res.status(400).json({ error: 'Cannot show diff for directories' }); + } + + currentContent = await fs.readFile(filePath, 'utf-8'); if (!isUntracked) { // Get the old content from HEAD for tracked files @@ -474,8 +490,14 @@ router.post('/generate-commit-message', async (req, res) => { for (const file of files) { try { const filePath = path.join(projectPath, file); - const content = await fs.readFile(filePath, 'utf-8'); - diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`; + const stats = await fs.stat(filePath); + + if (!stats.isDirectory()) { + const content = await fs.readFile(filePath, 'utf-8'); + diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`; + } else { + diffContext += `\n--- ${file} (new directory) ---\n`; + } } catch (error) { console.error(`Error reading file ${file}:`, error); } @@ -976,10 +998,17 @@ router.post('/discard', async (req, res) => { } const status = statusOutput.substring(0, 2); - + if (status === '??') { - // Untracked file - delete it - await fs.unlink(path.join(projectPath, file)); + // Untracked file or directory - delete it + const filePath = path.join(projectPath, file); + const stats = await fs.stat(filePath); + + if (stats.isDirectory()) { + await fs.rm(filePath, { recursive: true, force: true }); + } else { + await fs.unlink(filePath); + } } else if (status.includes('M') || status.includes('D')) { // Modified or deleted file - restore from HEAD await execAsync(`git restore "${file}"`, { cwd: projectPath }); @@ -1020,10 +1049,18 @@ router.post('/delete-untracked', async (req, res) => { return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' }); } - // Delete the untracked file - await fs.unlink(path.join(projectPath, file)); - - res.json({ success: true, message: `Untracked file ${file} deleted successfully` }); + // Delete the untracked file or directory + const filePath = path.join(projectPath, file); + const stats = await fs.stat(filePath); + + if (stats.isDirectory()) { + // Use rm with recursive option for directories + await fs.rm(filePath, { recursive: true, force: true }); + res.json({ success: true, message: `Untracked directory ${file} deleted successfully` }); + } else { + await fs.unlink(filePath); + res.json({ success: true, message: `Untracked file ${file} deleted successfully` }); + } } catch (error) { console.error('Git delete untracked error:', error); res.status(500).json({ error: error.message }); diff --git a/src/components/LoginModal.jsx b/src/components/LoginModal.jsx new file mode 100644 index 0000000..42b7fc2 --- /dev/null +++ b/src/components/LoginModal.jsx @@ -0,0 +1,86 @@ +import { X } from 'lucide-react'; +import StandaloneShell from './StandaloneShell'; + +/** + * Reusable login modal component for Claude and Cursor CLI authentication + * + * @param {Object} props + * @param {boolean} props.isOpen - Whether the modal is visible + * @param {Function} props.onClose - Callback when modal is closed + * @param {'claude'|'cursor'} props.provider - Which CLI provider to authenticate with + * @param {Object} props.project - Project object containing name and path information + * @param {Function} props.onComplete - Callback when login process completes (receives exitCode) + * @param {string} props.customCommand - Optional custom command to override defaults + */ +function LoginModal({ + isOpen, + onClose, + provider = 'claude', + project, + onComplete, + customCommand +}) { + if (!isOpen) return null; + + const getCommand = () => { + if (customCommand) return customCommand; + + switch (provider) { + case 'claude': + return 'claude setup-token --dangerously-skip-permissions'; + case 'cursor': + return 'cursor-agent login'; + default: + return 'claude setup-token --dangerously-skip-permissions'; + } + }; + + const getTitle = () => { + switch (provider) { + case 'claude': + return 'Claude CLI Login'; + case 'cursor': + return 'Cursor CLI Login'; + default: + return 'CLI Login'; + } + }; + + const handleComplete = (exitCode) => { + if (onComplete) { + onComplete(exitCode); + } + if (exitCode === 0) { + onClose(); + } + }; + + return ( +
+
+
+

+ {getTitle()} +

+ +
+
+ +
+
+
+ ); +} + +export default LoginModal; diff --git a/src/components/MainContent.jsx b/src/components/MainContent.jsx index 522f99f..58a8749 100644 --- a/src/components/MainContent.jsx +++ b/src/components/MainContent.jsx @@ -496,20 +496,25 @@ function MainContent({ /> -
- -
-
- -
-
- -
+ {activeTab === 'files' && ( +
+ +
+ )} + {activeTab === 'shell' && ( +
+ +
+ )} + {activeTab === 'git' && ( +
+ +
+ )} {shouldShowTasksTab && (
diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index 20bb0d4..0bae64e 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -5,10 +5,10 @@ import { Badge } from './ui/badge'; import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn, Key } from 'lucide-react'; import { useTheme } from '../contexts/ThemeContext'; import { useTasksSettings } from '../contexts/TasksSettingsContext'; -import StandaloneShell from './StandaloneShell'; import ClaudeLogo from './ClaudeLogo'; import CursorLogo from './CursorLogo'; import CredentialsSettings from './CredentialsSettings'; +import LoginModal from './LoginModal'; function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) { const { isDarkMode, toggleDarkMode } = useTheme(); @@ -441,8 +441,9 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) { const handleLoginComplete = (exitCode) => { if (exitCode === 0) { // Login successful - could show a success message here + setSaveStatus('success'); } - setShowLoginModal(false); + // Modal will close itself via the LoginModal component }; const saveSettings = () => { @@ -2207,31 +2208,13 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
{/* Login Modal */} - {showLoginModal && ( -
-
-
-

- {loginProvider === 'claude' ? 'Claude CLI Login' : 'Cursor CLI Login'} -

- -
-
- -
-
-
- )} + setShowLoginModal(false)} + provider={loginProvider} + project={selectedProject} + onComplete={handleLoginComplete} + />
); } diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx index 39ee41b..cbf54a7 100644 --- a/src/components/Shell.jsx +++ b/src/components/Shell.jsx @@ -1,11 +1,10 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { ClipboardAddon } from '@xterm/addon-clipboard'; import { WebglAddon } from '@xterm/addon-webgl'; import '@xterm/xterm/css/xterm.css'; -// CSS to remove xterm focus outline const xtermStyles = ` .xterm .xterm-screen { outline: none !important; @@ -18,7 +17,6 @@ const xtermStyles = ` } `; -// Inject styles if (typeof document !== 'undefined') { const styleSheet = document.createElement('style'); styleSheet.type = 'text/css'; @@ -26,10 +24,7 @@ if (typeof document !== 'undefined') { document.head.appendChild(styleSheet); } -// Global store for shell sessions to persist across tab switches -const shellSessions = new Map(); - -function Shell({ selectedProject, selectedSession, isActive, initialCommand, isPlainShell = false, onProcessComplete }) { +function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) { const terminalRef = useRef(null); const terminal = useRef(null); const fitAddon = useRef(null); @@ -40,177 +35,212 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP 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(); - }; + const selectedProjectRef = useRef(selectedProject); + const selectedSessionRef = useRef(selectedSession); + const initialCommandRef = useRef(initialCommand); + const isPlainShellRef = useRef(isPlainShell); + const onProcessCompleteRef = useRef(onProcessComplete); - // Disconnect from shell function - const disconnectFromShell = () => { - + 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; } - - // Clear terminal content completely + if (terminal.current) { terminal.current.clear(); - terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home + 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]); - // 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]); + }, [selectedSession?.id, isInitialized, disconnectFromShell]); - // Initialize terminal when component mounts useEffect(() => { - - if (!terminalRef.current || !selectedProject || isRestarting) { + if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) { 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; - } - } + console.log('[Shell] Terminal initializing, mounting component'); - 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 + allowProposedApi: true, 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', @@ -219,8 +249,6 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5', - - // Bright ANSI colors (8-15) brightBlack: '#666666', brightRed: '#f14c4c', brightGreen: '#23d18b', @@ -229,10 +257,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP 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', @@ -247,30 +272,21 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP terminal.current.loadAddon(fitAddon.current); terminal.current.loadAddon(clipboardAddon); - + try { terminal.current.loadAddon(webglAddon); } catch (error) { + console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback'); } - + 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) { @@ -279,20 +295,16 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP data: text })); } - }).catch(err => { - // Failed to read clipboard - }); + }).catch(() => {}); 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', @@ -302,10 +314,8 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP } } }, 100); - - setIsInitialized(true); - // Handle terminal input + setIsInitialized(true); terminal.current.onData((data) => { if (ws.current && ws.current.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify({ @@ -315,12 +325,10 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP } }); - // 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', @@ -337,178 +345,25 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP } return () => { + console.log('[Shell] Terminal cleanup, unmounting component'); 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) { - } + + 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; } }; - }, [terminalRef.current, selectedProject, selectedSession, isRestarting]); + }, [selectedProject?.path || selectedProject?.fullPath, 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 { - const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true'; - - // Construct WebSocket URL - let wsUrl; - - if (isPlatform) { - // Platform mode: Use same domain as the page (goes through proxy) - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl = `${protocol}//${window.location.host}/shell`; - } else { - // OSS mode: Connect to same host:port that served the page - 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); - - // 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: isPlainShell ? null : selectedSession?.id, - hasSession: isPlainShell ? false : !!selectedSession, - provider: isPlainShell ? 'plain-shell' : (selectedSession?.__provider || 'claude'), - cols: terminal.current.cols, - rows: terminal.current.rows, - initialCommand: initialCommand, - isPlainShell: isPlainShell - }; - - console.log('Shell init payload:', initPayload); - - 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 (isPlainShell && onProcessComplete) { - const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); // Remove ANSI codes - if (cleanOutput.includes('Process exited with code 0')) { - onProcessComplete(0); // Success - } else if (cleanOutput.match(/Process exited with code (\d+)/)) { - const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]); - if (exitCode !== 0) { - onProcessComplete(exitCode); // Error - } - } - } - - // 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 (!autoConnect || !isInitialized || isConnecting || isConnected) return; + connectToShell(); + }, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]); if (!selectedProject) { return ( @@ -526,23 +381,25 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP ); } + if (minimal) { + return ( +
+
+
+ ); + } + return (
- {/* Header */}
- {selectedSession && (() => { - const displaySessionName = selectedSession.__provider === 'cursor' - ? (selectedSession.name || 'Untitled Session') - : (selectedSession.summary || 'New Session'); - return ( - - ({displaySessionName.slice(0, 30)}...) - - ); - })()} + {selectedSession && ( + + ({sessionDisplayNameShort}...) + + )} {!selectedSession && ( (New Session) )} @@ -566,7 +423,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP Disconnect )} - +
- {/* Terminal */}
- - {/* Loading state */} + {!isInitialized && (
Loading terminal...
)} - - {/* Connect button when not connected */} + {isInitialized && !isConnected && !isConnecting && (
@@ -608,23 +462,17 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP Continue in Shell

- {isPlainShell ? + {isPlainShell ? `Run ${initialCommand || 'command'} in ${selectedProject.displayName}` : - selectedSession ? - (() => { - const displaySessionName = selectedSession.__provider === 'cursor' - ? (selectedSession.name || 'Untitled Session') - : (selectedSession.summary || 'New Session'); - return `Resume session: ${displaySessionName.slice(0, 50)}...`; - })() : + selectedSession ? + `Resume session: ${sessionDisplayNameLong}...` : 'Start a new Claude session' }

)} - - {/* Connecting state */} + {isConnecting && (
@@ -633,7 +481,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP Connecting to shell...

- {isPlainShell ? + {isPlainShell ? `Running ${initialCommand || 'command'} in ${selectedProject.displayName}` : `Starting Claude CLI in ${selectedProject.displayName}` } diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 4b7cddf..8a160cc 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -303,19 +303,25 @@ function Sidebar({ } try { + console.log('[Sidebar] Deleting session:', { projectName, sessionId }); const response = await api.deleteSession(projectName, sessionId); + console.log('[Sidebar] Delete response:', { ok: response.ok, status: response.status }); if (response.ok) { + console.log('[Sidebar] Session deleted successfully, calling callback'); // Call parent callback if provided if (onSessionDelete) { onSessionDelete(sessionId); + } else { + console.warn('[Sidebar] No onSessionDelete callback provided'); } } else { - console.error('Failed to delete session'); + const errorText = await response.text(); + console.error('[Sidebar] Failed to delete session:', { status: response.status, error: errorText }); alert('Failed to delete session. Please try again.'); } } catch (error) { - console.error('Error deleting session:', error); + console.error('[Sidebar] Error deleting session:', error); alert('Error deleting session. Please try again.'); } }; diff --git a/src/components/StandaloneShell.jsx b/src/components/StandaloneShell.jsx index 6ddcbe7..9f6f9f2 100644 --- a/src/components/StandaloneShell.jsx +++ b/src/components/StandaloneShell.jsx @@ -1,14 +1,13 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useCallback } from 'react'; import Shell from './Shell.jsx'; /** * Generic Shell wrapper that can be used in tabs, modals, and other contexts. * Provides a flexible API for both standalone and session-based usage. - * + * * @param {Object} project - Project object with name, fullPath/path, displayName * @param {Object} session - Session object (optional, for tab usage) * @param {string} command - Initial command to run (optional) - * @param {boolean} isActive - Whether the shell is active (for tab usage, default: true) * @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect) * @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true) * @param {function} onComplete - Callback when process completes (receives exitCode) @@ -17,33 +16,32 @@ import Shell from './Shell.jsx'; * @param {string} className - Additional CSS classes * @param {boolean} showHeader - Whether to show custom header (default: true) * @param {boolean} compact - Use compact layout (default: false) + * @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false) */ function StandaloneShell({ project, session = null, command = null, - isActive = true, - isPlainShell = null, // Auto-detect: true if command provided, false if session provided + isPlainShell = null, autoConnect = true, onComplete = null, onClose = null, title = null, className = "", showHeader = true, - compact = false + compact = false, + minimal = false }) { const [isCompleted, setIsCompleted] = useState(false); - // Auto-detect isPlainShell based on props const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null); - // Handle process completion - const handleProcessComplete = (exitCode) => { + const handleProcessComplete = useCallback((exitCode) => { setIsCompleted(true); if (onComplete) { onComplete(exitCode); } - }; + }, [onComplete]); if (!project) { return ( @@ -62,9 +60,9 @@ function StandaloneShell({ } return ( -

+
{/* Optional custom header */} - {showHeader && title && ( + {!minimal && showHeader && title && (
@@ -89,14 +87,15 @@ function StandaloneShell({ )} {/* Shell component wrapper */} -
+