Skip to main content

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​

  1. Industry Standard: Powers VS Code, Hyper, theia terminals
  2. Feature Complete: Full terminal emulation (VT100, xterm-256color)
  3. Performance: WebGL renderer, virtual scrolling
  4. Addons: Fit, Search, WebLinks, Unicode11
  5. Active Development: Regular updates, Microsoft-backed
  6. Customizable: Full theme control, font options
  7. 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​

AddonPurposeBundle Impact
FitAddonAuto-resize to container~2KB
WebLinksAddonClickable URLs~5KB
SearchAddonFind in terminal~8KB
Unicode11AddonUnicode 11 support~3KB
WebglAddonGPU rendering~12KB

Browser Support​

BrowserVersionSupport
Chrome90+✅ Full
Firefox88+✅ Full
Safari14+✅ Full
Edge90+✅ Full

References​