import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { useShellStart, useShellStatus, useShellWebSocket, } from "@/lib/api/process"; interface TerminalPanelProps { workspaceId: string | null; } export function TerminalPanel({ workspaceId }: TerminalPanelProps) { const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); const hasWelcomedRef = useRef(false); const isDeadRef = useRef(false); const [failed, setFailed] = useState(false); const [isDead, setIsDead] = useState(false); const { data: shellStatus } = useShellStatus(workspaceId); const shellRunning = shellStatus?.running === true; const { status, send, onData } = useShellWebSocket(workspaceId, shellRunning); const startShell = useShellStart(); // Initialize xterm once and keep it alive across WS status changes. useEffect(() => { const container = containerRef.current; if (!container) return; let terminal: Terminal | undefined; let fitAddon: FitAddon | undefined; try { terminal = new Terminal({ cursorBlink: true, convertEol: true, // Prevent Mac Option+key from emitting ESC. Without this, typing // Option+[ etc. leaks a literal 0x1b into the shell's stdin, which // can break downstream programs that don't interpret escape // sequences (e.g. Python's REPL reports "invalid non-printable // character U+001B"). macOptionIsMeta: false, fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontSize: 12, theme: { background: "#171717", foreground: "#e5e5e5", }, }); fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(container); terminalRef.current = terminal; fitAddonRef.current = fitAddon; } catch { setFailed(true); terminal?.dispose(); fitAddon?.dispose(); return; } let rafId: number | undefined; const scheduleFit = () => { if (rafId !== undefined) return; rafId = requestAnimationFrame(() => { rafId = undefined; try { fitAddon?.fit(); } catch { // fit() throws when the container has zero dimensions; ignore // and wait for the next ResizeObserver tick. } }); }; const resizeObserver = new ResizeObserver(scheduleFit); resizeObserver.observe(container); const handleResize = () => { scheduleFit(); }; window.addEventListener("resize", handleResize); return () => { if (rafId !== undefined) { cancelAnimationFrame(rafId); } resizeObserver.disconnect(); window.removeEventListener("resize", handleResize); terminal?.dispose(); fitAddon?.dispose(); terminalRef.current = null; fitAddonRef.current = null; }; }, []); // Reset per-workspace state and wire the active socket to xterm. useEffect(() => { const terminal = terminalRef.current; if (!terminal || !workspaceId) return; hasWelcomedRef.current = false; isDeadRef.current = false; setIsDead(false); // Clear the previous workspace's scrollback so the user does not see // shell output from another workspace in the new one. terminal.clear(); terminal.reset(); const removeInputHandler = terminal.onData((input) => { if (!isDeadRef.current) { send(input); } }).dispose; const unsubscribeData = onData((chunk) => { terminal.write(chunk); if (chunk.startsWith("[process exited")) { isDeadRef.current = true; setIsDead(true); } }); return () => { removeInputHandler(); unsubscribeData(); }; }, [workspaceId, send, onData]); // Print a one-time welcome banner the first time the socket opens. useEffect(() => { const terminal = terminalRef.current; if (!terminal || !workspaceId || status !== "open") return; if (hasWelcomedRef.current) return; hasWelcomedRef.current = true; terminal.write(`\r\n[connected to ${workspaceId}]\r\n$ `); }, [status, workspaceId]); if (!workspaceId) { return (
Select a workspace to use the terminal.
); } if (failed) { return (
Terminal failed to initialize
); } const statusHint = isDead ? "shell exited" : status === "error" ? "terminal disconnected" : status === "connecting" ? "connecting…" : null; const hint = shellRunning ? statusHint : "shell not running - start it to use the terminal"; return (
{hint && (
{hint} {!shellRunning && ( )}
)}
); }