スキル一覧に戻る

go

riordanpawley / azedarach

3🍴 0📅 2026年1月6日

TUI Kanban board for orchestrating parallel Claude Code sessions with Beads task tracking

SKILL.md

# 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:

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

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

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

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

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

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

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

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

```go
type State int

const (
    StateBoard State = iota
    StateDetail
    StateSettings
)
```

### State with Data

```go
type State struct {
    view ViewType
    selectedItem int
    isLoading   bool
}
```

## Message Types

### Custom Messages

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

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

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

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

## Component Patterns

### Using Bubbles

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

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

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

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

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

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

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

- [Bubbletea Tutorial](https://github.com/charmbracelet/bubbletea/tree/master/tutorials)
- [Bubbletea Examples](https://github.com/charmbracelet/bubbletea/tree/master/examples)
- [Lip Gloss Documentation](https://github.com/charmbracelet/lipgloss)
- [Bubbles Components](https://github.com/charmbracelet/bubbles)
- [Glow Source](https://github.com/charmbracelet/glow) - Production patterns
- [Soft Serve Source](https://github.com/charmbracelet/soft-serve) - Production patterns