Skip to content

Materialize libghostty scrollback into the Emacs buffer#73

Merged
dakra merged 5 commits intomainfrom
scrollback-in-buffer
Apr 11, 2026
Merged

Materialize libghostty scrollback into the Emacs buffer#73
dakra merged 5 commits intomainfrom
scrollback-in-buffer

Conversation

@dakra
Copy link
Copy Markdown
Owner

@dakra dakra commented Apr 10, 2026

Summary

  • Mirror libghostty's scrollback into the Emacs buffer so isearch, consult-line, swiper, occur, and any other buffer-based command work over the entire history without entering copy mode (vterm parity)
  • When a row scrolls off in libghostty, promote the existing top viewport row to scrollback by bumping a counter — the buffer text is never touched, so URL detection / ghostel-prompt text properties survive automatically: scrollback URLs stay clickable
  • Lower the default ghostel-max-scrollback from 20 MB to 5 MB (~5,000 rows on an 80-col terminal) to bound the new Emacs heap cost
  • Simplify copy mode to just "freeze the redraw timer + swap keymap"; remove ghostel-copy-mode-load-all, C-c C-a, ghostel-copy-mode-auto-load-scrollback, ghostel--copy-mode-full-buffer, and ghostel--redraw-full-scrollback
  • Preserve point in scrollback when live output arrives (you can scroll up to read history while a command is running without getting yanked back)

Background

Before this PR, ghostel's Emacs buffer only ever held the visible viewport (~24 rows). Full scrollback lived inside libghostty and was materialized on demand via C-c C-aghostel-copy-mode-load-all. Consequence: any buffer-based search command only saw the current screen.

vterm gets full-history search for free because libvterm fires a term_sb_push callback for each scrolled-off row. libghostty exposes no equivalent callback, so this PR polls getTotalRows() against a tracker on every redraw and synthesizes the same effect.

How it works

src/render.zig redraw() flow:

  1. Compute delta = libghostty_total_rows - rows - scrollback_in_buffer.
  2. If delta > 0: walk forward delta newlines from viewport_start_int. Whatever rows we walk past become scrollback simply by bumping scrollback_in_buffer — no fetch, no re-render. Any text properties on those rows survive because the text isn't touched. (The trailing cursor row, which has no terminating \n, is detected via (char-before) and excluded so it isn't promoted as a stale row.)
  3. If the buffer doesn't have enough viewport rows yet (cold start, post-resize, large bursts), fall back to insertScrollbackRange for the rest, which pages libghostty's viewport across the requested range.
  4. If delta < 0 (libghostty's scrollback cap evicted rows), trim from the top.
  5. Render the viewport anchored at viewport_start_int instead of point-min (deleteRegion(viewport_start, point-max) on full redraw, forwardLine offsets on partial redraw).

ghostel--detect-urls now takes optional (begin end) bounds; the redraw passes the viewport region only, avoiding O(N²) scans of the growing buffer.

Performance trade

Real-world PTY benchmark, 1 MB streamed through cat, all backends with 1000-line scrollback (the bench had a unit-mismatch bug where ghostel was getting 1000 bytes while vterm was getting 1000 lines — fixed in this PR for fair comparison):

Backend Plain ASCII URL-heavy
ghostel 64 MB/s 22 MB/s
ghostel (no detect) 65 MB/s 61 MB/s
vterm 28 MB/s 23 MB/s
eat 4.0 MB/s 3.1 MB/s
term 5.0 MB/s 4.2 MB/s

ghostel is ~7 % slower than its own pre-PR baseline (per-redraw bookkeeping) but still ~2.3× faster than vterm.

The synthetic streaming bench (in-process writes + periodic redraw) is bounded by scrollback size in the new model: at the new 5 MB default it runs at ~48 MB/s vs ~59 MB/s baseline. TUI apps (vim, htop) are unaffected — alt-screen content doesn't go through the scrollback path.

Test plan

  • make build — native module builds clean (zig 0.15.2)
  • make test — 54/54 pure Elisp tests pass
  • make test-all — 94/94 ghostel tests pass (54 elisp + 40 native module), including 3 new tests:
    • ghostel-test-scrollback-in-buffer — scrolled-off rows live in the buffer
    • ghostel-test-scrollback-grows-incrementally — earlier rows survive subsequent redraws (exercises the partial-promotion path)
    • ghostel-test-scrollback-preserves-url-properties — URL help-echo properties survive being scrolled into the materialized scrollback
  • make test-evil — 29/29 evil-mode tests pass
  • make lintpackage-lint and checkdoc clean
  • Manual: open ghostel, run find /usr -type f | head -500, M-x isearch-forward for an early line — finds it without entering copy mode
  • Manual: scroll up into history during live output, point stays put instead of jumping back to the prompt
  • Manual: enter copy mode, navigate up, exit copy mode, cursor jumps back to the input prompt
  • Manual: alt-screen apps (vim, less) round-trip cleanly without leaking phantom rows into scrollback

Known follow-ups

  • Stale scrollback under sustained streaming past the cap: when libghostty's scrollback is at the cap and rows are being evicted in lockstep with new rows arriving, the delta detection sees delta == 0 and doesn't update the buffer. The buffer's scrollback can show old rows that have since been evicted. Doesn't affect normal interactive use; would need a "force-rebuild on cap-bound steady state" check to fix.
  • URL detection in bootstrap-fetched scrollback rows: rows fetched via insertScrollbackRange (rare — only on cold start, post-resize, or huge bursts) don't get URL detection applied. The common case (interactive use) is fine because rows pass through the viewport first.
  • Streaming throughput optimization: the bootstrap-fallback insertScrollbackRange does ~5 Elisp calls per row. Batching into a single multi-row env.insert would meaningfully improve the worst-case streaming bench. Not pursued in this PR.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR changes ghostel’s rendering model so libghostty scrollback is continuously materialized into the Emacs buffer, enabling buffer-based search/navigation over full terminal history without entering copy mode, while simplifying copy mode and bounding memory usage via a lower default scrollback cap.

Changes:

  • Track and synchronize libghostty scrollback rows into the Emacs buffer during each redraw (including trimming when libghostty evicts rows).
  • Simplify copy mode to freeze live redraw and rely on normal Emacs buffer navigation (removing “load all scrollback” flows).
  • Reduce default ghostel-max-scrollback (and align benchmark configuration) to limit the new Emacs-side heap cost.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/render.zig Implements growing-buffer redraw, scrollback promotion/trim, viewport-anchored rendering, and bounds URL detection to viewport.
src/terminal.zig Adds scrollback_in_buffer state and resets it on resize due to reflow.
src/module.zig Lowers default scrollback size, removes full-scrollback redraw API, and clears buffer on resize to force rebuild.
src/emacs.zig Adds char-before to the pre-interned symbol cache used by render logic.
ghostel.el Removes full-buffer copy-mode machinery, switches scroll wheel behavior to Emacs scrolling, bounds URL detection, and preserves point while reading scrollback.
test/ghostel-test.el Adds tests for in-buffer scrollback growth and property preservation; updates copy-mode/clear-screen tests to match new model.
README.md Updates user docs for always-materialized scrollback, new default size, and revised benchmark numbers.
bench/ghostel-bench.el Fixes scrollback unit mismatch by converting benchmark “lines” into bytes for ghostel--new.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

dakra added 5 commits April 11, 2026 22:22
The Emacs buffer now mirrors libghostty's full scrollback above the
viewport, so isearch, consult-line, swiper, occur, and any other
buffer-based command work over the entire history without entering
copy mode.

How it works (src/render.zig redraw):
- Track scrollback_in_buffer in the Terminal struct (src/terminal.zig).
- Each redraw polls libghostty's total_rows. When new rows have
  scrolled off, promote the existing top viewport rows to scrollback
  by bumping the counter -- the buffer text is never touched, so any
  text properties applied while the row was the viewport (URL
  detection, ghostel-prompt) survive automatically.
- Bootstrap fallback: when the buffer doesn't have enough viewport
  rows yet (cold start, post-resize, large bursts), fetch the
  remaining rows from libghostty via insertScrollbackRange, which
  pages the libghostty viewport across the requested range. Each row
  is built into text_buf with its trailing newline appended in-buffer
  so the row + newline goes through a single env.insert call.
- forward-line counts the position past the last char as a moveable
  "line" when the buffer doesn't end in \\n -- that position is the
  terminal cursor row, not a real scrollback row, so detect it via
  char-before and decrement the promotion count.
- Trim from the top when libghostty's scrollback cap evicts old rows.
- Anchor the viewport render at viewport_start_int instead of
  point-min: deleteRegion(viewport_start, point-max) on full redraw,
  forwardLine offsets on partial redraw, applyHyperlinks gets a
  viewport_start parameter, cursor positioning uses it as base.
- ghostel--detect-urls now takes optional (begin end) bounds and the
  redraw passes the viewport region only -- avoids O(N^2) scans of
  the growing buffer.

Copy mode is now just "freeze the redraw timer + swap keymap":
- Removed ghostel--copy-mode-full-buffer state and the
  ghostel-copy-mode-load-all command (and its C-c C-a binding).
- Removed ghostel-copy-mode-auto-load-scrollback custom and the
  ghostel--redraw-full-scrollback module binding.
- Copy-mode scroll/navigation commands use plain Emacs primitives
  (scroll-up/down, forward-line, recenter) instead of toggling
  libghostty viewport scrolling.
- ghostel-copy-mode-exit moves point to point-max before invalidating
  so the redraw is allowed to position point at the terminal cursor
  (otherwise the new point-preservation logic would keep point in
  the scrollback region where the user was navigating).

Live-output point preservation:
- Track the terminal row count as ghostel--term-rows (set when the
  terminal is created/resized).
- ghostel--delayed-redraw saves point as a marker when point is in
  the scrollback region (above the last term-rows lines) and restores
  it after the redraw, so scrolling up to read history during live
  output no longer yanks the user back to the cursor.

Resize:
- fnSetSize now erases the buffer after term.resize, and
  Terminal.resize resets scrollback_in_buffer = 0. Reflow invalidates
  the row layout, so the next redraw rebuilds via the bootstrap path.

Default scrollback lowered from 20 MB to 5 MB:
- The new growing-buffer model materializes scrollback into the Emacs
  buffer with text properties on every cell, so the cost is no longer
  just libghostty's compact byte storage.
- Streaming throughput is bounded by scrollback size: at 20 MB it
  runs at ~25 MB/s, at 10 MB ~38 MB/s, at 5 MB ~48 MB/s, at 1 MB
  ~59 MB/s. 5 MB still holds ~5,000 rows on a typical 80-column
  terminal -- plenty for build outputs, grep results, log tailing --
  while keeping the per-redraw cost close to the no-scrollback
  baseline.
- Updated the docstring, README, and the C default fallback in
  fnNew to reflect the new memory model.

Bench fix (bench/ghostel-bench.el):
- ghostel-bench-scrollback is documented as "scrollback lines" and
  passed to vterm/term as lines, but ghostel--make-ghostel was
  passing it directly to ghostel--new which interprets it as bytes.
  At the default value of 1000 this meant vterm got 1000 lines of
  scrollback while ghostel got 1000 bytes (effectively zero rows),
  giving ghostel an unfair head start in the comparison. Convert
  to bytes (* 1024) so all backends test against ~1000 lines.

README perf numbers refreshed against the fair comparison and the
new growing-buffer model: ghostel 64 MB/s (was 72), vterm 28 MB/s
(was 33). Ghostel is still ~2x faster than vterm on PTY plain ASCII.
The drop versus the old 72 MB/s number reflects both (a) the new
model paying a small per-redraw cost for scrollback bookkeeping
even at zero scrollback and (b) the bench fix making the comparison
honest.

Tests (test/ghostel-test.el):
- New: ghostel-test-scrollback-in-buffer (12 rows into a 5-row term,
  assert scrolled-off rows live in the buffer).
- New: ghostel-test-scrollback-grows-incrementally (two-batch scroll,
  assert all earlier rows survive subsequent redraws -- exercises
  the partial-promotion path).
- New: ghostel-test-scrollback-preserves-url-properties (write a row
  with a URL, redraw, scroll the row off, redraw, assert the URL's
  help-echo property is still attached -- the regression test for
  "URLs in scrollback are clickable").
- Removed ghostel-test-copy-mode-load-all (function gone).
- Replaced ghostel-test-copy-mode-full-buffer-scroll with
  ghostel-test-copy-mode-buffer-navigation.
- Rewrote ghostel-test-copy-mode-recenter from 60 lines of mocks to
  6 lines verifying it delegates to recenter.
- Updated the scroll-event tests to mock scroll-up/scroll-down.
- Updated ghostel-test-clear-screen to check the materialized
  scrollback directly instead of relying on the legacy
  viewport-scroll behavior.
Follow-up correctness fix on top of the scrollback-in-buffer commit,
in a separate commit so it can be reverted independently.

When libghostty's scrollback hits its byte cap and starts evicting
the oldest rows in lockstep with new ones being pushed, the row at
scrollback index 0 changes underneath us. The normal delta-detection
in redraw() tracks `total_rows` deltas, but those don't capture
content rotation — and worse, the existing trim path (delta < 0)
removes our top rows under the assumption they match the rows
libghostty just evicted, which isn't true after rotation has shifted
the content.

User-visible symptom: after sustained streaming past the cap,
isearch / consult-line over the buffer's scrollback returns rows
that no longer exist in libghostty.

Fix:
- Add `wrote_since_redraw: bool` and `first_scrollback_row_hash: u64`
  to the Terminal struct.
- vtWrite sets `wrote_since_redraw = true`. The end of redraw clears
  it. resize() also clears `first_scrollback_row_hash` because reflow
  invalidates the row content.
- Add `computeFirstScrollbackRowHash` helper: scrolls libghostty's
  viewport to the top, reads the first row's first ~16 cells, mixes
  them into an FNV-1a 64-bit hash, restores the viewport. Six
  libghostty calls — cheap, gated to only run when rotation is
  suspected (writes happened + we have scrollback + we have a stored
  hash).
- At the start of redraw, before the existing delta sync, run the
  rotation check. If the stored hash differs from the freshly
  sampled hash, libghostty's scrollback has rotated underneath us:
  eraseBuffer, set scrollback_in_buffer = 0, force a full redraw.
  The delta-sync below will then see libghostty_sb - 0 = libghostty_sb
  and refetch everything fresh via insertScrollbackRange.
- After the delta-sync, update the stored hash whenever we have
  scrollback so the next redraw has a fresh baseline.

Why hash-the-row instead of comparing counts: libghostty's
total_rows is allowed to plateau OR shrink when the cap is hit
(it depends on page allocation and eviction semantics). Counter
comparison alone misses the case where total_rows is steady at the
cap with content rotating, and is wrong in the case where total_rows
shrinks while content has actually rotated. Sampling the first row's
content is the only signal that always tracks rotation correctly.

Test (test/ghostel-test.el):
- ghostel-test-scrollback-rotation-rebuild — write 5000 EARLY rows
  into a tiny-cap terminal (libghostty saturates at ~920 rows) +
  redraw, then write 5000 LATE rows without an intervening redraw,
  then redraw. The second redraw must detect rotation and rebuild
  so the buffer no longer contains any "early-" markers and shows
  the most recent late- rows.

A previous version of this commit also included a "batched
multi-row insert" optimization in insertScrollbackRange that
collapsed N per-row env.insert calls into roughly N/page_rows
calls. Empirically it only bought ~2-8% on the streaming bench
because libghostty's vt_write parsing dominates redraw cost in
that workload, not Elisp FFI. The added complexity (~140 lines:
new RowMeta struct, new flushScrollbackChunk helper, cumulative
char_offset arithmetic, chunk overflow handling, oversized-row
fallback) wasn't worth the small gain. The batched-insert patch
is preserved at .claude/batched-insert.patch and can be re-applied
with `git apply .claude/batched-insert.patch` if the streaming
hot path becomes more important later.
When a row's encoded bytes exactly fill text_buf, the in-buffer newline
append was skipped, leaving the row without a trailing newline. The
prompt/wrap property math downstream assumes `after_insert - 1` points
at the row's newline, so a missed newline would misapply those
properties to the last character of the row instead.

For a standard 80-column terminal the max encoded row is ~321 bytes
(far below the 16 KB buffer), so this was only reachable on pathological
column counts — but the invariant shouldn't depend on that. Fall back
to a separate env.insert("\n") when the newline couldn't fit in the
text buffer, so the "one row per line" contract always holds.
`buildRowContent' now walks the row cells and tracks the position
right after the last non-blank cell, then truncates `byte_len' and
`char_len' back to that position before returning.  A cell is
considered blank when it carries no grapheme (libghostty's
unwritten-cell padding) and has default style; cells the terminal
explicitly wrote — even spaces — anchor the trim point and are
preserved.  Cells with non-default style (colored background,
underline, …) are also preserved so visible styling is not lost.

This removes libghostty's full-terminal-width padding from the
Emacs buffer.  Short prompt rows no longer run out to column 80
with trailing spaces, and the right edge of the buffer reflects
the actual last character written by the terminal.

Style runs that extend past the new trim point are clipped by
`insertAndStyle''s existing `content.char_len' cap — no change
required there.  `prompt_char_len' is capped at the new `char_len'
so the leading-prompt region never points past the end of the
trimmed text (fixes an out-of-range text-property call that would
fire when the prompt ran to the viewport edge without further
input).

Three tests updated to match the new semantics and a new test
added:

- `ghostel-test-scrollback-in-buffer' now expects 12 lines instead
  of 13 (the trailing empty cursor row trims to nothing).
- `ghostel-test-incremental-redraw' expects 4 lines instead of 5
  for the same reason.
- `ghostel-test-wide-char-no-overflow' asserts the visual width is
  2 (the emoji) instead of 40 (the full terminal width).
- `ghostel-test-resize-width-change-full-repaint' asserts each row
  is no longer than the terminal width, instead of exactly equal.
- New `ghostel-test-render-trims-trailing-whitespace' covers both
  sides of the rule: unwritten padding is stripped, shell-written
  trailing spaces like `$ ' are preserved.
Re-ran `bench/run-bench.sh` at the new 5 MB default size (up from
1 MB — the larger run amortizes measurement overhead and gives
more stable numbers) on Apple M4 Max, Emacs 31.0.50, post-trim.

Plain-ASCII PTY throughput ticked up slightly for ghostel
(64 → 65 MB/s) on top of the wider engine improvements from
scrollback-in-buffer; the standout jump is URL-heavy input, which
doubled from 22 to 42 MB/s since the previous README snapshot
thanks to the scrollback promotion path preserving URL text
properties instead of re-detecting on every scroll-off.
With link detection disabled ghostel holds 65 MB/s regardless of
the input mix.  vterm / eat / term numbers are essentially
unchanged from the previous run, re-measured for consistency.
@dakra dakra force-pushed the scrollback-in-buffer branch from 95cacee to 1a31d37 Compare April 11, 2026 20:23
@dakra dakra merged commit 1a31d37 into main Apr 11, 2026
16 checks passed
@dakra dakra deleted the scrollback-in-buffer branch April 12, 2026 09:14
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.

2 participants