Skip to main content

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

logging.go:11-25
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:
README.md:312-313
tail -f debug.log
Run this in a separate terminal while your Bubble Tea app runs.

Custom Logger

Use a custom logger with LogToFileWith:
logging.go:27-53
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

README.md:277-283
# 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:
README.md:282-284
dlv connect 127.0.0.1:43000

API Version

README.md:286-293
# 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

  1. Run > Edit Configurations
  2. Add New Configuration > Go Remote
  3. Set Host: 127.0.0.1, Port: 43000
  4. Start headless Delve
  5. 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:
go run main.go

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:
go run main.go --debug

Panic Recovery

Bubble Tea catches panics by default:
options.go:72-80
// 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:
options.go:98-102
// 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:
go run -race main.go
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))

Build docs developers (and LLMs) love