
Building Glamorous Tuis
- 72 installs
- 185 repo stars
- Updated August 4, 2026
- dicklesworthstone/meta_skill
Builds terminal UIs with Charmbracelet tools (Bubble Tea, Lip Gloss, Gum) including layouts, async rendering, and data visualizations.
About
Guides building Go TUIs and shell-script prompts with Charmbracelet libraries, covering components, adaptive layouts, focus state machines, and performance patterns. A developer uses it to make a CLI prettier or build a production-grade terminal app.
- Router maps tasks to Gum, Bubble Tea, Lip Gloss, or Wish over SSH
- Elite patterns: two-phase async, immutable snapshots, viewport virtualization
Building Glamorous Tuis by the numbers
- 72 all-time installs (skills.sh)
- Ranked #273 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dicklesworthstone/meta_skill --skill building-glamorous-tuisAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 72 |
|---|---|
| repo stars | ★ 185 |
| Last updated | August 4, 2026 |
| Repository | dicklesworthstone/meta_skill ↗ |
What it does
Builds terminal UIs with Charmbracelet tools (Bubble Tea, Lip Gloss, Gum) including layouts, async rendering, and data visualizations.
Files
Building Glamorous TUIs with Charmbracelet
Quick Router — Start Here
| I need to... | Use | Reference |
|---|---|---|
| Add prompts/spinners to a shell script | Gum (no Go) | Shell Scripts |
| Build a Go TUI | Bubble Tea + Lip Gloss | Go TUI |
| Build a production-grade Go TUI | Above + elite patterns | Production Architecture |
| Serve a TUI over SSH | Wish + Bubble Tea | Infrastructure |
| Record a terminal demo | VHS | Shell Scripts |
| Find a Bubbles component | list, table, viewport, spinner, progress... | Component Catalog |
| Get a copy-paste pattern | Layouts, forms, animation, testing | Quick Reference / Advanced Patterns |
---
Decision Guide
Is it a shell script?
├─ Yes → Use Gum
│ Need recording? → VHS
│ Need AI? → Mods
│
└─ No (Go application)
│
├─ Just styled output? → Lip Gloss only
├─ Simple prompts/forms? → Huh standalone
├─ Full interactive TUI? → Bubble Tea + Bubbles + Lip Gloss
│ │
│ └─ Production-grade? → Also add elite patterns:
│ (multi-view, data- two-phase async, immutable snapshots,
│ dense, must be adaptive layout, focus state machine,
│ fast & polished) semantic theming, pre-computed styles
│ → See Production Architecture reference
│
└─ Need SSH access? → Wish + Bubble Tea---
Shell Scripts (No Go Required)
brew install gum # One-time install# Input
NAME=$(gum input --placeholder "Your name")
# Selection
COLOR=$(gum choose "red" "green" "blue")
# Fuzzy filter from stdin
BRANCH=$(git branch | gum filter)
# Confirmation
gum confirm "Continue?" && echo "yes"
# Spinner
gum spin --title "Working..." -- long-command
# Styled output
gum style --border rounded --padding "1 2" "Hello"[Full Gum Reference →](references/shell-scripts.md#gum-the-essential-tool) [VHS Recording →](references/shell-scripts.md#vhs-terminal-recording) [Mods AI →](references/shell-scripts.md#mods-ai-in-terminal)
---
Go Applications
go get github.com/charmbracelet/bubbletea github.com/charmbracelet/lipglossMinimal TUI (Copy & Run)
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var highlight = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
type model struct {
items []string
cursor int
}
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 "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 { m.cursor-- }
case "down", "j":
if m.cursor < len(m.items)-1 { m.cursor++ }
case "enter":
fmt.Printf("Selected: %s\n", m.items[m.cursor])
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
s := ""
for i, item := range m.items {
if i == m.cursor {
s += highlight.Render("▸ "+item) + "\n"
} else {
s += " " + item + "\n"
}
}
return s + "\n(↑/↓ move, enter select, q quit)"
}
func main() {
m := model{items: []string{"Option A", "Option B", "Option C"}}
tea.NewProgram(m).Run()
}Library Cheat Sheet
| Need | Library | Example |
|---|---|---|
| TUI framework | bubbletea | tea.NewProgram(model).Run() |
| Components | bubbles | list.New(), textinput.New() |
| Styling | lipgloss | style.Foreground(lipgloss.Color("212")) |
| Forms | huh | huh.NewInput().Title("Name").Run() |
| Markdown | glamour | glamour.Render(md, "dark") |
| Animation | harmonica | harmonica.NewSpring() |
[Full Go TUI Guide →](references/go-tui.md) [All Bubbles Components →](references/component-catalog.md) [Layout & Animation Patterns →](references/advanced-patterns.md)
---
SSH Apps (Infrastructure)
s, _ := wish.NewServer(
wish.WithAddress(":2222"),
wish.WithHostKeyPath(".ssh/key"),
wish.WithMiddleware(
bubbletea.Middleware(handler),
logging.Middleware(),
),
)
s.ListenAndServe()Connect: ssh localhost -p 2222
[Full Infrastructure Guide →](references/infrastructure.md)
---
Production TUI Architecture (Elite Patterns)
Beyond basic Bubble Tea: patterns that make TUIs feel fast, polished, and professional. Each links to a full code example in Production Architecture.
My TUI is slow or janky
| Symptom | Pattern | Fix |
|---|---|---|
| UI blocks during computation | Two-Phase Async | Phase 1 instant, Phase 2 background goroutine |
| Render path holds mutex | Immutable Snapshots | Pre-build snapshot, atomic pointer swap |
| File changes cause stutter | Background Worker | Debounced watcher + coalescing |
| Thousands of allocs per frame | Pre-Computed Styles | Allocate delegate styles once at startup |
| O(n²) string concat in View() | strings.Builder | Pre-allocated Builder with Grow() |
| Glamour re-renders every frame | Cached Markdown | Cache by content hash, invalidate on width change |
| GC pauses during interaction | Idle-Time GC | Trigger GC during idle periods |
| Large dataset = high memory | Object Pooling | sync.Pool with pre-allocated slices |
| Rendering off-screen items | Viewport Virtualization | Only render visible rows |
My layout breaks on different terminals
| Symptom | Pattern | Fix |
|---|---|---|
| Hardcoded widths break | Adaptive Layout | 3-4 responsive breakpoints (80/100/140/180 cols) |
| Colors wrong on light terminals | Semantic Theming | lipgloss.AdaptiveColor + WCAG AA contrast |
| Items have equal priority → list shuffles | Deterministic Sorting | Stable sort with tie-breaking secondary key |
| Sort mode not visible | Dynamic Status Bar | Left/right segments with gap-fill |
My TUI has multiple views and it's getting messy
| Symptom | Pattern | Fix |
|---|---|---|
| Key routing chaos | Focus State Machine | Explicit focus enum + modal priority layer |
| User gets lost in nested views | Breadcrumb Navigation | Home > Board > Priority path indicator |
| Overlay dismiss loses position | Focus Restoration | Save focus before overlay, restore on dismiss |
| Old async results overwrite new data | Stale Message Detection | Compare data hash before applying results |
| Multiple component updates per frame | tea.Batch Accumulation | Collect cmds in slice, return tea.Batch(cmds...) |
| Background goroutine panic kills TUI | Error Recovery | defer/recover wrapper for all goroutines |
I want to add data-rich visualizations
| Want | Pattern | Code |
|---|---|---|
| Bar charts in list columns | Unicode Sparklines | ▇▅▂ using 8-level block characters |
| Color-by-intensity | Perceptual Heatmaps | gray → blue → purple → pink gradient |
| Dependency graph in terminal | ASCII Graph Renderer | Canvas + Manhattan routing (╭─╮│╰╯) |
| Age at a glance | Age Color Coding | Fresh=green, aging=yellow, stale=red |
| Borders that mean something | Semantic Borders | Red=blocked, green=ready, yellow=high-impact |
I want my TUI to feel polished and professional
| Want | Pattern | Key Idea |
|---|---|---|
Vim-style gg/G | Vim Key Combos | Track waitingForG state between keystrokes |
| Search without jank | Debounced Search | 150ms timer, fire only when typing stops |
| Search across all fields at once | Composite FilterValue | Flatten all fields into one string |
| 4-line cards with metadata | Rich Delegates | Custom delegate with Height()=4 |
| Expand detail inline | Inline Expansion | Toggle with d, auto-collapse on j/k |
| Copy to clipboard | Clipboard Integration | y for ID, C for markdown + toast feedback |
? / ` ` / ;` help | Multi-Tier Help | Quick ref + tutorial + persistent sidebar |
| Kanban with mode switching | Kanban Swimlanes | Pre-computed board states, O(1) switch |
| Collapsible tree with h/l | Tree Navigation | Flatten tree to visible list for j/k nav |
| Suspend TUI for vim edit | Editor Dispatch | tea.ExecProcess for terminal, background for GUI |
| Remember expand/collapse | Persistent State | Save to JSON, graceful degradation on corrupt |
| Tune via env vars | Env Preferences | NO_COLOR, theme, debounce, split ratio |
| Optional feature missing? | Graceful Degradation | Detect at startup, hide unavailable features |
[Full Production Architecture Guide →](references/production-architecture.md)
---
Pre-Flight Checklist (Every TUI)
- [ ] Handle
tea.WindowSizeMsg— resize all components - [ ] Handle
ctrl+c— cleanup, restore terminal state - [ ] Detect piped stdin/stdout — fall back to plain text
- [ ] Test on 80×24 minimum terminal
- [ ] Provide
--no-tui/NO_TUIescape hatch - [ ] Test with both light AND dark backgrounds
- [ ] Test with
NO_COLOR=1andTERM=dumb
For production TUIs, see the full checklist (16 must-have + 20 polish items).
---
When NOT to Use Charm
- Output is piped:
mytool | grep→ plain text - CI/CD: No terminal → use flags/env vars
- One simple prompt: Maybe
fmt.Scanfis fine
Escape hatch:
if !term.IsTerminal(os.Stdin.Fd()) || os.Getenv("NO_TUI") != "" {
runPlainMode()
return
}---
All References
| I need... | Read this |
|---|---|
| Copy-paste one-liners | Quick Reference |
| Prompts to give Claude for TUI tasks | Prompts |
| Gum / VHS / Mods / Freeze / Glow | Shell Scripts |
| Bubble Tea architecture, debugging, anti-patterns | Go TUI |
| Bubbles component APIs (list, table, viewport...) | Component Catalog |
| Theming, layouts, animation, Huh forms, testing | Advanced Patterns |
| Elite patterns: async, snapshots, focus machines, adaptive layout, sparklines, kanban, trees, caching | Production Architecture |
| Wish SSH server, Soft Serve, teatest | Infrastructure |
Advanced Charm Patterns
Deep-dive reference for production Charm applications.
---
Table of Contents
- Complete Bubble Tea App Template
- Lip Gloss Layout Patterns
- Three-Column Dashboard
- Modal Overlay
- Status Bar
- Huh Advanced Forms
- Dynamic Options
- Validation Chains
- Custom Themes
- Harmonica Animation Recipes
- Wish SSH App Patterns
- Testing Patterns
- Performance Tips
---
Complete Bubble Tea App Template
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
)
// ─────────────────────────────────────────────────────────────
// Theme (define once, use everywhere)
// ─────────────────────────────────────────────────────────────
var (
subtle = lipgloss.AdaptiveColor{Light: "236", Dark: "248"}
highlight = lipgloss.AdaptiveColor{Light: "205", Dark: "212"}
special = lipgloss.AdaptiveColor{Light: "39", Dark: "86"}
titleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1)
infoStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderTop(true).
BorderForeground(subtle)
docStyle = lipgloss.NewStyle().Padding(1, 2)
)
// ─────────────────────────────────────────────────────────────
// Model
// ─────────────────────────────────────────────────────────────
type screen int
const (
screenLoading screen = iota
screenMain
screenDetail
)
type model struct {
screen screen
width int
height int
// Components
spinner spinner.Model
list list.Model
viewport viewport.Model
// State
loading bool
err error
}
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return model{
screen: screenLoading,
spinner: s,
loading: true,
}
}
// ─────────────────────────────────────────────────────────────
// Messages
// ─────────────────────────────────────────────────────────────
type dataLoadedMsg struct {
items []list.Item
}
type errMsg struct {
err error
}
// ─────────────────────────────────────────────────────────────
// Commands
// ─────────────────────────────────────────────────────────────
func loadData() tea.Msg {
// Simulate async data fetch
// In reality: HTTP call, DB query, etc.
items := []list.Item{
item{title: "Item 1", desc: "Description 1"},
item{title: "Item 2", desc: "Description 2"},
}
return dataLoadedMsg{items: items}
}
// ─────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────
func (m model) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
loadData,
)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.list.SetSize(m.width, m.height-4)
m.viewport.Width = m.width
m.viewport.Height = m.height - 6
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "esc":
if m.screen == screenDetail {
m.screen = screenMain
}
case "enter":
if m.screen == screenMain {
m.screen = screenDetail
// Load detail content into viewport
}
}
case spinner.TickMsg:
if m.loading {
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
}
case dataLoadedMsg:
m.loading = false
m.screen = screenMain
m.list = list.New(msg.items, list.NewDefaultDelegate(), m.width, m.height-4)
m.list.Title = "My Items"
case errMsg:
m.loading = false
m.err = msg.err
}
// Delegate to active component
switch m.screen {
case screenMain:
m.list, cmd = m.list.Update(msg)
cmds = append(cmds, cmd)
case screenDetail:
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m model) View() string {
if m.err != nil {
return fmt.Sprintf("Error: %v\n\nPress q to quit.", m.err)
}
switch m.screen {
case screenLoading:
return fmt.Sprintf("\n\n %s Loading...\n\n", m.spinner.View())
case screenMain:
return docStyle.Render(m.list.View())
case screenDetail:
return docStyle.Render(
titleStyle.Render("Detail View") + "\n\n" +
m.viewport.View() + "\n\n" +
infoStyle.Render("↑/↓: scroll • esc: back • q: quit"),
)
}
return ""
}
// ─────────────────────────────────────────────────────────────
// List item implementation
// ─────────────────────────────────────────────────────────────
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 }
// ─────────────────────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────────────────────
func main() {
// Enable debug logging to file
if os.Getenv("DEBUG") != "" {
f, _ := tea.LogToFile("debug.log", "debug")
defer f.Close()
}
p := tea.NewProgram(
initialModel(),
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
}---
Lip Gloss Layout Patterns
Three-Column Dashboard
func (m model) View() string {
// Calculate column widths
sidebarWidth := 25
mainWidth := m.width - sidebarWidth*2 - 4 // -4 for borders
// Style definitions
sidebarStyle := lipgloss.NewStyle().
Width(sidebarWidth).
Height(m.height - 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(subtle)
mainStyle := lipgloss.NewStyle().
Width(mainWidth).
Height(m.height - 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(highlight)
// Render columns
leftSidebar := sidebarStyle.Render(m.nav.View())
mainContent := mainStyle.Render(m.content.View())
rightSidebar := sidebarStyle.Render(m.details.View())
// Join horizontally
return lipgloss.JoinHorizontal(lipgloss.Top,
leftSidebar,
mainContent,
rightSidebar,
)
}Modal Overlay
func (m model) View() string {
// Base content
base := m.mainContent.View()
if !m.showModal {
return base
}
// Modal dimensions
modalWidth := 60
modalHeight := 20
// Modal style
modalStyle := lipgloss.NewStyle().
Width(modalWidth).
Height(modalHeight).
Border(lipgloss.DoubleBorder()).
BorderForeground(lipgloss.Color("205")).
Padding(1, 2)
modal := modalStyle.Render(m.modalContent)
// Center modal in viewport
modalX := (m.width - modalWidth) / 2
modalY := (m.height - modalHeight) / 2
// Overlay (Lip Gloss v2 has native overlay; v1 uses string manipulation)
return placeOverlay(modalX, modalY, modal, base)
}
// Simple overlay for v1 (v2 has lipgloss.Overlay)
func placeOverlay(x, y int, overlay, base string) string {
return lipgloss.Place(
lipgloss.Width(base),
lipgloss.Height(base),
lipgloss.Center, lipgloss.Center,
overlay,
)
}Status Bar
func statusBar(width int, mode string, filename string, modified bool) string {
modeStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color("#FF5F87")).
Padding(0, 1)
fileStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color("#6124DF")).
Padding(0, 1)
infoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color("#A550DF")).
Padding(0, 1)
modeStr := modeStyle.Render(mode)
name := filename
if modified {
name += " [+]"
}
fileStr := fileStyle.Render(name)
info := infoStyle.Render("UTF-8 | LF")
// Calculate gap
gap := width - lipgloss.Width(modeStr) - lipgloss.Width(fileStr) - lipgloss.Width(info)
if gap < 0 {
gap = 0
}
return modeStr + strings.Repeat(" ", gap) + fileStr + info
}---
Huh Advanced Forms
Dynamic Options
var (
country string
state string
)
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Country").
Options(
huh.NewOption("USA", "us"),
huh.NewOption("Canada", "ca"),
).
Value(&country),
huh.NewSelect[string]().
Title("State/Province").
OptionsFunc(func() []huh.Option[string] {
switch country {
case "us":
return huh.NewOptions("California", "New York", "Texas")
case "ca":
return huh.NewOptions("Ontario", "Quebec", "BC")
default:
return nil
}
}, &country). // Re-evaluate when country changes
Value(&state),
),
)Validation Chains
huh.NewInput().
Title("Email").
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("email required")
}
if !strings.Contains(s, "@") {
return fmt.Errorf("invalid email format")
}
if !strings.HasSuffix(s, ".com") && !strings.HasSuffix(s, ".org") {
return fmt.Errorf("must be .com or .org")
}
return nil
}).
Value(&email)Custom Themes
theme := huh.ThemeBase()
theme.Focused.Title = theme.Focused.Title.Foreground(lipgloss.Color("205"))
theme.Focused.Description = theme.Focused.Description.Foreground(lipgloss.Color("240"))
form.WithTheme(theme)---
Harmonica Animation Recipes
Smooth Scroll Position
type model struct {
scrollY float64
scrollVel float64
targetScrollY float64
spring harmonica.Spring
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "j", "down":
m.targetScrollY += 3 // Scroll down
return m, tick()
case "k", "up":
m.targetScrollY -= 3 // Scroll up
return m, tick()
}
case tickMsg:
m.scrollY, m.scrollVel = m.spring.Update(m.scrollY, m.scrollVel, m.targetScrollY)
// Stop ticking when settled
if math.Abs(m.scrollY-m.targetScrollY) < 0.01 && math.Abs(m.scrollVel) < 0.01 {
return m, nil
}
return m, tick()
}
return m, nil
}Progress Bar with Overshoot
type model struct {
progress float64
progressVel float64
target float64
spring harmonica.Spring
}
func newModel() model {
// Under-damped spring for bounce effect
return model{
spring: harmonica.NewSpring(harmonica.FPS(60), 8.0, 0.3),
}
}
func (m model) View() string {
percent := m.progress / 100.0
if percent > 1 {
percent = 1 // Clamp for render (but physics can overshoot)
}
if percent < 0 {
percent = 0
}
return progressBar.ViewAs(percent)
}Cursor Position Animation
type model struct {
cursorX, cursorXVel float64
cursorY, cursorYVel float64
targetX, targetY float64
spring harmonica.Spring
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tickMsg:
m.cursorX, m.cursorXVel = m.spring.Update(m.cursorX, m.cursorXVel, m.targetX)
m.cursorY, m.cursorYVel = m.spring.Update(m.cursorY, m.cursorYVel, m.targetY)
return m, tick()
}
return m, nil
}
func (m model) View() string {
// Round to integer for terminal position
x := int(math.Round(m.cursorX))
y := int(math.Round(m.cursorY))
return placeCursor(x, y, m.content)
}---
Wish SSH App Patterns
Per-User State
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pty, _, _ := s.Pty()
// User-specific initialization
user := s.User()
pubKey := s.PublicKey()
// Load user's saved state (from DB, file, etc.)
savedState := loadUserState(user)
return model{
user: user,
pubKey: pubKey,
width: pty.Window.Width,
height: pty.Window.Height,
state: savedState,
}, []tea.ProgramOption{tea.WithAltScreen()}
}Multi-Room Chat
type server struct {
rooms map[string]*room
mu sync.RWMutex
}
type room struct {
name string
clients map[string]*client
msgs chan message
}
func (srv *server) middleware() wish.Middleware {
return func(next ssh.Handler) ssh.Handler {
return func(s ssh.Session) {
roomName := s.Command()[0] // e.g., ssh server join #general
if roomName == "" {
roomName = "lobby"
}
room := srv.getOrCreateRoom(roomName)
client := room.addClient(s.User(), s)
p := tea.NewProgram(
chatModel{room: room, client: client},
tea.WithInput(s),
tea.WithOutput(s),
tea.WithAltScreen(),
)
p.Run()
room.removeClient(client)
}
}
}Rate Limiting Middleware
func rateLimitMiddleware(rps float64) wish.Middleware {
limiter := rate.NewLimiter(rate.Limit(rps), int(rps))
return func(next ssh.Handler) ssh.Handler {
return func(s ssh.Session) {
if !limiter.Allow() {
wish.Println(s, "Rate limited. Try again later.")
return
}
next(s)
}
}
}---
Testing Patterns
Headless Bubble Tea Tests
func TestApp(t *testing.T) {
m := initialModel()
// Simulate window size
m, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
// Simulate keypress
m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
// Assert state
model := m.(model)
if model.cursor != 1 {
t.Errorf("expected cursor=1, got %d", model.cursor)
}
// Assert view contains expected content
view := m.View()
if !strings.Contains(view, "Expected Text") {
t.Errorf("view missing expected content")
}
}Golden File Tests for Views
func TestView_GoldenFile(t *testing.T) {
m := model{
items: []string{"A", "B", "C"},
cursor: 1,
}
got := m.View()
golden := filepath.Join("testdata", "view.golden")
if os.Getenv("UPDATE_GOLDEN") != "" {
os.WriteFile(golden, []byte(got), 0644)
}
want, _ := os.ReadFile(golden)
if got != string(want) {
t.Errorf("view mismatch:\n%s", diff(string(want), got))
}
}---
Performance Tips
1. Minimize allocations in View(): Pre-allocate strings.Builder 2. Cache Glamour output: Render markdown once, not every frame 3. Batch component updates: Single tea.Batch, not multiple returns 4. Use viewport for long content: Don't render off-screen lines 5. Profile with pprof: go tool pprof http://localhost:6060/debug/pprof/profile
// Cache expensive renders
type model struct {
cachedMarkdown string
markdownDirty bool
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case contentChangedMsg:
m.markdownDirty = true
}
return m, nil
}
func (m model) View() string {
if m.markdownDirty {
m.cachedMarkdown, _ = glamour.Render(m.content, "dark")
m.markdownDirty = false
}
return m.cachedMarkdown
}Bubbles Component Catalog
Quick reference for all Bubbles components with key APIs and patterns.
---
Table of Contents
- Text Input
- Text Area
- List
- Table
- Viewport
- Spinner
- Progress
- File Picker
- Paginator
- Help
- Timer
- Stopwatch
- Key Bindings
---
Text Input
Package: github.com/charmbracelet/bubbles/textinput
import "github.com/charmbracelet/bubbles/textinput"
ti := textinput.New()
ti.Placeholder = "Type here..."
ti.Focus()
ti.CharLimit = 156
ti.Width = 40
// Styling
ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
ti.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
// Password mode
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '•'
// In Update
ti, cmd = ti.Update(msg)
// Get value
value := ti.Value()
// Reset
ti.SetValue("")
ti.Reset()Key Methods:
Focus()/Blur()- Control focus stateValue()/SetValue(string)- Get/set textCharLimit- Max charactersWidth- Display widthEchoMode- Normal, Password, or None
---
Text Area
Package: github.com/charmbracelet/bubbles/textarea
import "github.com/charmbracelet/bubbles/textarea"
ta := textarea.New()
ta.Placeholder = "Write something..."
ta.Focus()
ta.SetWidth(80)
ta.SetHeight(10)
ta.CharLimit = 1000
// Line numbers
ta.ShowLineNumbers = true
// Styling
ta.FocusedStyle.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color("236"))
// In Update
ta, cmd = ta.Update(msg)
// Get value
value := ta.Value()
// Set value
ta.SetValue("Initial content\nLine 2")Key Methods:
SetWidth(int)/SetHeight(int)- DimensionsValue()/SetValue(string)- ContentLine()/LineCount()- Current line infoCursorDown()/CursorUp()- Programmatic cursor movement
---
List
Package: github.com/charmbracelet/bubbles/list
import "github.com/charmbracelet/bubbles/list"
// Define item type implementing list.Item interface
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 }
// Create list
items := []list.Item{
item{"First", "Description 1"},
item{"Second", "Description 2"},
}
l := list.New(items, list.NewDefaultDelegate(), 40, 20)
l.Title = "My List"
l.SetShowStatusBar(true)
l.SetFilteringEnabled(true)
// Styling
l.Styles.Title = lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Bold(true)
// In Update
l, cmd = l.Update(msg)
// Get selected item
if i, ok := l.SelectedItem().(item); ok {
// Use i
}
// Update items
l.SetItems(newItems)Key Methods:
SelectedItem()- Get current selectionIndex()- Current indexSetItems([]list.Item)- Replace itemsInsertItem(index, item)- Add itemRemoveItem(index)- Remove itemSetSize(w, h)- DimensionsSetFilteringEnabled(bool)- Toggle fuzzy filter
Delegate Customization:
d := list.NewDefaultDelegate()
d.Styles.SelectedTitle = lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Bold(true)
d.Styles.SelectedDesc = lipgloss.NewStyle().
Foreground(lipgloss.Color("240"))
l := list.New(items, d, width, height)---
Table
Package: github.com/charmbracelet/bubbles/table
import "github.com/charmbracelet/bubbles/table"
columns := []table.Column{
{Title: "ID", Width: 4},
{Title: "Name", Width: 20},
{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(10),
)
// Styling
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
t.SetStyles(s)
// In Update
t, cmd = t.Update(msg)
// Get selected row
row := t.SelectedRow()
// Update data
t.SetRows(newRows)
t.SetColumns(newColumns)Key Methods:
SelectedRow()- Get selected row dataCursor()- Current row indexSetRows([]table.Row)- Replace rowsSetWidth(int)/SetHeight(int)- DimensionsFocus()/Blur()- Focus controlGotoTop()/GotoBottom()- Navigation
---
Viewport
Package: github.com/charmbracelet/bubbles/viewport
import "github.com/charmbracelet/bubbles/viewport"
vp := viewport.New(80, 20)
vp.SetContent(longContent)
// Mouse wheel scrolling
vp.MouseWheelEnabled = true
vp.MouseWheelDelta = 3
// Styling
vp.Style = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
// In Update
vp, cmd = vp.Update(msg)
// Programmatic scroll
vp.GotoTop()
vp.GotoBottom()
vp.LineDown(5)
vp.LineUp(5)
vp.HalfViewDown()
vp.HalfViewUp()
// Scroll info
percent := vp.ScrollPercent()
atTop := vp.AtTop()
atBottom := vp.AtBottom()Key Methods:
SetContent(string)- Set scrollable contentWidth/Height- Dimensions (set directly)YOffset- Current scroll positionScrollPercent()- 0.0-1.0 scroll progressAtTop()/AtBottom()- Boundary checks
Pattern: Glamour + Viewport
md, _ := glamour.Render(markdownContent, "dark")
vp.SetContent(md)---
Spinner
Package: github.com/charmbracelet/bubbles/spinner
import "github.com/charmbracelet/bubbles/spinner"
s := spinner.New()
s.Spinner = spinner.Dot // See presets below
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
// In Init (start spinning)
func (m model) Init() tea.Cmd {
return m.spinner.Tick
}
// In Update
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmdSpinner Presets:
spinner.Linespinner.Dotspinner.MiniDotspinner.Jumpspinner.Pulsespinner.Pointsspinner.Globespinner.Moonspinner.Monkeyspinner.Meterspinner.Hamburger
Custom Spinner:
s.Spinner = spinner.Spinner{
Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
FPS: time.Second / 10,
}---
Progress
Package: github.com/charmbracelet/bubbles/progress
import "github.com/charmbracelet/bubbles/progress"
// Gradient style
p := progress.New(progress.WithDefaultGradient())
// Solid color
p := progress.New(progress.WithSolidFill("#7D56F4"))
// Custom gradient
p := progress.New(progress.WithGradient("#7D56F4", "#FF5F87"))
// Width
p.Width = 40
// Without percentage display
p.ShowPercentage = false
// Render at percentage (0.0-1.0)
view := p.ViewAs(0.75)
// Or set percent and use View()
p.SetPercent(0.75)
view := p.View()Animation Pattern:
type model struct {
progress progress.Model
percent float64
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case progress.FrameMsg:
progressModel, cmd := m.progress.Update(msg)
m.progress = progressModel.(progress.Model)
return m, cmd
case downloadProgressMsg:
m.percent = msg.percent
return m, m.progress.SetPercent(m.percent)
}
return m, nil
}---
File Picker
Package: github.com/charmbracelet/bubbles/filepicker
import "github.com/charmbracelet/bubbles/filepicker"
fp := filepicker.New()
fp.AllowedTypes = []string{".txt", ".md", ".go"}
fp.CurrentDirectory, _ = os.UserHomeDir()
fp.ShowHidden = false
fp.ShowSize = true
fp.ShowPermissions = false
// Styling
fp.Styles.Selected = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
// In Init
func (m model) Init() tea.Cmd {
return m.filepicker.Init()
}
// In Update
fp, cmd = fp.Update(msg)
// Check for selection
if didSelect, path := fp.DidSelectFile(msg); didSelect {
// User selected path
}
if didSelect, path := fp.DidSelectDisabledFile(msg); didSelect {
// User tried to select disallowed file type
}Key Properties:
AllowedTypes- Whitelist extensionsCurrentDirectory- Starting directoryShowHidden- Show dotfilesDirAllowed- Allow directory selectionFileAllowed- Allow file selection
---
Paginator
Package: github.com/charmbracelet/bubbles/paginator
import "github.com/charmbracelet/bubbles/paginator"
p := paginator.New()
p.Type = paginator.Dots // or paginator.Arabic
p.PerPage = 10
p.SetTotalPages(len(items) / p.PerPage)
// Styling
p.ActiveDot = lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Render("●")
p.InactiveDot = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Render("○")
// In Update
p, cmd = p.Update(msg)
// Navigation
p.NextPage()
p.PrevPage()
p.Page = 3 // Jump to page
// Get items for current page
start, end := p.GetSliceBounds(len(items))
pageItems := items[start:end]Render:
func (m model) View() string {
start, end := m.paginator.GetSliceBounds(len(m.items))
var b strings.Builder
for _, item := range m.items[start:end] {
b.WriteString(item.Render())
b.WriteRune('\n')
}
b.WriteString("\n")
b.WriteString(m.paginator.View())
return b.String()
}---
Help
Package: github.com/charmbracelet/bubbles/help
import (
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
)
// Define key bindings
type keyMap struct {
Up key.Binding
Down key.Binding
Help key.Binding
Quit key.Binding
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down},
{k.Help, k.Quit},
}
}
var keys = keyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
}
// Create help model
h := help.New()
h.Width = 80 // Wrap at width
// Toggle full/short
h.ShowAll = true // Full help
h.ShowAll = false // Short help
// Render
helpView := h.View(keys)Styling:
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
h.Styles.FullKey = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))---
Timer
Package: github.com/charmbracelet/bubbles/timer
import "github.com/charmbracelet/bubbles/timer"
t := timer.NewWithInterval(5*time.Minute, time.Second)
// In Init (start timer)
func (m model) Init() tea.Cmd {
return m.timer.Init()
}
// In Update
case timer.TickMsg:
var cmd tea.Cmd
m.timer, cmd = m.timer.Update(msg)
return m, cmd
case timer.StartStopMsg:
var cmd tea.Cmd
m.timer, cmd = m.timer.Update(msg)
return m, cmd
case timer.TimeoutMsg:
// Timer finished
m.timerDone = true
// Controls
cmd := m.timer.Toggle() // Start/stop
cmd := m.timer.Start()
cmd := m.timer.Stop()
// Display
remaining := m.timer.Timeout.String()---
Stopwatch
Package: github.com/charmbracelet/bubbles/stopwatch
import "github.com/charmbracelet/bubbles/stopwatch"
sw := stopwatch.NewWithInterval(time.Millisecond * 100)
// In Init
func (m model) Init() tea.Cmd {
return m.stopwatch.Init()
}
// In Update
case stopwatch.TickMsg:
var cmd tea.Cmd
m.stopwatch, cmd = m.stopwatch.Update(msg)
return m, cmd
case stopwatch.StartStopMsg:
var cmd tea.Cmd
m.stopwatch, cmd = m.stopwatch.Update(msg)
return m, cmd
// Controls
cmd := m.stopwatch.Toggle()
cmd := m.stopwatch.Start()
cmd := m.stopwatch.Stop()
cmd := m.stopwatch.Reset()
// Display
elapsed := m.stopwatch.Elapsed().String()---
Key Bindings
Package: github.com/charmbracelet/bubbles/key
import "github.com/charmbracelet/bubbles/key"
// Define binding
quit := key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
)
// Check if pressed
case tea.KeyMsg:
if key.Matches(msg, quit) {
return m, tea.Quit
}
// Enable/disable
quit.SetEnabled(false) // Disable binding
if quit.Enabled() { ... }Common Key Names:
"enter","space","tab""up","down","left","right""home","end","pgup","pgdown""backspace","delete""esc","ctrl+c","ctrl+z""f1"through"f12"- Single characters:
"a","A","1","@"
Go TUI Development with Charm
Building terminal user interfaces in Go with Bubble Tea ecosystem.
---
Table of Contents
- The 5-Minute TUI
- Core Architecture: The Elm Pattern
- UI Pattern Recipes
- Command Palette
- Confirmation Dialog
- Split Pane Layout
- Toast/Notification
- Progress with Details
- Tab Navigation
- Error Display
- Library Quick Reference
- Progressive Enhancement Path
- Production Hardening
- Debugging TUIs
- Anti-Patterns
- When NOT to Use Full TUI
- THE EXACT PROMPTS
---
The 5-Minute TUI
Copy this, modify the items, ship it:
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
selected = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
normal = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
title = lipgloss.NewStyle().Bold(true).Padding(0, 1).Background(lipgloss.Color("62"))
)
type model struct {
items []string
cursor int
}
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 "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.items)-1 {
m.cursor++
}
case "enter":
fmt.Printf("\nYou chose: %s\n", m.items[m.cursor])
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
s := title.Render("Select an item") + "\n\n"
for i, item := range m.items {
cursor := " "
style := normal
if m.cursor == i {
cursor = "▸ "
style = selected
}
s += cursor + style.Render(item) + "\n"
}
s += "\n" + normal.Render("↑/↓: move • enter: select • q: quit")
return s
}
func main() {
m := model{items: []string{"Option A", "Option B", "Option C"}}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}Run: go mod init example && go get github.com/charmbracelet/bubbletea github.com/charmbracelet/lipgloss && go run .
---
Core Architecture: The Elm Pattern
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Model │───▸│ Update │───▸│ View │
│ (state) │ │ (logic) │ │ (render) │
└─────────────┘ └─────────────┘ └─────────────┘
▲ │
│ │
└──────────────────┘
Msg (events)Model: All state in one struct. Width, height, cursor, data, error, loading...
Update: Pure function. (model, msg) → (model, cmd). Never blocks. Never mutates.
View: Pure function. model → string. No side effects. Just render.
Cmd: Async work. Returns a Msg when done. HTTP calls, file I/O, timers...
type model struct {
width, height int // Terminal size
state screen // Current screen
err error // Last error
loading bool // Loading state
// ... your data
}
func (m model) Init() tea.Cmd {
return tea.Batch(
loadInitialData, // Async data fetch
m.spinner.Tick, // Start spinner
)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ALWAYS handle these first
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
// Resize all components here
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
case errMsg:
m.err = msg.err
m.loading = false
}
// Then delegate to current screen/components
return m, nil
}
func (m model) View() string {
if m.err != nil {
return renderError(m.err)
}
if m.loading {
return m.spinner.View() + " Loading..."
}
return m.renderCurrentScreen()
}---
UI Pattern Recipes
Command Palette (Fuzzy Search)
items := []list.Item{
item{title: "New File", key: "ctrl+n"},
item{title: "Open File", key: "ctrl+o"},
item{title: "Save", key: "ctrl+s"},
}
l := list.New(items, list.NewDefaultDelegate(), 40, 14)
l.Title = "Commands"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(true) // Built-in fuzzy search!
l.Styles.Title = titleStyleConfirmation Dialog
// With Huh (simplest)
var confirm bool
huh.NewConfirm().
Title("Delete all files?").
Description("This cannot be undone.").
Affirmative("Yes, delete").
Negative("Cancel").
Value(&confirm).
Run()
// Or styled with Lip Gloss
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("205")).
Padding(1, 2).
Width(40)
dialog := dialogStyle.Render(
titleStyle.Render("⚠️ Confirm Delete") + "\n\n" +
"This will delete 42 files.\n\n" +
"[Y]es [N]o",
)Split Pane Layout
func (m model) View() string {
sideW := 30
mainW := m.width - sideW - 3 // -3 for border
sideStyle := lipgloss.NewStyle().
Width(sideW).
Height(m.height - 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
mainStyle := lipgloss.NewStyle().
Width(mainW).
Height(m.height - 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62"))
side := sideStyle.Render(m.sidebar.View())
main := mainStyle.Render(m.content.View())
return lipgloss.JoinHorizontal(lipgloss.Top, side, main)
}Toast/Notification
type model struct {
toast string
toastTimer int
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case successMsg:
m.toast = "✓ " + string(msg)
m.toastTimer = 30 // frames
return m, tick()
case tickMsg:
if m.toastTimer > 0 {
m.toastTimer--
return m, tick()
}
m.toast = ""
}
return m, nil
}
func (m model) View() string {
view := m.mainContent()
if m.toast != "" {
toast := lipgloss.NewStyle().
Background(lipgloss.Color("35")).
Foreground(lipgloss.Color("255")).
Padding(0, 2).
Render(m.toast)
view = lipgloss.Place(m.width, m.height, lipgloss.Right, lipgloss.Top, toast)
}
return view
}Progress with Details
type model struct {
progress progress.Model
current string
done int
total int
}
func (m model) View() string {
pct := float64(m.done) / float64(m.total)
return lipgloss.JoinVertical(lipgloss.Left,
titleStyle.Render("Installing dependencies"),
"",
m.progress.ViewAs(pct),
"",
subtle.Render(fmt.Sprintf("(%d/%d) %s", m.done, m.total, m.current)),
)
}Tab Navigation
type model struct {
tabs []string
activeTab int
}
func (m model) View() string {
var renderedTabs []string
for i, t := range m.tabs {
style := inactiveTab
if i == m.activeTab {
style = activeTab
}
renderedTabs = append(renderedTabs, style.Render(t))
}
tabRow := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
content := m.tabContent[m.activeTab].View()
return lipgloss.JoinVertical(lipgloss.Left, tabRow, content)
}
var (
activeTab = lipgloss.NewStyle().
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(0, 2)
inactiveTab = lipgloss.NewStyle().
Border(lipgloss.HiddenBorder()).
Padding(0, 2)
)Error Display
func renderError(err error) string {
errStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("196")).
Padding(1, 2).
Width(60)
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
return errStyle.Render(
titleStyle.Render("✗ Error") + "\n\n" +
wordwrap.String(err.Error(), 56) + "\n\n" +
subtle.Render("Press any key to continue"),
)
}---
Library Quick Reference
| Library | Purpose | Key Types |
|---|---|---|
| Bubble Tea | TUI framework | tea.Model, tea.Cmd, tea.Msg |
| Bubbles | Components | list.Model, textinput.Model, viewport.Model, table.Model, spinner.Model, progress.Model |
| Lip Gloss | Styling | lipgloss.Style, lipgloss.Color, lipgloss.Border |
| Huh | Forms | huh.Form, huh.Input, huh.Select, huh.Confirm |
| Glamour | Markdown | glamour.Render(), glamour.NewTermRenderer() |
| Harmonica | Animation | harmonica.Spring, harmonica.FPS() |
| Log | Logging | log.Info(), log.Error() |
Install:
go get github.com/charmbracelet/bubbletea@latest \
github.com/charmbracelet/bubbles@latest \
github.com/charmbracelet/lipgloss@latest \
github.com/charmbracelet/huh@latest \
github.com/charmbracelet/glamour@latest \
github.com/charmbracelet/harmonica@latest \
github.com/charmbracelet/log@latestv2 Track (bleeding edge):
go get charm.land/bubbletea/v2@latest
go get charm.land/bubbles/v2@latest
go get charm.land/lipgloss/v2@latest---
Progressive Enhancement Path
Level 1: Styled Output
Replace fmt.Println with Lip Gloss:
// Before
fmt.Println("Error: file not found")
// After
errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
fmt.Println(errStyle.Render("Error: file not found"))Level 2: Interactive Prompts
Replace fmt.Scanf with Huh:
// Before
fmt.Print("Enter name: ")
fmt.Scanf("%s", &name)
// After
huh.NewInput().Title("Enter name").Value(&name).Run()Level 3: Full TUI
Convert to Bubble Tea with components.
Level 4: Polish
Add animation, mouse support, themes, help system...
---
Production Hardening
Must-Have Checklist
□ Handle tea.WindowSizeMsg (responsive layout)
□ Handle ctrl+c gracefully (cleanup, restore terminal)
□ Log to file, not stdout (use tea.LogToFile)
□ Test with small terminals (80x24 minimum)
□ Test with no color (TERM=dumb, NO_COLOR=1)
□ Test with light AND dark backgrounds
□ Add --no-tui or --plain flag for scripting
□ Handle errors visually (don't just crash)
□ Show loading states for async operations
□ Include keyboard hints (help component)Optional but Impressive
□ Mouse support (WithMouseCellMotion)
□ Focus reporting (pause when backgrounded)
□ Alt screen (full-window mode)
□ Smooth animations (Harmonica springs)
□ Accessible mode (screen reader support)
□ Custom themes
□ Config file for preferences
□ VHS tape for README demo---
Debugging TUIs
1. File Logging
if os.Getenv("DEBUG") != "" {
f, _ := tea.LogToFile("debug.log", "debug")
defer f.Close()
}
log.Printf("cursor moved to %d", m.cursor)Run: DEBUG=1 go run . 2>&1 | tee debug.log Watch: tail -f debug.log
2. Debug View Mode
func (m model) View() string {
view := m.normalView()
if m.debug {
debug := fmt.Sprintf(
"w=%d h=%d cursor=%d state=%v",
m.width, m.height, m.cursor, m.state,
)
view += "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Render(debug)
}
return view
}3. Message Tracing
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
log.Printf("msg: %T %+v", msg, msg)
// ... rest of update
}4. Panic Recovery
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "panic: %v\n%s", r, debug.Stack())
}
}()
// ...
}---
Anti-Patterns
| Anti-Pattern | Why Bad | Fix |
|---|---|---|
| Blocking in Update | Freezes entire UI | Use commands for I/O |
| Ignoring WindowSizeMsg | Broken layout on resize | Always handle, resize components |
| Logging to stdout | Corrupts TUI display | Log to file |
| Hardcoded dimensions | Breaks on different terminals | Calculate from WindowSizeMsg |
| Mutating model directly | Unpredictable state | Return new model from Update |
| Deeply nested Views | Hard to maintain | Extract render functions |
| One giant Update switch | Unmaintainable | Delegate to screen/component handlers |
| Raw ANSI codes | Won't adapt to terminal | Use Lip Gloss |
| Manual prompt loops | Reinventing Huh poorly | Use Huh forms |
---
When NOT to Use Full TUI
Charm adds complexity. Skip full Bubble Tea when:
- Output is piped:
mytool | grep foo— use plain text - No interaction needed: Pure data transformation — just print
- CI/CD scripts: Headless environments — use flags/env vars
- Very simple prompts: One yes/no — use Huh standalone
Escape hatch pattern:
func main() {
if !term.IsTerminal(int(os.Stdin.Fd())) || os.Getenv("NO_TUI") != "" {
runPlainMode()
return
}
runTUI()
}---
THE EXACT PROMPTS
"Make My CLI Glamorous"
I have a Go CLI tool that currently uses fmt.Println and flag parsing.
Transform it into a polished TUI using Charmbracelet libraries:
1. Replace all fmt.Println output with Lip Gloss styled text
2. Replace any user prompts with Huh forms or Bubbles inputs
3. Add a proper help screen using Glamour for markdown rendering
4. Add keyboard navigation with clear visual feedback
5. Handle terminal resize gracefully
6. Add a loading spinner for any async operations
7. Use the alt screen for full-window mode
Preserve all existing functionality while dramatically improving UX."Build a TUI Dashboard"
Create a terminal dashboard using Charmbracelet that displays:
- A header with app name and status
- A sidebar with navigation (list component)
- A main content area (viewport for scrolling)
- A footer with keyboard hints (help component)
Requirements:
- Responsive to terminal resize
- Mouse support for clicking items
- Smooth transitions when switching views
- Proper focus management between panes
- Clean exit behavior (restore terminal state)
Use Bubble Tea for state, Bubbles for components, Lip Gloss for layout."Add Charm to Existing CLI"
I have an existing CLI using [cobra/urfave/flag]. Add Charm polish:
1. Keep the existing command structure
2. Add interactive mode when run without args
3. Style all output with Lip Gloss
4. Add progress bars for long operations
5. Add confirmation prompts for destructive actions
6. Show errors in styled error boxes
7. Add --no-tui flag to disable for scripting
Show me how to integrate without breaking existing behavior.Charm Infrastructure & Development Tools
Self-hosted services, SSH applications, and development utilities.
---
Table of Contents
- Wish: SSH App Server
- Soft Serve: Git Server
- Pop: Terminal Email
- Skate: Key-Value Store
- Melt: SSH Key Backup
- Wishlist: SSH Gateway
- Testing TUIs: teatest
- Terminal Detection: x/term
- Quick Install
---
Wish: SSH App Server
Build SSH-accessible TUI applications.
package main
import (
"context"
"os"
"os/signal"
"syscall"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/activeterm"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
)
func main() {
s, err := wish.NewServer(
wish.WithAddress(":2222"),
wish.WithHostKeyPath(".ssh/term_info_ed25519"),
wish.WithMiddleware(
bubbletea.Middleware(teaHandler),
activeterm.Middleware(),
logging.Middleware(),
),
)
if err != nil {
log.Fatal("Could not start server", "error", err)
}
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
log.Info("Starting SSH server", "addr", s.Addr)
go func() {
if err := s.ListenAndServe(); err != nil {
log.Fatal("Server error", "error", err)
}
}()
<-done
log.Info("Shutting down...")
ctx := context.Background()
s.Shutdown(ctx)
}
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
m := NewModel(s.User())
return m, []tea.ProgramOption{tea.WithAltScreen()}
}Connect: ssh localhost -p 2222
Middleware Stack
wish.WithMiddleware(
bubbletea.Middleware(handler), // TUI app
activeterm.Middleware(), // Terminal detection
logging.Middleware(), // Request logging
// Custom auth:
func(h ssh.Handler) ssh.Handler {
return func(s ssh.Session) {
if !authorized(s) {
s.Exit(1)
return
}
h(s)
}
},
)---
Soft Serve: Git Server
Self-hosted Git with TUI.
# Install
brew install soft-serve
# Start
soft serve
# Access
ssh localhost -p 23231
git clone ssh://localhost:23231/repoConfiguration
# ~/.config/soft-serve/config.yaml
name: "My Soft Serve"
host: 0.0.0.0
port: 23231
initial_admin_keys:
- "ssh-ed25519 AAAA... you@example.com"SSH Commands
ssh git.example.com # Browse repos TUI
ssh git.example.com repo create myrepo
ssh git.example.com repo delete myrepo
ssh git.example.com repo list
ssh git.example.com repo info myrepo
ssh git.example.com user list
ssh git.example.com user add "ssh-ed25519..."---
Pop: Terminal Email
# Install
brew install pop
# Send email
pop send \
--from "me@example.com" \
--to "you@example.com" \
--subject "Hello" \
--body "Message body"
# With attachment
pop send \
--to "team@example.com" \
--subject "Report" \
--attach report.pdf \
--body "See attached"
# From stdin
cat update.md | pop send \
--to "team@example.com" \
--subject "Weekly Update"
# Interactive
popConfiguration
# ~/.config/pop/pop.yml
from: me@example.com
smtp:
host: smtp.gmail.com
port: 587
username: me@example.com
password_env: SMTP_PASSWORD---
Skate: Key-Value Store
Simple encrypted storage.
# Install
brew install skate
# Set/Get
skate set api_key "sk-1234567890"
skate get api_key
# Namespaced keys
skate set config.theme "dark"
skate set config.editor "vim"
skate list config.
# Delete
skate delete api_key
# Sync across machines
skate syncIn Scripts
API_KEY=$(skate get api_key)
curl -H "Authorization: Bearer $API_KEY" https://api.example.com
THEME=$(skate get config.theme || echo "light")In Go
import "github.com/charmbracelet/skate"
db, _ := skate.Open("myapp")
defer db.Close()
db.Set("key", []byte("value"))
value, _ := db.Get("key")
db.Delete("key")
// List with prefix
keys, _ := db.List("config.")---
Melt: SSH Key Backup
# Install
brew install melt
# Backup (creates encrypted file)
melt backup
# Creates ~/.melt/backup.melt
# Backup to specific file
melt backup -o my-keys.melt
# Restore
melt restore
melt restore -i my-keys.meltNew machine workflow:
# On old machine
melt backup -o keys.melt
# Transfer keys.melt securely
# On new machine
brew install melt
melt restore -i keys.melt---
Wishlist: SSH Gateway
Serve multiple SSH apps on one port.
# wishlist.yaml
listen: 0.0.0.0:22
endpoints:
- name: git
address: localhost:23231
- name: chat
address: localhost:2222
- name: todos
address: localhost:2223wishlist serve
ssh myserver.com # Shows menu: [git] [chat] [todos]---
Testing TUIs: teatest
Headless testing for Bubble Tea apps.
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
)
func TestApp(t *testing.T) {
m := NewModel()
tm := teatest.NewTestModel(t, m)
// Send keys
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
// Type text
tm.Type("hello world")
// Wait for condition
teatest.WaitFor(t, tm, func(bts []byte) bool {
return strings.Contains(string(bts), "Expected output")
}, teatest.WithDuration(time.Second))
// Get final output
out := tm.FinalOutput(t)
if !strings.Contains(string(out), "success") {
t.Fatal("expected success message")
}
// Quit
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
}Golden File Testing
func TestGolden(t *testing.T) {
m := NewModel()
tm := teatest.NewTestModel(t, m)
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
out := tm.FinalOutput(t)
// Compare against saved "golden" output
teatest.RequireEqualOutput(t, out)
// First run: creates testdata/TestGolden.golden
// Subsequent: compares against it
}
// Update golden files: go test -updateInstall:
go get github.com/charmbracelet/x/exp/teatest@latest---
Terminal Detection: x/term
Detect terminal capabilities.
import "github.com/charmbracelet/x/term"
// Is this a terminal?
if term.IsTerminal(os.Stdin.Fd()) {
runTUI()
} else {
runPlainMode()
}
// Terminal size
width, height, _ := term.GetSize(os.Stdout.Fd())
// Color support
if term.HasDarkBackground() {
useTheme("dark")
} else {
useTheme("light")
}Full Detection Pattern
func main() {
isTTY := term.IsTerminal(os.Stdin.Fd())
isPiped := !term.IsTerminal(os.Stdout.Fd())
noColor := os.Getenv("NO_COLOR") != ""
switch {
case !isTTY:
runFilter() // stdin is piped
case isPiped:
runPlainOutput() // stdout is piped
case noColor:
runNoColor()
default:
runTUI()
}
}Install:
go get github.com/charmbracelet/x/term@latest---
Quick Install
# Infrastructure tools
brew install soft-serve pop skate melt
# Go libraries
go get github.com/charmbracelet/wish@latest \
github.com/charmbracelet/x/exp/teatest@latest \
github.com/charmbracelet/x/term@latestTHE EXACT PROMPTS for Charm TUIs
Copy-paste prompts for common Charm tasks.
---
Table of Contents
- Go TUI Prompts
- Make My CLI Glamorous
- Build a TUI Dashboard
- Add Charm to Existing CLI
- Shell Script Prompts
- Interactive Deploy Script
- Git Commit Helper
- Menu-Driven Tool
- SSH App Prompts
- SSH TUI Service
- Documentation Prompts
- VHS Demo Recording
- Beautiful Code Screenshots
---
Go TUI Prompts
Make My CLI Glamorous
I have a Go CLI tool that currently uses fmt.Println and flag parsing.
Transform it into a polished TUI using Charmbracelet libraries:
1. Replace all fmt.Println output with Lip Gloss styled text
2. Replace any user prompts with Huh forms or Bubbles inputs
3. Add a proper help screen using Glamour for markdown rendering
4. Add keyboard navigation with clear visual feedback
5. Handle terminal resize gracefully
6. Add a loading spinner for any async operations
7. Use the alt screen for full-window mode
Preserve all existing functionality while dramatically improving UX.Build a TUI Dashboard
Create a terminal dashboard using Charmbracelet that displays:
- A header with app name and status
- A sidebar with navigation (list component)
- A main content area (viewport for scrolling)
- A footer with keyboard hints (help component)
Requirements:
- Responsive to terminal resize
- Mouse support for clicking items
- Smooth transitions when switching views
- Proper focus management between panes
- Clean exit behavior (restore terminal state)
Use Bubble Tea for state, Bubbles for components, Lip Gloss for layout.Add Charm to Existing CLI
I have an existing CLI using [cobra/urfave/flag]. Add Charm polish:
1. Keep the existing command structure
2. Add interactive mode when run without args
3. Style all output with Lip Gloss
4. Add progress bars for long operations
5. Add confirmation prompts for destructive actions
6. Show errors in styled error boxes
7. Add --no-tui flag to disable for scripting
Show me how to integrate without breaking existing behavior.---
Shell Script Prompts
Interactive Deploy Script
Create a bash deployment script using Gum that:
1. Shows a styled header/banner
2. Lets user select environment (staging/production) with gum choose
3. For production: requires confirmation with gum confirm
4. Lets user multi-select services to deploy with gum choose --no-limit
5. Shows a spinner during each deployment with gum spin
6. Displays success/failure with styled output
Include proper error handling and early exit on failures.Git Commit Helper
Create a bash script using Gum that helps write conventional commits:
1. Use gum choose for commit type (feat, fix, docs, style, refactor, test, chore)
2. Use gum input for optional scope
3. Use gum input for summary (with character limit)
4. Use gum confirm to ask about adding body
5. If yes, use gum write for multi-line body
6. Show final message in styled box with gum style
7. Confirm and run git commit
Handle empty inputs gracefully and allow user to cancel at any step.Menu-Driven Tool
Create a bash script using Gum that provides a menu-driven interface for:
1. Main menu with gum choose for actions
2. Sub-menus for complex operations
3. File/directory selection with gum file
4. Text viewing with gum pager for long outputs
5. Fuzzy filtering with gum filter for lists
6. Loop back to main menu until user selects "Exit"
Structure it with functions for each menu option.---
SSH App Prompts
SSH TUI Service
Create an SSH-accessible TUI application using Wish that:
1. Authenticates users via SSH keys
2. Shows a personalized welcome based on ssh username
3. Provides a Bubble Tea TUI with navigation
4. Handles multiple concurrent SSH sessions
5. Logs connections with wish logging middleware
6. Gracefully shuts down on SIGTERM
Include both the server code and a sample TUI model.
Show how to generate and configure host keys.---
Documentation Prompts
VHS Demo Recording
Create a VHS tape file to record a demo of my CLI tool that:
1. Shows installation command
2. Demonstrates 3-4 key features
3. Uses appropriate pauses for readability
4. Has clean theme and font settings
5. Outputs as optimized GIF for README
Include:
- Hide/Show for setup commands
- Realistic typing speed
- Strategic Sleep commands between actions
Target: 15-30 second final GIF, under 5MB.Beautiful Code Screenshots
Create Freeze configuration and commands to generate beautiful code
screenshots for my project documentation:
1. Consistent theme matching my project branding
2. Line numbers enabled
3. Appropriate padding and shadows
4. Window chrome for that "editor" look
Provide:
- freeze.json config file
- Shell commands for common screenshot tasks
- Batch script for multiple files---
Advanced Prompts
Refactor to Bubble Tea
I have this Go CLI code that uses a traditional loop with fmt.Scanf prompts.
Refactor it to use the Bubble Tea architecture:
1. Extract all state into a model struct
2. Convert input handling to Update with tea.KeyMsg
3. Convert output to View function with Lip Gloss styling
4. Replace blocking operations with tea.Cmd
5. Add proper initialization with Init()
Maintain all existing functionality while gaining:
- Non-blocking UI updates
- Proper terminal handling
- Resize support
- Clean exit behaviorComponent Composition
I have multiple Bubble Tea components that need to work together:
- A list for navigation
- A text input for search
- A viewport for content display
Show me how to:
1. Structure the parent model to contain child components
2. Route messages to the correct component based on focus
3. Handle focus switching between components (Tab key)
4. Coordinate state changes between components
5. Compose their Views with Lip Gloss layoutsMulti-Screen App
Create a Bubble Tea app with multiple screens:
- Loading screen with spinner
- Main menu screen
- Detail view screen
- Settings screen
Show:
1. Screen state enum
2. Routing in Update based on current screen
3. Per-screen component initialization
4. Transitions between screens
5. Shared header/footer across screensQuick Reference: Charm Copy-Paste Patterns
Fast-access patterns for common Charm tasks. Copy, paste, ship.
---
Table of Contents
- Shell: Gum One-Liners
- Go: Bubble Tea Starter
- Go: Lip Gloss Styles
- Go: Huh Forms
- Go: Common Components
- Install Commands
- Production Patterns
---
Shell: Gum One-Liners
# Input with placeholder
NAME=$(gum input --placeholder "Enter your name")
# Password input
PASS=$(gum input --password --placeholder "Password")
# Single selection
CHOICE=$(gum choose "option1" "option2" "option3")
# Multi-select
SELECTED=$(gum choose --no-limit "a" "b" "c" "d")
# Fuzzy filter from stdin
BRANCH=$(git branch | gum filter)
FILE=$(find . -name "*.go" | gum filter)
# Confirmation (returns exit code)
gum confirm "Delete?" && rm -rf ./tmp
# Spinner during command
gum spin --title "Installing..." -- npm install
# Styled box
gum style --border rounded --padding "1 2" --foreground 212 "Done!"
# Multi-line input
BODY=$(gum write --placeholder "Enter message...")
# File picker
FILE=$(gum file .)
# Horizontal layout
gum join --horizontal "$(gum style --border rounded 'Left')" "$(gum style --border rounded 'Right')"---
Go: Bubble Tea Starter
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type model struct {
cursor int
items []string
}
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 "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 { m.cursor-- }
case "down", "j":
if m.cursor < len(m.items)-1 { m.cursor++ }
case "enter":
fmt.Println("Selected:", m.items[m.cursor])
return m, tea.Quit
}
}
return m, nil
}
var selected = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
func (m model) View() string {
s := ""
for i, item := range m.items {
cursor := " "
if i == m.cursor {
cursor = "▸ "
s += selected.Render(cursor+item) + "\n"
} else {
s += cursor + item + "\n"
}
}
return s + "\n↑/↓: navigate • enter: select • q: quit"
}
func main() {
m := model{items: []string{"Option A", "Option B", "Option C"}}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}Run: go run .
---
Go: Lip Gloss Styles
import "github.com/charmbracelet/lipgloss"
// Basic styles
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
subtle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
error := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
success := lipgloss.NewStyle().Foreground(lipgloss.Color("82"))
// Box with border
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(1, 2)
// Adaptive colors (light/dark aware)
adaptive := lipgloss.AdaptiveColor{Light: "#000", Dark: "#fff"}
// Layout: join horizontal
left := lipgloss.NewStyle().Width(30).Render("Left")
right := lipgloss.NewStyle().Width(50).Render("Right")
lipgloss.JoinHorizontal(lipgloss.Top, left, right)
// Layout: join vertical
header := "Header"
body := "Body"
footer := "Footer"
lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
// Center in container
lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, "Centered content")---
Go: Huh Forms
import "github.com/charmbracelet/huh"
// Simple input
var name string
huh.NewInput().Title("Name").Value(&name).Run()
// Password
var password string
huh.NewInput().Title("Password").EchoMode(huh.EchoModePassword).Value(&password).Run()
// Select
var choice string
huh.NewSelect[string]().
Title("Pick one").
Options(
huh.NewOption("First", "first"),
huh.NewOption("Second", "second"),
).
Value(&choice).
Run()
// Multi-select
var selected []string
huh.NewMultiSelect[string]().
Title("Pick many").
Options(huh.NewOptions("A", "B", "C", "D")...).
Value(&selected).
Run()
// Confirm
var confirmed bool
huh.NewConfirm().Title("Continue?").Value(&confirmed).Run()
// Full form with groups
var (
name string
email string
confirm bool
)
huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Name").Value(&name),
huh.NewInput().Title("Email").Value(&email),
),
huh.NewGroup(
huh.NewConfirm().Title("Submit?").Value(&confirm),
),
).Run()---
Go: Common Components
import (
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/bubbles/progress"
)
// Spinner
s := spinner.New()
s.Spinner = spinner.Dot
// In Init: return s.Tick
// In Update: s, cmd = s.Update(msg)
// Text input
ti := textinput.New()
ti.Placeholder = "Type here..."
ti.Focus()
// In Update: ti, cmd = ti.Update(msg)
// Get value: ti.Value()
// Progress bar
p := progress.New(progress.WithDefaultGradient())
// Render: p.ViewAs(0.75) // 75%
// Viewport (scrollable content)
vp := viewport.New(80, 20)
vp.SetContent(longText)
// In Update: vp, cmd = vp.Update(msg)
// List (requires item type implementing list.Item interface)
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 }
items := []list.Item{item{"One", "First"}, item{"Two", "Second"}}
l := list.New(items, list.NewDefaultDelegate(), 40, 20)
l.Title = "My List"---
Install Commands
# Shell tools (all at once)
brew install gum glow vhs freeze mods
# Go libraries
go get github.com/charmbracelet/bubbletea@latest \
github.com/charmbracelet/bubbles@latest \
github.com/charmbracelet/lipgloss@latest \
github.com/charmbracelet/huh@latest \
github.com/charmbracelet/glamour@latest \
github.com/charmbracelet/wish@latest
# v2 track (bleeding edge)
go get charm.land/bubbletea/v2@latest
go get charm.land/lipgloss/v2@latest---
Production Patterns
Terminal Detection
import "github.com/charmbracelet/x/term"
func main() {
if !term.IsTerminal(int(os.Stdin.Fd())) || os.Getenv("NO_TUI") != "" {
runPlainMode()
return
}
runTUI()
}Window Size Handling
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
// Resize components
m.list.SetSize(m.width, m.height-4)
m.viewport.Width = m.width
m.viewport.Height = m.height - 6
}
return m, nil
}Alt Screen + Mouse
tea.NewProgram(
model,
tea.WithAltScreen(), // Full-screen mode
tea.WithMouseCellMotion(), // Mouse support
)Debug Logging
if os.Getenv("DEBUG") != "" {
f, _ := tea.LogToFile("debug.log", "debug")
defer f.Close()
}---
VHS Recording Template
Output demo.gif
Set FontSize 16
Set Width 1200
Set Height 600
Set Theme "Catppuccin Mocha"
Type "my-command --flag"
Sleep 500ms
Enter
Sleep 2s
Type "q"
Sleep 500msRun: vhs demo.tape
Shell Scripts with Charm
Beautiful terminal UI for bash/shell scripts without writing Go.
---
Table of Contents
- Gum: The Essential Tool
- Input & Text
- Selection
- Confirmation
- Spinners & Progress
- Styled Output
- Join & Layout
- Format & Markdown
- File Operations
- Complete Gum Recipes
- VHS: Terminal Recording
- Mods: AI in Terminal
- Glow: Markdown Viewer
- Freeze: Code Screenshots
- Quick Install
---
Gum: The Essential Tool
Gum provides all the UI primitives you need for shell scripts.
# Install
brew install gum
# or: go install github.com/charmbracelet/gum@latestInput & Text
# Single line input
NAME=$(gum input --placeholder "Your name")
NAME=$(gum input --value "default" --prompt "> ")
NAME=$(gum input --password --placeholder "Password") # Hidden input
NAME=$(gum input --char-limit 50) # Limit length
# Multi-line input (textarea)
DESCRIPTION=$(gum write --placeholder "Enter description...")
DESCRIPTION=$(gum write --width 80 --height 10)
COMMIT_MSG=$(gum write --header "Commit Message")Selection
# Single choice
COLOR=$(gum choose "red" "green" "blue")
COLOR=$(gum choose --header "Pick a color:" "red" "green" "blue")
ITEM=$(gum choose --limit 1 "one" "two" "three") # Explicit single
# Multi-select
TOPPINGS=$(gum choose --no-limit "cheese" "pepperoni" "mushrooms" "olives")
SELECTED=$(gum choose --limit 3 "a" "b" "c" "d" "e") # Max 3 selections
# From stdin (pipe anything!)
BRANCH=$(git branch | gum choose)
FILE=$(ls | gum choose)
PROCESS=$(ps aux | gum choose | awk '{print $2}')
# Fuzzy filter (searchable)
BRANCH=$(git branch | gum filter)
BRANCH=$(gum filter --placeholder "Search branches..." < <(git branch))
FILE=$(find . -name "*.go" | gum filter --height 20)Confirmation
# Basic confirm (returns exit code)
gum confirm "Delete all files?" && rm -rf ./tmp
# With custom labels
gum confirm "Deploy to production?" \
--affirmative "Yes, deploy" \
--negative "Cancel" && ./deploy.sh
# In conditionals
if gum confirm "Continue?"; then
echo "Proceeding..."
else
echo "Cancelled"
exit 1
fiSpinners & Progress
# Spinner while command runs
gum spin --title "Installing..." -- npm install
gum spin --spinner dot --title "Building..." -- make build
gum spin --spinner line --title "Fetching..." -- curl -s https://api.example.com
# Available spinners: line, dot, minidot, jump, pulse, points, globe, moon, monkey, meter, hamburger
# Show spinner with custom command
gum spin --title "Processing..." -- sleep 5
# Capture output while showing spinner
OUTPUT=$(gum spin --show-output --title "Running..." -- ./my-script.sh)Styled Output
# Basic styling
gum style "Hello World"
gum style --foreground 212 "Pink text"
gum style --foreground "#ff0000" "Red text"
gum style --background 235 --foreground 255 "Styled box"
# Borders
gum style --border normal "Normal border"
gum style --border rounded "Rounded border"
gum style --border double "Double border"
gum style --border thick "Thick border"
gum style --border hidden "Hidden border (padding only)"
# Padding and margins
gum style --padding "1 2" "Padded text" # vertical horizontal
gum style --margin "1 2 1 2" "With margin" # top right bottom left
# Alignment
gum style --width 40 --align center "Centered"
gum style --width 40 --align right "Right aligned"
# Bold, italic, etc.
gum style --bold "Bold text"
gum style --italic "Italic text"
gum style --strikethrough "Strikethrough"
gum style --underline "Underlined"
# Combine everything
gum style \
--border rounded \
--border-foreground 212 \
--padding "1 2" \
--foreground 212 \
--bold \
"Beautiful Box"Join & Layout
# Horizontal join
gum join --horizontal "Left" "Middle" "Right"
gum join --horizontal --align center "A" "B" "C"
# Vertical join
gum join --vertical "Line 1" "Line 2" "Line 3"
# Combined layouts
HEADER=$(gum style --bold "Header")
BODY=$(gum style --border rounded "Content")
FOOTER=$(gum style --faint "Footer")
gum join --vertical "$HEADER" "$BODY" "$FOOTER"Format & Markdown
# Format template strings
gum format "Hello, **world**!"
gum format "Code: \`inline\`"
gum format -- "# Heading" "Paragraph text" "- List item"
# From file
gum format < template.md
# With emoji
gum format ":rocket: Deploying..."
gum format ":white_check_mark: Done"File Operations
# File picker
FILE=$(gum file .)
FILE=$(gum file --directory) # Directories only
FILE=$(gum file --all) # Include hidden files
FILE=$(gum file --height 20 /path/to/start)
# Pager (for long output)
cat long-file.txt | gum pager
gum pager < README.md
git diff | gum pager --show-line-numbers---
Complete Gum Recipes
Git Commit Script
#!/bin/bash
# Conventional commit helper
TYPE=$(gum choose "feat" "fix" "docs" "style" "refactor" "test" "chore")
SCOPE=$(gum input --placeholder "scope (optional)")
SUMMARY=$(gum input --placeholder "summary" --char-limit 50)
# Build scope part
if [ -n "$SCOPE" ]; then
SCOPE="($SCOPE)"
fi
# Get optional body
gum confirm "Add body?" && BODY=$(gum write --placeholder "Details...")
# Build message
MSG="$TYPE$SCOPE: $SUMMARY"
if [ -n "$BODY" ]; then
MSG="$MSG
$BODY"
fi
# Confirm and commit
echo
gum style --border rounded --padding "1 2" "$MSG"
echo
gum confirm "Commit with this message?" && git commit -m "$MSG"Interactive Deploy Script
#!/bin/bash
gum style --border double --padding "1 2" --foreground 212 "🚀 Deployment Tool"
echo
# Select environment
ENV=$(gum choose --header "Select environment:" "staging" "production")
# Production confirmation
if [ "$ENV" = "production" ]; then
gum style --foreground 196 "⚠️ WARNING: Production deployment!"
gum confirm "Are you sure?" || exit 1
gum input --password --placeholder "Enter deploy password" | grep -q "secret" || {
gum style --foreground 196 "Wrong password"
exit 1
}
fi
# Select services
SERVICES=$(gum choose --no-limit --header "Select services:" \
"api" "web" "worker" "scheduler")
# Run deployment
for SERVICE in $SERVICES; do
gum spin --spinner dot --title "Deploying $SERVICE to $ENV..." -- \
./deploy.sh "$ENV" "$SERVICE"
done
gum style --foreground 82 "✓ Deployment complete!"File Browser & Editor
#!/bin/bash
while true; do
FILE=$(gum file --height 20 .)
if [ -z "$FILE" ]; then
break
fi
ACTION=$(gum choose "View" "Edit" "Delete" "Back")
case $ACTION in
"View")
cat "$FILE" | gum pager
;;
"Edit")
${EDITOR:-vim} "$FILE"
;;
"Delete")
gum confirm "Delete $FILE?" && rm "$FILE"
;;
"Back")
continue
;;
esac
doneDatabase Query Tool
#!/bin/bash
DB=$(gum choose "production" "staging" "development")
QUERY=$(gum write --header "Enter SQL query" --placeholder "SELECT * FROM...")
gum style --faint "Running on $DB..."
gum spin --title "Executing query..." -- \
psql -h "$DB.example.com" -c "$QUERY" | gum pager---
VHS: Terminal Recording
Record terminal sessions as GIFs for documentation.
# Install
brew install vhsBasic Tape File
# demo.tape
Output demo.gif
Set FontSize 14
Set Width 1200
Set Height 600
Set Theme "Catppuccin Mocha"
Type "echo 'Hello, World!'"
Sleep 500ms
Enter
Sleep 1s
Type "ls -la"
Enter
Sleep 2s# Record
vhs demo.tapeComplete Command Reference
# === OUTPUT ===
Output demo.gif # GIF (default)
Output demo.mp4 # Video
Output demo.webm # WebM
Output frames/ # PNG frames
# === SETTINGS ===
Set Shell "bash" # Shell to use
Set FontSize 16 # Font size
Set FontFamily "JetBrains Mono"
Set Width 1200 # Terminal width
Set Height 600 # Terminal height
Set Padding 20 # Padding around terminal
Set Theme "Dracula" # Color theme
Set TypingSpeed 50ms # Delay between keystrokes
Set Framerate 60 # FPS for recordings
Set CursorBlink false # Disable cursor blink
Set WindowBar Colorful # Window decoration style
Set WindowBarSize 40 # Window bar height
Set LoopOffset 0 # GIF loop start offset
# === TYPING ===
Type "echo hello" # Type text
Type@100ms "slow typing" # Custom typing speed
Type "fast" # comment # Comments after commands
# === KEYS ===
Enter # Press enter
Space # Press space
Tab # Tab key
Backspace # Backspace once
Backspace 5 # Backspace 5 times
Delete # Delete key
Up # Arrow up
Down # Arrow down
Left # Arrow left
Right # Arrow right
Ctrl+C # Key combination
Ctrl+A # Select all
Escape # Escape key
# === TIMING ===
Sleep 1s # Wait 1 second
Sleep 500ms # Wait 500 milliseconds
Sleep 2.5s # Wait 2.5 seconds
# === SCREEN ===
Hide # Hide commands from output
Show # Show commands again
Screenshot demo.png # Take screenshotPopular Themes
Set Theme "Dracula"
Set Theme "Catppuccin Mocha"
Set Theme "GitHub Dark"
Set Theme "One Dark"
Set Theme "Tokyo Night"
Set Theme "Nord"
Set Theme "Solarized Dark"Example: CLI Tool Demo
Output tool-demo.gif
Set FontSize 16
Set Width 1000
Set Height 600
Set Theme "Catppuccin Mocha"
Set TypingSpeed 30ms
# Show installation
Type "brew install mytool"
Enter
Sleep 1s
Hide
Type "echo 'Installed!'"
Enter
Show
Sleep 500ms
# Demo usage
Type "mytool --help"
Enter
Sleep 2s
Type "mytool create project"
Enter
Sleep 1s
Type "cd project && mytool run"
Enter
Sleep 3s
# Clean ending
Type "exit"
Enter---
Mods: AI in Terminal
Pipe anything to AI.
# Install
brew install mods
# Or with Go
go install github.com/charmbracelet/mods@latestBasic Usage
# Ask questions
mods "What is the capital of France?"
# Pipe content
echo "Explain this" | mods
cat error.log | mods "what's wrong?"
# Files
mods "summarize this" < README.md
cat *.go | mods "find bugs"Code Operations
# Code review
git diff | mods "review for bugs and style issues"
# Generate code
mods "write a bash function to backup dotfiles" > backup.sh
# Explain code
cat complex-function.py | mods "explain this code"
# Refactor suggestions
cat old-code.js | mods "modernize this JavaScript"Git Integration
# Generate commit message
git diff --staged | mods "write a conventional commit message"
# PR description
git log main..HEAD --oneline | mods "write PR description"
# Changelog
git log --oneline v1.0..v2.0 | mods "write changelog entry"Configuration
# ~/.config/mods/mods.yml
default-model: gpt-4
apis:
openai:
api-key-env: OPENAI_API_KEY
anthropic:
api-key-env: ANTHROPIC_API_KEY
# Model aliases
aliases:
fast: gpt-3.5-turbo
smart: gpt-4
creative: claude-3-opus# Use specific model
mods --model gpt-4 "complex question"
mods -m claude-3-opus "creative task"
# Conversation continuation
mods "initial question"
mods --continue "follow-up"
mods -c "another follow-up"
# Format output
mods --format "explain X" | glow # Pipe markdown to glow
mods -f "write docs" > DOCS.mdPower Recipes
# Auto-fix linting errors
eslint . 2>&1 | mods "fix these errors" > fixes.patch
# Explain error and suggest fix
./broken-script.sh 2>&1 | mods "explain this error and suggest fix"
# Generate tests
cat src/utils.js | mods "write Jest tests" > src/utils.test.js
# Documentation
cat *.go | mods "write godoc comments" > docs.go
# SQL from natural language
mods "SQL to get users who signed up last week"---
Glow: Markdown Viewer
Beautiful markdown in terminal.
# Install
brew install glow
# View file
glow README.md
# With pager (scrollable)
glow -p README.md
# From URL
glow https://raw.githubusercontent.com/user/repo/main/README.md
# From stdin
cat file.md | glow -
echo "# Hello" | glow -
# Styles
glow -s dark README.md # Dark theme
glow -s light README.md # Light theme
glow -s auto README.md # Auto-detect
glow -s notty README.md # No styling (for piping)
# Width
glow -w 80 README.md # Wrap at 80 charsStashing (Offline Reading)
# Save for later
glow stash README.md
glow stash https://example.com/article.md
# List stashed
glow stash list
# Read stashed
glow stash show 1
# Search stashed
glow stash list | grep "keyword"---
Freeze: Code Screenshots
Beautiful code images for documentation.
# Install
brew install freezeBasic Usage
# From file
freeze main.go -o code.png
# From stdin
cat snippet.py | freeze --language python -o snippet.png
# Specific lines
freeze main.go --lines 10,20 -o function.png
freeze main.go --lines 10-30 -o block.pngStyling Options
freeze main.go \
--theme "catppuccin-mocha" \
--font "JetBrains Mono" \
--font-size 14 \
--line-height 1.4 \
--shadow \
--padding 20 \
--margin 20 \
--line-numbers \
--window \
--border-radius 8 \
-o beautiful-code.pngConfiguration File
// freeze.json
{
"theme": "catppuccin-mocha",
"font": {
"family": "JetBrains Mono",
"size": 14
},
"shadow": {
"blur": 20,
"x": 0,
"y": 10
},
"padding": [20, 40, 20, 20],
"margin": [0, 0, 0, 0],
"line_numbers": true,
"window": true,
"border": {
"radius": 8,
"width": 1,
"color": "#444"
}
}freeze --config freeze.json main.go -o code.pngAvailable Themes
catppuccin-mocha, catppuccin-latte, dracula, github-dark, github-light,
monokai, nord, one-dark, solarized-dark, solarized-light, tokyo-night---
Quick Install
# All shell tools at once
brew install gum glow vhs freeze mods
# Or from Charm tap
brew tap charmbracelet/tap
brew install charmbracelet/tap/gum \
charmbracelet/tap/glow \
charmbracelet/tap/vhs \
charmbracelet/tap/freeze \
charmbracelet/tap/mods