import { useEffect, useRef, useState } from 'react'; import { Terminal } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import { SearchAddon } from 'xterm-addon-search'; import { WebLinksAddon } from 'xterm-addon-web-links'; import 'xterm/css/xterm.css'; import { ChevronDown, ChevronUp, RefreshCw, X } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import { chatInputsRegistry, sendToChat, sendToTerminal, terminalsRegistry, type ChatInputRegistration, } from '@/lib/events'; interface Props { sessionId: string; paneId: string; label: string; // v1.10.3: tells the pane it's the currently visible/focused one in the // grid. Drives a refit so a terminal that was hidden (e.g. behind a // maximize toggle) snaps to the right dimensions when it reappears. active?: boolean; } // v1.10.3: terminal background matches the pane container's `bg-[#0b0f14]` // so any sub-cell rounding gap at the right/bottom edge is invisible. If // the workspace theme changes, update both this and the container className. const TERM_BG = '#0b0f14'; const XTERM_THEME = { background: TERM_BG, foreground: '#d6deeb', cursor: '#82aaff', selectionBackground: '#1d3b53', black: '#011627', red: '#ef5350', green: '#22da6e', yellow: '#c5e478', blue: '#82aaff', magenta: '#c792ea', cyan: '#7fdbca', white: '#d6deeb', brightBlack: '#575656', brightRed: '#ef5350', brightGreen: '#22da6e', brightYellow: '#ffeb95', brightBlue: '#82aaff', brightMagenta: '#c792ea', brightCyan: '#7fdbca', brightWhite: '#ffffff', }; // v1.10.3 Issue 5: override xterm's default layout so the canvas stretches // to the full container width. The `!important` is necessary because xterm's // own stylesheet (loaded via `import 'xterm/css/xterm.css'`) sets inline // width/height that we need to override. Also hides the scrollbar — touch / // trackpad / wheel still scroll, the chrome just isn't drawn. const XTERM_STYLE_OVERRIDES = ` .xterm { width: 100% !important; height: 100% !important; } .xterm .xterm-screen { width: 100% !important; } /* v1.10.4 gap fix: hide overflow (was: auto) to eliminate scrollbar gutter * that FitAddon's proposeDimensions still accounts for. Transparent bg lets * the host's TERM_BG show through any sub-cell rounding strip. */ .xterm .xterm-viewport { overflow-y: hidden !important; scrollbar-width: none !important; -ms-overflow-style: none !important; background-color: transparent !important; } .xterm .xterm-viewport::-webkit-scrollbar { width: 0 !important; height: 0 !important; display: none !important; } `; type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected'; const MAX_RECONNECT_ATTEMPTS = 3; // v1.10.4: long-press timing for touch-driven selection. 500ms is the common // "long-press" threshold; 10px is the dead-zone before we treat the gesture // as a scroll/swipe instead. const LONG_PRESS_MS = 500; const LONG_PRESS_TOLERANCE_PX = 10; // xterm 5 ships no public dimensions API — `_core._renderService.dimensions` // is internal. We try it first and fall back to (container px / term.cols). // The fallback overcounts because xterm reserves the right edge for the // scrollbar (hidden, but the cells still respect the reserved px). function cellSize(term: Terminal, container: HTMLElement): { w: number; h: number } { // eslint-disable-next-line @typescript-eslint/no-explicit-any const dims = (term as any)._core?._renderService?.dimensions?.css?.cell; if (dims && dims.width > 0 && dims.height > 0) { return { w: dims.width, h: dims.height }; } const rect = container.getBoundingClientRect(); return { w: rect.width / Math.max(term.cols, 1), h: rect.height / Math.max(term.rows, 1) }; } // Pointer → buffer cell. Returns { col, bufferRow } where bufferRow is the // absolute row in the scrollback buffer (i.e. viewportY-offset applied), so // the result is stable across scroll. Clamped to valid ranges. function pointToCell( term: Terminal, container: HTMLElement, clientX: number, clientY: number, ): { col: number; bufferRow: number } { const rect = container.getBoundingClientRect(); const { w, h } = cellSize(term, container); const localX = Math.max(0, clientX - rect.left); const localY = Math.max(0, clientY - rect.top); const col = Math.min(term.cols - 1, Math.floor(localX / Math.max(w, 1))); const screenRow = Math.min(term.rows - 1, Math.floor(localY / Math.max(h, 1))); const bufferRow = term.buffer.active.viewportY + screenRow; return { col, bufferRow }; } // Word boundary on a buffer line. Letters/digits/_/-/./~/$ count as word // chars (path-friendly); everything else is a separator. matchAll keeps the // scan purely iterator-based — no manual cursor needed. const WORD_RE = /[\w.~$\-/]+/g; function wordRangeAt(line: string, col: number): { start: number; end: number } | null { for (const m of line.matchAll(WORD_RE)) { const start = m.index ?? 0; const end = start + m[0].length; if (col >= start && col < end) return { start, end }; if (start > col) return null; } return null; } export function TerminalPane({ sessionId, paneId, label, active = false }: Props) { const containerRef = useRef(null); const wsRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const searchRef = useRef(null); const reconnectRef = useRef<() => void>(() => {}); const [connState, setConnState] = useState('connecting'); // v1.10.4: floating menu state. Positioned in client coords. const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); const [searchOpen, setSearchOpen] = useState(false); // Forces the floating menu's chat list to re-read chatInputsRegistry when // it's actually opened — keeps the registry-subscription tick local. const [chatInputs, setChatInputs] = useState([]); // Refs over state so the long-living useEffect can call the latest setters // without re-running the effect (which would tear down xterm + WS). const setMenuRef = useRef(setMenu); setMenuRef.current = setMenu; const setSearchOpenRef = useRef(setSearchOpen); setSearchOpenRef.current = setSearchOpen; const setChatInputsRef = useRef(setChatInputs); setChatInputsRef.current = setChatInputs; useEffect(() => { const container = containerRef.current; if (!container) return; // TS doesn't preserve the null-narrowing across nested function bodies // below (onTouchStart, etc.) because container is a closure capture. Bind // a narrowed-type local that the inner closures can reference directly. const ctr: HTMLDivElement = container; let disposed = false; let resizeDebounceTimer: ReturnType | null = null; let reconnectTimer: ReturnType | null = null; let attempts = 0; const term = new Terminal({ fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace', fontSize: 13, lineHeight: 1.2, cursorBlink: true, scrollback: 10_000, fastScrollModifier: 'shift', altClickMovesCursor: false, theme: XTERM_THEME, allowProposedApi: true, }); termRef.current = term; const fit = new FitAddon(); fitRef.current = fit; const search = new SearchAddon(); searchRef.current = search; term.loadAddon(fit); term.loadAddon(search); term.loadAddon(new WebLinksAddon()); term.open(container); try { fit.fit(); } catch { /* container not yet sized */ } // v1.10.3 Issue 5: web-font metrics aren't final at mount. Re-fit after // the JetBrains Mono variable font lands so column count matches what // the canvas actually paints. document.fonts.ready resolves once the // initial font set is loaded; chaining .fit() on it is a one-shot. if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) { document.fonts.ready .then(() => { if (disposed) return; try { fit.fit(); } catch { /* ignore */ } }) .catch(() => { /* ignore */ }); } // Shared paste path used by Cmd-V handler, the floating menu's "Paste" // item, and the pane-header Paste button (via terminalsRegistry). function pasteFromClipboard(): void { if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') { toast.error('Paste blocked — long-press input area instead'); return; } navigator.clipboard .readText() .then((text) => { if (!text) return; const ws = wsRef.current; if (ws && ws.readyState === WebSocket.OPEN) ws.send(text); }) .catch(() => { toast.error('Paste blocked — long-press input area instead'); }); } // v1.10.3 copy/paste: xterm v5 ships no clipboard addon — bind manually. // Cmd/Ctrl-C + selection → copy, swallow keystroke (no ^C) // Cmd/Ctrl-C, no selection → fall through (xterm sends SIGINT) // Cmd/Ctrl-Shift-C → ALWAYS swallow; copy if selection, no-op otherwise // Cmd/Ctrl-V / Cmd/Ctrl-Shift-V → paste from clipboard, swallow // v1.10.4: Cmd/Ctrl-F → open search bar over terminal, swallow term.attachCustomKeyEventHandler((e) => { if (e.type !== 'keydown') return true; const mod = e.ctrlKey || e.metaKey; if (!mod) return true; const isC = e.key === 'c' || e.key === 'C'; const isV = e.key === 'v' || e.key === 'V'; const isF = e.key === 'f' || e.key === 'F'; if (isC) { if (term.hasSelection()) { const sel = term.getSelection(); if (sel) { navigator.clipboard.writeText(sel).catch(() => { toast.error('Clipboard write failed'); }); } return false; } // No selection: Shift means "force-swallow" (no ^C); without Shift // we let xterm send SIGINT. return !e.shiftKey; } if (isV) { pasteFromClipboard(); return false; } if (isF) { // Cmd/Ctrl-F when xterm has focus → open search. The Session-level // shortcut handles the case where it doesn't, but xterm intercepts // keys when focused so we need this binding too. setSearchOpenRef.current(true); return false; } return true; }); function buildUrl(): string { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const cols = term.cols; const rows = term.rows; return ( `${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` + `?cols=${cols}&rows=${rows}` ); } function connect(): void { if (disposed) return; try { const ws = new WebSocket(buildUrl()); ws.binaryType = 'arraybuffer'; wsRef.current = ws; ws.onopen = () => { if (disposed) return; attempts = 0; setConnState('open'); }; ws.onmessage = (e) => { if (typeof e.data === 'string') { try { const parsed = JSON.parse(e.data) as { type?: string; code?: number }; if (parsed.type === 'exit') { term.write( `\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`, ); return; } } catch { /* not JSON — fall through and write as text */ } term.write(e.data); } else { term.write(new Uint8Array(e.data)); } }; ws.onclose = () => { if (disposed) return; if (attempts >= MAX_RECONNECT_ATTEMPTS) { setConnState('disconnected'); return; } attempts += 1; setConnState('reconnecting'); const delay = 500 * Math.pow(2, attempts - 1); reconnectTimer = setTimeout(connect, delay); }; ws.onerror = () => { // onclose follows — let it own the retry decision. }; } catch { if (attempts >= MAX_RECONNECT_ATTEMPTS) { setConnState('disconnected'); return; } attempts += 1; setConnState('reconnecting'); reconnectTimer = setTimeout(connect, 500 * Math.pow(2, attempts - 1)); } } function manualReconnect(): void { if (disposed) return; if (reconnectTimer !== null) { clearTimeout(reconnectTimer); reconnectTimer = null; } attempts = 0; setConnState('reconnecting'); try { wsRef.current?.close(); } catch { /* ignore */ } reconnectTimer = setTimeout(connect, 50); } reconnectRef.current = manualReconnect; term.onData((data) => { const ws = wsRef.current; if (ws && ws.readyState === WebSocket.OPEN) { ws.send(data); } }); const fireResize = (): void => { try { fit.fit(); } catch { return; } api.terminals.resize(sessionId, paneId, term.cols, term.rows).catch(() => { /* transient — next resize will catch up */ }); }; const ro = new ResizeObserver(() => { if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer); // v1.10.3 Issue 5: defer the actual fit to the next frame so the // browser has applied the new layout before we measure cell sizes. resizeDebounceTimer = setTimeout(() => { requestAnimationFrame(fireResize); }, 100); }); ro.observe(container); const onVis = (): void => { if (document.visibilityState !== 'visible') return; const ws = wsRef.current; if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { manualReconnect(); } }; document.addEventListener('visibilitychange', onVis); // v1.10.4: long-press selection + floating menu. Touch handlers live on // the xterm host container; we don't preventDefault on touchmove unless // we've entered selection mode, so vertical scroll-by-finger still works. let lpTimer: ReturnType | null = null; let lpStart: { x: number; y: number } | null = null; let lpAnchor: { col: number; bufferRow: number } | null = null; let inSelection = false; function clearLp(): void { if (lpTimer !== null) { clearTimeout(lpTimer); lpTimer = null; } lpStart = null; } function selectWord(col: number, bufferRow: number): boolean { const line = term.buffer.active.getLine(bufferRow); if (!line) return false; const text = line.translateToString(true); const range = wordRangeAt(text, col); if (!range) return false; term.select(range.start, bufferRow, range.end - range.start); return true; } function extendSelection( anchor: { col: number; bufferRow: number }, to: { col: number; bufferRow: number }, ): void { // Compute the lexicographic min/max of (row, col) so the user can drag // up-left or down-right and still extend in the natural reading order. const a = anchor; const b = to; let s: { col: number; row: number }; let e: { col: number; row: number }; if (a.bufferRow < b.bufferRow || (a.bufferRow === b.bufferRow && a.col <= b.col)) { s = { col: a.col, row: a.bufferRow }; e = { col: b.col, row: b.bufferRow }; } else { s = { col: b.col, row: b.bufferRow }; e = { col: a.col, row: a.bufferRow }; } const rowsBetween = e.row - s.row; const length = rowsBetween * term.cols + (e.col - s.col) + 1; term.select(s.col, s.row, length); } function onTouchStart(e: TouchEvent): void { if (e.touches.length !== 1) return; const t = e.touches[0]!; if ((e.target as Element | null)?.closest('[data-term-menu]')) return; lpStart = { x: t.clientX, y: t.clientY }; lpAnchor = pointToCell(term, ctr, t.clientX, t.clientY); inSelection = false; lpTimer = setTimeout(() => { if (disposed || !lpAnchor) return; const ok = selectWord(lpAnchor.col, lpAnchor.bufferRow); if (!ok) return; inSelection = true; // Anchor the menu above the touch point. Slight upward offset so the // user's finger doesn't cover it. setMenuRef.current({ x: t.clientX, y: Math.max(t.clientY - 50, 8) }); }, LONG_PRESS_MS); } function onTouchMove(e: TouchEvent): void { if (e.touches.length !== 1) return; const t = e.touches[0]!; if (inSelection && lpAnchor) { e.preventDefault(); const to = pointToCell(term, ctr, t.clientX, t.clientY); extendSelection(lpAnchor, to); return; } if (!lpStart) return; const dx = t.clientX - lpStart.x; const dy = t.clientY - lpStart.y; if (Math.abs(dx) > LONG_PRESS_TOLERANCE_PX || Math.abs(dy) > LONG_PRESS_TOLERANCE_PX) { clearLp(); } } function onTouchEnd(): void { if (!inSelection) clearLp(); // Leave the menu visible after release; user dismisses via tap-outside. inSelection = false; } function onTouchCancel(): void { clearLp(); inSelection = false; } container.addEventListener('touchstart', onTouchStart, { passive: true }); container.addEventListener('touchmove', onTouchMove, { passive: false }); container.addEventListener('touchend', onTouchEnd, { passive: true }); container.addEventListener('touchcancel', onTouchCancel, { passive: true }); // Desktop right-click: open the same floating menu. function onContextMenu(e: MouseEvent): void { e.preventDefault(); setMenuRef.current({ x: e.clientX, y: e.clientY }); } container.addEventListener('contextmenu', onContextMenu); // Tap-outside dismiss for the floating menu. Pointerdown fires before // any click handler inside the menu, so we re-check the target. function onDocPointerDown(e: PointerEvent): void { const t = e.target as Element | null; if (t && t.closest('[data-term-menu]')) return; setMenuRef.current(null); } document.addEventListener('pointerdown', onDocPointerDown); const unregister = terminalsRegistry.register( paneId, label, () => { try { term.focus(); } catch { /* ignore */ } }, () => setSearchOpenRef.current(true), () => pasteFromClipboard(), ); const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => { if (pane_id !== paneId) return; const ws = wsRef.current; if (!ws || ws.readyState !== WebSocket.OPEN) return; const payload = text.endsWith('\n') ? text : `${text}\n`; ws.send(payload); }); setChatInputsRef.current(chatInputsRegistry.list()); const unsubChats = chatInputsRegistry.subscribe(() => { setChatInputsRef.current(chatInputsRegistry.list()); }); api.terminals.start(sessionId, paneId).catch(() => {}); connect(); return () => { disposed = true; document.removeEventListener('visibilitychange', onVis); document.removeEventListener('pointerdown', onDocPointerDown); container.removeEventListener('touchstart', onTouchStart); container.removeEventListener('touchmove', onTouchMove); container.removeEventListener('touchend', onTouchEnd); container.removeEventListener('touchcancel', onTouchCancel); container.removeEventListener('contextmenu', onContextMenu); unsubscribe(); unsubChats(); unregister(); if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer); if (reconnectTimer !== null) clearTimeout(reconnectTimer); if (lpTimer !== null) clearTimeout(lpTimer); ro.disconnect(); try { wsRef.current?.close(); } catch { /* ignore */ } wsRef.current = null; term.dispose(); termRef.current = null; fitRef.current = null; searchRef.current = null; reconnectRef.current = () => {}; }; }, [sessionId, paneId, label]); // v1.10.3 Issue 5 refit trigger: when this pane becomes active (e.g. after // a mobile pane switch or an unmaximize), the container's measured size // may differ from when xterm last fit. rAF defers the measurement to after // the layout pass that the visibility change triggered. useEffect(() => { if (!active) return; const fit = fitRef.current; if (!fit) return; const raf = requestAnimationFrame(() => { try { fit.fit(); } catch { /* ignore */ } }); return () => cancelAnimationFrame(raf); }, [active]); // Floating menu actions. Each operates on termRef.current and clears menu. function actCopy(): void { const term = termRef.current; if (!term) return; const sel = term.getSelection(); if (!sel) { setMenu(null); return; } navigator.clipboard.writeText(sel).catch(() => toast.error('Clipboard write failed')); term.clearSelection(); setMenu(null); } function actPaste(): void { const reg = terminalsRegistry.get(paneId); reg?.paste(); setMenu(null); } function actSelectAll(): void { termRef.current?.selectAll(); setMenu(null); } function actSearch(): void { setSearchOpen(true); setMenu(null); } function actSendToChat(chatId: string): void { const term = termRef.current; if (!term) return; const sel = term.getSelection(); if (!sel) { setMenu(null); return; } sendToChat.emit({ chat_id: chatId, text: sel }); setMenu(null); } const hasSelection = !!termRef.current?.getSelection(); return (
{searchOpen && ( setSearchOpen(false)} /> )} {menu && ( setMenu(null)} /> )} {connState === 'reconnecting' && (
Reconnecting…
)} {connState === 'disconnected' && (
Disconnected from terminal server.
)}
); } // v1.10.4: shared floating menu used by both desktop right-click and mobile // long-press. shadcn-style chrome: dark bg, subtle border, 14px text, 44px // touch targets via padding. Tap-outside dismiss is wired in the parent's // document pointerdown listener. interface FloatingMenuProps { x: number; y: number; hasSelection: boolean; chatInputs: ChatInputRegistration[]; onCopy: () => void; onPaste: () => void; onSelectAll: () => void; onSearch: () => void; onSendToChat: (chatId: string) => void; onDismiss: () => void; } function FloatingMenu({ x, y, hasSelection, chatInputs, onCopy, onPaste, onSelectAll, onSearch, onSendToChat, onDismiss, }: FloatingMenuProps) { const [submenu, setSubmenu] = useState(false); // Clamp into viewport so the menu doesn't render off-screen on small // viewports / near edges. const MENU_W = 200; const MENU_H = 220; const left = Math.min(x, window.innerWidth - MENU_W - 8); const top = Math.min(y, window.innerHeight - MENU_H - 8); useEffect(() => { function onKey(ev: KeyboardEvent): void { if (ev.key === 'Escape') onDismiss(); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onDismiss]); // Exactly one chat input registered → flat "Send to " entry instead of // a submenu (per v1.10.4 spec). const flatChat = chatInputs.length === 1 ? chatInputs[0] : null; return (
Copy Paste Select All Search {flatChat && ( onSendToChat(flatChat.chatId)}> Send to {flatChat.label} )} {chatInputs.length > 1 && (
{submenu && hasSelection && (
{chatInputs.map((c) => ( onSendToChat(c.chatId)}> {c.label} ))}
)}
)}
Dismiss
); } function MenuItem({ children, onClick, disabled = false, }: { children: React.ReactNode; onClick: () => void; disabled?: boolean; }) { return ( ); } // v1.10.4: floating search bar pinned to the top of the terminal pane. Uses // SearchAddon.findNext / findPrevious. Incremental search on each keystroke // keeps the highlighted match in sync. interface SearchBarProps { searchRef: React.MutableRefObject; theme: typeof XTERM_THEME; onClose: () => void; } function SearchBar({ searchRef, theme, onClose }: SearchBarProps) { const [q, setQ] = useState(''); const [counts, setCounts] = useState<{ idx: number; total: number }>({ idx: -1, total: 0 }); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); // onDidChangeResults fires whenever the SearchAddon's decoration set // updates. We mirror it into local state for the "N of M" indicator. useEffect(() => { const addon = searchRef.current; if (!addon) return; const sub = addon.onDidChangeResults(({ resultIndex, resultCount }) => { setCounts({ idx: resultIndex, total: resultCount }); }); return () => sub.dispose(); }, [searchRef]); useEffect(() => { const addon = searchRef.current; if (!addon) return; if (q.length === 0) { addon.clearDecorations?.(); setCounts({ idx: -1, total: 0 }); return; } addon.findNext(q, { incremental: true, decorations: { matchBackground: theme.selectionBackground, matchOverviewRuler: theme.cursor, activeMatchBackground: theme.cursor, activeMatchColorOverviewRuler: theme.cursor, }, }); }, [q, searchRef, theme]); function findNext(): void { if (!q) return; searchRef.current?.findNext(q); } function findPrev(): void { if (!q) return; searchRef.current?.findPrevious(q); } function onKey(ev: React.KeyboardEvent): void { if (ev.key === 'Escape') { ev.preventDefault(); onClose(); return; } if (ev.key === 'Enter') { ev.preventDefault(); if (ev.shiftKey) findPrev(); else findNext(); } } return (
setQ(ev.target.value)} onKeyDown={onKey} placeholder="Search…" style={{ background: 'transparent', border: 0, outline: 'none', color: '#d6deeb', padding: '8px 8px', fontSize: 13, width: 160, minHeight: 36, }} /> {q.length > 0 && ( {counts.total === 0 ? 'No match' : counts.idx === -1 ? `${counts.total}+` : `${counts.idx + 1} of ${counts.total}`} )}
); } const iconBtnStyle: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 44, height: 44, background: 'transparent', border: 0, color: '#d6deeb', cursor: 'pointer', borderRadius: 6, };