← the Atlas

Scrollback Backfill — Bounded Attach, Real Scrollback

Features·budding·implemented·

Attach paints instantly from a small snapshot; scrolling up backfills older history into the terminal's own scrollback via a pinned xterm-internals prepend — no pager, no view jump, up to the full 10k lines kaval holds.

Today a cross-host switch (or a page reload) pays for history up front: kaval serializes its whole 10,000-line mirror and the client writes it all back into xterm before the terminal is usable — the full-scrollback replay the W9 ship pinned as the cost of active-host-only rendering. This note is the ratified direction that kills that cost without giving up history: attach with a bounded snapshot, then backfill older lines into the live terminal’s own scrollback as the user scrolls up. A green prototype grounds every claim here (6/6 tests, branch xterm-prepend-spike, commit b3cfa37d1). Shipped in #1783.

One decision from the prototype changed under implementation, recorded here for honesty: the plan sketched a have-from-bottom (recent-relative) history cursor, but that cannot keep the seam where backfill meets existing content correct — it compares the host’s produced-line count against the client’s received count, which differ by the in-flight delta lag, so live output appending mid-fetch would duplicate or skip rows at the seam. The shipped cursor is instead an absolute mirror-line index, seeded from the attach snapshot’s topLine and anchored on an eviction origin the host tracks off the mirror’s onTrim; a fetch then serves strictly above the client’s content regardless of in-flight output. A width change (reflow renumbers absolute rows) makes backfill deliberately halt until the next snapshot re-seeds — the loaded history reflows correctly, only further loading waits.

Follow-up — the foreign-reflow residual (deliberate, honest, rare). The halt comes in two shapes. A local width resize pauses the controller directly. A foreign width reflow — a second client resizing the shared mirror at a different width, this client’s own columns unchanged — is caught by a per-mirror reflow generation the host stamps on the snapshot and every fetch echoes: once it moves, the host serves a typed stale reply and the client halts rather than splice a renumbered band. That generation bumps only on a real width change and on a full RIS reset (which likewise renumbers the mirror); a height-only or same-dims resize renumbers nothing and must never halt backfill. The residual left in place is narrow and by design: after a genuine foreign-width reflow, deeper backfill stops until the next natural snapshot re-seeds. It is halt-not-corrupt and honest — the reply is typed, nothing is corrupted, and the user keeps their buffer and every already-backfilled row; only further loading waits. Its structural cure is a reflow-invariant (logical-line) cursor that keeps backfilling across a reflow — the named follow-up. A re-attach-on-stale bandaid was considered and rejected: it would repaint the screen and discard the backfilled history the user may be reading — trading a rare quiet halt for a rare visible loss — and the reflow-invariant cursor would delete it anyway.

User-facing description

Switching to a host — or reloading the page — paints the terminal instantly. Only the recent screenful (roughly 500–1,000 lines) is sent from kaval to the browser; you’re typing into a live prompt while the 10,000 lines behind it stay on the host. The full-replay stall you pay today on every cross-host switch (the W9 cost) disappears.

Then scrolling up just works. As you approach the top of what’s loaded, kolu quietly fetches the next older chunk from the host and splices it into the terminal’s own scrollback — the same buffer, the same scrollbar, the same select/copy/search you already have. There is no separate history viewer and no pager: srid explicitly killed that shape.A separate copy-mode pager — the shape #1577 proposed for deep history — was rejected for this purpose: the user only cares about the actual terminal scrollback, not a second view with its own keys and its own scroll position. The view never jumps while it happens: the lines you’re looking at stay exactly where they are, and the only tell is the scrollbar thumb shrinking as more history appears above you. Keep scrolling and it keeps filling, up to the full 10,000 lines kaval already holds.

Attach, then scroll — the same terminal throughout
…lint · 0 warnings
$ just test
PASS · 42 passed (6.1s)
$ git push
→ origin master
$
Attach — instant. Only the recent screenful is sent from kaval; the prompt is live immediately.
$ nix build .#kolu
building 214 derivations…
copying 38 paths…
…lint · 0 warnings
$ just test
PASS · 42 passed (6.1s)
Scroll up — history streams in. Older lines (blue) splice in above; the lines you were reading don’t move — only the thumb shrinks.

The backfilled history is real terminal content, not a facsimile: colors survive, wrapped lines re-wrap correctly when you resize, wide characters land on the right cells — because the bytes are replayed through xterm’s own parser before they’re spliced in.

Architecture-level changes

kaval daemon (per PTY) browser client headless xterm mirror 10,000 lines · RAM DEFAULT_MIRROR_SCROLLBACK · ptyHost.ts:52 unchanged by this plan live xterm @xterm/xterm 6.1.0-beta.225 · WebGL client scrollback ≥ mirror + snapshot baked invariant — asserted at startup attach: bounded snapshot · ~500–1000 lines kills the W9 full-10k replay per switch user scrolls near top — ydisp < ~2× rows → fetch next older chunk history read verb NEW — the one new wire surface older raw chunk from the mirror raw chunk scratch headless terminal the real parser: wrap · SGR · wide chars replayed at the live terminal's cols 9,000 lines → 38 ms stolen BufferLines scrollbackBackfill.ts leaf beside xtermInternals.ts — but FAIL-LOUD lines.splice(0, 0, …stolen rows) ydisp · ybase · savedY += M _onScroll.fire(ydisp) · refresh(0, rows−1) throws on missing headroom clearSelection() first · skipped on alt buffer 11,394 rows spliced → 1.7 ms prepend in place — view never moves, thumb shrinks pinned internals contract — ~6 symbols, tripwire-tested an xterm bump that moves one symbol is red CI, not a corrupted terminal the 10 k RAM mirror is the horizon — #1577's memory goal stays open
The hot path (green) and the backfill loop (blue). Attach sends a bounded snapshot from kaval's 10k RAM mirror. Scrolling near the top fetches the next older raw chunk over a new kaval read verb, replays it through a scratch headless terminal — the real parser, so wraps/SGR/wide chars are exact — then scrollbackBackfill.ts steals the scratch BufferLine objects, splices them into the top of the live buffer, shifts the registers by the inserted count, and fires two renderer pokes. No terminal emulator ships this: xterm.js upstream declined lazy scrollback twice (#948, #2060) and VS Code full-replays — kolu goes first, which is exactly why the reach into internals is a pinned, tripwire-tested contract.

The moving parts, in the order a chunk travels:

  1. kaval’s mirror stays exactly what it is — a headless xterm holding 10,000 lines in RAM (packages/kaval/src/ptyHost.ts:52). Attach stops serializing all of it and sends a bounded snapshot (~500–1,000 lines). This plan deliberately does not touch the mirror’s memory footprint.The heap-OOM problem #1577 set out to solve — 10,000-line RAM mirrors per PTY — is not addressed here: the mirror stays 10k in RAM, and that memory goal remains open. This note’s business is the attach cost and in-place scrollback, nothing else.
  2. One new wire surface: a kaval history read verb that returns the next older raw chunk from the mirror, cursor-paged. That is the only addition to the wire — and it gets a second consumer in the same PR: a kaval-tui history <terminal> subcommand (ratified in-scope 2026-07-12), reading a terminal’s older output without attaching. Agent-orchestration workflows currently work around snapshot’s current-screen-only limit by passing report files; this closes that gap and doubles as incident forensics. Two consumers from day one also keeps the verb honest — it must serve a plain pager, not just the browser’s backfill loop.
  3. A scratch headless terminal in the client replays the chunk at the live terminal’s column width. This is the trick that makes backfilled history real: the actual xterm parser computes wrapping, isWrapped continuations, SGR attributes, and wide-character cells — no hand-rolled VT interpretation anywhere. A resize (or maximize — the same event) after a backfill is the proven case: the spike’s reflow oracle shows prepended history re-wraps row-for-row identically to natively-written history. A resize landing mid-backfill gets an explicit guard: at splice time the module compares the live terminal’s columns against fetch-time — changed means discard the scratch result rather than splice stale-width lines. A local width change goes further and pauses the controller (the absolute cursor a reflow renumbered is no longer valid) until the next snapshot re-seeds it; the loaded history still reflows correctly, only further loading waits. (The original sketch re-replayed at the new width instead; that path was dropped for the simpler halt-not-corrupt pause — see the follow-up note near the top.)
  4. scrollbackBackfill.ts steals the result: the scratch terminal’s BufferLine objects are plain data, so the module splices them into the top of the live buffer’s CircularList, shifts ydisp/ybase/savedY by the inserted count M, and pokes the renderer exactly twice — _onScroll.fire(ydisp) and refresh(0, rows−1). Because ydisp grew by the same M as the content above it, the scroll math means the visible content does not move; the thumb shrinks, and markers and decorations shift for free.Code-read against 6.1.0-beta.225: _onScroll.fire cascades selectionService.refresh + viewport.queueSync, with no feedback loop (the diff is 0, a no-op); markers shift via Buffer.addMarker’s onInsert handler and the DecorationService listens to onInsert too. The refresh rebuilds the WebGL atlas from ydisp..ydisp+rows — content-identical, flicker-free. The one gap found: SelectionService listens only to onTrim, not onInsert, so an active selection’s rows would go stale by M — hence the MVP clears the selection before each prepend.

Three boundary decisions, stated as verdicts:

Implementation details

One PR, five steps — the work threads existing seams (attach, the terminal module, kaval’s verbs) and nothing here can ship usefully alone:

  1. The modulescrollbackBackfill.ts (shipped under packages/client/src/terminal/; later graduated verbatim into packages/xterm-kit/src/ — see xterm-kit) exporting prependScrollback(term, rawChunk, servedRows): PrependResult — a discriminated { kind: "inserted"; rows } | { kind: "skipped" }, so the caller can never conflate “consumed, inserted M rows” with “not consumed” (an alt-buffer or late-splice skip that must not advance the backfill cursor). Steps exactly as the prototype’s prependHistory, plus term.clearSelection() before the splice and a skipped when the alt buffer is active. Fail-loud throughout.
  2. The scratch terminal — the shipped defaultScratch is an unopened @xterm/xterm Terminal (same version, so BufferLine internals match the live buffer’s; no renderer, no DOM — it just parses bytes into a buffer). Its one load-bearing assumption — an unopened write() parses into the buffer and fires its callback — is pinned by a contract test (the SHIPPED export, exercised directly), so a caret-range @xterm/xterm bump that defers pre-open parsing turns red in CI rather than into silent corruption. (No fallback to @xterm/headless shipped: promoting it to a client runtime dep would put a second parser in the bundle — weighed and declined.)
  3. The wiring — attach sends the bounded snapshot; an onScroll handler fetches the next older chunk from kaval’s new history read verb whenever ydisp drops under ~2× rows, prepends it, and stops when the mirror is exhausted.
  4. The sizing invariant, baked — client scrollback ≥ kaval’s DEFAULT_MIRROR_SCROLLBACK (10,000, packages/kaval/src/ptyHost.ts:52) + snapshot size, asserted at startup; and the throw-on-overflow guard stays in the splice path. No knob, no clamp-and-continue: headroom is a build-time fact, so a violation is a crash, not a degrade.
  5. The contract-pin tests, lifted from the spike into both packages — kaval (against @xterm/headless) and client (against @xterm/xterm) — so a pin bump that moves a pinned symbol fails CI loudly on both sides.
  6. kaval-tui history <terminal> — the read verb’s second consumer, in this same PR: cursor-paged dump of a terminal’s older mirror contents to stdout (newest-first paging, a --lines N bound), no attach, no TTY taken. Ships with a smoke-level test against a real kaval.

Risks, ranked — each with the mitigation that ships in the same PR:

# risk mitigation
1 Silent eviction on missing headroom — the one true corruption mode: splicing past maxLength silently trims the oldest rows while the register shift assumes they exist. Demonstrated live in the spike. Fully guarded: the fail-fast throw-on-overflow check + the baked sizing invariant (step 4) make the state unreachable.
2 Private-symbol churn on an xterm upgrade. Low (every symbol stable across 6.0.0 → 6.1.0-beta.225; versions exact-pinned); the contract-pin tests convert any move into red CI.
3 Scratch-replay fidelity edges — OSC-8 link ids dangle, addon-image placements are lost in backfilled rows. Cosmetic by construction; accepted (see the deliberately-not list).
4 Browser-only behaviors unverified headlessly — the unopened-Terminal write(), one-frame scrollbar jitter, a prepend racing a pending write. The unopened write() is now pinned by a contract test against the shipped defaultScratch (turns red on an @xterm/xterm bump that defers pre-open parsing); the remaining jitter/race edges are verified by eyeball + e2e.
5 Selection UX — an active selection is cleared by a prepend. Accepted for MVP; preserving it needs _selectionService._model, a later extension.

Deliberately not in scope: