Convert terminal to tab, make terminal read only (#7795)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
This commit is contained in:
Robert Brennan 2025-04-15 23:22:52 -04:00 committed by GitHub
parent 07e400b73d
commit 628003abef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 119 additions and 259 deletions

View File

@ -5,7 +5,7 @@ import { Command, appendInput, appendOutput } from "#/state/command-slice";
import Terminal from "#/components/features/terminal/terminal";
const renderTerminal = (commands: Command[] = []) =>
renderWithProviders(<Terminal secrets={[]} />, {
renderWithProviders(<Terminal />, {
preloadedState: {
cmd: {
commands,
@ -121,7 +121,7 @@ describe.skip("Terminal", () => {
// This test fails because it expects `disposeMock` to have been called before the component is unmounted.
it.skip("should dispose the terminal on unmount", () => {
const { unmount } = renderWithProviders(<Terminal secrets={[]} />);
const { unmount } = renderWithProviders(<Terminal />);
expect(mockTerminal.dispose).not.toHaveBeenCalled();

View File

@ -7,14 +7,12 @@ import { Command } from "#/state/command-slice";
interface TestTerminalComponentProps {
commands: Command[];
secrets: string[];
}
function TestTerminalComponent({
commands,
secrets,
}: TestTerminalComponentProps) {
const ref = useTerminal({ commands, secrets, disabled: false });
const ref = useTerminal({ commands });
return <div ref={ref} />;
}
@ -57,7 +55,7 @@ describe("useTerminal", () => {
});
it("should render", () => {
render(<TestTerminalComponent commands={[]} secrets={[]} />, {
render(<TestTerminalComponent commands={[]} />, {
wrapper: Wrapper,
});
});
@ -68,7 +66,7 @@ describe("useTerminal", () => {
{ content: "hello", type: "output" },
];
render(<TestTerminalComponent commands={commands} secrets={[]} />, {
render(<TestTerminalComponent commands={commands} />, {
wrapper: Wrapper,
});
@ -76,7 +74,8 @@ describe("useTerminal", () => {
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "hello");
});
it("should hide secrets in the terminal", () => {
// This test is no longer relevant as secrets filtering has been removed
it.skip("should hide secrets in the terminal", () => {
const secret = "super_secret_github_token";
const anotherSecret = "super_secret_another_token";
const commands: Command[] = [
@ -90,20 +89,12 @@ describe("useTerminal", () => {
render(
<TestTerminalComponent
commands={commands}
secrets={[secret, anotherSecret]}
/>,
{
wrapper: Wrapper,
},
);
// BUG: `vi.clearAllMocks()` does not clear the number of calls
// therefore, we need to assume the order of the calls based
// on the test order
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(
3,
`export GITHUB_TOKEN=${"*".repeat(10)},${"*".repeat(10)},${"*".repeat(10)}`,
);
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(4, "*".repeat(10));
// This test is no longer relevant as secrets filtering has been removed
});
});

View File

@ -1,22 +0,0 @@
import React from "react";
function CmdLine() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
);
}
export default CmdLine;

View File

@ -1,26 +0,0 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
import { AgentState } from "#/types/agent-state";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
export function TerminalStatusLabel() {
const { t } = useTranslation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
return (
<div className="flex items-center gap-2">
<div
className={cn(
"w-2 h-2 rounded-full",
curAgentState === AgentState.LOADING ||
curAgentState === AgentState.STOPPED
? "bg-red-500 animate-pulse"
: "bg-green-500",
)}
/>
{t(I18nKey.WORKSPACE$TERMINAL_TAB_LABEL)}
</div>
);
}

View File

@ -2,24 +2,16 @@ import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { useTerminal } from "#/hooks/use-terminal";
import "@xterm/xterm/css/xterm.css";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
interface TerminalProps {
secrets: string[];
}
function Terminal({ secrets }: TerminalProps) {
function Terminal() {
const { commands } = useSelector((state: RootState) => state.cmd);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const ref = useTerminal({
commands,
secrets,
disabled: RUNTIME_INACTIVE_STATES.includes(curAgentState),
});
return (
<div className="h-full p-2 min-h-0">
<div className="h-full p-2 min-h-0 flex-grow">
<div ref={ref} className="h-full w-full" />
</div>
);

View File

@ -23,7 +23,7 @@ export function Container({
return (
<div
className={clsx(
"bg-base-secondary border border-neutral-600 rounded-xl flex flex-col",
"bg-base-secondary border border-neutral-600 rounded-xl flex flex-col h-full",
className,
)}
>
@ -39,7 +39,7 @@ export function Container({
{label}
</div>
)}
<div className="overflow-hidden h-full rounded-b-xl">{children}</div>
<div className="overflow-hidden flex-grow rounded-b-xl">{children}</div>
</div>
);
}

View File

@ -2,9 +2,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import React from "react";
import { Command } from "#/state/command-slice";
import { getTerminalCommand } from "#/services/terminal-service";
import { parseTerminalOutput } from "#/utils/parse-terminal-output";
import { useWsClient } from "#/context/ws-client-provider";
/*
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
@ -13,27 +11,23 @@ import { useWsClient } from "#/context/ws-client-provider";
interface UseTerminalConfig {
commands: Command[];
secrets: string[];
disabled: boolean;
}
const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = {
commands: [],
secrets: [],
disabled: false,
};
// 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,
secrets,
disabled,
}: UseTerminalConfig = DEFAULT_TERMINAL_CONFIG) => {
const { send } = useWsClient();
const terminal = React.useRef<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(null);
const ref = React.useRef<HTMLDivElement>(null);
const lastCommandIndex = React.useRef(0);
const keyEventDisposable = React.useRef<{ dispose: () => void } | null>(null);
const lastCommandIndex = persistentLastCommandIndex; // Use the persistent reference
const createTerminal = () =>
new Terminal({
@ -51,86 +45,46 @@ export const useTerminal = ({
}
};
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");
send(getTerminalCommand(command));
};
const handleBackspace = (command: string) => {
terminal.current?.write("\b \b");
return command.slice(0, -1);
};
React.useEffect(() => {
/* Create a new terminal instance */
terminal.current = createTerminal();
fitAddon.current = new FitAddon();
let resizeObserver: ResizeObserver | null = null;
if (ref.current) {
/* Initialize the terminal in the DOM */
initializeTerminal();
terminal.current.write("$ ");
/* Listen for resize events */
resizeObserver = new ResizeObserver(() => {
fitAddon.current?.fit();
});
resizeObserver.observe(ref.current);
if (commands.length > 0) {
for (let i = 0; i < commands.length; i += 1) {
const { content, type } = commands[i];
terminal.current?.writeln(
parseTerminalOutput(content.replaceAll("\n", "\r\n").trim()),
);
if (type === "output") {
terminal.current.write(`\n`);
}
}
lastCommandIndex.current = commands.length;
}
terminal.current.write("$ ");
}
return () => {
terminal.current?.dispose();
resizeObserver?.disconnect();
};
}, []);
}, [commands]);
React.useEffect(() => {
/* Write commands to the terminal */
if (terminal.current && commands.length > 0) {
// Start writing commands from the last command index
if (
terminal.current &&
commands.length > 0 &&
lastCommandIndex.current < commands.length
) {
for (let i = lastCommandIndex.current; i < commands.length; i += 1) {
// eslint-disable-next-line prefer-const
let { content, type } = commands[i];
secrets.forEach((secret) => {
content = content.replaceAll(secret, "*".repeat(10));
});
terminal.current?.writeln(
parseTerminalOutput(content.replaceAll("\n", "\r\n").trim()),
);
@ -145,59 +99,20 @@ export const useTerminal = ({
}, [commands]);
React.useEffect(() => {
if (terminal.current) {
// Dispose of existing listeners if they exist
if (keyEventDisposable.current) {
keyEventDisposable.current.dispose();
keyEventDisposable.current = null;
}
let resizeObserver: ResizeObserver | null = null;
let commandBuffer = "";
resizeObserver = new ResizeObserver(() => {
fitAddon.current?.fit();
});
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();
});
}
if (ref.current) {
resizeObserver.observe(ref.current);
}
return () => {
if (keyEventDisposable.current) {
keyEventDisposable.current.dispose();
keyEventDisposable.current = null;
}
resizeObserver?.disconnect();
};
}, [disabled]);
}, []);
return ref;
};

View File

@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(0.85) translate(1.8, 1.8)">
<path d="M19.5 1.5C18.67 1.5 18 2.17 18 3C18 3.83 18.67 4.5 19.5 4.5C20.33 4.5 21 3.83 21 3C21 2.17 20.33 1.5 19.5 1.5Z" fill="currentColor"/>
<path d="M12 18C8.5 18 5.5 16.8 4 15C4 18.3137 7.13401 21 12 21C16.866 21 20 18.3137 20 15C18.5 16.8 15.5 18 12 18Z" fill="currentColor"/>
<path d="M12 6C15.5 6 18.5 7.2 20 9C20 5.68629 16.866 3 12 3C7.13401 3 4 5.68629 4 9C5.5 7.2 8.5 6 12 6Z" fill="currentColor"/>
<path d="M7.5 21C6.67 21 6 21.67 6 22.5C6 23.33 6.67 24 7.5 24C8.33 24 9 23.33 9 22.5C9 21.67 8.33 21 7.5 21Z" fill="currentColor"/>
<path d="M4.5 5.5C3.67 5.5 3 4.83 3 4C3 3.17 3.67 2.5 4.5 2.5C5.33 2.5 6 3.17 6 4C6 4.83 5.33 5.5 4.5 5.5Z" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 853 B

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 5C5.44772 5 5 5.44772 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V6C19 5.44772 18.5523 5 18 5H6ZM6 6H18V18H6V6Z" fill="currentColor"/>
<path d="M8.14645 9.64645C7.95118 9.84171 7.95118 10.1583 8.14645 10.3536L10.7929 13L8.14645 15.6464C7.95118 15.8417 7.95118 16.1583 8.14645 16.3536C8.34171 16.5488 8.65829 16.5488 8.85355 16.3536L11.8536 13.3536C12.0488 13.1583 12.0488 12.8417 11.8536 12.6464L8.85355 9.64645C8.65829 9.45118 8.34171 9.45118 8.14645 9.64645Z" fill="currentColor"/>
<path d="M13 16C12.7239 16 12.5 16.2239 12.5 16.5C12.5 16.7761 12.7239 17 13 17H16C16.2761 17 16.5 16.7761 16.5 16.5C16.5 16.2239 16.2761 16 16 16H13Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 799 B

View File

@ -17,6 +17,7 @@ export default [
route("browser", "routes/browser-tab.tsx"),
route("jupyter", "routes/jupyter-tab.tsx"),
route("served", "routes/served-tab.tsx"),
route("terminal", "routes/terminal-tab.tsx"),
]),
]),
] satisfies RouteConfig;

View File

@ -15,7 +15,8 @@ import { clearTerminal } from "#/state/command-slice";
import { useEffectOnce } from "#/hooks/use-effect-once";
import CodeIcon from "#/icons/code.svg?react";
import GlobeIcon from "#/icons/globe.svg?react";
import ListIcon from "#/icons/list-type-number.svg?react";
import JupyterIcon from "#/icons/jupyter.svg?react";
import TerminalIcon from "#/icons/terminal.svg?react";
import { clearJupyter } from "#/state/jupyter-slice";
import { FilesProvider } from "#/context/files";
import { ChatInterface } from "../components/features/chat/chat-interface";
@ -31,7 +32,6 @@ import Security from "#/components/shared/modals/security/security";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { ServedAppLabel } from "#/components/layout/served-app-label";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
import { useSettings } from "#/hooks/query/use-settings";
import { clearFiles, clearInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
@ -57,17 +57,6 @@ function AppContent() {
const [width, setWidth] = React.useState(window.innerWidth);
const secrets = React.useMemo(
// secrets to filter go here
() => [].filter((secret) => secret !== null),
[],
);
const Terminal = React.useMemo(
() => React.lazy(() => import("#/components/features/terminal/terminal")),
[],
);
React.useEffect(() => {
if (isFetched && !conversation) {
displayErrorToast(
@ -135,56 +124,40 @@ function AppContent() {
secondClassName="flex flex-col overflow-hidden"
firstChild={<ChatInterface />}
secondChild={
<ResizablePanel
orientation={Orientation.VERTICAL}
className="grow h-full min-h-0 min-w-0"
initialSize={500}
firstClassName="rounded-xl overflow-hidden border border-neutral-600"
secondClassName="flex flex-col overflow-hidden"
firstChild={
<Container
className="h-full"
labels={[
{
label: t(I18nKey.WORKSPACE$TITLE),
to: "",
icon: <CodeIcon />,
},
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
{
label: <ServedAppLabel />,
to: "served",
icon: <FaServer />,
},
{
label: (
<div className="flex items-center gap-1">
{t(I18nKey.BROWSER$TITLE)}
</div>
),
to: "browser",
icon: <GlobeIcon />,
},
]}
>
<FilesProvider>
<Outlet />
</FilesProvider>
</Container>
}
secondChild={
<Container
className="h-full overflow-scroll"
label={<TerminalStatusLabel />}
>
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
* that it loads only in the client-side. */}
<React.Suspense fallback={<div className="h-full" />}>
<Terminal secrets={secrets} />
</React.Suspense>
</Container>
}
/>
<Container
className="h-full w-full"
labels={[
{
label: t(I18nKey.WORKSPACE$TITLE),
to: "",
icon: <CodeIcon />,
},
{
label: t(I18nKey.WORKSPACE$TERMINAL_TAB_LABEL),
to: "terminal",
icon: <TerminalIcon />,
},
{ label: "Jupyter", to: "jupyter", icon: <JupyterIcon /> },
{
label: <ServedAppLabel />,
to: "served",
icon: <FaServer />,
},
{
label: (
<div className="flex items-center gap-1">
{t(I18nKey.BROWSER$TITLE)}
</div>
),
to: "browser",
icon: <GlobeIcon />,
},
]}
>
<FilesProvider>
<Outlet />
</FilesProvider>
</Container>
}
/>
);

View File

@ -0,0 +1,22 @@
import React from "react";
function TerminalTab() {
const Terminal = React.useMemo(
() => React.lazy(() => import("#/components/features/terminal/terminal")),
[],
);
return (
<div className="h-full flex flex-col">
<div className="flex-grow overflow-auto">
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
* that it loads only in the client-side. */}
<React.Suspense fallback={<div className="h-full" />}>
<Terminal />
</React.Suspense>
</div>
</div>
);
}
export default TerminalTab;