nandi/oripublic Fork 0
4166f8fba899b222fab287c398dcab9bf6f6e5c9
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

TerminalPane.tsx · 94 lines · 2.6 KBTypeScript Blame HistoryRaw
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday1/**
2 * TerminalPane is an interactive shell in the browser: xterm.js on the front,
3 * a real PTY on the backend, joined by the /ws/terminal WebSocket (raw output
4 * as binary frames, input and resizes as JSON — see the protocol reference).
5 *
6 * The pane is kept mounted (hidden, not unmounted) while other tabs are
7 * active, so the shell session survives tab switches.
8 *
9 * @example
10 * <TerminalPane />
11 */
12
13import { useEffect, useRef } from "react";
14import "@xterm/xterm/css/xterm.css";
15
16/** terminalWsUrl derives the terminal endpoint from the page's origin. */
17export function terminalWsUrl(): string {
18 const scheme = window.location.protocol === "https:" ? "wss" : "ws";
19 return `${scheme}://${window.location.host}/ws/terminal`;
20}
21
22export function TerminalPane() {
23 const containerRef = useRef<HTMLDivElement>(null);
24
25 useEffect(() => {
26 const container = containerRef.current;
27 if (!container) {
28 return;
29 }
30 let disposed = false;
31 let cleanup: (() => void) | undefined;
32
33 // xterm is heavy: loaded on first use only.
34 Promise.all([import("@xterm/xterm"), import("@xterm/addon-fit")]).then(
35 ([{ Terminal }, { FitAddon }]) => {
36 if (disposed) {
37 return;
38 }
39 // Always dark, IDE-style, whatever the app theme.
40 const term = new Terminal({
41 fontSize: 13,
42 cursorBlink: true,
43 theme: { background: "#1e1e1e" },
44 });
45 const fit = new FitAddon();
46 term.loadAddon(fit);
47 term.open(container);
48 fit.fit();
49
50 const socket = new WebSocket(terminalWsUrl());
51 socket.binaryType = "arraybuffer";
52 socket.onmessage = (event) =>
53 term.write(new Uint8Array(event.data as ArrayBuffer));
54 socket.onopen = () =>
55 socket.send(
56 JSON.stringify({
57 type: "resize",
58 cols: term.cols,
59 rows: term.rows,
60 }),
61 );
62 socket.onclose = () => term.write("\r\n[terminal session closed]\r\n");
63
64 const inputSubscription = term.onData((data) => {
65 if (socket.readyState === WebSocket.OPEN) {
66 socket.send(JSON.stringify({ type: "input", data }));
67 }
68 });
69 const resizeSubscription = term.onResize(({ cols, rows }) => {
70 if (socket.readyState === WebSocket.OPEN) {
71 socket.send(JSON.stringify({ type: "resize", cols, rows }));
72 }
73 });
74 const observer = new ResizeObserver(() => fit.fit());
75 observer.observe(container);
76
77 cleanup = () => {
78 observer.disconnect();
79 inputSubscription.dispose();
80 resizeSubscription.dispose();
81 socket.close();
82 term.dispose();
83 };
84 },
85 );
86
87 return () => {
88 disposed = true;
89 cleanup?.();
90 };
91 }, []);
92
93 return <div className="terminal" ref={containerRef} />;
94}