mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 18:03:56 +00:00
feat(inspector): replace xterm.js with ghostty-web
- Replace @xterm/xterm, @xterm/addon-fit, and @xterm/addon-web-links with ghostty-web - Update Terminal component to use ghostty-web API: - Add async WASM initialization via init() - Use FitAddon with observeResize() for auto-fitting - Use onResize callback for terminal resize events - ghostty-web is API-compatible with xterm.js but uses Ghostty's WASM-compiled VT100 parser - Benefits: - Better rendering of complex scripts (Devanagari, Arabic) - XTPUSHSGR/XTPOPSGR support - Same battle-tested code as native Ghostty app Ref: https://github.com/coder/ghostty-web
This commit is contained in:
parent
ac0a22cd07
commit
1d1069d6fb
3 changed files with 128 additions and 110 deletions
|
|
@ -22,8 +22,6 @@
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"ghostty-web": "^0.3.0"
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
|
||||||
"@xterm/addon-web-links": "^0.11.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { Terminal as XTerm } from "@xterm/xterm";
|
import { init, Terminal as GhosttyTerminal, FitAddon } from "ghostty-web";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
|
||||||
|
|
||||||
export interface TerminalProps {
|
export interface TerminalProps {
|
||||||
/** WebSocket URL for terminal connection */
|
/** WebSocket URL for terminal connection */
|
||||||
|
|
@ -28,6 +25,21 @@ interface TerminalMessage {
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module-level initialization state
|
||||||
|
let ghosttyInitialized = false;
|
||||||
|
let ghosttyInitPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function ensureGhosttyInitialized(): Promise<void> {
|
||||||
|
if (ghosttyInitialized) return;
|
||||||
|
if (ghosttyInitPromise) return ghosttyInitPromise;
|
||||||
|
|
||||||
|
ghosttyInitPromise = init().then(() => {
|
||||||
|
ghosttyInitialized = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return ghosttyInitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
const Terminal = ({
|
const Terminal = ({
|
||||||
wsUrl,
|
wsUrl,
|
||||||
active = true,
|
active = true,
|
||||||
|
|
@ -37,79 +49,12 @@ const Terminal = ({
|
||||||
rows = 24,
|
rows = 24,
|
||||||
}: TerminalProps) => {
|
}: TerminalProps) => {
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
const xtermRef = useRef<XTerm | null>(null);
|
const termRef = useRef<GhosttyTerminal | null>(null);
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
// Initialize terminal
|
|
||||||
useEffect(() => {
|
|
||||||
if (!terminalRef.current) return;
|
|
||||||
|
|
||||||
const term = new XTerm({
|
|
||||||
cursorBlink: true,
|
|
||||||
fontSize: 13,
|
|
||||||
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", Menlo, Monaco, "Courier New", monospace',
|
|
||||||
theme: {
|
|
||||||
background: "#1a1a1a",
|
|
||||||
foreground: "#d4d4d4",
|
|
||||||
cursor: "#d4d4d4",
|
|
||||||
cursorAccent: "#1a1a1a",
|
|
||||||
selectionBackground: "#264f78",
|
|
||||||
black: "#000000",
|
|
||||||
red: "#cd3131",
|
|
||||||
green: "#0dbc79",
|
|
||||||
yellow: "#e5e510",
|
|
||||||
blue: "#2472c8",
|
|
||||||
magenta: "#bc3fbc",
|
|
||||||
cyan: "#11a8cd",
|
|
||||||
white: "#e5e5e5",
|
|
||||||
brightBlack: "#666666",
|
|
||||||
brightRed: "#f14c4c",
|
|
||||||
brightGreen: "#23d18b",
|
|
||||||
brightYellow: "#f5f543",
|
|
||||||
brightBlue: "#3b8eea",
|
|
||||||
brightMagenta: "#d670d6",
|
|
||||||
brightCyan: "#29b8db",
|
|
||||||
brightWhite: "#e5e5e5",
|
|
||||||
},
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fitAddon = new FitAddon();
|
|
||||||
const webLinksAddon = new WebLinksAddon();
|
|
||||||
|
|
||||||
term.loadAddon(fitAddon);
|
|
||||||
term.loadAddon(webLinksAddon);
|
|
||||||
term.open(terminalRef.current);
|
|
||||||
|
|
||||||
// Fit terminal to container
|
|
||||||
setTimeout(() => fitAddon.fit(), 0);
|
|
||||||
|
|
||||||
xtermRef.current = term;
|
|
||||||
fitAddonRef.current = fitAddon;
|
|
||||||
|
|
||||||
// Handle window resize
|
|
||||||
const handleResize = () => {
|
|
||||||
if (fitAddonRef.current && xtermRef.current) {
|
|
||||||
fitAddonRef.current.fit();
|
|
||||||
// Send resize to server
|
|
||||||
const { cols, rows } = xtermRef.current;
|
|
||||||
sendResize(cols, rows);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
term.dispose();
|
|
||||||
xtermRef.current = null;
|
|
||||||
fitAddonRef.current = null;
|
|
||||||
};
|
|
||||||
}, [cols, rows]);
|
|
||||||
|
|
||||||
// Send resize message
|
// Send resize message
|
||||||
const sendResize = useCallback((cols: number, rows: number) => {
|
const sendResize = useCallback((cols: number, rows: number) => {
|
||||||
|
|
@ -119,9 +64,95 @@ const Terminal = ({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Connect WebSocket
|
// Initialize ghostty-web and terminal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wsUrl || !xtermRef.current) return;
|
if (!terminalRef.current) return;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let term: GhosttyTerminal | null = null;
|
||||||
|
let fitAddon: FitAddon | null = null;
|
||||||
|
|
||||||
|
const initTerminal = async () => {
|
||||||
|
// Initialize WASM module
|
||||||
|
await ensureGhosttyInitialized();
|
||||||
|
|
||||||
|
if (disposed || !terminalRef.current) return;
|
||||||
|
|
||||||
|
term = new GhosttyTerminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", Menlo, Monaco, "Courier New", monospace',
|
||||||
|
theme: {
|
||||||
|
background: "#1a1a1a",
|
||||||
|
foreground: "#d4d4d4",
|
||||||
|
cursor: "#d4d4d4",
|
||||||
|
cursorAccent: "#1a1a1a",
|
||||||
|
selectionBackground: "#264f78",
|
||||||
|
black: "#000000",
|
||||||
|
red: "#cd3131",
|
||||||
|
green: "#0dbc79",
|
||||||
|
yellow: "#e5e510",
|
||||||
|
blue: "#2472c8",
|
||||||
|
magenta: "#bc3fbc",
|
||||||
|
cyan: "#11a8cd",
|
||||||
|
white: "#e5e5e5",
|
||||||
|
brightBlack: "#666666",
|
||||||
|
brightRed: "#f14c4c",
|
||||||
|
brightGreen: "#23d18b",
|
||||||
|
brightYellow: "#f5f543",
|
||||||
|
brightBlue: "#3b8eea",
|
||||||
|
brightMagenta: "#d670d6",
|
||||||
|
brightCyan: "#29b8db",
|
||||||
|
brightWhite: "#e5e5e5",
|
||||||
|
},
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
scrollback: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
fitAddon = new FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.open(terminalRef.current);
|
||||||
|
|
||||||
|
// Fit terminal to container
|
||||||
|
setTimeout(() => fitAddon?.fit(), 0);
|
||||||
|
|
||||||
|
// Enable auto-resize when container changes
|
||||||
|
fitAddon.observeResize();
|
||||||
|
|
||||||
|
// Handle terminal resize events from ghostty-web
|
||||||
|
term.onResize((size: { cols: number; rows: number }) => {
|
||||||
|
sendResize(size.cols, size.rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
termRef.current = term;
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
setInitialized(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
initTerminal().catch((err) => {
|
||||||
|
console.error("Failed to initialize ghostty-web:", err);
|
||||||
|
setError("Failed to initialize terminal");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle window resize (backup for browsers that don't trigger ResizeObserver)
|
||||||
|
const handleResize = () => {
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
term?.dispose();
|
||||||
|
termRef.current = null;
|
||||||
|
fitAddonRef.current = null;
|
||||||
|
};
|
||||||
|
}, [cols, rows, sendResize]);
|
||||||
|
|
||||||
|
// Connect WebSocket after terminal is initialized
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wsUrl || !initialized || !termRef.current) return;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
@ -130,12 +161,12 @@ const Terminal = ({
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
onConnectionChange?.(true);
|
onConnectionChange?.(true);
|
||||||
xtermRef.current?.writeln("\x1b[32m● Connected to terminal\x1b[0m\r\n");
|
termRef.current?.writeln("\x1b[32m● Connected to terminal\x1b[0m\r\n");
|
||||||
|
|
||||||
// Send initial resize
|
// Send initial resize
|
||||||
if (fitAddonRef.current && xtermRef.current) {
|
if (fitAddonRef.current && termRef.current) {
|
||||||
fitAddonRef.current.fit();
|
fitAddonRef.current.fit();
|
||||||
const { cols, rows } = xtermRef.current;
|
const { cols, rows } = termRef.current;
|
||||||
sendResize(cols, rows);
|
sendResize(cols, rows);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -147,23 +178,23 @@ const Terminal = ({
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "data":
|
case "data":
|
||||||
if (msg.data) {
|
if (msg.data) {
|
||||||
xtermRef.current?.write(msg.data);
|
termRef.current?.write(msg.data);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "exit":
|
case "exit":
|
||||||
xtermRef.current?.writeln(`\r\n\x1b[33m● Process exited with code ${msg.code ?? "unknown"}\x1b[0m`);
|
termRef.current?.writeln(`\r\n\x1b[33m● Process exited with code ${msg.code ?? "unknown"}\x1b[0m`);
|
||||||
onClose?.();
|
onClose?.();
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
setError(msg.message || "Unknown error");
|
setError(msg.message || "Unknown error");
|
||||||
xtermRef.current?.writeln(`\r\n\x1b[31m● Error: ${msg.message}\x1b[0m`);
|
termRef.current?.writeln(`\r\n\x1b[31m● Error: ${msg.message}\x1b[0m`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Handle binary data
|
// Handle binary data
|
||||||
if (event.data instanceof Blob) {
|
if (event.data instanceof Blob) {
|
||||||
event.data.text().then((text: string) => {
|
event.data.text().then((text: string) => {
|
||||||
xtermRef.current?.write(text);
|
termRef.current?.write(text);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -178,11 +209,11 @@ const Terminal = ({
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
onConnectionChange?.(false);
|
onConnectionChange?.(false);
|
||||||
xtermRef.current?.writeln("\r\n\x1b[31m● Disconnected from terminal\x1b[0m");
|
termRef.current?.writeln("\r\n\x1b[31m● Disconnected from terminal\x1b[0m");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle terminal input
|
// Handle terminal input
|
||||||
const onData = xtermRef.current.onData((data) => {
|
const onData = termRef.current.onData((data: string) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
const msg: TerminalMessage = { type: "input", data };
|
const msg: TerminalMessage = { type: "input", data };
|
||||||
ws.send(JSON.stringify(msg));
|
ws.send(JSON.stringify(msg));
|
||||||
|
|
@ -194,33 +225,14 @@ const Terminal = ({
|
||||||
ws.close();
|
ws.close();
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
};
|
};
|
||||||
}, [wsUrl, onClose, onConnectionChange, sendResize]);
|
}, [wsUrl, initialized, onClose, onConnectionChange, sendResize]);
|
||||||
|
|
||||||
// Handle container resize with ResizeObserver
|
|
||||||
useEffect(() => {
|
|
||||||
if (!terminalRef.current) return;
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (fitAddonRef.current && xtermRef.current) {
|
|
||||||
fitAddonRef.current.fit();
|
|
||||||
const { cols, rows } = xtermRef.current;
|
|
||||||
sendResize(cols, rows);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
resizeObserver.observe(terminalRef.current);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, [sendResize]);
|
|
||||||
|
|
||||||
// Focus terminal when active
|
// Focus terminal when active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active && xtermRef.current) {
|
if (active && termRef.current) {
|
||||||
xtermRef.current.focus();
|
termRef.current.focus();
|
||||||
}
|
}
|
||||||
}, [active]);
|
}, [active, initialized]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="terminal-container" style={{ height: "100%", position: "relative" }}>
|
<div className="terminal-container" style={{ height: "100%", position: "relative" }}>
|
||||||
|
|
|
||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
|
|
@ -99,6 +99,9 @@ importers:
|
||||||
|
|
||||||
frontend/packages/inspector:
|
frontend/packages/inspector:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
ghostty-web:
|
||||||
|
specifier: ^0.3.0
|
||||||
|
version: 0.3.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.469.0
|
specifier: ^0.469.0
|
||||||
version: 0.469.0(react@18.3.1)
|
version: 0.469.0(react@18.3.1)
|
||||||
|
|
@ -2572,6 +2575,9 @@ packages:
|
||||||
get-tsconfig@4.13.0:
|
get-tsconfig@4.13.0:
|
||||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||||
|
|
||||||
|
ghostty-web@0.3.0:
|
||||||
|
resolution: {integrity: sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A==}
|
||||||
|
|
||||||
github-slugger@2.0.0:
|
github-slugger@2.0.0:
|
||||||
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
||||||
|
|
||||||
|
|
@ -6787,6 +6793,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
resolve-pkg-maps: 1.0.0
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
|
ghostty-web@0.3.0: {}
|
||||||
|
|
||||||
github-slugger@2.0.0: {}
|
github-slugger@2.0.0: {}
|
||||||
|
|
||||||
glob-parent@5.1.2:
|
glob-parent@5.1.2:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue