Go TUI with Bubble Tea
SKILL.md
Go TUI with Bubble Tea
This skill provides patterns for creating premium terminal user interfaces using charmbracelet/bubbletea, bubbles, and lipgloss.
When to Use
- Interactive CLI applications requiring navigation
- Real-time data displays with refresh capability
- Form inputs and selection interfaces
- Applications needing visual appeal in terminal
Dependencies
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/bubbles
go get github.com/charmbracelet/lipgloss
Architecture Pattern
ui/
├── table.go # Table-based interfaces
├── form.go # Form inputs
├── styles.go # Lipgloss style definitions
└── messages.go # Custom tea.Msg types
Core Templates
1. Style Definitions (ui/styles.go)
package ui
import "github.com/charmbracelet/lipgloss"
var (
// Base container styles
baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
// Header style
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
Background(lipgloss.Color("235")).
Padding(0, 1)
// Selection highlight
selectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(true)
// Title style
titleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Background(lipgloss.Color("235")).
Bold(true).
Padding(0, 1).
MarginBottom(1)
// Help text style
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
// Status styles
successStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
warningStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
)
2. Table Model (ui/table.go)
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Custom message types
type itemsMsg []Item
type errMsg struct{ err error }
func (e errMsg) Error() string { return e.err.Error() }
// Item represents a row in the table
type Item struct {
ID int
Name string
Status string
// Add more fields as needed
}
// Model is the Bubble Tea model for our TUI
type Model struct {
table table.Model
items []Item
message string
err error
}
// NewModel creates a new TUI model
func NewModel() Model {
columns := []table.Column{
{Title: "ID", Width: 5},
{Title: "Name", Width: 30},
{Title: "Status", Width: 15},
}
t := table.New(
table.WithColumns(columns),
table.WithFocused(true),
table.WithHeight(15),
)
// Apply styles
s := table.DefaultStyles()
s.Header = headerStyle
s.Selected = selectedStyle
t.SetStyles(s)
return Model{
table: t,
}
}
// Init initializes the model
func (m Model) Init() tea.Cmd {
return m.loadItems
}
// loadItems is a command that loads items
func (m Model) loadItems() tea.Msg {
// Replace with your data loading logic
items := []Item{
{ID: 1, Name: "Item 1", Status: "Active"},
{ID: 2, Name: "Item 2", Status: "Inactive"},
}
return itemsMsg(items)
}
// Update handles messages and updates the model
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c", "esc":
return m, tea.Quit
case "r":
m.message = "Refreshing..."
return m, m.loadItems
case "enter":
if len(m.items) > 0 && m.table.Cursor() < len(m.items) {
selected := m.items[m.table.Cursor()]
m.message = fmt.Sprintf("Selected: %s", selected.Name)
}
}
case itemsMsg:
m.items = msg
m.updateTable()
m.message = "Items loaded!"
return m, nil
case errMsg:
m.err = msg.err
m.message = fmt.Sprintf("Error: %v", msg.err)
return m, nil
}
m.table, cmd = m.table.Update(msg)
return m, cmd
}
// updateTable updates the table rows from items
func (m *Model) updateTable() {
rows := []table.Row{}
for _, item := range m.items {
status := item.Status
switch status {
case "Active":
status = successStyle.Render(status)
case "Inactive":
status = errorStyle.Render(status)
default:
status = warningStyle.Render(status)
}
rows = append(rows, table.Row{
fmt.Sprintf("%d", item.ID),
item.Name,
status,
})
}
m.table.SetRows(rows)
}
// View renders the TUI
func (m Model) View() string {
var b strings.Builder
// Title
title := titleStyle.Render("🚀 My Application")
b.WriteString(title)
b.WriteString("\n\n")
// Table
b.WriteString(baseStyle.Render(m.table.View()))
b.WriteString("\n")
// Message
if m.message != "" {
b.WriteString("\n")
if m.err != nil {
b.WriteString(errorStyle.Render(m.message))
} else {
b.WriteString(successStyle.Render(m.message))
}
b.WriteString("\n")
}
// Help
help := helpStyle.Render(
"↑/↓: Navigate • Enter: Select • r: Refresh • q: Quit",
)
b.WriteString("\n")
b.WriteString(help)
return b.String()
}
3. Running the TUI
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"yourapp/ui"
)
func main() {
p := tea.NewProgram(ui.NewModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Error: %v", err)
os.Exit(1)
}
}
Color Reference
Common Lipgloss Colors
| Code | Color | Use Case |
|---|---|---|
46 |
Green | Success, Running |
196 |
Red | Error, Stopped |
226 |
Yellow | Warning, Pending |
205 |
Pink | Accent, Headers |
229 |
Light Yellow | Selected |
57 |
Purple | Selection Background |
240 |
Gray | Borders |
241 |
Light Gray | Help text |
235 |
Dark Gray | Background |
Best Practices
-
Separate styles: Keep style definitions in a separate file for maintainability
-
Use the Elm Architecture:
Model- Application stateUpdate- Handle messages, return new model + commandsView- Render the model to a string
-
Async operations as Commands: Return
tea.Cmdfor async operationsfunc (m Model) doAsyncTask() tea.Cmd { return func() tea.Msg { result, err := asyncOperation() if err != nil { return errMsg{err} } return resultMsg(result) } } -
Handle window resize:
case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height -
Use consistent keybindings:
q/Esc/Ctrl+C- Quitr- RefreshEnter- Select/Confirm↑/↓orj/k- Navigate
-
Provide visual feedback for all actions
Integration with CLI
To launch TUI when no subcommand is provided:
// In cmd/root.go
var rootCmd = &cobra.Command{
Use: "app",
Short: "My app",
Run: func(cmd *cobra.Command, args []string) {
// Launch TUI when no subcommand
p := tea.NewProgram(ui.NewModel())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}