From eaaff320af31fdceb65e1fdc83d87d2b6c460f37 Mon Sep 17 00:00:00 2001 From: Cody Date: Thu, 2 Jul 2026 16:12:57 +0000 Subject: [PATCH] Fix custom image terminal interactivity --- ui/src/pages/CompanyEnvironments.test.tsx | 10 ++- ui/src/pages/CompanyEnvironments.tsx | 103 +++++++++++++++++++--- 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index 9950fe8b578..52c9459e6ce 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -676,14 +676,18 @@ describe("CompanyEnvironments — test provider button", () => { await act(async () => { FakeWebSocket.instances[0].open(); FakeWebSocket.instances[0].emitMessage(JSON.stringify({ type: "ready" })); - FakeWebSocket.instances[0].emitMessage(JSON.stringify({ type: "output", data: "setup shell\\n$ " })); + 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']", + ); await waitForAssertion(() => { - expect(getOpenDialog()?.textContent).toContain("setup shell"); + expect(terminal?.value).toContain("setup shell"); + expect(terminal?.value).not.toContain("[?2004h"); + expect(document.activeElement).toBe(terminal); expect(getOpenDialog()?.textContent).toContain(command); }); - const terminal = getOpenDialog()?.querySelector("[data-testid='custom-image-terminal-input-session-1']"); await act(async () => { terminal?.dispatchEvent(new KeyboardEvent("keydown", { key: "l", bubbles: true })); terminal?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index d1aab707308..c0c4f625c78 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +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 { @@ -224,6 +232,9 @@ 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; type CustomImageTerminalConnectionState = | "idle" @@ -246,6 +257,48 @@ function appendTerminalOutput(current: string, chunk: string) { : 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; @@ -322,7 +375,7 @@ function EnvironmentCustomImageBrowserTerminal({ const [connectionState, setConnectionState] = useState("idle"); const [output, setOutput] = useState(""); const [errorMessage, setErrorMessage] = useState(null); - const terminalRef = useRef(null); + const terminalRef = useRef(null); const socketRef = useRef(null); const autoConnectAttemptedSessionRef = useRef(null); @@ -432,20 +485,37 @@ function EnvironmentCustomImageBrowserTerminal({ socket.send(JSON.stringify({ type: "input", data })); }, []); - const handleTerminalKeyDown = useCallback((event: ReactKeyboardEvent) => { + 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 = output || customImageTerminalStatusCopy(connectionState); + const terminalDisplay = useMemo( + () => output ? renderTerminalOutput(output) : customImageTerminalStatusCopy(connectionState), + [connectionState, output], + ); + + useEffect(() => { + const terminal = terminalRef.current; + if (!terminal) return; + terminal.scrollTop = terminal.scrollHeight; + }, [terminalDisplay]); return (
@@ -474,16 +544,23 @@ function EnvironmentCustomImageBrowserTerminal({
-
{terminalDisplay}
+