SKILL

xterm.js Best Practices

3,182 lines of battle-tested patterns. Every line paid for in debugging hours.

Type Claude Code Skill Files 8 reference docs Phase Terminal Evolution
SKILL.md — xterm.js Best Practices
# xterm.js Best Practices Skill
## 13 core patterns distilled from 5+ projects
WARNING: These patterns exist because the bugs were real.
- EOL conversion bug: 2+ days to track down
- Resize coordination: dozens of iterations
- Output routing: escape sequence corruption
~ $ cat references/ | wc -l
3,182 total lines across 8 files
~ $

Stats

3,182
Total Lines
13
Core Patterns
8
Reference Files
5+
Projects Used

Field Manual

1

Refs & State Management

"Clear refs when state changes. Refs persist, state doesn't."
Bug: detach/reattach ignoring reconnection because processedAgentId ref wasn't cleared.
critical
// The trap: refs survive React re-renders const processedAgentId = useRef(null); // When detaching, you MUST clear: const handleDetach = () => { processedAgentId.current = null; // Clear ref! setConnected(false); }; // Without clearing, reattach checks ref, // sees "already processed", skips reconnection
2

WebSocket Message Types

"Know your destructive operations."
Bug: type: 'close' kills the tmux session. Use API endpoint for detach, not WebSocket.
critical
// WRONG: This KILLS the tmux session ws.send(JSON.stringify({ type: 'close', sessionId })) // RIGHT: Use API endpoint to detach without killing await fetch(`/api/sessions/${sessionId}/detach`, { method: 'POST' }) // The WebSocket 'close' type was designed for cleanup, // not for user-initiated disconnection
3

React Hooks Extraction

"Identify shared refs before extracting hooks."
Bug: extracted hook creates its own ref instead of sharing parent's WebSocket ref.
high
// WRONG: Hook creates its own WebSocket ref function useTerminal() { const ws = useRef(null); // New ref, not parent's! // ... } // RIGHT: Accept shared ref as parameter function useTerminal(wsRef) { // Uses parent's WebSocket ref wsRef.current.send(...); // Same connection! }
4

Terminal Initialization

"xterm.js requires non-zero container dimensions."
Bug: display:none tabs prevent xterm init. Fix: use visibility:hidden with absolute stacking.
critical
/* WRONG: Hidden tab has zero dimensions */ .tab-pane { display: none; } .tab-pane.active { display: block; } /* RIGHT: All panes rendered, only active visible */ .tab-pane { position: absolute; top: 0; left: 0; right: 0; bottom: 0; visibility: hidden; } .tab-pane.active { visibility: visible; z-index: 1; } /* xterm.js measures the container on open(). Zero dimensions = zero rows/cols = blank terminal */
5

useEffect Dependencies

"Early returns need corresponding dependencies."
Bug: useEffect with empty deps returns early on null ref and never re-runs.
high
// WRONG: Empty deps, early return, never retries useEffect(() => { if (!termRef.current) return; // Exits... // ...and never runs again because deps = [] }, []); // RIGHT: Include the dependency that gates the effect useEffect(() => { if (!termRef.current) return; // Now re-runs when isReady changes }, [isReady]);
6

Session Naming

"Use consistent session identifiers for reconnection."
Bug: generating new session name on each mount instead of reusing existing tmux session.
medium
// WRONG: New name every mount = orphaned sessions const name = `term-${Date.now()}`; // RIGHT: Deterministic name based on context const name = `${projectName}-${tabType}-${tabIndex}`; // On reconnect, tmux has-session -t name succeeds // and we reattach instead of creating a new session
7

Multi-Window Output Routing

"Backend must use ownership tracking, never broadcast."
Bug: broadcasting terminal output causes escape sequence corruption in wrong windows.
critical
// WRONG: Broadcast to all connected clients wss.clients.forEach(client => { client.send(data); // Every terminal gets every output }); // RIGHT: Route by session ownership const owner = sessionMap.get(sessionId); if (owner && owner.readyState === WebSocket.OPEN) { owner.send(data); // Only the owning terminal } // Escape sequences (colors, cursor movement) are // terminal-specific. Sending \e[31m to the wrong // terminal corrupts its display state.
8

Testing Workflows

"TypeScript compilation ≠ working code."
Checklist: spawn, type, resize, TUI tool, console check, backend logs.
high
// The minimum test sequence for any xterm.js change: // 1. Spawn a new terminal - does it connect? // 2. Type a command - does input reach the PTY? // 3. Resize the window - does the terminal reflow? // 4. Run a TUI (btop, vim) - do escape sequences render? // 5. Check browser console - any WebSocket errors? // 6. Check backend logs - any PTY/tmux errors? // 7. Detach and reattach - does state survive? // 8. Open multiple terminals - any cross-contamination? // // tsc --noEmit passing means NOTHING if step 4 fails
9

Diagnostic Logging

"Add logging before fixing."
Pattern: emoji-prefixed log messages showing exact code paths for rapid visual scanning.
medium
// Emoji prefixes make log scanning instant console.log('🔌 WS connecting:', url); console.log('✅ WS connected, session:', sessionId); console.log('📨 WS message:', type, data.length, 'bytes'); console.log('🔴 WS error:', err); console.log('📏 Resize:', cols, 'x', rows); console.log('🔄 Reattach attempt:', sessionName); // Add logging FIRST, reproduce the bug, THEN fix. // The logs often reveal the real cause isn't // where you thought it was.
10

Multi-Step State Changes

"Handle ALL side effects when changing state."
Checklist: Zustand state, refs, WebSocket, event listeners, localStorage.
high
// When closing a terminal, you must handle: const closeTerminal = (id) => { // 1. Zustand state store.removeTerminal(id); // 2. Refs termRefs.current.delete(id); fitAddonRefs.current.delete(id); // 3. WebSocket const ws = wsRefs.current.get(id); if (ws) ws.close(); wsRefs.current.delete(id); // 4. Event listeners resizeObservers.current.get(id)?.disconnect(); // 5. localStorage localStorage.removeItem(`term-${id}-history`); // Miss any one of these = memory leak or ghost state };
11

Tmux EOL Conversion

"Disable convertEol for tmux sessions."
Bug: multiple xterm instances sharing tmux session convert \n to \r\n independently, causing text bleeding between panes. 2+ days debugging.
critical — 2+ days
// THE MOST EXPENSIVE BUG IN THE ENTIRE SKILL // // xterm.js has a convertEol option that converts \n to \r\n // This is fine for single terminals. For tmux: DISASTER. // // What happens with convertEol: true + tmux: // 1. Tmux sends output with \n line endings // 2. xterm instance A converts \n -> \r\n // 3. xterm instance B ALSO converts \n -> \r\n // 4. If output routes to wrong instance (even briefly), // the double conversion creates \r\r\n // 5. Text from pane A bleeds into pane B's display // 6. Cursor positions drift, lines wrap wrong // // Symptoms: "ghost text", lines appearing in wrong panes, // TUI apps (btop, vim) rendering garbage // WRONG: const term = new Terminal({ convertEol: true, // NEVER for tmux }); // RIGHT: const term = new Terminal({ convertEol: !isTmuxSession, // Only for direct PTY });
12

Resize & Output Coordination

"Don't resize during active output."
Pattern: two-step resize trick, output guard on reconnection, deferred operation tracking. The most complex pattern in the skill.
critical — complex
// THE TWO-STEP RESIZE TRICK // // Problem: Resizing while output is streaming causes // race conditions. The PTY, tmux, and xterm.js all // need to agree on dimensions, but they update async. // Step 1: Set an output guard let resizePending = false; let outputActive = false; ws.onmessage = (e) => { outputActive = true; term.write(e.data); clearTimeout(outputTimer); outputTimer = setTimeout(() => { outputActive = false; // Step 2: Execute deferred resize if (resizePending) { doResize(); resizePending = false; } }, 150); // 150ms quiet period }; const requestResize = (cols, rows) => { if (outputActive) { resizePending = { cols, rows }; // Defer return; } doResize(cols, rows); // Safe to resize now }; // On reconnection: wait for initial output dump before resize // tmux sends a screen repaint on reattach that can be large
13

Tmux-Specific Resize Strategy

"Skip ResizeObserver for tmux sessions."
Bug: ResizeObserver on container changes causes unnecessary SIGWINCH signals to all tmux panes.
high
// WRONG: ResizeObserver fires on ANY layout change const observer = new ResizeObserver(() => { fitAddon.fit(); // Recalculates cols/rows ws.send(resize(cols, rows)); // SIGWINCH to tmux }); // Problem: tmux broadcasts SIGWINCH to ALL panes // in the session, not just the one you resized. // With 4 panes, a single container change triggers // 4 resize observers -> 4 SIGWINCHs -> cascade // RIGHT: For tmux, resize only on explicit user action const handleTabSwitch = (tabId) => { setActiveTab(tabId); // Single deliberate resize after tab switch requestAnimationFrame(() => { fitAddon.fit(); sendResize(term.cols, term.rows); }); };

Skill Structure

xterm-js-best-practices/
SKILL.md — 546 lines — core patterns & quick reference
references/
advanced-patterns.md — 660 lines — complex coordination logic
split-terminal-patterns.md — 541 lines — multi-pane layouts
testing-checklist.md — 401 lines — verification workflows
react-hooks-patterns.md — 392 lines — hook extraction & ref sharing
resize-patterns.md — 306 lines — resize coordination
websocket-patterns.md — 205 lines — message routing & lifecycle
refs-state-patterns.md — 131 lines — ref/state synchronization

Projects Using This Skill

How This Skill Was Forged

This skill represents months of accumulated debugging wisdom. It started with MultiTerminals, where the first terminal worked fine but adding a second revealed every assumption that breaks at scale. Each project layered on new patterns — XtermOrchestrator added the orchestration layer, Tabz brought tab management complexity, and TabzChrome proved the patterns work even inside a Chrome extension sandbox.

The EOL conversion bug alone took 2+ days to track down. The symptoms were maddening: text from one terminal pane would bleed into another, but only when tmux was involved, and only intermittently. It turned out that multiple xterm.js instances were each independently converting \n to \r\n, creating double line endings that corrupted cursor positioning across panes.

The resize coordination patterns went through dozens of iterations before stabilizing. The two-step resize trick — deferring resize operations until a quiet period in output — was the breakthrough that finally eliminated the race conditions between PTY output, tmux repainting, and xterm.js reflowing.

Now this skill is bundled with every terminal project so Claude never makes these mistakes again. The 3,182 lines aren't documentation — they're a field manual written by someone who already stepped on every mine.