beamtea ๐ต
A delightful terminal UI framework for the BEAM โ Bubble Tea for Erlang.
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: a set of high-level, reusable components inspired by charmbracelet/bubbles, styled with 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.1
Everything below is implemented and covered by tests (103 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.1"}}}]}.
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 ownprim_ttyon stdin and will fight beamtea for input (you'll seestealing control of fd=0), andCtrl-Cwill 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:
{key, Key}โ a keypress (see Keys below){resize, {Cols, Rows}}โ the terminal was resized
โฆ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:
| Command | Constructor | Effect |
|---|---|---|
| do nothing | beamtea:none() | โ |
| quit | beamtea:quit() | stop the program, return the final model |
| run many | beamtea:batch([Cmd]) | run several commands |
| defer a message | beamtea:msg(Msg) | deliver Msg to update/2 soon |
| one-shot timer | beamtea:tick(Ms, Msg) | deliver Msg after Ms ms |
| repeating timer | beamtea:every(Ms, Msg) | deliver Msg every Ms ms |
| async task | beamtea:task(fun () -> Msg end) | run in a process, deliver the result |
Keys
beamtea_key decodes raw bytes into ergonomic key events:
- Named atoms:
up,down,left,right,enter,esc,tab,back_tab,backspace,delete,insert,home,'end',page_up,page_down {ctrl, Letter}โ e.g.Ctrl-Cis{ctrl, $c}{char, CodePoint}โ a printable character, including space; a full Unicode code point so UTF-8 "just works" (build text with<<Text/binary, CodePoint/utf8>>)
Options
beamtea:start(Program, Flags, Opts):
alt_screen => boolean()(defaulttrue) โ use the alternate screen buffercatch_ctrl_c => boolean()(defaulttrue) โ quit onCtrl-Cinstead of passing it toupdate/2layout => top_left | center | {place, Opts}(defaulttop_left) โ position the whole view within the terminal (see Filling the terminal below)
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}).
%% or: #{layout => {place, #{halign => center, valign => bottom}}}
(All the examples use #{layout => center} โ that's why they fill the screen.)
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.
| Module | Component | Highlights |
|---|---|---|
beamtea_spinner | Spinner | 8 styles (dot, line, moon, points, โฆ), self-animating |
beamtea_textinput | Text input | UTF-8, caret, char limit, placeholder |
beamtea_textarea | Text area | multi-line editing, cross-line backspace, scrolling |
beamtea_progress | Progress bar | electric gradient fill, percentage |
beamtea_list | Selectable list | arrow/j/k nav, scrolling window, title |
beamtea_table | Data table | columns, row selection, scrolling, truncation |
beamtea_viewport | Scrollable viewport | page/line/home/end scrolling, scroll % |
beamtea_paginator | Paginator | dots or N/M, slice helper for the current page |
beamtea_keybind | Key binding | match keys by intent, carry help text |
beamtea_help | Help view | short (one line) and full (columns) help |
beamtea_timer | Countdown timer | self-ticking, timed_out/1 |
beamtea_stopwatch | Stopwatch | self-ticking count-up |
beamtea_filepicker | File picker | browse dirs, select files |
beamtea_cursor | Blinking cursor | blink / 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>
| Name | Shows off |
|---|---|
counter | the core loop โ keys, model, view |
keys | key inspector โ arrows, Ctrl-combos, UTF-8, emoji |
stopwatch | commands & timers via beamtea:every/2 |
spinner_demo | beamtea_spinner โ cycle every style |
textinput_demo | beamtea_textinput โ a live greeting |
textarea_demo | beamtea_textarea โ a bordered multi-line editor |
progress_demo | beamtea_progress โ a self-filling gradient bar |
list_demo | beamtea_list โ a drink chooser |
table_demo | beamtea_table โ a sortable-looking framework table |
viewport_demo | beamtea_viewport โ scroll a long document |
timer_demo | beamtea_timer โ a 10-second countdown |
filepicker_demo | beamtea_filepicker โ browse the filesystem |
help_demo | beamtea_keybind + beamtea_help + beamtea_paginator |
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.
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_util.erl shared helpers (time formatting, padding, ANSI width)
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:
- runs with
+Bito disable the break handler, and - disables
ISIGon the terminal (viastty -isigin the launcher) soCtrl-Carrives as a plain0x03byte.
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.