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:

  1. Runtime snapshot — one call returns everything the runtime knows about the app.
  2. Runtime trace — opt-in in-memory log of every message, render, command, and subscription event.
  3. 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 returned render?: false or the event isn't reaching handle_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_commandsCommand.async/2 calls currently running.
  • :polling_enabled?false under test_mode, true in real terminals. If it's unexpectedly false, test_mode was 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: :event for terminal input, source: :info for mailbox messages.
  • :renderrender/2 ran. Gives the frame and the widget count it returned.
  • :command — a Command was 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}
end

Useful 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: 4

If :message appears but no :render, either:

  • The transition returned render?: false
  • render/2 raised (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 run with 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 terminal: on a :local transport the TUI's poll path (crossterm, in the NIF) reads the controlling terminal directly, while the BEAM keeps its own reader on the same device — the OTP 26+ prim_tty reader process (:user_drv_reader) that backs iex, elixir script.exs, mix run, and shell-attached releases alike. In raw mode each keystroke byte goes to whichever read() wins, so under load some bytes are swallowed before they ever reach the app. The swallowed keys often surface at the shell prompt once the TUI exits. It barely shows at one-key-at-a-time speed, which is why simpler demos feel fine. The race is more likely to drop bytes on macOS than on Linux, so an app can look clean on one machine and lossy on another.

ExRatatui handles this automatically. On the :local transport — ExRatatui.run/2 and the local ExRatatui.App server — it parks the BEAM's terminal reader for the duration of the session, using prim_tty's own disable/enable handoff (the same one the shell performs on Ctrl-Z), so crossterm owns input exclusively. The reader resumes on teardown and the shell returns intact — termios stays raw the whole time, so nothing is left in cooked mode. No configuration is needed; iex, scripts, mix run, and releases all run cleanly.

The handoff is a no-op when there is no competing reader to park — older OTP without prim_tty, a release booted with -noinput, or piped / non-TTY stdin — and the session, SSH, and distributed transports never share a local terminal, so they are unaffected either way. To opt out of the handoff entirely:

config :ex_ratatui, detach_local_input: false

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
  • Testing — structured assertions with Runtime.inject_event/2 and the test backend.
  • PerformanceRuntime.enable_trace/2 as a timing tool with at_ms timestamps.
  • ExRatatui.Runtime module docs — full shape of every snapshot field.