Skip to content
Draft
90 changes: 83 additions & 7 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,23 +288,24 @@ The output reflects the complete visual state of the frame. The caller SHOULD
write the output to the terminal without modification.

The output `Uint8Array` is a view over renderer-owned memory. It is valid until
the next `render()` call on the same Term instance, at which point the buffer
may be reused. Callers who need to retain the output beyond the next render MUST
copy it.
the next `render()` or `update()` call on the same Term instance, at which point
the buffer may be reused or detached (§7.7). Callers who need to retain the
output beyond that point MUST copy it.

### 7.4 Lifecycle

A Term instance is created for specific terminal dimensions. The caller provides
width and height at creation time.

To handle terminal resize, the caller creates a new Term with the new
dimensions. The previous Term instance becomes stale and SHOULD NOT be used for
further rendering.

Creation of a Term is asynchronous because it may involve WASM module
preparation. A Term instance MAY be used for any number of render transactions.
The Term retains its cell buffers across frames for diffing purposes.

A Term instance's dimensions MAY be changed after creation through the update
transaction (§7.7). A resized Term remains valid: it retains its `TermInfo`
attachment (§7.6) and continues to accept render transactions at the new
dimensions. Creating a new Term is NOT required to handle terminal resize.

### 7.5 Clip semantics

An element whose `props` include a `clip` group declares a **clip region**: a
Expand Down Expand Up @@ -393,6 +394,53 @@ invariant (Terminfo Specification TINV-5), truecolor emission requires positive
evidence — a terminfo entry, environment evidence, or a probe reply — which
supersedes the renderer's historical unconditional truecolor output.

### 7.7 Update transaction (resize)

The update transaction changes a Term instance's dimensions in place. Like the
render transaction (§7.2), it is synchronous: it MUST NOT yield, suspend, or
require callbacks during execution.

**Inputs.** The update transaction accepts exactly one of:

- Explicit dimensions: a target width and height in character cells. Both MUST
be positive integers; the transaction MUST throw otherwise.
- A resize event array: an ordered array of event objects. The transaction reads
objects whose `type` field is `"resize"`, taking `width` and `height` from
them; objects with any other `type` MUST be ignored. When the array contains
multiple resize events, the last one wins (coalescing). An array with no
resize events is a no-op.

The accepted event shape is defined structurally by this specification: an
object with `type: "resize"` and numeric `width` and `height` fields. It is
intentionally assignable from the input specification's `ResizeEvent`, but the
renderer MUST NOT depend on the input parser (§11.4) — the shape, not the type,
is normative.

An update transaction whose target dimensions equal the Term's current
dimensions MUST be a no-op.

**Semantics.** A non-no-op update transaction:

1. Reallocates renderer state for the new dimensions within the Term's existing
WASM instance and linear memory, growing the memory if required. The renderer
MUST NOT create a new WASM module or instance; when the Term is attached to a
`TermInfo` handle, the shared memory and the handle's region pointers remain
valid throughout (Terminfo Specification's region-model constraints).
2. Discards all diff state. The next render transaction MUST emit the frame as a
complete redraw, exactly as under generation invalidation (§7.6).
3. Discards pointer interaction state (hover and press tracking), since cell
coordinates under the pointer have changed. No synthetic pointer events are
emitted.

**Memory.** WASM linear memory can only grow. An update to smaller dimensions
retains the high-water-mark allocation; memory is not reclaimed until the Term
itself is discarded. This is accepted behavior, not a defect.

**Output invalidation.** Growing linear memory detaches existing buffer views.
Output `Uint8Array`s returned by render transactions prior to an update
transaction MUST NOT be used after it (this strengthens the validity window in
§7.3: output is valid until the next `render()` **or** `update()` call).

---

## 8. Public Rendering API
Expand Down Expand Up @@ -573,6 +621,34 @@ Packs color channel values (each 0–255) into a single 32-bit integer in ARGB
format. Alpha defaults to 255 (fully opaque). The returned value is used
wherever the directive model expects a color.

### 8.6 Term update

```
term.update(options:
| { width: number; height: number }
| { events: ResizeEvent[] }
): void
```

Performs an update transaction as defined in §7.7, changing the Term's
dimensions in place. The options bag is a discriminated union: either explicit
dimensions, or an array of events from which resize events are read (last one
wins; non-resize events are ignored).

`ResizeEvent` here denotes the structural shape defined in §7.7 — an object with
`type: "resize"` and numeric `width`/`height` — not a type imported from the
input parser. The input specification's `ResizeEvent` is assignable to it, so
events produced by `input.scan()` can be passed through directly:

```
const { events } = input.scan(bytes);
const resizes = events.filter((e) => e.type === "resize");
if (resizes.length > 0) term.update({ events: resizes });
```

The method returns nothing. The next `render()` after a non-no-op update emits a
complete redraw (§7.7).

---

## 9. Directive Model
Expand Down
66 changes: 46 additions & 20 deletions term-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export interface Native {
memory: WebAssembly.Memory;
statePtr: number;
opsBuf: number;
/**
* Re-initialize renderer state for new dimensions in place
* (renderer-spec 7.7). Reuses this instance and memory; statePtr and
* opsBuf may change. Growing memory detaches prior buffer views.
*/
update(w: number, h: number): void;
reduce(
ct: number,
buf: number,
Expand Down Expand Up @@ -128,37 +134,57 @@ export async function createTermNative(
error_message_ptr(ct: number, index: number): number;
};

let size = ct.clayterm_size(w, h);

// The transfer budget is intentionally fixed: text/id/snapshot payload bytes
// get 1MB, and fixed op overhead gets one max-sized element per Clay element.
// Do not grow this dynamically per render; improve the wire format instead.
let transferBytes = TEXT_TRANSFER_BUFFER_BYTES +
CLAY_DEFAULT_MAX_ELEMENT_COUNT * MAX_FIXED_ELEMENT_WIRE_BYTES;

let statePtr: number;
let opsBuf: number;
if (attach) {
let arena = attach.alloc(size);
opsBuf = attach.alloc(transferBytes, 4);
statePtr = ct.init(arena, w, h, attach.structPtr);
} else {
// Grow memory once to fit heap + renderer state + fixed transfer buffer.
let heap = ct.__heap_base.value as number;
let needed = heap + size + transferBytes;
let pages = Math.ceil(needed / WASM_PAGE_BYTES);
let current = memory.buffer.byteLength / WASM_PAGE_BYTES;
if (pages > current) {
memory.grow(pages - current);
let statePtr!: number;
// opsBuf is fixed-size: attached mode allocates it exactly once here;
// standalone mode recomputes it in layout() as the state region resizes.
let opsBuf = attach ? attach.alloc(transferBytes, 4) : 0;
let arena: number | undefined;
let arenaCapacity = 0;

function layout(lw: number, lh: number): void {
let size = ct.clayterm_size(lw, lh);
if (attach) {
// Bump-allocated memory is never reclaimed: reuse the arena when
// it still fits, otherwise allocate a larger one and abandon the
// old (growth-only, renderer-spec 7.7).
if (arena === undefined || size > arenaCapacity) {
arena = attach.alloc(size);
arenaCapacity = size;
}
statePtr = ct.init(arena, lw, lh, attach.structPtr);
} else {
// Standalone layout is [heap: state][opsBuf]; opsBuf moves when
// the state size changes.
let heap = ct.__heap_base.value as number;
let needed = heap + size + transferBytes;
let pages = Math.ceil(needed / WASM_PAGE_BYTES);
let current = memory.buffer.byteLength / WASM_PAGE_BYTES;
if (pages > current) {
memory.grow(pages - current);
}
statePtr = ct.init(heap, lw, lh, 0);
opsBuf = (heap + size + 3) & ~3;
}
statePtr = ct.init(heap, w, h, 0);
opsBuf = (heap + size + 3) & ~3;
}
layout(w, h);

return {
memory,
statePtr,
opsBuf,
get statePtr() {
return statePtr;
},
get opsBuf() {
return opsBuf;
},
update(uw: number, uh: number): void {
layout(uw, uh);
},
reduce: ct.reduce,
output: ct.output,
length: ct.length,
Expand Down
84 changes: 75 additions & 9 deletions term.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@ export interface TermOptions {
terminfo?: TermInfo;
}

/**
* Structural resize event accepted by update() (renderer-spec 7.7).
* The input parser's ResizeEvent is assignable to this shape.
*/
export interface TermResizeEvent {
type: "resize";
width: number;
height: number;
}

/**
* Options bag for update() (renderer-spec 8.6): explicit dimensions,
* or an event array from which resize events are read (last one wins;
* non-resize events are ignored).
*/
export type UpdateOptions =
| { width: number; height: number }
| { events: ReadonlyArray<TermResizeEvent | { type: string }> };

export interface RenderOptions {
mode?: "line";

Expand Down Expand Up @@ -87,6 +106,13 @@ export interface RenderResult {

export interface Term {
render(ops: Op[], options?: RenderOptions): RenderResult;

/**
* Change dimensions in place (renderer-spec 7.7). Synchronous. The
* next render() after a non-no-op update emits a complete redraw,
* and output views from prior renders become invalid.
*/
update(options: UpdateOptions): void;
}

export async function createTerm(options: TermOptions): Promise<Term> {
Expand All @@ -103,7 +129,7 @@ export async function createTerm(options: TermOptions): Promise<Term> {
}

let native = await createTermNative(width, height, attach);
let { memory, statePtr, opsBuf } = native;
let { memory } = native;

let prev = new Set<string>();
let pressed = new Set<string>();
Expand All @@ -113,7 +139,12 @@ export async function createTerm(options: TermOptions): Promise<Term> {

return {
render(ops: Op[], options?: RenderOptions): RenderResult {
let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength);
let len = pack(
ops,
memory.buffer,
native.opsBuf,
memory.buffer.byteLength,
);
let mode = options?.mode === "line" ? 1 : 0;
let row = options?.row ?? 1;
let now = performance.now() / 1000;
Expand All @@ -126,7 +157,7 @@ export async function createTerm(options: TermOptions): Promise<Term> {
dt = now - lastRenderAt;
}
lastRenderAt = now;
native.reduce(statePtr, opsBuf, len, mode, row, dt);
native.reduce(native.statePtr, native.opsBuf, len, mode, row, dt);

if (options?.pointer) {
let { x, y, down } = options.pointer;
Expand All @@ -135,8 +166,8 @@ export async function createTerm(options: TermOptions): Promise<Term> {

let output = new Uint8Array(
memory.buffer,
native.output(statePtr),
native.length(statePtr),
native.output(native.statePtr),
native.length(native.statePtr),
);

let current = new Set(
Expand Down Expand Up @@ -185,18 +216,53 @@ export async function createTerm(options: TermOptions): Promise<Term> {
};

let errors: ClayError[] = [];
let count = native.errorCount(statePtr);
let count = native.errorCount(native.statePtr);
for (let i = 0; i < count; i++) {
let code = native.errorType(statePtr, i);
let code = native.errorType(native.statePtr, i);
errors.push({
type: ERROR_TYPES[code] ?? `UNKNOWN_${code}`,
message: native.errorMessage(statePtr, i),
message: native.errorMessage(native.statePtr, i),
});
}

let animating = native.animating(statePtr) > 0;
let animating = native.animating(native.statePtr) > 0;
wasAnimating = animating;
return { output, events, info, errors, animating };
},
update(options: UpdateOptions): void {
let w: number | undefined;
let h: number | undefined;
if ("events" in options) {
for (let e of options.events) {
if (e.type === "resize") {
let r = e as TermResizeEvent;
w = r.width;
h = r.height;
}
}
if (w === undefined || h === undefined) {
return;
}
} else {
w = options.width;
h = options.height;
}
if (
!Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0
) {
throw new RangeError(`invalid terminal dimensions ${w}x${h}`);
}
if (w === width && h === height) {
return;
}
width = w;
height = h;
native.update(w, h);
prev = new Set();
pressed = new Set();
wasDown = false;
lastRenderAt = undefined;
wasAnimating = false;
},
};
}
Loading
Loading