Claude Code Plugins

Community-maintained marketplace

Feedback

charm-stack

@yurifrl/cly
0
0

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.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name charm-stack
description 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.

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

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

Init - Initial command

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

Update - Handle messages, update state

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

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

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:

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:

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:

return m, tea.Batch(
    cmd1,
    cmd2,
    cmd3,
)

Tick command (for animations):

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

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

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

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

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

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

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)

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

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

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

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

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

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

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

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

Advanced Styling

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

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:

huh.NewInput().
    Title("Username").
    Placeholder("Enter username").
    Value(&username)

Text - Multi-line text:

huh.NewText().
    Title("Description").
    CharLimit(400).
    Value(&description)

Select - Choose one:

huh.NewSelect[string]().
    Title("Pick one").
    Options(
        huh.NewOption("Option 1", "opt1"),
        huh.NewOption("Option 2", "opt2"),
    ).
    Value(&choice)

MultiSelect - Choose multiple:

huh.NewMultiSelect[string]().
    Title("Pick several").
    Options(...).
    Limit(3).
    Value(&choices)

Confirm - Yes/No:

huh.NewConfirm().
    Title("Are you sure?").
    Affirmative("Yes").
    Negative("No").
    Value(&confirmed)

Accessible Mode

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

In Bubble Tea

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:

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:

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

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

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

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:

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    m.counter++  // Modify copy
    return m, nil
}

❌ BAD - Mutate pointer:

func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    m.counter++  // Mutates original
    return m, nil
}

Quit Handling

Always handle quit signals:

case tea.KeyMsg:
    switch msg.String() {
    case "ctrl+c", "q", "esc":
        return m, tea.Quit
    }

Alt Screen

Use alt screen for full-screen apps:

p := tea.NewProgram(
    initialModel(),
    tea.WithAltScreen(),
)

Component Composition

Embed Bubbles components:

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