Skip to content

Terminal Node Rendering (xterm.js)

dazeb edited this page Sep 17, 2026 · 2 revisions

Terminal Node Rendering (xterm.js)

TerminalNode is the React Flow custom node that hosts one xterm.js Terminal instance per canvas node. It owns three responsibilities: creating/destroying the xterm view, binding that view to either a local/SSH PTY session or a relayed host terminal, and keeping the terminal fitted to its node body without tripping Chromium's ResizeObserver failure modes. The node id doubling as the PTY session id is the load-bearing invariant: TerminalNode.tsx states that the PTY session id is the React Flow node id, so ids must stay stable or the session respawns.

Module map

File Role
src/renderer/src/nodes/TerminalNode.tsx The node component: xterm mount effect, PTY/relay binding, badges, header, resize wiring
src/renderer/src/hooks/useSafeResize.ts Shared rAF-deferred ResizeObserver hook used for fit/resize
src/renderer/src/ro-noise.ts Prefix matcher that classifies the two benign Chromium ResizeObserver loop messages
src/renderer/src/ro-noise.test.ts Unit test companion for the matcher (not read here)

The component imports Terminal/FitAddon from @xterm/xterm and @xterm/addon-fit, NodeResizer from @reactflow/node-resizer, TerminalNodeData and resumedSessionId from ../state/workspace, parseRelayTermFrame/RelayTermFrame from ../../../core/relay-term, and the useCanvas, useAgentStatuses, useProjects, useSafeResize, HelpBadge, LinkHandles helpers.

Renderer / data-path selection

Only one addon is loaded in the read evidence: FitAddon. There is no WebGL/canvas addon import here, so "renderer selection" inside this node means choosing the backing data path, driven by isRemote — typeof data.relayTerm === 'string' && data.relayTerm.length > 0. Both branches create the same xterm instance with the same options; they differ in where bytes come from and where input/resize bytes go.

flowchart TD
  A[TerminalNode mounts] --> B{terminalSettings loaded<br/>and hostRef set?}
  B -- no --> Z[mount effect returns early]
  B -- yes --> C[new Terminal + FitAddon<br/>term.open host; fit.fit]
  C --> D{isRemote?}
  D -- "local / SSH" --> E[subscribe pty.onData + pty.onExit<br/>BEFORE pty.create]
  E --> F[pty.create id, projectId, cols, rows,<br/>cwd, command, profile, proxy, remote?]
  F --> G{result.fresh}
  G -- yes --> H[pty.readScrollback id<br/>write clear-screen + snapshot + marker]
  G -- no --> I[warm reattach: tmux redraws]
  F -. reject .-> J[write red spawn-failed line]
  D -- remote relay --> K[relay.onFrame -> parseRelayTermFrame<br/>keep k=out, term=relayTerm]
  K --> L[term.onData -> relay frame k=in]
  L --> M[term.onResize -> relay frame k=resized<br/>gated by attachedRef]
Loading

Key branch points:

  • Local / SSH path. Output listeners are registered before pty.create explicitly "so no early data is lost". The create payload carries id (node id), projectId: ownerProjectIdRef.current ?? undefined, live term.cols/term.rows, data.cwd, data.command, terminalSettings.profile (terminalProfile) and terminalSettings.proxy (httpProxy). If the owning project has a remote descriptor (getOwningRemote reads useProjects.getState().projects), that descriptor is spread into the create call — the comment notes a remote project's terminal runs over ssh -tt + remote tmux.
  • Cold-start replay. Only when result.fresh is true does the node call pty.readScrollback(id) and replay it with \x1b[2J\x1b[H followed by a dim ── session restored ── marker. Warm reattach skips this because tmux already redraws.
  • Remote relay path. No local PTY is created. Frames arrive through window.termsprawl.relay.onFrame, are decoded with parseRelayTermFrame, and are written only when k === 'out' and term === data.relayTerm. Keystrokes become { v: 1, k: 'in', term, data }; resizes become { v: 1, k: 'resized', term, cols, rows }.
  • Failure surface. A rejected pty.create writes a red [spawn failed: …] line into the terminal; pty.onExit writes a dim [session ended] line but leaves the node mounted.

Mount lifecycle and PTY stream binding

The mount effect is gated on terminalSettings (loaded via window.termsprawl.settings.get(), defaulting fontFamily to Geist Mono, JetBrains Mono, monospace, profile and proxy to '') and re-runs on [id, data.cwd, data.command, isRemote, data.relayTerm, terminalSettings]. The terminal is constructed with fontSize: 13, cursorBlink: true and a hard-coded dark theme (#101010 background, #e8e8e6 foreground, #c6f135 cursor, rgba(198, 241, 53, 0.25) selection). FitAddon is loaded, term.open(host) mounts into hostRef, and fit.fit() runs immediately.

sequenceDiagram
  participant RO as useSafeResize
  participant X as xterm Terminal
  participant P as window.termsprawl.pty
  Note over X,P: local node
  P-->>X: onData(id) -> term.write(chunk)
  P-->>X: onExit(id) -> "[session ended]"
  X->>P: onData(chunk) -> pty.write(id, chunk)
  P-->>X: create(...) -> fresh ? readScrollback + replay
  RO->>X: fit.fit() (rAF-deferred, >=1px guard)
  X->>P: onResize(cols, rows) -> pty.resize(id, cols, rows)
  Note over X: remote node
  X->>X: relay.onFrame -> parse -> "out" -> term.write
  X->>X: onData -> relay frame "in"
  X->>X: onResize -> relay frame "resized" (only after attach)
Loading

The exact binding surface for a local node is pty.onData(id) → term.write, pty.onExit(id) → status line, term.onData → pty.write(id, chunk), and term.onResize → pty.resize(id, cols, rows). Disposal unsubscribes all four, nulls termRef/fitRef, then waits for term.write('', …) plus two requestAnimationFrames before term.dispose() — the comment explains that xterm parses writes and refreshes its viewport asynchronously, and a pending refresh can otherwise read already-disposed services. The relay branch follows the same drain-then-dispose pattern after removing the frame handler and input/resize disposables. An effect-local active boolean guards every async write-back.

Fit and resize pipeline

useSafeResize(hostRef, …) is the only place the node reacts to container-size changes. Its callback reads termRef.current/fitRef.current (so the mount effect retains ownership of their lifetimes), calls fit.fit(), then either forwards pty.resize(id, term.cols, term.rows) or returns early for remote nodes. The comment is explicit that a remote relay node sizes through its own onResize → 'resized' frame and "must never touch a local pty".

flowchart LR
  A[ResizeObserver delivery] --> B[judge only last entry<br/>last.contentRect]
  B --> C{"abs Δw < 1 and abs Δh < 1?"}
  C -- yes --> D[skip: subpixel feedback guard]
  C -- no --> E[store processed size<br/>cancel pending frame, schedule rAF]
  E --> F[onResize: fit.fit]
  F --> G{isRemote?}
  G -- no --> H[pty.resize id, cols, rows]
  G -- yes --> I[return; relay onResize sends resized]
Loading

useSafeResize encodes two hard-won rules:

  1. Loop-warning avoidance. Chromium's "ResizeObserver loop completed with undelivered notifications" fires when a callback synchronously mutates the observed element in the same frame (xterm fit() writes the host dimensions; Monaco layout writes its container). The hook defers the callback to requestAnimationFrame and ignores size deltas under 1px, killing subpixel feedback loops without dropping real resizes. It compares only the last entry of a batched delivery and against the last processed size — the comment notes a naive return-inside-the-loop-on-subpixel-entry previously desynced drag resizes permanently.
  2. Crash safety. A throwing ResizeObserver callback can blank the renderer. The observer body and the rAF callback are both wrapped in try/catch, and the hook never disconnects/re-observes from inside the callback; failures degrade to no-ops. The file explicitly warns against "improving" this back into a synchronous fit or a disconnect/re-observe dance.

At the message level, ro-noise.ts classifies the two benign reports — "ResizeObserver loop completed with undelivered notifications" (spec'd ErrorEvent) and "ResizeObserver loop limit exceeded" (older throwing variant) — via prefix match, case-sensitive so genuine errors that merely mention ResizeObserver still surface. Its comment attributes the noise to React Flow's internal node-wrapper observer plus NodeResizer's per-move style writes on every drag resize (NodeResizer isVisible={selected} minWidth={240} minHeight={140} is where those writes originate), and stresses that the message carries zero failure semantics. isResizeObserverNoise is not imported by TerminalNode.tsx in the read evidence; within this node the mitigation is structural (rAF deferral), while the predicate is the export used by a window-level error filter elsewhere.

Remote relay attach lifecycle

A second pair of effects drives relay nodes. One mirrors window.termsprawl.relay.status() and relay.onStatus into relayStatus (initial value 'disconnected'). The other runs only when isRemote && relayStatus === 'paired': it sets attachedRef.current = true, sends the attach frame, then sends an authoritative initial resized frame from the live terminal size. Its cleanup flips attachedRef back to false and sends detach. Because the effect depends on relayStatus and data.relayTerm, a reconnect re-attaches; attachedRef is what prevents pre-attach resized frames from being emitted by the mount effect. UI states observed as string comparisons: 'paired' (attached), 'connecting' → "connecting to relay peer", 'error' → "relay error", anything else → "waiting for relay peer".

Key state held by the node

State Purpose
hostRef xterm mount container; also the useSafeResize target
termRef / fitRef Live Terminal and FitAddon, read by the resize callback
ownerProjectIdRef Project id captured at mount — project switches update Zustand before Canvas swaps nodes, and reacting live would destroy the outgoing project's PTYs under the new id
attachedRef Relay attach gate for outgoing resized frames
relayStatus Relay connection string, remote nodes only
terminalSettings Settings snapshot; null blocks the mount effect entirely
integration agentTools.status(id) + agentTools.onStatus integration badge
agentStatus / hasUnread / agentHint Agent badge, unread dot, and badge visibility from the agent status store
editingTitle / titleDraft Inline header rename
active / alive Effect-local async guards

Agent-specific subscriptions only run when data.command is set: agent.onStatus for id plus, for resume commands, the original resumedSessionId(data.command); event.status === undefined lifecycle pings are ignored; cleanup clears both status entries. A parallel agent.onSessionName subscription mirrors transcript session names into the node title.

UI chrome and interaction boundaries

  • .terminal-node-host carries nodrag nowheel, so xterm owns mouse and wheel input; dragging happens through the header. The host's onContextMenu calls preventDefault() and stopPropagation() so tmux keeps right-click and neither React Flow's canvas menu nor the native menu opens.
  • The header holds the dot, the editable title (double-click to edit; Enter/blur commits, Escape cancels; the input is nodrag nowheel and stops pointer-down propagation), the integration badge (Connected / CLI fallback / Needs setup from integration.state), a remote badge, HelpBadge, the agent badge (RUNNING / NEEDS YOU / BLOCKED / DONE from STATUS_LABEL), an unread dot that clears on click, and the close button.
  • Renaming a Claude-backed node pushes /rename <title>\r through pty.write after updateNodeData(id, { title }, true), syncing the agent's own transcript session name.
  • Close semantics differ by branch: closeNode(id) asks Canvas to destroy the tmux session for local nodes, while the remote button's title is "Close terminal (detach from host)" — the host terminal keeps running. Plain React unmount only detaches the view.

Boundary conditions and extension points

  • Id stability is a contract. PTY session id = React Flow node id; renaming or regenerating ids respawns sessions.
  • Terminal creation waits for settings. If terminalSettings is null or the host ref is absent, the effect returns without creating a terminal.
  • Prop changes tear down the effect. data.cwd, data.command, isRemote, data.relayTerm and terminalSettings are all dependencies, so changing them disposes and rebuilds the xterm instance and its bindings.
  • Remote nodes never call pty.resize — the resize callback and the attach effect both route sizes through relay frames instead.
  • Replay is fresh-only. A warm tmux reattach trusts tmux to redraw.
  • Appearance is partly hard-coded. Font family is settings-driven, but font size and theme colors are literal options in the new Terminal({...}) call — retheming means editing that call.
  • Natural extension seams: adding an xterm addon happens next to term.loadAddon(fit); adding a new backing transport means adding a sibling to the isRemote branch that honors the same out-frame in / in-and-resized-frame out contract and the same drain-then-dispose cleanup; new status vocabularies map through STATUS_LABEL; any other node type that must fit content should reuse useSafeResize rather than a raw ResizeObserver.

Sources: src/renderer/src/nodes/TerminalNode.tsx, src/renderer/src/nodes/TerminalNode.tsx, src/renderer/src/nodes/TerminalNode.tsx, src/renderer/src/hooks/useSafeResize.ts, src/renderer/src/ro-noise.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally