Overview
Debugging terminal UIs presents unique challenges since stdout is occupied by your application. Bubble Tea provides logging utilities and debugger integration to help you debug effectively.
Log to File
The primary debugging technique is logging to a file:
LogToFile
import tea " charm.land/bubbletea/v2 "
f , err := tea . LogToFile ( "debug.log" , "debug" )
if err != nil {
fmt . Println ( "fatal:" , err )
os . Exit ( 1 )
}
defer f . Close ()
Complete Example
examples/simple/main.go:14-29
func main () {
// Log to a file. Useful in debugging since you can't really log to stdout.
logfilePath := os . Getenv ( "BUBBLETEA_LOG" )
if logfilePath != "" {
if _ , err := tea . LogToFile ( logfilePath , "simple" ); err != nil {
log . Fatal ( err )
}
}
// Initialize our program
p := tea . NewProgram ( model ( 5 ))
if _ , err := p . Run (); err != nil {
log . Fatal ( err )
}
}
Using the Logger
After setting up file logging, use Go’s standard log package:
import " log "
func ( m model ) Update ( msg tea . Msg ) ( tea . Model , tea . Cmd ) {
log . Printf ( "Received message: %T " , msg )
switch msg := msg .( type ) {
case tea . KeyPressMsg :
log . Printf ( "Key pressed: %s " , msg . String ())
case statusMsg :
log . Printf ( "Status: %d " , msg )
}
return m , nil
}
Watch Logs in Real-Time
View logs as your program runs:
Run this in a separate terminal while your Bubble Tea app runs.
Custom Logger
Use a custom logger with LogToFileWith:
import (
" log "
tea " charm.land/bubbletea/v2 "
)
customLogger := log . New ( os . Stdout , "" , 0 )
f , err := tea . LogToFileWith ( "debug.log" , "myapp" , customLogger )
if err != nil {
log . Fatal ( err )
}
defer f . Close ()
// Now customLogger writes to debug.log
customLogger . Println ( "Application started" )
Charm’s Log Library
import (
tea " charm.land/bubbletea/v2 "
" github.com/charmbracelet/log "
)
logger := log . New ( os . Stderr )
f , err := tea . LogToFileWith ( "debug.log" , "app" , logger )
if err != nil {
log . Fatal ( err )
}
defer f . Close ()
logger . Info ( "Application started" )
logger . Debug ( "Debug information" , "key" , "value" )
logger . Error ( "An error occurred" , "err" , err )
Debugging with Delve
Use Delve debugger in headless mode:
Start Headless Debugger
# Start the debugger
dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 .
The --listen flag sets a consistent port. Without it, the port changes on each run.
Connect to Debugger
From another terminal:
dlv connect 127.0.0.1:43000
API Version
# Use API version 2 (recommended)
dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 .
Delve defaults to version 1 for backwards compatibility, but version 2 is recommended for new development.
Debugger Commands
Once connected:
# Set breakpoint
( dlv ) break main.go:42
# Continue execution
( dlv ) continue
# Step through code
( dlv ) next
( dlv ) step
# Inspect variables
( dlv ) print m
( dlv ) print msg
# List local variables
( dlv ) locals
# View stack trace
( dlv ) stack
IDE Integration
VS Code
Configure .vscode/launch.json:
{
"version" : "0.2.0" ,
"configurations" : [
{
"name" : "Debug Bubble Tea" ,
"type" : "go" ,
"request" : "launch" ,
"mode" : "debug" ,
"program" : "${workspaceFolder}" ,
"console" : "integratedTerminal" ,
"apiVersion" : 2
},
{
"name" : "Attach to Delve" ,
"type" : "go" ,
"request" : "attach" ,
"mode" : "remote" ,
"remotePath" : "${workspaceFolder}" ,
"port" : 43000 ,
"host" : "127.0.0.1" ,
"apiVersion" : 2
}
]
}
Run headless Delve, then use “Attach to Delve” in VS Code.
GoLand / IntelliJ
Run > Edit Configurations
Add New Configuration > Go Remote
Set Host: 127.0.0.1, Port: 43000
Start headless Delve
Run your remote configuration
Debugging Strategies
Log Model State
func ( m model ) Update ( msg tea . Msg ) ( tea . Model , tea . Cmd ) {
log . Printf ( "Before update: %+v " , m )
// ... handle message ...
log . Printf ( "After update: %+v " , m )
return m , cmd
}
Log Message Flow
func ( m model ) Update ( msg tea . Msg ) ( tea . Model , tea . Cmd ) {
log . Printf ( "MSG: %T %+v " , msg , msg )
switch msg := msg .( type ) {
case tea . KeyPressMsg :
log . Printf ( "Key: code= %d text= %q keystroke= %s " ,
msg . Key (). Code , msg . Key (). Text , msg . Keystroke ())
}
return m , nil
}
Log Command Execution
func fetchData () tea . Msg {
log . Println ( "fetchData: starting" )
data , err := http . Get ( "..." )
if err != nil {
log . Printf ( "fetchData: error: %v " , err )
return errMsg { err }
}
log . Printf ( "fetchData: success, got %d bytes" , len ( data ))
return dataMsg ( data )
}
Trace View Rendering
func ( m model ) View () tea . View {
log . Printf ( "View: rendering with state: %+v " , m )
content := m . buildView ()
log . Printf ( "View: rendered %d lines" , strings . Count ( content , " \n " ))
return tea . NewView ( content )
}
Conditional Logging
Enable logging only when needed:
examples/simple/main.go:17-22
logfilePath := os . Getenv ( "BUBBLETEA_LOG" )
if logfilePath != "" {
if _ , err := tea . LogToFile ( logfilePath , "simple" ); err != nil {
log . Fatal ( err )
}
}
Run with logging:
BUBBLETEA_LOG = debug.log go run main.go
Run without logging:
Debug Mode Flag
var debug = flag . Bool ( "debug" , false , "enable debug logging" )
func main () {
flag . Parse ()
if * debug {
f , err := tea . LogToFile ( "debug.log" , "app" )
if err != nil {
log . Fatal ( err )
}
defer f . Close ()
}
p := tea . NewProgram ( initialModel ())
if _ , err := p . Run (); err != nil {
log . Fatal ( err )
}
}
Usage:
Panic Recovery
Bubble Tea catches panics by default:
// Disable panic catching (for debugging)
p := tea . NewProgram ( model , tea . WithoutCatchPanics ())
Disabling panic recovery will leave your terminal in an unusable state after a panic. Only use this during active debugging.
Testing and Debugging
Use test mode to debug without rendering:
// Run without renderer for debugging
p := tea . NewProgram ( model , tea . WithoutRenderer ())
This makes it easier to add debug prints that won’t interfere with the TUI.
Common Issues
Log in both Update and the command: func myCommand () tea . Msg {
log . Println ( "Command executing" )
result := doWork ()
log . Printf ( "Command returning: %+v " , result )
return result
}
func ( m model ) Update ( msg tea . Msg ) ( tea . Model , tea . Cmd ) {
log . Printf ( "Update received: %T " , msg )
// ...
}
Check View is called and returns content: func ( m model ) View () tea . View {
log . Println ( "View called" )
content := m . render ()
log . Printf ( "View content length: %d " , len ( content ))
return tea . NewView ( content )
}
Verify commands are returned and not nil: func ( m model ) Update ( msg tea . Msg ) ( tea . Model , tea . Cmd ) {
var cmd tea . Cmd
// ... update logic ...
if cmd != nil {
log . Printf ( "Returning command: %T " , cmd )
} else {
log . Println ( "Returning nil command" )
}
return m , cmd
}
Enable race detection: Never mutate model from commands - always return messages: // Bad: Mutates model from goroutine
func ( m * model ) badCommand () tea . Cmd {
go func () {
m . data = fetchData () // Race condition!
}()
return nil
}
// Good: Returns message
func fetchData () tea . Msg {
data := fetchData ()
return dataMsg ( data )
}
Best Practices
Log Liberally Add logs throughout your code during development. Remove or gate them behind flags for production.
Use Structured Logging Include context in your logs: log . Printf ( "user= %s action= %s result= %v " ,
user , action , result )
Watch Logs Live Always run tail -f debug.log in a separate terminal during debugging.
Log Before Crashes Add logs before operations that might panic: log . Printf ( "About to access index %d of slice len %d " ,
i , len ( slice ))