From PTY to Pixels: The xterm.js Integration and Component Architecture
Prerequisites
- ›Article 3: State Management and Redux
- ›React component lifecycle and refs
- ›Basic xterm.js knowledge
From PTY to Pixels: The xterm.js Integration and Component Architecture
We've traced data from the PTY through the IPC bridge and into Redux. Now comes the final hop — rendering actual terminal content on screen. This is where Hyper's web-technology bet pays its biggest dividend and exacts its highest cost: wrapping a high-performance terminal renderer (xterm.js) in React components introduces lifecycle management challenges that native terminals simply don't face.
In this article, we'll dissect the Term component that manages xterm.js, the WebGL-to-Canvas fallback logic, how DOM elements survive React unmount/remount cycles, the responsive sizing system, and the two-tier keyboard shortcut architecture.
The Term Component: xterm.js Wrapper
The Term component is a React.PureComponent that wraps a single xterm.js Terminal instance. It's responsible for:
- Creating and configuring the
Terminal - Loading addons (Fit, Search, WebLinks, Canvas/WebGL, Unicode11, Image, Ligatures)
- Handling keyboard events
- Managing the terminal's DOM element lifecycle
- Responding to prop changes (font size, colors, etc.)
- Providing search functionality
The addon loading sequence in componentDidMount reveals a layered architecture:
| Addon | Purpose | Always Loaded? |
|---|---|---|
FitAddon |
Auto-sizes terminal to container | Yes |
SearchAddon |
In-terminal search with decorations | Yes |
WebLinksAddon |
Clickable URLs | Yes |
CanvasAddon or WebglAddon |
GPU-accelerated rendering | One of the two |
Unicode11Addon |
Full Unicode 11 width support | Yes |
LigaturesAddon |
Font ligature support | Only with Canvas |
ImageAddon |
Inline image display (iTerm2 protocol) | If config enabled |
The constructor registers itself in the global terms registry (terms[this.props.uid] = this) — this is the registration point that enables the write middleware from Article 3 to bypass React and write directly to xterm.
Renderer Selection and WebGL Fallback
Hyper's rendering pipeline has three tiers of fallback logic for choosing between WebGL and Canvas rendering:
lib/components/term.tsx#L189-L205
flowchart TD
A["webGLRenderer config = true?"] -->|No| F["Use Canvas"]
A -->|Yes| B["Background needs transparency?"]
B -->|Yes| F
B -->|No| C["WebGL2 supported?"]
C -->|No| F
C -->|Yes| D["Use WebGL"]
D --> E{"WebGL context lost?"}
E -->|Yes| G["Dispose WebGL\nFallback to Canvas"]
E -->|No| H["Continue with WebGL"]
WebGL is only used when all three conditions are met: the config enables it, the background color is fully opaque, and the browser supports WebGL2. The transparency check exists because xterm.js's WebGL renderer doesn't support transparent backgrounds — a significant limitation for Hyper, which supports window transparency on macOS.
The WebGL context loss handler at lib/components/term.tsx#L227-L231 provides runtime resilience:
webglAddon.onContextLoss(() => {
console.warn('WebGL context lost. Falling back to canvas-based rendering.');
webglAddon.dispose();
this.term.loadAddon(new CanvasAddon());
});
WebGL contexts can be lost when the GPU is under pressure or when the system suspends. Rather than showing a blank terminal, Hyper seamlessly falls back to the Canvas renderer.
Tip: The ligatures addon is only loaded when using the Canvas renderer (
props.disableLigatures !== true && !useWebGL). If you're using Hyper with a ligature font like Fira Code and want ligatures to work, make surewebGLRendererisfalsein your config.
DOM Preservation and Responsive Sizing
React components mount and unmount frequently during split-pane operations — when you split a pane, the tree restructures and components re-mount. But xterm.js maintains internal state in its DOM element (cursor position, scrollback buffer, selection state). Destroying and recreating the terminal every time React re-mounts would be unacceptable.
Hyper solves this by managing the terminal's DOM element outside React's control:
lib/components/term.tsx#L182-L187
// The parent element for the terminal is attached and removed manually so
// that we can preserve it across mounts and unmounts of the component
this.termRef = props.term ? props.term.element!.parentElement! : document.createElement('div');
this.termRef.className = 'term_fit term_term';
this.termWrapperRef?.appendChild(this.termRef);
When a Term component mounts and receives an existing props.term (a previously-created Terminal instance), it reuses that terminal's existing DOM element instead of creating a new one. During unmount (componentWillUnmount), the DOM element is detached but not destroyed:
componentWillUnmount() {
terms[this.props.uid] = null;
this.termWrapperRef?.removeChild(this.termRef!);
// We remove listeners instead of invoking `destroy`, since it will make the
// term insta un-attachable in the future
this.disposableListeners.forEach((handler) => handler.dispose());
}
Responsive sizing uses a ResizeObserver with a 500ms debounce:
lib/components/term.tsx#L483-L494
sequenceDiagram
participant RO as ResizeObserver
participant T as Term Component
participant FA as FitAddon
participant PTY as PTY (via RPC)
Note over RO: Container div resizes
RO->>T: Callback fires
T->>T: Clear existing timeout
T->>T: Set 500ms timeout
Note over T: 500ms debounce
T->>FA: fitAddon.fit()
FA->>FA: Calculate cols/rows from container dimensions
FA->>T: Terminal resized (triggers onResize)
T->>PTY: rpc.emit('resize', {uid, cols, rows})
PTY->>PTY: pty.resize(cols, rows)
The 500ms debounce prevents resize storms during window drag operations. The FitAddon calculates the optimal number of columns and rows based on the container's pixel dimensions and the current font metrics, then resizes the terminal, which triggers an onResize callback that propagates the new dimensions back to the PTY via RPC.
Component Tree and Split Pane Rendering
The component hierarchy maps directly to the concepts we explored in Article 3's term groups reducer:
graph TD
HC["HyperContainer<br/>(Redux connected)"]
H["Header<br/>(tabs, window controls)"]
TC["TermsContainer<br/>(Redux connected)"]
TG1["TermGroup<br/>(root, direction: VERTICAL)"]
SP["SplitPane<br/>(flexbox container)"]
TG2["TermGroup<br/>(left leaf)"]
TG3["TermGroup<br/>(right leaf)"]
T1["Term<br/>(xterm.js instance)"]
T2["Term<br/>(xterm.js instance)"]
HC --> H
HC --> TC
TC --> TG1
TG1 --> SP
SP --> TG2
SP --> TG3
TG2 --> T1
TG3 --> T2
The TermGroup component is recursive — it either renders a Term (if it's a leaf with a sessionUid) or renders a SplitPane containing child TermGroups:
lib/components/term-group.tsx#L122-L139
Inactive tab groups are positioned off-screen at left: -9999em rather than unmounted. This is visible in the Terms component CSS:
.terms_termGroup {
position: absolute;
top: 0;
left: -9999em; /* Offscreen to pause xterm rendering */
}
.terms_termGroupActive {
left: 0;
}
This leverages xterm.js's IntersectionObserver-based rendering optimization — when the terminal element is offscreen, xterm pauses rendering, saving CPU cycles for inactive tabs without the cost of unmounting and remounting the component.
Keyboard Shortcut System
Hyper's keyboard handling coordinates two separate systems that must not interfere with each other:
- Mousetrap — A keyboard shortcut library that captures global shortcuts (Cmd+T for new tab, Cmd+D for split, etc.)
- xterm.js — The terminal's own key handler that processes input for the shell
The coordination point is the event.catched flag — a custom property that Mousetrap sets and xterm reads:
In lib/containers/hyper.tsx#L57-L69, Mousetrap binds keyboard shortcuts and marks matched events:
mousetrap.current?.bind(commandKeys, (e) => {
const command = keys[commandKeys];
(e as any).catched = true; // Flag for xterm
props.execCommand(command, getCommandHandler(command), e);
shouldPreventDefault(command) && e.preventDefault();
}, 'keydown');
Then in lib/components/term.tsx#L419-L422, xterm's custom key event handler checks this flag:
keyboardHandler(e: any) {
return !e.catched;
}
If e.catched is true, xterm ignores the event (the handler returns false). If false, xterm processes it normally as terminal input.
sequenceDiagram
participant K as Keyboard Event
participant M as Mousetrap
participant X as xterm.js
participant C as Command Registry
participant RPC as RPC → Main
K->>M: keydown event (e.g., Cmd+T)
M->>M: Match against registered shortcuts
alt Shortcut matched
M->>K: Set e.catched = true
M->>C: Look up command handler
C->>RPC: Execute command (e.g., 'tab:new')
K->>X: attachCustomKeyEventHandler
X->>X: Check e.catched → true → ignore
else No match
K->>X: attachCustomKeyEventHandler
X->>X: Check e.catched → false → process as input
end
The command registry at lib/command-registry.ts maintains a separate list of "role commands" like window:close and editor:copy that should not have preventDefault called — these are handled by Electron's native menu system and need the default browser behavior.
The main-process command definitions at app/commands.ts#L9-L139 show the full command vocabulary: window management, tab navigation, pane splitting, font zoom, editor shortcuts (move word, delete line), and plugin management. Profile-specific commands are dynamically generated at app/commands.ts#L150-L163 — each profile gets its own window:new:profileName, tab:new:profileName, and split commands.
Tip: When debugging keyboard issues in Hyper, check two things: (1) Is Mousetrap catching the key? Look at
window.mousetrapin DevTools. (2) Is xterm receiving it? Addconsole.log(e.catched)in thekeyboardHandlermethod. Thecatchedflag protocol is fragile — if a plugin adds event listeners between Mousetrap and xterm, it can break the coordination.
What's Next
We've now covered the complete data path from PTY to pixels. But throughout all four articles, we've repeatedly encountered one system that touches everything: plugins. The decorate() function wrapping components, the middleware intercepting Redux actions, the Module._load patching for shared dependencies — these are all pieces of Hyper's extensive plugin architecture. In the next article, we'll examine all 38 extension points and how they work together to make every surface of the terminal customizable.