diff --git a/apps/web/package.json b/apps/web/package.json
index f4df651..7ce1534 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -33,6 +33,9 @@
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@types/dompurify": "^3.2.0",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-web-links": "^0.12.0",
+ "@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.5.3",
"better-auth": "^1.4.17",
"date-fns": "^4.1.0",
diff --git a/apps/web/src/components/terminal/TerminalPanel.test.tsx b/apps/web/src/components/terminal/TerminalPanel.test.tsx
new file mode 100644
index 0000000..3ee9d83
--- /dev/null
+++ b/apps/web/src/components/terminal/TerminalPanel.test.tsx
@@ -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 }) => (
+
+ )),
+}));
+
+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(() as ReactElement);
+ expect(screen.getByRole("region", { name: "Terminal panel" })).toBeInTheDocument();
+ });
+
+ it("renders with height 280 when open", () => {
+ render(() 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(
+ () 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(() 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(
+ () 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(
+ () 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(() 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(
+ (
+
+ ) 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(
+ (
+
+ ) 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(
+ (
+
+ ) 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(() as ReactElement);
+ expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument();
+ });
+
+ it("calls onClose when close button is clicked", () => {
+ render(() 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(
+ () 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(() 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(() as ReactElement);
+ expect(screen.getByRole("tablist")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/src/components/terminal/TerminalPanel.tsx b/apps/web/src/components/terminal/TerminalPanel.tsx
index 3f0ac80..8da853a 100644
--- a/apps/web/src/components/terminal/TerminalPanel.tsx
+++ b/apps/web/src/components/terminal/TerminalPanel.tsx
@@ -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 */}