askill
bubble-tea

bubble-teaSafety 100Repository

Patterns for building TUI applications with Bubble Tea (charmbracelet/bubbletea). Use when creating terminal UIs, pagers, or interactive CLI tools in Go. Covers Elm architecture, viewport scrolling, keyboard/mouse handling, Lipgloss styling, and golden file testing with teatest.

0 stars
1.2k downloads
Updated 2/5/2026

Package Files

Loading files...
SKILL.md

Bubble Tea Patterns

Elm Architecture

type Model struct {
    content  string
    viewport viewport.Model
    ready    bool
}

func (m Model) Init() tea.Cmd { return nil }

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.String() == "q" {
            return m, tea.Quit
        }
    case tea.WindowSizeMsg:
        // Initialize viewport on first size message
        if !m.ready {
            m.viewport = viewport.New(msg.Width, msg.Height)
            m.viewport.SetContent(m.content)
            m.ready = true
        } else {
            m.viewport.Width = msg.Width
            m.viewport.Height = msg.Height
        }
    }
    var cmd tea.Cmd
    m.viewport, cmd = m.viewport.Update(msg)
    return m, cmd
}

func (m Model) View() string {
    if !m.ready {
        return "Loading..."
    }
    return m.viewport.View()
}

Critical: Wait for tea.WindowSizeMsg before initializing viewport - dimensions arrive async.

Stdin Piping (git diff | myapp)

func main() {
    stat, _ := os.Stdin.Stat()
    if stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 {
        fmt.Println("Usage: git diff | diffview")
        os.Exit(1)
    }

    content, _ := io.ReadAll(os.Stdin)
    m := Model{content: string(content)}

    p := tea.NewProgram(m,
        tea.WithAltScreen(),       // Full-screen, restores on exit
        tea.WithMouseCellMotion(), // Mouse wheel support
    )
    p.Run()
}

Keyboard Handling

Simple matching:

case tea.KeyMsg:
    switch msg.String() {
    case "j", "down":
        m.viewport.LineDown(1)
    case "k", "up":
        m.viewport.LineUp(1)
    case "ctrl+d":
        m.viewport.HalfViewDown()
    case "ctrl+u":
        m.viewport.HalfViewUp()
    case "G":
        m.viewport.GotoBottom()
    case "q", "ctrl+c":
        return m, tea.Quit
    }

Multi-key sequences (gg):

type Model struct {
    pendingKey string
    // ...
}

case tea.KeyMsg:
    if m.pendingKey == "g" && msg.String() == "g" {
        m.viewport.GotoTop()
        m.pendingKey = ""
        return m, nil
    }
    if msg.String() == "g" {
        m.pendingKey = "g"
        return m, nil
    }
    m.pendingKey = ""

Customizable keymaps with bubbles/key:

import "github.com/charmbracelet/bubbles/key"

type KeyMap struct {
    Down key.Binding
    Up   key.Binding
    Quit key.Binding
}

var DefaultKeyMap = KeyMap{
    Down: key.NewBinding(key.WithKeys("j", "down"), key.WithHelp("j/↓", "down")),
    Up:   key.NewBinding(key.WithKeys("k", "up"), key.WithHelp("k/↑", "up")),
    Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
}

// Usage: key.Matches(msg, m.keymap.Down)

Viewport Built-in Keys

KeyAction
j/↓Line down
k/↑Line up
d/ctrl+dHalf page down
u/ctrl+uHalf page up
f/pgdn/spacePage down
b/pgupPage up

Lipgloss Styling

import "github.com/charmbracelet/lipgloss"

// Diff line styles with adaptive colors
addedStyle := lipgloss.NewStyle().
    Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "34"}).
    Background(lipgloss.AdaptiveColor{Light: "194", Dark: "22"})

removedStyle := lipgloss.NewStyle().
    Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "203"}).
    Background(lipgloss.AdaptiveColor{Light: "224", Dark: "52"})

// Line numbers
lineNumStyle := lipgloss.NewStyle().
    Foreground(lipgloss.Color("245")).
    Width(6).
    Align(lipgloss.Right)

// Side-by-side layout
joined := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)

// Measure ANSI-aware width
width := lipgloss.Width(styledString)

Layering styles (syntax + diff): Render inner style first, wrap with outer.

Header/Footer Pattern

func (m Model) View() string {
    return fmt.Sprintf("%s\n%s\n%s",
        m.headerView(),
        m.viewport.View(),
        m.footerView(),
    )
}

// Calculate viewport height accounting for margins
case tea.WindowSizeMsg:
    headerHeight := lipgloss.Height(m.headerView())
    footerHeight := lipgloss.Height(m.footerView())
    m.viewport.Height = msg.Height - headerHeight - footerHeight

Testing

Package: github.com/charmbracelet/x/exp/teatest

Deterministic Color Output

Use explicit renderer to avoid terminal auto-detection:

// Test helper - creates renderer with fixed TrueColor profile
func trueColorRenderer() *lipgloss.Renderer {
    r := lipgloss.NewRenderer(io.Discard)
    r.SetColorProfile(termenv.TrueColor)
    return r
}

// Pass to model via option
m := NewModel(content,
    WithTheme(lipgloss.TestTheme()),      // Stable colors
    WithRenderer(trueColorRenderer()),     // Deterministic output
)

Why: Without explicit renderer, Lipgloss auto-detects terminal capabilities. Tests become flaky across environments.

Test Theme Pattern

Use TestTheme() with stable, predictable colors. Production themes can evolve without breaking tests:

// In lipgloss/theme.go
func TestTheme() diffview.Theme {
    return newTheme(diffview.Palette{
        Added:   "#00ff00",  // Pure green - easy to verify
        Deleted: "#ff0000",  // Pure red
        // ... stable values that won't change
    })
}

Principle: TestTheme() is a stable contract. DefaultTheme() can change aesthetically.

Behavior Tests vs Color Tests

Behavior tests - verify functionality, not appearance:

func TestNavigation(t *testing.T) {
    t.Parallel()

    m := NewModel(diff, WithTheme(lipgloss.TestTheme()))
    tm := teatest.NewTestModel(t, m,
        teatest.WithInitialTermSize(80, 24),
    )

    tm.Send(tea.KeyMsg{Runes: []rune{'j'}})

    // Check content presence, ignore colors
    teatest.WaitFor(t, tm.Output(), func(out []byte) bool {
        return bytes.Contains(out, []byte("expected content"))
    })
}

Color integration tests - verify colors apply correctly:

func TestColorsApplied(t *testing.T) {
    t.Parallel()

    m := NewModel(diff,
        WithTheme(lipgloss.TestTheme()),
        WithRenderer(trueColorRenderer()),
    )
    tm := teatest.NewTestModel(t, m,
        teatest.WithInitialTermSize(80, 24),
    )

    teatest.WaitFor(t, tm.Output(), func(out []byte) bool {
        // TrueColor format: ESC[48;2;R;G;Bm (background)
        hasBackground := bytes.Contains(out, []byte("48;2;"))
        hasContent := bytes.Contains(out, []byte("+added"))
        return hasBackground && hasContent
    })
}

Golden File Testing

func TestView(t *testing.T) {
    m := NewModel(testContent)
    tm := teatest.NewTestModel(t, m,
        teatest.WithInitialTermSize(80, 24),
    )

    tm.Send(tea.KeyMsg{Runes: []rune{'j'}})
    tm.Send(tea.KeyMsg{Runes: []rune{'q'}})

    out, _ := io.ReadAll(tm.FinalOutput(t))
    teatest.RequireEqualOutput(t, out)  // Compares to testdata/TestView.golden
}

Workflow:

  1. go test -update → creates/updates testdata/TestName.golden
  2. Golden files include ANSI codes - use TestTheme() for stability
  3. Tests fail with unified diff when output changes

Testing Principles

  1. Behavior tests use TestTheme() - decouples from aesthetic changes
  2. Always use explicit renderer - no terminal auto-detection in tests
  3. Check content, not colors for most tests - colors are implementation detail
  4. Color tests verify ANSI presence - bytes.Contains(out, []byte("48;2;")) not specific RGB values
  5. One theme change shouldn't break behavior tests - only color-specific tests

Gotchas

  1. Always return model from Update, even if modified via receiver
  2. View() must be pure - no side effects
  3. Commands run async - don't assume order
  4. No line wrapping - viewport truncates long lines
  5. Pass all messages to viewport for built-in scrolling to work
  6. Never use len(string) for display width - use lipgloss.Width() instead:
    // WRONG: len() counts bytes, not display width
    padding := strings.Repeat(" ", maxWidth - len(line))
    
    // CORRECT: lipgloss.Width() handles Unicode properly
    padding := strings.Repeat(" ", maxWidth - lipgloss.Width(line))
    
    • len("日本語") = 9 bytes, but displays as 6 cells (CJK are double-width)
    • len("emoji 😀") = 10 bytes, but displays as 8 cells
    • lipgloss.Width() uses go-runewidth internally for correct display width

Install

Download ZIP
Requires askill CLI v1.0+

AI Quality Score

95/100Analyzed 2/9/2026

An excellent, high-density technical guide for building Go TUIs with Bubble Tea. It provides practical code patterns, advanced testing strategies, and critical 'gotchas' like handling display width vs byte length.

100
98
90
95
95

Metadata

Licenseunknown
Version-
Updated2/5/2026
Publishermajiayu000

Tags

githubgithub-actionstesting