beamtea ๐Ÿต

CIHex.pmHex DocsErlang/OTPLicense

A delightful terminal UI framework for the BEAM โ€” Bubble Tea for Erlang.

Preview

beamtea brings The Elm Architecture to the terminal, built directly on OTP's prim_tty. You write three pure functions โ€” init, update, view โ€” and beamtea handles raw input, UTF-8 decoding, the alternate screen, resizing, timers, and a clean exit. It ships with bubbles (high-level components inspired by charmbracelet/bubbles) and beamtea_style, a composable, lipgloss-style styling layer โ€” all in an electric, Charm-inspired colour palette.

-module(counter).
-behaviour(beamtea).
-export([main/0, init/1, update/2, view/1]).
main() -> beamtea:start(?MODULE).
init(_Flags) -> {0, beamtea:none()}.
update({key, up}, N) -> {N + 1, beamtea:none()};
update({key, down}, N) -> {N - 1, beamtea:none()};
update({key, {char, $q}}, N) -> {N, beamtea:quit()};
update(_Msg, N) -> {N, beamtea:none()}.
view(N) ->
["\n ", beamtea_color:fg(charmple, beamtea_term:bold(<<"counter">>)),
"\n\n ", integer_to_binary(N),
"\n\n ", beamtea_term:faint(<<"โ†‘ up ยท โ†“ down ยท q quit"/utf8>>), "\n"].

Status โ€” v0.1.2

Everything below is implemented and covered by tests (128 EUnit cases) plus PTY integration checks on macOS and Linux:

Featureโœ“
Raw single-key inputโœ“
UTF-8 text inputโœ“
Arrow keys (CSI + SS3)โœ“
Elm-style init/update/viewโœ“
Commands and timersโœ“
Alternate screenโœ“
Cursor restorationโœ“
Terminal resizing (SIGWINCH)โœ“
Full-frame renderingโœ“
Clean q / Ctrl-C exitโœ“
Linux and macOS testsโœ“

Requires Erlang/OTP 28+ (developed against OTP 29) and rebar3. CI runs the suite on Ubuntu (OTP 28 & 29) and macOS on every push to main and every pull request.


Install

Add beamtea as a rebar3 dependency:

%% rebar.config
{deps, [{beamtea, {git, "https://github.com/tsirysndr/beamtea.git", {tag, "0.1.2"}}}]}.

Or clone and build locally:

git clone https://github.com/tsirysndr/beamtea.git
cd beamtea
rebar3 compile
rebar3 eunit

Running a program

Important: a full-screen TUI needs to own the terminal. Don't launch beamtea programs from inside rebar3 shell โ€” the interactive Erlang shell keeps its own prim_tty on stdin and will fight beamtea for input (you'll see stealing control of fd=0), and Ctrl-C will hit the BEAM break handler.

Use the included launcher, which sets the terminal up correctly and restores it on exit:

./bin/beamtea-run counter
# or
make run EXAMPLE=counter

Under the hood the launcher runs:

stty -isig -ixon # Ctrl-C / Ctrl-S arrive as bytes, not signals
erl +Bi -noshell -noinput ... \ # disable the break handler; own the terminal
-eval "counter:main(), halt()."

To ship your own app, do the same three things (or copy bin/beamtea-run): stty -isig, erl +Bi -noshell -noinput, and call beamtea:start/1.


The Elm Architecture

A beamtea program is three functions. Provide them as a module implementing the beamtea behaviour, or as a map #{init => F, update => F, view => F}.

init(Flags) -> {Model, Cmd}. %% initial model + first command
update(Msg, Model) -> {Model, Cmd}. %% fold a message into the model
view(Model) -> iodata(). %% render the whole screen

Messages

update/2 receives, from the runtime:

โ€ฆplus any message produced by the commands you return.

Commands

Return a command from init/2 or update/2 to ask the runtime to do something:

CommandConstructorEffect
do nothingbeamtea:none()โ€”
quitbeamtea:quit()stop the program, return the final model
run manybeamtea:batch([Cmd])run several commands
defer a messagebeamtea:msg(Msg)deliver Msg to update/2 soon
one-shot timerbeamtea:tick(Ms, Msg)deliver Msg after Ms ms
repeating timerbeamtea:every(Ms, Msg)deliver Msg every Ms ms
async taskbeamtea:task(fun () -> Msg end)run in a process, deliver the result

Keys

beamtea_key decodes raw bytes into ergonomic key events:

Options

beamtea:start(Program, Flags, Opts):

Filling the terminal

beamtea renders exactly what view/1 returns, anchored top-left โ€” so a small view sits in the corner and the rest of the screen is blank. To use the whole terminal you have two options:

1. Let the runtime place your view โ€” the simplest way to centre or corner-pin a view. The runtime knows the terminal size and re-flows on resize:

beamtea:start(?MODULE, undefined, #{layout => center}).
%% center ยท fill (full-screen bordered panel) ยท {frame, Opts} ยท {place, #{halign, valign}}

2. Lay out yourself โ€” for real full-screen UIs (headers, footers, sidebars). Your update/2 receives the terminal size as {resize, {Cols, Rows}} โ€” including once at startup, before the first frame โ€” so store it and build your view/1 to those dimensions. beamtea_layout (place/3, center/2, top_center/2) and beamtea_util:visible_width/1 (ANSI-aware) help you position and size content.

init(_) -> {#st{w = 80, h = 24}, beamtea:none()}.
update({resize, {W, H}}, St) -> {St#st{w = W, h = H}, beamtea:none()};
%% ...
view(#st{w = W, h = H} = St) ->
beamtea_layout:center(my_panel(St), {W, H}).

Bubbles โ€” reusable components

Each bubble is a self-contained mini-program (new, update, view, plus accessors). Compose them: keep the bubble's model inside yours, forward messages to its update/2, and embed its view/1. Components that animate (spinner, timer, โ€ฆ) keep themselves running by re-scheduling their own tick โ€” start them once from init/1.

ModuleComponentHighlights
beamtea_spinnerSpinner8 styles (dot, line, moon, points, โ€ฆ), self-animating
beamtea_textinputText inputUTF-8, caret, char limit, placeholder
beamtea_textareaText areamulti-line editing, cross-line backspace, scrolling
beamtea_progressProgress barelectric gradient fill, percentage
beamtea_listSelectable listarrow/j/k nav, scrolling window, title
beamtea_tableData tablecolumns, row selection, scrolling, truncation
beamtea_viewportScrollable viewportpage/line/home/end scrolling, scroll %
beamtea_paginatorPaginatordots or N/M, slice helper for the current page
beamtea_keybindKey bindingmatch keys by intent, carry help text
beamtea_helpHelp viewshort (one line) and full (columns) help
beamtea_timerCountdown timerself-ticking, timed_out/1
beamtea_stopwatchStopwatchself-ticking count-up
beamtea_filepickerFile pickerbrowse dirs, select files
beamtea_cursorBlinking cursorblink / static / hidden modes

Example โ€” wiring a spinner into your program:

init(_) ->
S = beamtea_spinner:new(dot),
{#st{spin = S}, beamtea_spinner:tick(S)}. %% start animating
update(Msg, St) ->
{S1, Cmd} = beamtea_spinner:update(Msg, St#st.spin),
{St#st{spin = S1}, Cmd}.
view(St) ->
[beamtea_spinner:view(St#st.spin), " loading..."].

Examples

Build once with rebar3 as examples compile, then run any of these with the launcher:

./bin/beamtea-run <name>
NameShows off
counterthe core loop โ€” keys, model, view
keyskey inspector โ€” arrows, Ctrl-combos, UTF-8, emoji
stopwatchcommands & timers via beamtea:every/2
spinner_demobeamtea_spinner โ€” cycle every style
textinput_demobeamtea_textinput โ€” a live greeting
textarea_demobeamtea_textarea โ€” a bordered multi-line editor
progress_demobeamtea_progress โ€” a self-filling gradient bar
list_demobeamtea_list โ€” a drink chooser
table_demobeamtea_table โ€” a sortable-looking framework table
viewport_demobeamtea_viewport โ€” scroll a long document
timer_demobeamtea_timer โ€” a 10-second countdown
filepicker_demobeamtea_filepicker โ€” browse the filesystem
help_demobeamtea_keybind + beamtea_help + beamtea_paginator
dashboard_demobeamtea_style โ€” a grid of stat cards composed with joins
tabs_demobeamtea_style โ€” a tabbed interface
statusbar_demobeamtea_style โ€” a full-width bottom status bar
modal_demoa floating yes/no modal overlaid with beamtea_layout:overlay_center/3
finder_demoadvanced โ€” an fzf-style fuzzy finder (press /) in a modal, over an app with a status bar

Most examples quit with q; the text-entry demos (textinput_demo, textarea_demo) quit with Esc or Ctrl-C.


Colours

beamtea_color provides an electric, Charm-inspired palette mapped to xterm-256 indices. Reference a colour by name:

beamtea_color:fg(hotpink, "hi") %% named foreground
beamtea_term:paint([38, 5, beamtea_color:c(charmple)], "hi")

Names include charmple, purple, indigo, violet, hotpink, pink, magenta, cyan, aqua, teal, blue, green, lime, mint, yellow, gold, orange, coral, red, and neutrals (cloud, gray, dim, charcoal). See beamtea_color:names/0.

Low-level escapes live in beamtea_term: alt_enter/0, hide_cursor/0, move/2, sgr/1, bold/1, faint/1, reverse/1, paint/2, โ€ฆ

Tip: always tag non-ASCII binary literals with /utf8 โ€” <<"โ†‘ up"/utf8>>, not <<"โ†‘ up">>. Without it the compiler byte-truncates each code point into garbage. beamtea's renderer also accepts plain Unicode code-point lists ("โ†‘ up"), so either works.


Styling โ€” beamtea_style

A composable, lipgloss-style layer. Build an immutable style with chained setters, then render/2 it onto text โ€” colours, weight, alignment, width, padding, margin and borders:

S0 = beamtea_style:new(),
S1 = beamtea_style:foreground(S0, pink),
S2 = beamtea_style:padding(S1, {1, 2}),
S3 = beamtea_style:border(S2, rounded), %% normal | rounded | thick | double
beamtea_style:render(beamtea_style:border_foreground(S3, pink), <<"Hello">>).

Compose rendered blocks with join_horizontal/2 (side by side) and join_vertical/2 (stacked) โ€” the building blocks for button rows, tab bars, cards and status bars. Widths are measured with a real wcwidth (via beamtea_util:visible_width/1), so borders stay aligned around emoji and CJK. beamtea_layout:overlay_center/3 composites one block over another (ANSI-aware) for floating modals.

See dashboard_demo, tabs_demo, statusbar_demo, modal_demo and finder_demo.


Testing

rebar3 eunit # 103 unit tests (pure logic: key parsing, rendering, commands, every bubble)
rebar3 xref # cross-reference checks

The unit tests are pure and run identically on Linux and macOS. Terminal behaviour (raw mode, alt screen, arrow keys, UTF-8, Ctrl-C, clean restore) is verified separately by driving real programs through a PTY.

Documentation

Every module is annotated with @doc/-spec. Generate browsable HTML API docs into ./doc:

rebar3 edoc # or: make docs
open doc/index.html

Project layout

src/
beamtea.erl public API, behaviour, command constructors
beamtea_runtime.erl the event loop that owns the terminal
beamtea_key.erl raw bytes -> key events
beamtea_term.erl ANSI / VT escape sequences
beamtea_render.erl full-frame rendering
beamtea_cmd.erl command -> effects (pure)
beamtea_color.erl electric colour palette
beamtea_style.erl composable styling (lipgloss-style)
beamtea_layout.erl placement, framing, ANSI-aware overlay
beamtea_util.erl shared helpers (time, padding, wcwidth, ANSI)
beamtea_*.erl the bubbles (spinner, textinput, table, โ€ฆ)
examples/ runnable example programs
bin/beamtea-run the launcher
test/ EUnit suites

Why a launcher, and how Ctrl-C works

prim_tty's raw mode keeps ISIG enabled โ€” exactly as the Erlang shell does โ€” so Ctrl-C would normally become a SIGINT handled by the BEAM break handler (BREAK: (a)bort ...), hanging the UI. The BEAM reserves SIGINT (you cannot os:set_signal(sigint, handle)), so beamtea instead:

  1. runs with +Bi to disable the break handler, and
  2. disables ISIG on the terminal (via stty -isig in the launcher) so Ctrl-C arrives as a plain 0x03 byte.

prim_tty preserves that setting, so the runtime sees {ctrl, $c} and quits cleanly, restoring cooked mode, the cursor, and the primary screen on the way out. Running with -noshell -noinput makes beamtea the sole owner of stdin, avoiding any conflict with the interactive shell.


License

MIT ยฉ 2026 Tsiry Sandratraina. See LICENSE.