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

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

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