agentsclimarketplace

Bubbletea

Skill vinsonconsulting/claude-skill-foundry/skills/tui/bubbletea

A scoring harness for Claude Agent Skills. Each carded skill ships a Skill Card: a SkillSpector security scan and trigger evals, generated and gated in CI.

Install
npx -y skills add vinsonconsulting/claude-skill-foundry --skill bubbletea

Assembled 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.

What its author says it does

Copied from the file, not written here

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.

SKILL.md

15.8 KB, 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 optional Cmd.
  • 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 from View, from a Cmd body, or from a goroutine. Update is 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 a Cmd, which the runtime runs in its own goroutine and whose result comes back as a Msg.
  • Models flow by value. Update takes the Model by value, mutates the local copy, and returns it. Bubbles (sub-components) work the same way — you reassign what their Update returns.

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:

RejectUse 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() stringView() tea.View (tea.NewView; child components still return string)
tea.WithAltScreen() / WithMouseCellMotion() as NewProgram optionsset v.AltScreen / v.MouseMode on the returned tea.View
KeyMsg struct with .Type / .RunesKeyPressMsg/KeyReleaseMsg; match msg.String() or msg.Key().Code
matching space as " "space is "space"
MouseMsg struct with .Action / .ButtonMouseClickMsg / MouseWheelMsg / MouseMotionMsg (MouseMsg is an interface)
mutating the Model from a goroutine, View, or a Cmd bodysend a Msg via p.Send; mutate only in Update
calling model.Update / component.Update from outside the looplet the runtime drive Update; from outside, only p.Send
blocking I/O in Updatedo it in a Cmd (runs in its own goroutine)
len(s) for display widthlipgloss.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.mdx/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.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.