Merge branch 'main' into feat/add-unit-testing

This commit is contained in:
Shirone
2025-12-13 19:53:00 +01:00
committed by GitHub
21 changed files with 3933 additions and 30 deletions

View File

@@ -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"],
});

View File

@@ -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"

View File

@@ -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;

View File

@@ -12,6 +12,8 @@ import { ContextView } from "@/components/views/context-view";
import { ProfilesView } from "@/components/views/profiles-view";
import { SetupView } from "@/components/views/setup-view";
import { RunningAgentsView } from "@/components/views/running-agents-view";
import { TerminalView } from "@/components/views/terminal-view";
import { WikiView } from "@/components/views/wiki-view";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
@@ -206,6 +208,10 @@ function HomeContent() {
return <ProfilesView />;
case "running-agents":
return <RunningAgentsView />;
case "terminal":
return <TerminalView />;
case "wiki":
return <WikiView />;
default:
return <WelcomeView />;
}

View File

@@ -32,6 +32,9 @@ import {
Bug,
Activity,
Recycle,
Sparkles,
Loader2,
Terminal,
} from "lucide-react";
import {
DropdownMenu,
@@ -72,7 +75,6 @@ import {
hasAutomakerDir,
} from "@/lib/project-init";
import { toast } from "sonner";
import { Sparkles, Loader2 } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import { Checkbox } from "@/components/ui/checkbox";
import type { SpecRegenerationEvent } from "@/types/electron";
@@ -609,6 +611,12 @@ export function Sidebar() {
icon: UserCircle,
shortcut: shortcuts.profiles,
},
{
id: "terminal",
label: "Terminal",
icon: Terminal,
shortcut: shortcuts.terminal,
},
],
},
];
@@ -1239,6 +1247,46 @@ export function Sidebar() {
<div className="border-t border-sidebar-border bg-sidebar-accent/10 shrink-0">
{/* Course Promo Badge */}
<CoursePromoBadge sidebarOpen={sidebarOpen} />
{/* Wiki Link */}
<div className="p-2 pb-0">
<button
onClick={() => setCurrentView("wiki")}
className={cn(
"group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
isActiveRoute("wiki")
? "bg-sidebar-accent/50 text-foreground border border-sidebar-border"
: "text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50",
sidebarOpen ? "justify-start" : "justify-center"
)}
title={!sidebarOpen ? "Wiki" : undefined}
data-testid="wiki-link"
>
{isActiveRoute("wiki") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<BookOpen
className={cn(
"w-4 h-4 shrink-0 transition-colors",
isActiveRoute("wiki")
? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
<span
className={cn(
"ml-2.5 font-medium text-sm flex-1 text-left",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Wiki
</span>
{!sidebarOpen && (
<span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-border">
Wiki
</span>
)}
</button>
</div>
{/* Running Agents Link */}
<div className="p-2 pb-0">
<button

View File

@@ -90,6 +90,7 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
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<keyof KeyboardShortcuts, string> = {
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<keyof KeyboardShortcuts, "navigation" | "ui" |
context: "navigation",
settings: "navigation",
profiles: "navigation",
terminal: "navigation",
toggleSidebar: "ui",
addFeature: "action",
addContextFile: "action",
@@ -120,6 +125,9 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" |
cyclePrevProject: "action",
cycleNextProject: "action",
addProfile: "action",
splitTerminalRight: "action",
splitTerminalDown: "action",
closeTerminal: "action",
};
// Category colors
@@ -153,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<string, Array<{ name: keyof KeyboardShortcuts; hasModifiers: boolean }>> = {};
(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);
@@ -168,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();
@@ -177,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)
@@ -223,7 +238,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
}
@@ -242,21 +257,23 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1">
{shortcuts.map((shortcut) => {
const shortcutStr = keyboardShortcuts[shortcut];
const shortcutStr = mergedShortcuts[shortcut];
const displayShortcut = formatShortcut(shortcutStr, true);
return (
<div key={shortcut} className="flex items-center gap-2">
<span
className={cn(
"w-2 h-2 rounded-full",
CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
SHORTCUT_CATEGORIES[shortcut] && CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]]
? CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
: "bg-muted-foreground"
)}
/>
<span className="text-sm">{SHORTCUT_LABELS[shortcut]}</span>
<span className="text-sm">{SHORTCUT_LABELS[shortcut] ?? shortcut}</span>
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
{displayShortcut}
</kbd>
{keyboardShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
{mergedShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
<span className="text-xs text-yellow-400">(custom)</span>
)}
</div>
@@ -343,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<string | null>(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<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
navigation: [],
@@ -354,14 +377,14 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
([shortcut, category]) => {
groups[category].push({
key: shortcut,
label: SHORTCUT_LABELS[shortcut],
value: keyboardShortcuts[shortcut],
label: SHORTCUT_LABELS[shortcut] ?? 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) => {
@@ -375,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] : null;
}, [keyboardShortcuts]);
return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null;
}, [mergedShortcuts]);
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
const currentValue = keyboardShortcuts[key];
const currentValue = mergedShortcuts[key];
const parsed = parseShortcut(currentValue);
setEditingShortcut(key);
setKeyValue(parsed.key);
@@ -485,7 +508,7 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
</h4>
<div className="grid grid-cols-2 gap-2">
{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 (

View File

@@ -0,0 +1,697 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
Terminal as TerminalIcon,
Plus,
Lock,
Unlock,
SplitSquareHorizontal,
SplitSquareVertical,
Loader2,
AlertCircle,
RefreshCw,
X,
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 {
Panel,
PanelGroup,
PanelResizeHandle,
} from "react-resizable-panels";
import { TerminalPanel } from "./terminal-view/terminal-panel";
import {
DndContext,
DragEndEvent,
DragStartEvent,
DragOverEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
DragOverlay,
useDroppable,
} from "@dnd-kit/core";
import { cn } from "@/lib/utils";
interface TerminalStatus {
enabled: boolean;
passwordRequired: boolean;
platform: {
platform: string;
isWSL: boolean;
defaultShell: string;
arch: string;
};
}
// Tab component with drop target support
function TerminalTabButton({
tab,
isActive,
onClick,
onClose,
isDropTarget,
}: {
tab: TerminalTab;
isActive: boolean;
onClick: () => void;
onClose: () => void;
isDropTarget: boolean;
}) {
const { setNodeRef, isOver } = useDroppable({
id: `tab-${tab.id}`,
data: { type: "tab", tabId: tab.id },
});
return (
<div
ref={setNodeRef}
className={cn(
"flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-pointer transition-colors",
isActive
? "bg-background border-brand-500 text-foreground"
: "bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent",
isOver && isDropTarget && "ring-2 ring-green-500"
)}
onClick={onClick}
>
<TerminalIcon className="h-3 w-3" />
<span className="max-w-24 truncate">{tab.name}</span>
<button
className="ml-1 p-0.5 rounded hover:bg-accent text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
}
// New tab drop zone
function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
const { setNodeRef, isOver } = useDroppable({
id: "new-tab-zone",
data: { type: "new-tab" },
});
return (
<div
ref={setNodeRef}
className={cn(
"flex items-center justify-center px-3 py-1.5 rounded-t-md border-2 border-dashed transition-all",
isOver && isDropTarget
? "border-green-500 bg-green-500/10 text-green-500"
: "border-transparent text-muted-foreground hover:border-border"
)}
>
<SquarePlus className="h-4 w-4" />
</div>
);
}
export function TerminalView() {
const {
terminalState,
setTerminalUnlocked,
addTerminalToLayout,
removeTerminalFromLayout,
setActiveTerminalSession,
swapTerminals,
currentProject,
addTerminalTab,
removeTerminalTab,
setActiveTerminalTab,
moveTerminalToTab,
setTerminalPanelFontSize,
} = useAppStore();
const [status, setStatus] = useState<TerminalStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
const lastCreateTimeRef = useRef<number>(0);
const isCreatingRef = useRef<boolean>(false);
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
// Get active tab
const activeTab = terminalState.tabs.find(t => t.id === terminalState.activeTabId);
// DnD sensors with activation constraint to avoid accidental drags
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
// Handle drag start
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveDragId(event.active.id as string);
}, []);
// Handle drag over - track which tab we're hovering
const handleDragOver = useCallback((event: DragOverEvent) => {
const { over } = event;
if (over?.data?.current?.type === "tab") {
setDragOverTabId(over.data.current.tabId);
} else if (over?.data?.current?.type === "new-tab") {
setDragOverTabId("new");
} else {
setDragOverTabId(null);
}
}, []);
// Handle drag end
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setActiveDragId(null);
setDragOverTabId(null);
if (!over) return;
const activeId = active.id as string;
const overData = over.data?.current;
// If dropped on a tab, move terminal to that tab
if (overData?.type === "tab") {
moveTerminalToTab(activeId, overData.tabId);
return;
}
// If dropped on new tab zone, create new tab with this terminal
if (overData?.type === "new-tab") {
moveTerminalToTab(activeId, "new");
return;
}
// Otherwise, swap terminals within current tab
if (active.id !== over.id) {
swapTerminals(activeId, over.id as string);
}
}, [swapTerminals, moveTerminalToTab]);
// Fetch terminal status
const fetchStatus = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`${serverUrl}/api/terminal/status`);
const data = await response.json();
if (data.success) {
setStatus(data.data);
if (!data.data.passwordRequired) {
setTerminalUnlocked(true);
}
} else {
setError(data.error || "Failed to get terminal status");
}
} catch (err) {
setError("Failed to connect to server");
console.error("[Terminal] Status fetch error:", err);
} finally {
setLoading(false);
}
}, [serverUrl, setTerminalUnlocked]);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Handle password authentication
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
setAuthLoading(true);
setAuthError(null);
try {
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await response.json();
if (data.success) {
setTerminalUnlocked(true, data.data.token);
setPassword("");
} else {
setAuthError(data.error || "Authentication failed");
}
} catch (err) {
setAuthError("Failed to authenticate");
console.error("[Terminal] Auth error:", err);
} finally {
setAuthLoading(false);
}
};
// Create a new terminal session
// targetSessionId: the terminal to split (if splitting an existing terminal)
const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => {
// Debounce: prevent rapid terminal creation
const now = Date.now();
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
console.log("[Terminal] Debounced terminal creation");
return;
}
lastCreateTimeRef.current = now;
isCreatingRef.current = true;
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (terminalState.authToken) {
headers["X-Terminal-Token"] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: "POST",
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
cols: 80,
rows: 24,
}),
});
const data = await response.json();
if (data.success) {
addTerminalToLayout(data.data.id, direction, targetSessionId);
} else {
console.error("[Terminal] Failed to create session:", data.error);
}
} catch (err) {
console.error("[Terminal] Create session error:", err);
} finally {
isCreatingRef.current = false;
}
};
// Create terminal in new tab
const createTerminalInNewTab = async () => {
const tabId = addTerminalTab();
try {
const headers: Record<string, string> = {
"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<string, string> = {};
if (terminalState.authToken) {
headers["X-Terminal-Token"] = terminalState.authToken;
}
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: "DELETE",
headers,
});
removeTerminalFromLayout(sessionId);
} catch (err) {
console.error("[Terminal] Kill session error:", err);
}
};
// Get keyboard shortcuts config
const shortcuts = useKeyboardShortcutsConfig();
// Handle terminal-specific keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle shortcuts when terminal is unlocked and has an active session
if (!terminalState.isUnlocked || !terminalState.activeSessionId) return;
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
// Parse shortcut string to check for match
const matchesShortcut = (shortcutStr: string | undefined) => {
if (!shortcutStr) return false;
const parts = shortcutStr.toLowerCase().split('+');
const key = parts[parts.length - 1];
const needsCmd = parts.includes('cmd');
const needsShift = parts.includes('shift');
const needsAlt = parts.includes('alt');
// Check modifiers
const cmdMatches = needsCmd ? cmdOrCtrl : !cmdOrCtrl;
const shiftMatches = needsShift ? e.shiftKey : !e.shiftKey;
const altMatches = needsAlt ? e.altKey : !e.altKey;
return (
e.key.toLowerCase() === key &&
cmdMatches &&
shiftMatches &&
altMatches
);
};
// Split terminal right (Cmd+D / Ctrl+D)
if (matchesShortcut(shortcuts.splitTerminalRight)) {
e.preventDefault();
createTerminal("horizontal", terminalState.activeSessionId);
return;
}
// Split terminal down (Cmd+Shift+D / Ctrl+Shift+D)
if (matchesShortcut(shortcuts.splitTerminalDown)) {
e.preventDefault();
createTerminal("vertical", terminalState.activeSessionId);
return;
}
// Close terminal (Cmd+W / Ctrl+W)
if (matchesShortcut(shortcuts.closeTerminal)) {
e.preventDefault();
killTerminal(terminalState.activeSessionId);
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [terminalState.isUnlocked, terminalState.activeSessionId, shortcuts]);
// Get a stable key for a panel
const getPanelKey = (panel: TerminalPanelContent): string => {
if (panel.type === "terminal") {
return panel.sessionId;
}
return `split-${panel.direction}-${panel.panels.map(getPanelKey).join("-")}`;
};
// Render panel content recursively
const renderPanelContent = (content: TerminalPanelContent): React.ReactNode => {
if (content.type === "terminal") {
// Use per-terminal fontSize or fall back to default
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
return (
<TerminalPanel
key={content.sessionId}
sessionId={content.sessionId}
authToken={terminalState.authToken}
isActive={terminalState.activeSessionId === content.sessionId}
onFocus={() => setActiveTerminalSession(content.sessionId)}
onClose={() => killTerminal(content.sessionId)}
onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)}
onSplitVertical={() => createTerminal("vertical", content.sessionId)}
isDragging={activeDragId === content.sessionId}
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
fontSize={terminalFontSize}
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
/>
);
}
const isHorizontal = content.direction === "horizontal";
const defaultSizePerPanel = 100 / content.panels.length;
return (
<PanelGroup direction={content.direction}>
{content.panels.map((panel, index) => {
const panelSize = panel.type === "terminal" && panel.size
? panel.size
: defaultSizePerPanel;
return (
<React.Fragment key={getPanelKey(panel)}>
{index > 0 && (
<PanelResizeHandle
className={
isHorizontal
? "w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
: "h-1 w-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
}
/>
)}
<Panel defaultSize={panelSize} minSize={15}>
{renderPanelContent(panel)}
</Panel>
</React.Fragment>
);
})}
</PanelGroup>
);
};
// Loading state
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
// Error state
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-destructive/10 mb-4">
<AlertCircle className="h-12 w-12 text-destructive" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Unavailable</h2>
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
<Button variant="outline" onClick={fetchStatus}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
);
}
// Disabled state
if (!status?.enabled) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<TerminalIcon className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Disabled</h2>
<p className="text-muted-foreground max-w-md">
Terminal access has been disabled. Set <code className="px-1.5 py-0.5 rounded bg-muted">TERMINAL_ENABLED=true</code> in your server .env file to enable it.
</p>
</div>
);
}
// Password gate
if (status.passwordRequired && !terminalState.isUnlocked) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Lock className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Protected</h2>
<p className="text-muted-foreground max-w-md mb-6">
Terminal access requires authentication. Enter the password to unlock.
</p>
<form onSubmit={handleAuth} className="w-full max-w-xs space-y-4">
<Input
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={authLoading}
autoFocus
/>
{authError && (
<p className="text-sm text-destructive">{authError}</p>
)}
<Button type="submit" className="w-full" disabled={authLoading || !password}>
{authLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Unlock className="h-4 w-4 mr-2" />
)}
Unlock Terminal
</Button>
</form>
{status.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && " (WSL)"}
{" | "}Shell: {status.platform.defaultShell}
</p>
)}
</div>
);
}
// No terminals yet - show welcome screen
if (terminalState.tabs.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-brand-500/10 mb-4">
<TerminalIcon className="h-12 w-12 text-brand-500" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal</h2>
<p className="text-muted-foreground max-w-md mb-6">
Create a new terminal session to start executing commands.
{currentProject && (
<span className="block mt-2 text-sm">
Working directory: <code className="px-1.5 py-0.5 rounded bg-muted">{currentProject.path}</code>
</span>
)}
</p>
<Button onClick={() => createTerminal()}>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
{status?.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && " (WSL)"}
{" | "}Shell: {status.platform.defaultShell}
</p>
)}
</div>
);
}
// Terminal view with tabs
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Tab bar */}
<div className="flex items-center bg-card border-b border-border px-2">
{/* Tabs */}
<div className="flex items-center gap-1 flex-1 overflow-x-auto py-1">
{terminalState.tabs.map((tab) => (
<TerminalTabButton
key={tab.id}
tab={tab}
isActive={tab.id === terminalState.activeTabId}
onClick={() => setActiveTerminalTab(tab.id)}
onClose={() => removeTerminalTab(tab.id)}
isDropTarget={activeDragId !== null}
/>
))}
{/* New tab drop zone (visible when dragging) */}
{activeDragId && (
<NewTabDropZone isDropTarget={true} />
)}
{/* New tab button */}
<button
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
onClick={createTerminalInNewTab}
title="New Tab"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* Toolbar buttons */}
<div className="flex items-center gap-1 pl-2 border-l border-border">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal("horizontal")}
title="Split Right"
>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal("vertical")}
title="Split Down"
>
<SplitSquareVertical className="h-4 w-4" />
</Button>
</div>
</div>
{/* Active tab content */}
<div className="flex-1 overflow-hidden bg-background">
{activeTab?.layout ? (
renderPanelContent(activeTab.layout)
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<p className="text-muted-foreground mb-4">This tab is empty</p>
<Button
variant="outline"
size="sm"
onClick={() => createTerminal()}
>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
</div>
)}
</div>
</div>
{/* Drag overlay */}
<DragOverlay dropAnimation={null} zIndex={1000}>
{activeDragId ? (
<div className="relative inline-flex items-center gap-2 px-3.5 py-2 bg-card border-2 border-brand-500 rounded-lg shadow-xl pointer-events-none overflow-hidden">
<TerminalIcon className="h-4 w-4 text-brand-500 shrink-0" />
<span className="text-sm font-medium text-foreground whitespace-nowrap">
{dragOverTabId === "new"
? "New tab"
: dragOverTabId
? "Move to tab"
: "Terminal"}
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -0,0 +1,624 @@
"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import {
X,
SplitSquareHorizontal,
SplitSquareVertical,
GripHorizontal,
Terminal,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { useAppStore } from "@/store/app-store";
import { getTerminalTheme } from "@/config/terminal-themes";
// Font size constraints
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 32;
const DEFAULT_FONT_SIZE = 14;
interface TerminalPanelProps {
sessionId: string;
authToken: string | null;
isActive: boolean;
onFocus: () => void;
onClose: () => void;
onSplitHorizontal: () => void;
onSplitVertical: () => void;
isDragging?: boolean;
isDropTarget?: boolean;
fontSize: number;
onFontSizeChange: (size: number) => void;
}
// Type for xterm Terminal - we'll use any since we're dynamically importing
type XTerminal = InstanceType<typeof import("@xterm/xterm").Terminal>;
type XFitAddon = InstanceType<typeof import("@xterm/addon-fit").FitAddon>;
export function TerminalPanel({
sessionId,
authToken,
isActive,
onFocus,
onClose,
onSplitHorizontal,
onSplitVertical,
isDragging = false,
isDropTarget = false,
fontSize,
onFontSizeChange,
}: TerminalPanelProps) {
const terminalRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerminal | null>(null);
const fitAddonRef = useRef<XFitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastShortcutTimeRef = useRef<number>(0);
const [isTerminalReady, setIsTerminalReady] = useState(false);
const [shellName, setShellName] = useState("shell");
// Get effective theme from store
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
const effectiveTheme = getEffectiveTheme();
// Use refs for callbacks and values to avoid effect re-runs
const onFocusRef = useRef(onFocus);
onFocusRef.current = onFocus;
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const onSplitHorizontalRef = useRef(onSplitHorizontal);
onSplitHorizontalRef.current = onSplitHorizontal;
const onSplitVerticalRef = useRef(onSplitVertical);
onSplitVerticalRef.current = onSplitVertical;
const fontSizeRef = useRef(fontSize);
fontSizeRef.current = fontSize;
const themeRef = useRef(effectiveTheme);
themeRef.current = effectiveTheme;
// Zoom functions - use the prop callback
const zoomIn = useCallback(() => {
onFontSizeChange(Math.min(fontSize + 1, MAX_FONT_SIZE));
}, [fontSize, onFontSizeChange]);
const zoomOut = useCallback(() => {
onFontSizeChange(Math.max(fontSize - 1, MIN_FONT_SIZE));
}, [fontSize, onFontSizeChange]);
const resetZoom = useCallback(() => {
onFontSizeChange(DEFAULT_FONT_SIZE);
}, [onFontSizeChange]);
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const wsUrl = serverUrl.replace(/^http/, "ws");
// Draggable - only the drag handle triggers drag
const {
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef: setDragRef,
} = useDraggable({
id: sessionId,
});
// Droppable - the entire panel is a drop target
const {
setNodeRef: setDropRef,
isOver,
} = useDroppable({
id: sessionId,
});
// Initialize terminal - dynamically import xterm to avoid SSR issues
useEffect(() => {
if (!terminalRef.current) return;
let mounted = true;
const initTerminal = async () => {
// Dynamically import xterm modules
const [
{ Terminal },
{ FitAddon },
{ WebglAddon },
] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-webgl"),
]);
// Also import CSS
await import("@xterm/xterm/css/xterm.css");
if (!mounted || !terminalRef.current) return;
// Get terminal theme matching the app theme
const terminalTheme = getTerminalTheme(themeRef.current);
// Create terminal instance with the current global font size and theme
const terminal = new Terminal({
cursorBlink: true,
cursorStyle: "block",
fontSize: fontSizeRef.current,
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
theme: terminalTheme,
allowProposedApi: true,
});
// Create fit addon
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
// Open terminal
terminal.open(terminalRef.current);
// Try to load WebGL addon for better performance
try {
const webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
webglAddon.dispose();
});
terminal.loadAddon(webglAddon);
} catch {
console.warn("[Terminal] WebGL addon not available, falling back to canvas");
}
// Fit terminal to container
setTimeout(() => {
fitAddon.fit();
}, 0);
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
setIsTerminalReady(true);
// Handle focus - use ref to avoid re-running effect
terminal.onData(() => {
onFocusRef.current();
});
// Custom key handler to intercept terminal shortcuts
// Return false to prevent xterm from handling the key
const SHORTCUT_COOLDOWN_MS = 300; // Prevent rapid firing
terminal.attachCustomKeyEventHandler((event) => {
// Only intercept keydown events
if (event.type !== 'keydown') return true;
// Check cooldown to prevent rapid terminal creation
const now = Date.now();
const canTrigger = now - lastShortcutTimeRef.current > SHORTCUT_COOLDOWN_MS;
// Use event.code for keyboard-layout-independent key detection
const code = event.code;
// Alt+D - Split right
if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyD') {
event.preventDefault();
if (canTrigger) {
lastShortcutTimeRef.current = now;
onSplitHorizontalRef.current();
}
return false;
}
// Alt+S - Split down
if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyS') {
event.preventDefault();
if (canTrigger) {
lastShortcutTimeRef.current = now;
onSplitVerticalRef.current();
}
return false;
}
// Alt+W - Close terminal
if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyW') {
event.preventDefault();
if (canTrigger) {
lastShortcutTimeRef.current = now;
onCloseRef.current();
}
return false;
}
// Let xterm handle all other keys
return true;
});
};
initTerminal();
// Cleanup
return () => {
mounted = false;
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
setIsTerminalReady(false);
};
}, []); // No dependencies - only run once on mount
// Connect WebSocket - wait for terminal to be ready
useEffect(() => {
if (!isTerminalReady || !sessionId) return;
const terminal = xtermRef.current;
if (!terminal) return;
const connect = () => {
// Build WebSocket URL with token
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
if (authToken) {
url += `&token=${encodeURIComponent(authToken)}`;
}
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
console.log(`[Terminal] WebSocket connected for session ${sessionId}`);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "data":
terminal.write(msg.data);
break;
case "scrollback":
// Replay scrollback buffer (previous terminal output)
if (msg.data) {
terminal.write(msg.data);
}
break;
case "connected":
console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`);
if (msg.shell) {
// Extract shell name from path (e.g., "/bin/bash" -> "bash")
const name = msg.shell.split("/").pop() || msg.shell;
setShellName(name);
}
break;
case "exit":
terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`);
break;
case "pong":
// Heartbeat response
break;
}
} catch (err) {
console.error("[Terminal] Message parse error:", err);
}
};
ws.onclose = (event) => {
console.log(`[Terminal] WebSocket closed for session ${sessionId}:`, event.code, event.reason);
wsRef.current = null;
// Don't reconnect if closed normally or auth failed
if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
return;
}
// Attempt reconnect after a delay
reconnectTimeoutRef.current = setTimeout(() => {
if (xtermRef.current) {
console.log(`[Terminal] Attempting reconnect for session ${sessionId}`);
connect();
}
}, 2000);
};
ws.onerror = (error) => {
console.error(`[Terminal] WebSocket error for session ${sessionId}:`, error);
};
};
connect();
// Handle terminal input
const dataHandler = terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "input", data }));
}
});
// Cleanup
return () => {
dataHandler.dispose();
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [sessionId, authToken, wsUrl, isTerminalReady]);
// Handle resize
const handleResize = useCallback(() => {
if (fitAddonRef.current && xtermRef.current) {
fitAddonRef.current.fit();
const { cols, rows } = xtermRef.current;
// Send resize to server
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
}, []);
// Resize observer
useEffect(() => {
const container = terminalRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver(() => {
handleResize();
});
resizeObserver.observe(container);
// Also handle window resize
window.addEventListener("resize", handleResize);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", handleResize);
};
}, [handleResize]);
// Focus terminal when becoming active
useEffect(() => {
if (isActive && xtermRef.current) {
xtermRef.current.focus();
}
}, [isActive]);
// Update terminal font size when it changes
useEffect(() => {
if (xtermRef.current && isTerminalReady) {
xtermRef.current.options.fontSize = fontSize;
// Refit after font size change
if (fitAddonRef.current) {
fitAddonRef.current.fit();
// Notify server of new dimensions
const { cols, rows } = xtermRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
}
}, [fontSize, isTerminalReady]);
// Update terminal theme when app theme changes
useEffect(() => {
if (xtermRef.current && isTerminalReady) {
const terminalTheme = getTerminalTheme(effectiveTheme);
xtermRef.current.options.theme = terminalTheme;
}
}, [effectiveTheme, isTerminalReady]);
// Handle keyboard shortcuts for zoom (Ctrl+Plus, Ctrl+Minus, Ctrl+0)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle if Ctrl (or Cmd on Mac) is pressed
if (!e.ctrlKey && !e.metaKey) return;
// Ctrl/Cmd + Plus or Ctrl/Cmd + = (for keyboards without numpad)
if (e.key === "+" || e.key === "=") {
e.preventDefault();
e.stopPropagation();
zoomIn();
return;
}
// Ctrl/Cmd + Minus
if (e.key === "-") {
e.preventDefault();
e.stopPropagation();
zoomOut();
return;
}
// Ctrl/Cmd + 0 to reset
if (e.key === "0") {
e.preventDefault();
e.stopPropagation();
resetZoom();
return;
}
};
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [zoomIn, zoomOut, resetZoom]);
// Handle mouse wheel zoom (Ctrl+Wheel)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleWheel = (e: WheelEvent) => {
// Only zoom if Ctrl (or Cmd on Mac) is pressed
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
e.stopPropagation();
if (e.deltaY < 0) {
// Scroll up = zoom in
zoomIn();
} else if (e.deltaY > 0) {
// Scroll down = zoom out
zoomOut();
}
};
// Use passive: false to allow preventDefault
container.addEventListener("wheel", handleWheel, { passive: false });
return () => container.removeEventListener("wheel", handleWheel);
}, [zoomIn, zoomOut]);
// Combine refs for the container
const setRefs = useCallback((node: HTMLDivElement | null) => {
containerRef.current = node;
setDropRef(node);
}, [setDropRef]);
// Get current terminal theme for xterm styling
const currentTerminalTheme = getTerminalTheme(effectiveTheme);
return (
<div
ref={setRefs}
className={cn(
"flex flex-col h-full relative",
isActive && "ring-1 ring-brand-500 ring-inset",
// Visual feedback when dragging this terminal
isDragging && "opacity-50",
// Visual feedback when hovering over as drop target
isOver && isDropTarget && "ring-2 ring-green-500 ring-inset"
)}
onClick={onFocus}
tabIndex={0}
data-terminal-container="true"
>
{/* Drop indicator overlay */}
{isOver && isDropTarget && (
<div className="absolute inset-0 bg-green-500/10 z-10 pointer-events-none flex items-center justify-center">
<div className="px-3 py-2 bg-green-500/90 rounded-md text-white text-sm font-medium">
Drop to swap
</div>
</div>
)}
{/* Header bar with drag handle - uses app theme CSS variables */}
<div className="flex items-center h-7 px-1 shrink-0 bg-card border-b border-border">
{/* Drag handle */}
<button
ref={setDragRef}
{...dragAttributes}
{...dragListeners}
className={cn(
"p-1 rounded cursor-grab active:cursor-grabbing mr-1 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent",
isDragging && "cursor-grabbing"
)}
title="Drag to swap terminals"
>
<GripHorizontal className="h-3 w-3" />
</button>
{/* Terminal icon and label */}
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<Terminal className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="text-xs truncate text-foreground">
{shellName}
</span>
{/* Font size indicator - only show when not default */}
{fontSize !== DEFAULT_FONT_SIZE && (
<button
onClick={(e) => {
e.stopPropagation();
resetZoom();
}}
className="text-[10px] px-1 rounded transition-colors text-muted-foreground hover:text-foreground hover:bg-accent"
title="Click to reset zoom (Ctrl+0)"
>
{fontSize}px
</button>
)}
</div>
{/* Zoom and action buttons */}
<div className="flex items-center gap-0.5">
{/* Zoom controls */}
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
zoomOut();
}}
title="Zoom Out (Ctrl+-)"
disabled={fontSize <= MIN_FONT_SIZE}
>
<ZoomOut className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
zoomIn();
}}
title="Zoom In (Ctrl++)"
disabled={fontSize >= MAX_FONT_SIZE}
>
<ZoomIn className="h-3 w-3" />
</Button>
<div className="w-px h-3 mx-0.5 bg-border" />
{/* Split/close buttons */}
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSplitHorizontal();
}}
title="Split Right (Cmd+D)"
>
<SplitSquareHorizontal className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSplitVertical();
}}
title="Split Down (Cmd+Shift+D)"
>
<SplitSquareVertical className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
title="Close Terminal (Cmd+W)"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{/* Terminal container - uses terminal theme */}
<div
ref={terminalRef}
className="flex-1 overflow-hidden"
style={{ backgroundColor: currentTerminalTheme.background }}
/>
</div>
);
}

View File

@@ -0,0 +1,479 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
ChevronDown,
ChevronRight,
Rocket,
Layers,
Sparkles,
GitBranch,
FolderTree,
Component,
Settings,
PlayCircle,
Bot,
LayoutGrid,
FileText,
Terminal,
Palette,
Keyboard,
Cpu,
Zap,
Image,
TestTube,
Brain,
Users,
} from "lucide-react";
interface WikiSection {
id: string;
title: string;
icon: React.ElementType;
content: React.ReactNode;
}
function CollapsibleSection({
section,
isOpen,
onToggle,
}: {
section: WikiSection;
isOpen: boolean;
onToggle: () => void;
}) {
const Icon = section.icon;
return (
<div className="border border-border rounded-lg overflow-hidden bg-card/50 backdrop-blur-sm">
<button
onClick={onToggle}
className="w-full flex items-center gap-3 p-4 text-left hover:bg-accent/50 transition-colors"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-brand-500/10 text-brand-500">
<Icon className="w-4 h-4" />
</div>
<span className="flex-1 font-medium text-foreground">{section.title}</span>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</button>
{isOpen && (
<div className="px-4 pb-4 pt-0 border-t border-border/50">
<div className="pt-4 text-sm text-muted-foreground leading-relaxed">
{section.content}
</div>
</div>
)}
</div>
);
}
function CodeBlock({ children, title }: { children: string; title?: string }) {
return (
<div className="my-3 rounded-lg overflow-hidden border border-border">
{title && (
<div className="px-3 py-1.5 bg-muted/50 border-b border-border text-xs font-medium text-muted-foreground">
{title}
</div>
)}
<pre className="p-3 bg-muted/30 overflow-x-auto text-xs font-mono text-foreground">
{children}
</pre>
</div>
);
}
function FeatureList({ items }: { items: { icon: React.ElementType; title: string; description: string }[] }) {
return (
<div className="grid gap-3 mt-3">
{items.map((item, index) => {
const ItemIcon = item.icon;
return (
<div key={index} className="flex items-start gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
<div className="flex items-center justify-center w-6 h-6 rounded bg-brand-500/10 text-brand-500 shrink-0 mt-0.5">
<ItemIcon className="w-3.5 h-3.5" />
</div>
<div>
<div className="font-medium text-foreground text-sm">{item.title}</div>
<div className="text-xs text-muted-foreground mt-0.5">{item.description}</div>
</div>
</div>
);
})}
</div>
);
}
export function WikiView() {
const [openSections, setOpenSections] = useState<Set<string>>(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: (
<div className="space-y-3">
<p>
<strong className="text-foreground">Automaker</strong> is an autonomous AI development studio that helps developers build software faster using AI agents.
</p>
<p>
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.
</p>
<div className="p-3 rounded-lg bg-brand-500/10 border border-brand-500/20 mt-4">
<p className="text-brand-400 text-sm">
Think of it as having a team of AI developers that can work on multiple features simultaneously while you focus on the bigger picture.
</p>
</div>
</div>
),
},
{
id: "architecture",
title: "Architecture",
icon: Layers,
content: (
<div className="space-y-3">
<p>Automaker is built as a monorepo with two main applications:</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li>
<strong className="text-foreground">apps/app</strong> - Next.js + Electron frontend for the desktop application
</li>
<li>
<strong className="text-foreground">apps/server</strong> - Express backend handling API requests and agent orchestration
</li>
</ul>
<div className="mt-4 space-y-2">
<p className="font-medium text-foreground">Key Technologies:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Electron wraps Next.js for cross-platform desktop support</li>
<li>Real-time communication via WebSocket for live agent updates</li>
<li>State management with Zustand for reactive UI updates</li>
<li>Claude Agent SDK for AI capabilities</li>
</ul>
</div>
</div>
),
},
{
id: "features",
title: "Key Features",
icon: Sparkles,
content: (
<div>
<FeatureList
items={[
{
icon: LayoutGrid,
title: "Kanban Board",
description: "4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.",
},
{
icon: Bot,
title: "AI Agent Integration",
description: "Powered by Claude via the Agent SDK with full file, bash, and git access.",
},
{
icon: Cpu,
title: "Multi-Model Support",
description: "Claude Haiku/Sonnet/Opus + OpenAI Codex models. Choose the right model for each task.",
},
{
icon: Brain,
title: "Extended Thinking",
description: "Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.",
},
{
icon: Zap,
title: "Real-time Streaming",
description: "Watch AI agents work in real-time with live output streaming.",
},
{
icon: GitBranch,
title: "Git Worktree Isolation",
description: "Each feature runs in its own git worktree for safe parallel development.",
},
{
icon: Users,
title: "AI Profiles",
description: "Pre-configured model + thinking level combinations for different task types.",
},
{
icon: Terminal,
title: "Integrated Terminal",
description: "Built-in terminal with tab support and split panes.",
},
{
icon: Keyboard,
title: "Keyboard Shortcuts",
description: "Fully customizable shortcuts for power users.",
},
{
icon: Palette,
title: "14 Themes",
description: "From light to dark, retro to synthwave - pick your style.",
},
{
icon: Image,
title: "Image Support",
description: "Attach images to features for visual context.",
},
{
icon: TestTube,
title: "Test Integration",
description: "Automatic test running and TDD support for quality assurance.",
},
]}
/>
</div>
),
},
{
id: "data-flow",
title: "How It Works (Data Flow)",
icon: GitBranch,
content: (
<div className="space-y-3">
<p>Here's what happens when you use Automaker to implement a feature:</p>
<ol className="list-decimal list-inside space-y-3 ml-2 mt-4">
<li className="text-foreground">
<strong>Create Feature</strong>
<p className="text-muted-foreground ml-5 mt-1">Add a new feature card to the Kanban board with description and steps</p>
</li>
<li className="text-foreground">
<strong>Feature Saved</strong>
<p className="text-muted-foreground ml-5 mt-1">Feature saved to <code className="px-1 py-0.5 bg-muted rounded text-xs">.automaker/features/&#123;id&#125;/feature.json</code></p>
</li>
<li className="text-foreground">
<strong>Start Work</strong>
<p className="text-muted-foreground ml-5 mt-1">Drag to "In Progress" or enable auto mode to start implementation</p>
</li>
<li className="text-foreground">
<strong>Git Worktree Created</strong>
<p className="text-muted-foreground ml-5 mt-1">Backend AutoModeService creates isolated git worktree (if enabled)</p>
</li>
<li className="text-foreground">
<strong>Agent Executes</strong>
<p className="text-muted-foreground ml-5 mt-1">Claude Agent SDK runs with file/bash/git tool access</p>
</li>
<li className="text-foreground">
<strong>Progress Streamed</strong>
<p className="text-muted-foreground ml-5 mt-1">Real-time updates via WebSocket as agent works</p>
</li>
<li className="text-foreground">
<strong>Completion</strong>
<p className="text-muted-foreground ml-5 mt-1">On success, feature moves to "waiting_approval" for your review</p>
</li>
<li className="text-foreground">
<strong>Verify</strong>
<p className="text-muted-foreground ml-5 mt-1">Review changes and move to "verified" when satisfied</p>
</li>
</ol>
</div>
),
},
{
id: "structure",
title: "Project Structure",
icon: FolderTree,
content: (
<div>
<p className="mb-3">The Automaker codebase is organized as follows:</p>
<CodeBlock title="Directory Structure">
{`/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`}
</CodeBlock>
</div>
),
},
{
id: "components",
title: "Key Components",
icon: Component,
content: (
<div className="space-y-3">
<p>The main UI components that make up Automaker:</p>
<div className="grid gap-2 mt-4">
{[
{ 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) => (
<div key={item.file} className="flex items-center gap-3 p-2 rounded bg-muted/30 border border-border/50">
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">{item.file}</code>
<span className="text-xs text-muted-foreground">{item.desc}</span>
</div>
))}
</div>
</div>
),
},
{
id: "configuration",
title: "Configuration",
icon: Settings,
content: (
<div className="space-y-3">
<p>Automaker stores project configuration in the <code className="px-1 py-0.5 bg-muted rounded text-xs">.automaker/</code> directory:</p>
<div className="grid gap-2 mt-4">
{[
{ 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) => (
<div key={item.file} className="flex items-center gap-3 p-2 rounded bg-muted/30 border border-border/50">
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">{item.file}</code>
<span className="text-xs text-muted-foreground">{item.desc}</span>
</div>
))}
</div>
<div className="mt-4 p-3 rounded-lg bg-muted/30 border border-border/50">
<p className="text-sm text-foreground font-medium mb-2">Tip: App Spec Best Practices</p>
<ul className="list-disc list-inside space-y-1 text-xs text-muted-foreground">
<li>Include your tech stack and key dependencies</li>
<li>Describe the project structure and conventions</li>
<li>List any important patterns or architectural decisions</li>
<li>Note testing requirements and coding standards</li>
</ul>
</div>
</div>
),
},
{
id: "getting-started",
title: "Getting Started",
icon: PlayCircle,
content: (
<div className="space-y-3">
<p>Follow these steps to start building with Automaker:</p>
<ol className="list-decimal list-inside space-y-4 ml-2 mt-4">
<li className="text-foreground">
<strong>Create or Open a Project</strong>
<p className="text-muted-foreground ml-5 mt-1">Use the sidebar to create a new project or open an existing folder</p>
</li>
<li className="text-foreground">
<strong>Write an App Spec</strong>
<p className="text-muted-foreground ml-5 mt-1">Go to Spec Editor and describe your project. This helps AI understand your codebase.</p>
</li>
<li className="text-foreground">
<strong>Add Context (Optional)</strong>
<p className="text-muted-foreground ml-5 mt-1">Add relevant documentation or examples to the Context view for better AI results</p>
</li>
<li className="text-foreground">
<strong>Create Features</strong>
<p className="text-muted-foreground ml-5 mt-1">Add feature cards to your Kanban board with clear descriptions and implementation steps</p>
</li>
<li className="text-foreground">
<strong>Configure AI Profile</strong>
<p className="text-muted-foreground ml-5 mt-1">Choose an AI profile or customize model/thinking settings per feature</p>
</li>
<li className="text-foreground">
<strong>Start Implementation</strong>
<p className="text-muted-foreground ml-5 mt-1">Drag features to "In Progress" or enable auto mode to let AI work</p>
</li>
<li className="text-foreground">
<strong>Review and Verify</strong>
<p className="text-muted-foreground ml-5 mt-1">Check completed features, review changes, and mark as verified</p>
</li>
</ol>
<div className="mt-6 p-4 rounded-lg bg-brand-500/10 border border-brand-500/20">
<p className="text-brand-400 text-sm font-medium mb-2">Pro Tips:</p>
<ul className="list-disc list-inside space-y-1 text-xs text-brand-400/80">
<li>Use keyboard shortcuts for faster navigation (press <code className="px-1 py-0.5 bg-brand-500/20 rounded">?</code> to see all)</li>
<li>Enable git worktree isolation for parallel feature development</li>
<li>Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work</li>
<li>Keep your app spec up to date as your project evolves</li>
</ul>
</div>
</div>
),
},
];
return (
<div className="flex-1 flex flex-col overflow-hidden bg-background">
{/* Header */}
<div className="border-b border-border bg-card/30 backdrop-blur-sm px-6 py-4 shrink-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-foreground">Wiki</h1>
<p className="text-sm text-muted-foreground mt-1">
Learn how Automaker works and how to use it effectively
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={expandAll}
className="px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
>
Expand All
</button>
<button
onClick={collapseAll}
className="px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
>
Collapse All
</button>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-6 py-6 space-y-3">
{sections.map((section) => (
<CollapsibleSection
key={section.id}
section={section}
isOpen={openSections.has(section.id)}
onToggle={() => toggleSection(section.id)}
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,393 @@
/**
* Terminal themes that match the app themes
* Each theme provides colors for xterm.js terminal emulator
*/
import type { ThemeMode } from "@/store/app-store";
export interface TerminalTheme {
background: string;
foreground: string;
cursor: string;
cursorAccent: string;
selectionBackground: string;
selectionForeground?: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
}
// Dark theme (default)
const darkTheme: TerminalTheme = {
background: "#0a0a0a",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
cursorAccent: "#0a0a0a",
selectionBackground: "#264f78",
black: "#1e1e1e",
red: "#f44747",
green: "#6a9955",
yellow: "#dcdcaa",
blue: "#569cd6",
magenta: "#c586c0",
cyan: "#4ec9b0",
white: "#d4d4d4",
brightBlack: "#808080",
brightRed: "#f44747",
brightGreen: "#6a9955",
brightYellow: "#dcdcaa",
brightBlue: "#569cd6",
brightMagenta: "#c586c0",
brightCyan: "#4ec9b0",
brightWhite: "#ffffff",
};
// Light theme
const lightTheme: TerminalTheme = {
background: "#ffffff",
foreground: "#383a42",
cursor: "#383a42",
cursorAccent: "#ffffff",
selectionBackground: "#add6ff",
black: "#383a42",
red: "#e45649",
green: "#50a14f",
yellow: "#c18401",
blue: "#4078f2",
magenta: "#a626a4",
cyan: "#0184bc",
white: "#fafafa",
brightBlack: "#4f525e",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#e5c07b",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
};
// Retro / Cyberpunk theme - neon green on black
const retroTheme: TerminalTheme = {
background: "#000000",
foreground: "#39ff14",
cursor: "#39ff14",
cursorAccent: "#000000",
selectionBackground: "#39ff14",
selectionForeground: "#000000",
black: "#000000",
red: "#ff0055",
green: "#39ff14",
yellow: "#ffff00",
blue: "#00ffff",
magenta: "#ff00ff",
cyan: "#00ffff",
white: "#39ff14",
brightBlack: "#555555",
brightRed: "#ff5555",
brightGreen: "#55ff55",
brightYellow: "#ffff55",
brightBlue: "#55ffff",
brightMagenta: "#ff55ff",
brightCyan: "#55ffff",
brightWhite: "#ffffff",
};
// Dracula theme
const draculaTheme: TerminalTheme = {
background: "#282a36",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#282a36",
selectionBackground: "#44475a",
black: "#21222c",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#bd93f9",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#f8f8f2",
brightBlack: "#6272a4",
brightRed: "#ff6e6e",
brightGreen: "#69ff94",
brightYellow: "#ffffa5",
brightBlue: "#d6acff",
brightMagenta: "#ff92df",
brightCyan: "#a4ffff",
brightWhite: "#ffffff",
};
// Nord theme
const nordTheme: TerminalTheme = {
background: "#2e3440",
foreground: "#d8dee9",
cursor: "#d8dee9",
cursorAccent: "#2e3440",
selectionBackground: "#434c5e",
black: "#3b4252",
red: "#bf616a",
green: "#a3be8c",
yellow: "#ebcb8b",
blue: "#81a1c1",
magenta: "#b48ead",
cyan: "#88c0d0",
white: "#e5e9f0",
brightBlack: "#4c566a",
brightRed: "#bf616a",
brightGreen: "#a3be8c",
brightYellow: "#ebcb8b",
brightBlue: "#81a1c1",
brightMagenta: "#b48ead",
brightCyan: "#8fbcbb",
brightWhite: "#eceff4",
};
// Monokai theme
const monokaiTheme: TerminalTheme = {
background: "#272822",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#272822",
selectionBackground: "#49483e",
black: "#272822",
red: "#f92672",
green: "#a6e22e",
yellow: "#f4bf75",
blue: "#66d9ef",
magenta: "#ae81ff",
cyan: "#a1efe4",
white: "#f8f8f2",
brightBlack: "#75715e",
brightRed: "#f92672",
brightGreen: "#a6e22e",
brightYellow: "#f4bf75",
brightBlue: "#66d9ef",
brightMagenta: "#ae81ff",
brightCyan: "#a1efe4",
brightWhite: "#f9f8f5",
};
// Tokyo Night theme
const tokyonightTheme: TerminalTheme = {
background: "#1a1b26",
foreground: "#a9b1d6",
cursor: "#c0caf5",
cursorAccent: "#1a1b26",
selectionBackground: "#33467c",
black: "#15161e",
red: "#f7768e",
green: "#9ece6a",
yellow: "#e0af68",
blue: "#7aa2f7",
magenta: "#bb9af7",
cyan: "#7dcfff",
white: "#a9b1d6",
brightBlack: "#414868",
brightRed: "#f7768e",
brightGreen: "#9ece6a",
brightYellow: "#e0af68",
brightBlue: "#7aa2f7",
brightMagenta: "#bb9af7",
brightCyan: "#7dcfff",
brightWhite: "#c0caf5",
};
// Solarized Dark theme
const solarizedTheme: TerminalTheme = {
background: "#002b36",
foreground: "#839496",
cursor: "#839496",
cursorAccent: "#002b36",
selectionBackground: "#073642",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
};
// Gruvbox Dark theme
const gruvboxTheme: TerminalTheme = {
background: "#282828",
foreground: "#ebdbb2",
cursor: "#ebdbb2",
cursorAccent: "#282828",
selectionBackground: "#504945",
black: "#282828",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#a89984",
brightBlack: "#928374",
brightRed: "#fb4934",
brightGreen: "#b8bb26",
brightYellow: "#fabd2f",
brightBlue: "#83a598",
brightMagenta: "#d3869b",
brightCyan: "#8ec07c",
brightWhite: "#ebdbb2",
};
// Catppuccin Mocha theme
const catppuccinTheme: TerminalTheme = {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "#45475a",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#cba6f7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#cba6f7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
};
// One Dark theme
const onedarkTheme: TerminalTheme = {
background: "#282c34",
foreground: "#abb2bf",
cursor: "#528bff",
cursorAccent: "#282c34",
selectionBackground: "#3e4451",
black: "#282c34",
red: "#e06c75",
green: "#98c379",
yellow: "#e5c07b",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
brightBlack: "#5c6370",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#e5c07b",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
};
// Synthwave '84 theme
const synthwaveTheme: TerminalTheme = {
background: "#262335",
foreground: "#ffffff",
cursor: "#ff7edb",
cursorAccent: "#262335",
selectionBackground: "#463465",
black: "#262335",
red: "#fe4450",
green: "#72f1b8",
yellow: "#fede5d",
blue: "#03edf9",
magenta: "#ff7edb",
cyan: "#03edf9",
white: "#ffffff",
brightBlack: "#614d85",
brightRed: "#fe4450",
brightGreen: "#72f1b8",
brightYellow: "#f97e72",
brightBlue: "#03edf9",
brightMagenta: "#ff7edb",
brightCyan: "#03edf9",
brightWhite: "#ffffff",
};
// Red theme - Dark theme with red accents
const redTheme: TerminalTheme = {
background: "#1a0a0a",
foreground: "#c8b0b0",
cursor: "#ff4444",
cursorAccent: "#1a0a0a",
selectionBackground: "#5a2020",
black: "#2a1010",
red: "#ff4444",
green: "#6a9a6a",
yellow: "#ccaa55",
blue: "#6688aa",
magenta: "#aa5588",
cyan: "#558888",
white: "#b0a0a0",
brightBlack: "#6a4040",
brightRed: "#ff6666",
brightGreen: "#88bb88",
brightYellow: "#ddbb66",
brightBlue: "#88aacc",
brightMagenta: "#cc77aa",
brightCyan: "#77aaaa",
brightWhite: "#d0c0c0",
};
// Theme mapping
const terminalThemes: Record<ThemeMode, TerminalTheme> = {
light: lightTheme,
dark: darkTheme,
system: darkTheme, // Will be resolved at runtime
retro: retroTheme,
dracula: draculaTheme,
nord: nordTheme,
monokai: monokaiTheme,
tokyonight: tokyonightTheme,
solarized: solarizedTheme,
gruvbox: gruvboxTheme,
catppuccin: catppuccinTheme,
onedark: onedarkTheme,
synthwave: synthwaveTheme,
red: redTheme,
};
/**
* Get terminal theme for the given app theme
* For "system" theme, it checks the user's system preference
*/
export function getTerminalTheme(theme: ThemeMode): TerminalTheme {
if (theme === "system") {
// Check system preference
if (typeof window !== "undefined") {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return prefersDark ? darkTheme : lightTheme;
}
return darkTheme; // Default to dark for SSR
}
return terminalThemes[theme] || darkTheme;
}
export default terminalThemes;

View File

@@ -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"]'

View File

@@ -12,7 +12,9 @@ export type ViewMode =
| "interview"
| "context"
| "profiles"
| "running-agents";
| "running-agents"
| "terminal"
| "wiki";
export type ThemeMode =
| "light"
@@ -47,7 +49,8 @@ export interface ShortcutKey {
}
// Helper to parse shortcut string to ShortcutKey object
export function parseShortcut(shortcut: string): ShortcutKey {
export function parseShortcut(shortcut: string | undefined | null): ShortcutKey {
if (!shortcut) return { key: "" };
const parts = shortcut.split("+").map((p) => p.trim());
const result: ShortcutKey = { key: parts[parts.length - 1] };
@@ -79,7 +82,8 @@ export function parseShortcut(shortcut: string): ShortcutKey {
}
// Helper to format ShortcutKey to display string
export function formatShortcut(shortcut: string, forDisplay = false): string {
export function formatShortcut(shortcut: string | undefined | null, forDisplay = false): string {
if (!shortcut) return "";
const parsed = parseShortcut(shortcut);
const parts: string[] = [];
@@ -144,6 +148,7 @@ export interface KeyboardShortcuts {
context: string;
settings: string;
profiles: string;
terminal: string;
// UI shortcuts
toggleSidebar: string;
@@ -158,6 +163,11 @@ export interface KeyboardShortcuts {
cyclePrevProject: string;
cycleNextProject: string;
addProfile: string;
// Terminal shortcuts
splitTerminalRight: string;
splitTerminalDown: string;
closeTerminal: string;
}
// Default keyboard shortcuts
@@ -169,6 +179,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
context: "C",
settings: "S",
profiles: "M",
terminal: "Cmd+`",
// UI
toggleSidebar: "`",
@@ -185,6 +196,12 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
cyclePrevProject: "Q", // Global shortcut
cycleNextProject: "E", // Global shortcut
addProfile: "N", // Only active in profiles view
// Terminal shortcuts (only active in terminal view)
// Using Alt modifier to avoid conflicts with both terminal signals AND browser shortcuts
splitTerminalRight: "Alt+D",
splitTerminalDown: "Alt+S",
closeTerminal: "Alt+W",
};
export interface ImageAttachment {
@@ -297,6 +314,27 @@ export interface ProjectAnalysis {
analyzedAt: string;
}
// Terminal panel layout types (recursive for splits)
export type TerminalPanelContent =
| { type: "terminal"; sessionId: string; size?: number; fontSize?: number }
| { type: "split"; direction: "horizontal" | "vertical"; panels: TerminalPanelContent[]; size?: number };
// Terminal tab - each tab has its own layout
export interface TerminalTab {
id: string;
name: string;
layout: TerminalPanelContent | null;
}
export interface TerminalState {
isUnlocked: boolean;
authToken: string | null;
tabs: TerminalTab[];
activeTabId: string | null;
activeSessionId: string | null;
defaultFontSize: number; // Default font size for new terminals
}
export interface AppState {
// Project state
projects: Project[];
@@ -386,6 +424,9 @@ export interface AppState {
// Theme Preview (for hover preview in theme selectors)
previewTheme: ThemeMode | null;
// Terminal state
terminalState: TerminalState;
}
// Default background settings for board backgrounds
@@ -565,6 +606,21 @@ export interface AppActions {
setHideScrollbar: (projectPath: string, hide: boolean) => void;
clearBoardBackground: (projectPath: string) => void;
// Terminal actions
setTerminalUnlocked: (unlocked: boolean, token?: string) => void;
setActiveTerminalSession: (sessionId: string | null) => void;
addTerminalToLayout: (sessionId: string, direction?: "horizontal" | "vertical", targetSessionId?: string) => void;
removeTerminalFromLayout: (sessionId: string) => void;
swapTerminals: (sessionId1: string, sessionId2: string) => void;
clearTerminalState: () => void;
setTerminalPanelFontSize: (sessionId: string, fontSize: number) => void;
addTerminalTab: (name?: string) => string;
removeTerminalTab: (tabId: string) => void;
setActiveTerminalTab: (tabId: string) => void;
renameTerminalTab: (tabId: string, name: string) => void;
moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => void;
addTerminalToTab: (sessionId: string, tabId: string, direction?: "horizontal" | "vertical") => void;
// Reset
reset: () => void;
}
@@ -670,6 +726,14 @@ const initialState: AppState = {
isAnalyzing: false,
boardBackgroundByProject: {},
previewTheme: null,
terminalState: {
isUnlocked: false,
authToken: null,
tabs: [],
activeTabId: null,
activeSessionId: null,
defaultFontSize: 14,
},
};
export const useAppStore = create<AppState & AppActions>()(
@@ -1483,6 +1547,464 @@ export const useAppStore = create<AppState & AppActions>()(
});
},
// Terminal actions
setTerminalUnlocked: (unlocked, token) => {
set({
terminalState: {
...get().terminalState,
isUnlocked: unlocked,
authToken: token || null,
},
});
},
setActiveTerminalSession: (sessionId) => {
set({
terminalState: {
...get().terminalState,
activeSessionId: sessionId,
},
});
},
addTerminalToLayout: (sessionId, direction = "horizontal", targetSessionId) => {
const current = get().terminalState;
const newTerminal: TerminalPanelContent = { type: "terminal", sessionId, size: 50 };
// If no tabs, create first tab
if (current.tabs.length === 0) {
const newTabId = `tab-${Date.now()}`;
set({
terminalState: {
...current,
tabs: [{ id: newTabId, name: "Terminal 1", layout: { type: "terminal", sessionId, size: 100 } }],
activeTabId: newTabId,
activeSessionId: sessionId,
},
});
return;
}
// Add to active tab's layout
const activeTab = current.tabs.find(t => t.id === current.activeTabId);
if (!activeTab) return;
// If targetSessionId is provided, find and split that specific terminal
const splitTargetTerminal = (
node: TerminalPanelContent,
targetId: string,
targetDirection: "horizontal" | "vertical"
): TerminalPanelContent => {
if (node.type === "terminal") {
if (node.sessionId === targetId) {
// Found the target - split it
return {
type: "split",
direction: targetDirection,
panels: [{ ...node, size: 50 }, newTerminal],
};
}
// Not the target, return unchanged
return node;
}
// It's a split - recurse into panels
return {
...node,
panels: node.panels.map(p => splitTargetTerminal(p, targetId, targetDirection)),
};
};
// Legacy behavior: add to root layout (when no targetSessionId)
const addToRootLayout = (
node: TerminalPanelContent,
targetDirection: "horizontal" | "vertical"
): TerminalPanelContent => {
if (node.type === "terminal") {
return {
type: "split",
direction: targetDirection,
panels: [{ ...node, size: 50 }, newTerminal],
};
}
// If same direction, add to existing split
if (node.direction === targetDirection) {
const newSize = 100 / (node.panels.length + 1);
return {
...node,
panels: [...node.panels.map(p => ({ ...p, size: newSize })), { ...newTerminal, size: newSize }],
};
}
// Different direction, wrap in new split
return {
type: "split",
direction: targetDirection,
panels: [{ ...node, size: 50 }, newTerminal],
};
};
let newLayout: TerminalPanelContent;
if (!activeTab.layout) {
newLayout = { type: "terminal", sessionId, size: 100 };
} else if (targetSessionId) {
newLayout = splitTargetTerminal(activeTab.layout, targetSessionId, direction);
} else {
newLayout = addToRootLayout(activeTab.layout, direction);
}
const newTabs = current.tabs.map(t =>
t.id === current.activeTabId ? { ...t, layout: newLayout } : t
);
set({
terminalState: {
...current,
tabs: newTabs,
activeSessionId: sessionId,
},
});
},
removeTerminalFromLayout: (sessionId) => {
const current = get().terminalState;
if (current.tabs.length === 0) return;
// Find which tab contains this session
const findFirstTerminal = (node: TerminalPanelContent | null): string | null => {
if (!node) return null;
if (node.type === "terminal") return node.sessionId;
for (const panel of node.panels) {
const found = findFirstTerminal(panel);
if (found) return found;
}
return null;
};
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
if (node.type === "terminal") {
return node.sessionId === sessionId ? null : node;
}
const newPanels: TerminalPanelContent[] = [];
for (const panel of node.panels) {
const result = removeAndCollapse(panel);
if (result !== null) newPanels.push(result);
}
if (newPanels.length === 0) return null;
if (newPanels.length === 1) return newPanels[0];
return { ...node, panels: newPanels };
};
let newTabs = current.tabs.map(tab => {
if (!tab.layout) return tab;
const newLayout = removeAndCollapse(tab.layout);
return { ...tab, layout: newLayout };
});
// Remove empty tabs
newTabs = newTabs.filter(tab => tab.layout !== null);
// Determine new active session
const newActiveTabId = newTabs.length > 0 ? (current.activeTabId && newTabs.find(t => t.id === current.activeTabId) ? current.activeTabId : newTabs[0].id) : null;
const newActiveSessionId = newActiveTabId
? findFirstTerminal(newTabs.find(t => t.id === newActiveTabId)?.layout || null)
: null;
set({
terminalState: {
...current,
tabs: newTabs,
activeTabId: newActiveTabId,
activeSessionId: newActiveSessionId,
},
});
},
swapTerminals: (sessionId1, sessionId2) => {
const current = get().terminalState;
if (current.tabs.length === 0) return;
const swapInLayout = (node: TerminalPanelContent): TerminalPanelContent => {
if (node.type === "terminal") {
if (node.sessionId === sessionId1) return { ...node, sessionId: sessionId2 };
if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 };
return node;
}
return { ...node, panels: node.panels.map(swapInLayout) };
};
const newTabs = current.tabs.map(tab => ({
...tab,
layout: tab.layout ? swapInLayout(tab.layout) : null,
}));
set({
terminalState: { ...current, tabs: newTabs },
});
},
clearTerminalState: () => {
set({
terminalState: {
isUnlocked: false,
authToken: null,
tabs: [],
activeTabId: null,
activeSessionId: null,
defaultFontSize: 14,
},
});
},
setTerminalPanelFontSize: (sessionId, fontSize) => {
const current = get().terminalState;
const clampedSize = Math.max(8, Math.min(32, fontSize));
const updateFontSize = (node: TerminalPanelContent): TerminalPanelContent => {
if (node.type === "terminal") {
if (node.sessionId === sessionId) {
return { ...node, fontSize: clampedSize };
}
return node;
}
return { ...node, panels: node.panels.map(updateFontSize) };
};
const newTabs = current.tabs.map(tab => {
if (!tab.layout) return tab;
return { ...tab, layout: updateFontSize(tab.layout) };
});
set({
terminalState: { ...current, tabs: newTabs },
});
},
addTerminalTab: (name) => {
const current = get().terminalState;
const newTabId = `tab-${Date.now()}`;
const tabNumber = current.tabs.length + 1;
const newTab: TerminalTab = { id: newTabId, name: name || `Terminal ${tabNumber}`, layout: null };
set({
terminalState: {
...current,
tabs: [...current.tabs, newTab],
activeTabId: newTabId,
},
});
return newTabId;
},
removeTerminalTab: (tabId) => {
const current = get().terminalState;
const newTabs = current.tabs.filter(t => t.id !== tabId);
let newActiveTabId = current.activeTabId;
let newActiveSessionId = current.activeSessionId;
if (current.activeTabId === tabId) {
newActiveTabId = newTabs.length > 0 ? newTabs[0].id : null;
if (newActiveTabId) {
const newActiveTab = newTabs.find(t => t.id === newActiveTabId);
const findFirst = (node: TerminalPanelContent): string | null => {
if (node.type === "terminal") return node.sessionId;
for (const p of node.panels) {
const f = findFirst(p);
if (f) return f;
}
return null;
};
newActiveSessionId = newActiveTab?.layout ? findFirst(newActiveTab.layout) : null;
} else {
newActiveSessionId = null;
}
}
set({
terminalState: { ...current, tabs: newTabs, activeTabId: newActiveTabId, activeSessionId: newActiveSessionId },
});
},
setActiveTerminalTab: (tabId) => {
const current = get().terminalState;
const tab = current.tabs.find(t => t.id === tabId);
if (!tab) return;
let newActiveSessionId = current.activeSessionId;
if (tab.layout) {
const findFirst = (node: TerminalPanelContent): string | null => {
if (node.type === "terminal") return node.sessionId;
for (const p of node.panels) {
const f = findFirst(p);
if (f) return f;
}
return null;
};
newActiveSessionId = findFirst(tab.layout);
}
set({
terminalState: { ...current, activeTabId: tabId, activeSessionId: newActiveSessionId },
});
},
renameTerminalTab: (tabId, name) => {
const current = get().terminalState;
const newTabs = current.tabs.map(t => t.id === tabId ? { ...t, name } : t);
set({
terminalState: { ...current, tabs: newTabs },
});
},
moveTerminalToTab: (sessionId, targetTabId) => {
const current = get().terminalState;
let sourceTabId: string | null = null;
let originalTerminalNode: (TerminalPanelContent & { type: "terminal" }) | null = null;
const findTerminal = (node: TerminalPanelContent): (TerminalPanelContent & { type: "terminal" }) | null => {
if (node.type === "terminal") {
return node.sessionId === sessionId ? node : null;
}
for (const panel of node.panels) {
const found = findTerminal(panel);
if (found) return found;
}
return null;
};
for (const tab of current.tabs) {
if (tab.layout) {
const found = findTerminal(tab.layout);
if (found) {
sourceTabId = tab.id;
originalTerminalNode = found;
break;
}
}
}
if (!sourceTabId || !originalTerminalNode) return;
if (sourceTabId === targetTabId) return;
const sourceTab = current.tabs.find(t => t.id === sourceTabId);
if (!sourceTab?.layout) return;
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
if (node.type === "terminal") {
return node.sessionId === sessionId ? null : node;
}
const newPanels: TerminalPanelContent[] = [];
for (const panel of node.panels) {
const result = removeAndCollapse(panel);
if (result !== null) newPanels.push(result);
}
if (newPanels.length === 0) return null;
if (newPanels.length === 1) return newPanels[0];
return { ...node, panels: newPanels };
};
const newSourceLayout = removeAndCollapse(sourceTab.layout);
let finalTargetTabId = targetTabId;
let newTabs = current.tabs;
if (targetTabId === "new") {
const newTabId = `tab-${Date.now()}`;
const sourceWillBeRemoved = !newSourceLayout;
const tabName = sourceWillBeRemoved ? sourceTab.name : `Terminal ${current.tabs.length + 1}`;
newTabs = [
...current.tabs,
{ id: newTabId, name: tabName, layout: { type: "terminal", sessionId, size: 100, fontSize: originalTerminalNode.fontSize } },
];
finalTargetTabId = newTabId;
} else {
const targetTab = current.tabs.find(t => t.id === targetTabId);
if (!targetTab) return;
const terminalNode: TerminalPanelContent = { type: "terminal", sessionId, size: 50, fontSize: originalTerminalNode.fontSize };
let newTargetLayout: TerminalPanelContent;
if (!targetTab.layout) {
newTargetLayout = { type: "terminal", sessionId, size: 100, fontSize: originalTerminalNode.fontSize };
} else if (targetTab.layout.type === "terminal") {
newTargetLayout = {
type: "split",
direction: "horizontal",
panels: [{ ...targetTab.layout, size: 50 }, terminalNode],
};
} else {
newTargetLayout = {
...targetTab.layout,
panels: [...targetTab.layout.panels, terminalNode],
};
}
newTabs = current.tabs.map(t =>
t.id === targetTabId ? { ...t, layout: newTargetLayout } : t
);
}
if (!newSourceLayout) {
newTabs = newTabs.filter(t => t.id !== sourceTabId);
} else {
newTabs = newTabs.map(t =>
t.id === sourceTabId ? { ...t, layout: newSourceLayout } : t
);
}
set({
terminalState: {
...current,
tabs: newTabs,
activeTabId: finalTargetTabId,
activeSessionId: sessionId,
},
});
},
addTerminalToTab: (sessionId, tabId, direction = "horizontal") => {
const current = get().terminalState;
const tab = current.tabs.find(t => t.id === tabId);
if (!tab) return;
const terminalNode: TerminalPanelContent = { type: "terminal", sessionId, size: 50 };
let newLayout: TerminalPanelContent;
if (!tab.layout) {
newLayout = { type: "terminal", sessionId, size: 100 };
} else if (tab.layout.type === "terminal") {
newLayout = {
type: "split",
direction,
panels: [{ ...tab.layout, size: 50 }, terminalNode],
};
} else {
if (tab.layout.direction === direction) {
const newSize = 100 / (tab.layout.panels.length + 1);
newLayout = {
...tab.layout,
panels: [...tab.layout.panels.map(p => ({ ...p, size: newSize })), { ...terminalNode, size: newSize }],
};
} else {
newLayout = {
type: "split",
direction,
panels: [{ ...tab.layout, size: 50 }, terminalNode],
};
}
}
const newTabs = current.tabs.map(t =>
t.id === tabId ? { ...t, layout: newLayout } : t
);
set({
terminalState: {
...current,
tabs: newTabs,
activeTabId: tabId,
activeSessionId: sessionId,
},
});
},
// Reset
reset: () => set(initialState),
}),

9
apps/app/src/types/css.d.ts vendored Normal file
View File

@@ -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;
}

View File

@@ -47,3 +47,14 @@ OPENAI_API_KEY=
# Google API key (for future Gemini support)
GOOGLE_API_KEY=
# ============================================
# OPTIONAL - Terminal Access
# ============================================
# Enable/disable terminal access (default: true)
TERMINAL_ENABLED=true
# Password to protect terminal access (leave empty for no password)
# If set, users must enter this password before accessing terminal
TERMINAL_PASSWORD=

View File

@@ -22,6 +22,7 @@
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"node-pty": "1.1.0-beta41",
"ws": "^8.18.0"
},
"devDependencies": {

View File

@@ -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<string, Set<WebSocket>> = 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);

View File

@@ -355,6 +355,9 @@ Format your response as markdown. Be specific and actionable.`;
} else if (msg.type === "result" && (msg as any).subtype === "success") {
console.log("[SpecRegeneration] Received success result");
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === "error") {
console.error("[SpecRegeneration] ❌ Received error message from stream:");
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
}
}
} catch (streamError) {
@@ -502,6 +505,9 @@ Generate 5-15 features that build on each other logically.`;
} else if (msg.type === "result" && (msg as any).subtype === "success") {
console.log("[SpecRegeneration] Received success result for features");
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === "error") {
console.error("[SpecRegeneration] ❌ Received error message from feature stream:");
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
}
}
} catch (streamError) {

View File

@@ -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<string, { createdAt: Date; expiresAt: Date }> = 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;
}

View File

@@ -0,0 +1,401 @@
/**
* Terminal Service
*
* Manages PTY (pseudo-terminal) sessions using node-pty.
* Supports cross-platform shell detection including WSL.
*/
import * as pty from "node-pty";
import { EventEmitter } from "events";
import * as os from "os";
import * as fs from "fs";
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
// Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 16; // ~60fps max update rate
const OUTPUT_BATCH_SIZE = 8192; // Max bytes to send per batch
export interface TerminalSession {
id: string;
pty: pty.IPty;
cwd: string;
createdAt: Date;
shell: string;
scrollbackBuffer: string; // Store recent output for replay on reconnect
outputBuffer: string; // Pending output to be flushed
flushTimeout: NodeJS.Timeout | null; // Throttle timer
}
export interface TerminalOptions {
cwd?: string;
shell?: string;
cols?: number;
rows?: number;
env?: Record<string, string>;
}
type DataCallback = (sessionId: string, data: string) => void;
type ExitCallback = (sessionId: string, exitCode: number) => void;
export class TerminalService extends EventEmitter {
private sessions: Map<string, TerminalSession> = new Map();
private dataCallbacks: Set<DataCallback> = new Set();
private exitCallbacks: Set<ExitCallback> = 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<string, string> = {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
TERM_PROGRAM: "automaker-terminal",
...options.env,
};
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
const ptyProcess = pty.spawn(shell, shellArgs, {
name: "xterm-256color",
cols: options.cols || 80,
rows: options.rows || 24,
cwd,
env,
});
const session: TerminalSession = {
id,
pty: ptyProcess,
cwd,
createdAt: new Date(),
shell,
scrollbackBuffer: "",
outputBuffer: "",
flushTimeout: null,
};
this.sessions.set(id, session);
// Flush buffered output to clients (throttled)
const flushOutput = () => {
if (session.outputBuffer.length === 0) return;
// Send in batches if buffer is large
let dataToSend = session.outputBuffer;
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
// Schedule another flush for remaining data
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
} else {
session.outputBuffer = "";
session.flushTimeout = null;
}
this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
this.emit("data", id, dataToSend);
};
// Forward data events with throttling
ptyProcess.onData((data) => {
// Append to scrollback buffer
session.scrollbackBuffer += data;
// Trim if too large (keep the most recent data)
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
}
// Buffer output for throttled delivery
session.outputBuffer += data;
// Schedule flush if not already scheduled
if (!session.flushTimeout) {
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
}
});
// Handle exit
ptyProcess.onExit(({ exitCode }) => {
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
this.emit("exit", id, exitCode);
});
console.log(`[Terminal] Session ${id} created successfully`);
return session;
}
/**
* Write data to a terminal session
*/
write(sessionId: string, data: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
console.warn(`[Terminal] Session ${sessionId} not found`);
return false;
}
session.pty.write(data);
return true;
}
/**
* Resize a terminal session
*/
resize(sessionId: string, cols: number, rows: number): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
return false;
}
try {
session.pty.resize(cols, rows);
return true;
} catch (error) {
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
return false;
}
}
/**
* Kill a terminal session
*/
killSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
try {
// Clean up flush timeout
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
session.flushTimeout = null;
}
session.pty.kill();
this.sessions.delete(sessionId);
console.log(`[Terminal] Session ${sessionId} killed`);
return true;
} catch (error) {
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
return false;
}
}
/**
* Get a session by ID
*/
getSession(sessionId: string): TerminalSession | undefined {
return this.sessions.get(sessionId);
}
/**
* Get scrollback buffer for a session (for replay on reconnect)
*/
getScrollback(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
return session?.scrollbackBuffer || null;
}
/**
* Get all active sessions
*/
getAllSessions(): Array<{
id: string;
cwd: string;
createdAt: Date;
shell: string;
}> {
return Array.from(this.sessions.values()).map((s) => ({
id: s.id,
cwd: s.cwd,
createdAt: s.createdAt,
shell: s.shell,
}));
}
/**
* Subscribe to data events
*/
onData(callback: DataCallback): () => void {
this.dataCallbacks.add(callback);
return () => this.dataCallbacks.delete(callback);
}
/**
* Subscribe to exit events
*/
onExit(callback: ExitCallback): () => void {
this.exitCallbacks.add(callback);
return () => this.exitCallbacks.delete(callback);
}
/**
* Clean up all sessions
*/
cleanup(): void {
console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`);
this.sessions.forEach((session, id) => {
try {
// Clean up flush timeout
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
}
session.pty.kill();
} catch {
// Ignore errors during cleanup
}
this.sessions.delete(id);
});
}
}
// Singleton instance
let terminalService: TerminalService | null = null;
export function getTerminalService(): TerminalService {
if (!terminalService) {
terminalService = new TerminalService();
}
return terminalService;
}

93
docs/terminal.md Normal file
View File

@@ -0,0 +1,93 @@
# Terminal
The integrated terminal provides a full-featured terminal emulator within Automaker, powered by xterm.js.
## Configuration
Configure the terminal via environment variables in `apps/server/.env`:
### Disable Terminal Completely
```
TERMINAL_ENABLED=false
```
Set to `false` to completely disable the terminal feature.
### Password Protection
```
TERMINAL_PASSWORD=yourpassword
```
By default, the terminal is **not password protected**. Add this variable to require a password.
When password protection is enabled:
- Enter the password in **Settings > Terminal** to unlock
- The terminal remains unlocked for the session
- You can toggle password requirement on/off in settings after unlocking
## Keyboard Shortcuts
When the terminal is focused, the following shortcuts are available:
| Shortcut | Action |
|----------|--------|
| `Alt+D` | Split terminal right (horizontal split) |
| `Alt+S` | Split terminal down (vertical split) |
| `Alt+W` | Close current terminal |
Global shortcut (works anywhere in the app):
| Shortcut | Action |
|----------|--------|
| `Cmd+`` (Mac) / `Ctrl+`` (Windows/Linux) | Toggle terminal view |
## Features
### Multiple Terminals
- Create multiple terminal tabs using the `+` button
- Split terminals horizontally or vertically within a tab
- Drag terminals to rearrange them
### Theming
The terminal automatically matches your app theme. Supported themes include:
- Light / Dark / System
- Retro, Dracula, Nord, Monokai
- Tokyo Night, Solarized, Gruvbox
- Catppuccin, One Dark, Synthwave, Red
### Font Size
- Use the zoom controls (`+`/`-` buttons) in each terminal panel
- Or use `Cmd/Ctrl + Scroll` to zoom
### Scrollback
- The terminal maintains a scrollback buffer of recent output
- Scroll up to view previous output
- Output is preserved when reconnecting
## Architecture
The terminal uses a client-server architecture:
1. **Frontend** (`apps/app`): xterm.js terminal emulator with WebGL rendering
2. **Backend** (`apps/server`): node-pty for PTY (pseudo-terminal) sessions
Communication happens over WebSocket for real-time bidirectional data flow.
### Shell Detection
The server automatically detects the best shell:
- **WSL**: User's shell or `/bin/bash`
- **macOS**: User's shell, zsh, or bash
- **Linux**: User's shell, bash, or sh
- **Windows**: PowerShell 7, PowerShell, or cmd.exe
## Troubleshooting
### Terminal not connecting
1. Ensure the server is running (`npm run dev:server`)
2. Check that port 3008 is available
3. Verify the terminal is unlocked
### Slow performance with heavy output
The terminal throttles output at ~60fps to prevent UI lockup. Very fast output (like `cat` on large files) will be batched.
### Shortcuts not working
- Ensure the terminal is focused (click inside it)
- Some system shortcuts may conflict (especially Alt+Shift combinations on Windows)

55
package-lock.json generated
View File

@@ -30,6 +30,9 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -40,6 +43,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
@@ -10256,6 +10260,7 @@
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"node-pty": "1.1.0-beta41",
"ws": "^8.18.0"
},
"devDependencies": {
@@ -11948,6 +11953,30 @@
"@types/node": "*"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-webgl": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
"integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/@zeit/schemas": {
"version": "2.36.0",
"resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz",
@@ -13517,6 +13546,22 @@
}
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.1.0-beta41",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta41.tgz",
"integrity": "sha512-OUT29KMnzh1IS0b2YcUwVz56D4iAXDsl2PtIKP3zHMljiUBq2WcaHEFfhzQfgkhWs2SExcXvfdlBPANDVU9SnQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@@ -13765,6 +13810,16 @@
"react": "^19.2.0"
}
},
"node_modules/react-resizable-panels": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
"integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==",
"license": "MIT",
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/registry-auth-token": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz",