diff --git a/apps/app/electron/main.js b/apps/app/electron/main.js index 9ae31c32..0fd95d97 100644 --- a/apps/app/electron/main.js +++ b/apps/app/electron/main.js @@ -7,6 +7,7 @@ const path = require("path"); const { spawn } = require("child_process"); +const fs = require("fs"); // Load environment variables from .env file require("dotenv").config({ path: path.join(__dirname, "../.env") }); @@ -30,10 +31,39 @@ function getIconPath() { async function startServer() { const isDev = !app.isPackaged; - // Server entry point - const serverPath = isDev - ? path.join(__dirname, "../../server/dist/index.js") - : path.join(process.resourcesPath, "server", "index.js"); + // Server entry point - use tsx in dev, compiled version in production + let command, args, serverPath; + if (isDev) { + // In development, use tsx to run TypeScript directly + // Use the node executable that's running Electron + command = process.execPath; // This is the path to node.exe + serverPath = path.join(__dirname, "../../server/src/index.ts"); + + // Find tsx CLI - check server node_modules first, then root + const serverNodeModules = path.join(__dirname, "../../server/node_modules/tsx"); + const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx"); + + let tsxCliPath; + if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) { + tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs"); + } else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) { + tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs"); + } else { + // Last resort: try require.resolve + try { + tsxCliPath = require.resolve("tsx/cli.mjs", { paths: [path.join(__dirname, "../../server")] }); + } catch { + throw new Error("Could not find tsx. Please run 'npm install' in the server directory."); + } + } + + args = [tsxCliPath, "watch", serverPath]; + } else { + // In production, use compiled JavaScript + command = "node"; + serverPath = path.join(process.resourcesPath, "server", "index.js"); + args = [serverPath]; + } // Set environment variables for server const env = { @@ -44,7 +74,7 @@ async function startServer() { console.log("[Electron] Starting backend server..."); - serverProcess = spawn("node", [serverPath], { + serverProcess = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"], }); 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/globals.css b/apps/app/src/app/globals.css index 1e522a2b..af1f2f93 100644 --- a/apps/app/src/app/globals.css +++ b/apps/app/src/app/globals.css @@ -1177,12 +1177,12 @@ } /* Custom scrollbar for dark themes */ -:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave) ::-webkit-scrollbar { +:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar { width: 8px; height: 8px; } -:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave) ::-webkit-scrollbar-track { +:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar-track { background: var(--muted); } @@ -1204,6 +1204,20 @@ background: var(--background); } +/* Red theme scrollbar */ +.red ::-webkit-scrollbar-thumb { + background: oklch(0.35 0.15 25); + border-radius: 4px; +} + +.red ::-webkit-scrollbar-thumb:hover { + background: oklch(0.45 0.18 25); +} + +.red ::-webkit-scrollbar-track { + background: oklch(0.15 0.05 25); +} + /* Always visible scrollbar for file diffs and code blocks */ .scrollbar-visible { overflow-y: auto !important; diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 0397f513..06dfd503 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -12,6 +12,8 @@ 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 { WikiView } from "@/components/views/wiki-view"; import { useAppStore } from "@/store/app-store"; import { useSetupStore } from "@/store/setup-store"; import { getElectronAPI, isElectron } from "@/lib/electron"; @@ -206,6 +208,10 @@ function HomeContent() { return ; case "running-agents": return ; + case "terminal": + return ; + case "wiki": + return ; default: return ; } diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index f1efa237..dce4a435 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -32,6 +32,9 @@ import { Bug, Activity, Recycle, + Sparkles, + Loader2, + Terminal, } from "lucide-react"; import { DropdownMenu, @@ -72,7 +75,6 @@ import { hasAutomakerDir, } from "@/lib/project-init"; import { toast } from "sonner"; -import { Sparkles, Loader2 } from "lucide-react"; import { themeOptions } from "@/config/theme-options"; import { Checkbox } from "@/components/ui/checkbox"; import type { SpecRegenerationEvent } from "@/types/electron"; @@ -609,6 +611,12 @@ export function Sidebar() { icon: UserCircle, shortcut: shortcuts.profiles, }, + { + id: "terminal", + label: "Terminal", + icon: Terminal, + shortcut: shortcuts.terminal, + }, ], }, ]; @@ -1239,6 +1247,46 @@ export function Sidebar() {
{/* Course Promo Badge */} + {/* Wiki Link */} +
+ +
{/* Running Agents Link */}
+
+ ); +} + +// 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 lastCreateTimeRef = useRef(0); + const isCreatingRef = useRef(false); + + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation + + // 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 + // targetSessionId: the terminal to split (if splitting an existing terminal) + const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => { + // Debounce: prevent rapid terminal creation + const now = Date.now(); + if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) { + console.log("[Terminal] Debounced terminal creation"); + return; + } + lastCreateTimeRef.current = now; + isCreatingRef.current = true; + + 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); + } else { + console.error("[Terminal] Failed to create session:", data.error); + } + } catch (err) { + console.error("[Terminal] Create session error:", err); + } finally { + isCreatingRef.current = false; + } + }; + + // 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 keyboard shortcuts config + const shortcuts = useKeyboardShortcutsConfig(); + + // Handle terminal-specific keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Only handle shortcuts when terminal is unlocked and has an active session + if (!terminalState.isUnlocked || !terminalState.activeSessionId) return; + + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey; + + // Parse shortcut string to check for match + const matchesShortcut = (shortcutStr: string | undefined) => { + if (!shortcutStr) return false; + const parts = shortcutStr.toLowerCase().split('+'); + const key = parts[parts.length - 1]; + const needsCmd = parts.includes('cmd'); + const needsShift = parts.includes('shift'); + const needsAlt = parts.includes('alt'); + + // Check modifiers + const cmdMatches = needsCmd ? cmdOrCtrl : !cmdOrCtrl; + const shiftMatches = needsShift ? e.shiftKey : !e.shiftKey; + const altMatches = needsAlt ? e.altKey : !e.altKey; + + return ( + e.key.toLowerCase() === key && + cmdMatches && + shiftMatches && + altMatches + ); + }; + + // Split terminal right (Cmd+D / Ctrl+D) + if (matchesShortcut(shortcuts.splitTerminalRight)) { + e.preventDefault(); + createTerminal("horizontal", terminalState.activeSessionId); + return; + } + + // Split terminal down (Cmd+Shift+D / Ctrl+Shift+D) + if (matchesShortcut(shortcuts.splitTerminalDown)) { + e.preventDefault(); + createTerminal("vertical", terminalState.activeSessionId); + return; + } + + // Close terminal (Cmd+W / Ctrl+W) + if (matchesShortcut(shortcuts.closeTerminal)) { + e.preventDefault(); + killTerminal(terminalState.activeSessionId); + return; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [terminalState.isUnlocked, terminalState.activeSessionId, shortcuts]); + + // 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", content.sessionId)} + onSplitVertical={() => createTerminal("vertical", content.sessionId)} + 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..fee8ecf3 --- /dev/null +++ b/apps/app/src/components/views/terminal-view/terminal-panel.tsx @@ -0,0 +1,624 @@ +"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 lastShortcutTimeRef = useRef(0); + const [isTerminalReady, setIsTerminalReady] = useState(false); + const [shellName, setShellName] = useState("shell"); + + // 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 onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const onSplitHorizontalRef = useRef(onSplitHorizontal); + onSplitHorizontalRef.current = onSplitHorizontal; + const onSplitVerticalRef = useRef(onSplitVertical); + onSplitVerticalRef.current = onSplitVertical; + 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(); + }); + + // Custom key handler to intercept terminal shortcuts + // Return false to prevent xterm from handling the key + const SHORTCUT_COOLDOWN_MS = 300; // Prevent rapid firing + + terminal.attachCustomKeyEventHandler((event) => { + // Only intercept keydown events + if (event.type !== 'keydown') return true; + + // Check cooldown to prevent rapid terminal creation + const now = Date.now(); + const canTrigger = now - lastShortcutTimeRef.current > SHORTCUT_COOLDOWN_MS; + + // Use event.code for keyboard-layout-independent key detection + const code = event.code; + + // Alt+D - Split right + if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyD') { + event.preventDefault(); + if (canTrigger) { + lastShortcutTimeRef.current = now; + onSplitHorizontalRef.current(); + } + return false; + } + + // Alt+S - Split down + if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyS') { + event.preventDefault(); + if (canTrigger) { + lastShortcutTimeRef.current = now; + onSplitVerticalRef.current(); + } + return false; + } + + // Alt+W - Close terminal + if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyW') { + event.preventDefault(); + if (canTrigger) { + lastShortcutTimeRef.current = now; + onCloseRef.current(); + } + return false; + } + + // Let xterm handle all other keys + return true; + }); + }; + + 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}`); + if (msg.shell) { + // Extract shell name from path (e.g., "/bin/bash" -> "bash") + const name = msg.shell.split("/").pop() || msg.shell; + setShellName(name); + } + 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 */} +
+ + + {shellName} + + {/* 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/components/views/wiki-view.tsx b/apps/app/src/components/views/wiki-view.tsx new file mode 100644 index 00000000..51aa825d --- /dev/null +++ b/apps/app/src/components/views/wiki-view.tsx @@ -0,0 +1,479 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { + ChevronDown, + ChevronRight, + Rocket, + Layers, + Sparkles, + GitBranch, + FolderTree, + Component, + Settings, + PlayCircle, + Bot, + LayoutGrid, + FileText, + Terminal, + Palette, + Keyboard, + Cpu, + Zap, + Image, + TestTube, + Brain, + Users, +} from "lucide-react"; + +interface WikiSection { + id: string; + title: string; + icon: React.ElementType; + content: React.ReactNode; +} + +function CollapsibleSection({ + section, + isOpen, + onToggle, +}: { + section: WikiSection; + isOpen: boolean; + onToggle: () => void; +}) { + const Icon = section.icon; + + return ( +
+ + {isOpen && ( +
+
+ {section.content} +
+
+ )} +
+ ); +} + +function CodeBlock({ children, title }: { children: string; title?: string }) { + return ( +
+ {title && ( +
+ {title} +
+ )} +
+        {children}
+      
+
+ ); +} + +function FeatureList({ items }: { items: { icon: React.ElementType; title: string; description: string }[] }) { + return ( +
+ {items.map((item, index) => { + const ItemIcon = item.icon; + return ( +
+
+ +
+
+
{item.title}
+
{item.description}
+
+
+ ); + })} +
+ ); +} + +export function WikiView() { + const [openSections, setOpenSections] = useState>(new Set(["overview"])); + + const toggleSection = (id: string) => { + setOpenSections((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }; + + const expandAll = () => { + setOpenSections(new Set(sections.map((s) => s.id))); + }; + + const collapseAll = () => { + setOpenSections(new Set()); + }; + + const sections: WikiSection[] = [ + { + id: "overview", + title: "Project Overview", + icon: Rocket, + content: ( +
+

+ Automaker is an autonomous AI development studio that helps developers build software faster using AI agents. +

+

+ At its core, Automaker provides a visual Kanban board to manage features. When you're ready, AI agents automatically implement those features in your codebase, complete with git worktree isolation for safe parallel development. +

+
+

+ Think of it as having a team of AI developers that can work on multiple features simultaneously while you focus on the bigger picture. +

+
+
+ ), + }, + { + id: "architecture", + title: "Architecture", + icon: Layers, + content: ( +
+

Automaker is built as a monorepo with two main applications:

+
    +
  • + apps/app - Next.js + Electron frontend for the desktop application +
  • +
  • + apps/server - Express backend handling API requests and agent orchestration +
  • +
+
+

Key Technologies:

+
    +
  • Electron wraps Next.js for cross-platform desktop support
  • +
  • Real-time communication via WebSocket for live agent updates
  • +
  • State management with Zustand for reactive UI updates
  • +
  • Claude Agent SDK for AI capabilities
  • +
+
+
+ ), + }, + { + id: "features", + title: "Key Features", + icon: Sparkles, + content: ( +
+ +
+ ), + }, + { + id: "data-flow", + title: "How It Works (Data Flow)", + icon: GitBranch, + content: ( +
+

Here's what happens when you use Automaker to implement a feature:

+
    +
  1. + Create Feature +

    Add a new feature card to the Kanban board with description and steps

    +
  2. +
  3. + Feature Saved +

    Feature saved to .automaker/features/{id}/feature.json

    +
  4. +
  5. + Start Work +

    Drag to "In Progress" or enable auto mode to start implementation

    +
  6. +
  7. + Git Worktree Created +

    Backend AutoModeService creates isolated git worktree (if enabled)

    +
  8. +
  9. + Agent Executes +

    Claude Agent SDK runs with file/bash/git tool access

    +
  10. +
  11. + Progress Streamed +

    Real-time updates via WebSocket as agent works

    +
  12. +
  13. + Completion +

    On success, feature moves to "waiting_approval" for your review

    +
  14. +
  15. + Verify +

    Review changes and move to "verified" when satisfied

    +
  16. +
+
+ ), + }, + { + id: "structure", + title: "Project Structure", + icon: FolderTree, + content: ( +
+

The Automaker codebase is organized as follows:

+ +{`/automaker/ +├── apps/ +│ ├── app/ # Frontend (Next.js + Electron) +│ │ ├── electron/ # Electron main process +│ │ └── src/ +│ │ ├── app/ # Next.js App Router pages +│ │ ├── components/ # React components +│ │ ├── store/ # Zustand state management +│ │ ├── hooks/ # Custom React hooks +│ │ └── lib/ # Utilities and helpers +│ └── server/ # Backend (Express) +│ └── src/ +│ ├── routes/ # API endpoints +│ └── services/ # Business logic (AutoModeService, etc.) +├── docs/ # Documentation +└── package.json # Workspace root`} + +
+ ), + }, + { + id: "components", + title: "Key Components", + icon: Component, + content: ( +
+

The main UI components that make up Automaker:

+
+ {[ + { file: "sidebar.tsx", desc: "Main navigation with project picker and view switching" }, + { file: "board-view.tsx", desc: "Kanban board with drag-and-drop cards" }, + { file: "agent-view.tsx", desc: "AI chat interface for conversational development" }, + { file: "spec-view.tsx", desc: "Project specification editor" }, + { file: "context-view.tsx", desc: "Context file manager for AI context" }, + { file: "terminal-view.tsx", desc: "Integrated terminal with splits and tabs" }, + { file: "profiles-view.tsx", desc: "AI profile management (model + thinking presets)" }, + { file: "app-store.ts", desc: "Central Zustand state management" }, + ].map((item) => ( +
+ {item.file} + {item.desc} +
+ ))} +
+
+ ), + }, + { + id: "configuration", + title: "Configuration", + icon: Settings, + content: ( +
+

Automaker stores project configuration in the .automaker/ directory:

+
+ {[ + { file: "app_spec.txt", desc: "Project specification describing your app for AI context" }, + { file: "context/", desc: "Additional context files (docs, examples) for AI" }, + { file: "features/", desc: "Feature definitions with descriptions and steps" }, + ].map((item) => ( +
+ {item.file} + {item.desc} +
+ ))} +
+
+

Tip: App Spec Best Practices

+
    +
  • Include your tech stack and key dependencies
  • +
  • Describe the project structure and conventions
  • +
  • List any important patterns or architectural decisions
  • +
  • Note testing requirements and coding standards
  • +
+
+
+ ), + }, + { + id: "getting-started", + title: "Getting Started", + icon: PlayCircle, + content: ( +
+

Follow these steps to start building with Automaker:

+
    +
  1. + Create or Open a Project +

    Use the sidebar to create a new project or open an existing folder

    +
  2. +
  3. + Write an App Spec +

    Go to Spec Editor and describe your project. This helps AI understand your codebase.

    +
  4. +
  5. + Add Context (Optional) +

    Add relevant documentation or examples to the Context view for better AI results

    +
  6. +
  7. + Create Features +

    Add feature cards to your Kanban board with clear descriptions and implementation steps

    +
  8. +
  9. + Configure AI Profile +

    Choose an AI profile or customize model/thinking settings per feature

    +
  10. +
  11. + Start Implementation +

    Drag features to "In Progress" or enable auto mode to let AI work

    +
  12. +
  13. + Review and Verify +

    Check completed features, review changes, and mark as verified

    +
  14. +
+
+

Pro Tips:

+
    +
  • Use keyboard shortcuts for faster navigation (press ? to see all)
  • +
  • Enable git worktree isolation for parallel feature development
  • +
  • Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work
  • +
  • Keep your app spec up to date as your project evolves
  • +
+
+
+ ), + }, + ]; + + return ( +
+ {/* Header */} +
+
+
+

Wiki

+

+ Learn how Automaker works and how to use it effectively +

+
+
+ + +
+
+
+ + {/* Content */} +
+
+ {sections.map((section) => ( + toggleSection(section.id)} + /> + ))} +
+
+
+ ); +} diff --git a/apps/app/src/config/terminal-themes.ts b/apps/app/src/config/terminal-themes.ts new file mode 100644 index 00000000..c10cea5c --- /dev/null +++ b/apps/app/src/config/terminal-themes.ts @@ -0,0 +1,393 @@ +/** + * 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", +}; + +// Red theme - Dark theme with red accents +const redTheme: TerminalTheme = { + background: "#1a0a0a", + foreground: "#c8b0b0", + cursor: "#ff4444", + cursorAccent: "#1a0a0a", + selectionBackground: "#5a2020", + black: "#2a1010", + red: "#ff4444", + green: "#6a9a6a", + yellow: "#ccaa55", + blue: "#6688aa", + magenta: "#aa5588", + cyan: "#558888", + white: "#b0a0a0", + brightBlack: "#6a4040", + brightRed: "#ff6666", + brightGreen: "#88bb88", + brightYellow: "#ddbb66", + brightBlue: "#88aacc", + brightMagenta: "#cc77aa", + brightCyan: "#77aaaa", + brightWhite: "#d0c0c0", +}; + +// 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, + red: redTheme, +}; + +/** + * 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 07d98969..1ad27604 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -12,7 +12,9 @@ export type ViewMode = | "interview" | "context" | "profiles" - | "running-agents"; + | "running-agents" + | "terminal" + | "wiki"; export type ThemeMode = | "light" @@ -47,7 +49,8 @@ export interface ShortcutKey { } // Helper to parse shortcut string to ShortcutKey object -export function parseShortcut(shortcut: string): ShortcutKey { +export function parseShortcut(shortcut: string | undefined | null): ShortcutKey { + if (!shortcut) return { key: "" }; const parts = shortcut.split("+").map((p) => p.trim()); const result: ShortcutKey = { key: parts[parts.length - 1] }; @@ -79,7 +82,8 @@ export function parseShortcut(shortcut: string): ShortcutKey { } // Helper to format ShortcutKey to display string -export function formatShortcut(shortcut: string, forDisplay = false): string { +export function formatShortcut(shortcut: string | undefined | null, forDisplay = false): string { + if (!shortcut) return ""; const parsed = parseShortcut(shortcut); const parts: string[] = []; @@ -144,6 +148,7 @@ export interface KeyboardShortcuts { context: string; settings: string; profiles: string; + terminal: string; // UI shortcuts toggleSidebar: string; @@ -158,6 +163,11 @@ export interface KeyboardShortcuts { cyclePrevProject: string; cycleNextProject: string; addProfile: string; + + // Terminal shortcuts + splitTerminalRight: string; + splitTerminalDown: string; + closeTerminal: string; } // Default keyboard shortcuts @@ -169,6 +179,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { context: "C", settings: "S", profiles: "M", + terminal: "Cmd+`", // UI toggleSidebar: "`", @@ -185,6 +196,12 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { cyclePrevProject: "Q", // Global shortcut cycleNextProject: "E", // Global shortcut addProfile: "N", // Only active in profiles view + + // Terminal shortcuts (only active in terminal view) + // Using Alt modifier to avoid conflicts with both terminal signals AND browser shortcuts + splitTerminalRight: "Alt+D", + splitTerminalDown: "Alt+S", + closeTerminal: "Alt+W", }; export interface ImageAttachment { @@ -297,6 +314,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[]; size?: number }; + +// 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[]; @@ -386,6 +424,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 @@ -565,6 +606,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", targetSessionId?: string) => 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; } @@ -670,6 +726,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()( @@ -1483,6 +1547,464 @@ 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", targetSessionId) => { + 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; + + // If targetSessionId is provided, find and split that specific terminal + const splitTargetTerminal = ( + node: TerminalPanelContent, + targetId: string, + targetDirection: "horizontal" | "vertical" + ): TerminalPanelContent => { + if (node.type === "terminal") { + if (node.sessionId === targetId) { + // Found the target - split it + return { + type: "split", + direction: targetDirection, + panels: [{ ...node, size: 50 }, newTerminal], + }; + } + // Not the target, return unchanged + return node; + } + // It's a split - recurse into panels + return { + ...node, + panels: node.panels.map(p => splitTargetTerminal(p, targetId, targetDirection)), + }; + }; + + // Legacy behavior: add to root layout (when no targetSessionId) + const addToRootLayout = ( + 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], + }; + }; + + let newLayout: TerminalPanelContent; + if (!activeTab.layout) { + newLayout = { type: "terminal", sessionId, size: 100 }; + } else if (targetSessionId) { + newLayout = splitTargetTerminal(activeTab.layout, targetSessionId, direction); + } else { + newLayout = addToRootLayout(activeTab.layout, direction); + } + + 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 | null): string | null => { + if (!node) return 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) + : 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 e9cf96dd..2b3bc433 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -47,3 +47,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= diff --git a/apps/server/package.json b/apps/server/package.json index 8c712d4d..239fe558 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,6 +22,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "node-pty": "1.1.0-beta41", "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/spec-regeneration.ts b/apps/server/src/routes/spec-regeneration.ts index 67df6c5b..f33c126c 100644 --- a/apps/server/src/routes/spec-regeneration.ts +++ b/apps/server/src/routes/spec-regeneration.ts @@ -355,6 +355,9 @@ 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 as { type: string }).type === "error") { + console.error("[SpecRegeneration] ❌ Received error message from stream:"); + console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2)); } } } catch (streamError) { @@ -502,6 +505,9 @@ 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 as { type: string }).type === "error") { + console.error("[SpecRegeneration] ❌ Received error message from feature stream:"); + console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2)); } } } catch (streamError) { 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..b05d987c --- /dev/null +++ b/apps/server/src/services/terminal-service.ts @@ -0,0 +1,401 @@ +/** + * 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 = 50000; // ~50KB per terminal + +// Throttle output to prevent overwhelming WebSocket under heavy load +const OUTPUT_THROTTLE_MS = 16; // ~60fps max update rate +const OUTPUT_BATCH_SIZE = 8192; // Max bytes to send per batch + +export interface TerminalSession { + id: string; + pty: pty.IPty; + cwd: string; + createdAt: Date; + shell: string; + scrollbackBuffer: string; // Store recent output for replay on reconnect + outputBuffer: string; // Pending output to be flushed + flushTimeout: NodeJS.Timeout | null; // Throttle timer +} + +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: "", + outputBuffer: "", + flushTimeout: null, + }; + + this.sessions.set(id, session); + + // Flush buffered output to clients (throttled) + const flushOutput = () => { + if (session.outputBuffer.length === 0) return; + + // Send in batches if buffer is large + let dataToSend = session.outputBuffer; + if (dataToSend.length > OUTPUT_BATCH_SIZE) { + dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); + session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE); + // Schedule another flush for remaining data + session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS); + } else { + session.outputBuffer = ""; + session.flushTimeout = null; + } + + this.dataCallbacks.forEach((cb) => cb(id, dataToSend)); + this.emit("data", id, dataToSend); + }; + + // Forward data events with throttling + 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); + } + + // Buffer output for throttled delivery + session.outputBuffer += data; + + // Schedule flush if not already scheduled + if (!session.flushTimeout) { + session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS); + } + }); + + // 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 { + // Clean up flush timeout + if (session.flushTimeout) { + clearTimeout(session.flushTimeout); + session.flushTimeout = null; + } + 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 { + // Clean up flush timeout + if (session.flushTimeout) { + clearTimeout(session.flushTimeout); + } + 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/docs/terminal.md b/docs/terminal.md new file mode 100644 index 00000000..38dece63 --- /dev/null +++ b/docs/terminal.md @@ -0,0 +1,93 @@ +# Terminal + +The integrated terminal provides a full-featured terminal emulator within Automaker, powered by xterm.js. + +## Configuration + +Configure the terminal via environment variables in `apps/server/.env`: + +### Disable Terminal Completely +``` +TERMINAL_ENABLED=false +``` +Set to `false` to completely disable the terminal feature. + +### Password Protection +``` +TERMINAL_PASSWORD=yourpassword +``` +By default, the terminal is **not password protected**. Add this variable to require a password. + +When password protection is enabled: +- Enter the password in **Settings > Terminal** to unlock +- The terminal remains unlocked for the session +- You can toggle password requirement on/off in settings after unlocking + +## Keyboard Shortcuts + +When the terminal is focused, the following shortcuts are available: + +| Shortcut | Action | +|----------|--------| +| `Alt+D` | Split terminal right (horizontal split) | +| `Alt+S` | Split terminal down (vertical split) | +| `Alt+W` | Close current terminal | + +Global shortcut (works anywhere in the app): +| Shortcut | Action | +|----------|--------| +| `Cmd+`` (Mac) / `Ctrl+`` (Windows/Linux) | Toggle terminal view | + +## Features + +### Multiple Terminals +- Create multiple terminal tabs using the `+` button +- Split terminals horizontally or vertically within a tab +- Drag terminals to rearrange them + +### Theming +The terminal automatically matches your app theme. Supported themes include: +- Light / Dark / System +- Retro, Dracula, Nord, Monokai +- Tokyo Night, Solarized, Gruvbox +- Catppuccin, One Dark, Synthwave, Red + +### Font Size +- Use the zoom controls (`+`/`-` buttons) in each terminal panel +- Or use `Cmd/Ctrl + Scroll` to zoom + +### Scrollback +- The terminal maintains a scrollback buffer of recent output +- Scroll up to view previous output +- Output is preserved when reconnecting + +## Architecture + +The terminal uses a client-server architecture: + +1. **Frontend** (`apps/app`): xterm.js terminal emulator with WebGL rendering +2. **Backend** (`apps/server`): node-pty for PTY (pseudo-terminal) sessions + +Communication happens over WebSocket for real-time bidirectional data flow. + +### Shell Detection + +The server automatically detects the best shell: +- **WSL**: User's shell or `/bin/bash` +- **macOS**: User's shell, zsh, or bash +- **Linux**: User's shell, bash, or sh +- **Windows**: PowerShell 7, PowerShell, or cmd.exe + +## Troubleshooting + +### Terminal not connecting +1. Ensure the server is running (`npm run dev:server`) +2. Check that port 3008 is available +3. Verify the terminal is unlocked + +### Slow performance with heavy output +The terminal throttles output at ~60fps to prevent UI lockup. Very fast output (like `cat` on large files) will be batched. + +### Shortcuts not working +- Ensure the terminal is focused (click inside it) +- Some system shortcuts may conflict (especially Alt+Shift combinations on Windows) diff --git a/package-lock.json b/package-lock.json index a8c3184c..3268efa5 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.1.0-beta41", "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", @@ -13517,6 +13546,22 @@ } } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-pty": { + "version": "1.1.0-beta41", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta41.tgz", + "integrity": "sha512-OUT29KMnzh1IS0b2YcUwVz56D4iAXDsl2PtIKP3zHMljiUBq2WcaHEFfhzQfgkhWs2SExcXvfdlBPANDVU9SnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.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",