[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.

feat(term): synchronized output / DEC mode 2026 with opt-out

Nate Moore (Jul 1, 2026, 3:39 PM EDT) 11c923cd 6043d876

+100 -12
+24 -4
src/clayterm.c
··· 84 84 Clay_ErrorData errors[MAX_ERRORS]; 85 85 int error_count; 86 86 int animating_count; 87 + /* wrap each full-screen frame in BSU/ESU (DEC mode 2026); host opt-out */ 88 + int sync; 87 89 }; 88 90 89 91 /* Memory layout inside the arena provided by the host: ··· 93 95 * full-screen redraws with truecolor SGR sequences on every cell. 94 96 */ 95 97 #define OUT_BYTES_PER_CELL 64 98 + 99 + /* Fixed per-frame slack on top of the per-cell budget, for output that wraps 100 + * the whole frame rather than scaling with cell count — the BSU/ESU 101 + * synchronized-output pair. Keeps the wrap from being dropped on tiny grids 102 + * where the per-cell budget alone is tight. */ 103 + #define OUT_FRAME_OVERHEAD 32 96 104 97 105 /* ── Cell buffer ops ──────────────────────────────────────────────── */ 98 106 ··· 200 208 ct->lastx = -1; 201 209 ct->lasty = -1; 202 210 211 + /* Synchronized Output (DEC mode 2026): the terminal buffers everything 212 + * between BSU and ESU and presents it in one atomic repaint, so a frame 213 + * never tears mid-update. Unsupported terminals ignore the private mode; 214 + * the host can still opt out (ct->sync) for raw output. */ 215 + if (ct->sync) 216 + buf_str(&ct->out, "\x1b[?2026h"); 217 + 203 218 for (int y = 0; y < ct->h; y++) { 204 219 for (int x = 0; x < ct->w;) { 205 220 Cell *back = cell_at(ct, ct->back, x, y); ··· 234 249 x += w; 235 250 } 236 251 } 252 + 253 + if (ct->sync) 254 + buf_str(&ct->out, "\x1b[?2026l"); /* ESU: end synchronized update */ 237 255 } 238 256 239 257 /** ··· 554 572 int clayterm_size(int w, int h) { 555 573 int cell_count = w * h; 556 574 int cell_bytes = cell_count * (int)sizeof(Cell); 557 - int out_bytes = cell_count * OUT_BYTES_PER_CELL; 575 + int out_bytes = cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD; 558 576 int clay_bytes = (int)Clay_MinMemorySize(); 559 577 return align8((int)sizeof(struct Clayterm)) + align8(cell_bytes) /* front */ 560 578 + align8(cell_bytes) /* back */ ··· 611 629 return (int)ct->errors[index].errorText.chars; 612 630 } 613 631 614 - struct Clayterm *init(void *mem, int w, int h) { 632 + struct Clayterm *init(void *mem, int w, int h, int sync) { 615 633 struct Clayterm *ct = (struct Clayterm *)mem; 616 634 int cell_count = w * h; 617 635 int cell_bytes = align8(cell_count * (int)sizeof(Cell)); 618 - int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL); 636 + int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD); 619 637 char *base = (char *)mem + align8((int)sizeof(struct Clayterm)); 620 638 621 639 char *clay_mem = base + cell_bytes * 2 + out_bytes; ··· 630 648 .h = h, 631 649 .front = (Cell *)base, 632 650 .back = (Cell *)(base + cell_bytes), 633 - .out = {base + cell_bytes * 2, 0, cell_count * OUT_BYTES_PER_CELL}, 651 + .out = {base + cell_bytes * 2, 0, 652 + cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD}, 634 653 .lastfg = 0xffffffff, 635 654 .lastbg = 0xffffffff, 636 655 .lastx = -1, 637 656 .lasty = -1, 657 + .sync = sync, 638 658 }; 639 659 640 660 // initialize back buffer with spaces and default fg/bg
+1 -1
src/clayterm.h
··· 11 11 12 12 /* WASM exports */ 13 13 int clayterm_size(int w, int h); 14 - struct Clayterm *init(void *mem, int w, int h); 14 + struct Clayterm *init(void *mem, int w, int h, int sync); 15 15 void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, 16 16 float deltaTime); 17 17 char *output(struct Clayterm *ct);
+3 -2
term-native.ts
··· 53 53 export async function createTermNative( 54 54 w: number, 55 55 h: number, 56 + sync: boolean, 56 57 ): Promise<Native> { 57 58 let memory = new WebAssembly.Memory({ initial: 2 }); 58 59 let exports: Record<string, CallableFunction> = {}; ··· 85 86 let ct = exports as unknown as { 86 87 __heap_base: WebAssembly.Global; 87 88 clayterm_size(w: number, h: number): number; 88 - init(mem: number, w: number, h: number): number; 89 + init(mem: number, w: number, h: number, sync: number): number; 89 90 reduce( 90 91 ct: number, 91 92 buf: number, ··· 124 125 memory.grow(pages - current); 125 126 } 126 127 127 - let statePtr = ct.init(heap, w, h); 128 + let statePtr = ct.init(heap, w, h, sync ? 1 : 0); 128 129 let opsBuf = (heap + size + 3) & ~3; 129 130 130 131 return {
+12 -2
term.ts
··· 4 4 export interface TermOptions { 5 5 height: number; 6 6 width: number; 7 + 8 + /** 9 + * Wrap every full-screen frame in Synchronized Output (DEC mode 2026: 10 + * `CSI ?2026 h` … `CSI ?2026 l`) so the terminal presents it atomically and 11 + * never tears mid-update. Terminals that don't support the mode ignore it, so 12 + * this is safe to leave on. Set `false` for raw output (e.g. piping, or a 13 + * terminal you've found misbehaves). Inline (line mode) frames are never 14 + * wrapped. Defaults to `true`. 15 + */ 16 + sync?: boolean; 7 17 } 8 18 9 19 export interface RenderOptions { ··· 74 84 } 75 85 76 86 export async function createTerm(options: TermOptions): Promise<Term> { 77 - let { width, height } = options; 78 - let native = await createTermNative(width, height); 87 + let { width, height, sync = true } = options; 88 + let native = await createTermNative(width, height, sync); 79 89 let { memory, statePtr, opsBuf } = native; 80 90 81 91 let prev = new Set<string>();
+60 -3
test/term.test.ts
··· 52 52 ]).output, 53 53 ); 54 54 55 - // the SGR active when "h" is emitted should include the 56 - // parent's red background (48;2;255;0;0), not terminal default 57 - let before = ansi.slice(0, ansi.indexOf("h")); 55 + // the SGR active when the "hi" glyphs are emitted should include the 56 + // parent's red background (48;2;255;0;0), not terminal default. 57 + // (anchored to the text, not a bare "h", which also appears in the 58 + // synchronized-output BSU prefix \x1b[?2026h) 59 + let before = ansi.slice(0, ansi.indexOf("hi")); 58 60 expect(before).toContain("\x1b[48;2;255;0;0"); 59 61 }); 60 62 ··· 395 397 }); 396 398 }); 397 399 }); 400 + 401 + describe("synchronized output", () => { 402 + // DEC mode 2026: every full-screen frame is wrapped in BSU/ESU so the 403 + // terminal presents it atomically and never tears mid-update. 404 + let BSU = "\x1b[?2026h"; 405 + let ESU = "\x1b[?2026l"; 406 + 407 + it("wraps the full-screen (cups) frame in BSU/ESU", async () => { 408 + let term = await createTerm({ width: 20, height: 5 }); 409 + let out = decode( 410 + term.render([ 411 + open("root", { 412 + layout: { width: grow(), height: grow() }, 413 + bg: rgba(10, 20, 30), 414 + }), 415 + close(), 416 + ]).output, 417 + ); 418 + expect(out.startsWith(BSU)).toBe(true); 419 + expect(out.endsWith(ESU)).toBe(true); 420 + // exactly one wrap per frame 421 + expect(out.split(BSU).length).toBe(2); 422 + expect(out.split(ESU).length).toBe(2); 423 + }); 424 + 425 + it("does not wrap inline (line mode) output", async () => { 426 + let term = await createTerm({ width: 20, height: 5 }); 427 + let out = decode( 428 + term.render([ 429 + open("root", { 430 + layout: { width: grow(), height: grow() }, 431 + bg: rgba(10, 20, 30), 432 + }), 433 + close(), 434 + ], { mode: "line" }).output, 435 + ); 436 + expect(out.includes("\x1b[?2026")).toBe(false); 437 + }); 438 + 439 + it("omits the wrap when sync is disabled", async () => { 440 + let term = await createTerm({ width: 20, height: 5, sync: false }); 441 + let out = decode( 442 + term.render([ 443 + open("root", { 444 + layout: { width: grow(), height: grow() }, 445 + bg: rgba(10, 20, 30), 446 + }), 447 + close(), 448 + ]).output, 449 + ); 450 + expect(out.includes("\x1b[?2026")).toBe(false); 451 + // the actual frame content (bg SGR) is still emitted 452 + expect(out.includes("\x1b[48;2;10;20;30")).toBe(true); 453 + }); 454 + });