mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge branch 'main' into feat/add-unit-testing
This commit is contained in:
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
697
apps/app/src/components/views/terminal-view.tsx
Normal file
697
apps/app/src/components/views/terminal-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
624
apps/app/src/components/views/terminal-view/terminal-panel.tsx
Normal file
624
apps/app/src/components/views/terminal-view/terminal-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
479
apps/app/src/components/views/wiki-view.tsx
Normal file
479
apps/app/src/components/views/wiki-view.tsx
Normal 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/{id}/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>
|
||||
);
|
||||
}
|
||||
393
apps/app/src/config/terminal-themes.ts
Normal file
393
apps/app/src/config/terminal-themes.ts
Normal 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;
|
||||
@@ -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"]'
|
||||
|
||||
@@ -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
9
apps/app/src/types/css.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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=
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
312
apps/server/src/routes/terminal.ts
Normal file
312
apps/server/src/routes/terminal.ts
Normal 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;
|
||||
}
|
||||
401
apps/server/src/services/terminal-service.ts
Normal file
401
apps/server/src/services/terminal-service.ts
Normal 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
93
docs/terminal.md
Normal 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
55
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user