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:
parent
039cbe6a43
commit
e1fc698f2e
@ -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 />
|
||||
|
||||
709
apps/web/app/components/terminal/terminal-drawer.tsx
Normal file
709
apps/web/app/components/terminal/terminal-drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) */}
|
||||
|
||||
6
apps/web/instrumentation.ts
Normal file
6
apps/web/instrumentation.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
170
apps/web/lib/terminal-server.ts
Normal file
170
apps/web/lib/terminal-server.ts
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -10,5 +10,6 @@ onlyBuiltDependencies:
|
||||
- authenticate-pam
|
||||
- esbuild
|
||||
- node-llama-cpp
|
||||
- node-pty
|
||||
- protobufjs
|
||||
- sharp
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user