xterm-kit — Graduating Kolu's xterm Machinery
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:
- Owner-correct async dispose. Construction must await the terminal font,
and SolidJS loses its reactive owner across any
await— every cleanup registered after it is a silent no-op, which is exactly how kolu leaked whole xterm graphs per mode toggle.The #591/#606 leak class: ~900 KB ofInputHandler+BufferLines per orphaned instance, found by heap-snapshot retainer walks (190+ retainedxterm Terminaltrees). The fix is structural — capture the owner before theawait, re-enter it withrunWithOwner, register teardown synchronously before the async body, and bail on adisposedflag after the await (packages/xterm-kit/src/solid/xtermLifecycle.ts:50-108). - WebGL context-loss recovery and deterministic release. The addon is
single-owner, self-heals on
webglcontextlost, and explicitly firesWEBGL_lose_context.loseContext()on unload — without that, detached canvases hold GPU contexts until GC and Chrome’s per-tab context budget evicts live ones.Chrome allows ~16 WebGL contexts per tab; rapid focus changes created contexts faster than GC released them, flickering every tile (#575, #591, the #1399 budget). The mechanism — accessor-gated load/unload with in-microtask release — is the package’s (packages/xterm-kit/src/solid/webgl.ts); the policy of which panes deserve a context stays the consumer’s. - Version-pinned internals with tripwire tests. Every reach into xterm’s
private
_core.*is confined to the package, typed structurally, and pinned by contract tests that turn an xterm bump into red CI instead of silent corruption — with two philosophies, chosen per operation (see the API contract below). - The hardened write path. Snapshot-vs-delta discrimination across re-attaches, the scroll lock that freezes output while the user reads scrollback (buffered writes keep their parse callbacks), seam tokens that bind a snapshot’s re-seed to its byte position, and RIS handling that halts backfill rather than corrupt it (#1783).
- Mobile input. xterm 6.0 ships no touch path at all — it declares
IViewport.handleTouchStart/Movetypes with zero wiring, and the WebGL canvas eats touch events before the viewport sees them — so the kit owns touch-scroll bridging, tap-vs-scroll discrimination, the iOS soft-keyboard summoning rules, and (once #1634 merges) Android IME/composition routing. What a tap means stays the consumer’sonTaphook — designed in the Mobile section below.
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
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:
- Domain-agnostic — the interface names terminals, buffers, chunks, and accessors; no terminal-app concept (host, tile, agent, PTY) appears in any signature.
- A real volatility — three axes the system demonstrably varies along:
xterm itself (an exact-pinned beta whose private
_coreshape 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 acrossawait(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. - 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 oldsolid-xtermplan 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
createBackfillController(term, { fetch, onError })exists because extending a live buffer upward is a minefield of races — in-flight fetches vs. reset/resize/RIS, foreign reflows, seam provenance under scroll lock — and it owns all of them behind two calls (consumeSnapshotFrame,reset) and an injectedfetch.onErroris required: an omitted handler would silently recreate the swallow it exists to prevent.prependScrollback(term, chunk, servedRows, opts): PrependResultexists as the controller’s engine and as a standalone primitive: replay raw bytes through a scratch terminal (the real parser), splice the resultingBufferLines above everything, shift the registers — the viewport never moves. Its result is a discriminated union so “inserted 0 rows” and “nothing consumed” cannot be conflated.createMirrorAnchor(headless)exists because a long-lived mirror’s absolute line numbering silently breaks twice — scrollback eviction trims the top, and a RIS reset swaps the whole line list, orphaning anyonTrimsubscription — and every history-paging feature dies with it. It pinsonTrim, detects the buffer swap by identity from the consumer’s write callback, advances its origin past discarded rows, and bumps areflowEpochthat stales outstanding cursors.Lifted fromptyHost.ts’sEntrybookkeeping, behavior-identical:mirrorBaseLine+ the trim pin and the RIS identity re-anchor now live in the kit (packages/xterm-kit/src/mirrorAnchor.ts), and kaval drives it from its write callback and resize path (packages/kaval/src/ptyHost.ts) — including the F5 fix that keeps re-subscriptions from leaking one disposable per reset.snapToWrapHead(buffer, start)exists because a serialize cut through a soft-wrapped line replays the continuation as a fresh hard line: it is the one home for the invariant that kaval’s bounded-snapshot start and history chunk tops both enforce (packages/xterm-kit/src/mirrorAnchor.ts)./internalsexists as the single door to_core.*for cosmetic reads: a render-service probe, a DEC-mode read, per-buffer byte counts, and the transform-aware mouse-coords patchThe coords patch corrects a known-but-unfixed upstream asymmetry (xterm.js #2488 made char measurement transform-agnostic; the hit-test was never reconciled — #3242 closed as its duplicate), so a pin bump won’t retire it; only an upstream coord-path fix would (packages/xterm-kit/src/internals.ts:203-241). — anything whose worst failure is a wrong number or a no-op.
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";
createScrollLock(enabled)lives here, not on the core root, because it issolid-reactive today (its latch and buffered-write queue arecreateSignals /createEffects) and the core root must stay free ofsolid-jsso kaval’s daemon never vendors it. It exists because live output and a user reading scrollback fight over the same viewport: it buffers writes while the user is scrolled up and — the load-bearing part — preserves each buffered chunk’s parse callback across the flush, which lets a snapshot’s re-seed committer survive the lock.The reactivity is incidental, not intrinsic — the logic is a buffering latch, not a rendering concern. A future framework-neutral rewrite (plain callbacks overcreateSignal) could return it to the runtime-neutral core, but only with that named change; until then its home is/solidand the closure-guard test keeps it there.<Xterm>exists so the whole hazard set is one JSX element (the hello-world above). ItsonReadyhands the consumer a handle —{ terminal, container, addons: { fit, search, serialize }, write, scrollLock, hasWebgl, clearTextureAtlas }— inside the component’s reactive owner, so policy wiring (kolu’s key handler, link provider, e2e__xtermbridge,data-*attributes) registers cleanups that actually run.createXtermLifecycle(container, options)exists because the await-the-font construction is where the owner is lost: it owns the capture-owner /runWithOwner/ synchronous-teardown /disposed-bail choreography once, for any consumer whose composition the component doesn’t fit.attachWebGL(terminal, should: Accessor<boolean>)exists because the addon’s lifetime has exactly one safe shape: single owner, self-heal on context loss, explicitloseContext()on unload. The gate is an accessor by type — budget policy (kolu’s recency budget, a preference toggle, always-on) stays whatever the consumer computes.createRenderRecovery(term, visible)exists because Chromium parks the rAF loop under window occlusion and xterm’s paint silently freezes: it forces a synchronous repaint when buffered data outruns the last paint, keyed to xterm’s parse, not stream receipt.wireScrollIntent(el, lock)exists to keep the scroll lock’s state machine DOM-free: the wheel/pointer capture-hold-release rules live here, beside the component, so the core stays runtime-neutral.
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
- Touch intent and the soft-keyboard focus rules join as owned mechanism —
with exactly one policy hook. The mechanism is browser-quirk knowledge
end to end:
preventDefaulton pointerdown to stop the focus shuffle iOS Safari rejects, focus deferred to pointerup inside the user-gesture window, a tap-sized movement threshold so scrolls never summon the keyboard, and the rule that on touch the keyboard rises only from an explicit tap — never from a tile switch or reveal (focusOnSelectionis a deliberate no-op on touch).All shipped behavior: the focus-is-a-no-op rule (packages/client/src/terminal/Terminal.tsx:169-176) and the tap surface (packages/xterm-kit/src/solid/touch.ts:38-75 — theTAP_THRESHOLD_PXdiscrimination and the pointerup gesture window; the contenteditable input via packages/xterm-kit/src/solid/softKeyboardInput.ts). That is volatility, not product choice — which is why it’s owned, not hook-provided. The one domain decision in the gesture — what a tap on content means (kolu: follow apath:lineref into the Code tab, else focus-to-type) — is theonTaphook, threaded through<Xterm>as a prop. - The touch divisor was a hand-rolled parallel of a fact xterm already owns —
PR 2 (#1808) deleted it. One invariant — pointer→cell under a
CSS transform — used to be computed by two separate divisors: xterm’s
hit-test path (its font-metric cell mapping, the authority that actually places
the glyphs, corrected on the input by
unscaleEventPoint), and the tap path’sfileRefAtPoint, which derived its OWN cell size from the post-transform rect (rect.width / cols). They agreed only when xterm’s internal cell equalledrect.width / cols— which nothing guarantees: font-metric rounding, sub-pixel, or screen padding can make a tap near a cell boundary resolve to a different cell under zoom. So the rect-derived divisor was a hand-rolled parallel of the existing source of truth. PR 2 addedcellAtPointto/internals(the single pointer→cell authority, a cosmetic read that degrades to null) and routes the tap through it, deleting the rect divisor and the mirrored “keep both in step” fence warnings that guarded the pair.The tap resolver now lives inTerminal.tsx’sonTap(viacellAtPoint), andinternals.tsrecords the history in past tense — the single divisor isunscaleEventPoint+getCoords, shared by selection, hover, and the tap (packages/xterm-kit/src/internals.ts). Its done-criterion was correctness, not equivalence — a tap resolves to the cell the user visually tapped, under a CSS transform — proven by a standard-suite browser e2e. No user-observed regression surfaced, so it landed as the reuse-the-source-of-truth dedup rather than a bug fix. - The e2e proves it under a transform, but NOT in the literal desktop-zoom
quadrant — because Chromium cannot emulate that quadrant (do not re-attempt
it). The real state the tap-under-zoom guards is a touch tap on the desktop
canvas’s zoomed tile, which needs a coarse-PRIMARY pointer that can hover
(
isTouchwires the tap;handheld = coarse ∧ hover:nonebeing false mounts the canvas). Chromium welds pointer↔hover to touch capability: coarse ⟺ touch ⟺hover:none. A two-run CI oracle proved both directions — with PlaywrighthasTouchthe media is{coarse, hover:none}; without it,{fine, hover}; and CDPsetEmulatedMedia’spointer/hoverfeatures had no effect (pure device defaults). So(pointer:coarse) and (hover:hover)is unconstructable, and the gate instead runs in the reachable@mobiletouch config with an injected CSSscale()standing in for the tile-zoom transform. That is honest, not a cheat:cellAtPointreads the scale offgetBoundingClientRect/offsetWidth, so the transform’s source is transparent to the code under test — the exact tap path runs under a real CSS transform. It is corroborated bycanvas-selection.feature(the sameunscaleEventPoint+getCoordsauthority under real canvas zoom, for selection) and thecellAtPointunit delegation pin. A future reviewer should NOT try to build the desktop-zoom quadrant in e2e — it is a browser-emulation limit, not a missing test. stickyModifiersstays out — the boundary test, applied honestly. It never touches aTerminal: it is an arming latch over@kolu/terminal-protocol’s control/meta byte tables, armed by kolu’s MobileKeyBar and folded into outgoing input. The volatility it leans on (PTY chord encoding) is already owned byterminal-protocol; nothing xterm-shaped remains to encapsulate. A leaf beside the key bar, not kit material (packages/client/src/terminal/stickyModifiers.ts).- IME/composition routing is adapter-level; its machinery lands here once
#1634 merges — open today, not shipped. Android soft
keyboards replay autocorrect and suggestion state through xterm’s
prose-oriented helper textarea (the
keyCode=229diff path); the open PR routes input through a hiddentype=passwordtransport with a zero-width sentinel, forwardingbeforeinputas raw data. That is soft-keyboard + xterm-internals knowledge with zero kolu domain — the kit’s mobile surface is its home when it lands, and this plan reserves the slot rather than claiming the ship.
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.
- Create the package —
packages/xterm-kit/,package.jsonmodeled on@kolu/solid-statepip, exports map././backfill/./internals/./solid.solid-jsis a peer of the/solidentry only. The xterm versions keep the single root pnpm-overrides pin — scratch and liveBufferLineshapes must match, so one workspace-wide version is a correctness requirement. - Move the core verbatim:
scrollbackBackfill.ts(+ itsCONTRACT PINsuite),snapshotBoundary.ts(+ test),scrollLock.ts(+ test) from the client;xtermInternals.ts(+ test) becomes./internals;xtermMirrorContract.test.tsfrom kaval. Zero semantic edits in moved files. - Lift
createMirrorAnchorout ofptyHost.ts(normalLinesOf, the trim handler, the RIS identity re-anchor, thereflowEpochbump,snapToWrapHead) and re-point kaval onto it — the one non-mechanical change, behavior-identical by construction, gated on kaval’s existing suites (ptyHostHistory.test.tscovers eviction, RIS, and history paging). - Re-point both consumers onto the kit — client and kaval, import paths only.
- 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:
- kaval runs from TS source under tsx (eager ESM, no tree-shaking). So a
module the root barrel re-exports is loaded by the daemon, whichever symbol it
imports.
createScrollLockissolid-reactive, so it moved to/solid(the root must stay solid-free); and a staticimport { Terminal } from "@xterm/xterm"crashes Node’s cjs-module-lexer under tsx (“does not provide an export named ‘Terminal’”), so the@xterm/xterm-constructing backfill write path moved behind/backfill. The root barrel is now genuinely what it claims — runtime- neutral, daemon-safe — and the closure guard turns any future misclassification into red CI rather than a broken daemon (superseding the first draft’s conditional./scratchcontainment). - kaval’s currency slice treats
@kolu/xterm-kitas a stable leaf, like the daemon spine: the anchor’s kaval-relevant behavioral surface isPTY_HOST_CONTRACT_VERSION(hashed in kaval), so a wire-breaking anchor change rides the contract bump while a/solidor/backfillchange never recycles a live PTY.
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).
- The component cut.
Terminal.tsx’s ~700-lineonMountbecomes the policy half consuming<Xterm onReady onTap>: the font-wait/owner/disposed-bail choreography becomescreateXtermLifecycle;loadWebgl/unloadWebglbecomeattachWebGL; the touch machinery becomeswireTouchTaps/wireTouchScroll(enableSoftKeyboardInput,createRenderRecovery,wireScrollIntentalready moved in PR 1). The__xterme2e bridge,data-*attributes, keybindings, paste/drop, MobileKeyBar + sticky modifiers, and diagnostics all stay, so cucumber steps are untouched. - The divisor fix. Delete the tap path’s hand-rolled
rect.width / colsdivisor and route the tap through xterm’s font-metric mapping in/internals— the one authority that places the glyphs. This is a behavior change (see the Mobile section): its done-criterion is correctness, not equivalence to today — a tap resolves to the cell the user visually tapped, at normal scale AND under zoom. If the e2e shows today’s taps already mis-target under zoom, an issue is filed when PR 1 ships so the symptom is on record before its fix.
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:
- Sharing a package with the daemon does not couple browser churn to PTY
lifetimes. A
/solid(or/backfill) change never recycles kaval: kaval restarts only on its ownPTY_HOST_CONTRACT_VERSIONbump (the staleKey), never on build drift — and the currency-slice leaf classification above enforces it. - The core +
/solidboundary is an enabler for per-binary nix source filtering — change-scoped build identity, where a/solid-only edit wouldn’t dirty the daemon’s build input. That opportunity pre-exists this refactor and it deliberately does not take it on.
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.