| name | terminal-streaming |
| description | xterm.js terminal integration for streaming agent output. Use when implementing AgentCard terminal display, configuring xterm addons, or handling real-time output streaming. |
Terminal Streaming
Source: xterm.js API, GitHub
Installation
npm install @xterm/xterm @xterm/addon-fit @xterm/addon-web-links
Import & CSS
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
Terminal Constructor
const terminal = new Terminal(options?: ITerminalOptions)
ITerminalOptions (Key Properties)
| Property | Type | Default | Description |
|---|
theme | ITheme | - | Color theme |
fontSize | number | 15 | Font size in pixels |
fontFamily | string | 'monospace' | Font family |
fontWeight | FontWeight | 'normal' | Normal text weight |
fontWeightBold | FontWeight | 'bold' | Bold text weight |
lineHeight | number | 1.0 | Line height multiplier |
letterSpacing | number | 0 | Pixel spacing between chars |
cursorBlink | boolean | false | Enable cursor blinking |
cursorStyle | 'block' | 'underline' | 'bar' | 'block' | Cursor style when focused |
cursorInactiveStyle | 'outline' | 'block' | 'bar' | 'underline' | 'none' | 'outline' | Cursor when unfocused |
cursorWidth | number | 1 | Bar cursor width in pixels |
scrollback | number | 1000 | Lines retained above viewport |
scrollSensitivity | number | 1 | Scroll speed multiplier |
fastScrollSensitivity | number | 5 | Scroll speed with Alt key |
smoothScrollDuration | number | 0 | Smooth scroll ms (0=instant) |
scrollOnUserInput | boolean | true | Auto-scroll on input |
disableStdin | boolean | false | Disable user input |
allowTransparency | boolean | false | Allow transparent backgrounds |
tabStopWidth | number | 8 | Tab stop size |
screenReaderMode | boolean | false | Accessibility mode |
minimumContrastRatio | number | 1 | Min color contrast (1-21) |
logLevel | LogLevel | 'info' | Logging verbosity |
allowProposedApi | boolean | false | Enable experimental APIs |
ITheme (Color Properties)
interface ITheme {
foreground?: string
background?: string
cursor?: string
cursorAccent?: string
selectionBackground?: string
selectionForeground?: string
selectionInactiveBackground?: string
scrollbarSliderBackground?: string
scrollbarSliderHoverBackground?: string
scrollbarSliderActiveBackground?: string
black?: string
red?: string
green?: string
yellow?: string
blue?: string
magenta?: string
cyan?: string
white?: string
brightBlack?: string
brightRed?: string
brightGreen?: string
brightYellow?: string
brightBlue?: string
brightMagenta?: string
brightCyan?: string
brightWhite?: string
extendedAnsi?: string[]
}
Recommended Dark Theme
const darkTheme: ITheme = {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
cursorAccent: '#1e1e1e',
selectionBackground: '#264f78',
selectionForeground: '#ffffff',
black: '#1e1e1e',
red: '#f44747',
green: '#6a9955',
yellow: '#dcdcaa',
blue: '#569cd6',
magenta: '#c586c0',
cyan: '#4ec9b0',
white: '#d4d4d4',
brightBlack: '#808080',
brightRed: '#f44747',
brightGreen: '#6a9955',
brightYellow: '#dcdcaa',
brightBlue: '#569cd6',
brightMagenta: '#c586c0',
brightCyan: '#4ec9b0',
brightWhite: '#ffffff'
}
Terminal Methods
Core Methods
terminal.open(container: HTMLElement): void
terminal.write(data: string | Uint8Array, callback?: () => void): void
terminal.writeln(data: string | Uint8Array, callback?: () => void): void
terminal.clear(): void
terminal.reset(): void
terminal.scrollToTop(): void
terminal.scrollToBottom(): void
terminal.scrollLines(amount: number): void
terminal.scrollPages(amount: number): void
terminal.scrollToLine(line: number): void
terminal.select(column: number, row: number, length: number): void
terminal.selectAll(): void
terminal.selectLines(start: number, end: number): void
terminal.clearSelection(): void
terminal.getSelection(): string
terminal.hasSelection(): boolean
terminal.focus(): void
terminal.blur(): void
terminal.dispose(): void
terminal.loadAddon(addon: ITerminalAddon): void
Properties
terminal.cols: number
terminal.rows: number
terminal.buffer: IBuffer
terminal.options: ITerminalOptions
terminal.element: HTMLElement | undefined
terminal.textarea: HTMLTextAreaElement | undefined
Events
terminal.onData((data: string) => {
})
terminal.onKey(({ key, domEvent }: { key: string; domEvent: KeyboardEvent }) => {
})
terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => {
})
terminal.onTitleChange((title: string) => {
})
terminal.onSelectionChange(() => {
})
terminal.onScroll((newPosition: number) => {
})
terminal.onCursorMove(() => {
})
terminal.onLineFeed(() => {
})
FitAddon
Auto-resize terminal to fit container.
import { FitAddon } from '@xterm/addon-fit'
const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(container)
fitAddon.fit()
const dimensions = fitAddon.proposeDimensions()
WebLinksAddon
Make URLs clickable.
import { WebLinksAddon } from '@xterm/addon-web-links'
const webLinksAddon = new WebLinksAddon(
(event: MouseEvent, uri: string) => {
window.open(uri, '_blank')
},
{
urlRegex: /https?:\/\/[^\s]+/,
hover: (event, uri, range) => { }
}
)
terminal.loadAddon(webLinksAddon)
Complete React Component
import { useEffect, useRef, useCallback } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
interface TerminalComponentProps {
agentId: string
onData?: (data: string) => void
}
export function TerminalComponent({ agentId, onData }: TerminalComponentProps) {
const containerRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const handleResize = useCallback(() => {
fitAddonRef.current?.fit()
}, [])
useEffect(() => {
if (!containerRef.current) return
const terminal = new Terminal({
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
selectionBackground: '#264f78'
},
fontSize: 12,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
cursorBlink: false,
scrollback: 10000,
disableStdin: true
})
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(webLinksAddon)
terminal.open(containerRef.current)
fitAddon.fit()
terminalRef.current = terminal
fitAddonRef.current = fitAddon
if (onData) {
terminal.onData(onData)
}
const handleOutput = (data: { agentId: string; chunk: string }) => {
if (data.agentId === agentId) {
terminal.write(data.chunk)
}
}
const unsubscribe = window.api?.onAgentOutput?.(handleOutput)
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
unsubscribe?.()
terminal.dispose()
}
}, [agentId, onData, handleResize])
const write = useCallback((data: string) => {
terminalRef.current?.write(data)
}, [])
const clear = useCallback(() => {
terminalRef.current?.clear()
}, [])
return (
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: '#1e1e1e' }}
/>
)
}
Grid Layout (2x2)
<div className="grid grid-cols-2 grid-rows-2 h-screen gap-1 p-1 bg-neutral-900">
{agents.map(agent => (
<div key={agent.id} className="relative bg-neutral-800 rounded overflow-hidden">
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-10 px-2 py-1 bg-neutral-800/90 flex items-center justify-between">
<span className="text-sm text-neutral-300">{agent.label}</span>
<StatusBadge status={agent.status} />
</div>
{/* Terminal */}
<div className="absolute inset-0 pt-8">
<TerminalComponent agentId={agent.id} />
</div>
</div>
))}
</div>
Output Buffering
Buffer output to prevent IPC flooding (100ms intervals):
class OutputBuffer {
private buffer = ''
private timer: NodeJS.Timeout | null = null
private readonly flushInterval = 100
constructor(private onFlush: (data: string) => void) {}
append(chunk: string): void {
this.buffer += chunk
if (!this.timer) {
this.timer = setTimeout(() => this.flush(), this.flushInterval)
}
}
flush(): void {
if (this.buffer) {
this.onFlush(this.buffer)
this.buffer = ''
}
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
}
const buffer = new OutputBuffer((data) => {
mainWindow.webContents.send('agent-output', { agentId, chunk: data })
})
sandbox.process.getSessionCommandLogs(session, cmdId,
(stdout) => buffer.append(stdout),
(stderr) => buffer.append(stderr)
)
Memory Management
if (terminal.buffer.active.length > 10000) {
terminal.clear()
terminal.write('[Scrollback cleared]\r\n')
}
useEffect(() => {
return () => {
terminal.dispose()
}
}, [])
useEffect(() => {
const unsubscribe = window.api.onAgentOutput(handler)
return () => unsubscribe()
}, [])
ResizeObserver Pattern (Current Implementation)
MVP uses ResizeObserver for container-aware resizing:
useEffect(() => {
const observer = new ResizeObserver(() => {
if (fitAddonRef.current) {
fitAddonRef.current.fit()
}
})
if (containerRef.current) {
observer.observe(containerRef.current)
}
return () => observer.disconnect()
}, [])
Line Ending Normalization
xterm.js needs \r\n for proper line breaks:
const normalized = data.chunk.replace(/\r?\n/g, '\r\n')
terminal.write(normalized)
Performance Target
- Latency: < 500ms from sandbox stdout to terminal display
- Buffer interval: 100ms batching
- Scrollback: 10000 lines max
- Resize debounce: Consider debouncing fit() calls during rapid resize