Repository navigation
perf(zerolag): rebuild only the changed overlay line, one paint layer per line - #713
Merged
Merged
Conversation
… per line The local echo overlay rebuilt every character of the prompt on each keystroke, one absolutely positioned span per character, so a keystroke cost O(prompt length) in DOM work, style, layout and paint. Every positioned span was also its own paint layer, so the browser re-layerized and repainted thousands of them per frame. In headless Chromium with a 6x CPU slowdown, a ~4,100-character prompt ran at a median 813 ms per frame, and ~300 characters already missed 60 fps (54 ms). renderOverlay() now keeps a per-container record of the line elements it drew and rebuilds only lines whose text, position or composition range changed. It starts over when the font, colour, cell size or Unicode version changes, or when something else emptied or edited the container. makeLine() lays the spans out in a flex row instead of positioning each one, and sets the shared font styles once on the line. Cell edges are snapped to a quarter pixel, which Blink, WebKit and Gecko all store exactly, so the widths add up to the grid without drift and every edge stays within 1/8 px of col * cellW. Same prompt: 17 ms per frame. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N
marked this pull request as ready for review
October 25, 2026 14:49
Ark0N
pushed a commit
that referenced
this pull request
Oct 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
xterm-zerolag-inputis the package behind Codeman's local echo: it draws what you type on top of the terminal immediately, before the session echoes it back, which is what makes typing on a phone over a slow link feel instant. Its renderer rebuilt the whole prompt on every keystroke, one absolutely positioned<span>per character, so each key cost more the longer the prompt was. On a phone-class CPU it already drops below 60 fps at a few hundred characters (46 to 54 ms frames across two runs while the prompt grows from 200 to 400), and at about 4,000 characters a keystroke takes most of a second to appear.This PR changes only that renderer (
packages/xterm-zerolag-input/src/overlay-renderer.ts), its tests, the READMEs that describe it and a changeset. No server, API orapp.jschanges, andPredictiveEchoAddon(the Codex echo) is untouched: its renderer already adds and removes one span per unconfirmed keystroke.What changes
renderOverlay()keeps a record (aWeakMapkeyed by the overlay element) of the line elements it drew and the inputs each depended on, and skips any line whose text, position and composition range are unchanged. Typing a character changes just the last line. It starts over when the font, colour, cell size or the terminal's Unicode version changes, or when something else emptied or edited the overlay element (the addon's hide path setsinnerHTML = '').makeLine()the spans are no longerposition:absolute. Chromium gives every positioned element its own paint layer, so even with (1) alone the browser still re-layerized and repainted thousands of them per frame. The spans now sit in a flex row (flex:none, explicit width), and the font, colour, line height, centring and ligature settings are set once on the line div and inherited.The flex row is what normally makes DOM text drift off a canvas grid, so the widths are snapped: every cell edge is
round(col * cellW * 4) / 4, and each span is the difference between two edges. A quarter pixel is stored exactly by Blink and WebKit (1/64 px layout units) and Gecko (1/60 px app units), so the widths add up to the snapped edge with no accumulated drift, and every edge stays within 1/8 px ofcol * cellW.Why I believe this was the cost
Headless Chromium, real xterm 6 with the WebGL renderer, a buffer with 3,000 lines of scrollback and a
❯prompt, the bundle Codeman ships (LocalEchoOverlay). "Work" is the median script plus style and layout per keystroke (forced synchronously); "frame" is the median frame time with one keystroke per frame (16.7 ms = 60 fps). The phone-class rows use CDP CPU throttling at 6x. The table is from a run on the pushed commit; an earlier run (the one quoted in the package README) differed by up to about 15%, master at ~4,100 characters measuring 813 ms there.The same holds in the real app. I ran this branch and master (from a clean detached worktree of
origin/master) as isolated Codeman instances (ownCODEMAN_INSTANCE, so their own data dir and tmux socket), opened a shell session in headless Chromium, typedecho xxx… END(about 1,500 characters) through the page's own keystroke path with local echo on, and timed frames over the last 300 keys: master a median 58.8 ms per frame (p95 103) unthrottled and 423 ms (p95 567) at 6x, this branch 16.7 ms (p95 18.8) and 20.4 ms (p95 36.5). Enter then sent the whole line, the overlay cleared, and the pane printed the command's output in both.The real-app run and the traces below used the commit before a last, render-neutral tweak (the record's validity check now also compares child order); the table and the geometry check were rerun on the pushed commit.
A Chromium trace over 60 frames at ~4,100 characters (phone-class) shows where it went: Paint 28.6 s on master vs 0.47 s here, Layout 16.2 s vs 0.74 s, script 15.6 s vs 0.34 s, style 9.2 s vs 0.18 s. A first version with only change (1), still with absolute spans, sat at 66 ms frames, and its trace was dominated by Paint (1.7 s) and Layerize (1.2 s): that is why change (2) is in here too.
Is the rendering the same?
Measured, not assumed: I rendered the same sequences with master's bundle and this one (mixed ASCII and Japanese wrapped over several lines, 40 backspaces, an IME composition, a 130-character paste, and a 9,000-character prompt taller than the viewport) and compared every glyph span's bounding box and computed style (colour, font family, size and weight, line height, alignment, underline, font features, pointer events) plus each line div's box and background.
Reviewing the diff
src/overlay-renderer.tsis the real change:cellEdge(), theRenderedLinesrecord and its validity check, the reuse loop inrenderOverlay(), and the flex layout inmakeLine().span.style.left(which no longer exists) now reads the left edge from the newlineSpans()helper intest/helpers.ts, which adds up the widths before it the way the browser does, and assertions on styles that moved to the line now check the line. A few cases that used a cell width of 8.4 px now use a whole-pixel width so the expected numbers stay exact, and the fractional case is covered by its own drift test.rebuilding only the lines that changedblock (append keeps earlier line elements, cursor stays last, backspace across a wrap, cursor removal, font/colour/cell-size/Unicode-version rebuilds, an emptied, reordered or edited container, a composition on the last line, two containers). Forcing a full rebuild on every render (making the reset condition always true) failsappending to the last line keeps every earlier line elementanda composition growing on the last line leaves the lines it does not reach alone, so they do test the reuse.Risk
styleKeyor the per-line key, or a reused line keeps the old look. The keys are built in one place, next tomakeLine()'s inputs. Likewise, anything else that writes into the overlay element must empty it (as_hide()and the render error path do) or go throughrenderOverlay(); a container whose children no longer match the record (count, order or parent) is rebuilt from scratch.Testing
packages/xterm-zerolag-input: 276 tests pass (261 before, all still passing after the assertion updates; 15 new). Typecheck clean.npm run typecheck,npm run lint,npm run format:checkandnpm testpass.npm run test:browser -- test/mobile-ime-preview.browser.test.ts test/capture-geometry-retry.browser.test.ts: 16 of 16 pass.npm run test:mobile -- test/mobile/keyboard.test.ts: 47 of 52 pass. The same 5 fail on master (layout and tap-focus tests; the one about CJK local echo swaps the overlay for a mock), so nothing new.Related
#711 (hold the PTY rows while the on-screen keyboard is up) and #608 (Codex compose) also touch this package, in
zerolag-input-addon.ts,predictive-echo-addon.tsand other README sections. None of their hunks overlap this one, and the renderer takes whatever row count the addon passes it.