Releases: charmbracelet/bubbletea
v2.0.2
This release contains a small patch fixing a rendering that might affect Wish users running on Unix platforms.
Changelog
Fixed
- f25595a: fix(renderer): use mapNl optimization when not on Windows and no PTY input (#1615) (@aymanbagabas)
Thoughts? Questions? We love hearing from you. Feel free to reach out on X, Discord, Slack, The Fediverse, Bluesky.
v2.0.1
A small patch release to fix opening the proper default stdin file for input.
Changelog
Fixed
- 110a919: fix(examples): add missing
WithWidthto table example (#1598) (@shv-ng) - 66b7abd: fix: check if os.Stdin is a terminal before opening the TTY (@aymanbagabas)
Docs
- c751374: docs: correct whats new link (@aymanbagabas)
- 736fba2: docs: upgrade guide: correct badge url (@aymanbagabas)
Thoughts? Questions? We love hearing from you. Feel free to reach out on X, Discord, Slack, The Fediverse, Bluesky.
v2.0.0
What's New in Bubble Tea v2
We're very excited to announce the second major release of Bubble Tea!
If you (or your LLM) are just looking for technical details on on migrating from v1, please check out the Upgrade Guide.
Note
We don't take API changes lightly and strive to make the upgrade process as simple as possible. We believe the changes bring necessary improvements as well as pave the way for the future. If something feels way off, let us know.
❤️ Charm Land Import Path
We've updated our import paths to use vanity domains and use our domain to import Go packages.
// Before
import tea "github.com/charmbracelet/bubbletea"
// After
import tea "charm.land/bubbletea/v2"Everything else stays the same 🙂
👾 The Cursed Renderer
Bubble Tea v2 ships with the all-new Cursed Renderer which was built from the ground up. It's based on the ncurses rendering algorithm and is highly optimized for speed, efficiency, and accuracy and is built on an enormous amount of research and development.
Optimized renders also means that Wish users get big performance benefits and lower bandwidth usage by orders of magnitude.
To take advantage of the new Cursed Renderer you don't need to do anything at all except keep on using the Bubble Tea you know and love.
✌️ Key handling is way better now
Newer terminals can now take advantage of all sorts keyboard input via progressive keyboard enhancements. You can now map all sorts of keys and modifiers like shift+enter and super+space. You can also detect key releases (we're looking at you, game developers).
It's easy to detect support for supporting terminals and add fallbacks for those that don't. For details, see keyboard enhancements below.
🥊 No more fighting
In the past, Bubble Tea and Lip Gloss would often fight over i/o. Bubble Tea wanted to read keyboard input and Lip Gloss wanted to query for the background color. This means that things could get messy. Not anymore! In v2, Lip Gloss is now pure, which means, Bubble Tea manages i/o and gives orders to Lip Gloss. In short, we only need one lib to call the shots, and in the context of this relationship, that lib is Bubble Tea.
But what about color downsampling? That's a great question.
👨🏻🎨 Built-in Color Downsampling
We sneakily released a little library called colorprofile that will detect the terminal's color profile and auto-downsample any ANSI styling that flows through it to the best available color profile. This means that color will "just work" (and not misbehave) no matter where the ANSI styling comes from.
Downsampling is built-into Bubble Tea and is automatically enabled.
🧘 Declarative, Not Imperative
This is a big one. In v1, you'd toggle terminal features on and off with commands like tea.EnterAltScreen, tea.EnableMouseCellMotion, tea.EnableReportFocus, and so on. In v2, all of that is gone and replaced by fields on the View struct. You just declare what you want your view to look like and Bubble Tea takes care of the rest.
This means no more fighting over startup options and commands. Just set the fields and forget about it. For example, to enter full screen mode:
func (m Model) View() tea.View {
v := tea.NewView("Hello, full screen!")
v.AltScreen = true
return v
}The same goes for mouse mode, bracketed paste, focus reporting, window title, keyboard enhancements, and more. See A Declarative View below for the full picture.
Keyboard Enhancements
Progressive keyboard enhancements allow you to receive key events not normally possible in traditional terminals. For example, you can now listen for the ctrl+m key, as well as previously unavailable key combinations like shift+enter.
Bubble Tea v2 will always try to enable basic keyboard enhancements that disambiguate keys. If your terminal supports it, your program will receive a tea.KeyboardEnhancementsMsg message that indicates support for requested features.
func (m Model) View() tea.View {
var v tea.View
// ...
v.KeyboardEnhancements.ReportEventTypes = true // Enable key release events
return v
}Historically, certain key combinations in terminals map to control codes. For example, ctrl+h outputs a backspace by default, which means you can't normally bind a key event to ctrl+h. With key disambiguation, you can now actually bind events to those key combinations.
You can detect if a terminal supports keyboard enhancements by listening for tea.KeyboardEnhancementsMsg.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyboardEnhancementsMsg:
if msg.SupportsKeyDisambiguation() {
// More keys, please!
}
}
}Which terminals support progressive enhancement?
Key Messages
Key messages are now split into tea.KeyPressMsg and tea.KeyReleaseMsg. Use tea.KeyMsg to match against both. We've also replaced key.Type and key.Runes with key.Code and key.Text. Modifiers live in key.Mod now instead of being separate booleans. Oh, and space bar returns "space" instead of " ".
The easiest way to match against key press events is to use msg.String():
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "space":
// Space bar returns "space" now :D
return m, tea.Println("You pressed the space bar!")
case "ctrl+c":
return m, tea.SetClipboard("Howdy")
case "shift+enter":
// Awesome, right?
case "ctrl+alt+super+enter":
// Yes, you can do that now!
}
}
}The Key struct also has some nice new fields:
key.BaseCode— the key according to a standard US PC-101 layout. Handy for international keyboards where the physical key might differ.key.IsRepeat— tells you if the key is being held down and auto-repeating. Only available with the Kitty Keyboard Protocol or Windows Console API.key.Keystroke()— a new method that returns the keystroke representation (e.g.,"ctrl+shift+alt+a"). UnlikeString(), it always includes modifier info.
For the full list of changes and before/after code samples, see the Upgrade Guide.
Paste Messages
Paste events used to arrive as tea.KeyMsg with a confusing msg.Paste flag. Now they're their own thing:
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.PasteMsg:
// Here comes a paste!
m.text += msg.Content
case tea.PasteStartMsg:
// The user started pasting.
case tea.PasteEndMsg:
// The user stopped pasting.
}
}Mouse Messages
We've improved the mouse API. Mouse messages are now split into tea.MouseClickMsg, tea.MouseReleaseMsg, tea.MouseWheelMsg, and tea.MouseMotionMsg. And mouse mode is set declaratively in your View():
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
// Clickety click
}
case tea.MouseWheelMsg:
// Scroll, scroll, scrollllll
}
return m, nil
}
func (m model) View() tea.View {
v := tea.NewView("Move that mouse around!")
v.MouseMode = tea.MouseModeAllMotion // or tea.MouseModeCellMotion
return v
}A Declarative View
In v1, View() returned a string. In v2, it returns a tea.View struct that lets you declare everything about your view — content, cursor, alt screen, mouse mode, colors, window title, progress bar, and more:
type View struct {
Content string
OnMouse func(msg MouseMsg) Cmd
Cursor *Cursor
BackgroundColor color.Color
ForegroundColor color.Color
WindowTitle string
ProgressBar *ProgressBar
AltScreen bool
ReportFocus bool
DisableBracketedPasteMode bool
MouseMode MouseMode
KeyboardEnhancements KeyboardEnhancements
}No more fighting over options and commands! Just set the fields:
func (m Model) View() tea.View {
v := tea.NewView(fmt.Sprintf("Hello, world!"))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.WindowTitle = "My Awesome App"
return v
}An Actual Cursor
You can now control the cursor position, color, and shape right from your view function. Want it hidden? Just set view.Cursor = nil.
func (m Model) View() tea.View {
var v tea.View
if m.showCursor {
v.Cursor = &tea.Cursor{
Position: tea.Position{
X: 14, // At the 14th column
Y: 0, // On the first row
},
Shape: tea.CursorBlock, // Just ...v2.0.0-rc.2
Smooth Operator (Mode 2026)
This release introduces synchronized output updates, which is enabled by default. This mode enhances how supported terminals render program updates by triggering the updates atomically.
We've also fixed a bunch of renderer bugs and edge cases 🙂
Changelog
New!
- 94247ed: feat: add SupportsKeyDisambiguation to KeyboardEnhancementsMsg (@aymanbagabas)
- f1799cd: feat: add synchronized updates option to renderer interface (@aymanbagabas)
- 7120058: feat: introduce ModeReportMsg for terminal mode reports (@aymanbagabas)
- 349783e: feat: query terminal for synchronized output support (mode 2026) (@aymanbagabas)
- d88ab58: feat: renderer: use uv altscreen helpers (@aymanbagabas)
Fixed
- 101fd11: Revert "fix: renderer: sync buffer resize with frame rate" (@aymanbagabas)
- 8f17c35: fix(examples): revert textarea pointers to values (@aymanbagabas)
- 78766cd: fix(examples): use updated progress bar blend api (@aymanbagabas)
- d9f7210: fix(renderer): avoid unnecessary resize on alt screen buffer (@aymanbagabas)
- 03b6524: fix: edge case where cursor visibility is the only change (@aymanbagabas)
- 1ef7dd4: fix: ensure TERM_PROGRAM is set (@aymanbagabas)
- 5133131: fix: renderer: always erase to end of line when prepending lines (@aymanbagabas)
- f206009: fix: renderer: always redraw on resize (@aymanbagabas)
- 2c7af7f: fix: renderer: avoid redundant cursor toggles (@aymanbagabas)
- 4687350: fix: renderer: check for cellbuf size change before skipping flush (@aymanbagabas)
- f19cb68: fix: renderer: defer resize and draw until flush (@aymanbagabas)
- 3a71934: fix: renderer: ensure prepended lines queued before render (@aymanbagabas)
- 83e606b: fix: renderer: handle cursor visibility and syncd output efficiently (@aymanbagabas)
- f01fc81: fix: renderer: reset cursor position in inline mode to avoid line wraps (@aymanbagabas)
- f952eb9: fix: renderer: use an interface for inline height calculation (@aymanbagabas)
- 1dcdea9: fix: renderer: use the correct size when resizing the screen (@aymanbagabas)
- aa0071e: fix: renderer: we want to always use the frame area for drawing (@aymanbagabas)
Docs
- 9878b7a: docs: update NewView and SetContent to add examples and notes (@aymanbagabas)
- cfe6724: docs: update code example formatting in screen.go (@aymanbagabas)
- 3780c0e: docs: update keyboard enhancement flags comment (@aymanbagabas)
Other stuff
- 514da28: perf: renderer: skip flush if no changes (@aymanbagabas)
- ed050cd: refactor: extract terminal capability detection into helper function (#1543) (@Copilot)
- f03c467: refactor: keyboard enhancements api improvements (#1534) (@aymanbagabas)
- a1ecb94: refactor: rename View.Layer to View.Content for clarity (#1536) (@aymanbagabas)
- c3c752d: refactor: renderer: support mode 2026, manage cursor visibility and altscreen (@aymanbagabas)
Thoughts? Questions? We love hearing from you. Feel free to reach out on X, Discord, Slack, The Fediverse, Bluesky.
v2.0.0-rc.1
This release includes a big change in the module name, and several message type changes. These types changed from type aliases to structs to improve extensibility and allow for future enhancements without breaking changes:
github.com/charmbracelet/bubbletea/v2- now moved tocharm.land/bubbletea/v2CursorPositionMsg- now a struct typeKeyboardEnhancementsMsg- now a struct typePasteMsg- now a struct typeCapabilityMsg- now a struct typeTerminalVersionMsg- now struct types
Migration Guide
Charm Land!
// Before
import tea "github.com/charmbracelet/bubbletea/v2"
// After
import tea "charm.land/bubbletea/v2"Or you can use this GNU sed oneliner in your project root 😉
find . -name \*.go | xargs -I{} sed -i 's/"github.com\/charmbracelet\/bubbletea\/v2"/"charm.land\/bubbletea\/v2"/' {}CursorPositionMsg
// Before
case CursorPositionMsg:
x, y := msg.X, msg.Y
// After (no change needed - fields remain the same)
case CursorPositionMsg:
x, y := msg.X, msg.YKeyboardEnhancementsMsg
// Before
case KeyboardEnhancementsMsg:
if msg&ansi.KittyDisambiguateEscapeCodes != 0 {
// ...
}
// After
case KeyboardEnhancementsMsg:
if msg.Flags&ansi.KittyDisambiguateEscapeCodes != 0 {
// ...
}
// Or use the helper methods:
if msg.SupportsKeyDisambiguation() {
// ...
}PasteMsg
// Before
case PasteMsg:
content := string(msg)
// After
case PasteMsg:
content := msg.Content
// Or use the String() method:
content := msg.String()CapabilityMsg
// Before
case CapabilityMsg:
switch msg {
case "RGB", "Tc":
// ...
}
// After
case CapabilityMsg:
switch msg.Content {
case "RGB", "Tc":
// ...
}
// Or use the String() method:
switch msg.String() {
case "RGB", "Tc":
// ...
}TerminalVersionMsg
// Before
case TerminalVersionMsg:
version := string(msg)
// After
case TerminalVersionMsg:
version := msg.Name
// Or use the String() method:
version := msg.String()Changelog
Docs
- ff8c824: docs(examples): migrate imports to charm.land (@aymanbagabas)
Other stuff
- b947ade: refactor: change several message types to structs for extensibility (@aymanbagabas)
- cc0168c: refactor: migrate imports to charm.land domain (@aymanbagabas)
- b864579: refactor: update module path to charm.land/bubbletea/v2 (@aymanbagabas)
Thoughts? Questions? We love hearing from you. Feel free to reach out on X, Discord, Slack, The Fediverse, Bluesky.
v2.0.0-beta.6
Renderer Fixes
This new beta release includes a number of fixes related to the renderer.
Changelog
Fixed
- 4accb1c: fix: renderer: avoid requesting keyboard enhancements on close (@aymanbagabas)
- 6781fb5: fix: renderer: ensure we reset cursor style and color on close (@aymanbagabas)
- fa36fb1: fix: renderer: only move cursor to bottom when we're in the main screen (@aymanbagabas)
- d7c95b8: fix: renderer: sync buffer resize with frame rate (@aymanbagabas)
Thoughts? Questions? We love hearing from you. Feel free to reach out on X, Discord, Slack, The Fediverse, Bluesky.
v2.0.0-beta.5
Bubble Tea beta 5 is here!
We're excited to share the new Bubble Tea v2 beta 5 release with you! In this release, we focused on refining the View API, making it more declarative with a single source of truth for view-related properties.
Summary
All the changes since Beta 4 are to supporting a more declarative View type. This might sound like a lot but it's fairly simple in practice. This is what it looks like now:
func (m model) View() tea.View {
v := tea.NewView("Hi, mom!")
v.AltScreen = true
v.WindowTitle = "It’s all about Mom today"
}Why the change?
The general impetus for doing this is to eliminate the possibility of race conditions and greatly improve view performance. For example, the altscreen was previously triggered via a Cmd, which would not necessarily hit at the same moment the view was being rendered.
What else happened?
We've removed many view-based startup options and commands in favor of a more declarative approach using the View type. For example, WithMouseCellMotion() is now a property on View, i.e. view.MouseMode = tea.MouseModeCellMotion.
Now, the View() method returns a View struct that encapsulates all view-related properties. This change simplifies the API and makes it more intuitive to use with a single point of configuration for views.
The View API
Previously, Bubble Tea used functional options and commands to configure various view-related properties. This approach was flexible but led to a fragmented API surface. Should I use WithAltScreen() or EnterAltScreen? Is there a difference between them? Why can't I enable focus reporting with WithReportFocus() but not via a command?
These questions and inconsistencies led us to rethink how we handle view configuration. The new View struct consolidates all view-related properties, making it easier to understand and use with a single source of truth. I.e., this is how your view looks like, and these are its properties.
type View struct {
Layer Layer // Layer represents the content of the view
Cursor *Cursor // Position, style, color (nil = hidden)
BackgroundColor color.Color // Terminal default background color
ForegroundColor color.Color // Terminal default foreground color
WindowTitle string // Window title
ProgressBar *ProgressBar // Progress bar (nil = no progressbar)
AltScreen bool // Alternate screen buffer (fullscreen mode)
MouseMode MouseMode // Mouse event mode
ReportFocus bool // Focus/blur events
DisableBracketedPasteMode bool // Bracketed paste
DisableKeyEnhancements bool // Keyboard enhancements
KeyReleases bool // Key release events
UniformKeyLayout bool // Uniform key layout
}Options and Commands
We've removed many of the view-related options and commands that were previously set using functional options or commands. Instead, these properties are now part of the View struct returned by the View() method.
Removed options include:
WithAltScreen()→View.AltScreen = trueWithMouseCellMotion()→View.MouseMode = tea.MouseModeCellMotionWithMouseAllMotion()→View.MouseMode = tea.MouseModeAllMotionWithReportFocus()→View.ReportFocus = trueWithKeyReleases()→View.KeyReleases = trueWithUniformKeyLayout()→View.UniformKeyLayout = trueWithoutBracketedPaste()→View.DisableBracketedPasteMode = trueWithInputTTY()→ useOpenTTY()and set input/output manually
Removed commands include:
EnterAltScreen/ExitAltScreen→View.AltScreenSetBackgroundColor()→View.BackgroundColorSetForegroundColor()→View.ForegroundColorSetCursorColor()→View.Cursor.ColorSetWindowTitle()→View.WindowTitleEnableMouseCellMotion/EnableMouseAllMotion/DisableMouse→View.MouseMode
Model Interface
The Model interface has been updated to make View() return a View struct instead of a string. We know that a string is often more convenient and easier to work with, however, due to the number of view-related options we wanted to support, we felt it was best to encapsulate them in a dedicated struct.
You can still use strings for your views of course! You just need to wrap them in a NewView() call, or use view.SetContent(yourString).
// Before
type Model interface {
Init() Cmd
Update(Msg) (Model, Cmd)
View() string
// Or...
View() (string, *Cursor)
}
// After
type Model interface {
Init() Cmd
Update(Msg) (Model, Cmd)
View() View
}Example?
Let's get to the fun part! Here's a simple example that displays a text in the center of the screen using the alt-screen buffer.
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
)
type model struct {
width, height 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.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.KeyPressMsg:
return m, tea.Quit
}
return m, nil
}
func (m model) View() tea.View {
var v tea.View
content := lipgloss.NewStyle().
Width(m.width).
Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Foreground(lipgloss.Cyan).
Render(" Bubble Tea Beta 5! ")
v.AltScreen = true
v.SetContent(content)
return v
}
func main() {
p := tea.NewProgram(model{})
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Alas, there's been an error: %v", err)
}
}Like details?
Here’s the full changelog since v2.0.0-beta.4
Changelog
New!
- 7f2ee3d: feat(examples): add a canvas example (@aymanbagabas)
- 9ea9a8c: feat(examples): add clickable example (@aymanbagabas)
- e67ab35: feat(examples): add layer hit message to canvas example (@aymanbagabas)
- 19dc7c2: feat: add NewView helper function (@aymanbagabas)
- 94c70a4: feat: add OpenTTY to open the terminal's TTY (@aymanbagabas)
- f51fc36: feat: add helper method SetContent to View (@aymanbagabas)
- 9b921df: feat: add option to disable key enhancements (@aymanbagabas)
- 69b915a: feat: add support for setting terminal window title using the View API (@aymanbagabas)
- 4cafc09: feat: add support for terminal progress bars (#1499) (@aymanbagabas)
- f1eadf4: feat: embed mouse events in hit messages (@aymanbagabas)
- 1e6aaa5: feat: implement hit detection for layers in cursed renderer (@aymanbagabas)
- 98f59a4: feat: implement viewable model and layers (@aymanbagabas)
- c3a99ef: feat: input: use new uv input reader api (@aymanbagabas)
- 314b50c: feat: properly call nested sequenceMsg and batchMsg (@wolfmagnate)
- bd45f5b: feat: renderer: use the new promoted tv renderer (@aymanbagabas)
- 718c8e7: feat: restore WithoutRenderer option (@aymanbagabas)
- a73e084: feat: support setting terminal fg/bg using the View API (@aymanbagabas)
- 31fb503: feat: update to use uv package (@aymanbagabas)
Fixed
- 256e348: fix(examples): add newline to keyboard-enhancements example output (@aymanbagabas)
- 51804bc: fix(examples): ensure we have a newline at the end of the simple example (@aymanbagabas)
- 70e94a2: fix(keyboard): use kitty keyboard state command (@aymanbagabas)
- 3b687ff: fix(progress): reset on shutdown (#1511) (@caarlos0)
- f9df81b: fix(renderer): clear screen on exit when not using alt screen (@aymanbagabas)
- d4d69f6: fix(renderer): drop lines from top of buffer when frame height exceeds screen height (@aymanbagabas)
- 73b42b8: fix(renderer): only send non-empty layer hit messages (@aymanbagabas)
- 28ab4f4: fix(renderer): properly reset cursor position to start of line (#1472) (@aymanbagabas)
- 56ff714: fix(renderer): reset cursor style on close (@aymanbagabas)
- cf6866f: fix: add View to Model interface and introduce DisableRenderer field (@aymanbagabas)
- c76509a: fix: compact sequences like batches (#958) (@jdhenke)
- ab0bc8e: fix: correct event type name (@aymanbagabas)
- d2ecd81: fix: cursed_renderer: use Erase instead of Clear (@aymanbagabas)
- 875f553: fix: disable modifyOtherKeys for tmux (@aymanbagabas)
- c4303e1: fix: dry passed width and height to newCursedRenderer and send resize (@aymanbagabas)
- 30a273a...
v1.3.10
Changelog
Bug fixes
- 9edf69c: fix: handle setWindowTitleMsg and windowSizeMsg in eventLoop (@aymanbagabas)
Thoughts? Questions? We love hearing from you. Feel free to reach out on X, Discord, Slack, The Fediverse, Bluesky.
v1.3.9
Changelog
New Features
- 314b50c: feat: properly call nested sequenceMsg and batchMsg (@wolfmagnate)
Bug fixes
- 9e0e8f0: fix: recover from nested panics in Sequence and Batch commands (@aymanbagabas)
Other work
- 6e1282a: add example for the nested Sequence and Batch (@wolfmagnate)
- 0290af4: simplify case for BatchMsg (@wolfmagnate)
Thoughts? Questions? We love hearing from you. Feel free to reach out on Twitter, Discord, Slack, The Fediverse.
v1.3.8
Changelog
Bug fixes
- 21eecd5: fix: send batch commands to cmds channel instead of executing them in event loop (#1473) (@aymanbagabas)
Thoughts? Questions? We love hearing from you. Feel free to reach out on Twitter, Discord, Slack, The Fediverse.

