name: charm-bubbletea description: "Build terminal UIs in Go with Bubble Tea v2's Elm Architecture (Model/Update/View). Use when building Go TUI apps, tea.Model, tea.Cmd, Elm architecture, or terminal applications. NOT for pre-built TUI components (use bubbles)." argument-hint: "[component or pattern name]"
Bubble Tea v2
Build terminal UIs in Go using the Elm Architecture. Import as tea "charm.land/bubbletea/v2".
$ARGUMENTS context: If a specific component or pattern is requested, focus guidance on that area.
Quick Start
Minimal working program - a counter:
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
)
type model struct{ count 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.KeyPressMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
m.count++
case "down", "j":
m.count--
}
}
return m, nil
}
func (m model) View() tea.View {
return tea.NewView(fmt.Sprintf("Count: %d\n\nup/down to change, q to quit\n", m.count))
}
func main() {
if _, err := tea.NewProgram(model{}).Run(); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}
Core API Reference
The Elm Architecture
Bubble Tea follows the Elm Architecture - a unidirectional data flow pattern:
- Model holds all application state
- Update receives messages, returns updated model + optional command
- View renders the model to a string (called after every Update)
- Commands perform async I/O, returning messages back to Update
The runtime owns the loop. You never mutate state outside Update. You never call View yourself.
tea.Model Interface
type Model interface {
Init() Cmd // initial command (return nil for none)
Update(Msg) (Model, Cmd) // handle messages, return new state + command
View() View // render UI
}
tea.Cmd and tea.Msg
type Msg = any // messages can be any type
type Cmd func() Msg // commands are functions that return a message
A Cmd is a function that performs I/O and returns a Msg. Return nil for no command.
tea.View
// Create a view from a string
v := tea.NewView("Hello, World!")
// View struct fields (set after creation):
v.AltScreen = true // fullscreen mode
v.MouseMode = tea.MouseModeCellMotion // enable mouse
v.ReportFocus = true // get FocusMsg/BlurMsg
v.Cursor = tea.NewCursor(x, y) // show cursor at position
v.WindowTitle = "My App" // set terminal title
v.BackgroundColor = someColor // set terminal bg
v.KeyboardEnhancements.ReportEventTypes = true // key release events
Program
p := tea.NewProgram(model{}, opts...) // create program
m, err := p.Run() // run (blocks until quit)
p.Send(msg) // send message from outside
p.Quit() // quit from outside
p.Kill() // force kill
p.Wait() // block until shutdown
Program Options
tea.WithContext(ctx) // cancellable context
tea.WithInput(reader) // custom input (nil to disable)
tea.WithOutput(writer) // custom output
tea.WithFilter(func(Model, Msg) Msg) // intercept/filter messages
tea.WithFPS(fps) // custom FPS (default 60, max 120)
tea.WithEnvironment([]string) // custom env vars (SSH)
tea.WithWindowSize(w, h) // initial size (testing)
tea.WithColorProfile(profile) // force color profile
tea.WithoutRenderer() // no TUI rendering
tea.WithoutSignalHandler() // handle signals yourself
tea.WithoutCatchPanics() // disable panic recovery
Built-in Messages
| Message | When |
|---|---|
tea.KeyPressMsg |
Key pressed. Use msg.String() to match (e.g. "ctrl+c", "enter", "a") |
tea.KeyReleaseMsg |
Key released (needs keyboard enhancements) |
tea.WindowSizeMsg |
Terminal resized. Fields: Width, Height. Sent on startup + resize |
tea.MouseClickMsg |
Mouse click. Fields: X, Y, Button, Mod |
tea.MouseReleaseMsg |
Mouse button released |
tea.MouseWheelMsg |
Scroll wheel |
tea.MouseMotionMsg |
Mouse moved (needs AllMotion mode) |
tea.FocusMsg |
Terminal gained focus (needs ReportFocus) |
tea.BlurMsg |
Terminal lost focus |
tea.PasteMsg |
Bracketed paste. Field: Content |
tea.ColorProfileMsg |
Terminal color profile on startup |
tea.BackgroundColorMsg |
Response to RequestBackgroundColor. Has IsDark() |
tea.ResumeMsg |
Program resumed after suspend |
tea.KeyboardEnhancementsMsg |
Terminal keyboard capabilities |
Built-in Commands
| Command | What it does |
|---|---|
tea.Quit |
Exit the program |
tea.Suspend |
Suspend (ctrl+z behavior) |
tea.Interrupt |
Interrupt (returns ErrInterrupted) |
tea.ClearScreen |
Clear terminal |
tea.Batch(cmds...) |
Run commands concurrently |
tea.Sequence(cmds...) |
Run commands in order |
tea.Every(dur, fn) |
Tick synced with system clock |
tea.Tick(dur, fn) |
Tick from invocation time |
tea.Println(args...) |
Print above TUI (persists across renders) |
tea.Printf(tmpl, args...) |
Printf above TUI |
tea.SetClipboard(s) |
Set system clipboard (OSC52) |
tea.ReadClipboard |
Read system clipboard |
tea.RequestWindowSize |
Query current window size |
tea.RequestBackgroundColor |
Query terminal background color |
tea.ExecProcess(cmd, callback) |
Run external process (e.g. editor) |
tea.Raw(seq) |
Send raw escape sequence |
Key Handling
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "q": // string matching (most common)
return m, tea.Quit
case "up", "k": // arrow keys have string names
case "enter", "space": // special keys
case "ctrl+s": // modifier combos
case "a": // regular characters
}
// Or use the Key struct for type-safe matching:
case tea.KeyPressMsg:
key := msg.Key()
switch key.Code {
case tea.KeyEnter: // typed constant
case tea.KeyTab:
case tea.KeyEsc:
}
// key.Mod for modifiers: tea.ModCtrl, tea.ModAlt, tea.ModShift
// key.Text for printable characters
Mouse Handling
Enable mouse in View, handle in Update:
func (m model) View() tea.View {
v := tea.NewView(m.render())
v.MouseMode = tea.MouseModeCellMotion // or tea.MouseModeAllMotion
return v
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseClickMsg:
x, y := msg.X, msg.Y
if msg.Button == tea.MouseLeft { /* handle click */ }
case tea.MouseWheelMsg:
if msg.Button == tea.MouseWheelUp { /* scroll up */ }
case tea.MouseMsg:
// catches all mouse events
mouse := msg.Mouse()
}
return m, nil
}
Common Patterns
Pattern 1: Async I/O (HTTP, file, etc.)
Define a custom Msg type. Write a Cmd that does the I/O and returns it.
type statusMsg int
type errMsg struct{ error }
func checkServer() tea.Msg {
res, err := http.Get("https://example.com")
if err != nil {
return errMsg{err}
}
defer res.Body.Close()
return statusMsg(res.StatusCode)
}
func (m model) Init() tea.Cmd { return checkServer } // note: pass function, don't call it
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.error
return m, nil
}
return m, nil
}
Pattern 2: Ticking / Timers
tea.Tick and tea.Every send a single message. Re-dispatch to loop.
type tickMsg time.Time
func doTick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m model) Init() tea.Cmd { return doTick() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tickMsg:
m.elapsed++
return m, doTick() // re-dispatch to keep ticking
}
return m, nil
}
tea.Every syncs with the system clock (wall-clock aligned ticks). tea.Tick starts from invocation.
Pattern 3: Composing Child Components
Child components follow the same Model/Update/View pattern. Parent delegates messages.
type parentModel struct {
spinner spinner.Model
input textinput.Model
focus int
}
func (m parentModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, m.input.Focus())
}
func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Always update spinner (it needs ticks regardless of focus)
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
// Only update focused component for key events
switch msg.(type) {
case tea.KeyPressMsg:
if m.focus == 0 {
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
func (m parentModel) View() tea.View {
return tea.NewView(m.spinner.View() + "\n" + m.input.View())
}
Pattern 4: External Messages via Channel
Use p.Send() to push messages from goroutines, or use a channel-based Cmd.
// Channel-based approach (preferred for subscription-like behavior)
func waitForActivity(sub chan resultMsg) tea.Cmd {
return func() tea.Msg {
return <-sub // blocks until message arrives
}
}
func (m model) Init() tea.Cmd {
return waitForActivity(m.sub)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case resultMsg:
m.results = append(m.results, msg)
return m, waitForActivity(m.sub) // re-subscribe
}
return m, nil
}
// p.Send approach (from outside the program)
go func() {
p.Send(myMsg{data: "hello"}) // thread-safe
}()
Pattern 5: Fullscreen with Alt Screen
Set AltScreen in your View. No Program option needed in v2.
func (m model) View() tea.View {
v := tea.NewView(fmt.Sprintf("Fullscreen app! Size: %dx%d", m.width, m.height))
v.AltScreen = true
return v
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
Integration Patterns
Lip Gloss (Styling)
import "charm.land/lipgloss/v2"
var style = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
Padding(0, 1)
func (m model) View() tea.View {
return tea.NewView(style.Render("styled text"))
}
Bubbles (Components)
Standard components: spinner, textinput, textarea, list, table, viewport, paginator, timer, stopwatch, help, key, filepicker, progress.
Import: "charm.land/bubbles/v2/<component>"
Each bubble follows the same Init/Update/View pattern. See Pattern 3 above.
Huh (Forms)
import "charm.land/huh/v2"
// Huh can run standalone or embed in a bubbletea program
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Name").Value(&name),
),
)
Common Mistakes
-
Calling a Cmd instead of passing it.
Initreturnstea.Cmd, nottea.Msg. Writereturn checkServernotreturn checkServer(). The runtime calls the function. -
Forgetting to return a Cmd from tick handlers.
tea.Tickandtea.Everyfire once. You must return a new tick command in your Update handler to keep ticking. -
Mutating the model outside Update. The Elm Architecture requires all state changes go through Update. Don't share model pointers with goroutines. Use
p.Send()or channel-based Cmds to communicate. -
Not handling WindowSizeMsg. It's sent on startup and every resize. If you do any layout math, store the dimensions and use them in View.
-
Using fmt.Println for output. stdout is owned by the TUI. Use
tea.LogToFile()for debug logging. Usetea.Println()/tea.Printf()to print above the TUI. -
Returning
tea.Batch()with nil commands. This is safe -tea.Batchfilters nil commands - but returning a plainnilis cleaner when you have no commands. -
AltScreen in v2 is a View property, not a Program option. Set
v.AltScreen = truein your View method. Same for mouse mode - setv.MouseModein View. -
Blocking in Update. Update must return quickly. Any I/O (HTTP calls, file reads, sleeps) belongs in a Cmd, not directly in Update.
-
Not handling ctrl+c. The terminal is in raw mode - ctrl+c won't kill your app automatically. Always match
"ctrl+c"in your KeyPressMsg handler and returntea.Quitortea.Interrupt.
Checklist
- Model implements
Init() tea.Cmd,Update(tea.Msg) (tea.Model, tea.Cmd),View() tea.View - View returns
tea.NewView(s), not a raw string - ctrl+c / q handling exists in Update
- WindowSizeMsg handled if doing any layout
- All I/O in Cmds, never in Update
- Tick commands re-dispatched in Update handler
- Child component updates collected with
tea.Batch(cmds...) - No
fmt.Println- usetea.LogToFilefor debugging - AltScreen and MouseMode set in View, not as Program options
Reference
For full message/type/constant listings, see references/api.md.