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.
When & Why to Use This Skill
This Claude skill provides a comprehensive set of design patterns and best practices for building sophisticated Terminal User Interfaces (TUI) in Go using the Bubble Tea framework. It streamlines the development of interactive CLI tools by offering production-ready implementations of the Elm architecture, responsive viewport management, Lipgloss styling, and robust testing strategies, ensuring high-quality terminal applications with consistent cross-platform behavior.
Use Cases
- Case 1: Developing interactive command-line dashboards or system monitors that require real-time data updates and responsive layouts.
- Case 2: Implementing complex terminal navigation such as pagers, file explorers, or git-like diff viewers with keyboard and mouse support.
- Case 3: Creating visually rich CLI applications with adaptive color themes and precise Unicode-aware layout alignment using Lipgloss.
- Case 4: Establishing automated QA pipelines for terminal UIs using golden file testing and deterministic rendering to prevent visual regressions.
| name | bubble-tea |
|---|---|
| description | 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. |
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:
go test -update→ creates/updatestestdata/TestName.golden- Golden files include ANSI codes - use
TestTheme()for stability - Tests fail with unified diff when output changes
Testing Principles
- Behavior tests use
TestTheme()- decouples from aesthetic changes - Always use explicit renderer - no terminal auto-detection in tests
- Check content, not colors for most tests - colors are implementation detail
- Color tests verify ANSI presence -
bytes.Contains(out, []byte("48;2;"))not specific RGB values - One theme change shouldn't break behavior tests - only color-specific tests
Gotchas
- Always return model from Update, even if modified via receiver
- View() must be pure - no side effects
- Commands run async - don't assume order
- No line wrapping - viewport truncates long lines
- Pass all messages to viewport for built-in scrolling to work
- Never use
len(string)for display width - uselipgloss.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 cellslipgloss.Width()uses go-runewidth internally for correct display width