PhoenixExRatatui

Hex.pmDocsCILicense

Run ExRatatui apps inside a Phoenix LiveView.

PhoenixExRatatui is the LiveView counterpart to kino_ex_ratatui: 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. Phones get real touch events.

PhoenixExRatatui Demo

Features

Examples

The examples/demo/ Phoenix app showcases the unified LV and LC side-by-side:

ViewRouteDemonstrates
Home/Full-page LiveView, callbacks runtime, navigation intents
Chat/chatMarkdown, Textarea, Throbber, a slash-command popup, and scrollback
Admin/adminAn embedded reducer-runtime LiveComponent with a live Gauge/Table system monitor

Run it with mix deps.get && mix phx.server from inside examples/demo/.

Installation

Add phoenix_ex_ratatui to the deps in mix.exs:

def deps do
[
{:phoenix_ex_ratatui, "~> 0.1"}
]
end

Then fetch:

mix deps.get

Prerequisites

phoenix_ex_ratatui pulls in ex_ratatui (~> 0.10) transitively, which ships a precompiled NIF — no Rust toolchain required.

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.

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 Appgenerated by macro)
└────────┬────────┘ └──────────┬───────────┘
│ │
│ PhoenixExRatatui.Transport │ ExRatatui.Server
▼ ▼
CellSession ──── %CellSession.Diff{} ────▶ Renderer.Html
push_event("phx_ex_ratatui:render", payload)
JS hook paints <span> cells
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.

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:

IntentEffect
{: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

GuideDescription
Getting StartedExtended walkthrough of both the full-page and embedded APIs, the JS hook wiring, and the typical project structure

Module references:

Contributing

PhoenixExRatatui is built on ExRatatui, a general-purpose terminal UI library for Elixir. Contributions to the underlying rendering, widgets, or layout engine are very welcome there too. See CONTRIBUTING.md for the local-dev setup.

License

MIT — see LICENSE for details.