SKILL.md

  1---
  2name: charm-bubbletea
  3description: "Build terminal UIs in Go with Bubble Tea v2's Elm Architecture (Model/Update/View). Use when building Go TUI apps, tea.Model, tea.Cmd, Elm architecture, or terminal applications. NOT for pre-built TUI components (use bubbles)."
  4argument-hint: "[component or pattern name]"
  5---
  6
  7# Bubble Tea v2
  8
  9Build terminal UIs in Go using the Elm Architecture. Import as `tea "charm.land/bubbletea/v2"`.
 10
 11**$ARGUMENTS context**: If a specific component or pattern is requested, focus guidance on that area.
 12
 13## Quick Start
 14
 15Minimal working program - a counter:
 16
 17```go
 18package main
 19
 20import (
 21    "fmt"
 22    "os"
 23
 24    tea "charm.land/bubbletea/v2"
 25)
 26
 27type model struct{ count int }
 28
 29func (m model) Init() tea.Cmd { return nil }
 30
 31func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 32    switch msg := msg.(type) {
 33    case tea.KeyPressMsg:
 34        switch msg.String() {
 35        case "q", "ctrl+c":
 36            return m, tea.Quit
 37        case "up", "k":
 38            m.count++
 39        case "down", "j":
 40            m.count--
 41        }
 42    }
 43    return m, nil
 44}
 45
 46func (m model) View() tea.View {
 47    return tea.NewView(fmt.Sprintf("Count: %d\n\nup/down to change, q to quit\n", m.count))
 48}
 49
 50func main() {
 51    if _, err := tea.NewProgram(model{}).Run(); err != nil {
 52        fmt.Println("Error:", err)
 53        os.Exit(1)
 54    }
 55}
 56```
 57
 58## Core API Reference
 59
 60### The Elm Architecture
 61
 62Bubble Tea follows the Elm Architecture - a unidirectional data flow pattern:
 63
 641. **Model** holds all application state
 652. **Update** receives messages, returns updated model + optional command
 663. **View** renders the model to a string (called after every Update)
 674. **Commands** perform async I/O, returning messages back to Update
 68
 69The runtime owns the loop. You never mutate state outside Update. You never call View yourself.
 70
 71### tea.Model Interface
 72
 73```go
 74type Model interface {
 75    Init() Cmd                      // initial command (return nil for none)
 76    Update(Msg) (Model, Cmd)        // handle messages, return new state + command
 77    View() View                     // render UI
 78}
 79```
 80
 81### tea.Cmd and tea.Msg
 82
 83```go
 84type Msg = any                      // messages can be any type
 85type Cmd func() Msg                 // commands are functions that return a message
 86```
 87
 88**A Cmd is a function that performs I/O and returns a Msg.** Return `nil` for no command.
 89
 90### tea.View
 91
 92```go
 93// Create a view from a string
 94v := tea.NewView("Hello, World!")
 95
 96// View struct fields (set after creation):
 97v.AltScreen = true                          // fullscreen mode
 98v.MouseMode = tea.MouseModeCellMotion       // enable mouse
 99v.ReportFocus = true                        // get FocusMsg/BlurMsg
100v.Cursor = tea.NewCursor(x, y)             // show cursor at position
101v.WindowTitle = "My App"                    // set terminal title
102v.BackgroundColor = someColor               // set terminal bg
103v.KeyboardEnhancements.ReportEventTypes = true // key release events
104```
105
106### Program
107
108```go
109p := tea.NewProgram(model{}, opts...)       // create program
110m, err := p.Run()                           // run (blocks until quit)
111p.Send(msg)                                 // send message from outside
112p.Quit()                                    // quit from outside
113p.Kill()                                    // force kill
114p.Wait()                                    // block until shutdown
115```
116
117### Program Options
118
119```go
120tea.WithContext(ctx)                         // cancellable context
121tea.WithInput(reader)                        // custom input (nil to disable)
122tea.WithOutput(writer)                       // custom output
123tea.WithFilter(func(Model, Msg) Msg)         // intercept/filter messages
124tea.WithFPS(fps)                             // custom FPS (default 60, max 120)
125tea.WithEnvironment([]string)                // custom env vars (SSH)
126tea.WithWindowSize(w, h)                     // initial size (testing)
127tea.WithColorProfile(profile)                // force color profile
128tea.WithoutRenderer()                        // no TUI rendering
129tea.WithoutSignalHandler()                   // handle signals yourself
130tea.WithoutCatchPanics()                     // disable panic recovery
131```
132
133### Built-in Messages
134
135| Message | When |
136|---------|------|
137| `tea.KeyPressMsg` | Key pressed. Use `msg.String()` to match (e.g. `"ctrl+c"`, `"enter"`, `"a"`) |
138| `tea.KeyReleaseMsg` | Key released (needs keyboard enhancements) |
139| `tea.WindowSizeMsg` | Terminal resized. Fields: `Width`, `Height`. Sent on startup + resize |
140| `tea.MouseClickMsg` | Mouse click. Fields: `X`, `Y`, `Button`, `Mod` |
141| `tea.MouseReleaseMsg` | Mouse button released |
142| `tea.MouseWheelMsg` | Scroll wheel |
143| `tea.MouseMotionMsg` | Mouse moved (needs AllMotion mode) |
144| `tea.FocusMsg` | Terminal gained focus (needs `ReportFocus`) |
145| `tea.BlurMsg` | Terminal lost focus |
146| `tea.PasteMsg` | Bracketed paste. Field: `Content` |
147| `tea.ColorProfileMsg` | Terminal color profile on startup |
148| `tea.BackgroundColorMsg` | Response to `RequestBackgroundColor`. Has `IsDark()` |
149| `tea.ResumeMsg` | Program resumed after suspend |
150| `tea.KeyboardEnhancementsMsg` | Terminal keyboard capabilities |
151
152### Built-in Commands
153
154| Command | What it does |
155|---------|-------------|
156| `tea.Quit` | Exit the program |
157| `tea.Suspend` | Suspend (ctrl+z behavior) |
158| `tea.Interrupt` | Interrupt (returns `ErrInterrupted`) |
159| `tea.ClearScreen` | Clear terminal |
160| `tea.Batch(cmds...)` | Run commands concurrently |
161| `tea.Sequence(cmds...)` | Run commands in order |
162| `tea.Every(dur, fn)` | Tick synced with system clock |
163| `tea.Tick(dur, fn)` | Tick from invocation time |
164| `tea.Println(args...)` | Print above TUI (persists across renders) |
165| `tea.Printf(tmpl, args...)` | Printf above TUI |
166| `tea.SetClipboard(s)` | Set system clipboard (OSC52) |
167| `tea.ReadClipboard` | Read system clipboard |
168| `tea.RequestWindowSize` | Query current window size |
169| `tea.RequestBackgroundColor` | Query terminal background color |
170| `tea.ExecProcess(cmd, callback)` | Run external process (e.g. editor) |
171| `tea.Raw(seq)` | Send raw escape sequence |
172
173### Key Handling
174
175```go
176case tea.KeyPressMsg:
177    switch msg.String() {
178    case "ctrl+c", "q":        // string matching (most common)
179        return m, tea.Quit
180    case "up", "k":            // arrow keys have string names
181    case "enter", "space":     // special keys
182    case "ctrl+s":             // modifier combos
183    case "a":                  // regular characters
184    }
185
186// Or use the Key struct for type-safe matching:
187case tea.KeyPressMsg:
188    key := msg.Key()
189    switch key.Code {
190    case tea.KeyEnter:          // typed constant
191    case tea.KeyTab:
192    case tea.KeyEsc:
193    }
194    // key.Mod for modifiers: tea.ModCtrl, tea.ModAlt, tea.ModShift
195    // key.Text for printable characters
196```
197
198### Mouse Handling
199
200Enable mouse in View, handle in Update:
201
202```go
203func (m model) View() tea.View {
204    v := tea.NewView(m.render())
205    v.MouseMode = tea.MouseModeCellMotion  // or tea.MouseModeAllMotion
206    return v
207}
208
209func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
210    switch msg := msg.(type) {
211    case tea.MouseClickMsg:
212        x, y := msg.X, msg.Y
213        if msg.Button == tea.MouseLeft { /* handle click */ }
214    case tea.MouseWheelMsg:
215        if msg.Button == tea.MouseWheelUp { /* scroll up */ }
216    case tea.MouseMsg:
217        // catches all mouse events
218        mouse := msg.Mouse()
219    }
220    return m, nil
221}
222```
223
224## Common Patterns
225
226### Pattern 1: Async I/O (HTTP, file, etc.)
227
228Define a custom Msg type. Write a Cmd that does the I/O and returns it.
229
230```go
231type statusMsg int
232type errMsg struct{ error }
233
234func checkServer() tea.Msg {
235    res, err := http.Get("https://example.com")
236    if err != nil {
237        return errMsg{err}
238    }
239    defer res.Body.Close()
240    return statusMsg(res.StatusCode)
241}
242
243func (m model) Init() tea.Cmd { return checkServer } // note: pass function, don't call it
244
245func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
246    switch msg := msg.(type) {
247    case statusMsg:
248        m.status = int(msg)
249        return m, tea.Quit
250    case errMsg:
251        m.err = msg.error
252        return m, nil
253    }
254    return m, nil
255}
256```
257
258### Pattern 2: Ticking / Timers
259
260`tea.Tick` and `tea.Every` send a single message. Re-dispatch to loop.
261
262```go
263type tickMsg time.Time
264
265func doTick() tea.Cmd {
266    return tea.Tick(time.Second, func(t time.Time) tea.Msg {
267        return tickMsg(t)
268    })
269}
270
271func (m model) Init() tea.Cmd { return doTick() }
272
273func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
274    switch msg.(type) {
275    case tickMsg:
276        m.elapsed++
277        return m, doTick()  // re-dispatch to keep ticking
278    }
279    return m, nil
280}
281```
282
283**`tea.Every`** syncs with the system clock (wall-clock aligned ticks). **`tea.Tick`** starts from invocation.
284
285### Pattern 3: Composing Child Components
286
287Child components follow the same Model/Update/View pattern. Parent delegates messages.
288
289```go
290type parentModel struct {
291    spinner spinner.Model
292    input   textinput.Model
293    focus   int
294}
295
296func (m parentModel) Init() tea.Cmd {
297    return tea.Batch(m.spinner.Tick, m.input.Focus())
298}
299
300func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
301    var cmds []tea.Cmd
302
303    // Always update spinner (it needs ticks regardless of focus)
304    var cmd tea.Cmd
305    m.spinner, cmd = m.spinner.Update(msg)
306    cmds = append(cmds, cmd)
307
308    // Only update focused component for key events
309    switch msg.(type) {
310    case tea.KeyPressMsg:
311        if m.focus == 0 {
312            m.input, cmd = m.input.Update(msg)
313            cmds = append(cmds, cmd)
314        }
315    }
316
317    return m, tea.Batch(cmds...)
318}
319
320func (m parentModel) View() tea.View {
321    return tea.NewView(m.spinner.View() + "\n" + m.input.View())
322}
323```
324
325### Pattern 4: External Messages via Channel
326
327Use `p.Send()` to push messages from goroutines, or use a channel-based Cmd.
328
329```go
330// Channel-based approach (preferred for subscription-like behavior)
331func waitForActivity(sub chan resultMsg) tea.Cmd {
332    return func() tea.Msg {
333        return <-sub  // blocks until message arrives
334    }
335}
336
337func (m model) Init() tea.Cmd {
338    return waitForActivity(m.sub)
339}
340
341func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
342    switch msg := msg.(type) {
343    case resultMsg:
344        m.results = append(m.results, msg)
345        return m, waitForActivity(m.sub)  // re-subscribe
346    }
347    return m, nil
348}
349
350// p.Send approach (from outside the program)
351go func() {
352    p.Send(myMsg{data: "hello"})  // thread-safe
353}()
354```
355
356### Pattern 5: Fullscreen with Alt Screen
357
358Set `AltScreen` in your View. No Program option needed in v2.
359
360```go
361func (m model) View() tea.View {
362    v := tea.NewView(fmt.Sprintf("Fullscreen app! Size: %dx%d", m.width, m.height))
363    v.AltScreen = true
364    return v
365}
366
367func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
368    switch msg := msg.(type) {
369    case tea.WindowSizeMsg:
370        m.width = msg.Width
371        m.height = msg.Height
372    }
373    return m, nil
374}
375```
376
377## Integration Patterns
378
379### Lip Gloss (Styling)
380
381```go
382import "charm.land/lipgloss/v2"
383
384var style = lipgloss.NewStyle().
385    Bold(true).
386    Foreground(lipgloss.Color("205")).
387    Padding(0, 1)
388
389func (m model) View() tea.View {
390    return tea.NewView(style.Render("styled text"))
391}
392```
393
394### Bubbles (Components)
395
396Standard components: `spinner`, `textinput`, `textarea`, `list`, `table`, `viewport`, `paginator`, `timer`, `stopwatch`, `help`, `key`, `filepicker`, `progress`.
397
398Import: `"charm.land/bubbles/v2/<component>"`
399
400Each bubble follows the same Init/Update/View pattern. See Pattern 3 above.
401
402### Huh (Forms)
403
404```go
405import "charm.land/huh/v2"
406
407// Huh can run standalone or embed in a bubbletea program
408form := huh.NewForm(
409    huh.NewGroup(
410        huh.NewInput().Title("Name").Value(&name),
411    ),
412)
413```
414
415## Common Mistakes
416
4171. **Calling a Cmd instead of passing it.** `Init` returns `tea.Cmd`, not `tea.Msg`. Write `return checkServer` not `return checkServer()`. The runtime calls the function.
418
4192. **Forgetting to return a Cmd from tick handlers.** `tea.Tick` and `tea.Every` fire once. You must return a new tick command in your Update handler to keep ticking.
420
4213. **Mutating the model outside Update.** The Elm Architecture requires all state changes go through Update. Don't share model pointers with goroutines. Use `p.Send()` or channel-based Cmds to communicate.
422
4234. **Not handling WindowSizeMsg.** It's sent on startup and every resize. If you do any layout math, store the dimensions and use them in View.
424
4255. **Using fmt.Println for output.** stdout is owned by the TUI. Use `tea.LogToFile()` for debug logging. Use `tea.Println()` / `tea.Printf()` to print above the TUI.
426
4276. **Returning `tea.Batch()` with nil commands.** This is safe - `tea.Batch` filters nil commands - but returning a plain `nil` is cleaner when you have no commands.
428
4297. **AltScreen in v2 is a View property, not a Program option.** Set `v.AltScreen = true` in your View method. Same for mouse mode - set `v.MouseMode` in View.
430
4318. **Blocking in Update.** Update must return quickly. Any I/O (HTTP calls, file reads, sleeps) belongs in a Cmd, not directly in Update.
432
4339. **Not handling ctrl+c.** The terminal is in raw mode - ctrl+c won't kill your app automatically. Always match `"ctrl+c"` in your KeyPressMsg handler and return `tea.Quit` or `tea.Interrupt`.
434
435## Checklist
436
437- [ ] Model implements `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() tea.View`
438- [ ] View returns `tea.NewView(s)`, not a raw string
439- [ ] ctrl+c / q handling exists in Update
440- [ ] WindowSizeMsg handled if doing any layout
441- [ ] All I/O in Cmds, never in Update
442- [ ] Tick commands re-dispatched in Update handler
443- [ ] Child component updates collected with `tea.Batch(cmds...)`
444- [ ] No `fmt.Println` - use `tea.LogToFile` for debugging
445- [ ] AltScreen and MouseMode set in View, not as Program options
446
447## Reference
448
449For full message/type/constant listings, see [references/api.md](references/api.md).