Debugging Guide

TUI applications are harder to debug than typical Ruby programs. The terminal is in raw mode. Standard output corrupts the display. Debuggers that rely on REPL input conflict with the event loop. Rust panics produce cryptic stack traces without symbols.

This guide covers what RatatuiRuby offers and what works (and what does not) when debugging TUI apps.

Debug Mode

RatatuiRuby ships with debug symbols in release builds. Call RatatuiRuby::Debug.enable! to get Rust backtraces with meaningful stack frames.

Activation Methods

You can turn on debug features in three ways.

  1. Environment variable (Rust only): RUST_BACKTRACE=1 turns on Rust backtraces without Ruby-side debug features.

  2. Environment variable (full): RR_DEBUG=1 turns on full debug mode at process startup.

  3. Programmatic: Call RatatuiRuby.debug_mode! or RatatuiRuby::Debug.enable!.

[!WARNING] Debug mode opens a remote debugging socket. This is a security vulnerability. Do not use it in production. See Remote Debugging for details.

Including RatatuiRuby::TestHelper auto-enables debug mode. Test authors get backtraces automatically.

# Option 1: Environment variable
# $ RR_DEBUG=1 ruby my_app.rb

# Option 2: Programmatic
RatatuiRuby.debug_mode!

# Now Rust panics show meaningful stack traces

Panics vs. Exceptions

Rust backtraces only appear for panics (unrecoverable crashes). When Rust code raises a Ruby exception (like TypeError), Ruby handles the backtrace. Rust provides the error message.

Error Type Backtrace When It Happens
Panic Rust stack trace Internal Rust bug, Debug.test_panic!
Exception Ruby stack trace Type mismatch, invalid arguments

The RUST_BACKTRACE=1 environment variable and Debug.enable! affect panic backtraces. Exceptions always show Ruby backtraces, but RatatuiRuby includes contextual error messages showing the actual value that caused the error:

# Without context (generic):
expected array for rows

# With context (RatatuiRuby):
expected array for rows, got 42

Inspecting the Buffer

The following methods help you debug rendering issues from tests or scripts.

print_buffer

Outputs the current terminal buffer to STDOUT with full ANSI colors. Call it inside with_test_terminal to see exactly what would render.

with_test_terminal do
  MyApp.new.render
  print_buffer  # Outputs the screen with colors
end

buffer_content

Returns the terminal buffer as an array of strings (one per row). Use it for programmatic inspection.

with_test_terminal do
  MyApp.new.render
  pp buffer_content  # ["Line 1: ...", "Line 2: ...", ...]
end

get_cell

Returns a Buffer::Cell with the character, foreground color, background color, and modifiers at specific coordinates.

with_test_terminal do
  MyApp.new.render
  cell = get_cell(0, 0)
  pp cell.symbol  # "H"
  pp cell.fg      # :red
  pp cell.bold?   # true
end

Protecting Output

During a TUI session, writes to $stdout or $stderr corrupt the display. Third-party gems often print warnings or debug output unexpectedly.

Use guard_io to temporarily swallow output from chatty code.

RatatuiRuby.run do |tui|
  RatatuiRuby.guard_io do
    SomeChattyGem.process  # Any puts/warn calls are swallowed
  end
end

Interactive Debuggers

[!WARNING] This section has not been verified by a human.

[!CAUTION] Traditional interactive debuggers (Pry, IRB, debug.gem) do not work inside an active TUI session. They require terminal input and output, which conflicts with raw mode.

Workarounds

Temporarily exit TUI mode. Restore the terminal, run your debugger, then re-initialize.

RatatuiRuby.restore_terminal
binding.pry  # Now Pry works normally
RatatuiRuby.init_terminal

Use test mode. Debug rendering logic inside with_test_terminal where there is no real terminal conflict.

with_test_terminal do
  binding.pry  # Works fine in test mode
  MyApp.new.render
end

Remote Debugging

Debug mode uses Ruby’s debug gem for remote debugging. Attach from another terminal (or IDE or Chrome DevTools) while the TUI runs.

Debugging Showcase

For a hands-on demo, see the Debugging Showcase example.

Debug mode loads the debug gem and creates a UNIX domain socket. Debuggers attach from another terminal. This works well for TUI apps since the main terminal is in raw mode.

How It Works

Attach from another terminal with rdbg --attach.

$ rdbg --attach

[!CAUTION] Remote debugging opens a backdoor to your application. This is a security vulnerability. The debug/open_nonstop mode is particularly dangerous because it allows attachment at any time. Do not run debug mode in production. Anyone who can access the socket can execute arbitrary code.

Example: Debugging a Running TUI

Terminal 1 (your app): <!– SPDX-SnippetBegin –> <!– SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 –>

$ ruby my_tui_app.rb
# App starts, TUI is running
# In your code: RatatuiRuby.debug_mode!
# Console shows: DEBUGGER: Debugger can attach via UNIX domain socket (...)

Terminal 2 (debugger): <!– SPDX-SnippetBegin –> <!– SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 –>

$ rdbg --attach
# Now you have a full debugger REPL
(rdbg) info locals
(rdbg) break MyApp#handle_key
(rdbg) continue

Requirements

Add the debug gem to your Gemfile:

gem "debug", ">= 1.0"

If RR_DEBUG=1 is set but the debug gem is missing, RatatuiRuby raises a LoadError with installation instructions.

File Logging

You can write debug output to a log file instead of stdout.

Basic Logging

DEBUG_LOG = File.open("debug.log", "a")

def debug(msg)
  DEBUG_LOG.puts("[#{Time.now}] #{msg}")
  DEBUG_LOG.flush
end

Then tail the log in a separate terminal.

tail -f debug.log

Timestamped Logging

For high-frequency logging (like inside a render loop), use timestamped files to avoid overwrites:

FileUtils.mkdir_p(File.join(Dir.tmpdir, "my_debug"))
timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%N')
File.write(
  File.join(Dir.tmpdir, "my_debug", "#{timestamp}.log"),
  "variable=#{value.inspect}\n"
)

Then tail the directory.

watch -n 0.5 'ls -la /tmp/my_debug/ && cat /tmp/my_debug/*.log'

REPL Without the TUI

Unit tests verify correctness, but sometimes you want to poke at objects interactively. Wrap your main execution in a guard:

if __FILE__ == $PROGRAM_NAME
  MyApp.new.run
end

Then load the file without entering raw mode.

ruby -e 'load "./bin/my_tui"; obj = MyClass.new; puts obj.result'

This exercises domain logic without the terminal conflict. Use it for exploration. Write tests with TestHelper for regression coverage.

Isolating Terminal Issues

Sometimes code works in a ruby -e script but fails in the TUI. Here are common causes.

  1. Thread context. Ruby threads share the process’s terminal state.

  2. Raw mode. External commands fail when stdin/stdout are reconfigured.

  3. SSH/Git auth. Commands that prompt for credentials hang or return empty.

See Async Operations for solutions.

Error Classes

RatatuiRuby has semantic exception classes for different failure modes:

Class Meaning
RatatuiRuby::Error::Terminal I/O failure (backend crashed, terminal unavailable)
RatatuiRuby::Error::Safety Lifetime violation (using Frame after draw block exits)
RatatuiRuby::Error::Invariant Contract violation (double init, headless mode conflict)

Catch these specifically instead of rescuing StandardError broadly.

begin
  RatatuiRuby.run { |tui| ... }
rescue RatatuiRuby::Error::Terminal => e
  puts "Terminal I/O failed: #{e.message}"
rescue RatatuiRuby::Error::Safety => e
  puts "API misuse: #{e.message}"
end

Further Reading