From c07cbc000aa229c9b4ad4f4b83122bc6a35e2ef3 Mon Sep 17 00:00:00 2001 From: Prateek Date: Sat, 14 Feb 2026 19:05:13 +0530 Subject: [PATCH] feat: implement proper interactive terminal with WebSocket Replace hacky SSE polling with real-time WebSocket for bidirectional terminal communication. This is a proper interactive terminal - type directly, like tmux attach in the browser. Architecture: - WebSocket server on port 3001 alongside Next.js - Uses tmux pipe-pane for real-time output streaming - Sends input character-by-character via tmux send-keys - Handles terminal resize events - Connection status indicator Implementation: - packages/web/src/server/terminal-websocket.ts: WebSocket server - Terminal component now fully interactive (not read-only) - Runs both servers via concurrently in dev mode - Green dot = connected, red dot = disconnected - Proper cursor, no more clunky input box Benefits: - Real-time streaming (not 2-second polling) - Type directly into terminal - Proper terminal control sequences - Handles resize - Like native tmux attach Dependencies added: - ws (WebSocket server) - @types/ws - concurrently (run multiple servers) Co-Authored-By: Claude Sonnet 4.5 --- packages/web/package.json | 9 +- packages/web/src/components/Terminal.tsx | 150 ++++++++--------- packages/web/src/server/terminal-websocket.ts | 153 ++++++++++++++++++ pnpm-lock.yaml | 108 +++++++++++++ 4 files changed, 345 insertions(+), 75 deletions(-) create mode 100644 packages/web/src/server/terminal-websocket.ts diff --git a/packages/web/package.json b/packages/web/package.json index 973fe33ed..b67bb647c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -5,7 +5,9 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev", + "dev": "concurrently \"npm:dev:next\" \"npm:dev:ws\"", + "dev:next": "next dev", + "dev:ws": "tsx src/server/terminal-websocket.ts", "build": "next build", "start": "next start", "typecheck": "tsc --noEmit", @@ -26,7 +28,8 @@ "@xterm/xterm": "^6.0.0", "next": "^15.1.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "ws": "^8.19.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.0", @@ -34,7 +37,9 @@ "@testing-library/react": "^16.1.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.0", + "concurrently": "^9.2.1", "jsdom": "^25.0.0", "playwright": "^1.49.0", "tailwindcss": "^4.0.0", diff --git a/packages/web/src/components/Terminal.tsx b/packages/web/src/components/Terminal.tsx index fcf8d70d3..ebc40164d 100644 --- a/packages/web/src/components/Terminal.tsx +++ b/packages/web/src/components/Terminal.tsx @@ -11,26 +11,24 @@ interface TerminalProps { /** * Terminal embed using xterm.js. - * Streams tmux pane output via SSE. Interactive terminal with direct input. + * Proper interactive terminal via WebSocket - type directly, real-time streaming. */ export function Terminal({ sessionId }: TerminalProps) { const [fullscreen, setFullscreen] = useState(false); - const [isInteractive, setIsInteractive] = useState(false); + const [connected, setConnected] = useState(false); const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); - const eventSourceRef = useRef(null); - const lastContentRef = useRef(""); + const wsRef = useRef(null); // Initialize xterm.js useEffect(() => { if (!terminalRef.current) return; - // Create terminal instance + // Create terminal instance - fully interactive const term = new XTerm({ cursorBlink: true, cursorStyle: "block", - disableStdin: true, // Will be enabled when interactive mode is on theme: { background: "#000000", foreground: "#d0d0d0", @@ -70,88 +68,84 @@ export function Terminal({ sessionId }: TerminalProps) { // Refit terminal when fullscreen changes useEffect(() => { if (fitAddonRef.current) { - setTimeout(() => fitAddonRef.current?.fit(), 150); + setTimeout(() => { + fitAddonRef.current?.fit(); + // Send resize to server + const term = xtermRef.current; + const ws = wsRef.current; + if (term && ws && ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "resize", + cols: term.cols, + rows: term.rows, + }), + ); + } + }, 150); } }, [fullscreen]); - // Handle interactive mode + // Connect to WebSocket for interactive terminal useEffect(() => { const term = xtermRef.current; if (!term) return; - if (isInteractive) { - term.options.disableStdin = false; - term.options.cursorBlink = true; + // Connect to WebSocket server + const wsUrl = `ws://localhost:${process.env.NEXT_PUBLIC_WS_PORT ?? "3001"}?session=${sessionId}`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; - // Handle keyboard input - const disposable = term.onData((data) => { - // Send each keystroke to the session - void (async () => { - try { - await fetch(`/api/sessions/${sessionId}/send`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: data }), - }); - } catch (err) { - console.error("[Terminal] Failed to send input:", err); - } - })(); - }); + ws.addEventListener("open", () => { + setConnected(true); + console.log("[Terminal] WebSocket connected"); - return () => { - disposable.dispose(); - }; - } else { - term.options.disableStdin = true; - term.options.cursorBlink = false; - } - }, [isInteractive, sessionId]); + // Send initial terminal size + ws.send( + JSON.stringify({ + type: "resize", + cols: term.cols, + rows: term.rows, + }), + ); + }); - // Connect to SSE stream - useEffect(() => { - const term = xtermRef.current; - if (!term) return; + ws.addEventListener("message", (event) => { + // Write output from server + term.write(event.data); + }); - const eventSource = new EventSource(`/api/sessions/${sessionId}/terminal`); - eventSourceRef.current = eventSource; + ws.addEventListener("error", (err) => { + console.error("[Terminal] WebSocket error:", err); + term.writeln("\r\n\r\n[Connection error]"); + setConnected(false); + }); - eventSource.addEventListener("message", (event) => { - try { - const data = JSON.parse(event.data) as - | { type: "snapshot" | "update"; content: string } - | { type: "exit" }; + ws.addEventListener("close", () => { + console.log("[Terminal] WebSocket closed"); + term.writeln("\r\n\r\n[Connection closed]"); + setConnected(false); + }); - if (data.type === "snapshot" || data.type === "update") { - const content = data.content; - - // Only update if content changed - if (content !== lastContentRef.current) { - lastContentRef.current = content; - - // Reset terminal and write new content - term.reset(); - term.write(content); - - // Scroll to bottom - term.scrollToBottom(); - } - } else if (data.type === "exit") { - term.writeln("\r\n\r\n[Session exited]"); - } - } catch (err) { - console.error("[Terminal] Failed to parse SSE event:", err); + // Handle keyboard input - send to server + const inputDisposable = term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "input", data })); } }); - eventSource.addEventListener("error", () => { - eventSource.close(); - term.writeln("\r\n\r\n[Connection lost]"); + // Handle terminal resize - send to server + const resizeDisposable = term.onResize(({ cols, rows }) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } }); return () => { - eventSource.close(); - eventSourceRef.current = null; + inputDisposable.dispose(); + resizeDisposable.dispose(); + ws.close(); + wsRef.current = null; }; }, [sessionId]); @@ -164,15 +158,25 @@ export function Terminal({ sessionId }: TerminalProps) { >
-
+
-
+
{sessionId} - - Read-only + + {connected ? "Interactive" : "Connecting..."}