bubbletea — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited bubbletea (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
charm.landEvery 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
)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:
Cmd.Msg, returns a (possibly changed) Model and an optional Cmd.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:
View, from a Cmd body, or from agoroutine. Update is the single writer; everything else races the render loop.
subprocess, a timer — inside a Cmd, which the runtime runs in its own goroutine and whose result comes back as a Msg.
Update takes the Model by value, mutates the local copy, andreturns it. Bubbles (sub-components) work the same way — you reassign what their Update returns.
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.
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 anotherBatch, 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 + footerFull key/mouse taxonomy (the Key struct, Mod.Contains, mouse message types) is in references/architecture.md.
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.
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) // centeringStyles, 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.
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 stringThe 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.
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 escapesFootgun: 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.
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.
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.
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/ansi width/wrap/truncate, graphemes, emoji/ZWJ footguns, Glamour streaming flicker.references/agent-ui.md — streaming deep dive (in-process only), the Crush pubsub→Send bridge, width-keyed render cache.references/ecosystem.md — the Charm constellation (huh, wish, glamour, log, …) and pinned versions.references/testing.md — pure Update state-machine tests and teatest/v2 (with its experimental caveats).references/versioning.md — the v1↔v2 migration map, module paths, the Go floor, and the verified Charm-stack table.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.