refactor(frontend): Terminal (#1315)

* create new modal for loading previous session

* style and replace modal

* retire old components and group modals into folder

* Utilise i18n for text content and add en translations

* prevent modal from being dismissed via the backdrop

* reference issue that its fixing

* fix incorrect role in tests

* initial commit'

* add output support

* update addon-fit library and mgirate to useXTerm

* add test todos

* move useXTerm to hooks folder

* Fix import path error

---------

Co-authored-by: Jim Su <jimsu@protonmail.com>
This commit is contained in:
sp.wack 2024-04-24 05:34:46 +03:00 committed by GitHub
parent ba556ea4d8
commit 27246aca7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 139 additions and 138 deletions

View File

@ -13,6 +13,7 @@
"@react-types/shared": "^3.22.1",
"@reduxjs/toolkit": "^2.2.2",
"@vitejs/plugin-react": "^4.2.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"clsx": "^2.1.0",
"eslint-config-airbnb-typescript": "^18.0.0",
@ -32,8 +33,7 @@
"react-syntax-highlighter": "^15.5.0",
"tailwind-merge": "^2.2.2",
"vite": "^5.1.6",
"web-vitals": "^2.1.4",
"xterm-addon-fit": "^0.8.0"
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.2",
@ -5247,6 +5247,14 @@
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"dev": true
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
@ -14260,22 +14268,6 @@
"node": ">=0.4"
}
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"peer": true
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -12,6 +12,7 @@
"@react-types/shared": "^3.22.1",
"@reduxjs/toolkit": "^2.2.2",
"@vitejs/plugin-react": "^4.2.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"clsx": "^2.1.0",
"eslint-config-airbnb-typescript": "^18.0.0",
@ -31,8 +32,7 @@
"react-syntax-highlighter": "^15.5.0",
"tailwind-merge": "^2.2.2",
"vite": "^5.1.6",
"web-vitals": "^2.1.4",
"xterm-addon-fit": "^0.8.0"
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "vite",

View File

@ -2,8 +2,11 @@ import { useDisclosure } from "@nextui-org/react";
import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import CogTooth from "#/assets/cog-tooth";
import AgentControlBar from "#/components/AgentControlBar";
import AgentStatusBar from "#/components/AgentStatusBar";
import ChatInterface from "#/components/ChatInterface";
import Errors from "#/components/Errors";
import Terminal from "#/components/terminal/Terminal";
import { Container, Orientation } from "#/components/Resizable";
import Workspace from "#/components/Workspace";
import LoadPreviousSessionModal from "#/components/modals/load-previous-session/LoadPreviousSessionModal";
@ -13,9 +16,6 @@ import { initializeAgent } from "#/services/settingsService";
import Socket from "#/services/socket";
import { ResFetchMsgTotal } from "#/types/ResponseType";
import "./App.css";
import AgentControlBar from "./components/AgentControlBar";
import AgentStatusBar from "./components/AgentStatusBar";
import Terminal from "./components/Terminal";
interface Props {
setSettingOpen: (isOpen: boolean) => void;

View File

@ -1,115 +0,0 @@
import { IDisposable, Terminal as XtermTerminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import React, { useEffect, useRef } from "react";
import { VscTerminal } from "react-icons/vsc";
import { useSelector } from "react-redux";
import { FitAddon } from "xterm-addon-fit";
import Socket from "#/services/socket";
import { RootState } from "#/store";
import ActionType from "#/types/ActionType";
import ObservationType from "#/types/ObservationType";
class JsonWebsocketAddon {
_disposables: IDisposable[];
constructor() {
this._disposables = [];
}
activate(terminal: XtermTerminal) {
this._disposables.push(
terminal.onData((data) => {
const payload = JSON.stringify({ action: "terminal", data });
Socket.send(payload);
}),
);
Socket.addEventListener("message", (event) => {
const { action, args, observation, content } = JSON.parse(event.data);
if (action === ActionType.RUN) {
terminal.writeln(args.command);
}
if (observation === ObservationType.RUN) {
content.split("\n").forEach((line: string) => {
terminal.writeln(line);
});
terminal.write("\n$ ");
}
});
}
dispose() {
this._disposables.forEach((d) => d.dispose());
Socket.removeEventListener("message", () => {});
}
}
/**
* The terminal's content is set by write messages. To avoid complicated state logic,
* we keep the terminal persistently open as a child of <App /> and hidden when not in use.
*/
function Terminal(): JSX.Element {
const terminalRef = useRef<HTMLDivElement>(null);
const { commands } = useSelector((state: RootState) => state.cmd);
useEffect(() => {
const terminal = new XtermTerminal({
// This value is set to the appropriate value by the
// `fitAddon.fit()` call below.
// If not set here, the terminal does not respect the width
// of its parent element. This causes a bug where the terminal
// is too large and switching tabs causes a layout shift.
cols: 0,
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
fontSize: 14,
theme: {
background: "#262626",
},
});
terminal.write("$ ");
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(terminalRef.current as HTMLDivElement);
// Without this timeout, `fitAddon.fit()` throws the error
// "this._renderer.value is undefined"
setTimeout(() => {
fitAddon.fit();
}, 1);
const jsonWebsocketAddon = new JsonWebsocketAddon();
terminal.loadAddon(jsonWebsocketAddon);
// FIXME, temporary solution to display the terminal,
// but it will rerender the terminal every time the commands change
commands.forEach((command) => {
if (command.type === "input") {
terminal.writeln(command.content);
} else {
command.content.split("\n").forEach((line: string) => {
terminal.writeln(line);
});
terminal.write("\n$ ");
}
});
return () => {
terminal.dispose();
};
}, [commands]);
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 px-4 py-2 text-sm border-b border-neutral-600">
<VscTerminal />
Terminal
</div>
<div className="grow p-2 flex min-h-0">
<div ref={terminalRef} className="h-full w-full" />
</div>
</div>
);
}
export default Terminal;

View File

@ -0,0 +1,3 @@
import { describe } from "vitest";
describe.todo("Terminal");

View File

@ -0,0 +1,34 @@
import React from "react";
import { useSelector } from "react-redux";
import { VscTerminal } from "react-icons/vsc";
import { RootState } from "#/store";
import useXTerm from "../../hooks/useXTerm";
function Terminal() {
const { commands } = useSelector((state: RootState) => state.cmd);
const xtermRef = useXTerm({
commands,
options: {
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
fontSize: 14,
theme: {
background: "#262626",
},
},
});
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 px-4 py-2 text-sm border-b border-neutral-600">
<VscTerminal />
Terminal (read-only)
</div>
<div className="grow p-2 flex min-h-0">
<div ref={xtermRef} className="h-full w-full" />
</div>
</div>
);
}
export default Terminal;

View File

@ -0,0 +1,3 @@
import { describe } from "vitest";
describe.todo("useXTerm");

View File

@ -0,0 +1,84 @@
import { FitAddon } from "@xterm/addon-fit";
import {
ITerminalAddon,
ITerminalInitOnlyOptions,
ITerminalOptions,
Terminal,
} from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import React from "react";
type CommandType = "input" | "output";
type Command = { type: CommandType; content: string };
interface XTermProps {
options?: ITerminalOptions & ITerminalInitOnlyOptions;
commands?: Command[];
addons?: ITerminalAddon[];
}
function useXTerm({ options, commands, addons }: XTermProps) {
const [terminal, setTerminal] = React.useState<Terminal | null>(null);
const [fitAddon] = React.useState<FitAddon>(new FitAddon());
const xtermRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
/* Create a new terminal instance */
const xterm = new Terminal(options);
xterm.loadAddon(fitAddon);
addons?.forEach((addon) => xterm.loadAddon(addon));
setTerminal(xterm);
return () => {
xterm.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
/* Open the terminal in the DOM */
if (terminal && xtermRef.current) {
terminal.open(xtermRef.current);
}
}, [terminal, fitAddon]);
React.useEffect(() => {
/* Write commands to the terminal */
if (terminal && commands) {
commands.forEach((command) => {
if (command.type === "input") {
terminal.write("$ ");
terminal.write(`${command.content}\r`); // \r is needed to move the cursor to the beginning of the line to prevent tabbing the next line
} else {
terminal.writeln(command.content);
}
terminal.write("\n");
});
terminal.write("$ ");
}
}, [terminal, commands]);
React.useEffect(() => {
/* Resize the terminal when the window is resized */
let resizeObserver: ResizeObserver;
if (xtermRef.current) {
resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
});
resizeObserver.observe(xtermRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [fitAddon]);
return xtermRef;
}
export default useXTerm;