Bubbletea
Skill vinsonconsulting/claude-skill-foundry/skills/tui/bubbletea
Use this skill when building a terminal UI (TUI) in Go with Bubble Tea and the Charm stack: the Elm Architecture / MVU pattern (`tea.Model` with `Init`/`Update`/`View`), `Cmd`/`Msg` event flow, Lip Gloss styling and layout, Bubbles components (`viewport`, `list`, `table`, `textinput`, `textarea`, `spinner`, `progress`), Glamour markdown rendering, and teatest for testing. Especially apt for streaming tokens from an LLM/agent into a terminal (goroutine → `p.Send` → `Update` → `viewport`), an agentic CLI, or any interactive full-screen or inline terminal app in Go. Targets v2 on the `charm.land/*/v2` import paths (Go 1.25+); writes current-version-correct code and avoids v1/beta patterns. Not for: Rust TUIs (→ Ratatui sibling), Python TUIs (→ Textual sibling), plain non-interactive CLI output (use `fmt`/`cobra`/standalone `lipgloss`), web/GUI UIs, or agent session orchestration / tmux / process multiplexing.From its SKILL.md
npx -y skills add vinsonconsulting/claude-skill-foundry --skill bubbleteaAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
15.8 KB, ~3.5k tokens by cl100k_base, as published. Nobody here has run it
Bubble Tea
Write current, compiling Bubble Tea v2 code (pinned to v2.0.x on the
charm.land/*/v2 import paths) and refuse the v1/beta patterns the model remembers
from training. The body is the load-bearing 20%: the Elm mental model and one verified
example per concept. Everything enumerable — the full Bubbles catalog, the key/mouse
taxonomy, styling, streaming, testing, migration — lives in references/. Open the
matching reference before writing nontrivial code in that area.
Imports — the whole stack is on charm.land
Every Charm framework package lives on charm.land/*/v2. The most common v2 mistake is
importing lipgloss or bubbles from the old github.com/charmbracelet/… path while using
charm.land bubbletea — that pulls two incompatible trees into one build and fails to
compile. Use these paths; only the x/* helpers stay on github.com:
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" // NOT github.com/charmbracelet/lipgloss
"charm.land/bubbles/v2/viewport" // list, table, textinput, textarea, spinner, … under bubbles/v2/<c>
"charm.land/glamour/v2"
"github.com/charmbracelet/x/ansi" // width/wrap helpers — these have no charm.land alias
)
Mental model — The Elm Architecture is the whole framework
Bubble Tea is not a widget toolkit you poke at; it is one mandatory loop. You give the runtime a Model (your immutable state) and three methods, and it owns everything else:
- Init returns an optional first
Cmd. - Update receives a
Msg, returns a (possibly changed) Model and an optionalCmd. - View renders the Model to a
tea.View.
The runtime calls Update for every message, then View, and paints the result. This is
MVU (Model-View-Update). The entire discipline follows from it:
- Mutate state only inside
Update. Never fromView, from aCmdbody, or from a goroutine.Updateis the single writer; everything else races the render loop. - Never block in
Update. It runs on the render path. Do I/O — network, disk, subprocess, a timer — inside aCmd, which the runtime runs in its own goroutine and whose result comes back as aMsg. - Models flow by value.
Updatetakes the Model by value, mutates the local copy, and returns it. Bubbles (sub-components) work the same way — you reassign what theirUpdatereturns.
The Model interface (v2 signatures)
import tea "charm.land/bubbletea/v2"
type model struct{ count int }
func (m model) Init() tea.Cmd { return nil } // v2: Init returns ONLY a Cmd
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg: // KeyMsg is an interface; this is the press case
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit // tea.Quit IS a Cmd — return it, don't call it
case "up":
m.count++
case "down":
m.count--
}
}
return m, nil // always return the (value) model
}
func (m model) View() tea.View { // v2: returns a tea.View, NOT a string
return tea.NewView(fmt.Sprintf("count: %d", m.count))
}
Only one interface signature changed from v1: View() string became View() tea.View.
Init() Cmd and Update(Msg) (Model, Cmd) are unchanged — do not "modernize" them.
Cmd & Msg
A Cmd is func() tea.Msg — a deferred IO/effect the runtime runs in its own
goroutine; its returned Msg is fed back into Update. A Msg is any value (the type
is the empty interface), so your own types are messages:
type tickMsg time.Time
func tick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
}
// In Update, combine effects:
return m, tea.Batch(tick(), fetchData()) // Batch: concurrent; Sequence: one after another
Batch, Sequence, Tick, Every, and Quit are the everyday command constructors.
The runtime also sends you built-in messages you switch on and route down yourself —
most importantly tea.WindowSizeMsg (one at startup and on every resize), plus key and
mouse messages:
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.vp.SetWidth(msg.Width) // forward sizes into child components
m.vp.SetHeight(msg.Height - 2) // leave room for header + footer
Full key/mouse taxonomy (the Key struct, Mod.Contains, mouse message types) is in
references/architecture.md.
Program & lifecycle
func main() {
p := tea.NewProgram(initialModel()) // NO WithAltScreen()/WithMouseCellMotion() — gone in v2
if _, err := p.Run(); err != nil { // Run returns (final Model, error)
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
In v2, alt-screen and mouse are tea.View fields, not NewProgram options — you set
them on the view you return, so they are part of your render state:
func (m model) View() tea.View {
v := tea.NewView(body)
v.AltScreen = true // full-window mode
v.MouseMode = tea.MouseModeCellMotion // also: MouseModeNone, MouseModeAllMotion
return v
}
The runtime owns raw mode and the cursor (the "Cursed Renderer", backed by ultraviolet).
It catches panics by default and restores the terminal, returning ErrProgramPanic
from Run() (WithoutCatchPanics opts out); ErrInterrupted and ErrProgramKilled are
the other sentinels. Do not hand-roll raw mode or teardown. For logs, never write to
stdout while the UI is up — use tea.LogToFile("debug.log", ""). Details and the remaining
ProgramOptions are in references/architecture.md.
Layout with Lip Gloss
Lip Gloss has no constraint solver — layout is styled-string composition. You size
blocks (from WindowSizeMsg), style them, and glue them with JoinHorizontal /
JoinVertical / Place. Measure with lipgloss.Width / lipgloss.Size, never
len() (bytes ≠ display columns):
// 30-cell sidebar + flexible main, with header and footer
sidebar := lipgloss.NewStyle().Width(30).Render(nav)
main := lipgloss.NewStyle().Width(m.width - 30).Render(content) // total - sidebar
body := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, main)
ui := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
_ = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) // centering
Styles, colors (and v2's "pure" color model + colorprofile downsampling), borders, and
absolute-positioned overlays via lipgloss.NewCanvas/NewLayer are in
references/styling-and-layout.md.
Components (Bubbles)
Every Bubble is itself a Model (Init/Update/View). Nest its model in your
struct, route messages (including WindowSizeMsg) into its Update, reassign the
returned model, and bubble its Cmd up:
type model struct{ list list.Model }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg) // route down, reassign, bubble the Cmd up
return m, cmd
}
func (m model) View() tea.View { return tea.NewView(m.list.View()) } // child returns a string
The catalog (viewport, list, table, textinput, textarea, spinner, progress,
help, key, paginator, filepicker, stopwatch, timer, cursor, all under
charm.land/bubbles/v2/<c>), how to author your own Bubble, and the Crush escape hatch
— at scale, one top-level tea.Model whose sub-components are plain structs with
imperative methods returning tea.Cmd instead of a full Update fan-out — are in
references/components.md.
Text width & wrapping — the streaming-chat footgun
One char (or byte) is not one column. CJK ideographs and many emoji are two
columns; combining marks are zero; ZWJ/flag emoji are several runes but a couple of
columns. Measure display width, and slice/wrap with width-aware helpers:
import "github.com/charmbracelet/x/ansi"
w := lipgloss.Width(s) // display columns; == ansi.StringWidth(s)
clipped := ansi.Truncate(s, 20, "…") // ANSI-safe, width-aware truncate (not byte slicing)
wrapped := ansi.Wrap(longLine, 40, "") // hard wrap to 40 columns without breaking escapes
Footgun: ansi.StringWidth misreads if you feed it rune-by-rune — measure whole strings.
Markdown via Glamour buffers the entire input and re-renders, and partial/unclosed
markdown flickers while streaming. Full rules and a grapheme-safe approach:
references/text-and-unicode.md.
Streaming LLM output
The canonical agent-UI loop: a producer goroutine pushes deltas through p.Send, the
only goroutine-safe channel into the UI; Update appends and re-renders into a
viewport; auto-follow is gated on AtBottom() so it pins when the user scrolls up.
type deltaMsg string
type model struct {
vp viewport.Model
buf string
}
// v2 viewport is built with functional options — NOT positional viewport.New(w, h):
func newModel() model {
return model{vp: viewport.New(viewport.WithWidth(80), viewport.WithHeight(24))}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.vp.SetWidth(msg.Width)
m.vp.SetHeight(msg.Height - 1)
case deltaMsg:
atBottom := m.vp.AtBottom() // check BEFORE mutating content
m.buf += string(msg)
m.vp.SetContent(m.buf) // or glamour.Render(m.buf) for markdown
if atBottom {
m.vp.GotoBottom() // follow only if we were already pinned
}
}
return m, nil
}
// Capture *tea.Program once, then stream from a goroutine:
func stream(p *tea.Program, tokens <-chan string) {
go func() {
for t := range tokens {
p.Send(deltaMsg(t)) // NEVER touch m or call vp.Update from here
}
}()
}
The viewport Bubble owns scrollback — don't hand-roll a ring buffer. tea.Listen is a
proposal, not real; Cmd + p.Send is the whole concurrency story. The deep dive
(width-keyed render cache, the Crush pubsub→program.Send bridge, follow/freeze) is in
references/agent-ui.md; this skill covers the in-process UI only — multi-session
orchestration / tmux is a separate concern.
Stale patterns to reject (v1 / github.com beta)
If you catch yourself writing any of these, stop — they won't compile on v2:
| Reject | Use instead |
|---|---|
github.com/charmbracelet/bubbletea (or its /v2 beta path) | charm.land/bubbletea/v2 — keep the whole stack on charm.land/*/v2 |
github.com/charmbracelet/lipgloss or …/bubbles (v1) | charm.land/lipgloss/v2, charm.land/bubbles/v2/<c> — never mix github.com + charm.land |
viewport.New(w, h) (positional, v1) | viewport.New(viewport.WithWidth(w), viewport.WithHeight(h)) |
View() string | View() tea.View (tea.NewView; child components still return string) |
tea.WithAltScreen() / WithMouseCellMotion() as NewProgram options | set v.AltScreen / v.MouseMode on the returned tea.View |
KeyMsg struct with .Type / .Runes | KeyPressMsg/KeyReleaseMsg; match msg.String() or msg.Key().Code |
matching space as " " | space is "space" |
MouseMsg struct with .Action / .Button | MouseClickMsg / MouseWheelMsg / MouseMotionMsg (MouseMsg is an interface) |
mutating the Model from a goroutine, View, or a Cmd body | send a Msg via p.Send; mutate only in Update |
calling model.Update / component.Update from outside the loop | let the runtime drive Update; from outside, only p.Send |
blocking I/O in Update | do it in a Cmd (runs in its own goroutine) |
len(s) for display width | lipgloss.Width(s) / ansi.StringWidth(s) |
Note Init() tea.Cmd is correct in v2 (unchanged from v1) — do not "fix" it.
Verified against bubbletea v2.0.7 / lipgloss v2.0.4 / bubbles v2.1.0.
Reference map
references/architecture.md— MVU depth, the full v2 key/mouse taxonomy, the Cursed Renderer, lifecycle &ProgramOptions, logging.references/styling-and-layout.md— Lip Gloss styles/joins/Place/color, v2 color purity +colorprofile, canvas/layers.references/components.md— the Bubbles catalog, authoring a custom Bubble, the Crush sole-model scaling pattern.references/text-and-unicode.md—x/ansiwidth/wrap/truncate, graphemes, emoji/ZWJ footguns, Glamour streaming flicker.references/agent-ui.md— streaming deep dive (in-process only), the Crush pubsub→Sendbridge, width-keyed render cache.references/ecosystem.md— the Charm constellation (huh, wish, glamour, log, …) and pinned versions.references/testing.md— pureUpdatestate-machine tests andteatest/v2(with its experimental caveats).references/versioning.md— the v1↔v2 migration map, module paths, the Go floor, and the verified Charm-stack table.
What ships with it: 19 files
68.7 KB alongside SKILL.md, 2 of them executable
evals/
- evals.json4.9 KB
- functional/graders.pyruns7.4 KB
- functional/run_grader.pyruns1.8 KB
- functional/tasks.json4.3 KB
- triggering.jsonl2.8 KB
references/
- agent-ui.md3.6 KB
- architecture.md5.3 KB
- components.md4.2 KB
- ecosystem.md2.7 KB
- styling-and-layout.md3.3 KB
- testing.md2.5 KB
- text-and-unicode.md2.8 KB
- versioning.md3.4 KB
- card.authored.yaml450 B
- card.json4.8 KB
- card-review.md916 B
- README.md4.8 KB
- scan.json3.1 KB
- skill-card.md5.5 KB