| name | go-tui |
| description | Conventions for building Go terminal UIs with Bubble Tea v2, Bubbles v2, and Lip Gloss v2 (charm.land module path). Covers Elm-architecture discipline, tea.Cmd/tea.Msg side effects, sub-model composition, keymaps, styling, and testing. Use when writing or reviewing a Go TUI, terminal UI, or interactive CLI screen; when the user says "bubbletea", "bubbles", "lipgloss", "TUI", "terminal UI", or "charm"; or when go.mod imports charm.land/bubbletea, charm.land/bubbles, charm.land/lipgloss, or github.com/charmbracelet/bubbletea (v1 — flag for migration). |
Go TUIs with Bubble Tea v2
Module paths (v2 moved to charm.land)
Bubble Tea v2 and friends live at charm.land, not github.com/charmbracelet:
import (
tea "charm.land/bubbletea/v2"
"charm.land/bubbles/v2/viewport"
"charm.land/lipgloss/v2"
)
Never mix github.com/charmbracelet/bubbletea (v1) imports with charm.land/.../v2 in one program — the tea.Msg types won't match and components silently stop receiving messages. If go.mod has the v1 path, treat it as a migration candidate. See references/v2-api.md when you need exact v1→v2 differences or component API signatures.
Elm architecture discipline
Three roles, strictly separated:
- Model is pure state — plain struct fields, no goroutines, no open handles doing work.
- Update is the only place state changes. It receives a
tea.Msg, mutates its value-receiver copy, and returns (tea.Model, tea.Cmd). No I/O, no time.Sleep, no network, no disk reads — Update must return in microseconds or the whole UI freezes.
- View is a pure render of the model. No state changes, no I/O, no style construction (see Styling).
Every side effect is a tea.Cmd: a func() tea.Msg the runtime executes on its own goroutine, whose result re-enters Update as a message.
func (m appModel) Init() tea.Cmd { return nil }
func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height
m.resize()
return m, nil
case tea.KeyPressMsg:
return m.handleKey(msg)
case respMsg:
m.resp, m.sending = msg.resp, false
return m, nil
}
return m, nil
}
func (m appModel) View() tea.View {
v := tea.NewView(m.render())
v.AltScreen = true
return v
}
Run with tea.NewProgram(m).Run() — v2 needs no WithAltScreen option; declare it on the View.
Commands: how work gets done
Define one msg struct per result, close over inputs, return the msg:
type respMsg struct{ resp Response; err string }
func (m appModel) sendCmd() tea.Cmd {
req, env := m.cur, m.envName()
return func() tea.Msg {
resp, err := client.Send(context.Background(), req, env)
return respMsg{resp: resp, err: errString(err)}
}
}
- Fire several at once with
tea.Batch(m.spin.Tick, m.sendCmd()); in order with tea.Sequence.
- Timers:
tea.Tick(time.Second, func(time.Time) tea.Msg { return tickMsg{} }) — one-shot; re-arm it from Update while the condition holds.
- External processes ($EDITOR):
tea.ExecProcess(cmd, func(err error) tea.Msg { ... }).
- Streams/channels: a command that blocks on
<-ch and re-arms itself after each message (pump pattern — see references/patterns.md).
- From outside the program entirely:
program.Send(msg).
- Guard stale results: commands capture state at dispatch, so tag msgs with the request path/ID and drop them in Update if the model moved on.
Goroutines never touch the model. A goroutine that mutates model fields races with Update and corrupts rendering. Results come back only as messages.
Composing sub-models
Parent owns children as struct fields (embed bubbles components — viewport, textinput, spinner — rather than reimplementing scrolling/editing/ticking). Parent Update routes messages down; children return (child, cmd); collect cmds and tea.Batch them:
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
Route by focus, not broadcast: track which pane is focused and forward key/scroll msgs only to it. Focus() on textinput/textarea returns a cmd — return it. Propagate tea.WindowSizeMsg yourself: compute layout once in a resize() helper and push sizes into every child (vp.SetWidth/SetHeight, ti.SetWidth); children never see the raw msg unless you forward it.
Keys
tea.KeyPressMsg.String() gives canonical names: "q", "enter", "ctrl+c", "shift+tab", "space" (not " "). Simple apps can switch on the string; for user-facing help, define a keymap with bubbles/key (key.NewBinding(key.WithKeys(...), key.WithHelp(...)), match with key.Matches(msg, km.Up)) and render it with bubbles/help. Overlays (modal, palette, editor) must consume keys before global bindings, and global quit (ctrl+c) before focused-pane fallthrough.
Styling (lipgloss v2)
Define colors and styles once at package level (or in a theme struct); View only calls .Render. Building lipgloss.NewStyle().... per frame allocates and re-parses constantly.
var (
colAccent = lipgloss.Color("#7aa2f7")
styleTitle = lipgloss.NewStyle().Foreground(colAccent).Bold(true)
)
Compose layout with lipgloss.JoinHorizontal/JoinVertical, measure with lipgloss.Width (ANSI-aware). Deriving a variant inside Update on a state change is fine; per-frame in View is not.
Testing
senda-style: plain Go tests, no harness. Build a model with a fixed size, drive it, assert on state or rendered output — Update is a pure function, so tests are just msg-in/state-out:
m := newTestModel()
mm, cmd := m.Update(tea.KeyPressMsg{...})
got := mm.(appModel)
Smoke-test View: render every layout/overlay at a realistic size and assert non-empty (catches panics and zero-size math). For full program-loop tests, teatest exists, but pure Update tests cover most logic cheaper. Patterns and snapshot testing: references/patterns.md.
Pitfalls
- Blocking in Update (HTTP call, file read, sleep) — freezes every keystroke. Move it to a tea.Cmd.
- Dropping the cmd:
m.vp, _ = m.vp.Update(msg) breaks spinners, cursor blink, focus. Return every cmd, Batch if several.
- Mutating the model from a goroutine — data race; send a msg instead.
- Ignoring
tea.WindowSizeMsg — panes render at zero size until resize; it arrives first, handle it and store w/h.
- Rebuilding styles in View per frame — allocate once at package level.
- v1 imports mixed with v2 (
github.com/charmbracelet/... + charm.land/.../v2) — msg types diverge, components go dead.
- Not reassigning value-type children after
child.Update(msg).
- One-shot
tea.Tick assumed periodic — re-arm from Update, and stop re-arming when the condition ends.
References
- references/v2-api.md — read when you need exact v2 signatures: msg types, tea.View fields, bubbles component APIs, lipgloss v2 changes, v1→v2 migration table.
- references/patterns.md — read when wiring async work (channel pumps, tickers, ExecProcess, Program.Send), multi-pane focus/overlay routing, or writing TUI tests.