168 lines
4.7 KiB
TypeScript
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"
|
|
/>
|
|
);
|
|
}
|