Files
claudecodeui/src/components/Shell.jsx

631 lines
21 KiB
JavaScript

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 (
<div className="h-full flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Select a Project</h3>
<p>Choose a project to open an interactive shell in that directory</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-gray-900 w-full">
{/* Header */}
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
{selectedSession && (
<span className="text-xs text-blue-300">
({selectedSession.summary.slice(0, 30)}...)
</span>
)}
{!selectedSession && (
<span className="text-xs text-gray-400">(New Session)</span>
)}
{!isInitialized && (
<span className="text-xs text-yellow-400">(Initializing...)</span>
)}
{isRestarting && (
<span className="text-xs text-blue-400">(Restarting...)</span>
)}
</div>
<div className="flex items-center space-x-3">
{isConnected && (
<button
onClick={disconnectFromShell}
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
title="Disconnect from shell"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>Disconnect</span>
</button>
)}
<button
onClick={restartShell}
disabled={isRestarting || isConnected}
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
title="Restart Shell (disconnect first)"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Restart</span>
</button>
</div>
</div>
</div>
{/* Terminal */}
<div className="flex-1 p-2 overflow-hidden relative">
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
{/* Loading state */}
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">Loading terminal...</div>
</div>
)}
{/* Connect button when not connected */}
{isInitialized && !isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="text-center max-w-sm w-full">
<button
onClick={connectToShell}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
title="Connect to shell"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Continue in Shell</span>
</button>
<p className="text-gray-400 text-sm mt-3 px-2">
{selectedSession ?
`Resume session: ${selectedSession.summary.slice(0, 50)}...` :
'Start a new Claude session'
}
</p>
</div>
</div>
)}
{/* Connecting state */}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="text-center max-w-sm w-full">
<div className="flex items-center justify-center space-x-3 text-yellow-400">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">Connecting to shell...</span>
</div>
<p className="text-gray-400 text-sm mt-3 px-2">
Starting Claude CLI in {selectedProject.displayName}
</p>
</div>
</div>
)}
</div>
</div>
);
}
export default Shell;