mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-01-23 18:07:34 +00:00
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.
This commit is contained in:
86
src/components/LoginModal.jsx
Normal file
86
src/components/LoginModal.jsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] max-md:items-stretch max-md:justify-stretch">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{getTitle()}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label="Close login modal"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<StandaloneShell
|
||||
project={project}
|
||||
command={getCommand()}
|
||||
onComplete={handleComplete}
|
||||
minimal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginModal;
|
||||
@@ -496,20 +496,25 @@ function MainContent({
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
|
||||
<FileTree selectedProject={selectedProject} />
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'shell' ? 'block' : 'hidden'}`}>
|
||||
<StandaloneShell
|
||||
project={selectedProject}
|
||||
session={selectedSession}
|
||||
isActive={activeTab === 'shell'}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
||||
</div>
|
||||
{activeTab === 'files' && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<FileTree selectedProject={selectedProject} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'shell' && (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<StandaloneShell
|
||||
project={selectedProject}
|
||||
session={selectedSession}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'git' && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
||||
</div>
|
||||
)}
|
||||
{shouldShowTasksTab && (
|
||||
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
|
||||
@@ -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' }) {
|
||||
</div>
|
||||
|
||||
{/* Login Modal */}
|
||||
{showLoginModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 max-md:items-stretch max-md:justify-stretch">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{loginProvider === 'claude' ? 'Claude CLI Login' : 'Cursor CLI Login'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowLoginModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<StandaloneShell
|
||||
project={selectedProject}
|
||||
command={loginProvider === 'claude' ? 'claude /login' : 'cursor-agent login'}
|
||||
onComplete={handleLoginComplete}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<LoginModal
|
||||
isOpen={showLoginModal}
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
provider={loginProvider}
|
||||
project={selectedProject}
|
||||
onComplete={handleLoginComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
{/* 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 && (() => {
|
||||
const displaySessionName = selectedSession.__provider === 'cursor'
|
||||
? (selectedSession.name || 'Untitled Session')
|
||||
: (selectedSession.summary || 'New Session');
|
||||
return (
|
||||
<span className="text-xs text-blue-300">
|
||||
({displaySessionName.slice(0, 30)}...)
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{selectedSession && (
|
||||
<span className="text-xs text-blue-300">
|
||||
({sessionDisplayNameShort}...)
|
||||
</span>
|
||||
)}
|
||||
{!selectedSession && (
|
||||
<span className="text-xs text-gray-400">(New Session)</span>
|
||||
)}
|
||||
@@ -566,7 +423,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
<span>Disconnect</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
<button
|
||||
onClick={restartShell}
|
||||
disabled={isRestarting || isConnected}
|
||||
@@ -582,18 +439,15 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
</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">
|
||||
@@ -608,23 +462,17 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
<span>Continue in Shell</span>
|
||||
</button>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{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'
|
||||
}
|
||||
</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">
|
||||
@@ -633,7 +481,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
<span className="text-base font-medium">Connecting to shell...</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{isPlainShell ?
|
||||
{isPlainShell ?
|
||||
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
`Starting Claude CLI in ${selectedProject.displayName}`
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className={`h-full flex flex-col ${className}`}>
|
||||
<div className={`h-full w-full flex flex-col ${className}`}>
|
||||
{/* Optional custom header */}
|
||||
{showHeader && title && (
|
||||
{!minimal && showHeader && title && (
|
||||
<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">
|
||||
@@ -89,14 +87,15 @@ function StandaloneShell({
|
||||
)}
|
||||
|
||||
{/* Shell component wrapper */}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 w-full min-h-0">
|
||||
<Shell
|
||||
selectedProject={project}
|
||||
selectedSession={session}
|
||||
isActive={isActive}
|
||||
initialCommand={command}
|
||||
isPlainShell={shouldUsePlainShell}
|
||||
onProcessComplete={handleProcessComplete}
|
||||
minimal={minimal}
|
||||
autoConnect={minimal ? true : autoConnect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user