github charmbracelet/bubbletea v2.0.0

6 hours ago

bubbletea-v2-block

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/v2"

// 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"). Unlike String(), 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 give me a block cursor '█'
			Blink: true,            // Blink baby, blink!
			Color: lipgloss.Green,  // Green cursor, because why not?
		}
	}
	v.SetContent(fmt.Sprintf("Hello, world!"))
	return v
}

You can also use tea.NewCursor(x, y) for a quick block cursor with default settings.

Progress Bar Support

Now you can ask Bubble Tea to render a native progress bar for your application. Just set the view.ProgressBar field and Bubble Tea will take care of the rest.

func (m Model) View() tea.View {
    var v tea.View
    v.SetContent("Downloading...")
    v.ProgressBar = tea.NewProgressBar(tea.ProgressBarDefault, m.downloadProgress)
    return v
}

Synchronized Updates (Mode 2026)

Bubble Tea will try and use mode 2026 to push updates to the terminal. This mode helps reduce tearing and cursor flickering by atomically updating the terminal window once all the update sequences are pushed out and read by the terminal. This is enabled by default and there's nothing you need to do.

Better Terminal Unicode Support (mode 2027)

Now Bubble Tea will automatically enable mode 2027
on terminals that support it. This mode allows the terminal to properly handle wide Unicode
characters and emojis without breaking the layout of your app. Again, this is
enabled by default and there's nothing you need to do.

Native Clipboard Support

Bubble Tea now supports native clipboard operations, also known as OSC52. This means you can even copy and paste over SSH!

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyPressMsg:
        switch msg.String() {
        case "ctrl+c":
            return m, tea.SetClipboard("Howdy")
        case "ctrl+v":
            return m, tea.ReadClipboard()
        }
    case tea.ClipboardMsg:
        fmt.Printf("Clipboard contents: %s\n", msg.String())
    }
}

X11 and Wayland users can also use tea.SetPrimaryClipboard to set the primary clipboard. Note that this is a very niche sort of thing and may or may not work on macOS, Windows, and other platforms without the notion of more than one clipboard.

Terminal Colors

You can now read and set the terminal's foreground, background, and cursor colors. To change them, set view.ForegroundColor, view.BackgroundColor, and view.Cursor.Color in your View() function.

func (m Model) Init() tea.Cmd {
    return tea.Batch(
        tea.RequestForegroundColor,
        tea.RequestBackgroundColor,
        tea.RequestCursorColor,
    )
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.BackgroundColorMsg:
        return m, tea.Printf("Background color: %s\n", msg)
    case tea.ForegroundColorMsg:
        return m, tea.Printf("Foreground color: %s\n", msg)
    case tea.CursorColorMsg:
        return m, tea.Printf("Cursor color: %s\n", msg)
    case tea.KeyPressMsg:
        switch msg.String() {
        case "enter":
            m.fg, m.bg, m.cursor = ansi.Red, ansi.Green, ansi.Blue
        case "esc":
            return m, tea.Quit
        }
    }
    return m, nil
}

func (m Model) View() tea.View {
    var v tea.View
    v.SetContent("\nPress Enter to change terminal colors, Esc to quit.")
    v.ForegroundColor = m.fg
    v.BackgroundColor = m.bg
    if m.cursor != nil {
        v.Cursor = tea.NewCursor(0, 1)
        v.Cursor.Color = m.cursor
    }
    return v
}

🌍 Environment Variables

Bubble Tea now sends you a tea.EnvMsg at startup with the environment variables. This is especially handy for SSH apps where os.Getenv would give you the server's environment, not the client's.

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.EnvMsg:
        m.term = msg.Getenv("TERM") // the client's TERM, not the server's!
    }
    return m, nil
}

🔮 Raw Escape Sequences

For the power users out there, you can now send raw escape sequences directly to the terminal with tea.Raw. This is great for querying terminal capabilities or doing things Bubble Tea doesn't have a built-in for (yet).

return m, tea.Raw(ansi.RequestPrimaryDeviceAttributes)

Responses from the terminal will come back as messages in Update. Just be sure you know what you're doing — with great power comes great terminal weirdness.

📍 Cursor Position Queries

Need to know where the cursor is? Now you can ask.

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

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.CursorPositionMsg:
        m.cursorX, m.cursorY = msg.X, msg.Y
    }
    return m, nil
}

📊 Terminal Mode Reports

You can query whether the terminal supports specific modes (like focus events or synchronized output) using DECRPM mode reports. Send a raw DECRQM request and listen for tea.ModeReportMsg.

func (m model) Init() tea.Cmd {
    return tea.Raw(ansi.RequestModeFocusEvent)
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.ModeReportMsg:
        if msg.Mode == ansi.ModeFocusEvent && !msg.Value.IsNotRecognized() {
            m.supportsFocus = true
        }
    }
    return m, nil
}

Terminal Version and Name

Don't know what terminal you're running in? $TERM is too vague? Bubble Tea now has a tea.RequestTerminalVersion command that queries the terminal for its name and version using the XTVERSION escape sequence.

Note

This feature is not supported by all terminals.

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

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.TerminalVersionMsg:
        fmt.Printf("Terminal: %s\n", string(msg))
    }
}

Terminfo and Termcap Capabilities

Sometimes you need to know what capabilities the terminal has. Bubble Tea now has a tea.RequestCapability command that queries the terminal for a specific terminfo/termcap capability.

Note

This feature is not supported by all terminals.

func (m Model) Init() tea.Cmd {
    return tea.RequestCapability("RGB") // RGB is the terminfo capability for direct colors
}

Detecting the Color Profile

Need to use the detected color profile in your app? Listen to tea.ColorProfileMsg in Update:

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.ColorProfileMsg:
        m.colorProfile = msg.Profile // gottem!
    }
    return m, nil
}

Manually Applying a Color Profile

Want to manually set a color profile for testing? Now you can, on the program level.

import (
    tea "charm.land/bubbletea/v2"
    "github.com/charmbracelet/colorprofile"
)

p := colorprofile.TrueColor // i love colors. lets' use 16,777,216 of 'em
p = colorprofile.ANSI256    // jk, 256 colors are plenty
p = colorprofile.ANSI       // actually let's juse use 16 colors
p = colorprofile.Ascii      // nm, no colors, but keep things like bold, italics, etc.
p = colorprofile.NoTTY      // lol actually strip all ANSI sequences

prog := tea.NewProgram(model, tea.WithColorProfile(p))

Want to hard detect the color profile in Wish? We bet you do.

func main() {
    var s ssh.Session
    pty, _, _ := s.Pty()

    // Get the environment...
    envs := append(s.Environ(), "TERM="+pty.Term)

    // ...and give it to Bubble Tea so it can detect the color profile.
    opt := tea.WithEnvironment(envs)

    p := tea.NewProgram(model,
        tea.WithInput(pty.Slave),
        tea.WithOutput(pty.Slave),
        opt, // wow
    )
}

🪟 Window Size for Testing

When running tests or in non-interactive environments, you can now set the initial terminal size:

p := tea.NewProgram(model, tea.WithWindowSize(80, 24))

No more mocking terminals just to run your tests. Nice!

Use the Terminal's TTY

Sometimes your program will write to stdout while it's being piped or
redirected. In these cases, you might want to write directly to the terminal's
TTY instead of stdout because stdout might not be a terminal. Or your program
expects to read from stdin but stdin is being piped from another program.

In Bubble Tea v1, there wasn't a good way to do this. In the latter case, you
could use the WithInputTTY() option to read from the terminal's TTY instead
of stdin. However, there was no easy way to write to the terminal's TTY instead
of stdout without fiddling with file descriptors.

In Bubble Tea v2, you can now simply use the global OpenTTY() to open the
terminal's TTY for reading and writing. You can then pass the TTY file handles
to the WithInput() and WithOutput() options.

Note that Bubble Tea v2 will always use the TTY for input when input is not specified
via WithInput(...).

ttyIn, ttyOut, err := tea.OpenTTY()
if err != nil {
    log.Fatal(err)
}

p := tea.NewProgram(model,
    tea.WithInput(ttyIn),
    tea.WithOutput(ttyOut),
)

Changelog

New!

Fixed

Docs

Other stuff

🌈 More on Bubble Tea v2

Ready to migrate? Head over to the Upgrade Guide for the full migration checklist.

Feedback

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


Part of Charm.

The Charm logo

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

Don't miss a new bubbletea release

NewReleases is sending notifications on new releases.