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.
Dropped keystrokes or missing characters
Characters vanish under fast or sustained typing — hello world echoes as helo wld, or 30 key presses register as 25. The cause is two readers competing for the same stdin: the app is running under iex, and the IEx shell's line reader consumes the terminal's stdin continuously while the TUI's poll path (crossterm) reads the same file descriptor. In raw mode each keystroke byte goes to whichever read wins, so under load some bytes are swallowed by the shell and never reach the app. The swallowed keys often surface at the IEx prompt once the TUI exits. It barely shows at one-key-at-a-time speed, which is why simpler demos feel fine.
This is structural to running a terminal app inside an interactive shell, not a bug in poll_event/1 — only the interactive shell competes. A plain elixir script.exs, mix run, a release, an escript, and the SSH / distributed transports all leave stdin to the TUI alone.
Fix: Run input-heavy apps outside iex. elixir my_app.exs for a standalone script, mix run -e "MyApp.run()" inside a project, or a release / transport for anything beyond a quick local run. If a live shell on the same node is needed, start the app with the BEAM off stdin (elixir --erl "-noinput" ... or a release) and attach a remote shell from another terminal with iex --remsh.
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.