bare-tui
v0.0.3
Published
A little TUI framework for Bare, based on bubbletea
Readme
bare-tui
A little TUI framework for Bare, based on The Elm Architecture. It's a functional, stateful way to build terminal apps that's pleasant for both simple and complex programs — and it runs anywhere Bare runs, with no Node.js dependencies.
It's designed for AI. You should be able to point your agent to CLAUDE.md and instruct it to build out your UI. That's how the mock claude code example was built.

[!NOTE]
This an experimental library. A version 1.0.0 release will signal stability.
bare-tui is shaped after Charm's wonderful Bubble Tea; if you know that, you already know this. It ships its own component set (the Bubbles equivalent) and a styling/layout helper (the Lip Gloss equivalent), all built on Bare's native primitives (bare-tty, bare-ansi-escapes).
The snippets below import
require('bare-tui'). Inside this repo's ownexamples/andtest/, that's a relativerequire('..').
Tutorial
This tutorial assumes you have Bare installed. We'll build a simple counter.
bare-tui programs are made of a model describing the application state, and three methods on that model:
init— a function that returns an initial command (ornull).update— a function that handles incoming messages and updates the model.view— a function that renders the model to a string.
The Model
Start with a model — anything that holds your app's state. A class is idiomatic:
const { Program, quit, key } = require('bare-tui')
class Counter {
constructor() {
this.count = 0
}
}Initialization
init returns the first command to run, or null for none. Commands are how you kick off work (timers, I/O); more on them below.
init() {
return null
}The Update Method
update is called when a message arrives. A message is any tagged value — a keypress, a window resize, the result of a command. It returns a [model, command] pair (returning a bare model means "no command").
update(msg) {
if (msg.type !== 'key') return [this, null]
if (key.matches(msg, 'q', 'ctrl+c')) return [this, quit] // quit the program
if (key.matches(msg, 'up', 'k')) this.count++
if (key.matches(msg, 'down', 'j')) this.count--
return [this, null]
}Mutating this and returning [this, cmd] is the idiomatic style here.
The View Method
view renders the current model to a string. bare-tui draws it for you and only repaints the lines that changed.
view() {
return `count: ${this.count}\n\n↑/↓ change · q quit`
}All Together Now
const { Program, quit, key } = require('bare-tui')
class Counter {
constructor() {
this.count = 0
}
init() {
return null
}
update(msg) {
if (msg.type !== 'key') return [this, null]
if (key.matches(msg, 'q', 'ctrl+c')) return [this, quit]
if (key.matches(msg, 'up', 'k')) this.count++
if (key.matches(msg, 'down', 'j')) this.count--
return [this, null]
}
view() {
return `count: ${this.count}\n\n↑/↓ change · q quit`
}
}
new Program(new Counter()).run()Run it with bare counter.js. The Program puts the terminal into raw mode, enters the alternate screen, decodes input into messages, and — importantly — restores the terminal on exit, even if your code throws.
Commands
A command (Cmd) is a function () => Msg | Promise<Msg> | null. The runtime runs it off the update path and feeds whatever message it returns back into update. This is how you do anything asynchronous — timers, file or network I/O, talking to a worker — without blocking the UI.
const { quit, batch, sequence, tick, every, suspend } = require('bare-tui')
quit // a Cmd that quits the program
tick(1000, () => ({ type: 'tick' })) // fire a Msg after 1s
every(1000, () => ({ type: 'tick' })) // fire on the wall-clock second
batch(cmdA, cmdB) // run several Cmds concurrently
sequence(cmdA, cmdB) // run several Cmds in order
suspend(fn) // drop the TUI, run fn() with the real terminal, then resumeUse suspend to hand the terminal to an external program that needs it — an editor ($EDITOR), a pager, a sub-shell. The runtime drops raw mode, leaves the alt-screen and releases stdin while fn() runs, then re-attaches and repaints; the message fn resolves to is delivered once the TUI is back:
return [
model,
suspend(async () => {
await spawnEditor(file) // owns the real terminal while it runs
return { type: 'edited', file }
})
]An async command just returns a promise:
const load = () =>
fetch(url)
.then((res) => res.json())
.then((data) => ({ type: 'loaded', data }))Return commands from init or update; the result comes back as a message.
Key & mouse input
Keys arrive as { type: 'key' } messages (a KeyMsg). Match them with key.matches, which is null- and type-safe:
if (key.matches(msg, 'enter')) ...
if (key.matches(msg, 'ctrl+c', 'q')) ...Define reusable, self-documenting bindings with key.binding — the help component renders them automatically:
const keys = {
up: key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } })
}Enable the mouse with a Program option; clicks/scroll/drag arrive as { type: 'mouse', action, button, x, y }:
new Program(model, { mouse: true }) // true | 'drag' | 'all'Components
Ready-made, composable pieces — each is a model (update/view) you embed in your own. See each doc for options, methods, messages, and keybindings.
| Component | Description |
| ------------------------------------ | --------------------------------------- |
| spinner | Animated loading indicator |
| textinput | Single-line text field |
| autocomplete | Text field with a suggestion menu |
| textarea | Multi-line text editor |
| list | Selectable, filterable list |
| select | Compact dropdown over a fixed list |
| radio | Single choice from a fixed set |
| checkbox | Boolean toggle |
| focus | Focus ring across child components |
| table | Columns with selectable, scrolling rows |
| viewport | Scrollable window over long content |
| paginator | Page state + indicator |
| progress | Progress bar |
| help | Keybinding hints from key.bindings |
| stopwatch | Counts elapsed time up |
| timer | Counts a duration down |
| filepicker | Browse the filesystem and pick a file |
To embed one, hold it as a field, route messages to it, and thread its command back up:
update(msg) {
if (msg.type === 'spinner.tick') {
const [s, cmd] = this.spinner.update(msg)
this.spinner = s
return [this, cmd]
}
...
}Styling & layout
style is a small, chainable, immutable styling and layout helper (a Lip Gloss equivalent). All measurement is ANSI- and wide-character-aware, so styled text composes correctly.
const { style } = require('bare-tui')
style()
.bold(true)
.foreground('cyan') // name, 0–255, or #hex
.padding(1, 2)
.border(style.borders.rounded)
.borderForeground('blue')
.render('Hello')
// Layout: place blocks side by side or stacked.
style.joinHorizontal(style.position.top, left, ' ', right)
style.joinVertical(style.position.left, header, body, footer)Set .width(n) to pin a block to a fixed width (short/blank lines pad out, so a bordered box tracks the screen edge instead of shrinking to its content).
Testing
bare-tui is built to be tested headlessly — no real terminal, no real I/O.
- Drive a Program with injected streams: pass
{ input, output, isTTY: true }(abare-streamPassThroughand a capturingWritable), write key bytes toinput,await program.run(), and assert on the captured output. Use{ fps: 0 }to render synchronously per update for deterministic frames. - Mock dependencies: components that touch the outside world take their deps by injection. For example
filepicker.mock(tree)returns an in-memory{ fs, path }so you can test a file browser with zero disk access. - Unit-test components by calling
update(msg)and asserting on state orview().style.stripAnsi(view)gives you the visible text.
Examples
Runnable, one per concept, in examples/:
counter · form · controls · list · table · dashboard · pager ·
progress · paginator · mouse · textarea · timer · filepicker ·
claude-code
bare examples/dashboard.jsBuilding your own component
Components are just models, so you can build your own and they'll compose like the built-ins. Keep to the conventions the built-ins follow — a create() factory, update → [model, cmd] / view → string, ignore unrelated messages, gate input on a focused flag, define keymaps with key.binding, stay style-agnostic, and do animation/I/O through commands (with an injectable, mockable dependency). The shipped components are short and meant to be read; copy the closest one. The tea-tui skill in this repo walks through it in detail.
Acknowledgments
Deeply indebted to Charm — bare-tui is a port of the ideas in Bubble Tea, Bubbles, and Lip Gloss to the Bare runtime. Built on Bare by Holepunch.
Logo

A Tui bird of New Zealand
License
Apache-2.0
