smithery.ai

bubble-tea

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.

First seen May 1, 2026

Installation

$ npx skills add https://smithery.ai

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from smithery.ai · top by installs.

npx skills add https://smithery.ai

Browse all from smithery.ai

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 8,803 B
  • docs SUMMARY.md 297 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 2 installs

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

Key Action
j/↓ Line down
k/↑ Line up
d/ctrl+d Half page down
u/ctrl+u Half page up
f/pgdn/space Page down
b/pgup Page 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:

```go // 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