Tela
A zero-dependency Elixir library for building interactive terminal UIs using the Elm Architecture.
args
│
▼
init/1
│
▼
┌─────model─────┐
│ │
▼ │
view/1 handle_event/2 ◄── key events from stdin
│ handle_info/2 ◄── cmd results, timer ticks, external messages
│ │
▼ ▼
Frame.t() {new_model, cmd}
│
▼
rendered to stdout (diff only)
Inspired by Bubble Tea. Designed to feel familiar
to Elixir developers through a callback interface modelled on GenServer and Phoenix.LiveView.
Features
- Zero runtime dependencies — built entirely on OTP 28 stdlib
- Plain library — no
Applicationcallback, no hidden processes; you own supervision - Pure callbacks —
init/1,handle_event/2,handle_info/2, andview/1are pure functions; test your UI logic without starting a terminal - Diff rendering — only changed lines are written to stdout
- Composable styles — ANSI colours, bold, italic, borders via
Tela.Style - Built-in components —
Tela.Component.SpinnerandTela.Component.TextInput
Requirements
-
Elixir
~> 1.19 -
OTP 28 (uses
shell.start_interactive/1for raw terminal mode, introduced in OTP 28)
Installation
def deps do
[
{:tela, "~> 0.1"}
]
endQuick start
defmodule Counter do
use Tela
@impl Tela
def init(_args), do: {0, nil}
@impl Tela
def handle_event(count, %Tela.Key{key: {:char, "k"}}), do: {count + 1, nil}
def handle_event(count, %Tela.Key{key: {:char, "j"}}), do: {count - 1, nil}
def handle_event(count, %Tela.Key{key: {:char, "q"}}), do: {count, :quit}
def handle_event(count, _key), do: {count, nil}
@impl Tela
def handle_info(count, _msg), do: {count, nil}
@impl Tela
def view(count) do
Tela.Frame.new("Count: #{count}\n\nk = increment j = decrement q = quit")
end
end
{:ok, final_count} = Tela.run(Counter, [])
IO.puts("Final count: #{final_count}")Run it:
mix run -e "Tela.run(Counter, [])"Callbacks
init/1
@callback init(args :: term()) :: {model :: term(), Tela.cmd()}
Called once at startup. Returns {initial_model, cmd}. Use {:task, fun} to kick off background
work, or nil for no side effect.
handle_event/2
@callback handle_event(model :: term(), key :: Tela.Key.t()) :: {term(), Tela.cmd()}Called for every keystroke from stdin. Must be pure — no side effects.
handle_info/2
@callback handle_info(model :: term(), msg :: term()) :: {term(), Tela.cmd()}
Called for cmd results, timer ticks, and any message sent to the runtime process via
Process.send/2. Must be pure.
view/1
@callback view(model :: term()) :: Tela.Frame.t()
Called after every update. Returns a Tela.Frame.t(). The runtime diffs the content against the
previous frame and writes only changed lines.
Commands
@type cmd :: nil | :quit | {:task, (() -> term())}nil— no side effect:quit— stop the runtime and restore the terminal{:task, fun}— runfunin a separate process; its return value is delivered tohandle_info/2
Keys
Every keystroke arrives as a %Tela.Key{key: key, raw: binary()}. Pattern match on key:
# Printable characters
%Tela.Key{key: {:char, "a"}}
# Control keys
%Tela.Key{key: {:ctrl, "c"}}
%Tela.Key{key: {:alt, "f"}}
# Named keys
%Tela.Key{key: :enter}
%Tela.Key{key: :backspace}
%Tela.Key{key: :up}
%Tela.Key{key: :down}
%Tela.Key{key: :left}
%Tela.Key{key: :right}
%Tela.Key{key: :escape}
%Tela.Key{key: :tab}
%Tela.Key{key: :shift_tab}
%Tela.Key{key: :home}
%Tela.Key{key: :end}
%Tela.Key{key: :page_up}
%Tela.Key{key: :page_down}
%Tela.Key{key: {:f, 1}} # F1–F12
# Unknown byte sequences
%Tela.Key{key: :unknown}Note: Always match
{:char, "q"}, never a bare"q". The latter will never match.
Frames and layout
Tela.Frame.new/1 wraps a string (lines separated by \n) into a frame. Frames compose
vertically with Frame.join/2:
alias Tela.Frame
header = Frame.new("My App\n")
body = Frame.new("Content here")
footer = Frame.new("\n\nq to quit")
frame = Frame.join([header, body, footer], separator: "")Frame.join/2 adjusts cursor row offsets automatically, so components that expose a cursor
position remain correct when embedded in a larger layout.
Real terminal cursor
Pass a cursor: option to Frame.new/2 to position the real terminal cursor:
Frame.new("Hello", cursor: {0, 3, :block})
# row col shape
Shapes: :block, :bar, :underline. Use nil (the default) to hide the cursor.
Styles
Tela.Style produces composable ANSI style structs. All functions are pure.
alias Tela.Style
style =
Style.new()
|> Style.bold()
|> Style.foreground(:cyan)
|> Style.background(:black)
|> Style.border(:rounded)
|> Style.padding(1, 2)
Style.render(style, "Hello, world!")Text attributes:bold/1, dim/1, italic/1, underline/1, strikethrough/1, reverse/1
Colours::black, :red, :green, :yellow, :blue, :magenta, :cyan, :white,
bright_ variants (e.g. :bright_cyan), and :default
Borders::single, :double, :rounded, :thick
Padding:padding(style, all), padding(style, vertical, horizontal),
padding(style, top, right, bottom, left)
Use Style.width/1 to measure the visible width of a styled string (strips ANSI escapes).
Components
Spinner
alias Tela.Component.Spinner
defmodule Loading do
use Tela
@impl Tela
def init(_) do
spinner = Spinner.init(spinner: :dot)
{%{spinner: spinner, done: false}, Spinner.tick_cmd(spinner)}
end
@impl Tela
def handle_event(model, %Tela.Key{key: {:char, "q"}}), do: {model, :quit}
def handle_event(model, _key), do: {model, nil}
@impl Tela
def handle_info(model, msg) do
{spinner, cmd} = Spinner.handle_tick(model.spinner, msg)
{%{model | spinner: spinner}, cmd}
end
@impl Tela
def view(model) do
Tela.Frame.new(Spinner.view(model.spinner).content <> " Loading... q to quit")
end
endPresets::line, :dot, :mini_dot, :jump, :pulse, :points, :globe, :moon,
:monkey, :meter, :hamburger, :ellipsis
Custom spinner: pass spinner: {frames_list, interval_ms}.
The parent owns the tick loop. Call Spinner.tick_cmd/1 from init/1 and re-arm from
handle_info/2 by passing the result of Spinner.handle_tick/2 as your cmd. Stale ticks
(arriving after a spinner is replaced) are silently dropped.
TextInput
alias Tela.Component.TextInput
alias Tela.Frame
defmodule Search do
use Tela
@impl Tela
def init(_) do
input = TextInput.init(placeholder: "Search...", char_limit: 100) |> TextInput.focus()
{%{input: input}, TextInput.blink_cmd(input)}
end
@impl Tela
def handle_event(model, %Tela.Key{key: :escape}), do: {model, :quit}
def handle_event(model, key) do
{input, cmd} = TextInput.handle_event(model.input, key)
{%{model | input: input}, cmd}
end
@impl Tela
def handle_info(model, msg) do
{input, cmd} = TextInput.handle_blink(model.input, msg)
{%{model | input: input}, cmd}
end
@impl Tela
def view(model) do
Frame.join(
[Frame.new("Query:\n\n"), TextInput.view(model.input), Frame.new("\n\nesc to quit")],
separator: ""
)
end
end
{:ok, model} = Tela.run(Search, [])
IO.puts("Searched for: #{TextInput.value(model.input)}")Key bindings:
| Key | Action |
|---|---|
{:char, c} | Insert character |
:backspace / {:ctrl, "h"} | Delete before cursor |
:delete / {:ctrl, "d"} | Delete at cursor |
{:ctrl, "k"} | Delete to end of line |
{:ctrl, "u"} | Delete to start of line |
{:ctrl, "w"} | Delete word backward |
{:alt, "d"} | Delete word forward |
:left / {:ctrl, "b"} | Move left one character |
:right / {:ctrl, "f"} | Move right one character |
{:alt, "b"} | Move left one word |
{:alt, "f"} | Move right one word |
:home / {:ctrl, "a"} | Jump to start |
:end / {:ctrl, "e"} | Jump to end |
Options:placeholder, char_limit, echo_mode (:normal, :password, :none),
echo_char, focused_style, blurred_style
Cursor modes::blink (default), :static, :hidden — change with set_cursor_mode/2.
TextInput uses a virtual cursor (reverse-video character embedded in content); the real terminal
cursor stays hidden.
Timers and background work
Any {:task, fun} cmd spawns fun in a separate process. The return value is sent back to
the runtime and delivered to handle_info/2. This is how timers work:
# A tick that fires every 16ms
def tick_cmd, do: {:task, fn -> Process.sleep(16); :tick end}
def init(_), do: {initial_model(), tick_cmd()}
def handle_info(model, :tick) do
{update(model), tick_cmd()} # re-arm
end
def handle_info(model, _msg), do: {model, nil}External processes
Capture self() before calling Tela.run/2; that pid is the runtime process. External
processes can send messages to it directly:
runtime_pid = self()
Task.start(fn ->
Stream.interval(1000)
|> Enum.each(fn i -> send(runtime_pid, {:tick, i}) end)
end)
Tela.run(MyApp, [])
Messages arrive in handle_info/2.
Reading results
Tela.run/2 blocks until the program quits and returns {:ok, final_model}:
{:ok, model} = Tela.run(Picker, items: ["one", "two", "three"])
IO.puts("You chose: #{model.selected}")Testing
Because all callbacks are pure functions, test them directly — no terminal or runtime needed:
defmodule CounterTest do
use ExUnit.Case
test "increment" do
{model, cmd} = Counter.init([])
{model, _cmd} = Counter.handle_event(model, %Tela.Key{key: {:char, "k"}, raw: "k"})
assert model == 1
assert cmd == nil
end
test "quit" do
{model, cmd} = Counter.handle_event(0, %Tela.Key{key: {:char, "q"}, raw: "q"})
assert cmd == :quit
end
endExamples
The examples/ directory contains runnable scripts:
| File | Demonstrates |
|---|---|
result.ex |
Reading {:ok, model} after quit |
spinners.ex | All 12 spinner presets, runtime swapping |
realtime.ex | External process sending events to the runtime |
stopwatch.ex | Start/stop tick loop, millisecond timer |
timer.ex |
Automatic :quit from handle_info/2 |
debounce.ex | Debounce pattern using stale-task guards |
text_input.ex |
Single TextInput field with placeholder and blink |
text_inputs.ex | Multi-field form with tab navigation and password masking |
burrito/ | Self-contained binary via Burrito |
Run any example with:
mix run examples/stopwatch.exLicense
MIT