Scrollback Backfill — Bounded Attach, Real Scrollback
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.
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
The moving parts, in the order a chunk travels:
- 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.
- 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 aroundsnapshot’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. - 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,
isWrappedcontinuations, 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.) scrollbackBackfill.tssteals the result: the scratch terminal’sBufferLineobjects are plain data, so the module splices them into the top of the live buffer’sCircularList, shiftsydisp/ybase/savedYby the inserted count M, and pokes the renderer exactly twice —_onScroll.fire(ydisp)andrefresh(0, rows−1). Becauseydispgrew 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.firecascadesselectionService.refresh+viewport.queueSync, with no feedback loop (the diff is 0, a no-op); markers shift viaBuffer.addMarker’sonInserthandler and theDecorationServicelistens toonInserttoo. Therefreshrebuilds the WebGL atlas fromydisp..ydisp+rows— content-identical, flicker-free. The one gap found:SelectionServicelistens only toonTrim, notonInsert, 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:
-
A leaf, not electricity — but a fail-loud leaf. It hides no transport, no reconnect, no persistence — a bounded algorithm over xterm’s buffer, so on its own it earned no package. It has since graduated into
@kolu/xterm-kit— not as electricity in its own right, but as one part of the accumulated xterm machinery that package owns — where it lives atpackages/xterm-kit/src/scrollbackBackfill.ts(the/backfillentry, which constructs the@xterm/xtermscratch), a sibling of the pinned-internals door packages/xterm-kit/src/internals.ts. It inverts that sibling’s philosophy:internals.tsdegrades to a no-op when a private symbol is missing, which is right for cosmetic reads — and wrong here, because a silent partial prepend corrupts a terminal. The prototype demonstrated the failure live: without the guard, a splice pastmaxLengthsilently trims the oldest rows while the register arithmetic still assumes they exist. Every missing symbol and every headroom shortfall throws. -
The internals dependency is a pinned contract, not a hope. The whole reach into xterm privates is ~6 symbols:
pinned symbol role term._corethe door into internals _core._bufferService._onScroll.firerenderer poke 1 _core.buffers.normalthe live normal buffer buffer.lines—CircularList:length/maxLength/get/splice/onInsert/onTrimline storage; the splice point buffer.ydisp/ybase/savedY/ythe registers shifted by += M IBufferLine.isWrapped/.translateToStringwrap continuation + text oracle A contract-pin test (symbol existence + splice-fires-
onInsert) is the tripwire: an xterm version bump that moves any symbol turns into red CI, not user-facing corruption. Churn risk is low — every pinned symbol is shape-identical between@xterm/headless6.0.0 and the client’s exact-pinned@xterm/xterm6.1.0-beta.225 (pnpm overrides, with webgl 0.20.0-beta.224). -
kolu goes first, knowingly. There is no prior art to copy: xterm.js upstream declined infinite/lazy scrollback twice, and VS Code restores terminals by serialize-plus-full-replay — precisely the cost this plan removes.xterm.js #948 and #2060 — “up the scrollback” was the official answer, and a VTE maintainer’s paging design was declined. VS Code’s persistent terminals do a full serialize + replay on restore, not lazy backfill. Wanting what no emulator ships is why the internals reach exists — and why it’s fenced by the contract table above rather than scattered through the codebase.
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:
- The module —
scrollbackBackfill.ts(shipped underpackages/client/src/terminal/; later graduated verbatim intopackages/xterm-kit/src/— see xterm-kit) exportingprependScrollback(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’sprependHistory, plusterm.clearSelection()before the splice and askippedwhen the alt buffer is active. Fail-loud throughout. - The scratch terminal — the shipped
defaultScratchis an unopened@xterm/xtermTerminal(same version, soBufferLineinternals match the live buffer’s; no renderer, no DOM — it just parses bytes into a buffer). Its one load-bearing assumption — an unopenedwrite()parses into the buffer and fires its callback — is pinned by a contract test (the SHIPPED export, exercised directly), so a caret-range@xterm/xtermbump that defers pre-open parsing turns red in CI rather than into silent corruption. (No fallback to@xterm/headlessshipped: promoting it to a client runtime dep would put a second parser in the bundle — weighed and declined.) - The wiring — attach sends the bounded snapshot; an
onScrollhandler fetches the next older chunk from kaval’s new history read verb wheneverydispdrops under ~2× rows, prepends it, and stops when the mirror is exhausted. - 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. - 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. 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 Nbound), 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:
- No backfill while the alt buffer is active (full-screen apps have no scrollback to extend).
- Selection is not preserved across a backfill — cleared in the MVP.
- OSC-8 links render plain in backfilled rows.
- No recreation of history at its original recorded widths — the replay wraps at the current cols, and the spike’s reflow oracle shows that’s exactly what native history does too.
- No infinite or disk-backed scrollback — kaval’s 10k RAM mirror is the horizon of this plan.