This guide walks through wiring a TUI into a Phoenix LiveView from scratch, then explains the two integration APIs and when to reach for each.
Project setup
phoenix_ex_ratatui runs alongside the rest of a normal Phoenix project — no special generator is needed. Add the deps:
# mix.exs
defp deps do
[
# …
{:phoenix, "~> 1.7"},
{:phoenix_live_view, "~> 1.1"},
{:phoenix_ex_ratatui, "~> 0.1"}
]
endWire the JS hook. phoenix_ex_ratatui ships a top-level package.json, so it imports like any other npm module. Add it to the assets/package.json:
{
"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"
}
}Then cd assets && npm install to symlink it, and 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 }
})
liveSocket.connect()That's the only client-side wiring. The hook auto-discovers each TUI's container by phx-hook="PhoenixExRatatuiHook" and handles cell measurement, paint, keypress forwarding, and resize observation itself.
The unified-module pattern
Both APIs (PhoenixExRatatui.LiveView and PhoenixExRatatui.LiveComponent) are unified modules: the same module is both the Phoenix LiveView/LiveComponent AND the ExRatatui.App driving it.
The macro doesn't fight Phoenix's handle_info/2 callback (which takes a socket) and the App's handle_info/2 callback (which takes App state) — they have the same name and arity but different semantics. Instead, the macro auto-generates a hidden Module.Runtime proxy via @after_compile that conforms to ExRatatui.App by delegating to a small set of tui_* callbacks on the host module:
| Callback | Purpose | Default |
|---|---|---|
tui_mount(opts) | Initialise App state | {:ok, %{}} |
tui_render(state, frame) | Produce widgets | [] |
tui_handle_event(event, state) | Handle a key/mouse/resize event | {:noreply, state} |
tui_handle_info(msg, state) | Handle a non-terminal message (PubSub, send) | {:noreply, state} |
tui_terminate(reason, state) | Cleanup on shutdown | :ok |
tui_mount_opts(socket) | Bridge socket assigns into tui_mount/1 | [] |
All are overridable; implement only what's needed. Phoenix's regular LV/LC callbacks (mount/3, render/1, handle_event/3, etc.) remain available and overridable through the same defoverridable mechanism.
Two ways to mount a TUI
Option A — Full-page TUI route (PhoenixExRatatui.LiveView)
When the page IS a TUI, write a unified module and mount it through the router's regular live/3 macro:
defmodule MyAppWeb.CounterLive do
use PhoenixExRatatui.LiveView
alias ExRatatui.Event.Key
alias ExRatatui.Layout.Rect
alias ExRatatui.Widgets.{Block, Paragraph}
def tui_mount(_opts), do: {:ok, %{n: 0}}
def tui_render(state, frame) do
[
{%Paragraph{
text: "Count: #{state.n}\n\n+ increment - decrement q quit",
block: %Block{title: " counter ", borders: [:all]}
},
%Rect{x: 0, y: 0, width: frame.width, height: frame.height}}
]
end
def tui_handle_event(%Key{code: "+"}, s), do: {:noreply, %{s | n: s.n + 1}}
def tui_handle_event(%Key{code: "-"}, s), do: {:noreply, %{s | n: s.n - 1}}
def tui_handle_event(%Key{code: "q"}, s), do: {:stop, s}
def tui_handle_event(_, s), do: {:noreply, s}
endIn the router:
scope "/", MyAppWeb do
pipe_through :browser
live "/counter", CounterLive
endThat's the full integration. The @after_compile hook generates MyAppWeb.CounterLive.Runtime automatically — it's never referenced directly.
Threading socket data into the App
To pass per-connection context (current user, session, URL params) from the LiveView mount into tui_mount/1, override tui_mount_opts/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]}}
endsuper/3 delegates to the macro's default mount/3 (which sets up internal assigns and trap_exit); layer additional assigns on top afterward. tui_mount_opts/1 reads them off the socket and returns the keyword list that becomes opts in tui_mount/1.
Option B — Embedded TUI (PhoenixExRatatui.LiveComponent)
When the page is a regular Phoenix dashboard with a TUI sidebar, dev console, or modal — anything where the TUI lives alongside other content the user already controls — write a unified LiveComponent:
defmodule MyAppWeb.SystemMonitorPanel do
use PhoenixExRatatui.LiveComponent
def tui_mount(_opts), do: {:ok, %{cpu: 0.0, mem: 0.0}}
def tui_render(state, frame) do
# widgets…
end
def tui_handle_event(_event, state), do: {:noreply, state}
endEmbed it inside any LiveView's render:
defmodule MyAppWeb.AdminLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, :recent_orders, fetch_recent_orders())}
end
def render(assigns) do
~H"""
<h1>Admin Dashboard</h1>
<div class="grid grid-cols-2 gap-4">
<div>
<h2>Recent Orders</h2>
<ul>
<li :for={order <- @recent_orders}>{order.id} — {order.total}</li>
</ul>
</div>
<div>
<h2>Live System Monitor</h2>
<.live_component module={MyAppWeb.SystemMonitorPanel} id="admin-tui" />
</div>
</div>
"""
end
endThe TUI's diff stream routes through Phoenix.LiveView.send_update/3 into the component's update/2 (LiveComponents have no handle_info — they share the parent LV's process). Everything else is identical to the full-page path.
Inter-page navigation
A TUI can request navigation to another LV route by returning a list of runtime intents from any handler. The intents flow through ExRatatui.Server's intent writer into the LV, which dispatches them to Phoenix.LiveView.push_navigate/2 (and its siblings):
defmodule MyAppWeb.LoginTui do
use PhoenixExRatatui.LiveView
alias ExRatatui.Event.Key
def tui_mount(_opts), do: {:ok, %{}}
def tui_render(_state, _frame), do: # …
# Press <enter> → push_navigate to /dashboard
def tui_handle_event(%Key{code: "enter"}, state) do
{:noreply, state, intents: [{:navigate, "/dashboard"}]}
end
def tui_handle_event(_, state), do: {:noreply, state}
endRecognised intent shapes:
| Intent | Effect |
|---|---|
{:navigate, "/path"} | Phoenix.LiveView.push_navigate(socket, to: path) |
{:patch, "/path"} | Phoenix.LiveView.push_patch(socket, to: path) |
{:redirect, "/path"} | Phoenix.LiveView.redirect(socket, to: path) |
{:redirect, [external: "https://…"]} | external redirect |
Unrecognised intents are dropped (logged at warning level), so a TUI that returns an intent the host doesn't know how to handle stays alive instead of crashing.
Embedded LiveComponent navigation
Phoenix LV forbids redirects from inside LiveComponent.update/2, so when the embedded TUI emits a navigation intent the LiveComponent sends it to its parent LV process via send/2 and the parent dispatches. Add this clause to the parent LV:
def handle_info({:phoenix_ex_ratatui, :intent, intent}, socket) do
{:noreply, PhoenixExRatatui.LiveView.dispatch_intent(socket, intent)}
endIf the parent is itself a PhoenixExRatatui.LiveView, the clause is generated automatically — nothing else is needed.
Stop-then-redirect
Intents from {:stop, state, intents: ...} transitions fire before the runtime server exits, so a TUI can return {:stop, state, intents: [{:redirect, "/login"}]} from a "logout" key and trust the redirect reaches the LV before the server's EXIT signal propagates.
Decision matrix
| Use | When |
|---|---|
use PhoenixExRatatui.LiveView | The whole page IS the TUI |
use PhoenixExRatatui.LiveComponent | The page contains the TUI alongside other content (admin panels, dashboards, modals, dev tooling) |
Telemetry
Both integrations emit the same :telemetry events, one layer above the events ex_ratatui already emits. Attach the default logger in dev with PhoenixExRatatui.Telemetry.attach_default_logger(level: :info), or wire Telemetry.Metrics for production dashboards. The Telemetry guide covers the full event tree, a Telemetry.Metrics example, and how the two event layers pair up.
What about ANSI / xterm.js / a real terminal in a browser?
That's kino_ex_ratatui — same parent library, but it's built around xterm.js and is the right pick for a real terminal emulator in the page.
phoenix_ex_ratatui is deliberately different: cells are pushed directly to the DOM as styled <span>s. The advantages are that the bundle is tiny (~5KB minified, no third-party deps), phones get real touch events, and the cell grid is just HTML — themeable with CSS, accessible to screen readers, copy/pasteable. The trade-off is no scrollback, no shell semantics, no ANSI alt-screen — if a TUI was relying on those, kino_ex_ratatui (or running the App over SSH) is the right call.