| 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.Linespinner.Dotspinner.MiniDotspinner.Jumpspinner.Pulsespinner.Pointsspinner.Globespinner.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
- Bubbletea Tutorial
- Bubbletea Examples
- Bubbles Components
- Lipgloss Docs
- Huh Forms
- CLY examples:
modules/demo/*/