mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-07 22:14:03 -05:00
fix(frontend): Disable terminal stdin if the runtime is starting up (#5625)
This commit is contained in:
@@ -4,26 +4,6 @@ import { vi, describe, afterEach, it, expect } from "vitest";
|
|||||||
import { Command, appendInput, appendOutput } from "#/state/command-slice";
|
import { Command, appendInput, appendOutput } from "#/state/command-slice";
|
||||||
import Terminal from "#/components/features/terminal/terminal";
|
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[] = []) =>
|
const renderTerminal = (commands: Command[] = []) =>
|
||||||
renderWithProviders(<Terminal secrets={[]} />, {
|
renderWithProviders(<Terminal secrets={[]} />, {
|
||||||
preloadedState: {
|
preloadedState: {
|
||||||
@@ -34,6 +14,26 @@ const renderTerminal = (commands: Command[] = []) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe.skip("Terminal", () => {
|
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(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ReactNode } from "react";
|
|||||||
import { useTerminal } from "#/hooks/use-terminal";
|
import { useTerminal } from "#/hooks/use-terminal";
|
||||||
import { Command } from "#/state/command-slice";
|
import { Command } from "#/state/command-slice";
|
||||||
|
|
||||||
|
|
||||||
interface TestTerminalComponentProps {
|
interface TestTerminalComponentProps {
|
||||||
commands: Command[];
|
commands: Command[];
|
||||||
secrets: string[];
|
secrets: string[];
|
||||||
@@ -15,7 +14,7 @@ function TestTerminalComponent({
|
|||||||
commands,
|
commands,
|
||||||
secrets,
|
secrets,
|
||||||
}: TestTerminalComponentProps) {
|
}: TestTerminalComponentProps) {
|
||||||
const ref = useTerminal(commands, secrets);
|
const ref = useTerminal({ commands, secrets, disabled: false });
|
||||||
return <div ref={ref} />;
|
return <div ref={ref} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,9 +23,7 @@ interface WrapperProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Wrapper({ children }: WrapperProps) {
|
function Wrapper({ children }: WrapperProps) {
|
||||||
return (
|
return <div>{children}</div>;
|
||||||
<div>{children}</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useTerminal", () => {
|
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 { useSelector } from "react-redux";
|
||||||
import { RootState } from "#/store";
|
import { RootState } from "#/store";
|
||||||
import { useTerminal } from "#/hooks/use-terminal";
|
import { useTerminal } from "#/hooks/use-terminal";
|
||||||
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import {
|
||||||
|
useWsClient,
|
||||||
|
WsClientProviderStatus,
|
||||||
|
} from "#/context/ws-client-provider";
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
secrets: string[];
|
secrets: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Terminal({ secrets }: TerminalProps) {
|
function Terminal({ secrets }: TerminalProps) {
|
||||||
|
const { status } = useWsClient();
|
||||||
const { commands } = useSelector((state: RootState) => state.cmd);
|
const { commands } = useSelector((state: RootState) => state.cmd);
|
||||||
const ref = useTerminal(commands, secrets);
|
|
||||||
|
const ref = useTerminal({
|
||||||
|
commands,
|
||||||
|
secrets,
|
||||||
|
disabled: status === WsClientProviderStatus.OPENING,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full p-2 min-h-0">
|
<div className="h-full p-2 min-h-0">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { NavTab } from "./nav-tab";
|
import { NavTab } from "./nav-tab";
|
||||||
|
|
||||||
interface ContainerProps {
|
interface ContainerProps {
|
||||||
label?: string;
|
label?: React.ReactNode;
|
||||||
labels?: {
|
labels?: {
|
||||||
label: string | React.ReactNode;
|
label: string | React.ReactNode;
|
||||||
to: string;
|
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.
|
The reason for this is that the hook exposes a ref that requires a DOM element to be rendered.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const useTerminal = (
|
interface UseTerminalConfig {
|
||||||
commands: Command[] = [],
|
commands: Command[];
|
||||||
secrets: string[] = [],
|
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 { send } = useWsClient();
|
||||||
const terminal = React.useRef<Terminal | null>(null);
|
const terminal = React.useRef<Terminal | null>(null);
|
||||||
const fitAddon = React.useRef<FitAddon | null>(null);
|
const fitAddon = React.useRef<FitAddon | null>(null);
|
||||||
@@ -85,36 +98,12 @@ export const useTerminal = (
|
|||||||
terminal.current = createTerminal();
|
terminal.current = createTerminal();
|
||||||
fitAddon.current = new FitAddon();
|
fitAddon.current = new FitAddon();
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let commandBuffer = "";
|
|
||||||
|
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
/* Initialize the terminal in the DOM */
|
/* Initialize the terminal in the DOM */
|
||||||
initializeTerminal();
|
initializeTerminal();
|
||||||
|
|
||||||
terminal.current.write("$ ");
|
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 */
|
/* Listen for resize events */
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
@@ -125,7 +114,7 @@ export const useTerminal = (
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
terminal.current?.dispose();
|
terminal.current?.dispose();
|
||||||
resizeObserver.disconnect();
|
resizeObserver?.disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -152,5 +141,42 @@ export const useTerminal = (
|
|||||||
}
|
}
|
||||||
}, [commands]);
|
}, [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;
|
return ref;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useConversationConfig } from "#/hooks/query/use-conversation-config";
|
|||||||
import { Container } from "#/components/layout/container";
|
import { Container } from "#/components/layout/container";
|
||||||
import Security from "#/components/shared/modals/security/security";
|
import Security from "#/components/shared/modals/security/security";
|
||||||
import { CountBadge } from "#/components/layout/count-badge";
|
import { CountBadge } from "#/components/layout/count-badge";
|
||||||
|
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { token, gitHubToken } = useAuth();
|
const { token, gitHubToken } = useAuth();
|
||||||
@@ -101,7 +102,10 @@ function App() {
|
|||||||
</Container>
|
</Container>
|
||||||
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
|
{/* 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. */}
|
* 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" />}>
|
<React.Suspense fallback={<div className="h-full" />}>
|
||||||
<Terminal secrets={secrets} />
|
<Terminal secrets={secrets} />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
|
|||||||
Reference in New Issue
Block a user