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.
-
Environment variable (Rust only):
RUST_BACKTRACE=1turns on Rust backtraces without Ruby-side debug features. -
Environment variable (full):
RR_DEBUG=1turns on full debug mode at process startup. -
Programmatic: Call
RatatuiRuby.debug_mode!orRatatuiRuby::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.
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
-
RR_DEBUG=1: Loadsdebug/open. The app stops at startup and waits for a debugger to attach. -
RatatuiRuby.debug_mode!: Loadsdebug/open_nonstop. The app continues running. Attach whenever you want.
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_nonstopmode 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.
-
Thread context. Ruby threads share the process’s terminal state.
-
Raw mode. External commands fail when stdin/stdout are reconfigured.
-
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
-
Application Testing Guide — Test helpers, snapshots, event injection
-
RatatuiRuby::Debug — Debug module source