PhoenixExRatatui

Hex.pm Docs CI License

Run ExRatatui apps inside a Phoenix LiveView.

PhoenixExRatatui Demo

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

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

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

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, …). Plain handle_event/3 / handle_info/2 are your page's own LiveView callbacks — define them freely for phx-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.