ADR-009: Use xterm.js for terminal Emulation
Date: 2025-10-06 Status: Accepted Deciders: Development Team Tags: terminal, ui, components
Context​
The IDE requires a terminal emulator with:
- Full VT100/ANSI escape sequence support
- Unicode and emoji rendering
- Performance for high-throughput output
- Addons for features (fit, search, links)
- Theme support matching editor
- WebGL rendering option
Decision​
We will use xterm.js 5.3 with the Fit addon for terminal emulation.
Rationale​
Why xterm.js​
- Industry Standard: Powers VS Code, Hyper, theia terminals
- Feature Complete: Full terminal emulation (VT100, xterm-256color)
- Performance: WebGL renderer, virtual scrolling
- Addons: Fit, Search, WebLinks, Unicode11
- Active Development: Regular updates, Microsoft-backed
- Customizable: Full theme control, font options
- Accessibility: Screen reader support
Key Features​
- True Colors: 24-bit color support
- Ligatures: Programming font ligatures
- Buffer: Unlimited scrollback
- Selection: Mouse and keyboard selection
- Links: Clickable URLs and file paths
- Search: Built-in search addon
Alternatives Considered​
Termynal​
- Pros: Lightweight, simple
- Cons: Not a real terminal, just animations
- Rejected: Need actual terminal emulation
term.js (deprecated)​
- Pros: Simpler than xterm.js
- Cons: No longer maintained, replaced by xterm.js
- Rejected: Deprecated library
terminal Kit​
- Pros: Node.js native
- Cons: Not browser-compatible
- Rejected: Need browser-based solution
Custom terminal​
- Pros: Full control, minimal bundle
- Cons: Years of development, complex escape sequences
- Rejected: Not feasible for full terminal emulation
Consequences​
Positive​
- Full terminal compatibility
- Excellent performance
- VS Code-like terminal experience
- Rich addon ecosystem
- WebGL acceleration option
- Good documentation
Negative​
- ~100KB min+gzip bundle size
- Requires addons for some features
- Font loading affects initial render
- WebGL renderer may have edge cases
Neutral​
- Need to handle PTY in backend
- Theme must be manually synced
- Scrollback management required
Implementation​
Basic Setup​
// src/components/terminal/Xterminal.tsx
import { useEffect, useRef } from 'react';
import { terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { WebLinksAddon } from 'xterm-addon-web-links';
import { SearchAddon } from 'xterm-addon-search';
import { Unicode11Addon } from 'xterm-addon-unicode11';
import 'xterm/css/xterm.css';
export default function Xterminal() {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
useEffect(() => {
if (!terminalRef.current || xtermRef.current) return;
// Create terminal
const term = new XTerm({
cursorBlink: true,
cursorStyle: 'block',
fontSize: 14,
fontFamily: 'Fira Code, Monaco, Consolas, monospace',
fontWeight: 400,
fontWeightBold: 700,
lineHeight: 1.2,
letterSpacing: 0,
theme: {
background: '#1e1e1e',
foreground: '#cccccc',
cursor: '#ffffff',
},
scrollback: 10000,
allowTransparency: false,
convertEol: true,
});
// Load addons
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
const searchAddon = new SearchAddon();
const unicode11Addon = new Unicode11Addon();
term.loadAddon(fitAddon);
term.loadAddon(webLinksAddon);
term.loadAddon(searchAddon);
term.loadAddon(unicode11Addon);
// Activate unicode
term.unicode.activeVersion = '11';
// Open terminal
term.open(terminalRef.current);
// Fit to container
setTimeout(() => {
try {
fitAddon.fit();
} catch (err) {
console.warn('terminal fit failed:', err);
}
}, 0);
// Handle input
term.onData((data) => {
// Send to PTY backend
terminalService.write(data);
});
// Handle resize
window.addEventListener('resize', () => {
try {
fitAddon.fit();
} catch (err) {
console.warn('terminal resize failed:', err);
}
});
xtermRef.current = term;
fitAddonRef.current = fitAddon;
return () => {
term.dispose();
};
}, []);
return (
<Box h="100%" bg="terminal.bg">
<div ref={terminalRef} style={{ height: '100%', width: '100%' }} />
</Box>
);
}
terminal Service Integration​
// src/services/terminal-service.ts
class terminalService {
private terminal: XTerm | null = null;
private socket: WebSocket | null = null;
connect(terminal: XTerm): void {
this.terminal = terminal;
// Connect to backend PTY via WebSocket
this.socket = new WebSocket('ws://localhost:3001/terminal');
this.socket.onopen = () => {
console.log('terminal connected');
};
this.socket.onmessage = (event) => {
// Write output to terminal
this.terminal?.write(event.data);
};
this.socket.onerror = (error) => {
console.error('terminal error:', error);
this.terminal?.writeln('\r\n\x1b[31mConnection error\x1b[0m');
};
}
write(data: string): void {
this.socket?.send(data);
}
resize(cols: number, rows: number): void {
this.socket?.send(JSON.stringify({
type: 'resize',
cols,
rows
}));
}
dispose(): void {
this.socket?.close();
}
}
export const terminalService = new terminalService();
Theme Integration​
// src/hooks/useterminalTheme.ts
import { useColorMode } from '@chakra-ui/react';
import { useEffect } from 'react';
import type { ITheme } from 'xterm';
export function useterminalTheme(terminal: XTerm | null) {
const { colorMode } = useColorMode();
useEffect(() => {
if (!terminal) return;
const darkTheme: ITheme = {
background: '#1e1e1e',
foreground: '#cccccc',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selectionBackground: '#264f78',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5',
};
const lightTheme: ITheme = {
background: '#ffffff',
foreground: '#333333',
cursor: '#000000',
cursorAccent: '#ffffff',
selectionBackground: '#add6ff',
// ... light theme colors
};
terminal.options.theme = colorMode === 'dark' ? darkTheme : lightTheme;
}, [terminal, colorMode]);
}
Search Integration​
// src/components/terminal/terminalSearch.tsx
import { SearchAddon } from 'xterm-addon-search';
export function useterminalSearch(terminal: XTerm | null) {
const searchAddonRef = useRef<SearchAddon | null>(null);
useEffect(() => {
if (!terminal || searchAddonRef.current) return;
const searchAddon = new SearchAddon();
terminal.loadAddon(searchAddon);
searchAddonRef.current = searchAddon;
}, [terminal]);
const search = useCallback((query: string) => {
searchAddonRef.current?.findNext(query, {
caseSensitive: false,
wholeWord: false,
regex: false,
});
}, []);
return { search };
}
Multi-Session Support​
// src/components/terminal/Sessionterminal.tsx
export function Sessionterminal({ sessionId }: { sessionId: string }) {
const terminalRef = useRef<XTerm | null>(null);
const session = useSession();
useEffect(() => {
// Create terminal for this session
const term = createterminal();
// Restore session terminal state
const { buffer, cwd } = session.terminalState;
term.write(buffer);
// Save state on changes
term.onData(() => {
updateSessionterminalState(sessionId, {
buffer: term.buffer.active.getLine(0)?.translateToString() || '',
cwd: getCurrentWorkingDirectory(),
});
});
terminalRef.current = term;
return () => term.dispose();
}, [sessionId]);
return <Xterminal terminal={terminalRef.current} />;
}
WebGL Renderer (Optional)​
// Enable WebGL renderer for better performance
import { WebglAddon } from 'xterm-addon-webgl';
const webglAddon = new WebglAddon();
term.loadAddon(webglAddon);
// Handle fallback if WebGL not supported
webglAddon.onContextLoss(() => {
webglAddon.dispose();
console.warn('WebGL context lost, falling back to canvas renderer');
});
Clipboard Integration​
// Enable clipboard operations
term.attachCustomKeyEventHandler((event) => {
if (event.ctrlKey && event.key === 'c' && term.hasSelection()) {
const selection = term.getSelection();
navigator.clipboard.writeText(selection);
return false;
}
if (event.ctrlKey && event.key === 'v') {
navigator.clipboard.readText().then((text) => {
term.paste(text);
});
return false;
}
return true;
});
Performance Optimization​
Buffer Management​
// Limit scrollback to prevent memory issues
const MAX_SCROLLBACK = 10000;
term.options.scrollback = MAX_SCROLLBACK;
// Clear buffer periodically if needed
function clearBuffer() {
term.clear();
term.reset();
}
Lazy Loading​
// Lazy load terminal component
const Xterminal = lazy(() => import('./components/terminal/Xterminal'));
<Suspense fallback={<Spinner />}>
<Xterminal />
</Suspense>
Addons Used​
| Addon | Purpose | Bundle Impact |
|---|---|---|
| FitAddon | Auto-resize to container | ~2KB |
| WebLinksAddon | Clickable URLs | ~5KB |
| SearchAddon | Find in terminal | ~8KB |
| Unicode11Addon | Unicode 11 support | ~3KB |
| WebglAddon | GPU rendering | ~12KB |
Browser Support​
| Browser | Version | Support |
|---|---|---|
| Chrome | 90+ | ✅ Full |
| Firefox | 88+ | ✅ Full |
| Safari | 14+ | ✅ Full |
| Edge | 90+ | ✅ Full |