skillby yurifrl

charm-stack

Build terminal UIs with Bubbletea, Bubbles, Lipgloss, and Huh. Use when creating TUI applications, interactive forms, styled terminal output, or when user mentions Bubbletea, Bubbles, Lipgloss, Huh, Charm, or TUI development.

Installs: 0
Used in: 1 repos
Updated: 2d ago
$npx ai-builder add skill yurifrl/charm-stack

Installs to .claude/skills/charm-stack/

# Charm Stack TUI Development

Build beautiful, functional terminal user interfaces using the Charm stack: Bubbletea (framework), Bubbles (components), Lipgloss (styling), and Huh (forms).

## Your Role: TUI Architect

You build terminal applications using Elm Architecture patterns. You:

✅ **Implement Model-Update-View** - Core Bubbletea pattern
✅ **Compose Bubbles components** - Spinners, lists, text inputs
✅ **Style with Lipgloss** - Colors, borders, layouts
✅ **Build forms with Huh** - Interactive prompts
✅ **Handle messages properly** - KeyMsg, WindowMsg, custom messages
✅ **Follow project patterns** - Module structure from CLY

❌ **Do NOT fight the framework** - Use Elm Architecture
❌ **Do NOT skip Init** - Commands need initialization
❌ **Do NOT ignore tea.Cmd** - Critical for async operations

## Core Architecture: The Elm Architecture

### The Three Functions

Every Bubbletea program has three parts:

**Model** - Application state
```go
type model struct {
    cursor   int
    choices  []string
    selected map[int]struct{}
}
```

**Init** - Initial command
```go
func (m model) Init() tea.Cmd {
    return nil  // or return a command
}
```

**Update** - Handle messages, update state
```go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        // Handle keyboard
    }
    return m, nil
}
```

**View** - Render UI
```go
func (m model) View() string {
    return "Hello, World!"
}
```

### Message Flow

```
User Input → Msg → Update → Model → View → Screen
    ↑                                    ↓
    └────────── tea.Cmd ─────────────────┘
```

**Key concepts:**
- Messages are immutable events
- Update returns new model (don't mutate)
- Commands run async, generate more messages
- View is pure function of model state

## Bubbletea Patterns

### Basic Program

```go
package main

import (
    "fmt"
    "os"

    tea "github.com/charmbracelet/bubbletea"
)

type model struct {
    choices  []string
    cursor   int
    selected map[int]struct{}
}

func initialModel() model {
    return model{
        choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
        selected: make(map[int]struct{}),
    }
}

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:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit

        case "up", "k":
            if m.cursor > 0 {
                m.cursor--
            }

        case "down", "j":
            if m.cursor < len(m.choices)-1 {
                m.cursor++
            }

        case "enter", " ":
            _, ok := m.selected[m.cursor]
            if ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        }
    }
    return m, nil
}

func (m model) View() string {
    s := "What should we buy?\n\n"

    for i, choice := range m.choices {
        cursor := " "
        if m.cursor == i {
            cursor = ">"
        }

        checked := " "
        if _, ok := m.selected[i]; ok {
            checked = "x"
        }

        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
    }

    s += "\nPress q to quit.\n"
    return s
}

func main() {
    p := tea.NewProgram(initialModel())
    if _, err := p.Run(); err != nil {
        fmt.Printf("Error: %v", err)
        os.Exit(1)
    }
}
```

### Commands (tea.Cmd)

Commands enable async operations. They return messages.

**Simple command:**
```go
func checkServer() tea.Msg {
    // Do work
    return statusMsg{online: true}
}

// In Update:
case tea.KeyMsg:
    if msg.String() == "c" {
        return m, checkServer  // Execute command
    }
```

**Command that runs async:**
```go
func fetchData() tea.Cmd {
    return func() tea.Msg {
        resp, err := http.Get("https://api.example.com/data")
        if err != nil {
            return errMsg{err}
        }
        return dataMsg{resp}
    }
}
```

**Batch commands:**
```go
return m, tea.Batch(
    cmd1,
    cmd2,
    cmd3,
)
```

**Tick command (for animations):**
```go
type tickMsg time.Time

func tick() tea.Cmd {
    return tea.Tick(time.Second, func(t time.Time) tea.Msg {
        return tickMsg(t)
    })
}

// In Init:
func (m model) Init() tea.Cmd {
    return tick()
}

// In Update:
case tickMsg:
    m.lastTick = time.Time(msg)
    return m, tick()  // Keep ticking
```

### Window Size Handling

```go
type model struct {
    width  int
    height int
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.width = msg.Width
        m.height = msg.Height
        return m, nil
    }
    return m, nil
}

func (m model) View() string {
    return lipgloss.Place(
        m.width,
        m.height,
        lipgloss.Center,
        lipgloss.Center,
        "Centered text",
    )
}
```

### Program Options

```go
p := tea.NewProgram(
    initialModel(),
    tea.WithAltScreen(),       // Use alternate screen buffer
    tea.WithMouseCellMotion(), // Enable mouse
)
```

## Bubbles Components

Bubbles provides ready-made components. Each is a tea.Model.

### Spinner

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

type model struct {
    spinner spinner.Model
}

func initialModel() model {
    s := spinner.New()
    s.Spinner = spinner.Dot
    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
    return model{spinner: s}
}

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

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.spinner, cmd = m.spinner.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return m.spinner.View() + " Loading..."
}
```

**Spinner types:**
- `spinner.Line`
- `spinner.Dot`
- `spinner.MiniDot`
- `spinner.Jump`
- `spinner.Pulse`
- `spinner.Points`
- `spinner.Globe`
- `spinner.Moon`

### Text Input

```go
import "github.com/charmbracelet/bubbles/textinput"

type model struct {
    textInput textinput.Model
}

func initialModel() model {
    ti := textinput.New()
    ti.Placeholder = "Enter your name"
    ti.Focus()
    ti.CharLimit = 156
    ti.Width = 20

    return model{textInput: ti}
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd

    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "enter":
            name := m.textInput.Value()
            // Use the name
            return m, tea.Quit
        }
    }

    m.textInput, cmd = m.textInput.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return fmt.Sprintf(
        "What's your name?\n\n%s\n\n%s",
        m.textInput.View(),
        "(esc to quit)",
    )
}
```

### List

```go
import "github.com/charmbracelet/bubbles/list"

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 }

type model struct {
    list list.Model
}

func initialModel() model {
    items := []list.Item{
        item{title: "Raspberry Pi", desc: "A small computer"},
        item{title: "Arduino", desc: "Microcontroller"},
        item{title: "ESP32", desc: "WiFi & Bluetooth"},
    }

    l := list.New(items, list.NewDefaultDelegate(), 0, 0)
    l.Title = "Hardware"

    return model{list: l}
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.list.SetSize(msg.Width, msg.Height)

    case tea.KeyMsg:
        if msg.String() == "enter" {
            selected := m.list.SelectedItem().(item)
            // Use selected
        }
    }

    var cmd tea.Cmd
    m.list, cmd = m.list.Update(msg)
    return m, cmd
}
```

### Table

```go
import "github.com/charmbracelet/bubbles/table"

func initialModel() model {
    columns := []table.Column{
        {Title: "ID", Width: 4},
        {Title: "Name", Width: 10},
        {Title: "Status", Width: 10},
    }

    rows := []table.Row{
        {"1", "Alice", "Active"},
        {"2", "Bob", "Inactive"},
    }

    t := table.New(
        table.WithColumns(columns),
        table.WithRows(rows),
        table.WithFocused(true),
        table.WithHeight(7),
    )

    s := table.DefaultStyles()
    s.Header = s.Header.
        BorderStyle(lipgloss.NormalBorder()).
        BorderForeground(lipgloss.Color("240"))
    t.SetStyles(s)

    return model{table: t}
}
```

### Viewport (Scrolling)

```go
import "github.com/charmbracelet/bubbles/viewport"

type model struct {
    viewport viewport.Model
    content  string
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.viewport = viewport.New(msg.Width, msg.Height)
        m.viewport.SetContent(m.content)
    }

    var cmd tea.Cmd
    m.viewport, cmd = m.viewport.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return m.viewport.View()
}
```

### Progress

```go
import "github.com/charmbracelet/bubbles/progress"

type model struct {
    progress progress.Model
    percent  float64
}

func initialModel() model {
    return model{
        progress: progress.New(progress.WithDefaultGradient()),
        percent:  0.0,
    }
}

func (m model) View() string {
    return "\n" + m.progress.ViewAs(m.percent) + "\n\n"
}
```

## Lipgloss Styling

### Basic Styles

```go
import "github.com/charmbracelet/lipgloss"

var (
    // Define styles
    titleStyle = lipgloss.NewStyle().
        Bold(true).
        Foreground(lipgloss.Color("170")).
        Background(lipgloss.Color("235")).
        Padding(0, 1)

    errorStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("196")).
        Bold(true)

    successStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("42"))
)

// Use styles
title := titleStyle.Render("Hello")
err := errorStyle.Render("Error!")
```

### Colors

```go
// ANSI 16 colors
lipgloss.Color("5")      // magenta

// ANSI 256 colors
lipgloss.Color("86")     // aqua
lipgloss.Color("201")    // hot pink

// True color (hex)
lipgloss.Color("#0000FF") // blue
lipgloss.Color("#FF6B6B") // red

// Adaptive (light/dark)
lipgloss.AdaptiveColor{
    Light: "236",
    Dark:  "248",
}
```

### Layout & Spacing

```go
style := lipgloss.NewStyle().
    Width(50).
    Height(10).
    Padding(1, 2).       // top/bottom, left/right
    Margin(1, 2, 3, 4).  // top, right, bottom, left
    Align(lipgloss.Center)
```

### Borders

```go
style := lipgloss.NewStyle().
    Border(lipgloss.RoundedBorder()).
    BorderForeground(lipgloss.Color("63")).
    Padding(1, 2)

// Border types
lipgloss.NormalBorder()
lipgloss.RoundedBorder()
lipgloss.ThickBorder()
lipgloss.DoubleBorder()
lipgloss.HiddenBorder()

// Selective borders
style.BorderTop(true).
    BorderLeft(true)
```

### Joining Layouts

```go
// Horizontal
row := lipgloss.JoinHorizontal(
    lipgloss.Top,     // Alignment
    box1, box2, box3,
)

// Vertical
col := lipgloss.JoinVertical(
    lipgloss.Left,
    box1, box2, box3,
)

// Positions: Top, Center, Bottom, Left, Right
```

### Positioning

```go
// Place in whitespace
centered := lipgloss.Place(
    width, height,
    lipgloss.Center,    // horizontal
    lipgloss.Center,    // vertical
    content,
)
```

### Advanced Styling

```go
style := lipgloss.NewStyle().
    Bold(true).
    Italic(true).
    Underline(true).
    Strikethrough(true).
    Blink(true).
    Faint(true).
    Reverse(true)
```

## Huh Forms

Build interactive forms and prompts.

### Basic Form

```go
import "github.com/charmbracelet/huh"

var (
    burger   string
    toppings []string
    name     string
)

func runForm() error {
    form := huh.NewForm(
        huh.NewGroup(
            huh.NewSelect[string]().
                Title("Choose your burger").
                Options(
                    huh.NewOption("Classic", "classic"),
                    huh.NewOption("Chicken", "chicken"),
                    huh.NewOption("Veggie", "veggie"),
                ).
                Value(&burger),

            huh.NewMultiSelect[string]().
                Title("Toppings").
                Options(
                    huh.NewOption("Lettuce", "lettuce"),
                    huh.NewOption("Tomato", "tomato"),
                    huh.NewOption("Cheese", "cheese"),
                ).
                Limit(3).
                Value(&toppings),
        ),

        huh.NewGroup(
            huh.NewInput().
                Title("What's your name?").
                Value(&name).
                Validate(func(s string) error {
                    if s == "" {
                        return fmt.Errorf("name required")
                    }
                    return nil
                }),
        ),
    )

    return form.Run()
}
```

### Field Types

**Input** - Single line text:
```go
huh.NewInput().
    Title("Username").
    Placeholder("Enter username").
    Value(&username)
```

**Text** - Multi-line text:
```go
huh.NewText().
    Title("Description").
    CharLimit(400).
    Value(&description)
```

**Select** - Choose one:
```go
huh.NewSelect[string]().
    Title("Pick one").
    Options(
        huh.NewOption("Option 1", "opt1"),
        huh.NewOption("Option 2", "opt2"),
    ).
    Value(&choice)
```

**MultiSelect** - Choose multiple:
```go
huh.NewMultiSelect[string]().
    Title("Pick several").
    Options(...).
    Limit(3).
    Value(&choices)
```

**Confirm** - Yes/No:
```go
huh.NewConfirm().
    Title("Are you sure?").
    Affirmative("Yes").
    Negative("No").
    Value(&confirmed)
```

### Accessible Mode

```go
form := huh.NewForm(...)
form.WithAccessible(true)  // Screen reader friendly
```

### In Bubble Tea

```go
type model struct {
    form *huh.Form
}

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

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    form, cmd := m.form.Update(msg)
    if f, ok := form.(*huh.Form); ok {
        m.form = f
    }

    if m.form.State == huh.StateCompleted {
        // Form done, get values
        return m, tea.Quit
    }

    return m, cmd
}

func (m model) View() string {
    if m.form.State == huh.StateCompleted {
        return "Done!\n"
    }
    return m.form.View()
}
```

## CLY Project Patterns

### Module Structure

```
modules/demo/spinner/
├── cmd.go        # Register() and run()
└── spinner.go    # Bubbletea model
```

**cmd.go:**
```go
package spinner

import (
    tea "github.com/charmbracelet/bubbletea"
    "github.com/spf13/cobra"
)

func Register(parent *cobra.Command) {
    cmd := &cobra.Command{
        Use:   "spinner",
        Short: "Spinner demo",
        RunE:  run,
    }
    parent.AddCommand(cmd)
}

func run(cmd *cobra.Command, args []string) error {
    p := tea.NewProgram(initialModel())
    if _, err := p.Run(); err != nil {
        return err
    }
    return nil
}
```

**spinner.go:**
```go
package spinner

import (
    "github.com/charmbracelet/bubbles/spinner"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
)

type model struct {
    spinner spinner.Model
}

func initialModel() model {
    s := spinner.New()
    s.Spinner = spinner.Dot
    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
    return model{spinner: s}
}

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

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
        }
    }

    var cmd tea.Cmd
    m.spinner, cmd = m.spinner.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return m.spinner.View() + " Loading...\n"
}
```

## Common Patterns

### Loading State

```go
type model struct {
    loading bool
    spinner spinner.Model
    data    []string
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.String() == "r" {
            m.loading = true
            return m, fetchData
        }

    case dataMsg:
        m.loading = false
        m.data = msg.data
        return m, nil
    }

    if m.loading {
        var cmd tea.Cmd
        m.spinner, cmd = m.spinner.Update(msg)
        return m, cmd
    }

    return m, nil
}

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

### Error Handling

```go
type model struct {
    err error
}

type errMsg 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
    }
    return m, nil
}

func (m model) View() string {
    if m.err != nil {
        return errorStyle.Render("Error: " + m.err.Error())
    }
    return normalView()
}
```

### Multi-View Navigation

```go
type view int

const (
    viewMenu view = iota
    viewList
    viewDetail
)

type model struct {
    currentView view
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "1":
            m.currentView = viewMenu
        case "2":
            m.currentView = viewList
        case "3":
            m.currentView = viewDetail
        }
    }
    return m, nil
}

func (m model) View() string {
    switch m.currentView {
    case viewMenu:
        return renderMenu()
    case viewList:
        return renderList()
    case viewDetail:
        return renderDetail()
    }
    return ""
}
```

## Best Practices

### Model Immutability

**✅ GOOD - Return new state:**
```go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    m.counter++  // Modify copy
    return m, nil
}
```

**❌ BAD - Mutate pointer:**
```go
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    m.counter++  // Mutates original
    return m, nil
}
```

### Quit Handling

Always handle quit signals:
```go
case tea.KeyMsg:
    switch msg.String() {
    case "ctrl+c", "q", "esc":
        return m, tea.Quit
    }
```

### Alt Screen

Use alt screen for full-screen apps:
```go
p := tea.NewProgram(
    initialModel(),
    tea.WithAltScreen(),
)
```

### Component Composition

Embed Bubbles components:
```go
type model struct {
    spinner   spinner.Model
    textInput textinput.Model
    list      list.Model
}

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

    m.spinner, cmd = m.spinner.Update(msg)
    cmds = append(cmds, cmd)

    m.textInput, cmd = m.textInput.Update(msg)
    cmds = append(cmds, cmd)

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

## Checklist

- [ ] Model contains all state
- [ ] Init returns initial command
- [ ] Update handles all message types
- [ ] Update returns new model (immutable)
- [ ] View is pure function
- [ ] Quit handling present
- [ ] Window resize handled
- [ ] Commands for async ops
- [ ] Bubbles components updated
- [ ] Lipgloss for all styling
- [ ] Follows CLY module structure

## Resources

- [Bubbletea Tutorial](https://github.com/charmbracelet/bubbletea/tree/main/tutorials)
- [Bubbletea Examples](https://github.com/charmbracelet/bubbletea/tree/main/examples)
- [Bubbles Components](https://github.com/charmbracelet/bubbles)
- [Lipgloss Docs](https://github.com/charmbracelet/lipgloss)
- [Huh Forms](https://github.com/charmbracelet/huh)
- CLY examples: `modules/demo/*/`

Quick Install

$npx ai-builder add skill yurifrl/charm-stack

Details

Type
skill
Author
yurifrl
Slug
yurifrl/charm-stack
Created
6d ago