AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified Apache-2.0 Self-run

Bubbletea

skill-vinsonconsulting-claude-skill-foundry-bubbletea · by vinsonconsulting

>-

No reviews yet
0 installs
22 views
0.0% view→install

Install

$ agentstack add skill-vinsonconsulting-claude-skill-foundry-bubbletea

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution Used
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-vinsonconsulting-claude-skill-foundry-bubbletea)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
2mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Bubbletea? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

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/
	"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/), 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 ` — 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 & `ProgramOption`s, 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.

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [vinsonconsulting](https://github.com/vinsonconsulting)
- **Source:** [vinsonconsulting/claude-skill-foundry](https://github.com/vinsonconsulting/claude-skill-foundry)
- **License:** Apache-2.0

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.