From xterm
Expert guidance for building, configuring, and integrating xterm.js terminal emulators in web and Electron applications. Use this skill whenever the user mentions xterm, xterm.js, @xterm/xterm, terminal emulator in the browser, web terminal, WebSSH, in-browser shell, or asks about addons like FitAddon, WebglAddon, SearchAddon, AttachAddon, or integration with node-pty. Also trigger for questions about ANSI/VT sequences, terminal theming, PTY over WebSocket, custom key handlers, parser hooks, or embedding a terminal in React/Vue/Angular/Electron apps. TRIGGER WHEN: the user requires assistance with tasks related to this domain. DO NOT TRIGGER WHEN: the task is outside the specific scope of this component.
npx claudepluginhub acaprino/alfio-claude-plugins --plugin xtermThis skill uses the workspace's default tool permissions.
xterm.js (`@xterm/xterm`) is a full-featured terminal emulator that runs in the browser or
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Guides MCP server integration in Claude Code plugins via .mcp.json or plugin.json configs for stdio, SSE, HTTP types, enabling external services as tools.
xterm.js (@xterm/xterm) is a full-featured terminal emulator that runs in the browser or
Electron. It is NOT a shell — it must be connected to a backend process (e.g. via node-pty +
WebSocket) to execute commands.
npm install @xterm/xterm
# Required addons (install only what you need):
npm install @xterm/addon-fit @xterm/addon-attach @xterm/addon-web-links \
@xterm/addon-search @xterm/addon-webgl @xterm/addon-clipboard \
@xterm/addon-web-fonts @xterm/addon-unicode11
Always import the CSS:
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
Or in JS/TS:
import '@xterm/xterm/css/xterm.css';
import { Terminal } from '@xterm/xterm';
const term = new Terminal({
cols: 80,
rows: 24,
cursorBlink: true,
scrollback: 5000,
fontFamily: '"Cascadia Code", Menlo, monospace',
fontSize: 14,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
},
});
term.open(document.getElementById('terminal')!);
term.write('Hello from \x1B[1;32mxterm.js\x1B[0m\r\n$ ');
Critical: Always call term.open(element) AFTER the element is in the DOM.
Use \r\n (not just \n) for newlines when writing directly.
| Addon | Package | Purpose |
|---|---|---|
| FitAddon | @xterm/addon-fit | Resize terminal to fill its container |
| AttachAddon | @xterm/addon-attach | Connect to a WebSocket backend |
| SearchAddon | @xterm/addon-search | In-terminal text search |
| WebglAddon | @xterm/addon-webgl | GPU-accelerated WebGL2 renderer |
| WebLinksAddon | @xterm/addon-web-links | Clickable URLs |
| ClipboardAddon | @xterm/addon-clipboard | Browser clipboard integration |
| WebFontsAddon | @xterm/addon-web-fonts | Wait for web fonts before rendering |
| Unicode11Addon | @xterm/addon-unicode11 | Unicode 11 character width support |
| LigaturesAddon | @xterm/addon-ligatures | Font ligature support (canvas renderer only) |
import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { SearchAddon } from '@xterm/addon-search';
const fitAddon = new FitAddon();
const searchAddon = new SearchAddon();
term.loadAddon(fitAddon);
term.loadAddon(searchAddon);
term.loadAddon(new WebLinksAddon());
term.open(document.getElementById('terminal')!);
// WebGL: load AFTER open(), handle fallback
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose()); // fallback on context loss
term.loadAddon(webgl);
} catch {
console.warn('WebGL2 not available, falling back to canvas renderer');
}
fitAddon.fit();
FitAddon resizes cols/rows to fit the container's pixel dimensions.
// Resize on window resize
const ro = new ResizeObserver(() => fitAddon.fit());
ro.observe(document.getElementById('terminal')!);
// Or with window resize event (less precise):
window.addEventListener('resize', () => fitAddon.fit());
Gotcha: Container must have explicit dimensions (CSS width/height).
FitAddon returns undefined if the container has zero size.
import { AttachAddon } from '@xterm/addon-attach';
const socket = new WebSocket('ws://localhost:3000/ws');
const attachAddon = new AttachAddon(socket);
term.loadAddon(attachAddon);
// Sync terminal resize with PTY on the server
term.onResize(({ cols, rows }) => {
socket.send(JSON.stringify({ type: 'resize', cols, rows }));
});
import * as pty from 'node-pty';
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 3000 });
wss.on('connection', (ws) => {
const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-color',
cols: 80,
rows: 24,
env: process.env,
});
ptyProcess.onData((data) => ws.send(data));
ws.on('message', (msg) => {
const parsed = JSON.parse(msg.toString());
if (parsed.type === 'resize') {
ptyProcess.resize(parsed.cols, parsed.rows);
} else {
ptyProcess.write(msg.toString());
}
});
ws.on('close', () => ptyProcess.kill());
});
For simple bidirectional use (no resize), AttachAddon handles piping automatically;
manual term.onData / pty.onData wiring is only needed for custom protocols.
// PTY → terminal
ptyProcess.onData((data) => term.write(data));
// Terminal → PTY (user keystrokes)
term.onData((data) => ptyProcess.write(data));
// Binary events (e.g., certain mouse reports)
term.onBinary((data) => ptyProcess.write(data));
Pass an ITheme object in options or use term.options.theme = {...} at runtime:
const darkTheme = {
background: '#0d1117',
foreground: '#c9d1d9',
cursor: '#58a6ff',
cursorAccent: '#0d1117',
selectionBackground: '#264f78',
black: '#484f58', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4',
brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364',
brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta:'#d2a8ff',
brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
};
// Apply at construction
const term = new Terminal({ theme: darkTheme });
// Or update at runtime
term.options.theme = darkTheme;
// Intercept keys before terminal processes them
// Return false to suppress, true to allow
term.attachCustomKeyEventHandler((ev: KeyboardEvent) => {
// Example: Ctrl+Shift+C → copy
if (ev.ctrlKey && ev.shiftKey && ev.key === 'C') {
document.execCommand('copy');
return false; // don't pass to terminal
}
return true;
});
// Find next/previous
searchAddon.findNext('search term', {
regex: false,
wholeWord: false,
caseSensitive: false,
incremental: false, // true = highlight while typing
decorations: {
matchBackground: '#ffff0040',
matchBorder: '#ffff00',
matchOverviewRuler: '#ffff00',
activeMatchBackground: '#ff000080',
activeMatchBorder: '#ff0000',
activeMatchColorOverviewRuler: '#ff0000',
},
});
searchAddon.findPrevious('search term');
// Add a marker on the current row
const marker = term.registerMarker(0); // 0 = current row offset
// Add a decoration (e.g. highlight a line)
const decoration = term.registerDecoration({
marker,
overviewRulerOptions: { color: '#ff0000' },
});
decoration?.onRender((element) => {
element.style.backgroundColor = 'rgba(255,0,0,0.2)';
});
// Register a custom OSC sequence handler (e.g. OSC 1337)
term.parser.registerOscHandler(1337, (data: string) => {
console.log('Custom OSC 1337 payload:', data);
return true; // handled
});
// Register a custom CSI sequence handler
term.parser.registerCsiHandler({ final: 'z' }, (params) => {
console.log('Custom CSI z params:', params);
return true;
});
import { useEffect, useRef } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
export function XTerminal() {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
useEffect(() => {
const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(containerRef.current!);
fitAddon.fit();
termRef.current = term;
const ro = new ResizeObserver(() => fitAddon.fit());
ro.observe(containerRef.current!);
return () => {
ro.disconnect();
term.dispose();
};
}, []);
return <div ref={containerRef} style={{ width: '100%', height: '400px' }} />;
}
| Problem | Cause | Fix |
|---|---|---|
| Terminal renders blank / wrong size | FitAddon called before DOM paint | Call fitAddon.fit() after open(), use ResizeObserver |
| Characters appear wrong width | Unicode handling | Load Unicode11Addon, call term.unicode.activeVersion = '11' |
| Backspace doesn't work | PTY not connected / wrong escape | Send \x7f or \x08; check PTY termios settings |
| Copy/paste broken on some browsers | Browser security | Use ClipboardAddon and ensure HTTPS or localhost |
| WebGL context lost after tab switch | GPU resource reclaim | webgl.onContextLoss(() => webgl.dispose()) |
| Terminal doesn't fill container | Container has no height | Set explicit CSS height on container element |
\n without \r causes staircase | Missing carriage return | Use \r\n or enable convertEol: true in options |
const term = new Terminal({
cols: 80, // initial columns
rows: 24, // initial rows
cursorBlink: true, // blinking cursor
cursorStyle: 'block', // 'block' | 'underline' | 'bar'
scrollback: 10000, // scrollback buffer lines
tabStopWidth: 4, // tab stop width
convertEol: false, // auto-add \r on \n (avoid for real PTY)
disableStdin: false, // read-only mode
allowProposedApi: false, // enable experimental APIs
allowTransparency: false, // transparent background support
windowsMode: false, // Windows-style line ending behavior
macOptionIsMeta: false, // treat Option key as Meta (macOS)
rightClickSelectsWord: false,
fontSize: 14,
fontFamily: 'monospace',
fontWeight: 'normal',
fontWeightBold: 'bold',
lineHeight: 1.0,
letterSpacing: 0,
logLevel: 'info', // 'debug' | 'info' | 'warn' | 'error' | 'off'
});
When connected to high-throughput backends (LLMs, build tools, log streams), naive patterns cause UI freezes and data corruption.
Multi-byte UTF-8 sequences can be split across read chunks from the PTY. The reader must maintain a remainder buffer to reassemble them before passing to term.write().
Do NOT synchronize React state on every PTY onData chunk -- hundreds per second during heavy output will freeze the UI.
requestAnimationFrame to extract terminal state (cursor position, buffer length, bookmarks) at most 60 times per secondterm.refresh() during active output -- it resets scroll position. Use a debounced timer for manual refreshesDuring fast PTY output (LLM streaming, build logs), the cursor appears and disappears at random positions creating a flickering/ghost effect.
Fix: Hide cursor during ALL PTY writes with term.write('\x1b[?25l'), then use a debounced timer (80-100ms) that restores the cursor only when output stabilizes. Consolidate cursor movement detection into a single regex instead of multiple sequential string scans.
To read the correct buffer line, always use buffer.baseY + buffer.cursorY for the absolute index. Using cursorY alone reads from the top of the scrollback, not the current viewport row.
Components that re-render based on terminal state (minimap, bookmarks, line counters) must throttle updates:
requestAnimationFrame instead of per-chunk updatesBefore calling term.write(), the data stream may need preprocessing:
Windows ConPTY corrupts supplementary plane characters (emoji) when passed through CreateProcessW. Strip or replace emoji from text before passing to ConPTY to prevent lone surrogates corrupting the data stream.
When the shell resolves to a .cmd/.bat shim, cmd.exe metacharacters in arguments can cause injection. Use cmd.exe-specific quoting: "" for embedded quotes and %% for percent signs.
Terminal control characters in pasted text can inject commands or corrupt output. Sanitize pasted text by filtering terminal output control characters. Block the native paste event with preventDefault() to prevent double-paste, and add a debounce guard for rapid paste events.
To replace or filter backend output (e.g. replacing a startup banner with custom content):
ESC[2J), re-activate the interception to handle redraws (e.g. on window resize)Prefer HTML overlay approaches (React component with CSS fade-out) over inline ANSI manipulation when possible -- inline approaches conflict with CUP offset tracking.
NEVER use display: none on the xterm container during initialization -- cols/rows calculation will fail. Use visibility: hidden instead, or mount the component only when visible.
A single onContextLoss callback is insufficient. After system sleep/standby, the WebGL context dies silently and the addon callback often does not fire.
Use a 3-layer detection strategy:
webglAddon.onContextLoss(() => fallbackToCanvas())webglcontextlost on the canvas element generated by the addonAll three layers should trigger automatic fallback to the canvas renderer.
Defer fitAddon.fit() to the next requestAnimationFrame when switching tabs, to give the DOM time to update container dimensions. Fitting immediately causes narrow columns.
Throttle terminal resize events to prevent a burst of resize messages to the backend PTY. Also reset PTY size tracking after spawning a new process to avoid stale column counts from the previous session.
When injecting custom content (banners, status bars) that the backend PTY does not know about, absolute cursor positioning (CUP sequences) breaks because the backend's row count does not match the frontend's.
Math.max(10, rows - OFFSET)) to prevent backend crashes on tiny terminals\x1b[<row>;<col>H), adding the offset back before passing to term.write()// Example: frontend has 30 rows, custom banner is 12 rows
const CUP_ROW_OFFSET = 12; // derive from banner content, not magic number
// Lie to PTY on spawn and resize
term.onResize(({ cols, rows }) => {
const virtualRows = Math.max(10, rows - CUP_ROW_OFFSET);
socket.send(JSON.stringify({ type: 'resize', cols, rows: virtualRows }));
});
Derive the offset from the actual content size (e.g. bannerLines.length) rather than hardcoding magic numbers. When the injected content can change (resize-triggered redraw), re-derive the offset dynamically.
Always dispose to prevent memory leaks:
term.dispose(); // disposes terminal and all loaded addons
fitAddon.dispose(); // or dispose individual addons
marker.dispose(); // markers and decorations
decoration.dispose();