Skip to content

✨ Slice D of #848: draw and own a REPL terminal - #852

Merged
taras merged 3 commits into
mainfrom
agent/issue-848-terminal
Sep 27, 2026
Merged

taras merged 3 commits into
mainfrom
agent/issue-848-terminal

Conversation

@taras

@taras taras commented Sep 27, 2026

Copy link
Copy Markdown
Owner

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 repl exist.

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 repl does not appear in help.

How it works

committed frame → placement → engine ops → bytes → the terminal
                       ↓
              element id + geometry → the live node it came from
                       ↑
   bytes → normalized key or position → target → the same ancestry walk

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 — 160x36 wide,
120x30 medium, 72x20 narrow, and a refusal below the narrow minimum that
recovers 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.ts

Then review:

  1. packages/cli/src/repl/renderer.ts — the frame map and capacity recovery
  2. packages/cli/src/repl/frame.ts — the acknowledged stream and its ownership rule
  3. packages/cli/src/repl/screen.ts — modes, the reader, and the single reset
  4. packages/cli/src/repl/input.ts — normalization, and the two decoder repairs

Look carefully at:

  • renderer.ts copies the engine's output immediately: render() returns a view
    into WASM memory that the next render overwrites and a resize detaches
  • screen.ts registers each release before taking the thing it releases
  • input.ts drains the scanner, which returns at most 128 events per call

What must stay true

  • A frame map names only mounted nodes, for one tree revision — enforced by the
    renderer filtering against tree.mounted() and carrying the frame id, checked
    by the stale-frame, removed-node and behind-a-drawer rows.
  • Output bytes are the caller's — enforced by copying out of WASM memory, checked
    by 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

  • F1 asserts semantic frames at all four profiles, five History rows, marker
    grouping that keeps every marker's identity, an explicit empty Sessions and a
    refusal that exposes no target.
  • H1 proves one acknowledged stream, copied bytes, real capacity recovery and
    nearest-common-ancestor ownership; a settled tree schedules zero frames.
  • H2 proves the restoration contract on every exit path, including halts during
    registration and during a pending read.

Scope

Included

  • deterministic responsive placement, the renderer and its frame map
  • the one acknowledged frame stream
  • the terminal Api, its portable half, and the one owner of its modes
  • byte and pointer normalization
  • four unregistered runtime-named adapter factories

Intentionally unchanged

  • no screens, no command, no retained user data: Slice E
  • nothing registers the adapters, so no other command acquires a terminal

New dependencies

  • Package: @bomb.sh/tty 0.9.0, exact, private to packages/cli
  • Used for: terminal layout, rendering and input decoding
  • Why existing dependencies are insufficient: the repository has no terminal UI
    layer at all. It is used only behind renderer.ts and input.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 paste
    longer than that was silently truncated; the decoder drains until the buffer stops
    producing.
  • scan() never resolves a lone Escape — the documented flush returns the same
    pending 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 ESC cannot join the next keystroke.

Generated or mechanical changes

  • deno.lock and pnpm-lock.yaml are the intentional result of
    deno install --frozen=false then deno task setup, not hand-edited.
  • test-weights.json comes from the Measure test weights workflow, run
    36319908505 at the accepted implementation head, committed unchanged. Floors
    12 deno / 7 node / 4 bun against installed 15/10/5, so no shard count moves.

Risks and limitations

  • No frame this REPL produces comes close to the engine's capacity limits, so the
    recovery path is exercised with a deliberately oversized frame. Measured: text
    measurement capacity scales with the dimensions the engine was built for.
  • Cells are word-wrapped rather than clipped, so text longer than a cell reflows.
    Every row the application emits is a single line that fits its region, so this
    is not reachable from the product; clip was tried and reverted because it made
    the engine report INTERNAL_ERROR at frame sizes that previously worked.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

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.
@taras
taras merged commit 5886ba9 into main Sep 27, 2026
43 of 44 checks passed
@taras
taras deleted the agent/issue-848-terminal branch September 27, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant