bubbletea-docs

Original🇺🇸 English
Translated

Bubble Tea is a Go framework for building elegant terminal user interfaces (TUIs). Use when building CLI applications with interactive menus, forms, lists, tables, or any terminal UI. Based on The Elm Architecture (Model-View-Update). Key features: keyboard/mouse input, responsive layouts, async commands, Bubbles components (spinner, list, table, viewport, textinput, progress). Includes Lip Gloss for styling and Huh for forms.

2installs
Added on

NPX Install

npx skill4agent add quantmind-br/skills bubbletea-docs

Tags

Translated version includes tags in frontmatter

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

bash
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

go
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

go
// 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

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

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

Window Size Handling

go
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

go
// 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

go
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

go
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

ComponentImportInitKey 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)

go
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

go
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

go
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

go
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

go
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

go
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

go
// 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

go
// 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

go
// 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

go
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

go
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)

go
// 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

go
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)

go
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

go
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

go
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

go
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:
    go
    // 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:
    go
    // BAD
    go func() { m.data = fetch() }()  // Race!
    
    // GOOD
    return m, func() tea.Msg { return dataMsg{fetch()} }
  3. Viewport before WindowSizeMsg: Initialize after receiving dimensions:
    go
    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:
    go
    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:
    go
    // BAD
    style.Bold(true)  // Result discarded!
    
    // GOOD
    style = style.Bold(true)
  2. Alignment not working: Requires explicit Width:
    go
    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:
    go
    // 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:
    go
    .OptionsFunc(fn, &country)  // Recomputes when country changes
  4. Don't use Placeholder() in huh forms: Causes rendering bugs. Put examples in Description instead:
    go
    // 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:
    go
    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

go
// 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

  • references/API.md - Complete API reference
  • references/EXAMPLES.md - Extended code examples
  • references/TROUBLESHOOTING.md - Common errors and solutions

Essential Imports

go
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:
go
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.
go
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
:
go
// 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:
go
// 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

go
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:
go
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:
go
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()
}