mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
fix(frontend): Disable terminal stdin if the runtime is starting up (#5625)
This commit is contained in:
parent
ee8438cd59
commit
b04ec03062
@ -4,26 +4,6 @@ import { vi, describe, afterEach, it, expect } from "vitest";
|
||||
import { Command, appendInput, appendOutput } from "#/state/command-slice";
|
||||
import Terminal from "#/components/features/terminal/terminal";
|
||||
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockTerminal = {
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
loadAddon: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@xterm/xterm", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@xterm/xterm")>()),
|
||||
Terminal: vi.fn().mockImplementation(() => mockTerminal),
|
||||
}));
|
||||
|
||||
const renderTerminal = (commands: Command[] = []) =>
|
||||
renderWithProviders(<Terminal secrets={[]} />, {
|
||||
preloadedState: {
|
||||
@ -34,6 +14,26 @@ const renderTerminal = (commands: Command[] = []) =>
|
||||
});
|
||||
|
||||
describe.skip("Terminal", () => {
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockTerminal = {
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
loadAddon: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@xterm/xterm", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@xterm/xterm")>()),
|
||||
Terminal: vi.fn().mockImplementation(() => mockTerminal),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@ -5,7 +5,6 @@ import { ReactNode } from "react";
|
||||
import { useTerminal } from "#/hooks/use-terminal";
|
||||
import { Command } from "#/state/command-slice";
|
||||
|
||||
|
||||
interface TestTerminalComponentProps {
|
||||
commands: Command[];
|
||||
secrets: string[];
|
||||
@ -15,7 +14,7 @@ function TestTerminalComponent({
|
||||
commands,
|
||||
secrets,
|
||||
}: TestTerminalComponentProps) {
|
||||
const ref = useTerminal(commands, secrets);
|
||||
const ref = useTerminal({ commands, secrets, disabled: false });
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
|
||||
@ -24,9 +23,7 @@ interface WrapperProps {
|
||||
}
|
||||
|
||||
function Wrapper({ children }: WrapperProps) {
|
||||
return (
|
||||
<div>{children}</div>
|
||||
)
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
describe("useTerminal", () => {
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
import {
|
||||
useWsClient,
|
||||
WsClientProviderStatus,
|
||||
} from "#/context/ws-client-provider";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export function TerminalStatusLabel() {
|
||||
const { status } = useWsClient();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
status === WsClientProviderStatus.ACTIVE && "bg-green-500",
|
||||
status !== WsClientProviderStatus.ACTIVE &&
|
||||
"bg-red-500 animate-pulse",
|
||||
)}
|
||||
/>
|
||||
Terminal
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,25 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "#/store";
|
||||
import { useTerminal } from "#/hooks/use-terminal";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import {
|
||||
useWsClient,
|
||||
WsClientProviderStatus,
|
||||
} from "#/context/ws-client-provider";
|
||||
|
||||
interface TerminalProps {
|
||||
secrets: string[];
|
||||
}
|
||||
|
||||
function Terminal({ secrets }: TerminalProps) {
|
||||
const { status } = useWsClient();
|
||||
const { commands } = useSelector((state: RootState) => state.cmd);
|
||||
const ref = useTerminal(commands, secrets);
|
||||
|
||||
const ref = useTerminal({
|
||||
commands,
|
||||
secrets,
|
||||
disabled: status === WsClientProviderStatus.OPENING,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full p-2 min-h-0">
|
||||
|
||||
@ -3,7 +3,7 @@ import React from "react";
|
||||
import { NavTab } from "./nav-tab";
|
||||
|
||||
interface ContainerProps {
|
||||
label?: string;
|
||||
label?: React.ReactNode;
|
||||
labels?: {
|
||||
label: string | React.ReactNode;
|
||||
to: string;
|
||||
|
||||
@ -11,10 +11,23 @@ import { useWsClient } from "#/context/ws-client-provider";
|
||||
The reason for this is that the hook exposes a ref that requires a DOM element to be rendered.
|
||||
*/
|
||||
|
||||
export const useTerminal = (
|
||||
commands: Command[] = [],
|
||||
secrets: string[] = [],
|
||||
) => {
|
||||
interface UseTerminalConfig {
|
||||
commands: Command[];
|
||||
secrets: string[];
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = {
|
||||
commands: [],
|
||||
secrets: [],
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
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);
|
||||
@ -85,36 +98,12 @@ export const useTerminal = (
|
||||
terminal.current = createTerminal();
|
||||
fitAddon.current = new FitAddon();
|
||||
|
||||
let resizeObserver: ResizeObserver;
|
||||
let commandBuffer = "";
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
if (ref.current) {
|
||||
/* Initialize the terminal in the DOM */
|
||||
initializeTerminal();
|
||||
|
||||
terminal.current.write("$ ");
|
||||
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);
|
||||
}
|
||||
});
|
||||
terminal.current.attachCustomKeyEventHandler((event) =>
|
||||
pasteHandler(event, (text) => {
|
||||
commandBuffer += text;
|
||||
}),
|
||||
);
|
||||
|
||||
/* Listen for resize events */
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
@ -125,7 +114,7 @@ export const useTerminal = (
|
||||
|
||||
return () => {
|
||||
terminal.current?.dispose();
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -152,5 +141,42 @@ export const useTerminal = (
|
||||
}
|
||||
}, [commands]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (terminal.current) {
|
||||
let commandBuffer = "";
|
||||
|
||||
if (!disabled) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
terminal.current.attachCustomKeyEventHandler((event) =>
|
||||
pasteHandler(event, (text) => {
|
||||
commandBuffer += text;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
terminal.current.onKey((e) => {
|
||||
e.domEvent.preventDefault();
|
||||
e.domEvent.stopPropagation();
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [disabled, terminal]);
|
||||
|
||||
return ref;
|
||||
};
|
||||
|
||||
@ -22,6 +22,7 @@ import { useConversationConfig } from "#/hooks/query/use-conversation-config";
|
||||
import { Container } from "#/components/layout/container";
|
||||
import Security from "#/components/shared/modals/security/security";
|
||||
import { CountBadge } from "#/components/layout/count-badge";
|
||||
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
|
||||
|
||||
function App() {
|
||||
const { token, gitHubToken } = useAuth();
|
||||
@ -101,7 +102,10 @@ function App() {
|
||||
</Container>
|
||||
{/* 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. */}
|
||||
<Container className="h-1/3 overflow-scroll" label="Terminal">
|
||||
<Container
|
||||
className="h-1/3 overflow-scroll"
|
||||
label={<TerminalStatusLabel />}
|
||||
>
|
||||
<React.Suspense fallback={<div className="h-full" />}>
|
||||
<Terminal secrets={secrets} />
|
||||
</React.Suspense>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user