forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | /** |
| 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 | ||
| 13 | import { useEffect, useRef } from "react"; | |
| 14 | import "@xterm/xterm/css/xterm.css"; | |
| 15 | ||
| 16 | /** terminalWsUrl derives the terminal endpoint from the page's origin. */ | |
| 17 | export function terminalWsUrl(): string { | |
| 18 | const scheme = window.location.protocol === "https:" ? "wss" : "ws"; | |
| 19 | return `${scheme}://${window.location.host}/ws/terminal`; | |
| 20 | } | |
| 21 | ||
| 22 | export 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 | } |