feat(web): integrate xterm.js with WebSocket terminal backend (#518)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #518.
This commit is contained in:
2026-02-26 02:55:53 +00:00
committed by jason.woltje
parent 8128eb7fbe
commit 417c6ab49c
9 changed files with 1694 additions and 100 deletions

View File

@@ -0,0 +1,195 @@
/**
* @file TerminalPanel.test.tsx
* @description Unit tests for the TerminalPanel component
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ReactElement } from "react";
// ==========================================
// Mocks
// ==========================================
// Mock XTerminal to avoid xterm.js DOM dependencies in panel tests
vi.mock("./XTerminal", () => ({
XTerminal: vi.fn(({ token, isVisible }: { token: string; isVisible: boolean }) => (
<div
data-testid="mock-xterminal"
data-token={token}
data-visible={isVisible ? "true" : "false"}
/>
)),
}));
import { TerminalPanel } from "./TerminalPanel";
// ==========================================
// Tests
// ==========================================
describe("TerminalPanel", () => {
const onClose = vi.fn();
const onTabChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
// ==========================================
// Rendering
// ==========================================
describe("rendering", () => {
it("renders the terminal panel", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("region", { name: "Terminal panel" })).toBeInTheDocument();
});
it("renders with height 280 when open", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const panel = screen.getByRole("region", { name: "Terminal panel" });
expect(panel).toHaveStyle({ height: "280px" });
});
it("renders with height 0 when closed", () => {
const { container } = render(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
);
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
expect(panel).toHaveStyle({ height: "0px" });
});
it("passes isVisible=true to XTerminal when open", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const xterm = screen.getByTestId("mock-xterminal");
expect(xterm).toHaveAttribute("data-visible", "true");
});
it("passes isVisible=false to XTerminal when closed", () => {
const { container } = render(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
);
// Use container query since the element is inside an aria-hidden region
const xterm = container.querySelector('[data-testid="mock-xterminal"]');
expect(xterm).toHaveAttribute("data-visible", "false");
});
it("passes token to XTerminal", () => {
render(
(<TerminalPanel open={true} onClose={onClose} token="my-auth-token" />) as ReactElement
);
const xterm = screen.getByTestId("mock-xterminal");
expect(xterm).toHaveAttribute("data-token", "my-auth-token");
});
});
// ==========================================
// Tab bar
// ==========================================
describe("tab bar", () => {
it("renders default tabs when none provided", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tab", { name: "main" })).toBeInTheDocument();
});
it("renders custom tabs", () => {
const tabs = [
{ id: "tab1", label: "Terminal 1" },
{ id: "tab2", label: "Terminal 2" },
];
render(
(
<TerminalPanel open={true} onClose={onClose} tabs={tabs} token="test-token" />
) as ReactElement
);
expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument();
});
it("marks the active tab as selected", () => {
const tabs = [
{ id: "tab1", label: "Tab 1" },
{ id: "tab2", label: "Tab 2" },
];
render(
(
<TerminalPanel
open={true}
onClose={onClose}
tabs={tabs}
activeTab="tab2"
token="test-token"
/>
) as ReactElement
);
const tab2 = screen.getByRole("tab", { name: "Tab 2" });
expect(tab2).toHaveAttribute("aria-selected", "true");
});
it("calls onTabChange when a tab is clicked", () => {
const tabs = [
{ id: "tab1", label: "Tab 1" },
{ id: "tab2", label: "Tab 2" },
];
render(
(
<TerminalPanel
open={true}
onClose={onClose}
tabs={tabs}
onTabChange={onTabChange}
token="test-token"
/>
) as ReactElement
);
fireEvent.click(screen.getByRole("tab", { name: "Tab 2" }));
expect(onTabChange).toHaveBeenCalledWith("tab2");
});
});
// ==========================================
// Close button
// ==========================================
describe("close button", () => {
it("renders the close button", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument();
});
it("calls onClose when close button is clicked", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("button", { name: "Close terminal" }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
// ==========================================
// Accessibility
// ==========================================
describe("accessibility", () => {
it("has aria-hidden=true when closed", () => {
const { container } = render(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
);
// When aria-hidden=true, testing-library role queries ignore the element's aria-label.
// Use a direct DOM query to verify the attribute.
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
expect(panel).toHaveAttribute("aria-hidden", "true");
});
it("has aria-hidden=false when open", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const panel = screen.getByRole("region", { name: "Terminal panel" });
expect(panel).toHaveAttribute("aria-hidden", "false");
});
it("has tablist role on the tab bar", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tablist")).toBeInTheDocument();
});
});
});

View File

@@ -1,9 +1,18 @@
import type { ReactElement, CSSProperties } from "react";
"use client";
export interface TerminalLine {
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
content: string;
}
/**
* TerminalPanel
*
* Shell panel that wraps the XTerminal component with a tab bar and close button.
* Replaces the former mock terminal with a real xterm.js PTY terminal.
*/
import type { ReactElement, CSSProperties } from "react";
import { XTerminal } from "./XTerminal";
// ==========================================
// Types (retained for backwards compatibility)
// ==========================================
export interface TerminalTab {
id: string;
@@ -16,51 +25,16 @@ export interface TerminalPanelProps {
tabs?: TerminalTab[];
activeTab?: string;
onTabChange?: (id: string) => void;
lines?: TerminalLine[];
/** Authentication token for the WebSocket connection */
token?: string;
className?: string;
}
const defaultTabs: TerminalTab[] = [
{ id: "main", label: "main" },
{ id: "build", label: "build" },
{ id: "logs", label: "logs" },
];
const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
const blinkKeyframes = `
@keyframes ms-terminal-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`;
let blinkStyleInjected = false;
function ensureBlinkStyle(): void {
if (blinkStyleInjected || typeof document === "undefined") return;
const styleEl = document.createElement("style");
styleEl.textContent = blinkKeyframes;
document.head.appendChild(styleEl);
blinkStyleInjected = true;
}
function getLineColor(type: TerminalLine["type"]): string {
switch (type) {
case "prompt":
return "var(--success)";
case "command":
return "var(--text-2)";
case "output":
return "var(--muted)";
case "error":
return "var(--danger)";
case "warning":
return "var(--warn)";
case "success":
return "var(--success)";
default:
return "var(--muted)";
}
}
// ==========================================
// Component
// ==========================================
export function TerminalPanel({
open,
@@ -68,11 +42,9 @@ export function TerminalPanel({
tabs,
activeTab,
onTabChange,
lines = [],
token = "",
className = "",
}: TerminalPanelProps): ReactElement {
ensureBlinkStyle();
const resolvedTabs = tabs ?? defaultTabs;
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
@@ -109,21 +81,10 @@ export function TerminalPanel({
const bodyStyle: CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "10px 16px",
fontFamily: "var(--mono)",
fontSize: "0.78rem",
lineHeight: 1.6,
};
const cursorStyle: CSSProperties = {
display: "inline-block",
width: 7,
height: 14,
background: "var(--success)",
marginLeft: 2,
animation: "ms-terminal-blink 1s step-end infinite",
verticalAlign: "text-bottom",
overflow: "hidden",
display: "flex",
flexDirection: "column",
minHeight: 0,
};
return (
@@ -208,7 +169,7 @@ export function TerminalPanel({
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
{/* Close icon — simple X using SVG */}
{/* Close icon */}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M1 1L11 11M11 1L1 11"
@@ -221,36 +182,9 @@ export function TerminalPanel({
</div>
</div>
{/* Body */}
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
{lines.map((line, index) => {
const isLast = index === lines.length - 1;
const lineStyle: CSSProperties = {
display: "flex",
gap: 8,
};
const contentStyle: CSSProperties = {
color: getLineColor(line.type),
};
return (
<div key={index} style={lineStyle}>
<span style={contentStyle}>
{line.content}
{isLast && <span aria-hidden="true" style={cursorStyle} />}
</span>
</div>
);
})}
{/* Show cursor even when no lines */}
{lines.length === 0 && (
<div style={{ display: "flex", gap: 8 }}>
<span style={{ color: "var(--success)" }}>
<span aria-hidden="true" style={cursorStyle} />
</span>
</div>
)}
{/* Terminal body */}
<div style={bodyStyle}>
<XTerminal token={token} isVisible={open} style={{ flex: 1, minHeight: 0 }} />
</div>
</div>
);

View File

@@ -0,0 +1,227 @@
/**
* @file XTerminal.test.tsx
* @description Unit tests for the XTerminal component
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import type { ReactElement } from "react";
// ==========================================
// Mocks — set up before importing components
// ==========================================
// Mock socket.io-client
vi.mock("socket.io-client");
// Mock lib/config
vi.mock("@/lib/config", () => ({
API_BASE_URL: "http://localhost:3001",
}));
// Mock xterm packages — they require a DOM canvas not available in jsdom
const mockTerminalDispose = vi.fn();
const mockTerminalWrite = vi.fn();
const mockTerminalClear = vi.fn();
const mockTerminalOpen = vi.fn();
const mockOnData = vi.fn((_handler: (data: string) => void) => ({ dispose: vi.fn() }));
const mockLoadAddon = vi.fn();
let mockTerminalCols = 80;
let mockTerminalRows = 24;
const MockTerminal = vi.fn(function MockTerminalConstructor(
this: Record<string, unknown>,
_options: unknown
) {
this.open = mockTerminalOpen;
this.loadAddon = mockLoadAddon;
this.onData = mockOnData;
this.write = mockTerminalWrite;
this.clear = mockTerminalClear;
this.dispose = mockTerminalDispose;
this.options = {};
Object.defineProperty(this, "cols", {
get: () => mockTerminalCols,
configurable: true,
});
Object.defineProperty(this, "rows", {
get: () => mockTerminalRows,
configurable: true,
});
});
const mockFitAddonFit = vi.fn();
const MockFitAddon = vi.fn(function MockFitAddonConstructor(this: Record<string, unknown>) {
this.fit = mockFitAddonFit;
});
const MockWebLinksAddon = vi.fn(function MockWebLinksAddonConstructor(
this: Record<string, unknown>
) {
// no-op
});
vi.mock("@xterm/xterm", () => ({
Terminal: MockTerminal,
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: MockFitAddon,
}));
vi.mock("@xterm/addon-web-links", () => ({
WebLinksAddon: MockWebLinksAddon,
}));
// Mock the CSS import
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
// Mock useTerminal hook
const mockCreateSession = vi.fn();
const mockSendInput = vi.fn();
const mockResize = vi.fn();
const mockCloseSession = vi.fn();
let mockIsConnected = false;
let mockSessionId: string | null = null;
vi.mock("@/hooks/useTerminal", () => ({
useTerminal: vi.fn(() => ({
isConnected: mockIsConnected,
sessionId: mockSessionId,
createSession: mockCreateSession,
sendInput: mockSendInput,
resize: mockResize,
closeSession: mockCloseSession,
connectionError: null,
})),
}));
// Mock ResizeObserver
const mockObserve = vi.fn();
const mockUnobserve = vi.fn();
const mockDisconnect = vi.fn();
vi.stubGlobal(
"ResizeObserver",
vi.fn(function MockResizeObserver(this: Record<string, unknown>, _callback: unknown) {
this.observe = mockObserve;
this.unobserve = mockUnobserve;
this.disconnect = mockDisconnect;
})
);
// Mock MutationObserver
const mockMutationObserve = vi.fn();
const mockMutationDisconnect = vi.fn();
vi.stubGlobal(
"MutationObserver",
vi.fn(function MockMutationObserver(this: Record<string, unknown>, _callback: unknown) {
this.observe = mockMutationObserve;
this.disconnect = mockMutationDisconnect;
})
);
// ==========================================
// Import component after mocks are set up
// ==========================================
import { XTerminal } from "./XTerminal";
// ==========================================
// Tests
// ==========================================
describe("XTerminal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsConnected = false;
mockSessionId = null;
mockTerminalCols = 80;
mockTerminalRows = 24;
});
afterEach(() => {
vi.clearAllMocks();
});
// ==========================================
// Rendering
// ==========================================
describe("rendering", () => {
it("renders the terminal container", () => {
render((<XTerminal token="test-token" />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
});
it("renders the xterm viewport div", () => {
render((<XTerminal token="test-token" />) as ReactElement);
expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument();
});
it("applies the className prop to the container", () => {
render((<XTerminal token="test-token" className="custom-class" />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class");
});
it("shows connecting message when not connected", () => {
mockIsConnected = false;
render((<XTerminal token="test-token" />) as ReactElement);
expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument();
});
it("does not show connecting message when connected", async () => {
mockIsConnected = true;
const { useTerminal } = await import("@/hooks/useTerminal");
vi.mocked(useTerminal).mockReturnValue({
isConnected: true,
sessionId: "session-xyz",
createSession: mockCreateSession,
sendInput: mockSendInput,
resize: mockResize,
closeSession: mockCloseSession,
connectionError: null,
});
render((<XTerminal token="test-token" />) as ReactElement);
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
});
});
// ==========================================
// useTerminal integration
// ==========================================
describe("useTerminal integration", () => {
it("passes the token to useTerminal", async () => {
const { useTerminal } = await import("@/hooks/useTerminal");
render((<XTerminal token="my-auth-token" />) as ReactElement);
expect(vi.mocked(useTerminal)).toHaveBeenCalledWith(
expect.objectContaining({ token: "my-auth-token" })
);
});
it("passes onOutput, onExit, onError callbacks to useTerminal", async () => {
const { useTerminal } = await import("@/hooks/useTerminal");
render((<XTerminal token="test-token" />) as ReactElement);
const callArgs = vi.mocked(useTerminal).mock.calls[0]?.[0];
expect(typeof callArgs?.onOutput).toBe("function");
expect(typeof callArgs?.onExit).toBe("function");
expect(typeof callArgs?.onError).toBe("function");
});
});
// ==========================================
// Accessibility
// ==========================================
describe("accessibility", () => {
it("has an accessible region role", () => {
render((<XTerminal token="test-token" />) as ReactElement);
expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,427 @@
"use client";
/**
* XTerminal component
*
* Renders a real xterm.js terminal connected to the backend /terminal WebSocket namespace.
* Handles resize, copy/paste, theme, and session lifecycle.
*/
import { useEffect, useRef, useCallback, useState } from "react";
import type { ReactElement, CSSProperties } from "react";
import "@xterm/xterm/css/xterm.css";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { FitAddon as XFitAddon } from "@xterm/addon-fit";
import { useTerminal } from "@/hooks/useTerminal";
// ==========================================
// Types
// ==========================================
export interface XTerminalProps {
/** Authentication token for the WebSocket connection */
token: string;
/** Optional CSS class name for the outer container */
className?: string;
/** Optional inline styles for the outer container */
style?: CSSProperties;
/** Whether the terminal is visible (used to trigger re-fit) */
isVisible?: boolean;
}
// ==========================================
// Theme helpers
// ==========================================
/**
* Read a CSS variable value from :root via computed styles.
* Falls back to the provided default value if not available (e.g., during SSR).
*/
function getCssVar(varName: string, fallback: string): string {
if (typeof document === "undefined") return fallback;
const value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
return value.length > 0 ? value : fallback;
}
/**
* Build an xterm.js ITheme object from the current design system CSS variables.
* All 5 themes (dark, light, aurora, midnight, sunlit, ocean) update --ms-* primitives
* which flow through to the semantic aliases we read here.
*/
function buildXtermTheme(): Record<string, string> {
return {
background: getCssVar("--bg-deep", "#080b12"),
foreground: getCssVar("--text", "#eef3ff"),
cursor: getCssVar("--success", "#14b8a6"),
cursorAccent: getCssVar("--bg-deep", "#080b12"),
selectionBackground: `${getCssVar("--primary", "#2f80ff")}40`,
selectionForeground: getCssVar("--text", "#eef3ff"),
selectionInactiveBackground: `${getCssVar("--muted", "#8f9db7")}30`,
// Standard ANSI colors mapped to design system
black: getCssVar("--bg-deep", "#080b12"),
red: getCssVar("--danger", "#e5484d"),
green: getCssVar("--success", "#14b8a6"),
yellow: getCssVar("--warn", "#f59e0b"),
blue: getCssVar("--primary", "#2f80ff"),
magenta: getCssVar("--purple", "#8b5cf6"),
cyan: "#06b6d4",
white: getCssVar("--text-2", "#c5d0e6"),
brightBlack: getCssVar("--muted", "#8f9db7"),
brightRed: "#f06a6f",
brightGreen: "#2dd4bf",
brightYellow: "#fbbf24",
brightBlue: "#56a0ff",
brightMagenta: "#a78bfa",
brightCyan: "#22d3ee",
brightWhite: getCssVar("--text", "#eef3ff"),
};
}
// ==========================================
// Component
// ==========================================
/**
* XTerminal renders a real PTY terminal powered by xterm.js,
* connected to the backend /terminal WebSocket namespace.
*/
export function XTerminal({
token,
className = "",
style,
isVisible = true,
}: XTerminalProps): ReactElement {
const containerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<XFitAddon | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const isTerminalMountedRef = useRef(false);
const [hasExited, setHasExited] = useState(false);
const [exitCode, setExitCode] = useState<number | null>(null);
// ==========================================
// Terminal session callbacks
// ==========================================
const handleOutput = useCallback((sessionId: string, data: string): void => {
void sessionId; // sessionId is single-session in this component
terminalRef.current?.write(data);
}, []);
const handleExit = useCallback((event: { sessionId: string; exitCode: number }): void => {
void event.sessionId;
setHasExited(true);
setExitCode(event.exitCode);
const term = terminalRef.current;
if (term) {
term.write(`\r\n\x1b[33m[Process exited with code ${event.exitCode.toString()}]\x1b[0m\r\n`);
}
}, []);
const handleError = useCallback((message: string): void => {
const term = terminalRef.current;
if (term) {
term.write(`\r\n\x1b[31m[Error: ${message}]\x1b[0m\r\n`);
}
}, []);
const { isConnected, sessionId, createSession, sendInput, resize, closeSession } = useTerminal({
token,
onOutput: handleOutput,
onExit: handleExit,
onError: handleError,
});
// ==========================================
// Fit helper
// ==========================================
const fitAndResize = useCallback((): void => {
const fitAddon = fitAddonRef.current;
const terminal = terminalRef.current;
if (!fitAddon || !terminal) return;
try {
fitAddon.fit();
resize(terminal.cols, terminal.rows);
} catch {
// Ignore fit errors (e.g., when container has zero dimensions)
}
}, [resize]);
// ==========================================
// Mount xterm.js terminal (client-only)
// ==========================================
useEffect(() => {
if (!containerRef.current || isTerminalMountedRef.current) return;
let cancelled = false;
const mountTerminal = async (): Promise<void> => {
// Dynamic imports ensure DOM-dependent xterm.js modules are never loaded server-side
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-web-links"),
]);
if (cancelled || !containerRef.current) return;
const theme = buildXtermTheme();
const terminal = new Terminal({
fontFamily: "var(--mono, 'Fira Code', 'Cascadia Code', monospace)",
fontSize: 13,
lineHeight: 1.4,
cursorBlink: true,
cursorStyle: "block",
scrollback: 10000,
theme,
allowTransparency: false,
convertEol: true,
// Accessibility
screenReaderMode: false,
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(containerRef.current);
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
isTerminalMountedRef.current = true;
// Initial fit
try {
fitAddon.fit();
} catch {
// Container might not have dimensions yet
}
// Set up ResizeObserver for automatic re-fitting
const observer = new ResizeObserver(() => {
fitAndResize();
});
observer.observe(containerRef.current);
resizeObserverRef.current = observer;
};
void mountTerminal();
return (): void => {
cancelled = true;
};
}, []);
// ==========================================
// Re-fit when visibility changes
// ==========================================
useEffect(() => {
if (isVisible) {
// Small delay allows CSS transitions to complete before fitting
const id = setTimeout(fitAndResize, 50);
return (): void => {
clearTimeout(id);
};
}
return undefined;
}, [isVisible, fitAndResize]);
// ==========================================
// Wire terminal input → sendInput
// ==========================================
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) return;
const disposable = terminal.onData((data: string): void => {
sendInput(data);
});
return (): void => {
disposable.dispose();
};
}, [sendInput]);
// ==========================================
// Create PTY session when connected
// ==========================================
useEffect(() => {
if (!isConnected || sessionId !== null) return;
const terminal = terminalRef.current;
const fitAddon = fitAddonRef.current;
let cols = 80;
let rows = 24;
if (terminal && fitAddon) {
try {
fitAddon.fit();
cols = terminal.cols;
rows = terminal.rows;
} catch {
// Use defaults
}
}
createSession({ cols, rows });
}, [isConnected, sessionId, createSession]);
// ==========================================
// Update xterm theme when data-theme attribute changes
// ==========================================
useEffect(() => {
const observer = new MutationObserver(() => {
const terminal = terminalRef.current;
if (terminal) {
terminal.options.theme = buildXtermTheme();
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return (): void => {
observer.disconnect();
};
}, []);
// ==========================================
// Cleanup on unmount
// ==========================================
useEffect(() => {
return (): void => {
// Cleanup ResizeObserver
resizeObserverRef.current?.disconnect();
resizeObserverRef.current = null;
// Close PTY session
closeSession();
// Dispose xterm terminal
terminalRef.current?.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
isTerminalMountedRef.current = false;
};
}, [closeSession]);
// ==========================================
// Render
// ==========================================
const containerStyle: CSSProperties = {
flex: 1,
overflow: "hidden",
position: "relative",
backgroundColor: "var(--bg-deep)",
...style,
};
return (
<div
className={className}
style={containerStyle}
role="region"
aria-label="Terminal"
data-testid="xterminal-container"
>
{/* Status bar */}
{!isConnected && !hasExited && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
padding: "4px 12px",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--warn)",
backgroundColor: "var(--bg-deep)",
zIndex: 10,
borderBottom: "1px solid var(--border)",
}}
>
Connecting to terminal...
</div>
)}
{/* Exit overlay */}
{hasExited && (
<div
style={{
position: "absolute",
bottom: 8,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
zIndex: 10,
}}
>
<button
style={{
padding: "4px 12px",
borderRadius: "4px",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--text)",
backgroundColor: "var(--surface)",
border: "1px solid var(--border)",
cursor: "pointer",
}}
onClick={(): void => {
setHasExited(false);
setExitCode(null);
if (isConnected) {
const terminal = terminalRef.current;
const fitAddon = fitAddonRef.current;
let cols = 80;
let rows = 24;
if (terminal && fitAddon) {
try {
cols = terminal.cols;
rows = terminal.rows;
} catch {
// Use defaults
}
}
terminal?.clear();
createSession({ cols, rows });
}
}}
>
Restart terminal {exitCode !== null ? `(exit ${exitCode.toString()})` : ""}
</button>
</div>
)}
{/* xterm.js render target */}
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
padding: "4px 8px",
boxSizing: "border-box",
}}
data-testid="xterm-viewport"
/>
</div>
);
}

View File

@@ -1,2 +1,4 @@
export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel";
export type { TerminalTab, TerminalPanelProps } from "./TerminalPanel";
export { TerminalPanel } from "./TerminalPanel";
export type { XTerminalProps } from "./XTerminal";
export { XTerminal } from "./XTerminal";