Back to Bubbletea

Bubble Tea v2 Upgrade Guide

UPGRADE_GUIDE_V2.md

2.0.616.8 KB
Original Source

Bubble Tea v2 Upgrade Guide

This guide covers everything you need to change when upgrading from Bubble Tea v1 to v2. For a tour of all the exciting new features, check out the What's New doc.

[!NOTE] We don't take API changes lightly and strive to make the upgrade process as simple as possible. If something feels way off, let us know.

Migration Checklist

Here's the short version — a checklist you can follow top to bottom. Each item links to the relevant section below.

Import Paths

The module path changed to a vanity domain. Lip Gloss moved too.

go
// Before
import tea "github.com/charmbracelet/bubbletea"
import "github.com/charmbracelet/lipgloss"

// After
import tea "charm.land/bubbletea/v2"
import "charm.land/lipgloss/v2"

The Big Idea: Declarative Views

The single biggest change in v2 is the shift from imperative commands to declarative View fields. In v1, you'd use program options like tea.WithAltScreen() and commands like tea.EnterAltScreen to toggle terminal features on and off. In v2, you just set fields on the tea.View struct in your View() method and Bubble Tea handles the rest.

This means: no more startup option flags, no more toggle commands, no more fighting over state. Just declare what you want and Bubble Tea will make it so.

go
// v1: imperative — scattered across NewProgram, Init, and Update
p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion())

// v2: declarative — everything lives in View()
func (m model) View() tea.View {
    v := tea.NewView("Hello!")
    v.AltScreen = true
    v.MouseMode = tea.MouseModeCellMotion
    return v
}

Keep this in mind as you go through the rest of the guide — most of the "removed" things simply moved into View fields.

View Returns a tea.View Now

The View() method no longer returns a string. It returns a tea.View struct.

go
// Before:
func (m model) View() string {
    return "Hello, world!"
}

// After:
func (m model) View() tea.View {
    return tea.NewView("Hello, world!")
}

You can also use the longer form if you need to set additional fields:

go
func (m model) View() tea.View {
    var v tea.View
    v.SetContent("Hello, world!")
    v.AltScreen = true
    return v
}

The tea.View struct has fields for everything that used to be controlled by options and commands:

View FieldWhat It Does
ContentThe rendered string (set via SetContent() or NewView())
AltScreenEnter/exit the alternate screen buffer
MouseModeMouseModeNone, MouseModeCellMotion, or MouseModeAllMotion
ReportFocusEnable focus/blur event reporting
DisableBracketedPasteModeDisable bracketed paste
WindowTitleSet the terminal window title
CursorControl cursor position, shape, color, and blink
ForegroundColorSet the terminal foreground color
BackgroundColorSet the terminal background color
ProgressBarShow a native terminal progress bar
KeyboardEnhancementsRequest keyboard enhancement features
OnMouseIntercept mouse messages based on view content

Key Messages

Key messages got a major overhaul. Here's the quick rundown:

tea.KeyMsg is now an interface

In v1, tea.KeyMsg was a struct you'd match on for key presses. In v2, it's an interface that covers both key presses and releases. For most code, you want tea.KeyPressMsg:

go
// Before:
case tea.KeyMsg:
    switch msg.String() {
    case "q":
        return m, tea.Quit
    }

// After:
case tea.KeyPressMsg:
    switch msg.String() {
    case "q":
        return m, tea.Quit
    }

If you want to handle both presses and releases, use tea.KeyMsg and type-switch inside:

go
case tea.KeyMsg:
    switch key := msg.(type) {
    case tea.KeyPressMsg:
        // key press
    case tea.KeyReleaseMsg:
        // key release
    }

Key fields changed

v1v2Notes
msg.Typemsg.CodeA rune — can be tea.KeyEnter, 'a', etc.
msg.Runesmsg.TextNow a string, not []rune
msg.Altmsg.Modmsg.Mod.Contains(tea.ModAlt) for alt, etc.
tea.KeyRuneCheck len(msg.Text) > 0 instead
tea.KeyCtrlCUse msg.String() == "ctrl+c" or check msg.Code + msg.Mod

Space bar changed

Space bar now returns "space" instead of " " when using msg.String():

go
// Before:
case " ":

// After:
case "space":

key.Code is still ' ' and key.Text is still " ", but String() returns "space".

Ctrl+key matching

go
// Before:
case tea.KeyCtrlC:
    // ctrl+c

// After (option A — string matching):
case tea.KeyPressMsg:
    switch msg.String() {
    case "ctrl+c":
        // ctrl+c
    }

// After (option B — field matching):
case tea.KeyPressMsg:
    if msg.Code == 'c' && msg.Mod == tea.ModCtrl {
        // ctrl+c
    }

New Key fields

These are new in v2 and don't have v1 equivalents:

  • key.ShiftedCode — the shifted key code (e.g., 'B' when pressing shift+b)
  • key.BaseCode — the key on a US PC-101 layout (handy for international keyboards)
  • key.IsRepeat — whether the key is auto-repeating (Kitty protocol / Windows Console only)
  • key.Keystroke() — like String() but always includes modifier info

Paste Messages

Paste events no longer come in as tea.KeyMsg with a Paste flag. They're now their own message types:

go
// Before:
case tea.KeyMsg:
    if msg.Paste {
        m.text += string(msg.Runes)
    }

// After:
case tea.PasteMsg:
    m.text += msg.Content
case tea.PasteStartMsg:
    // paste started
case tea.PasteEndMsg:
    // paste ended

Mouse Messages

tea.MouseMsg is now an interface

In v1, tea.MouseMsg was a struct with X, Y, Button, etc. In v2, it's an interface. You get the coordinates by calling msg.Mouse():

go
// Before:
case tea.MouseMsg:
    x, y := msg.X, msg.Y

// After:
case tea.MouseMsg:
    mouse := msg.Mouse()
    x, y := mouse.X, mouse.Y

Mouse events are split by type

Instead of checking msg.Action, match on specific message types:

go
// Before:
case tea.MouseMsg:
    if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
        // left click
    }

// After:
case tea.MouseClickMsg:
    if msg.Button == tea.MouseLeft {
        // left click
    }
case tea.MouseReleaseMsg:
    // release
case tea.MouseWheelMsg:
    // scroll
case tea.MouseMotionMsg:
    // movement

Button constants renamed

v1v2
tea.MouseButtonLefttea.MouseLeft
tea.MouseButtonRighttea.MouseRight
tea.MouseButtonMiddletea.MouseMiddle
tea.MouseButtonWheelUptea.MouseWheelUp
tea.MouseButtonWheelDowntea.MouseWheelDown
tea.MouseButtonWheelLefttea.MouseWheelLeft
tea.MouseButtonWheelRighttea.MouseWheelRight

tea.MouseEventtea.Mouse

The MouseEvent struct is gone. The new Mouse struct has X, Y, Button, and Mod fields.

Mouse mode is now a View field

go
// Before:
p := tea.NewProgram(model{}, tea.WithMouseCellMotion())

// After:
func (m model) View() tea.View {
    v := tea.NewView("...")
    v.MouseMode = tea.MouseModeCellMotion
    return v
}

Removed Program Options

These options no longer exist. They all moved to View fields.

Removed OptionDo This Instead
tea.WithAltScreen()view.AltScreen = true
tea.WithMouseCellMotion()view.MouseMode = tea.MouseModeCellMotion
tea.WithMouseAllMotion()view.MouseMode = tea.MouseModeAllMotion
tea.WithReportFocus()view.ReportFocus = true
tea.WithoutBracketedPaste()view.DisableBracketedPasteMode = true
tea.WithInputTTY()Just remove it — v2 always opens the TTY for input automatically
tea.WithANSICompressor()Just remove it — the new renderer handles optimization automatically

Removed Commands

These commands no longer exist. Set the corresponding View field instead.

Removed CommandDo This Instead
tea.EnterAltScreenview.AltScreen = true
tea.ExitAltScreenview.AltScreen = false
tea.EnableMouseCellMotionview.MouseMode = tea.MouseModeCellMotion
tea.EnableMouseAllMotionview.MouseMode = tea.MouseModeAllMotion
tea.DisableMouseview.MouseMode = tea.MouseModeNone
tea.HideCursorview.Cursor = nil
tea.ShowCursorview.Cursor = &tea.Cursor{...} or tea.NewCursor(x, y)
tea.EnableBracketedPasteview.DisableBracketedPasteMode = false
tea.DisableBracketedPasteview.DisableBracketedPasteMode = true
tea.EnableReportFocusview.ReportFocus = true
tea.DisableReportFocusview.ReportFocus = false
tea.SetWindowTitle("...")view.WindowTitle = "..."

Removed Program Methods

These methods on *Program are gone.

Removed MethodDo This Instead
p.Start()p.Run()
p.StartReturningModel()p.Run()
p.EnterAltScreen()view.AltScreen = true in View()
p.ExitAltScreen()view.AltScreen = false in View()
p.EnableMouseCellMotion()view.MouseMode in View()
p.DisableMouseCellMotion()view.MouseMode = tea.MouseModeNone in View()
p.EnableMouseAllMotion()view.MouseMode in View()
p.DisableMouseAllMotion()view.MouseMode = tea.MouseModeNone in View()
p.SetWindowTitle(...)view.WindowTitle in View()

Renamed APIs

v1v2Notes
tea.Sequentially(...)tea.Sequence(...)Sequentially was already deprecated in v1
tea.WindowSize()tea.RequestWindowSizeNow returns Msg directly, not a Cmd

New Program Options

These are new in v2:

OptionWhat It Does
tea.WithColorProfile(p)Force a specific color profile (great for testing)
tea.WithWindowSize(w, h)Set initial terminal size (great for testing)

Complete Before & After

Here's a minimal but complete program showing the most common migration patterns side by side.

v1:

go
package main

import (
    "fmt"
    "os"

    tea "github.com/charmbracelet/bubbletea"
)

type model struct {
    count int
}

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 "q", "ctrl+c":
            return m, tea.Quit
        case " ":
            m.count++
        }
    case tea.MouseMsg:
        if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
            m.count++
        }
    }
    return m, nil
}

func (m model) View() string {
    return fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count)
}

func main() {
    p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion())
    if _, err := p.Run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

v2:

go
package main

import (
    "fmt"
    "os"

    tea "charm.land/bubbletea/v2"
)

type model struct {
    count int
}

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.KeyPressMsg:
        switch msg.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        case "space":
            m.count++
        }
    case tea.MouseClickMsg:
        if msg.Button == tea.MouseLeft {
            m.count++
        }
    }
    return m, nil
}

func (m model) View() tea.View {
    v := tea.NewView(fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count))
    v.AltScreen = true
    v.MouseMode = tea.MouseModeCellMotion
    return v
}

func main() {
    p := tea.NewProgram(model{})
    if _, err := p.Run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Notice how the NewProgram call got simpler? All the terminal feature flags moved into View() where they belong.

Quick Reference

A flat old → new lookup table. Handy for search-and-replace and LLM-assisted migration.

Import Paths

v1v2
github.com/charmbracelet/bubbleteacharm.land/bubbletea/v2
github.com/charmbracelet/lipglosscharm.land/lipgloss/v2

Model Interface

v1v2
View() stringView() tea.View

Key Events

v1v2
tea.KeyMsg (struct)tea.KeyPressMsg for presses, tea.KeyMsg (interface) for both
msg.Typemsg.Code
msg.Runesmsg.Text (string, not []rune)
msg.Altmsg.Mod.Contains(tea.ModAlt)
tea.KeyRunecheck len(msg.Text) > 0
tea.KeyCtrlCmsg.Code == 'c' && msg.Mod == tea.ModCtrl or msg.String() == "ctrl+c"
case " ": (space)case "space":

Mouse Events

v1v2
tea.MouseMsg (struct)tea.MouseMsg (interface) — call .Mouse() for the data
tea.MouseEventtea.Mouse
tea.MouseButtonLefttea.MouseLeft
tea.MouseButtonRighttea.MouseRight
tea.MouseButtonMiddletea.MouseMiddle
tea.MouseButtonWheelUptea.MouseWheelUp
tea.MouseButtonWheelDowntea.MouseWheelDown
msg.X, msg.Y (direct)msg.Mouse().X, msg.Mouse().Y

Options → View Fields

v1 Optionv2 View Field
tea.WithAltScreen()view.AltScreen = true
tea.WithMouseCellMotion()view.MouseMode = tea.MouseModeCellMotion
tea.WithMouseAllMotion()view.MouseMode = tea.MouseModeAllMotion
tea.WithReportFocus()view.ReportFocus = true
tea.WithoutBracketedPaste()view.DisableBracketedPasteMode = true

Commands → View Fields

v1 Commandv2 View Field
tea.EnterAltScreen / tea.ExitAltScreenview.AltScreen = true/false
tea.EnableMouseCellMotionview.MouseMode = tea.MouseModeCellMotion
tea.EnableMouseAllMotionview.MouseMode = tea.MouseModeAllMotion
tea.DisableMouseview.MouseMode = tea.MouseModeNone
tea.HideCursor / tea.ShowCursorview.Cursor = nil / view.Cursor = &tea.Cursor{...}
tea.EnableBracketedPaste / tea.DisableBracketedPasteview.DisableBracketedPasteMode = false/true
tea.EnableReportFocus / tea.DisableReportFocusview.ReportFocus = true/false
tea.SetWindowTitle("...")view.WindowTitle = "..."

Removed Options (No Replacement Needed)

v1 OptionWhat Happened
tea.WithInputTTY()v2 always opens the TTY for input automatically
tea.WithANSICompressor()The new renderer handles optimization automatically

Removed Program Methods

v1 Methodv2 Replacement
p.Start()p.Run()
p.StartReturningModel()p.Run()
p.EnterAltScreen()view.AltScreen = true in View()
p.ExitAltScreen()view.AltScreen = false in View()
p.EnableMouseCellMotion()view.MouseMode in View()
p.DisableMouseCellMotion()view.MouseMode = tea.MouseModeNone in View()
p.EnableMouseAllMotion()view.MouseMode in View()
p.DisableMouseAllMotion()view.MouseMode = tea.MouseModeNone in View()
p.SetWindowTitle(...)view.WindowTitle in View()

Other Renames

v1v2
tea.Sequentially(...)tea.Sequence(...)
tea.WindowSize()tea.RequestWindowSize (now returns Msg, not Cmd)

New Program Options

OptionDescription
tea.WithColorProfile(p)Force a specific color profile
tea.WithWindowSize(w, h)Set initial window size (great for testing)

Feedback

Have thoughts on the v2 upgrade? We'd love to hear about it. Let us know on…


Part of Charm.

<a href="https://charm.land/"></a>

Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة