# 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`](https://github.com/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: 1. **`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. 2. **`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` / `MouseReleaseMsg` instead of one coarse `MouseMsg`. - Cleaner `View` struct with `AltScreen`, `MouseMode` fields instead of `tea.Cmd` setters. - Pluggable rendering pipeline; better performance under high message volume. --- ## `go.mod` ```go 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 ```go 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: 1. `ta.SetVirtualCursor(false)` — disables the virtual `█`. 2. `view.Cursor = cursor` (where `cursor = 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`: ```go 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 ```go 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`: ```go 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` ```go 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` ```go 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 `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. ```go // 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 ```go 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` ```go 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.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. - **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 `slog` to stderr and `tea.Program` only if interactivity becomes necessary. - The app must run as a non-tty subprocess (CI, redirected stdin). `tea.Program` requires a tty for input. Detect via `term.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/