Ruletypescript

Bubbletea Patterns.Skill Rule

Bubbletea implements the Elm Architecture:

View Source

Bubbletea Patterns Skill

Version: 1.0 Purpose: Idiomatic Bubbletea patterns learned from Glow, Soft Serve, and community best practices

Overview

Bubbletea implements the Elm Architecture:

  • Model - Application state
  • Update - State transitions (messages → new model, commands)
  • View - Rendering model to terminal

Key: The program sends messages (tea.Msg), Update handles them, returns (newModel, optionalCmd)

Model Architecture

Nested Models Pattern

For non-trivial apps, use nested models with a top-level router:

type Model struct {
    // Shared state accessible to all sub-models
    common *CommonModel

    // Sub-models (each implements tea.Model)
    board    *board.Model
    detail   *detail.Model
    settings *settings.Model
    overlays  *overlay.Stack

    // Current state for routing
    state State
}

type CommonModel struct {
    config  *config.Config
    width   int
    height  int
    styles  *styles.Styles
    program *tea.Program // For sending messages from goroutines
}

Key insight from Glow: Share common state via pointer to avoid duplication across sub-models.

Init: Batch Sub-Model Initialization

func (m Model) Init() tea.Cmd {
    return tea.Batch(
        m.board.Init(),
        m.detail.Init(),
        m.settings.Init(),
        loadInitialData,  // Your custom init command
    )
}

Update: Message Routing Pattern

Pass ALL messages to relevant sub-models, not just "active" one:

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmds []tea.Cmd

    // Global handlers first (window size, quit, etc.)
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.common.width = msg.Width
        m.common.height = msg.Height
        // Propagate to all sub-models
        m.board.SetSize(msg.Width, msg.Height)
        m.detail.SetSize(msg.Width, msg.Height)
        return m, nil

    case tea.KeyMsg:
        // Handle quit regardless of state
        if msg.String() == "ctrl+c" {
            return m, tea.Quit
        }
    }

    // Route to active overlay first (if any)
    if m.overlays.Current() != nil {
        overlay, cmd := m.overlays.Current().Update(msg)
        m.overlays.SetCurrent(overlay)
        if cmd != nil {
            cmds = append(cmds, cmd)
        }
        // Overlays may consume the message
        if m.overlays.Current() != nil {
            return m, tea.Batch(cmds...)
        }
    }

    // Route to current view
    switch m.state {
    case StateBoard:
        newBoard, cmd := m.board.Update(msg)
        m.board = newBoard.(*board.Model)
        cmds = append(cmds, cmd)
    case StateDetail:
        newDetail, cmd := m.detail.Update(msg)
        m.detail = newDetail.(*detail.Model)
        cmds = append(cmds, cmd)
    }

    return m, tea.Batch(cmds...)
}

Commands

tea.Cmd Types

// Command returning a message
func fetchTasks() tea.Cmd {
    return func() tea.Msg {
        return TasksLoadedMsg{tasks: getTasks()}
    }
}

// Command with timeout
func fetchWithTimeout() tea.Cmd {
    return tea.Tick(time.Second, func(t time.Time) tea.Msg {
        return TimeoutMsg{}
    })
}

// Command from goroutine
func asyncWork() tea.Cmd {
    ch := make(chan tea.Msg)
    go func() {
        defer close(ch)
        ch <- WorkDoneMsg{result: doWork()}
    }()
    return func() tea.Msg {
        return <-ch
    }
}

// Batch commands
func (m Model) Init() tea.Cmd {
    return tea.Batch(
        m.board.Init(),
        m.detail.Init(),
        tea.Tick(time.Second, tickerTick),  // Periodic
    )
}

Sending Messages from Goroutines

// In goroutine, send via program
func (s *Service) pollState(program *tea.Program) {
    for {
        time.Sleep(500 * time.Millisecond)
        state := s.checkState()
        program.Send(StateChangedMsg{state: state})
    }
}

// In model Update, handle the message
case StateChangedMsg msg:
    // Handle state change

View Pattern

Delegated Views

func (m Model) View() string {
    switch m.state {
    case StateBoard:
        return m.board.View()
    case StateDetail:
        return m.detail.View()
    case StateSettings:
        return m.settings.View()
    default:
        return ""
    }
}

Styled Views (Lip Gloss)

var (
    // Define styles once (avoid reallocation in View())
    baseStyle   = lipgloss.NewStyle()
    titleStyle  = baseStyle.Bold(true).Foreground(lipgloss.Color("205"))
    activeStyle = baseStyle.Background(lipgloss.Color("240"))
)

func (m Model) View() string {
    return lipgloss.NewStyle().
        Width(m.common.width).
        Height(m.common.height).
        Render(
            lipgloss.Place(
                m.common.width, m.common.height,
                lipgloss.Center, lipgloss.Center,
                m.content.View(),
            ),
        )
}

Border Styles

var (
    boxStyle = lipgloss.NewStyle().
        Border(lipgloss.RoundedBorder()).
        BorderForeground(lipgloss.Color("63")).
        Padding(1, 2)
)

func (m Model) View() string {
    return boxStyle.Render(m.content)
}

State Management

Simple State Enum

type State int

const (
    StateBoard State = iota
    StateDetail
    StateSettings
)

State with Data

type State struct {
    view ViewType
    selectedItem int
    isLoading   bool
}

Message Types

Custom Messages

// Define custom message types (avoid collisions)
type TaskSelectedMsg struct {
    id string
}

type TasksLoadedMsg struct {
    tasks []Task
}

type StateChangedMsg struct {
    state SessionState
}

Built-in Messages

| Message | Description | |----------|-------------| | tea.InitMsg | Program started (return initial cmd) | | tea.QuitMsg | Request program exit | | tea.WindowSizeMsg | Terminal size changed | | tea.KeyMsg | Key press event | | tea.MouseMsg | Mouse event (enable with tea.WithMouseCellMotion()) | | tea.TickMsg | Timer event (from tea.Tick) |

Keyboard Patterns

Key Matching

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit
        case "up", "k":
            m.cursor--
        case "down", "j":
            m.cursor++
        case "enter":
            return m, m.selectCurrent()
        }
    }
    return m, nil
}

Key Types

case tea.KeyMsg:
    switch msg.Type {
    case tea.KeyRunes:
        // Any character key (letters, numbers, etc.)
    case tea.KeyEnter, tea.KeySpace:
        // Enter or Space
    case tea.KeyUp, tea.KeyDown, tea.KeyLeft, tea.KeyRight:
        // Arrow keys
    case tea.KeyCtrlC, tea.KeyCtrlD:
        // Ctrl combinations
    }

Alt/Modifer Keys

case tea.KeyMsg:
    if msg.Alt {
        // Alt+key
    }

Component Patterns

Using Bubbles

import (
    "github.com/charmbracelet/bubbles/list"
    "github.com/charmbracelet/bubbles/textinput"
    "github.com/charmbracelet/bubbles/viewport"
)

type Model struct {
    list    list.Model
    input   textinput.Model
    content viewport.Model
}

func (m Model) Init() tea.Cmd {
    m.list = list.New(..., list.NewDefaultDelegate())
    m.input = textinput.New()
    m.content = viewport.New(0, 0)
    return nil
}

Focus Management

type Model struct {
    focused focusedComponent
}

type focusedComponent int

const (
    focusList focusedComponent = iota
    focusInput
)

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.String() == "tab" {
            // Cycle focus
            m.focused = (m.focused + 1) % 2
        }
    }

    // Update based on focus
    switch m.focused {
    case focusList:
        var cmd tea.Cmd
        m.list, cmd = m.list.Update(msg)
        return m, cmd
    case focusInput:
        var cmd tea.Cmd
        m.input, cmd = m.input.Update(msg)
        return m, cmd
    }
}

Performance Patterns

View Memoization

// ❌ BAD: Reallocates styles every render
func (m Model) View() string {
    return lipgloss.NewStyle().
        Bold(true).
        Render("Title")
}

// ✅ GOOD: Styles defined once
var titleStyle = lipgloss.NewStyle().Bold(true)

func (m Model) View() string {
    return titleStyle.Render("Title")
}

View Optimization

// ❌ BAD: Renders entire content every frame
func (m Model) View() string {
    return m.content
}

// ✅ GOOD: Only render what changed
func (m Model) View() string {
    if m.needsRender {
        m.needsRender = false
        return m.content
    }
    return ""  // Skip rendering
}

Common Patterns

Confirmation Dialog

type ConfirmModel struct {
    question string
    confirmed bool
}

func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.String() == "y" {
            m.confirmed = true
            return m, tea.Quit
        }
        if msg.String() == "n" || msg.String() == "esc" {
            return m, tea.Quit
        }
    }
    return m, nil
}

func (m ConfirmModel) View() string {
    return fmt.Sprintf("%s [y/n]", m.question)
}

Loading State

type Model struct {
    loading bool
    content string
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case LoadStartMsg:
        m.loading = true
    case LoadDoneMsg:
        m.loading = false
        m.content = msg.data
    }
    return m, nil
}

func (m Model) View() string {
    if m.loading {
        return "Loading..."
    }
    return m.content
}

Error Handling

type Model struct {
    err error
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case errMsg:
        m.err = msg.err
    }
    return m, nil
}

func (m Model) View() string {
    if m.err != nil {
        return lipgloss.NewStyle().
            Foreground(lipgloss.Color("196")).
            Render(fmt.Sprintf("Error: %v", m.err))
    }
    return m.content
}

Best Practices

  1. Define styles once as package-level variables (avoid View() reallocation)
  2. Pass context to goroutines and check for cancellation
  3. Use tea.Batch to combine multiple commands
  4. Handle tea.WindowSizeMsg to respond to terminal resizes
  5. Type switch on messages (not fmt.Sprintf, slower)
  6. Keep View() pure - no side effects, just render state
  7. Use bubbles components for lists, inputs, viewports
  8. Close channels from sender side only
  9. Use tea.Quit to exit gracefully
  10. Test with different terminal sizes for responsive layouts

References