Просмотр исходного кода

Revert "refactor(frontend): Terminal (#1315)" (#1360)

This reverts commit 27246aca7e0f3d399740db466f31026c891a5374.
Robert Brennan 1 год назад
Родитель
Сommit
a1a0767681

+ 18 - 10
frontend/package-lock.json

@@ -13,7 +13,6 @@
         "@react-types/shared": "^3.22.1",
         "@react-types/shared": "^3.22.1",
         "@reduxjs/toolkit": "^2.2.2",
         "@reduxjs/toolkit": "^2.2.2",
         "@vitejs/plugin-react": "^4.2.1",
         "@vitejs/plugin-react": "^4.2.1",
-        "@xterm/addon-fit": "^0.10.0",
         "@xterm/xterm": "^5.4.0",
         "@xterm/xterm": "^5.4.0",
         "clsx": "^2.1.0",
         "clsx": "^2.1.0",
         "eslint-config-airbnb-typescript": "^18.0.0",
         "eslint-config-airbnb-typescript": "^18.0.0",
@@ -33,7 +32,8 @@
         "react-syntax-highlighter": "^15.5.0",
         "react-syntax-highlighter": "^15.5.0",
         "tailwind-merge": "^2.2.2",
         "tailwind-merge": "^2.2.2",
         "vite": "^5.1.6",
         "vite": "^5.1.6",
-        "web-vitals": "^2.1.4"
+        "web-vitals": "^2.1.4",
+        "xterm-addon-fit": "^0.8.0"
       },
       },
       "devDependencies": {
       "devDependencies": {
         "@testing-library/jest-dom": "^6.4.2",
         "@testing-library/jest-dom": "^6.4.2",
@@ -5247,14 +5247,6 @@
       "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
       "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
       "dev": true
       "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": {
     "node_modules/@xterm/xterm": {
       "version": "5.5.0",
       "version": "5.5.0",
       "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
       "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
@@ -14268,6 +14260,22 @@
         "node": ">=0.4"
         "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": {
     "node_modules/y18n": {
       "version": "5.0.8",
       "version": "5.0.8",
       "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
       "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

+ 2 - 2
frontend/package.json

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

+ 3 - 3
frontend/src/App.tsx

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

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

@@ -0,0 +1,115 @@
+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;

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

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

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

@@ -1,34 +0,0 @@
-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;

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

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

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

@@ -1,84 +0,0 @@
-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;