← BACK TO ENGINEERING
Runtime 10 min read

Finishing Rise: The TUI Framework That Said It Was Done, and the App That Proved It Wasn't

Rise is our terminal UI framework for Bun. The screen is a Uint32Array, one packed integer per cell: background, foreground, glyph. Widgets draw straight into slices of that buffer. No virtual DOM, no dependencies, no allocation in the hot path.

The repository said it was finished. There was an implementation report. There was a changelog titled "Implementation Complete". There were 313 passing tests, "100% coverage", and a line saying every feature from the design document had shipped "with zero compromises."

So I asked the only question that matters for a framework: can you build a real application with it?

The answer was no. Here's what it took to change that, and the app that proves it did.

Rise Studio's dashboard: live CPU sparkline with 30 seconds of history, memory gauge and chart, 18 per-core load bars, a large block-digit clock and a system info panel


I – What "Complete" Looked Like

The tests were green. The first thing I ran was the type checker, and it didn't even start: tsconfig.json pointed at a bun-types package that wasn't installed. Nobody had run tsc in a while. Once it could run, it printed 71 errors.

Most were noise from example files. Four were not. The four "feedback" widgets (ProgressBar, Spinner, Dialog, Toast), the ones the report listed first, contained lines like this:

const w = rect.width;          // Rect has w and h, not width and height
const fg = PALETTE.green;      // PALETTE has GREEN; this is undefined
slice.set(x, y, packCell(char, fg, cellBg)); // signature is packCell(bg, fg, glyph)

Three separate bugs per line, and every one of them silent at runtime. rect.width is undefined, so the render loops run zero times. Where they did run, the glyph went into the background slot. These widgets had never drawn anything correct.

The tests passed anyway. This is from the test file:

test("Toast renders without crashing", () => {
  toast.render(slice);
  expect(true).toBe(true);
});

A test that asserts true === true can't fail, so it tells you nothing. Several e2e tests also called writeString(slice, x, y, text, …) against a signature of writeString(text, slice, x, y, …). Their cell renderers quietly drew nothing, and nothing checked.

Then the App class, the thing you actually subclass:

for (let i = 0; i < this.widgets.length; i++) {
  const nodeId = this.layout.createNode(rootId);
  this.layout.setConstraints(nodeId, 10, 3, 1, 1);
}

Every top-level widget got the same hard-coded constraints (a 10×3 minimum, flex-grow 1), and nothing a widget could say about its own size. There was no focus model, so keys never reached widgets unless your app forwarded them by hand. The input loop quit on q unconditionally, including while you were typing into a text field. Mouse-wheel direction was discarded, so every list scrolled down no matter which way you turned the wheel. Nothing handled terminal resize. loadTheme() called a method that didn't exist.

None of this is unusual. It's what happens when a project is measured by its own reports instead of by use.


II – The Rule: Build Something Real

The fix wasn't to write more tests first. It was to pick an application ambitious enough to hit every seam, and refuse to call the framework done until that application worked.

That application is Rise Studio: six screens, about 1,700 lines, built only from Rise widgets.

Screen What it exercises
Dashboard Live CPU/memory Sparklines, 18 per-core ProgressBars, block Digits clock, styled Labels
Processes Live ps data in a sortable, filterable DataTable, a details Dialog, header-click sorting
Files Lazy-loading Tree, code preview with line numbers, rendered Markdown preview, $EDITOR via shellOut
Tasks A persisted to-do list: Input + Select + Button form, rich ListView rows, filters, confirm-delete
Notes Markdown editor with a live side-by-side preview and ctrl+s save
Logs Every action the app took, in a tail-following LogView

On top of that: a fuzzy command palette on ctrl+p, six themes on ctrl+t, a help modal, toast notifications, and mouse support throughout.

The Processes screen: 951 live processes sorted by CPU, with a filter box above and heat-colored CPU and memory columns

Every screen forced a framework change. That was the point.


III – A Real App Runtime

Most of the work landed in App, which grew from a stub into roughly 800 lines.

Layout. compose() now returns a real tree. Row and Column size children along their main axis with a small integer-only algorithm: fixed cells, percentages, then flex shares of whatever is left. Widgets opt in with withLayout():

new Row({
  gap: 1,
  children: [
    new Panel({ title: "Explorer", child: tree }).withLayout({ width: "32%" }),
    new Panel({ title: "Preview", child: preview }), // flex: 1 by default
  ],
});

Set widget.visible = false and it takes no space on the next frame. The Files screen uses exactly that to swap between a code viewer and a Markdown renderer.

Focus and key routing. Tab and shift+tab walk the focusable widgets in tree order, and clicking focuses. Keys then flow through a fixed order:

  1. ctrl+c always quits
  2. An open modal gets everything else (a focus trap)
  3. The focused widget; returning true stops propagation
  4. The app's onKey
  5. Bindings registered with bind()
  6. Tab / shift+tab focus cycling
  7. q quits, if the app allows it

The rule that fixes the "q quits while typing" bug is one flag. Text-entry widgets set capturesText, and the router never lets a printable key escape them.

Modals and toasts. app.showModal(widget) centers the widget, dims everything behind it, traps focus and restores the previous focus when it closes. app.notify(message, level) stacks toasts in the top-right corner and expires them.

A process-details dialog over the dimmed Processes screen, showing the full command path, user, CPU and memory, with

Themes that actually apply. The old widgets hard-coded 16-color palette constants. They now read semantic roles (primary, surface, border, success…) from a live theme registry at render time, so app.setTheme(THEMES.nord) restyles every widget on the next frame with no bookkeeping.


IV – Rendering Only What Changed

The original renderer tracked dirty regions, but the app cleared the whole screen every frame. That marked every cell dirty, so every frame repainted the entire terminal.

The new renderer is double-buffered. It keeps a copy of the last frame it sent and compares cell by cell:

for (let x = 0; x < width; x++) {
  const idx = rowStart + x;
  const cell = buffer[idx];
  if (!full && prev![idx] === cell) continue; // unchanged: send nothing

  if (cursorY === y && x > cursorX && x - cursorX <= 4) {
    // Re-sending a few unchanged cells is cheaper than a cursor move
    for (let gx = cursorX; gx < x; gx++) pos = this.writeCell(pos, buffer[rowStart + gx]);
  } else if (cursorY !== y || cursorX !== x) {
    pos = this.writeCursorPos(pos, y + 1, x + 1);
  }
  pos = this.writeCell(pos, cell);
  // …
}

Comparing packed integers is about as cheap as comparison gets. The gap rule came from measurement: a cursor move costs around 8 bytes, so re-sending up to four unchanged cells is cheaper than jumping over them.

On a real 120×36 terminal, the first paint of Rise Studio is about 15 KB. With the dashboard ticking twice a second, updating the clock, spinner and charts, 1.2 seconds of output measured 1,056 bytes before the gap rule and 441 bytes after. An idle screen sends nothing at all.


V – Input Is Harder Than It Looks

The old parser assumed one key per read(). If a single read held more than one key (fast typing, or any paste), the whole chunk matched nothing and was dropped. The new one parses every event in a chunk: modifiers (ctrl+, alt+, shift+), home/end/page keys, F1–F12, SGR mouse with drag and motion, bracketed paste (even when a paste spans two reads), and UTF-8.

One bug only showed up in a real terminal. Pressing esc and then ctrl+p quickly can deliver both bytes in a single read, and the parser read ESC + 0x10 as alt+ctrl+p. The palette didn't open. The fix follows the usual terminal convention: Alt combines only with printable characters, and ESC followed by a control byte is two keys.


VI – Testing Apps, Not Widgets

Widget unit tests couldn't have caught most of this. The bugs lived between components: key routing, focus restore after a dialog, layout after a visibility change. So Rise now has a headless driver, the Pilot, borrowed in spirit from Textual:

const pilot = await new StudioApp({ persist: false }).runHeadless({ width: 120, height: 36 });

pilot.press("4");                       // Tasks screen
pilot.press("n");
pilot.type("Ship Rise Studio");
pilot.press("enter");
expect(pilot.screenText().join("\n")).toContain("Tasks — 5 open");

pilot.press("d");                       // delete → confirmation dialog
expect(app.hasModal()).toBe(true);
pilot.press("enter");
expect(pilot.screenText().join("\n")).not.toContain("Ship Rise Studio");

It drives the real App through the same dispatch path as a terminal, with no raw mode, no stdin and no ANSI, and asserts on what's actually on screen.

The Tasks screen: a new-task form with priority selector, six tasks with colored priority tags (two completed), progress stats, filters and a success toast

Those end-to-end tests found real bugs:

  • The Notes trap. The editor captures text, so 1–6 and [/] got typed into your note, and there was no keyboard way off the screen. Now esc leaves a text field, and ctrl+pageup/ctrl+pagedown switch screens from anywhere.
  • Toasts took 56 columns regardless of content. They now size to the message.
  • Markdown treated every source line as a paragraph. Wrapped prose rendered as ragged fragments. Consecutive lines now join, as in real Markdown.

The delete-confirmation dialog:


VII – Then We Ran It in a Real Terminal

Headless tests skip exactly the parts that break in production: raw mode, the stdin stream, SIGWINCH, restoring the terminal on exit. So the last layer of testing spawns the app in a pseudo-terminal from Python, types into it and reads the raw bytes back.

It found the worst bug of the day. Quitting from the confirmation dialog called stop(), which restored the terminal. But the key event that triggered it was still being dispatched, and it scheduled one more frame. That frame was painted into your normal shell after the app had exited: a flash of UI debris over your prompt. The fix is two guards: the app stops rendering once it's no longer running, and the renderer refuses to flush after it has restored the terminal. The byte stream now ends exactly on the exit sequence.

The same harness smoke-tested all eleven of the older examples. One of them, agent-dashboard, drew to the screen and then called app.render(), which clears it. It could never have shown anything. It now draws inside a small new Canvas widget.

The Files screen: a tree explorer with colored file-type icons and indent guides, and src/app.ts previewed with line numbers


VIII – The Screenshots Are Rendered by Rise

Every image in this post was produced by Rise itself, not a screen capture. pilot.screenshot() turns the cell buffer into an SVG:

  • Text runs are placed with explicit per-character x coordinates, so the grid is exact whatever the font's advance width.
  • Box-drawing characters and block elements (─│╭╮, ▁▂▃…█, ▏▎▍…) are drawn as vector shapes instead of glyphs, so borders always connect and the sparklines stay crisp.
  • Colors come from the xterm 256-color palette; the canvas color is the most common background, not whatever happens to be in the top-left cell (which was the purple header in the first draft).

A capture script drives Studio through every screen and overlay, then converts the SVGs to 2× PNGs with rsvg-convert:

bun run screenshots   # --warmup 30 --size 140x40 --out docs/screenshots

The warmup matters: the dashboard samples once a second, so without it the CPU chart would be a single bar. The history in these images is 30 seconds of real load on the machine that took them.

The Notes screen: a Markdown source editor with line numbers on the left and a live rendered preview on the right, with headings, bullets, a checklist, a blockquote and a code block

The command palette over a dimmed dashboard, fuzzy-filtering

The theme registry, shown in three of the six themes:

Rise Studio's dashboard in the warm Ember theme

Rise Studio's dashboard in the Nord theme

Rise Studio's dashboard in the Light theme


IX – The Honest Accounting

Before After
TypeScript errors 71 (and tsc couldn't start) 0
Tests 313 358, including 13 end-to-end tests of Studio
Widgets drawing correctly 4 of the headline widgets broken all, with assertions on rendered output
App layout same hard-coded constraints for every widget flex/percent/fixed tree
Bytes per idle frame full repaint 0
Examples verified in a real terminal never checked (agent-dashboard could never render) 11 of 11, plus Studio

What it doesn't do yet:

  • Wide characters. Cells are 16-bit and one column wide. Emoji and CJK are substituted or misaligned; the widgets avoid them on purpose.
  • Text attributes. No bold, italic or underline. The cell format is [bg:8][fg:8][glyph:16] and there's no room left without widening it.
  • TCSS is half wired. loadTheme(css) now compiles and attaches computed styles without crashing, but the built-in widgets read the semantic theme, not the stylesheet.

And one lesson I keep relearning: a report is a claim, a test is a claim about a claim, and only use is evidence. The fastest way to find out if a framework is finished is to build the app you'd be embarrassed to demo, and demo it.

– Antonio

"Simplicity is the ultimate sophistication."