OpenHands / frontend /src /hooks /use-terminal.ts
Backup-bdg's picture
Upload 565 files
b59aa07 verified
raw
history blame
6.92 kB
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;
};