11 KiB
Bubbletea v2 — TUI with First-Class CJK / IME Support
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.
Why v2 (not v1) — the IME story
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}— yourView()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.
Other v2 wins (incidental)
tea.MouseClickMsg/MouseMotionMsg/MouseReleaseMsginstead of one coarseMouseMsg.- Cleaner
Viewstruct withAltScreen,MouseModefields instead oftea.Cmdsetters. - Pluggable rendering pipeline; better performance under high message volume.
go.mod
module 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.
Minimal app — the IME-correct skeleton
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(wherecursor = m.ta.Cursor()) — exports the real cursor position to the framework.
Without both, IME breaks.
CJK width — go-runewidth, not 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.
Mouse — v2 has typed events
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.
Components from bubbles/v2
import (
"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.
Styling — lipgloss/v2
import "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.
Architecture pattern — Model–Update–View
+--------------------------------------------+
| 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:
- Model is a value type, not a pointer. Bubbletea calls
Updatewith a value receiver and expects a new value returned. Pointer receivers cause subtle bugs where state mutation leaks across draws. Updateis pure. No I/O. No goroutines started inline. Any I/O returns atea.Cmd— Bubbletea runs it in a goroutine and feeds the result back as a message.Viewis read-only. It returns atea.Viewwithout modifying state.tea.Cmdisfunc() tea.Msg. It runs once, returns a message, exits. For repeating work, usetea.Tickor 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}
})
}
Splitting the model — sub-models
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
Testing TUI code — teatest
import "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.
Common antipatterns
| 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 |
Performance — when v2 starts to crawl
- Reduce View frequency. If the model changes 60 times/sec but the rendered view changes once/sec, gate redraws on a "dirty" flag.
viewport.Modelfor scrollable content. Avoid re-rendering thousands of lines on every keystroke.Batchyour commands. A series of synchronoustea.Cmdreturns serializes;tea.Batchparallelizes.- Profile with
tea.WithFPS(N)to cap repaint rate during development.
When NOT to use Bubbletea
- The app is one prompt + one answer. Use
huh(also from Charm) — simpler, no Model–Update–View ceremony. - The app is a long-running daemon with occasional status output. Use
slogto stderr andtea.Programonly if interactivity becomes necessary. - The app must run as a non-tty subprocess (CI, redirected stdin).
tea.Programrequires a tty for input. Detect viaterm.IsTerminal(int(os.Stdin.Fd()))and fall back to a non-interactive path.
Sources
- bubbletea v2 RC: https://github.com/charmbracelet/bubbletea/tree/v2
- bubbles v2: https://github.com/charmbracelet/bubbles/tree/v2
- lipgloss v2: https://github.com/charmbracelet/lipgloss/tree/v2
- bubbletea-wm (IME reference): https://github.com/code-yeongyu/bubbletea-wm
- crush CLI (production IME impl): https://github.com/charmbracelet/crush
- go-runewidth: https://github.com/mattn/go-runewidth
- Unicode East Asian Width: https://www.unicode.org/reports/tr11/