Back to Oh My Openagent

Bubbletea v2 — TUI with First-Class CJK / IME Support

packages/omo-codex/plugin/skills/programming/references/go/bubbletea-v2.md

4.5.1211.3 KB
Original Source

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:

  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

BadWhyGood
tea.Program with tea.WithoutSignals()Ctrl-C does not workDefault signal handling
Pointer receivers on ModelBubbletea expects value semanticsValue receivers, return new model
time.Sleep inside UpdateBlocks the event looptea.Tick or async tea.Cmd
fmt.Println for debugCorrupts the rendered outputtea.Printf for logging, or write to a file
len(s) for CJK widthOff by 2xrunewidth.StringWidth(s)
Bubbletea v1 for an app with text inputKorean/Japanese IME breaksv2 + SetVirtualCursor(false)
Drawing your own block cursor in v2Conflicts with view.CursorLet 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