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; | |
}; | |