| name | xterm-js |
| description | This skill should be used when working with xterm.js terminal implementations, React-based terminal applications, WebSocket terminal communication, or refactoring terminal-related code. It provides battle-tested patterns, common pitfalls, and debugging strategies learned from building production terminal applications. |
xterm.js Best Practices
Overview
This skill provides comprehensive best practices for building terminal applications with xterm.js, React, and WebSockets. It captures critical patterns discovered through debugging production terminal applications, including state management, WebSocket communication, React hooks integration, and terminal lifecycle management.
When to Use This Skill
Use this skill when:
- Building or debugging xterm.js terminal implementations
- Integrating xterm.js with React (hooks, state, refs)
- Implementing WebSocket-based terminal I/O
- Managing terminal persistence with tmux or similar backends
- Refactoring terminal-related React components into custom hooks
- Debugging terminal initialization, resize, or rendering issues
- Implementing split terminal layouts or multi-window terminal management
- Working on detach/reattach terminal functionality
Core Best Practices
1. Refs and State Management
Critical Pattern: Clear Refs When State Changes
Refs persist across state changes. When clearing state, also clear related refs.
if (terminal.agentId) {
clearProcessedAgentId(terminal.agentId)
}
updateTerminal(id, { agentId: undefined })
Key Insight:
- State (Zustand/Redux) = what the terminal is
- Refs (useRef) = what we've processed
- When state changes, check if related refs need updating
Common Scenario: Detach/reattach workflows where the same agentId returns from backend. Without clearing the ref, the frontend thinks it already processed this agentId and ignores reconnection messages.
See references/refs-state-patterns.md for detailed examples.
2. WebSocket Message Types
Critical Pattern: Know Your Destructive Operations
Backend WebSocket handlers often have different semantics for similar-looking message types:
type: 'disconnect' - Graceful disconnect, keep session alive
type: 'close' - FORCE CLOSE and KILL session (destructive!)
wsRef.current.send(JSON.stringify({
type: 'close',
terminalId: terminal.agentId,
}))
await fetch(`/api/tmux/detach/${sessionName}`, { method: 'POST' })
Key Insight: Read backend code to understand what each message type does. "Close" often means "destroy" in WebSocket contexts.
See references/websocket-patterns.md for backend routing patterns.
3. React Hooks & Refactoring
Critical Pattern: Identify Shared Refs Before Extracting Hooks
When extracting custom hooks that manage shared resources:
export function useWebSocketManager(...) {
const wsRef = useRef<WebSocket | null>(null)
}
export function useWebSocketManager(
wsRef: React.MutableRefObject<WebSocket | null>,
...
) {
}
Checklist Before Extracting Hooks:
See references/react-hooks-patterns.md for refactoring workflows.
4. Terminal Initialization
Critical Pattern: xterm.js Requires Non-Zero Container Dimensions
xterm.js cannot initialize on containers with 0x0 dimensions. Use visibility-based hiding, not display:none.
<div style={{ display: isActive ? 'block' : 'none' }}>
<Terminal />
</div>
<div style={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
visibility: isActive ? 'visible' : 'hidden',
zIndex: isActive ? 1 : 0,
}}>
<Terminal />
</div>
Why This Works:
- All terminals render with full dimensions (stacked via absolute positioning)
- xterm.js can initialize properly on all terminals
visibility: hidden hides inactive terminals without removing dimensions
- Use
isSelected prop to trigger refresh when tab becomes active
Common Scenario: Tab-based terminal UI where switching tabs should show different terminals. After refresh, only active tab would render if using display: none.
5. useEffect Dependencies for Initialization
Critical Pattern: Early Returns Need Corresponding Dependencies
If a useEffect checks a ref and returns early, include ref.current in dependencies so it re-runs when ref becomes available.
useEffect(() => {
if (!terminalRef.current) return
}, [])
useEffect(() => {
if (!terminalRef.current) return
}, [terminalRef.current])
Common Pattern: Wait for DOM refs AND library instances (xterm, fitAddon) before setup:
useEffect(() => {
if (!terminalRef.current?.parentElement ||
!xtermRef.current ||
!fitAddonRef.current) {
return
}
}, [terminalRef.current, xtermRef.current, fitAddonRef.current])
6. Session Naming & Reconnection
Critical Pattern: Use Consistent Session Identifiers
When reconnecting, use the existing sessionName to find the existing PTY. Don't generate a new one.
const config = {
sessionName: terminal.sessionName,
resumable: true,
useTmux: true,
}
const config = {
sessionName: generateNewSessionName(),
}
Key Insight: Tmux sessions have stable names. Use them as the source of truth for reconnection.
7. Multi-Window Terminal Management
Critical Pattern: Backend Output Routing Must Use Ownership Tracking
For multi-window setups, track which WebSocket connections own which terminals. Never broadcast terminal output to all clients.
const terminalOwners = new Map()
terminalRegistry.on('output', (terminalId, data) => {
const owners = terminalOwners.get(terminalId)
owners.forEach(client => client.send(message))
})
Why: Broadcasting terminal output causes escape sequence corruption (DSR sequences) in wrong windows.
Frontend Pattern: Filter terminals by windowId before adding to agents:
if (existingTerminal) {
const terminalWindow = existingTerminal.windowId || 'main'
if (terminalWindow !== currentWindowId) {
return
}
}
See CLAUDE.md "Multi-Window Support - Critical Architecture" section for complete flow.
8. Testing Workflows
Critical Pattern: Test Real Usage Immediately After Refactoring
TypeScript compilation ≠ working code. Always test with real usage:
npm run build
Refactoring Checklist:
Prevention: Don't batch multiple hook extractions. Extract one, test, commit.
9. Debugging Patterns
Critical Pattern: Add Diagnostic Logging Before Fixing
When debugging complex state issues, add comprehensive logging first to understand the problem:
console.log('[useWebSocketManager] 📨 Received terminal-spawned:', {
agentId: message.data.id,
requestId: message.requestId,
sessionName: message.data.sessionName,
pendingSpawnsSize: pendingSpawns.current.size
})
if (!existingTerminal) {
existingTerminal = storedTerminals.find(t => t.requestId === message.requestId)
console.log('[useWebSocketManager] 🔍 Checking by requestId:',
existingTerminal ? 'FOUND' : 'NOT FOUND')
}
Benefits:
- Shows exactly which code path is executing
- Reveals data mismatches (wrong ID, missing state, etc.)
- Helps users self-diagnose issues
- Can be left in for production debugging
10. Multi-Step State Changes
Critical Pattern: Handle All Side Effects When Changing State
When a state change affects multiple systems, update all of them.
Checklist for Terminal State Changes:
Example (Detach):
await fetch(`/api/tmux/detach/${sessionName}`, { method: 'POST' })
if (terminal.agentId) {
clearProcessedAgentId(terminal.agentId)
}
updateTerminal(id, {
status: 'detached',
agentId: undefined,
})
11. Tmux Split Terminals & EOL Conversion
Critical Pattern: Disable EOL Conversion for Tmux Sessions
When multiple xterm.js instances share a tmux session (e.g., React split terminals), enabling convertEol: true causes output corruption.
Problem:
- Tmux sends terminal sequences with proper line endings (
\n)
- xterm with
convertEol: true converts \n → \r\n independently
- Each xterm instance converts the SAME tmux output differently
- Result: text bleeding between panes, misaligned split divider
Solution:
const isTmuxSession = !!agent.sessionName || shouldUseTmux;
const xtermOptions = {
theme: theme.xterm,
fontSize: savedFontSize,
cursorBlink: true,
scrollback: isTmuxSession ? 0 : 10000,
convertEol: !isTmuxSession,
windowsMode: false,
};
Why This Works:
- Tmux sessions:
convertEol: false → xterm displays raw PTY output
- Regular shells:
convertEol: true → xterm converts for Windows compatibility
- Both xterm instances handle tmux output identically → no corruption
Key Insight: Tmux is a terminal multiplexer that manages its own terminal protocol. Multiple xterm instances sharing one tmux session must handle output identically to prevent corruption.
Reference: Tmux EOL Fix Gist - Complete guide with font normalization patterns
12. Resize & Output Coordination
Critical Pattern: Don't Resize During Active Output
Resizing terminals (especially tmux) sends SIGWINCH which triggers a full screen redraw. During active output streaming, this causes "redraw storms" where the same content appears multiple times.
const lastOutputTimeRef = useRef(0)
const OUTPUT_QUIET_PERIOD = 500
const handleOutput = (data: string) => {
lastOutputTimeRef.current = Date.now()
xterm.write(data)
}
const safeToResize = () => {
const timeSinceOutput = Date.now() - lastOutputTimeRef.current
return timeSinceOutput >= OUTPUT_QUIET_PERIOD
}
Critical Pattern: Two-Step Resize Trick for Tmux
Tmux sometimes doesn't properly rewrap text after dimension changes. The "resize trick" forces a full redraw:
const triggerResizeTrick = (force = false) => {
if (!xtermRef.current || !fitAddonRef.current) return
const currentCols = xtermRef.current.cols
const currentRows = xtermRef.current.rows
if (!force && !safeToResize()) {
setTimeout(() => triggerResizeTrick(), OUTPUT_QUIET_PERIOD)
return
}
xtermRef.current.resize(currentCols - 1, currentRows)
sendResize(currentCols - 1, currentRows)
setTimeout(() => {
xtermRef.current.resize(currentCols, currentRows)
sendResize(currentCols, currentRows)
}, 100)
}
Critical Pattern: Clear Write Queue After Resize Trick
The two-step resize causes TWO tmux redraws. If you're queueing writes during resize, you'll have duplicate content:
const writeQueueRef = useRef<string[]>([])
const isResizingRef = useRef(false)
isResizingRef.current = true
isResizingRef.current = false
writeQueueRef.current = []
Critical Pattern: Output Guard on Reconnection
When reconnecting to an active tmux session (e.g., page refresh during Claude streaming), buffer initial output to prevent escape sequence corruption:
const isOutputGuardedRef = useRef(true)
const outputGuardBufferRef = useRef<string[]>([])
const handleOutput = (data: string) => {
if (isOutputGuardedRef.current) {
outputGuardBufferRef.current.push(data)
return
}
xterm.write(data)
}
useEffect(() => {
const timer = setTimeout(() => {
isOutputGuardedRef.current = false
if (outputGuardBufferRef.current.length > 0) {
const buffered = outputGuardBufferRef.current.join('')
outputGuardBufferRef.current = []
xtermRef.current?.write(buffered)
}
setTimeout(() => triggerResizeTrick(true), 100)
}, 1000)
return () => clearTimeout(timer)
}, [])
Critical Pattern: Track and Cancel Deferred Operations
Multiple resize events in quick succession create orphaned timeouts. Track them:
const deferredResizeTrickRef = useRef<NodeJS.Timeout | null>(null)
const deferredFitTerminalRef = useRef<NodeJS.Timeout | null>(null)
const handleResize = () => {
if (deferredResizeTrickRef.current) {
clearTimeout(deferredResizeTrickRef.current)
deferredResizeTrickRef.current = null
}
if (deferredFitTerminalRef.current) {
clearTimeout(deferredFitTerminalRef.current)
deferredFitTerminalRef.current = null
}
deferredFitTerminalRef.current = setTimeout(() => {
deferredFitTerminalRef.current = null
fitTerminal()
}, 150)
}
See references/resize-patterns.md for complete resize coordination patterns.
13. Tmux-Specific Resize Strategy
Critical Pattern: Skip ResizeObserver for Tmux Sessions
Tmux manages its own pane dimensions. ResizeObserver firing on container changes (focus, clicks, layout) causes unnecessary SIGWINCH signals:
useEffect(() => {
if (useTmux) {
console.log('[Resize] Skipping ResizeObserver (tmux session)')
return
}
const resizeObserver = new ResizeObserver((entries) => {
})
resizeObserver.observe(containerRef.current)
return () => resizeObserver.disconnect()
}, [useTmux])
Why Tmux Is Different:
- Regular shells: Each xterm instance owns its PTY, resize freely
- Tmux sessions: Single PTY with tmux managing internal panes
- Tmux receives SIGWINCH and redraws ALL panes
- Multiple resize events = multiple full redraws = corruption
For Tmux:
- DO resize: Once on initial connection (sets viewport)
- DO resize: On actual browser window resize
- DON'T resize: On focus, tab switch, container changes
Resources
references/
This skill includes detailed reference documentation organized by topic:
refs-state-patterns.md - Ref management patterns and examples
websocket-patterns.md - WebSocket communication and backend routing
react-hooks-patterns.md - React hooks refactoring workflows
testing-checklist.md - Comprehensive testing workflows
split-terminal-patterns.md - Split terminal and detach/reattach patterns
advanced-patterns.md - Advanced patterns (emoji width fix, mouse coordinate transformation, tmux reconnection)
resize-patterns.md - Resize coordination and output handling
Load these references as needed when working on specific aspects of terminal development.
Highlights from advanced-patterns.md:
- Unicode11 Addon - Fix emoji/Unicode width issues (2 days of debugging → 1 line fix)
- Mouse Coordinate Transformation - Handle CSS zoom/transform on terminal containers
- Tmux Reconnection Best Practices - Prevent reconnecting to wrong sessions
scripts/
No scripts included - xterm.js integration is primarily about patterns and architecture, not executable utilities.
assets/
No assets included - this skill focuses on best practices and patterns rather than templates.