feat(web): add in-browser terminal panel (Cmd+J)

xterm.js frontend + node-pty WebSocket server spawning the user's real shell,
with drag-to-resize drawer, multi-terminal tabs, live theme sync, and URL state.
This commit is contained in:
kumarabhirup 2026-03-08 20:45:10 -07:00
parent 039cbe6a43
commit e1fc698f2e
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
8 changed files with 941 additions and 4 deletions

View File

@ -11,7 +11,7 @@
</p>
<p align="center">
<a href="https://denchclaw.com">Website</a> · <a href="https://discord.gg/PDFXNVQj9n">Discord</a> · <a href="https://skills.sh">Skills Store</a>
<a href="https://denchclaw.com">Website</a> · <a href="https://discord.gg/PDFXNVQj9n">Discord</a> · <a href="https://skills.sh">Skills Store</a> · <a href="https://www.youtube.com/watch?v=pfACTbc3Bh4&t=44s">Demo Video</a>
</p>
<br />
@ -20,6 +20,8 @@
<a href="https://denchclaw.com">
<img src="assets/denchclaw-app.png" alt="DenchClaw Web UI — workspace, object tables, and AI chat" width="780" />
</a>
<br />
<a href="https://www.youtube.com/watch?v=pfACTbc3Bh4&t=44s">Demo Video</a>
</p>
<br />

View File

@ -0,0 +1,709 @@
"use client";
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Terminal, type ITheme } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
const MIN_DRAWER_HEIGHT = 180;
const MAX_DRAWER_HEIGHT_RATIO = 0.75;
const DEFAULT_DRAWER_HEIGHT = 280;
const STORAGE_KEY = "dench-terminal-height";
const WS_PORT = 3101;
const MAX_TERMINALS = 8;
function maxDrawerHeight(): number {
if (typeof window === "undefined") return DEFAULT_DRAWER_HEIGHT;
return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO));
}
function clampHeight(height: number): number {
const safe = Number.isFinite(height) ? height : DEFAULT_DRAWER_HEIGHT;
return Math.min(Math.max(Math.round(safe), MIN_DRAWER_HEIGHT), maxDrawerHeight());
}
function loadHeight(): number {
if (typeof window === "undefined") return DEFAULT_DRAWER_HEIGHT;
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_DRAWER_HEIGHT;
const parsed = Number(raw);
return Number.isFinite(parsed) ? clampHeight(parsed) : DEFAULT_DRAWER_HEIGHT;
}
function terminalTheme(): ITheme {
const isDark = document.documentElement.classList.contains("dark");
const rootStyles = getComputedStyle(document.documentElement);
const background = rootStyles.getPropertyValue("--color-bg").trim() || (isDark ? "#0c0c0b" : "#f5f5f4");
const foreground = rootStyles.getPropertyValue("--color-text").trim() || (isDark ? "#ececea" : "#1c1c1a");
if (isDark) {
return {
background,
foreground,
cursor: "rgb(180, 203, 255)",
selectionBackground: "rgba(180, 203, 255, 0.25)",
scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)",
scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)",
scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)",
black: "#181e26",
red: "#ff7a8e",
green: "#86e795",
yellow: "#f4cd72",
blue: "#89beff",
magenta: "#d0b0ff",
cyan: "#7ce8ed",
white: "#d2dae6",
brightBlack: "#6e7888",
brightRed: "#ffa8b4",
brightGreen: "#b0f5ba",
brightYellow: "#ffe095",
brightBlue: "#aed2ff",
brightMagenta: "#e5cbff",
brightCyan: "#a7f4f7",
brightWhite: "#f4f7fc",
};
}
return {
background,
foreground,
cursor: "rgb(38, 56, 78)",
selectionBackground: "rgba(37, 63, 99, 0.2)",
scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)",
scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)",
scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)",
black: "#2c3542",
red: "#bf4657",
green: "#3c7e56",
yellow: "#927023",
blue: "#4866a3",
magenta: "#845695",
cyan: "#357f8d",
white: "#d2d7df",
brightBlack: "#707b8c",
brightRed: "#d45f70",
brightGreen: "#55946f",
brightYellow: "#ad852d",
brightBlue: "#5b7cc2",
brightMagenta: "#996bac",
brightCyan: "#4695a4",
brightWhite: "#ecf0f6",
};
}
function isMac(): boolean {
if (typeof navigator === "undefined") return false;
return /mac/i.test(navigator.platform);
}
// ---------------------------------------------------------------------------
// TerminalViewport — single xterm instance connected to a WS/PTY session
// ---------------------------------------------------------------------------
interface TerminalViewportProps {
terminalId: string;
active: boolean;
focusRequestId: number;
resizeEpoch: number;
drawerHeight: number;
cwd?: string;
onExited: () => void;
}
function TerminalViewport({
terminalId,
active,
focusRequestId,
resizeEpoch,
drawerHeight,
cwd,
onExited,
}: TerminalViewportProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const onExitedRef = useRef(onExited);
useEffect(() => {
onExitedRef.current = onExited;
}, [onExited]);
useEffect(() => {
const mount = containerRef.current;
if (!mount) return;
let disposed = false;
const fitAddon = new FitAddon();
const terminal = new Terminal({
cursorBlink: true,
lineHeight: 1.2,
fontSize: 12,
scrollback: 5_000,
fontFamily:
'"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
theme: terminalTheme(),
});
terminal.loadAddon(fitAddon);
terminal.open(mount);
fitAddon.fit();
termRef.current = terminal;
fitRef.current = fitAddon;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//127.0.0.1:${WS_PORT}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (disposed) return;
ws.send(
JSON.stringify({
type: "spawn",
cols: terminal.cols,
rows: terminal.rows,
...(cwd ? { cwd } : {}),
}),
);
};
ws.onmessage = (ev) => {
if (disposed) return;
let msg: { type: string; data?: string; exitCode?: number; signal?: number };
try {
msg = JSON.parse(ev.data as string);
} catch {
return;
}
if (msg.type === "output" && msg.data) {
terminal.write(msg.data);
} else if (msg.type === "exit") {
terminal.write(`\r\n[process exited]\r\n`);
onExitedRef.current();
} else if (msg.type === "ready") {
if (active) {
window.requestAnimationFrame(() => terminal.focus());
}
}
};
ws.onclose = () => {
if (disposed) return;
terminal.write("\r\n[connection closed]\r\n");
};
terminal.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data }));
}
});
terminal.attachCustomKeyEventHandler((event) => {
if (event.type !== "keydown") return true;
const key = event.key.toLowerCase();
// Cmd+K / Ctrl+L — clear terminal
if (
(isMac() && key === "k" && event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) ||
(key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey)
) {
event.preventDefault();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data: "\u000c" }));
}
return false;
}
// Cmd+J — let it bubble to toggle the drawer
if (
key === "j" &&
((isMac() && event.metaKey) || (!isMac() && event.ctrlKey)) &&
!event.altKey &&
!event.shiftKey
) {
return false;
}
// Option+Left/Right — word navigation
if (isMac() && event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
if (key === "arrowleft") {
event.preventDefault();
ws.readyState === WebSocket.OPEN &&
ws.send(JSON.stringify({ type: "input", data: "\x1bb" }));
return false;
}
if (key === "arrowright") {
event.preventDefault();
ws.readyState === WebSocket.OPEN &&
ws.send(JSON.stringify({ type: "input", data: "\x1bf" }));
return false;
}
}
// Cmd+Left/Right — line start/end
if (isMac() && event.metaKey && !event.altKey && !event.ctrlKey && !event.shiftKey) {
if (key === "arrowleft") {
event.preventDefault();
ws.readyState === WebSocket.OPEN &&
ws.send(JSON.stringify({ type: "input", data: "\x01" }));
return false;
}
if (key === "arrowright") {
event.preventDefault();
ws.readyState === WebSocket.OPEN &&
ws.send(JSON.stringify({ type: "input", data: "\x05" }));
return false;
}
}
return true;
});
// Theme observer — defer read until after CSS recalculates
let themeFrame: number | null = null;
const applyTheme = () => {
if (themeFrame !== null) cancelAnimationFrame(themeFrame);
themeFrame = requestAnimationFrame(() => {
themeFrame = null;
const t = termRef.current;
if (!t) return;
t.options.theme = terminalTheme();
t.refresh(0, t.rows - 1);
});
};
const themeObserver = new MutationObserver(applyTheme);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style"],
});
themeObserver.observe(document.body, {
attributes: true,
attributeFilter: ["class", "style"],
});
const colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
colorSchemeQuery.addEventListener("change", applyTheme);
return () => {
disposed = true;
themeObserver.disconnect();
colorSchemeQuery.removeEventListener("change", applyTheme);
if (themeFrame !== null) cancelAnimationFrame(themeFrame);
ws.close();
wsRef.current = null;
termRef.current = null;
fitRef.current = null;
terminal.dispose();
};
// terminalId is the stable identity; active only used at mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminalId]);
// Focus when becoming active
useEffect(() => {
if (!active) return;
const t = termRef.current;
if (!t) return;
const frame = window.requestAnimationFrame(() => t.focus());
return () => window.cancelAnimationFrame(frame);
}, [active, focusRequestId]);
// Re-fit on resize
useEffect(() => {
const t = termRef.current;
const f = fitRef.current;
const ws = wsRef.current;
if (!t || !f) return;
const frame = window.requestAnimationFrame(() => {
const wasAtBottom = t.buffer.active.viewportY >= t.buffer.active.baseY;
f.fit();
if (wasAtBottom) t.scrollToBottom();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: t.cols, rows: t.rows }));
}
});
return () => window.cancelAnimationFrame(frame);
}, [drawerHeight, resizeEpoch]);
return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />;
}
// ---------------------------------------------------------------------------
// TerminalDrawer — T3 Code-style bottom drawer
// ---------------------------------------------------------------------------
interface TerminalDrawerProps {
onClose: () => void;
cwd?: string;
}
interface TerminalTab {
id: string;
label: string;
}
export default function TerminalDrawer({ onClose, cwd }: TerminalDrawerProps) {
const [drawerHeight, setDrawerHeight] = useState(loadHeight);
const [resizeEpoch, setResizeEpoch] = useState(0);
const [focusRequestId, setFocusRequestId] = useState(0);
const [terminals, setTerminals] = useState<TerminalTab[]>(() => [
{ id: crypto.randomUUID(), label: "Terminal 1" },
]);
const [activeId, setActiveId] = useState(() => terminals[0].id);
const drawerHeightRef = useRef(drawerHeight);
const resizeStateRef = useRef<{
pointerId: number;
startY: number;
startHeight: number;
} | null>(null);
const didResizeRef = useRef(false);
useEffect(() => {
drawerHeightRef.current = drawerHeight;
}, [drawerHeight]);
// Persist height
useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, String(drawerHeight));
}, [drawerHeight]);
// Window resize
useEffect(() => {
const onResize = () => {
const clamped = clampHeight(drawerHeightRef.current);
if (clamped !== drawerHeightRef.current) {
setDrawerHeight(clamped);
drawerHeightRef.current = clamped;
}
setResizeEpoch((v) => v + 1);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Drag resize handlers
const handlePointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
didResizeRef.current = false;
resizeStateRef.current = {
pointerId: e.pointerId,
startY: e.clientY,
startHeight: drawerHeightRef.current,
};
}, []);
const handlePointerMove = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
const state = resizeStateRef.current;
if (!state || state.pointerId !== e.pointerId) return;
e.preventDefault();
const next = clampHeight(state.startHeight + (state.startY - e.clientY));
if (next === drawerHeightRef.current) return;
didResizeRef.current = true;
drawerHeightRef.current = next;
setDrawerHeight(next);
}, []);
const handlePointerEnd = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
const state = resizeStateRef.current;
if (!state || state.pointerId !== e.pointerId) return;
resizeStateRef.current = null;
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
if (didResizeRef.current) {
setResizeEpoch((v) => v + 1);
}
}, []);
// Terminal management
const addTerminal = useCallback(() => {
if (terminals.length >= MAX_TERMINALS) return;
const id = crypto.randomUUID();
const label = `Terminal ${terminals.length + 1}`;
setTerminals((prev) => [...prev, { id, label }]);
setActiveId(id);
setFocusRequestId((v) => v + 1);
}, [terminals.length]);
const closeTerminal = useCallback(
(id: string) => {
setTerminals((prev) => {
const next = prev.filter((t) => t.id !== id);
if (next.length === 0) {
onClose();
return prev;
}
return next;
});
if (activeId === id) {
setActiveId((prev) => {
const remaining = terminals.filter((t) => t.id !== id);
return remaining[0]?.id ?? prev;
});
}
},
[activeId, terminals, onClose],
);
const handleExited = useCallback(
(id: string) => {
closeTerminal(id);
},
[closeTerminal],
);
const hasMultiple = terminals.length > 1;
const hasReachedLimit = terminals.length >= MAX_TERMINALS;
const terminalLabels = useMemo(
() => new Map(terminals.map((t, i) => [t.id, `Terminal ${i + 1}`])),
[terminals],
);
return (
<aside
className="relative flex min-w-0 shrink-0 flex-col overflow-hidden"
style={{
height: `${drawerHeight}px`,
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg)",
}}
>
{/* Resize handle */}
<div
className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
/>
{/* Action buttons (when single terminal) */}
{!hasMultiple && (
<div className="pointer-events-none absolute right-2 top-2 z-20">
<div
className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md"
style={{ border: "1px solid var(--color-border)", background: "var(--color-surface)" }}
>
<ActionButton
label={hasReachedLimit ? `New Terminal (max ${MAX_TERMINALS})` : "New Terminal"}
disabled={hasReachedLimit}
onClick={addTerminal}
>
<PlusIcon />
</ActionButton>
<div className="h-4 w-px" style={{ background: "var(--color-border)" }} />
<ActionButton label="Close Terminal" onClick={onClose}>
<TrashIcon />
</ActionButton>
</div>
</div>
)}
<div className="min-h-0 w-full flex-1">
<div className={`flex h-full min-h-0 ${hasMultiple ? "gap-0" : ""}`}>
{/* Terminal viewports */}
<div className="min-w-0 flex-1">
<div className="h-full p-1">
{terminals.map((tab) => (
<div
key={tab.id}
className="h-full"
style={{ display: tab.id === activeId ? "block" : "none" }}
>
<TerminalViewport
terminalId={tab.id}
active={tab.id === activeId}
focusRequestId={focusRequestId}
resizeEpoch={resizeEpoch}
drawerHeight={drawerHeight}
cwd={cwd}
onExited={() => handleExited(tab.id)}
/>
</div>
))}
</div>
</div>
{/* Terminal sidebar (when multiple) */}
{hasMultiple && (
<aside
className="flex w-36 min-w-36 flex-col"
style={{
borderLeft: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}
>
<div
className="flex h-[28px] items-stretch justify-end"
style={{ borderBottom: "1px solid var(--color-border)" }}
>
<div className="inline-flex h-full items-stretch">
<ActionButton
label={
hasReachedLimit ? `New Terminal (max ${MAX_TERMINALS})` : "New Terminal"
}
disabled={hasReachedLimit}
onClick={addTerminal}
className="inline-flex h-full items-center px-1.5"
>
<PlusIcon />
</ActionButton>
<ActionButton
label="Close Terminal"
onClick={() => closeTerminal(activeId)}
className="inline-flex h-full items-center px-1.5"
borderLeft
>
<TrashIcon />
</ActionButton>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
{terminals.map((tab) => {
const isActive = tab.id === activeId;
return (
<div
key={tab.id}
className="group flex items-center gap-1 rounded px-1 py-0.5 text-[11px]"
style={{
background: isActive ? "var(--color-accent-light)" : "transparent",
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
}}
onMouseEnter={(e) => {
if (!isActive) (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1 text-left"
onClick={() => {
setActiveId(tab.id);
setFocusRequestId((v) => v + 1);
}}
>
<TerminalIcon />
<span className="truncate">
{terminalLabels.get(tab.id) ?? "Terminal"}
</span>
</button>
{hasMultiple && (
<button
type="button"
className="inline-flex items-center justify-center rounded opacity-0 group-hover:opacity-100"
style={{
width: 14,
height: 14,
color: "var(--color-text-muted)",
}}
onClick={() => closeTerminal(tab.id)}
title={`Close ${terminalLabels.get(tab.id) ?? "terminal"}`}
>
<XIcon />
</button>
)}
</div>
);
})}
</div>
</aside>
)}
</div>
</div>
</aside>
);
}
// ---------------------------------------------------------------------------
// Small icons (inline SVGs matching Lucide style)
// ---------------------------------------------------------------------------
function ActionButton({
label,
disabled,
onClick,
children,
className,
borderLeft,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
className?: string;
borderLeft?: boolean;
}) {
return (
<button
type="button"
className={className ?? "p-1"}
onClick={disabled ? undefined : onClick}
aria-label={label}
title={label}
style={{
color: "var(--color-text-muted)",
opacity: disabled ? 0.45 : 1,
cursor: disabled ? "not-allowed" : "pointer",
borderLeft: borderLeft ? "1px solid var(--color-border)" : undefined,
}}
onMouseEnter={(e) => {
if (!disabled) (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
{children}
</button>
);
}
function PlusIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
);
}
function TrashIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
);
}
function TerminalIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" />
</svg>
);
}
function XIcon() {
return (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
);
}

View File

@ -45,6 +45,12 @@ import {
import { UnicodeSpinner } from "../components/unicode-spinner";
import { resolveActiveViewSyncDecision } from "./object-view-active-view";
import { resetWorkspaceStateOnSwitch } from "./workspace-switch";
import dynamic from "next/dynamic";
const TerminalDrawer = dynamic(
() => import("../components/terminal/terminal-drawer"),
{ ssr: false },
);
// --- Types ---
@ -476,6 +482,9 @@ function WorkspacePageInner() {
const [leftSidebarCollapsed, setLeftSidebarCollapsed] = useState(false);
const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(false);
// Terminal drawer state
const [terminalOpen, setTerminalOpen] = useState(false);
// Resizable sidebar widths (desktop only; persisted in localStorage).
// Use static defaults so server and client match on first render (avoid hydration mismatch).
const [leftSidebarWidth, setLeftSidebarWidth] = useState(260);
@ -499,16 +508,26 @@ function WorkspacePageInner() {
window.localStorage.setItem(STORAGE_RIGHT, String(rightSidebarWidth));
}, [rightSidebarWidth]);
// Keyboard shortcuts: Cmd+B = toggle left sidebar, Cmd+Shift+B = toggle right sidebar
// Keyboard shortcuts: Cmd+B = toggle left sidebar, Cmd+Shift+B = toggle right sidebar, Cmd+J = toggle terminal
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
const mod = e.metaKey || e.ctrlKey;
const key = e.key.toLowerCase();
if (mod && key === "b") {
e.preventDefault();
if (e.shiftKey) {
setRightSidebarCollapsed((v) => !v);
} else {
setLeftSidebarCollapsed((v) => !v);
}
return;
}
if (mod && key === "j" && !e.shiftKey && !e.altKey) {
e.preventDefault();
setTerminalOpen((v) => !v);
return;
}
};
window.addEventListener("keydown", handler);
@ -1208,6 +1227,7 @@ function WorkspacePageInner() {
if (browseDir) params.set("browse", browseDir);
if (showHidden) params.set("hidden", "1");
if (chatSidebarPreview) params.set("preview", chatSidebarPreview.path);
// terminal param is managed by its own effect below
const nextQs = params.toString();
const currentQs = current.toString();
@ -1220,6 +1240,21 @@ function WorkspacePageInner() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excludes searchParams to avoid infinite loop
}, [activePath, activeSessionId, activeSubagentKey, fileChatSessionId, browseDir, showHidden, chatSidebarPreview, router, cronView, cronCalMode, cronDate, cronRunFilter, cronRun]);
// Terminal URL sync — independent of workspace hydration so it works app-wide.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const current = params.get("terminal") === "1";
if (current === terminalOpen) return;
if (terminalOpen) {
params.set("terminal", "1");
} else {
params.delete("terminal");
}
const qs = params.toString();
const url = qs ? `/?${qs}` : "/";
window.history.replaceState(window.history.state, "", url);
}, [terminalOpen]);
// Open entry modal handler
const handleOpenEntry = useCallback(
(objectName: string, entryId: string) => {
@ -1311,6 +1346,9 @@ function WorkspacePageInner() {
}
});
}
if (urlState.terminal) {
setTerminalOpen(true);
}
}, [tree, treeLoading, searchParams, loadContent, setBrowseDir, setShowHidden, loadSidebarPreviewFromNode]);
// Handle browser back/forward navigation.
@ -1390,6 +1428,8 @@ function WorkspacePageInner() {
});
}
setTerminalOpen(urlState.terminal);
lastPushedQs.current = qs;
};
@ -1978,6 +2018,11 @@ function WorkspacePageInner() {
</>
)}
</div>
{/* Terminal drawer (Cmd+J) */}
{terminalOpen && (
<TerminalDrawer onClose={() => setTerminalOpen(false)} cwd={workspaceRoot ?? undefined} />
)}
</main>
{/* Entry detail modal (rendered on top of everything) */}

View File

@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { startTerminalServer } = await import("./lib/terminal-server");
startTerminalServer(Number(process.env.TERMINAL_WS_PORT) || 3101);
}
}

View File

@ -0,0 +1,170 @@
import { WebSocketServer, type WebSocket } from "ws";
import type { IncomingMessage } from "node:http";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import { chmodSync, existsSync } from "node:fs";
interface TerminalSession {
pty: import("node-pty").IPty;
ws: WebSocket;
}
const sessions = new Map<WebSocket, TerminalSession>();
let wss: WebSocketServer | null = null;
let didFixSpawnHelper = false;
function ensureSpawnHelperExecutable() {
if (didFixSpawnHelper || process.platform === "win32") return;
didFixSpawnHelper = true;
try {
const req = createRequire(import.meta.url);
const pkgPath = req.resolve("node-pty/package.json");
const pkgDir = dirname(pkgPath);
const candidates = [
join(pkgDir, "build", "Release", "spawn-helper"),
join(pkgDir, "build", "Debug", "spawn-helper"),
join(pkgDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
chmodSync(candidate, 0o755);
}
}
} catch {
// best-effort
}
}
function defaultShell(): string {
if (process.platform === "win32") {
return process.env.ComSpec ?? "cmd.exe";
}
return process.env.SHELL ?? "/bin/zsh";
}
function shellArgs(shell: string): string[] {
const name = shell.split("/").pop()?.toLowerCase() ?? "";
if (process.platform !== "win32" && name === "zsh") {
return ["-o", "nopromptsp"];
}
return [];
}
function spawnTerminal(ws: WebSocket, cols: number, rows: number, cwd?: string) {
ensureSpawnHelperExecutable();
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nodePty = require("node-pty") as typeof import("node-pty");
const shell = defaultShell();
const pty = nodePty.spawn(shell, shellArgs(shell), {
name: "xterm-256color",
cols,
rows,
cwd: cwd || process.env.HOME || process.cwd(),
env: Object.fromEntries(
Object.entries(process.env).filter(
([, v]) => v !== undefined,
),
) as Record<string, string>,
});
const session: TerminalSession = { pty, ws };
sessions.set(ws, session);
pty.onData((data) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: "output", data }));
}
});
pty.onExit(({ exitCode, signal }) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: "exit", exitCode, signal }));
}
sessions.delete(ws);
});
ws.send(JSON.stringify({ type: "ready", pid: pty.pid }));
}
function handleMessage(ws: WebSocket, raw: string) {
let msg: { type: string; data?: string; cols?: number; rows?: number; cwd?: string };
try {
msg = JSON.parse(raw);
} catch {
return;
}
const session = sessions.get(ws);
switch (msg.type) {
case "spawn": {
if (session) {
session.pty.kill();
sessions.delete(ws);
}
spawnTerminal(ws, msg.cols ?? 80, msg.rows ?? 24, msg.cwd);
break;
}
case "input": {
if (session && msg.data) {
session.pty.write(msg.data);
}
break;
}
case "resize": {
if (session && msg.cols && msg.rows) {
session.pty.resize(msg.cols, msg.rows);
}
break;
}
}
}
function handleConnection(ws: WebSocket, _req: IncomingMessage) {
ws.on("message", (data) => {
handleMessage(ws, data.toString());
});
ws.on("close", () => {
const session = sessions.get(ws);
if (session) {
session.pty.kill();
sessions.delete(ws);
}
});
ws.on("error", () => {
const session = sessions.get(ws);
if (session) {
session.pty.kill();
sessions.delete(ws);
}
});
}
export function startTerminalServer(port: number) {
if (wss) return;
wss = new WebSocketServer({ port, host: "127.0.0.1" });
wss.on("connection", handleConnection);
wss.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") {
console.warn(`[terminal] Port ${port} in use, retrying on ${port + 1}`);
wss = null;
startTerminalServer(port + 1);
}
});
}
export function stopTerminalServer() {
if (!wss) return;
for (const session of sessions.values()) {
session.pty.kill();
}
sessions.clear();
wss.close();
wss = null;
}

View File

@ -64,6 +64,8 @@ export type WorkspaceUrlState = {
cronRunFilter: CronRunStatusFilter | null;
/** Selected run timestamp in cron job detail. */
cronRun: number | null;
/** Whether the terminal drawer is open. */
terminal: boolean;
};
const VALID_VIEW_TYPES: ViewType[] = [
@ -147,6 +149,7 @@ export function parseUrlState(search: string | URLSearchParams): WorkspaceUrlSta
cronDate: params.get("cronDate"),
cronRunFilter: cronRunFilterRaw && VALID_CRON_RUN_FILTERS.includes(cronRunFilterRaw) ? cronRunFilterRaw : null,
cronRun: cronRunRaw ? parseInt(cronRunRaw, 10) || null : null,
terminal: params.get("terminal") === "1",
};
}
@ -182,6 +185,7 @@ export function serializeUrlState(state: Partial<WorkspaceUrlState>): string {
if (state.cronDate) params.set("cronDate", state.cronDate);
if (state.cronRunFilter && state.cronRunFilter !== "all") params.set("cronRunFilter", state.cronRunFilter);
if (state.cronRun != null) params.set("cronRun", String(state.cronRun));
if (state.terminal) params.set("terminal", "1");
return params.toString();
}

View File

@ -14,7 +14,7 @@ const nextConfig: NextConfig = {
outputFileTracingRoot: path.join(import.meta.dirname, "..", ".."),
// Externalize packages with native addons so webpack doesn't break them
serverExternalPackages: ["ws", "bufferutil", "utf-8-validate"],
serverExternalPackages: ["ws", "bufferutil", "utf-8-validate", "node-pty"],
// Transpile ESM-only packages so webpack can bundle them
transpilePackages: ["react-markdown", "remark-gfm"],

View File

@ -10,5 +10,6 @@ onlyBuiltDependencies:
- authenticate-pam
- esbuild
- node-llama-cpp
- node-pty
- protobufjs
- sharp