import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import React from "react"; import { useSelector } from "react-redux"; import { Command } from "#/state/command-slice"; import { RootState } from "#/store"; import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; import { useWsClient } from "#/context/ws-client-provider"; import { getTerminalCommand } from "#/services/terminal-service"; import { parseTerminalOutput } from "#/utils/parse-terminal-output"; /* NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component. The reason for this is that the hook exposes a ref that requires a DOM element to be rendered. */ interface UseTerminalConfig { commands: Command[]; } const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = { commands: [], }; const renderCommand = (command: Command, terminal: Terminal) => { const { content } = command; terminal.writeln( parseTerminalOutput(content.replaceAll("\n", "\r\n").trim()), ); }; // Create a persistent reference that survives component unmounts // This ensures terminal history is preserved when navigating away and back const persistentLastCommandIndex = { current: 0 }; export const useTerminal = ({ commands, }: UseTerminalConfig = DEFAULT_TERMINAL_CONFIG) => { const { send } = useWsClient(); const { curAgentState } = useSelector((state: RootState) => state.agent); const terminal = React.useRef(null); const fitAddon = React.useRef(null); const ref = React.useRef(null); const lastCommandIndex = persistentLastCommandIndex; // Use the persistent reference const keyEventDisposable = React.useRef<{ dispose: () => void } | null>(null); const disabled = RUNTIME_INACTIVE_STATES.includes(curAgentState); const createTerminal = () => new Terminal({ fontFamily: "Menlo, Monaco, 'Courier New', monospace", fontSize: 14, theme: { background: "#24272E", }, }); const initializeTerminal = () => { if (terminal.current) { if (fitAddon.current) terminal.current.loadAddon(fitAddon.current); if (ref.current) terminal.current.open(ref.current); } }; const copySelection = (selection: string) => { const clipboardItem = new ClipboardItem({ "text/plain": new Blob([selection], { type: "text/plain" }), }); navigator.clipboard.write([clipboardItem]); }; const pasteSelection = (callback: (text: string) => void) => { navigator.clipboard.readText().then(callback); }; const pasteHandler = (event: KeyboardEvent, cb: (text: string) => void) => { const isControlOrMetaPressed = event.type === "keydown" && (event.ctrlKey || event.metaKey); if (isControlOrMetaPressed) { if (event.code === "KeyV") { pasteSelection((text: string) => { terminal.current?.write(text); cb(text); }); } if (event.code === "KeyC") { const selection = terminal.current?.getSelection(); if (selection) copySelection(selection); } } return true; }; const handleEnter = (command: string) => { terminal.current?.write("\r\n"); // Don't write the command again as it will be added to the commands array // and rendered by the useEffect that watches commands send(getTerminalCommand(command)); // Don't add the prompt here as it will be added when the command is processed // and the commands array is updated }; const handleBackspace = (command: string) => { terminal.current?.write("\b \b"); return command.slice(0, -1); }; // Initialize terminal and handle cleanup React.useEffect(() => { terminal.current = createTerminal(); fitAddon.current = new FitAddon(); if (ref.current) { initializeTerminal(); // Render all commands in array // This happens when we just switch to Terminal from other tabs if (commands.length > 0) { for (let i = 0; i < commands.length; i += 1) { if (commands[i].type === "input") { terminal.current.write("$ "); } renderCommand(commands[i], terminal.current); } lastCommandIndex.current = commands.length; } terminal.current.write("$ "); } return () => { terminal.current?.dispose(); }; }, []); React.useEffect(() => { if ( terminal.current && commands.length > 0 && lastCommandIndex.current < commands.length ) { let lastCommandType = ""; for (let i = lastCommandIndex.current; i < commands.length; i += 1) { lastCommandType = commands[i].type; renderCommand(commands[i], terminal.current); } lastCommandIndex.current = commands.length; if (lastCommandType === "output") { terminal.current.write("$ "); } } }, [commands, disabled]); React.useEffect(() => { let resizeObserver: ResizeObserver | null = null; resizeObserver = new ResizeObserver(() => { fitAddon.current?.fit(); }); if (ref.current) { resizeObserver.observe(ref.current); } return () => { resizeObserver?.disconnect(); }; }, []); React.useEffect(() => { if (terminal.current) { // Dispose of existing listeners if they exist if (keyEventDisposable.current) { keyEventDisposable.current.dispose(); keyEventDisposable.current = null; } let commandBuffer = ""; if (!disabled) { // Add new key event listener and store the disposable keyEventDisposable.current = terminal.current.onKey( ({ key, domEvent }) => { if (domEvent.key === "Enter") { handleEnter(commandBuffer); commandBuffer = ""; } else if (domEvent.key === "Backspace") { if (commandBuffer.length > 0) { commandBuffer = handleBackspace(commandBuffer); } } else { // Ignore paste event if (key.charCodeAt(0) === 22) { return; } commandBuffer += key; terminal.current?.write(key); } }, ); // Add custom key handler and store the disposable terminal.current.attachCustomKeyEventHandler((event) => pasteHandler(event, (text) => { commandBuffer += text; }), ); } else { // Add a noop handler when disabled keyEventDisposable.current = terminal.current.onKey((e) => { e.domEvent.preventDefault(); e.domEvent.stopPropagation(); }); } } return () => { if (keyEventDisposable.current) { keyEventDisposable.current.dispose(); keyEventDisposable.current = null; } }; }, [disabled]); return ref; };