import { useCallback, useEffect, useRef, useState } from 'react'; import { RotateCcw, Minus, X } from 'lucide-react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import '@xterm/xterm/css/xterm.css'; import { getElectronApi } from '@renderer/lib/electronApi'; import type { TerminalSnapshot } from '@shared/domain/terminal'; import type { TerminalExitInfo } from '@shared/domain/terminal'; /* ── Theme ────────────────────────────────────────────────── */ const terminalTheme = { background: '#07080e', foreground: '#e8eaf0', cursor: '#245CF9', cursorAccent: '#07080e', selectionBackground: 'rgba(36, 92, 249, 0.2)', selectionForeground: '#e8eaf0', black: '#1e2233', red: '#f87171', green: '#4ade80', yellow: '#facc15', blue: '#248CFD', magenta: '#a855f7', cyan: '#22d3ee', white: '#e8eaf0', brightBlack: '#4e5368', brightRed: '#fca5a5', brightGreen: '#86efac', brightYellow: '#fde047', brightBlue: '#60a5fa', brightMagenta: '#c084fc', brightCyan: '#67e8f9', brightWhite: '#f8f9fc', }; /* ── Constants ────────────────────────────────────────────── */ const MIN_HEIGHT = 120; const MAX_HEIGHT_FRACTION = 0.7; const DEFAULT_HEIGHT = 280; /* ── TerminalPanel ────────────────────────────────────────── */ interface TerminalPanelProps { height: number; onHeightChange: (height: number) => void; onClose: () => void; onMinimize: () => void; } export function TerminalPanel({ height, onHeightChange, onClose, onMinimize, }: TerminalPanelProps) { const api = getElectronApi(); const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); const [snapshot, setSnapshot] = useState(); const [isRunning, setIsRunning] = useState(false); const [isDragging, setIsDragging] = useState(false); const dragStartRef = useRef<{ y: number; height: number } | null>(null); // Create or recover terminal on mount useEffect(() => { let disposed = false; void api.describeTerminal().then((existing) => { if (disposed) return; if (existing) { setSnapshot(existing); setIsRunning(true); } else { void api.createTerminal().then((created) => { if (disposed) return; setSnapshot(created); setIsRunning(true); }); } }); return () => { disposed = true; }; }, [api]); // Initialize xterm.js useEffect(() => { if (!containerRef.current) return; const terminal = new Terminal({ theme: terminalTheme, fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace', fontSize: 13, lineHeight: 1.4, cursorBlink: true, cursorStyle: 'bar', scrollback: 5000, allowProposedApi: true, }); const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(containerRef.current); terminalRef.current = terminal; fitAddonRef.current = fitAddon; // Send keystrokes to the backend const dataDisposable = terminal.onData((data) => { api.writeTerminal(data); }); // Initial fit requestAnimationFrame(() => { fitAddon.fit(); api.resizeTerminal({ cols: terminal.cols, rows: terminal.rows }); }); return () => { dataDisposable.dispose(); terminal.dispose(); terminalRef.current = null; fitAddonRef.current = null; }; }, [api]); // Subscribe to terminal data and exit events useEffect(() => { const offData = api.onTerminalData((data) => { terminalRef.current?.write(data); }); const offExit = api.onTerminalExit((_info: TerminalExitInfo) => { setIsRunning(false); terminalRef.current?.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n'); }); return () => { offData(); offExit(); }; }, [api]); // Refit on height changes useEffect(() => { if (!fitAddonRef.current || !terminalRef.current) return; requestAnimationFrame(() => { fitAddonRef.current?.fit(); const terminal = terminalRef.current; if (terminal) { api.resizeTerminal({ cols: terminal.cols, rows: terminal.rows }); } }); }, [height, api]); // ResizeObserver for container width changes useEffect(() => { const container = containerRef.current; if (!container) return; const observer = new ResizeObserver(() => { requestAnimationFrame(() => { fitAddonRef.current?.fit(); const terminal = terminalRef.current; if (terminal) { api.resizeTerminal({ cols: terminal.cols, rows: terminal.rows }); } }); }); observer.observe(container); return () => observer.disconnect(); }, [api]); // Drag-to-resize const handleDragStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); dragStartRef.current = { y: e.clientY, height }; setIsDragging(true); const handleDragMove = (moveEvent: MouseEvent) => { if (!dragStartRef.current) return; const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION; const delta = dragStartRef.current.y - moveEvent.clientY; const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta)); onHeightChange(nextHeight); }; const handleDragEnd = () => { setIsDragging(false); dragStartRef.current = null; document.removeEventListener('mousemove', handleDragMove); document.removeEventListener('mouseup', handleDragEnd); }; document.addEventListener('mousemove', handleDragMove); document.addEventListener('mouseup', handleDragEnd); }, [height, onHeightChange]); const handleRestart = useCallback(() => { void api.restartTerminal().then((restarted) => { setSnapshot(restarted); setIsRunning(true); terminalRef.current?.clear(); }); }, [api]); const handleClose = useCallback(() => { void api.killTerminal(); onClose(); }, [api, onClose]); return (
{/* Resize handle */}
{ if (e.key === 'ArrowUp') { e.preventDefault(); onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20)); } else if (e.key === 'ArrowDown') { e.preventDefault(); onHeightChange(Math.max(MIN_HEIGHT, height - 20)); } }} /> {/* Header bar */}
{snapshot ? `${snapshot.shell} — ${snapshot.cwd}` : 'Terminal'}
{/* Terminal body */}
); } export { DEFAULT_HEIGHT, MIN_HEIGHT };