Terminal UIs are harder to debug than web apps — no devtools, no browser console, and a single IO.inspect in render/2 will garble the output. This guide covers the tools ExRatatui provides instead.
Three layers, from least invasive to most:
- Runtime snapshot — one call returns everything the runtime knows about the app.
- Runtime trace — opt-in in-memory log of every message, render, command, and subscription event.
- Headless buffer inspection — render a frame to the test backend and dump the string.
Runtime snapshot
ExRatatui.Runtime.snapshot/1 is the quickest way to see what's going on. It works on any running ExRatatui.App:
iex> {:ok, pid} = MyApp.TUI.start_link(name: nil)
iex> ExRatatui.Runtime.snapshot(pid)
%{mode: :callbacks, mod: MyApp.TUI, transport: :local, render_count: 17, ...}ExRatatui.Runtime documents every field. The ones that answer most questions:
:render_count— did render actually run? If this stays flat, the transition returnedrender?: falseor the event isn't reachinghandle_event/2.:dimensions— the size the runtime thinks it has. Off if something grabbed the terminal before mount.:subscriptions— reducer-runtime only; shows which timers are active and whether they've fired at least once.:active_async_commands—Command.async/2calls currently running.:polling_enabled?—falseundertest_mode,truein real terminals. If it's unexpectedlyfalse,test_modewas probably passed accidentally.
Runtime trace
For questions like "why did state transition here?" or "what commands did that event produce?", turn on tracing:
iex> :ok = ExRatatui.Runtime.enable_trace(pid)
iex> # ... interact with the app ...
iex> ExRatatui.Runtime.trace_events(pid)
[
%{kind: :message, at_ms: 123456, details: %{source: :event, payload: %ExRatatui.Event.Key{code: "up", ...}}},
%{kind: :render, at_ms: 123457, details: %{frame: %ExRatatui.Frame{width: 120, height: 40}, widget_count: 4}},
%{kind: :command, at_ms: 123458, details: %{kind: :message, message: :refresh}},
%{kind: :subscription, at_ms: 123500, details: %{action: :fire, id: :tick, kind: :interval}},
...
]Each event is a map with :kind, :at_ms (monotonic ms), and :details. Kinds:
:message— a message arrived.source: :eventfor terminal input,source: :infofor mailbox messages.:render—render/2ran. Gives the frame and the widget count it returned.:command— aCommandwas queued. Kind is:message,:after, or:async.:subscription— subscription lifecycle (:start,:cancel,:fire).
The buffer defaults to 200 events, oldest dropped first. Bump it for long sessions:
ExRatatui.Runtime.enable_trace(pid, limit: 1_000)Turn it off when done — traces cost memory per transition:
ExRatatui.Runtime.disable_trace(pid)From inside a reducer-runtime update/2, tracing can be flipped per-transition via the runtime opts:
def update({:event, %Event.Key{code: "?", modifiers: ["ctrl"]}}, state) do
{:noreply, state, trace?: true}
endUseful to capture a specific interaction without leaving tracing on forever.
Reading a trace
A typical "button press → state change → re-render" sequence looks like:
:message source: :event payload: %Event.Key{code: "up"}
:command kind: :message message: :boot # whatever the update returned
:render widget_count: 4If :message appears but no :render, either:
- The transition returned
render?: false render/2raised (check logs and the server's exit status)
If multiple :render appear for one event, something in handle_info/2 triggered another transition — common with subscriptions firing during the same scheduler slot.
Buffer inspection as a dev tool
When "is my widget actually there?" can't be eyeballed, render to a headless test terminal and print the buffer:
terminal = ExRatatui.init_test_terminal(80, 24)
:ok = ExRatatui.draw(terminal, my_widget_tree)
IO.puts(ExRatatui.get_buffer_content(terminal))This works anywhere — dev console, IEx, inside a test, inside terminate/2. It strips styling and gives the pure character grid. Great for layout bugs where borders don't line up or text gets clipped.
To capture a supervised app's scene mid-run, factor render/2 so the scene-building is pure — the same scene/2 split the Testing guide recommends. Then from IEx or a test:
state = :sys.get_state(pid).user_state
frame = %ExRatatui.Frame{width: 80, height: 24}
scene = MyApp.TUI.scene(state, frame)
terminal = ExRatatui.init_test_terminal(80, 24)
:ok = ExRatatui.draw(terminal, scene)
IO.puts(ExRatatui.get_buffer_content(terminal))The result is a snapshot of what the user's seeing without touching their terminal.
dbg/1 inside callbacks
dbg/1 is tempting in render/2 but will destroy the display — anything written to stdout while raw mode is active garbles the output. Two options:
Log instead of printing. Logger.debug/1 goes to configured log backends, not the terminal. In dev, route it to a file:
# config/dev.exs
config :logger, :default_handler, config: %{file: ~c"log/dev.log"}Use dbg in handle_event/2 only when the app won't render afterwards. If the event ends with {:stop, state}, stdout output is safe because the terminal gets restored during shutdown.
For interactive debugging, prefer Runtime.snapshot/1 or the trace — both are non-invasive.
Common errors
{:terminal_init_failed, reason} on startup
The server tried to initialize a real terminal but the process has no TTY. Happens when:
- Running
mix runwith stdin redirected or piped - Starting a TUI from an IDE's non-interactive test runner
- Backgrounding a process that later tries to render
Fix: For tests, pass test_mode: {width, height}. For dev, run from a real terminal emulator (Ghostty, iTerm2, Alacritty, Windows Terminal). For production use over SSH, don't use :local — use transport: :ssh so the daemon handles PTY allocation per client.
Terminal looks garbled, colors wrong
The terminal emulator isn't reporting 256-color or true-color support. Most modern emulators are fine. Under tmux or screen, set TERM=xterm-256color. Some SSH clients strip the outer TERM — if colors are right locally but wrong over SSH, check the remote echo $TERM.
SSH client hangs, shows nothing
Most SSH clients don't allocate a PTY by default. Connect with -t:
ssh -t demo@localhost -p 2222
Without it, the TUI has nowhere to render. See the SSH guide.
mix run examples/foo.exs exits immediately
The script finished because stdin wasn't a TTY and poll_event/1 returned without input. Run from a real terminal. For daemon-mode examples (SSH, distributed), use --no-halt so the VM stays up after the script returns:
mix run --no-halt examples/apps/system_monitor.exs --ssh
Render works once, then freezes
Usually a long-running computation inside render/2. Terminal events keep queuing, but the render loop is blocked. Move the heavy work to handle_event/2 / update/2 (fine — runs between renders) or an async command (Command.async/2 in reducer runtime, Task.Supervisor.async_nolink/2 in callback). See Performance.
Runtime stops on its own with {:stop, reason}
Check the logs — an exception in any callback crashes the server. The generated child_spec uses restart: :transient, so the supervisor restarts the app after an abnormal exit but leaves it down after a clean {:stop, state}. In tests, start_supervised! propagates the crash into the test.
Force-killed TUI left the shell broken
If a TUI crashes without running terminate/2 (SIGKILL, a kernel OOM, a disconnected SSH session), the terminal can be left in raw mode — characters don't echo, the cursor vanishes, or output wraps oddly. Restore it from the dazed shell:
reset # full terminal reset — safest
stty sane # lighter: restores line discipline without clearing
Both are safe to type blind. Under supervised runs this is rare because terminate/2 restores the terminal on any :normal, :shutdown, or exception exit — but it can't fire if the BEAM itself is killed.
Rust NIF rebuilds
When editing the native code under native/ex_ratatui/:
rm -rf _build
EX_RATATUI_BUILD=1 mix compile
EX_RATATUI_BUILD=1 mix test
The rm -rf _build is important — stale BEAM artifacts reference the old NIF image and the Rust changes won't take effect. Prepend EX_RATATUI_BUILD=1 to every mix command until the Rust edits are done, otherwise mix falls back to precompiled binaries and silently ignores them.
Symptoms of a stale NIF:
- Adding a new NIF function and getting
UndefinedFunctionError - Changing a Rust signature and seeing the old behavior
- Compile succeeds but tests use old binary
Related
- Testing — structured assertions with
Runtime.inject_event/2and the test backend. - Performance —
Runtime.enable_trace/2as a timing tool withat_mstimestamps. ExRatatui.Runtimemodule docs — full shape of every snapshot field.