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

♻️ make row a 1-based render option matching DSR native format

Move the row offset from a constructor parameter to a render-time
option. Row is now 1-based (matching ECMA-48 DSR/CPR format) so
callers can pass the queried cursor position directly without
conversion. Remove line mode from the inline region demo in favor
of raw newline allocation followed by CUP rendering for all frames.

Charles Lowell (Apr 10, 2026, 4:06 PM -0500) c09a67ed 0f964786

+396 -26
+16
demo/inline-region.out.txt
··· 1 + [step 1] line mode render 2 + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-10197916,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"⠋ Compiling modules...","color":-7607811},{"id":4},{"id":4}] 3 + [step 2] DSR query 4 + cursor at top=52, region top=50 5 + [step 3] save cursor, start CUP renders 6 + [step 4] CUP frame 1 7 + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-10197916,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"⠙ Compiling modules...","color":-7607811},{"id":4},{"id":4}] 8 + [step 5] CUP frame 2 9 + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-10197916,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"⠹ Compiling modules...","color":-7607811},{"id":4},{"id":4}] 10 + [step 6] CUP frame 3 11 + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-10197916,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"⠸ Compiling modules...","color":-7607811},{"id":4},{"id":4}] 12 + [step 7] CUP frame 4 13 + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-10197916,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"⠼ Compiling modules...","color":-7607811},{"id":4},{"id":4}] 14 + [step 8] CUP frame 5 15 + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-11470213,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"✓ Compiling modules...","color":-11470213},{"id":4},{"id":4}] 16 + [step 9] commit — restore cursor, advance
+337
demo/inline-region.ts
··· 1 + /** 2 + * Inline region demo — renders animated regions into normal scrollback. 3 + * 4 + * Shows the region lifecycle: 5 + * 1. Allocate space with raw newlines 6 + * 2. DSR — queries cursor position to compute `top` 7 + * 3. CUP mode (all frames) — renders at `top` 8 + * 4. Commit — restore cursor past region, advance with \n 9 + */ 10 + 11 + import { main, type Operation, sleep, until } from "effection"; 12 + import { 13 + close, 14 + createInput, 15 + createTerm, 16 + type CursorEvent, 17 + fixed, 18 + grow, 19 + type Op, 20 + open, 21 + rgba, 22 + text, 23 + } from "../mod.ts"; 24 + import { cursor, settings } from "../settings.ts"; 25 + import { validated } from "../validate.ts"; 26 + 27 + let write = (b: Uint8Array) => Deno.stdout.writeSync(b); 28 + let encode = (s: string) => new TextEncoder().encode(s); 29 + let esc = (s: string) => write(encode(s)); 30 + 31 + let GREEN = rgba(80, 250, 123); 32 + let GRAY = rgba(100, 100, 100); 33 + let CYAN = rgba(139, 233, 253); 34 + 35 + let RED = rgba(255, 0, 0); 36 + let ORANGE = rgba(255, 153, 0); 37 + let YELLOW = rgba(255, 255, 0); 38 + let NGREEN = rgba(51, 255, 0); 39 + let BLUE = rgba(0, 153, 255); 40 + let VIOLET = rgba(102, 0, 255); 41 + let RAINBOW = [RED, ORANGE, YELLOW, NGREEN, BLUE, VIOLET]; 42 + 43 + let BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; 44 + 45 + function* queryCursor(): Operation<CursorEvent> { 46 + let parser = yield* until(createInput({ escLatency: 100 })); 47 + esc("\x1b[6n"); 48 + 49 + let buf = new Uint8Array(32); 50 + while (true) { 51 + let n = Deno.stdin.readSync(buf); 52 + if (n === null) continue; 53 + let result = parser.scan(buf.subarray(0, n)); 54 + for (let ev of result.events) { 55 + if (ev.type === "cursor") { 56 + return ev; 57 + } 58 + } 59 + } 60 + } 61 + 62 + function waitKey() { 63 + let buf = new Uint8Array(32); 64 + while (true) { 65 + let n = Deno.stdin.readSync(buf); 66 + if (n === null) continue; 67 + for (let i = 0; i < n; i++) { 68 + if (buf[i] === 0x03) { 69 + Deno.stdin.setRaw(false); 70 + esc("\x1b[?25h"); 71 + Deno.exit(0); 72 + } 73 + } 74 + return; 75 + } 76 + } 77 + 78 + function box(msg: string, fg: number, border: number): Op[] { 79 + return [ 80 + open("root", { 81 + layout: { width: grow(), height: grow(), direction: "ttb" }, 82 + }), 83 + open("box", { 84 + layout: { 85 + width: grow(), 86 + height: grow(), 87 + direction: "ttb", 88 + padding: { left: 1 }, 89 + alignY: 2, 90 + }, 91 + border: { 92 + color: border, 93 + left: 1, 94 + right: 1, 95 + top: 1, 96 + bottom: 1, 97 + }, 98 + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, 99 + }), 100 + text(msg, { color: fg }), 101 + close(), 102 + close(), 103 + ]; 104 + } 105 + 106 + function* transaction( 107 + height: number, 108 + renderFrame: (frame: number) => Op[], 109 + frames: number, 110 + interval: number, 111 + ): Operation<void> { 112 + let { columns } = Deno.consoleSize(); 113 + 114 + esc("\n".repeat(height)); 115 + 116 + let pos = yield* queryCursor(); 117 + /** 1-based terminal row where the region starts */ 118 + let row = pos.top - height + 1; 119 + 120 + esc("\x1b[s"); 121 + let tty = settings(cursor(false)); 122 + write(tty.apply); 123 + 124 + let term = validated( 125 + yield* until(createTerm({ width: columns, height })), 126 + ); 127 + for (let i = 0; i < frames; i++) { 128 + let result = term.render(renderFrame(i), { row }); 129 + write(new Uint8Array(result.output)); 130 + yield* sleep(interval); 131 + } 132 + 133 + write(tty.revert); 134 + esc("\x1b[u"); 135 + esc("\n"); 136 + } 137 + 138 + function say(msg: string) { 139 + esc(msg + "\n"); 140 + } 141 + 142 + function pause() { 143 + waitKey(); 144 + esc("\n"); 145 + } 146 + 147 + await main(function* () { 148 + let { columns } = Deno.consoleSize(); 149 + Deno.stdin.setRaw(true); 150 + let tty = settings(cursor(false)); 151 + write(tty.apply); 152 + 153 + // Introduction 154 + say("Clayterm can render entire scenes, but it can also render"); 155 + say('"inline" for a streaming UI. This is useful for semi-interactive'); 156 + say("CLI commands that write output to the normal console screen."); 157 + say(""); 158 + 159 + // Demo 1: Spinner box 160 + esc("\n\n\n"); 161 + 162 + let pos = yield* queryCursor(); 163 + /** 1-based terminal row where the region starts */ 164 + let row = pos.top - 2; 165 + 166 + esc("\x1b[s"); 167 + 168 + let frames = 30; 169 + let term = validated( 170 + yield* until(createTerm({ width: columns, height: 3 })), 171 + ); 172 + 173 + let first = term.render( 174 + box("Press any key to compile modules.", CYAN, GRAY), 175 + { row }, 176 + ); 177 + write(new Uint8Array(first.output)); 178 + 179 + waitKey(); 180 + 181 + for (let i = 0; i < frames; i++) { 182 + let done = i === frames - 1; 183 + let icon = done ? "✓" : BRAILLE[i % BRAILLE.length]; 184 + let time = `${((i + 1) * 0.08).toFixed(1)}s`; 185 + let label = done ? "Compiled modules" : "Compiling modules..."; 186 + let result = term.render( 187 + box( 188 + `${icon} ${label} ${time}`, 189 + done ? GREEN : CYAN, 190 + done ? GREEN : GRAY, 191 + ), 192 + { row }, 193 + ); 194 + write(new Uint8Array(result.output)); 195 + yield* sleep(80); 196 + } 197 + 198 + esc("\x1b[u"); 199 + esc("\x1b[0m"); 200 + esc("\n"); 201 + 202 + yield* sleep(500); 203 + 204 + esc( 205 + "\nRegions can be multi-line, but they can be a single line too. (continue...)", 206 + ); 207 + pause(); 208 + 209 + // Demo 2: Progress bar 210 + let barWidth = Math.min(columns, 50); 211 + let barFrames = 40; 212 + yield* transaction( 213 + 1, 214 + (i) => { 215 + let done = i === barFrames - 1; 216 + if (done) { 217 + return [ 218 + open("root", { 219 + layout: { 220 + width: fixed(barWidth), 221 + height: fixed(1), 222 + direction: "ltr", 223 + }, 224 + }), 225 + text("✓ Frobnicated", { color: GREEN }), 226 + close(), 227 + ]; 228 + } 229 + let progress = i / (barFrames - 1); 230 + let label = "Frobnicating.. "; 231 + let remaining = barWidth - label.length - 5; 232 + let filled = Math.round(remaining * Math.min(progress, 1)); 233 + let empty = remaining - filled; 234 + let pct = `${Math.round(progress * 100)}%`; 235 + let bar = "█".repeat(filled) + "░".repeat(empty); 236 + return [ 237 + open("root", { 238 + layout: { 239 + width: fixed(barWidth), 240 + height: fixed(1), 241 + direction: "ltr", 242 + }, 243 + }), 244 + text(label, { color: CYAN }), 245 + text(bar, { color: CYAN }), 246 + text(` ${pct.padStart(4)}`, { color: GRAY }), 247 + close(), 248 + ]; 249 + }, 250 + barFrames, 251 + 50, 252 + ); 253 + 254 + esc("\x1b[0m"); 255 + yield* sleep(500); 256 + esc("\nGoodbye sadness with limitless sky. (continue...)"); 257 + pause(); 258 + 259 + // Demo 3: Nyan cat 260 + let nyanWidth = Math.min(columns, 120); 261 + let nyanFrames = 50; 262 + let cat = [ 263 + "╭─────╮", 264 + "│ ^.^ │", 265 + "╰─────╯", 266 + ]; 267 + let catWidth = cat[0].length; 268 + 269 + yield* transaction( 270 + 3, 271 + (i) => { 272 + let done = i === nyanFrames - 1; 273 + let progress = i / (nyanFrames - 1); 274 + let trail = Math.round((nyanWidth - catWidth) * Math.min(progress, 1)); 275 + 276 + if (done) { 277 + // "IMAGINATION IS BEAUTIFUL WORLD!" in 3-row block font 278 + let font: string[] = [ 279 + "█ █▄█▄█ █▀█ █▀▀ █ █▀█ █▀█ ▀█▀ █ █▀█ █▀█ █ █▀▀ ██▄ █▀▀ █▀█ █ █ ▀█▀ █ █▀▀ █ █ █ █ █ █ █▀█ █▀█ █ █▀▄ █", 280 + "█ █ ▀ █ █▀█ █ █ █ █ █ █▀█ █ █ █ █ █ █ █ ▀▀█ █▀█ █▀▀ █▀█ █ █ █ █ █▀ █ █ █ █▄█▄█ █ █ █▀▄ █ █ █ ▀", 281 + "▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀▀ ▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀ █", 282 + ]; 283 + let ops: Op[] = [ 284 + open("root", { 285 + layout: { 286 + width: fixed(nyanWidth), 287 + height: fixed(3), 288 + direction: "ttb", 289 + }, 290 + }), 291 + ]; 292 + for (let row = 0; row < 3; row++) { 293 + let color = RAINBOW[(row * 2) % RAINBOW.length]; 294 + ops.push(text(font[row], { color })); 295 + } 296 + ops.push(close()); 297 + return ops; 298 + } 299 + 300 + let ops: Op[] = [ 301 + open("root", { 302 + layout: { 303 + width: fixed(nyanWidth), 304 + height: fixed(3), 305 + direction: "ttb", 306 + }, 307 + }), 308 + ]; 309 + 310 + for (let row = 0; row < 3; row++) { 311 + ops.push( 312 + open(`row${row}`, { 313 + layout: { width: grow(), height: fixed(1), direction: "ltr" }, 314 + }), 315 + ); 316 + 317 + if (trail > 0) { 318 + let color = RAINBOW[(row * 2 + i) % RAINBOW.length]; 319 + ops.push(text("█".repeat(trail), { color })); 320 + } 321 + 322 + ops.push(text(cat[row], { color: CYAN })); 323 + 324 + ops.push(close()); 325 + } 326 + 327 + ops.push(close()); 328 + return ops; 329 + }, 330 + nyanFrames, 331 + 60, 332 + ); 333 + 334 + esc("\x1b[0m\n"); 335 + write(tty.revert); 336 + Deno.stdin.setRaw(false); 337 + });
+12 -12
src/clayterm.c
··· 35 35 /* ── Instance state ───────────────────────────────────────────────── */ 36 36 37 37 struct Clayterm { 38 - int w, h, row; 38 + int w, h; 39 39 Cell *front; 40 40 Cell *back; 41 41 Buffer out; ··· 129 129 ct->lastbg = bg; 130 130 } 131 131 132 - static void emit_cursor(struct Clayterm *ct, int x, int y) { 132 + /** Emit CUP sequence. `row` is the 1-based terminal row of the region. */ 133 + static void emit_cursor(struct Clayterm *ct, int x, int y, int row) { 133 134 buf_str(&ct->out, "\x1b["); 134 - buf_num(&ct->out, y + 1 + ct->row); 135 + buf_num(&ct->out, y + row); 135 136 buf_put(&ct->out, ";", 1); 136 137 buf_num(&ct->out, x + 1); 137 138 buf_put(&ct->out, "H", 1); 138 139 } 139 140 140 - static void emit_ch(struct Clayterm *ct, int x, int y, uint32_t ch) { 141 + static void emit_ch(struct Clayterm *ct, int x, int y, int row, uint32_t ch) { 141 142 if (ct->lastx != x - 1 || ct->lasty != y) { 142 - emit_cursor(ct, x, y); 143 + emit_cursor(ct, x, y, row); 143 144 } 144 145 ct->lastx = x; 145 146 ct->lasty = y; ··· 155 156 * skipped, making this efficient for subsequent frames where most of 156 157 * the screen is static. Derived from termbox2 tb_present. 157 158 */ 158 - static void present_cups(struct Clayterm *ct) { 159 + static void present_cups(struct Clayterm *ct, int row) { 159 160 ct->lastx = -1; 160 161 ct->lasty = -1; 161 162 ··· 177 178 if (w > 1 && x >= ct->w - (w - 1)) { 178 179 /* wide char doesn't fit, send spaces */ 179 180 for (int i = x; i < ct->w; i++) 180 - emit_ch(ct, i, y, ' '); 181 + emit_ch(ct, i, y, row, ' '); 181 182 } else { 182 - emit_ch(ct, x, y, back->ch); 183 + emit_ch(ct, x, y, row, back->ch); 183 184 /* mark trailing cells of wide char as invalid in front 184 185 * so they'll diff when overwritten by narrow chars */ 185 186 for (int i = 1; i < w; i++) { ··· 400 401 401 402 static void clay_error(Clay_ErrorData err) { (void)err; } 402 403 403 - struct Clayterm *init(void *mem, int w, int h, int row) { 404 + struct Clayterm *init(void *mem, int w, int h) { 404 405 struct Clayterm *ct = (struct Clayterm *)mem; 405 406 int cell_count = w * h; 406 407 int cell_bytes = align8(cell_count * (int)sizeof(Cell)); ··· 417 418 *ct = (struct Clayterm){ 418 419 .w = w, 419 420 .h = h, 420 - .row = row, 421 421 .front = (Cell *)base, 422 422 .back = (Cell *)(base + cell_bytes), 423 423 .out = {base + cell_bytes * 2, 0, cell_count * OUT_BYTES_PER_CELL}, ··· 435 435 return ct; 436 436 } 437 437 438 - void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode) { 438 + void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { 439 439 int i = 0; 440 440 uint32_t idx = 0; 441 441 ··· 604 604 if (mode == 1) { 605 605 present_lines(ct); 606 606 } else { 607 - present_cups(ct); 607 + present_cups(ct, row); 608 608 } 609 609 } 610 610
+2 -2
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, int row); 15 - void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode); 14 + struct Clayterm *init(void *mem, int w, int h); 15 + void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row); 16 16 char *output(struct Clayterm *ct); 17 17 int length(struct Clayterm *ct); 18 18 void measure(int ret, int txt);
+10 -5
term-native.ts
··· 2 2 memory: WebAssembly.Memory; 3 3 statePtr: number; 4 4 opsBuf: number; 5 - reduce(ct: number, buf: number, len: number, mode: number): void; 5 + reduce(ct: number, buf: number, len: number, mode: number, row: number): void; 6 6 output(ct: number): number; 7 7 length(ct: number): number; 8 8 setPointer(x: number, y: number, down: boolean): void; ··· 14 14 export async function createTermNative( 15 15 w: number, 16 16 h: number, 17 - row: number = 0, 18 17 ): Promise<Native> { 19 18 let memory = new WebAssembly.Memory({ initial: 2 }); 20 19 let exports: Record<string, CallableFunction> = {}; ··· 47 46 let ct = exports as unknown as { 48 47 __heap_base: WebAssembly.Global; 49 48 clayterm_size(w: number, h: number): number; 50 - init(mem: number, w: number, h: number, row: number): number; 51 - reduce(ct: number, buf: number, len: number, mode: number): void; 49 + init(mem: number, w: number, h: number): number; 50 + reduce( 51 + ct: number, 52 + buf: number, 53 + len: number, 54 + mode: number, 55 + row: number, 56 + ): void; 52 57 output(ct: number): number; 53 58 length(ct: number): number; 54 59 Clay_SetPointerState(vec: number, down: number): void; ··· 68 73 memory.grow(pages - current); 69 74 } 70 75 71 - let statePtr = ct.init(heap, w, h, row); 76 + let statePtr = ct.init(heap, w, h); 72 77 let opsBuf = (heap + size + 3) & ~3; 73 78 74 79 return {
+16 -4
term.ts
··· 4 4 export interface TermOptions { 5 5 height: number; 6 6 width: number; 7 - top?: number; 8 7 } 9 8 10 9 export interface RenderOptions { 11 10 mode?: "line"; 11 + 12 + /** 13 + * Row where to begin rendering. This should only be used when 14 + * rendering into a region as part of the CLI main screen. For 15 + * interfaces that use the entire screen, leave unset which will 16 + * default to 0. This is 1-based which which is the DSR native 17 + * format. 18 + * 19 + * https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ 20 + */ 21 + row?: number; 22 + 12 23 pointer?: { 13 24 x: number; 14 25 y: number; ··· 31 42 } 32 43 33 44 export async function createTerm(options: TermOptions): Promise<Term> { 34 - let { width, height, top = 0 } = options; 35 - let native = await createTermNative(width, height, top); 45 + let { width, height } = options; 46 + let native = await createTermNative(width, height); 36 47 let { memory, statePtr, opsBuf } = native; 37 48 38 49 let prev = new Set<string>(); ··· 43 54 render(ops: Op[], options?: RenderOptions): RenderResult { 44 55 let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); 45 56 let mode = options?.mode === "line" ? 1 : 0; 46 - native.reduce(statePtr, opsBuf, len, mode); 57 + let row = options?.row ?? 1; 58 + native.reduce(statePtr, opsBuf, len, mode, row); 47 59 48 60 if (options?.pointer) { 49 61 let { x, y, down } = options.pointer;
+3 -3
test/term.test.ts
··· 153 153 154 154 describe("row offset", () => { 155 155 it("renders two frames at the offset position", async () => { 156 - let term = await createTerm({ width: 20, height: 5, top: 5 }); 156 + let term = await createTerm({ width: 20, height: 5 }); 157 157 let box = (msg: string) => [ 158 158 open("root", { 159 159 layout: { width: grow(), height: grow(), direction: "ttb" }, ··· 181 181 let header = await createTerm({ width: 20, height: 5 }); 182 182 let banner = decode(header.render(box("hello")).output); 183 183 184 - let first = decode(term.render(box("world")).output); 184 + let first = decode(term.render(box("world"), { row: 6 }).output); 185 185 expect(print(banner + first, 20, 10)).toEqual(`\ 186 186 ┌──────────────────┐ 187 187 │hello │ ··· 194 194 │ │ 195 195 └──────────────────┘`); 196 196 197 - let second = decode(term.render(box("universe")).output); 197 + let second = decode(term.render(box("universe"), { row: 6 }).output); 198 198 expect(print(banner + first + second, 20, 10)).toEqual(`\ 199 199 ┌──────────────────┐ 200 200 │hello │