Files
boocode/apps/web/src/components/panes/TerminalPane.tsx

168 lines
4.7 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { WebLinksAddon } from 'xterm-addon-web-links';
import 'xterm/css/xterm.css';
import { api } from '@/api/client';
import { sendToTerminal, terminalsRegistry } from '@/lib/events';
interface Props {
sessionId: string;
paneId: string;
label: string;
}
// Minimal dark theme. xterm.js renders against its own canvas; CSS variables
// don't reach it, so we hardcode. Matches the obsidian-dark base in spirit.
const XTERM_THEME = {
background: '#0b0f14',
foreground: '#d6deeb',
cursor: '#82aaff',
selectionBackground: '#1d3b53',
black: '#011627',
red: '#ef5350',
green: '#22da6e',
yellow: '#c5e478',
blue: '#82aaff',
magenta: '#c792ea',
cyan: '#7fdbca',
white: '#d6deeb',
brightBlack: '#575656',
brightRed: '#ef5350',
brightGreen: '#22da6e',
brightYellow: '#ffeb95',
brightBlue: '#82aaff',
brightMagenta: '#c792ea',
brightCyan: '#7fdbca',
brightWhite: '#ffffff',
};
export function TerminalPane({ sessionId, paneId, label }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let disposed = false;
let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const term = new Terminal({
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace',
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
scrollback: 10_000,
theme: XTERM_THEME,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
try {
fit.fit();
} catch {
/* container not yet sized */
}
// POST start kicks the tmux window into existence before the WS upgrade.
// It's idempotent so a refresh just no-ops. Failures fall through to the
// WS handler which will also call ensureWindow.
api.terminals.start(sessionId, paneId).catch(() => {
/* surfaced by WS error if it matters */
});
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const initialCols = term.cols;
const initialRows = term.rows;
const wsUrl =
`${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` +
`?cols=${initialCols}&rows=${initialRows}`;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onmessage = (e) => {
if (typeof e.data === 'string') {
// Control frame from server (e.g. {"type":"exit","code":0}).
try {
const parsed = JSON.parse(e.data) as { type?: string; code?: number };
if (parsed.type === 'exit') {
term.write(`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`);
return;
}
} catch {
/* not JSON — fall through and write as text */
}
term.write(e.data);
} else {
term.write(new Uint8Array(e.data));
}
};
ws.onclose = () => {
if (disposed) return;
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
};
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
const fireResize = () => {
try {
fit.fit();
} catch {
return;
}
const cols = term.cols;
const rows = term.rows;
api.terminals.resize(sessionId, paneId, cols, rows).catch(() => {
/* transient — next resize will catch up */
});
};
const ro = new ResizeObserver(() => {
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
resizeDebounceTimer = setTimeout(fireResize, 100);
});
ro.observe(container);
const unregister = terminalsRegistry.register(paneId, label);
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
if (pane_id !== paneId) return;
if (ws.readyState !== WebSocket.OPEN) return;
const payload = text.endsWith('\n') ? text : `${text}\n`;
ws.send(payload);
});
return () => {
disposed = true;
unsubscribe();
unregister();
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
ro.disconnect();
try {
ws.close();
} catch {
/* ignore */
}
wsRef.current = null;
term.dispose();
};
}, [sessionId, paneId, label]);
return (
<div
ref={containerRef}
className="w-full h-full bg-[#0b0f14] overflow-hidden"
data-testid="terminal-pane"
/>
);
}