
Research
/Security News
Miasma Mini Shai-Hulud Hits ImmobiliareLabs npm Packages
Miasma Mini Shai-Hulud hits @immobiliarelabs Backstage plugins, targeting GitLab and LDAP auth packages on npm.
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('..').
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.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
}
}
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
}
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.
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`
}
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.
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 resume
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) // 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.
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'
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]
}
...
}
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).
bare-tui is built to be tested headlessly — no real terminal, no real I/O.
{ 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.filepicker.mock(tree) returns an in-memory { fs, path } so you can test a file browser with zero disk access.update(msg) and asserting on state or view(). style.stripAnsi(view) gives you the visible text.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
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.
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.

A Tui bird of New Zealand
Apache-2.0
FAQs
A little TUI framework for Bare, based on bubbletea
We found that bare-tui demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Research
/Security News
Miasma Mini Shai-Hulud hits @immobiliarelabs Backstage plugins, targeting GitLab and LDAP auth packages on npm.

Security News
Rolldown paused Rust React Compiler integration after a 5MB binary size increase raised concerns about shipping React-specific code to all Vite users.

Security News
/Research
Mini Shai-Hulud expands into the Go ecosystem after hitting LeoPlatform npm packages and targeting GitHub Actions workflows.