✨ Slice D of #848: draw and own a REPL terminal - #852
Merged
Merged
Conversation
Slice D of #848: the terminal platform. A committed tree is placed at four sizes, drawn through one layout engine, and answered back with the exact live node a pointer landed on; presentation time has one owner; and every way out of the terminal gives it back. No screens, no retained user data, no `xmd repl` — Slice E consumes this. `layout.ts` is presentation and nothing else. It reads the committed frame and tells the tree nothing, so resizing moves things and changes no selection, no model object and no action's meaning. Four profiles are decided — `160x36` wide, `120x30` medium, `72x20` narrow, and anything smaller a refusal that recovers when the window grows. Wide and medium carry the sidebar, transcript, bindings/history inspection, drawer layer and the fixed full-width footer; narrow shows the one surface the route selected, and keeps the same drawer and footer contracts. Every unrouted surface stays mounted and stays off the narrow frame — in no cell, in no frame map, reachable by no pointer — because a narrow screen that stacked every region would be a wide screen with the columns taken out, and every row of the lists nobody asked for would still be a target. Sessions says so when it is empty. The History band is five rows at every size, and when its labels cannot all fit they are grouped visually while every marker keeps its own identity, so a compact band never costs a reader the ability to select an exact position. A refusal draws no cell at all, which is also why nothing it hides can be pointed at. `renderer.ts` draws only what is mounted. A frame laid out before a removal still names the node that has gone, so the renderer filters against the tree as it stands rather than trusting the frame it was handed. Each result carries an immutable map from the element the engine measured to the live node that asked for it, and the tree revision that produced it travels with it: a pointer from a frame the tree has moved past, one naming a node that has since gone, and one landing behind an open drawer all reach nothing. A pointer that does resolve becomes the same normalized event a key produces and walks the same ancestry, so clicking a control and pressing Enter on it are one action. Every render input is frozen before it crosses into the engine, and the bytes that come back are copied immediately — `render()` returns a view into WASM memory that the next render overwrites and a resize detaches outright. The engine's measurement arenas are sized from the dimensions it was built for, so a frame too large for the engine in hand is redrawn on one rebuilt for that frame's own size, from the retained snapshot: same selection, same focus, same actions, same frame. `frame.ts` is the one acknowledged frame stream. Holding a subscription is what asks for frames; there is no start and no stop. An advance waits until every subscriber has **applied** the last timestamp and said so — receiving a frame is not applying it, because laying out and drawing with a timestamp suspends, and a stream that counted the handover would publish the next frame while this one was still being drawn. A cancelled subscriber's demand goes with it and its acknowledgement does not arrive, because it did not finish the frame it held. When the last subscription goes the stream is settled and no timer is scheduled at all. An animation spanning components belongs to their nearest common mounted ancestor and is refused to anyone else: a participant's own lifetime is shorter than the animation's, and a distant ancestor would hold the clock for a subtree that stopped. `delta` is seconds, because that is what the engine's transitions take. `terminal.ts` is the contextual Api, `terminal-host.ts` the portable half over five capabilities a runtime supplies, and `screen.ts` the one owner of what a full-screen interface takes. It registers each release before taking the thing it releases, so a cancellation in between still restores; success, refusal, failure, cancellation and end of input all stop the reader, remove every listener, restore the modes and write the final reset exactly once. End of input is a lifecycle outcome, not a key. Input is normalized once and never interpreted. The scanner decides what a byte was; this decides which of those the composition layer has a meaning for, and drops the rest rather than inventing an action for them. A pointer carries a column and a row and no target, because which node is there is the committed frame's answer. Text is its own member of the normalized union rather than a key with a payload, and `Backspace` joins the named keys. The difference is what they mean: a named key is a command — submit, dismiss, traverse, erase — that whoever claims it recognizes, and text is content that goes wherever content goes. Text carries what the terminal decoded, so a shifted capital, an accent and a multi-byte character all arrive already assembled, and the `LF` inside a paste arrives as a newline even though the decoder reports it as Control-J with no text of its own. A chord's physical key code is a letter — Control-C is `c`, Alt-a is `a`, and Alt-a even carries the text `a` — so a chord is dropped whole, payload included; reading the code would insert the letter somebody held Control with, which is how an editor types a `c` when a person asked it to stop. Backspace is the terminal's `DEL`, because that is what the key sends; `0x08` is Control-H and is a chord. One thing the pinned scanner does not do: `@bomb.sh/tty` 0.9.0 never resolves a lone Escape — rescanning with no bytes, as its own documentation describes, returns the same pending report and no event, however many times it is called. So `useReplDecoder` makes that decision, where it is a normalization decision rather than a decoding one, and replaces the scanner afterwards because the spent `ESC` would otherwise join the next keystroke. The four runtime adapters name their runtime's spelling of those five capabilities and nothing else. None of them is registered; no entrypoint imports one, and no command mentions a REPL.
Measured on run 36319908505 at the accepted Slice D implementation head, and committed unchanged. Its floors are 12 deno, 7 node and 4 bun against installed counts of 15/10/5, so nothing the measurement says requires a shard count to move.
Every package here is typechecked under Node as well as Deno, and that check is a step of every `test-node` shard. A module that names the `Deno` global does not compile there even when nothing would ever load it, so the two Deno-named terminal adapters failed all ten shards with `Cannot find name 'Deno'`. The capabilities are now read off `globalThis` and parsed, which is how the other runtime-named adapters in this repository stay compilable everywhere and constructible only where they work. A host missing one of them has no terminal, and says so, rather than being discovered halfway through taking one.
4 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Issue #848 asks for a REPL that can run one XMD entry and reconstruct it from a
URL. It is delivered as five slices; this is Slice D, the terminal platform —
the layer that decides where things go, draws them, and gives the terminal back.
It references #848 and does not close it: only Slice E makes
xmd replexist.What changes
Before: a described screen reconciles into a mounted tree, but nothing places it,
nothing draws it, and nothing turns terminal bytes into the events it understands.
After: a committed tree is laid out at four sizes, rendered through one layout
engine, and answered back with the exact live node a pointer landed on;
presentation time has one owner; and every way out of the terminal restores it.
The runtime adapters exist as construction boundaries and are not registered:
no entrypoint imports one, and
xmd repldoes not appear in help.How it works
Placement is presentation and nothing else: it reads the committed frame and
tells the tree nothing, so resizing moves things and changes no selection, no
model object and no action's meaning. Four profiles are decided —
160x36wide,120x30medium,72x20narrow, and a refusal below the narrow minimum thatrecovers when the window grows.
Only mounted nodes are drawn. Each frame carries an immutable map from the element
the engine measured to the live node that asked for it, valid for exactly the tree
revision that produced it — so a pointer from a frame the tree has moved past, one
naming a node that has gone, and one landing behind an open modal all reach
nothing. A pointer that does resolve becomes the same normalized event a key
produces and walks the same ancestry.
Presentation time has one owner: a single acknowledged frame stream. Holding a
subscription is what asks for frames, an advance waits until every subscriber has
applied the previous timestamp, and a settled tree schedules no timer at all.
Review guide
Start with:
packages/cli/src/repl/layout.tsThen review:
packages/cli/src/repl/renderer.ts— the frame map and capacity recoverypackages/cli/src/repl/frame.ts— the acknowledged stream and its ownership rulepackages/cli/src/repl/screen.ts— modes, the reader, and the single resetpackages/cli/src/repl/input.ts— normalization, and the two decoder repairsLook carefully at:
renderer.tscopies the engine's output immediately:render()returns a viewinto WASM memory that the next render overwrites and a resize detaches
screen.tsregisters each release before taking the thing it releasesinput.tsdrains the scanner, which returns at most 128 events per callWhat must stay true
A frame map names only mounted nodes, for one tree revision— enforced by therenderer filtering against
tree.mounted()and carrying the frame id, checkedby the stale-frame, removed-node and behind-a-drawer rows.
Output bytes are the caller's— enforced by copying out of WASM memory, checkedby two renderers driven through one sequence where one caller scribbles over
every result.
Every exit restores the terminal exactly once— checked for success, refusal,failure, cancellation, end of input, and a cancellation during registration.
How to verify it
grouping that keeps every marker's identity, an explicit empty Sessions and a
refusal that exposes no target.
nearest-common-ancestor ownership; a settled tree schedules zero frames.
registration and during a pending read.
Scope
Included
Intentionally unchanged
New dependencies
@bomb.sh/tty0.9.0, exact, private topackages/clilayer at all. It is used only behind
renderer.tsandinput.ts.Two things this version does that the host has to work around, both documented at
the call site:
scan()returns at most 128 events per call and buffers the rest, so a pastelonger than that was silently truncated; the decoder drains until the buffer stops
producing.
scan()never resolves a lone Escape — the documented flush returns the samepending report and no event, however many times it is called. That decision is
made at the normalization boundary, and the scanner is replaced afterwards so the
spent
ESCcannot join the next keystroke.Generated or mechanical changes
deno.lockandpnpm-lock.yamlare the intentional result ofdeno install --frozen=falsethendeno task setup, not hand-edited.test-weights.jsoncomes from the Measure test weights workflow, run36319908505at the accepted implementation head, committed unchanged. Floors12 deno / 7 node / 4 bun against installed 15/10/5, so no shard count moves.
Risks and limitations
recovery path is exercised with a deliberately oversized frame. Measured: text
measurement capacity scales with the dimensions the engine was built for.
Every row the application emits is a single line that fits its region, so this
is not reachable from the product;
clipwas tried and reverted because it madethe engine report
INTERNAL_ERRORat frame sizes that previously worked.Scope confirmation