mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: add integrated terminal with tab system and theme support
- Add terminal view with draggable split panels and multi-tab support - Implement terminal WebSocket server with password protection - Add per-terminal font size that persists when moving between tabs - Support all 12 app themes with matching terminal colors - Add keyboard shortcut (Ctrl+`) to toggle terminal view - Include scrollback buffer for session history on reconnect 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,9 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -52,6 +55,7 @@
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.9"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ContextView } from "@/components/views/context-view";
|
||||
import { ProfilesView } from "@/components/views/profiles-view";
|
||||
import { SetupView } from "@/components/views/setup-view";
|
||||
import { RunningAgentsView } from "@/components/views/running-agents-view";
|
||||
import { TerminalView } from "@/components/views/terminal-view";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { getElectronAPI, isElectron } from "@/lib/electron";
|
||||
@@ -206,6 +207,8 @@ function HomeContent() {
|
||||
return <ProfilesView />;
|
||||
case "running-agents":
|
||||
return <RunningAgentsView />;
|
||||
case "terminal":
|
||||
return <TerminalView />;
|
||||
default:
|
||||
return <WelcomeView />;
|
||||
}
|
||||
|
||||
@@ -609,6 +609,12 @@ export function Sidebar() {
|
||||
icon: UserCircle,
|
||||
shortcut: shortcuts.profiles,
|
||||
},
|
||||
{
|
||||
id: "terminal",
|
||||
label: "Terminal",
|
||||
icon: Terminal,
|
||||
shortcut: shortcuts.terminal,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -90,6 +90,7 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
context: "Context",
|
||||
settings: "Settings",
|
||||
profiles: "AI Profiles",
|
||||
terminal: "Terminal",
|
||||
toggleSidebar: "Toggle Sidebar",
|
||||
addFeature: "Add Feature",
|
||||
addContextFile: "Add Context File",
|
||||
@@ -100,6 +101,9 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
cyclePrevProject: "Prev Project",
|
||||
cycleNextProject: "Next Project",
|
||||
addProfile: "Add Profile",
|
||||
splitTerminalRight: "Split Right",
|
||||
splitTerminalDown: "Split Down",
|
||||
closeTerminal: "Close Terminal",
|
||||
};
|
||||
|
||||
// Categorize shortcuts for color coding
|
||||
@@ -110,6 +114,7 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" |
|
||||
context: "navigation",
|
||||
settings: "navigation",
|
||||
profiles: "navigation",
|
||||
terminal: "navigation",
|
||||
toggleSidebar: "ui",
|
||||
addFeature: "action",
|
||||
addContextFile: "action",
|
||||
@@ -120,6 +125,9 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" |
|
||||
cyclePrevProject: "action",
|
||||
cycleNextProject: "action",
|
||||
addProfile: "action",
|
||||
splitTerminalRight: "action",
|
||||
splitTerminalDown: "action",
|
||||
closeTerminal: "action",
|
||||
};
|
||||
|
||||
// Category colors
|
||||
|
||||
621
apps/app/src/components/views/terminal-view.tsx
Normal file
621
apps/app/src/components/views/terminal-view.tsx
Normal file
@@ -0,0 +1,621 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } 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 { 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 serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
||||
|
||||
// Get active tab
|
||||
const activeTab = terminalState.tabs.find(t => t.id === terminalState.activeTabId);
|
||||
|
||||
// DnD sensors with activation constraint to avoid accidental drags
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveDragId(event.active.id as string);
|
||||
}, []);
|
||||
|
||||
// Handle drag over - track which tab we're hovering
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { over } = event;
|
||||
if (over?.data?.current?.type === "tab") {
|
||||
setDragOverTabId(over.data.current.tabId);
|
||||
} else if (over?.data?.current?.type === "new-tab") {
|
||||
setDragOverTabId("new");
|
||||
} else {
|
||||
setDragOverTabId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle drag end
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveDragId(null);
|
||||
setDragOverTabId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overData = over.data?.current;
|
||||
|
||||
// If dropped on a tab, move terminal to that tab
|
||||
if (overData?.type === "tab") {
|
||||
moveTerminalToTab(activeId, overData.tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If dropped on new tab zone, create new tab with this terminal
|
||||
if (overData?.type === "new-tab") {
|
||||
moveTerminalToTab(activeId, "new");
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, swap terminals within current tab
|
||||
if (active.id !== over.id) {
|
||||
swapTerminals(activeId, over.id as string);
|
||||
}
|
||||
}, [swapTerminals, moveTerminalToTab]);
|
||||
|
||||
// Fetch terminal status
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`${serverUrl}/api/terminal/status`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setStatus(data.data);
|
||||
if (!data.data.passwordRequired) {
|
||||
setTerminalUnlocked(true);
|
||||
}
|
||||
} else {
|
||||
setError(data.error || "Failed to get terminal status");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to connect to server");
|
||||
console.error("[Terminal] Status fetch error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [serverUrl, setTerminalUnlocked]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
// Handle password authentication
|
||||
const handleAuth = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setAuthLoading(true);
|
||||
setAuthError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setTerminalUnlocked(true, data.data.token);
|
||||
setPassword("");
|
||||
} else {
|
||||
setAuthError(data.error || "Authentication failed");
|
||||
}
|
||||
} catch (err) {
|
||||
setAuthError("Failed to authenticate");
|
||||
console.error("[Terminal] Auth error:", err);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new terminal session
|
||||
const createTerminal = async (direction?: "horizontal" | "vertical") => {
|
||||
try {
|
||||
const headers: Record<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);
|
||||
} else {
|
||||
console.error("[Terminal] Failed to create session:", data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Terminal] Create session error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Create terminal in new tab
|
||||
const createTerminalInNewTab = async () => {
|
||||
const tabId = addTerminalTab();
|
||||
try {
|
||||
const headers: Record<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 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")}
|
||||
onSplitVertical={() => createTerminal("vertical")}
|
||||
isDragging={activeDragId === content.sessionId}
|
||||
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
|
||||
fontSize={terminalFontSize}
|
||||
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isHorizontal = content.direction === "horizontal";
|
||||
const defaultSizePerPanel = 100 / content.panels.length;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
562
apps/app/src/components/views/terminal-view/terminal-panel.tsx
Normal file
562
apps/app/src/components/views/terminal-view/terminal-panel.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import {
|
||||
X,
|
||||
SplitSquareHorizontal,
|
||||
SplitSquareVertical,
|
||||
GripHorizontal,
|
||||
Terminal,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getTerminalTheme } from "@/config/terminal-themes";
|
||||
|
||||
// Font size constraints
|
||||
const MIN_FONT_SIZE = 8;
|
||||
const MAX_FONT_SIZE = 32;
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
|
||||
interface TerminalPanelProps {
|
||||
sessionId: string;
|
||||
authToken: string | null;
|
||||
isActive: boolean;
|
||||
onFocus: () => void;
|
||||
onClose: () => void;
|
||||
onSplitHorizontal: () => void;
|
||||
onSplitVertical: () => void;
|
||||
isDragging?: boolean;
|
||||
isDropTarget?: boolean;
|
||||
fontSize: number;
|
||||
onFontSizeChange: (size: number) => void;
|
||||
}
|
||||
|
||||
// Type for xterm Terminal - we'll use any since we're dynamically importing
|
||||
type XTerminal = InstanceType<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 [isTerminalReady, setIsTerminalReady] = useState(false);
|
||||
|
||||
// Get effective theme from store
|
||||
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
|
||||
const effectiveTheme = getEffectiveTheme();
|
||||
|
||||
// Use refs for callbacks and values to avoid effect re-runs
|
||||
const onFocusRef = useRef(onFocus);
|
||||
onFocusRef.current = onFocus;
|
||||
const fontSizeRef = useRef(fontSize);
|
||||
fontSizeRef.current = fontSize;
|
||||
const themeRef = useRef(effectiveTheme);
|
||||
themeRef.current = effectiveTheme;
|
||||
|
||||
// Zoom functions - use the prop callback
|
||||
const zoomIn = useCallback(() => {
|
||||
onFontSizeChange(Math.min(fontSize + 1, MAX_FONT_SIZE));
|
||||
}, [fontSize, onFontSizeChange]);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
onFontSizeChange(Math.max(fontSize - 1, MIN_FONT_SIZE));
|
||||
}, [fontSize, onFontSizeChange]);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
onFontSizeChange(DEFAULT_FONT_SIZE);
|
||||
}, [onFontSizeChange]);
|
||||
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
||||
const wsUrl = serverUrl.replace(/^http/, "ws");
|
||||
|
||||
// Draggable - only the drag handle triggers drag
|
||||
const {
|
||||
attributes: dragAttributes,
|
||||
listeners: dragListeners,
|
||||
setNodeRef: setDragRef,
|
||||
} = useDraggable({
|
||||
id: sessionId,
|
||||
});
|
||||
|
||||
// Droppable - the entire panel is a drop target
|
||||
const {
|
||||
setNodeRef: setDropRef,
|
||||
isOver,
|
||||
} = useDroppable({
|
||||
id: sessionId,
|
||||
});
|
||||
|
||||
// Initialize terminal - dynamically import xterm to avoid SSR issues
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const initTerminal = async () => {
|
||||
// Dynamically import xterm modules
|
||||
const [
|
||||
{ Terminal },
|
||||
{ FitAddon },
|
||||
{ WebglAddon },
|
||||
] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
import("@xterm/addon-webgl"),
|
||||
]);
|
||||
|
||||
// Also import CSS
|
||||
await import("@xterm/xterm/css/xterm.css");
|
||||
|
||||
if (!mounted || !terminalRef.current) return;
|
||||
|
||||
// Get terminal theme matching the app theme
|
||||
const terminalTheme = getTerminalTheme(themeRef.current);
|
||||
|
||||
// Create terminal instance with the current global font size and theme
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
fontSize: fontSizeRef.current,
|
||||
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
||||
theme: terminalTheme,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
// Create fit addon
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
// Open terminal
|
||||
terminal.open(terminalRef.current);
|
||||
|
||||
// Try to load WebGL addon for better performance
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
webglAddon.onContextLoss(() => {
|
||||
webglAddon.dispose();
|
||||
});
|
||||
terminal.loadAddon(webglAddon);
|
||||
} catch {
|
||||
console.warn("[Terminal] WebGL addon not available, falling back to canvas");
|
||||
}
|
||||
|
||||
// Fit terminal to container
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 0);
|
||||
|
||||
xtermRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
setIsTerminalReady(true);
|
||||
|
||||
// Handle focus - use ref to avoid re-running effect
|
||||
terminal.onData(() => {
|
||||
onFocusRef.current();
|
||||
});
|
||||
};
|
||||
|
||||
initTerminal();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
}
|
||||
fitAddonRef.current = null;
|
||||
setIsTerminalReady(false);
|
||||
};
|
||||
}, []); // No dependencies - only run once on mount
|
||||
|
||||
// Connect WebSocket - wait for terminal to be ready
|
||||
useEffect(() => {
|
||||
if (!isTerminalReady || !sessionId) return;
|
||||
const terminal = xtermRef.current;
|
||||
if (!terminal) return;
|
||||
|
||||
const connect = () => {
|
||||
// Build WebSocket URL with token
|
||||
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
|
||||
if (authToken) {
|
||||
url += `&token=${encodeURIComponent(authToken)}`;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`[Terminal] WebSocket connected for session ${sessionId}`);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (msg.type) {
|
||||
case "data":
|
||||
terminal.write(msg.data);
|
||||
break;
|
||||
case "scrollback":
|
||||
// Replay scrollback buffer (previous terminal output)
|
||||
if (msg.data) {
|
||||
terminal.write(msg.data);
|
||||
}
|
||||
break;
|
||||
case "connected":
|
||||
console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`);
|
||||
break;
|
||||
case "exit":
|
||||
terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`);
|
||||
break;
|
||||
case "pong":
|
||||
// Heartbeat response
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Terminal] Message parse error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log(`[Terminal] WebSocket closed for session ${sessionId}:`, event.code, event.reason);
|
||||
wsRef.current = null;
|
||||
|
||||
// Don't reconnect if closed normally or auth failed
|
||||
if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt reconnect after a delay
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (xtermRef.current) {
|
||||
console.log(`[Terminal] Attempting reconnect for session ${sessionId}`);
|
||||
connect();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`[Terminal] WebSocket error for session ${sessionId}:`, error);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
// Handle terminal input
|
||||
const dataHandler = terminal.onData((data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
dataHandler.dispose();
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId, authToken, wsUrl, isTerminalReady]);
|
||||
|
||||
// Handle resize
|
||||
const handleResize = useCallback(() => {
|
||||
if (fitAddonRef.current && xtermRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
const { cols, rows } = xtermRef.current;
|
||||
|
||||
// Send resize to server
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Resize observer
|
||||
useEffect(() => {
|
||||
const container = terminalRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleResize();
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Also handle window resize
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
// Focus terminal when becoming active
|
||||
useEffect(() => {
|
||||
if (isActive && xtermRef.current) {
|
||||
xtermRef.current.focus();
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
// Update terminal font size when it changes
|
||||
useEffect(() => {
|
||||
if (xtermRef.current && isTerminalReady) {
|
||||
xtermRef.current.options.fontSize = fontSize;
|
||||
// Refit after font size change
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
// Notify server of new dimensions
|
||||
const { cols, rows } = xtermRef.current;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [fontSize, isTerminalReady]);
|
||||
|
||||
// Update terminal theme when app theme changes
|
||||
useEffect(() => {
|
||||
if (xtermRef.current && isTerminalReady) {
|
||||
const terminalTheme = getTerminalTheme(effectiveTheme);
|
||||
xtermRef.current.options.theme = terminalTheme;
|
||||
}
|
||||
}, [effectiveTheme, isTerminalReady]);
|
||||
|
||||
// Handle keyboard shortcuts for zoom (Ctrl+Plus, Ctrl+Minus, Ctrl+0)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle if Ctrl (or Cmd on Mac) is pressed
|
||||
if (!e.ctrlKey && !e.metaKey) return;
|
||||
|
||||
// Ctrl/Cmd + Plus or Ctrl/Cmd + = (for keyboards without numpad)
|
||||
if (e.key === "+" || e.key === "=") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
zoomIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + Minus
|
||||
if (e.key === "-") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
zoomOut();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + 0 to reset
|
||||
if (e.key === "0") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetZoom();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener("keydown", handleKeyDown);
|
||||
return () => container.removeEventListener("keydown", handleKeyDown);
|
||||
}, [zoomIn, zoomOut, resetZoom]);
|
||||
|
||||
// Handle mouse wheel zoom (Ctrl+Wheel)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// Only zoom if Ctrl (or Cmd on Mac) is pressed
|
||||
if (!e.ctrlKey && !e.metaKey) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.deltaY < 0) {
|
||||
// Scroll up = zoom in
|
||||
zoomIn();
|
||||
} else if (e.deltaY > 0) {
|
||||
// Scroll down = zoom out
|
||||
zoomOut();
|
||||
}
|
||||
};
|
||||
|
||||
// Use passive: false to allow preventDefault
|
||||
container.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => container.removeEventListener("wheel", handleWheel);
|
||||
}, [zoomIn, zoomOut]);
|
||||
|
||||
// Combine refs for the container
|
||||
const setRefs = useCallback((node: HTMLDivElement | null) => {
|
||||
containerRef.current = node;
|
||||
setDropRef(node);
|
||||
}, [setDropRef]);
|
||||
|
||||
// Get current terminal theme for xterm styling
|
||||
const currentTerminalTheme = getTerminalTheme(effectiveTheme);
|
||||
|
||||
return (
|
||||
<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">
|
||||
bash
|
||||
</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>
|
||||
);
|
||||
}
|
||||
367
apps/app/src/config/terminal-themes.ts
Normal file
367
apps/app/src/config/terminal-themes.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Terminal themes that match the app themes
|
||||
* Each theme provides colors for xterm.js terminal emulator
|
||||
*/
|
||||
|
||||
import type { ThemeMode } from "@/store/app-store";
|
||||
|
||||
export interface TerminalTheme {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
cursorAccent: string;
|
||||
selectionBackground: string;
|
||||
selectionForeground?: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
}
|
||||
|
||||
// Dark theme (default)
|
||||
const darkTheme: TerminalTheme = {
|
||||
background: "#0a0a0a",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
cursorAccent: "#0a0a0a",
|
||||
selectionBackground: "#264f78",
|
||||
black: "#1e1e1e",
|
||||
red: "#f44747",
|
||||
green: "#6a9955",
|
||||
yellow: "#dcdcaa",
|
||||
blue: "#569cd6",
|
||||
magenta: "#c586c0",
|
||||
cyan: "#4ec9b0",
|
||||
white: "#d4d4d4",
|
||||
brightBlack: "#808080",
|
||||
brightRed: "#f44747",
|
||||
brightGreen: "#6a9955",
|
||||
brightYellow: "#dcdcaa",
|
||||
brightBlue: "#569cd6",
|
||||
brightMagenta: "#c586c0",
|
||||
brightCyan: "#4ec9b0",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Light theme
|
||||
const lightTheme: TerminalTheme = {
|
||||
background: "#ffffff",
|
||||
foreground: "#383a42",
|
||||
cursor: "#383a42",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "#add6ff",
|
||||
black: "#383a42",
|
||||
red: "#e45649",
|
||||
green: "#50a14f",
|
||||
yellow: "#c18401",
|
||||
blue: "#4078f2",
|
||||
magenta: "#a626a4",
|
||||
cyan: "#0184bc",
|
||||
white: "#fafafa",
|
||||
brightBlack: "#4f525e",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Retro / Cyberpunk theme - neon green on black
|
||||
const retroTheme: TerminalTheme = {
|
||||
background: "#000000",
|
||||
foreground: "#39ff14",
|
||||
cursor: "#39ff14",
|
||||
cursorAccent: "#000000",
|
||||
selectionBackground: "#39ff14",
|
||||
selectionForeground: "#000000",
|
||||
black: "#000000",
|
||||
red: "#ff0055",
|
||||
green: "#39ff14",
|
||||
yellow: "#ffff00",
|
||||
blue: "#00ffff",
|
||||
magenta: "#ff00ff",
|
||||
cyan: "#00ffff",
|
||||
white: "#39ff14",
|
||||
brightBlack: "#555555",
|
||||
brightRed: "#ff5555",
|
||||
brightGreen: "#55ff55",
|
||||
brightYellow: "#ffff55",
|
||||
brightBlue: "#55ffff",
|
||||
brightMagenta: "#ff55ff",
|
||||
brightCyan: "#55ffff",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Dracula theme
|
||||
const draculaTheme: TerminalTheme = {
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#44475a",
|
||||
black: "#21222c",
|
||||
red: "#ff5555",
|
||||
green: "#50fa7b",
|
||||
yellow: "#f1fa8c",
|
||||
blue: "#bd93f9",
|
||||
magenta: "#ff79c6",
|
||||
cyan: "#8be9fd",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#6272a4",
|
||||
brightRed: "#ff6e6e",
|
||||
brightGreen: "#69ff94",
|
||||
brightYellow: "#ffffa5",
|
||||
brightBlue: "#d6acff",
|
||||
brightMagenta: "#ff92df",
|
||||
brightCyan: "#a4ffff",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Nord theme
|
||||
const nordTheme: TerminalTheme = {
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
cursor: "#d8dee9",
|
||||
cursorAccent: "#2e3440",
|
||||
selectionBackground: "#434c5e",
|
||||
black: "#3b4252",
|
||||
red: "#bf616a",
|
||||
green: "#a3be8c",
|
||||
yellow: "#ebcb8b",
|
||||
blue: "#81a1c1",
|
||||
magenta: "#b48ead",
|
||||
cyan: "#88c0d0",
|
||||
white: "#e5e9f0",
|
||||
brightBlack: "#4c566a",
|
||||
brightRed: "#bf616a",
|
||||
brightGreen: "#a3be8c",
|
||||
brightYellow: "#ebcb8b",
|
||||
brightBlue: "#81a1c1",
|
||||
brightMagenta: "#b48ead",
|
||||
brightCyan: "#8fbcbb",
|
||||
brightWhite: "#eceff4",
|
||||
};
|
||||
|
||||
// Monokai theme
|
||||
const monokaiTheme: TerminalTheme = {
|
||||
background: "#272822",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#272822",
|
||||
selectionBackground: "#49483e",
|
||||
black: "#272822",
|
||||
red: "#f92672",
|
||||
green: "#a6e22e",
|
||||
yellow: "#f4bf75",
|
||||
blue: "#66d9ef",
|
||||
magenta: "#ae81ff",
|
||||
cyan: "#a1efe4",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#75715e",
|
||||
brightRed: "#f92672",
|
||||
brightGreen: "#a6e22e",
|
||||
brightYellow: "#f4bf75",
|
||||
brightBlue: "#66d9ef",
|
||||
brightMagenta: "#ae81ff",
|
||||
brightCyan: "#a1efe4",
|
||||
brightWhite: "#f9f8f5",
|
||||
};
|
||||
|
||||
// Tokyo Night theme
|
||||
const tokyonightTheme: TerminalTheme = {
|
||||
background: "#1a1b26",
|
||||
foreground: "#a9b1d6",
|
||||
cursor: "#c0caf5",
|
||||
cursorAccent: "#1a1b26",
|
||||
selectionBackground: "#33467c",
|
||||
black: "#15161e",
|
||||
red: "#f7768e",
|
||||
green: "#9ece6a",
|
||||
yellow: "#e0af68",
|
||||
blue: "#7aa2f7",
|
||||
magenta: "#bb9af7",
|
||||
cyan: "#7dcfff",
|
||||
white: "#a9b1d6",
|
||||
brightBlack: "#414868",
|
||||
brightRed: "#f7768e",
|
||||
brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68",
|
||||
brightBlue: "#7aa2f7",
|
||||
brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff",
|
||||
brightWhite: "#c0caf5",
|
||||
};
|
||||
|
||||
// Solarized Dark theme
|
||||
const solarizedTheme: TerminalTheme = {
|
||||
background: "#002b36",
|
||||
foreground: "#839496",
|
||||
cursor: "#839496",
|
||||
cursorAccent: "#002b36",
|
||||
selectionBackground: "#073642",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
};
|
||||
|
||||
// Gruvbox Dark theme
|
||||
const gruvboxTheme: TerminalTheme = {
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
cursor: "#ebdbb2",
|
||||
cursorAccent: "#282828",
|
||||
selectionBackground: "#504945",
|
||||
black: "#282828",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#a89984",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#fb4934",
|
||||
brightGreen: "#b8bb26",
|
||||
brightYellow: "#fabd2f",
|
||||
brightBlue: "#83a598",
|
||||
brightMagenta: "#d3869b",
|
||||
brightCyan: "#8ec07c",
|
||||
brightWhite: "#ebdbb2",
|
||||
};
|
||||
|
||||
// Catppuccin Mocha theme
|
||||
const catppuccinTheme: TerminalTheme = {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
cursor: "#f5e0dc",
|
||||
cursorAccent: "#1e1e2e",
|
||||
selectionBackground: "#45475a",
|
||||
black: "#45475a",
|
||||
red: "#f38ba8",
|
||||
green: "#a6e3a1",
|
||||
yellow: "#f9e2af",
|
||||
blue: "#89b4fa",
|
||||
magenta: "#cba6f7",
|
||||
cyan: "#94e2d5",
|
||||
white: "#bac2de",
|
||||
brightBlack: "#585b70",
|
||||
brightRed: "#f38ba8",
|
||||
brightGreen: "#a6e3a1",
|
||||
brightYellow: "#f9e2af",
|
||||
brightBlue: "#89b4fa",
|
||||
brightMagenta: "#cba6f7",
|
||||
brightCyan: "#94e2d5",
|
||||
brightWhite: "#a6adc8",
|
||||
};
|
||||
|
||||
// One Dark theme
|
||||
const onedarkTheme: TerminalTheme = {
|
||||
background: "#282c34",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#282c34",
|
||||
selectionBackground: "#3e4451",
|
||||
black: "#282c34",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#e5c07b",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Synthwave '84 theme
|
||||
const synthwaveTheme: TerminalTheme = {
|
||||
background: "#262335",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#ff7edb",
|
||||
cursorAccent: "#262335",
|
||||
selectionBackground: "#463465",
|
||||
black: "#262335",
|
||||
red: "#fe4450",
|
||||
green: "#72f1b8",
|
||||
yellow: "#fede5d",
|
||||
blue: "#03edf9",
|
||||
magenta: "#ff7edb",
|
||||
cyan: "#03edf9",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#614d85",
|
||||
brightRed: "#fe4450",
|
||||
brightGreen: "#72f1b8",
|
||||
brightYellow: "#f97e72",
|
||||
brightBlue: "#03edf9",
|
||||
brightMagenta: "#ff7edb",
|
||||
brightCyan: "#03edf9",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Theme mapping
|
||||
const terminalThemes: Record<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,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get terminal theme for the given app theme
|
||||
* For "system" theme, it checks the user's system preference
|
||||
*/
|
||||
export function getTerminalTheme(theme: ThemeMode): TerminalTheme {
|
||||
if (theme === "system") {
|
||||
// Check system preference
|
||||
if (typeof window !== "undefined") {
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
return prefersDark ? darkTheme : lightTheme;
|
||||
}
|
||||
return darkTheme; // Default to dark for SSR
|
||||
}
|
||||
return terminalThemes[theme] || darkTheme;
|
||||
}
|
||||
|
||||
export default terminalThemes;
|
||||
@@ -34,6 +34,18 @@ function isInputFocused(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if focus is inside an xterm terminal (they use a hidden textarea)
|
||||
const xtermContainer = activeElement.closest(".xterm");
|
||||
if (xtermContainer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check if any parent has data-terminal-container attribute
|
||||
const terminalContainer = activeElement.closest("[data-terminal-container]");
|
||||
if (terminalContainer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for autocomplete/typeahead dropdowns being open
|
||||
const autocompleteList = document.querySelector(
|
||||
'[data-testid="category-autocomplete-list"]'
|
||||
|
||||
@@ -12,7 +12,8 @@ export type ViewMode =
|
||||
| "interview"
|
||||
| "context"
|
||||
| "profiles"
|
||||
| "running-agents";
|
||||
| "running-agents"
|
||||
| "terminal";
|
||||
|
||||
export type ThemeMode =
|
||||
| "light"
|
||||
@@ -296,6 +297,27 @@ export interface ProjectAnalysis {
|
||||
analyzedAt: string;
|
||||
}
|
||||
|
||||
// Terminal panel layout types (recursive for splits)
|
||||
export type TerminalPanelContent =
|
||||
| { type: "terminal"; sessionId: string; size?: number; fontSize?: number }
|
||||
| { type: "split"; direction: "horizontal" | "vertical"; panels: TerminalPanelContent[] };
|
||||
|
||||
// Terminal tab - each tab has its own layout
|
||||
export interface TerminalTab {
|
||||
id: string;
|
||||
name: string;
|
||||
layout: TerminalPanelContent | null;
|
||||
}
|
||||
|
||||
export interface TerminalState {
|
||||
isUnlocked: boolean;
|
||||
authToken: string | null;
|
||||
tabs: TerminalTab[];
|
||||
activeTabId: string | null;
|
||||
activeSessionId: string | null;
|
||||
defaultFontSize: number; // Default font size for new terminals
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
// Project state
|
||||
projects: Project[];
|
||||
@@ -385,6 +407,9 @@ export interface AppState {
|
||||
|
||||
// Theme Preview (for hover preview in theme selectors)
|
||||
previewTheme: ThemeMode | null;
|
||||
|
||||
// Terminal state
|
||||
terminalState: TerminalState;
|
||||
}
|
||||
|
||||
// Default background settings for board backgrounds
|
||||
@@ -563,6 +588,21 @@ export interface AppActions {
|
||||
setHideScrollbar: (projectPath: string, hide: boolean) => void;
|
||||
clearBoardBackground: (projectPath: string) => void;
|
||||
|
||||
// Terminal actions
|
||||
setTerminalUnlocked: (unlocked: boolean, token?: string) => void;
|
||||
setActiveTerminalSession: (sessionId: string | null) => void;
|
||||
addTerminalToLayout: (sessionId: string, direction?: "horizontal" | "vertical") => void;
|
||||
removeTerminalFromLayout: (sessionId: string) => void;
|
||||
swapTerminals: (sessionId1: string, sessionId2: string) => void;
|
||||
clearTerminalState: () => void;
|
||||
setTerminalPanelFontSize: (sessionId: string, fontSize: number) => void;
|
||||
addTerminalTab: (name?: string) => string;
|
||||
removeTerminalTab: (tabId: string) => void;
|
||||
setActiveTerminalTab: (tabId: string) => void;
|
||||
renameTerminalTab: (tabId: string, name: string) => void;
|
||||
moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => void;
|
||||
addTerminalToTab: (sessionId: string, tabId: string, direction?: "horizontal" | "vertical") => void;
|
||||
|
||||
// Reset
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -658,6 +698,14 @@ const initialState: AppState = {
|
||||
isAnalyzing: false,
|
||||
boardBackgroundByProject: {},
|
||||
previewTheme: null,
|
||||
terminalState: {
|
||||
isUnlocked: false,
|
||||
authToken: null,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
activeSessionId: null,
|
||||
defaultFontSize: 14,
|
||||
},
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState & AppActions>()(
|
||||
@@ -1464,6 +1512,432 @@ 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") => {
|
||||
const current = get().terminalState;
|
||||
const newTerminal: TerminalPanelContent = { type: "terminal", sessionId, size: 50 };
|
||||
|
||||
// If no tabs, create first tab
|
||||
if (current.tabs.length === 0) {
|
||||
const newTabId = `tab-${Date.now()}`;
|
||||
set({
|
||||
terminalState: {
|
||||
...current,
|
||||
tabs: [{ id: newTabId, name: "Terminal 1", layout: { type: "terminal", sessionId, size: 100 } }],
|
||||
activeTabId: newTabId,
|
||||
activeSessionId: sessionId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to active tab's layout
|
||||
const activeTab = current.tabs.find(t => t.id === current.activeTabId);
|
||||
if (!activeTab) return;
|
||||
|
||||
const addToLayout = (
|
||||
node: TerminalPanelContent,
|
||||
targetDirection: "horizontal" | "vertical"
|
||||
): TerminalPanelContent => {
|
||||
if (node.type === "terminal") {
|
||||
return {
|
||||
type: "split",
|
||||
direction: targetDirection,
|
||||
panels: [{ ...node, size: 50 }, newTerminal],
|
||||
};
|
||||
}
|
||||
// If same direction, add to existing split
|
||||
if (node.direction === targetDirection) {
|
||||
const newSize = 100 / (node.panels.length + 1);
|
||||
return {
|
||||
...node,
|
||||
panels: [...node.panels.map(p => ({ ...p, size: newSize })), { ...newTerminal, size: newSize }],
|
||||
};
|
||||
}
|
||||
// Different direction, wrap in new split
|
||||
return {
|
||||
type: "split",
|
||||
direction: targetDirection,
|
||||
panels: [{ ...node, size: 50 }, newTerminal],
|
||||
};
|
||||
};
|
||||
|
||||
const newLayout = activeTab.layout
|
||||
? addToLayout(activeTab.layout, direction)
|
||||
: { type: "terminal" as const, sessionId, size: 100 };
|
||||
|
||||
const newTabs = current.tabs.map(t =>
|
||||
t.id === current.activeTabId ? { ...t, layout: newLayout } : t
|
||||
);
|
||||
|
||||
set({
|
||||
terminalState: {
|
||||
...current,
|
||||
tabs: newTabs,
|
||||
activeSessionId: sessionId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
removeTerminalFromLayout: (sessionId) => {
|
||||
const current = get().terminalState;
|
||||
if (current.tabs.length === 0) return;
|
||||
|
||||
// Find which tab contains this session
|
||||
const findFirstTerminal = (node: TerminalPanelContent): string | null => {
|
||||
if (node.type === "terminal") return node.sessionId;
|
||||
for (const panel of node.panels) {
|
||||
const found = findFirstTerminal(panel);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
||||
if (node.type === "terminal") {
|
||||
return node.sessionId === sessionId ? null : node;
|
||||
}
|
||||
const newPanels: TerminalPanelContent[] = [];
|
||||
for (const panel of node.panels) {
|
||||
const result = removeAndCollapse(panel);
|
||||
if (result !== null) newPanels.push(result);
|
||||
}
|
||||
if (newPanels.length === 0) return null;
|
||||
if (newPanels.length === 1) return newPanels[0];
|
||||
return { ...node, panels: newPanels };
|
||||
};
|
||||
|
||||
let newTabs = current.tabs.map(tab => {
|
||||
if (!tab.layout) return tab;
|
||||
const newLayout = removeAndCollapse(tab.layout);
|
||||
return { ...tab, layout: newLayout };
|
||||
});
|
||||
|
||||
// Remove empty tabs
|
||||
newTabs = newTabs.filter(tab => tab.layout !== null);
|
||||
|
||||
// Determine new active session
|
||||
const newActiveTabId = newTabs.length > 0 ? (current.activeTabId && newTabs.find(t => t.id === current.activeTabId) ? current.activeTabId : newTabs[0].id) : null;
|
||||
const newActiveSessionId = newActiveTabId
|
||||
? findFirstTerminal(newTabs.find(t => t.id === newActiveTabId)?.layout || null as unknown as TerminalPanelContent)
|
||||
: null;
|
||||
|
||||
set({
|
||||
terminalState: {
|
||||
...current,
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveTabId,
|
||||
activeSessionId: newActiveSessionId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
swapTerminals: (sessionId1, sessionId2) => {
|
||||
const current = get().terminalState;
|
||||
if (current.tabs.length === 0) return;
|
||||
|
||||
const swapInLayout = (node: TerminalPanelContent): TerminalPanelContent => {
|
||||
if (node.type === "terminal") {
|
||||
if (node.sessionId === sessionId1) return { ...node, sessionId: sessionId2 };
|
||||
if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 };
|
||||
return node;
|
||||
}
|
||||
return { ...node, panels: node.panels.map(swapInLayout) };
|
||||
};
|
||||
|
||||
const newTabs = current.tabs.map(tab => ({
|
||||
...tab,
|
||||
layout: tab.layout ? swapInLayout(tab.layout) : null,
|
||||
}));
|
||||
|
||||
set({
|
||||
terminalState: { ...current, tabs: newTabs },
|
||||
});
|
||||
},
|
||||
|
||||
clearTerminalState: () => {
|
||||
set({
|
||||
terminalState: {
|
||||
isUnlocked: false,
|
||||
authToken: null,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
activeSessionId: null,
|
||||
defaultFontSize: 14,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
setTerminalPanelFontSize: (sessionId, fontSize) => {
|
||||
const current = get().terminalState;
|
||||
const clampedSize = Math.max(8, Math.min(32, fontSize));
|
||||
|
||||
const updateFontSize = (node: TerminalPanelContent): TerminalPanelContent => {
|
||||
if (node.type === "terminal") {
|
||||
if (node.sessionId === sessionId) {
|
||||
return { ...node, fontSize: clampedSize };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
return { ...node, panels: node.panels.map(updateFontSize) };
|
||||
};
|
||||
|
||||
const newTabs = current.tabs.map(tab => {
|
||||
if (!tab.layout) return tab;
|
||||
return { ...tab, layout: updateFontSize(tab.layout) };
|
||||
});
|
||||
|
||||
set({
|
||||
terminalState: { ...current, tabs: newTabs },
|
||||
});
|
||||
},
|
||||
|
||||
addTerminalTab: (name) => {
|
||||
const current = get().terminalState;
|
||||
const newTabId = `tab-${Date.now()}`;
|
||||
const tabNumber = current.tabs.length + 1;
|
||||
const newTab: TerminalTab = { id: newTabId, name: name || `Terminal ${tabNumber}`, layout: null };
|
||||
set({
|
||||
terminalState: {
|
||||
...current,
|
||||
tabs: [...current.tabs, newTab],
|
||||
activeTabId: newTabId,
|
||||
},
|
||||
});
|
||||
return newTabId;
|
||||
},
|
||||
|
||||
removeTerminalTab: (tabId) => {
|
||||
const current = get().terminalState;
|
||||
const newTabs = current.tabs.filter(t => t.id !== tabId);
|
||||
let newActiveTabId = current.activeTabId;
|
||||
let newActiveSessionId = current.activeSessionId;
|
||||
|
||||
if (current.activeTabId === tabId) {
|
||||
newActiveTabId = newTabs.length > 0 ? newTabs[0].id : null;
|
||||
if (newActiveTabId) {
|
||||
const newActiveTab = newTabs.find(t => t.id === newActiveTabId);
|
||||
const findFirst = (node: TerminalPanelContent): string | null => {
|
||||
if (node.type === "terminal") return node.sessionId;
|
||||
for (const p of node.panels) {
|
||||
const f = findFirst(p);
|
||||
if (f) return f;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
newActiveSessionId = newActiveTab?.layout ? findFirst(newActiveTab.layout) : null;
|
||||
} else {
|
||||
newActiveSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
terminalState: { ...current, tabs: newTabs, activeTabId: newActiveTabId, activeSessionId: newActiveSessionId },
|
||||
});
|
||||
},
|
||||
|
||||
setActiveTerminalTab: (tabId) => {
|
||||
const current = get().terminalState;
|
||||
const tab = current.tabs.find(t => t.id === tabId);
|
||||
if (!tab) return;
|
||||
|
||||
let newActiveSessionId = current.activeSessionId;
|
||||
if (tab.layout) {
|
||||
const findFirst = (node: TerminalPanelContent): string | null => {
|
||||
if (node.type === "terminal") return node.sessionId;
|
||||
for (const p of node.panels) {
|
||||
const f = findFirst(p);
|
||||
if (f) return f;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
newActiveSessionId = findFirst(tab.layout);
|
||||
}
|
||||
|
||||
set({
|
||||
terminalState: { ...current, activeTabId: tabId, activeSessionId: newActiveSessionId },
|
||||
});
|
||||
},
|
||||
|
||||
renameTerminalTab: (tabId, name) => {
|
||||
const current = get().terminalState;
|
||||
const newTabs = current.tabs.map(t => t.id === tabId ? { ...t, name } : t);
|
||||
set({
|
||||
terminalState: { ...current, tabs: newTabs },
|
||||
});
|
||||
},
|
||||
|
||||
moveTerminalToTab: (sessionId, targetTabId) => {
|
||||
const current = get().terminalState;
|
||||
|
||||
let sourceTabId: string | null = null;
|
||||
let originalTerminalNode: (TerminalPanelContent & { type: "terminal" }) | null = null;
|
||||
|
||||
const findTerminal = (node: TerminalPanelContent): (TerminalPanelContent & { type: "terminal" }) | null => {
|
||||
if (node.type === "terminal") {
|
||||
return node.sessionId === sessionId ? node : null;
|
||||
}
|
||||
for (const panel of node.panels) {
|
||||
const found = findTerminal(panel);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const tab of current.tabs) {
|
||||
if (tab.layout) {
|
||||
const found = findTerminal(tab.layout);
|
||||
if (found) {
|
||||
sourceTabId = tab.id;
|
||||
originalTerminalNode = found;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sourceTabId || !originalTerminalNode) return;
|
||||
if (sourceTabId === targetTabId) return;
|
||||
|
||||
const sourceTab = current.tabs.find(t => t.id === sourceTabId);
|
||||
if (!sourceTab?.layout) return;
|
||||
|
||||
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
||||
if (node.type === "terminal") {
|
||||
return node.sessionId === sessionId ? null : node;
|
||||
}
|
||||
const newPanels: TerminalPanelContent[] = [];
|
||||
for (const panel of node.panels) {
|
||||
const result = removeAndCollapse(panel);
|
||||
if (result !== null) newPanels.push(result);
|
||||
}
|
||||
if (newPanels.length === 0) return null;
|
||||
if (newPanels.length === 1) return newPanels[0];
|
||||
return { ...node, panels: newPanels };
|
||||
};
|
||||
|
||||
const newSourceLayout = removeAndCollapse(sourceTab.layout);
|
||||
|
||||
let finalTargetTabId = targetTabId;
|
||||
let newTabs = current.tabs;
|
||||
|
||||
if (targetTabId === "new") {
|
||||
const newTabId = `tab-${Date.now()}`;
|
||||
const sourceWillBeRemoved = !newSourceLayout;
|
||||
const tabName = sourceWillBeRemoved ? sourceTab.name : `Terminal ${current.tabs.length + 1}`;
|
||||
newTabs = [
|
||||
...current.tabs,
|
||||
{ id: newTabId, name: tabName, layout: { type: "terminal", sessionId, size: 100, fontSize: originalTerminalNode.fontSize } },
|
||||
];
|
||||
finalTargetTabId = newTabId;
|
||||
} else {
|
||||
const targetTab = current.tabs.find(t => t.id === targetTabId);
|
||||
if (!targetTab) return;
|
||||
|
||||
const terminalNode: TerminalPanelContent = { type: "terminal", sessionId, size: 50, fontSize: originalTerminalNode.fontSize };
|
||||
let newTargetLayout: TerminalPanelContent;
|
||||
|
||||
if (!targetTab.layout) {
|
||||
newTargetLayout = { type: "terminal", sessionId, size: 100, fontSize: originalTerminalNode.fontSize };
|
||||
} else if (targetTab.layout.type === "terminal") {
|
||||
newTargetLayout = {
|
||||
type: "split",
|
||||
direction: "horizontal",
|
||||
panels: [{ ...targetTab.layout, size: 50 }, terminalNode],
|
||||
};
|
||||
} else {
|
||||
newTargetLayout = {
|
||||
...targetTab.layout,
|
||||
panels: [...targetTab.layout.panels, terminalNode],
|
||||
};
|
||||
}
|
||||
|
||||
newTabs = current.tabs.map(t =>
|
||||
t.id === targetTabId ? { ...t, layout: newTargetLayout } : t
|
||||
);
|
||||
}
|
||||
|
||||
if (!newSourceLayout) {
|
||||
newTabs = newTabs.filter(t => t.id !== sourceTabId);
|
||||
} else {
|
||||
newTabs = newTabs.map(t =>
|
||||
t.id === sourceTabId ? { ...t, layout: newSourceLayout } : t
|
||||
);
|
||||
}
|
||||
|
||||
set({
|
||||
terminalState: {
|
||||
...current,
|
||||
tabs: newTabs,
|
||||
activeTabId: finalTargetTabId,
|
||||
activeSessionId: sessionId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
addTerminalToTab: (sessionId, tabId, direction = "horizontal") => {
|
||||
const current = get().terminalState;
|
||||
const tab = current.tabs.find(t => t.id === tabId);
|
||||
if (!tab) return;
|
||||
|
||||
const terminalNode: TerminalPanelContent = { type: "terminal", sessionId, size: 50 };
|
||||
let newLayout: TerminalPanelContent;
|
||||
|
||||
if (!tab.layout) {
|
||||
newLayout = { type: "terminal", sessionId, size: 100 };
|
||||
} else if (tab.layout.type === "terminal") {
|
||||
newLayout = {
|
||||
type: "split",
|
||||
direction,
|
||||
panels: [{ ...tab.layout, size: 50 }, terminalNode],
|
||||
};
|
||||
} else {
|
||||
if (tab.layout.direction === direction) {
|
||||
const newSize = 100 / (tab.layout.panels.length + 1);
|
||||
newLayout = {
|
||||
...tab.layout,
|
||||
panels: [...tab.layout.panels.map(p => ({ ...p, size: newSize })), { ...terminalNode, size: newSize }],
|
||||
};
|
||||
} else {
|
||||
newLayout = {
|
||||
type: "split",
|
||||
direction,
|
||||
panels: [{ ...tab.layout, size: 50 }, terminalNode],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const newTabs = current.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, layout: newLayout } : t
|
||||
);
|
||||
|
||||
set({
|
||||
terminalState: {
|
||||
...current,
|
||||
tabs: newTabs,
|
||||
activeTabId: tabId,
|
||||
activeSessionId: sessionId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Reset
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -43,3 +43,14 @@ OPENAI_API_KEY=
|
||||
|
||||
# Google API key (for future Gemini support)
|
||||
GOOGLE_API_KEY=
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Terminal Access
|
||||
# ============================================
|
||||
|
||||
# Enable/disable terminal access (default: true)
|
||||
TERMINAL_ENABLED=true
|
||||
|
||||
# Password to protect terminal access (leave empty for no password)
|
||||
# If set, users must enter this password before accessing terminal
|
||||
TERMINAL_PASSWORD=test
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -30,9 +30,11 @@ import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
|
||||
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
|
||||
import { createWorkspaceRoutes } from "./routes/workspace.js";
|
||||
import { createTemplatesRoutes } from "./routes/templates.js";
|
||||
import { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired } from "./routes/terminal.js";
|
||||
import { AgentService } from "./services/agent-service.js";
|
||||
import { FeatureLoader } from "./services/feature-loader.js";
|
||||
import { AutoModeService } from "./services/auto-mode-service.js";
|
||||
import { getTerminalService } from "./services/terminal-service.js";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -116,13 +118,34 @@ app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
|
||||
app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
|
||||
app.use("/api/workspace", createWorkspaceRoutes());
|
||||
app.use("/api/templates", createTemplatesRoutes());
|
||||
app.use("/api/terminal", createTerminalRoutes());
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
// WebSocket server for streaming events
|
||||
const wss = new WebSocketServer({ server, path: "/api/events" });
|
||||
// WebSocket servers using noServer mode for proper multi-path support
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const terminalWss = new WebSocketServer({ noServer: true });
|
||||
const terminalService = getTerminalService();
|
||||
|
||||
// Handle HTTP upgrade requests manually to route to correct WebSocket server
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const { pathname } = new URL(request.url || "", `http://${request.headers.host}`);
|
||||
|
||||
if (pathname === "/api/events") {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, request);
|
||||
});
|
||||
} else if (pathname === "/api/terminal/ws") {
|
||||
terminalWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
terminalWss.emit("connection", ws, request);
|
||||
});
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Events WebSocket connection handler
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
console.log("[WebSocket] Client connected");
|
||||
|
||||
@@ -144,15 +167,153 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Track WebSocket connections per session
|
||||
const terminalConnections: Map<string, Set<WebSocket>> = new Map();
|
||||
|
||||
// Terminal WebSocket connection handler
|
||||
terminalWss.on("connection", (ws: WebSocket, req: import("http").IncomingMessage) => {
|
||||
// Parse URL to get session ID and token
|
||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
const sessionId = url.searchParams.get("sessionId");
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
|
||||
|
||||
// Check if terminal is enabled
|
||||
if (!isTerminalEnabled()) {
|
||||
console.log("[Terminal WS] Terminal is disabled");
|
||||
ws.close(4003, "Terminal access is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate token if password is required
|
||||
if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) {
|
||||
console.log("[Terminal WS] Invalid or missing token");
|
||||
ws.close(4001, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
console.log("[Terminal WS] No session ID provided");
|
||||
ws.close(4002, "Session ID required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if session exists
|
||||
const session = terminalService.getSession(sessionId);
|
||||
if (!session) {
|
||||
console.log(`[Terminal WS] Session ${sessionId} not found`);
|
||||
ws.close(4004, "Session not found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
|
||||
|
||||
// Track this connection
|
||||
if (!terminalConnections.has(sessionId)) {
|
||||
terminalConnections.set(sessionId, new Set());
|
||||
}
|
||||
terminalConnections.get(sessionId)!.add(ws);
|
||||
|
||||
// Subscribe to terminal data
|
||||
const unsubscribeData = terminalService.onData((sid, data) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "data", data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to terminal exit
|
||||
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode }));
|
||||
ws.close(1000, "Session ended");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on("message", (message) => {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
// Write user input to terminal
|
||||
terminalService.write(sessionId, msg.data);
|
||||
break;
|
||||
|
||||
case "resize":
|
||||
// Resize terminal
|
||||
if (msg.cols && msg.rows) {
|
||||
terminalService.resize(sessionId, msg.cols, msg.rows);
|
||||
}
|
||||
break;
|
||||
|
||||
case "ping":
|
||||
// Respond to ping
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Terminal WS] Error processing message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log(`[Terminal WS] Client disconnected from session ${sessionId}`);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
|
||||
// Remove from connections tracking
|
||||
const connections = terminalConnections.get(sessionId);
|
||||
if (connections) {
|
||||
connections.delete(ws);
|
||||
if (connections.size === 0) {
|
||||
terminalConnections.delete(sessionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
});
|
||||
|
||||
// Send initial connection success
|
||||
ws.send(JSON.stringify({
|
||||
type: "connected",
|
||||
sessionId,
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
}));
|
||||
|
||||
// Send scrollback buffer to replay previous output
|
||||
const scrollback = terminalService.getScrollback(sessionId);
|
||||
if (scrollback && scrollback.length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "scrollback",
|
||||
data: scrollback,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
server.listen(PORT, () => {
|
||||
const terminalStatus = isTerminalEnabled()
|
||||
? (isTerminalPasswordRequired() ? "enabled (password protected)" : "enabled")
|
||||
: "disabled";
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Automaker Backend Server ║
|
||||
╠═══════════════════════════════════════════════════════╣
|
||||
║ HTTP API: http://localhost:${PORT} ║
|
||||
║ WebSocket: ws://localhost:${PORT}/api/events ║
|
||||
║ Terminal: ws://localhost:${PORT}/api/terminal/ws ║
|
||||
║ Health: http://localhost:${PORT}/api/health ║
|
||||
║ Terminal: ${terminalStatus.padEnd(37)}║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
@@ -160,6 +321,7 @@ server.listen(PORT, () => {
|
||||
// Graceful shutdown
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("SIGTERM received, shutting down...");
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
@@ -168,6 +330,7 @@ process.on("SIGTERM", () => {
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log("SIGINT received, shutting down...");
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Router, type Request, type Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { validatePath, addAllowedPath, isPathAllowed } from "../lib/security.js";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
|
||||
|
||||
@@ -355,7 +355,7 @@ Format your response as markdown. Be specific and actionable.`;
|
||||
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||
console.log("[SpecRegeneration] Received success result");
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if (msg.type === "error") {
|
||||
} else if ((msg as { type: string }).type === "error") {
|
||||
console.error("[SpecRegeneration] ❌ Received error message from stream:");
|
||||
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
|
||||
}
|
||||
@@ -505,7 +505,7 @@ Generate 5-15 features that build on each other logically.`;
|
||||
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||
console.log("[SpecRegeneration] Received success result for features");
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if (msg.type === "error") {
|
||||
} else if ((msg as { type: string }).type === "error") {
|
||||
console.error("[SpecRegeneration] ❌ Received error message from feature stream:");
|
||||
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
359
apps/server/src/services/terminal-service.ts
Normal file
359
apps/server/src/services/terminal-service.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Terminal Service
|
||||
*
|
||||
* Manages PTY (pseudo-terminal) sessions using node-pty.
|
||||
* Supports cross-platform shell detection including WSL.
|
||||
*/
|
||||
|
||||
import * as pty from "node-pty";
|
||||
import { EventEmitter } from "events";
|
||||
import * as os from "os";
|
||||
import * as fs from "fs";
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 100000; // ~100KB per terminal
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
pty: pty.IPty;
|
||||
cwd: string;
|
||||
createdAt: Date;
|
||||
shell: string;
|
||||
scrollbackBuffer: string; // Store recent output for replay on reconnect
|
||||
}
|
||||
|
||||
export interface TerminalOptions {
|
||||
cwd?: string;
|
||||
shell?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
env?: Record<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: "",
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
|
||||
// Forward data events and store in scrollback buffer
|
||||
ptyProcess.onData((data) => {
|
||||
// Append to scrollback buffer
|
||||
session.scrollbackBuffer += data;
|
||||
// Trim if too large (keep the most recent data)
|
||||
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
|
||||
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
|
||||
}
|
||||
|
||||
this.dataCallbacks.forEach((cb) => cb(id, data));
|
||||
this.emit("data", id, data);
|
||||
});
|
||||
|
||||
// Handle exit
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
|
||||
this.sessions.delete(id);
|
||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
||||
this.emit("exit", id, exitCode);
|
||||
});
|
||||
|
||||
console.log(`[Terminal] Session ${id} created successfully`);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write data to a terminal session
|
||||
*/
|
||||
write(sessionId: string, data: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.warn(`[Terminal] Session ${sessionId} not found`);
|
||||
return false;
|
||||
}
|
||||
session.pty.write(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a terminal session
|
||||
*/
|
||||
resize(sessionId: string, cols: number, rows: number): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
session.pty.resize(cols, rows);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a terminal session
|
||||
*/
|
||||
killSession(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
session.pty.kill();
|
||||
this.sessions.delete(sessionId);
|
||||
console.log(`[Terminal] Session ${sessionId} killed`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session by ID
|
||||
*/
|
||||
getSession(sessionId: string): TerminalSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scrollback buffer for a session (for replay on reconnect)
|
||||
*/
|
||||
getScrollback(sessionId: string): string | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return session?.scrollbackBuffer || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions
|
||||
*/
|
||||
getAllSessions(): Array<{
|
||||
id: string;
|
||||
cwd: string;
|
||||
createdAt: Date;
|
||||
shell: string;
|
||||
}> {
|
||||
return Array.from(this.sessions.values()).map((s) => ({
|
||||
id: s.id,
|
||||
cwd: s.cwd,
|
||||
createdAt: s.createdAt,
|
||||
shell: s.shell,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to data events
|
||||
*/
|
||||
onData(callback: DataCallback): () => void {
|
||||
this.dataCallbacks.add(callback);
|
||||
return () => this.dataCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to exit events
|
||||
*/
|
||||
onExit(callback: ExitCallback): () => void {
|
||||
this.exitCallbacks.add(callback);
|
||||
return () => this.exitCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all sessions
|
||||
*/
|
||||
cleanup(): void {
|
||||
console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`);
|
||||
this.sessions.forEach((session, id) => {
|
||||
try {
|
||||
session.pty.kill();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
this.sessions.delete(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let terminalService: TerminalService | null = null;
|
||||
|
||||
export function getTerminalService(): TerminalService {
|
||||
if (!terminalService) {
|
||||
terminalService = new TerminalService();
|
||||
}
|
||||
return terminalService;
|
||||
}
|
||||
55
package-lock.json
generated
55
package-lock.json
generated
@@ -30,6 +30,9 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -40,6 +43,7 @@
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.9"
|
||||
@@ -10256,6 +10260,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -11948,6 +11953,30 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
|
||||
"integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@zeit/schemas": {
|
||||
"version": "2.36.0",
|
||||
"resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz",
|
||||
@@ -13436,6 +13465,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.24.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz",
|
||||
"integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -13517,6 +13552,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
|
||||
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nan": "^2.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-path": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||
@@ -13765,6 +13810,16 @@
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-resizable-panels": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
|
||||
"integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/registry-auth-token": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz",
|
||||
|
||||
Reference in New Issue
Block a user