import { onBeforeUnmount } from "vue"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { Call, Events } from "@wailsio/runtime"; import "@xterm/xterm/css/xterm.css"; const SSH = "github.com/vstockwell/wraith/internal/ssh.SSHService"; /** MobaXTerm Classic–inspired terminal theme colors. */ const defaultTheme = { background: "#0d1117", foreground: "#e0e0e0", cursor: "#58a6ff", cursorAccent: "#0d1117", selectionBackground: "rgba(88, 166, 255, 0.3)", selectionForeground: "#ffffff", black: "#0d1117", red: "#f85149", green: "#3fb950", yellow: "#e3b341", blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39c5cf", white: "#e0e0e0", brightBlack: "#484f58", brightRed: "#ff7b72", brightGreen: "#56d364", brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff", brightCyan: "#56d4dd", brightWhite: "#f0f6fc", }; export interface UseTerminalReturn { terminal: Terminal; fitAddon: FitAddon; mount: (container: HTMLElement) => void; destroy: () => void; write: (data: string) => void; fit: () => void; } /** * Composable that manages an xterm.js Terminal lifecycle. * * Wires bidirectional I/O: * - User keystrokes → SSHService.Write (via Wails Call) * - SSH stdout → xterm.js (via Wails Events, base64 encoded) * - Terminal resize → SSHService.Resize (via Wails Call) */ export function useTerminal(sessionId: string): UseTerminalReturn { const fitAddon = new FitAddon(); const searchAddon = new SearchAddon(); const webLinksAddon = new WebLinksAddon(); const terminal = new Terminal({ theme: defaultTheme, fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, monospace", fontSize: 14, lineHeight: 1.2, cursorBlink: true, cursorStyle: "block", scrollback: 10000, allowProposedApi: true, convertEol: true, }); terminal.loadAddon(fitAddon); terminal.loadAddon(searchAddon); terminal.loadAddon(webLinksAddon); // Forward typed data to the SSH backend terminal.onData((data: string) => { Call.ByName(`${SSH}.Write`, sessionId, data).catch((err: unknown) => { console.error("SSH write error:", err); }); }); // Forward resize events to the SSH backend terminal.onResize((size: { cols: number; rows: number }) => { Call.ByName(`${SSH}.Resize`, sessionId, size.cols, size.rows).catch((err: unknown) => { console.error("SSH resize error:", err); }); }); // Listen for SSH output events from the Go backend (base64 encoded) let cleanupEvent: (() => void) | null = null; let resizeObserver: ResizeObserver | null = null; function mount(container: HTMLElement): void { terminal.open(container); fitAddon.fit(); // Subscribe to SSH output events for this session // Wails v3 Events.On callback receives a CustomEvent object with .data property cleanupEvent = Events.On(`ssh:data:${sessionId}`, (event: any) => { // Extract the base64 string from the event // event.data is the first argument passed to app.Event.Emit() let b64data: string; if (typeof event === "string") { b64data = event; } else if (event?.data && typeof event.data === "string") { b64data = event.data; } else if (Array.isArray(event?.data)) { b64data = String(event.data[0] ?? ""); } else { // Debug: log the actual shape so we can fix it console.warn("ssh:data unexpected shape:", JSON.stringify(event)?.slice(0, 200)); return; } try { const decoded = atob(b64data); terminal.write(decoded); } catch { // Fallback: write raw if not valid base64 terminal.write(b64data); } }); // Auto-fit when the container resizes resizeObserver = new ResizeObserver(() => { fitAddon.fit(); }); resizeObserver.observe(container); } function destroy(): void { if (cleanupEvent) { cleanupEvent(); cleanupEvent = null; } if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; } terminal.dispose(); } function write(data: string): void { terminal.write(data); } function fit(): void { fitAddon.fit(); } onBeforeUnmount(() => { destroy(); }); return { terminal, fitAddon, mount, destroy, write, fit }; }