npm stats
  • Search
  • About
  • Repo
  • Sponsor
  • more
    • Search
    • About
    • Repo
    • Sponsor

Made by Antonio Ramirez

bare-tui

0.0.3

@GitHub Actions

npmHomeRepoSnykSocket
Downloads:16
$ npm install bare-tui
DailyWeeklyMonthlyYearly

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.

Mock Claude Code

[!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]   // 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 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.

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.

ComponentDescription
spinnerAnimated loading indicator
textinputSingle-line text field
autocompleteText field with a suggestion menu
textareaMulti-line text editor
listSelectable, filterable list
selectCompact dropdown over a fixed list
radioSingle choice from a fixed set
checkboxBoolean toggle
focusFocus ring across child components
tableColumns with selectable, scrolling rows
viewportScrollable window over long content
paginatorPage state + indicator
progressProgress bar
helpKeybinding hints from key.bindings
stopwatchCounts elapsed time up
timerCounts a duration down
filepickerBrowse 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 } (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

tui-bird

A Tui bird of New Zealand

License

Apache-2.0