Spaces:
Build error
Build error
| 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<Terminal | null>(null); | |
| const fitAddon = React.useRef<FitAddon | null>(null); | |
| const ref = React.useRef<HTMLDivElement>(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; | |
| }; | |