All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[0.1.0] - 2026-06-01
Added
First release.
phoenix_ex_ratatuiruns anExRatatui.Appinside a Phoenix LiveView, painting the rendered cell buffer directly into the DOM as<span>cells over the LiveView socket. No xterm.js, no ANSI on the wire — just structured cell deltas.Reducer runtime support. Both macros accept
runtime: :reducerto generate a reducer-style proxy (use ExRatatui.App, runtime: :reducer). User-facing callbacks shift fromtui_mount/1+tui_handle_event/2+tui_handle_info/2totui_init/1+tui_update/2(with{:event, _}/{:info, _}wrapped messages) +tui_subscriptions/1. The default remains:callbacks. Concrete demo:examples/demo/lib/demo_web/live/system_monitor_panel.exportsex_ratatui'ssystem_monitor.exsexample, dropping theProcess.send_afterticking pattern in favor of a singleSubscription.interval/3declared intui_subscriptions/1.Auto-focus on full-page TUIs. The LV macro emits
data-phx-ex-ratatui-autofocus="true"on its container; the JS hook reads it on mount and callsfocus({ preventScroll: true }). Users no longer need to click the cell grid to start typing. EmbeddedLiveComponents deliberately don't auto-focus — they're alongside other page content the user already interacts with.Inter-page navigation via runtime intents. A TUI can return
{:noreply, state, intents: [{:navigate, "/path"}]}(or:patch,:redirect, including[external: url]) from any handler. Intents flow throughExRatatui.Server'sintent_writer_fninto the LV, where the macro'shandle_info/2dispatches viaPhoenix.LiveView.push_navigate/2(and siblings). Unrecognised intents are dropped at warning level — TUIs stay forward-compatible. Public helperPhoenixExRatatui.LiveView.dispatch_intent/2handles the four standard shapes; consumers can layer their own dispatch table on top. Intents from{:stop, state, intents: ...}transitions fire before the server exits, so a "logout" key that returns{:stop, state, intents: [{:redirect, "/login"}]}works as expected. For embedded LiveComponents, intents bubble up to the parent LV viasend/2(Phoenix forbids redirects from insideLiveComponent.update/2); the parent must forward{:phoenix_ex_ratatui, :intent, intent}messages todispatch_intent/2. New[:phoenix_ex_ratatui, :intent, :dispatch]telemetry event fires once per intent.Two unified-module integration APIs, both backed by the same
PhoenixExRatatui.Transport. The same module is both the Phoenix LV/LC and theExRatatui.Appdriving it — a hiddenModule.Runtimeproxy generated via@after_compileconforms toExRatatui.Appby delegating totui_*callbacks on your module. This sidesteps thehandle_info/2arity collision between Phoenix LV (msg, socket) andExRatatui.App(msg, state).use PhoenixExRatatui.LiveView— full-page TUI route. Mounted via Phoenix's regularlive/3. Defines six overridable callbacks:tui_mount/1,tui_render/2,tui_handle_event/2,tui_handle_info/2,tui_terminate/2,tui_mount_opts/1. Phoenix LV callbacks (mount/3,render/1,handle_event/3,handle_info/2) are alsodefoverridablefor users who need to threadcurrent_user, custom assigns, or per-route logic.use PhoenixExRatatui.LiveComponent— embeddable variant hosting a TUI inside an existing LiveView alongside other content. Sametui_*callback shape as the LV macro, with diffs routed viaPhoenix.LiveView.send_update/3(since LiveComponents share the parent LV's process and have nohandle_info/2).
PhoenixExRatatui.Transport— connection-level helper implementing theExRatatui.Transportbehaviour.start_link/1constructs anExRatatui.CellSessionat the given dimensions, builds a writer that ships rendered diffs to a target pid (or via a custom writer override for the LiveComponent'ssend_updatepath), and starts anExRatatui.Serverlinked to the caller. Public surface:start_link/1,push_event/2,resize/3,stop/2. Server lifecycle is managed by the link — when the LiveView exits, the linked Server'sterminate/2runs deterministically, closing the CellSession and emitting transport-disconnect telemetry.PhoenixExRatatui.Renderer.Html— wire-encoder converting%CellSession.Diff{}into a JSON-friendly%{"width", "height", "ops"}payload suitable forPhoenix.LiveView.push_event/3. Each op is a 7-element array[row, col, sym, fg, bg, mods, skip]— arrays not objects to halve the wire size on full payloads (a 200×60 diff goes from ~1MB to ~360KB before gzip). Color/modifier encoding documented in the module: named atoms become strings,{:rgb, r, g, b}becomes["rgb", r, g, b],{:indexed, n}becomes["indexed", n]. The headline guarantee isJason.encode!/decode!round-trip — pinned by a property test.PhoenixExRatatui.Telemetry—:telemetryintegration mirroring the shape ofExRatatui.Telemetryone layer up. Events fire at the boundaries this package controls (mount + Transport boot, frame push, input forward, Transport teardown) without overlapping the:runtime/:session/:transporteventsex_ratatuialready emits. Two spans ([:phoenix_ex_ratatui, :transport, :connect]with:mod/:width/:height/:target;[:phoenix_ex_ratatui, :render, :frame]with:mod/:width/:height/:ops_count), two single events ([:phoenix_ex_ratatui, :transport, :disconnect]with:mod/:reason;[:phoenix_ex_ratatui, :input, :forward]with:mod/:event). Public helpers:span/3,execute/3,attach_default_logger/1,detach_default_logger/0. Documented in the moduledoc with aTelemetry.Metricsexample for LiveDashboard wiring.JS hook bundle at
lib/assets/phoenix_ex_ratatui/main.js— pure ES2020, no third-party deps (4.1KB minified vs. kino's ~250KB xterm.js bundle). Cell-grid painter that measures char box on mount, pushes initialphx_ex_ratatui:resize, listens forphx_ex_ratatui:renderevents to paint cells by directcells[row][col]lookup, forwardskeydownasphx_ex_ratatui:inputwith browser-key → ExRatatui-code mapping (ArrowUp→"up", F-keys preserved, modifiers tracked), and re-pushes:resizeon container resize viaResizeObserver. 16-color Tango palette + computed 256-color cube + RGB passthrough. Auto-applied container defaults (monospace font,white-space: pre,line-height: 1,tabIndex: 0) only fire if the user hasn't already supplied a value. Bundled output committed so installing from hex needs no Node toolchain; CI's "Verify JS bundle is in sync" step runsnpm ci && npm run buildand assertsgit diff --exit-code lib/assets/.Server-side dependency on
ex_ratatui's:cell_sessiontransport tag — the upstream half of the integration ships inex_ratatui 0.10, viaExRatatui.Server's{:cell_session, %CellSession{}, cell_writer_fn}shape. This package depends on it through{:ex_ratatui, "~> 0.10"}.Test coverage at 100% across every covered module (Transport, Renderer.Html, LiveView, LiveComponent, Telemetry, the main module). Test fixtures and
Phoenix.LiveComponent-based modules (which have a known:coverline-1 quirk caused by macro-injected helpers) are excluded from the threshold check via documentedmix.exsignore_modules entries. 94 tests + 7 doctests + 5 properties + LiveViewTest integration coverage withlive_isolated/3+render_hook/3+assert_push_event/3against a minimumPhoenix.Endpointtest fixture.Three-job CI matrix mirroring
kino_ex_ratatui's shape: Elixir 1.17/Erlang 26.2.5.16, Elixir 1.18/Erlang 27.3.4.6, Elixir 1.19/Erlang 28.2 (lint job). Lint job runsmix format --check-formatted,mix deps.unlock --check-unused,mix credo --strict,mix compile --warnings-as-errors,mix xref graph --format cycles --fail-above 0,mix dialyzer --format github, JS bundle sync verification, andmix test --cover. Non-lint jobs runmix test.Getting Started guide walking through both unified-module APIs, the JS hook wiring, and the typical project structure. Examples directory ships a minimal Phoenix app under
examples/demo/that demonstrates the unified LV and LC side-by-side — useful as a copy-paste starting point for new integrations.