← the Atlas

xterm-kit — Graduating Kolu's xterm Machinery

Features·seedling·implemented·

Plan of record for a core + /solid package that owns xterm's hazards — pinned internals with two failure philosophies, buffer surgery, the hardened write path, mirror anchoring, and the SolidJS adapter — with kaval and the client as two consumers from day one.

Ratified by srid (2026-07-13) as the next refactor task: graduate kolu’s xterm machinery into its own workspace package. Shipped as two PRs on evidence class (see the migration): the behavior-neutral graduation in #1795 (PR 1), the <Xterm> wrapper + touch-divisor unification in the e2e-gated #1808 (PR 2). The electricity ledger has carried this as its one not-yet-built row since the framework audit, under the working name @kolu/solid-xterm — and that name turned out to be the plan’s own first bug. What kolu has actually accumulated is high-level xterm.js machinery, most of which has nothing Solid about it: the pinned reaches into _core.* with two deliberate failure philosophies, in-place scrollback surgery (#1783), seam-token parsing, scroll-lock write buffering, RIS detection and re-anchoring — all of it operating on any xterm Terminal, browser or @xterm/headless. So the package is a runtime-neutral core plus a /solid entrypoint (the @kolu/surface@kolu/surface/solid pattern), and it has two consumers on day one: kaval imports the core; the client imports core + /solid.

This note is the plan and the API design record — the exported surface is designed here, per srid’s instruction, because the API is the product of this refactor. It is not the reference manual (kolu.dev reference docs come later): each export gets the sentence that justifies its existence, not its option list. The name is decided (srid, 2026-07-13): @kolu/xterm-kit, with four entrypoints — @kolu/xterm-kit (the daemon-safe, runtime-neutral core), @kolu/xterm-kit/backfill (the @xterm/xterm-constructing scrollback write path), @kolu/xterm-kit/internals (the cosmetic _core.* reads), and @kolu/xterm-kit/solid (the SolidJS adapter). The core//solid split is the headline (@kolu/surface@kolu/surface/solid); /backfill and /internals exist because a static @xterm/xterm value import and the _core reaches must stay off the daemon-imported root (see the build-time correction below).

What a consumer gets

The “user” of this feature is a developer embedding a terminal. Both consumers get the same promise: the hazards are owned by the package, and the consumer writes only its own policy — which bytes, when, for whom.

A SolidJS app renders a live terminal in one component (the <Xterm> wrapper shipped in PR 2, #1808, composing the primitives PR 1 graduated):

import { Xterm, type XtermHandle } from "@kolu/xterm-kit/solid";
import "@xterm/xterm/css/xterm.css";

<Xterm
  theme={dracula}                 // reactive — live re-theme, texture atlas cleared
  fontSize={14}                   // reactive — refit + atlas clear
  fontFamily="MonoLisa"           // the face awaited before construction
  visible={props.visible}         // fit gate: hidden panes wait at 80×24
  webgl={() => underBudget()}     // renderer gate — an accessor, never a snapshot
  scrollLockEnabled={() => true}  // scroll-lock gate — an accessor (e.g. a pref)
  onData={(bytes) => pty.write(bytes)}                    // keystrokes out
  onResize={({ cols, rows }) => pty.resize(cols, rows)}   // grid → PTY
  onTap={(x, y) => false}         // touch: what a tap MEANS is the consumer's
  onReady={(h: XtermHandle) => pty.onData((c) => h.write(c))} // bytes in
/>

Behind that hello-world, the package owns the hazards kolu paid to learn:

And a headless consumer — kaval today, any TUI or server tomorrow — uses the same core to keep a long-lived mirror addressable:

import { createMirrorAnchor, snapToWrapHead } from "@kolu/xterm-kit";

const anchor = createMirrorAnchor(headless); // pins onTrim, survives RIS buffer swaps
// absolute line coordinates that survive eviction and reset:
const topLine = anchor.baseLine() + snapToWrapHead(buf, start);
// anchor.reflowEpoch() stales any history cursor a width reflow renumbered

The package boundary

kolu client · Terminal.tsx — the policy half (stays) which bytes, when, for whom padi attach wire · frame kinds (snapshot | delta) · unenrolledStreamCall keybindings + prohibited chords · theme values · renderer preference WebGL budget policy — which tiles hold a context (webglBudget.ts) paste/drop upload · file-ref links · tap policy (what a tap opens) MobileKeyBar + sticky modifiers · mobile chrome activity + diagnostics · the e2e __xterm bridge kaval daemon · ptyHost.ts — the policy half (stays) PTYs, verbs, metadata node-pty spawn/exit · attach + getHistory verbs bounded-snapshot cache · reflow-epoch stale replies OSC 7/633 metadata · foreground sampling · XTVERSION answer the mirror itself — one headless xterm per PTY imports core + /solid imports core only no solid-js in the daemon closure @kolu/xterm-kit — high-level xterm machinery no PTY · no wire · no host — bytes and buffers only /solid — the browser adapter <Xterm> the component form — reactive props, onReady handle createXtermLifecycle owner-correct async construct + dispose (#591/#606) attachWebGL single-owner addon · context-loss recovery · deterministic loseContext() release createRenderRecovery forced sync repaint when the rAF loop parks wireScrollIntent DOM wheel/pointer wiring for the core scroll lock enableSoftKeyboardInput · wireTouch* tap-vs-scroll · soft-keyboard rules · IME slot uses core — runtime-neutral: any Terminal, browser or headless · no DOM · no solid-js hardened write path createScrollLock createBackfillController seam tokens · RIS halt · generation guards fetch is injected — no wire type enters buffer surgery — FAIL-LOUD prependScrollback in-place splice · register shift · renderer pokes throws on a missing symbol or headroom a partial prepend is corruption, never a degrade mirror anchoring — joins from kaval createMirrorAnchor · snapToWrapHead onTrim eviction origin · RIS re-anchor · reflowEpoch wrap-safe serialize cuts fail-loud too — a frozen origin corrupts history /internals — the door to _core.* cosmetic reads degrade to null renderService · readBufferBytes · coords patch CONTRACT PINS ship in the package red CI on any pinned-symbol move the volatility adapted — every _core reach pinned @xterm/xterm 6.1.0-beta.225 + 8 addons browser · exact pnpm-overrides pin @xterm/headless 6.0.0 node · BufferLine shape pin-verified identical
The boundary, its two compartments, and both day-one consumers. The runtime-neutral core (blue) owns the hardened write path, fail-loud buffer surgery, the mirror anchoring lifted from kaval, and the /internals door — with the contract-pin tests shipping inside the package, so the pins travel WITH the dependents. The /solid entrypoint (green) owns the component, owner-correct async dispose, WebGL lifetime, and the touch surface. Above the line, each consumer keeps its policy half: Terminal.tsx keeps which bytes and when (padi wire, keybindings, theme, budget policy, e2e bridge); ptyHost.ts keeps PTYs, verbs, and metadata. Below, the volatility being adapted: one workspace-wide exact xterm pin set, browser and headless.

The line, in one sentence: a robust adapter over xterm goes in; “which bytes and when” stays out. Nothing in the package knows a PTY, a host, a padi frame, or a keybinding — it speaks bytes, buffers, and Terminal objects. The backfill controller is the proof the line already holds: its inputs are consumeSnapshotFrame(topLine, reflowEpoch, carriesReset) and an injected fetch — numbers, booleans, and a function — so kolu’s wire types never cross the boundary even though the controller drives kolu’s wire.Terminal.tsx keeps the wire knowledge: it discriminates frame.kind === "snapshot", checks for the leading TERMINAL_RESET, and calls the controller with plain values (packages/client/src/terminal/Terminal.tsx:410-440). The seam bytes themselves are minted by the controller (packages/xterm-kit/src/scrollbackBackfill.ts:309-351); the consumer only concatenates.

Against the electricity three tests:

  1. Domain-agnostic — the interface names terminals, buffers, chunks, and accessors; no terminal-app concept (host, tile, agent, PTY) appears in any signature.
  2. A real volatility — three axes the system demonstrably varies along: xterm itself (an exact-pinned beta whose private _core shape moves under version bumps, plus buffer semantics — eviction, RIS buffer swaps, reflow — that both runtimes share), GPU context loss (the browser reclaims contexts at will), and SolidJS owner semantics across await (the dispose-ordering axis). The pins travel with the dependents: the contract-pin tests ship inside the package, so an xterm bump is one package’s red CI, not a repo-wide archaeology.
  3. Graduates with two consumers day one — kaval (a Node daemon, none of the browser domain) imports the core; the client imports core + /solid. Both are in-repo, so an external consumer remains future proof — but the population-of-one caveat that shadowed the old solid-xterm plan is gone.

API shape — component plus escape-hatch primitives. The /solid entry leads with a component (<Xterm>), modeled on @kolu/solid-statepip’s component-first surface; every capability under it is also exported as a primitive, and the component is built from those primitives so the two can’t drift. Kolu’s own Terminal.tsx consumes the component and wires its policy in onReady — the component is consumer #1, not aspiration. The split entrypoints (. / ./internals / ./solid) follow @kolu/surface’s exports map.

The name — decided: @kolu/xterm-kit

solid-xterm was wrong (srid’s own flag): the core is the larger half and Solid is one entrypoint. srid picked @kolu/xterm-kit (2026-07-13) — it says “machinery over xterm, not a fork,” and a kit has compartments, which core + /solid is. The bare @kolu/xterm was passed over as one character class away from upstream @xterm/xterm in an import block.

The public API

Two tiers, matching the two consumers. Every export earns its line with one sentence; option lists and edge-case semantics are the future reference docs’ job, not this note’s.

Core — @kolu/xterm-kit and /internals

The root barrel is the daemon-safe core — everything on it works on a browser Terminal or an @xterm/headless one, imports no solid-js, and constructs no concrete terminal, so kaval (a Node daemon run from TS source under tsx, eager ESM) imports it with no UI framework in its closure and no @xterm/xterm ESM-named-import to crash Node’s cjs-module-lexer. Two exports the first draft put here moved out under that constraint, each behind the entrypoint that owns its dependency: createScrollLock is solid-reactive (/solid), and the backfill write path constructs @xterm/xterm scratch terminals (/backfill). A closure-guard test walks the root’s import graph and fails if either returns.

import {
  // mirror anchoring (the kaval half) — runtime-neutral
  createMirrorAnchor,       // absolute-line origin that survives eviction and RIS
  snapToWrapHead,           // never cut a serialize at a soft-wrap continuation
} from "@kolu/xterm-kit";

import {
  createBackfillController, // scroll-triggered older-history backfill; fetch injected
  prependScrollback,        // the fail-loud in-place prepend under the controller
  defaultScratch,           // the @xterm/xterm scratch the controller replays through
} from "@kolu/xterm-kit/backfill"; // browser-only — constructs @xterm/xterm

import {
  renderService, readDecPrivateMode, readBufferBytes,
  patchTransformAwareMouseCoords,
} from "@kolu/xterm-kit/internals"; // cosmetic reads — degrade to null

The two internals philosophies are not an implementation detail — they are the API contract a consumer can rely on:

you call when a pinned _core symbol moves why the tripwire
a cosmetic read (/internals) returns null / no-ops — the probe reports “unknown” a byte count or a coord patch may degrade; crashing a live terminal for it may not internals.test.ts
a buffer mutation (prependScrollback, createMirrorAnchor) throws, immediately a partial splice or a frozen eviction origin is silent scrollback corruption the CONTRACT PIN suites, browser and headlesspackages/xterm-kit/src/scrollbackBackfill.test.ts pins the @xterm/xterm shape (including the behavioral splice-fires-onInsert pin and the unopened-write() scratch pin); packages/xterm-kit/src/xtermMirrorContract.test.ts pins the @xterm/headless twin and verifies the shapes identical. Both suites now live in the package — its CI teeth.

/solid — the browser adapter

The first three below shipped in PR 2 (the <Xterm> component and its lifecycle/WebGL primitives); the rest in PR 1:

import {
  Xterm,                // the component: lifecycle + addons + WebGL + fit, in JSX form (PR 2)
  createXtermLifecycle, // the primitive under it, for a non-JSX composition (PR 2)
  attachWebGL,          // single-owner WebglAddon lifetime + context-loss recovery (PR 2)
  createRenderRecovery, // forced sync repaint when the rAF paint loop parks
  wireScrollIntent,     // DOM wheel/pointer wiring for the core scroll lock
  createScrollLock,     // freeze-while-reading latch; buffered writes keep their callbacks
  enableSoftKeyboardInput, // iOS contenteditable soft-keyboard surface
  isCoarsePointer,      // pointer:coarse media query (the touch gate)
} from "@kolu/xterm-kit/solid";

Mobile — the touch half of the adapter

The plan’s first draft had no mobile lens (srid’s review added it), and the gap was real: the client carries touch machinery that is adapter-grade, not kolu chrome. xterm 6.0 ships no touch support — the types exist, the wiring doesn’t, and the WebGL canvas swallows touch events — so everything a terminal does on a phone, kolu hand-built inside Terminal.tsx. The adapter-grade parts join /solid:

import {
  enableSoftKeyboardInput, // contenteditable input surface — the iOS quirk knowledge, in one place
  wireTouchTaps,           // tap-vs-scroll discrimination; `onTap` is the policy hook
  wireTouchScroll,         // touch → scrollback bridge (xterm ships none); arms the scroll lock
} from "@kolu/xterm-kit/solid"; // `<Xterm>` composes all three on touch devices

What stays kolu: MobileKeyBar and the mobile chrome (sheets, pull chrome, the mobile tile view) are pure policy — which keys a phone deserves and what the chrome looks like — plus the tap→file-ref decision via onTap, and the sticky-modifier arming above.

Two interplays are already handled, recorded so mobile doesn’t re-open them: raising the soft keyboard is a rows-only resize, and the backfill controller pauses only on a cols change — so the keyboard no longer stales backfill (the #1783 family-3 fixpackages/xterm-kit/src/scrollbackBackfill.ts:657-666 — “a height-only change is harmless”; a width reflow is what invalidates the absolute cursor.); and momentum-scroll flinging the viewport into the trigger band mid-backfill was a named spike risk, covered by the controller’s in-flight serialization and generation guards.

Deliberately not exported

The package refuses to learn: wire and frame types (padi’s attach schema, TERMINAL_RESET, oRPC anything), host semantics and metadata (cwd, git, agents), keybinding policy (actions, prohibited chords), theme values (it takes an ITheme, never defines one), activity semantics (what counts as “live”), the WebGL budget policy (which panes deserve a context), and kolu’s diagnostics registry and e2e bridge. Each is a “which bytes and when” concern — the consumer’s policy half, per the diagram.

The migration — two PRs

The first draft planned one PR. Grounding the build against the installed system split it, on evidence class: what is behavior-neutral ships now, proven by the moved suites staying green plus two-platform CI; what changes on-screen behavior is its own immediate fast-follow, proven by e2e. One PR carrying both classes leaves its headline half-proven; two PRs each prove their own claim completely. The split is not decomposition theater — the hard volatility graduates whole in PR 1 and both consumers rest on it; PR 2 is ergonomics over already-graduated primitives, a leaf that may follow.

PR 1 — graduate the volatility (behavior-neutral)

The pinned internals, the fail-loud buffer surgery, the mirror anchoring, and the hardened write path move into the package, and both consumers import them. No on-screen behavior changes: Terminal.tsx still constructs its terminal inline, now from the kit’s primitives — the <Xterm> wrapper is PR 2.

  1. Create the packagepackages/xterm-kit/, package.json modeled on @kolu/solid-statepip, exports map . / ./backfill / ./internals / ./solid. solid-js is a peer of the /solid entry only. The xterm versions keep the single root pnpm-overrides pin — scratch and live BufferLine shapes must match, so one workspace-wide version is a correctness requirement.
  2. Move the core verbatim: scrollbackBackfill.ts (+ its CONTRACT PIN suite), snapshotBoundary.ts (+ test), scrollLock.ts (+ test) from the client; xtermInternals.ts (+ test) becomes ./internals; xtermMirrorContract.test.ts from kaval. Zero semantic edits in moved files.
  3. Lift createMirrorAnchor out of ptyHost.ts (normalLinesOf, the trim handler, the RIS identity re-anchor, the reflowEpoch bump, snapToWrapHead) and re-point kaval onto it — the one non-mechanical change, behavior-identical by construction, gated on kaval’s existing suites (ptyHostHistory.test.ts covers eviction, RIS, and history paging).
  4. Re-point both consumers onto the kit — client and kaval, import paths only.
  5. Prove the teeth: typecheck drives the re-point; deliberately break one pinned symbol and watch the package’s vitest lane go red before merge.

Two corrections the first draft got wrong, each grounded against the installed system (not deduced) and pinned by a closure-guard test that walks the root barrel’s import graph:

PR 2 — the <Xterm> wrapper + the divisor fix (e2e-gated, immediate fast-follow)

Started as soon as PR 1 ships, same owner, its tests written into the standard e2e suite (packages/tests/features/*, which runs on the standing two-platform lanes — no special infrastructure).

Its gate is the @touch-desktop tap-under-zoom e2e (that the extracted touch tap still lands on the visually-tapped cell, at normal scale and under zoom) plus the 123 client terminal unit tests staying green UNMODIFIED — the behavior-neutral proof for the policy surrounding the split. WebGL context-loss recovery (webgl.ts’s self-heal) and owner-correct async dispose (the lifecycle’s owner-capture-before-await + LIFO cleanup) carry no dedicated unit test — they are preserved as behavior-identical extracted logic, held to correctness by review + typecheck and the leak-invariant argument spelled out in this note, not by an automated scenario.

Risks (PR 1 unless noted):

# risk mitigation
1 The Terminal.tsx split line drifts (PR 2) — a policy line crosses in, or a hazard stays behind. One review question per moved line: does it know which bytes, which host, which keybind? Yes → stays. The diagram’s stays-lists are the checklist.
2 Import-churn breadth across client and kaval hides a semantic edit. Moved files are byte-identical (git mv); the only edited lines are import paths. Typecheck is the linter; the moved + kaval suites are the behavior gate.
3 Test relocation loses CI teeth — a moved contract pin the unit lane no longer runs. The package registers test:unit like every workspace package (the lane runs all workspaces), proven by the deliberate-red rehearsal.
4 The mirror-anchor lift regresses a hot daemon path (attach, history paging, RIS under load). Extract-and-delegate, no logic change; kaval’s ptyHost.test.ts + ptyHostHistory.test.ts are the oracle and stay green untouched.
5 The daemon imports a browser/solid dependency it can’t run (tsx eager ESM). Resolved structurally: /backfill and /solid hold the browser/solid code; the root is daemon-safe and noSolidInDaemon.test.ts fails CI if either leaks back.
6 The WebGL gate crosses as a stale boolean (PR 2). attachWebGL takes Accessor<boolean> in its signature; <Xterm>’s webgl prop is that accessor; no boolean overload exists to misuse.

Two operational notes, for scope honesty:

Done, PR 1: both consumers compile against the package, the contract-pin rehearsal has been seen red then green, kaval’s suites and the client suite pass unmodified, the closure guard is green, and the electricity ledger’s row flips from “to build” to done. Done, PR 2: Terminal.tsx consumes <Xterm>, the touch tap routes through the single-authority divisor (guarded by the @touch-desktop tap-under-zoom e2e), and the 123 client terminal unit tests stay green unmodified.