import { useEffect, useRef, useCallback, useState } from 'react'; import { X, SplitSquareHorizontal, SplitSquareVertical, GripHorizontal, Terminal, ZoomIn, ZoomOut, Copy, ClipboardPaste, CheckSquare, Trash2, ImageIcon, Loader2, Settings, RotateCcw, Search, ChevronUp, ChevronDown, Maximize2, Minimize2, ArrowDown, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Slider } from '@/components/ui/slider'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { useDraggable, useDroppable } from '@dnd-kit/core'; import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, type KeyboardShortcuts } from '@/store/app-store'; import { useShallow } from 'zustand/react/shallow'; import { matchesShortcutWithCode } from '@/hooks/use-keyboard-shortcuts'; import { getTerminalTheme, TERMINAL_FONT_OPTIONS, DEFAULT_TERMINAL_FONT, } from '@/config/terminal-themes'; import { toast } from 'sonner'; import { getElectronAPI } from '@/lib/electron'; import { getApiKey, getSessionToken } from '@/lib/http-api-client'; // Font size constraints const MIN_FONT_SIZE = 8; const MAX_FONT_SIZE = 32; const DEFAULT_FONT_SIZE = 14; // Resize constraints const RESIZE_DEBOUNCE_MS = 100; // Short debounce for responsive feel // Image drag-drop constants const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB // Large paste handling constants const LARGE_PASTE_WARNING_THRESHOLD = 1024 * 1024; // 1MB - show warning for pastes this size or larger const PASTE_CHUNK_SIZE = 8 * 1024; // 8KB chunks for large pastes const PASTE_CHUNK_DELAY_MS = 10; // Small delay between chunks to prevent overwhelming WebSocket interface TerminalPanelProps { sessionId: string; authToken: string | null; isActive: boolean; onFocus: () => void; onClose: () => void; onSplitHorizontal: () => void; onSplitVertical: () => void; onNewTab?: () => void; onNavigateUp?: () => void; // Navigate to terminal pane above onNavigateDown?: () => void; // Navigate to terminal pane below onNavigateLeft?: () => void; // Navigate to terminal pane on the left onNavigateRight?: () => void; // Navigate to terminal pane on the right onSessionInvalid?: () => void; // Called when session is no longer valid on server (e.g., server restarted) isDragging?: boolean; isDropTarget?: boolean; fontSize: number; onFontSizeChange: (size: number) => void; runCommandOnConnect?: string; // Command to run when terminal first connects (for new terminals) onCommandRan?: () => void; // Callback when the initial command has been sent isMaximized?: boolean; onToggleMaximize?: () => void; } // Type for xterm Terminal - we'll use any since we're dynamically importing type XTerminal = InstanceType; type XFitAddon = InstanceType; type XSearchAddon = InstanceType; type XWebLinksAddon = InstanceType; export function TerminalPanel({ sessionId, authToken, isActive, onFocus, onClose, onSplitHorizontal, onSplitVertical, onNewTab, onNavigateUp, onNavigateDown, onNavigateLeft, onNavigateRight, onSessionInvalid, isDragging = false, isDropTarget = false, fontSize, onFontSizeChange, runCommandOnConnect, onCommandRan, isMaximized = false, onToggleMaximize, }: TerminalPanelProps) { const terminalRef = useRef(null); const containerRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); const heartbeatIntervalRef = useRef(null); const lastShortcutTimeRef = useRef(0); const resizeDebounceRef = useRef(null); const focusHandlerRef = useRef<{ dispose: () => void } | null>(null); const linkProviderRef = useRef<{ dispose: () => void } | null>(null); const [isTerminalReady, setIsTerminalReady] = useState(false); const [shellName, setShellName] = useState('shell'); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [isMac, setIsMac] = useState(false); const isMacRef = useRef(false); const contextMenuRef = useRef(null); const [focusedMenuIndex, setFocusedMenuIndex] = useState(0); const focusedMenuIndexRef = useRef(0); const [isImageDragOver, setIsImageDragOver] = useState(false); const [isProcessingImage, setIsProcessingImage] = useState(false); const hasRunInitialCommandRef = useRef(false); const searchAddonRef = useRef(null); const searchInputRef = useRef(null); const [showSearch, setShowSearch] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const showSearchRef = useRef(false); const [isAtBottom, setIsAtBottom] = useState(true); const [connectionStatus, setConnectionStatus] = useState< 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'auth_failed' >('connecting'); const reconnectAttemptsRef = useRef(0); const MAX_RECONNECT_ATTEMPTS = 5; const INITIAL_RECONNECT_DELAY = 1000; const [processExitCode, setProcessExitCode] = useState(null); // Get current project for image saving const currentProject = useAppStore((state) => state.currentProject); // Get terminal settings from store - grouped with shallow comparison to reduce re-renders const { defaultRunScript, screenReaderMode, fontFamily, scrollbackLines, lineHeight } = useAppStore( useShallow((state) => ({ defaultRunScript: state.terminalState.defaultRunScript, screenReaderMode: state.terminalState.screenReaderMode, fontFamily: state.terminalState.fontFamily, scrollbackLines: state.terminalState.scrollbackLines, lineHeight: state.terminalState.lineHeight, })) ); // Action setters are stable references, can use individual selectors const setTerminalDefaultRunScript = useAppStore((state) => state.setTerminalDefaultRunScript); const setTerminalScreenReaderMode = useAppStore((state) => state.setTerminalScreenReaderMode); const setTerminalFontFamily = useAppStore((state) => state.setTerminalFontFamily); const setTerminalScrollbackLines = useAppStore((state) => state.setTerminalScrollbackLines); const setTerminalLineHeight = useAppStore((state) => state.setTerminalLineHeight); // Detect platform on mount useEffect(() => { // Use modern userAgentData API with fallback to navigator.platform const nav = navigator as Navigator & { userAgentData?: { platform: string } }; let detected = false; if (nav.userAgentData?.platform) { detected = nav.userAgentData.platform.toLowerCase().includes('mac'); } else if (typeof navigator !== 'undefined') { // Fallback for browsers without userAgentData (intentionally using deprecated API) detected = /mac/i.test(navigator.platform); } setIsMac(detected); isMacRef.current = detected; }, []); // Get effective theme from store const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme); const effectiveTheme = getEffectiveTheme(); // Get keyboard shortcuts from store - merged with defaults const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); const mergedShortcuts: KeyboardShortcuts = { ...DEFAULT_KEYBOARD_SHORTCUTS, ...keyboardShortcuts, }; const shortcutsRef = useRef(mergedShortcuts); shortcutsRef.current = mergedShortcuts; // Track system dark mode preference for "system" theme const [systemIsDark, setSystemIsDark] = useState(() => { if (typeof window !== 'undefined') { return window.matchMedia('(prefers-color-scheme: dark)').matches; } return false; }); // Listen for system theme changes useEffect(() => { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handleChange = (e: MediaQueryListEvent) => { setSystemIsDark(e.matches); }; mediaQuery.addEventListener('change', handleChange); return () => mediaQuery.removeEventListener('change', handleChange); }, []); // Resolve "system" theme to actual light/dark const resolvedTheme = effectiveTheme === 'system' ? (systemIsDark ? 'dark' : 'light') : effectiveTheme; // Use refs for callbacks and values to avoid effect re-runs const onFocusRef = useRef(onFocus); onFocusRef.current = onFocus; const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const onSplitHorizontalRef = useRef(onSplitHorizontal); onSplitHorizontalRef.current = onSplitHorizontal; const onSplitVerticalRef = useRef(onSplitVertical); onSplitVerticalRef.current = onSplitVertical; const onNewTabRef = useRef(onNewTab); onNewTabRef.current = onNewTab; const onNavigateUpRef = useRef(onNavigateUp); onNavigateUpRef.current = onNavigateUp; const onNavigateDownRef = useRef(onNavigateDown); onNavigateDownRef.current = onNavigateDown; const onNavigateLeftRef = useRef(onNavigateLeft); onNavigateLeftRef.current = onNavigateLeft; const onNavigateRightRef = useRef(onNavigateRight); onNavigateRightRef.current = onNavigateRight; const onSessionInvalidRef = useRef(onSessionInvalid); onSessionInvalidRef.current = onSessionInvalid; const fontSizeRef = useRef(fontSize); fontSizeRef.current = fontSize; const themeRef = useRef(resolvedTheme); themeRef.current = resolvedTheme; const copySelectionRef = useRef<() => Promise>(() => Promise.resolve(false)); const pasteFromClipboardRef = useRef<() => Promise>(() => Promise.resolve()); // Zoom functions - use the prop callback const zoomIn = useCallback(() => { onFontSizeChange(Math.min(fontSize + 1, MAX_FONT_SIZE)); }, [fontSize, onFontSizeChange]); const zoomOut = useCallback(() => { onFontSizeChange(Math.max(fontSize - 1, MIN_FONT_SIZE)); }, [fontSize, onFontSizeChange]); const resetZoom = useCallback(() => { onFontSizeChange(DEFAULT_FONT_SIZE); }, [onFontSizeChange]); // Strip ANSI escape codes from text const stripAnsi = (text: string): string => { // Match ANSI escape sequences: // - CSI sequences: \x1b[...letter // - OSC sequences: \x1b]...ST // - Other escape sequences: \x1b followed by various characters // eslint-disable-next-line no-control-regex return text.replace( /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[>=<]|\x1b[78HM]|\x1b#[0-9]|\x1b./g, '' ); }; // Copy selected text to clipboard const copySelection = useCallback(async (): Promise => { const terminal = xtermRef.current; if (!terminal) return false; const selection = terminal.getSelection(); if (!selection) { toast.error('Nothing to copy', { description: 'Select some text first', }); return false; } try { // Strip any ANSI escape codes that might be in the selection const cleanText = stripAnsi(selection); await navigator.clipboard.writeText(cleanText); toast.success('Copied to clipboard'); return true; } catch (err) { console.error('[Terminal] Copy failed:', err); const errorMessage = err instanceof Error ? err.message : 'Unknown error'; toast.error('Copy failed', { description: errorMessage.includes('permission') ? 'Clipboard permission denied' : 'Could not access clipboard', }); return false; } }, []); copySelectionRef.current = copySelection; // Helper function to send text in chunks with delay const sendTextInChunks = useCallback(async (text: string) => { const ws = wsRef.current; if (!ws || ws.readyState !== WebSocket.OPEN) return; // For small pastes, send all at once if (text.length <= PASTE_CHUNK_SIZE) { ws.send(JSON.stringify({ type: 'input', data: text })); return; } // For large pastes, chunk it for (let i = 0; i < text.length; i += PASTE_CHUNK_SIZE) { if (ws.readyState !== WebSocket.OPEN) break; const chunk = text.slice(i, i + PASTE_CHUNK_SIZE); ws.send(JSON.stringify({ type: 'input', data: chunk })); // Small delay between chunks to prevent overwhelming the WebSocket if (i + PASTE_CHUNK_SIZE < text.length) { await new Promise((resolve) => setTimeout(resolve, PASTE_CHUNK_DELAY_MS)); } } }, []); // Paste from clipboard const pasteFromClipboard = useCallback(async () => { const terminal = xtermRef.current; if (!terminal || !wsRef.current) return; try { const text = await navigator.clipboard.readText(); if (!text) { toast.error('Nothing to paste', { description: 'Clipboard is empty', }); return; } if (wsRef.current.readyState !== WebSocket.OPEN) { toast.error('Terminal not connected'); return; } // Warn for large pastes if (text.length >= LARGE_PASTE_WARNING_THRESHOLD) { const sizeMB = (text.length / (1024 * 1024)).toFixed(1); toast.warning(`Large paste (${sizeMB}MB)`, { description: 'Sending in chunks, this may take a moment...', duration: 3000, }); } await sendTextInChunks(text); } catch (err) { console.error('[Terminal] Paste failed:', err); const errorMessage = err instanceof Error ? err.message : 'Unknown error'; toast.error('Paste failed', { description: errorMessage.includes('permission') ? 'Clipboard permission denied' : 'Could not read from clipboard', }); } }, [sendTextInChunks]); pasteFromClipboardRef.current = pasteFromClipboard; // Select all terminal content const selectAll = useCallback(() => { xtermRef.current?.selectAll(); }, []); // Clear terminal const clearTerminal = useCallback(() => { xtermRef.current?.clear(); }, []); // Get theme colors for search highlighting const terminalTheme = getTerminalTheme(effectiveTheme); const searchOptions = { caseSensitive: false, regex: false, decorations: { matchBackground: terminalTheme.searchMatchBackground, matchBorder: terminalTheme.searchMatchBorder, matchOverviewRuler: terminalTheme.searchMatchBorder, activeMatchBackground: terminalTheme.searchActiveMatchBackground, activeMatchBorder: terminalTheme.searchActiveMatchBorder, activeMatchColorOverviewRuler: terminalTheme.searchActiveMatchBorder, }, }; // Search functions const searchNext = useCallback(() => { if (searchAddonRef.current && searchQuery) { searchAddonRef.current.findNext(searchQuery, searchOptions); } }, [searchQuery, searchOptions]); const searchPrevious = useCallback(() => { if (searchAddonRef.current && searchQuery) { searchAddonRef.current.findPrevious(searchQuery, searchOptions); } }, [searchQuery, searchOptions]); const closeSearch = useCallback(() => { setShowSearch(false); showSearchRef.current = false; setSearchQuery(''); searchAddonRef.current?.clearDecorations(); xtermRef.current?.focus(); }, []); // Handle pane navigation keyboard shortcuts at container level (capture phase) // This ensures we intercept before xterm can process the event const handleContainerKeyDownCapture = useCallback( (event: React.KeyboardEvent) => { // Ctrl+Alt+Arrow / Cmd+Alt+Arrow - Navigate between panes directionally if ((event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey) { const code = event.nativeEvent.code; if (code === 'ArrowRight') { event.preventDefault(); event.stopPropagation(); onNavigateRight?.(); } else if (code === 'ArrowLeft') { event.preventDefault(); event.stopPropagation(); onNavigateLeft?.(); } else if (code === 'ArrowDown') { event.preventDefault(); event.stopPropagation(); onNavigateDown?.(); } else if (code === 'ArrowUp') { event.preventDefault(); event.stopPropagation(); onNavigateUp?.(); } } }, [onNavigateUp, onNavigateDown, onNavigateLeft, onNavigateRight] ); // Scroll to bottom of terminal const scrollToBottom = useCallback(() => { if (xtermRef.current) { xtermRef.current.scrollToBottom(); setIsAtBottom(true); } }, []); // Close context menu const closeContextMenu = useCallback(() => { setContextMenu(null); }, []); // Handle context menu action const handleContextMenuAction = useCallback( async (action: 'copy' | 'paste' | 'selectAll' | 'clear') => { closeContextMenu(); switch (action) { case 'copy': await copySelection(); break; case 'paste': await pasteFromClipboard(); break; case 'selectAll': selectAll(); break; case 'clear': clearTerminal(); break; } xtermRef.current?.focus(); }, [closeContextMenu, copySelection, pasteFromClipboard, selectAll, clearTerminal] ); const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; const wsUrl = serverUrl.replace(/^http/, 'ws'); // Fetch a short-lived WebSocket token for secure authentication const fetchWsToken = useCallback(async (): Promise => { try { const headers: Record = { 'Content-Type': 'application/json', }; const sessionToken = getSessionToken(); if (sessionToken) { headers['X-Session-Token'] = sessionToken; } const response = await fetch(`${serverUrl}/api/auth/token`, { headers, credentials: 'include', }); if (!response.ok) { console.warn('[Terminal] Failed to fetch wsToken:', response.status); return null; } const data = await response.json(); if (data.success && data.token) { return data.token; } return null; } catch (error) { console.error('[Terminal] Error fetching wsToken:', error); return null; } }, [serverUrl]); // Draggable - only the drag handle triggers drag const { attributes: dragAttributes, listeners: dragListeners, setNodeRef: setDragRef, } = useDraggable({ id: sessionId, }); // Droppable - the entire panel is a drop target const { setNodeRef: setDropRef, isOver } = useDroppable({ id: sessionId, }); // Initialize terminal - dynamically import xterm to avoid SSR issues useEffect(() => { if (!terminalRef.current) return; let mounted = true; const initTerminal = async () => { // Dynamically import xterm modules const [{ Terminal }, { FitAddon }, { WebglAddon }, { SearchAddon }, { WebLinksAddon }] = await Promise.all([ import('@xterm/xterm'), import('@xterm/addon-fit'), import('@xterm/addon-webgl'), import('@xterm/addon-search'), import('@xterm/addon-web-links'), ]); // Also import CSS await import('@xterm/xterm/css/xterm.css'); if (!mounted || !terminalRef.current) return; // Get terminal theme matching the app theme const terminalTheme = getTerminalTheme(themeRef.current); // Get settings from store (read at initialization time) const terminalSettings = useAppStore.getState().terminalState; const screenReaderEnabled = terminalSettings.screenReaderMode; const terminalFontFamily = terminalSettings.fontFamily || DEFAULT_TERMINAL_FONT; const terminalScrollback = terminalSettings.scrollbackLines || 5000; const terminalLineHeight = terminalSettings.lineHeight || 1.0; // Create terminal instance with the current global font size and theme const terminal = new Terminal({ cursorBlink: true, cursorStyle: 'block', fontSize: fontSizeRef.current, fontFamily: terminalFontFamily, lineHeight: terminalLineHeight, letterSpacing: 0, theme: terminalTheme, allowProposedApi: true, screenReaderMode: screenReaderEnabled, scrollback: terminalScrollback, }); // Create fit addon const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); // Create search addon const searchAddon = new SearchAddon(); terminal.loadAddon(searchAddon); searchAddonRef.current = searchAddon; // Create web links addon for clickable URLs with custom handler for Electron const webLinksAddon = new WebLinksAddon((_event: MouseEvent, uri: string) => { // Use Electron API to open external links in system browser const api = getElectronAPI(); if (api?.openExternalLink) { api.openExternalLink(uri).catch((error) => { console.error('[Terminal] Failed to open URL:', error); // Fallback to window.open if Electron API fails window.open(uri, '_blank', 'noopener,noreferrer'); }); } else { // Web fallback window.open(uri, '_blank', 'noopener,noreferrer'); } }); terminal.loadAddon(webLinksAddon); // Open terminal terminal.open(terminalRef.current); // Register custom link provider for file paths // Detects patterns like /path/to/file.ts:123:45 or ./src/file.js:10 const filePathLinkProvider = { provideLinks: ( lineNumber: number, callback: ( links: | { range: { start: { x: number; y: number }; end: { x: number; y: number } }; text: string; activate: (event: MouseEvent, text: string) => void; }[] | undefined ) => void ) => { const line = terminal.buffer.active.getLine(lineNumber - 1); if (!line) { callback(undefined); return; } const lineText = line.translateToString(true); const links: { range: { start: { x: number; y: number }; end: { x: number; y: number } }; text: string; activate: (event: MouseEvent, text: string) => void; }[] = []; // File path patterns: // 1. Absolute Unix: /path/to/file.ext:line:col or /path/to/file.ext:line // 2. Home directory: ~/path/to/file.ext:line:col // 3. Absolute Windows: C:\path\to\file.ext:line:col (less common in terminal output) // 4. Relative: ./path/file.ext:line or src/file.ext:line // Common formats from compilers/linters: // - ESLint: /path/file.ts:10:5 // - TypeScript: src/file.ts(10,5) // - Go: /path/file.go:10:5 const filePathRegex = /(?:^|[\s'"(])(((?:\/|\.\/|\.\.\/|~\/)[^\s:'"()]+|[a-zA-Z]:\\[^\s:'"()]+|[a-zA-Z0-9_-]+\/[^\s:'"()]+)(?:[:(\s](\d+)(?:[:,)](\d+))?)?)/g; let match; while ((match = filePathRegex.exec(lineText)) !== null) { const fullMatch = match[1]; const filePath = match[2]; const lineNum = match[3] ? parseInt(match[3], 10) : undefined; const colNum = match[4] ? parseInt(match[4], 10) : undefined; // Skip common false positives (URLs, etc.) if ( filePath.startsWith('http://') || filePath.startsWith('https://') || filePath.startsWith('ws://') ) { continue; } // Calculate the start position (1-indexed for xterm) const startX = match.index + (match[0].length - match[1].length) + 1; const endX = startX + fullMatch.length; links.push({ range: { start: { x: startX, y: lineNumber }, end: { x: endX, y: lineNumber }, }, text: fullMatch, activate: async (event: MouseEvent, text: string) => { // Parse the path and line/column from the matched text const pathMatch = text.match(/^([^\s:()]+)(?:[:(\s](\d+)(?:[:,)](\d+))?)?/); if (!pathMatch) return; const clickedPath = pathMatch[1]; const clickedLine = pathMatch[2] ? parseInt(pathMatch[2], 10) : undefined; const clickedCol = pathMatch[3] ? parseInt(pathMatch[3], 10) : undefined; // Resolve paths to absolute paths let absolutePath = clickedPath; const api = getElectronAPI(); if (clickedPath.startsWith('~/')) { // Home directory path - expand ~ to user's home directory try { const homePath = await api.getPath?.('home'); if (homePath) { absolutePath = homePath + clickedPath.slice(1); // Replace ~ with home path } } catch { // If we can't get home path, just use the path as-is console.warn('[Terminal] Could not resolve home directory path'); } } else if (!clickedPath.startsWith('/') && !clickedPath.match(/^[a-zA-Z]:\\/)) { // Relative path - resolve against project path const projectPath = useAppStore.getState().currentProject?.path; if (projectPath) { absolutePath = `${projectPath}/${clickedPath}`.replace(/\/+/g, '/'); } else { toast.warning('Cannot open relative path', { description: 'No project selected. Open a project to click relative file paths.', }); return; } } // Open in editor using VS Code URL scheme // Works in both web (via anchor click) and Electron (via shell.openExternal) try { const result = await api.openInEditor?.(absolutePath, clickedLine, clickedCol); if (result && !result.success) { toast.error('Failed to open in editor', { description: result.error }); } } catch (error) { console.error('[Terminal] Failed to open file:', error); toast.error('Failed to open file', { description: error instanceof Error ? error.message : 'Unknown error', }); } }, }); } callback(links.length > 0 ? links : undefined); }, }; linkProviderRef.current = terminal.registerLinkProvider(filePathLinkProvider); // Try to load WebGL addon for better performance try { const webglAddon = new WebglAddon(); webglAddon.onContextLoss(() => { webglAddon.dispose(); }); terminal.loadAddon(webglAddon); } catch { console.warn('[Terminal] WebGL addon not available, falling back to canvas'); } // Fit terminal to container - wait for stable dimensions // Use initial delay then multiple RAFs to let react-resizable-panels finish layout let fitAttempts = 0; const MAX_FIT_ATTEMPTS = 10; let lastWidth = 0; let lastHeight = 0; const attemptFit = () => { if (!fitAddon || !terminalRef.current || fitAttempts >= MAX_FIT_ATTEMPTS) return; const rect = terminalRef.current.getBoundingClientRect(); fitAttempts++; // Check if dimensions are stable (same as last attempt) and valid if ( rect.width === lastWidth && rect.height === lastHeight && rect.width > 0 && rect.height > 0 ) { try { fitAddon.fit(); } catch (err) { console.error('[Terminal] Initial fit error:', err); } return; } // Dimensions still changing or too small, try again lastWidth = rect.width; lastHeight = rect.height; requestAnimationFrame(attemptFit); }; // Initial delay allows complex layouts to settle before attempting fit setTimeout(() => requestAnimationFrame(attemptFit), 50); xtermRef.current = terminal; fitAddonRef.current = fitAddon; setIsTerminalReady(true); // Handle focus - use ref to avoid re-running effect // Store disposer to prevent memory leak focusHandlerRef.current = terminal.onData(() => { onFocusRef.current(); }); // Custom key handler to intercept terminal shortcuts // Return false to prevent xterm from handling the key const SHORTCUT_COOLDOWN_MS = 300; // Prevent rapid firing terminal.attachCustomKeyEventHandler((event) => { // Only intercept keydown events if (event.type !== 'keydown') return true; // Use event.code for keyboard-layout-independent key detection const code = event.code; // Ctrl+Alt+Arrow / Cmd+Alt+Arrow - Navigate between panes directionally // Handle this FIRST before any other checks to prevent xterm from capturing it // Use explicit check for both Ctrl and Meta to work on all platforms if ((event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey) { if (code === 'ArrowRight') { event.preventDefault(); event.stopPropagation(); onNavigateRightRef.current?.(); return false; } else if (code === 'ArrowLeft') { event.preventDefault(); event.stopPropagation(); onNavigateLeftRef.current?.(); return false; } else if (code === 'ArrowDown') { event.preventDefault(); event.stopPropagation(); onNavigateDownRef.current?.(); return false; } else if (code === 'ArrowUp') { event.preventDefault(); event.stopPropagation(); onNavigateUpRef.current?.(); return false; } } // Check cooldown to prevent rapid terminal creation const now = Date.now(); const canTrigger = now - lastShortcutTimeRef.current > SHORTCUT_COOLDOWN_MS; // Get current shortcuts from ref (allows customization) const shortcuts = shortcutsRef.current; // Split right (default: Alt+D) if (matchesShortcutWithCode(event, shortcuts.splitTerminalRight)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; onSplitHorizontalRef.current(); } return false; } // Split down (default: Alt+S) if (matchesShortcutWithCode(event, shortcuts.splitTerminalDown)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; onSplitVerticalRef.current(); } return false; } // Close terminal (default: Alt+W) if (matchesShortcutWithCode(event, shortcuts.closeTerminal)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; onCloseRef.current(); } return false; } // New terminal tab (default: Alt+T) if (matchesShortcutWithCode(event, shortcuts.newTerminalTab)) { event.preventDefault(); if (canTrigger && onNewTabRef.current) { lastShortcutTimeRef.current = now; onNewTabRef.current(); } return false; } const modKey = isMacRef.current ? event.metaKey : event.ctrlKey; const otherModKey = isMacRef.current ? event.ctrlKey : event.metaKey; // Ctrl+Shift+C / Cmd+Shift+C - Always copy (Linux terminal convention) if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyC') { event.preventDefault(); copySelectionRef.current(); return false; } // Ctrl+C / Cmd+C - Copy if text is selected, otherwise send SIGINT if (modKey && !otherModKey && !event.shiftKey && !event.altKey && code === 'KeyC') { const hasSelection = terminal.hasSelection(); if (hasSelection) { event.preventDefault(); copySelectionRef.current(); terminal.clearSelection(); return false; } // No selection - let xterm handle it (sends SIGINT) return true; } // Ctrl+V / Cmd+V or Ctrl+Shift+V / Cmd+Shift+V - Paste if (modKey && !otherModKey && !event.altKey && code === 'KeyV') { event.preventDefault(); pasteFromClipboardRef.current(); return false; } // Ctrl+A / Cmd+A - Select all if (modKey && !otherModKey && !event.shiftKey && !event.altKey && code === 'KeyA') { event.preventDefault(); terminal.selectAll(); return false; } // Ctrl+Shift+F / Cmd+Shift+F - Toggle search if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyF') { event.preventDefault(); showSearchRef.current = !showSearchRef.current; setShowSearch(showSearchRef.current); return false; } // Let xterm handle all other keys return true; }); }; initTerminal(); // Cleanup return () => { mounted = false; // Dispose focus handler to prevent memory leak if (focusHandlerRef.current) { focusHandlerRef.current.dispose(); focusHandlerRef.current = null; } // Dispose link provider to prevent memory leak if (linkProviderRef.current) { linkProviderRef.current.dispose(); linkProviderRef.current = null; } // Clear resize debounce timer if (resizeDebounceRef.current) { clearTimeout(resizeDebounceRef.current); resizeDebounceRef.current = null; } // Clear search decorations before disposing to prevent visual artifacts if (searchAddonRef.current) { searchAddonRef.current.clearDecorations(); searchAddonRef.current = null; } if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; } fitAddonRef.current = null; setIsTerminalReady(false); }; }, []); // No dependencies - only run once on mount // Connect WebSocket - wait for terminal to be ready useEffect(() => { if (!isTerminalReady || !sessionId) return; const terminal = xtermRef.current; if (!terminal) return; const connect = async () => { // Build WebSocket URL with auth params let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`; // Add API key for Electron mode auth const apiKey = getApiKey(); if (apiKey) { url += `&apiKey=${encodeURIComponent(apiKey)}`; } else { // In web mode, fetch a short-lived wsToken for secure authentication const wsToken = await fetchWsToken(); if (wsToken) { url += `&wsToken=${encodeURIComponent(wsToken)}`; } // Cookies are also sent automatically with same-origin WebSocket } // Add terminal password token if required if (authToken) { url += `&token=${encodeURIComponent(authToken)}`; } const ws = new WebSocket(url); wsRef.current = ws; ws.onopen = () => { console.log(`[Terminal] WebSocket connected for session ${sessionId}`); setConnectionStatus('connected'); reconnectAttemptsRef.current = 0; // Start heartbeat to keep connection alive (prevents proxy/load balancer timeouts) if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); } heartbeatIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, 30000); // Ping every 30 seconds }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); switch (msg.type) { case 'data': terminal.write(msg.data); break; case 'scrollback': // Only process scrollback if there's actual data // Don't clear if empty - prevents blank terminal issue if (msg.data && msg.data.length > 0) { // Clear any stale search decorations before restoring content searchAddonRef.current?.clearDecorations(); // Use reset() which is more reliable than clear() or escape sequences terminal.reset(); terminal.write(msg.data); // Mark as already initialized - don't run initial command for restored sessions hasRunInitialCommandRef.current = true; } break; case 'connected': { console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`); // Detect shell type from path const shellPath = (msg.shell || '').toLowerCase(); // Windows shells use backslash paths and include powershell/pwsh/cmd const isWindowsShell = shellPath.includes('\\') || shellPath.includes('powershell') || shellPath.includes('pwsh') || shellPath.includes('cmd.exe'); const isPowerShell = shellPath.includes('powershell') || shellPath.includes('pwsh'); if (msg.shell) { // Extract shell name from path (e.g., "/bin/bash" -> "bash", "C:\...\powershell.exe" -> "powershell.exe") const name = msg.shell.split(/[/\\]/).pop() || msg.shell; setShellName(name); } // Run initial command if specified and not already run // Only run for new terminals (no scrollback received) if ( runCommandOnConnect && !hasRunInitialCommandRef.current && ws.readyState === WebSocket.OPEN ) { hasRunInitialCommandRef.current = true; // Use appropriate line ending for the shell type // Windows shells (PowerShell, cmd) expect \r\n, Unix shells expect \n const lineEnding = isWindowsShell ? '\r\n' : '\n'; // PowerShell takes longer to initialize (profile loading, etc.) // Use 500ms for PowerShell, 100ms for other shells const delay = isPowerShell ? 500 : 100; setTimeout(() => { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: 'input', data: runCommandOnConnect + lineEnding }) ); onCommandRan?.(); } }, delay); } break; } case 'exit': terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`); setProcessExitCode(msg.exitCode); break; case 'pong': // Heartbeat response break; } } catch (err) { console.error('[Terminal] Message parse error:', err); } }; ws.onclose = (event) => { console.log( `[Terminal] WebSocket closed for session ${sessionId}:`, event.code, event.reason ); wsRef.current = null; // Clear heartbeat interval if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; } if (event.code === 4001) { setConnectionStatus('auth_failed'); toast.error('Terminal authentication expired', { description: 'Please unlock the terminal again to reconnect.', duration: 5000, }); return; } // Don't reconnect if closed normally if (event.code === 1000 || event.code === 4003) { setConnectionStatus('disconnected'); return; } if (event.code === 4004) { setConnectionStatus('disconnected'); // Notify parent that this session is no longer valid on the server // This allows automatic cleanup of stale sessions (e.g., after server restart) if (onSessionInvalidRef.current) { onSessionInvalidRef.current(); toast.info('Terminal session expired', { description: 'The session was automatically removed. Create a new terminal to continue.', duration: 5000, }); } else { toast.error('Terminal session not found', { description: 'The session may have expired. Please create a new terminal.', duration: 5000, }); } return; } reconnectAttemptsRef.current++; if (reconnectAttemptsRef.current > MAX_RECONNECT_ATTEMPTS) { setConnectionStatus('disconnected'); toast.error('Terminal disconnected', { description: 'Maximum reconnection attempts reached. Click to retry.', action: { label: 'Retry', onClick: () => { reconnectAttemptsRef.current = 0; setConnectionStatus('reconnecting'); connect(); }, }, duration: 10000, }); return; } // Exponential backoff: 1s, 2s, 4s, 8s, 16s const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttemptsRef.current - 1); setConnectionStatus('reconnecting'); // Attempt reconnect after exponential delay reconnectTimeoutRef.current = setTimeout(() => { if (xtermRef.current) { console.log( `[Terminal] Attempting reconnect for session ${sessionId} (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})` ); connect(); } }, delay); }; ws.onerror = (error) => { console.error(`[Terminal] WebSocket error for session ${sessionId}:`, error); }; }; connect(); // Handle terminal input const dataHandler = terminal.onData((data) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'input', data })); } }); // Cleanup return () => { dataHandler.dispose(); if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; } if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); } if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } }; }, [sessionId, authToken, wsUrl, isTerminalReady, fetchWsToken]); // Handle resize with debouncing const handleResize = useCallback(() => { // Clear any pending resize if (resizeDebounceRef.current) { clearTimeout(resizeDebounceRef.current); } // Debounce resize operations to prevent race conditions resizeDebounceRef.current = setTimeout(() => { if (!fitAddonRef.current || !xtermRef.current || !terminalRef.current) return; const container = terminalRef.current; const rect = container.getBoundingClientRect(); // Only skip if container has no size at all if (rect.width <= 0 || rect.height <= 0) { return; } try { fitAddonRef.current.fit(); const { cols, rows } = xtermRef.current; // Send resize to server if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'resize', cols, rows })); } } catch (err) { console.error('[Terminal] Resize error:', err); } }, RESIZE_DEBOUNCE_MS); }, []); // Resize observer useEffect(() => { const container = terminalRef.current; if (!container) return; const resizeObserver = new ResizeObserver(() => { handleResize(); }); resizeObserver.observe(container); // Also handle window resize window.addEventListener('resize', handleResize); return () => { resizeObserver.disconnect(); window.removeEventListener('resize', handleResize); }; }, [handleResize]); useEffect(() => { if (xtermRef.current && isTerminalReady) { xtermRef.current.options.fontSize = fontSize; fitAddonRef.current?.fit(); } }, [fontSize, isTerminalReady]); useEffect(() => { if (xtermRef.current && isTerminalReady) { xtermRef.current.options.fontFamily = fontFamily; fitAddonRef.current?.fit(); } }, [fontFamily, isTerminalReady]); useEffect(() => { if (xtermRef.current && isTerminalReady) { xtermRef.current.options.lineHeight = lineHeight; fitAddonRef.current?.fit(); } }, [lineHeight, isTerminalReady]); // Focus terminal when becoming active or when terminal becomes ready useEffect(() => { if (isActive && isTerminalReady && xtermRef.current && !showSearch) { xtermRef.current.focus(); } }, [isActive, isTerminalReady, showSearch]); // Focus search input when search bar opens useEffect(() => { if (showSearch && searchInputRef.current) { searchInputRef.current.focus(); searchInputRef.current.select(); } }, [showSearch]); // Monitor scroll position to show/hide "Jump to bottom" button useEffect(() => { if (!isTerminalReady || !terminalRef.current) return; // xterm creates a viewport element with class .xterm-viewport const viewport = terminalRef.current.querySelector('.xterm-viewport') as HTMLElement | null; if (!viewport) return; const checkScrollPosition = () => { // Check if scrolled to bottom (with small tolerance for rounding) const scrollTop = viewport.scrollTop; const scrollHeight = viewport.scrollHeight; const clientHeight = viewport.clientHeight; const isBottom = scrollHeight - scrollTop - clientHeight <= 5; setIsAtBottom(isBottom); }; // Initial check checkScrollPosition(); // Listen for scroll events viewport.addEventListener('scroll', checkScrollPosition, { passive: true }); return () => { viewport.removeEventListener('scroll', checkScrollPosition); }; }, [isTerminalReady]); // Update terminal font size when it changes useEffect(() => { if (xtermRef.current && isTerminalReady) { xtermRef.current.options.fontSize = fontSize; // Refit after font size change if (fitAddonRef.current && terminalRef.current) { const rect = terminalRef.current.getBoundingClientRect(); // Only fit if container has any size if (rect.width > 0 && rect.height > 0) { fitAddonRef.current.fit(); // Notify server of new dimensions const { cols, rows } = xtermRef.current; if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'resize', cols, rows })); } } } } }, [fontSize, isTerminalReady]); // Update terminal theme when app theme changes (including system preference) useEffect(() => { if (xtermRef.current && isTerminalReady) { // Clear any search decorations first to prevent stale color artifacts searchAddonRef.current?.clearDecorations(); const terminalTheme = getTerminalTheme(resolvedTheme); xtermRef.current.options.theme = terminalTheme; } }, [resolvedTheme, isTerminalReady]); // Handle keyboard shortcuts for zoom (Ctrl+Plus, Ctrl+Minus, Ctrl+0) useEffect(() => { const container = containerRef.current; if (!container) return; const handleKeyDown = (e: KeyboardEvent) => { // Only handle if Ctrl (or Cmd on Mac) is pressed if (!e.ctrlKey && !e.metaKey) return; // Ctrl/Cmd + Plus (Equal key or NumpadAdd for international keyboard support) if (e.code === 'Equal' || e.code === 'NumpadAdd') { e.preventDefault(); e.stopPropagation(); zoomIn(); return; } // Ctrl/Cmd + Minus (Minus key or NumpadSubtract) if (e.code === 'Minus' || e.code === 'NumpadSubtract') { e.preventDefault(); e.stopPropagation(); zoomOut(); return; } // Ctrl/Cmd + 0 to reset (Digit0 or Numpad0) if (e.code === 'Digit0' || e.code === 'Numpad0') { e.preventDefault(); e.stopPropagation(); resetZoom(); return; } }; container.addEventListener('keydown', handleKeyDown); return () => container.removeEventListener('keydown', handleKeyDown); }, [zoomIn, zoomOut, resetZoom]); // Handle mouse wheel zoom (Ctrl+Wheel) useEffect(() => { const container = containerRef.current; if (!container) return; const handleWheel = (e: WheelEvent) => { // Only zoom if Ctrl (or Cmd on Mac) is pressed if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); e.stopPropagation(); if (e.deltaY < 0) { // Scroll up = zoom in zoomIn(); } else if (e.deltaY > 0) { // Scroll down = zoom out zoomOut(); } }; // Use passive: false to allow preventDefault container.addEventListener('wheel', handleWheel, { passive: false }); return () => container.removeEventListener('wheel', handleWheel); }, [zoomIn, zoomOut]); // Context menu actions for keyboard navigation const menuActions = ['copy', 'paste', 'selectAll', 'clear'] as const; // Keep ref in sync with state for use in event handlers useEffect(() => { focusedMenuIndexRef.current = focusedMenuIndex; }, [focusedMenuIndex]); // Close context menu on click outside or scroll, handle keyboard navigation useEffect(() => { if (!contextMenu) return; // Reset focus index and focus menu when opened setFocusedMenuIndex(0); focusedMenuIndexRef.current = 0; requestAnimationFrame(() => { const firstButton = contextMenuRef.current?.querySelector('[role="menuitem"]'); firstButton?.focus(); }); const handleClick = () => closeContextMenu(); const handleScroll = () => closeContextMenu(); const handleKeyDown = (e: KeyboardEvent) => { const updateFocusIndex = (newIndex: number) => { focusedMenuIndexRef.current = newIndex; setFocusedMenuIndex(newIndex); }; switch (e.key) { case 'Escape': e.preventDefault(); e.stopPropagation(); closeContextMenu(); xtermRef.current?.focus(); break; case 'ArrowDown': e.preventDefault(); e.stopPropagation(); updateFocusIndex((focusedMenuIndexRef.current + 1) % menuActions.length); break; case 'ArrowUp': e.preventDefault(); e.stopPropagation(); updateFocusIndex( (focusedMenuIndexRef.current - 1 + menuActions.length) % menuActions.length ); break; case 'Enter': case ' ': e.preventDefault(); e.stopPropagation(); handleContextMenuAction(menuActions[focusedMenuIndexRef.current]); break; case 'Tab': e.preventDefault(); e.stopPropagation(); closeContextMenu(); break; } }; document.addEventListener('click', handleClick); document.addEventListener('scroll', handleScroll, true); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('click', handleClick); document.removeEventListener('scroll', handleScroll, true); document.removeEventListener('keydown', handleKeyDown); }; }, [contextMenu, closeContextMenu, handleContextMenuAction]); // Focus the correct menu item when navigation changes useEffect(() => { if (!contextMenu || !contextMenuRef.current) return; const buttons = contextMenuRef.current.querySelectorAll('[role="menuitem"]'); buttons[focusedMenuIndex]?.focus(); }, [focusedMenuIndex, contextMenu]); // Handle right-click context menu with boundary checking const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); // Menu dimensions (approximate) const menuWidth = 160; const menuHeight = 152; // 4 items + separator + padding const padding = 8; // Calculate position with boundary checks let x = e.clientX; let y = e.clientY; // Check right edge if (x + menuWidth + padding > window.innerWidth) { x = window.innerWidth - menuWidth - padding; } // Check bottom edge if (y + menuHeight + padding > window.innerHeight) { y = window.innerHeight - menuHeight - padding; } // Ensure not negative x = Math.max(padding, x); y = Math.max(padding, y); setContextMenu({ x, y }); }, []); // Convert file to base64 const fileToBase64 = useCallback((file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result === 'string') { resolve(reader.result); } else { reject(new Error('Failed to read file as base64')); } }; reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsDataURL(file); }); }, []); // Save image to temp folder via Electron API const saveImageToTemp = useCallback( async (base64Data: string, filename: string, mimeType: string): Promise => { try { const api = getElectronAPI(); if (!api.saveImageToTemp) { // Fallback path when Electron API is not available (browser mode) console.warn('[Terminal] saveImageToTemp not available, returning fallback path'); return `.automaker/images/${Date.now()}_${filename}`; } const projectPath = currentProject?.path; const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath); if (result.success && result.path) { return result.path; } console.error('[Terminal] Failed to save image:', result.error); return null; } catch (error) { console.error('[Terminal] Error saving image:', error); return null; } }, [currentProject?.path] ); // Check if drag event contains image files const hasImageFiles = useCallback((e: React.DragEvent): boolean => { const types = e.dataTransfer.types; const items = e.dataTransfer.items; // Check if Files type is present if (!types.includes('Files')) return false; // Check if any item is an image for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === 'file' && ACCEPTED_IMAGE_TYPES.includes(item.type)) { return true; } } return false; }, []); // Handle image drag over terminal const handleImageDragOver = useCallback( (e: React.DragEvent) => { // Only handle if contains image files if (!hasImageFiles(e)) return; e.preventDefault(); e.stopPropagation(); setIsImageDragOver(true); }, [hasImageFiles] ); // Handle image drag leave const handleImageDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); // Only reset if leaving the actual container (not just moving to a child) const relatedTarget = e.relatedTarget as HTMLElement | null; if (relatedTarget && terminalRef.current?.contains(relatedTarget)) { return; } setIsImageDragOver(false); }, []); // Handle image drop on terminal const handleImageDrop = useCallback( async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsImageDragOver(false); if (isProcessingImage) return; const files = e.dataTransfer.files; if (!files.length) return; // Filter to only image files const imageFiles: File[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; if (ACCEPTED_IMAGE_TYPES.includes(file.type)) { if (file.size > MAX_IMAGE_SIZE) { toast.error(`Image too large: ${file.name}`, { description: 'Maximum size is 10MB', }); continue; } imageFiles.push(file); } } if (imageFiles.length === 0) { toast.error('No valid images found', { description: 'Drop PNG, JPG, GIF, or WebP images', }); return; } setIsProcessingImage(true); const savedPaths: string[] = []; for (const file of imageFiles) { try { const base64 = await fileToBase64(file); const savedPath = await saveImageToTemp(base64, file.name, file.type); if (savedPath) { savedPaths.push(savedPath); } else { toast.error(`Failed to save: ${file.name}`); } } catch (error) { console.error('[Terminal] Error processing image:', error); toast.error(`Error processing: ${file.name}`); } } setIsProcessingImage(false); if (savedPaths.length === 0) return; // Send image paths to terminal as input // Format: space-separated paths, each wrapped in quotes if containing spaces const formattedPaths = savedPaths.map((p) => (p.includes(' ') ? `"${p}"` : p)).join(' '); if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'input', data: formattedPaths })); toast.success( savedPaths.length === 1 ? 'Image path inserted' : `${savedPaths.length} image paths inserted`, { description: 'Press Enter to send' } ); } else { toast.error('Terminal not connected'); } }, [isProcessingImage, fileToBase64, saveImageToTemp] ); // Combine refs for the container const setRefs = useCallback( (node: HTMLDivElement | null) => { containerRef.current = node; setDropRef(node); }, [setDropRef] ); // Get current terminal theme for xterm styling (resolved for system preference) const currentTerminalTheme = getTerminalTheme(resolvedTheme); return (
{/* Drop indicator overlay */} {isOver && isDropTarget && (
Drop to swap
)} {/* Image drop overlay */} {isImageDragOver && (
{isProcessingImage ? ( <> Processing... ) : ( <> Drop image for Claude Code )}
)} {/* Header bar with drag handle - uses app theme CSS variables */}
{/* Drag handle */} {/* Terminal icon and label */}
{shellName} {/* Font size indicator - only show when not default */} {fontSize !== DEFAULT_FONT_SIZE && ( )} {connectionStatus === 'reconnecting' && ( Reconnecting... )} {connectionStatus === 'disconnected' && ( Disconnected )} {connectionStatus === 'auth_failed' && ( Auth Failed )} {processExitCode !== null && ( Exited ({processExitCode}) )}
{/* Zoom and action buttons */}
{/* Zoom controls */} {/* Settings popover */} e.stopPropagation()} >
{fontSize}px
onFontSizeChange(value)} className="flex-1" />
setTerminalDefaultRunScript(e.target.value)} placeholder="e.g., claude" className="h-7 text-xs" />

Command to run when creating a new terminal

{(scrollbackLines / 1000).toFixed(0)}k lines
{ setTerminalScrollbackLines(value); }} onValueCommit={() => { toast.info('Scrollback changed', { description: 'Restart terminal for changes to take effect', }); }} className="flex-1" />
{lineHeight.toFixed(1)}
{ setTerminalLineHeight(value); }} onValueCommit={() => { toast.info('Line height changed', { description: 'Restart terminal for changes to take effect', }); }} className="flex-1" />

Enable accessibility mode

{ setTerminalScreenReaderMode(checked); toast.info(checked ? 'Screen reader enabled' : 'Screen reader disabled', { description: 'Restart terminal for changes to take effect', }); }} />

Zoom: Ctrl++ / Ctrl+- / Ctrl+0

Or use Ctrl+scroll wheel

{/* Split/close buttons */} {onToggleMaximize && ( )}
{/* Search bar */} {showSearch && (
{ setSearchQuery(e.target.value); // Auto-search as user types if (searchAddonRef.current && e.target.value) { searchAddonRef.current.findNext(e.target.value, searchOptions); } else if (searchAddonRef.current) { searchAddonRef.current.clearDecorations(); } }} onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) { searchPrevious(); } else { searchNext(); } } else if (e.key === 'Escape') { e.preventDefault(); closeSearch(); } }} placeholder="Search..." className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none min-w-0" />
)} {/* Terminal container - uses terminal theme */}
{/* Jump to bottom button - shown when scrolled up */} {!isAtBottom && ( )} {/* Context menu */} {contextMenu && (
e.stopPropagation()} >
)}
); }