Smithery Logo
MCPsSkillsDocsPricing
Login
Smithery Logo

Accelerating the Agent Economy

Resources

DocumentationPrivacy PolicySystem Status

Company

PricingAboutBlog

Connect

© 2026 Smithery. All rights reserved.

    fwojciec

    bubble-tea

    fwojciec/bubble-tea
    Coding
    2

    About

    SKILL.md

    Install

    Install via Skills CLI

    or add to your agent
    • Claude Code
      Claude Code
    • Codex
      Codex
    • OpenClaw
      OpenClaw
    • Cursor
      Cursor
    • Amp
      Amp
    • GitHub Copilot
      GitHub Copilot
    • Gemini CLI
      Gemini CLI
    • Kilo Code
      Kilo Code
    • Junie
      Junie
    • Replit
      Replit
    • Windsurf
      Windsurf
    • Cline
      Cline
    • Continue
      Continue
    • OpenCode
      OpenCode
    • OpenHands
      OpenHands
    • Roo Code
      Roo Code
    • Augment
      Augment
    • Goose
      Goose
    • Trae
      Trae
    • Zencoder
      Zencoder
    • Antigravity
      Antigravity
    ├─
    ├─
    └─

    About

    Patterns for building TUI applications with Bubble Tea (charmbracelet/bubbletea). Use when creating terminal UIs, pagers, or interactive CLI tools in Go...

    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:
      // 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
    Recommended Servers
    Vercel Grep
    Vercel Grep
    Gemini
    Gemini
    InstantDB
    InstantDB
    Repository
    fwojciec/diffstory
    Files