agent-ecosystem/src/renderer/components/terminal/EmbeddedTerminal.tsx

155 lines
4.3 KiB
TypeScript

import '@xterm/xterm/css/xterm.css';
import { useEffect, useRef } from 'react';
import { api } from '@renderer/api';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { Terminal } from '@xterm/xterm';
import type { PtySpawnOptions } from '@shared/types/terminal';
interface EmbeddedTerminalProps {
/** Command to run (if not provided, opens default shell) */
command?: string;
/** Arguments for the command */
args?: string[];
/** Working directory */
cwd?: string;
/** Environment variables merged into the PTY process env */
env?: Record<string, string>;
/** Callback when PTY process exits */
onExit?: (exitCode: number) => void;
/** CSS class for container */
className?: string;
}
export const EmbeddedTerminal = ({
command,
args,
cwd,
env,
onExit,
className,
}: EmbeddedTerminalProps): React.JSX.Element => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let ptyId: string | null = null;
let disposed = false;
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#141416',
foreground: '#fafafa',
cursor: '#fafafa',
selectionBackground: 'rgba(255, 255, 255, 0.2)',
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
// Clickable URLs — opens in external browser
const webLinksAddon = new WebLinksAddon((_event, uri) => {
void api.openExternal(uri);
});
term.loadAddon(webLinksAddon);
term.open(container);
// Fit after opening so dimensions are correct
const rafId = requestAnimationFrame(() => fitAddon.fit());
// Ctrl+C with selection → copy to clipboard (instead of sending SIGINT)
term.attachCustomKeyEventHandler((event) => {
if (event.type === 'keydown' && event.code === 'KeyC' && (event.ctrlKey || event.metaKey)) {
const selection = term.getSelection();
if (selection) {
void navigator.clipboard.writeText(selection);
return false; // Prevent sending to PTY
}
}
return true;
});
// User input → PTY (returns IDisposable — must dispose in cleanup)
const inputDisposable = term.onData((data) => {
if (ptyId) api.terminal.write(ptyId, data);
});
// PTY output → xterm
const unsubData = api.terminal.onData((_, id, data) => {
if (id === ptyId && !disposed) term.write(data);
});
// PTY exit
const unsubExit = api.terminal.onExit((_, id, exitCode) => {
if (id === ptyId) {
ptyId = null;
onExit?.(exitCode);
}
});
// Spawn PTY
const spawnOptions: PtySpawnOptions = {
...(command ? { command } : {}),
...(args ? { args } : {}),
...(cwd ? { cwd } : {}),
...(env ? { env } : {}),
cols: term.cols,
rows: term.rows,
};
api.terminal
.spawn(spawnOptions)
.then((id) => {
if (disposed) return;
ptyId = id;
// Send actual terminal size after spawn (fitAddon.fit() may have
// changed cols/rows via RAF after spawnOptions was constructed)
api.terminal.resize(id, term.cols, term.rows);
})
.catch((err: unknown) => {
if (disposed) return;
term.write(
`\r\n\x1b[31mFailed to start terminal: ${err instanceof Error ? err.message : String(err)}\x1b[0m\r\n`
);
});
// ResizeObserver → fitAddon.fit() → pty.resize()
const observer = new ResizeObserver(() => {
fitAddon.fit();
if (ptyId) {
api.terminal.resize(ptyId, term.cols, term.rows);
}
});
observer.observe(container);
return () => {
disposed = true;
cancelAnimationFrame(rafId);
inputDisposable.dispose();
unsubData();
unsubExit();
if (ptyId) api.terminal.kill(ptyId);
observer.disconnect();
term.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally run once on mount
}, []);
return (
<div
ref={containerRef}
className={`min-h-0 flex-1 ${className ?? ''}`}
style={{ overflow: 'hidden' }}
/>
);
};