From c1e025b6658e9d3bcc1c6bc03b1766e7e94d572f Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:05:28 +0300 Subject: [PATCH] fix: claude code login issues (#375) * fix: claude code login issues 1. Now, the browser opens in a new tab automatically 2. Clicking "C" to copy works 3. I have removed the "x-term" link selector since it didn't select the whole link * fix: remove unnecessary terminal hyperlink for auth URL * fix(shell): resolve clipboard handling for copy and paste events * feat(shell): add authentication URL display and copy functionality - allows copy for mobile users * revert: Update login command for unauthenticated users to use '/exit' --------- Co-authored-by: Haileyesus --- server/index.js | 133 +++++++++++++++++++++------ src/components/LoginModal.jsx | 4 +- src/components/Shell.jsx | 168 ++++++++++++++++++++++++++++++++-- 3 files changed, 264 insertions(+), 41 deletions(-) diff --git a/server/index.js b/server/index.js index 1db726d..8a2604d 100755 --- a/server/index.js +++ b/server/index.js @@ -178,6 +178,69 @@ const server = http.createServer(app); const ptySessionsMap = new Map(); const PTY_SESSION_TIMEOUT = 30 * 60 * 1000; +const SHELL_URL_PARSE_BUFFER_LIMIT = 32768; +const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g; +const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/; + +function stripAnsiSequences(value = '') { + return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, ''); +} + +function normalizeDetectedUrl(url) { + if (!url || typeof url !== 'string') return null; + + const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, ''); + if (!cleaned) return null; + + try { + const parsed = new URL(cleaned); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + return parsed.toString(); + } catch { + return null; + } +} + +function extractUrlsFromText(value = '') { + const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || []; + + // Handle wrapped terminal URLs split across lines by terminal width. + const wrappedMatches = []; + const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/; + const lines = value.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i); + if (!startMatch) continue; + + let combined = startMatch[0]; + let j = i + 1; + while (j < lines.length) { + const continuation = lines[j].trim(); + if (!continuation) break; + if (!continuationRegex.test(continuation)) break; + combined += continuation; + j++; + } + + wrappedMatches.push(combined.replace(/\r?\n\s*/g, '')); + } + + return Array.from(new Set([...directMatches, ...wrappedMatches])); +} + +function shouldAutoOpenUrlFromOutput(value = '') { + const normalized = value.toLowerCase(); + return ( + normalized.includes('browser didn\'t open') || + normalized.includes('open this url') || + normalized.includes('continue in your browser') || + normalized.includes('press enter to open') || + normalized.includes('open_url:') + ); +} // Single WebSocket server that handles both paths const wss = new WebSocketServer({ @@ -960,7 +1023,8 @@ function handleShellConnection(ws) { console.log('🐚 Shell client connected'); let shellProcess = null; let ptySessionKey = null; - let outputBuffer = []; + let urlDetectionBuffer = ''; + const announcedAuthUrls = new Set(); ws.on('message', async (message) => { try { @@ -974,6 +1038,8 @@ function handleShellConnection(ws) { const provider = data.provider || 'claude'; const initialCommand = data.initialCommand; const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell'; + urlDetectionBuffer = ''; + announcedAuthUrls.clear(); // Login commands (Claude/Cursor auth) should never reuse cached sessions const isLoginCommand = initialCommand && ( @@ -1113,9 +1179,7 @@ function handleShellConnection(ws) { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', - FORCE_COLOR: '3', - // Override browser opening commands to echo URL for detection - BROWSER: os.platform() === 'win32' ? 'echo "OPEN_URL:"' : 'echo "OPEN_URL:"' + FORCE_COLOR: '3' } }); @@ -1145,38 +1209,47 @@ function handleShellConnection(ws) { if (session.ws && session.ws.readyState === WebSocket.OPEN) { let outputData = data; - // Check for various URL opening patterns - const patterns = [ - // Direct browser opening commands - /(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g, - // BROWSER environment variable override + const cleanChunk = stripAnsiSequences(data); + urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT); + + outputData = outputData.replace( /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g, - // Git and other tools opening URLs - /Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi, - // General URL patterns that might be opened - /Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi, - /View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi, - /Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi - ]; + '[INFO] Opening in browser: $1' + ); - patterns.forEach(pattern => { - let match; - while ((match = pattern.exec(data)) !== null) { - const url = match[1]; - console.log('[DEBUG] Detected URL for opening:', url); + const emitAuthUrl = (detectedUrl, autoOpen = false) => { + const normalizedUrl = normalizeDetectedUrl(detectedUrl); + if (!normalizedUrl) return; - // Send URL opening message to client + const isNewUrl = !announcedAuthUrls.has(normalizedUrl); + if (isNewUrl) { + announcedAuthUrls.add(normalizedUrl); session.ws.send(JSON.stringify({ - type: 'url_open', - url: url + type: 'auth_url', + url: normalizedUrl, + autoOpen })); - - // Replace the OPEN_URL pattern with a user-friendly message - if (pattern.source.includes('OPEN_URL')) { - outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`); - } } - }); + + }; + + const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer) + .map((url) => normalizeDetectedUrl(url)) + .filter(Boolean); + + // Prefer the most complete URL if shorter prefix variants are also present. + const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) => + !urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url)) + ); + + dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false)); + + if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) { + const bestUrl = dedupedDetectedUrls.reduce((longest, current) => + current.length > longest.length ? current : longest + ); + emitAuthUrl(bestUrl, true); + } // Send regular output session.ws.send(JSON.stringify({ diff --git a/src/components/LoginModal.jsx b/src/components/LoginModal.jsx index 811daf2..beebe59 100644 --- a/src/components/LoginModal.jsx +++ b/src/components/LoginModal.jsx @@ -57,9 +57,7 @@ function LoginModal({ if (onComplete) { onComplete(exitCode); } - if (exitCode === 0) { - onClose(); - } + // Keep modal open so users can read login output and close explicitly. }; return ( diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx index 0bf78fd..ca18a86 100644 --- a/src/components/Shell.jsx +++ b/src/components/Shell.jsx @@ -26,6 +26,31 @@ if (typeof document !== 'undefined') { 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; +} + function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) { const { t } = useTranslation('chat'); const terminalRef = useRef(null); @@ -37,12 +62,15 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell 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 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; @@ -52,6 +80,42 @@ function Shell({ selectedProject, selectedSession, initialCommand, 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; @@ -77,6 +141,9 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell ws.current.onopen = () => { setIsConnected(true); setIsConnecting(false); + authUrlRef.current = ''; + setAuthUrl(''); + setAuthUrlCopyStatus('idle'); setTimeout(() => { if (fitAddon.current && terminal.current) { @@ -119,8 +186,16 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell if (terminal.current) { terminal.current.write(output); } + } else if (data.type === 'auth_url' && data.url) { + authUrlRef.current = data.url; + setAuthUrl(data.url); + setAuthUrlCopyStatus('idle'); } else if (data.type === 'url_open') { - window.open(data.url, '_blank'); + if (data.url) { + authUrlRef.current = data.url; + setAuthUrl(data.url); + setAuthUrlCopyStatus('idle'); + } } } catch (error) { console.error('[Shell] Error handling WebSocket message:', error, event.data); @@ -130,6 +205,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell ws.current.onclose = (event) => { setIsConnected(false); setIsConnecting(false); + setAuthUrlCopyStatus('idle'); if (terminal.current) { terminal.current.clear(); @@ -145,7 +221,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell setIsConnected(false); setIsConnecting(false); } - }, [isConnecting, isConnected]); + }, [isConnecting, isConnected, openAuthUrlInBrowser]); const connectToShell = useCallback(() => { if (!isInitialized || isConnected || isConnecting) return; @@ -166,6 +242,9 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell setIsConnected(false); setIsConnecting(false); + authUrlRef.current = ''; + setAuthUrl(''); + setAuthUrlCopyStatus('idle'); }, []); const sessionDisplayName = useMemo(() => { @@ -201,6 +280,9 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell setIsConnected(false); setIsInitialized(false); + authUrlRef.current = ''; + setAuthUrl(''); + setAuthUrlCopyStatus('idle'); setTimeout(() => { setIsRestarting(false); @@ -272,7 +354,10 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell const webLinksAddon = new WebLinksAddon(); terminal.current.loadAddon(fitAddon.current); - terminal.current.loadAddon(webLinksAddon); + // 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 { @@ -284,12 +369,41 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell terminal.current.open(terminalRef.current); terminal.current.attachCustomKeyEventHandler((event) => { - if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) { + if ( + event.type === 'keydown' && + minimal && + isPlainShellRef.current && + authUrlRef.current && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + event.key?.toLowerCase() === 'c' + ) { + copyAuthUrlToClipboard(authUrlRef.current).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.ctrlKey || event.metaKey) && event.key === 'v') { + 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({ @@ -359,7 +473,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell terminal.current = null; } }; - }, [selectedProject?.path || selectedProject?.fullPath, isRestarting]); + }, [selectedProject?.path || selectedProject?.fullPath, isRestarting, minimal, copyAuthUrlToClipboard]); useEffect(() => { if (!autoConnect || !isInitialized || isConnecting || isConnected) return; @@ -383,9 +497,47 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell } if (minimal) { + const hasAuthUrl = Boolean(authUrl); + return ( -
+
+ {hasAuthUrl && ( +
+
+

Open or copy the login URL:

+ 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" + /> +
+ + +
+
+
+ )}
); } @@ -495,4 +647,4 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell ); } -export default Shell; \ No newline at end of file +export default Shell;