From cff3d0bdab3688c0967a1a26474dcf3ff84fac87 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 21 Aug 2026 19:06:57 -0400 Subject: [PATCH 1/9] spec: in-place resize via term.update() (renderer-spec 7.7, 8.6) Replaces the create-a-new-Term resize story (old 7.4) with a synchronous update transaction: reallocate renderer state in place within the existing wasm instance and linear memory, never re-instantiating over shared memory, so a TermInfo attachment survives resize. Accepts a discriminated options bag: explicit dimensions or an event array (resize events coalesce last-wins; non-resize events ignored). The event shape is structural, deliberately assignable from input's ResizeEvent without introducing a renderer->input dependency (11.4). After a non-no-op update the next render is a complete redraw, reusing the 7.6 generation-invalidation mechanism. Growth-only memory (high-water mark on downsize) is documented as accepted behavior. Output views are invalidated by update() since memory.grow detaches buffers; 7.3's validity window is widened accordingly. --- specs/renderer-spec.md | 90 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 9cad0f0..eba4389 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -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 @@ -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 @@ -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 From 82901c1acc919022781dafe684fe68df9c3cd9dd Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 21 Aug 2026 19:17:34 -0400 Subject: [PATCH 2/9] feat: in-place resize via term.update() (renderer-spec 7.7) --- term-native.ts | 68 +++++++++++++++++++++++++++++++++-------------- term.ts | 57 ++++++++++++++++++++++++++++++++------- test/term.test.ts | 32 ++++++++++++++++++++++ validate.ts | 3 +++ 4 files changed, 131 insertions(+), 29 deletions(-) diff --git a/term-native.ts b/term-native.ts index e84a6ea..d81097d 100644 --- a/term-native.ts +++ b/term-native.ts @@ -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, @@ -128,37 +134,59 @@ 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; + let opsBuf!: number; + 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). opsBuf is fixed-size and + // allocated once. + if (arena === undefined || size > arenaCapacity) { + arena = attach.alloc(size); + arenaCapacity = size; + } + if (opsBuf === undefined) { + opsBuf = attach.alloc(transferBytes, 4); + } + 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, diff --git a/term.ts b/term.ts index b1f0bf2..ec78594 100644 --- a/term.ts +++ b/term.ts @@ -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 }; + export interface RenderOptions { mode?: "line"; @@ -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 { @@ -103,7 +129,7 @@ export async function createTerm(options: TermOptions): Promise { } let native = await createTermNative(width, height, attach); - let { memory, statePtr, opsBuf } = native; + let { memory } = native; let prev = new Set(); let pressed = new Set(); @@ -113,7 +139,12 @@ export async function createTerm(options: TermOptions): Promise { 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; @@ -126,7 +157,7 @@ export async function createTerm(options: TermOptions): Promise { 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; @@ -135,8 +166,8 @@ export async function createTerm(options: TermOptions): Promise { let output = new Uint8Array( memory.buffer, - native.output(statePtr), - native.length(statePtr), + native.output(native.statePtr), + native.length(native.statePtr), ); let current = new Set( @@ -185,18 +216,26 @@ export async function createTerm(options: TermOptions): Promise { }; 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 { + if ("events" in options) { + return; + } + width = options.width; + height = options.height; + native.update(width, height); + }, }; } diff --git a/test/term.test.ts b/test/term.test.ts index 6d12a91..2505a9f 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -340,6 +340,38 @@ describe("term", () => { }); }); + describe("update", () => { + let frame: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hi"), + close(), + ]; + + it("resizes in place and lays out at the new dimensions", () => { + term.render(frame); + term.update({ width: 12, height: 4 }); + let result = term.render(frame); + expect(result.info.get("root")?.bounds).toEqual({ + x: 0, + y: 0, + width: 12, + height: 4, + }); + expect(trim(print(decode(result.output), 12, 4))).toBe( + trim( + [ + "Hi", + "", + "", + "", + ].join("\n"), + ), + ); + }); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5 }); diff --git a/validate.ts b/validate.ts index 07c3f61..e92c1e6 100644 --- a/validate.ts +++ b/validate.ts @@ -234,5 +234,8 @@ export function validated(term: Term): Term { assert(ops); return term.render(ops, options); }, + update(options) { + return term.update(options); + }, }; } From a99a00c8f4764cdde7e2bf8dde07e0b50941163f Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 21 Aug 2026 21:21:02 -0400 Subject: [PATCH 3/9] refactor: allocate attached opsBuf eagerly, drop definite-assignment guard Review follow-up: 'let opsBuf!: number' plus a runtime '=== undefined' check sent conflicting signals. Allocating the fixed-size attached buffer once at declaration makes opsBuf always a number and removes the guard from layout(). --- term-native.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/term-native.ts b/term-native.ts index d81097d..6f2eafa 100644 --- a/term-native.ts +++ b/term-native.ts @@ -141,7 +141,9 @@ export async function createTermNative( CLAY_DEFAULT_MAX_ELEMENT_COUNT * MAX_FIXED_ELEMENT_WIRE_BYTES; let statePtr!: number; - let opsBuf!: 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; @@ -150,15 +152,11 @@ export async function createTermNative( 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). opsBuf is fixed-size and - // allocated once. + // old (growth-only, renderer-spec 7.7). if (arena === undefined || size > arenaCapacity) { arena = attach.alloc(size); arenaCapacity = size; } - if (opsBuf === undefined) { - opsBuf = attach.alloc(transferBytes, 4); - } statePtr = ct.init(arena, lw, lh, attach.structPtr); } else { // Standalone layout is [heap: state][opsBuf]; opsBuf moves when From 393c0e8dac88d7869efb0cf531650b3bff21a647 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 21 Aug 2026 21:22:16 -0400 Subject: [PATCH 4/9] test: full redraw after term.update() --- test/term.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/term.test.ts b/test/term.test.ts index 2505a9f..c3f2671 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -349,6 +349,15 @@ describe("term", () => { close(), ]; + it("emits a complete redraw on the first render after update", () => { + term.render(frame); + expect(term.render(frame).output.length).toBe(0); + term.update({ width: 20, height: 5 }); + let out = decode(term.render(frame).output); + expect(trim(print(out, 20, 5))).toContain("Hi"); + expect(out.length).toBeGreaterThan(0); + }); + it("resizes in place and lays out at the new dimensions", () => { term.render(frame); term.update({ width: 12, height: 4 }); From 2ce14e5b6d87a5c8ecbc658921176fb50c987f75 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 21 Aug 2026 21:22:40 -0400 Subject: [PATCH 5/9] feat: update() no-op on same dimensions, throw on invalid --- term.ts | 19 ++++++++++++++++--- test/term.test.ts | 12 ++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/term.ts b/term.ts index ec78594..de84d4b 100644 --- a/term.ts +++ b/term.ts @@ -230,12 +230,25 @@ export async function createTerm(options: TermOptions): Promise { return { output, events, info, errors, animating }; }, update(options: UpdateOptions): void { + let w: number; + let h: number; if ("events" in options) { 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 = options.width; - height = options.height; - native.update(width, height); + width = w; + height = h; + native.update(w, h); }, }; } diff --git a/test/term.test.ts b/test/term.test.ts index c3f2671..601e2da 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -358,6 +358,18 @@ describe("term", () => { expect(out.length).toBeGreaterThan(0); }); + it("is a no-op when dimensions are unchanged", () => { + term.render(frame); + term.update({ width: 40, height: 10 }); + expect(term.render(frame).output.length).toBe(0); + }); + + it("throws on non-positive or non-integer dimensions", () => { + expect(() => term.update({ width: 0, height: 10 })).toThrow(RangeError); + expect(() => term.update({ width: 40, height: -1 })).toThrow(RangeError); + expect(() => term.update({ width: 40.5, height: 10 })).toThrow(RangeError); + }); + it("resizes in place and lays out at the new dimensions", () => { term.render(frame); term.update({ width: 12, height: 4 }); From c20ab2060617b75d3502a7153174ceb1ef18a9ad Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 21 Aug 2026 21:23:07 -0400 Subject: [PATCH 6/9] feat: update() event arm with last-wins resize coalescing --- term.ts | 15 ++++++++++++--- test/term.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/term.ts b/term.ts index de84d4b..705d35a 100644 --- a/term.ts +++ b/term.ts @@ -230,10 +230,19 @@ export async function createTerm(options: TermOptions): Promise { return { output, events, info, errors, animating }; }, update(options: UpdateOptions): void { - let w: number; - let h: number; + let w: number | undefined; + let h: number | undefined; if ("events" in options) { - return; + 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; diff --git a/test/term.test.ts b/test/term.test.ts index 601e2da..99d048c 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -370,6 +370,32 @@ describe("term", () => { expect(() => term.update({ width: 40.5, height: 10 })).toThrow(RangeError); }); + it("accepts an event array, last resize wins, non-resize ignored", () => { + term.update({ + events: [ + { type: "key" }, + { type: "resize", width: 30, height: 8 }, + { type: "paste" }, + { type: "resize", width: 12, height: 4 }, + ], + }); + let result = term.render(frame); + expect(result.info.get("root")?.bounds).toEqual({ + x: 0, + y: 0, + width: 12, + height: 4, + }); + }); + + it("treats an event array with no resize events as a no-op", () => { + term.render(frame); + term.update({ events: [{ type: "key" }, { type: "paste" }] }); + expect(term.render(frame).output.length).toBe(0); + term.update({ events: [] }); + expect(term.render(frame).output.length).toBe(0); + }); + it("resizes in place and lays out at the new dimensions", () => { term.render(frame); term.update({ width: 12, height: 4 }); From 43ccaa8de8656e4a5c0722180aa51b35a62d013e Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 21 Aug 2026 21:24:48 -0400 Subject: [PATCH 7/9] feat: reset pointer and timing state on update() --- term.ts | 5 +++++ test/term.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/term.ts b/term.ts index 705d35a..f9535ef 100644 --- a/term.ts +++ b/term.ts @@ -258,6 +258,11 @@ export async function createTerm(options: TermOptions): Promise { width = w; height = h; native.update(w, h); + prev = new Set(); + pressed = new Set(); + wasDown = false; + lastRenderAt = undefined; + wasAnimating = false; }, }; } diff --git a/test/term.test.ts b/test/term.test.ts index 99d048c..c35727b 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -396,6 +396,22 @@ describe("term", () => { expect(term.render(frame).output.length).toBe(0); }); + it("discards pointer interaction state on resize", () => { + let pointer = { x: 1, y: 0, down: false }; + let first = term.render(frame, { pointer }); + expect(first.events).toEqual([ + { type: "pointerenter", id: "Clay__RootContainer" }, + { type: "pointerenter", id: "root" }, + ]); + expect(term.render(frame, { pointer }).events).toEqual([]); + term.update({ width: 20, height: 5 }); + let after = term.render(frame, { pointer }); + expect(after.events).toEqual([ + { type: "pointerenter", id: "Clay__RootContainer" }, + { type: "pointerenter", id: "root" }, + ]); + }); + it("resizes in place and lays out at the new dimensions", () => { term.render(frame); term.update({ width: 12, height: 4 }); From a4c0ca7a921ab86953ad71fc579b1970b3f21ffb Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 21 Aug 2026 21:25:26 -0400 Subject: [PATCH 8/9] test: attached-mode resize keeps capabilities; arena downsize/upsize --- test/term.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/test/term.test.ts b/test/term.test.ts index c35727b..6813f2c 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; import { createInput } from "../input.ts"; -import { offlineTermInfo } from "./caps.ts"; +import { offlineTermInfo, trueColorTermInfo } from "./caps.ts"; import { close, fixed, @@ -367,7 +367,9 @@ describe("term", () => { it("throws on non-positive or non-integer dimensions", () => { expect(() => term.update({ width: 0, height: 10 })).toThrow(RangeError); expect(() => term.update({ width: 40, height: -1 })).toThrow(RangeError); - expect(() => term.update({ width: 40.5, height: 10 })).toThrow(RangeError); + expect(() => term.update({ width: 40.5, height: 10 })).toThrow( + RangeError, + ); }); it("accepts an event array, last resize wins, non-resize ignored", () => { @@ -433,6 +435,49 @@ describe("term", () => { ), ); }); + + it("keeps the terminfo attachment across resize", async () => { + let attached = await createTerm({ + width: 40, + height: 10, + terminfo: await trueColorTermInfo(), + }); + let red: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + bg: rgba(255, 0, 0), + }), + close(), + ]; + expect(decode(attached.render(red).output)).toContain( + "\x1b[48;2;255;0;0", + ); + attached.update({ width: 20, height: 5 }); + let out = decode(attached.render(red).output); + expect(out).toContain("\x1b[48;2;255;0;0"); + expect(out).not.toContain("\x1b[48;5;196"); + }); + + it("survives downsize then upsize past the original size", () => { + term.render(frame); + term.update({ width: 10, height: 3 }); + let small = term.render(frame); + expect(small.info.get("root")?.bounds).toEqual({ + x: 0, + y: 0, + width: 10, + height: 3, + }); + term.update({ width: 120, height: 40 }); + let large = term.render(frame); + expect(large.info.get("root")?.bounds).toEqual({ + x: 0, + y: 0, + width: 120, + height: 40, + }); + expect(trim(print(decode(large.output), 120, 40))).toContain("Hi"); + }); }); describe("row offset", () => { From 92a8ef5859bd44447f3326960cf3ba2be9f08061 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 21 Aug 2026 21:26:29 -0400 Subject: [PATCH 9/9] test: assert pointer reset via toContainEqual, not exact event lists Clay__RootContainer leaks into pointer-over ids (pre-existing, elastic per renderer-spec 12.4); the pointer suite matches with filters and toContainEqual rather than encoding the internal id, so do the same. --- test/term.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/test/term.test.ts b/test/term.test.ts index 6813f2c..62faeb4 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -401,17 +401,11 @@ describe("term", () => { it("discards pointer interaction state on resize", () => { let pointer = { x: 1, y: 0, down: false }; let first = term.render(frame, { pointer }); - expect(first.events).toEqual([ - { type: "pointerenter", id: "Clay__RootContainer" }, - { type: "pointerenter", id: "root" }, - ]); + expect(first.events).toContainEqual({ type: "pointerenter", id: "root" }); expect(term.render(frame, { pointer }).events).toEqual([]); term.update({ width: 20, height: 5 }); let after = term.render(frame, { pointer }); - expect(after.events).toEqual([ - { type: "pointerenter", id: "Clay__RootContainer" }, - { type: "pointerenter", id: "root" }, - ]); + expect(after.events).toContainEqual({ type: "pointerenter", id: "root" }); }); it("resizes in place and lays out at the new dimensions", () => {