From 04ccd6f81c98814900bb164e3bb25528bb57e2a3 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 00:57:28 -0500 Subject: [PATCH 01/22] feat: add integrated terminal with tab system and theme support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add terminal view with draggable split panels and multi-tab support - Implement terminal WebSocket server with password protection - Add per-terminal font size that persists when moving between tabs - Support all 12 app themes with matching terminal colors - Add keyboard shortcut (Ctrl+`) to toggle terminal view - Include scrollback buffer for session history on reconnect 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/package.json | 4 + apps/app/src/app/page.tsx | 3 + apps/app/src/components/layout/sidebar.tsx | 6 + apps/app/src/components/ui/keyboard-map.tsx | 8 + .../src/components/views/terminal-view.tsx | 621 ++++++++++++++++++ .../views/terminal-view/terminal-panel.tsx | 562 ++++++++++++++++ apps/app/src/config/terminal-themes.ts | 367 +++++++++++ apps/app/src/hooks/use-keyboard-shortcuts.ts | 12 + apps/app/src/store/app-store.ts | 476 +++++++++++++- apps/app/src/types/css.d.ts | 9 + apps/server/.env.example | 11 + apps/server/package.json | 1 + apps/server/src/index.ts | 167 ++++- apps/server/src/routes/fs.ts | 1 - apps/server/src/routes/spec-regeneration.ts | 4 +- apps/server/src/routes/terminal.ts | 312 +++++++++ apps/server/src/services/terminal-service.ts | 359 ++++++++++ package-lock.json | 55 ++ 18 files changed, 2972 insertions(+), 6 deletions(-) create mode 100644 apps/app/src/components/views/terminal-view.tsx create mode 100644 apps/app/src/components/views/terminal-view/terminal-panel.tsx create mode 100644 apps/app/src/config/terminal-themes.ts create mode 100644 apps/app/src/types/css.d.ts create mode 100644 apps/server/src/routes/terminal.ts create mode 100644 apps/server/src/services/terminal-service.ts diff --git a/apps/app/package.json b/apps/app/package.json index 9c973c0e..98a8b0b4 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -42,6 +42,9 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -52,6 +55,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-markdown": "^10.1.0", + "react-resizable-panels": "^3.0.6", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 0397f513..2fb46f87 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -12,6 +12,7 @@ import { ContextView } from "@/components/views/context-view"; import { ProfilesView } from "@/components/views/profiles-view"; import { SetupView } from "@/components/views/setup-view"; import { RunningAgentsView } from "@/components/views/running-agents-view"; +import { TerminalView } from "@/components/views/terminal-view"; import { useAppStore } from "@/store/app-store"; import { useSetupStore } from "@/store/setup-store"; import { getElectronAPI, isElectron } from "@/lib/electron"; @@ -206,6 +207,8 @@ function HomeContent() { return ; case "running-agents": return ; + case "terminal": + return ; default: return ; } diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index f1efa237..d8453b2d 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -609,6 +609,12 @@ export function Sidebar() { icon: UserCircle, shortcut: shortcuts.profiles, }, + { + id: "terminal", + label: "Terminal", + icon: Terminal, + shortcut: shortcuts.terminal, + }, ], }, ]; diff --git a/apps/app/src/components/ui/keyboard-map.tsx b/apps/app/src/components/ui/keyboard-map.tsx index 5ae2c4f5..73196e70 100644 --- a/apps/app/src/components/ui/keyboard-map.tsx +++ b/apps/app/src/components/ui/keyboard-map.tsx @@ -90,6 +90,7 @@ const SHORTCUT_LABELS: Record = { context: "Context", settings: "Settings", profiles: "AI Profiles", + terminal: "Terminal", toggleSidebar: "Toggle Sidebar", addFeature: "Add Feature", addContextFile: "Add Context File", @@ -100,6 +101,9 @@ const SHORTCUT_LABELS: Record = { cyclePrevProject: "Prev Project", cycleNextProject: "Next Project", addProfile: "Add Profile", + splitTerminalRight: "Split Right", + splitTerminalDown: "Split Down", + closeTerminal: "Close Terminal", }; // Categorize shortcuts for color coding @@ -110,6 +114,7 @@ const SHORTCUT_CATEGORIES: Record void; + onClose: () => void; + isDropTarget: boolean; +}) { + const { setNodeRef, isOver } = useDroppable({ + id: `tab-${tab.id}`, + data: { type: "tab", tabId: tab.id }, + }); + + return ( +
+ + {tab.name} + +
+ ); +} + +// New tab drop zone +function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) { + const { setNodeRef, isOver } = useDroppable({ + id: "new-tab-zone", + data: { type: "new-tab" }, + }); + + return ( +
+ +
+ ); +} + +export function TerminalView() { + const { + terminalState, + setTerminalUnlocked, + addTerminalToLayout, + removeTerminalFromLayout, + setActiveTerminalSession, + swapTerminals, + currentProject, + addTerminalTab, + removeTerminalTab, + setActiveTerminalTab, + moveTerminalToTab, + setTerminalPanelFontSize, + } = useAppStore(); + + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [password, setPassword] = useState(""); + const [authLoading, setAuthLoading] = useState(false); + const [authError, setAuthError] = useState(null); + const [activeDragId, setActiveDragId] = useState(null); + const [dragOverTabId, setDragOverTabId] = useState(null); + + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + + // Get active tab + const activeTab = terminalState.tabs.find(t => t.id === terminalState.activeTabId); + + // DnD sensors with activation constraint to avoid accidental drags + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + // Handle drag start + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveDragId(event.active.id as string); + }, []); + + // Handle drag over - track which tab we're hovering + const handleDragOver = useCallback((event: DragOverEvent) => { + const { over } = event; + if (over?.data?.current?.type === "tab") { + setDragOverTabId(over.data.current.tabId); + } else if (over?.data?.current?.type === "new-tab") { + setDragOverTabId("new"); + } else { + setDragOverTabId(null); + } + }, []); + + // Handle drag end + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + setActiveDragId(null); + setDragOverTabId(null); + + if (!over) return; + + const activeId = active.id as string; + const overData = over.data?.current; + + // If dropped on a tab, move terminal to that tab + if (overData?.type === "tab") { + moveTerminalToTab(activeId, overData.tabId); + return; + } + + // If dropped on new tab zone, create new tab with this terminal + if (overData?.type === "new-tab") { + moveTerminalToTab(activeId, "new"); + return; + } + + // Otherwise, swap terminals within current tab + if (active.id !== over.id) { + swapTerminals(activeId, over.id as string); + } + }, [swapTerminals, moveTerminalToTab]); + + // Fetch terminal status + const fetchStatus = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await fetch(`${serverUrl}/api/terminal/status`); + const data = await response.json(); + if (data.success) { + setStatus(data.data); + if (!data.data.passwordRequired) { + setTerminalUnlocked(true); + } + } else { + setError(data.error || "Failed to get terminal status"); + } + } catch (err) { + setError("Failed to connect to server"); + console.error("[Terminal] Status fetch error:", err); + } finally { + setLoading(false); + } + }, [serverUrl, setTerminalUnlocked]); + + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); + + // Handle password authentication + const handleAuth = async (e: React.FormEvent) => { + e.preventDefault(); + setAuthLoading(true); + setAuthError(null); + + try { + const response = await fetch(`${serverUrl}/api/terminal/auth`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + const data = await response.json(); + + if (data.success) { + setTerminalUnlocked(true, data.data.token); + setPassword(""); + } else { + setAuthError(data.error || "Authentication failed"); + } + } catch (err) { + setAuthError("Failed to authenticate"); + console.error("[Terminal] Auth error:", err); + } finally { + setAuthLoading(false); + } + }; + + // Create a new terminal session + const createTerminal = async (direction?: "horizontal" | "vertical") => { + try { + const headers: Record = { + "Content-Type": "application/json", + }; + if (terminalState.authToken) { + headers["X-Terminal-Token"] = terminalState.authToken; + } + + const response = await fetch(`${serverUrl}/api/terminal/sessions`, { + method: "POST", + headers, + body: JSON.stringify({ + cwd: currentProject?.path || undefined, + cols: 80, + rows: 24, + }), + }); + const data = await response.json(); + + if (data.success) { + addTerminalToLayout(data.data.id, direction); + } else { + console.error("[Terminal] Failed to create session:", data.error); + } + } catch (err) { + console.error("[Terminal] Create session error:", err); + } + }; + + // Create terminal in new tab + const createTerminalInNewTab = async () => { + const tabId = addTerminalTab(); + try { + const headers: Record = { + "Content-Type": "application/json", + }; + if (terminalState.authToken) { + headers["X-Terminal-Token"] = terminalState.authToken; + } + + const response = await fetch(`${serverUrl}/api/terminal/sessions`, { + method: "POST", + headers, + body: JSON.stringify({ + cwd: currentProject?.path || undefined, + cols: 80, + rows: 24, + }), + }); + const data = await response.json(); + + if (data.success) { + // Add to the newly created tab + const { addTerminalToTab } = useAppStore.getState(); + addTerminalToTab(data.data.id, tabId); + } + } catch (err) { + console.error("[Terminal] Create session error:", err); + } + }; + + // Kill a terminal session + const killTerminal = async (sessionId: string) => { + try { + const headers: Record = {}; + if (terminalState.authToken) { + headers["X-Terminal-Token"] = terminalState.authToken; + } + + await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { + method: "DELETE", + headers, + }); + removeTerminalFromLayout(sessionId); + } catch (err) { + console.error("[Terminal] Kill session error:", err); + } + }; + + // Get a stable key for a panel + const getPanelKey = (panel: TerminalPanelContent): string => { + if (panel.type === "terminal") { + return panel.sessionId; + } + return `split-${panel.direction}-${panel.panels.map(getPanelKey).join("-")}`; + }; + + // Render panel content recursively + const renderPanelContent = (content: TerminalPanelContent): React.ReactNode => { + if (content.type === "terminal") { + // Use per-terminal fontSize or fall back to default + const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize; + return ( + setActiveTerminalSession(content.sessionId)} + onClose={() => killTerminal(content.sessionId)} + onSplitHorizontal={() => createTerminal("horizontal")} + onSplitVertical={() => createTerminal("vertical")} + isDragging={activeDragId === content.sessionId} + isDropTarget={activeDragId !== null && activeDragId !== content.sessionId} + fontSize={terminalFontSize} + onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)} + /> + ); + } + + const isHorizontal = content.direction === "horizontal"; + const defaultSizePerPanel = 100 / content.panels.length; + + return ( + + {content.panels.map((panel, index) => { + const panelSize = panel.type === "terminal" && panel.size + ? panel.size + : defaultSizePerPanel; + + return ( + + {index > 0 && ( + + )} + + {renderPanelContent(panel)} + + + ); + })} + + ); + }; + + // Loading state + if (loading) { + return ( +
+ +
+ ); + } + + // Error state + if (error) { + return ( +
+
+ +
+

Terminal Unavailable

+

{error}

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

Terminal Disabled

+

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

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

Terminal Protected

+

+ Terminal access requires authentication. Enter the password to unlock. +

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

{authError}

+ )} + +
+ + {status.platform && ( +

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

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

Terminal

+

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

+ + + + {status?.platform && ( +

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

+ )} +
+ ); + } + + // Terminal view with tabs + return ( + +
+ {/* Tab bar */} +
+ {/* Tabs */} +
+ {terminalState.tabs.map((tab) => ( + setActiveTerminalTab(tab.id)} + onClose={() => removeTerminalTab(tab.id)} + isDropTarget={activeDragId !== null} + /> + ))} + + {/* New tab drop zone (visible when dragging) */} + {activeDragId && ( + + )} + + {/* New tab button */} + +
+ + {/* Toolbar buttons */} +
+ + +
+
+ + {/* Active tab content */} +
+ {activeTab?.layout ? ( + renderPanelContent(activeTab.layout) + ) : ( +
+

This tab is empty

+ +
+ )} +
+
+ + {/* Drag overlay */} + + {activeDragId ? ( +
+ + + {dragOverTabId === "new" + ? "New tab" + : dragOverTabId + ? "Move to tab" + : "Terminal"} + +
+ ) : null} +
+
+ ); +} diff --git a/apps/app/src/components/views/terminal-view/terminal-panel.tsx b/apps/app/src/components/views/terminal-view/terminal-panel.tsx new file mode 100644 index 00000000..eb0f8eb8 --- /dev/null +++ b/apps/app/src/components/views/terminal-view/terminal-panel.tsx @@ -0,0 +1,562 @@ +"use client"; + +import { useEffect, useRef, useCallback, useState } from "react"; +import { + X, + SplitSquareHorizontal, + SplitSquareVertical, + GripHorizontal, + Terminal, + ZoomIn, + ZoomOut, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useDraggable, useDroppable } from "@dnd-kit/core"; +import { useAppStore } from "@/store/app-store"; +import { getTerminalTheme } from "@/config/terminal-themes"; + +// Font size constraints +const MIN_FONT_SIZE = 8; +const MAX_FONT_SIZE = 32; +const DEFAULT_FONT_SIZE = 14; + +interface TerminalPanelProps { + sessionId: string; + authToken: string | null; + isActive: boolean; + onFocus: () => void; + onClose: () => void; + onSplitHorizontal: () => void; + onSplitVertical: () => void; + isDragging?: boolean; + isDropTarget?: boolean; + fontSize: number; + onFontSizeChange: (size: number) => void; +} + +// Type for xterm Terminal - we'll use any since we're dynamically importing +type XTerminal = InstanceType; +type XFitAddon = InstanceType; + +export function TerminalPanel({ + sessionId, + authToken, + isActive, + onFocus, + onClose, + onSplitHorizontal, + onSplitVertical, + isDragging = false, + isDropTarget = false, + fontSize, + onFontSizeChange, +}: TerminalPanelProps) { + const terminalRef = useRef(null); + const containerRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const [isTerminalReady, setIsTerminalReady] = useState(false); + + // Get effective theme from store + const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme); + const effectiveTheme = getEffectiveTheme(); + + // Use refs for callbacks and values to avoid effect re-runs + const onFocusRef = useRef(onFocus); + onFocusRef.current = onFocus; + const fontSizeRef = useRef(fontSize); + fontSizeRef.current = fontSize; + const themeRef = useRef(effectiveTheme); + themeRef.current = effectiveTheme; + + // Zoom functions - use the prop callback + const zoomIn = useCallback(() => { + onFontSizeChange(Math.min(fontSize + 1, MAX_FONT_SIZE)); + }, [fontSize, onFontSizeChange]); + + const zoomOut = useCallback(() => { + onFontSizeChange(Math.max(fontSize - 1, MIN_FONT_SIZE)); + }, [fontSize, onFontSizeChange]); + + const resetZoom = useCallback(() => { + onFontSizeChange(DEFAULT_FONT_SIZE); + }, [onFontSizeChange]); + + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + const wsUrl = serverUrl.replace(/^http/, "ws"); + + // Draggable - only the drag handle triggers drag + const { + attributes: dragAttributes, + listeners: dragListeners, + setNodeRef: setDragRef, + } = useDraggable({ + id: sessionId, + }); + + // Droppable - the entire panel is a drop target + const { + setNodeRef: setDropRef, + isOver, + } = useDroppable({ + id: sessionId, + }); + + // Initialize terminal - dynamically import xterm to avoid SSR issues + useEffect(() => { + if (!terminalRef.current) return; + + let mounted = true; + + const initTerminal = async () => { + // Dynamically import xterm modules + const [ + { Terminal }, + { FitAddon }, + { WebglAddon }, + ] = await Promise.all([ + import("@xterm/xterm"), + import("@xterm/addon-fit"), + import("@xterm/addon-webgl"), + ]); + + // Also import CSS + await import("@xterm/xterm/css/xterm.css"); + + if (!mounted || !terminalRef.current) return; + + // Get terminal theme matching the app theme + const terminalTheme = getTerminalTheme(themeRef.current); + + // Create terminal instance with the current global font size and theme + const terminal = new Terminal({ + cursorBlink: true, + cursorStyle: "block", + fontSize: fontSizeRef.current, + fontFamily: "Menlo, Monaco, 'Courier New', monospace", + theme: terminalTheme, + allowProposedApi: true, + }); + + // Create fit addon + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + + // Open terminal + terminal.open(terminalRef.current); + + // Try to load WebGL addon for better performance + try { + const webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + webglAddon.dispose(); + }); + terminal.loadAddon(webglAddon); + } catch { + console.warn("[Terminal] WebGL addon not available, falling back to canvas"); + } + + // Fit terminal to container + setTimeout(() => { + fitAddon.fit(); + }, 0); + + xtermRef.current = terminal; + fitAddonRef.current = fitAddon; + setIsTerminalReady(true); + + // Handle focus - use ref to avoid re-running effect + terminal.onData(() => { + onFocusRef.current(); + }); + }; + + initTerminal(); + + // Cleanup + return () => { + mounted = false; + if (xtermRef.current) { + xtermRef.current.dispose(); + xtermRef.current = null; + } + fitAddonRef.current = null; + setIsTerminalReady(false); + }; + }, []); // No dependencies - only run once on mount + + // Connect WebSocket - wait for terminal to be ready + useEffect(() => { + if (!isTerminalReady || !sessionId) return; + const terminal = xtermRef.current; + if (!terminal) return; + + const connect = () => { + // Build WebSocket URL with token + let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`; + if (authToken) { + url += `&token=${encodeURIComponent(authToken)}`; + } + + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + console.log(`[Terminal] WebSocket connected for session ${sessionId}`); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + switch (msg.type) { + case "data": + terminal.write(msg.data); + break; + case "scrollback": + // Replay scrollback buffer (previous terminal output) + if (msg.data) { + terminal.write(msg.data); + } + break; + case "connected": + console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`); + break; + case "exit": + terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`); + break; + case "pong": + // Heartbeat response + break; + } + } catch (err) { + console.error("[Terminal] Message parse error:", err); + } + }; + + ws.onclose = (event) => { + console.log(`[Terminal] WebSocket closed for session ${sessionId}:`, event.code, event.reason); + wsRef.current = null; + + // Don't reconnect if closed normally or auth failed + if (event.code === 1000 || event.code === 4001 || event.code === 4003) { + return; + } + + // Attempt reconnect after a delay + reconnectTimeoutRef.current = setTimeout(() => { + if (xtermRef.current) { + console.log(`[Terminal] Attempting reconnect for session ${sessionId}`); + connect(); + } + }, 2000); + }; + + ws.onerror = (error) => { + console.error(`[Terminal] WebSocket error for session ${sessionId}:`, error); + }; + }; + + connect(); + + // Handle terminal input + const dataHandler = terminal.onData((data) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "input", data })); + } + }); + + // Cleanup + return () => { + dataHandler.dispose(); + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [sessionId, authToken, wsUrl, isTerminalReady]); + + // Handle resize + const handleResize = useCallback(() => { + if (fitAddonRef.current && xtermRef.current) { + fitAddonRef.current.fit(); + const { cols, rows } = xtermRef.current; + + // Send resize to server + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "resize", cols, rows })); + } + } + }, []); + + // Resize observer + useEffect(() => { + const container = terminalRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver(() => { + handleResize(); + }); + + resizeObserver.observe(container); + + // Also handle window resize + window.addEventListener("resize", handleResize); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener("resize", handleResize); + }; + }, [handleResize]); + + // Focus terminal when becoming active + useEffect(() => { + if (isActive && xtermRef.current) { + xtermRef.current.focus(); + } + }, [isActive]); + + // Update terminal font size when it changes + useEffect(() => { + if (xtermRef.current && isTerminalReady) { + xtermRef.current.options.fontSize = fontSize; + // Refit after font size change + if (fitAddonRef.current) { + fitAddonRef.current.fit(); + // Notify server of new dimensions + const { cols, rows } = xtermRef.current; + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "resize", cols, rows })); + } + } + } + }, [fontSize, isTerminalReady]); + + // Update terminal theme when app theme changes + useEffect(() => { + if (xtermRef.current && isTerminalReady) { + const terminalTheme = getTerminalTheme(effectiveTheme); + xtermRef.current.options.theme = terminalTheme; + } + }, [effectiveTheme, isTerminalReady]); + + // Handle keyboard shortcuts for zoom (Ctrl+Plus, Ctrl+Minus, Ctrl+0) + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleKeyDown = (e: KeyboardEvent) => { + // Only handle if Ctrl (or Cmd on Mac) is pressed + if (!e.ctrlKey && !e.metaKey) return; + + // Ctrl/Cmd + Plus or Ctrl/Cmd + = (for keyboards without numpad) + if (e.key === "+" || e.key === "=") { + e.preventDefault(); + e.stopPropagation(); + zoomIn(); + return; + } + + // Ctrl/Cmd + Minus + if (e.key === "-") { + e.preventDefault(); + e.stopPropagation(); + zoomOut(); + return; + } + + // Ctrl/Cmd + 0 to reset + if (e.key === "0") { + e.preventDefault(); + e.stopPropagation(); + resetZoom(); + return; + } + }; + + container.addEventListener("keydown", handleKeyDown); + return () => container.removeEventListener("keydown", handleKeyDown); + }, [zoomIn, zoomOut, resetZoom]); + + // Handle mouse wheel zoom (Ctrl+Wheel) + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleWheel = (e: WheelEvent) => { + // Only zoom if Ctrl (or Cmd on Mac) is pressed + if (!e.ctrlKey && !e.metaKey) return; + + e.preventDefault(); + e.stopPropagation(); + + if (e.deltaY < 0) { + // Scroll up = zoom in + zoomIn(); + } else if (e.deltaY > 0) { + // Scroll down = zoom out + zoomOut(); + } + }; + + // Use passive: false to allow preventDefault + container.addEventListener("wheel", handleWheel, { passive: false }); + return () => container.removeEventListener("wheel", handleWheel); + }, [zoomIn, zoomOut]); + + // Combine refs for the container + const setRefs = useCallback((node: HTMLDivElement | null) => { + containerRef.current = node; + setDropRef(node); + }, [setDropRef]); + + // Get current terminal theme for xterm styling + const currentTerminalTheme = getTerminalTheme(effectiveTheme); + + return ( +
+ {/* Drop indicator overlay */} + {isOver && isDropTarget && ( +
+
+ Drop to swap +
+
+ )} + + {/* Header bar with drag handle - uses app theme CSS variables */} +
+ {/* Drag handle */} + + + {/* Terminal icon and label */} +
+ + + bash + + {/* Font size indicator - only show when not default */} + {fontSize !== DEFAULT_FONT_SIZE && ( + + )} +
+ + {/* Zoom and action buttons */} +
+ {/* Zoom controls */} + + + +
+ + {/* Split/close buttons */} + + + +
+
+ + {/* Terminal container - uses terminal theme */} +
+
+ ); +} diff --git a/apps/app/src/config/terminal-themes.ts b/apps/app/src/config/terminal-themes.ts new file mode 100644 index 00000000..eb85a203 --- /dev/null +++ b/apps/app/src/config/terminal-themes.ts @@ -0,0 +1,367 @@ +/** + * Terminal themes that match the app themes + * Each theme provides colors for xterm.js terminal emulator + */ + +import type { ThemeMode } from "@/store/app-store"; + +export interface TerminalTheme { + background: string; + foreground: string; + cursor: string; + cursorAccent: string; + selectionBackground: string; + selectionForeground?: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; +} + +// Dark theme (default) +const darkTheme: TerminalTheme = { + background: "#0a0a0a", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + cursorAccent: "#0a0a0a", + selectionBackground: "#264f78", + black: "#1e1e1e", + red: "#f44747", + green: "#6a9955", + yellow: "#dcdcaa", + blue: "#569cd6", + magenta: "#c586c0", + cyan: "#4ec9b0", + white: "#d4d4d4", + brightBlack: "#808080", + brightRed: "#f44747", + brightGreen: "#6a9955", + brightYellow: "#dcdcaa", + brightBlue: "#569cd6", + brightMagenta: "#c586c0", + brightCyan: "#4ec9b0", + brightWhite: "#ffffff", +}; + +// Light theme +const lightTheme: TerminalTheme = { + background: "#ffffff", + foreground: "#383a42", + cursor: "#383a42", + cursorAccent: "#ffffff", + selectionBackground: "#add6ff", + black: "#383a42", + red: "#e45649", + green: "#50a14f", + yellow: "#c18401", + blue: "#4078f2", + magenta: "#a626a4", + cyan: "#0184bc", + white: "#fafafa", + brightBlack: "#4f525e", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#e5c07b", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", +}; + +// Retro / Cyberpunk theme - neon green on black +const retroTheme: TerminalTheme = { + background: "#000000", + foreground: "#39ff14", + cursor: "#39ff14", + cursorAccent: "#000000", + selectionBackground: "#39ff14", + selectionForeground: "#000000", + black: "#000000", + red: "#ff0055", + green: "#39ff14", + yellow: "#ffff00", + blue: "#00ffff", + magenta: "#ff00ff", + cyan: "#00ffff", + white: "#39ff14", + brightBlack: "#555555", + brightRed: "#ff5555", + brightGreen: "#55ff55", + brightYellow: "#ffff55", + brightBlue: "#55ffff", + brightMagenta: "#ff55ff", + brightCyan: "#55ffff", + brightWhite: "#ffffff", +}; + +// Dracula theme +const draculaTheme: TerminalTheme = { + background: "#282a36", + foreground: "#f8f8f2", + cursor: "#f8f8f2", + cursorAccent: "#282a36", + selectionBackground: "#44475a", + black: "#21222c", + red: "#ff5555", + green: "#50fa7b", + yellow: "#f1fa8c", + blue: "#bd93f9", + magenta: "#ff79c6", + cyan: "#8be9fd", + white: "#f8f8f2", + brightBlack: "#6272a4", + brightRed: "#ff6e6e", + brightGreen: "#69ff94", + brightYellow: "#ffffa5", + brightBlue: "#d6acff", + brightMagenta: "#ff92df", + brightCyan: "#a4ffff", + brightWhite: "#ffffff", +}; + +// Nord theme +const nordTheme: TerminalTheme = { + background: "#2e3440", + foreground: "#d8dee9", + cursor: "#d8dee9", + cursorAccent: "#2e3440", + selectionBackground: "#434c5e", + black: "#3b4252", + red: "#bf616a", + green: "#a3be8c", + yellow: "#ebcb8b", + blue: "#81a1c1", + magenta: "#b48ead", + cyan: "#88c0d0", + white: "#e5e9f0", + brightBlack: "#4c566a", + brightRed: "#bf616a", + brightGreen: "#a3be8c", + brightYellow: "#ebcb8b", + brightBlue: "#81a1c1", + brightMagenta: "#b48ead", + brightCyan: "#8fbcbb", + brightWhite: "#eceff4", +}; + +// Monokai theme +const monokaiTheme: TerminalTheme = { + background: "#272822", + foreground: "#f8f8f2", + cursor: "#f8f8f2", + cursorAccent: "#272822", + selectionBackground: "#49483e", + black: "#272822", + red: "#f92672", + green: "#a6e22e", + yellow: "#f4bf75", + blue: "#66d9ef", + magenta: "#ae81ff", + cyan: "#a1efe4", + white: "#f8f8f2", + brightBlack: "#75715e", + brightRed: "#f92672", + brightGreen: "#a6e22e", + brightYellow: "#f4bf75", + brightBlue: "#66d9ef", + brightMagenta: "#ae81ff", + brightCyan: "#a1efe4", + brightWhite: "#f9f8f5", +}; + +// Tokyo Night theme +const tokyonightTheme: TerminalTheme = { + background: "#1a1b26", + foreground: "#a9b1d6", + cursor: "#c0caf5", + cursorAccent: "#1a1b26", + selectionBackground: "#33467c", + black: "#15161e", + red: "#f7768e", + green: "#9ece6a", + yellow: "#e0af68", + blue: "#7aa2f7", + magenta: "#bb9af7", + cyan: "#7dcfff", + white: "#a9b1d6", + brightBlack: "#414868", + brightRed: "#f7768e", + brightGreen: "#9ece6a", + brightYellow: "#e0af68", + brightBlue: "#7aa2f7", + brightMagenta: "#bb9af7", + brightCyan: "#7dcfff", + brightWhite: "#c0caf5", +}; + +// Solarized Dark theme +const solarizedTheme: TerminalTheme = { + background: "#002b36", + foreground: "#839496", + cursor: "#839496", + cursorAccent: "#002b36", + selectionBackground: "#073642", + black: "#073642", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#eee8d5", + brightBlack: "#002b36", + brightRed: "#cb4b16", + brightGreen: "#586e75", + brightYellow: "#657b83", + brightBlue: "#839496", + brightMagenta: "#6c71c4", + brightCyan: "#93a1a1", + brightWhite: "#fdf6e3", +}; + +// Gruvbox Dark theme +const gruvboxTheme: TerminalTheme = { + background: "#282828", + foreground: "#ebdbb2", + cursor: "#ebdbb2", + cursorAccent: "#282828", + selectionBackground: "#504945", + black: "#282828", + red: "#cc241d", + green: "#98971a", + yellow: "#d79921", + blue: "#458588", + magenta: "#b16286", + cyan: "#689d6a", + white: "#a89984", + brightBlack: "#928374", + brightRed: "#fb4934", + brightGreen: "#b8bb26", + brightYellow: "#fabd2f", + brightBlue: "#83a598", + brightMagenta: "#d3869b", + brightCyan: "#8ec07c", + brightWhite: "#ebdbb2", +}; + +// Catppuccin Mocha theme +const catppuccinTheme: TerminalTheme = { + background: "#1e1e2e", + foreground: "#cdd6f4", + cursor: "#f5e0dc", + cursorAccent: "#1e1e2e", + selectionBackground: "#45475a", + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#cba6f7", + cyan: "#94e2d5", + white: "#bac2de", + brightBlack: "#585b70", + brightRed: "#f38ba8", + brightGreen: "#a6e3a1", + brightYellow: "#f9e2af", + brightBlue: "#89b4fa", + brightMagenta: "#cba6f7", + brightCyan: "#94e2d5", + brightWhite: "#a6adc8", +}; + +// One Dark theme +const onedarkTheme: TerminalTheme = { + background: "#282c34", + foreground: "#abb2bf", + cursor: "#528bff", + cursorAccent: "#282c34", + selectionBackground: "#3e4451", + black: "#282c34", + red: "#e06c75", + green: "#98c379", + yellow: "#e5c07b", + blue: "#61afef", + magenta: "#c678dd", + cyan: "#56b6c2", + white: "#abb2bf", + brightBlack: "#5c6370", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#e5c07b", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", +}; + +// Synthwave '84 theme +const synthwaveTheme: TerminalTheme = { + background: "#262335", + foreground: "#ffffff", + cursor: "#ff7edb", + cursorAccent: "#262335", + selectionBackground: "#463465", + black: "#262335", + red: "#fe4450", + green: "#72f1b8", + yellow: "#fede5d", + blue: "#03edf9", + magenta: "#ff7edb", + cyan: "#03edf9", + white: "#ffffff", + brightBlack: "#614d85", + brightRed: "#fe4450", + brightGreen: "#72f1b8", + brightYellow: "#f97e72", + brightBlue: "#03edf9", + brightMagenta: "#ff7edb", + brightCyan: "#03edf9", + brightWhite: "#ffffff", +}; + +// Theme mapping +const terminalThemes: Record = { + light: lightTheme, + dark: darkTheme, + system: darkTheme, // Will be resolved at runtime + retro: retroTheme, + dracula: draculaTheme, + nord: nordTheme, + monokai: monokaiTheme, + tokyonight: tokyonightTheme, + solarized: solarizedTheme, + gruvbox: gruvboxTheme, + catppuccin: catppuccinTheme, + onedark: onedarkTheme, + synthwave: synthwaveTheme, +}; + +/** + * Get terminal theme for the given app theme + * For "system" theme, it checks the user's system preference + */ +export function getTerminalTheme(theme: ThemeMode): TerminalTheme { + if (theme === "system") { + // Check system preference + if (typeof window !== "undefined") { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + return prefersDark ? darkTheme : lightTheme; + } + return darkTheme; // Default to dark for SSR + } + return terminalThemes[theme] || darkTheme; +} + +export default terminalThemes; diff --git a/apps/app/src/hooks/use-keyboard-shortcuts.ts b/apps/app/src/hooks/use-keyboard-shortcuts.ts index 8e12c2f5..1f1c7a95 100644 --- a/apps/app/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/app/src/hooks/use-keyboard-shortcuts.ts @@ -34,6 +34,18 @@ function isInputFocused(): boolean { return true; } + // Check if focus is inside an xterm terminal (they use a hidden textarea) + const xtermContainer = activeElement.closest(".xterm"); + if (xtermContainer) { + return true; + } + + // Also check if any parent has data-terminal-container attribute + const terminalContainer = activeElement.closest("[data-terminal-container]"); + if (terminalContainer) { + return true; + } + // Check for autocomplete/typeahead dropdowns being open const autocompleteList = document.querySelector( '[data-testid="category-autocomplete-list"]' diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index acc02f85..bd278f01 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -12,7 +12,8 @@ export type ViewMode = | "interview" | "context" | "profiles" - | "running-agents"; + | "running-agents" + | "terminal"; export type ThemeMode = | "light" @@ -296,6 +297,27 @@ export interface ProjectAnalysis { analyzedAt: string; } +// Terminal panel layout types (recursive for splits) +export type TerminalPanelContent = + | { type: "terminal"; sessionId: string; size?: number; fontSize?: number } + | { type: "split"; direction: "horizontal" | "vertical"; panels: TerminalPanelContent[] }; + +// Terminal tab - each tab has its own layout +export interface TerminalTab { + id: string; + name: string; + layout: TerminalPanelContent | null; +} + +export interface TerminalState { + isUnlocked: boolean; + authToken: string | null; + tabs: TerminalTab[]; + activeTabId: string | null; + activeSessionId: string | null; + defaultFontSize: number; // Default font size for new terminals +} + export interface AppState { // Project state projects: Project[]; @@ -385,6 +407,9 @@ export interface AppState { // Theme Preview (for hover preview in theme selectors) previewTheme: ThemeMode | null; + + // Terminal state + terminalState: TerminalState; } // Default background settings for board backgrounds @@ -563,6 +588,21 @@ export interface AppActions { setHideScrollbar: (projectPath: string, hide: boolean) => void; clearBoardBackground: (projectPath: string) => void; + // Terminal actions + setTerminalUnlocked: (unlocked: boolean, token?: string) => void; + setActiveTerminalSession: (sessionId: string | null) => void; + addTerminalToLayout: (sessionId: string, direction?: "horizontal" | "vertical") => void; + removeTerminalFromLayout: (sessionId: string) => void; + swapTerminals: (sessionId1: string, sessionId2: string) => void; + clearTerminalState: () => void; + setTerminalPanelFontSize: (sessionId: string, fontSize: number) => void; + addTerminalTab: (name?: string) => string; + removeTerminalTab: (tabId: string) => void; + setActiveTerminalTab: (tabId: string) => void; + renameTerminalTab: (tabId: string, name: string) => void; + moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => void; + addTerminalToTab: (sessionId: string, tabId: string, direction?: "horizontal" | "vertical") => void; + // Reset reset: () => void; } @@ -658,6 +698,14 @@ const initialState: AppState = { isAnalyzing: false, boardBackgroundByProject: {}, previewTheme: null, + terminalState: { + isUnlocked: false, + authToken: null, + tabs: [], + activeTabId: null, + activeSessionId: null, + defaultFontSize: 14, + }, }; export const useAppStore = create()( @@ -1464,6 +1512,432 @@ export const useAppStore = create()( }); }, + // Terminal actions + setTerminalUnlocked: (unlocked, token) => { + set({ + terminalState: { + ...get().terminalState, + isUnlocked: unlocked, + authToken: token || null, + }, + }); + }, + + setActiveTerminalSession: (sessionId) => { + set({ + terminalState: { + ...get().terminalState, + activeSessionId: sessionId, + }, + }); + }, + + addTerminalToLayout: (sessionId, direction = "horizontal") => { + const current = get().terminalState; + const newTerminal: TerminalPanelContent = { type: "terminal", sessionId, size: 50 }; + + // If no tabs, create first tab + if (current.tabs.length === 0) { + const newTabId = `tab-${Date.now()}`; + set({ + terminalState: { + ...current, + tabs: [{ id: newTabId, name: "Terminal 1", layout: { type: "terminal", sessionId, size: 100 } }], + activeTabId: newTabId, + activeSessionId: sessionId, + }, + }); + return; + } + + // Add to active tab's layout + const activeTab = current.tabs.find(t => t.id === current.activeTabId); + if (!activeTab) return; + + const addToLayout = ( + node: TerminalPanelContent, + targetDirection: "horizontal" | "vertical" + ): TerminalPanelContent => { + if (node.type === "terminal") { + return { + type: "split", + direction: targetDirection, + panels: [{ ...node, size: 50 }, newTerminal], + }; + } + // If same direction, add to existing split + if (node.direction === targetDirection) { + const newSize = 100 / (node.panels.length + 1); + return { + ...node, + panels: [...node.panels.map(p => ({ ...p, size: newSize })), { ...newTerminal, size: newSize }], + }; + } + // Different direction, wrap in new split + return { + type: "split", + direction: targetDirection, + panels: [{ ...node, size: 50 }, newTerminal], + }; + }; + + const newLayout = activeTab.layout + ? addToLayout(activeTab.layout, direction) + : { type: "terminal" as const, sessionId, size: 100 }; + + const newTabs = current.tabs.map(t => + t.id === current.activeTabId ? { ...t, layout: newLayout } : t + ); + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeSessionId: sessionId, + }, + }); + }, + + removeTerminalFromLayout: (sessionId) => { + const current = get().terminalState; + if (current.tabs.length === 0) return; + + // Find which tab contains this session + const findFirstTerminal = (node: TerminalPanelContent): string | null => { + if (node.type === "terminal") return node.sessionId; + for (const panel of node.panels) { + const found = findFirstTerminal(panel); + if (found) return found; + } + return null; + }; + + const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { + if (node.type === "terminal") { + return node.sessionId === sessionId ? null : node; + } + const newPanels: TerminalPanelContent[] = []; + for (const panel of node.panels) { + const result = removeAndCollapse(panel); + if (result !== null) newPanels.push(result); + } + if (newPanels.length === 0) return null; + if (newPanels.length === 1) return newPanels[0]; + return { ...node, panels: newPanels }; + }; + + let newTabs = current.tabs.map(tab => { + if (!tab.layout) return tab; + const newLayout = removeAndCollapse(tab.layout); + return { ...tab, layout: newLayout }; + }); + + // Remove empty tabs + newTabs = newTabs.filter(tab => tab.layout !== null); + + // Determine new active session + const newActiveTabId = newTabs.length > 0 ? (current.activeTabId && newTabs.find(t => t.id === current.activeTabId) ? current.activeTabId : newTabs[0].id) : null; + const newActiveSessionId = newActiveTabId + ? findFirstTerminal(newTabs.find(t => t.id === newActiveTabId)?.layout || null as unknown as TerminalPanelContent) + : null; + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: newActiveTabId, + activeSessionId: newActiveSessionId, + }, + }); + }, + + swapTerminals: (sessionId1, sessionId2) => { + const current = get().terminalState; + if (current.tabs.length === 0) return; + + const swapInLayout = (node: TerminalPanelContent): TerminalPanelContent => { + if (node.type === "terminal") { + if (node.sessionId === sessionId1) return { ...node, sessionId: sessionId2 }; + if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 }; + return node; + } + return { ...node, panels: node.panels.map(swapInLayout) }; + }; + + const newTabs = current.tabs.map(tab => ({ + ...tab, + layout: tab.layout ? swapInLayout(tab.layout) : null, + })); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + clearTerminalState: () => { + set({ + terminalState: { + isUnlocked: false, + authToken: null, + tabs: [], + activeTabId: null, + activeSessionId: null, + defaultFontSize: 14, + }, + }); + }, + + setTerminalPanelFontSize: (sessionId, fontSize) => { + const current = get().terminalState; + const clampedSize = Math.max(8, Math.min(32, fontSize)); + + const updateFontSize = (node: TerminalPanelContent): TerminalPanelContent => { + if (node.type === "terminal") { + if (node.sessionId === sessionId) { + return { ...node, fontSize: clampedSize }; + } + return node; + } + return { ...node, panels: node.panels.map(updateFontSize) }; + }; + + const newTabs = current.tabs.map(tab => { + if (!tab.layout) return tab; + return { ...tab, layout: updateFontSize(tab.layout) }; + }); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + addTerminalTab: (name) => { + const current = get().terminalState; + const newTabId = `tab-${Date.now()}`; + const tabNumber = current.tabs.length + 1; + const newTab: TerminalTab = { id: newTabId, name: name || `Terminal ${tabNumber}`, layout: null }; + set({ + terminalState: { + ...current, + tabs: [...current.tabs, newTab], + activeTabId: newTabId, + }, + }); + return newTabId; + }, + + removeTerminalTab: (tabId) => { + const current = get().terminalState; + const newTabs = current.tabs.filter(t => t.id !== tabId); + let newActiveTabId = current.activeTabId; + let newActiveSessionId = current.activeSessionId; + + if (current.activeTabId === tabId) { + newActiveTabId = newTabs.length > 0 ? newTabs[0].id : null; + if (newActiveTabId) { + const newActiveTab = newTabs.find(t => t.id === newActiveTabId); + const findFirst = (node: TerminalPanelContent): string | null => { + if (node.type === "terminal") return node.sessionId; + for (const p of node.panels) { + const f = findFirst(p); + if (f) return f; + } + return null; + }; + newActiveSessionId = newActiveTab?.layout ? findFirst(newActiveTab.layout) : null; + } else { + newActiveSessionId = null; + } + } + + set({ + terminalState: { ...current, tabs: newTabs, activeTabId: newActiveTabId, activeSessionId: newActiveSessionId }, + }); + }, + + setActiveTerminalTab: (tabId) => { + const current = get().terminalState; + const tab = current.tabs.find(t => t.id === tabId); + if (!tab) return; + + let newActiveSessionId = current.activeSessionId; + if (tab.layout) { + const findFirst = (node: TerminalPanelContent): string | null => { + if (node.type === "terminal") return node.sessionId; + for (const p of node.panels) { + const f = findFirst(p); + if (f) return f; + } + return null; + }; + newActiveSessionId = findFirst(tab.layout); + } + + set({ + terminalState: { ...current, activeTabId: tabId, activeSessionId: newActiveSessionId }, + }); + }, + + renameTerminalTab: (tabId, name) => { + const current = get().terminalState; + const newTabs = current.tabs.map(t => t.id === tabId ? { ...t, name } : t); + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + moveTerminalToTab: (sessionId, targetTabId) => { + const current = get().terminalState; + + let sourceTabId: string | null = null; + let originalTerminalNode: (TerminalPanelContent & { type: "terminal" }) | null = null; + + const findTerminal = (node: TerminalPanelContent): (TerminalPanelContent & { type: "terminal" }) | null => { + if (node.type === "terminal") { + return node.sessionId === sessionId ? node : null; + } + for (const panel of node.panels) { + const found = findTerminal(panel); + if (found) return found; + } + return null; + }; + + for (const tab of current.tabs) { + if (tab.layout) { + const found = findTerminal(tab.layout); + if (found) { + sourceTabId = tab.id; + originalTerminalNode = found; + break; + } + } + } + if (!sourceTabId || !originalTerminalNode) return; + if (sourceTabId === targetTabId) return; + + const sourceTab = current.tabs.find(t => t.id === sourceTabId); + if (!sourceTab?.layout) return; + + const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { + if (node.type === "terminal") { + return node.sessionId === sessionId ? null : node; + } + const newPanels: TerminalPanelContent[] = []; + for (const panel of node.panels) { + const result = removeAndCollapse(panel); + if (result !== null) newPanels.push(result); + } + if (newPanels.length === 0) return null; + if (newPanels.length === 1) return newPanels[0]; + return { ...node, panels: newPanels }; + }; + + const newSourceLayout = removeAndCollapse(sourceTab.layout); + + let finalTargetTabId = targetTabId; + let newTabs = current.tabs; + + if (targetTabId === "new") { + const newTabId = `tab-${Date.now()}`; + const sourceWillBeRemoved = !newSourceLayout; + const tabName = sourceWillBeRemoved ? sourceTab.name : `Terminal ${current.tabs.length + 1}`; + newTabs = [ + ...current.tabs, + { id: newTabId, name: tabName, layout: { type: "terminal", sessionId, size: 100, fontSize: originalTerminalNode.fontSize } }, + ]; + finalTargetTabId = newTabId; + } else { + const targetTab = current.tabs.find(t => t.id === targetTabId); + if (!targetTab) return; + + const terminalNode: TerminalPanelContent = { type: "terminal", sessionId, size: 50, fontSize: originalTerminalNode.fontSize }; + let newTargetLayout: TerminalPanelContent; + + if (!targetTab.layout) { + newTargetLayout = { type: "terminal", sessionId, size: 100, fontSize: originalTerminalNode.fontSize }; + } else if (targetTab.layout.type === "terminal") { + newTargetLayout = { + type: "split", + direction: "horizontal", + panels: [{ ...targetTab.layout, size: 50 }, terminalNode], + }; + } else { + newTargetLayout = { + ...targetTab.layout, + panels: [...targetTab.layout.panels, terminalNode], + }; + } + + newTabs = current.tabs.map(t => + t.id === targetTabId ? { ...t, layout: newTargetLayout } : t + ); + } + + if (!newSourceLayout) { + newTabs = newTabs.filter(t => t.id !== sourceTabId); + } else { + newTabs = newTabs.map(t => + t.id === sourceTabId ? { ...t, layout: newSourceLayout } : t + ); + } + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: finalTargetTabId, + activeSessionId: sessionId, + }, + }); + }, + + addTerminalToTab: (sessionId, tabId, direction = "horizontal") => { + const current = get().terminalState; + const tab = current.tabs.find(t => t.id === tabId); + if (!tab) return; + + const terminalNode: TerminalPanelContent = { type: "terminal", sessionId, size: 50 }; + let newLayout: TerminalPanelContent; + + if (!tab.layout) { + newLayout = { type: "terminal", sessionId, size: 100 }; + } else if (tab.layout.type === "terminal") { + newLayout = { + type: "split", + direction, + panels: [{ ...tab.layout, size: 50 }, terminalNode], + }; + } else { + if (tab.layout.direction === direction) { + const newSize = 100 / (tab.layout.panels.length + 1); + newLayout = { + ...tab.layout, + panels: [...tab.layout.panels.map(p => ({ ...p, size: newSize })), { ...terminalNode, size: newSize }], + }; + } else { + newLayout = { + type: "split", + direction, + panels: [{ ...tab.layout, size: 50 }, terminalNode], + }; + } + } + + const newTabs = current.tabs.map(t => + t.id === tabId ? { ...t, layout: newLayout } : t + ); + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: tabId, + activeSessionId: sessionId, + }, + }); + }, + // Reset reset: () => set(initialState), }), diff --git a/apps/app/src/types/css.d.ts b/apps/app/src/types/css.d.ts new file mode 100644 index 00000000..54ff221f --- /dev/null +++ b/apps/app/src/types/css.d.ts @@ -0,0 +1,9 @@ +declare module "*.css" { + const content: { [className: string]: string }; + export default content; +} + +declare module "@xterm/xterm/css/xterm.css" { + const content: unknown; + export default content; +} diff --git a/apps/server/.env.example b/apps/server/.env.example index 6ce580b1..12b7bfcd 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -43,3 +43,14 @@ OPENAI_API_KEY= # Google API key (for future Gemini support) GOOGLE_API_KEY= + +# ============================================ +# OPTIONAL - Terminal Access +# ============================================ + +# Enable/disable terminal access (default: true) +TERMINAL_ENABLED=true + +# Password to protect terminal access (leave empty for no password) +# If set, users must enter this password before accessing terminal +TERMINAL_PASSWORD=test diff --git a/apps/server/package.json b/apps/server/package.json index 6e16d10b..113595a1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "node-pty": "^1.0.0", "ws": "^8.18.0" }, "devDependencies": { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index de7c7240..e55f55bf 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -30,9 +30,11 @@ import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js"; import { createRunningAgentsRoutes } from "./routes/running-agents.js"; import { createWorkspaceRoutes } from "./routes/workspace.js"; import { createTemplatesRoutes } from "./routes/templates.js"; +import { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired } from "./routes/terminal.js"; import { AgentService } from "./services/agent-service.js"; import { FeatureLoader } from "./services/feature-loader.js"; import { AutoModeService } from "./services/auto-mode-service.js"; +import { getTerminalService } from "./services/terminal-service.js"; // Load environment variables dotenv.config(); @@ -116,13 +118,34 @@ app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events)); app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService)); app.use("/api/workspace", createWorkspaceRoutes()); app.use("/api/templates", createTemplatesRoutes()); +app.use("/api/terminal", createTerminalRoutes()); // Create HTTP server const server = createServer(app); -// WebSocket server for streaming events -const wss = new WebSocketServer({ server, path: "/api/events" }); +// WebSocket servers using noServer mode for proper multi-path support +const wss = new WebSocketServer({ noServer: true }); +const terminalWss = new WebSocketServer({ noServer: true }); +const terminalService = getTerminalService(); +// Handle HTTP upgrade requests manually to route to correct WebSocket server +server.on("upgrade", (request, socket, head) => { + const { pathname } = new URL(request.url || "", `http://${request.headers.host}`); + + if (pathname === "/api/events") { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request); + }); + } else if (pathname === "/api/terminal/ws") { + terminalWss.handleUpgrade(request, socket, head, (ws) => { + terminalWss.emit("connection", ws, request); + }); + } else { + socket.destroy(); + } +}); + +// Events WebSocket connection handler wss.on("connection", (ws: WebSocket) => { console.log("[WebSocket] Client connected"); @@ -144,15 +167,153 @@ wss.on("connection", (ws: WebSocket) => { }); }); +// Track WebSocket connections per session +const terminalConnections: Map> = new Map(); + +// Terminal WebSocket connection handler +terminalWss.on("connection", (ws: WebSocket, req: import("http").IncomingMessage) => { + // Parse URL to get session ID and token + const url = new URL(req.url || "", `http://${req.headers.host}`); + const sessionId = url.searchParams.get("sessionId"); + const token = url.searchParams.get("token"); + + console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`); + + // Check if terminal is enabled + if (!isTerminalEnabled()) { + console.log("[Terminal WS] Terminal is disabled"); + ws.close(4003, "Terminal access is disabled"); + return; + } + + // Validate token if password is required + if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) { + console.log("[Terminal WS] Invalid or missing token"); + ws.close(4001, "Authentication required"); + return; + } + + if (!sessionId) { + console.log("[Terminal WS] No session ID provided"); + ws.close(4002, "Session ID required"); + return; + } + + // Check if session exists + const session = terminalService.getSession(sessionId); + if (!session) { + console.log(`[Terminal WS] Session ${sessionId} not found`); + ws.close(4004, "Session not found"); + return; + } + + console.log(`[Terminal WS] Client connected to session ${sessionId}`); + + // Track this connection + if (!terminalConnections.has(sessionId)) { + terminalConnections.set(sessionId, new Set()); + } + terminalConnections.get(sessionId)!.add(ws); + + // Subscribe to terminal data + const unsubscribeData = terminalService.onData((sid, data) => { + if (sid === sessionId && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "data", data })); + } + }); + + // Subscribe to terminal exit + const unsubscribeExit = terminalService.onExit((sid, exitCode) => { + if (sid === sessionId && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "exit", exitCode })); + ws.close(1000, "Session ended"); + } + }); + + // Handle incoming messages + ws.on("message", (message) => { + try { + const msg = JSON.parse(message.toString()); + + switch (msg.type) { + case "input": + // Write user input to terminal + terminalService.write(sessionId, msg.data); + break; + + case "resize": + // Resize terminal + if (msg.cols && msg.rows) { + terminalService.resize(sessionId, msg.cols, msg.rows); + } + break; + + case "ping": + // Respond to ping + ws.send(JSON.stringify({ type: "pong" })); + break; + + default: + console.warn(`[Terminal WS] Unknown message type: ${msg.type}`); + } + } catch (error) { + console.error("[Terminal WS] Error processing message:", error); + } + }); + + ws.on("close", () => { + console.log(`[Terminal WS] Client disconnected from session ${sessionId}`); + unsubscribeData(); + unsubscribeExit(); + + // Remove from connections tracking + const connections = terminalConnections.get(sessionId); + if (connections) { + connections.delete(ws); + if (connections.size === 0) { + terminalConnections.delete(sessionId); + } + } + }); + + ws.on("error", (error) => { + console.error(`[Terminal WS] Error on session ${sessionId}:`, error); + unsubscribeData(); + unsubscribeExit(); + }); + + // Send initial connection success + ws.send(JSON.stringify({ + type: "connected", + sessionId, + shell: session.shell, + cwd: session.cwd, + })); + + // Send scrollback buffer to replay previous output + const scrollback = terminalService.getScrollback(sessionId); + if (scrollback && scrollback.length > 0) { + ws.send(JSON.stringify({ + type: "scrollback", + data: scrollback, + })); + } +}); + // Start server server.listen(PORT, () => { + const terminalStatus = isTerminalEnabled() + ? (isTerminalPasswordRequired() ? "enabled (password protected)" : "enabled") + : "disabled"; console.log(` ╔═══════════════════════════════════════════════════════╗ ║ Automaker Backend Server ║ ╠═══════════════════════════════════════════════════════╣ ║ HTTP API: http://localhost:${PORT} ║ ║ WebSocket: ws://localhost:${PORT}/api/events ║ +║ Terminal: ws://localhost:${PORT}/api/terminal/ws ║ ║ Health: http://localhost:${PORT}/api/health ║ +║ Terminal: ${terminalStatus.padEnd(37)}║ ╚═══════════════════════════════════════════════════════╝ `); }); @@ -160,6 +321,7 @@ server.listen(PORT, () => { // Graceful shutdown process.on("SIGTERM", () => { console.log("SIGTERM received, shutting down..."); + terminalService.cleanup(); server.close(() => { console.log("Server closed"); process.exit(0); @@ -168,6 +330,7 @@ process.on("SIGTERM", () => { process.on("SIGINT", () => { console.log("SIGINT received, shutting down..."); + terminalService.cleanup(); server.close(() => { console.log("Server closed"); process.exit(0); diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts index 7befe07f..ef227918 100644 --- a/apps/server/src/routes/fs.ts +++ b/apps/server/src/routes/fs.ts @@ -7,7 +7,6 @@ import { Router, type Request, type Response } from "express"; import fs from "fs/promises"; import os from "os"; import path from "path"; -import os from "os"; import { validatePath, addAllowedPath, isPathAllowed } from "../lib/security.js"; import type { EventEmitter } from "../lib/events.js"; diff --git a/apps/server/src/routes/spec-regeneration.ts b/apps/server/src/routes/spec-regeneration.ts index c9373675..f33c126c 100644 --- a/apps/server/src/routes/spec-regeneration.ts +++ b/apps/server/src/routes/spec-regeneration.ts @@ -355,7 +355,7 @@ Format your response as markdown. Be specific and actionable.`; } else if (msg.type === "result" && (msg as any).subtype === "success") { console.log("[SpecRegeneration] Received success result"); responseText = (msg as any).result || responseText; - } else if (msg.type === "error") { + } else if ((msg as { type: string }).type === "error") { console.error("[SpecRegeneration] ❌ Received error message from stream:"); console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2)); } @@ -505,7 +505,7 @@ Generate 5-15 features that build on each other logically.`; } else if (msg.type === "result" && (msg as any).subtype === "success") { console.log("[SpecRegeneration] Received success result for features"); responseText = (msg as any).result || responseText; - } else if (msg.type === "error") { + } else if ((msg as { type: string }).type === "error") { console.error("[SpecRegeneration] ❌ Received error message from feature stream:"); console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2)); } diff --git a/apps/server/src/routes/terminal.ts b/apps/server/src/routes/terminal.ts new file mode 100644 index 00000000..622935b8 --- /dev/null +++ b/apps/server/src/routes/terminal.ts @@ -0,0 +1,312 @@ +/** + * Terminal routes with password protection + * + * Provides REST API for terminal session management and authentication. + * WebSocket connections for real-time I/O are handled separately in index.ts. + */ + +import { Router, Request, Response, NextFunction } from "express"; +import { getTerminalService } from "../services/terminal-service.js"; + +// Read env variables lazily to ensure dotenv has loaded them +function getTerminalPassword(): string | undefined { + return process.env.TERMINAL_PASSWORD; +} + +function getTerminalEnabledConfig(): boolean { + return process.env.TERMINAL_ENABLED !== "false"; // Enabled by default +} + +// In-memory session tokens (would use Redis in production) +const validTokens: Map = new Map(); +const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** + * Generate a secure random token + */ +function generateToken(): string { + return `term-${Date.now()}-${Math.random().toString(36).substr(2, 15)}${Math.random().toString(36).substr(2, 15)}`; +} + +/** + * Clean up expired tokens + */ +function cleanupExpiredTokens(): void { + const now = new Date(); + validTokens.forEach((data, token) => { + if (data.expiresAt < now) { + validTokens.delete(token); + } + }); +} + +// Clean up expired tokens every 5 minutes +setInterval(cleanupExpiredTokens, 5 * 60 * 1000); + +/** + * Validate a terminal session token + */ +export function validateTerminalToken(token: string | undefined): boolean { + if (!token) return false; + + const tokenData = validTokens.get(token); + if (!tokenData) return false; + + if (tokenData.expiresAt < new Date()) { + validTokens.delete(token); + return false; + } + + return true; +} + +/** + * Check if terminal requires password + */ +export function isTerminalPasswordRequired(): boolean { + return !!getTerminalPassword(); +} + +/** + * Check if terminal is enabled + */ +export function isTerminalEnabled(): boolean { + return getTerminalEnabledConfig(); +} + +/** + * Terminal authentication middleware + * Checks for valid session token if password is configured + */ +export function terminalAuthMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + // Check if terminal is enabled + if (!getTerminalEnabledConfig()) { + res.status(403).json({ + success: false, + error: "Terminal access is disabled", + }); + return; + } + + // If no password configured, allow all requests + if (!getTerminalPassword()) { + next(); + return; + } + + // Check for session token + const token = + (req.headers["x-terminal-token"] as string) || + (req.query.token as string); + + if (!validateTerminalToken(token)) { + res.status(401).json({ + success: false, + error: "Terminal authentication required", + passwordRequired: true, + }); + return; + } + + next(); +} + +export function createTerminalRoutes(): Router { + const router = Router(); + const terminalService = getTerminalService(); + + /** + * GET /api/terminal/status + * Get terminal status (enabled, password required, platform info) + */ + router.get("/status", (_req, res) => { + res.json({ + success: true, + data: { + enabled: getTerminalEnabledConfig(), + passwordRequired: !!getTerminalPassword(), + platform: terminalService.getPlatformInfo(), + }, + }); + }); + + /** + * POST /api/terminal/auth + * Authenticate with password to get a session token + */ + router.post("/auth", (req, res) => { + if (!getTerminalEnabledConfig()) { + res.status(403).json({ + success: false, + error: "Terminal access is disabled", + }); + return; + } + + const terminalPassword = getTerminalPassword(); + + // If no password required, return immediate success + if (!terminalPassword) { + res.json({ + success: true, + data: { + authenticated: true, + passwordRequired: false, + }, + }); + return; + } + + const { password } = req.body; + + if (!password || password !== terminalPassword) { + res.status(401).json({ + success: false, + error: "Invalid password", + }); + return; + } + + // Generate session token + const token = generateToken(); + const now = new Date(); + validTokens.set(token, { + createdAt: now, + expiresAt: new Date(now.getTime() + TOKEN_EXPIRY_MS), + }); + + res.json({ + success: true, + data: { + authenticated: true, + token, + expiresIn: TOKEN_EXPIRY_MS, + }, + }); + }); + + /** + * POST /api/terminal/logout + * Invalidate a session token + */ + router.post("/logout", (req, res) => { + const token = + (req.headers["x-terminal-token"] as string) || + req.body.token; + + if (token) { + validTokens.delete(token); + } + + res.json({ + success: true, + }); + }); + + // Apply terminal auth middleware to all routes below + router.use(terminalAuthMiddleware); + + /** + * GET /api/terminal/sessions + * List all active terminal sessions + */ + router.get("/sessions", (_req, res) => { + const sessions = terminalService.getAllSessions(); + res.json({ + success: true, + data: sessions, + }); + }); + + /** + * POST /api/terminal/sessions + * Create a new terminal session + */ + router.post("/sessions", (req, res) => { + try { + const { cwd, cols, rows, shell } = req.body; + + const session = terminalService.createSession({ + cwd, + cols: cols || 80, + rows: rows || 24, + shell, + }); + + res.json({ + success: true, + data: { + id: session.id, + cwd: session.cwd, + shell: session.shell, + createdAt: session.createdAt, + }, + }); + } catch (error) { + console.error("[Terminal] Error creating session:", error); + res.status(500).json({ + success: false, + error: "Failed to create terminal session", + details: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + /** + * DELETE /api/terminal/sessions/:id + * Kill a terminal session + */ + router.delete("/sessions/:id", (req, res) => { + const { id } = req.params; + const killed = terminalService.killSession(id); + + if (!killed) { + res.status(404).json({ + success: false, + error: "Session not found", + }); + return; + } + + res.json({ + success: true, + }); + }); + + /** + * POST /api/terminal/sessions/:id/resize + * Resize a terminal session + */ + router.post("/sessions/:id/resize", (req, res) => { + const { id } = req.params; + const { cols, rows } = req.body; + + if (!cols || !rows) { + res.status(400).json({ + success: false, + error: "cols and rows are required", + }); + return; + } + + const resized = terminalService.resize(id, cols, rows); + + if (!resized) { + res.status(404).json({ + success: false, + error: "Session not found", + }); + return; + } + + res.json({ + success: true, + }); + }); + + return router; +} diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts new file mode 100644 index 00000000..17fc5d49 --- /dev/null +++ b/apps/server/src/services/terminal-service.ts @@ -0,0 +1,359 @@ +/** + * Terminal Service + * + * Manages PTY (pseudo-terminal) sessions using node-pty. + * Supports cross-platform shell detection including WSL. + */ + +import * as pty from "node-pty"; +import { EventEmitter } from "events"; +import * as os from "os"; +import * as fs from "fs"; + +// Maximum scrollback buffer size (characters) +const MAX_SCROLLBACK_SIZE = 100000; // ~100KB per terminal + +export interface TerminalSession { + id: string; + pty: pty.IPty; + cwd: string; + createdAt: Date; + shell: string; + scrollbackBuffer: string; // Store recent output for replay on reconnect +} + +export interface TerminalOptions { + cwd?: string; + shell?: string; + cols?: number; + rows?: number; + env?: Record; +} + +type DataCallback = (sessionId: string, data: string) => void; +type ExitCallback = (sessionId: string, exitCode: number) => void; + +export class TerminalService extends EventEmitter { + private sessions: Map = new Map(); + private dataCallbacks: Set = new Set(); + private exitCallbacks: Set = new Set(); + + /** + * Detect the best shell for the current platform + */ + detectShell(): { shell: string; args: string[] } { + const platform = os.platform(); + + // Check if running in WSL + if (platform === "linux" && this.isWSL()) { + // In WSL, prefer the user's configured shell or bash + const userShell = process.env.SHELL || "/bin/bash"; + if (fs.existsSync(userShell)) { + return { shell: userShell, args: ["--login"] }; + } + return { shell: "/bin/bash", args: ["--login"] }; + } + + switch (platform) { + case "win32": { + // Windows: prefer PowerShell, fall back to cmd + const pwsh = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + const pwshCore = "C:\\Program Files\\PowerShell\\7\\pwsh.exe"; + + if (fs.existsSync(pwshCore)) { + return { shell: pwshCore, args: [] }; + } + if (fs.existsSync(pwsh)) { + return { shell: pwsh, args: [] }; + } + return { shell: "cmd.exe", args: [] }; + } + + case "darwin": { + // macOS: prefer user's shell, then zsh, then bash + const userShell = process.env.SHELL; + if (userShell && fs.existsSync(userShell)) { + return { shell: userShell, args: ["--login"] }; + } + if (fs.existsSync("/bin/zsh")) { + return { shell: "/bin/zsh", args: ["--login"] }; + } + return { shell: "/bin/bash", args: ["--login"] }; + } + + case "linux": + default: { + // Linux: prefer user's shell, then bash, then sh + const userShell = process.env.SHELL; + if (userShell && fs.existsSync(userShell)) { + return { shell: userShell, args: ["--login"] }; + } + if (fs.existsSync("/bin/bash")) { + return { shell: "/bin/bash", args: ["--login"] }; + } + return { shell: "/bin/sh", args: [] }; + } + } + } + + /** + * Detect if running inside WSL (Windows Subsystem for Linux) + */ + isWSL(): boolean { + try { + // Check /proc/version for Microsoft/WSL indicators + if (fs.existsSync("/proc/version")) { + const version = fs.readFileSync("/proc/version", "utf-8").toLowerCase(); + return version.includes("microsoft") || version.includes("wsl"); + } + // Check for WSL environment variable + if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) { + return true; + } + } catch { + // Ignore errors + } + return false; + } + + /** + * Get platform info for the client + */ + getPlatformInfo(): { + platform: string; + isWSL: boolean; + defaultShell: string; + arch: string; + } { + const { shell } = this.detectShell(); + return { + platform: os.platform(), + isWSL: this.isWSL(), + defaultShell: shell, + arch: os.arch(), + }; + } + + /** + * Validate and resolve a working directory path + */ + private resolveWorkingDirectory(requestedCwd?: string): string { + const homeDir = os.homedir(); + + // If no cwd requested, use home + if (!requestedCwd) { + return homeDir; + } + + // Clean up the path + let cwd = requestedCwd.trim(); + + // Fix double slashes at start (but not for Windows UNC paths) + if (cwd.startsWith("//") && !cwd.startsWith("//wsl")) { + cwd = cwd.slice(1); + } + + // Check if path exists and is a directory + try { + const stat = fs.statSync(cwd); + if (stat.isDirectory()) { + return cwd; + } + console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`); + return homeDir; + } catch { + console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`); + return homeDir; + } + } + + /** + * Create a new terminal session + */ + createSession(options: TerminalOptions = {}): TerminalSession { + const id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const { shell: detectedShell, args: shellArgs } = this.detectShell(); + const shell = options.shell || detectedShell; + + // Validate and resolve working directory + const cwd = this.resolveWorkingDirectory(options.cwd); + + // Build environment with some useful defaults + const env: Record = { + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + TERM_PROGRAM: "automaker-terminal", + ...options.env, + }; + + console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`); + + const ptyProcess = pty.spawn(shell, shellArgs, { + name: "xterm-256color", + cols: options.cols || 80, + rows: options.rows || 24, + cwd, + env, + }); + + const session: TerminalSession = { + id, + pty: ptyProcess, + cwd, + createdAt: new Date(), + shell, + scrollbackBuffer: "", + }; + + this.sessions.set(id, session); + + // Forward data events and store in scrollback buffer + ptyProcess.onData((data) => { + // Append to scrollback buffer + session.scrollbackBuffer += data; + // Trim if too large (keep the most recent data) + if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { + session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); + } + + this.dataCallbacks.forEach((cb) => cb(id, data)); + this.emit("data", id, data); + }); + + // Handle exit + ptyProcess.onExit(({ exitCode }) => { + console.log(`[Terminal] Session ${id} exited with code ${exitCode}`); + this.sessions.delete(id); + this.exitCallbacks.forEach((cb) => cb(id, exitCode)); + this.emit("exit", id, exitCode); + }); + + console.log(`[Terminal] Session ${id} created successfully`); + return session; + } + + /** + * Write data to a terminal session + */ + write(sessionId: string, data: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + console.warn(`[Terminal] Session ${sessionId} not found`); + return false; + } + session.pty.write(data); + return true; + } + + /** + * Resize a terminal session + */ + resize(sessionId: string, cols: number, rows: number): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + console.warn(`[Terminal] Session ${sessionId} not found for resize`); + return false; + } + try { + session.pty.resize(cols, rows); + return true; + } catch (error) { + console.error(`[Terminal] Error resizing session ${sessionId}:`, error); + return false; + } + } + + /** + * Kill a terminal session + */ + killSession(sessionId: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + return false; + } + try { + session.pty.kill(); + this.sessions.delete(sessionId); + console.log(`[Terminal] Session ${sessionId} killed`); + return true; + } catch (error) { + console.error(`[Terminal] Error killing session ${sessionId}:`, error); + return false; + } + } + + /** + * Get a session by ID + */ + getSession(sessionId: string): TerminalSession | undefined { + return this.sessions.get(sessionId); + } + + /** + * Get scrollback buffer for a session (for replay on reconnect) + */ + getScrollback(sessionId: string): string | null { + const session = this.sessions.get(sessionId); + return session?.scrollbackBuffer || null; + } + + /** + * Get all active sessions + */ + getAllSessions(): Array<{ + id: string; + cwd: string; + createdAt: Date; + shell: string; + }> { + return Array.from(this.sessions.values()).map((s) => ({ + id: s.id, + cwd: s.cwd, + createdAt: s.createdAt, + shell: s.shell, + })); + } + + /** + * Subscribe to data events + */ + onData(callback: DataCallback): () => void { + this.dataCallbacks.add(callback); + return () => this.dataCallbacks.delete(callback); + } + + /** + * Subscribe to exit events + */ + onExit(callback: ExitCallback): () => void { + this.exitCallbacks.add(callback); + return () => this.exitCallbacks.delete(callback); + } + + /** + * Clean up all sessions + */ + cleanup(): void { + console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`); + this.sessions.forEach((session, id) => { + try { + session.pty.kill(); + } catch { + // Ignore errors during cleanup + } + this.sessions.delete(id); + }); + } +} + +// Singleton instance +let terminalService: TerminalService | null = null; + +export function getTerminalService(): TerminalService { + if (!terminalService) { + terminalService = new TerminalService(); + } + return terminalService; +} diff --git a/package-lock.json b/package-lock.json index a8c3184c..f27451ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,9 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -40,6 +43,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-markdown": "^10.1.0", + "react-resizable-panels": "^3.0.6", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" @@ -10256,6 +10260,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "node-pty": "^1.0.0", "ws": "^8.18.0" }, "devDependencies": { @@ -11948,6 +11953,30 @@ "@types/node": "*" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz", + "integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/@zeit/schemas": { "version": "2.36.0", "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", @@ -13436,6 +13465,12 @@ "dev": true, "license": "MIT" }, + "node_modules/nan": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -13517,6 +13552,16 @@ } } }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "nan": "^2.17.0" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -13765,6 +13810,16 @@ "react": "^19.2.0" } }, + "node_modules/react-resizable-panels": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz", + "integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/registry-auth-token": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", From 21cbdba5307b537ee2fd3b7bebfb1b473c9fda5b Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:00:01 -0500 Subject: [PATCH 02/22] fix: add missing Terminal icon import in sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/components/layout/sidebar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index d8453b2d..425234bc 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"; From 272905b8849e608797998b048e2a1f6d997c4f1d Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:02:38 -0500 Subject: [PATCH 03/22] fix: add terminal keyboard shortcut to KeyboardShortcuts interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/store/app-store.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index bd278f01..93deb21f 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -145,6 +145,7 @@ export interface KeyboardShortcuts { context: string; settings: string; profiles: string; + terminal: string; // UI shortcuts toggleSidebar: string; @@ -170,6 +171,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { context: "C", settings: "S", profiles: "M", + terminal: "Cmd+`", // UI toggleSidebar: "`", From 18494547bc8df945bc4aa4fc52cfc949214ffe95 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:05:12 -0500 Subject: [PATCH 04/22] fix: address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Display actual shell name instead of hardcoded "bash" - Fix type assertion by making findFirstTerminal accept null 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/components/views/terminal-view/terminal-panel.tsx | 8 +++++++- apps/app/src/store/app-store.ts | 5 +++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/views/terminal-view/terminal-panel.tsx b/apps/app/src/components/views/terminal-view/terminal-panel.tsx index eb0f8eb8..c0fb1eab 100644 --- a/apps/app/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/app/src/components/views/terminal-view/terminal-panel.tsx @@ -59,6 +59,7 @@ export function TerminalPanel({ const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); const [isTerminalReady, setIsTerminalReady] = useState(false); + const [shellName, setShellName] = useState("shell"); // Get effective theme from store const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme); @@ -223,6 +224,11 @@ export function TerminalPanel({ 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`); @@ -462,7 +468,7 @@ export function TerminalPanel({
- bash + {shellName} {/* Font size indicator - only show when not default */} {fontSize !== DEFAULT_FONT_SIZE && ( diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 93deb21f..12547c6a 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -1605,7 +1605,8 @@ export const useAppStore = create()( if (current.tabs.length === 0) return; // Find which tab contains this session - const findFirstTerminal = (node: TerminalPanelContent): string | null => { + 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); @@ -1640,7 +1641,7 @@ export const useAppStore = create()( // Determine new active session const newActiveTabId = newTabs.length > 0 ? (current.activeTabId && newTabs.find(t => t.id === current.activeTabId) ? current.activeTabId : newTabs[0].id) : null; const newActiveSessionId = newActiveTabId - ? findFirstTerminal(newTabs.find(t => t.id === newActiveTabId)?.layout || null as unknown as TerminalPanelContent) + ? findFirstTerminal(newTabs.find(t => t.id === newActiveTabId)?.layout || null) : null; set({ From be4a0b292cd3677a3a7a3d693fbf70227b944164 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:07:33 -0500 Subject: [PATCH 05/22] fix: split terminal inside current panel instead of at root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When clicking split on a terminal, the new terminal is now added as a sibling of that specific terminal rather than at the root of the layout tree. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/components/views/terminal-view.tsx | 9 ++-- apps/app/src/store/app-store.ts | 43 ++++++++++++++++--- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/app/src/components/views/terminal-view.tsx b/apps/app/src/components/views/terminal-view.tsx index 038ae5cd..59410d7a 100644 --- a/apps/app/src/components/views/terminal-view.tsx +++ b/apps/app/src/components/views/terminal-view.tsx @@ -257,7 +257,8 @@ export function TerminalView() { }; // Create a new terminal session - const createTerminal = async (direction?: "horizontal" | "vertical") => { + // targetSessionId: the terminal to split (if splitting an existing terminal) + const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => { try { const headers: Record = { "Content-Type": "application/json", @@ -278,7 +279,7 @@ export function TerminalView() { const data = await response.json(); if (data.success) { - addTerminalToLayout(data.data.id, direction); + addTerminalToLayout(data.data.id, direction, targetSessionId); } else { console.error("[Terminal] Failed to create session:", data.error); } @@ -358,8 +359,8 @@ export function TerminalView() { isActive={terminalState.activeSessionId === content.sessionId} onFocus={() => setActiveTerminalSession(content.sessionId)} onClose={() => killTerminal(content.sessionId)} - onSplitHorizontal={() => createTerminal("horizontal")} - onSplitVertical={() => createTerminal("vertical")} + onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)} + onSplitVertical={() => createTerminal("vertical", content.sessionId)} isDragging={activeDragId === content.sessionId} isDropTarget={activeDragId !== null && activeDragId !== content.sessionId} fontSize={terminalFontSize} diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 12547c6a..eed56d67 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -593,7 +593,7 @@ export interface AppActions { // Terminal actions setTerminalUnlocked: (unlocked: boolean, token?: string) => void; setActiveTerminalSession: (sessionId: string | null) => void; - addTerminalToLayout: (sessionId: string, direction?: "horizontal" | "vertical") => void; + addTerminalToLayout: (sessionId: string, direction?: "horizontal" | "vertical", targetSessionId?: string) => void; removeTerminalFromLayout: (sessionId: string) => void; swapTerminals: (sessionId1: string, sessionId2: string) => void; clearTerminalState: () => void; @@ -1534,7 +1534,7 @@ export const useAppStore = create()( }); }, - addTerminalToLayout: (sessionId, direction = "horizontal") => { + addTerminalToLayout: (sessionId, direction = "horizontal", targetSessionId) => { const current = get().terminalState; const newTerminal: TerminalPanelContent = { type: "terminal", sessionId, size: 50 }; @@ -1556,7 +1556,33 @@ export const useAppStore = create()( const activeTab = current.tabs.find(t => t.id === current.activeTabId); if (!activeTab) return; - const addToLayout = ( + // 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 => { @@ -1583,9 +1609,14 @@ export const useAppStore = create()( }; }; - const newLayout = activeTab.layout - ? addToLayout(activeTab.layout, direction) - : { type: "terminal" as const, sessionId, size: 100 }; + 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 From 11ddcfaf906a7d1bfbf3abbd241f1a00cefae114 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:21:52 -0500 Subject: [PATCH 06/22] fix: throttle terminal output to prevent system lockup under heavy load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Batch terminal output at ~60fps max to prevent overwhelming WebSocket - Reduce scrollback buffer from 100KB to 50KB per terminal - Clean up flush timeouts on session kill/cleanup - Should fix lockups when running npm run dev with high output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/services/terminal-service.ts | 50 ++++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 17fc5d49..b05d987c 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -11,7 +11,11 @@ import * as os from "os"; import * as fs from "fs"; // Maximum scrollback buffer size (characters) -const MAX_SCROLLBACK_SIZE = 100000; // ~100KB per terminal +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; @@ -20,6 +24,8 @@ export interface TerminalSession { 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 { @@ -205,11 +211,33 @@ export class TerminalService extends EventEmitter { createdAt: new Date(), shell, scrollbackBuffer: "", + outputBuffer: "", + flushTimeout: null, }; this.sessions.set(id, session); - // Forward data events and store in scrollback buffer + // 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; @@ -218,8 +246,13 @@ export class TerminalService extends EventEmitter { session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); } - this.dataCallbacks.forEach((cb) => cb(id, data)); - this.emit("data", id, data); + // 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 @@ -274,6 +307,11 @@ export class TerminalService extends EventEmitter { 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`); @@ -339,6 +377,10 @@ export class TerminalService extends EventEmitter { 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 From 2ebb650609321ff699938d0bbc0ed58878fb25a3 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:28:58 -0500 Subject: [PATCH 07/22] feat: add terminal keyboard shortcuts with cross-platform support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add splitTerminalRight, splitTerminalDown, closeTerminal to KeyboardShortcuts - Wire up shortcuts in terminal view (Cmd+D, Cmd+Shift+D, Cmd+W on Mac) - Auto-detect platform and use Ctrl instead of Cmd on Linux/Windows 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/components/views/terminal-view.tsx | 57 ++++++++++++++++++- apps/app/src/store/app-store.ts | 10 ++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/views/terminal-view.tsx b/apps/app/src/components/views/terminal-view.tsx index 59410d7a..eea43135 100644 --- a/apps/app/src/components/views/terminal-view.tsx +++ b/apps/app/src/components/views/terminal-view.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Terminal as TerminalIcon, Plus, @@ -15,6 +15,7 @@ import { SquarePlus, } from "lucide-react"; import { useAppStore, type TerminalPanelContent, type TerminalTab } from "@/store/app-store"; +import { useKeyboardShortcutsConfig, type KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -338,6 +339,60 @@ export function TerminalView() { } }; + // 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) => { + 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'); + + return ( + e.key.toLowerCase() === key && + cmdOrCtrl === needsCmd && + e.shiftKey === needsShift && + e.altKey === needsAlt + ); + }; + + // 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") { diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index eed56d67..2321df53 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -160,6 +160,11 @@ export interface KeyboardShortcuts { cyclePrevProject: string; cycleNextProject: string; addProfile: string; + + // Terminal shortcuts + splitTerminalRight: string; + splitTerminalDown: string; + closeTerminal: string; } // Default keyboard shortcuts @@ -188,6 +193,11 @@ 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) + splitTerminalRight: "Cmd+D", + splitTerminalDown: "Cmd+Shift+D", + closeTerminal: "Cmd+W", }; export interface ImageAttachment { From a2bd1b593bf0975198975aed2dbedd90b8aae1fc Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:29:39 -0500 Subject: [PATCH 08/22] fix: handle undefined shortcuts for users with persisted state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users with existing persisted state won't have the new terminal shortcuts, so guard against undefined values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/components/views/terminal-view.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/views/terminal-view.tsx b/apps/app/src/components/views/terminal-view.tsx index eea43135..28325014 100644 --- a/apps/app/src/components/views/terminal-view.tsx +++ b/apps/app/src/components/views/terminal-view.tsx @@ -352,7 +352,8 @@ export function TerminalView() { const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey; // Parse shortcut string to check for match - const matchesShortcut = (shortcutStr: string) => { + 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'); From 998ad354d29bd2cf002f231cc934da688a783f45 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:31:02 -0500 Subject: [PATCH 09/22] fix: change terminal shortcuts to avoid conflicts with shell signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split right: Cmd+Shift+D / Ctrl+Shift+D (was Cmd+D which conflicts with EOF) - Split down: Cmd+Shift+E / Ctrl+Shift+E - Close: Cmd+Shift+W / Ctrl+Shift+W (was Cmd+W which conflicts with delete word) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/store/app-store.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 2321df53..cb749986 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -195,9 +195,10 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { addProfile: "N", // Only active in profiles view // Terminal shortcuts (only active in terminal view) - splitTerminalRight: "Cmd+D", - splitTerminalDown: "Cmd+Shift+D", - closeTerminal: "Cmd+W", + // Using Shift modifier to avoid conflicts with terminal signals (Ctrl+D=EOF, Ctrl+W=delete word) + splitTerminalRight: "Cmd+Shift+D", + splitTerminalDown: "Cmd+Shift+E", + closeTerminal: "Cmd+Shift+W", }; export interface ImageAttachment { From 8eb374d77cc18b1aa70dc6d9fbe2410b13a2b64e Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:32:36 -0500 Subject: [PATCH 10/22] fix: use Alt-based shortcuts to avoid browser conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split right: Alt+D - Split down: Alt+Shift+D - Close terminal: Alt+W Alt modifier avoids conflicts with both terminal signals and browser shortcuts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/components/views/terminal-view.tsx | 11 ++++++++--- apps/app/src/store/app-store.ts | 8 ++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/views/terminal-view.tsx b/apps/app/src/components/views/terminal-view.tsx index 28325014..55000fa1 100644 --- a/apps/app/src/components/views/terminal-view.tsx +++ b/apps/app/src/components/views/terminal-view.tsx @@ -360,11 +360,16 @@ export function TerminalView() { 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 && - cmdOrCtrl === needsCmd && - e.shiftKey === needsShift && - e.altKey === needsAlt + cmdMatches && + shiftMatches && + altMatches ); }; diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index cb749986..5a0f0bd8 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -195,10 +195,10 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { addProfile: "N", // Only active in profiles view // Terminal shortcuts (only active in terminal view) - // Using Shift modifier to avoid conflicts with terminal signals (Ctrl+D=EOF, Ctrl+W=delete word) - splitTerminalRight: "Cmd+Shift+D", - splitTerminalDown: "Cmd+Shift+E", - closeTerminal: "Cmd+Shift+W", + // Using Alt modifier to avoid conflicts with both terminal signals AND browser shortcuts + splitTerminalRight: "Alt+D", + splitTerminalDown: "Alt+Shift+D", + closeTerminal: "Alt+W", }; export interface ImageAttachment { From 8c100230ab33bcd7feffadc6dd9e02631fd25683 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:34:27 -0500 Subject: [PATCH 11/22] fix: add safety checks for undefined shortcuts in keyboard map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle cases where users have old persisted state that doesn't include the new terminal shortcuts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/components/ui/keyboard-map.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/app/src/components/ui/keyboard-map.tsx b/apps/app/src/components/ui/keyboard-map.tsx index 73196e70..f880829c 100644 --- a/apps/app/src/components/ui/keyboard-map.tsx +++ b/apps/app/src/components/ui/keyboard-map.tsx @@ -231,7 +231,7 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap > {isBound && shortcuts.length > 0 ? (shortcuts.length === 1 - ? SHORTCUT_LABELS[shortcuts[0]].split(" ")[0] + ? (SHORTCUT_LABELS[shortcuts[0]]?.split(" ")[0] ?? shortcuts[0]) : `${shortcuts.length}x`) : "\u00A0" // Non-breaking space to maintain height } @@ -257,10 +257,12 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap - {SHORTCUT_LABELS[shortcut]} + {SHORTCUT_LABELS[shortcut] ?? shortcut} {displayShortcut} @@ -362,7 +364,7 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa ([shortcut, category]) => { groups[category].push({ key: shortcut, - label: SHORTCUT_LABELS[shortcut], + label: SHORTCUT_LABELS[shortcut] ?? shortcut, value: keyboardShortcuts[shortcut], }); } @@ -386,7 +388,7 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa const conflict = Object.entries(keyboardShortcuts).find( ([k, v]) => k !== currentKey && v.toUpperCase() === shortcutStr.toUpperCase() ); - return conflict ? SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] : null; + return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null; }, [keyboardShortcuts]); const handleStartEdit = (key: keyof KeyboardShortcuts) => { From 14d1562903dc8c102873c0bdde8757cf8b393478 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:35:18 -0500 Subject: [PATCH 12/22] fix: handle undefined shortcuts in parseShortcut and formatShortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add guards to handle undefined/null shortcuts for users with old persisted state missing the new terminal shortcuts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/store/app-store.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 5a0f0bd8..409d001b 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -48,7 +48,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] }; @@ -80,7 +81,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[] = []; From deae01712a034fb9a6caa4c1f13aa5f4b6243b7f Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:39:05 -0500 Subject: [PATCH 13/22] fix: intercept terminal shortcuts at xterm level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the terminal is focused, xterm captures keyboard events before they reach the window. Use attachCustomKeyEventHandler to intercept Alt+D, Alt+Shift+D, and Alt+W directly at the xterm level. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../views/terminal-view/terminal-panel.tsx | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/app/src/components/views/terminal-view/terminal-panel.tsx b/apps/app/src/components/views/terminal-view/terminal-panel.tsx index c0fb1eab..aef9bd19 100644 --- a/apps/app/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/app/src/components/views/terminal-view/terminal-panel.tsx @@ -68,6 +68,12 @@ export function TerminalPanel({ // 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); @@ -173,6 +179,37 @@ export function TerminalPanel({ terminal.onData(() => { onFocusRef.current(); }); + + // Custom key handler to intercept terminal shortcuts + // Return false to prevent xterm from handling the key + terminal.attachCustomKeyEventHandler((event) => { + // Only intercept keydown events + if (event.type !== 'keydown') return true; + + // Alt+D - Split right + if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === 'd') { + event.preventDefault(); + onSplitHorizontalRef.current(); + return false; + } + + // Alt+Shift+D - Split down + if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === 'd') { + event.preventDefault(); + onSplitVerticalRef.current(); + return false; + } + + // Alt+W - Close terminal + if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === 'w') { + event.preventDefault(); + onCloseRef.current(); + return false; + } + + // Let xterm handle all other keys + return true; + }); }; initTerminal(); From ffd8752cde92890336db1312a81860d6570b86ba Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:42:40 -0500 Subject: [PATCH 14/22] feat: add debounce to terminal shortcuts and show in keyboard layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 300ms cooldown to prevent rapid terminal creation when holding keys - Merge DEFAULT_KEYBOARD_SHORTCUTS with user shortcuts so terminal shortcuts (Alt+D, Alt+Shift+D, Alt+W) show in keyboard layout - Fix keyboard map to handle undefined shortcuts from old persisted state 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/components/ui/keyboard-map.tsx | 37 +++++++++++++------ .../views/terminal-view/terminal-panel.tsx | 22 +++++++++-- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/apps/app/src/components/ui/keyboard-map.tsx b/apps/app/src/components/ui/keyboard-map.tsx index f880829c..4d9f1fe3 100644 --- a/apps/app/src/components/ui/keyboard-map.tsx +++ b/apps/app/src/components/ui/keyboard-map.tsx @@ -161,11 +161,18 @@ interface KeyboardMapProps { export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMapProps) { const { keyboardShortcuts } = useAppStore(); + // Merge with defaults to ensure new shortcuts are always shown + const mergedShortcuts = React.useMemo(() => ({ + ...DEFAULT_KEYBOARD_SHORTCUTS, + ...keyboardShortcuts, + }), [keyboardShortcuts]); + // Create a reverse map: base key -> list of shortcut names (including info about modifiers) const keyToShortcuts = React.useMemo(() => { const map: Record> = {}; - (Object.entries(keyboardShortcuts) as [keyof KeyboardShortcuts, string][]).forEach( + (Object.entries(mergedShortcuts) as [keyof KeyboardShortcuts, string][]).forEach( ([shortcutName, shortcutStr]) => { + if (!shortcutStr) return; // Skip undefined shortcuts const parsed = parseShortcut(shortcutStr); const normalizedKey = parsed.key.toUpperCase(); const hasModifiers = !!(parsed.shift || parsed.cmdCtrl || parsed.alt); @@ -176,7 +183,7 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap } ); return map; - }, [keyboardShortcuts]); + }, [mergedShortcuts]); const renderKey = (keyDef: { key: string; label: string; width: number }) => { const normalizedKey = keyDef.key.toUpperCase(); @@ -185,7 +192,7 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap const isBound = shortcuts.length > 0; const isSelected = selectedKey?.toUpperCase() === normalizedKey; const isModified = shortcuts.some( - (s) => keyboardShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s] + (s) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s] ); // Get category for coloring (use first shortcut's category if multiple) @@ -250,7 +257,7 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
{shortcuts.map((shortcut) => { - const shortcutStr = keyboardShortcuts[shortcut]; + const shortcutStr = mergedShortcuts[shortcut]; const displayShortcut = formatShortcut(shortcutStr, true); return (
@@ -266,7 +273,7 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap {displayShortcut} - {keyboardShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && ( + {mergedShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && ( (custom) )}
@@ -353,6 +360,12 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false }); const [shortcutError, setShortcutError] = React.useState(null); + // Merge with defaults to ensure new shortcuts are always shown + const mergedShortcuts = React.useMemo(() => ({ + ...DEFAULT_KEYBOARD_SHORTCUTS, + ...keyboardShortcuts, + }), [keyboardShortcuts]); + const groupedShortcuts = React.useMemo(() => { const groups: Record> = { navigation: [], @@ -365,13 +378,13 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa groups[category].push({ key: shortcut, label: SHORTCUT_LABELS[shortcut] ?? shortcut, - value: keyboardShortcuts[shortcut], + value: mergedShortcuts[shortcut], }); } ); return groups; - }, [keyboardShortcuts]); + }, [mergedShortcuts]); // Build the full shortcut string from key + modifiers const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => { @@ -385,14 +398,14 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa // Check for conflicts with other shortcuts const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => { - const conflict = Object.entries(keyboardShortcuts).find( - ([k, v]) => k !== currentKey && v.toUpperCase() === shortcutStr.toUpperCase() + const conflict = Object.entries(mergedShortcuts).find( + ([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase() ); return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null; - }, [keyboardShortcuts]); + }, [mergedShortcuts]); const handleStartEdit = (key: keyof KeyboardShortcuts) => { - const currentValue = keyboardShortcuts[key]; + const currentValue = mergedShortcuts[key]; const parsed = parseShortcut(currentValue); setEditingShortcut(key); setKeyValue(parsed.key); @@ -495,7 +508,7 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
{shortcuts.map(({ key, label, value }) => { - const isModified = keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key]; + const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key]; const isEditing = editingShortcut === key; return ( diff --git a/apps/app/src/components/views/terminal-view/terminal-panel.tsx b/apps/app/src/components/views/terminal-view/terminal-panel.tsx index aef9bd19..f0283e66 100644 --- a/apps/app/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/app/src/components/views/terminal-view/terminal-panel.tsx @@ -58,6 +58,7 @@ export function TerminalPanel({ 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"); @@ -182,28 +183,43 @@ export function TerminalPanel({ // 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; + // Alt+D - Split right if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === 'd') { event.preventDefault(); - onSplitHorizontalRef.current(); + if (canTrigger) { + lastShortcutTimeRef.current = now; + onSplitHorizontalRef.current(); + } return false; } // Alt+Shift+D - Split down if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === 'd') { event.preventDefault(); - onSplitVerticalRef.current(); + if (canTrigger) { + lastShortcutTimeRef.current = now; + onSplitVerticalRef.current(); + } return false; } // Alt+W - Close terminal if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === 'w') { event.preventDefault(); - onCloseRef.current(); + if (canTrigger) { + lastShortcutTimeRef.current = now; + onCloseRef.current(); + } return false; } From 08221c666045fcbf6d3fccaf805a1df0dee5f8c5 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:44:24 -0500 Subject: [PATCH 15/22] fix: move terminal creation debounce to view level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-panel debounce didn't work because each new terminal has its own fresh ref. Move debounce to createTerminal function with: - 500ms cooldown between creations - isCreating flag to prevent concurrent requests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/components/views/terminal-view.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/views/terminal-view.tsx b/apps/app/src/components/views/terminal-view.tsx index 55000fa1..27d548c2 100644 --- a/apps/app/src/components/views/terminal-view.tsx +++ b/apps/app/src/components/views/terminal-view.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Terminal as TerminalIcon, Plus, @@ -141,8 +141,11 @@ export function TerminalView() { 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); @@ -260,6 +263,15 @@ export function TerminalView() { // 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", @@ -286,6 +298,8 @@ export function TerminalView() { } } catch (err) { console.error("[Terminal] Create session error:", err); + } finally { + isCreatingRef.current = false; } }; From 951010b64d7a7424fc60cbff7744f5f3cbe3316f Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:47:49 -0500 Subject: [PATCH 16/22] fix: add missing red terminal theme and fix split panel type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add red terminal theme with dark red-accented color scheme - Add size property to split type in TerminalPanelContent to support nested splits with size tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/config/terminal-themes.ts | 26 ++++++++++++++++++++++++++ apps/app/src/store/app-store.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/app/src/config/terminal-themes.ts b/apps/app/src/config/terminal-themes.ts index eb85a203..8cef0d77 100644 --- a/apps/app/src/config/terminal-themes.ts +++ b/apps/app/src/config/terminal-themes.ts @@ -331,6 +331,31 @@ const synthwaveTheme: TerminalTheme = { brightWhite: "#ffffff", }; +// Red theme - Dark theme with red accents +const redTheme: TerminalTheme = { + background: "#1a0a0a", + foreground: "#e0d0d0", + cursor: "#ff4444", + cursorAccent: "#1a0a0a", + selectionBackground: "#5a2020", + black: "#2a1010", + red: "#ff4444", + green: "#88cc88", + yellow: "#ffcc66", + blue: "#7799cc", + magenta: "#cc6699", + cyan: "#66aaaa", + white: "#e0d0d0", + brightBlack: "#6a4040", + brightRed: "#ff6666", + brightGreen: "#aaddaa", + brightYellow: "#ffdd88", + brightBlue: "#99bbdd", + brightMagenta: "#dd88bb", + brightCyan: "#88cccc", + brightWhite: "#fff0f0", +}; + // Theme mapping const terminalThemes: Record = { light: lightTheme, @@ -346,6 +371,7 @@ const terminalThemes: Record = { catppuccin: catppuccinTheme, onedark: onedarkTheme, synthwave: synthwaveTheme, + red: redTheme, }; /** diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 409d001b..58079315 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -315,7 +315,7 @@ export interface ProjectAnalysis { // 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[] }; + | { type: "split"; direction: "horizontal" | "vertical"; panels: TerminalPanelContent[]; size?: number }; // Terminal tab - each tab has its own layout export interface TerminalTab { From cbca6fa6e4ac601715a1e2a3db69de2fe74e1aef Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:54:04 -0500 Subject: [PATCH 17/22] fix: change split-down shortcut to Alt+S to avoid system conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change split-down from Alt+Shift+D to Alt+S (Alt+Shift is Windows keyboard layout switch shortcut) - Use event.code for keyboard-layout-independent key detection - Add red theme to dark theme scrollbar selectors - Add red-themed scrollbar styling with dark red colors - Tone down white/bright colors in red terminal theme 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/app/globals.css | 18 +++++++++++-- .../views/terminal-view/terminal-panel.tsx | 11 +++++--- apps/app/src/config/terminal-themes.ts | 26 +++++++++---------- apps/app/src/store/app-store.ts | 2 +- 4 files changed, 37 insertions(+), 20 deletions(-) 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/components/views/terminal-view/terminal-panel.tsx b/apps/app/src/components/views/terminal-view/terminal-panel.tsx index f0283e66..fee8ecf3 100644 --- a/apps/app/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/app/src/components/views/terminal-view/terminal-panel.tsx @@ -193,8 +193,11 @@ export function TerminalPanel({ 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 && event.key.toLowerCase() === 'd') { + if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyD') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -203,8 +206,8 @@ export function TerminalPanel({ return false; } - // Alt+Shift+D - Split down - if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === 'd') { + // Alt+S - Split down + if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyS') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -214,7 +217,7 @@ export function TerminalPanel({ } // Alt+W - Close terminal - if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === 'w') { + if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyW') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; diff --git a/apps/app/src/config/terminal-themes.ts b/apps/app/src/config/terminal-themes.ts index 8cef0d77..c10cea5c 100644 --- a/apps/app/src/config/terminal-themes.ts +++ b/apps/app/src/config/terminal-themes.ts @@ -334,26 +334,26 @@ const synthwaveTheme: TerminalTheme = { // Red theme - Dark theme with red accents const redTheme: TerminalTheme = { background: "#1a0a0a", - foreground: "#e0d0d0", + foreground: "#c8b0b0", cursor: "#ff4444", cursorAccent: "#1a0a0a", selectionBackground: "#5a2020", black: "#2a1010", red: "#ff4444", - green: "#88cc88", - yellow: "#ffcc66", - blue: "#7799cc", - magenta: "#cc6699", - cyan: "#66aaaa", - white: "#e0d0d0", + green: "#6a9a6a", + yellow: "#ccaa55", + blue: "#6688aa", + magenta: "#aa5588", + cyan: "#558888", + white: "#b0a0a0", brightBlack: "#6a4040", brightRed: "#ff6666", - brightGreen: "#aaddaa", - brightYellow: "#ffdd88", - brightBlue: "#99bbdd", - brightMagenta: "#dd88bb", - brightCyan: "#88cccc", - brightWhite: "#fff0f0", + brightGreen: "#88bb88", + brightYellow: "#ddbb66", + brightBlue: "#88aacc", + brightMagenta: "#cc77aa", + brightCyan: "#77aaaa", + brightWhite: "#d0c0c0", }; // Theme mapping diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 58079315..4976aa07 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -199,7 +199,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { // 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+Shift+D", + splitTerminalDown: "Alt+S", closeTerminal: "Alt+W", }; From ca506a208e22439c76a7ec85c299e5d83c587036 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 02:00:52 -0500 Subject: [PATCH 18/22] docs: add terminal documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explains terminal features including: - Password protection and how to disable it - Keyboard shortcuts (Alt+D, Alt+S, Alt+W) - Theming, font size, scrollback - Architecture overview - Troubleshooting tips 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/terminal.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/terminal.md diff --git a/docs/terminal.md b/docs/terminal.md new file mode 100644 index 00000000..617e87cf --- /dev/null +++ b/docs/terminal.md @@ -0,0 +1,85 @@ +# Terminal + +The integrated terminal provides a full-featured terminal emulator within Automaker, powered by xterm.js. + +## Unlocking the Terminal + +The terminal is password-protected by default. To unlock: + +1. Go to **Settings** (gear icon in sidebar) +2. Navigate to the **Terminal** section +3. Enter your password and click **Unlock** + +To disable password protection entirely: +1. Unlock the terminal first +2. Toggle off **Require password to unlock terminal** +3. The terminal will now be accessible without a password + +## 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) From 3a553c892d7b2a1edb04390e7b61167fc670ecb5 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 02:04:05 -0500 Subject: [PATCH 19/22] docs: fix terminal documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Terminal is NOT password protected by default - Add TERMINAL_PASSWORD to .env to enable protection - Add TERMINAL_ENABLED=false to disable terminal completely 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/terminal.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/terminal.md b/docs/terminal.md index 617e87cf..38dece63 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -2,18 +2,26 @@ The integrated terminal provides a full-featured terminal emulator within Automaker, powered by xterm.js. -## Unlocking the Terminal +## Configuration -The terminal is password-protected by default. To unlock: +Configure the terminal via environment variables in `apps/server/.env`: -1. Go to **Settings** (gear icon in sidebar) -2. Navigate to the **Terminal** section -3. Enter your password and click **Unlock** +### Disable Terminal Completely +``` +TERMINAL_ENABLED=false +``` +Set to `false` to completely disable the terminal feature. -To disable password protection entirely: -1. Unlock the terminal first -2. Toggle off **Require password to unlock terminal** -3. The terminal will now be accessible without a password +### 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 From 66fe3392ad626125c2e34259c4e09ce0dee717ad Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 02:04:14 -0500 Subject: [PATCH 20/22] commit --- apps/server/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/.env.example b/apps/server/.env.example index 12b7bfcd..f00b5109 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -53,4 +53,4 @@ TERMINAL_ENABLED=true # Password to protect terminal access (leave empty for no password) # If set, users must enter this password before accessing terminal -TERMINAL_PASSWORD=test +TERMINAL_PASSWORD= From 89216c01e57a6b66e7a9e7b015f0f8cf8220acee Mon Sep 17 00:00:00 2001 From: trueheads Date: Sat, 13 Dec 2025 11:23:58 -0600 Subject: [PATCH 21/22] fixes for windows, but maybe breaking linux --- apps/app/electron/main.js | 40 ++++++++++++++++++++++++++++++++++----- apps/server/package.json | 2 +- package-lock.json | 22 ++++++++++----------- 3 files changed, 47 insertions(+), 17 deletions(-) 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/server/package.json b/apps/server/package.json index 113595a1..9dce2d38 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,7 +16,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", - "node-pty": "^1.0.0", + "node-pty": "1.1.0-beta41", "ws": "^8.18.0" }, "devDependencies": { diff --git a/package-lock.json b/package-lock.json index f27451ed..3268efa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10260,7 +10260,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", - "node-pty": "^1.0.0", + "node-pty": "1.1.0-beta41", "ws": "^8.18.0" }, "devDependencies": { @@ -13465,12 +13465,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nan": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", - "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -13552,14 +13546,20 @@ } } }, + "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.0.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", - "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "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": { - "nan": "^2.17.0" + "node-addon-api": "^7.1.0" } }, "node_modules/npm-run-path": { From e27e0b2343797cbc4986d13ffe94906682326af5 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 13:23:13 -0500 Subject: [PATCH 22/22] feat: add Wiki view and sidebar link - Introduced a new Wiki view component to the application. - Updated the sidebar to include a button for navigating to the Wiki view. - Modified the app store to support the new "wiki" view mode. --- apps/app/src/app/page.tsx | 3 + apps/app/src/components/layout/sidebar.tsx | 40 ++ apps/app/src/components/views/wiki-view.tsx | 479 ++++++++++++++++++++ apps/app/src/store/app-store.ts | 3 +- 4 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 apps/app/src/components/views/wiki-view.tsx diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 2fb46f87..06dfd503 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -13,6 +13,7 @@ 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"; @@ -209,6 +210,8 @@ function HomeContent() { 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 425234bc..dce4a435 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -1247,6 +1247,46 @@ export function Sidebar() {
{/* Course Promo Badge */} + {/* Wiki Link */} +
+ +
{/* Running Agents Link */}
+ {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/store/app-store.ts b/apps/app/src/store/app-store.ts index 4976aa07..7dd781d1 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -13,7 +13,8 @@ export type ViewMode = | "context" | "profiles" | "running-agents" - | "terminal"; + | "terminal" + | "wiki"; export type ThemeMode = | "light"