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.1 - 2026-06-03
Fixed
- Key modifiers now decode to strings, not atoms. Input from the JS hook produces
%ExRatatui.Event.Key{modifiers: ["ctrl"]}— the same[String.t()]shape the NIF-backed transports (SSH, terminal, kino) emit — instead of[:ctrl]. A TUI matching%Key{modifiers: ["ctrl"]}now behaves identically across all transports; previously a Ctrl binding written the upstream way silently failed to match under phoenix. - Ship the
guides/directory in the Hex package (mix.exsfiles) so the Getting Started guide renders on hexdocs. - Correct the README examples table: the Home demo runs on the reducer runtime, not callbacks.
- Fix the documented JS bundle size (~5KB, not ~4KB) and the
Transport.start_link/1return-shape doc (it includes:mod).
Changed
- Move the README demo GIF from
assets/to.github/demo.gif, leavingassets/for the JS build pipeline only. - Promote telemetry to a standalone Telemetry guide, extracted from Getting Started and surfaced in the README Guides table — matching
ex_ratatuiandkino_ex_ratatui. - Add an Ecosystem section to the README linking
ex_ratatuiandkino_ex_ratatui. - Add
:telemetrytoextra_applications, matchingex_ratatuiandkino_ex_ratatui. - Drop the
skip_code_autolink_todocs config: link thehandle_info/2/terminate/2callback mentions with thec:prefix and render the hidden upstream ExRatatui.Server as plain text.
Examples
- The
examples/demoapp now pullsex_ratatuifrom Hex transitively through thephoenix_ex_ratatuipath dependency, dropping the siblingex_ratatuipath override and therustlerbuild fallback now that ex_ratatui 0.10 is released.
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 through ExRatatui.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 an ExRatatui.Server linked 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, via ExRatatui.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.