[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

benchmark: replace simulation with walltime macro benches + e2e correctness (#91)

* test: add input event-loop integration test

Drives createInput as a consumer would (read chunk, scan, flush a pending ESC, dispatch) and asserts the same event stream whether input is fed whole, byte-by-byte, or split mid-sequence. Covers the lone-ESC flush path.

* bench: add macro throughput WallTime bench

In-process bench over a large mixed corpus, measured in WallTime where real work dominates placement/JIT/alloc noise (the Simulation micro-benches sit on codegen cliffs). Feeds the corpus in small reads: a single scan() drains at most 256 events (the wasm event-buffer cap), so small reads keep every call under the cap and process the whole corpus.

* bench: remove input micro-suite (codegen-cliff artifacts)

The Simulation input micro-benches (long input burst, printable ASCII single char) move by 50-90% on unrelated changes, even a test-only rename, because their simulated cost snaps to a different value when the combined wasm shifts. Input perf is now gated by the throughput WallTime bench; correctness by the event-loop integration test.

* ci(bench): pin build runner and drop wasm cache

Pin ubuntu-24.04 so the wasm toolchain is stable and drop the wasm cache so main and PRs always rebuild identically. The cache froze main's baseline on a stale build, so every PR compared a fresh build against a stale baseline and produced phantom regressions.

* fix(ci): return a promise from throughput bench for CodSpeed walltime

CodSpeed's walltime tinybench plugin only populates result.latency on its async path; a sync task fn leaves it undefined and crashes (Cannot destructure 'min' of result.latency). startup.bench works because its tasks return a promise (spawnFixture). Return Promise.resolve() from the throughput task so the plugin takes the async path — a bare async fn with no await would trip deno's require-await lint. The walltime job runs startup and throughput as separate node processes.

* bench: move render/ops to WallTime macro benches, drop the simulation job

CodSpeed Simulation (Valgrind) is unviable for CI here: flaky measurements (dashboard layout swung 20x, diff render 17% on changes that touch no render code) and unpredictable runtime — the same commit's simulation job finished in ~2 min one run and hung past 30 min the next. Convert render/ops to ms-scale WallTime macro benches (looped, promise-returning, ~7-11ms at <1% variance) run as separate node processes in the walltime job, and drop the simulation job entirely. mod.ts is now a local aggregator for deno task bench.

* ref(test): use built-in

* ref(bench): centralize withCodSpeed workaround

* ref(test): use semantic util functions

authored by

Nate Moore and committed by
GitHub
(Jun 17, 2026, 11:45 AM EDT) 25fb6a09 72890ded

+265 -195
+8 -59
.github/workflows/benchmark.yml
··· 17 17 jobs: 18 18 build: 19 19 name: Build 20 - runs-on: ubuntu-latest 20 + runs-on: ubuntu-24.04 21 21 permissions: 22 22 contents: read # clone repo 23 23 steps: ··· 32 32 with: 33 33 deno-version: v2.x 34 34 35 - - name: Cache WASM 36 - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 37 - id: wasm-cache 38 - with: 39 - path: | 40 - clayterm.wasm 41 - wasm.ts 42 - key: wasm-${{ hashFiles('Makefile', 'src/**', 'tasks/bundle-wasm.ts') }} 43 - 44 35 - name: Build WASM 45 - if: steps.wasm-cache.outputs.cache-hit != 'true' 46 36 run: make 47 37 48 38 - name: Cache dependencies ··· 63 53 retention-days: 1 64 54 path: wasm.ts 65 55 66 - simulation: 67 - name: Run benchmarks (simulation) 68 - needs: build 69 - runs-on: codspeed-macro 70 - permissions: 71 - contents: read # clone repo 72 - id-token: write # upload benchmark results to codspeed 73 - 74 - steps: 75 - - name: Checkout 76 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 77 - 78 - - name: Setup Deno 79 - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 80 - with: 81 - deno-version: v2.x 82 - 83 - - name: Setup Node 84 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 85 - with: 86 - node-version: 24 87 - 88 - - name: Download build artifact 89 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 90 - with: 91 - name: bench-build 92 - 93 - - name: Restore dependencies 94 - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 95 - id: deno-cache 96 - with: 97 - path: node_modules 98 - key: deno-${{ hashFiles('deno.lock') }} 99 - 100 - - name: Install dependencies 101 - if: steps.deno-cache.outputs.cache-hit != 'true' 102 - run: deno install 103 - 104 - - name: Run benchmarks 105 - uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 106 - with: 107 - mode: simulation 108 - # IMPORTANT! deno task bench fails in CI due to incompatible V8 bindings 109 - run: node bench/mod.ts 110 - 111 - walltime: 56 + benchmarks: 112 57 name: Run benchmarks (walltime) 113 58 needs: build 114 59 runs-on: codspeed-macro ··· 146 91 if: steps.deno-cache.outputs.cache-hit != 'true' 147 92 run: deno install 148 93 149 - - name: Run process startup benchmarks 94 + - name: Run walltime benchmarks 150 95 uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 151 96 with: 152 97 mode: walltime 153 - run: node bench/startup.bench.ts 98 + run: | 99 + node bench/startup.bench.ts 100 + node bench/throughput.bench.ts 101 + node bench/render.bench.ts 102 + node bench/ops.bench.ts
+11
bench/fixtures/utils.ts
··· 1 1 import { fileURLToPath } from "node:url"; 2 2 import { spawn } from "node:child_process"; 3 3 4 + export { withCodSpeed } from "@codspeed/tinybench-plugin"; 5 + 6 + // CodSpeed walltime reads `task.result.latency` which 7 + // tinybench only sets for async tasks 8 + export function sync(fn: () => void): () => Promise<void> { 9 + return () => { 10 + fn(); 11 + return Promise.resolve(); 12 + }; 13 + } 14 + 4 15 export const fixture = (name: string) => { 5 16 return new URL(`./${name}/mod.ts`, import.meta.url); 6 17 };
-55
bench/input.bench.ts
··· 1 - import { Bench } from "tinybench"; 2 - import { withCodSpeed } from "@codspeed/tinybench-plugin"; 3 - import { createInput } from "../input.ts"; 4 - 5 - function bytes(...values: number[]): Uint8Array { 6 - return new Uint8Array(values); 7 - } 8 - 9 - function str(s: string): Uint8Array { 10 - return new TextEncoder().encode(s); 11 - } 12 - 13 - let input = await createInput({ escLatency: 25 }); 14 - 15 - let longBurst = new Uint8Array(200); 16 - for (let i = 0; i < 200; i++) { 17 - longBurst[i] = 0x61 + (i % 26); 18 - } 19 - 20 - let bench = withCodSpeed(new Bench()); 21 - 22 - bench 23 - .add("printable ASCII (single char)", () => { 24 - input.scan(bytes(0x61)); 25 - }) 26 - .add("printable ASCII (short string)", () => { 27 - input.scan(str("hello world")); 28 - }) 29 - .add("arrow key (CSI sequence)", () => { 30 - input.scan(bytes(0x1b, 0x5b, 0x41)); 31 - }) 32 - .add("modifier combo (Ctrl+Shift+Arrow)", () => { 33 - input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x38, 0x41)); 34 - }) 35 - .add("SGR mouse press", () => { 36 - input.scan(str("\x1b[<0;35;12M")); 37 - }) 38 - .add("multi-event burst (arrows + text)", () => { 39 - input.scan(bytes(0x1b, 0x5b, 0x41, 0x1b, 0x5b, 0x42, 0x68, 0x69)); 40 - }) 41 - .add("UTF-8 3-byte character", () => { 42 - input.scan(bytes(0xe4, 0xb8, 0xad)); 43 - }) 44 - .add("UTF-8 4-byte emoji", () => { 45 - input.scan(bytes(0xf0, 0x9f, 0x8e, 0x89)); 46 - }) 47 - .add("Kitty protocol (CSI u with modifiers)", () => { 48 - input.scan(str("\x1b[97;3u")); 49 - }) 50 - .add("long input burst (200 bytes)", () => { 51 - input.scan(longBurst); 52 - }); 53 - 54 - await bench.run(); 55 - console.table(bench.table());
+1 -1
bench/mod.ts
··· 1 - import "./input.bench.ts"; 1 + import "./throughput.bench.ts"; 2 2 import "./render.bench.ts"; 3 3 import "./ops.bench.ts";
+16 -26
bench/ops.bench.ts
··· 1 1 import { Bench } from "tinybench"; 2 - import { withCodSpeed } from "@codspeed/tinybench-plugin"; 2 + import { sync, withCodSpeed } from "./fixtures/utils.ts"; 3 3 import { close, fixed, grow, open, pack, rgba, text } from "../ops.ts"; 4 4 import type { Op } from "../ops.ts"; 5 - 6 - function makeBuf(size: number): ArrayBuffer { 7 - return new ArrayBuffer(size); 8 - } 9 - 10 - let simpleOps: Op[] = [ 11 - open("root", { 12 - layout: { width: grow(), height: grow(), direction: "ttb" }, 13 - }), 14 - text("Hello, World!"), 15 - close(), 16 - ]; 17 5 18 6 let complexOps: Op[] = [ 19 7 open("root", { ··· 104 92 close(), 105 93 ]; 106 94 107 - let bench = withCodSpeed(new Bench()); 95 + let buf = new ArrayBuffer(32768); 96 + 97 + let bench = withCodSpeed(new Bench({ name: "ops" })); 108 98 109 99 bench 110 - .add("simple tree (root + text)", () => { 111 - let buf = makeBuf(4096); 112 - pack(simpleOps, buf, 0); 113 - }) 114 - .add("complex layout (header + sidebar + main + footer)", () => { 115 - let buf = makeBuf(8192); 116 - pack(complexOps, buf, 0); 117 - }) 118 - .add("large list (50 items)", () => { 119 - let buf = makeBuf(32768); 120 - pack(listOps, buf, 0); 121 - }); 100 + .add( 101 + "pack complex layout", 102 + sync(() => { 103 + for (let i = 0; i < 1500; i++) pack(complexOps, buf, 0); 104 + }), 105 + ) 106 + .add( 107 + "pack large list", 108 + sync(() => { 109 + for (let i = 0; i < 250; i++) pack(listOps, buf, 0); 110 + }), 111 + ); 122 112 123 113 await bench.run(); 124 114 console.table(bench.table());
+18 -52
bench/render.bench.ts
··· 1 1 import { Bench } from "tinybench"; 2 - import { withCodSpeed } from "@codspeed/tinybench-plugin"; 2 + import { sync, withCodSpeed } from "./fixtures/utils.ts"; 3 3 import { createTerm } from "../term.ts"; 4 4 import { close, fixed, grow, open, rgba, text } from "../ops.ts"; 5 5 import type { Op } from "../ops.ts"; 6 6 7 7 let term = await createTerm({ width: 80, height: 24 }); 8 - let termPtr = await createTerm({ width: 80, height: 24 }); 9 - 10 - let helloOps: Op[] = [ 11 - open("root", { 12 - layout: { width: grow(), height: grow(), direction: "ttb" }, 13 - }), 14 - text("Hello, World!"), 15 - close(), 16 - ]; 17 - 18 - let borderedOps: Op[] = [ 19 - open("root", { 20 - layout: { width: grow(), height: grow(), direction: "ttb" }, 21 - }), 22 - open("box", { 23 - layout: { 24 - width: grow(), 25 - height: grow(), 26 - padding: { left: 1, right: 1, top: 1, bottom: 1 }, 27 - direction: "ttb", 28 - }, 29 - border: { 30 - color: rgba(0, 255, 0), 31 - left: 1, 32 - right: 1, 33 - top: 1, 34 - bottom: 1, 35 - }, 36 - cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, 37 - }), 38 - text("Bordered content"), 39 - close(), 40 - close(), 41 - ]; 42 8 43 9 let dashboardOps: Op[] = [ 44 10 open("root", { ··· 134 100 close(), 135 101 ]; 136 102 137 - let bench = withCodSpeed(new Bench()); 103 + let bench = withCodSpeed(new Bench({ name: "render" })); 138 104 139 105 bench 140 - .add("simple text", () => { 141 - term.render(helloOps); 142 - }) 143 - .add("bordered box with corner radius", () => { 144 - term.render(borderedOps); 145 - }) 146 - .add("dashboard layout", () => { 147 - term.render(dashboardOps); 148 - }) 149 - .add("diff render (second frame)", () => { 150 - term.render(dashboardOps); 151 - term.render(dashboardOps); 152 - }) 153 - .add("render with pointer hit testing", () => { 154 - termPtr.render(uiOps, { pointer: { x: 10, y: 1, down: false } }); 155 - }); 106 + .add( 107 + "render mixed frames", 108 + sync(() => { 109 + for (let i = 0; i < 250; i++) { 110 + term.render(i % 2 === 0 ? dashboardOps : uiOps); 111 + } 112 + }), 113 + ) 114 + .add( 115 + "render steady diff", 116 + sync(() => { 117 + for (let i = 0; i < 250; i++) { 118 + term.render(dashboardOps); 119 + } 120 + }), 121 + ); 156 122 157 123 await bench.run(); 158 124 console.table(bench.table());
+1 -2
bench/startup.bench.ts
··· 1 1 import { Bench } from "tinybench"; 2 - import { withCodSpeed } from "@codspeed/tinybench-plugin"; 3 - import { spawnFixture } from "./fixtures/utils.ts"; 2 + import { spawnFixture, withCodSpeed } from "./fixtures/utils.ts"; 4 3 5 4 let bench = withCodSpeed(new Bench({ name: "startup" })); 6 5
+49
bench/throughput.bench.ts
··· 1 + import { Bench } from "tinybench"; 2 + import { sync, withCodSpeed } from "./fixtures/utils.ts"; 3 + import { createInput } from "../input.ts"; 4 + 5 + function str(s: string): Uint8Array { 6 + return new TextEncoder().encode(s); 7 + } 8 + 9 + function concat(parts: Uint8Array[]): Uint8Array { 10 + let len = parts.reduce((n, p) => n + p.length, 0); 11 + let out = new Uint8Array(len); 12 + let off = 0; 13 + for (let p of parts) { 14 + out.set(p, off); 15 + off += p.length; 16 + } 17 + return out; 18 + } 19 + 20 + let unit = concat([ 21 + str("the quick brown fox "), 22 + new Uint8Array([0x1b, 0x5b, 0x41]), // ArrowUp 23 + str("\x1b[<0;40;12M"), // SGR mouse press 24 + new Uint8Array([0xe4, 0xb8, 0xad]), // 中 25 + str("\x1b[97;3u"), // Kitty a+Alt 26 + new Uint8Array([0xf0, 0x9f, 0x8e, 0x89]), // 🎉 27 + ]); 28 + let corpus = concat(new Array(1000).fill(unit)); 29 + 30 + let READ = 64; 31 + 32 + let input = await createInput({ escLatency: 25 }); 33 + 34 + let bench = withCodSpeed(new Bench({ name: "throughput" })); 35 + 36 + bench.add( 37 + "input throughput (mixed corpus, chunked read loop)", 38 + sync(() => { 39 + let dispatched = 0; 40 + for (let off = 0; off < corpus.length; off += READ) { 41 + let { events } = input.scan(corpus.subarray(off, off + READ)); 42 + dispatched += events.length; 43 + } 44 + if (dispatched === 0) throw new Error("expected events"); 45 + }), 46 + ); 47 + 48 + await bench.run(); 49 + console.table(bench.table());
+161
test/input-loop.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { setTimeout as sleep } from "node:timers/promises"; 3 + import { createInput, type InputEvent, type InputOptions } from "../input.ts"; 4 + 5 + function str(s: string): Uint8Array { 6 + return new TextEncoder().encode(s); 7 + } 8 + 9 + function concat(...parts: Uint8Array[]): Uint8Array { 10 + let len = parts.reduce((n, p) => n + p.length, 0); 11 + let out = new Uint8Array(len); 12 + let off = 0; 13 + for (let p of parts) { 14 + out.set(p, off); 15 + off += p.length; 16 + } 17 + return out; 18 + } 19 + 20 + function bytes(...parts: Array<string | Uint8Array>): Uint8Array { 21 + return concat(...parts.map((p) => typeof p === "string" ? str(p) : p)); 22 + } 23 + 24 + function arrowUp(): Uint8Array { 25 + return str("\x1b[A"); 26 + } 27 + 28 + function mousePress({ x, y }: { x: number; y: number }): Uint8Array { 29 + // SGR coords are 1-based; callers pass 0-based to match emitted events. 30 + return str(`\x1b[<0;${x + 1};${y + 1}M`); 31 + } 32 + 33 + function kittyAltKey(key: string): Uint8Array { 34 + return str(`\x1b[${key.codePointAt(0)};3u`); 35 + } 36 + 37 + function sig(e: InputEvent): string { 38 + switch (e.type) { 39 + case "keydown": 40 + case "keyrepeat": 41 + case "keyup": { 42 + let m = `${e.alt ? "a" : ""}${e.ctrl ? "c" : ""}${e.shift ? "s" : ""}`; 43 + return `${e.type}:${e.key}${m ? `:${m}` : ""}`; 44 + } 45 + case "mousedown": 46 + case "mouseup": 47 + case "mousemove": 48 + return `${e.type}:${e.button}:${e.x},${e.y}`; 49 + case "wheel": 50 + return `${e.type}:${e.direction}:${e.x},${e.y}`; 51 + case "cursor": 52 + return `cursor:${e.row},${e.column}`; 53 + case "resize": 54 + return `resize:${e.width}x${e.height}`; 55 + default: 56 + return e.type; 57 + } 58 + } 59 + 60 + async function drive( 61 + chunks: Uint8Array[], 62 + opts: InputOptions = { escLatency: 25 }, 63 + ): Promise<string[]> { 64 + let input = await createInput(opts); 65 + let all: InputEvent[] = []; 66 + for (let i = 0; i < chunks.length; i++) { 67 + let { events, pending } = input.scan(chunks[i]); 68 + all.push(...events); 69 + if (pending && i === chunks.length - 1) { 70 + await sleep(pending.delay + 10); 71 + all.push(...input.scan().events); 72 + } 73 + } 74 + return all.map(sig); 75 + } 76 + 77 + function perByte(buf: Uint8Array): Uint8Array[] { 78 + let out: Uint8Array[] = []; 79 + for (let i = 0; i < buf.length; i++) out.push(buf.subarray(i, i + 1)); 80 + return out; 81 + } 82 + 83 + function splitAt(buf: Uint8Array, offsets: number[]): Uint8Array[] { 84 + let out: Uint8Array[] = []; 85 + let prev = 0; 86 + for (let o of [...offsets, buf.length]) { 87 + out.push(buf.subarray(prev, o)); 88 + prev = o; 89 + } 90 + return out; 91 + } 92 + 93 + describe("input event loop", () => { 94 + let stream = bytes( 95 + "hi", 96 + arrowUp(), 97 + mousePress({ x: 34, y: 11 }), 98 + "中", 99 + kittyAltKey("a"), 100 + "🎉", 101 + ); 102 + 103 + let expected = [ 104 + "keydown:h", 105 + "keydown:i", 106 + "keydown:ArrowUp", 107 + "mousedown:left:34,11", 108 + "keydown:中", 109 + "keydown:a:a", 110 + "keydown:🎉", 111 + ]; 112 + 113 + it("produces the expected sequence when fed whole", async () => { 114 + expect(await drive([stream])).toEqual(expected); 115 + }); 116 + 117 + it("is invariant to chunk boundaries (byte-by-byte)", async () => { 118 + expect(await drive(perByte(stream))).toEqual(expected); 119 + }); 120 + 121 + it("is invariant to chunk boundaries (mid-sequence splits)", async () => { 122 + expect(await drive(splitAt(stream, [3, 5, 9, 16, 21]))).toEqual(expected); 123 + }); 124 + 125 + describe("pending ESC flush", () => { 126 + it("flushes a lone trailing ESC as Escape after the latency", async () => { 127 + expect(await drive([bytes("hi"), str("\x1b")])).toEqual([ 128 + "keydown:h", 129 + "keydown:i", 130 + "keydown:Escape", 131 + ]); 132 + }); 133 + 134 + it("resolves ESC as a sequence when the rest arrives next chunk", async () => { 135 + expect(await drive(splitAt(arrowUp(), [1]))).toEqual(["keydown:ArrowUp"]); 136 + }); 137 + }); 138 + 139 + it("handles a large mixed burst across many small chunks", async () => { 140 + let unit = bytes( 141 + arrowUp(), 142 + "ab", 143 + mousePress({ x: 0, y: 0 }), 144 + "中", 145 + ); 146 + let n = 50; 147 + let big = bytes(...new Array(n).fill(unit)); 148 + let chunks: Uint8Array[] = []; 149 + for (let i = 0; i < big.length; i += 7) chunks.push(big.subarray(i, i + 7)); 150 + 151 + let sigs = await drive(chunks); 152 + expect(sigs.length).toBe(n * 5); 153 + expect(sigs.slice(0, 5)).toEqual([ 154 + "keydown:ArrowUp", 155 + "keydown:a", 156 + "keydown:b", 157 + "mousedown:left:0,0", 158 + "keydown:中", 159 + ]); 160 + }); 161 + });