mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
Merge branch 'main' into feat/add-unit-testing
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { spawn } = require("child_process");
|
const { spawn } = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
// Load environment variables from .env file
|
// Load environment variables from .env file
|
||||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
||||||
@@ -30,10 +31,39 @@ function getIconPath() {
|
|||||||
async function startServer() {
|
async function startServer() {
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
|
|
||||||
// Server entry point
|
// Server entry point - use tsx in dev, compiled version in production
|
||||||
const serverPath = isDev
|
let command, args, serverPath;
|
||||||
? path.join(__dirname, "../../server/dist/index.js")
|
if (isDev) {
|
||||||
: path.join(process.resourcesPath, "server", "index.js");
|
// 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
|
// Set environment variables for server
|
||||||
const env = {
|
const env = {
|
||||||
@@ -44,7 +74,7 @@ async function startServer() {
|
|||||||
|
|
||||||
console.log("[Electron] Starting backend server...");
|
console.log("[Electron] Starting backend server...");
|
||||||
|
|
||||||
serverProcess = spawn("node", [serverPath], {
|
serverProcess = spawn(command, args, {
|
||||||
env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -52,6 +55,7 @@
|
|||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
|
|||||||
@@ -1177,12 +1177,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for dark themes */
|
/* 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;
|
width: 8px;
|
||||||
height: 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);
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,6 +1204,20 @@
|
|||||||
background: var(--background);
|
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 */
|
/* Always visible scrollbar for file diffs and code blocks */
|
||||||
.scrollbar-visible {
|
.scrollbar-visible {
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { ContextView } from "@/components/views/context-view";
|
|||||||
import { ProfilesView } from "@/components/views/profiles-view";
|
import { ProfilesView } from "@/components/views/profiles-view";
|
||||||
import { SetupView } from "@/components/views/setup-view";
|
import { SetupView } from "@/components/views/setup-view";
|
||||||
import { RunningAgentsView } from "@/components/views/running-agents-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 { useAppStore } from "@/store/app-store";
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
import { useSetupStore } from "@/store/setup-store";
|
||||||
import { getElectronAPI, isElectron } from "@/lib/electron";
|
import { getElectronAPI, isElectron } from "@/lib/electron";
|
||||||
@@ -206,6 +208,10 @@ function HomeContent() {
|
|||||||
return <ProfilesView />;
|
return <ProfilesView />;
|
||||||
case "running-agents":
|
case "running-agents":
|
||||||
return <RunningAgentsView />;
|
return <RunningAgentsView />;
|
||||||
|
case "terminal":
|
||||||
|
return <TerminalView />;
|
||||||
|
case "wiki":
|
||||||
|
return <WikiView />;
|
||||||
default:
|
default:
|
||||||
return <WelcomeView />;
|
return <WelcomeView />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import {
|
|||||||
Bug,
|
Bug,
|
||||||
Activity,
|
Activity,
|
||||||
Recycle,
|
Recycle,
|
||||||
|
Sparkles,
|
||||||
|
Loader2,
|
||||||
|
Terminal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -72,7 +75,6 @@ import {
|
|||||||
hasAutomakerDir,
|
hasAutomakerDir,
|
||||||
} from "@/lib/project-init";
|
} from "@/lib/project-init";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Sparkles, Loader2 } from "lucide-react";
|
|
||||||
import { themeOptions } from "@/config/theme-options";
|
import { themeOptions } from "@/config/theme-options";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import type { SpecRegenerationEvent } from "@/types/electron";
|
import type { SpecRegenerationEvent } from "@/types/electron";
|
||||||
@@ -609,6 +611,12 @@ export function Sidebar() {
|
|||||||
icon: UserCircle,
|
icon: UserCircle,
|
||||||
shortcut: shortcuts.profiles,
|
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">
|
<div className="border-t border-sidebar-border bg-sidebar-accent/10 shrink-0">
|
||||||
{/* Course Promo Badge */}
|
{/* Course Promo Badge */}
|
||||||
<CoursePromoBadge sidebarOpen={sidebarOpen} />
|
<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 */}
|
{/* Running Agents Link */}
|
||||||
<div className="p-2 pb-0">
|
<div className="p-2 pb-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
|||||||
context: "Context",
|
context: "Context",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
profiles: "AI Profiles",
|
profiles: "AI Profiles",
|
||||||
|
terminal: "Terminal",
|
||||||
toggleSidebar: "Toggle Sidebar",
|
toggleSidebar: "Toggle Sidebar",
|
||||||
addFeature: "Add Feature",
|
addFeature: "Add Feature",
|
||||||
addContextFile: "Add Context File",
|
addContextFile: "Add Context File",
|
||||||
@@ -100,6 +101,9 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
|||||||
cyclePrevProject: "Prev Project",
|
cyclePrevProject: "Prev Project",
|
||||||
cycleNextProject: "Next Project",
|
cycleNextProject: "Next Project",
|
||||||
addProfile: "Add Profile",
|
addProfile: "Add Profile",
|
||||||
|
splitTerminalRight: "Split Right",
|
||||||
|
splitTerminalDown: "Split Down",
|
||||||
|
closeTerminal: "Close Terminal",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Categorize shortcuts for color coding
|
// Categorize shortcuts for color coding
|
||||||
@@ -110,6 +114,7 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" |
|
|||||||
context: "navigation",
|
context: "navigation",
|
||||||
settings: "navigation",
|
settings: "navigation",
|
||||||
profiles: "navigation",
|
profiles: "navigation",
|
||||||
|
terminal: "navigation",
|
||||||
toggleSidebar: "ui",
|
toggleSidebar: "ui",
|
||||||
addFeature: "action",
|
addFeature: "action",
|
||||||
addContextFile: "action",
|
addContextFile: "action",
|
||||||
@@ -120,6 +125,9 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" |
|
|||||||
cyclePrevProject: "action",
|
cyclePrevProject: "action",
|
||||||
cycleNextProject: "action",
|
cycleNextProject: "action",
|
||||||
addProfile: "action",
|
addProfile: "action",
|
||||||
|
splitTerminalRight: "action",
|
||||||
|
splitTerminalDown: "action",
|
||||||
|
closeTerminal: "action",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Category colors
|
// Category colors
|
||||||
@@ -153,11 +161,18 @@ interface KeyboardMapProps {
|
|||||||
export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMapProps) {
|
export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMapProps) {
|
||||||
const { keyboardShortcuts } = useAppStore();
|
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)
|
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
|
||||||
const keyToShortcuts = React.useMemo(() => {
|
const keyToShortcuts = React.useMemo(() => {
|
||||||
const map: Record<string, Array<{ name: keyof KeyboardShortcuts; hasModifiers: boolean }>> = {};
|
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]) => {
|
([shortcutName, shortcutStr]) => {
|
||||||
|
if (!shortcutStr) return; // Skip undefined shortcuts
|
||||||
const parsed = parseShortcut(shortcutStr);
|
const parsed = parseShortcut(shortcutStr);
|
||||||
const normalizedKey = parsed.key.toUpperCase();
|
const normalizedKey = parsed.key.toUpperCase();
|
||||||
const hasModifiers = !!(parsed.shift || parsed.cmdCtrl || parsed.alt);
|
const hasModifiers = !!(parsed.shift || parsed.cmdCtrl || parsed.alt);
|
||||||
@@ -168,7 +183,7 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
return map;
|
return map;
|
||||||
}, [keyboardShortcuts]);
|
}, [mergedShortcuts]);
|
||||||
|
|
||||||
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
|
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
|
||||||
const normalizedKey = keyDef.key.toUpperCase();
|
const normalizedKey = keyDef.key.toUpperCase();
|
||||||
@@ -177,7 +192,7 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
|||||||
const isBound = shortcuts.length > 0;
|
const isBound = shortcuts.length > 0;
|
||||||
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
|
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
|
||||||
const isModified = shortcuts.some(
|
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)
|
// 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
|
{isBound && shortcuts.length > 0
|
||||||
? (shortcuts.length === 1
|
? (shortcuts.length === 1
|
||||||
? SHORTCUT_LABELS[shortcuts[0]].split(" ")[0]
|
? (SHORTCUT_LABELS[shortcuts[0]]?.split(" ")[0] ?? shortcuts[0])
|
||||||
: `${shortcuts.length}x`)
|
: `${shortcuts.length}x`)
|
||||||
: "\u00A0" // Non-breaking space to maintain height
|
: "\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">
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{shortcuts.map((shortcut) => {
|
{shortcuts.map((shortcut) => {
|
||||||
const shortcutStr = keyboardShortcuts[shortcut];
|
const shortcutStr = mergedShortcuts[shortcut];
|
||||||
const displayShortcut = formatShortcut(shortcutStr, true);
|
const displayShortcut = formatShortcut(shortcutStr, true);
|
||||||
return (
|
return (
|
||||||
<div key={shortcut} className="flex items-center gap-2">
|
<div key={shortcut} className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-2 h-2 rounded-full",
|
"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">
|
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
|
||||||
{displayShortcut}
|
{displayShortcut}
|
||||||
</kbd>
|
</kbd>
|
||||||
{keyboardShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
|
{mergedShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
|
||||||
<span className="text-xs text-yellow-400">(custom)</span>
|
<span className="text-xs text-yellow-400">(custom)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -343,6 +360,12 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
|||||||
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
|
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
|
||||||
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
|
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 groupedShortcuts = React.useMemo(() => {
|
||||||
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
|
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
|
||||||
navigation: [],
|
navigation: [],
|
||||||
@@ -354,14 +377,14 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
|||||||
([shortcut, category]) => {
|
([shortcut, category]) => {
|
||||||
groups[category].push({
|
groups[category].push({
|
||||||
key: shortcut,
|
key: shortcut,
|
||||||
label: SHORTCUT_LABELS[shortcut],
|
label: SHORTCUT_LABELS[shortcut] ?? shortcut,
|
||||||
value: keyboardShortcuts[shortcut],
|
value: mergedShortcuts[shortcut],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}, [keyboardShortcuts]);
|
}, [mergedShortcuts]);
|
||||||
|
|
||||||
// Build the full shortcut string from key + modifiers
|
// Build the full shortcut string from key + modifiers
|
||||||
const buildShortcutString = React.useCallback((key: string, mods: typeof 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
|
// Check for conflicts with other shortcuts
|
||||||
const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
|
const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
|
||||||
const conflict = Object.entries(keyboardShortcuts).find(
|
const conflict = Object.entries(mergedShortcuts).find(
|
||||||
([k, v]) => k !== currentKey && v.toUpperCase() === shortcutStr.toUpperCase()
|
([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase()
|
||||||
);
|
);
|
||||||
return conflict ? SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] : null;
|
return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null;
|
||||||
}, [keyboardShortcuts]);
|
}, [mergedShortcuts]);
|
||||||
|
|
||||||
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
|
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
|
||||||
const currentValue = keyboardShortcuts[key];
|
const currentValue = mergedShortcuts[key];
|
||||||
const parsed = parseShortcut(currentValue);
|
const parsed = parseShortcut(currentValue);
|
||||||
setEditingShortcut(key);
|
setEditingShortcut(key);
|
||||||
setKeyValue(parsed.key);
|
setKeyValue(parsed.key);
|
||||||
@@ -485,7 +508,7 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{shortcuts.map(({ key, label, value }) => {
|
{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;
|
const isEditing = editingShortcut === key;
|
||||||
|
|
||||||
return (
|
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;
|
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
|
// Check for autocomplete/typeahead dropdowns being open
|
||||||
const autocompleteList = document.querySelector(
|
const autocompleteList = document.querySelector(
|
||||||
'[data-testid="category-autocomplete-list"]'
|
'[data-testid="category-autocomplete-list"]'
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export type ViewMode =
|
|||||||
| "interview"
|
| "interview"
|
||||||
| "context"
|
| "context"
|
||||||
| "profiles"
|
| "profiles"
|
||||||
| "running-agents";
|
| "running-agents"
|
||||||
|
| "terminal"
|
||||||
|
| "wiki";
|
||||||
|
|
||||||
export type ThemeMode =
|
export type ThemeMode =
|
||||||
| "light"
|
| "light"
|
||||||
@@ -47,7 +49,8 @@ export interface ShortcutKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to parse shortcut string to ShortcutKey object
|
// 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 parts = shortcut.split("+").map((p) => p.trim());
|
||||||
const result: ShortcutKey = { key: parts[parts.length - 1] };
|
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
|
// 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 parsed = parseShortcut(shortcut);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
@@ -144,6 +148,7 @@ export interface KeyboardShortcuts {
|
|||||||
context: string;
|
context: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
profiles: string;
|
profiles: string;
|
||||||
|
terminal: string;
|
||||||
|
|
||||||
// UI shortcuts
|
// UI shortcuts
|
||||||
toggleSidebar: string;
|
toggleSidebar: string;
|
||||||
@@ -158,6 +163,11 @@ export interface KeyboardShortcuts {
|
|||||||
cyclePrevProject: string;
|
cyclePrevProject: string;
|
||||||
cycleNextProject: string;
|
cycleNextProject: string;
|
||||||
addProfile: string;
|
addProfile: string;
|
||||||
|
|
||||||
|
// Terminal shortcuts
|
||||||
|
splitTerminalRight: string;
|
||||||
|
splitTerminalDown: string;
|
||||||
|
closeTerminal: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default keyboard shortcuts
|
// Default keyboard shortcuts
|
||||||
@@ -169,6 +179,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|||||||
context: "C",
|
context: "C",
|
||||||
settings: "S",
|
settings: "S",
|
||||||
profiles: "M",
|
profiles: "M",
|
||||||
|
terminal: "Cmd+`",
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
toggleSidebar: "`",
|
toggleSidebar: "`",
|
||||||
@@ -185,6 +196,12 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|||||||
cyclePrevProject: "Q", // Global shortcut
|
cyclePrevProject: "Q", // Global shortcut
|
||||||
cycleNextProject: "E", // Global shortcut
|
cycleNextProject: "E", // Global shortcut
|
||||||
addProfile: "N", // Only active in profiles view
|
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 {
|
export interface ImageAttachment {
|
||||||
@@ -297,6 +314,27 @@ export interface ProjectAnalysis {
|
|||||||
analyzedAt: string;
|
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 {
|
export interface AppState {
|
||||||
// Project state
|
// Project state
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
@@ -386,6 +424,9 @@ export interface AppState {
|
|||||||
|
|
||||||
// Theme Preview (for hover preview in theme selectors)
|
// Theme Preview (for hover preview in theme selectors)
|
||||||
previewTheme: ThemeMode | null;
|
previewTheme: ThemeMode | null;
|
||||||
|
|
||||||
|
// Terminal state
|
||||||
|
terminalState: TerminalState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default background settings for board backgrounds
|
// Default background settings for board backgrounds
|
||||||
@@ -565,6 +606,21 @@ export interface AppActions {
|
|||||||
setHideScrollbar: (projectPath: string, hide: boolean) => void;
|
setHideScrollbar: (projectPath: string, hide: boolean) => void;
|
||||||
clearBoardBackground: (projectPath: string) => 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
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -670,6 +726,14 @@ const initialState: AppState = {
|
|||||||
isAnalyzing: false,
|
isAnalyzing: false,
|
||||||
boardBackgroundByProject: {},
|
boardBackgroundByProject: {},
|
||||||
previewTheme: null,
|
previewTheme: null,
|
||||||
|
terminalState: {
|
||||||
|
isUnlocked: false,
|
||||||
|
authToken: null,
|
||||||
|
tabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
activeSessionId: null,
|
||||||
|
defaultFontSize: 14,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppStore = create<AppState & AppActions>()(
|
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
|
||||||
reset: () => set(initialState),
|
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 (for future Gemini support)
|
||||||
GOOGLE_API_KEY=
|
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",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"node-pty": "1.1.0-beta41",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
|
|||||||
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
|
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
|
||||||
import { createWorkspaceRoutes } from "./routes/workspace.js";
|
import { createWorkspaceRoutes } from "./routes/workspace.js";
|
||||||
import { createTemplatesRoutes } from "./routes/templates.js";
|
import { createTemplatesRoutes } from "./routes/templates.js";
|
||||||
|
import { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired } from "./routes/terminal.js";
|
||||||
import { AgentService } from "./services/agent-service.js";
|
import { AgentService } from "./services/agent-service.js";
|
||||||
import { FeatureLoader } from "./services/feature-loader.js";
|
import { FeatureLoader } from "./services/feature-loader.js";
|
||||||
import { AutoModeService } from "./services/auto-mode-service.js";
|
import { AutoModeService } from "./services/auto-mode-service.js";
|
||||||
|
import { getTerminalService } from "./services/terminal-service.js";
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -116,13 +118,34 @@ app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
|
|||||||
app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
|
app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
|
||||||
app.use("/api/workspace", createWorkspaceRoutes());
|
app.use("/api/workspace", createWorkspaceRoutes());
|
||||||
app.use("/api/templates", createTemplatesRoutes());
|
app.use("/api/templates", createTemplatesRoutes());
|
||||||
|
app.use("/api/terminal", createTerminalRoutes());
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|
||||||
// WebSocket server for streaming events
|
// WebSocket servers using noServer mode for proper multi-path support
|
||||||
const wss = new WebSocketServer({ server, path: "/api/events" });
|
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) => {
|
wss.on("connection", (ws: WebSocket) => {
|
||||||
console.log("[WebSocket] Client connected");
|
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
|
// Start server
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
|
const terminalStatus = isTerminalEnabled()
|
||||||
|
? (isTerminalPasswordRequired() ? "enabled (password protected)" : "enabled")
|
||||||
|
: "disabled";
|
||||||
console.log(`
|
console.log(`
|
||||||
╔═══════════════════════════════════════════════════════╗
|
╔═══════════════════════════════════════════════════════╗
|
||||||
║ Automaker Backend Server ║
|
║ Automaker Backend Server ║
|
||||||
╠═══════════════════════════════════════════════════════╣
|
╠═══════════════════════════════════════════════════════╣
|
||||||
║ HTTP API: http://localhost:${PORT} ║
|
║ HTTP API: http://localhost:${PORT} ║
|
||||||
║ WebSocket: ws://localhost:${PORT}/api/events ║
|
║ WebSocket: ws://localhost:${PORT}/api/events ║
|
||||||
|
║ Terminal: ws://localhost:${PORT}/api/terminal/ws ║
|
||||||
║ Health: http://localhost:${PORT}/api/health ║
|
║ Health: http://localhost:${PORT}/api/health ║
|
||||||
|
║ Terminal: ${terminalStatus.padEnd(37)}║
|
||||||
╚═══════════════════════════════════════════════════════╝
|
╚═══════════════════════════════════════════════════════╝
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@@ -160,6 +321,7 @@ server.listen(PORT, () => {
|
|||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
console.log("SIGTERM received, shutting down...");
|
console.log("SIGTERM received, shutting down...");
|
||||||
|
terminalService.cleanup();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log("Server closed");
|
console.log("Server closed");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -168,6 +330,7 @@ process.on("SIGTERM", () => {
|
|||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
console.log("SIGINT received, shutting down...");
|
console.log("SIGINT received, shutting down...");
|
||||||
|
terminalService.cleanup();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log("Server closed");
|
console.log("Server closed");
|
||||||
process.exit(0);
|
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") {
|
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||||
console.log("[SpecRegeneration] Received success result");
|
console.log("[SpecRegeneration] Received success result");
|
||||||
responseText = (msg as any).result || responseText;
|
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) {
|
} 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") {
|
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||||
console.log("[SpecRegeneration] Received success result for features");
|
console.log("[SpecRegeneration] Received success result for features");
|
||||||
responseText = (msg as any).result || responseText;
|
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) {
|
} 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-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -40,6 +43,7 @@
|
|||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
@@ -10256,6 +10260,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"node-pty": "1.1.0-beta41",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -11948,6 +11953,30 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@zeit/schemas": {
|
||||||
"version": "2.36.0",
|
"version": "2.36.0",
|
||||||
"resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz",
|
"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": {
|
"node_modules/npm-run-path": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||||
@@ -13765,6 +13810,16 @@
|
|||||||
"react": "^19.2.0"
|
"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": {
|
"node_modules/registry-auth-token": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user