mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-01 20:17:40 +00:00
fix(shell): remove Shell.jsx and add provider fallback from a previous commit
This commit is contained in:
@@ -1,692 +0,0 @@
|
|||||||
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';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { IS_PLATFORM } from '../constants/config';
|
|
||||||
|
|
||||||
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 fallbackCopyToClipboard(text) {
|
|
||||||
if (!text || typeof document === 'undefined') return false;
|
|
||||||
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.setAttribute('readonly', '');
|
|
||||||
textarea.style.position = 'fixed';
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
textarea.style.pointerEvents = 'none';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.focus();
|
|
||||||
textarea.select();
|
|
||||||
|
|
||||||
let copied = false;
|
|
||||||
try {
|
|
||||||
copied = document.execCommand('copy');
|
|
||||||
} catch {
|
|
||||||
copied = false;
|
|
||||||
} finally {
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
|
|
||||||
return copied;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
|
|
||||||
|
|
||||||
function isCodexLoginCommand(command) {
|
|
||||||
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
|
|
||||||
const { t } = useTranslation('chat');
|
|
||||||
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 [authUrl, setAuthUrl] = useState('');
|
|
||||||
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle');
|
|
||||||
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
|
|
||||||
|
|
||||||
const selectedProjectRef = useRef(selectedProject);
|
|
||||||
const selectedSessionRef = useRef(selectedSession);
|
|
||||||
const initialCommandRef = useRef(initialCommand);
|
|
||||||
const isPlainShellRef = useRef(isPlainShell);
|
|
||||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
|
||||||
const authUrlRef = useRef('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedProjectRef.current = selectedProject;
|
|
||||||
selectedSessionRef.current = selectedSession;
|
|
||||||
initialCommandRef.current = initialCommand;
|
|
||||||
isPlainShellRef.current = isPlainShell;
|
|
||||||
onProcessCompleteRef.current = onProcessComplete;
|
|
||||||
});
|
|
||||||
|
|
||||||
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
|
|
||||||
if (!url) return false;
|
|
||||||
|
|
||||||
const popup = window.open(url, '_blank', 'noopener,noreferrer');
|
|
||||||
if (popup) {
|
|
||||||
try {
|
|
||||||
popup.opener = null;
|
|
||||||
} catch {
|
|
||||||
// Ignore cross-origin restrictions when trying to null opener
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
|
|
||||||
if (!url) return false;
|
|
||||||
|
|
||||||
let copied = false;
|
|
||||||
try {
|
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(url);
|
|
||||||
copied = true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
copied = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!copied) {
|
|
||||||
copied = fallbackCopyToClipboard(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return copied;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const connectWebSocket = useCallback(async () => {
|
|
||||||
if (isConnecting || isConnected) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let wsUrl;
|
|
||||||
|
|
||||||
if (IS_PLATFORM) {
|
|
||||||
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);
|
|
||||||
authUrlRef.current = '';
|
|
||||||
setAuthUrl('');
|
|
||||||
setAuthUrlCopyStatus('idle');
|
|
||||||
setIsAuthPanelHidden(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 || localStorage.getItem('selected-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 === 'auth_url' && data.url) {
|
|
||||||
authUrlRef.current = data.url;
|
|
||||||
setAuthUrl(data.url);
|
|
||||||
setAuthUrlCopyStatus('idle');
|
|
||||||
setIsAuthPanelHidden(false);
|
|
||||||
} else if (data.type === 'url_open') {
|
|
||||||
if (data.url) {
|
|
||||||
authUrlRef.current = data.url;
|
|
||||||
setAuthUrl(data.url);
|
|
||||||
setAuthUrlCopyStatus('idle');
|
|
||||||
setIsAuthPanelHidden(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Shell] Error handling WebSocket message:', error, event.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.current.onclose = (event) => {
|
|
||||||
setIsConnected(false);
|
|
||||||
setIsConnecting(false);
|
|
||||||
setAuthUrlCopyStatus('idle');
|
|
||||||
setIsAuthPanelHidden(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, openAuthUrlInBrowser]);
|
|
||||||
|
|
||||||
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);
|
|
||||||
authUrlRef.current = '';
|
|
||||||
setAuthUrl('');
|
|
||||||
setAuthUrlCopyStatus('idle');
|
|
||||||
setIsAuthPanelHidden(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);
|
|
||||||
authUrlRef.current = '';
|
|
||||||
setAuthUrl('');
|
|
||||||
setAuthUrlCopyStatus('idle');
|
|
||||||
setIsAuthPanelHidden(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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: true,
|
|
||||||
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);
|
|
||||||
// Disable xterm link auto-detection in minimal (login) mode to avoid partial wrapped URL links.
|
|
||||||
if (!minimal) {
|
|
||||||
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) => {
|
|
||||||
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
|
||||||
? CODEX_DEVICE_AUTH_URL
|
|
||||||
: authUrlRef.current;
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.type === 'keydown' &&
|
|
||||||
minimal &&
|
|
||||||
isPlainShellRef.current &&
|
|
||||||
activeAuthUrl &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
event.key?.toLowerCase() === 'c'
|
|
||||||
) {
|
|
||||||
copyAuthUrlToClipboard(activeAuthUrl).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.type === 'keydown' &&
|
|
||||||
(event.ctrlKey || event.metaKey) &&
|
|
||||||
event.key?.toLowerCase() === 'c' &&
|
|
||||||
terminal.current.hasSelection()
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
document.execCommand('copy');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.type === 'keydown' &&
|
|
||||||
(event.ctrlKey || event.metaKey) &&
|
|
||||||
event.key?.toLowerCase() === 'v'
|
|
||||||
) {
|
|
||||||
// Block native browser/xterm paste so clipboard data is only sent after
|
|
||||||
// the explicit clipboard-read flow resolves (avoids duplicate pastes).
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
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 () => {
|
|
||||||
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, minimal, copyAuthUrlToClipboard]);
|
|
||||||
|
|
||||||
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">{t('shell.selectProject.title')}</h3>
|
|
||||||
<p>{t('shell.selectProject.description')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minimal) {
|
|
||||||
const displayAuthUrl = isCodexLoginCommand(initialCommand)
|
|
||||||
? CODEX_DEVICE_AUTH_URL
|
|
||||||
: authUrl;
|
|
||||||
const hasAuthUrl = Boolean(displayAuthUrl);
|
|
||||||
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
|
|
||||||
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full bg-gray-900 relative">
|
|
||||||
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
|
||||||
{showMobileAuthPanel && (
|
|
||||||
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsAuthPanelHidden(true)}
|
|
||||||
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
Hide
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayAuthUrl}
|
|
||||||
readOnly
|
|
||||||
onClick={(event) => event.currentTarget.select()}
|
|
||||||
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
aria-label="Authentication URL"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
openAuthUrlInBrowser(displayAuthUrl);
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Open URL
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
|
|
||||||
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showMobileAuthPanelToggle && (
|
|
||||||
<div className="absolute bottom-14 right-3 z-20 md:hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsAuthPanelHidden(false)}
|
|
||||||
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Show login URL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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">{t('shell.status.newSession')}</span>
|
|
||||||
)}
|
|
||||||
{!isInitialized && (
|
|
||||||
<span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
|
|
||||||
)}
|
|
||||||
{isRestarting && (
|
|
||||||
<span className="text-xs text-blue-400">{t('shell.status.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={t('shell.actions.disconnectTitle')}
|
|
||||||
>
|
|
||||||
<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>{t('shell.actions.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={t('shell.actions.restartTitle')}
|
|
||||||
>
|
|
||||||
<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>{t('shell.actions.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">{t('shell.loading')}</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={t('shell.actions.connectTitle')}
|
|
||||||
>
|
|
||||||
<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>{t('shell.actions.connect')}</span>
|
|
||||||
</button>
|
|
||||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
|
||||||
{isPlainShell ?
|
|
||||||
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
|
|
||||||
selectedSession ?
|
|
||||||
t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
|
|
||||||
t('shell.startSession')
|
|
||||||
}
|
|
||||||
</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">{t('shell.connecting')}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
|
||||||
{isPlainShell ?
|
|
||||||
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
|
|
||||||
t('shell.startCli', { projectName: selectedProject.displayName })
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Shell;
|
|
||||||
@@ -144,9 +144,7 @@ export function useShellConnection({
|
|||||||
projectPath: currentProject.fullPath || currentProject.path || '',
|
projectPath: currentProject.fullPath || currentProject.path || '',
|
||||||
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id || null,
|
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id || null,
|
||||||
hasSession: isPlainShellRef.current ? false : Boolean(selectedSessionRef.current),
|
hasSession: isPlainShellRef.current ? false : Boolean(selectedSessionRef.current),
|
||||||
provider: isPlainShellRef.current
|
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || localStorage.getItem('selected-provider') || 'claude'),
|
||||||
? 'plain-shell'
|
|
||||||
: selectedSessionRef.current?.__provider || 'claude',
|
|
||||||
cols: currentTerminal.cols,
|
cols: currentTerminal.cols,
|
||||||
rows: currentTerminal.rows,
|
rows: currentTerminal.rows,
|
||||||
initialCommand: initialCommandRef.current,
|
initialCommand: initialCommandRef.current,
|
||||||
|
|||||||
Reference in New Issue
Block a user