Reference skill for BubbleTea TUI development with lipgloss styling. Load this skill when implementing an interactive terminal UI using the charmbracelet stack — covers the Model/Update/View pattern, async data loading with tea.Cmd/tea.Msg, tickMsg auto-refresh, tea.WindowSizeMsg for responsive width, lipgloss style constants, and graceful quit handling. Use whenever implementing or modifying internal/tui/ packages, adding TUI panels, or debugging BubbleTea rendering issues.
BubbleTea uses the Elm architecture. Every TUI is a struct implementing tea.Model:
// internal/tui/model.go
package tui
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
type Model struct {
data BoardData
selected int // cursor in NEEDS ATTENTION section
loading bool
err error
width int
projectRoot string
}
// Init fires the first command(s) when the program starts.
// Always kick off data loading + the first tick here.
func (m Model) Init() tea.Cmd {
return tea.Batch(loadDataCmd(m.projectRoot), tickCmd())
}
// Update is the pure state transition function.
// It receives a message, returns the new model + next command(s).
// Never block in Update — defer work to Cmds.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
return m, nil
case tickMsg:
return m, tea.Batch(loadDataCmd(m.projectRoot), tickCmd())
case dataLoadedMsg:
m.data = BoardData(msg)
m.loading = false
return m, nil
case errMsg:
m.err = msg.err
m.loading = false
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
// View renders the current state as a string. Called after every Update.
// Returning an empty string is valid (e.g., during loading).
func (m Model) View() string {
if m.loading && m.data.LoadedAt.IsZero() {
return "Loading...\n"
}
if m.err != nil {
return "Error: " + m.err.Error() + "\n"
}
return RenderBoard(m)
}
Define message types as named types. Use struct for rich payloads, plain types for signals:
// Messages used in Update switch
type dataLoadedMsg BoardData // data fetch succeeded
type tickMsg time.Time // periodic refresh signal
type approveResultMsg string // action completed
type errMsg struct{ err error } // wraps error for tea.Msg interface
func (e errMsg) Error() string { return e.err.Error() }
tea.Cmd is func() tea.Msg. It runs off the main goroutine — this is how you do
async work without blocking the event loop:
// internal/tui/cmds.go
package tui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/your-org/your-repo/internal/state"
"github.com/your-org/your-repo/internal/config"
)
func loadDataCmd(projectRoot string) tea.Cmd {
return func() tea.Msg {
data, err := fetchBoardData(projectRoot)
if err != nil {
return errMsg{err}
}
return dataLoadedMsg(data)
}
}
func fetchBoardData(root string) (BoardData, error) {
pending, err := state.ReadPending(root)
if err != nil {
return BoardData{}, err
}
inProgress, err := state.ReadInProgress(root)
if err != nil {
return BoardData{}, err
}
gates, err := config.ReadGates(root)
if err != nil {
return BoardData{}, err
}
// ... other reads
return BoardData{
Pending: pending,
InProgress: inProgress,
Gates: gates,
LoadedAt: time.Now(),
}, nil
}
Key: the func() tea.Msg closure captures all the parameters it needs. When
BubbleTea runs it, the result comes back through Update as a message.
Use tea.Tick for periodic refresh. This pattern fires once every N seconds and
re-schedules itself:
func tickCmd() tea.Cmd {
return tea.Tick(10*time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
In Update, handle tickMsg by loading fresh data AND scheduling the next tick:
case tickMsg:
return m, tea.Batch(loadDataCmd(m.projectRoot), tickCmd())
This is a recurring chain: each tick fires a load + schedules the next tick.
Immediate refresh (on r key) does the same without waiting for the ticker:
case "r":
m.loading = true
return m, loadDataCmd(m.projectRoot)
// internal/tui/keys.go
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.selected > 0 {
m.selected--
}
return m, nil
case "down", "j":
if m.selected < len(m.data.Pending)-1 {
m.selected++
}
return m, nil
case "a":
if len(m.data.Pending) == 0 || m.selected >= len(m.data.Pending) {
return m, nil
}
item := m.data.Pending[m.selected]
return m, approveCmd(m.projectRoot, item)
case "e":
if len(m.data.Pending) == 0 || m.selected >= len(m.data.Pending) {
return m, nil
}
item := m.data.Pending[m.selected]
return m, escalateCmd(m.projectRoot, item)
case "r":
return m, loadDataCmd(m.projectRoot)
}
return m, nil
}
Define a style palette once and reuse across board sections:
// internal/tui/styles.go
package tui
import "github.com/charmbracelet/lipgloss"
var (
// Colors — AdaptiveColor works in both light and dark terminals
colorAccent = lipgloss.AdaptiveColor{Light: "#7D56F4", Dark: "#9D86F4"}
colorWarning = lipgloss.AdaptiveColor{Light: "#D08000", Dark: "#F0A020"}
colorMuted = lipgloss.AdaptiveColor{Light: "#666666", Dark: "#888888"}
colorGreen = lipgloss.AdaptiveColor{Light: "#22863A", Dark: "#4EC94E"}
colorRed = lipgloss.AdaptiveColor{Light: "#B31D28", Dark: "#FF6B6B"}
// Base styles
bold = lipgloss.NewStyle().Bold(true)
muted = lipgloss.NewStyle().Foreground(colorMuted)
accentStr = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
// Section header (e.g., "⚡ NEEDS ATTENTION (1)")
sectionHeader = lipgloss.NewStyle().
Bold(true).
Foreground(colorAccent).
MarginBottom(0)
// Selected item highlight
selectedStyle = lipgloss.NewStyle().
Background(lipgloss.AdaptiveColor{Light: "#EEE8FF", Dark: "#2D2060"}).
Foreground(colorAccent)
// Status bar at top
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(colorAccent)
// Divider line (━ repeated)
dividerStyle = lipgloss.NewStyle().Foreground(colorMuted)
)
The board renders as a sequence of string sections joined with newlines. Width clamping keeps it readable at any terminal width:
// internal/tui/board.go
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
func RenderBoard(m Model) string {
width := m.width
if width == 0 {
width = 80
}
// clamp width to avoid runaway layout
if width > 120 {
width = 120
}
var b strings.Builder
// Header row
header := headerStyle.Render("CARDO")
meta := muted.Render(fmt.Sprintf("Sprint %d · %s",
m.data.Sprint, time.Now().Format("Jan 2")))
b.WriteString(lipgloss.JoinHorizontal(
lipgloss.Left,
lipgloss.NewStyle().Width(width/2).Render(header),
lipgloss.NewStyle().Width(width/2).Align(lipgloss.Right).Render(meta),
))
b.WriteString("\n")
b.WriteString(dividerStyle.Render(strings.Repeat("━", width)))
b.WriteString("\n\n")
// NEEDS ATTENTION section
b.WriteString(renderNeedsAttention(m, width))
b.WriteString("\n")
// IN PROGRESS section
b.WriteString(renderInProgress(m, width))
b.WriteString("\n")
// QUEUE section
b.WriteString(renderQueue(m, width))
b.WriteString("\n")
// SYSTEM status line
b.WriteString(renderSystem(m, width))
b.WriteString("\n\n")
// Key hints
b.WriteString(muted.Render("[↑↓] navigate [a] approve [e] escalate [r] refresh [q] quit"))
b.WriteString("\n")
return b.String()
}
func renderNeedsAttention(m Model, width int) string {
count := len(m.data.Pending)
header := sectionHeader.Render(fmt.Sprintf("⚡ NEEDS ATTENTION (%d)", count))
if count == 0 {
return header + "\n" + muted.Render(" None") + "\n"
}
var lines []string
lines = append(lines, header)
for i, item := range m.data.Pending {
line := fmt.Sprintf(" [%s] #%s %s score %d/%d",
item.Gate, item.Issue, item.Recommendation, item.Score, item.Threshold)
age := time.Since(item.Timestamp).Round(time.Minute)
line += fmt.Sprintf(" %s", age)
if i == m.selected {
line = selectedStyle.Render("▶ " + line[2:])
}
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}
// in cmd/cardo root RunE or a dedicated board command
func runBoard(projectRoot string) error {
m := tui.Model{
projectRoot: projectRoot,
loading: true,
}
p := tea.NewProgram(m,
tea.WithAltScreen(), // full-screen mode, restores terminal on exit
tea.WithMouseCellMotion(), // optional: enable mouse support
)
if _, err := p.Run(); err != nil {
return fmt.Errorf("TUI: %w", err)
}
return nil
}
tea.WithAltScreen() is important for a board-style TUI — it uses the alternate
terminal buffer so the user's previous output is preserved when they quit.
BubbleTea sends tea.WindowSizeMsg whenever the terminal is resized. Store the
width and use it in View():
case tea.WindowSizeMsg:
m.width = msg.Width
// m.height = msg.Height // store if you need to scroll
return m, nil
The same actions/state packages work without the TUI — useful for cardo approve N
as a standalone command:
// cmd/cardo/approve.go — non-TUI path
func newApproveCmd() *cobra.Command {
return &cobra.Command{
Use: "approve <issue-number>",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
msg, err := actions.Approve(projectRoot(), args[0])
if err != nil {
return err
}
fmt.Println(msg)
return nil
},
}
}
The TUI and non-TUI paths share internal/actions/ and internal/state/ — no
duplication.
Update — even time.Sleep will freeze the UI. All I/O goes
in tea.Cmd functions.Init() runs before first render — fire initial commands here, not in the
struct constructor.return m, someCmd() not
return m, nil followed by a separate assignment.tea.Quit is a command, not a message — return it from Update:
return m, tea.Quitlipgloss.Width(s) for accurate terminal
width of styled strings (handles ANSI escape sequences).tea.WithAltScreen() needed for full-screen — without it, the TUI renders
inline and scrolls the terminal, which looks bad for a board view.