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 (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"].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.
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 eunitImportant: 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=counterUnder 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.
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 screenupdate/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.
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 |
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>>)
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 | fill | {frame, Opts} | {place, Opts}(defaulttop_left) — position the whole view within the terminal (see Filling the terminal below)
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}).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..."].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 |
dashboard_demo |
beamtea_style — a grid of stat cards composed with joins |
tabs_demo |
beamtea_style — a tabbed interface |
statusbar_demo |
beamtea_style — a full-width bottom status bar |
modal_demo |
a floating yes/no modal overlaid with beamtea_layout:overlay_center/3 |
finder_demo |
advanced — 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.
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.
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.
rebar3 eunit # 103 unit tests (pure logic: key parsing, rendering, commands, every bubble)
rebar3 xref # cross-reference checksThe 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.
Every module is annotated with @doc/-spec. Generate browsable HTML API docs into ./doc:
rebar3 edoc # or: make docs
open doc/index.htmlsrc/
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
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.
MIT © 2026 Tsiry Sandratraina. See LICENSE.
