# Debugging

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`:

```elixir
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_commands` — `Command.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:

```elixir
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.
- `:render` — `render/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:

```elixir
ExRatatui.Runtime.enable_trace(pid, limit: 1_000)
```

Turn it off when done — traces cost memory per transition:

```elixir
ExRatatui.Runtime.disable_trace(pid)
```

**From inside a reducer-runtime `update/2`**, tracing can be flipped per-transition via the runtime opts:

```elixir
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:

```elixir
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](testing.md) guide recommends. Then from IEx or a test:

```elixir
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:

```elixir
# 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 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`:

```sh
ssh -t demo@localhost -p 2222
```

Without it, the TUI has nowhere to render. See the [SSH guide](../transports/ssh_transport.md).

### `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:

```sh
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](performance.md).

### 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:

```sh
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/`:

```sh
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](testing.md) — structured assertions with `Runtime.inject_event/2` and the test backend.
- [Performance](performance.md) — `Runtime.enable_trace/2` as a timing tool with `at_ms` timestamps.
- **`ExRatatui.Runtime`** module docs — full shape of every snapshot field.
