diff --git a/ui/package.json b/ui/package.json index 0e144db7f30..9665b0012a9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -50,6 +50,8 @@ "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.20", "@tanstack/react-query": "^5.90.21", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index 52c9459e6ce..b2ae96eee40 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -6,6 +6,120 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { CompanyEnvironments } from "./CompanyEnvironments"; +const xtermMocks = vi.hoisted(() => { + class MockTerminal { + readonly options: Record; + cols: number; + rows: number; + writes: string[] = []; + focused = false; + disposed = false; + openedElement: HTMLElement | null = null; + private readonly dataHandlers: Array<(data: string) => void> = []; + + constructor(options: Record = {}) { + this.options = options; + this.cols = typeof options.cols === "number" ? options.cols : 80; + this.rows = typeof options.rows === "number" ? options.rows : 24; + xtermMocks.terminalInstances.push(this); + } + + loadAddon(addon: { activate?: (terminal: MockTerminal) => void }) { + addon.activate?.(this); + } + + open(element: HTMLElement) { + this.openedElement = element; + element.dataset.mockXtermOpen = "true"; + } + + onData(handler: (data: string) => void) { + this.dataHandlers.push(handler); + return { + dispose: () => { + const index = this.dataHandlers.indexOf(handler); + if (index >= 0) this.dataHandlers.splice(index, 1); + }, + }; + } + + emitData(data: string) { + for (const handler of this.dataHandlers) handler(data); + } + + write(data: string) { + this.writes.push(data); + if (!this.openedElement) return; + const chunk = document.createElement("span"); + // Keep test DOM readable without reimplementing xterm's ANSI parser. + chunk.textContent = data.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ""); + this.openedElement.appendChild(chunk); + } + + resize(cols: number, rows: number) { + this.cols = cols; + this.rows = rows; + } + + focus() { + this.focused = true; + this.openedElement?.focus(); + } + + reset() { + this.writes = []; + if (this.openedElement) this.openedElement.textContent = ""; + } + + clear() { + if (this.openedElement) this.openedElement.textContent = ""; + } + + dispose() { + this.disposed = true; + } + } + + class MockFitAddon { + fitCalls = 0; + terminal: MockTerminal | null = null; + + constructor() { + xtermMocks.fitAddonInstances.push(this); + } + + activate(terminal: MockTerminal) { + this.terminal = terminal; + } + + fit() { + this.fitCalls += 1; + this.terminal?.resize(120, 32); + } + } + + return { + MockTerminal, + MockFitAddon, + terminalInstances: [] as MockTerminal[], + fitAddonInstances: [] as MockFitAddon[], + reset() { + this.terminalInstances.length = 0; + this.fitAddonInstances.length = 0; + }, + }; +}); + +vi.mock("@xterm/xterm", () => ({ + Terminal: xtermMocks.MockTerminal, +})); + +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: xtermMocks.MockFitAddon, +})); + +vi.mock("@xterm/xterm/css/xterm.css", () => ({})); + const mockEnvironmentsApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn(), @@ -246,6 +360,7 @@ describe("CompanyEnvironments — test provider button", () => { probeResolvers = new Map(); originalWebSocket = globalThis.WebSocket; FakeWebSocket.instances = []; + xtermMocks.reset(); globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true }); @@ -670,27 +785,30 @@ describe("CompanyEnvironments — test provider button", () => { expect(FakeWebSocket.instances[0].url).toContain( "/api/environment-custom-image-setup-sessions/session-1/terminal/ws?terminalSessionId=terminal-session-1", ); - expect(FakeWebSocket.instances[0].url).toContain("cols=100"); - expect(FakeWebSocket.instances[0].url).toContain("rows=28"); + expect(FakeWebSocket.instances[0].url).toContain("cols=120"); + expect(FakeWebSocket.instances[0].url).toContain("rows=32"); + expect(xtermMocks.terminalInstances[0].options.cursorBlink).toBe(true); await act(async () => { FakeWebSocket.instances[0].open(); FakeWebSocket.instances[0].emitMessage(JSON.stringify({ type: "ready" })); FakeWebSocket.instances[0].emitMessage(JSON.stringify({ type: "output", data: "\u001b[?2004hsetup shell\r\n$ " })); }); - const terminal = getOpenDialog()?.querySelector( - "[data-testid='custom-image-terminal-input-session-1']", + const terminalScreen = getOpenDialog()?.querySelector( + "[data-testid='custom-image-terminal-screen-session-1']", ); await waitForAssertion(() => { - expect(terminal?.value).toContain("setup shell"); - expect(terminal?.value).not.toContain("[?2004h"); - expect(document.activeElement).toBe(terminal); + expect(terminalScreen?.dataset.mockXtermOpen).toBe("true"); + expect(xtermMocks.terminalInstances[0].writes.join("")).toContain("setup shell"); + expect(xtermMocks.terminalInstances[0].focused).toBe(true); + expect(document.activeElement).toBe(terminalScreen); expect(getOpenDialog()?.textContent).toContain(command); }); + expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "resize", cols: 120, rows: 32 })); await act(async () => { - terminal?.dispatchEvent(new KeyboardEvent("keydown", { key: "l", bubbles: true })); - terminal?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + xtermMocks.terminalInstances[0].emitData("l"); + xtermMocks.terminalInstances[0].emitData("\r"); }); expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "input", data: "l" })); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index c0c4f625c78..b96ee6115e3 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -1,14 +1,14 @@ import { useCallback, useEffect, - useMemo, useRef, useState, - type ClipboardEvent as ReactClipboardEvent, - type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal as XTermTerminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; import { type EnvBinding, type Environment, @@ -231,10 +231,7 @@ function setupConnectionFallbackMessage(input: { const CUSTOM_IMAGE_TERMINAL_COLS = 100; const CUSTOM_IMAGE_TERMINAL_ROWS = 28; -const CUSTOM_IMAGE_TERMINAL_SCROLLBACK_LIMIT = 120_000; -const TERMINAL_CONTROL_SEQUENCE_PATTERN = - // eslint-disable-next-line no-control-regex - /(?:\x1B\][\s\S]*?(?:\x07|\x1B\\))|(?:\x1B\[[0-?]*[ -/]*[@-~])|(?:\x1B[PX^_][\s\S]*?\x1B\\)|(?:\x1B[@-Z\\-_])/g; +const CUSTOM_IMAGE_TERMINAL_SCROLLBACK_ROWS = 5_000; type CustomImageTerminalConnectionState = | "idle" @@ -250,55 +247,6 @@ function appendTerminalQuery(path: string, params: Record CUSTOM_IMAGE_TERMINAL_SCROLLBACK_LIMIT - ? next.slice(-CUSTOM_IMAGE_TERMINAL_SCROLLBACK_LIMIT) - : next; -} - -function renderTerminalOutput(raw: string) { - const cleaned = raw.replace(TERMINAL_CONTROL_SEQUENCE_PATTERN, "").replace(/\r\n/g, "\n"); - const buffer: string[] = []; - let cursor = 0; - let lineStart = 0; - - for (const char of cleaned) { - if (char === "\r") { - cursor = lineStart; - continue; - } - - if (char === "\n") { - cursor = buffer.length; - buffer.push(char); - cursor += 1; - lineStart = cursor; - continue; - } - - if (char === "\b" || char === "\x7f") { - if (cursor > lineStart) { - cursor -= 1; - } - continue; - } - - if (char.charCodeAt(0) < 32 && char !== "\t") { - continue; - } - - if (cursor >= buffer.length) { - buffer.push(char); - } else { - buffer[cursor] = char; - } - cursor += 1; - } - - return buffer.join(""); -} - function parseTerminalFrame(raw: string): Record | null { try { const parsed = JSON.parse(raw) as unknown; @@ -310,45 +258,6 @@ function parseTerminalFrame(raw: string): Record | null { } } -function terminalInputForKey(event: ReactKeyboardEvent): string | null { - if (event.metaKey || event.altKey) return null; - - if (event.ctrlKey) { - const key = event.key.toLowerCase(); - if (key.length === 1 && key >= "a" && key <= "z") { - return String.fromCharCode(key.charCodeAt(0) - 96); - } - return null; - } - - switch (event.key) { - case "Enter": - return "\r"; - case "Backspace": - return "\x7f"; - case "Tab": - return "\t"; - case "Escape": - return "\x1b"; - case "ArrowUp": - return "\x1b[A"; - case "ArrowDown": - return "\x1b[B"; - case "ArrowRight": - return "\x1b[C"; - case "ArrowLeft": - return "\x1b[D"; - case "Home": - return "\x1b[H"; - case "End": - return "\x1b[F"; - case "Delete": - return "\x1b[3~"; - default: - return event.key.length === 1 ? event.key : null; - } -} - function customImageTerminalStatusCopy(state: CustomImageTerminalConnectionState) { switch (state) { case "connecting": @@ -373,11 +282,15 @@ function EnvironmentCustomImageBrowserTerminal({ sessionId: string; }) { const [connectionState, setConnectionState] = useState("idle"); - const [output, setOutput] = useState(""); const [errorMessage, setErrorMessage] = useState(null); - const terminalRef = useRef(null); + const terminalElementRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const terminalInputDisposableRef = useRef<{ dispose: () => void } | null>(null); + const resizeObserverRef = useRef(null); const socketRef = useRef(null); const autoConnectAttemptedSessionRef = useRef(null); + const lastSentResizeRef = useRef<{ cols: number; rows: number } | null>(null); const closeSocket = useCallback((reason = "operator_closed") => { const socket = socketRef.current; @@ -387,19 +300,118 @@ function EnvironmentCustomImageBrowserTerminal({ } }, []); + const getTerminalDimensions = useCallback(() => { + const terminal = xtermRef.current; + return { + cols: terminal?.cols || CUSTOM_IMAGE_TERMINAL_COLS, + rows: terminal?.rows || CUSTOM_IMAGE_TERMINAL_ROWS, + }; + }, []); + + const sendTerminalResize = useCallback((force = false) => { + const socket = socketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + + const dimensions = getTerminalDimensions(); + const previous = lastSentResizeRef.current; + if (!force && previous?.cols === dimensions.cols && previous.rows === dimensions.rows) return; + + lastSentResizeRef.current = dimensions; + socket.send(JSON.stringify({ + type: "resize", + cols: dimensions.cols, + rows: dimensions.rows, + })); + }, [getTerminalDimensions]); + + const fitTerminal = useCallback(() => { + const fitAddon = fitAddonRef.current; + if (!fitAddon || !xtermRef.current) return; + try { + fitAddon.fit(); + sendTerminalResize(); + } catch { + // The fit addon can throw during hidden/dialog layout transitions. The + // next ResizeObserver tick or reconnect will retry with stable dimensions. + } + }, [sendTerminalResize]); + + const sendTerminalInput = useCallback((data: string) => { + const socket = socketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: "input", data })); + }, []); + + const resetTerminalScreen = useCallback(() => { + const terminal = xtermRef.current; + if (!terminal) return; + terminal.reset(); + terminal.clear(); + }, []); + + useEffect(() => { + const element = terminalElementRef.current; + if (!element || xtermRef.current) return undefined; + + const terminal = new XTermTerminal({ + allowTransparency: true, + cols: CUSTOM_IMAGE_TERMINAL_COLS, + rows: CUSTOM_IMAGE_TERMINAL_ROWS, + convertEol: false, + cursorBlink: true, + cursorStyle: "block", + fontFamily: "Menlo, Monaco, Consolas, 'Liberation Mono', monospace", + fontSize: 12, + lineHeight: 1.35, + scrollback: CUSTOM_IMAGE_TERMINAL_SCROLLBACK_ROWS, + theme: { + background: "#0a0a0a", + foreground: "#f5f5f5", + cursor: "#f8fafc", + selectionBackground: "#2563eb55", + }, + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(element); + + xtermRef.current = terminal; + fitAddonRef.current = fitAddon; + terminalInputDisposableRef.current = terminal.onData(sendTerminalInput); + + if (typeof ResizeObserver !== "undefined") { + const resizeObserver = new ResizeObserver(() => fitTerminal()); + resizeObserver.observe(element); + resizeObserverRef.current = resizeObserver; + } + + window.setTimeout(fitTerminal, 0); + + return () => { + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = null; + terminalInputDisposableRef.current?.dispose(); + terminalInputDisposableRef.current = null; + fitAddonRef.current = null; + xtermRef.current = null; + terminal.dispose(); + }; + }, [fitTerminal, sendTerminalInput]); + useEffect(() => () => closeSocket("component_unmounted"), [closeSocket]); useEffect(() => { closeSocket("session_changed"); autoConnectAttemptedSessionRef.current = null; + lastSentResizeRef.current = null; setConnectionState("idle"); - setOutput(""); setErrorMessage(null); - }, [closeSocket, sessionId]); + resetTerminalScreen(); + }, [closeSocket, resetTerminalScreen, sessionId]); useEffect(() => { if (connectionState === "connected") { - terminalRef.current?.focus(); + xtermRef.current?.focus(); } }, [connectionState]); @@ -412,18 +424,26 @@ function EnvironmentCustomImageBrowserTerminal({ closeSocket("reconnect"); setConnectionState("connecting"); - setOutput(""); + lastSentResizeRef.current = null; setErrorMessage(null); + resetTerminalScreen(); try { + fitTerminal(); + const dimensions = getTerminalDimensions(); const terminalToken = await environmentsApi.createCustomImageTerminalSessionToken(sessionId, {}); const websocketPath = appendTerminalQuery(terminalToken.websocketPath, { - cols: CUSTOM_IMAGE_TERMINAL_COLS, - rows: CUSTOM_IMAGE_TERMINAL_ROWS, + cols: dimensions.cols, + rows: dimensions.rows, }); const socket = new WebSocket(buildSameOriginWebSocketUrl(websocketPath)); socketRef.current = socket; + socket.onopen = () => { + if (socketRef.current !== socket) return; + sendTerminalResize(true); + }; + socket.onmessage = (message) => { if (socketRef.current !== socket) return; const raw = typeof message.data === "string" ? message.data : ""; @@ -436,7 +456,7 @@ function EnvironmentCustomImageBrowserTerminal({ } if (frame.type === "output" && typeof frame.data === "string") { - setOutput((current) => appendTerminalOutput(current, frame.data as string)); + xtermRef.current?.write(frame.data as string); return; } @@ -467,7 +487,7 @@ function EnvironmentCustomImageBrowserTerminal({ setConnectionState("error"); setErrorMessage(error instanceof Error ? error.message : "Terminal session could not be opened."); } - }, [closeSocket, sessionId]); + }, [closeSocket, fitTerminal, getTerminalDimensions, resetTerminalScreen, sendTerminalResize, sessionId]); useEffect(() => { if (!autoConnect || connectionState !== "idle") return; @@ -479,43 +499,12 @@ function EnvironmentCustomImageBrowserTerminal({ return () => window.clearTimeout(timeoutId); }, [autoConnect, connectTerminal, connectionState, sessionId]); - const sendTerminalInput = useCallback((data: string) => { - const socket = socketRef.current; - if (!socket || socket.readyState !== WebSocket.OPEN) return; - socket.send(JSON.stringify({ type: "input", data })); - }, []); - - const handleTerminalKeyDown = useCallback((event: ReactKeyboardEvent) => { - const input = terminalInputForKey(event); - if (!input) return; - event.preventDefault(); - sendTerminalInput(input); - }, [sendTerminalInput]); - - const handleTerminalPaste = useCallback((event: ReactClipboardEvent) => { - if (connectionState !== "connected") return; - const text = event.clipboardData.getData("text"); - if (!text) return; - event.preventDefault(); - sendTerminalInput(text.replace(/\r?\n/g, "\r")); - }, [connectionState, sendTerminalInput]); - const disconnectTerminal = useCallback(() => { closeSocket("operator_closed"); setConnectionState("closed"); }, [closeSocket]); const terminalInteractive = connectionState === "connected"; - const terminalDisplay = useMemo( - () => output ? renderTerminalOutput(output) : customImageTerminalStatusCopy(connectionState), - [connectionState, output], - ); - - useEffect(() => { - const terminal = terminalRef.current; - if (!terminal) return; - terminal.scrollTop = terminal.scrollHeight; - }, [terminalDisplay]); return (
@@ -543,23 +532,15 @@ function EnvironmentCustomImageBrowserTerminal({ )}
-
-