-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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.
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]
Key branch points:
-
Local / SSH path. Output listeners are registered before
pty.createexplicitly "so no early data is lost". The create payload carriesid(node id),projectId: ownerProjectIdRef.current ?? undefined, liveterm.cols/term.rows,data.cwd,data.command,terminalSettings.profile(terminalProfile) andterminalSettings.proxy(httpProxy). If the owning project has aremotedescriptor (getOwningRemotereadsuseProjects.getState().projects), that descriptor is spread into the create call — the comment notes a remote project's terminal runs overssh -tt+ remote tmux. -
Cold-start replay. Only when
result.freshis true does the node callpty.readScrollback(id)and replay it with\x1b[2J\x1b[Hfollowed 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 withparseRelayTermFrame, and are written only whenk === 'out'andterm === data.relayTerm. Keystrokes become{ v: 1, k: 'in', term, data }; resizes become{ v: 1, k: 'resized', term, cols, rows }. -
Failure surface. A rejected
pty.createwrites a red[spawn failed: …]line into the terminal;pty.onExitwrites a dim[session ended]line but leaves the node mounted.
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)
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.
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]
useSafeResize encodes two hard-won rules:
-
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 torequestAnimationFrameand 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. -
Crash safety. A throwing
ResizeObservercallback can blank the renderer. The observer body and the rAF callback are both wrapped intry/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.
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".
| 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.
-
.terminal-node-hostcarriesnodrag nowheel, so xterm owns mouse and wheel input; dragging happens through the header. The host'sonContextMenucallspreventDefault()andstopPropagation()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 nowheeland stops pointer-down propagation), the integration badge (Connected/CLI fallback/Needs setupfromintegration.state), aremotebadge,HelpBadge, the agent badge (RUNNING/NEEDS YOU/BLOCKED/DONEfromSTATUS_LABEL), an unread dot that clears on click, and the close button. - Renaming a Claude-backed node pushes
/rename <title>\rthroughpty.writeafterupdateNodeData(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.
- Id stability is a contract. PTY session id = React Flow node id; renaming or regenerating ids respawns sessions.
-
Terminal creation waits for settings. If
terminalSettingsisnullor the host ref is absent, the effect returns without creating a terminal. -
Prop changes tear down the effect.
data.cwd,data.command,isRemote,data.relayTermandterminalSettingsare 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 theisRemotebranch that honors the sameout-frame in /in-and-resized-frame out contract and the same drain-then-dispose cleanup; new status vocabularies map throughSTATUS_LABEL; any other node type that must fit content should reuseuseSafeResizerather than a rawResizeObserver.
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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance