mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-11 16:49:38 +00:00
631 lines
21 KiB
JavaScript
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; |