/* Copyright 2026 Marimo. All rights reserved. */ import { AttachAddon } from "@xterm/addon-attach"; import { CanvasAddon } from "@xterm/addon-canvas"; import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { Terminal } from "@xterm/xterm"; import React, { useEffect, useMemo, useRef, useState } from "react"; import "@xterm/xterm/css/xterm.css"; import "./xterm.css"; import { ClipboardPasteIcon, CopyIcon, TextSelectionIcon, Trash2Icon, } from "lucide-react"; import useEvent from "react-use-event-hook"; import { waitForConnectionOpen } from "@/core/network/connection"; import { useRuntimeManager } from "@/core/runtime/config"; import { useDebouncedCallback } from "@/hooks/useDebounce"; import { cn } from "@/utils/cn"; import { copyToClipboard } from "@/utils/copy"; import { Logger } from "@/utils/Logger"; import { MinimalHotkeys } from "../shortcuts/renderShortcut"; import { useTerminalActions, useTerminalState } from "./state"; import { createTerminalTheme } from "./theme"; interface TerminalButtonProps { onClick: () => void; disabled?: boolean; icon: React.ComponentType<{ className?: string }>; children: React.ReactNode; keyboardShortcut?: string; } const TerminalButton: React.FC = ({ onClick, disabled = false, icon: Icon, children, keyboardShortcut, }) => ( ); interface TerminalComponentProps { visible: boolean; onClose: () => void; } interface Position { x: number; y: number; placement: "bottom" | "top"; // Whether to place the menu above or below the cursor } // Keyboard shortcut handlers function createKeyboardHandler(terminal: Terminal, _searchAddon: SearchAddon) { return (event: KeyboardEvent) => { const { ctrlKey, metaKey, key } = event; const modifier = ctrlKey || metaKey; if (modifier) { switch (key) { case "c": if (terminal.hasSelection()) { event.preventDefault(); void copyToClipboard(terminal.getSelection()); } break; case "v": event.preventDefault(); void navigator.clipboard.readText().then((text) => { terminal.paste(text); }); break; case "a": event.preventDefault(); terminal.selectAll(); break; case "l": event.preventDefault(); terminal.clear(); break; } } }; } // Context menu actions function createContextMenuActions( terminal: Terminal, setContextMenu: (menu: Position | null) => void, ) { const closeMenu = () => setContextMenu(null); return { handleCopy: () => { if (terminal.hasSelection()) { navigator.clipboard.writeText(terminal.getSelection()); } closeMenu(); }, handlePaste: () => { navigator.clipboard.readText().then((text) => { terminal.paste(text); }); closeMenu(); }, handleSelectAll: () => { terminal.selectAll(); closeMenu(); }, handleClear: () => { terminal.clear(); closeMenu(); }, closeMenu, }; } const RESIZE_DEBOUNCE_TIME = 100; const TerminalComponent: React.FC = ({ visible, onClose, }) => { const terminalRef = useRef(null); const wsRef = useRef(null); // oxlint-disable-next-line react/hook-use-state const [{ terminal, fitAddon, searchAddon }] = useState(() => { // Create a new terminal instance const term = new Terminal({ fontFamily: "Menlo, DejaVu Sans Mono, Consolas, Lucida Console, monospace", fontSize: 14, scrollback: 10_000, cursorBlink: true, cursorStyle: "block", allowTransparency: false, theme: createTerminalTheme("dark"), rightClickSelectsWord: true, wordSeparator: " \t\r\n\"'`(){}[]<>|&;", allowProposedApi: true, }); // Load essential addons const fitAddon = new FitAddon(); const searchAddon = new SearchAddon(); const canvasAddon = new CanvasAddon(); const unicode11Addon = new Unicode11Addon(); const webLinksAddon = new WebLinksAddon(); term.loadAddon(fitAddon); term.loadAddon(searchAddon); term.loadAddon(canvasAddon); term.loadAddon(unicode11Addon); term.loadAddon(webLinksAddon); // Set Unicode version term.unicode.activeVersion = "11"; return { terminal: term, fitAddon, searchAddon }; }); const [initialized, setInitialized] = React.useState(false); const [contextMenu, setContextMenu] = useState(null); const runtimeManager = useRuntimeManager(); // Terminal command state management const terminalState = useTerminalState(); const { removeCommand, setReady } = useTerminalActions(); // Keyboard shortcuts handler const handleKeyDown = useEvent(createKeyboardHandler(terminal, searchAddon)); // Context menu handler const handleContextMenu = useEvent((event: MouseEvent) => { event.preventDefault(); const menuHeight = 200; // Approximate height of the context menu const viewportHeight = window.innerHeight; const cursorY = event.clientY; // Check if there's enough space below the cursor const spaceBelow = viewportHeight - cursorY; const shouldPlaceAbove = spaceBelow < menuHeight; setContextMenu({ x: event.clientX, y: event.clientY, placement: shouldPlaceAbove ? "top" : "bottom", }); }); // Close context menu on click outside const handleClickOutside = useEvent((event: MouseEvent) => { const target = event.target; const isInsideContextMenu = target && target instanceof HTMLElement && target.closest(".xterm-context-menu"); if (contextMenu && !isInsideContextMenu) { setContextMenu(null); } }); const handleBackendResizeDebounced = useDebouncedCallback( ({ cols, rows }: { cols: number; rows: number }) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { Logger.debug("Sending resize to backend terminal", { cols, rows }); wsRef.current.send(JSON.stringify({ type: "resize", cols, rows })); } }, RESIZE_DEBOUNCE_TIME, ); const handleResize = useEvent(() => { if (!terminal || !fitAddon) { return; } fitAddon.fit(); }); // Context menu actions const { handleCopy, handlePaste, handleSelectAll, handleClear } = useMemo( () => createContextMenuActions(terminal, setContextMenu), [terminal], ); // Websocket Connection useEffect(() => { if (initialized) { return; } const connectTerminal = async () => { try { await waitForConnectionOpen(); const socket = new WebSocket(runtimeManager.getTerminalWsURL()); const attachAddon = new AttachAddon(socket); terminal.loadAddon(attachAddon); wsRef.current = socket; // Terminal is ready when the websocket is open const updateReadyState = () => { setReady(socket.readyState === WebSocket.OPEN); }; const handleError = () => { updateReadyState(); }; const handleOpen = () => { updateReadyState(); }; const handleDisconnect = () => { onClose(); // Reset attachAddon.dispose(); wsRef.current = null; terminal.clear(); setInitialized(false); setReady(false); }; socket.addEventListener("open", handleOpen); socket.addEventListener("close", handleDisconnect); socket.addEventListener("error", handleError); // Set initial ready state updateReadyState(); setInitialized(true); } catch (error) { Logger.error("Runtime health check failed for terminal", error); onClose(); } }; connectTerminal(); return () => { // noop }; // oxlint-disable-next-line react-hooks/exhaustive-deps }, [initialized]); // Process pending commands when terminal is ready useEffect(() => { if (!terminalState.isReady || terminalState.pendingCommands.length === 0) { return; } // Process all pending commands for (const command of terminalState.pendingCommands) { if (terminal && wsRef.current?.readyState === WebSocket.OPEN) { Logger.debug("Sending programmatic command to terminal", { command: command.text, }); terminal.input(command.text); terminal.focus(); removeCommand(command.id); } } }, [ terminal, terminalState.isReady, terminalState.pendingCommands, removeCommand, ]); // When visible useEffect(() => { if (visible) { fitAddon.fit(); terminal.focus(); } return; // oxlint-disable-next-line react-hooks/exhaustive-deps }, [visible]); // On mount useEffect(() => { if (!terminalRef.current) { return; } terminal.open(terminalRef.current); // Initial fit with delay to ensure DOM is ready setTimeout(() => { fitAddon.fit(); }, RESIZE_DEBOUNCE_TIME); terminal.focus(); const abortController = new AbortController(); // Add event listeners window.addEventListener("resize", handleResize, { signal: abortController.signal, }); terminalRef.current.addEventListener("keydown", handleKeyDown, { signal: abortController.signal, }); terminalRef.current.addEventListener("contextmenu", handleContextMenu, { signal: abortController.signal, }); terminal.onResize(handleBackendResizeDebounced); document.addEventListener("click", handleClickOutside, { signal: abortController.signal, }); return () => { abortController.abort(); }; // oxlint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{contextMenu && (
Copy Paste
Select all Clear terminal
)}
); }; export default React.memo(TerminalComponent);