Five Agents in Split Panes: How xterm.js Draws Text With a WebGL Glyph Atlas
Book: System Design Pocket Guide: Interviews Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools Me: xgabriel.com | GitHub You split the window five ways. Claude Code is printing a diff in one pane, Codex is running a test suite in another, and a third agent is dumping a 4,000-line build log because you asked it to "check why CI fails." Every one of those panes is a terminal emulator, and in an Electron or Tauri app, or in a browser tab, every one of them is JavaScript drawing characters into the same web page. Nobody picks an agent setup by asking how the terminal paints glyphs. But when five streams arrive at once, the renderer decides whether the window stays responsive or whether your keystrokes land late. Here is how xterm.js, the terminal component inside VS Code, actually puts text on screen, and a small page you can use to measure it on your own machine. A terminal is a grid with a 16.6 ms budget A terminal emulator holds a grid of cells. Each cell has a character, a foreground color, a background color and style flags. Bytes from the process go through a parser that updates the grid, and a renderer turns the visible rows into pixels. At 60 frames per second you get 16.6 ms per frame for everything on the page's main thread. That number is the budget the VS Code team used when they moved the terminal renderer from the DOM to canvas in 2017, and it still frames the problem. xterm.js does not redraw on every write. Its RenderDebouncer asks for one requestAnimationFrame and, while that request is pending, widens the dirty range with Math.min on the start row and Math.max on the end row. A thousand writes inside one frame become one render of the rows they touched. Only rows in the viewport get drawn, never the scrollback. So the renderer's cost is per frame, not per byte. The parser is the part that scales with output. Keep those two apart, because they behave differently when you add panes. The DOM renderer: spans, layout, garbage The renderer built into xterm.js core builds HTML. Since xterm.js 6.0 removed the canvas addon, it is that or WebGL. For each dirty row, DomRenderer calls replaceChildren with fresh nodes from DomRendererRowFactory. The factory is careful: it creates one per run of adjacent cells that share background, foreground and style, rather than one per cell. Plain log lines collapse into a few spans per row. Color-heavy output does not collapse. Agent output is full of ANSI color: green tool names, red diff lines, dim timestamps. Every color change starts a new span. After that the browser has to style, lay out and paint those nodes, and the old nodes become garbage. The 2017 VS Code post put the ceiling plainly: with a DOM renderer, "performance will always have the hard cap imposed by the layout engine." It also says that composing and laying out the elements "could take longer than a frame (16.6ms) all by itself," and that under heavy output the old renderer dropped "below 10 FPS" (source). That post describes an older DOM renderer, but the shape of the cost is the same: more spans per frame, more layout per frame. The atlas idea: draw each glyph once into a texture, then copy tiles out of it for every cell that needs that glyph. The WebGL renderer: rasterise on first use, then copy quads @xterm/addon-webgl replaces all of that with a WebGL2 renderer. The trick is the glyph atlas. The first time a cell needs a glyph in a given style, TextureAtlas draws it with the 2D canvas API into a texture page and caches its coordinates. The cache key is the character plus its background, foreground and extended attributes, so a red x and a green x are two entries. Every later cell with that same key is a lookup, not a draw. Pages start at 512 px; when the atlas runs out of page slots, four same-sized pages are merged into one page twice as wide and twice as tall, as long as that stays under the texture-size limit (4096 px when no other limit is set). If they can't be merged, the atlas is evicted and starts over. During idle callbacks it also pre-fills printable ASCII so the first screen of output is mostly cache hits. Then the drawing. GlyphRenderer keeps a typed array with 11 floats (44 bytes) per cell: offset, size, texture page, texture coordinates, texture size and cell position. Each of those attributes advances once per instance. The whole visible grid then goes to the GPU in a single call: gl.drawElementsInstanced( gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / Constants.INDICES_PER_CELL ); That is one unit quad with four vertices, drawn once per cell. The CPU's job per frame shrinks to filling a float array for the dirty rows; there are no DOM nodes to lay out and no discarded spans to collect. The fragment shader picks the right texture page from an array of samplers, up to 32 pages or the device's texture-unit limit, whichever is lower. One detail matters for split panes. In CharAtlasCache, terminals whose font and color configuration match share one atlas: the cache finds an entry with an equal config and adds the new terminal to its ownedBy list. Five panes with the same theme rasterise a glyph once, not five times. What WebGL does not fix The xterm.js flow control guide puts the terminal's input throughput at "5 - 35 MB/s", against producers that can push "several GB/s." Most of that is parsing, and the parser is the same code under both renderers. write() buffers and returns at once, and the buffer has a hard limit: "Any data beyond that limit gets discarded." Five agents flooding at once still share that parsing time on one main thread. WebGL frees up the frame budget the DOM renderer was spending on layout, and that time goes back to parsing and to your input. WebGL can also go away under you. The addon's README warns that browsers "may drop WebGL contexts for various reasons like OOM or after the system has been suspended," and exposes onContextLoss so the app can dispose the addon. When an app does that, it ends up back on the DOM renderer. VS Code's default, per its terminal appearance docs, is auto: try WebGL, "fall back to the DOM renderer" if that fails. The same page lists Linux VMs, browsers without WebGL and outdated drivers as places where WebGL may not work well. In code, the defensive load looks like this: import { WebglAddon } from '@xterm/addon-webgl'; function loadWebgl(term) { try { const webgl = new WebglAddon(); webgl.onContextLoss(() => webgl.dispose()); term.loadAddon(webgl); return true; } catch { return false; // stays on the DOM renderer } } If you have ever seen one pane in a terminal app suddenly feel slower after the laptop woke from sleep, a dropped context and a silent fallback is one candidate worth checking. A benchmark you can run Save this as bench.html, serve the folder (npx serve . works), and open it twice: once with ?r=dom and once with ?r=webgl. It writes about 200,000 colored log lines in 200 chunks, waits for each chunk's write callback, and records how often the page managed to paint while it was busy. import { Terminal } from 'https://esm.sh/@xterm/xterm@6.0.0'; import { WebglAddon } from 'https://esm.sh/@xterm/addon-webgl@0.19.0'; const q = new URLSearchParams(location.search); const mode = q.get('r') ?? 'dom'; const term = new Terminal({ scrollback: 1000 }); term.open(document.getElementById('t')); if (mode === 'webgl') term.loadAddon(new WebglAddon()); const line = (i) => `\x1b[32m[agent]\x1b[0m step ${i} src/f${i % 97}.ts ` + `\x1b[33mok\x1b[0m ${'.'.repeat(i % 40)}\r\n`; const chunks = []; for (let c = 0; c < 200; c++) { let s = ''; for (let i = 0; i < 1000; i++) s += line(c * 1000 + i); chunks.push(s); } let frames = 0, worst = 0, last = performance.now(); let running = true; const tick = (now) => { frames++; worst = Math.max(worst, now - last); last = now; if (running) requestAnimationFrame(tick); }; requestAnimationFrame(tick); const t0 = performance.now(); for (const chunk of chunks) { await new Promise((done) => term.write(chunk, done)); } running = false; const ms = performance.now() - t0; document.getElementById('out').textContent = `${mode}: ${ms.toFixed(0)} ms, ${frames} frames, ` + `worst gap ${worst.toFixed(1)} ms`; Read the three numbers together. Total time is mostly parser time, so expect it to move less than you'd guess. Frame count and worst gap are the renderer's story: how often the page got to paint, and the longest stretch where it couldn't. A long worst gap is the keystroke that shows up late. To mimic five panes, duplicate the div and the terminal five times and write to all of them in the same loop. I haven't run this across a range of machines, so I won't quote numbers. Run it on yours, and on the oldest laptop you can borrow. The write callback fires when the parser finishes a chunk, per the write API docs, not when pixels hit the screen, so the final frame lands a moment after the total. Where Hermes IDE sits in this I build Hermes IDE, so weigh this section with that in mind. Hermes runs each agent (Claude Code, Codex, Antigravity CLI and others) in its own real terminal, with split panes so sessions sit side by side. Those terminals are xterm.js: the package.json pins @xterm/xterm 6.0.0 and depends on @xterm/addon-webgl. The README describes the rendering as "GPU-accelerated (WebGL) where the system supports it," which is the same try-WebGL, keep-DOM-as-fallback idea VS Code uses. Hermes 2.3.4: Codex and Claude Code in split panes, each in its own hermes/ worktree, with the sessions sidebar showing status, context use, cost and branch. None of that is specific to Hermes. Any app built on xterm.js can load the same addon, and VS Code has done so by default since its 1.55 release. Hermes is free to download, source-available under BSL 1.1, runs on macOS, Windows and Linux, and is not affiliated with any agent vendor. Download it here, or read the code on GitHub. If this was useful The glyph atlas is a small system-design lesson hiding in a terminal: a cache keyed on exactly what makes an entry unique, an eviction plan for when it fills, batching a thousand updates into one unit of work per frame, and sharing one expensive resource across tenants with the same config. Those are the trade-offs interviewers want you to say out loud. The System Design Pocket Guide: Interviews walks through 15 real designs step by step, with that reasoning on every page.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to