charm-stack

Charm Stack TUI Development

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "charm-stack" with this command: npx skills add yurifrl/cly/yurifrl-cly-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

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.NewSelectstring. 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.NewSelectstring. Title("Pick one"). Options( huh.NewOption("Option 1", "opt1"), huh.NewOption("Option 2", "opt2"), ). Value(&choice)

MultiSelect - Choose multiple:

huh.NewMultiSelectstring. 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

  • Bubbletea Tutorial

  • Bubbletea Examples

  • Bubbles Components

  • Lipgloss Docs

  • Huh Forms

  • CLY examples: modules/demo/*/

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

cli-config

No summary provided by upstream source.

Repository SourceNeeds Review
General

cobra-modularity

No summary provided by upstream source.

Repository SourceNeeds Review
General

add-module

No summary provided by upstream source.

Repository SourceNeeds Review