From e1fc698f2e9334a6178a4a1479391b3f99dd6afb Mon Sep 17 00:00:00 2001
From: kumarabhirup
Date: Sun, 8 Mar 2026 20:45:10 -0700
Subject: [PATCH] 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.
---
README.md | 4 +-
.../components/terminal/terminal-drawer.tsx | 709 ++++++++++++++++++
apps/web/app/workspace/workspace-content.tsx | 49 +-
apps/web/instrumentation.ts | 6 +
apps/web/lib/terminal-server.ts | 170 +++++
apps/web/lib/workspace-links.ts | 4 +
apps/web/next.config.ts | 2 +-
pnpm-workspace.yaml | 1 +
8 files changed, 941 insertions(+), 4 deletions(-)
create mode 100644 apps/web/app/components/terminal/terminal-drawer.tsx
create mode 100644 apps/web/instrumentation.ts
create mode 100644 apps/web/lib/terminal-server.ts
diff --git a/README.md b/README.md
index 49fb0bfff5b..876e450074d 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
- Website · Discord · Skills Store
+ Website · Discord · Skills Store · Demo Video
@@ -20,6 +20,8 @@
+
+ Demo Video
diff --git a/apps/web/app/components/terminal/terminal-drawer.tsx b/apps/web/app/components/terminal/terminal-drawer.tsx
new file mode 100644
index 00000000000..6eeec3fc546
--- /dev/null
+++ b/apps/web/app/components/terminal/terminal-drawer.tsx
@@ -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(null);
+ const termRef = useRef(null);
+ const fitRef = useRef(null);
+ const wsRef = useRef(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 ;
+}
+
+// ---------------------------------------------------------------------------
+// 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(() => [
+ { 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) => {
+ 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) => {
+ 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) => {
+ 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 (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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 (
+
+ );
+}
+
+function PlusIcon() {
+ return (
+
+ );
+}
+
+function TrashIcon() {
+ return (
+
+ );
+}
+
+function TerminalIcon() {
+ return (
+
+ );
+}
+
+function XIcon() {
+ return (
+
+ );
+}
diff --git a/apps/web/app/workspace/workspace-content.tsx b/apps/web/app/workspace/workspace-content.tsx
index cbe4ac66392..7493666dd01 100644
--- a/apps/web/app/workspace/workspace-content.tsx
+++ b/apps/web/app/workspace/workspace-content.tsx
@@ -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() {
>
)}
+
+ {/* Terminal drawer (Cmd+J) */}
+ {terminalOpen && (
+ setTerminalOpen(false)} cwd={workspaceRoot ?? undefined} />
+ )}
{/* Entry detail modal (rendered on top of everything) */}
diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts
new file mode 100644
index 00000000000..0138f0dd227
--- /dev/null
+++ b/apps/web/instrumentation.ts
@@ -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);
+ }
+}
diff --git a/apps/web/lib/terminal-server.ts b/apps/web/lib/terminal-server.ts
new file mode 100644
index 00000000000..75a38482c66
--- /dev/null
+++ b/apps/web/lib/terminal-server.ts
@@ -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();
+
+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,
+ });
+
+ 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;
+}
diff --git a/apps/web/lib/workspace-links.ts b/apps/web/lib/workspace-links.ts
index 2fbe7e447e4..113d4ae179d 100644
--- a/apps/web/lib/workspace-links.ts
+++ b/apps/web/lib/workspace-links.ts
@@ -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): 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();
}
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index bf08a619cc0..56b3ab6816c 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -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"],
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index f56c2ec078d..3fa5bc4b27d 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -10,5 +10,6 @@ onlyBuiltDependencies:
- authenticate-pam
- esbuild
- node-llama-cpp
+ - node-pty
- protobufjs
- sharp