import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Terminal as TerminalIcon, Plus, Lock, Unlock, SplitSquareHorizontal, SplitSquareVertical, Loader2, AlertCircle, RefreshCw, X, SquarePlus, Settings, } from "lucide-react"; import { useAppStore, type TerminalPanelContent, type TerminalTab, type PersistedTerminalPanel } from "@/store/app-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Slider } from "@/components/ui/slider"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { TERMINAL_FONT_OPTIONS } from "@/config/terminal-themes"; import { toast } from "sonner"; import { Panel, PanelGroup, PanelResizeHandle, } from "react-resizable-panels"; import { TerminalPanel } from "./terminal-view/terminal-panel"; import { TerminalErrorBoundary } from "./terminal-view/terminal-error-boundary"; import { DndContext, DragEndEvent, DragStartEvent, DragOverEvent, PointerSensor, TouchSensor, KeyboardSensor, useSensor, useSensors, closestCenter, DragOverlay, useDroppable, useDraggable, defaultDropAnimationSideEffects, } from "@dnd-kit/core"; import { cn } from "@/lib/utils"; interface TerminalStatus { enabled: boolean; passwordRequired: boolean; platform: { platform: string; isWSL: boolean; defaultShell: string; arch: string; }; } // Tab component with drag-drop support and double-click to rename function TerminalTabButton({ tab, isActive, onClick, onClose, onRename, isDropTarget, isDraggingTab, }: { tab: TerminalTab; isActive: boolean; onClick: () => void; onClose: () => void; onRename: (newName: string) => void; isDropTarget: boolean; isDraggingTab: boolean; }) { const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(tab.name); const inputRef = useRef(null); const { setNodeRef: setDropRef, isOver } = useDroppable({ id: `tab-${tab.id}`, data: { type: "tab", tabId: tab.id }, }); const { attributes: dragAttributes, listeners: dragListeners, setNodeRef: setDragRef, isDragging, } = useDraggable({ id: `drag-tab-${tab.id}`, data: { type: "drag-tab", tabId: tab.id }, }); // Combine refs const setRefs = (node: HTMLDivElement | null) => { setDropRef(node); setDragRef(node); }; // Focus input when entering edit mode useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, [isEditing]); const handleDoubleClick = (e: React.MouseEvent) => { e.stopPropagation(); setEditName(tab.name); setIsEditing(true); }; const handleKeyDown = (e: React.KeyboardEvent) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); finishEditing(); } else if (e.key === "Escape") { e.preventDefault(); setIsEditing(false); setEditName(tab.name); } }; const finishEditing = () => { const trimmedName = editName.trim(); if (trimmedName && trimmedName !== tab.name) { onRename(trimmedName); } setIsEditing(false); }; return (
{isEditing ? ( setEditName(e.target.value)} onKeyDown={handleKeyDown} onBlur={finishEditing} onClick={(e) => e.stopPropagation()} className="w-20 px-1 py-0 text-sm bg-background border border-border rounded focus:outline-none focus:ring-1 focus:ring-brand-500" /> ) : ( {tab.name} )}
); } // New tab drop zone function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) { const { setNodeRef, isOver } = useDroppable({ id: "new-tab-zone", data: { type: "new-tab" }, }); return (
); } export function TerminalView() { const { terminalState, setTerminalUnlocked, addTerminalToLayout, removeTerminalFromLayout, setActiveTerminalSession, swapTerminals, currentProject, addTerminalTab, removeTerminalTab, setActiveTerminalTab, renameTerminalTab, reorderTerminalTabs, moveTerminalToTab, setTerminalPanelFontSize, setTerminalTabLayout, toggleTerminalMaximized, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, setTerminalDefaultFontSize, setTerminalDefaultRunScript, setTerminalFontFamily, setTerminalLineHeight, updateTerminalPanelSizes, } = useAppStore(); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [password, setPassword] = useState(""); const [authLoading, setAuthLoading] = useState(false); const [authError, setAuthError] = useState(null); const [activeDragId, setActiveDragId] = useState(null); const [activeDragTabId, setActiveDragTabId] = useState(null); const [dragOverTabId, setDragOverTabId] = useState(null); const lastCreateTimeRef = useRef(0); const isCreatingRef = useRef(false); const prevProjectPathRef = useRef(null); const isRestoringLayoutRef = useRef(false); const [newSessionIds, setNewSessionIds] = useState>(new Set()); const [serverSessionInfo, setServerSessionInfo] = useState<{ current: number; max: number } | null>(null); const hasShownHighRamWarningRef = useRef(false); // Show warning when 20+ terminals are open useEffect(() => { if (serverSessionInfo && serverSessionInfo.current >= 20 && !hasShownHighRamWarningRef.current) { hasShownHighRamWarningRef.current = true; toast.warning("Many terminals open", { description: `${serverSessionInfo.current} terminals open. Each uses system resources (processes, memory). Consider closing unused terminals.`, duration: 8000, }); } // Reset warning flag when session count drops below 20 if (serverSessionInfo && serverSessionInfo.current < 20) { hasShownHighRamWarningRef.current = false; } }, [serverSessionInfo]); // Get the default run script from terminal settings const defaultRunScript = useAppStore((state) => state.terminalState.defaultRunScript); const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation // Helper to check if terminal creation should be debounced const canCreateTerminal = (debounceMessage: string): boolean => { const now = Date.now(); if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) { console.log(debounceMessage); return false; } lastCreateTimeRef.current = now; isCreatingRef.current = true; return true; }; // Get active tab const activeTab = terminalState.tabs.find(t => t.id === terminalState.activeTabId); // DnD sensors with activation constraint to avoid accidental drags const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5, }, }), useSensor(KeyboardSensor) ); // Handle drag start const handleDragStart = useCallback((event: DragStartEvent) => { const activeId = event.active.id as string; const activeData = event.active.data?.current; if (activeData?.type === "drag-tab") { // Tab being dragged setActiveDragTabId(activeData.tabId); setActiveDragId(null); } else { // Terminal panel being dragged setActiveDragId(activeId); setActiveDragTabId(null); } }, []); // Handle drag over - track which tab we're hovering const handleDragOver = useCallback((event: DragOverEvent) => { const { over } = event; if (over?.data?.current?.type === "tab") { setDragOverTabId(over.data.current.tabId); } else if (over?.data?.current?.type === "new-tab") { setDragOverTabId("new"); } else { setDragOverTabId(null); } }, []); // Handle drag end const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event; const activeData = active.data?.current; // Reset drag states setActiveDragId(null); setActiveDragTabId(null); setDragOverTabId(null); if (!over) return; const overData = over.data?.current; // Handle tab-to-tab drag (reordering) if (activeData?.type === "drag-tab" && overData?.type === "tab") { const fromTabId = activeData.tabId as string; const toTabId = overData.tabId as string; if (fromTabId !== toTabId) { reorderTerminalTabs(fromTabId, toTabId); } return; } // Handle terminal panel drops const activeId = active.id as string; // If dropped on a tab, move terminal to that tab if (overData?.type === "tab") { moveTerminalToTab(activeId, overData.tabId); return; } // If dropped on new tab zone, create new tab with this terminal if (overData?.type === "new-tab") { moveTerminalToTab(activeId, "new"); return; } // Otherwise, swap terminals within current tab if (active.id !== over.id) { swapTerminals(activeId, over.id as string); } }, [swapTerminals, moveTerminalToTab, reorderTerminalTabs]); const handleDragCancel = useCallback(() => { setActiveDragId(null); setActiveDragTabId(null); setDragOverTabId(null); }, []); // Fetch terminal status const fetchStatus = useCallback(async () => { try { setLoading(true); setError(null); const response = await fetch(`${serverUrl}/api/terminal/status`); const data = await response.json(); if (data.success) { setStatus(data.data); if (!data.data.passwordRequired) { setTerminalUnlocked(true); } } else { setError(data.error || "Failed to get terminal status"); } } catch (err) { setError("Failed to connect to server"); console.error("[Terminal] Status fetch error:", err); } finally { setLoading(false); } }, [serverUrl, setTerminalUnlocked]); // Fetch server session settings const fetchServerSettings = useCallback(async () => { if (!terminalState.isUnlocked) return; try { const headers: Record = {}; if (terminalState.authToken) { headers["X-Terminal-Token"] = terminalState.authToken; } const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers }); const data = await response.json(); if (data.success) { setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions }); } } catch (err) { console.error("[Terminal] Failed to fetch server settings:", err); } }, [serverUrl, terminalState.isUnlocked, terminalState.authToken]); useEffect(() => { fetchStatus(); }, [fetchStatus]); // Fetch server settings when terminal is unlocked useEffect(() => { if (terminalState.isUnlocked) { fetchServerSettings(); } }, [terminalState.isUnlocked, fetchServerSettings]); // Handle project switching - save and restore terminal layouts useEffect(() => { const currentPath = currentProject?.path || null; const prevPath = prevProjectPathRef.current; // Skip if no change or if we're restoring layout if (currentPath === prevPath || isRestoringLayoutRef.current) { return; } // Save layout for previous project (if there was one and has terminals) if (prevPath && terminalState.tabs.length > 0) { saveTerminalLayout(prevPath); } // Update the previous project ref prevProjectPathRef.current = currentPath; // If no current project, just clear terminals if (!currentPath) { clearTerminalState(); return; } // Check for saved layout for this project const savedLayout = getPersistedTerminalLayout(currentPath); if (savedLayout && savedLayout.tabs.length > 0) { // Restore the saved layout - try to reconnect to existing sessions isRestoringLayoutRef.current = true; // Clear existing terminals first (only client state, sessions stay on server) clearTerminalState(); // Create terminals and build layout - try to reconnect or create new const restoreLayout = async () => { let failedSessions = 0; let totalSessions = 0; let reconnectedSessions = 0; try { const headers: Record = { "Content-Type": "application/json", }; // Get fresh auth token from store const authToken = useAppStore.getState().terminalState.authToken; if (authToken) { headers["X-Terminal-Token"] = authToken; } // Helper to check if a session still exists on server const checkSessionExists = async (sessionId: string): Promise => { try { const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { method: "GET", headers, }); const data = await response.json(); return data.success === true; } catch { return false; } }; // Helper to create a new terminal session const createSession = async (): Promise => { try { const response = await fetch(`${serverUrl}/api/terminal/sessions`, { method: "POST", headers, body: JSON.stringify({ cwd: currentPath, cols: 80, rows: 24, }), }); const data = await response.json(); return data.success ? data.data.id : null; } catch (err) { console.error("[Terminal] Failed to create terminal session:", err); return null; } }; // Recursively rebuild the layout - reuse existing sessions or create new const rebuildLayout = async ( persisted: PersistedTerminalPanel ): Promise => { if (persisted.type === "terminal") { totalSessions++; let sessionId: string | null = null; // If we have a saved sessionId, try to reconnect to it if (persisted.sessionId) { const exists = await checkSessionExists(persisted.sessionId); if (exists) { sessionId = persisted.sessionId; reconnectedSessions++; } } // If no saved session or it's gone, create a new one if (!sessionId) { sessionId = await createSession(); } if (!sessionId) { failedSessions++; return null; } return { type: "terminal", sessionId, size: persisted.size, fontSize: persisted.fontSize, }; } // It's a split - rebuild all child panels const childPanels: TerminalPanelContent[] = []; for (const childPersisted of persisted.panels) { const rebuilt = await rebuildLayout(childPersisted); if (rebuilt) { childPanels.push(rebuilt); } } // If no children were rebuilt, return null if (childPanels.length === 0) return null; // If only one child, return it directly (collapse the split) if (childPanels.length === 1) return childPanels[0]; return { type: "split", id: persisted.id || `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, direction: persisted.direction, panels: childPanels, size: persisted.size, }; }; // For each saved tab, rebuild the layout for (let tabIndex = 0; tabIndex < savedLayout.tabs.length; tabIndex++) { const savedTab = savedLayout.tabs[tabIndex]; // Create the tab first const newTabId = addTerminalTab(savedTab.name); if (savedTab.layout) { const rebuiltLayout = await rebuildLayout(savedTab.layout); if (rebuiltLayout) { const { setTerminalTabLayout } = useAppStore.getState(); setTerminalTabLayout(newTabId, rebuiltLayout); } } } // Set active tab based on saved index if (savedLayout.tabs.length > 0) { const { setActiveTerminalTab } = useAppStore.getState(); const newTabs = useAppStore.getState().terminalState.tabs; if (newTabs.length > savedLayout.activeTabIndex) { setActiveTerminalTab(newTabs[savedLayout.activeTabIndex].id); } } if (failedSessions > 0) { toast.error("Some terminals failed to restore", { description: `${failedSessions} of ${totalSessions} terminal sessions could not be created. The server may be unavailable.`, duration: 5000, }); } else if (reconnectedSessions > 0) { toast.success("Terminals restored", { description: `Reconnected to ${reconnectedSessions} existing session${reconnectedSessions > 1 ? "s" : ""}`, duration: 3000, }); } } catch (err) { console.error("[Terminal] Failed to restore terminal layout:", err); toast.error("Failed to restore terminals", { description: "Could not restore terminal layout. Please try creating new terminals.", duration: 5000, }); } finally { isRestoringLayoutRef.current = false; } }; restoreLayout(); } }, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]); // Save terminal layout whenever it changes (debounced via the effect) // Also save when tabs become empty so closed terminals stay closed on refresh useEffect(() => { if (currentProject?.path && !isRestoringLayoutRef.current) { saveTerminalLayout(currentProject.path); } }, [terminalState.tabs, currentProject?.path, saveTerminalLayout]); // Handle password authentication const handleAuth = async (e: React.FormEvent) => { e.preventDefault(); setAuthLoading(true); setAuthError(null); try { const response = await fetch(`${serverUrl}/api/terminal/auth`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }), }); const data = await response.json(); if (data.success) { setTerminalUnlocked(true, data.data.token); setPassword(""); } else { setAuthError(data.error || "Authentication failed"); } } catch (err) { setAuthError("Failed to authenticate"); console.error("[Terminal] Auth error:", err); } finally { setAuthLoading(false); } }; // Create a new terminal session // targetSessionId: the terminal to split (if splitting an existing terminal) const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => { if (!canCreateTerminal("[Terminal] Debounced terminal creation")) { return; } try { const headers: Record = { "Content-Type": "application/json", }; if (terminalState.authToken) { headers["X-Terminal-Token"] = terminalState.authToken; } const response = await fetch(`${serverUrl}/api/terminal/sessions`, { method: "POST", headers, body: JSON.stringify({ cwd: currentProject?.path || undefined, cols: 80, rows: 24, }), }); const data = await response.json(); if (data.success) { addTerminalToLayout(data.data.id, direction, targetSessionId); // Mark this session as new for running initial command if (defaultRunScript) { setNewSessionIds(prev => new Set(prev).add(data.data.id)); } // Refresh session count fetchServerSettings(); } else { // Handle session limit error with a helpful toast if (response.status === 429 || data.error?.includes("Maximum")) { toast.error("Terminal session limit reached", { description: data.details || `Please close unused terminals. Limit: ${data.maxSessions || "unknown"}`, }); } else { console.error("[Terminal] Failed to create session:", data.error); toast.error("Failed to create terminal", { description: data.error || "Unknown error", }); } } } catch (err) { console.error("[Terminal] Create session error:", err); toast.error("Failed to create terminal", { description: "Could not connect to server", }); } finally { isCreatingRef.current = false; } }; // Create terminal in new tab const createTerminalInNewTab = async () => { if (!canCreateTerminal("[Terminal] Debounced terminal tab creation")) { return; } const tabId = addTerminalTab(); try { const headers: Record = { "Content-Type": "application/json", }; if (terminalState.authToken) { headers["X-Terminal-Token"] = terminalState.authToken; } const response = await fetch(`${serverUrl}/api/terminal/sessions`, { method: "POST", headers, body: JSON.stringify({ cwd: currentProject?.path || undefined, cols: 80, rows: 24, }), }); const data = await response.json(); if (data.success) { // Add to the newly created tab const { addTerminalToTab } = useAppStore.getState(); addTerminalToTab(data.data.id, tabId); // Mark this session as new for running initial command if (defaultRunScript) { setNewSessionIds(prev => new Set(prev).add(data.data.id)); } // Refresh session count fetchServerSettings(); } else { // Remove the empty tab that was created const { removeTerminalTab } = useAppStore.getState(); removeTerminalTab(tabId); // Handle session limit error with a helpful toast if (response.status === 429 || data.error?.includes("Maximum")) { toast.error("Terminal session limit reached", { description: data.details || `Please close unused terminals. Limit: ${data.maxSessions || "unknown"}`, }); } else { toast.error("Failed to create terminal", { description: data.error || "Unknown error", }); } } } catch (err) { console.error("[Terminal] Create session error:", err); // Remove the empty tab on error const { removeTerminalTab } = useAppStore.getState(); removeTerminalTab(tabId); toast.error("Failed to create terminal", { description: "Could not connect to server", }); } finally { isCreatingRef.current = false; } }; // Kill a terminal session const killTerminal = async (sessionId: string) => { try { const headers: Record = {}; if (terminalState.authToken) { headers["X-Terminal-Token"] = terminalState.authToken; } const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { method: "DELETE", headers, }); // Always remove from UI - even if server says 404 (session may have already exited) removeTerminalFromLayout(sessionId); if (!response.ok && response.status !== 404) { // Log non-404 errors but still proceed with UI cleanup const data = await response.json().catch(() => ({})); console.error("[Terminal] Server failed to kill session:", data.error || response.statusText); } // Refresh session count fetchServerSettings(); } catch (err) { console.error("[Terminal] Kill session error:", err); // Still remove from UI on network error - better UX than leaving broken terminal removeTerminalFromLayout(sessionId); } }; // Kill all terminals in a tab and then remove the tab const killTerminalTab = async (tabId: string) => { const tab = terminalState.tabs.find((t) => t.id === tabId); if (!tab) return; // Collect all session IDs from the tab's layout const collectSessionIds = ( node: TerminalPanelContent | null ): string[] => { if (!node) return []; if (node.type === "terminal") return [node.sessionId]; return node.panels.flatMap(collectSessionIds); }; const sessionIds = collectSessionIds(tab.layout); // Kill all sessions on the server const headers: Record = {}; if (terminalState.authToken) { headers["X-Terminal-Token"] = terminalState.authToken; } await Promise.all( sessionIds.map(async (sessionId) => { try { await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { method: "DELETE", headers, }); } catch (err) { console.error(`[Terminal] Failed to kill session ${sessionId}:`, err); } }) ); // Now remove the tab from state removeTerminalTab(tabId); // Refresh session count fetchServerSettings(); }; // NOTE: Terminal keyboard shortcuts (Alt+D, Alt+S, Alt+W) are handled in // terminal-panel.tsx via attachCustomKeyEventHandler. This is more reliable // because it uses event.code (keyboard-layout independent) instead of event.key // which can produce special characters when Alt is pressed on some systems. // See: terminal-panel.tsx lines 319-399 for the shortcut handlers. // Collect all terminal IDs from a panel tree in order const getTerminalIds = (panel: TerminalPanelContent): string[] => { if (panel.type === "terminal") { return [panel.sessionId]; } return panel.panels.flatMap(getTerminalIds); }; // Get a STABLE key for a panel - uses the stable id for splits // This prevents unnecessary remounts when layout structure changes const getPanelKey = (panel: TerminalPanelContent): string => { if (panel.type === "terminal") { return panel.sessionId; } // Use the stable id for split nodes return panel.id; }; const findTerminalFontSize = useCallback((sessionId: string): number => { const findInPanel = (panel: TerminalPanelContent): number | null => { if (panel.type === "terminal") { if (panel.sessionId === sessionId) { return panel.fontSize ?? terminalState.defaultFontSize; } return null; } for (const child of panel.panels) { const found = findInPanel(child); if (found !== null) return found; } return null; }; // Search across all tabs for (const tab of terminalState.tabs) { if (tab.layout) { const found = findInPanel(tab.layout); if (found !== null) return found; } } return terminalState.defaultFontSize; }, [terminalState.tabs, terminalState.defaultFontSize]); // Handler for when a terminal has run its initial command const handleCommandRan = useCallback((sessionId: string) => { setNewSessionIds(prev => { const next = new Set(prev); next.delete(sessionId); return next; }); }, []); // Navigate between terminal panes with Ctrl+Alt+Arrow keys const navigateToTerminal = useCallback((direction: "next" | "prev") => { if (!activeTab?.layout) return; const terminalIds = getTerminalIds(activeTab.layout); if (terminalIds.length <= 1) return; const currentIndex = terminalIds.indexOf(terminalState.activeSessionId || ""); if (currentIndex === -1) { // If no terminal is active, focus the first one setActiveTerminalSession(terminalIds[0]); return; } let newIndex: number; if (direction === "next") { newIndex = (currentIndex + 1) % terminalIds.length; } else { newIndex = (currentIndex - 1 + terminalIds.length) % terminalIds.length; } setActiveTerminalSession(terminalIds[newIndex]); }, [activeTab?.layout, terminalState.activeSessionId, setActiveTerminalSession]); // Handle global keyboard shortcuts for pane navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl+Alt+Arrow (or Cmd+Alt+Arrow on Mac) for pane navigation if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey) { if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); navigateToTerminal("next"); } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); navigateToTerminal("prev"); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [navigateToTerminal]); // Render panel content recursively const renderPanelContent = (content: TerminalPanelContent): React.ReactNode => { if (content.type === "terminal") { // Use per-terminal fontSize or fall back to default const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize; // Only run command on new sessions (not restored ones) const isNewSession = newSessionIds.has(content.sessionId); return ( { // When terminal crashes and is restarted, recreate the session killTerminal(content.sessionId); createTerminal(); }} > setActiveTerminalSession(content.sessionId)} onClose={() => killTerminal(content.sessionId)} onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)} onSplitVertical={() => createTerminal("vertical", content.sessionId)} onNewTab={createTerminalInNewTab} isDragging={activeDragId === content.sessionId} isDropTarget={activeDragId !== null && activeDragId !== content.sessionId} fontSize={terminalFontSize} onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)} runCommandOnConnect={isNewSession ? defaultRunScript : undefined} onCommandRan={() => handleCommandRan(content.sessionId)} isMaximized={terminalState.maximizedSessionId === content.sessionId} onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)} /> ); } const isHorizontal = content.direction === "horizontal"; const defaultSizePerPanel = 100 / content.panels.length; const handleLayoutChange = (sizes: number[]) => { if (!activeTab) return; const panelKeys = content.panels.map(getPanelKey); updateTerminalPanelSizes(activeTab.id, panelKeys, sizes); }; return ( {content.panels.map((panel, index) => { const panelSize = panel.type === "terminal" && panel.size ? panel.size : defaultSizePerPanel; const panelKey = getPanelKey(panel); return ( {index > 0 && ( )} {renderPanelContent(panel)} ); })} ); }; // Loading state if (loading) { return (
); } // Error state if (error) { return (

Terminal Unavailable

{error}

); } // Disabled state if (!status?.enabled) { return (

Terminal Disabled

Terminal access has been disabled. Set TERMINAL_ENABLED=true in your server .env file to enable it.

); } // Password gate if (status.passwordRequired && !terminalState.isUnlocked) { return (

Terminal Protected

Terminal access requires authentication. Enter the password to unlock.

setPassword(e.target.value)} disabled={authLoading} autoFocus /> {authError && (

{authError}

)}
{status.platform && (

Platform: {status.platform.platform} {status.platform.isWSL && " (WSL)"} {" | "}Shell: {status.platform.defaultShell}

)}
); } // No terminals yet - show welcome screen if (terminalState.tabs.length === 0) { return (

Terminal

Create a new terminal session to start executing commands. {currentProject && ( Working directory: {currentProject.path} )}

{status?.platform && (

Platform: {status.platform.platform} {status.platform.isWSL && " (WSL)"} {" | "}Shell: {status.platform.defaultShell}

)}
); } // Terminal view with tabs return (
{/* Tab bar */}
{/* Tabs */}
{terminalState.tabs.map((tab) => ( setActiveTerminalTab(tab.id)} onClose={() => killTerminalTab(tab.id)} onRename={(newName) => renameTerminalTab(tab.id, newName)} isDropTarget={activeDragId !== null || activeDragTabId !== null} isDraggingTab={activeDragTabId !== null} /> ))} {(activeDragId || activeDragTabId) && ( )} {/* New tab button */}
{/* Toolbar buttons */}
{/* Global Terminal Settings */}

Terminal Settings

Configure global terminal appearance

{/* Default Font Size */}
{terminalState.defaultFontSize}px
setTerminalDefaultFontSize(value)} onValueCommit={() => { toast.info("Font size changed", { description: "New terminals will use this size", }); }} />
{/* Font Family */}
{/* Line Height */}
{terminalState.lineHeight.toFixed(1)}
setTerminalLineHeight(value)} onValueCommit={() => { toast.info("Line height changed", { description: "Restart terminal for changes to take effect", }); }} />
{/* Default Run Script */}
setTerminalDefaultRunScript(e.target.value)} placeholder="e.g., claude, npm run dev" className="h-8 text-sm" />

Command to run when opening new terminals

{/* Active tab content */}
{terminalState.maximizedSessionId ? ( // When a terminal is maximized, render only that terminal { const sessionId = terminalState.maximizedSessionId!; toggleTerminalMaximized(sessionId); killTerminal(sessionId); createTerminal(); }} > setActiveTerminalSession(terminalState.maximizedSessionId!)} onClose={() => killTerminal(terminalState.maximizedSessionId!)} onSplitHorizontal={() => createTerminal("horizontal", terminalState.maximizedSessionId!)} onSplitVertical={() => createTerminal("vertical", terminalState.maximizedSessionId!)} onNewTab={createTerminalInNewTab} isDragging={false} isDropTarget={false} fontSize={findTerminalFontSize(terminalState.maximizedSessionId)} onFontSizeChange={(size) => setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)} isMaximized={true} onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)} /> ) : activeTab?.layout ? ( renderPanelContent(activeTab.layout) ) : (

This tab is empty

)}
{/* Drag overlay */} {activeDragId ? (
{dragOverTabId === "new" ? "New tab" : dragOverTabId ? "Move to tab" : "Terminal"}
) : null}
); }