Bubbletea Quick Reference Card

Core Pattern

type model struct {
    // Your state here
    list list.Model
    commands []Command
    selected Command
}

func (m model) Init() tea.Cmd {
    // Return initial command (or nil)
    return nil
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        case "enter":
            // Do something
            return m, someCommand
        }
    }

    // Delegate to child components
    var cmd tea.Cmd
    m.list, cmd = m.list.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return m.list.View()
}

Essential Imports

import (
    "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/bubbles/list"
    "github.com/charmbracelet/lipgloss"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

List Component

// Define item
type item struct {
    title, desc string
}

func (i item) Title() string       { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }

// Create list
items := []list.Item{
    item{title: "Commands", desc: "Shell commands"},
}

l := list.New(items, list.NewDefaultDelegate(), 80, 20)
l.Title = "Menu"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(true)

// Get selection
selected := m.list.SelectedItem().(item)

Styling with Lipgloss

var (
    titleStyle = lipgloss.NewStyle().
        Bold(true).
        Foreground(lipgloss.Color("170")).
        Padding(1, 2).
        Border(lipgloss.RoundedBorder()).
        BorderForeground(lipgloss.Color("63"))

    selectedStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("170")).
        Bold(true)

    dimStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("241"))
)

// Adaptive colors (light/dark)
adaptiveColor := lipgloss.AdaptiveColor{
    Light: "16",  // Dark text for light bg
    Dark: "255",  // Light text for dark bg
}

External Commands

// Non-interactive (task, git status, etc.)
func runTask(name string) tea.Cmd {
    return func() tea.Msg {
        cmd := exec.Command("task", name)
        output, err := cmd.CombinedOutput()
        return taskFinishedMsg{output, err}
    }
}

// Interactive (vim, tmux, etc.)
func openEditor(file string) tea.Cmd {
    c := exec.Command(os.Getenv("EDITOR"), file)
    return tea.ExecProcess(c, func(err error) tea.Msg {
        return editorFinishedMsg{err}
    })
}

Configuration with Viper

type Config struct {
    Menu struct {
        Height int `mapstructure:"height"`
        PreviewEnabled bool `mapstructure:"preview_enabled"`
    } `mapstructure:"menu"`

    Registry struct {
        Commands string `mapstructure:"commands"`
    } `mapstructure:"registry"`
}

func LoadConfig() (*Config, error) {
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.AddConfigPath("$HOME/.config/menu")

    if err := viper.ReadInConfig(); err != nil {
        return nil, err
    }

    var cfg Config
    if err := viper.Unmarshal(&cfg); err != nil {
        return nil, err
    }

    return &cfg, nil
}

YAML Parsing

import "gopkg.in/yaml.v3"

type Command struct {
    Name        string   `yaml:"name"`
    Type        string   `yaml:"type"`
    Description string   `yaml:"description"`
    Keywords    []string `yaml:"keywords"`
    Command     string   `yaml:"command"`
    Examples    []struct {
        Command     string `yaml:"command"`
        Description string `yaml:"description"`
    } `yaml:"examples"`
    Notes    string   `yaml:"notes"`
    Related  []string `yaml:"related"`
    Platform string   `yaml:"platform"`
}

func loadCommands(path string) ([]Command, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }

    var commands []Command
    if err := yaml.Unmarshal(data, &commands); err != nil {
        return nil, err
    }

    return commands, nil
}

Cobra Integration

var rootCmd = &cobra.Command{
    Use:   "menu",
    Short: "Universal menu system",
    RunE: func(cmd *cobra.Command, args []string) error {
        p := tea.NewProgram(initialModel())
        if _, err := p.Run(); err != nil {
            return err
        }
        return nil
    },
}

var sessCmd = &cobra.Command{
    Use:   "sess",
    Short: "Session management",
    RunE: func(cmd *cobra.Command, args []string) error {
        p := tea.NewProgram(newSessionModel())
        return p.Start()
    },
}

func init() {
    rootCmd.AddCommand(sessCmd)
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

State Management

// Multi-view navigation
type view int

const (
    viewMenu view = iota
    viewCommands
    viewSessions
    viewDetails
)

type model struct {
    currentView view
    menuModel   menuModel
    commandsModel commandsModel
    // ...
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch m.currentView {
    case viewMenu:
        return m.updateMenu(msg)
    case viewCommands:
        return m.updateCommands(msg)
    // ...
    }
}

func (m model) View() string {
    switch m.currentView {
    case viewMenu:
        return m.menuModel.View()
    case viewCommands:
        return m.commandsModel.View()
    // ...
    }
}

Common Key Bindings

case tea.KeyMsg:
    switch msg.String() {
    case "q", "ctrl+c":
        return m, tea.Quit
    case "esc":
        m.currentView = viewMenu
        return m, nil
    case "enter":
        item := m.list.SelectedItem().(myItem)
        return m, handleSelection(item)
    case "j", "down":
        m.list.CursorDown()
    case "k", "up":
        m.list.CursorUp()
    case "/":
        m.list.SetFilteringEnabled(true)
    }

Window Size Handling

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.list.SetWidth(msg.Width)
        m.list.SetHeight(msg.Height - 4)  // Leave space for header
        return m, nil
    }
    // ...
}

Testing

import "github.com/charmbracelet/x/exp/teatest"

func TestModel(t *testing.T) {
    m := initialModel()
    tm := teatest.NewTestModel(t, m)

    // Send keys
    tm.Send(tea.KeyMsg{Type: tea.KeyDown})
    tm.Send(tea.KeyMsg{Type: tea.KeyEnter})

    // Wait for specific output
    teatest.WaitFor(
        t, tm.Output(),
        func(bts []byte) bool {
            return strings.Contains(string(bts), "Success")
        },
        teatest.WithDuration(time.Second),
    )
}

Common Patterns

Async Loading

type dataLoadedMsg struct {
    data []Command
    err  error
}

func loadDataCmd() tea.Msg {
    data, err := loadCommands("commands.yml")
    return dataLoadedMsg{data, err}
}

func (m model) Init() tea.Cmd {
    return loadDataCmd
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case dataLoadedMsg:
        if msg.err != nil {
            m.err = msg.err
            return m, nil
        }
        m.commands = msg.data
        m.list.SetItems(toListItems(msg.data))
        return m, nil
    }
}

Progress Indicator

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

type model struct {
    spinner  spinner.Model
    loading  bool
}

func (m model) Init() tea.Cmd {
    return tea.Batch(
        m.spinner.Tick,
        loadDataCmd,
    )
}

func (m model) View() string {
    if m.loading {
        return m.spinner.View() + " Loading..."
    }
    return m.list.View()
}

Debugging

import "github.com/davecgh/go-spew/spew"

// Log messages to file
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    if os.Getenv("DEBUG") == "true" {
        f, _ := os.OpenFile("/tmp/bubbletea.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
        spew.Fdump(f, msg)
        f.Close()
    }
    // ...
}

Resources

  • Docs: github.com/charmbracelet/bubbletea
  • Examples: github.com/charmbracelet/bubbletea/tree/main/examples
  • Gum source: github.com/charmbracelet/gum
  • Best practices: leg100.github.io/en/posts/building-bubbletea-programs
  • Detailed research: Go TUI Ecosystem Research