'use client' import * as React from 'react' import type { Terminal as XTerm } from 'xterm' import type { FitAddon } from '@xterm/addon-fit' import { AlertCircle, RefreshCw, TerminalSquare, Wifi, WifiOff } from 'lucide-react' import { cn } from '../../lib/utils' import { TERMINAL_THEMES, type TerminalStatus, type TerminalTheme } from './themes' /** * Imperative handle the PARENT uses to drive the terminal. The parent owns the * transport (a websocket, an SSE stream, …) and pumps inbound bytes in via * `write()` — the terminal never re-renders per byte. */ export interface TerminalHandle { /** Write raw output (including ANSI escapes) to the terminal. */ write(data: string): void /** Clear the screen + scrollback. */ clear(): void /** Re-fit the terminal to its container. */ fit(): void /** Move keyboard focus into the terminal. */ focus(): void } export interface TerminalProps { /** xterm colour theme. Defaults to `TERMINAL_THEMES.dark`. */ theme?: TerminalTheme fontFamily?: string fontSize?: number scrollback?: number /** Connection status shown in the chrome's indicator (purely cosmetic). */ status?: TerminalStatus /** Fired with keystrokes/data typed into the terminal. Wire to your transport. */ onData?: (data: string) => void /** Fired when the terminal is resized (after a fit). Report to the PTY. */ onResize?: (cols: number, rows: number) => void /** Fired once the terminal is initialised, handing back the imperative handle. */ onReady?: (handle: TerminalHandle) => void /** Fired when the reconnect button is clicked (only shown when provided). */ onReconnect?: () => void /** Show the header chrome (title, status indicator, reconnect). Default true. */ showChrome?: boolean className?: string /** Optional text written to the terminal once, right after init. */ banner?: string } type Loggable = unknown function StatusIndicator({ status }: { status: TerminalStatus }) { switch (status) { case 'connected': return ( ) case 'connecting': return ( ) case 'error': return ( ) case 'disconnected': default: return ( ) } } /** * A presentational xterm.js terminal. Generic — it knows nothing about the * transport, the session model, or any app. The parent feeds output through the * `TerminalHandle` (via `onReady`) and receives keystrokes/resizes through * `onData`/`onResize`. * * The defensive init sequence (DOM-ready wait, render-service wait, safe fit, and * a React-StrictMode double-init guard) is ported verbatim from dev-hq's * TerminalInterface — it exists for real timing bugs, not decoration. xterm is * loaded via dynamic import so this module is SSR- and test-safe (importing it * never touches `xterm` at module-eval time). */ export function Terminal({ theme = TERMINAL_THEMES.dark, fontFamily = 'Menlo, Monaco, "Courier New", monospace', fontSize = 14, scrollback = 10000, status = 'disconnected', onData, onResize, onReady, onReconnect, showChrome = true, className, banner, }: TerminalProps) { const containerRef = React.useRef(null) const xtermRef = React.useRef(null) const fitAddonRef = React.useRef(null) const isInitializedRef = React.useRef(false) // guard against StrictMode double-init const isMountedRef = React.useRef(true) const resizeTimeoutRef = React.useRef | null>(null) // Keep callbacks in refs so the (sessionUuid-less) mount effect stays stable and // xterm is never re-created just because a parent re-renders with a new closure. const onDataRef = React.useRef(onData) const onResizeRef = React.useRef(onResize) const onReadyRef = React.useRef(onReady) onDataRef.current = onData onResizeRef.current = onResize onReadyRef.current = onReady const [errorMessage, setErrorMessage] = React.useState(null) // ---- defensive init helpers (ported from dev-hq, logging stripped) ---------- const waitForDOMReady = React.useCallback((): Promise => { return new Promise((resolve, reject) => { const container = containerRef.current if (!container) { reject(new Error('Terminal container ref not available')) return } if (container.offsetWidth > 0 && container.offsetHeight > 0) { resolve() return } const ro = new ResizeObserver((entries) => { for (const entry of entries) { if (entry.contentRect.width > 0 && entry.contentRect.height > 0) { ro.disconnect() resolve() return } } }) ro.observe(container) setTimeout(() => { ro.disconnect() reject(new Error('Container dimensions timeout')) }, 5000) }) }, []) // Poll until xterm's render service exists and the terminal is attached. Never // throws on timeout — the terminal is usable and fit() retries on resize. const waitForRenderService = React.useCallback(async (terminal: XTerm): Promise => { const maxAttempts = 10 const delay = 50 for (let attempt = 0; attempt < maxAttempts; attempt++) { if (!isMountedRef.current) throw new Error('Component unmounted during initialization') try { const term = terminal as unknown as { _core?: { _renderService?: Loggable; element?: Loggable } } if (term._core?._renderService && term._core?.element) return } catch { /* render service not ready — keep polling */ } await new Promise((r) => setTimeout(r, delay)) } }, []) const safeFit = React.useCallback((fitAddon: FitAddon): void => { if (!isMountedRef.current || !containerRef.current) return try { fitAddon.fit() } catch { /* fit failures are timing-related; retries happen on resize */ } }, []) const handleResize = React.useCallback(() => { if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current) resizeTimeoutRef.current = setTimeout(() => { if (fitAddonRef.current && xtermRef.current && containerRef.current && isMountedRef.current) { try { fitAddonRef.current.fit() } catch { /* ignore — retries on next resize */ } } }, 150) }, []) // Mount / unmount: create + dispose xterm exactly once per mount. React.useEffect(() => { isMountedRef.current = true let disposed = false let cleanupCalled = false const init = async () => { if (!containerRef.current || xtermRef.current || isInitializedRef.current) return isInitializedRef.current = true try { // Dynamic import keeps xterm out of the SSR/test module graph. const [{ Terminal: XTermCtor }, { FitAddon: FitAddonCtor }] = await Promise.all([ import('xterm'), import('@xterm/addon-fit'), ]) if (!isMountedRef.current || disposed) return await waitForDOMReady() if (!isMountedRef.current || disposed) return const terminal = new XTermCtor({ cursorBlink: true, fontSize, fontFamily, fontWeight: '400', fontWeightBold: '700', lineHeight: 1.2, letterSpacing: 0, theme, scrollback, allowProposedApi: true, allowTransparency: true, windowsMode: false, }) const fitAddon = new FitAddonCtor() terminal.loadAddon(fitAddon) terminal.open(containerRef.current) xtermRef.current = terminal fitAddonRef.current = fitAddon try { await waitForRenderService(terminal) } catch { /* continue — terminal still usable */ } if (!isMountedRef.current || disposed) { terminal.dispose() return } safeFit(fitAddon) terminal.onData((data: string) => onDataRef.current?.(data)) terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => onResizeRef.current?.(cols, rows), ) if (banner) terminal.writeln(banner) const handle: TerminalHandle = { write: (d) => terminal.write(d), clear: () => terminal.clear(), fit: () => safeFit(fitAddon), focus: () => terminal.focus(), } onReadyRef.current?.(handle) } catch (error) { const err = error instanceof Error ? error : new Error('Failed to initialize terminal') setErrorMessage(err.message) isInitializedRef.current = false // allow retry } } // Small delay so refs are set and the container is laid out. const initTimer = setTimeout(init, 100) return () => { clearTimeout(initTimer) if (cleanupCalled) return // StrictMode runs cleanup twice in dev cleanupCalled = true disposed = true isMountedRef.current = false if (resizeTimeoutRef.current) { clearTimeout(resizeTimeoutRef.current) resizeTimeoutRef.current = null } if (xtermRef.current) { xtermRef.current.dispose() xtermRef.current = null } fitAddonRef.current = null isInitializedRef.current = false } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Live-apply theme changes without re-creating the terminal. React.useEffect(() => { if (xtermRef.current) xtermRef.current.options.theme = theme }, [theme]) React.useEffect(() => { window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) }, [handleResize]) const showReconnect = !!onReconnect && (status === 'disconnected' || status === 'error') return (
{showChrome && (
{showReconnect && ( )}
)} {errorMessage && (
)}
) } /** Skeleton placeholder shown while a terminal surface is loading. */ export function TerminalSkeleton({ className }: { className?: string }) { return (
{Array.from({ length: 10 }).map((_, i) => (
))}
) }