PhoenixExRatatui
Run ExRatatui apps inside a Phoenix LiveView.
PhoenixExRatatui is a thin transport that pipes the runtime's rendered cell buffer to the browser, where a small JS hook paints cells directly into the DOM as <span> elements. No terminal emulator, no ANSI on the wire — just structured cell deltas over the LiveView socket.
Features
- Two unified-module APIs —
use PhoenixExRatatui.LiveViewfor a full-page TUI route,use PhoenixExRatatui.LiveComponentto embed a TUI inside an existing LiveView. The same module is both the Phoenix component and theExRatatui.Appdriving it; a hiddenModule.Runtimeproxy bridges the twohandle_info/2arities. - Callback and reducer runtimes —
runtime: :reduceropts into command/subscription-driven apps (tui_init/1+tui_update/2+tui_subscriptions/1); the default:callbacksruntime usestui_mount/1+tui_handle_event/2+tui_handle_info/2. - Cell-diff rendering over the socket — the rendered cell buffer ships as a structured
%{width, height, ops}payload of<span>-cell deltas, plus aregionslist whenever the pixel regions change. Arrays not objects, to roughly halve the wire size on full frames. - Pixel regions for images and 3D — the hook reports the measured cell size, so the
CellSessionis a pixel surface:ExRatatui.Widgets.Viewport3DandExRatatui.Widgets.Imagein pixel modes arrive as PNG regions painted as<img>overlays at the pane's real resolution (HiDPI aware), instead of half-block cells. Unchanged regions are never re-sent. - Tiny, dependency-free JS hook — ~6KB minified (vs. xterm.js's ~250KB). Measures the cell box, paints diffs by direct
cells[row][col]lookup, forwardskeydownas input events, and re-reports size viaResizeObserver. - Inter-page navigation via runtime intents — return
{:navigate, "/path"},:patch, or:redirect(internal or external) from any handler; the macro dispatches throughpush_navigate/2and friends. - Auto-focus on full-page TUIs — keystrokes flow without clicking the grid first. Embedded components deliberately don't steal focus.
:telemetryintegration — transport connect/disconnect spans, a per-frame render span, and input-forward events, layered above the eventsex_ratatuialready emits.- Full color and modifiers — named, RGB, and 256-color indexed; bold, italic, underline, and more, inherited straight from ExRatatui.
Examples
The examples/demo/ Phoenix app showcases the unified LV and LC side-by-side:
| View | Route | Demonstrates |
|---|---|---|
| Home | / |
Full-page LiveView, reducer runtime, navigation intents |
| Chat | /chat |
Full-page LiveView, callbacks runtime, Markdown/Textarea/Throbber/slash-command popup/scrollback |
| Admin | /admin |
An embedded reducer-runtime LiveComponent with a live Gauge/Table system monitor |
| Coexistence | /coexistence |
A full-page TUI LiveView that also defines its own handle_event/3 and handle_info/2 next to the tui_* callbacks |
| Cube | /cube |
Pixel regions: a spinning Viewport3D next to a random picsum.photos Image, both painted as <img> overlays; m toggles to cells, n fetches another photo |
Run it with mix deps.get && mix phx.server from inside examples/demo/.
Ecosystem
- ex_ratatui — The core terminal UI library this builds on.
- kino_ex_ratatui — Run TUIs inside Livebook notebooks.
- raster_ex_ratatui — Run TUIs on pixel displays such as e-ink panels, with helpers for Linux framebuffers.
Installation
Add phoenix_ex_ratatui to the deps in mix.exs:
def deps do
[
{:phoenix_ex_ratatui, "~> 0.3"}
]
end
Then fetch:
mix deps.get
Prerequisites
- Elixir 1.17+
- Phoenix LiveView 1.1+
- ExRatatui 0.14+ (pixel regions)
Wiring the JS hook
The hook is resolved as a normal npm module. Add it to assets/package.json alongside Phoenix's own JS deps:
{
"dependencies": {
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"phoenix_ex_ratatui": "file:../deps/phoenix_ex_ratatui"
}
}
Run npm install (or cd assets && npm install), then import the hook in assets/js/app.js:
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
import { PhoenixExRatatuiHook } from "phoenix_ex_ratatui"
const liveSocket = new LiveSocket("/live", Socket, {
hooks: { PhoenixExRatatuiHook }
})
The hook sets sensible defaults on the container (monospace font, white-space: pre, line-height: 1) only when they aren't already supplied, so the grid stays themeable with CSS.
Quick Start
Both shapes are unified modules — the same module is both a Phoenix LiveView/LiveComponent and the ExRatatui.App driving it. The macro auto-generates a hidden Module.Runtime proxy that conforms to ExRatatui.App by delegating to the tui_* callbacks.
The TUI runs on the
tui_-prefixed callbacks (tui_handle_event/2,tui_render/2, …). Plainhandle_event/3/handle_info/2are your page's own LiveView callbacks — define them freely forphx-clicks, PubSub, and timers; they coexist with the TUI. See Defining your own page callbacks.
Full-page TUI route
defmodule MyAppWeb.MyTuiLive do
use PhoenixExRatatui.LiveView
def tui_mount(_opts), do: {:ok, %{count: 0}}
def tui_render(state, frame) do
alias ExRatatui.Layout.Rect
alias ExRatatui.Widgets.Paragraph
[{%Paragraph{text: "Count: #{state.count}"},
%Rect{x: 0, y: 0, width: frame.width, height: frame.height}}]
end
def tui_handle_event(%ExRatatui.Event.Key{code: "+"}, state),
do: {:noreply, %{state | count: state.count + 1}}
def tui_handle_event(%ExRatatui.Event.Key{code: "q"}, state),
do: {:stop, state}
def tui_handle_event(_event, state), do: {:noreply, state}
end
# In the router (no special macro):
live "/tui", MyAppWeb.MyTuiLive
Embedded LiveComponent
defmodule MyAppWeb.AdminCounterPanel do
use PhoenixExRatatui.LiveComponent
def tui_mount(_opts), do: {:ok, %{n: 0}}
def tui_render(state, frame), do: # ...
def tui_handle_event(_event, state), do: {:noreply, state}
end
defmodule MyAppWeb.AdminLive do
use Phoenix.LiveView
def render(assigns) do
~H"""
<h1>Admin Dashboard</h1>
<.live_component module={MyAppWeb.AdminCounterPanel} id="admin-tui" />
<p>Other admin content</p>
"""
end
end
How It Works
┌─────────────────┐ tui_* callbacks ┌──────────────────────┐
│ Your module │ ◀────────────────── │ Module.Runtime │ (hidden proxy,
│ (LiveView/LC) │ │ conforms to App │ generated by macro)
└────────┬────────┘ └──────────┬───────────┘
│ │
│ PhoenixExRatatui.Transport │ ExRatatui.Server
▼ ▼
CellSession ──── %CellSession.Diff{} ────▶ Renderer.Html
│
push_event("phx_ex_ratatui:render", payload)
▼
JS hook paints <span> cells
and <img> pixel regions
browser keydown ──── "phx_ex_ratatui:input" ────▶ back into the runtime
A CellSession plus a linked ExRatatui.Server drive the module. On each render the server hands a %CellSession.Diff{} to the transport, which forwards it to the LiveView; PhoenixExRatatui.Renderer.Html encodes it to a JSON-friendly payload and push_event/3s it to the browser. The hook paints the deltas and forwards keystrokes back as phx_ex_ratatui:input events. Because the Server is linked to the LiveView process, teardown is deterministic — when the LiveView exits, the session closes and disconnect telemetry fires.
The hook also measures the cell box and reports it (in device pixels) with its first resize, so the transport opens the CellSession with that font_size:. From then on Viewport3D and Image widgets in pixel modes render to real bitmaps: the payload carries a regions list of [x, y, width, height, png_data_url] entries — the complete set on screen — and the hook paints each as an <img> in a layer over the grid, sized with the same CSS variables as the cells. When a frame's region set is unchanged the key is omitted and nothing is re-encoded or re-sent, so a static picture costs nothing per frame. Widgets in cell modes (:braille, :half_block, :halfblocks) keep painting cells. See ExRatatui.CellSession.Region and the ex_ratatui guide on rendering to non-terminal surfaces for the contract.
Inter-page navigation via runtime intents
A TUI can navigate to another route by emitting a runtime intent from any handler:
def tui_handle_event(%Key{code: "enter"}, state) do
{:noreply, state, intents: [{:navigate, "/dashboard"}]}
end
def tui_handle_event(%Key{code: "q"}, state) do
{:noreply, state, intents: [{:redirect, "/login"}]}
end
Recognised intent shapes:
| Intent | Effect |
|---|---|
{:navigate, "/path"} |
Phoenix.LiveView.push_navigate/2 |
{:patch, "/path"} |
Phoenix.LiveView.push_patch/2 |
{:redirect, "/path"} |
Phoenix.LiveView.redirect/2 (internal) |
{:redirect, [external: "https://…"]} |
redirect/2 to an external URL |
Unrecognised intents are dropped (logged at warning) so a TUI stays portable across consumers — return whatever the runtime understands and the LV ignores the rest.
For the embeddable LiveComponent, intents bubble up to the parent LV via send/2 (Phoenix LV forbids redirects from inside LiveComponent.update/2). Add this clause to the parent LV:
def handle_info({:phoenix_ex_ratatui, :intent, intent}, socket) do
{:noreply, PhoenixExRatatui.LiveView.dispatch_intent(socket, intent)}
end
Threading socket data into the App
LiveView assigns and TUI state live in different processes. The tui_mount_opts/1 callback is the bridge — it receives the LiveView socket and returns the keyword list passed as opts to tui_mount/1:
defmodule MyAppWeb.AdminTui do
use PhoenixExRatatui.LiveView
@impl Phoenix.LiveView
def mount(_params, session, socket) do
{:ok, socket} = super(nil, nil, socket)
{:ok, assign(socket, :user_id, session["user_id"])}
end
def tui_mount_opts(socket), do: [user_id: socket.assigns.user_id]
def tui_mount(opts), do: {:ok, %{user_id: opts[:user_id]}}
end
Guides
| Guide | Description |
|---|---|
| Getting Started | Extended walkthrough of both the full-page and embedded APIs, the JS hook wiring, and the typical project structure |
| Telemetry | :telemetry events for transport, render, input, and intents — logging and Telemetry.Metrics |
Contributing
See CONTRIBUTING.md for development setup and guidelines.
PhoenixExRatatui is built on ExRatatui, a general-purpose terminal UI library for Elixir. Contributions to its underlying rendering, widgets, or layout engine are very welcome too.
License
MIT — see LICENSE.