'use client'; import type { ITheme, Terminal } from '@xterm/xterm'; import { useEffect, useRef, useState } from 'react'; import { useTheme } from 'next-themes'; import { Button } from '@spoon/ui'; import { scheduleTerminalFits } from './terminal-fit'; import '@xterm/xterm/css/xterm.css'; const TERMINAL_FONT = "var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace"; // Persisted user preference for copy-on-select. A stored value takes precedence // over the `copyOnSelect` prop default so the toggle sticks across sessions. const COPY_ON_SELECT_KEY = 'spoon.terminal.copyOnSelect'; const readStoredCopyOnSelect = (): boolean | undefined => { try { const stored = window.localStorage.getItem(COPY_ON_SELECT_KEY); if (stored === 'true') return true; if (stored === 'false') return false; } catch { // localStorage may be unavailable (private mode, SSR); fall back to prop. } return undefined; }; export type XtermStatus = | 'idle' | 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured'; const darkTheme: ITheme = { background: '#080e14', foreground: '#eef3f5', cursor: '#1fb895', cursorAccent: '#080e14', selectionBackground: '#1fb89544', black: '#10171e', red: '#f3625d', green: '#8fd6b4', yellow: '#e3b341', blue: '#6aa6ff', magenta: '#b692e8', cyan: '#5fd0e0', white: '#cdd6dc', brightBlack: '#93a1a9', brightRed: '#f3625d', brightGreen: '#8fd6b4', brightYellow: '#e3b341', brightBlue: '#6aa6ff', brightMagenta: '#b692e8', brightCyan: '#5fd0e0', brightWhite: '#eef3f5', }; const lightTheme: ITheme = { background: '#f7fbfa', foreground: '#0d1218', cursor: '#007560', cursorAccent: '#f7fbfa', selectionBackground: '#00756033', black: '#0d1218', red: '#d73337', green: '#2f8f6e', yellow: '#9a6b00', blue: '#2f6bd8', magenta: '#7c4dd1', cyan: '#0f7d92', white: '#26323c', brightBlack: '#555f68', brightRed: '#d73337', brightGreen: '#2f8f6e', brightYellow: '#9a6b00', brightBlue: '#2f6bd8', brightMagenta: '#7c4dd1', brightCyan: '#0f7d92', brightWhite: '#0d1218', }; export const XtermSession = ({ active, visible = true, tokenUrl, sessionKey, waitingLabel, copyOnSelect, label = 'workspace shell', }: { active: boolean; visible?: boolean; tokenUrl: string; sessionKey: string; waitingLabel?: string; copyOnSelect?: boolean; label?: string; }) => { const containerRef = useRef(null); const termRef = useRef(null); const fitRef = useRef<{ fit: () => void } | null>(null); const wsRef = useRef(null); const { resolvedTheme } = useTheme(); const themeIsLight = resolvedTheme === 'light'; const [status, setStatus] = useState('idle'); const [errorText, setErrorText] = useState(); const [reconnectKey, setReconnectKey] = useState(0); // localStorage overrides the prop default; the prop is only the initial value // when nothing has been persisted yet. const [copyEnabled, setCopyEnabled] = useState( () => readStoredCopyOnSelect() ?? copyOnSelect ?? false, ); // The connect effect registers onSelectionChange once; it reads the current // toggle through this ref so live flips take effect without a reconnect. const copyEnabledRef = useRef(copyEnabled); useEffect(() => { copyEnabledRef.current = copyEnabled; }, [copyEnabled]); const toggleCopyOnSelect = () => { setCopyEnabled((prev) => { const next = !prev; try { window.localStorage.setItem(COPY_ON_SELECT_KEY, String(next)); } catch { // Persistence is best-effort; ignore storage failures. } return next; }); }; // Update the live terminal's theme without tearing down the session. useEffect(() => { if (termRef.current) { termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme; } }, [themeIsLight]); useEffect(() => { if (!active) return; const container = containerRef.current; if (!container) return; const abortController = new AbortController(); const signal = abortController.signal; // Read through a function so TS doesn't narrow `aborted` to a constant after // the first guard (it changes asynchronously, on cleanup). const isAborted = () => signal.aborted; let ws: WebSocket | undefined; let resizeObserver: ResizeObserver | undefined; const encoder = new TextEncoder(); const start = async () => { const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all( [ import('@xterm/xterm'), import('@xterm/addon-fit'), import('@xterm/addon-web-links'), ], ); if (isAborted()) return; setStatus('connecting'); setErrorText(undefined); let response: Response; try { response = await fetch(tokenUrl, { signal }); } catch { if (!isAborted()) setStatus('error'); return; } if (isAborted()) return; if (response.status === 503) { setStatus('unconfigured'); return; } if (!response.ok) { setStatus('error'); setErrorText(await response.text().catch(() => undefined)); return; } const { url } = (await response.json()) as { url: string }; const term = new Terminal({ fontFamily: TERMINAL_FONT, fontSize: 13, lineHeight: 1.2, cursorBlink: true, theme: themeIsLight ? lightTheme : darkTheme, allowProposedApi: true, scrollback: 5000, }); const fit = new FitAddon(); term.loadAddon(fit); term.loadAddon(new WebLinksAddon()); term.open(container); termRef.current = term; fitRef.current = fit; // Copy-on-select: when enabled, mirror the selection to the clipboard. // Registered once; the ref lets live toggle flips apply without a // reconnect. Clipboard access is guarded — it may be unavailable or // denied, and empty selections are skipped. term.onSelectionChange(() => { if (!copyEnabledRef.current) return; const selection = term.getSelection(); if (!selection) return; try { void navigator.clipboard.writeText(selection).catch(() => { // clipboard write denied/unavailable; ignore }); } catch { // navigator.clipboard missing entirely; ignore } }); const sendResize = () => { if (ws?.readyState !== WebSocket.OPEN) return; ws.send( JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }), ); }; scheduleTerminalFits({ fit: () => { try { fit.fit(); } catch { // ignore transient layout errors } }, sendResize, fontsReady: document.fonts.ready, loadNerdFont: () => document.fonts.load("16px 'Symbols Nerd Font Mono'", '').then(() => { if (!isAborted()) term.refresh(0, term.rows - 1); }), raf: (cb) => requestAnimationFrame(cb), isAborted, }); ws = new WebSocket(url); wsRef.current = ws; ws.binaryType = 'arraybuffer'; ws.onopen = () => { if (isAborted()) return; setStatus('connected'); sendResize(); term.focus(); }; ws.onmessage = (event: MessageEvent) => { if (typeof event.data === 'string') term.write(event.data); else term.write(new Uint8Array(event.data)); }; ws.onclose = () => { if (!isAborted()) setStatus('closed'); }; ws.onerror = () => { if (!isAborted()) setStatus('error'); }; term.onData((data) => { if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data)); }); term.onResize(() => sendResize()); resizeObserver = new ResizeObserver(() => { try { fit.fit(); } catch { // ignore transient layout errors } }); resizeObserver.observe(container); }; void start(); return () => { abortController.abort(); resizeObserver?.disconnect(); ws?.close(); termRef.current?.dispose(); termRef.current = null; fitRef.current = null; wsRef.current = null; }; // resolvedTheme intentionally excluded: handled by the theme effect above. // eslint-disable-next-line react-hooks/exhaustive-deps }, [active, sessionKey, reconnectKey]); // A hidden (display:none) container measures 0×0, so xterm mis-sizes while the // tab is inactive. When the terminal becomes visible again, refit inside a // frame (so layout has settled) and push the new dimensions to the PTY. The // WebSocket stays open across visibility changes — this never reconnects. useEffect(() => { if (!visible || !active) return; const frame = requestAnimationFrame(() => { try { fitRef.current?.fit(); } catch { // ignore transient layout errors } const ws = wsRef.current; const term = termRef.current; if (ws?.readyState === WebSocket.OPEN && term) { ws.send( JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }), ); } }); return () => cancelAnimationFrame(frame); }, [visible, active]); return (

{status === 'connected' ? `Connected · ${label}` : status === 'idle' ? (waitingLabel ? 'Waiting…' : 'Idle') : status === 'connecting' ? 'Connecting…' : status === 'closed' ? 'Session ended' : status === 'unconfigured' ? 'Terminal not configured' : 'Connection error'}

{status === 'closed' || status === 'error' ? ( ) : null}
{status === 'unconfigured' ? (
The terminal is not enabled on this deployment.
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
) : !active && waitingLabel ? (
{waitingLabel}
) : (
{errorText ? (

{errorText}

) : null}
)}
); };