packages/shared-skills/skills/programming/references/go/bubbletea-v2.md
The TUI stack for 2026. Use v2 RC, not v1. If your users include Korean, Japanese, or Chinese speakers, v1 is broken — IME composition lands in the wrong cells. v2 fixes this. This document is the canonical setup.
The reference implementation this document is distilled from: code-yeongyu/bubbletea-wm — a floating window manager built specifically to nail down v2 + IME.
Bubbletea v1 manages cursor positioning in software ("virtual cursor"). It draws a █ at the cursor position. The terminal's real cursor stays at (0, 0).
This breaks every CJK input method. IME candidate windows (the popup showing Hangul composition choices for Korean, kana → kanji for Japanese, and pinyin lookup for Chinese) anchor to the terminal's real cursor position. With v1, the candidate window appears at top-left while you are typing somewhere in the middle of the screen.
Bubbletea v2 fixes this with two changes:
tea.View{Cursor: *tea.Cursor} — your View() method returns a view that includes the desired cursor position. The framework moves the terminal's real cursor there.textarea.SetVirtualCursor(false) — textareas no longer draw their own █. They expose .Cursor() so you can read where they want the real cursor.Together: IME popups appear where the user is typing. As they should.
tea.MouseClickMsg / MouseMotionMsg / MouseReleaseMsg instead of one coarse MouseMsg.View struct with AltScreen, MouseMode fields instead of tea.Cmd setters.go.modmodule github.com/your-org/mytui
go 1.23
require (
charm.land/bubbletea/v2 v2.0.0-rc.2
charm.land/bubbles/v2 v2.0.0-rc.1
charm.land/lipgloss/v2 v2.0.0-beta.3
github.com/mattn/go-runewidth v0.0.19
)
The packages live under charm.land/ (NOT github.com/charmbracelet/...) for v2. This is the Charm team's deliberate import-path break to keep v2 separate from v1 until stable.
package main
import (
"fmt"
"log"
tea "charm.land/bubbletea/v2"
"charm.land/bubbles/v2/textarea"
)
type model struct {
width, height int
ta textarea.Model
}
func initial() model {
ta := textarea.New()
ta.Placeholder = "Type Korean / Japanese / Chinese here..."
ta.SetWidth(60)
ta.SetHeight(10)
ta.SetVirtualCursor(false) // ← THE LINE. Without this, IME breaks.
ta.Focus()
return model{ta: ta}
}
func (m model) Init() tea.Cmd { return textarea.Blink }
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
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
}
var cmd tea.Cmd
m.ta, cmd = m.ta.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
var view tea.View
view.AltScreen = true
view.SetContent(m.ta.View())
// ── THE OTHER LINE. Position the REAL cursor for IME. ──
if cursor := m.ta.Cursor(); cursor != nil {
view.Cursor = cursor
}
return view
}
func main() {
if _, err := tea.NewProgram(initial(), tea.WithAltScreen()).Run(); err != nil {
log.Fatal(err)
}
fmt.Println("bye")
}
The two lines that matter:
ta.SetVirtualCursor(false) — disables the virtual █.view.Cursor = cursor (where cursor = m.ta.Cursor()) — exports the real cursor position to the framework.Without both, IME breaks.
len()Korean, Japanese, Chinese characters render as two terminal cells (wide characters per Unicode East Asian Width). Naive len(string) returns byte count, not display width. utf8.RuneCountInString returns rune count, also not display width.
Use github.com/mattn/go-runewidth:
import "github.com/mattn/go-runewidth"
func displayWidth(s string) int {
return runewidth.StringWidth(s)
}
// Wide character occupies two cells; pad accordingly
for _, r := range s {
cell := string(r)
w := runewidth.RuneWidth(r)
canvas = append(canvas, cell)
if w == 2 {
canvas = append(canvas, "") // placeholder for second cell
}
}
lipgloss/v2 uses go-runewidth internally — lipgloss.Width("\u4e2d\u6587") returns 4, not 2. If you measure outside lipgloss, you must call runewidth directly.
case tea.MouseClickMsg:
// msg.X, msg.Y, msg.Button
return m.handleClick(msg.X, msg.Y, msg.Button)
case tea.MouseMotionMsg:
return m.handleHover(msg.X, msg.Y)
case tea.MouseReleaseMsg:
return m.handleRelease(msg.X, msg.Y)
Enable mouse via the View:
view.MouseMode = tea.MouseModeCellMotion // or MouseModeAll
CellMotion reports clicks + motion-while-button-pressed (drag). MouseModeAll reports motion always — heavier, only when you need hover.
bubbles/v2import (
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/viewport"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/table"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
)
All v2 components support SetVirtualCursor(false) where they accept text input. Use it for every text input that users might type CJK into — and "might" should be assumed yes.
lipgloss/v2import "charm.land/lipgloss/v2"
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("230")).
Background(lipgloss.Color("62")).
Padding(0, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63"))
rendered := titleStyle.Render("\u4e2d\u6587")
lipgloss/v2 width and padding correctly account for CJK display width. v1 did too — this is not a v2-specific fix, just a reminder.
+--------------------------------------------+
| tea.Program runs the event loop |
| |
| loop: |
| msg <- queue |
| model, cmd = model.Update(msg) |
| view = model.View() |
| render(view) |
| if cmd != nil: go run(cmd) -> queue |
+--------------------------------------------+
Rules:
Update with a value receiver and expects a new value returned. Pointer receivers cause subtle bugs where state mutation leaks across draws.Update is pure. No I/O. No goroutines started inline. Any I/O returns a tea.Cmd — Bubbletea runs it in a goroutine and feeds the result back as a message.View is read-only. It returns a tea.View without modifying state.tea.Cmd is func() tea.Msg. It runs once, returns a message, exits. For repeating work, use tea.Tick or a self-resending command.// One-shot command
func loadData() tea.Cmd {
return func() tea.Msg {
data, err := fetch()
if err != nil { return errMsg{err} }
return dataLoadedMsg{data}
}
}
// Periodic
func tickEvery() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg{t}
})
}
type model struct {
list list.Model
input textinput.Model
spinner spinner.Model
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
cmds = append(cmds, cmd)
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
tea.Batch runs commands concurrently. The framework collects their results in the order they arrive.
When the model exceeds 250 LOC, split by sub-model into separate files:
internal/ui/
├── model.go # root model orchestration
├── list.go # list sub-model state + update + view
├── input.go # input sub-model
└── spinner.go # spinner sub-model
teatestimport "charm.land/bubbletea/v2/teatest"
func TestModel_typing_cjk_keeps_cursor_in_position(t *testing.T) {
// Given
m := initial()
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
// When — simulate typing two CJK wide characters
tm.Send(tea.KeyPressMsg{Code: '\u4e2d'})
tm.Send(tea.KeyPressMsg{Code: '\u6587'})
// Then
out := tm.FinalOutput(t)
require.Contains(t, string(out), "\u4e2d\u6587")
// Cursor should be at column 4 (two wide chars = 4 cells)
// ...
}
teatest lets you drive the model through synthetic messages and inspect the rendered output. Pair with autogold snapshots for full-view regression tests.
| Bad | Why | Good |
|---|---|---|
tea.Program with tea.WithoutSignals() | Ctrl-C does not work | Default signal handling |
| Pointer receivers on Model | Bubbletea expects value semantics | Value receivers, return new model |
time.Sleep inside Update | Blocks the event loop | tea.Tick or async tea.Cmd |
fmt.Println for debug | Corrupts the rendered output | tea.Printf for logging, or write to a file |
len(s) for CJK width | Off by 2x | runewidth.StringWidth(s) |
Bubbletea v1 for an app with text input | Korean/Japanese IME breaks | v2 + SetVirtualCursor(false) |
Drawing your own █ block cursor in v2 | Conflicts with view.Cursor | Let the terminal handle it |
viewport.Model for scrollable content. Avoid re-rendering thousands of lines on every keystroke.Batch your commands. A series of synchronous tea.Cmd returns serializes; tea.Batch parallelizes.tea.WithFPS(N) to cap repaint rate during development.huh (also from Charm) — simpler, no Model–Update–View ceremony.slog to stderr and tea.Program only if interactivity becomes necessary.tea.Program requires a tty for input. Detect via term.IsTerminal(int(os.Stdin.Fd())) and fall back to a non-interactive path.