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.
Here's the short version โ a checklist you can follow top to bottom. Each item links to the relevant section below.
- Update import paths
- Change
View() stringtoView() tea.View - Replace
tea.KeyMsgwithtea.KeyPressMsg - Update key fields:
msg.Type/msg.Runes/msg.Alt - Replace
case " ":withcase "space": - Update mouse message usage
- Rename mouse button constants
- Remove old program options โ use View fields
- Remove imperative commands โ use View fields
- Remove old program methods
- Rename
tea.WindowSize()โtea.RequestWindowSize - Replace
tea.Sequentially(...)โtea.Sequence(...)
The module path changed to a vanity domain. Lip Gloss moved too.
// 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 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.
// 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.
The View() method no longer returns a string. It returns a tea.View struct.
// 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:
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 Field | What It Does |
|---|---|
Content |
The rendered string (set via SetContent() or NewView()) |
AltScreen |
Enter/exit the alternate screen buffer |
MouseMode |
MouseModeNone, MouseModeCellMotion, or MouseModeAllMotion |
ReportFocus |
Enable focus/blur event reporting |
DisableBracketedPasteMode |
Disable bracketed paste |
WindowTitle |
Set the terminal window title |
Cursor |
Control cursor position, shape, color, and blink |
ForegroundColor |
Set the terminal foreground color |
BackgroundColor |
Set the terminal background color |
ProgressBar |
Show a native terminal progress bar |
KeyboardEnhancements |
Request keyboard enhancement features |
OnMouse |
Intercept mouse messages based on view content |
Key messages got a major overhaul. Here's the quick rundown:
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:
// 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:
case tea.KeyMsg:
switch key := msg.(type) {
case tea.KeyPressMsg:
// key press
case tea.KeyReleaseMsg:
// key release
}| v1 | v2 | Notes |
|---|---|---|
msg.Type |
msg.Code |
A rune โ can be tea.KeyEnter, 'a', etc. |
msg.Runes |
msg.Text |
Now a string, not []rune |
msg.Alt |
msg.Mod |
msg.Mod.Contains(tea.ModAlt) for alt, etc. |
tea.KeyRune |
โ | Check len(msg.Text) > 0 instead |
tea.KeyCtrlC |
โ | Use msg.String() == "ctrl+c" or check msg.Code + msg.Mod |
Space bar now returns "space" instead of " " when using msg.String():
// Before:
case " ":
// After:
case "space":key.Code is still ' ' and key.Text is still " ", but String() returns "space".
// 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
}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()โ likeString()but always includes modifier info
Paste events no longer come in as tea.KeyMsg with a Paste flag. They're now their own message types:
// 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 endedIn 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():
// Before:
case tea.MouseMsg:
x, y := msg.X, msg.Y
// After:
case tea.MouseMsg:
mouse := msg.Mouse()
x, y := mouse.X, mouse.YInstead of checking msg.Action, match on specific message types:
// 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| v1 | v2 |
|---|---|
tea.MouseButtonLeft |
tea.MouseLeft |
tea.MouseButtonRight |
tea.MouseRight |
tea.MouseButtonMiddle |
tea.MouseMiddle |
tea.MouseButtonWheelUp |
tea.MouseWheelUp |
tea.MouseButtonWheelDown |
tea.MouseWheelDown |
tea.MouseButtonWheelLeft |
tea.MouseWheelLeft |
tea.MouseButtonWheelRight |
tea.MouseWheelRight |
The MouseEvent struct is gone. The new Mouse struct has X, Y, Button, and Mod fields.
// Before:
p := tea.NewProgram(model{}, tea.WithMouseCellMotion())
// After:
func (m model) View() tea.View {
v := tea.NewView("...")
v.MouseMode = tea.MouseModeCellMotion
return v
}These options no longer exist. They all moved to View fields.
| Removed Option | Do 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 |
These commands no longer exist. Set the corresponding View field instead.
| Removed Command | Do This Instead |
|---|---|
tea.EnterAltScreen |
view.AltScreen = true |
tea.ExitAltScreen |
view.AltScreen = false |
tea.EnableMouseCellMotion |
view.MouseMode = tea.MouseModeCellMotion |
tea.EnableMouseAllMotion |
view.MouseMode = tea.MouseModeAllMotion |
tea.DisableMouse |
view.MouseMode = tea.MouseModeNone |
tea.HideCursor |
view.Cursor = nil |
tea.ShowCursor |
view.Cursor = &tea.Cursor{...} or tea.NewCursor(x, y) |
tea.EnableBracketedPaste |
view.DisableBracketedPasteMode = false |
tea.DisableBracketedPaste |
view.DisableBracketedPasteMode = true |
tea.EnableReportFocus |
view.ReportFocus = true |
tea.DisableReportFocus |
view.ReportFocus = false |
tea.SetWindowTitle("...") |
view.WindowTitle = "..." |
These methods on *Program are gone.
| Removed Method | Do 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() |
| v1 | v2 | Notes |
|---|---|---|
tea.Sequentially(...) |
tea.Sequence(...) |
Sequentially was already deprecated in v1 |
tea.WindowSize() |
tea.RequestWindowSize |
Now returns Msg directly, not a Cmd |
These are new in v2:
| Option | What It Does |
|---|---|
tea.WithColorProfile(p) |
Force a specific color profile (great for testing) |
tea.WithWindowSize(w, h) |
Set initial terminal size (great for testing) |
Here's a minimal but complete program showing the most common migration patterns side by side.
v1:
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:
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.
A flat old โ new lookup table. Handy for search-and-replace and LLM-assisted migration.
| v1 | v2 |
|---|---|
github.com/charmbracelet/bubbletea |
charm.land/bubbletea/v2 |
github.com/charmbracelet/lipgloss |
charm.land/lipgloss/v2 |
| v1 | v2 |
|---|---|
View() string |
View() tea.View |
| v1 | v2 |
|---|---|
tea.KeyMsg (struct) |
tea.KeyPressMsg for presses, tea.KeyMsg (interface) for both |
msg.Type |
msg.Code |
msg.Runes |
msg.Text (string, not []rune) |
msg.Alt |
msg.Mod.Contains(tea.ModAlt) |
tea.KeyRune |
check len(msg.Text) > 0 |
tea.KeyCtrlC |
msg.Code == 'c' && msg.Mod == tea.ModCtrl or msg.String() == "ctrl+c" |
case " ": (space) |
case "space": |
| v1 | v2 |
|---|---|
tea.MouseMsg (struct) |
tea.MouseMsg (interface) โ call .Mouse() for the data |
tea.MouseEvent |
tea.Mouse |
tea.MouseButtonLeft |
tea.MouseLeft |
tea.MouseButtonRight |
tea.MouseRight |
tea.MouseButtonMiddle |
tea.MouseMiddle |
tea.MouseButtonWheelUp |
tea.MouseWheelUp |
tea.MouseButtonWheelDown |
tea.MouseWheelDown |
msg.X, msg.Y (direct) |
msg.Mouse().X, msg.Mouse().Y |
| v1 Option | v2 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 |
| v1 Command | v2 View Field |
|---|---|
tea.EnterAltScreen / tea.ExitAltScreen |
view.AltScreen = true/false |
tea.EnableMouseCellMotion |
view.MouseMode = tea.MouseModeCellMotion |
tea.EnableMouseAllMotion |
view.MouseMode = tea.MouseModeAllMotion |
tea.DisableMouse |
view.MouseMode = tea.MouseModeNone |
tea.HideCursor / tea.ShowCursor |
view.Cursor = nil / view.Cursor = &tea.Cursor{...} |
tea.EnableBracketedPaste / tea.DisableBracketedPaste |
view.DisableBracketedPasteMode = false/true |
tea.EnableReportFocus / tea.DisableReportFocus |
view.ReportFocus = true/false |
tea.SetWindowTitle("...") |
view.WindowTitle = "..." |
| v1 Option | What Happened |
|---|---|
tea.WithInputTTY() |
v2 always opens the TTY for input automatically |
tea.WithANSICompressor() |
The new renderer handles optimization automatically |
| v1 Method | v2 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() |
| v1 | v2 |
|---|---|
tea.Sequentially(...) |
tea.Sequence(...) |
tea.WindowSize() |
tea.RequestWindowSize (now returns Msg, not Cmd) |
| Option | Description |
|---|---|
tea.WithColorProfile(p) |
Force a specific color profile |
tea.WithWindowSize(w, h) |
Set initial window size (great for testing) |
Have thoughts on the v2 upgrade? We'd love to hear about it. Let us know onโฆ
Part of Charm.
Charm็ญ็ฑๅผๆบ โข Charm loves open source โข ูุญูู ูุญุจ ุงูู ุตุงุฏุฑ ุงูู ูุชูุญุฉ
