mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-14 09:19:32 +00:00
Replace ClipboardAddon with WebLinksAddon to enable automatic detection and clickable handling of URLs in terminal output. This improves user experience by allowing direct interaction with links displayed in the terminal.
498 lines
17 KiB
JavaScript
498 lines
17 KiB
JavaScript
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';
|
|
|
|
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 Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
|
|
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 selectedProjectRef = useRef(selectedProject);
|
|
const selectedSessionRef = useRef(selectedSession);
|
|
const initialCommandRef = useRef(initialCommand);
|
|
const isPlainShellRef = useRef(isPlainShell);
|
|
const onProcessCompleteRef = useRef(onProcessComplete);
|
|
|
|
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;
|
|
}
|
|
|
|
if (terminal.current) {
|
|
terminal.current.clear();
|
|
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]);
|
|
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
console.log('[Shell] Terminal initializing, mounting component');
|
|
|
|
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: false,
|
|
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);
|
|
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) => {
|
|
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
|
|
document.execCommand('copy');
|
|
return false;
|
|
}
|
|
|
|
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(() => {});
|
|
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 () => {
|
|
console.log('[Shell] Terminal cleanup, unmounting component');
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
|
|
connectToShell();
|
|
}, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
if (minimal) {
|
|
return (
|
|
<div className="h-full w-full bg-gray-900">
|
|
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-gray-900 w-full">
|
|
<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">
|
|
({sessionDisplayNameShort}...)
|
|
</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>
|
|
|
|
<div className="flex-1 p-2 overflow-hidden relative">
|
|
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
|
|
|
{!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>
|
|
)}
|
|
|
|
{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">
|
|
{isPlainShell ?
|
|
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
|
selectedSession ?
|
|
`Resume session: ${sessionDisplayNameLong}...` :
|
|
'Start a new Claude session'
|
|
}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{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">
|
|
{isPlainShell ?
|
|
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
|
`Starting Claude CLI in ${selectedProject.displayName}`
|
|
}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Shell; |