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 own examples/ and test/, that's a relative require('..').
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 (or null).
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]
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
tick(1000, () => ({ type: 'tick' }))
every(1000, () => ({ type: 'tick' }))
batch(cmdA, cmdB)
sequence(cmdA, cmdB)
suspend(fn)
Use 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)
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 })
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.
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')
.padding(1, 2)
.border(style.borders.rounded)
.borderForeground('blue')
.render('Hello')
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 } (a bare-stream PassThrough and a capturing Writable), write key bytes to input, 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 or view(). 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.js
Building 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