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 @@ DenchClaw Web UI — workspace, object tables, and AI chat +
+ 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