diff --git a/apps/app/package.json b/apps/app/package.json index 9c973c0e..98a8b0b4 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -42,6 +42,9 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -52,6 +55,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-markdown": "^10.1.0", + "react-resizable-panels": "^3.0.6", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 0397f513..2fb46f87 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -12,6 +12,7 @@ import { ContextView } from "@/components/views/context-view"; import { ProfilesView } from "@/components/views/profiles-view"; import { SetupView } from "@/components/views/setup-view"; import { RunningAgentsView } from "@/components/views/running-agents-view"; +import { TerminalView } from "@/components/views/terminal-view"; import { useAppStore } from "@/store/app-store"; import { useSetupStore } from "@/store/setup-store"; import { getElectronAPI, isElectron } from "@/lib/electron"; @@ -206,6 +207,8 @@ function HomeContent() { return ; case "running-agents": return ; + case "terminal": + return ; default: return ; } diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index f1efa237..d8453b2d 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -609,6 +609,12 @@ export function Sidebar() { icon: UserCircle, shortcut: shortcuts.profiles, }, + { + id: "terminal", + label: "Terminal", + icon: Terminal, + shortcut: shortcuts.terminal, + }, ], }, ]; diff --git a/apps/app/src/components/ui/keyboard-map.tsx b/apps/app/src/components/ui/keyboard-map.tsx index 5ae2c4f5..73196e70 100644 --- a/apps/app/src/components/ui/keyboard-map.tsx +++ b/apps/app/src/components/ui/keyboard-map.tsx @@ -90,6 +90,7 @@ const SHORTCUT_LABELS: Record = { context: "Context", settings: "Settings", profiles: "AI Profiles", + terminal: "Terminal", toggleSidebar: "Toggle Sidebar", addFeature: "Add Feature", addContextFile: "Add Context File", @@ -100,6 +101,9 @@ const SHORTCUT_LABELS: Record = { cyclePrevProject: "Prev Project", cycleNextProject: "Next Project", addProfile: "Add Profile", + splitTerminalRight: "Split Right", + splitTerminalDown: "Split Down", + closeTerminal: "Close Terminal", }; // Categorize shortcuts for color coding @@ -110,6 +114,7 @@ const SHORTCUT_CATEGORIES: Record void; + onClose: () => void; + isDropTarget: boolean; +}) { + const { setNodeRef, isOver } = useDroppable({ + id: `tab-${tab.id}`, + data: { type: "tab", tabId: tab.id }, + }); + + return ( +
+ + {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, + moveTerminalToTab, + setTerminalPanelFontSize, + } = 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 [dragOverTabId, setDragOverTabId] = useState(null); + + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + + // 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, + }, + }) + ); + + // Handle drag start + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveDragId(event.active.id as string); + }, []); + + // 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; + setActiveDragId(null); + setDragOverTabId(null); + + if (!over) return; + + const activeId = active.id as string; + const overData = over.data?.current; + + // 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]); + + // 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]); + + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); + + // 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 + const createTerminal = async (direction?: "horizontal" | "vertical") => { + 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); + } else { + console.error("[Terminal] Failed to create session:", data.error); + } + } catch (err) { + console.error("[Terminal] Create session error:", err); + } + }; + + // Create terminal in new tab + const createTerminalInNewTab = async () => { + 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); + } + } catch (err) { + console.error("[Terminal] Create session error:", err); + } + }; + + // Kill a terminal session + const killTerminal = async (sessionId: string) => { + try { + const headers: Record = {}; + if (terminalState.authToken) { + headers["X-Terminal-Token"] = terminalState.authToken; + } + + await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { + method: "DELETE", + headers, + }); + removeTerminalFromLayout(sessionId); + } catch (err) { + console.error("[Terminal] Kill session error:", err); + } + }; + + // Get a stable key for a panel + const getPanelKey = (panel: TerminalPanelContent): string => { + if (panel.type === "terminal") { + return panel.sessionId; + } + return `split-${panel.direction}-${panel.panels.map(getPanelKey).join("-")}`; + }; + + // 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; + return ( + setActiveTerminalSession(content.sessionId)} + onClose={() => killTerminal(content.sessionId)} + onSplitHorizontal={() => createTerminal("horizontal")} + onSplitVertical={() => createTerminal("vertical")} + isDragging={activeDragId === content.sessionId} + isDropTarget={activeDragId !== null && activeDragId !== content.sessionId} + fontSize={terminalFontSize} + onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)} + /> + ); + } + + const isHorizontal = content.direction === "horizontal"; + const defaultSizePerPanel = 100 / content.panels.length; + + return ( + + {content.panels.map((panel, index) => { + const panelSize = panel.type === "terminal" && panel.size + ? panel.size + : defaultSizePerPanel; + + 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={() => removeTerminalTab(tab.id)} + isDropTarget={activeDragId !== null} + /> + ))} + + {/* New tab drop zone (visible when dragging) */} + {activeDragId && ( + + )} + + {/* New tab button */} + +
+ + {/* Toolbar buttons */} +
+ + +
+
+ + {/* Active tab content */} +
+ {activeTab?.layout ? ( + renderPanelContent(activeTab.layout) + ) : ( +
+

This tab is empty

+ +
+ )} +
+
+ + {/* Drag overlay */} + + {activeDragId ? ( +
+ + + {dragOverTabId === "new" + ? "New tab" + : dragOverTabId + ? "Move to tab" + : "Terminal"} + +
+ ) : null} +
+
+ ); +} diff --git a/apps/app/src/components/views/terminal-view/terminal-panel.tsx b/apps/app/src/components/views/terminal-view/terminal-panel.tsx new file mode 100644 index 00000000..eb0f8eb8 --- /dev/null +++ b/apps/app/src/components/views/terminal-view/terminal-panel.tsx @@ -0,0 +1,562 @@ +"use client"; + +import { useEffect, useRef, useCallback, useState } from "react"; +import { + X, + SplitSquareHorizontal, + SplitSquareVertical, + GripHorizontal, + Terminal, + ZoomIn, + ZoomOut, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useDraggable, useDroppable } from "@dnd-kit/core"; +import { useAppStore } from "@/store/app-store"; +import { getTerminalTheme } from "@/config/terminal-themes"; + +// Font size constraints +const MIN_FONT_SIZE = 8; +const MAX_FONT_SIZE = 32; +const DEFAULT_FONT_SIZE = 14; + +interface TerminalPanelProps { + sessionId: string; + authToken: string | null; + isActive: boolean; + onFocus: () => void; + onClose: () => void; + onSplitHorizontal: () => void; + onSplitVertical: () => void; + isDragging?: boolean; + isDropTarget?: boolean; + fontSize: number; + onFontSizeChange: (size: number) => void; +} + +// Type for xterm Terminal - we'll use any since we're dynamically importing +type XTerminal = InstanceType; +type XFitAddon = InstanceType; + +export function TerminalPanel({ + sessionId, + authToken, + isActive, + onFocus, + onClose, + onSplitHorizontal, + onSplitVertical, + isDragging = false, + isDropTarget = false, + fontSize, + onFontSizeChange, +}: 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 [isTerminalReady, setIsTerminalReady] = useState(false); + + // Get effective theme from store + const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme); + const effectiveTheme = getEffectiveTheme(); + + // Use refs for callbacks and values to avoid effect re-runs + const onFocusRef = useRef(onFocus); + onFocusRef.current = onFocus; + const fontSizeRef = useRef(fontSize); + fontSizeRef.current = fontSize; + const themeRef = useRef(effectiveTheme); + themeRef.current = effectiveTheme; + + // 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]); + + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + const wsUrl = serverUrl.replace(/^http/, "ws"); + + // 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 }, + ] = await Promise.all([ + import("@xterm/xterm"), + import("@xterm/addon-fit"), + import("@xterm/addon-webgl"), + ]); + + // 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); + + // Create terminal instance with the current global font size and theme + const terminal = new Terminal({ + cursorBlink: true, + cursorStyle: "block", + fontSize: fontSizeRef.current, + fontFamily: "Menlo, Monaco, 'Courier New', monospace", + theme: terminalTheme, + allowProposedApi: true, + }); + + // Create fit addon + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + + // Open terminal + terminal.open(terminalRef.current); + + // 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 + setTimeout(() => { + fitAddon.fit(); + }, 0); + + xtermRef.current = terminal; + fitAddonRef.current = fitAddon; + setIsTerminalReady(true); + + // Handle focus - use ref to avoid re-running effect + terminal.onData(() => { + onFocusRef.current(); + }); + }; + + initTerminal(); + + // Cleanup + return () => { + mounted = false; + 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 = () => { + // Build WebSocket URL with token + let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`; + if (authToken) { + url += `&token=${encodeURIComponent(authToken)}`; + } + + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + console.log(`[Terminal] WebSocket connected for session ${sessionId}`); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + switch (msg.type) { + case "data": + terminal.write(msg.data); + break; + case "scrollback": + // Replay scrollback buffer (previous terminal output) + if (msg.data) { + terminal.write(msg.data); + } + break; + case "connected": + console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`); + break; + case "exit": + terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`); + 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; + + // Don't reconnect if closed normally or auth failed + if (event.code === 1000 || event.code === 4001 || event.code === 4003) { + return; + } + + // Attempt reconnect after a delay + reconnectTimeoutRef.current = setTimeout(() => { + if (xtermRef.current) { + console.log(`[Terminal] Attempting reconnect for session ${sessionId}`); + connect(); + } + }, 2000); + }; + + 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 (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [sessionId, authToken, wsUrl, isTerminalReady]); + + // Handle resize + const handleResize = useCallback(() => { + if (fitAddonRef.current && xtermRef.current) { + 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 })); + } + } + }, []); + + // 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]); + + // Focus terminal when becoming active + useEffect(() => { + if (isActive && xtermRef.current) { + xtermRef.current.focus(); + } + }, [isActive]); + + // 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) { + 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 + useEffect(() => { + if (xtermRef.current && isTerminalReady) { + const terminalTheme = getTerminalTheme(effectiveTheme); + xtermRef.current.options.theme = terminalTheme; + } + }, [effectiveTheme, 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 or Ctrl/Cmd + = (for keyboards without numpad) + if (e.key === "+" || e.key === "=") { + e.preventDefault(); + e.stopPropagation(); + zoomIn(); + return; + } + + // Ctrl/Cmd + Minus + if (e.key === "-") { + e.preventDefault(); + e.stopPropagation(); + zoomOut(); + return; + } + + // Ctrl/Cmd + 0 to reset + if (e.key === "0") { + 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]); + + // Combine refs for the container + const setRefs = useCallback((node: HTMLDivElement | null) => { + containerRef.current = node; + setDropRef(node); + }, [setDropRef]); + + // Get current terminal theme for xterm styling + const currentTerminalTheme = getTerminalTheme(effectiveTheme); + + return ( +
+ {/* Drop indicator overlay */} + {isOver && isDropTarget && ( +
+
+ Drop to swap +
+
+ )} + + {/* Header bar with drag handle - uses app theme CSS variables */} +
+ {/* Drag handle */} + + + {/* Terminal icon and label */} +
+ + + bash + + {/* Font size indicator - only show when not default */} + {fontSize !== DEFAULT_FONT_SIZE && ( + + )} +
+ + {/* Zoom and action buttons */} +
+ {/* Zoom controls */} + + + +
+ + {/* Split/close buttons */} + + + +
+
+ + {/* Terminal container - uses terminal theme */} +
+
+ ); +} diff --git a/apps/app/src/config/terminal-themes.ts b/apps/app/src/config/terminal-themes.ts new file mode 100644 index 00000000..eb85a203 --- /dev/null +++ b/apps/app/src/config/terminal-themes.ts @@ -0,0 +1,367 @@ +/** + * Terminal themes that match the app themes + * Each theme provides colors for xterm.js terminal emulator + */ + +import type { ThemeMode } from "@/store/app-store"; + +export interface TerminalTheme { + background: string; + foreground: string; + cursor: string; + cursorAccent: string; + selectionBackground: string; + selectionForeground?: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; +} + +// Dark theme (default) +const darkTheme: TerminalTheme = { + background: "#0a0a0a", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + cursorAccent: "#0a0a0a", + selectionBackground: "#264f78", + black: "#1e1e1e", + red: "#f44747", + green: "#6a9955", + yellow: "#dcdcaa", + blue: "#569cd6", + magenta: "#c586c0", + cyan: "#4ec9b0", + white: "#d4d4d4", + brightBlack: "#808080", + brightRed: "#f44747", + brightGreen: "#6a9955", + brightYellow: "#dcdcaa", + brightBlue: "#569cd6", + brightMagenta: "#c586c0", + brightCyan: "#4ec9b0", + brightWhite: "#ffffff", +}; + +// Light theme +const lightTheme: TerminalTheme = { + background: "#ffffff", + foreground: "#383a42", + cursor: "#383a42", + cursorAccent: "#ffffff", + selectionBackground: "#add6ff", + black: "#383a42", + red: "#e45649", + green: "#50a14f", + yellow: "#c18401", + blue: "#4078f2", + magenta: "#a626a4", + cyan: "#0184bc", + white: "#fafafa", + brightBlack: "#4f525e", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#e5c07b", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", +}; + +// Retro / Cyberpunk theme - neon green on black +const retroTheme: TerminalTheme = { + background: "#000000", + foreground: "#39ff14", + cursor: "#39ff14", + cursorAccent: "#000000", + selectionBackground: "#39ff14", + selectionForeground: "#000000", + black: "#000000", + red: "#ff0055", + green: "#39ff14", + yellow: "#ffff00", + blue: "#00ffff", + magenta: "#ff00ff", + cyan: "#00ffff", + white: "#39ff14", + brightBlack: "#555555", + brightRed: "#ff5555", + brightGreen: "#55ff55", + brightYellow: "#ffff55", + brightBlue: "#55ffff", + brightMagenta: "#ff55ff", + brightCyan: "#55ffff", + brightWhite: "#ffffff", +}; + +// Dracula theme +const draculaTheme: TerminalTheme = { + background: "#282a36", + foreground: "#f8f8f2", + cursor: "#f8f8f2", + cursorAccent: "#282a36", + selectionBackground: "#44475a", + black: "#21222c", + red: "#ff5555", + green: "#50fa7b", + yellow: "#f1fa8c", + blue: "#bd93f9", + magenta: "#ff79c6", + cyan: "#8be9fd", + white: "#f8f8f2", + brightBlack: "#6272a4", + brightRed: "#ff6e6e", + brightGreen: "#69ff94", + brightYellow: "#ffffa5", + brightBlue: "#d6acff", + brightMagenta: "#ff92df", + brightCyan: "#a4ffff", + brightWhite: "#ffffff", +}; + +// Nord theme +const nordTheme: TerminalTheme = { + background: "#2e3440", + foreground: "#d8dee9", + cursor: "#d8dee9", + cursorAccent: "#2e3440", + selectionBackground: "#434c5e", + black: "#3b4252", + red: "#bf616a", + green: "#a3be8c", + yellow: "#ebcb8b", + blue: "#81a1c1", + magenta: "#b48ead", + cyan: "#88c0d0", + white: "#e5e9f0", + brightBlack: "#4c566a", + brightRed: "#bf616a", + brightGreen: "#a3be8c", + brightYellow: "#ebcb8b", + brightBlue: "#81a1c1", + brightMagenta: "#b48ead", + brightCyan: "#8fbcbb", + brightWhite: "#eceff4", +}; + +// Monokai theme +const monokaiTheme: TerminalTheme = { + background: "#272822", + foreground: "#f8f8f2", + cursor: "#f8f8f2", + cursorAccent: "#272822", + selectionBackground: "#49483e", + black: "#272822", + red: "#f92672", + green: "#a6e22e", + yellow: "#f4bf75", + blue: "#66d9ef", + magenta: "#ae81ff", + cyan: "#a1efe4", + white: "#f8f8f2", + brightBlack: "#75715e", + brightRed: "#f92672", + brightGreen: "#a6e22e", + brightYellow: "#f4bf75", + brightBlue: "#66d9ef", + brightMagenta: "#ae81ff", + brightCyan: "#a1efe4", + brightWhite: "#f9f8f5", +}; + +// Tokyo Night theme +const tokyonightTheme: TerminalTheme = { + background: "#1a1b26", + foreground: "#a9b1d6", + cursor: "#c0caf5", + cursorAccent: "#1a1b26", + selectionBackground: "#33467c", + black: "#15161e", + red: "#f7768e", + green: "#9ece6a", + yellow: "#e0af68", + blue: "#7aa2f7", + magenta: "#bb9af7", + cyan: "#7dcfff", + white: "#a9b1d6", + brightBlack: "#414868", + brightRed: "#f7768e", + brightGreen: "#9ece6a", + brightYellow: "#e0af68", + brightBlue: "#7aa2f7", + brightMagenta: "#bb9af7", + brightCyan: "#7dcfff", + brightWhite: "#c0caf5", +}; + +// Solarized Dark theme +const solarizedTheme: TerminalTheme = { + background: "#002b36", + foreground: "#839496", + cursor: "#839496", + cursorAccent: "#002b36", + selectionBackground: "#073642", + black: "#073642", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#eee8d5", + brightBlack: "#002b36", + brightRed: "#cb4b16", + brightGreen: "#586e75", + brightYellow: "#657b83", + brightBlue: "#839496", + brightMagenta: "#6c71c4", + brightCyan: "#93a1a1", + brightWhite: "#fdf6e3", +}; + +// Gruvbox Dark theme +const gruvboxTheme: TerminalTheme = { + background: "#282828", + foreground: "#ebdbb2", + cursor: "#ebdbb2", + cursorAccent: "#282828", + selectionBackground: "#504945", + black: "#282828", + red: "#cc241d", + green: "#98971a", + yellow: "#d79921", + blue: "#458588", + magenta: "#b16286", + cyan: "#689d6a", + white: "#a89984", + brightBlack: "#928374", + brightRed: "#fb4934", + brightGreen: "#b8bb26", + brightYellow: "#fabd2f", + brightBlue: "#83a598", + brightMagenta: "#d3869b", + brightCyan: "#8ec07c", + brightWhite: "#ebdbb2", +}; + +// Catppuccin Mocha theme +const catppuccinTheme: TerminalTheme = { + background: "#1e1e2e", + foreground: "#cdd6f4", + cursor: "#f5e0dc", + cursorAccent: "#1e1e2e", + selectionBackground: "#45475a", + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#cba6f7", + cyan: "#94e2d5", + white: "#bac2de", + brightBlack: "#585b70", + brightRed: "#f38ba8", + brightGreen: "#a6e3a1", + brightYellow: "#f9e2af", + brightBlue: "#89b4fa", + brightMagenta: "#cba6f7", + brightCyan: "#94e2d5", + brightWhite: "#a6adc8", +}; + +// One Dark theme +const onedarkTheme: TerminalTheme = { + background: "#282c34", + foreground: "#abb2bf", + cursor: "#528bff", + cursorAccent: "#282c34", + selectionBackground: "#3e4451", + black: "#282c34", + red: "#e06c75", + green: "#98c379", + yellow: "#e5c07b", + blue: "#61afef", + magenta: "#c678dd", + cyan: "#56b6c2", + white: "#abb2bf", + brightBlack: "#5c6370", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#e5c07b", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", +}; + +// Synthwave '84 theme +const synthwaveTheme: TerminalTheme = { + background: "#262335", + foreground: "#ffffff", + cursor: "#ff7edb", + cursorAccent: "#262335", + selectionBackground: "#463465", + black: "#262335", + red: "#fe4450", + green: "#72f1b8", + yellow: "#fede5d", + blue: "#03edf9", + magenta: "#ff7edb", + cyan: "#03edf9", + white: "#ffffff", + brightBlack: "#614d85", + brightRed: "#fe4450", + brightGreen: "#72f1b8", + brightYellow: "#f97e72", + brightBlue: "#03edf9", + brightMagenta: "#ff7edb", + brightCyan: "#03edf9", + brightWhite: "#ffffff", +}; + +// Theme mapping +const terminalThemes: Record = { + light: lightTheme, + dark: darkTheme, + system: darkTheme, // Will be resolved at runtime + retro: retroTheme, + dracula: draculaTheme, + nord: nordTheme, + monokai: monokaiTheme, + tokyonight: tokyonightTheme, + solarized: solarizedTheme, + gruvbox: gruvboxTheme, + catppuccin: catppuccinTheme, + onedark: onedarkTheme, + synthwave: synthwaveTheme, +}; + +/** + * Get terminal theme for the given app theme + * For "system" theme, it checks the user's system preference + */ +export function getTerminalTheme(theme: ThemeMode): TerminalTheme { + if (theme === "system") { + // Check system preference + if (typeof window !== "undefined") { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + return prefersDark ? darkTheme : lightTheme; + } + return darkTheme; // Default to dark for SSR + } + return terminalThemes[theme] || darkTheme; +} + +export default terminalThemes; diff --git a/apps/app/src/hooks/use-keyboard-shortcuts.ts b/apps/app/src/hooks/use-keyboard-shortcuts.ts index 8e12c2f5..1f1c7a95 100644 --- a/apps/app/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/app/src/hooks/use-keyboard-shortcuts.ts @@ -34,6 +34,18 @@ function isInputFocused(): boolean { return true; } + // Check if focus is inside an xterm terminal (they use a hidden textarea) + const xtermContainer = activeElement.closest(".xterm"); + if (xtermContainer) { + return true; + } + + // Also check if any parent has data-terminal-container attribute + const terminalContainer = activeElement.closest("[data-terminal-container]"); + if (terminalContainer) { + return true; + } + // Check for autocomplete/typeahead dropdowns being open const autocompleteList = document.querySelector( '[data-testid="category-autocomplete-list"]' diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index acc02f85..bd278f01 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -12,7 +12,8 @@ export type ViewMode = | "interview" | "context" | "profiles" - | "running-agents"; + | "running-agents" + | "terminal"; export type ThemeMode = | "light" @@ -296,6 +297,27 @@ export interface ProjectAnalysis { analyzedAt: string; } +// Terminal panel layout types (recursive for splits) +export type TerminalPanelContent = + | { type: "terminal"; sessionId: string; size?: number; fontSize?: number } + | { type: "split"; direction: "horizontal" | "vertical"; panels: TerminalPanelContent[] }; + +// Terminal tab - each tab has its own layout +export interface TerminalTab { + id: string; + name: string; + layout: TerminalPanelContent | null; +} + +export interface TerminalState { + isUnlocked: boolean; + authToken: string | null; + tabs: TerminalTab[]; + activeTabId: string | null; + activeSessionId: string | null; + defaultFontSize: number; // Default font size for new terminals +} + export interface AppState { // Project state projects: Project[]; @@ -385,6 +407,9 @@ export interface AppState { // Theme Preview (for hover preview in theme selectors) previewTheme: ThemeMode | null; + + // Terminal state + terminalState: TerminalState; } // Default background settings for board backgrounds @@ -563,6 +588,21 @@ export interface AppActions { setHideScrollbar: (projectPath: string, hide: boolean) => void; clearBoardBackground: (projectPath: string) => void; + // Terminal actions + setTerminalUnlocked: (unlocked: boolean, token?: string) => void; + setActiveTerminalSession: (sessionId: string | null) => void; + addTerminalToLayout: (sessionId: string, direction?: "horizontal" | "vertical") => void; + removeTerminalFromLayout: (sessionId: string) => void; + swapTerminals: (sessionId1: string, sessionId2: string) => void; + clearTerminalState: () => void; + setTerminalPanelFontSize: (sessionId: string, fontSize: number) => void; + addTerminalTab: (name?: string) => string; + removeTerminalTab: (tabId: string) => void; + setActiveTerminalTab: (tabId: string) => void; + renameTerminalTab: (tabId: string, name: string) => void; + moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => void; + addTerminalToTab: (sessionId: string, tabId: string, direction?: "horizontal" | "vertical") => void; + // Reset reset: () => void; } @@ -658,6 +698,14 @@ const initialState: AppState = { isAnalyzing: false, boardBackgroundByProject: {}, previewTheme: null, + terminalState: { + isUnlocked: false, + authToken: null, + tabs: [], + activeTabId: null, + activeSessionId: null, + defaultFontSize: 14, + }, }; export const useAppStore = create()( @@ -1464,6 +1512,432 @@ export const useAppStore = create()( }); }, + // Terminal actions + setTerminalUnlocked: (unlocked, token) => { + set({ + terminalState: { + ...get().terminalState, + isUnlocked: unlocked, + authToken: token || null, + }, + }); + }, + + setActiveTerminalSession: (sessionId) => { + set({ + terminalState: { + ...get().terminalState, + activeSessionId: sessionId, + }, + }); + }, + + addTerminalToLayout: (sessionId, direction = "horizontal") => { + const current = get().terminalState; + const newTerminal: TerminalPanelContent = { type: "terminal", sessionId, size: 50 }; + + // If no tabs, create first tab + if (current.tabs.length === 0) { + const newTabId = `tab-${Date.now()}`; + set({ + terminalState: { + ...current, + tabs: [{ id: newTabId, name: "Terminal 1", layout: { type: "terminal", sessionId, size: 100 } }], + activeTabId: newTabId, + activeSessionId: sessionId, + }, + }); + return; + } + + // Add to active tab's layout + const activeTab = current.tabs.find(t => t.id === current.activeTabId); + if (!activeTab) return; + + const addToLayout = ( + node: TerminalPanelContent, + targetDirection: "horizontal" | "vertical" + ): TerminalPanelContent => { + if (node.type === "terminal") { + return { + type: "split", + direction: targetDirection, + panels: [{ ...node, size: 50 }, newTerminal], + }; + } + // If same direction, add to existing split + if (node.direction === targetDirection) { + const newSize = 100 / (node.panels.length + 1); + return { + ...node, + panels: [...node.panels.map(p => ({ ...p, size: newSize })), { ...newTerminal, size: newSize }], + }; + } + // Different direction, wrap in new split + return { + type: "split", + direction: targetDirection, + panels: [{ ...node, size: 50 }, newTerminal], + }; + }; + + const newLayout = activeTab.layout + ? addToLayout(activeTab.layout, direction) + : { type: "terminal" as const, sessionId, size: 100 }; + + const newTabs = current.tabs.map(t => + t.id === current.activeTabId ? { ...t, layout: newLayout } : t + ); + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeSessionId: sessionId, + }, + }); + }, + + removeTerminalFromLayout: (sessionId) => { + const current = get().terminalState; + if (current.tabs.length === 0) return; + + // Find which tab contains this session + const findFirstTerminal = (node: TerminalPanelContent): string | null => { + if (node.type === "terminal") return node.sessionId; + for (const panel of node.panels) { + const found = findFirstTerminal(panel); + if (found) return found; + } + return null; + }; + + const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { + if (node.type === "terminal") { + return node.sessionId === sessionId ? null : node; + } + const newPanels: TerminalPanelContent[] = []; + for (const panel of node.panels) { + const result = removeAndCollapse(panel); + if (result !== null) newPanels.push(result); + } + if (newPanels.length === 0) return null; + if (newPanels.length === 1) return newPanels[0]; + return { ...node, panels: newPanels }; + }; + + let newTabs = current.tabs.map(tab => { + if (!tab.layout) return tab; + const newLayout = removeAndCollapse(tab.layout); + return { ...tab, layout: newLayout }; + }); + + // Remove empty tabs + newTabs = newTabs.filter(tab => tab.layout !== null); + + // Determine new active session + const newActiveTabId = newTabs.length > 0 ? (current.activeTabId && newTabs.find(t => t.id === current.activeTabId) ? current.activeTabId : newTabs[0].id) : null; + const newActiveSessionId = newActiveTabId + ? findFirstTerminal(newTabs.find(t => t.id === newActiveTabId)?.layout || null as unknown as TerminalPanelContent) + : null; + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: newActiveTabId, + activeSessionId: newActiveSessionId, + }, + }); + }, + + swapTerminals: (sessionId1, sessionId2) => { + const current = get().terminalState; + if (current.tabs.length === 0) return; + + const swapInLayout = (node: TerminalPanelContent): TerminalPanelContent => { + if (node.type === "terminal") { + if (node.sessionId === sessionId1) return { ...node, sessionId: sessionId2 }; + if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 }; + return node; + } + return { ...node, panels: node.panels.map(swapInLayout) }; + }; + + const newTabs = current.tabs.map(tab => ({ + ...tab, + layout: tab.layout ? swapInLayout(tab.layout) : null, + })); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + clearTerminalState: () => { + set({ + terminalState: { + isUnlocked: false, + authToken: null, + tabs: [], + activeTabId: null, + activeSessionId: null, + defaultFontSize: 14, + }, + }); + }, + + setTerminalPanelFontSize: (sessionId, fontSize) => { + const current = get().terminalState; + const clampedSize = Math.max(8, Math.min(32, fontSize)); + + const updateFontSize = (node: TerminalPanelContent): TerminalPanelContent => { + if (node.type === "terminal") { + if (node.sessionId === sessionId) { + return { ...node, fontSize: clampedSize }; + } + return node; + } + return { ...node, panels: node.panels.map(updateFontSize) }; + }; + + const newTabs = current.tabs.map(tab => { + if (!tab.layout) return tab; + return { ...tab, layout: updateFontSize(tab.layout) }; + }); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + addTerminalTab: (name) => { + const current = get().terminalState; + const newTabId = `tab-${Date.now()}`; + const tabNumber = current.tabs.length + 1; + const newTab: TerminalTab = { id: newTabId, name: name || `Terminal ${tabNumber}`, layout: null }; + set({ + terminalState: { + ...current, + tabs: [...current.tabs, newTab], + activeTabId: newTabId, + }, + }); + return newTabId; + }, + + removeTerminalTab: (tabId) => { + const current = get().terminalState; + const newTabs = current.tabs.filter(t => t.id !== tabId); + let newActiveTabId = current.activeTabId; + let newActiveSessionId = current.activeSessionId; + + if (current.activeTabId === tabId) { + newActiveTabId = newTabs.length > 0 ? newTabs[0].id : null; + if (newActiveTabId) { + const newActiveTab = newTabs.find(t => t.id === newActiveTabId); + const findFirst = (node: TerminalPanelContent): string | null => { + if (node.type === "terminal") return node.sessionId; + for (const p of node.panels) { + const f = findFirst(p); + if (f) return f; + } + return null; + }; + newActiveSessionId = newActiveTab?.layout ? findFirst(newActiveTab.layout) : null; + } else { + newActiveSessionId = null; + } + } + + set({ + terminalState: { ...current, tabs: newTabs, activeTabId: newActiveTabId, activeSessionId: newActiveSessionId }, + }); + }, + + setActiveTerminalTab: (tabId) => { + const current = get().terminalState; + const tab = current.tabs.find(t => t.id === tabId); + if (!tab) return; + + let newActiveSessionId = current.activeSessionId; + if (tab.layout) { + const findFirst = (node: TerminalPanelContent): string | null => { + if (node.type === "terminal") return node.sessionId; + for (const p of node.panels) { + const f = findFirst(p); + if (f) return f; + } + return null; + }; + newActiveSessionId = findFirst(tab.layout); + } + + set({ + terminalState: { ...current, activeTabId: tabId, activeSessionId: newActiveSessionId }, + }); + }, + + renameTerminalTab: (tabId, name) => { + const current = get().terminalState; + const newTabs = current.tabs.map(t => t.id === tabId ? { ...t, name } : t); + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + moveTerminalToTab: (sessionId, targetTabId) => { + const current = get().terminalState; + + let sourceTabId: string | null = null; + let originalTerminalNode: (TerminalPanelContent & { type: "terminal" }) | null = null; + + const findTerminal = (node: TerminalPanelContent): (TerminalPanelContent & { type: "terminal" }) | null => { + if (node.type === "terminal") { + return node.sessionId === sessionId ? node : null; + } + for (const panel of node.panels) { + const found = findTerminal(panel); + if (found) return found; + } + return null; + }; + + for (const tab of current.tabs) { + if (tab.layout) { + const found = findTerminal(tab.layout); + if (found) { + sourceTabId = tab.id; + originalTerminalNode = found; + break; + } + } + } + if (!sourceTabId || !originalTerminalNode) return; + if (sourceTabId === targetTabId) return; + + const sourceTab = current.tabs.find(t => t.id === sourceTabId); + if (!sourceTab?.layout) return; + + const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { + if (node.type === "terminal") { + return node.sessionId === sessionId ? null : node; + } + const newPanels: TerminalPanelContent[] = []; + for (const panel of node.panels) { + const result = removeAndCollapse(panel); + if (result !== null) newPanels.push(result); + } + if (newPanels.length === 0) return null; + if (newPanels.length === 1) return newPanels[0]; + return { ...node, panels: newPanels }; + }; + + const newSourceLayout = removeAndCollapse(sourceTab.layout); + + let finalTargetTabId = targetTabId; + let newTabs = current.tabs; + + if (targetTabId === "new") { + const newTabId = `tab-${Date.now()}`; + const sourceWillBeRemoved = !newSourceLayout; + const tabName = sourceWillBeRemoved ? sourceTab.name : `Terminal ${current.tabs.length + 1}`; + newTabs = [ + ...current.tabs, + { id: newTabId, name: tabName, layout: { type: "terminal", sessionId, size: 100, fontSize: originalTerminalNode.fontSize } }, + ]; + finalTargetTabId = newTabId; + } else { + const targetTab = current.tabs.find(t => t.id === targetTabId); + if (!targetTab) return; + + const terminalNode: TerminalPanelContent = { type: "terminal", sessionId, size: 50, fontSize: originalTerminalNode.fontSize }; + let newTargetLayout: TerminalPanelContent; + + if (!targetTab.layout) { + newTargetLayout = { type: "terminal", sessionId, size: 100, fontSize: originalTerminalNode.fontSize }; + } else if (targetTab.layout.type === "terminal") { + newTargetLayout = { + type: "split", + direction: "horizontal", + panels: [{ ...targetTab.layout, size: 50 }, terminalNode], + }; + } else { + newTargetLayout = { + ...targetTab.layout, + panels: [...targetTab.layout.panels, terminalNode], + }; + } + + newTabs = current.tabs.map(t => + t.id === targetTabId ? { ...t, layout: newTargetLayout } : t + ); + } + + if (!newSourceLayout) { + newTabs = newTabs.filter(t => t.id !== sourceTabId); + } else { + newTabs = newTabs.map(t => + t.id === sourceTabId ? { ...t, layout: newSourceLayout } : t + ); + } + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: finalTargetTabId, + activeSessionId: sessionId, + }, + }); + }, + + addTerminalToTab: (sessionId, tabId, direction = "horizontal") => { + const current = get().terminalState; + const tab = current.tabs.find(t => t.id === tabId); + if (!tab) return; + + const terminalNode: TerminalPanelContent = { type: "terminal", sessionId, size: 50 }; + let newLayout: TerminalPanelContent; + + if (!tab.layout) { + newLayout = { type: "terminal", sessionId, size: 100 }; + } else if (tab.layout.type === "terminal") { + newLayout = { + type: "split", + direction, + panels: [{ ...tab.layout, size: 50 }, terminalNode], + }; + } else { + if (tab.layout.direction === direction) { + const newSize = 100 / (tab.layout.panels.length + 1); + newLayout = { + ...tab.layout, + panels: [...tab.layout.panels.map(p => ({ ...p, size: newSize })), { ...terminalNode, size: newSize }], + }; + } else { + newLayout = { + type: "split", + direction, + panels: [{ ...tab.layout, size: 50 }, terminalNode], + }; + } + } + + const newTabs = current.tabs.map(t => + t.id === tabId ? { ...t, layout: newLayout } : t + ); + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: tabId, + activeSessionId: sessionId, + }, + }); + }, + // Reset reset: () => set(initialState), }), diff --git a/apps/app/src/types/css.d.ts b/apps/app/src/types/css.d.ts new file mode 100644 index 00000000..54ff221f --- /dev/null +++ b/apps/app/src/types/css.d.ts @@ -0,0 +1,9 @@ +declare module "*.css" { + const content: { [className: string]: string }; + export default content; +} + +declare module "@xterm/xterm/css/xterm.css" { + const content: unknown; + export default content; +} diff --git a/apps/server/.env.example b/apps/server/.env.example index 6ce580b1..12b7bfcd 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -43,3 +43,14 @@ OPENAI_API_KEY= # Google API key (for future Gemini support) GOOGLE_API_KEY= + +# ============================================ +# OPTIONAL - Terminal Access +# ============================================ + +# Enable/disable terminal access (default: true) +TERMINAL_ENABLED=true + +# Password to protect terminal access (leave empty for no password) +# If set, users must enter this password before accessing terminal +TERMINAL_PASSWORD=test diff --git a/apps/server/package.json b/apps/server/package.json index 6e16d10b..113595a1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "node-pty": "^1.0.0", "ws": "^8.18.0" }, "devDependencies": { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index de7c7240..e55f55bf 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -30,9 +30,11 @@ import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js"; import { createRunningAgentsRoutes } from "./routes/running-agents.js"; import { createWorkspaceRoutes } from "./routes/workspace.js"; import { createTemplatesRoutes } from "./routes/templates.js"; +import { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired } from "./routes/terminal.js"; import { AgentService } from "./services/agent-service.js"; import { FeatureLoader } from "./services/feature-loader.js"; import { AutoModeService } from "./services/auto-mode-service.js"; +import { getTerminalService } from "./services/terminal-service.js"; // Load environment variables dotenv.config(); @@ -116,13 +118,34 @@ app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events)); app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService)); app.use("/api/workspace", createWorkspaceRoutes()); app.use("/api/templates", createTemplatesRoutes()); +app.use("/api/terminal", createTerminalRoutes()); // Create HTTP server const server = createServer(app); -// WebSocket server for streaming events -const wss = new WebSocketServer({ server, path: "/api/events" }); +// WebSocket servers using noServer mode for proper multi-path support +const wss = new WebSocketServer({ noServer: true }); +const terminalWss = new WebSocketServer({ noServer: true }); +const terminalService = getTerminalService(); +// Handle HTTP upgrade requests manually to route to correct WebSocket server +server.on("upgrade", (request, socket, head) => { + const { pathname } = new URL(request.url || "", `http://${request.headers.host}`); + + if (pathname === "/api/events") { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request); + }); + } else if (pathname === "/api/terminal/ws") { + terminalWss.handleUpgrade(request, socket, head, (ws) => { + terminalWss.emit("connection", ws, request); + }); + } else { + socket.destroy(); + } +}); + +// Events WebSocket connection handler wss.on("connection", (ws: WebSocket) => { console.log("[WebSocket] Client connected"); @@ -144,15 +167,153 @@ wss.on("connection", (ws: WebSocket) => { }); }); +// Track WebSocket connections per session +const terminalConnections: Map> = new Map(); + +// Terminal WebSocket connection handler +terminalWss.on("connection", (ws: WebSocket, req: import("http").IncomingMessage) => { + // Parse URL to get session ID and token + const url = new URL(req.url || "", `http://${req.headers.host}`); + const sessionId = url.searchParams.get("sessionId"); + const token = url.searchParams.get("token"); + + console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`); + + // Check if terminal is enabled + if (!isTerminalEnabled()) { + console.log("[Terminal WS] Terminal is disabled"); + ws.close(4003, "Terminal access is disabled"); + return; + } + + // Validate token if password is required + if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) { + console.log("[Terminal WS] Invalid or missing token"); + ws.close(4001, "Authentication required"); + return; + } + + if (!sessionId) { + console.log("[Terminal WS] No session ID provided"); + ws.close(4002, "Session ID required"); + return; + } + + // Check if session exists + const session = terminalService.getSession(sessionId); + if (!session) { + console.log(`[Terminal WS] Session ${sessionId} not found`); + ws.close(4004, "Session not found"); + return; + } + + console.log(`[Terminal WS] Client connected to session ${sessionId}`); + + // Track this connection + if (!terminalConnections.has(sessionId)) { + terminalConnections.set(sessionId, new Set()); + } + terminalConnections.get(sessionId)!.add(ws); + + // Subscribe to terminal data + const unsubscribeData = terminalService.onData((sid, data) => { + if (sid === sessionId && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "data", data })); + } + }); + + // Subscribe to terminal exit + const unsubscribeExit = terminalService.onExit((sid, exitCode) => { + if (sid === sessionId && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "exit", exitCode })); + ws.close(1000, "Session ended"); + } + }); + + // Handle incoming messages + ws.on("message", (message) => { + try { + const msg = JSON.parse(message.toString()); + + switch (msg.type) { + case "input": + // Write user input to terminal + terminalService.write(sessionId, msg.data); + break; + + case "resize": + // Resize terminal + if (msg.cols && msg.rows) { + terminalService.resize(sessionId, msg.cols, msg.rows); + } + break; + + case "ping": + // Respond to ping + ws.send(JSON.stringify({ type: "pong" })); + break; + + default: + console.warn(`[Terminal WS] Unknown message type: ${msg.type}`); + } + } catch (error) { + console.error("[Terminal WS] Error processing message:", error); + } + }); + + ws.on("close", () => { + console.log(`[Terminal WS] Client disconnected from session ${sessionId}`); + unsubscribeData(); + unsubscribeExit(); + + // Remove from connections tracking + const connections = terminalConnections.get(sessionId); + if (connections) { + connections.delete(ws); + if (connections.size === 0) { + terminalConnections.delete(sessionId); + } + } + }); + + ws.on("error", (error) => { + console.error(`[Terminal WS] Error on session ${sessionId}:`, error); + unsubscribeData(); + unsubscribeExit(); + }); + + // Send initial connection success + ws.send(JSON.stringify({ + type: "connected", + sessionId, + shell: session.shell, + cwd: session.cwd, + })); + + // Send scrollback buffer to replay previous output + const scrollback = terminalService.getScrollback(sessionId); + if (scrollback && scrollback.length > 0) { + ws.send(JSON.stringify({ + type: "scrollback", + data: scrollback, + })); + } +}); + // Start server server.listen(PORT, () => { + const terminalStatus = isTerminalEnabled() + ? (isTerminalPasswordRequired() ? "enabled (password protected)" : "enabled") + : "disabled"; console.log(` ╔═══════════════════════════════════════════════════════╗ ║ Automaker Backend Server ║ ╠═══════════════════════════════════════════════════════╣ ║ HTTP API: http://localhost:${PORT} ║ ║ WebSocket: ws://localhost:${PORT}/api/events ║ +║ Terminal: ws://localhost:${PORT}/api/terminal/ws ║ ║ Health: http://localhost:${PORT}/api/health ║ +║ Terminal: ${terminalStatus.padEnd(37)}║ ╚═══════════════════════════════════════════════════════╝ `); }); @@ -160,6 +321,7 @@ server.listen(PORT, () => { // Graceful shutdown process.on("SIGTERM", () => { console.log("SIGTERM received, shutting down..."); + terminalService.cleanup(); server.close(() => { console.log("Server closed"); process.exit(0); @@ -168,6 +330,7 @@ process.on("SIGTERM", () => { process.on("SIGINT", () => { console.log("SIGINT received, shutting down..."); + terminalService.cleanup(); server.close(() => { console.log("Server closed"); process.exit(0); diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts index 7befe07f..ef227918 100644 --- a/apps/server/src/routes/fs.ts +++ b/apps/server/src/routes/fs.ts @@ -7,7 +7,6 @@ import { Router, type Request, type Response } from "express"; import fs from "fs/promises"; import os from "os"; import path from "path"; -import os from "os"; import { validatePath, addAllowedPath, isPathAllowed } from "../lib/security.js"; import type { EventEmitter } from "../lib/events.js"; diff --git a/apps/server/src/routes/spec-regeneration.ts b/apps/server/src/routes/spec-regeneration.ts index c9373675..f33c126c 100644 --- a/apps/server/src/routes/spec-regeneration.ts +++ b/apps/server/src/routes/spec-regeneration.ts @@ -355,7 +355,7 @@ Format your response as markdown. Be specific and actionable.`; } else if (msg.type === "result" && (msg as any).subtype === "success") { console.log("[SpecRegeneration] Received success result"); responseText = (msg as any).result || responseText; - } else if (msg.type === "error") { + } else if ((msg as { type: string }).type === "error") { console.error("[SpecRegeneration] ❌ Received error message from stream:"); console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2)); } @@ -505,7 +505,7 @@ Generate 5-15 features that build on each other logically.`; } else if (msg.type === "result" && (msg as any).subtype === "success") { console.log("[SpecRegeneration] Received success result for features"); responseText = (msg as any).result || responseText; - } else if (msg.type === "error") { + } else if ((msg as { type: string }).type === "error") { console.error("[SpecRegeneration] ❌ Received error message from feature stream:"); console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2)); } diff --git a/apps/server/src/routes/terminal.ts b/apps/server/src/routes/terminal.ts new file mode 100644 index 00000000..622935b8 --- /dev/null +++ b/apps/server/src/routes/terminal.ts @@ -0,0 +1,312 @@ +/** + * Terminal routes with password protection + * + * Provides REST API for terminal session management and authentication. + * WebSocket connections for real-time I/O are handled separately in index.ts. + */ + +import { Router, Request, Response, NextFunction } from "express"; +import { getTerminalService } from "../services/terminal-service.js"; + +// Read env variables lazily to ensure dotenv has loaded them +function getTerminalPassword(): string | undefined { + return process.env.TERMINAL_PASSWORD; +} + +function getTerminalEnabledConfig(): boolean { + return process.env.TERMINAL_ENABLED !== "false"; // Enabled by default +} + +// In-memory session tokens (would use Redis in production) +const validTokens: Map = new Map(); +const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** + * Generate a secure random token + */ +function generateToken(): string { + return `term-${Date.now()}-${Math.random().toString(36).substr(2, 15)}${Math.random().toString(36).substr(2, 15)}`; +} + +/** + * Clean up expired tokens + */ +function cleanupExpiredTokens(): void { + const now = new Date(); + validTokens.forEach((data, token) => { + if (data.expiresAt < now) { + validTokens.delete(token); + } + }); +} + +// Clean up expired tokens every 5 minutes +setInterval(cleanupExpiredTokens, 5 * 60 * 1000); + +/** + * Validate a terminal session token + */ +export function validateTerminalToken(token: string | undefined): boolean { + if (!token) return false; + + const tokenData = validTokens.get(token); + if (!tokenData) return false; + + if (tokenData.expiresAt < new Date()) { + validTokens.delete(token); + return false; + } + + return true; +} + +/** + * Check if terminal requires password + */ +export function isTerminalPasswordRequired(): boolean { + return !!getTerminalPassword(); +} + +/** + * Check if terminal is enabled + */ +export function isTerminalEnabled(): boolean { + return getTerminalEnabledConfig(); +} + +/** + * Terminal authentication middleware + * Checks for valid session token if password is configured + */ +export function terminalAuthMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + // Check if terminal is enabled + if (!getTerminalEnabledConfig()) { + res.status(403).json({ + success: false, + error: "Terminal access is disabled", + }); + return; + } + + // If no password configured, allow all requests + if (!getTerminalPassword()) { + next(); + return; + } + + // Check for session token + const token = + (req.headers["x-terminal-token"] as string) || + (req.query.token as string); + + if (!validateTerminalToken(token)) { + res.status(401).json({ + success: false, + error: "Terminal authentication required", + passwordRequired: true, + }); + return; + } + + next(); +} + +export function createTerminalRoutes(): Router { + const router = Router(); + const terminalService = getTerminalService(); + + /** + * GET /api/terminal/status + * Get terminal status (enabled, password required, platform info) + */ + router.get("/status", (_req, res) => { + res.json({ + success: true, + data: { + enabled: getTerminalEnabledConfig(), + passwordRequired: !!getTerminalPassword(), + platform: terminalService.getPlatformInfo(), + }, + }); + }); + + /** + * POST /api/terminal/auth + * Authenticate with password to get a session token + */ + router.post("/auth", (req, res) => { + if (!getTerminalEnabledConfig()) { + res.status(403).json({ + success: false, + error: "Terminal access is disabled", + }); + return; + } + + const terminalPassword = getTerminalPassword(); + + // If no password required, return immediate success + if (!terminalPassword) { + res.json({ + success: true, + data: { + authenticated: true, + passwordRequired: false, + }, + }); + return; + } + + const { password } = req.body; + + if (!password || password !== terminalPassword) { + res.status(401).json({ + success: false, + error: "Invalid password", + }); + return; + } + + // Generate session token + const token = generateToken(); + const now = new Date(); + validTokens.set(token, { + createdAt: now, + expiresAt: new Date(now.getTime() + TOKEN_EXPIRY_MS), + }); + + res.json({ + success: true, + data: { + authenticated: true, + token, + expiresIn: TOKEN_EXPIRY_MS, + }, + }); + }); + + /** + * POST /api/terminal/logout + * Invalidate a session token + */ + router.post("/logout", (req, res) => { + const token = + (req.headers["x-terminal-token"] as string) || + req.body.token; + + if (token) { + validTokens.delete(token); + } + + res.json({ + success: true, + }); + }); + + // Apply terminal auth middleware to all routes below + router.use(terminalAuthMiddleware); + + /** + * GET /api/terminal/sessions + * List all active terminal sessions + */ + router.get("/sessions", (_req, res) => { + const sessions = terminalService.getAllSessions(); + res.json({ + success: true, + data: sessions, + }); + }); + + /** + * POST /api/terminal/sessions + * Create a new terminal session + */ + router.post("/sessions", (req, res) => { + try { + const { cwd, cols, rows, shell } = req.body; + + const session = terminalService.createSession({ + cwd, + cols: cols || 80, + rows: rows || 24, + shell, + }); + + res.json({ + success: true, + data: { + id: session.id, + cwd: session.cwd, + shell: session.shell, + createdAt: session.createdAt, + }, + }); + } catch (error) { + console.error("[Terminal] Error creating session:", error); + res.status(500).json({ + success: false, + error: "Failed to create terminal session", + details: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + /** + * DELETE /api/terminal/sessions/:id + * Kill a terminal session + */ + router.delete("/sessions/:id", (req, res) => { + const { id } = req.params; + const killed = terminalService.killSession(id); + + if (!killed) { + res.status(404).json({ + success: false, + error: "Session not found", + }); + return; + } + + res.json({ + success: true, + }); + }); + + /** + * POST /api/terminal/sessions/:id/resize + * Resize a terminal session + */ + router.post("/sessions/:id/resize", (req, res) => { + const { id } = req.params; + const { cols, rows } = req.body; + + if (!cols || !rows) { + res.status(400).json({ + success: false, + error: "cols and rows are required", + }); + return; + } + + const resized = terminalService.resize(id, cols, rows); + + if (!resized) { + res.status(404).json({ + success: false, + error: "Session not found", + }); + return; + } + + res.json({ + success: true, + }); + }); + + return router; +} diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts new file mode 100644 index 00000000..17fc5d49 --- /dev/null +++ b/apps/server/src/services/terminal-service.ts @@ -0,0 +1,359 @@ +/** + * Terminal Service + * + * Manages PTY (pseudo-terminal) sessions using node-pty. + * Supports cross-platform shell detection including WSL. + */ + +import * as pty from "node-pty"; +import { EventEmitter } from "events"; +import * as os from "os"; +import * as fs from "fs"; + +// Maximum scrollback buffer size (characters) +const MAX_SCROLLBACK_SIZE = 100000; // ~100KB per terminal + +export interface TerminalSession { + id: string; + pty: pty.IPty; + cwd: string; + createdAt: Date; + shell: string; + scrollbackBuffer: string; // Store recent output for replay on reconnect +} + +export interface TerminalOptions { + cwd?: string; + shell?: string; + cols?: number; + rows?: number; + env?: Record; +} + +type DataCallback = (sessionId: string, data: string) => void; +type ExitCallback = (sessionId: string, exitCode: number) => void; + +export class TerminalService extends EventEmitter { + private sessions: Map = new Map(); + private dataCallbacks: Set = new Set(); + private exitCallbacks: Set = new Set(); + + /** + * Detect the best shell for the current platform + */ + detectShell(): { shell: string; args: string[] } { + const platform = os.platform(); + + // Check if running in WSL + if (platform === "linux" && this.isWSL()) { + // In WSL, prefer the user's configured shell or bash + const userShell = process.env.SHELL || "/bin/bash"; + if (fs.existsSync(userShell)) { + return { shell: userShell, args: ["--login"] }; + } + return { shell: "/bin/bash", args: ["--login"] }; + } + + switch (platform) { + case "win32": { + // Windows: prefer PowerShell, fall back to cmd + const pwsh = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + const pwshCore = "C:\\Program Files\\PowerShell\\7\\pwsh.exe"; + + if (fs.existsSync(pwshCore)) { + return { shell: pwshCore, args: [] }; + } + if (fs.existsSync(pwsh)) { + return { shell: pwsh, args: [] }; + } + return { shell: "cmd.exe", args: [] }; + } + + case "darwin": { + // macOS: prefer user's shell, then zsh, then bash + const userShell = process.env.SHELL; + if (userShell && fs.existsSync(userShell)) { + return { shell: userShell, args: ["--login"] }; + } + if (fs.existsSync("/bin/zsh")) { + return { shell: "/bin/zsh", args: ["--login"] }; + } + return { shell: "/bin/bash", args: ["--login"] }; + } + + case "linux": + default: { + // Linux: prefer user's shell, then bash, then sh + const userShell = process.env.SHELL; + if (userShell && fs.existsSync(userShell)) { + return { shell: userShell, args: ["--login"] }; + } + if (fs.existsSync("/bin/bash")) { + return { shell: "/bin/bash", args: ["--login"] }; + } + return { shell: "/bin/sh", args: [] }; + } + } + } + + /** + * Detect if running inside WSL (Windows Subsystem for Linux) + */ + isWSL(): boolean { + try { + // Check /proc/version for Microsoft/WSL indicators + if (fs.existsSync("/proc/version")) { + const version = fs.readFileSync("/proc/version", "utf-8").toLowerCase(); + return version.includes("microsoft") || version.includes("wsl"); + } + // Check for WSL environment variable + if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) { + return true; + } + } catch { + // Ignore errors + } + return false; + } + + /** + * Get platform info for the client + */ + getPlatformInfo(): { + platform: string; + isWSL: boolean; + defaultShell: string; + arch: string; + } { + const { shell } = this.detectShell(); + return { + platform: os.platform(), + isWSL: this.isWSL(), + defaultShell: shell, + arch: os.arch(), + }; + } + + /** + * Validate and resolve a working directory path + */ + private resolveWorkingDirectory(requestedCwd?: string): string { + const homeDir = os.homedir(); + + // If no cwd requested, use home + if (!requestedCwd) { + return homeDir; + } + + // Clean up the path + let cwd = requestedCwd.trim(); + + // Fix double slashes at start (but not for Windows UNC paths) + if (cwd.startsWith("//") && !cwd.startsWith("//wsl")) { + cwd = cwd.slice(1); + } + + // Check if path exists and is a directory + try { + const stat = fs.statSync(cwd); + if (stat.isDirectory()) { + return cwd; + } + console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`); + return homeDir; + } catch { + console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`); + return homeDir; + } + } + + /** + * Create a new terminal session + */ + createSession(options: TerminalOptions = {}): TerminalSession { + const id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const { shell: detectedShell, args: shellArgs } = this.detectShell(); + const shell = options.shell || detectedShell; + + // Validate and resolve working directory + const cwd = this.resolveWorkingDirectory(options.cwd); + + // Build environment with some useful defaults + const env: Record = { + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + TERM_PROGRAM: "automaker-terminal", + ...options.env, + }; + + console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`); + + const ptyProcess = pty.spawn(shell, shellArgs, { + name: "xterm-256color", + cols: options.cols || 80, + rows: options.rows || 24, + cwd, + env, + }); + + const session: TerminalSession = { + id, + pty: ptyProcess, + cwd, + createdAt: new Date(), + shell, + scrollbackBuffer: "", + }; + + this.sessions.set(id, session); + + // Forward data events and store in scrollback buffer + ptyProcess.onData((data) => { + // Append to scrollback buffer + session.scrollbackBuffer += data; + // Trim if too large (keep the most recent data) + if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { + session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); + } + + this.dataCallbacks.forEach((cb) => cb(id, data)); + this.emit("data", id, data); + }); + + // Handle exit + ptyProcess.onExit(({ exitCode }) => { + console.log(`[Terminal] Session ${id} exited with code ${exitCode}`); + this.sessions.delete(id); + this.exitCallbacks.forEach((cb) => cb(id, exitCode)); + this.emit("exit", id, exitCode); + }); + + console.log(`[Terminal] Session ${id} created successfully`); + return session; + } + + /** + * Write data to a terminal session + */ + write(sessionId: string, data: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + console.warn(`[Terminal] Session ${sessionId} not found`); + return false; + } + session.pty.write(data); + return true; + } + + /** + * Resize a terminal session + */ + resize(sessionId: string, cols: number, rows: number): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + console.warn(`[Terminal] Session ${sessionId} not found for resize`); + return false; + } + try { + session.pty.resize(cols, rows); + return true; + } catch (error) { + console.error(`[Terminal] Error resizing session ${sessionId}:`, error); + return false; + } + } + + /** + * Kill a terminal session + */ + killSession(sessionId: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + return false; + } + try { + session.pty.kill(); + this.sessions.delete(sessionId); + console.log(`[Terminal] Session ${sessionId} killed`); + return true; + } catch (error) { + console.error(`[Terminal] Error killing session ${sessionId}:`, error); + return false; + } + } + + /** + * Get a session by ID + */ + getSession(sessionId: string): TerminalSession | undefined { + return this.sessions.get(sessionId); + } + + /** + * Get scrollback buffer for a session (for replay on reconnect) + */ + getScrollback(sessionId: string): string | null { + const session = this.sessions.get(sessionId); + return session?.scrollbackBuffer || null; + } + + /** + * Get all active sessions + */ + getAllSessions(): Array<{ + id: string; + cwd: string; + createdAt: Date; + shell: string; + }> { + return Array.from(this.sessions.values()).map((s) => ({ + id: s.id, + cwd: s.cwd, + createdAt: s.createdAt, + shell: s.shell, + })); + } + + /** + * Subscribe to data events + */ + onData(callback: DataCallback): () => void { + this.dataCallbacks.add(callback); + return () => this.dataCallbacks.delete(callback); + } + + /** + * Subscribe to exit events + */ + onExit(callback: ExitCallback): () => void { + this.exitCallbacks.add(callback); + return () => this.exitCallbacks.delete(callback); + } + + /** + * Clean up all sessions + */ + cleanup(): void { + console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`); + this.sessions.forEach((session, id) => { + try { + session.pty.kill(); + } catch { + // Ignore errors during cleanup + } + this.sessions.delete(id); + }); + } +} + +// Singleton instance +let terminalService: TerminalService | null = null; + +export function getTerminalService(): TerminalService { + if (!terminalService) { + terminalService = new TerminalService(); + } + return terminalService; +} diff --git a/package-lock.json b/package-lock.json index a8c3184c..f27451ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,9 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -40,6 +43,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-markdown": "^10.1.0", + "react-resizable-panels": "^3.0.6", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" @@ -10256,6 +10260,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "node-pty": "^1.0.0", "ws": "^8.18.0" }, "devDependencies": { @@ -11948,6 +11953,30 @@ "@types/node": "*" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz", + "integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/@zeit/schemas": { "version": "2.36.0", "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", @@ -13436,6 +13465,12 @@ "dev": true, "license": "MIT" }, + "node_modules/nan": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -13517,6 +13552,16 @@ } } }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "nan": "^2.17.0" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -13765,6 +13810,16 @@ "react": "^19.2.0" } }, + "node_modules/react-resizable-panels": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz", + "integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/registry-auth-token": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz",