Răsfoiți Sursa

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>
sp.wack 1 an în urmă
părinte
comite
27246aca7e

+ 10 - 18
frontend/package-lock.json

@@ -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",

+ 2 - 2
frontend/package.json

@@ -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",

+ 3 - 3
frontend/src/App.tsx

@@ -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;

+ 0 - 115
frontend/src/components/Terminal.tsx

@@ -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;

+ 3 - 0
frontend/src/components/terminal/Terminal.test.tsx

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

+ 34 - 0
frontend/src/components/terminal/Terminal.tsx

@@ -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;

+ 3 - 0
frontend/src/hooks/useXTerm.test.ts

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

+ 84 - 0
frontend/src/hooks/useXTerm.ts

@@ -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;