Read OSS

From PTY to Pixels: The xterm.js Integration and Component Architecture

Advanced

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 sure webGLRenderer is false in 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:

  1. Mousetrap — A keyboard shortcut library that captures global shortcuts (Cmd+T for new tab, Cmd+D for split, etc.)
  2. 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.mousetrap in DevTools. (2) Is xterm receiving it? Add console.log(e.catched) in the keyboardHandler method. The catched flag 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.