import { useEffect, useRef, useState } from 'react'; import { Terminal, type ITheme } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { ClipboardAddon, Base64 } from '@xterm/addon-clipboard'; import '@xterm/xterm/css/xterm.css'; import type { Cli, Session } from '../types'; import { STATE_LABEL } from '../types'; import Logo from './Logo'; import { CloseGlyph, RefreshGlyph } from './icons'; const THEMES: Record<'light' | 'dark', ITheme> = { dark: { background: '#0e1217', foreground: '#c6d0d8', cursor: '#43c98a', cursorAccent: '#0e1217', selectionBackground: '#2bb3bd44', // ANSI tuned bright enough for the dark background (used by the prompt). green: '#43c98a', brightGreen: '#5fe0a0', cyan: '#5fd0d8', brightCyan: '#7fe3ea', blue: '#6ea8fe', brightBlue: '#8cbcff', red: '#e0726a', yellow: '#e0a948', }, light: { background: '#ffffff', foreground: '#1b2329', cursor: '#0e7c86', cursorAccent: '#ffffff', selectionBackground: '#0e7c8633', // ANSI darkened for contrast on white (fixes the unreadable prompt in light mode). green: '#1c8c57', brightGreen: '#1c8c57', cyan: '#0e7c86', brightCyan: '#0e7c86', blue: '#2257c4', brightBlue: '#2257c4', red: '#c2433a', yellow: '#9a6a00', }, }; type ConnState = 'connecting' | 'connected' | 'closed' | 'exited'; // Close code the server uses when the session's process exited for real (vs a // transient drop). The client must NOT auto-reconnect on this, or it would // respawn the agent in a loop and trample an in-progress login flow. const EXIT_CODE = 4000; function workspaceLabel(p: string | null) { const rel = (p || '').replace(/^\.\/?/, '').replace(/^\/+|\/+$/g, ''); return rel ? `workspace/${rel}/` : 'workspace/'; } // Serialize the current selection, joining soft-wrapped rows instead of // emitting a newline per visual row. tmux repaints with explicit cursor moves, // so a wrapped logical line arrives as separate full-width rows that xterm's // default getSelection() would break with '\n'. We rejoin a row to the next // when xterm flags it wrapped OR it's filled to the last column (the tmux case). function selectionText(term: Terminal): string { try { const pos = term.getSelectionPosition?.(); if (!pos) return term.getSelection(); const buf = term.buffer.active; const cols = term.cols; const out: string[] = []; let cur = ''; for (let y = pos.start.y; y <= pos.end.y; y++) { const line = buf.getLine(y); if (!line) continue; const startX = y === pos.start.y ? pos.start.x : 0; const endX = y === pos.end.y ? pos.end.x : cols; cur += line.translateToString(false, startX, endX); const lastChar = line.getCell(cols - 1)?.getChars() ?? ''; const full = endX >= cols && lastChar !== '' && lastChar !== ' '; const wrapped = y < pos.end.y && ((buf.getLine(y + 1)?.isWrapped ?? false) || full); if (!wrapped) { out.push(cur.replace(/[ \t]+$/, '')); cur = ''; } } if (cur) out.push(cur.replace(/[ \t]+$/, '')); const joined = out.join('\n'); return joined || term.getSelection(); } catch { return term.getSelection(); } } // Copying to the system clipboard has to survive two hostile conditions on the // Space: the app is usually embedded in Hugging Face's cross-origin iframe // (which blocks the async Clipboard API entirely), and the tab is often // unfocused. So explicit user copies layer: // 1. execCommand('copy') via a hidden textarea — the ONLY path that works // inside the iframe, and it works during any user gesture. // 2. navigator.clipboard.writeText — the modern path (focused, top-level). // 3. if both fail (a requested copy couldn't write), stash briefly // and flush on the user's next click, so the copy lands when they return. // It must run synchronously inside the triggering event — deferring to a // setTimeout loses the activation that execCommand needs. const OSC52_COPY_WINDOW_MS = 1500; let osc52CopyAllowedUntil = 0; function allowNextOsc52Copy() { osc52CopyAllowedUntil = Date.now() + OSC52_COPY_WINDOW_MS; } function consumeOsc52CopyPermission(): boolean { if (Date.now() > osc52CopyAllowedUntil) return false; osc52CopyAllowedUntil = 0; return true; } function legacyCopy(text: string): boolean { const prev = document.activeElement as HTMLElement | null; try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.top = '-9999px'; document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, text.length); const ok = document.execCommand('copy'); document.body.removeChild(ta); prev?.focus?.(); // don't steal focus from the terminal return ok; } catch { return false; } } let clipStash: { text: string; at: number } | null = null; function copyText(text: string) { if (!text) return; if (legacyCopy(text)) { clipStash = null; return; } if (navigator.clipboard?.writeText) { navigator.clipboard.writeText(text) .then(() => { clipStash = null; }) .catch(() => { clipStash = { text, at: Date.now() }; }); } else { clipStash = { text, at: Date.now() }; } } // A user-requested copy we couldn't complete lands on the next real interaction // — bounded so a stale copy can't clobber the clipboard much later. if (typeof window !== 'undefined') { window.addEventListener('pointerdown', () => { if (clipStash && Date.now() - clipStash.at < 8000) legacyCopy(clipStash.text); clipStash = null; }, true); } export default function TerminalPane({ session, cli, theme, focused, active, zoom = 100, dragId, onDragActive, onFocus, onRename, onClose, }: { session: Session; cli?: Cli; theme: 'light' | 'dark'; focused?: boolean; active?: boolean; zoom?: number; dragId?: string; // set when the pane can be rearranged (group view) onDragActive?: (dragging: boolean) => void; onFocus?: () => void; onRename?: (name: string) => void; onClose: () => void; }) { const hostRef = useRef(null); const termRef = useRef(null); const resyncRef = useRef<() => void>(() => {}); const reconnectRef = useRef<() => void>(() => {}); const [conn, setConn] = useState('connecting'); // "starting…" cover while the CLI boots into an empty pane (attach on the // Space can take seconds). Hidden only when the screen actually SHOWS // something — byte counts lie, because tmux's attach repaint of a blank // 200×50 screen is already kilobytes of escapes. const [booting, setBooting] = useState(true); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(session.name); const commitName = () => { const v = draft.trim(); if (v && v !== session.name) onRename?.(v); setEditing(false); }; useEffect(() => { const term = new Terminal({ fontFamily: "'Geist Mono', ui-monospace, 'SF Mono', Menlo, 'Cascadia Code', monospace", fontSize: 13, cursorBlink: true, scrollback: 20000, theme: THEMES[theme], // Let users make a *local* selection even when the app (tmux / an agent // TUI) has grabbed the mouse: ⌥-drag on macOS, Shift-drag elsewhere. macOptionClickForcesSelection: true, }); const fit = new FitAddon(); term.loadAddon(fit); let lastSelection = ''; let tmuxSelectionPending = false; let tmuxSelectionText = ''; let tmuxSelectionAt = 0; // tmux emits OSC 52 when copy-mode commits a mouse selection. Do not write // the system clipboard there; stash the decoded text and wait for Cmd/C. const clipboardProvider = { readText: (sel: string) => (sel !== 'p' && navigator.clipboard?.readText ? navigator.clipboard.readText().catch(() => '') : ''), writeText: (sel: string, data: string) => { const allowed = sel !== 'p' && consumeOsc52CopyPermission(); if (sel !== 'p') { tmuxSelectionText = data; tmuxSelectionAt = Date.now(); tmuxSelectionPending = true; } if (allowed) copyText(data); }, }; // addon-clipboard@0.1.0 has a (base64, provider) runtime constructor but // types it as (provider) — construct positionally to match the runtime. term.loadAddon(new (ClipboardAddon as unknown as new (b: Base64, p: typeof clipboardProvider) => ClipboardAddon)(new Base64(), clipboardProvider)); term.open(hostRef.current!); termRef.current = term; // Track the committed local xterm selection so Cmd/Ctrl+C can copy it // synchronously — deferring (setTimeout) would break execCommand's gesture. const selSub = term.onSelectionChange(() => { lastSelection = term.hasSelection() ? selectionText(term) : ''; if (lastSelection) tmuxSelectionPending = false; }); const copySelection = () => { const text = term.hasSelection() ? selectionText(term) : lastSelection; if (text) copyText(text); }; const copyTmuxSelection = (e?: ClipboardEvent): boolean => { const fresh = tmuxSelectionText && Date.now() - tmuxSelectionAt < 120_000; if (!tmuxSelectionPending || !fresh) return false; if (e?.clipboardData) { e.clipboardData.setData('text/plain', tmuxSelectionText); e.preventDefault(); e.stopPropagation(); } else { copyText(tmuxSelectionText); } return true; }; const host = hostRef.current!; let mouseDragStart: { x: number; y: number } | null = null; let mouseDragged = false; const onPointerDown = (e: PointerEvent) => { if (e.pointerType !== 'mouse' || e.button !== 0) return; mouseDragStart = { x: e.clientX, y: e.clientY }; mouseDragged = false; }; const onPointerMove = (e: PointerEvent) => { if (!mouseDragStart || (e.buttons & 1) === 0) return; if (Math.abs(e.clientX - mouseDragStart.x) > 3 || Math.abs(e.clientY - mouseDragStart.y) > 3) mouseDragged = true; }; const onPointerUp = (e: PointerEvent) => { if (e.pointerType !== 'mouse' || e.button !== 0) return; if (mouseDragged && !term.hasSelection()) { tmuxSelectionPending = true; } else if (!term.hasSelection()) { tmuxSelectionPending = false; } mouseDragStart = null; mouseDragged = false; }; const onClick = (e: MouseEvent) => { if (e.detail >= 2 && !term.hasSelection()) { tmuxSelectionPending = true; } }; host.addEventListener('pointerdown', onPointerDown, true); host.addEventListener('pointermove', onPointerMove, true); host.addEventListener('pointerup', onPointerUp, true); host.addEventListener('click', onClick, true); const onDocumentPointerDown = (e: PointerEvent) => { if (!host.contains(e.target as Node)) tmuxSelectionPending = false; }; const onCopy = (e: ClipboardEvent) => { copyTmuxSelection(e); }; document.addEventListener('pointerdown', onDocumentPointerDown, true); document.addEventListener('copy', onCopy, true); let ws: WebSocket | null = null; const send = (o: unknown) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o)); }; // ⌘/Ctrl+C copies selected text. With a tmux mouse selection, ask the server // to run tmux's copy command; the resulting OSC 52 is accepted only because // it follows this key gesture. Paste is left to xterm's native handler so // bracketed-paste framing is preserved. term.attachCustomKeyEventHandler((e) => { if (e.type !== 'keydown') return true; if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && term.hasSelection()) { copySelection(); term.clearSelection(); tmuxSelectionPending = false; return false; } if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && tmuxSelectionPending) { if (!copyTmuxSelection()) { allowNextOsc52Copy(); send({ t: 'copy' }); } return false; } if (e.metaKey && (e.key === 'c' || e.key === 'C')) return false; if (e.key === 'Escape') tmuxSelectionPending = false; return true; }); try { fit.fit(); } catch { /* layout not ready yet */ } // Re-measure once the webfont is ready (glyph width changes vs the fallback). document.fonts?.ready.then(() => { try { fit.fit(); } catch { /* ignore */ } }); let closedByUs = false; let retry: ReturnType | null = null; // Reconnect with backoff: a sleeping/unreachable Space shouldn't be hammered // every second by every open pane. Reset once a connection succeeds. let retryDelay = 1200; // Does the visible screen show real text in its UPPER two-thirds? Agent // TUIs paint their bottom input bar first and load the actual content // (banner, resumed history) seconds later — only the upper region tells us // the pane is genuinely ready. Shell prompts paint at the top anyway. const screenHasContent = () => { try { const buf = term.buffer.active; const upper = Math.max(2, Math.floor(term.rows * 2 / 3)); for (let y = 0; y < upper; y++) { const line = buf.getLine(buf.baseY + y); if (line && line.translateToString(true).trim().length >= 2) return true; } } catch { return true; } // never trap the cover on an internal error return false; }; let bootLive = false; let bootTimer: ReturnType | null = null; // safety cap let bootCheck: ReturnType | null = null; // throttled content probe const endBoot = () => { bootLive = false; if (bootTimer) { clearTimeout(bootTimer); bootTimer = null; } if (bootCheck) { clearTimeout(bootCheck); bootCheck = null; } setBooting(false); }; const connect = () => { setConn('connecting'); setBooting(true); bootLive = true; if (bootTimer) clearTimeout(bootTimer); bootTimer = setTimeout(endBoot, 20_000); const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const url = `${proto}://${location.host}/ws?session=${encodeURIComponent(session.id)}&cols=${term.cols}&rows=${term.rows}`; ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; ws.onopen = () => { setConn('connected'); retryDelay = 1200; try { fit.fit(); } catch { /* ignore */ } send({ t: 'r', cols: term.cols, rows: term.rows }); }; ws.onmessage = (e) => { const d = typeof e.data === 'string' ? e.data : new Uint8Array(e.data as ArrayBuffer); term.write(d); // Probe shortly after each burst (throttled; write() is async). if (bootLive && !bootCheck) { bootCheck = setTimeout(() => { bootCheck = null; if (bootLive && screenHasContent()) endBoot(); }, 150); } }; ws.onclose = (e) => { // A real process exit: stop here and let the user relaunch. Anything // else is a transient drop (sleep/wake, network) → auto-reconnect and // reattach to the still-running tmux session. endBoot(); if (e.code === EXIT_CODE) { setConn('exited'); return; } setConn('closed'); if (!closedByUs) { retry = setTimeout(connect, retryDelay); retryDelay = Math.min(retryDelay * 1.7, 15_000); } }; ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } }; }; // Manual restart: clear the dead run's screen so the content probe watches // the NEW process paint, not leftovers (tmux repaints the live screen). reconnectRef.current = () => { if (retry) clearTimeout(retry); retryDelay = 1200; try { term.reset(); } catch { /* ignore */ } connect(); }; // Re-fit and tell the server our size. With tmux window-size=latest this // makes the focused window own the size, so a duplicate window open at a // different size doesn't leave us with tmux's "dots" filler. const resync = () => { try { fit.fit(); send({ t: 'r', cols: term.cols, rows: term.rows }); } catch { /* ignore */ } }; const onVisible = () => { if (!document.hidden) resync(); }; resyncRef.current = resync; // so the zoom control can refit // Typing means the user sees enough to interact — drop the boot cover. // Real keystrokes only (onKey): onData ALSO fires for xterm's automatic // replies to the TUI's terminal queries (DA/CPR), which arrive instantly // on attach and must not count as "the user typed". const keySub = term.onKey(() => endBoot()); const dataSub = term.onData((d) => send({ t: 'i', d })); const ro = new ResizeObserver(resync); ro.observe(hostRef.current!); window.addEventListener('focus', resync); document.addEventListener('visibilitychange', onVisible); // Touch scrolling: xterm doesn't translate touch gestures for applications // that grabbed the mouse (tmux always does here), so swipes did nothing on // phones. Convert drags into wheel steps: SGR mouse-wheel sequences when // the app tracks the mouse (tmux scrolls its history), local scrollLines // otherwise. let touchY: number | null = null; const onTouchStart = (e: TouchEvent) => { touchY = e.touches[0].clientY; }; const onTouchMove = (e: TouchEvent) => { if (touchY == null) return; const y = e.touches[0].clientY; const dy = y - touchY; const steps = Math.trunc(dy / 24); // ~one wheel notch per 24px of drag if (steps !== 0) { touchY = y; let tracking = false; try { tracking = ((term as unknown as { modes?: { mouseTrackingMode?: string } }).modes?.mouseTrackingMode ?? 'none') !== 'none'; } catch { /* older xterm */ } const btn = steps > 0 ? 64 : 65; // drag down reveals earlier output = wheel up for (let i = 0; i < Math.abs(steps); i++) { if (tracking) send({ t: 'i', d: `\x1b[<${btn};${Math.max(1, Math.floor(term.cols / 2))};${Math.max(1, Math.floor(term.rows / 2))}M` }); else term.scrollLines(steps > 0 ? -1 : 1); } } if (Math.abs(dy) > 4) e.preventDefault(); // keep the page from rubber-banding }; const onTouchEnd = () => { touchY = null; }; host.addEventListener('touchstart', onTouchStart, { passive: true }); host.addEventListener('touchmove', onTouchMove, { passive: false }); host.addEventListener('touchend', onTouchEnd); connect(); return () => { closedByUs = true; if (retry) clearTimeout(retry); if (bootTimer) clearTimeout(bootTimer); if (bootCheck) clearTimeout(bootCheck); ro.disconnect(); host.removeEventListener('pointerdown', onPointerDown, true); host.removeEventListener('pointermove', onPointerMove, true); host.removeEventListener('pointerup', onPointerUp, true); host.removeEventListener('click', onClick, true); document.removeEventListener('pointerdown', onDocumentPointerDown, true); document.removeEventListener('copy', onCopy, true); host.removeEventListener('touchstart', onTouchStart); host.removeEventListener('touchmove', onTouchMove); host.removeEventListener('touchend', onTouchEnd); window.removeEventListener('focus', resync); document.removeEventListener('visibilitychange', onVisible); dataSub.dispose(); keySub.dispose(); selSub.dispose(); try { ws?.close(); } catch { /* ignore */ } term.dispose(); termRef.current = null; }; }, [session.id]); // Switch theme live without tearing down the terminal / connection. useEffect(() => { if (termRef.current) termRef.current.options.theme = THEMES[theme]; }, [theme]); // Zoom: adjust font size (100% = 13px) and refit. useEffect(() => { const t = termRef.current; if (!t) return; t.options.fontSize = Math.round((13 * zoom) / 100); resyncRef.current(); }, [zoom]); // Move keyboard focus into the terminal whenever this pane becomes the active // one (e.g. selected from the sidebar, or newly created). useEffect(() => { if (!active) return; const t = setTimeout(() => termRef.current?.focus(), 0); return () => clearTimeout(t); }, [active]); // Focused panes tint toward THEIR agent's brand color, not the app accent. const tint = cli?.color; const pathLabel = workspaceLabel(session.path); return (
onFocus?.()} > {/* preventDefault so clicking the (non-focusable) header keeps keyboard focus in the terminal instead of the browser blurring it — except when the header doubles as a drag handle, where it would block dragstart. */}
{ e.dataTransfer.setData('text/plain', dragId); e.dataTransfer.effectAllowed = 'move'; onDragActive?.(true); } : undefined} onDragEnd={dragId ? () => onDragActive?.(false) : undefined} onMouseDown={(e) => { if (!dragId) e.preventDefault(); onFocus?.(); termRef.current?.focus(); }} >
{editing ? ( e.stopPropagation()} onChange={(e) => setDraft(e.target.value)} onBlur={commitName} onKeyDown={(e) => { if (e.key === 'Enter') commitName(); if (e.key === 'Escape') setEditing(false); }} /> ) : ( { setDraft(session.name); setEditing(true); }}>{session.name} )}
{pathLabel}
{booting && conn !== 'exited' && (
{conn === 'connecting' ? 'connecting' : `starting ${cli?.label || session.cli}`}
)} {conn === 'exited' && (
{cli?.label || session.cli} stopped · output preserved
)}
); }