bubbletea
Bubble Tea TUI (Go)
Bubble Tea is a Go framework for building terminal user interfaces based on The Elm Architecture: immutable Model, pure Update function that returns a new model and a Cmd, and a pure View function that renders a string. Async work (HTTP, timers, I/O) is dispatched as tea.Cmd values that return tea.Msg values, which are delivered back to Update.
github.com/charmbracelet/bubbletea- core runtime (v1 stable; use/v2for v2)github.com/charmbracelet/bubbles- ready-made components (spinner, textinput, list, table, viewport, progress, paginator, help, key, textarea, stopwatch, timer, filepicker)github.com/charmbracelet/lipgloss- styling (colors, borders, padding, layout)
This skill targets the v1 API on github.com/charmbracelet/bubbletea because that is still the most widely deployed module path. A short migration note at the end covers v2 (github.com/charmbracelet/bubbletea/v2).
1. Minimal program
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
cursor int
choices []string
selected map[int]struct{}
}
func initialModel() model {
return model{
choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
selected: make(map[int]struct{}),
}
}
// Init runs once when the program starts. Return any startup Cmd (or nil).
func (m model) Init() tea.Cmd {
return tea.SetWindowTitle("Grocery List")
}
// Update is called for every message. It MUST be pure: take a model, return a
// new model and optionally a Cmd. Never mutate external state here.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
case "enter", " ":
if _, ok := m.selected[m.cursor]; ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}
// View renders the current UI as a string. Called after every Update.
func (m model) View() string {
s := "What should we buy?\n\n"
for i, choice := range m.choices {
cursor := " "
if m.cursor == i {
cursor = ">"
}
checked := " "
if _, ok := m.selected[i]; ok {
checked = "x"
}
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
s += "\nPress q to quit.\n"
return s
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
}
The tea.Model interface
type Model interface {
Init() Cmd
Update(Msg) (Model, Cmd)
View() string
}
type Msg interface{} // any value, type-switched in Update
type Cmd func() Msg // async work; runs in its own goroutine
Use value receivers, not pointer receivers, for the Model methods. The runtime treats the returned model as the new state. Mutating in-place through a pointer still works, but the idiomatic pattern is to return a modified copy — it keeps Update pure and makes concurrency safe.
2. Commands and messages
A tea.Cmd is a function func() tea.Msg that Bubble Tea runs on a goroutine. Whatever Msg it returns is fed back into Update. This is how you do any async or side-effectful work: HTTP calls, reading files, timers, shelling out, receiving from channels.
type statusMsg int
type errMsg struct{ err error }
func (e errMsg) Error() string { return e.err.Error() }
// A Cmd is just a func returning a Msg. Signature: func() tea.Msg
func checkServer() tea.Msg {
c := &http.Client{Timeout: 10 * time.Second}
res, err := c.Get("https://charm.sh/")
if err != nil {
return errMsg{err}
}
defer res.Body.Close()
return statusMsg(res.StatusCode)
}
func (m model) Init() tea.Cmd { return checkServer }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case statusMsg:
m.status = int(msg)
return m, tea.Quit
case errMsg:
m.err = msg
return m, tea.Quit
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
}
}
return m, nil
}
Commands that take arguments
Return a closure:
func fetch(url string) tea.Cmd {
return func() tea.Msg {
resp, err := http.Get(url)
if err != nil {
return errMsg{err}
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return bodyMsg(b)
}
}
// usage: return m, fetch("https://example.com")
Composing commands
tea.Batch(cmds...)- run in parallel, messages arrive in any order.tea.Sequence(cmds...)- run one after the other, waiting for each to complete.tea.Tick(d, fn)- fire a single message after a duration.tea.Every(d, fn)- fire a message on a recurring interval (aligned to wall clock).tea.Printf/tea.Println- print above the TUI (inline mode) as a Cmd.
func tickEverySecond() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// Re-arm inside Update to keep ticking:
case tickMsg:
return m, tickEverySecond()
Sending messages from outside the Elm loop
Pass the *tea.Program into a goroutine and call p.Send(msg):
var p *tea.Program
func main() {
p = tea.NewProgram(model{})
go func() {
for event := range externalChan {
p.Send(externalMsg(event))
}
}()
if _, err := p.Run(); err != nil { log.Fatal(err) }
}
3. Handling keyboard input
tea.KeyMsg comes in on every keystroke. Three common ways to match:
case tea.KeyMsg:
// 1. By string representation (most common)
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "enter", " ":
// ...
case "up", "k":
// ...
}
// 2. By KeyType (enum) for special keys
switch msg.Type {
case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc:
return m, tea.Quit
case tea.KeyTab, tea.KeyShiftTab:
// ...
}
// 3. Raw runes for printable characters
if msg.Type == tea.KeyRunes {
for _, r := range msg.Runes { /* ... */ }
}
Structured keybindings via bubbles/key
Prefer this pattern for real apps - it makes help text automatic:
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up, Down, Help, Quit key.Binding
}
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", "esc", "ctrl+c"), key.WithHelp("q", "quit")),
}
// In Update:
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Up): /* ... */
case key.Matches(msg, keys.Down): /* ... */
case key.Matches(msg, keys.Quit): return m, tea.Quit
}
Toggle bindings dynamically with keys.Foo.SetEnabled(false) so the help view hides them contextually.
4. Program options
p := tea.NewProgram(
model{},
tea.WithAltScreen(), // take over the full terminal
tea.WithMouseCellMotion(), // enable mouse (cell-level motion only)
// tea.WithMouseAllMotion() // enable mouse (track every motion event)
tea.WithContext(ctx), // propagate cancellation
tea.WithOutput(os.Stderr), // render to a custom writer
tea.WithInput(inputReader), // use a custom input source (SSH, tests)
tea.WithFPS(60), // cap renders per second
tea.WithoutCatchPanics(), // let panics through (debugging)
tea.WithoutSignalHandler(), // don't install SIGINT handler
tea.WithFilter(func(m tea.Model, msg tea.Msg) tea.Msg {
// intercept/rewrite any message before Update sees it
return msg
}),
)
Runtime commands that toggle screen state
These are also messages you can return from Update:
tea.EnterAltScreen,tea.ExitAltScreentea.EnableMouseCellMotion,tea.EnableMouseAllMotion,tea.DisableMousetea.HideCursor,tea.ShowCursortea.ClearScreentea.EnableBracketedPaste,tea.DisableBracketedPastetea.EnableReportFocus,tea.DisableReportFocustea.Suspend(withtea.ResumeMsgarriving after)tea.SetWindowTitle("...")
case " ":
var cmd tea.Cmd
if m.altscreen {
cmd = tea.ExitAltScreen
} else {
cmd = tea.EnterAltScreen
}
m.altscreen = !m.altscreen
return m, cmd
5. Window sizing and responsive layout
Bubble Tea sends a tea.WindowSizeMsg on startup and whenever the terminal resizes. Store the width/height on your model and propagate them to child components.
type model struct {
width, height int
viewport viewport.Model
ready bool
}
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
headerH := lipgloss.Height(m.headerView())
footerH := lipgloss.Height(m.footerView())
vMargin := headerH + footerH
if !m.ready {
m.viewport = viewport.New(msg.Width, msg.Height-vMargin)
m.viewport.SetContent(m.content)
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - vMargin
}
}
// ...
}
Call tea.WindowSize() as a Cmd to request a size message on demand (useful after returning from a suspended state).
6. Styling with lipgloss
import "github.com/charmbracelet/lipgloss"
var style = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
PaddingTop(2).
PaddingLeft(4).
Width(22)
fmt.Println(style.Render("Hello, kitty"))
Colors
- ANSI-16:
lipgloss.Color("5") - ANSI-256:
lipgloss.Color("86") - True color:
lipgloss.Color("#0000FF") - Adapt to light/dark terminals:
lipgloss.AdaptiveColor{Light: "236", Dark: "248"} - Explicit per-profile:
lipgloss.CompleteColor{TrueColor: "#0000FF", ANSI256: "21", ANSI: "4"}
Text attributes
Bold, Italic, Faint, Underline, Strikethrough, Reverse, Blink.
Box model (CSS-like shorthands)
s := lipgloss.NewStyle().
Padding(2). // all sides
Margin(1, 4). // vertical, horizontal
Padding(1, 4, 2, 1). // top, right, bottom, left (clockwise)
Width(40).Height(10).
Align(lipgloss.Center) // horizontal alignment
Borders
lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()). // NormalBorder, ThickBorder, DoubleBorder, HiddenBorder, ASCIIBorder
BorderForeground(lipgloss.Color("228")).
BorderTop(true).
BorderLeft(true)
// Custom border:
b := lipgloss.Border{Top: "─", Bottom: "─", Left: "│", Right: "│",
TopLeft: "╭", TopRight: "╮", BottomLeft: "╰", BottomRight: "╯"}
Composition
lipgloss.JoinHorizontal(lipgloss.Top, left, right) // side-by-side
lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, s) // center within a box
lipgloss.Width(s) // measure rendered width (honoring ANSI)
lipgloss.Height(s) // measure rendered height
Inheritance and unset
base := lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
bold := lipgloss.NewStyle().Inherit(base).Bold(true)
plain := bold.UnsetBold()
7. Bubbles: ready-made components
All Bubbles components follow a mini Elm loop of their own with Init() tea.Cmd, Update(tea.Msg) (Model, tea.Cmd), and View() string. Embed them on your parent model and forward messages in Update.
spinner
import "github.com/charmbracelet/bubbles/spinner"
type model struct{ spinner spinner.Model }
func initial() model {
s := spinner.New()
s.Spinner = spinner.Dot // Line, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey, ...
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return model{spinner: s}
}
func (m model) Init() tea.Cmd { return m.spinner.Tick }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m model) View() string {
return fmt.Sprintf("%s loading...", m.spinner.View())
}
textinput
import "github.com/charmbracelet/bubbles/textinput"
ti := textinput.New()
ti.Placeholder = "Pikachu"
ti.Focus()
ti.CharLimit = 156
ti.Width = 20
// ti.EchoMode = textinput.EchoPassword; ti.EchoCharacter = '•'
func (m model) Init() tea.Cmd { return textinput.Blink }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
// submit: m.textInput.Value()
return m, tea.Quit
case tea.KeyCtrlC, tea.KeyEsc:
return m, tea.Quit
}
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
Multi-input form (tab to cycle)
type model struct {
focusIndex int
inputs []textinput.Model
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "tab", "shift+tab", "up", "down":
if msg.String() == "up" || msg.String() == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}
if m.focusIndex >= len(m.inputs) { m.focusIndex = 0 }
if m.focusIndex < 0 { m.focusIndex = len(m.inputs) - 1 }
cmds := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
if i == m.focusIndex {
cmds[i] = m.inputs[i].Focus()
} else {
m.inputs[i].Blur()
}
}
return m, tea.Batch(cmds...)
}
}
// forward to all; only focused ones actually react
cmds := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
}
return m, tea.Batch(cmds...)
}
textarea
import "github.com/charmbracelet/bubbles/textarea"
ta := textarea.New()
ta.Placeholder = "Say something..."
ta.Focus()
ta.CharLimit = 280
// forward msgs like textinput; read with ta.Value()
list
import "github.com/charmbracelet/bubbles/list"
type item string
func (i item) FilterValue() string { return string(i) }
// Minimal custom delegate:
type delegate struct{}
func (delegate) Height() int { return 1 }
func (delegate) Spacing() int { return 0 }
func (delegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (delegate) Render(w io.Writer, m list.Model, index int, li list.Item) {
it, ok := li.(item)
if !ok { return }
prefix := " "
if index == m.Index() { prefix = "> " }
fmt.Fprint(w, prefix+string(it))
}
l := list.New([]list.Item{item("Alpha"), item("Bravo")}, delegate{}, 30, 14)
l.Title = "Pick one"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(true)
// In Update, forward and respond to window sizes:
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
if msg.String() == "enter" {
if it, ok := m.list.SelectedItem().(item); ok {
m.choice = string(it)
}
}
m.list, cmd = m.list.Update(msg)
For a batteries-included default, use list.NewDefaultDelegate() and items that implement both Title() string and Description() string.
viewport (scrollable pager)
import "github.com/charmbracelet/bubbles/viewport"
case tea.WindowSizeMsg:
if !m.ready {
m.viewport = viewport.New(msg.Width, msg.Height-headerFooterHeight)
m.viewport.SetContent(m.content)
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - headerFooterHeight
}
// always forward key/mouse for scroll:
m.viewport, cmd = m.viewport.Update(msg)
Enable mouse wheel scrolling with tea.WithMouseCellMotion() in program options. m.viewport.ScrollPercent() gives the current scroll ratio for status bars.
progress
import "github.com/charmbracelet/bubbles/progress"
pb := progress.New(progress.WithDefaultGradient())
// or: progress.WithScaledGradient("#FF7CCB", "#FDFF8C")
// or: progress.WithSolidFill("63")
// Animated set:
case progressMsg: // your own msg carrying a float64 percentage
cmd := m.progress.SetPercent(float64(msg))
return m, cmd
case progress.FrameMsg:
var cmd tea.Cmd
newModel, cmd := m.progress.Update(msg)
m.progress = newModel.(progress.Model)
return m, cmd
// View: m.progress.View()
Set width after window size messages: m.progress.Width = msg.Width - padding.
paginator
import "github.com/charmbracelet/bubbles/paginator"
p := paginator.New()
p.Type = paginator.Dots // or paginator.Arabic
p.PerPage = 10
p.ActiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}).Render("•")
p.InactiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "238"}).Render("•")
p.SetTotalPages(len(items))
// In View:
start, end := m.paginator.GetSliceBounds(len(m.items))
for _, it := range m.items[start:end] { /* render */ }
sb.WriteString(m.paginator.View())
// Forward in Update:
m.paginator, cmd = m.paginator.Update(msg)
table
import "github.com/charmbracelet/bubbles/table"
cols := []table.Column{
{Title: "Rank", Width: 4},
{Title: "City", Width: 20},
}
rows := []table.Row{{"1", "Tokyo"}, {"2", "Delhi"}}
t := table.New(
table.WithColumns(cols),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(7),
)
s := table.DefaultStyles()
s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).BorderBottom(true).Bold(false)
s.Selected = s.Selected.Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).Bold(false)
t.SetStyles(s)
// In Update:
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
return m, tea.Batch(tea.Printf("chose %s", m.table.SelectedRow()[1]))
case "esc":
if m.table.Focused() { m.table.Blur() } else { m.table.Focus() }
}
}
m.table, cmd = m.table.Update(msg)
help
import (
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
)
type keyMap struct{ Up, Down, Help, 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}}
}
h := help.New()
// View:
helpView := m.help.View(m.keys) // one-liner; m.help.ShowAll = true for full view
// Resize:
case tea.WindowSizeMsg:
m.help.Width = msg.Width
timer / stopwatch
import "github.com/charmbracelet/bubbles/timer"
m.timer = timer.New(time.Minute)
func (m model) Init() tea.Cmd { return m.timer.Init() }
case timer.TickMsg:
m.timer, cmd = m.timer.Update(msg)
case timer.StartStopMsg:
m.timer, cmd = m.timer.Update(msg)
case timer.TimeoutMsg:
return m, tea.Quit
Stopwatch has the same shape (stopwatch.New(), stopwatch.TickMsg, Start/Stop/Reset).
filepicker
import "github.com/charmbracelet/bubbles/filepicker"
fp := filepicker.New()
fp.AllowedTypes = []string{".go", ".md"}
fp.CurrentDirectory, _ = os.UserHomeDir()
// In Update, forward msgs:
m.fp, cmd = m.fp.Update(msg)
if didSelect, path := m.fp.DidSelectFile(msg); didSelect {
m.selected = path
}
8. Common patterns
Loading state while an async op runs
type doneMsg struct{ result string }
func loadStuff() tea.Cmd {
return func() tea.Msg {
time.Sleep(2 * time.Second)
return doneMsg{result: "fetched"}
}
}
type model struct {
spinner spinner.Model
loading bool
result string
}
func (m model) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, loadStuff())
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case doneMsg:
m.loading = false
m.result = msg.result
return m, nil
case tea.KeyMsg:
if msg.String() == "q" { return m, tea.Quit }
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m model) View() string {
if m.loading {
return fmt.Sprintf("%s loading...", m.spinner.View())
}
return "Result: " + m.result
}
Multi-screen navigation (state machine)
Keep an enum of screens on the model and dispatch in Update and View:
type screen int
const (
screenMenu screen = iota
screenForm
screenResult
)
type model struct {
screen screen
menu list.Model
form formModel
result string
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.screen {
case screenMenu:
return m.updateMenu(msg)
case screenForm:
return m.updateForm(msg)
case screenResult:
return m.updateResult(msg)
}
return m, nil
}
func (m model) View() string {
switch m.screen {
case screenMenu: return m.menu.View()
case screenForm: return m.form.View()
case screenResult: return m.result
}
return ""
}
Each sub-update returns m, cmd and can change m.screen to transition.
Modal dialog overlay
Store a modal *modalModel on the parent. When non-nil, render it on top:
func (m model) View() string {
base := m.mainView()
if m.modal == nil { return base }
dialog := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Padding(1, 2).
Render(m.modal.View())
return lipgloss.Place(
m.width, m.height,
lipgloss.Center, lipgloss.Center,
dialog,
lipgloss.WithWhitespaceChars(" "),
)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.modal != nil {
// let the modal consume messages; swallow keys from the base
var cmd tea.Cmd
*m.modal, cmd = m.modal.Update(msg)
if m.modal.done { m.modal = nil }
return m, cmd
}
return m.baseUpdate(msg)
}
Composable/embedded models (tab layout)
type sessionState uint
const (
timerView sessionState = iota
spinnerView
)
type mainModel struct {
state sessionState
timer timer.Model
spinner spinner.Model
}
func (m mainModel) Init() tea.Cmd {
return tea.Batch(m.timer.Init(), m.spinner.Tick)
}
func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "tab":
if m.state == timerView { m.state = spinnerView } else { m.state = timerView }
}
// only forward keys to the focused child:
if m.state == spinnerView {
m.spinner, cmd = m.spinner.Update(msg)
} else {
m.timer, cmd = m.timer.Update(msg)
}
cmds = append(cmds, cmd)
case spinner.TickMsg:
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
case timer.TickMsg:
m.timer, cmd = m.timer.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m mainModel) View() string {
box := lipgloss.NewStyle().Width(15).Height(5).
Align(lipgloss.Center, lipgloss.Center).
BorderStyle(lipgloss.HiddenBorder())
focused := box.BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("69"))
if m.state == timerView {
return lipgloss.JoinHorizontal(lipgloss.Top,
focused.Render(m.timer.View()), box.Render(m.spinner.View()))
}
return lipgloss.JoinHorizontal(lipgloss.Top,
box.Render(m.timer.View()), focused.Render(m.spinner.View()))
}
Pager (viewport + header + footer)
var (
titleStyle = func() lipgloss.Style {
b := lipgloss.RoundedBorder(); b.Right = "├"
return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1)
}()
infoStyle = func() lipgloss.Style {
b := lipgloss.RoundedBorder(); b.Left = "┤"
return titleStyle.BorderStyle(b)
}()
)
func (m model) headerView() string {
title := titleStyle.Render("Mr. Pager")
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
}
func (m model) footerView() string {
info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100))
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info)))
return lipgloss.JoinHorizontal(lipgloss.Center, line, info)
}
func (m model) View() string {
if !m.ready { return "\n Initializing..." }
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView())
}
// Launch with alt-screen + mouse:
p := tea.NewProgram(model{content: string(b)},
tea.WithAltScreen(), tea.WithMouseCellMotion())
Mouse events
p := tea.NewProgram(model{}, tea.WithMouseAllMotion())
case tea.MouseMsg:
// msg.X, msg.Y, msg.Action (MouseActionPress/Release/Motion),
// msg.Button (MouseButtonLeft/Right/Wheel...), msg.Alt/Ctrl/Shift
return m, tea.Printf("(%d, %d) %s", msg.X, msg.Y, msg.String())
9. Logging and debugging
TUIs own stdout/stderr, so you can't fmt.Println for debugging while the program runs. Instead:
if os.Getenv("DEBUG") != "" {
f, err := tea.LogToFile("debug.log", "debug")
if err != nil { log.Fatal(err) }
defer f.Close()
}
// now log.Printf(...) writes to debug.log
Then tail -f debug.log in another pane. Or print above the TUI (only visible in inline mode, not alt-screen) with tea.Println(...) / tea.Printf(...) returned as a Cmd.
10. Testing TUIs with teatest
import (
"bytes"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
)
func TestMyModel(t *testing.T) {
m := initialModel()
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
// Type keystrokes
tm.Type("hello")
// Send a synthetic message
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
// Wait until the rendered output matches a condition
teatest.WaitFor(t, tm.Output(),
func(bts []byte) bool {
return bytes.Contains(bts, []byte("expected text"))
},
teatest.WithDuration(time.Second),
)
// Quit and assert final state
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
finalModel := tm.FinalModel(t).(model)
if finalModel.choice != "hello" {
t.Fatalf("want choice=hello, got %q", finalModel.choice)
}
// Or compare the final rendered output (golden-file friendly)
out := tm.FinalOutput(t)
teatest.RequireEqualOutput(t, out)
}
Key helpers:
teatest.NewTestModel(t, m, opts...)- harness;WithInitialTermSize(w, h)is usually needed.tm.Send(msg)- inject anytea.Msg.tm.Type("abc")- send keystrokes.tm.Output()-io.Readerof the live render stream.tm.FinalModel(t)/tm.FinalOutput(t)- wait for exit, then inspect.teatest.WaitFor(t, r, pred, opts...)- block until render output satisfiespred.teatest.RequireEqualOutput(t, bytes)- golden-file comparison under-update.
11. Running a program
p := tea.NewProgram(initialModel(), tea.WithAltScreen())
finalModel, err := p.Run()
if err != nil {
log.Fatalf("alas: %v", err)
}
// finalModel is a tea.Model holding terminal state; type-assert to your concrete type:
if m, ok := finalModel.(model); ok {
fmt.Println("chose:", m.choice)
}
Other *tea.Program methods worth knowing:
p.Send(msg)- inject a message from outside (goroutines).p.Quit()- request shutdown.p.Kill()- force shutdown (returnstea.ErrProgramKilled).p.Wait()- block until the program finishes.p.ReleaseTerminal()/p.RestoreTerminal()- temporarily hand the TTY back (e.g. to shell out to$EDITOR). Or usetea.ExecProcess(cmd, cb)/tea.Exec(...)as aCmd.
12. Conventions and gotchas
- Pure Update. Don't do I/O, sleep, or HTTP in
Update. Return aCmdfor that. - Value receivers.
func (m model) Update(...)— returning a modified copy is the idiom. - Forward messages. When you embed a Bubbles component, you must call
m.sub, cmd = m.sub.Update(msg)in yourUpdateor it won't animate/scroll/blink. - Always handle
tea.WindowSizeMsgif any child usesWidth/Height(viewport, list, table, progress, help). - Handle
ctrl+c. Always map it totea.Quitearly in the switch. - Alt-screen + logs.
fmt.Printlnis swallowed whenWithAltScreen()is on; usetea.LogToFile. - Batch, not chain. When an Update branch wants to issue multiple commands, collect them and return
tea.Batch(cmds...)— don't callUpdaterecursively. - Rate-limit updates. If an external source (channel, file watch) can flood
p.Send, coalesce on the sender side; the renderer caps at 60 FPS by default. - Styles are immutable.
s.Bold(true)returns a new style; reassign if you want it to stick.
13. v2 migration notes (github.com/charmbracelet/bubbletea/v2)
If the project imports github.com/charmbracelet/bubbletea/v2 (or charm.land/bubbletea/v2), key differences:
View()returnstea.View(viatea.NewView(s)), not a plainstring.- Key input is split: prefer
tea.KeyPressMsg(key-down) inUpdate.Keyfields includeMod(e.g.tea.ModCtrl) andCode(a rune). tea.SetWindowTitleis available as aCmd(also present in v1).- The import for bubbles is
charm.land/bubbles/v2/...and for lipglosscharm.land/lipgloss/v2.
Every pattern in this skill has a v2 equivalent; the Model/Update/View loop is unchanged. When in doubt, inspect the project's go.mod to see which major version is in use and match existing style.
More from brojonat/llmsrules
ibis-data
Use Ibis for database-agnostic data access in Python. Use when writing data queries, connecting to databases (DuckDB, PostgreSQL, SQLite), or building portable data pipelines that should work across backends.
13go-service
Build Go microservices with stdlib HTTP handlers, sqlc, urfave/cli, and slog. Use when creating or modifying a Go HTTP server, adding routes, middleware, database queries, or CLI commands.
13temporal-go
Build Temporal workflow applications in Go. Use when creating or modifying Temporal workflows, activities, workers, clients, signals, queries, updates, retry policies, saga patterns, or writing Temporal tests.
13parquet-analysis
Analyze parquet files using Python and Ibis. Use when the user wants to explore, transform, or analyze parquet data files, perform aggregations, joins, or export results. Works with local parquet files and provides database-agnostic data operations.
12ducklake
Work with DuckLake, an open lakehouse format built on DuckDB. Use when creating or querying DuckLake tables, managing snapshots, time travel, schema evolution, partitioning, or lakehouse maintenance operations.
12temporal-python
Build Temporal applications in Python using the temporalio SDK. Use when creating workflows, activities, workers, clients, signals, queries, updates, child workflows, timers, retry policies, saga/compensation patterns, testing, or any durable execution pattern in Python.
12