skills/quantmind-br/skills/bubbletea-docs

bubbletea-docs

SKILL.md

Bubble Tea TUI Framework Skill

Overview

Bubble Tea is a powerful TUI framework for Go based on The Elm Architecture. Every program has a Model (state) and three methods: Init() (initial command), Update() (handle messages), View() (render UI as string).

This skill covers the complete Charm ecosystem:

  • Bubble Tea - Core TUI framework
  • Bubbles - Reusable UI components
  • Lip Gloss - Terminal styling
  • Huh - Interactive forms

Installation

go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/bubbles    # UI components
go get github.com/charmbracelet/lipgloss   # Styling
go get github.com/charmbracelet/huh        # Forms

Quick Start

package main

import (
    "fmt"
    "log"
    tea "github.com/charmbracelet/bubbletea"
)

type model struct {
    cursor   int
    choices  []string
    selected map[int]struct{}
}

func (m model) Init() tea.Cmd { return nil }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit
        case "up", "k":
            if m.cursor > 0 { m.cursor-- }
        case "down", "j":
            if m.cursor < len(m.choices)-1 { m.cursor++ }
        case "enter", " ":
            if _, ok := m.selected[m.cursor]; ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        }
    }
    return m, nil
}

func (m model) View() string {
    s := "Choose:\n\n"
    for i, choice := range m.choices {
        cursor, checked := " ", " "
        if m.cursor == i { cursor = ">" }
        if _, ok := m.selected[i]; ok { checked = "x" }
        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
    }
    return s + "\nq to quit.\n"
}

func main() {
    p := tea.NewProgram(model{
        choices:  []string{"Option A", "Option B", "Option C"},
        selected: make(map[int]struct{}),
    })
    if _, err := p.Run(); err != nil { log.Fatal(err) }
}

Core Concepts

Messages and Commands

// Custom messages
type tickMsg time.Time
type dataMsg struct{ data string }
type errMsg struct{ error }

// Command returns a message (runs async)
func fetchData() tea.Msg {
    resp, err := http.Get("https://api.example.com")
    if err != nil { return errMsg{err} }
    defer resp.Body.Close()
    // ... process response
    return dataMsg{data: "result"}
}

// Handle in Update
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case dataMsg:
        m.data = msg.data
    case errMsg:
        m.err = msg.error
    }
    return m, nil
}

// Start command in Init
func (m model) Init() tea.Cmd {
    return fetchData  // Runs async, sends message when done
}

Batch and Sequence Commands

// Parallel execution
return tea.Batch(cmd1, cmd2, cmd3)

// Sequential execution
return tea.Sequence(step1, step2, tea.Quit)

Window Size Handling

type model struct {
    width, height int
    viewport      viewport.Model
    ready         bool
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.width, m.height = msg.Width, msg.Height
        if !m.ready {
            m.viewport = viewport.New(msg.Width, msg.Height-4)
            m.viewport.SetContent(m.content)
            m.ready = true
        } else {
            m.viewport.Width = msg.Width
            m.viewport.Height = msg.Height - 4
        }
    }
    return m, nil
}

Common Patterns

Program Options

// Fullscreen
p := tea.NewProgram(m, tea.WithAltScreen())

// Mouse support
p := tea.NewProgram(m, tea.WithMouseCellMotion())

// Combined
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())

Key Bindings with bubbles/key

import "github.com/charmbracelet/bubbles/key"

type keyMap struct {
    Up   key.Binding
    Down key.Binding
    Quit key.Binding
}

var keys = keyMap{
    Up:   key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
    Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
    Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
}

// In Update
case tea.KeyMsg:
    switch {
    case key.Matches(msg, keys.Up):
        m.cursor--
    case key.Matches(msg, keys.Quit):
        return m, tea.Quit
    }

Multiple Views

type model struct {
    state int  // 0=menu, 1=detail
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch m.state {
    case 0: return m.updateMenu(msg)
    case 1: return m.updateDetail(msg)
    }
    return m, nil
}

func (m model) View() string {
    switch m.state {
    case 0: return m.menuView()
    case 1: return m.detailView()
    }
    return ""
}

Bubbles Components

Component Quick Reference

Component Import Init Key Method
Spinner bubbles/spinner spinner.New() .Tick in Init
TextInput bubbles/textinput textinput.New() .Focus(), .Value()
TextArea bubbles/textarea textarea.New() .Focus(), .Value()
List bubbles/list list.New(items, delegate, w, h) .SelectedItem()
Table bubbles/table table.New(opts...) .SelectedRow()
Viewport bubbles/viewport viewport.New(w, h) .SetContent(), .ScrollUp(), .ScrollDown()
Progress bubbles/progress progress.New() .SetPercent()
Help bubbles/help help.New() .View(keyMap)
FilePicker bubbles/filepicker filepicker.New() .DidSelectFile()
Timer bubbles/timer timer.New(duration) .Toggle()
Stopwatch bubbles/stopwatch stopwatch.New() .Elapsed()

Focus Management (Multiple Inputs)

type model struct {
    inputs     []textinput.Model
    focusIndex int
}

func (m *model) nextInput() tea.Cmd {
    m.inputs[m.focusIndex].Blur()
    m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
    return m.inputs[m.focusIndex].Focus()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "tab":
            return m, m.nextInput()
        }
    }
    // Update all inputs
    cmds := make([]tea.Cmd, len(m.inputs))
    for i := range m.inputs {
        m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
    }
    return m, tea.Batch(cmds...)
}

Component Composition

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmds []tea.Cmd
    var cmd tea.Cmd

    // Update all components and collect commands
    m.spinner, cmd = m.spinner.Update(msg)
    cmds = append(cmds, cmd)

    m.textInput, cmd = m.textInput.Update(msg)
    cmds = append(cmds, cmd)

    m.viewport, cmd = m.viewport.Update(msg)
    cmds = append(cmds, cmd)

    return m, tea.Batch(cmds...)
}

Spinner Example

s := spinner.New()
s.Spinner = spinner.Dot  // Line, Dot, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))

// In Init(): return m.spinner.Tick
// In Update(): handle spinner.TickMsg

List with Custom Items

type item struct{ title, desc string }

func (i item) Title() string       { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }  // Required for filtering

items := []list.Item{item{"One", "Description"}}
l := list.New(items, list.NewDefaultDelegate(), 30, 10)
l.Title = "Select Item"

Table

columns := []table.Column{
    {Title: "ID", Width: 10},
    {Title: "Name", Width: 20},
}
rows := []table.Row{
    {"1", "Alice"},
    {"2", "Bob"},
}
t := table.New(
    table.WithColumns(columns),
    table.WithRows(rows),
    table.WithFocused(true),
    table.WithHeight(7),
)

Lip Gloss Styling

Basic Styling

import "github.com/charmbracelet/lipgloss"

var style = lipgloss.NewStyle().
    Bold(true).
    Foreground(lipgloss.Color("205")).
    Background(lipgloss.Color("#7D56F4")).
    Border(lipgloss.RoundedBorder()).
    Padding(1, 2)

output := style.Render("Hello, World!")

Colors

// Hex colors
lipgloss.Color("#FF00FF")

// ANSI 256 colors
lipgloss.Color("205")

// Adaptive colors (auto light/dark background)
lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}

// Complete color profiles
lipgloss.CompleteColor{
    TrueColor: "#FF00FF",
    ANSI256:   "205",
    ANSI:      "5",
}

Layout Composition

// Horizontal layout
left := lipgloss.NewStyle().Width(20).Render("Left")
right := lipgloss.NewStyle().Width(20).Render("Right")
row := lipgloss.JoinHorizontal(lipgloss.Top, left, right)

// Vertical layout
header := "Header"
body := "Body content"
footer := "Footer"
page := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)

// Center content in box
centered := lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, content)

Frame Size for Calculations

// Account for padding/border/margin in calculations
h, v := docStyle.GetFrameSize()
m.list.SetSize(m.width-h, m.height-v)

// Dynamic content height
headerH := lipgloss.Height(m.header())
footerH := lipgloss.Height(m.footer())
m.viewport.Height = m.height - headerH - footerH

Border Styles

lipgloss.NormalBorder()   // Standard box
lipgloss.RoundedBorder()  // Rounded corners
lipgloss.ThickBorder()    // Thick lines
lipgloss.DoubleBorder()   // Double lines
lipgloss.HiddenBorder()   // Invisible (for spacing)

Huh Forms

Quick Start

import "github.com/charmbracelet/huh"

var name string
var confirmed bool

form := huh.NewForm(
    huh.NewGroup(
        huh.NewInput().
            Title("What's your name?").
            Value(&name),
        
        huh.NewConfirm().
            Title("Ready to proceed?").
            Value(&confirmed),
    ),
)

err := form.Run()
if err != nil {
    if err == huh.ErrUserAborted {
        fmt.Println("Cancelled")
        return
    }
    log.Fatal(err)
}

Field Types (GENERIC TYPES REQUIRED)

// Input - single line text
huh.NewInput().Title("Name").Value(&name)

// Text - multi-line
huh.NewText().Title("Bio").Lines(5).Value(&bio)

// Select - MUST specify type
huh.NewSelect[string]().
    Title("Choose").
    Options(
        huh.NewOption("Option A", "a"),
        huh.NewOption("Option B", "b"),
    ).
    Value(&choice)

// MultiSelect - MUST specify type  
huh.NewMultiSelect[string]().
    Title("Choose many").
    Options(huh.NewOptions("A", "B", "C")...).
    Limit(2).
    Value(&choices)

// Confirm
huh.NewConfirm().
    Title("Sure?").
    Affirmative("Yes").
    Negative("No").
    Value(&confirmed)

// FilePicker
huh.NewFilePicker().
    Title("Select file").
    AllowedTypes([]string{".go", ".md"}).
    Value(&filepath)

Validation

huh.NewInput().
    Title("Email").
    Value(&email).
    Validate(func(s string) error {
        if !strings.Contains(s, "@") {
            return errors.New("invalid email")
        }
        return nil
    })

Dynamic Forms (OptionsFunc/TitleFunc)

var country string
var state string

huh.NewSelect[string]().
    Value(&state).
    TitleFunc(func() string {
        if country == "Canada" { return "Province" }
        return "State"
    }, &country).  // Recompute when country changes
    OptionsFunc(func() []huh.Option[string] {
        return huh.NewOptions(getStatesFor(country)...)
    }, &country)

Bubble Tea Integration

type Model struct {
    form *huh.Form
}

func (m Model) Init() tea.Cmd {
    return m.form.Init()
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    form, cmd := m.form.Update(msg)
    if f, ok := form.(*huh.Form); ok {
        m.form = f
    }
    
    if m.form.State == huh.StateCompleted {
        // Form completed - access values
        name := m.form.GetString("name")
        return m, tea.Quit
    }
    
    return m, cmd
}

func (m Model) View() string {
    if m.form.State == huh.StateCompleted {
        return "Done!"
    }
    return m.form.View()
}

Themes

form.WithTheme(huh.ThemeCharm())      // Default pink/purple
form.WithTheme(huh.ThemeDracula())    // Dark purple/pink
form.WithTheme(huh.ThemeCatppuccin()) // Pastel
form.WithTheme(huh.ThemeBase16())     // Muted
form.WithTheme(huh.ThemeBase())       // Minimal

Spinner for Loading

import "github.com/charmbracelet/huh/spinner"

err := spinner.New().
    Title("Processing...").
    Action(func() {
        time.Sleep(2 * time.Second)
        processData()
    }).
    Run()

Common Gotchas

Bubble Tea Core

  1. Blocking in Update/View: Never block. Use commands for I/O:

    // BAD
    time.Sleep(time.Second)  // Blocks event loop!
    
    // GOOD
    return m, func() tea.Msg {
        time.Sleep(time.Second)
        return doneMsg{}
    }
    
  2. Goroutines modifying model: Race condition! Use commands instead:

    // BAD
    go func() { m.data = fetch() }()  // Race!
    
    // GOOD
    return m, func() tea.Msg { return dataMsg{fetch()} }
    
  3. Viewport before WindowSizeMsg: Initialize after receiving dimensions:

    if !m.ready {
        m.viewport = viewport.New(msg.Width, msg.Height)
        m.ready = true
    }
    
  4. Using receiver methods incorrectly: Only use func (m *model) for internal helpers. Model interface methods must use value receivers func (m model).

  5. Startup commands via Init: Don't use tea.EnterAltScreen in Init. Use tea.WithAltScreen() option instead.

  6. Messages not in order: tea.Batch results arrive in ANY order. Use tea.Sequence when order matters.

Bubbles Components

  1. Spinner not animating: Must return m.spinner.Tick from Init() and handle spinner.TickMsg in Update.

  2. TextInput not accepting input: Must call .Focus() before input accepts keystrokes.

  3. Component updates not reflected: Always reassign after Update:

    m.spinner, cmd = m.spinner.Update(msg)  // Must reassign!
    
  4. Multiple components losing commands: Use tea.Batch(cmds...) to combine all commands.

  5. List items not filtering: Must implement FilterValue() string on items.

  6. Table not responding: Must set table.WithFocused(true) or call t.Focus().

Lip Gloss

  1. Style method has no effect: Styles are immutable, must reassign:

    // BAD
    style.Bold(true)  // Result discarded!
    
    // GOOD
    style = style.Bold(true)
    
  2. Alignment not working: Requires explicit Width:

    style := lipgloss.NewStyle().Width(40).Align(lipgloss.Center)
    
  3. Width calculation wrong: Use lipgloss.Width() not len() for unicode.

  4. Layout arithmetic errors: Use style.GetFrameSize() to account for padding/border/margin.

Huh Forms

  1. Generic types required: Select and MultiSelect MUST have type parameter:

    // BAD - won't compile
    huh.NewSelect()
    
    // GOOD
    huh.NewSelect[string]()
    
  2. Value takes pointer: Always pass pointer: .Value(&myVar) not .Value(myVar).

  3. OptionsFunc not updating: Must pass binding variable:

    .OptionsFunc(fn, &country)  // Recomputes when country changes
    
  4. Don't use Placeholder() in huh forms: Causes rendering bugs. Put examples in Description instead:

    // BAD
    huh.NewInput().Placeholder("example@email.com")
    
    // GOOD
    huh.NewInput().Description("e.g. example@email.com")
    
  5. Loop variable closure capture: Capture explicitly in loops:

    for _, name := range items {
        currentName := name  // Capture!
        huh.NewInput().Value(&configs[currentName].Field)
    }
    
  6. Don't intercept Enter before form: Let huh handle Enter for navigation.


Debugging

// Log to file (stdout is the TUI)
if os.Getenv("DEBUG") != "" {
    f, _ := tea.LogToFile("debug.log", "app")
    defer f.Close()
}
log.Println("Debug message")

Best Practices

  1. Keep Update/View fast - The event loop blocks on these
  2. Use tea.Cmd for all I/O - HTTP, file, database operations
  3. Use tea.Batch for parallel - Multiple independent commands
  4. Use tea.Sequence for ordered - Commands that must run in order
  5. Store window dimensions - Handle tea.WindowSizeMsg, update components
  6. Initialize viewport after WindowSizeMsg - Dimensions aren't available at start
  7. Use value receivers - func (m model) Update not func (m *model) Update
  8. Define styles as package variables - Reuse instead of creating in loops
  9. Use AdaptiveColor - For light/dark terminal support
  10. Handle ErrUserAborted - Graceful Ctrl+C handling in huh forms

Additional Resources

Essential Imports

import (
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
    "github.com/charmbracelet/lipgloss/table"  // Static tables
    "github.com/charmbracelet/lipgloss/tree"   // Hierarchical data
    "github.com/charmbracelet/bubbles/spinner"
    "github.com/charmbracelet/bubbles/textinput"
    "github.com/charmbracelet/bubbles/textarea"
    "github.com/charmbracelet/bubbles/list"
    "github.com/charmbracelet/bubbles/table"   // Interactive tables
    "github.com/charmbracelet/bubbles/viewport"
    "github.com/charmbracelet/bubbles/help"
    "github.com/charmbracelet/bubbles/key"
    "github.com/charmbracelet/bubbles/filepicker"
    "github.com/charmbracelet/bubbles/progress"
    "github.com/charmbracelet/bubbles/timer"
    "github.com/charmbracelet/bubbles/stopwatch"
    "github.com/charmbracelet/huh"
    "github.com/charmbracelet/huh/spinner"
    "github.com/charmbracelet/wish"            // SSH TUI server
    "github.com/lrstanley/bubblezone"          // Mouse zones
)

Lip Gloss Tree (Hierarchical Data)

Render tree structures with customizable enumerators:

import "github.com/charmbracelet/lipgloss/tree"

t := tree.Root("Root").
    Child("Child 1").
    Child(
        tree.Root("Child 2").
            Child("Grandchild 1").
            Child("Grandchild 2"),
    ).
    Child("Child 3")

// Custom enumerator
t.Enumerator(tree.RoundedEnumerator)  // ├── └──
t.Enumerator(tree.BulletEnumerator)   // • bullets
t.Enumerator(tree.NumberedEnumerator) // 1. 2. 3.

// Custom styling
t.EnumeratorStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99")))
t.ItemStyle(lipgloss.NewStyle().Bold(true))

fmt.Println(t)

Lip Gloss Table (Static Rendering)

For high-performance static tables (reports, logs). Use bubbles/table for interactive selection.

import "github.com/charmbracelet/lipgloss/table"

t := table.New().
    Border(lipgloss.NormalBorder()).
    BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))).
    Headers("NAME", "AGE", "CITY").
    Row("Alice", "30", "NYC").
    Row("Bob", "25", "LA").
    Wrap(true).  // Enable text wrapping
    StyleFunc(func(row, col int) lipgloss.Style {
        if row == table.HeaderRow {
            return lipgloss.NewStyle().Bold(true)
        }
        return lipgloss.NewStyle()
    })

fmt.Println(t)

When to use which table:

  • lipgloss/table: Static rendering, reports, logs, non-interactive
  • bubbles/table: Interactive selection, keyboard navigation, focused rows

Custom Component Pattern (Sub-Model)

Create reusable Bubble Tea components by exposing Init, Update, View:

// counter.go - Reusable component
package counter

import tea "github.com/charmbracelet/bubbletea"

type Model struct {
    Count int
    Min   int
    Max   int
}

func New(min, max int) Model {
    return Model{Min: min, Max: max}
}

func (m Model) Init() tea.Cmd { return nil }

func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "+", "=":
            if m.Count < m.Max { m.Count++ }
        case "-":
            if m.Count > m.Min { m.Count-- }
        }
    }
    return m, nil
}

func (m Model) View() string {
    return fmt.Sprintf("Count: %d", m.Count)
}

// parent.go - Using the component
type parentModel struct {
    counter counter.Model
}

func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.counter, cmd = m.counter.Update(msg)
    return m, cmd
}

ProgramContext Pattern (Production)

Share global state across components without prop drilling:

// context.go
type ProgramContext struct {
    Config     *Config
    Theme      *Theme
    Width      int
    Height     int
    StartTask  func(Task) tea.Cmd  // Callback for background tasks
}

// model.go
type Model struct {
    ctx       *ProgramContext
    sidebar   sidebar.Model
    main      main.Model
    tasks     map[string]Task
    spinner   spinner.Model
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.ctx.Width = msg.Width
        m.ctx.Height = msg.Height
        // Sync all components
        m.sidebar.SetHeight(msg.Height)
        m.main.SetSize(msg.Width - sidebarWidth, msg.Height)
    }
    return m, nil
}

// Initialize context with task callback
func NewModel(cfg *Config) Model {
    ctx := &ProgramContext{Config: cfg}
    m := Model{ctx: ctx, tasks: make(map[string]Task)}
    
    ctx.StartTask = func(t Task) tea.Cmd {
        m.tasks[t.ID] = t
        return m.spinner.Tick
    }
    
    return m
}

Testing with teatest

import (
    "testing"
    "time"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/x/exp/teatest"
)

func TestModel(t *testing.T) {
    m := NewModel()
    tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
    
    // Send key presses
    tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
    tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
    
    // Wait for specific output
    teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
        return strings.Contains(string(bts), "Selected")
    }, teatest.WithDuration(time.Second))
    
    // Check final state
    tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
    tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
    
    final := tm.FinalModel(t).(Model)
    if final.selected != "expected" {
        t.Errorf("expected 'expected', got %q", final.selected)
    }
}

SSH Integration with Wish

Serve TUI apps over SSH:

import (
    "github.com/charmbracelet/wish"
    "github.com/charmbracelet/wish/bubbletea"
    "github.com/charmbracelet/ssh"
)

func main() {
    s, err := wish.NewServer(
        wish.WithAddress(":2222"),
        wish.WithHostKeyPath(".ssh/term_info_ed25519"),
        wish.WithMiddleware(
            bubbletea.Middleware(teaHandler),
        ),
    )
    if err != nil { log.Fatal(err) }
    
    log.Println("Starting SSH server on :2222")
    if err := s.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
    pty, _, _ := s.Pty()
    m := NewModel(pty.Window.Width, pty.Window.Height)
    return m, []tea.ProgramOption{tea.WithAltScreen()}
}

Mouse Zones with bubblezone

Define clickable regions without coordinate math:

import zone "github.com/lrstanley/bubblezone"

type model struct {
    zone *zone.Manager
}

func newModel() model {
    return model{zone: zone.New()}
}

func (m model) View() string {
    // Wrap clickable elements
    button1 := m.zone.Mark("btn1", "[Button 1]")
    button2 := m.zone.Mark("btn2", "[Button 2]")
    
    return lipgloss.JoinHorizontal(lipgloss.Top, button1, " ", button2)
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.MouseMsg:
        // Check which zone was clicked
        if m.zone.Get("btn1").InBounds(msg) {
            // Button 1 clicked
        }
        if m.zone.Get("btn2").InBounds(msg) {
            // Button 2 clicked
        }
    }
    return m, nil
}

// Wrap program with zone.NewGlobal() for simpler API
func main() {
    zone.NewGlobal()
    p := tea.NewProgram(newModel(), tea.WithMouseCellMotion())
    p.Run()
}
Weekly Installs
10
GitHub Stars
2
First Seen
Feb 18, 2026
Installed on
opencode10
gemini-cli10
github-copilot10
codex10
kimi-cli10
amp10