import React, { useEffect, useRef, useState, useCallback } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { isDarkModeActive } from "../services/theme"; import "@xterm/xterm/css/xterm.css"; function base64ToUint8Array(base64String: string): Uint8Array { // @ts-expect-error Uint8Array.fromBase64 is a newer API if (Uint8Array.fromBase64) { // @ts-expect-error Uint8Array.fromBase64 is a newer API return Uint8Array.fromBase64(base64String); } const binaryString = atob(base64String); return Uint8Array.from(binaryString, (char) => char.charCodeAt(0)); } export interface EphemeralTerminal { id: string; command: string; cwd: string; createdAt: Date; } interface TerminalPanelProps { terminals: EphemeralTerminal[]; onClose: (id: string) => void; onInsertIntoInput?: (text: string) => void; } // Theme colors for xterm.js function getTerminalTheme(isDark: boolean): Record { if (isDark) { return { background: "#1a1b26", foreground: "#c0caf5", cursor: "#c0caf5", cursorAccent: "#1a1b26", selectionBackground: "#364a82", selectionForeground: "#c0caf5", black: "#32344a", red: "#f7768e", green: "#9ece6a", yellow: "#e0af68", blue: "#7aa2f7", magenta: "#ad8ee6", cyan: "#449dab", white: "#9699a8", brightBlack: "#444b6a", brightRed: "#ff7a93", brightGreen: "#b9f27c", brightYellow: "#ff9e64", brightBlue: "#7da6ff", brightMagenta: "#bb9af7", brightCyan: "#0db9d7", brightWhite: "#acb0d0", }; } return { background: "#f8f9fa", foreground: "#383a42", cursor: "#526eff", cursorAccent: "#f8f9fa", selectionBackground: "#bfceff", selectionForeground: "#383a42", black: "#383a42", red: "#e45649", green: "#50a14f", yellow: "#c18401", blue: "#4078f2", magenta: "#a626a4", cyan: "#0184bc", white: "#a0a1a7", brightBlack: "#4f525e", brightRed: "#e06c75", brightGreen: "#98c379", brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd", brightCyan: "#56b6c2", brightWhite: "#ffffff", }; } type TermStatus = "connecting" | "running" | "exited" | "error"; // SVG icons const CopyIcon = () => ( ); const CopyAllIcon = () => ( ); const InsertIcon = () => ( ); const InsertAllIcon = () => ( ); const CheckIcon = () => ( ); const CloseIcon = () => ( ); function ActionButton({ onClick, title, children, feedback, }: { onClick: () => void; title: string; children: React.ReactNode; feedback?: boolean; }) { return ( ); } export default function TerminalPanel({ terminals, onClose, onInsertIntoInput, }: TerminalPanelProps) { const [activeTabId, setActiveTabId] = useState(null); const [height, setHeight] = useState(300); const [heightLocked, setHeightLocked] = useState(false); const isFirstTerminalRef = useRef(true); const [copyFeedback, setCopyFeedback] = useState(null); const [statusMap, setStatusMap] = useState< Map >(new Map()); const isResizingRef = useRef(false); const startYRef = useRef(0); const startHeightRef = useRef(0); // Detect dark mode const [isDark, setIsDark] = useState(isDarkModeActive); useEffect(() => { const observer = new MutationObserver(() => { setIsDark(isDarkModeActive()); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], }); return () => observer.disconnect(); }, []); // Auto-select newest tab when a new terminal is added useEffect(() => { if (terminals.length > 0) { const lastTerminal = terminals[terminals.length - 1]; setActiveTabId(lastTerminal.id); } else { setActiveTabId(null); } }, [terminals.length]); // If active tab got closed, switch to the last remaining useEffect(() => { if (activeTabId && !terminals.find((t) => t.id === activeTabId)) { if (terminals.length > 0) { setActiveTabId(terminals[terminals.length - 1].id); } else { setActiveTabId(null); } } }, [terminals, activeTabId]); const handleStatusChange = useCallback( (id: string, status: TermStatus, exitCode: number | null, contentLines: number) => { setStatusMap((prev) => { const next = new Map(prev); const existing = next.get(id); // Don't overwrite exit status with ws.onclose if ( existing && existing.status === "exited" && status === "exited" && contentLines === -1 ) { return prev; } const lines = contentLines === -1 ? existing?.contentLines || 0 : contentLines; next.set(id, { status, exitCode: exitCode ?? existing?.exitCode ?? null, contentLines: lines, }); return next; }); }, [], ); // Auto-size only for the very first terminal. After that, keep whatever height we have. useEffect(() => { if (heightLocked || !activeTabId) return; if (!isFirstTerminalRef.current) return; const info = statusMap.get(activeTabId); if (!info) return; const cellHeight = 17; // approximate const minHeight = 60; const maxHeight = 500; const tabBarHeight = 38; if (info.status === "exited" || info.status === "error") { const needed = Math.min( maxHeight, Math.max(minHeight, info.contentLines * cellHeight + tabBarHeight + 16), ); setHeight(needed); setHeightLocked(true); isFirstTerminalRef.current = false; } else if (info.status === "running") { // While the first command is still running, grow if needed const needed = Math.min( maxHeight, Math.max(minHeight, info.contentLines * cellHeight + tabBarHeight + 16), ); setHeight((prev) => Math.max(prev, needed)); } }, [statusMap, activeTabId, heightLocked]); // Resize drag const handleResizeMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); isResizingRef.current = true; startYRef.current = e.clientY; startHeightRef.current = height; const handleMouseMove = (e: MouseEvent) => { if (!isResizingRef.current) return; // Dragging up increases height const delta = startYRef.current - e.clientY; setHeight(Math.max(80, Math.min(800, startHeightRef.current + delta))); setHeightLocked(true); isFirstTerminalRef.current = false; }; const handleMouseUp = () => { isResizingRef.current = false; document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, [height], ); const showFeedback = useCallback((type: string) => { setCopyFeedback(type); setTimeout(() => setCopyFeedback(null), 1500); }, []); // Get the xterm instance for the active tab const xtermRegistryRef = useRef>(new Map()); const registerXterm = useCallback((id: string, xterm: Terminal) => { xtermRegistryRef.current.set(id, xterm); }, []); const unregisterXterm = useCallback((id: string) => { xtermRegistryRef.current.delete(id); }, []); const getBufferText = useCallback( (mode: "screen" | "all"): string => { if (!activeTabId) return ""; const xterm = xtermRegistryRef.current.get(activeTabId); if (!xterm) return ""; const lines: string[] = []; const buffer = xterm.buffer.active; if (mode === "screen") { const startRow = buffer.viewportY; for (let i = 0; i < xterm.rows; i++) { const line = buffer.getLine(startRow + i); if (line) lines.push(line.translateToString(true)); } } else { for (let i = 0; i < buffer.length; i++) { const line = buffer.getLine(i); if (line) lines.push(line.translateToString(true)); } } return lines.join("\n").trimEnd(); }, [activeTabId], ); const copyScreen = useCallback(() => { navigator.clipboard.writeText(getBufferText("screen")); showFeedback("copyScreen"); }, [getBufferText, showFeedback]); const copyAll = useCallback(() => { navigator.clipboard.writeText(getBufferText("all")); showFeedback("copyAll"); }, [getBufferText, showFeedback]); const insertScreen = useCallback(() => { if (onInsertIntoInput) { onInsertIntoInput(getBufferText("screen")); showFeedback("insertScreen"); } }, [getBufferText, onInsertIntoInput, showFeedback]); const insertAll = useCallback(() => { if (onInsertIntoInput) { onInsertIntoInput(getBufferText("all")); showFeedback("insertAll"); } }, [getBufferText, onInsertIntoInput, showFeedback]); const handleCloseActive = useCallback(() => { if (activeTabId) onClose(activeTabId); }, [activeTabId, onClose]); if (terminals.length === 0) return null; // Truncate command for tab label const tabLabel = (cmd: string) => { // Show first word or first 30 chars const firstWord = cmd.split(/\s+/)[0]; if (firstWord.length > 30) return firstWord.substring(0, 27) + "..."; return firstWord; }; return (
{/* Resize handle at top */}
{/* Tab bar + actions */}
{terminals.map((t) => { const info = statusMap.get(t.id); const isActive = t.id === activeTabId; return (
setActiveTabId(t.id)} title={t.command} > {info?.status === "running" && ( )} {info?.status === "exited" && info.exitCode === 0 && ( )} {info?.status === "exited" && info.exitCode !== 0 && ( )} {info?.status === "error" && ( )} {tabLabel(t.command)}
); })}
{/* Action buttons */}
{copyFeedback === "copyScreen" ? : } {copyFeedback === "copyAll" ? : } {onInsertIntoInput && ( <> {copyFeedback === "insertScreen" ? : } {copyFeedback === "insertAll" ? : } )}
{/* Terminal content area */}
{terminals.map((t) => ( ))}
); } // Wrapper that also registers the xterm instance function TerminalInstanceWithRegistry({ term, isVisible, isDark, onStatusChange, onRegister, onUnregister, }: { term: EphemeralTerminal; isVisible: boolean; isDark: boolean; onStatusChange: ( id: string, status: TermStatus, exitCode: number | null, contentLines: number, ) => void; onRegister: (id: string, xterm: Terminal) => void; onUnregister: (id: string) => void; }) { const containerRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const wsRef = useRef(null); useEffect(() => { if (!containerRef.current) return; const xterm = new Terminal({ cursorBlink: true, fontSize: 13, fontFamily: 'Consolas, "Liberation Mono", Menlo, Courier, monospace', theme: getTerminalTheme(isDark), scrollback: 10000, }); xtermRef.current = xterm; const fitAddon = new FitAddon(); fitAddonRef.current = fitAddon; xterm.loadAddon(fitAddon); xterm.loadAddon(new WebLinksAddon()); xterm.open(containerRef.current); fitAddon.fit(); onRegister(term.id, xterm); const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.host}/api/exec-ws?cmd=${encodeURIComponent(term.command)}&cwd=${encodeURIComponent(term.cwd)}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { ws.send(JSON.stringify({ type: "init", cols: xterm.cols, rows: xterm.rows })); onStatusChange(term.id, "running", null, 0); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.type === "output" && msg.data) { xterm.write(base64ToUint8Array(msg.data)); const buf = xterm.buffer.active; let lines = 0; for (let i = buf.length - 1; i >= 0; i--) { const line = buf.getLine(i); if (line && line.translateToString(true).trim()) { lines = i + 1; break; } } onStatusChange(term.id, "running", null, lines); } else if (msg.type === "exit") { const code = parseInt(msg.data, 10) || 0; const buf = xterm.buffer.active; let lines = 0; for (let i = buf.length - 1; i >= 0; i--) { const line = buf.getLine(i); if (line && line.translateToString(true).trim()) { lines = i + 1; break; } } onStatusChange(term.id, "exited", code, lines); } else if (msg.type === "error") { xterm.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`); onStatusChange(term.id, "error", null, 0); } } catch (err) { console.error("Failed to parse terminal message:", err); } }; ws.onerror = (event) => console.error("WebSocket error:", event); ws.onclose = () => { onStatusChange(term.id, "exited", null, -1); }; xterm.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "input", data })); } }); const ro = new ResizeObserver(() => { if (!fitAddonRef.current) return; fitAddonRef.current.fit(); if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && xtermRef.current) { wsRef.current.send( JSON.stringify({ type: "resize", cols: xtermRef.current.cols, rows: xtermRef.current.rows, }), ); } }); ro.observe(containerRef.current); return () => { ro.disconnect(); ws.close(); xterm.dispose(); onUnregister(term.id); }; }, [term.id, term.command, term.cwd]); // Update theme useEffect(() => { if (xtermRef.current) { xtermRef.current.options.theme = getTerminalTheme(isDark); } }, [isDark]); // Refit when visibility changes useEffect(() => { if (isVisible && fitAddonRef.current) { setTimeout(() => fitAddonRef.current?.fit(), 20); } }, [isVisible]); return (
); }