Skip to content

perf(terminal): stop xterm redrawing the rows libghostty covers, and let screen readers read them - #2959

Merged
devswha merged 3 commits into
mainfrom
perf/ghostty-draw-cost
Nov 5, 2026
Merged

devswha merged 3 commits into
mainfrom
perf/ghostty-draw-cost

Conversation

@devswha

@devswha devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner

What

Settings > Terminal > Draw with libghostty (#2908) cost more than plain xterm on a busy pane, and screen readers read nothing from it.

  • xterm stops redrawing the rows libghostty covers. xterm keeps parsing every byte (its buffer still serves input, selection, search and the input row) and keeps firing its render events, but its DOM renderer's renderRows is a no-op while the layer draws (restored, with a full refresh, on dispose). Those hidden rows were about a third of the extra cost.
  • Unchanged rows are not rewritten: a row libghostty marks dirty that comes out with the same HTML is skipped. Row writes were already batched per animation frame.
  • Screen readers: the libghostty rows are no longer aria-hidden (pictures and cursor still are), and a visually hidden polite live region announces the lines a program writes, as xterm's screen reader mode does: the latest frame's new lines, at most 20, never the screen a pane opens on.

xterm.js has no public way to stop its renderer, so this reaches _core._renderService._renderer the way lib/terminalReplies.ts reaches _core.coreService; without it, xterm simply keeps drawing as before.

Measurements

Isolated hc 0.1.17 core + this branch's server, headless Chromium, 390x844 touch, CPU throttled 6x, seq 1 200000 in a herdr-core pane, runs alternating off/on (ABBA):

finish stalls > 50 ms p95 frame gap
main, off 7.5 / 8.4 / 9.7 s 1 / 2 / 9 50-83 ms
main, on 10.9 / 11.3 / 12.6 s 11 / 11 / 26 83-100 ms
this PR, off 9.9 / 10.8 / 12.4 s 2 / 7 / 23 67-83 ms
this PR, on 9.6 / 10.5 / 11.1 s 1 / 4 / 8 67-83 ms
this PR final build, off 12.1 / 16.4 s 29 / 57 100-117 ms
this PR final build, on 10.1 / 12.9 s 9 / 6 83 ms

(the machine was shared and loaded, so absolute numbers move between batches; within a batch on is now at or below off.)

Real NVDA 2026.2 in the Windows 11 test VM, Edge 156, the pane printing two marker lines: with the setting on NVDA spoke ECHOLINE-1 ECHOLINE-2 $; with it off (xterm, screen reader mode off) it spoke nothing for the same output.

Validation

  • bun scripts/terminal-ghostty-demo-regression.ts: new assertions: the screen a pane opens on is not announced, a line the program writes is, and xterm's hidden rows no longer receive it. They fail on main's overlay (nothing announced).
  • bun run check fast: pass (exit 0) on this head (merged with origin/main).

@coderabbitai

coderabbitai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 10 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 00421a3 and defed19.

Files selected for processing: 5

  • DESIGN.md
  • changelog.d/ghostty-draw-cost.md
  • scripts/terminal-ghostty-demo-regression.ts
  • src/lib/ghostty/overlay.test.ts
  • src/lib/ghostty/overlay.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Review round on a80e055 (omo BLOCK, Codex no HIGH). Resolved in the next commit:

  • MEDIUM, reproduced: once the screen was full, a scroll made every moved row look new, so ~20 old lines were re-read per new line. newLines() now picks the scroll shift that best matches the previous screen and announces only rows that came in at the bottom or changed in place (unit test src/lib/ghostty/overlay.test.ts; the browser regression prints 60 lines and then one, and checks only that one is announced).
  • MEDIUM, cause reproduced: a folded mirror (lib/terminalFold.ts) reads xterm's rows and cursor, which no longer updated. While the host is data-folded (the libghostty layer is hidden then), xterm draws again; the mute comes back when the fold ends.

@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Second review round on defed19: omo and Codex, no HIGH. Per the lane rule (one extra fix round), the rest is recorded here instead of another round:

  • MEDIUM, reproduced: viewport scrollback announces scrolled-in rows; a resize/font change makes the next line announce the whole screen. Both in follow-up libghostty screen reader: scrollback and resize make the live region re-read the screen #2963.
  • LOW, reproduced: a screen made entirely of one repeated line stops announcing further identical lines (newLines sees a pure scroll). The written-bytes flag from the follow-up covers it.
  • LOW, not reproduced: the live region sits inside .ghostty-layer, hidden while folded, so a folded pane announces nothing (on main the folded view was not readable either).
  • LOW, not reproduced: fold cursor read from xterm rows; this head already lets xterm draw while data-folded.

Validation on defed19: bun run check fast pass; bun test src/lib/ghostty/overlay.test.ts pass; bun scripts/terminal-ghostty-demo-regression.ts pass (includes the 60-line scroll then one new line check). Real NVDA was run on the first head, not re-run after this commit.

@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Merge note: all GitHub checks green at this head, check fast passed, omo + Codex second round: no HIGH (the two reproduced MEDIUMs are #2963; LOWs in the PR comment), threads resolved. Low-end CPU x6 runaway output, ABBA: ghostty on now ties or beats xterm (median 10.5 s vs 10.8 s, stalls 4 vs 7). NVDA reads new output with ghostty on (not with xterm).

@devswha
devswha merged commit bbad99c into main Nov 5, 2026
15 checks passed
@devswha
devswha deleted the perf/ghostty-draw-cost branch November 5, 2026 11:43
devswha added a commit that referenced this pull request Nov 5, 2026
…#2976)

## What

Settings > Terminal > Draw with libghostty (#2908) is on by default.

- It still only draws on a herdr-core server (`terminal-history`, as the
desktop app runs); on herdr and mirrored panes xterm.js draws exactly as
before.
- The setting stays, per device. A device that turned it off stays off:
a stored `false` is kept, only a missing or invalid value means on.
Note: since #2908 every settings save writes this key, so a device that
saved any setting on a main build after #2908 also has `false` stored
and stays off; released versions never had the key, so their users get
the new default.

## Gates (step 1 checklist, measured on isolated hc 0.1.17 + this stack)

| Gate | Result |
|---|---|
| Phone first load (slow 4G 1.6 Mbps/150 ms, CPU x4, fresh profile,
median of 5) | off 7,510 ms / on 7,918 ms: +408 ms, within +0.5 s (wasm
served br since #2953: 1.11 MB -> 345 KB, re-open fetches no wasm) |
| Low-end phone frames (CPU x6, `seq 1 200000`, ABBA) | after #2959 on
is at or below off: median finish 10.5 s vs 10.8 s, stalls > 50 ms 4 vs
7 |
| Screen reader (real NVDA 2026.2, Windows 11 VM, Edge 156) | on: NVDA
reads new output; off (xterm, screen reader mode off): reads nothing.
Known follow-ups in #2963 |
| iOS long-press copy (iOS 27 Simulator, Safari) | **provisionally
equal**: neither xterm nor libghostty shows a selection/copy menu on a
1.6 s long-press (driven with simctl + AXe touch). Pending one manual
check once Simulator components are installed on the test Mac. Copy
keeps working through the key bar's copy button and OSC 52 (#2925) |

## Validation

- `bun test src/lib/settings`: pass (the two tests that pinned the old
default now pin the new one: missing/invalid means on, a stored `false`
stays off, the key stays out of the whole-record save).
- `bun scripts/terminal-ghostty-demo-regression.ts`: pass (herdr-core
pane drawn with libghostty, herdr's own and off stay xterm).
- `bun run check fast`: pass (exit 0) on this head
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant