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.
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
references/refs-state-patterns.mdreferences/resize-patterns.mdreferences/websocket-patterns.mdThis 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.
Use this skill when:
Critical Pattern: Clear Refs When State Changes
Refs persist across state changes. When clearing state, also clear related refs.
// CORRECT - Clear both state AND ref
if (terminal.agentId) {
clearProcessedAgentId(terminal.agentId) // Clear ref
}
updateTerminal(id, { agentId: undefined }) // Clear state
Key Insight:
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.
Critical Pattern: Know Your Destructive Operations
Backend WebSocket handlers often have different semantics for similar-looking message types:
type: 'disconnect' - Graceful disconnect, keep session alivetype: 'close' - FORCE CLOSE and KILL session (destructive!)// WRONG - This KILLS the tmux session!
wsRef.current.send(JSON.stringify({
type: 'close',
terminalId: terminal.agentId,
}))
// CORRECT - For detach, use API endpoint only
await fetch(`/api/tmux/detach/${sessionName}`, { method: 'POST' })
// Don't send WebSocket message - let PTY disconnect naturally
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.
Critical Pattern: Identify Shared Refs Before Extracting Hooks
When extracting custom hooks that manage shared resources:
// WRONG - Hook creates its own ref
export function useWebSocketManager(...) {
const wsRef = useRef<WebSocket | null>(null) // Creates NEW ref!
}
// RIGHT - Hook uses shared ref from parent
export function useWebSocketManager(
wsRef: React.MutableRefObject<WebSocket | null>, // Pass as parameter
...
) {
// Uses parent's ref - all components share same WebSocket
}
Checklist Before Extracting Hooks:
See references/react-hooks-patterns.md for refactoring workflows.
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.
// WRONG - Prevents xterm initialization
<div style={{ display: isActive ? 'block' : 'none' }}>
<Terminal />
</div>
// CORRECT - All terminals get dimensions, use visibility to hide
<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:
visibility: hidden hides inactive terminals without removing dimensionsisSelected prop to trigger refresh when tab becomes activeCommon Scenario: Tab-based terminal UI where switching tabs should show different terminals. After refresh, only active tab would render if using display: none.
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.
// WRONG - Only runs once, may return early forever
useEffect(() => {
if (!terminalRef.current) return // Returns if null
// Setup ResizeObserver
}, []) // Never re-runs!
// CORRECT - Re-runs when ref becomes available
useEffect(() => {
if (!terminalRef.current) return
// Setup ResizeObserver
}, [terminalRef.current]) // Re-runs when ref changes!
Common Pattern: Wait for DOM refs AND library instances (xterm, fitAddon) before setup:
useEffect(() => {
if (!terminalRef.current?.parentElement ||
!xtermRef.current ||
!fitAddonRef.current) {
return // Wait for all refs
}
// Setup ResizeObserver
}, [terminalRef.current, xtermRef.current, fitAddonRef.current])
Critical Pattern: Use Consistent Session Identifiers
When reconnecting, use the existing sessionName to find the existing PTY. Don't generate a new one.
// CORRECT - Reconnect to existing session
const config = {
sessionName: terminal.sessionName, // Use existing!
resumable: true,
useTmux: true,
}
// WRONG - Would create new session
const config = {
sessionName: generateNewSessionName(), // DON'T DO THIS
}
Key Insight: Tmux sessions have stable names. Use them as the source of truth for reconnection.
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.
// Backend: Track ownership
const terminalOwners = new Map() // terminalId -> Set<WebSocket>
// On output: send ONLY to owners (no broadcast!)
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:
// Check windowId BEFORE adding to webSocketAgents
if (existingTerminal) {
const terminalWindow = existingTerminal.windowId || 'main'
if (terminalWindow !== currentWindowId) {
return // Ignore terminals from other windows
}
// Now safe to add to webSocketAgents
}
See CLAUDE.md "Multi-Window Support - Critical Architecture" section for complete flow.
Critical Pattern: Test Real Usage Immediately After Refactoring
TypeScript compilation ≠ working code. Always test with real usage:
# After refactoring:
npm run build # 1. Check TypeScript
# Open http://localhost:5173
# Spawn terminal # 2. Test spawning
# Type in terminal # 3. Test input (WebSocket)
# Resize window # 4. Test resize (ResizeObserver)
# Spawn TUI tool # 5. Test complex interactions
Refactoring Checklist:
npm testPrevention: Don't batch multiple hook extractions. Extract one, test, commit.
Critical Pattern: Add Diagnostic Logging Before Fixing
When debugging complex state issues, add comprehensive logging first to understand the problem:
// BEFORE fixing, add logging:
console.log('[useWebSocketManager] 📨 Received terminal-spawned:', {
agentId: message.data.id,
requestId: message.requestId,
sessionName: message.data.sessionName,
pendingSpawnsSize: pendingSpawns.current.size
})
// Log each fallback attempt:
if (!existingTerminal) {
existingTerminal = storedTerminals.find(t => t.requestId === message.requestId)
console.log('[useWebSocketManager] 🔍 Checking by requestId:',
existingTerminal ? 'FOUND' : 'NOT FOUND')
}
Benefits:
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):
// 1. API call
await fetch(`/api/tmux/detach/${sessionName}`, { method: 'POST' })
// 2. Clear ref (DON'T FORGET THIS!)
if (terminal.agentId) {
clearProcessedAgentId(terminal.agentId)
}
// 3. Update state
updateTerminal(id, {
status: 'detached',
agentId: undefined,
})
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:
\n)convertEol: true converts \n → \r\n independentlySolution:
const isTmuxSession = !!agent.sessionName || shouldUseTmux;
const xtermOptions = {
theme: theme.xterm,
fontSize: savedFontSize,
cursorBlink: true,
scrollback: isTmuxSession ? 0 : 10000,
// CRITICAL: Disable EOL conversion for tmux
convertEol: !isTmuxSession, // Only convert for regular shells
windowsMode: false, // Ensure UNIX-style line endings
};
Why This Works:
convertEol: false → xterm displays raw PTY outputconvertEol: true → xterm converts for Windows compatibilityKey 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
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.
// Track output timing
const lastOutputTimeRef = useRef(0)
const OUTPUT_QUIET_PERIOD = 500 // Wait 500ms after last output
// In output handler
const handleOutput = (data: string) => {
lastOutputTimeRef.current = Date.now()
xterm.write(data)
}
// Before any resize operation
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
// Skip if output is active (unless forced)
if (!force && !safeToResize()) {
// Defer and retry later
setTimeout(() => triggerResizeTrick(), OUTPUT_QUIET_PERIOD)
return
}
// Step 1: Resize down by 1 column (sends SIGWINCH)
xtermRef.current.resize(currentCols - 1, currentRows)
sendResize(currentCols - 1, currentRows)
// Step 2: Resize back (sends another SIGWINCH)
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)
// During resize trick
isResizingRef.current = true
// ... do resize ...
isResizingRef.current = false
// CRITICAL: Clear queue instead of flushing after resize trick
// Both redraws are queued - flushing writes duplicate content!
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[]>([])
// Buffer output during guard period
const handleOutput = (data: string) => {
if (isOutputGuardedRef.current) {
outputGuardBufferRef.current.push(data)
return
}
xterm.write(data)
}
// Lift guard after initialization (1000ms), flush buffer, then force resize
useEffect(() => {
const timer = setTimeout(() => {
isOutputGuardedRef.current = false
// Flush buffered output
if (outputGuardBufferRef.current.length > 0) {
const buffered = outputGuardBufferRef.current.join('')
outputGuardBufferRef.current = []
xtermRef.current?.write(buffered)
}
// Force resize trick to fix any tmux state issues
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)
// On new resize event, cancel pending deferred operations
const handleResize = () => {
if (deferredResizeTrickRef.current) {
clearTimeout(deferredResizeTrickRef.current)
deferredResizeTrickRef.current = null
}
if (deferredFitTerminalRef.current) {
clearTimeout(deferredFitTerminalRef.current)
deferredFitTerminalRef.current = null
}
// Schedule new operation
deferredFitTerminalRef.current = setTimeout(() => {
deferredFitTerminalRef.current = null
fitTerminal()
}, 150)
}
See references/resize-patterns.md for complete resize coordination patterns.
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(() => {
// For tmux sessions, only send initial resize - skip ResizeObserver
if (useTmux) {
console.log('[Resize] Skipping ResizeObserver (tmux session)')
return // Don't set up observer at all
}
// For regular shells, use ResizeObserver
const resizeObserver = new ResizeObserver((entries) => {
// ... handle resize
})
resizeObserver.observe(containerRef.current)
return () => resizeObserver.disconnect()
}, [useTmux])
Why Tmux Is Different:
For Tmux:
This skill includes detailed reference documentation organized by topic:
refs-state-patterns.md - Ref management patterns and exampleswebsocket-patterns.md - WebSocket communication and backend routingreact-hooks-patterns.md - React hooks refactoring workflowstesting-checklist.md - Comprehensive testing workflowssplit-terminal-patterns.md - Split terminal and detach/reattach patternsadvanced-patterns.md - Advanced patterns (emoji width fix, mouse coordinate transformation, tmux reconnection)resize-patterns.md - Resize coordination and output handlingLoad these references as needed when working on specific aspects of terminal development.
Highlights from advanced-patterns.md:
No scripts included - xterm.js integration is primarily about patterns and architecture, not executable utilities.
No assets included - this skill focuses on best practices and patterns rather than templates.