"use client"; /** * AgentTerminal component * * Read-only terminal view for displaying orchestrator agent output. * Uses a
element with monospace font rather than xterm.js because
* this is read-only agent stdout/stderr, not an interactive PTY.
*
* Features:
* - Displays accumulated output lines with basic ANSI color rendering
* - Status badge (spinning/checkmark/X) indicating agent lifecycle
* - Header bar with agent type, status, and elapsed duration
* - Auto-scrolls to bottom as new output arrives
* - Copy-to-clipboard button for full output
*/
import { useEffect, useRef, useState, useCallback } from "react";
import type { ReactElement, CSSProperties } from "react";
import type { AgentSession, AgentStatus } from "@/hooks/useAgentStream";
// ==========================================
// Types
// ==========================================
export interface AgentTerminalProps {
/** The agent session to display */
agent: AgentSession;
/** Optional CSS class name for the outer container */
className?: string;
/** Optional inline style for the outer container */
style?: CSSProperties;
}
// ==========================================
// ANSI color strip helper
// ==========================================
// Simple ANSI escape sequence stripper — produces readable plain text for .
// We strip rather than parse for security and simplicity in read-only display.
// eslint-disable-next-line no-control-regex
const ANSI_PATTERN = /\x1b\[[0-9;]*[mGKHF]/g;
function stripAnsi(text: string): string {
return text.replace(ANSI_PATTERN, "");
}
// ==========================================
// Duration helper
// ==========================================
function formatDuration(startedAt: number, endedAt?: number): string {
const elapsed = Math.floor(((endedAt ?? Date.now()) - startedAt) / 1000);
if (elapsed < 60) return `${elapsed.toString()}s`;
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
return `${minutes.toString()}m ${seconds.toString()}s`;
}
// ==========================================
// Status indicator
// ==========================================
interface StatusIndicatorProps {
status: AgentStatus;
}
function StatusIndicator({ status }: StatusIndicatorProps): ReactElement {
const baseStyle: CSSProperties = {
display: "inline-block",
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
};
if (status === "running" || status === "spawning") {
return (
);
}
if (status === "completed") {
return (
);
}
// error
return (
);
}
// ==========================================
// Status badge
// ==========================================
interface StatusBadgeProps {
status: AgentStatus;
}
function StatusBadge({ status }: StatusBadgeProps): ReactElement {
const colorMap: Record = {
spawning: "var(--warn)",
running: "var(--success)",
completed: "var(--muted)",
error: "var(--danger)",
};
const labelMap: Record = {
spawning: "spawning",
running: "running",
completed: "completed",
error: "error",
};
return (
{labelMap[status]}
);
}
// ==========================================
// Component
// ==========================================
/**
* AgentTerminal renders accumulated agent output in a scrollable pre block.
* It is intentionally read-only — no keyboard input is accepted.
*/
export function AgentTerminal({ agent, className = "", style }: AgentTerminalProps): ReactElement {
const outputRef = useRef(null);
const [copied, setCopied] = useState(false);
const [tick, setTick] = useState(0);
// ==========================================
// Duration ticker — only runs while active
// ==========================================
useEffect(() => {
if (agent.status === "running" || agent.status === "spawning") {
const id = setInterval(() => {
setTick((t) => t + 1);
}, 1000);
return (): void => {
clearInterval(id);
};
}
return undefined;
}, [agent.status]);
// Consume tick to avoid unused-var lint
void tick;
// ==========================================
// Auto-scroll to bottom on new output
// ==========================================
useEffect(() => {
const el = outputRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [agent.outputLines]);
// ==========================================
// Copy to clipboard
// ==========================================
const handleCopy = useCallback((): void => {
const text = agent.outputLines.map(stripAnsi).join("");
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
});
}, [agent.outputLines]);
// ==========================================
// Styles
// ==========================================
const containerStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
background: "var(--bg-deep)",
overflow: "hidden",
...style,
};
const headerStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
background: "var(--bg-deep)",
};
const titleStyle: CSSProperties = {
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--text)",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const durationStyle: CSSProperties = {
fontSize: "0.65rem",
fontFamily: "var(--mono)",
color: "var(--muted)",
flexShrink: 0,
};
const outputStyle: CSSProperties = {
flex: 1,
overflow: "auto",
margin: 0,
padding: "8px 12px",
fontFamily: "var(--mono)",
fontSize: "0.75rem",
lineHeight: 1.5,
color: "var(--text)",
background: "var(--bg-deep)",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
};
const copyButtonStyle: CSSProperties = {
background: "transparent",
border: "1px solid var(--border)",
borderRadius: 3,
color: copied ? "var(--success)" : "var(--muted)",
cursor: "pointer",
fontSize: "0.65rem",
fontFamily: "var(--mono)",
padding: "2px 6px",
flexShrink: 0,
};
const duration = formatDuration(agent.startedAt, agent.endedAt);
return (
{/* Header */}
{agent.agentType}
{agent.jobId !== undefined ? ` · ${agent.jobId}` : ""}
{duration}
{/* Copy button */}
{/* Output area */}
{agent.outputLines.length === 0 ? (
{agent.status === "spawning" ? "Spawning agent..." : "Waiting for output..."}
) : (
agent.outputLines.map(stripAnsi).join("")
)}
{/* Error message overlay */}
{agent.status === "error" && agent.errorMessage !== undefined && (
Error: {agent.errorMessage}
)}
{/* Pulse animation keyframes — injected inline via style tag for zero deps */}
);
}