Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/element-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset needs revision.

Changeset package scope does not match the affected packages confidently. Changeset bump does not match the consumer-visible impact.

View the proposed replacement
---
'@bomb.sh/tty': minor
---

TODO: Write changeset message (inference failed: HTTP 401: {"error":{"message":"User not found.","code":401}})

"@bomb.sh/tty": minor
---

Adds `result.info.get(id).replay` to reuse an entire rendered element, including
its descendants, in the next `render()` call without rebuilding or repacking its
directives. Obtain a fresh replay from each completed render; `update()` and
rejected renders preserve existing replays.

Removes `snapshot()`. Use the rendered element's `replay` directive instead.
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ Do not include any agent marketing material (e.g. "Generated with...",
renderer-spec.md §7.10).

- Each frame is a complete snapshot. The renderer carries no UI tree state
between frames — only cell buffers for diffing.
between frames. Retained directives support element replay; cell buffers
support diffing.

- Public API values are string literals (`"center-center"`), not numeric
constants or enums. Use functions when a value is parameterized.
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ EXPORTS = \
-Wl,--export=pointer_over_count \
-Wl,--export=pointer_over_id_string_length \
-Wl,--export=pointer_over_id_string_ptr \
-Wl,--export=get_element_bounds \
-Wl,--export=get_element_info \
-Wl,--export=animating \
-Wl,--export=error_count \
-Wl,--export=error_type \
Expand Down
12 changes: 12 additions & 0 deletions bench/render.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ let uiOps: Op[] = [
];

let bench = withCodSpeed(new Bench({ name: "render" }));
let replayTerm = await createTerm({ width: 80, height: 24 });
let replayResult = replayTerm.render(dashboardOps);

bench
.add(
Expand All @@ -118,6 +120,16 @@ bench
term.render(dashboardOps);
}
}),
)
.add(
"render steady replay",
sync(() => {
for (let i = 0; i < 250; i++) {
replayResult = replayTerm.render([
replayResult.info.get("root")!.replay,
]);
}
}),
);

await bench.run();
Expand Down
61 changes: 11 additions & 50 deletions ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface Transition {
const OP_OPEN_ELEMENT = 0x02;
const OP_TEXT = 0x03;
const OP_CLOSE_ELEMENT = 0x04;
const OP_SNAPSHOT = 0x05;
const OP_REPLAY = 0x05;

/* Property group masks for OPEN_ELEMENT */
const PROP_LAYOUT = 0x01;
Expand Down Expand Up @@ -294,9 +294,11 @@ export function pack(
break;
}

case OP_SNAPSHOT: {
new Uint8Array(mem).set(op.data, o);
o += op.data.length;
case OP_REPLAY: {
view.setUint32(o, OP_REPLAY, true);
view.setUint32(o + 4, op.offset, true);
view.setUint32(o + 8, op.length, true);
o += 12;
break;
}

Expand Down Expand Up @@ -501,12 +503,13 @@ export interface Text {
caret?: number;
}

interface Snapshot {
directive: typeof OP_SNAPSHOT;
data: Uint8Array;
interface Replay {
directive: typeof OP_REPLAY;
offset: number;
length: number;
}

export type Op = OpenElement | Text | CloseElement | Snapshot;
export type Op = OpenElement | Text | CloseElement | Replay;

export function open(
id: string,
Expand All @@ -526,48 +529,6 @@ export function close(): CloseElement {
return { directive: OP_CLOSE_ELEMENT };
}

function packSize(ops: Op[]): number {
let n = 0;
for (let op of ops) {
switch (op.directive) {
case OP_CLOSE_ELEMENT:
n += 4;
break;
case OP_SNAPSHOT:
n += op.data.length;
break;
case OP_OPEN_ELEMENT: {
n += 4; // opcode
n += 4 + Math.ceil(encoder.encode(op.id).length / 4) * 4; // id string
n += 4; // mask
if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align
if (op.bg !== undefined) n += 4;
if (op.cornerRadius) n += 4;
if (op.border) n += 36; // widths word + 4 sides × (fg + bg)
if (op.clip) n += 4;
// x, y, expand width/height, parent, attach/pointer, clip/z
if (op.floating) n += 7 * 4;
if (op.transition) n += 8;
if (pointerShapeIndex(op.cursor) !== undefined) n += 4;
break;
}
case OP_TEXT: {
n += 4 + 4 + 4 + 4 + 4; // opcode + color + bg + cfg + caret
n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string
break;
}
}
}
return n;
}

export function snapshot(ops: Op[]): Op {
let size = packSize(ops);
let buf = new ArrayBuffer(size);
let words = pack(ops, buf, 0, size);
return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) };
}

const TP_X = 1;
const TP_Y = 2;
const TP_WIDTH = 4;
Expand Down
80 changes: 49 additions & 31 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,18 @@ yields, or partial results.

Each render transaction operates on a complete, self-contained snapshot of the
UI. The renderer MUST NOT maintain an internal component tree or UI state across
frames. The only state the renderer retains between frames is the cell buffer
used for diffing, which is an implementation detail of output minimization and
not observable to the caller except through reduced output size.
frames. Elements from the previous render MAY be supplied through their replay
directives (§8.3.4). The caller still supplies the complete frame on every
render.

### 4.4 Double-buffered diffing

The renderer maintains two cell buffers: a front buffer (the previously rendered
frame) and a back buffer (the frame being rendered). After populating the back
buffer from the current frame's render commands, the renderer compares it
against the front buffer and emits ANSI bytes only for cells that differ.
Changed cells are then copied from the back buffer to the front buffer so that
both buffers are identical at the end of the transaction. This mechanism is
internal to the renderer and not directly observable to the caller.
against the front buffer and emits ANSI bytes only for cells that differ. The
completed frame becomes the comparison baseline for the next render. This
mechanism is internal to the renderer and not directly observable to the caller.

---

Expand Down Expand Up @@ -218,7 +217,8 @@ complete frame as a directive array and receives ANSI bytes in return.

**INV-3. Frame-snapshot independence.** The renderer MUST NOT require the caller
to maintain or provide state across frames beyond calling `render()` on the same
Term instance. Each directive array fully describes its frame.
Term instance. Each directive array fully describes its frame, directly or
through valid element replay directives.

**INV-4. ANSI byte output.** The output of a render transaction MUST be a byte
sequence of valid UTF-8–encoded ANSI escape codes that is directly writable to a
Expand Down Expand Up @@ -549,8 +549,8 @@ shape:
element in `"capture"` mode hides the elements beneath it; one in
`"passthrough"` mode lets them decide.

Elements inside a `snapshot()` participate as direct directives would.
Resolution and output are identical in line mode (§8.2.2).
Replayed elements participate as direct directives would. Resolution and output
are identical in line mode (§8.2.2).

**Output.** The Term tracks the shape it last emitted, starting at `default`.
When the resolved shape differs, the render MUST append `ESC ] 22 ; <shape> ST`
Expand Down Expand Up @@ -704,25 +704,31 @@ occupied by a grapheme cluster with combining marks MUST be treated as changed
The set of styling properties accepted by `props` is part of the current
implementation surface and may be extended.

#### 8.3.4 snapshot
#### 8.3.4 Element replay

```
snapshot(ops: Op[]): Op
```ts
let sidebar = previous.info.get("sidebar")!;
let next = term.render([
open("root", rootProps),
sidebar.replay,
close(),
]);
```

Creates a snapshot by pre-packing the given directive array into its transfer
encoding. The returned value is an `Op` and can appear anywhere in a directive
array where the original ops would have appeared. The internal representation is
opaque.
Each `ElementInfo` MUST contain a `replay` field whose value is an `Op`. It can
be placed directly in a directive array as a substitute for that entire element,
from its `open()` through its matching `close()`, including all descendants. Its
representation is opaque. Callers can reuse unchanged elements without
rebuilding their directives or packing their contents into the transfer buffer.

When the renderer encounters a snapshot during transfer, it copies the
pre-packed bytes directly into the command buffer without re-encoding. The
snapshot's ops MUST be structurally balanced (every `open` matched by a
`close`).
A replay is equivalent to supplying the element's original directives in that
position. Layout, pointer handling, caret placement, and transitions use the new
render's context and options. Replayed elements and their descendants provide
fresh `ElementInfo` in the result.

Snapshots enable higher-level frameworks to implement dirty tracking: a
component whose inputs have not changed can reuse a previously created snapshot,
avoiding the cost of re-packing its subtree each frame.
A replay is valid until the next render completes. `update()` and renders that
throw do not expire it. To reuse the element again, obtain its replay from the
new result. Using an expired replay is invalid input (§9.1).

### 8.4 Sizing helpers

Expand Down Expand Up @@ -805,10 +811,10 @@ _This section is normative._
### 9.1 Directive-array pattern

The rendering input is an ordered array of directive objects. Each directive is
a plain JavaScript/TypeScript object created by a constructor function (Section
8.3). Directives are not classes, do not carry methods, and do not participate
in a prototype chain. They MAY be spread, composed, stored, or inspected as
ordinary objects.
a plain JavaScript/TypeScript object created by a constructor function or
returned as an element's replay (Section 8.3). Directives are not classes, do
not carry methods, and do not participate in a prototype chain. They MAY be
spread, composed, stored, or inspected as ordinary objects.

The array is processed in order. Open and close directives form an implicit
tree. The renderer processes them sequentially.
Expand All @@ -818,9 +824,10 @@ that do not match a preceding open, is invalid input. Callers SHOULD validate
directive arrays before rendering. The renderer's behavior when given an invalid
directive array is unspecified by this specification.

A snapshot is semantically equivalent to splicing its source ops into the array
at the snapshot's position. The renderer MUST produce identical layout and
output regardless of whether ops are provided directly or via a snapshot.
A replay is semantically equivalent to splicing the referenced element's
directives into the array at the replay's position. The renderer MUST produce
identical layout and output for direct and replayed directives given the same
rendering history and options.

### 9.2 Transfer to the WASM module

Expand All @@ -836,6 +843,9 @@ expose only the raw host-level TypedArray message `"offset is out of bounds"`
for this condition. The error message SHOULD direct callers to render a smaller
visible slice or reduce frame content.

The same capacity policy applies when replayed content makes the frame exceed
renderer capacity, even if its transfer data fits.

### 9.3 Directive identity

Each element directive carries an `id` provided by the caller via `open()`.
Expand Down Expand Up @@ -1138,6 +1148,7 @@ interface RenderInfo {

interface ElementInfo {
bounds: BoundingBox;
replay: Op;
}

interface BoundingBox {
Expand All @@ -1153,6 +1164,9 @@ element's computed bounding box in character cells, as determined by the layout
engine after the render transaction completes. `x` and `y` are zero-indexed from
the top-left corner of the layout root.

The `replay` field substitutes for the entire element as defined in §8.3.4. A
result's `info` MUST NOT return metadata belonging to a later render.

Querying an element with an empty-string id or an id not present in the frame
returns `undefined`.

Expand Down Expand Up @@ -1210,6 +1224,10 @@ acknowledged gaps, and the approach may evolve.
structural errors (unbalanced open/close pairs, invalid field types). Exported
and used in tests.

**`validated(term)`** — An optional wrapper that validates directives before
rendering, including rejecting expired replay directives. These diagnostics are
not required when calling the unwrapped Term.

**`pack(ops, mem, offset)`** — An internal function that serializes a directive
array into the transfer encoding described in Section 12.1. Currently exported
but not public API; its exposure is incidental to the module structure.
Expand Down
Loading
Loading