From ddd96852ec155bf0c03e7ae9abee1055af6ea419 Mon Sep 17 00:00:00 2001 From: yyovil Date: Sun, 12 Apr 2026 04:22:27 +0530 Subject: [PATCH] fix(web): sync fullscreen terminal viewport fix (#1140) --- .../web/src/components/DirectTerminal.tsx | 789 ++++++++++++------ 1 file changed, 521 insertions(+), 268 deletions(-) diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index 22655699c..07228a09e 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -72,326 +72,579 @@ export function buildTerminalThemes(variant: TerminalVariant): { dark: ITheme; l cursorAccent: "#fafafa", selectionBackground: accent.selLight, selectionInactiveBackground: "rgba(128, 128, 128, 0.15)", - // ANSI colors — darkened for legibility on white - black: "#1f2937", - red: "#dc2626", - green: "#16a34a", - yellow: "#ca8a04", - blue: "#2563eb", - magenta: "#9333ea", - cyan: "#0891b2", - white: "#6b7280", - brightBlack: "#4b5563", - brightRed: "#ef4444", - brightGreen: "#22c55e", - brightYellow: "#eab308", - brightBlue: "#3b82f6", - brightMagenta: "#a855f7", - brightCyan: "#06b6d4", - brightWhite: "#9ca3af", + // ANSI colors — darkened for legibility on #fafafa terminal background + black: "#24292f", + red: "#b42318", + green: "#1f7a3d", + yellow: "#8a5a00", + blue: "#175cd3", + magenta: "#8e24aa", + cyan: "#0b7285", + white: "#4b5563", + brightBlack: "#374151", + brightRed: "#912018", + brightGreen: "#176639", + brightYellow: "#6f4a00", + brightBlue: "#1d4ed8", + brightMagenta: "#7b1fa2", + brightCyan: "#155e75", + brightWhite: "#374151", }; return { dark, light }; } +/** + * Direct xterm.js terminal with native WebSocket connection. + * Implements Extended Device Attributes (XDA) handler to enable + * tmux clipboard support (OSC 52) without requiring iTerm2 attachment. + * + * Based on DeepWiki analysis: + * - tmux queries for XDA (CSI > q / XTVERSION) to detect terminal type + * - When tmux sees "XTerm(" in response, it enables TTYC_MS (clipboard) + * - xterm.js doesn't implement XDA by default, so we register custom handler + */ export function DirectTerminal({ sessionId, startFullscreen = false, variant = "agent", appearance = "theme", - height = "max(440px, calc(100vh - 440px))", + height = "max(440px, calc(100dvh - 440px))", isOpenCodeSession = false, - reloadCommand = "/exit", + reloadCommand, chromeless = false, }: DirectTerminalProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const { resolvedTheme } = useTheme(); - const { subscribeTerminal, writeTerminal, openTerminal, closeTerminal, resizeTerminal, status: muxStatus } = useMux(); + const terminalThemes = useMemo(() => buildTerminalThemes(variant), [variant]); + const { subscribeTerminal, writeTerminal, resizeTerminal: resizeTerminalMux, openTerminal, closeTerminal, status: muxStatus } = useMux(); const terminalRef = useRef(null); - const termRef = useRef(null); - const fitAddonRef = useRef(null); - const cleanupRef = useRef<(() => void) | null>(null); - const resizeObserverRef = useRef(null); - const [connected, setConnected] = useState(false); + const terminalInstance = useRef(null); + const fitAddon = useRef(null); + const muxStatusRef = useRef(muxStatus); + muxStatusRef.current = muxStatus; const [fullscreen, setFullscreen] = useState(startFullscreen); + const [error, setError] = useState(null); const [reloading, setReloading] = useState(false); const [reloadError, setReloadError] = useState(null); - const terminalThemes = useMemo(() => buildTerminalThemes(variant), [variant]); - const isDark = appearance === "dark" || (appearance === "theme" && resolvedTheme !== "light"); - const theme = isDark ? terminalThemes.dark : terminalThemes.light; + // Update URL when fullscreen changes + useEffect(() => { + const params = new URLSearchParams(searchParams.toString()); - const updateFullscreenQuery = (next: boolean) => { - const params = new URLSearchParams(searchParams?.toString() ?? ""); - if (next) { - params.set("fullscreen", sessionId); - } else if (params.get("fullscreen") === sessionId) { + if (fullscreen) { + params.set("fullscreen", "true"); + } else { params.delete("fullscreen"); } - const query = params.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); - }; - useEffect(() => { - const requestedSession = searchParams?.get("fullscreen"); - if (requestedSession === sessionId && !fullscreen) { - setFullscreen(true); - } else if (requestedSession !== sessionId && fullscreen && startFullscreen === false) { - setFullscreen(false); - } - }, [fullscreen, searchParams, sessionId, startFullscreen]); + const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; + router.replace(newUrl, { scroll: false }); + }, [fullscreen, pathname, router, searchParams]); - useEffect(() => { - let cancelled = false; - - async function init() { - if (!terminalRef.current || termRef.current) return; - - const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([ - import("xterm"), - import("@xterm/addon-fit"), - import("@xterm/addon-web-links"), - ]); - - if (cancelled || !terminalRef.current) return; - - const term = new Terminal({ - theme, - convertEol: true, - cursorBlink: true, - allowTransparency: true, - fontFamily: - "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", - fontSize: 13, - lineHeight: 1.25, - scrollback: 5000, - }); - const fitAddon = new FitAddon(); - const webLinksAddon = new WebLinksAddon(); - - termRef.current = term; - fitAddonRef.current = fitAddon; - term.loadAddon(fitAddon); - term.loadAddon(webLinksAddon); - term.open(terminalRef.current); - fitAddon.fit(); - - // Keep xterm selection styling in sync on theme changes - term.options.theme = theme; - - // Allow copy from selected text with Cmd/Ctrl+C without sending interrupt - term.attachCustomKeyEventHandler((e) => { - const isCopy = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "c"; - if (isCopy && term.hasSelection()) { - navigator.clipboard?.writeText(term.getSelection()); - return false; - } - return true; - }); - - const onDataDispose = term.onData((data) => { - writeTerminal(sessionId, data); - }); - - cleanupRef.current = subscribeTerminal(sessionId, (event) => { - if (event.type === "data") { - term.write(event.data); - setConnected(true); - } else if (event.type === "open") { - setConnected(true); - } else if (event.type === "error") { - setReloadError(event.message); - } - }); - - openTerminal(sessionId); - - resizeObserverRef.current = new ResizeObserver(() => { - fitAddon.fit(); - if (term.cols && term.rows) { - resizeTerminal(sessionId, term.cols, term.rows); - } - }); - resizeObserverRef.current.observe(terminalRef.current); - - const fontsReady = (document as Document & { fonts?: FontFaceSet }).fonts?.ready; - fontsReady?.then(() => { - fitAddon.fit(); - if (term.cols && term.rows) { - resizeTerminal(sessionId, term.cols, term.rows); - } - }); - - return () => { - onDataDispose.dispose(); - }; - } - - const disposePromise = init(); - return () => { - cancelled = true; - disposePromise?.then((dispose) => dispose?.()); - resizeObserverRef.current?.disconnect(); - resizeObserverRef.current = null; - cleanupRef.current?.(); - cleanupRef.current = null; - closeTerminal(sessionId); - termRef.current?.dispose(); - termRef.current = null; - fitAddonRef.current = null; - setConnected(false); - }; - }, [closeTerminal, openTerminal, resizeTerminal, sessionId, subscribeTerminal, theme, writeTerminal]); - - useEffect(() => { - if (!termRef.current || !fitAddonRef.current) return; - termRef.current.options.theme = theme; - fitAddonRef.current.fit(); - if (termRef.current.cols && termRef.current.rows) { - resizeTerminal(sessionId, termRef.current.cols, termRef.current.rows); - } - }, [resizeTerminal, sessionId, theme]); - - useEffect(() => { - if (!fitAddonRef.current || !termRef.current) return; - const id = window.setTimeout(() => { - fitAddonRef.current?.fit(); - if (termRef.current?.cols && termRef.current?.rows) { - resizeTerminal(sessionId, termRef.current.cols, termRef.current.rows); - } - }, 16); - return () => window.clearTimeout(id); - }, [fullscreen, resizeTerminal, sessionId]); - - const handleToggleFullscreen = () => { - const next = !fullscreen; - setFullscreen(next); - updateFullscreenQuery(next); - }; - - const handleReload = async () => { - if (reloading || muxStatus !== "connected") return; - setReloading(true); + async function handleReload(): Promise { + if (!isOpenCodeSession || reloading) return; setReloadError(null); + setReloading(true); try { - writeTerminal(sessionId, `${reloadCommand} `); - } catch (error) { - setReloadError(error instanceof Error ? error.message : "Failed to restart session"); + let commandToSend = reloadCommand; + + if (!commandToSend) { + const remapRes = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/remap`, { + method: "POST", + }); + if (!remapRes.ok) { + throw new Error(`Failed to remap OpenCode session: ${remapRes.status}`); + } + const remapData = (await remapRes.json()) as { opencodeSessionId?: unknown }; + if ( + typeof remapData.opencodeSessionId !== "string" || + remapData.opencodeSessionId.length === 0 + ) { + throw new Error("Missing OpenCode session id after remap"); + } + commandToSend = `/exit\nopencode --session ${remapData.opencodeSessionId}\n`; + } + + const sendRes = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: commandToSend }), + }); + if (!sendRes.ok) { + throw new Error(`Failed to send reload command: ${sendRes.status}`); + } + } catch (err) { + setReloadError(err instanceof Error ? err.message : "Failed to reload OpenCode session"); } finally { setReloading(false); } - }; + } + useEffect(() => { + if (!terminalRef.current) return; + + // Dynamically import xterm.js to avoid SSR issues + let mounted = true; + let cleanup: (() => void) | null = null; + let inputDisposable: { dispose(): void } | null = null; + let unsubscribe: (() => void) | null = null; + + Promise.all([ + import("xterm").then((mod) => mod.Terminal), + import("@xterm/addon-fit").then((mod) => mod.FitAddon), + import("@xterm/addon-web-links").then((mod) => mod.WebLinksAddon), + document.fonts.ready, + ]) + .then(([Terminal, FitAddon, WebLinksAddon]) => { + if (!mounted || !terminalRef.current) return; + + const isDark = appearance === "dark" || resolvedTheme !== "light"; + const activeTheme = isDark ? terminalThemes.dark : terminalThemes.light; + + // Initialize xterm.js Terminal + const terminal = new Terminal({ + cursorBlink: true, + fontSize: 13, + fontFamily: + 'var(--font-jetbrains-mono), "JetBrains Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace', + theme: activeTheme, + // Light mode needs an explicit contrast floor because agent UIs often emit + // dim/faint ANSI sequences that become unreadable on a near-white background. + minimumContrastRatio: isDark ? 1 : 7, + scrollback: 10000, + allowProposedApi: true, + fastScrollModifier: "alt", + fastScrollSensitivity: 3, + scrollSensitivity: 1, + }); + + // Add FitAddon for responsive sizing + const fit = new FitAddon(); + terminal.loadAddon(fit); + fitAddon.current = fit; + + // Add WebLinksAddon for clickable links + const webLinks = new WebLinksAddon(); + terminal.loadAddon(webLinks); + + // **CRITICAL FIX**: Register XDA (Extended Device Attributes) handler + // This makes tmux recognize our terminal and enable clipboard support + terminal.parser.registerCsiHandler( + { prefix: ">", final: "q" }, // CSI > q is XTVERSION / XDA + () => { + // Respond with XTerm identification that tmux recognizes + // tmux looks for "XTerm(" in the response (see tmux tty-keys.c) + // Format: DCS > | XTerm(version) ST + // DCS = \x1bP, ST = \x1b\\ + terminal.write("\x1bP>|XTerm(370)\x1b\\"); + console.log("[DirectTerminal] Sent XDA response for clipboard support"); + return true; // Handled + }, + ); + + // Register OSC 52 handler for clipboard support + // tmux sends OSC 52 with base64-encoded text when copying + terminal.parser.registerOscHandler(52, (data) => { + const parts = data.split(";"); + if (parts.length < 2) return false; + const b64 = parts[parts.length - 1]; + try { + // Decode base64 → binary string → Uint8Array → UTF-8 text + // atob() alone only handles Latin-1; TextDecoder is needed for UTF-8 + const binary = atob(b64); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + const text = new TextDecoder().decode(bytes); + navigator.clipboard?.writeText(text).catch(() => {}); + } catch { + // Ignore decode errors + } + return true; + }); + + // Open terminal in DOM + terminal.open(terminalRef.current); + terminalInstance.current = terminal; + + // Fit terminal to container + fit.fit(); + + // ── Preserve selection while terminal receives output ──────── + // xterm.js clears the selection on every terminal.write(). We + // buffer incoming data while a selection is active so the + // highlight stays visible for Cmd+C. The buffer is flushed + // when the selection is cleared (click, keypress, etc.). + const writeBuffer: string[] = []; + let selectionActive = false; + let safetyTimer: ReturnType | null = null; + let bufferBytes = 0; + const MAX_BUFFER_BYTES = 1_048_576; // 1 MB + + const flushWriteBuffer = () => { + if (safetyTimer) { + clearTimeout(safetyTimer); + safetyTimer = null; + } + if (writeBuffer.length > 0) { + terminal.write(writeBuffer.join("")); + writeBuffer.length = 0; + bufferBytes = 0; + } + }; + + const selectionDisposable = terminal.onSelectionChange(() => { + if (terminal.hasSelection()) { + selectionActive = true; + // Safety: flush after 5s to prevent unbounded buffering + if (!safetyTimer) { + safetyTimer = setTimeout(() => { + selectionActive = false; + flushWriteBuffer(); + }, 5_000); + } + } else { + selectionActive = false; + flushWriteBuffer(); + } + }); + + // Intercept Cmd+C (Mac) and Ctrl+Shift+C (Linux/Win) for copy. + // Paste (Cmd+V / Ctrl+Shift+V) is handled natively by xterm.js + // via its internal textarea — no custom handler needed. + terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => { + if (e.type !== "keydown") return true; + + // Cmd+C / Ctrl+Shift+C — copy selection + const isCopy = + (e.metaKey && !e.ctrlKey && !e.altKey && e.code === "KeyC") || + (e.ctrlKey && e.shiftKey && e.code === "KeyC"); + if (isCopy && terminal.hasSelection()) { + navigator.clipboard?.writeText(terminal.getSelection()).catch(() => {}); + // Clear selection so the terminal resumes receiving output + terminal.clearSelection(); + return false; + } + + return true; + }); + + // Open terminal via mux + openTerminal(sessionId); + + // Subscribe to terminal data via mux + unsubscribe = subscribeTerminal(sessionId, (data) => { + if (selectionActive) { + writeBuffer.push(data); + bufferBytes += data.length; + // Flush if buffer exceeds 1 MB to prevent OOM + if (bufferBytes > MAX_BUFFER_BYTES) { + selectionActive = false; + flushWriteBuffer(); + } + } else { + terminal.write(data); + } + }); + + // Handle window resize + const handleResize = () => { + if (fit) { + fit.fit(); + resizeTerminalMux(sessionId, terminal.cols, terminal.rows); + } + }; + + window.addEventListener("resize", handleResize); + + // Terminal input → mux + inputDisposable = terminal.onData((data) => { + writeTerminal(sessionId, data); + }); + + // Send initial size + resizeTerminalMux(sessionId, terminal.cols, terminal.rows); + + // Store cleanup function to be called from useEffect cleanup + cleanup = () => { + selectionDisposable.dispose(); + if (safetyTimer) clearTimeout(safetyTimer); + window.removeEventListener("resize", handleResize); + inputDisposable?.dispose(); + inputDisposable = null; + unsubscribe?.(); + closeTerminal(sessionId); + terminal.dispose(); + }; + }) + .catch((err) => { + console.error("[DirectTerminal] Failed to load xterm.js:", err); + setError("Failed to load terminal"); + }); + + return () => { + mounted = false; + cleanup?.(); + }; + }, [ + appearance, + sessionId, + variant, + resolvedTheme, + terminalThemes, + subscribeTerminal, + writeTerminal, + resizeTerminalMux, + openTerminal, + closeTerminal, + ]); + + // Re-send terminal dimensions on every reconnect so the server-side PTY + // matches the client's xterm.js size (new PTYs spawn at 80×24 default). + useEffect(() => { + if (muxStatus !== "connected") return; + const fit = fitAddon.current; + const terminal = terminalInstance.current; + if (!fit || !terminal) return; + fit.fit(); + resizeTerminalMux(sessionId, terminal.cols, terminal.rows); + }, [muxStatus, sessionId, resizeTerminalMux]); + + // Live theme switching without terminal recreation + useEffect(() => { + const terminal = terminalInstance.current; + if (!terminal) return; + const isDark = appearance === "dark" || resolvedTheme !== "light"; + terminal.options.theme = isDark ? terminalThemes.dark : terminalThemes.light; + terminal.options.minimumContrastRatio = isDark ? 1 : 7; + }, [appearance, resolvedTheme, terminalThemes]); + + // Re-fit terminal when fullscreen changes + useEffect(() => { + const fit = fitAddon.current; + const terminal = terminalInstance.current; + const container = terminalRef.current; + + if (!fit || !terminal || muxStatusRef.current !== "connected" || !container) { + return; + } + + let resizeAttempts = 0; + const maxAttempts = 60; + let cancelled = false; + let rafId = 0; + let lastHeight = -1; + + const resizeTerminal = () => { + if (cancelled) return; + resizeAttempts++; + + // Wait for the container height to stabilise (CSS transition finished) + const currentHeight = container.getBoundingClientRect().height; + const settled = lastHeight >= 0 && Math.abs(currentHeight - lastHeight) < 1; + lastHeight = currentHeight; + + if (!settled && resizeAttempts < maxAttempts) { + // Container is still transitioning, try again next frame + rafId = requestAnimationFrame(resizeTerminal); + return; + } + + // Container is at target size, now resize terminal + terminal.refresh(0, terminal.rows - 1); + fit.fit(); + terminal.refresh(0, terminal.rows - 1); + + // Send new size to server via mux + resizeTerminalMux(sessionId, terminal.cols, terminal.rows); + }; + + // Start resize polling + rafId = requestAnimationFrame(resizeTerminal); + + // Also try on transitionend + const handleTransitionEnd = (e: TransitionEvent) => { + if (cancelled) return; + if (e.target === container.parentElement) { + resizeAttempts = 0; + lastHeight = -1; + setTimeout(() => { + if (!cancelled) rafId = requestAnimationFrame(resizeTerminal); + }, 50); + } + }; + + const parent = container.parentElement; + parent?.addEventListener("transitionend", handleTransitionEnd); + + // Backup timers in case RAF polling doesn't work + const timer1 = setTimeout(() => { + if (cancelled) return; + resizeAttempts = 0; + lastHeight = -1; + resizeTerminal(); + }, 300); + const timer2 = setTimeout(() => { + if (cancelled) return; + resizeAttempts = 0; + lastHeight = -1; + resizeTerminal(); + }, 600); + + return () => { + cancelled = true; + cancelAnimationFrame(rafId); + parent?.removeEventListener("transitionend", handleTransitionEnd); + clearTimeout(timer1); + clearTimeout(timer2); + }; + }, [fullscreen, sessionId, resizeTerminalMux]); + + const accentColor = "var(--color-accent)"; + + // Local errors (e.g. xterm.js load failure) take priority over mux connection state + const displayStatus = error ? "error" : muxStatus; + + const statusDotClass = + displayStatus === "connected" + ? "bg-[var(--color-status-ready)]" + : displayStatus === "error" || displayStatus === "disconnected" + ? "bg-[var(--color-status-error)]" + : "bg-[var(--color-status-attention)] animate-[pulse_1.5s_ease-in-out_infinite]"; + + const statusText = + displayStatus === "connected" + ? "Connected" + : displayStatus === "error" + ? (error ?? "Error") + : displayStatus === "disconnected" + ? "Disconnected" + : "Connecting…"; + + const statusTextColor = + displayStatus === "connected" + ? "text-[var(--color-status-ready)]" + : displayStatus === "error" || displayStatus === "disconnected" + ? "text-[var(--color-status-error)]" + : "text-[var(--color-text-tertiary)]"; + const isDarkChrome = appearance === "dark" || resolvedTheme !== "light"; const fullscreenButton = ( ); return (
{!chromeless ? ( -
-
- - XDA - - - {sessionId} - - +
+ + {sessionId} + + + {statusText} + + + XDA + + {isOpenCodeSession ? ( + + ) : null} + {reloadError ? ( + + {reloadError} -
-
- {isOpenCodeSession ? ( - - ) : null} - {reloadError ? ( - - {reloadError} - - ) : null} - {fullscreenButton} -
+ ) : null} + {fullscreenButton}
) : null} {chromeless ? (