skills/yurifrl/cly/charm-stack

charm-stack

SKILL.md

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

Weekly Installs
20
Repository
yurifrl/cly
First Seen
Feb 15, 2026
Installed on
gemini-cli20
github-copilot19
codex19
opencode19
claude-code18
amp18