skills/charmbracelet-tui/SKILL.md
This skill should be used when the user asks to "build a TUI", "create a terminal UI", "use bubbletea", "add a text input", "add a spinner", "create a progress bar", "style with lipgloss", "test a bubbletea model", "use teatest", or when code imports charm.land or charmbracelet packages. Covers Bubbletea v2, Bubbles v2, Lipgloss v2, tea.Model implementations, keyboard/mouse input, program lifecycle, bubbles components (textinput, progress, spinner, viewport, list), teatest, glamour rendering, inline mode, and tea.Println usage.
npx skillsauth add raphi011/skills charmbracelet-tuiInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Best practices for building terminal UIs with the Charmbracelet stack: Bubbletea (framework), Bubbles (components), Lipgloss (styling). All libraries are v2 with charm.land import paths.
For detailed patterns, component API, all 11 gotchas with full code, inline mode, wizard patterns, and complete testing guide, consult references/full-reference.md.
tea "charm.land/bubbletea/v2" // framework
"charm.land/bubbles/v2/textinput" // text input component
"charm.land/bubbles/v2/progress" // progress bar component
"charm.land/bubbles/v2/spinner" // spinner component
"charm.land/bubbles/v2/viewport" // scrollable content
"charm.land/bubbles/v2/list" // filterable list
lipgloss "charm.land/lipgloss/v2" // styling
"charm.land/lipgloss/v2/table" // table component
"github.com/charmbracelet/colorprofile" // terminal color detection
| v1 | v2 |
|----|-----|
| View() string | View() tea.View via tea.NewView(s) |
| tea.KeyMsg | tea.KeyPressMsg |
| msg.Type == tea.KeySpace | msg.String() == "space" |
| msg.Alt | msg.Mod.Contains(tea.ModAlt) |
| msg.Runes | msg.Text (string) |
| msg.Type | msg.Code (rune) |
| tea.KeyCtrlC | msg.String() == "ctrl+c" |
| tea.WithAltScreen() | view.AltScreen = true |
| tea.WithMouseCellMotion() | view.MouseMode = tea.MouseModeCellMotion |
| tea.SetWindowTitle("x") cmd | view.WindowTitle = "x" |
| tea.MouseMsg direct fields | tea.MouseClickMsg etc., call .Mouse() |
| tea.Sequentially() | tea.Sequence() |
| tea.WindowSize() | tea.RequestWindowSize |
| spinner.Tick() package func | model.Tick() method |
| v1 | v2 |
|----|-----|
| viewport.New(w, h) | viewport.New(viewport.WithWidth(80)) |
| vp.YOffset field | vp.SetYOffset() / vp.YOffset() |
| vp.HighPerformanceRendering | Removed (Cursed Renderer handles it) |
| textinput.NewModel() | textinput.New() |
| ti.PromptStyle | ti.Styles.Focused.Prompt |
| ti.Cursor field | ti.Cursor() method -> *tea.Cursor |
| help.DefaultKeyMap var | help.DefaultKeyMap() func |
| DefaultStyles() | DefaultStyles(isDark bool) |
Every interactive component implements tea.Model:
type Model interface {
Init() tea.Cmd // initial command (e.g., start spinner)
Update(tea.Msg) (tea.Model, tea.Cmd) // handle messages, return new state + side effects
View() tea.View // render current state (MUST be pure)
}
func() tea.Msg) — never perform I/O in Update/Viewtea.Msgfunc (m model) View() tea.View {
return tea.NewView("rendered content")
}
tea.View has declarative fields that replace v1 commands:
v := tea.NewView(content)
v.AltScreen = true // replaces tea.EnterAltScreen
v.MouseMode = tea.MouseModeCellMotion // replaces tea.EnableMouseCellMotion
v.ReportFocus = true // replaces tea.EnableReportFocus
v.WindowTitle = "My App" // replaces tea.SetWindowTitle
Use tea.KeyPressMsg (not the v1 tea.KeyMsg):
case tea.KeyPressMsg:
switch msg.String() {
case "enter", "ctrl+c", "space", "up", "esc", "q":
}
Field access for programmatic matching:
msg.Code // rune: tea.KeyEnter, tea.KeyUp, 'a', ' ', etc.
msg.Text // string: typed text (e.g., "a")
msg.Mod // modifier: tea.ModCtrl, tea.ModAlt, tea.ModShift
Common key constants: tea.KeyEnter, tea.KeyEscape, tea.KeyUp, tea.KeyDown, tea.KeyLeft, tea.KeyRight, tea.KeyHome, tea.KeyEnd, tea.KeyTab, tea.KeyBackspace, tea.KeyDelete
Mouse messages are split by event type: tea.MouseClickMsg, tea.MouseReleaseMsg, tea.MouseWheelMsg, tea.MouseMotionMsg. Access data via msg.Mouse() -> .X, .Y.
profile := colorprofile.Detect(os.Stderr, os.Environ())
p := tea.NewProgram(model,
tea.WithOutput(os.Stderr), // ALWAYS for piping support
tea.WithColorProfile(profile), // explicit color profile
tea.WithoutSignalHandler(), // for background/embedded programs
)
finalModel, err := p.Run()
Always output to stderr when stdout needs to be pipeable (e.g., cd $(mytool select-dir)).
// A command is func() tea.Msg
func fetchData() tea.Msg { ... }
// Return from Update
return m, fetchData // runs async, sends result back as message
// Built-in commands
tea.Quit // quit the program
tea.Batch(cmd1, cmd2) // run commands in parallel
tea.Sequence(cmd1, cmd2) // run commands sequentially
For channel-based streaming and SSE integration, see references/full-reference.md -> "SSE / Channel Streaming".
// In Init() — non-blocking, works over SSH
func (m Model) Init() tea.Cmd {
return tea.RequestBackgroundColor
}
// In Update()
case tea.BackgroundColorMsg:
m.isDark = msg.IsDark()
m.styles = newStyles(m.isDark)
// Quick alternative (blocking, no SSH support)
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stderr)
lipgloss.AdaptiveColor is removed in v2 — use tea.BackgroundColorMsg instead.
These five issues bite silently — no error messages, just broken behavior. For all 11 gotchas with full code examples, see references/full-reference.md -> "All Gotchas".
textinput.Focus() is a pointer receiver. When not focused, textinput.Update() silently drops all messages — the input appears frozen.
Why it fails early:
Init() operates on a copy of the model, so Focus() mutations are lostFix: Defer focus until WindowSizeMsg arrives:
case tea.WindowSizeMsg:
if !m.termReady {
m.termReady = true
return m, m.input.Focus() // safe — terminal queries consumed
}
// BAD — silent race condition with View()
go func() { m.data = fetchData() }()
// GOOD — send message through event loop
func fetchCmd() tea.Msg { return dataMsg{fetchData()} }
glamour.WithAutoStyle() reads /dev/tty directly — the same fd bubbletea's TerminalReader uses. This splits escape sequences, producing garbage in textinput.
Fix: Detect dark/light before p.Run(), use glamour.WithStandardStyle():
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stderr)
// pass isDark to model, use glamour.WithStandardStyle("dark"/"light")
Only event-loop panics trigger terminal recovery. A panic inside a tea.Cmd goroutine leaves the terminal in raw mode (run reset to fix).
Fix: Add defer recover() in production Cmds.
v2 doesn't auto-handle ctrl+c. Without explicit handling, the program ignores ctrl+c silently.
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
Beyond the v2 breaking changes table above, watch for these non-obvious pitfalls:
| Mistake | Fix |
|---------|-----|
| view.Content == nil | view.Content == "" (string in v2) |
| Printing to stdout | tea.WithOutput(os.Stderr) for piping |
| Missing color profile | colorprofile.Detect() + tea.WithColorProfile() |
| Style variables for themed UI | Style functions that read current theme |
| glamour.WithAutoStyle() in Update | Detect before p.Run(), use WithStandardStyle() |
| lipgloss.AdaptiveColor | tea.BackgroundColorMsg + IsDark() |
| os.Getwd() in commands | Use context-injected working directory |
Drive Update() directly with synthetic keys — no tea.Program needed:
tea.KeyPressMsg{Code: tea.KeyEnter} // enter
tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} // ctrl+c
tea.KeyPressMsg{Code: rune('a'), Text: "a"} // character
For teatest integration testing, golden files, and debug helpers, see references/full-reference.md -> "Complete Testing Guide".
references/full-reference.md covers: architecture patterns (composition, layout arithmetic), SSE/channel streaming, full bubbles component API (TextInput, Progress, Table, Viewport, List, Spinner), lipgloss styling (colors, borders, style architecture), all 11 gotchas with code, inline mode (tea.Println, scrollback chat), wizard framework, and the complete testing guide (teatest, golden files, debug helpers).
development
Create polished, professional reveal.js presentations. Use when the user asks to create slides, a presentation, a deck, or a slideshow. Supports themes, multi-column layouts, code highlighting, animations, speaker notes, and custom styling. Generates HTML + CSS with no build step required.
tools
Use when writing Tailwind CSS v4 code, configuring Tailwind v4 with @theme or @variant directives, migrating from Tailwind v3 to v4, setting up CSS-native config (no tailwind.config.js), defining semantic color tokens, implementing dark mode with class-based @variant, creating design system tokens, or styling components with utility classes. Covers @import "tailwindcss", @theme blocks, @variant, @layer, CSS custom properties for colors, and common layout/component patterns.
development
Use whenever working with SurrealDB — writing queries, defining schemas, configuring indexes, debugging errors, handling record IDs, using the Go SDK, or discussing SurrealDB architecture. Activate on any mention of SurrealDB, SurrealQL, HNSW indexes, or surreal-related Go SDK code.
development
Use when visually verifying terminal UI rendering, testing TUI interactions, debugging Bubbletea display issues, or when asked to "test the TUI", "screenshot the terminal", "check what the TUI looks like", or "visually verify". Requires Kitty terminal with allow_remote_control and macOS for screencapture.