Go TUIs with Bubble Tea
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Bubble Tea is a Go framework for terminal user interfaces based on The Elm Architecture. A program is a model (a value
that holds all the state), an Update function that receives messages (key presses, timer ticks, results of I/O) and
returns the next model plus an optional command, and a View function that renders the model. The runtime owns the
terminal: it switches it to raw mode, decodes the input, runs the commands, draws the frames and restores everything when
the program exits. This page builds the Bookshelf shelf tui (a catalogue list, a details pane, a loans table and a
search box) with Bubble Tea v2 and the libraries around it.
The page was written against Bubble Tea v2.1.0 (published 2026-10-08, the charm.land/bubbletea/v2 module, package
name tea), Bubbles v2.2.1, Lip Gloss v2.0.6, Huh v2.0.3, Glamour v2.0.1 and VHS v0.12.1, with
Go 1.27.3 (the Bubble Tea module declares go 1.26.0, Glamour declares go 1.26.7). The Go snippets on this page were
not compiled or run while it was written, because no Go toolchain was available; they were checked against the
published package documentation and, where the documentation is silent, against the library sources at the tags above,
and every example is followed by the official page it derives from. Treat them as starting points to build with
go build, not as verified binaries.
The concepts that apply to every TUI library (the input, update and render loop, raw mode, the cell buffer and layout) are
on TUI Architecture & Concepts, and the escape sequences behind
the features described here (alternate screen, SGR mouse reporting, bracketed paste) are on
Escape Sequences & Terminal Capabilities. When a TUI is
appropriate in the first place, and why a TUI must never be the only interface, is on
TUIs vs. CLIs. The Go side of command-line programs (flags, subcommands,
signals, golang.org/x/term) is on Command-Line Programs in Go, and the
language itself in the Go section. A short Bubble Tea introduction sits on
Go & Rust CLI Frameworks; this page goes much further.
The Charm modules and their import paths
Bubble Tea is one library of a family published by Charm. Since the v2 releases the family lives under the vanity
domain charm.land, and the import paths changed with it.
| Library | v2 import path | Version | Role |
|---|---|---|---|
Bubble Tea |
|
v2.1.0 |
The runtime: model loop, messages, commands, input decoding, renderer |
Bubbles |
|
v2.2.1 |
Ready-made components: list, table, textinput, textarea, viewport, spinner, progress, paginator, help, key, filepicker, timer, stopwatch, cursor, tree |
Lip Gloss |
|
v2.0.6 |
Styles, colours, borders, layout (joining, placing), colour downsampling when printing |
Huh |
|
v2.0.3 |
Forms and prompts; a |
Glamour |
|
v2.0.1 |
Markdown rendering for ANSI terminals |
teatest |
|
untagged (a pseudo-version dated 2026-10-25 when this page was written) |
Test helpers for |
VHS |
command-line tool, |
v0.12.1 |
Scripts a terminal session (a "tape") and records it as a GIF, video or text |
The old paths (github.com/charmbracelet/bubbletea, github.com/charmbracelet/lipgloss, github.com/charmbracelet/bubbles)
are the v1 modules. A program must not mix the two generations: a v1 Bubbles component does not implement the v2
tea.Model interface, and the Lip Gloss Color type changed shape (see the Lip Gloss section). The teatest module keeps
the github.com/charmbracelet/x/exp/teatest/v2 path and has no tagged release, so go get resolves it to a pseudo-version.
A go.mod for the programs on this page:
module example.com/shelf-tui
go 1.27
require (
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.1.0
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.6
charm.land/glamour/v2 v2.0.1
)
In practice go mod tidy writes the require block (and the indirect dependencies) for you; add the teatest module
with go get github.com/charmbracelet/x/exp/teatest/v2@latest when you write the tests.
Source: Bubble Tea v2 package documentation, Bubble Tea UPGRADE_GUIDE_V2.md (import paths) and Bubbles v2 package documentation.
The Elm Architecture in Bubble Tea
Bubble Tea’s model interface has three methods.
type Model interface {
Init() Cmd
Update(Msg) (Model, Cmd)
View() View
}
| Concept | Meaning in Bubble Tea v2 |
|---|---|
|
Any value with the three methods above. It holds the whole state of the program. The usual choice is a struct with value receivers, so every |
|
Called once at start-up. It returns the first command (an initial load, a timer) or |
|
The only place where state changes. It receives one message, returns the next model and optionally a command |
|
A pure function from model to a |
|
Any value. Bubble Tea sends key, mouse, paste, focus and window-size messages; your own types carry the results of your commands |
|
A function with the signature |
The flow of one interaction, from a key press to a redrawn screen, with a command that does I/O in between:
The renderer redraws at a limited frame rate (the WithFPS program option, with a default and a maximum), so the runtime may
call View less often than it calls Update. View must therefore have no side effects and Update must not wait for the
screen.
A first program
The smallest useful program for the Bookshelf scenario lets you tick books to take on loan. It is adapted from the grocery-list program in the official tutorial, using the v2 API.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
)
type model struct {
books []string
cursor int
selected map[int]struct{}
}
func initialModel() model {
return model{
books: []string{
"The Left Hand of Darkness",
"A Canticle for Leibowitz",
"The Dispossessed",
},
selected: make(map[int]struct{}),
}
}
func (m model) Init() tea.Cmd {
return nil // no I/O needed at start-up
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.books)-1 {
m.cursor++
}
case "enter", "space":
if _, ok := m.selected[m.cursor]; ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}
func (m model) View() tea.View {
s := "Which books do you want to borrow?\n\n"
for i, b := range m.books {
cursor := " "
if m.cursor == i {
cursor = ">"
}
checked := " "
if _, ok := m.selected[i]; ok {
checked = "x"
}
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, b)
}
s += "\nPress q to quit.\n"
return tea.NewView(s)
}
func main() {
if _, err := tea.NewProgram(initialModel()).Run(); err != nil {
fmt.Fprintln(os.Stderr, "shelf:", err)
os.Exit(1)
}
}
Three details of the v2 API are visible here. View returns tea.NewView(s) instead of a string. Key messages are
tea.KeyPressMsg (not tea.KeyMsg, which is now an interface that covers presses and releases). And the space bar is
named space in msg.String(), where v1 returned a literal space. The map in the model is shared between copies of the
model, which is why the toggle works without returning a new map; a slice or a struct field would need to be copied
explicitly.
Messages and commands
A command is how a program does anything that is not a pure state change: reading a file, calling shelfd, waiting for a
timer. The runtime executes commands in goroutines (the source of handleCommands says so) and sends the returned message
back to Update. A function that returns a message after it did its work is therefore all a command is:
type loadedMsg []book
type failedMsg struct{ err error }
func loadBooks(path string) tea.Cmd {
return func() tea.Msg {
data, err := os.ReadFile(path)
if err != nil {
return failedMsg{err}
}
var books []book
if err := json.Unmarshal(data, &books); err != nil {
return failedMsg{err}
}
return loadedMsg(books)
}
}
Errors travel as ordinary messages: the command returns a message type that carries the error, and Update decides what to
show. The package documentation notes that a command should not be used to send a message to another part of your program;
that can almost always be done directly in Update.
Commands the package provides
| Function | Behaviour |
|---|---|
|
Runs the commands concurrently, with no ordering guarantee about the results |
|
Runs the commands one at a time, in order (the v1 name was |
|
Sends one message after |
|
Like |
|
Special commands (their functions return a |
|
Print a line above the program; the output is not managed by the renderer and persists across redraws. Nothing is printed while the alternate screen is active |
|
Pause the program, run an interactive command (an editor or a shell) on the terminal, then resume and send the message built by |
|
Ask the terminal for its size or background colour; the answers arrive as |
Batch and Sequence both drop nil commands, and when exactly one command is left they return it directly, so it is safe to
build a list with conditional entries. A typical Init starts a spinner and a load in parallel, and a later step chains work
that must happen in order:
func (m model) Init() tea.Cmd {
return tea.Batch(m.spin.Tick, loadBooks(m.path))
}
// after the user confirms a loan: write it, and only then reload the list
func confirmLoan(isbn, who string) tea.Cmd {
return tea.Sequence(saveLoan(isbn, who), loadBooks("books.json"))
}
(m.spin.Tick is a method value of type func() tea.Msg, which is assignable to tea.Cmd; this is how the Bubbles
spinner is started.) The sequence delivers the result of saveLoan before loadBooks runs.
Editing a note in $EDITOR
tea.ExecProcess is the documented way to hand the terminal to another interactive program. The program below the official
example opens the user’s editor on the notes file of a book and reloads when it exits.
type editorFinishedMsg struct{ err error }
func editNotes(path string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vim" // on Windows set EDITOR; there is no universal default
}
c := exec.Command(editor, path)
return tea.ExecProcess(c, func(err error) tea.Msg {
return editorFinishedMsg{err}
})
}
Source: Bubble Tea example — exec, example — sequence and the Cmd documentation.
Messages sent from outside the program
(*tea.Program).Send(msg) delivers a message from any goroutine, which is how a background listener (a watcher on the
shelfd socket, for example) feeds the model. The WithFilter program option installs a function that sees every message
before Update and may replace or swallow it (the package documentation gives it the signature
func(Model, Msg) Msg); the repository’s prevent-quit example uses it to intercept the quit. What the official pages do not
provide is a recipe for combining Send with an application that has several goroutines; keep the model itself on the
Update path and send only messages.
Input messages
Bubble Tea v2 decodes the terminal input into typed messages. The encoding of the bytes themselves is on Escape Sequences & Terminal Capabilities.
| Message | Notes |
|---|---|
|
A key press. |
|
A key release. It is only delivered when the terminal reports key event types (the Kitty keyboard protocol); a |
|
An interface satisfied by both of the above, with |
|
Bracketed paste. The pasted text is |
|
Mouse events, each with |
|
|
|
Terminal window focus gained or lost, delivered when the view sets |
|
The answer to |
The tea.Key struct has the fields Text (the printable text, empty for special keys), Mod (a tea.KeyMod bit set), Code
(a rune such as tea.KeyEnter or 'a'), ShiftedCode, BaseCode (the key on a US PC-101 layout) and IsRepeat. The
upgrade guide maps the v1 idioms to these fields:
| v1 | v2 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Because the terminal is in raw mode while the program runs, Ctrl+C is delivered as a tea.KeyPressMsg, and the official
tutorial handles ctrl+c explicitly; a program that ignores it has to be stopped another way. Interrupts that do
arrive as signals end the run with tea.ErrInterrupted. Signal handling can be switched off with the WithoutSignalHandler and WithoutSignals options, and the
general rules for signals are on POSIX Processes, Signals & I/O.
The declarative View: tea.View
In v1, terminal features were program options (WithAltScreen, WithMouseCellMotion) or commands (tea.EnterAltScreen,
tea.SetWindowTitle). In v2 they are fields on the value that View returns, so the model describes the terminal state it
wants on every frame and the runtime makes it so. A frame that stops asking for the alternate screen leaves it.
tea.View field |
Effect |
|---|---|
|
The frame text, with ANSI styling. Set with |
|
Run on the alternate screen buffer (full-screen mode). Replaces |
|
|
|
A mouse callback on the view |
|
Deliver |
|
Bracketed paste is on by default; set this to turn it off |
|
The terminal window title (replaces |
|
Show a real cursor: |
|
|
|
The terminal’s own progress indicator ( |
|
A status the program reports to the terminal; the package documentation lists the field without a longer description |
|
Opt in to richer keyboard reporting such as key-release events |
A view that asks for the full-screen mode, the mouse and a title looks like this:
func (m model) View() tea.View {
v := tea.NewView(m.render())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.WindowTitle = "shelf tui"
return v
}
Inline programs and full-screen programs
A program without AltScreen draws inside the normal scrollback, below the prompt. When it exits, the last frame stays in
the terminal history. That is the right choice for a short prompt such as a book picker or a confirmation, and it keeps the
terminal output usable in a log. A full-screen program uses the alternate screen: the previous contents of the terminal are
restored on exit and the program owns every cell, which suits a browser such as shelf tui.
| Aspect | Inline (no AltScreen) |
Full screen (AltScreen = true) |
|---|---|---|
After the program exits |
The last frame remains in the scrollback |
The terminal returns to what it showed before |
Output from the program ( |
Printed above the live frame and kept |
Nothing is printed while the alternate screen is active |
Typical |
|
|
Resize |
The frame is redrawn at the new width |
The whole screen is redrawn; the layout has to follow |
An inline picker that must work in $(shelf pick) writes the TUI to a different stream than the result. Bubble Tea’s
WithOutput option accepts any io.Writer, and the final model is returned from Run:
func main() {
p := tea.NewProgram(newPicker(), tea.WithOutput(os.Stderr))
final, err := p.Run()
if err != nil {
fmt.Fprintln(os.Stderr, "shelf:", err)
os.Exit(1)
}
if m, ok := final.(picker); ok && m.choice != "" {
fmt.Println(m.choice) // only the ISBN reaches stdout
}
}
Drawing on standard error so that standard output carries only data follows the stream rules on
Streams & Exit Codes. The official documentation lists WithOutput and
WithInput but does not describe this particular combination, so test it in your target shells (it matters in particular
that standard input is still the terminal).
Source: UPGRADE_GUIDE_V2.md — Declarative Views,
Bubble Tea v2 package documentation (View, Println, WithOutput).
Running the program
tea.NewProgram(model, opts...) returns a *tea.Program. Run() blocks until the program quits and returns the final model
and an error. It replaces Start and StartReturningModel from v1.
| Option or method | Purpose |
|---|---|
|
Replace standard input and output. Needed for tests, for sending the UI to standard error and for |
|
Cancel the program from outside; |
|
The renderer’s frame rate (it has a default and a maximum) |
|
Fix the initial size, the colour profile or the environment. Useful in tests and under |
|
See each message before |
|
Disable the renderer; output goes plainly to standard output. For running the same model non-interactively (logs, daemons) |
|
Do not install the program’s signal handling |
|
Let panics propagate instead of being recovered and reported as |
|
Control the running program from other goroutines |
The errors tea.ErrInterrupted (the program received an interrupt), tea.ErrProgramKilled and tea.ErrProgramPanic are
declared in the package, and Run wraps them, so errors.Is distinguishes them. The runtime restores the terminal when the
program exits normally or recovers from a panic; it cannot do so if the process is killed with SIGKILL, and a
terminal left in raw mode needs reset or stty sane. The details of that restore are in
Terminals, TTYs & Pseudo-Terminals.
Do not start a TUI when there is no terminal
Bubble Tea assumes a terminal. The README and the package documentation do not describe what a program does when standard
input is a pipe or standard output is a file, so a command that offers both a TUI and a script-friendly mode should decide
before it creates the program, with golang.org/x/term:
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return runPlain(args) // print the same data as text or JSON, no TUI
}
return runTUI(args)
This is the same rule as in TTY, Colour & Interactivity, and
shelf list stays a plain command either way.
Logging
The TUI owns standard output, so fmt.Println debugging corrupts the display. The README’s advice is to log to a file with
tea.LogToFile("debug.log", "debug") when an environment variable such as DEBUG is set, and to follow it with
tail -f debug.log. It also documents debugging with Delve in headless mode
(dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 .) and connecting with dlv connect.
Source: Bubble Tea v2 package documentation (Program, ProgramOption,
Run) and README — Debugging.
Platform behaviour
The official README and package documentation do not contain a platform support table, so the following is limited to what they and the library source state.
| Topic | Linux | macOS | Windows |
|---|---|---|---|
Terminal setup |
The source has Unix terminal files ( |
Same Unix files; |
Separate files ( |
Resize notification |
The program listens for |
Same |
The source states that |
Ctrl+C |
A key press in raw mode, handled by the program |
Same |
The same message type; confirm the behaviour in your host (Windows Terminal, |
Colour and key features |
Depend on the terminal emulator; the runtime downsamples colours to the detected profile ( |
Same |
Depend on the console host; modern Windows Terminal supports the sequences, legacy hosts may not |
Terminal capabilities (the Kitty keyboard protocol for key releases, synchronized output, hyperlinks) vary more between emulators than between operating systems; the table on Cross-Platform Portability is the place to start.
Bubbles: components for the common widgets
Bubbles components are sub-models: each has Update(msg) (Model, tea.Cmd) and View() string. A parent model stores them
as fields, forwards the messages it wants them to see, keeps the returned model and collects the returned command. The View
of a component returns a plain string in v2; the parent embeds it in its own tea.View.
| Package | Component |
|---|---|
|
A feature-rich list with titles and descriptions, optional filtering, pagination, a status bar, help and spinner. Items implement |
|
A table with |
|
A single-line field with placeholder, prompt, character limit, echo mode (for passwords), validation and suggestions |
|
A multi-line field |
|
A vertically scrollable area, with optional soft wrapping, horizontal scrolling, mouse wheel support and highlights |
|
An animated indicator with presets such as |
|
A progress bar with optional animation and a colour gradient |
|
Pagination logic with an optional dots or numeric indicator |
|
|
|
A directory browser, a countdown, a count-up timer, the virtual cursor and a tree view |
Component by component, the API that the shelf tui uses:
// list: items, delegate, initial width and height
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.Title = "Catalogue"
l.SetFilteringEnabled(false) // the program has its own search box
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetItems(items) // []list.Item
sel, ok := l.SelectedItem().(book)
// table: columns and rows, focus decides whether it reacts to keys
t := table.New(
table.WithColumns([]table.Column{{Title: "Title", Width: 26}, {Title: "Due", Width: 12}}),
table.WithRows([]table.Row{{"Dune", "2026-11-02"}}),
table.WithHeight(6),
)
t.Focus() // t.Blur() stops it reacting to key presses
// textinput: Focus returns a tea.Cmd (it starts the cursor blink)
ti := textinput.New()
ti.Placeholder = "title or author"
cmd := ti.Focus()
// viewport: width and height are options, content is a string
vp := viewport.New(viewport.WithWidth(40), viewport.WithHeight(10))
vp.SoftWrap = true
vp.SetContent(longText)
// spinner and progress
sp := spinner.New(spinner.WithSpinner(spinner.Dot))
pb := progress.New(progress.WithWidth(30))
cmd = pb.SetPercent(0.4) // returns a tea.Cmd that animates to the new value
Differences from v1 that matter when porting or reading older tutorials: viewport width and height are
options and methods (SetWidth, SetHeight), and textinput width is a method pair (SetWidth, Width), not fields; textinput prompt styling lives in Styles().Focused.Prompt and
Styles().Blurred.Prompt; styles are chosen with an isDark flag (textinput.DefaultStyles(isDark),
list.DefaultStyles(isDark)); table.Update does nothing while the table is blurred; progress.SetPercent returns a
command because the bar animates. In the v2.2.1 sources the list’s constructor sets its styles with the dark palette
hard-coded (the source comment calls it temporary), so on a light terminal pass your own styles built from
tea.BackgroundColorMsg.
Source: Bubbles v2 package index and the package pages for list, table, textinput, viewport, spinner, progress and paginator.
A paginator pairs with a long slice that the program renders itself:
p := paginator.New(paginator.WithPerPage(10))
p.SetTotalPages(len(books))
start, end := p.GetSliceBounds(len(books))
visible := books[start:end]
// forward key presses: p, cmd = p.Update(msg)
Source: paginator package documentation and the paginator example.
Lip Gloss: styling and layout
Lip Gloss describes styles as values. lipgloss.NewStyle() returns a Style, and each chained method returns a new style, so
a base style can be copied and specialised safely. Render produces the string with ANSI sequences.
var (
box = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
activeBox = box.BorderForeground(lipgloss.Color("63"))
title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7D56F4"))
)
left := box.Padding(0, 1).Render(title.Render("Catalogue") + "\n" + body)
row := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
screen := lipgloss.JoinVertical(lipgloss.Left, header, row, footer)
| Area | API |
|---|---|
Text attributes |
|
Box model |
|
Layout |
|
Colours |
|
Compositing |
|
Sub-packages |
|
What changed in v2: Color is now a function returning image/color.Color (it was a string type); TerminalColor, the
Renderer type and AdaptiveColor in the root package are gone; and Render always emits full-fidelity ANSI. Colours are
downsampled where the text is written. Inside Bubble Tea v2 that is handled for you; for a plain shelf list that prints
styled text, use the Lip Gloss writers:
lipgloss.Println(title.Render("Overdue loans")) // stdout, downsampled to the terminal's profile
lipgloss.Fprintln(os.Stderr, warn.Render("shelfd offline")) // stderr
To choose between light and dark palettes in a Bubble Tea program, request the terminal’s background colour in Init and
build the styles when the answer arrives:
func (m model) Init() tea.Cmd { return tea.RequestBackgroundColor }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.BackgroundColorMsg:
m.styles = newStyles(msg.IsDark())
}
return m, nil
}
func newStyles(isDark bool) styles {
pick := lipgloss.LightDark(isDark)
return styles{
title: lipgloss.NewStyle().Foreground(pick(lipgloss.Color("#333333"), lipgloss.Color("#f1f1f1"))),
}
}
A compat sub-package (charm.land/lipgloss/v2/compat) offers v1-style adaptive colours for a quick port. Two details of the
official documentation matter when measuring layouts. The upgrade guide does not say whether Style.Width and Height count
the border, so the shelf tui below sizes the components first and draws the borders around them without setting a width.
And wide characters (CJK text, emoji) occupy two cells: use lipgloss.Width rather than len or utf8.RuneCountInString
to align columns, as explained on
Escape Sequences & Terminal Capabilities.
The complete shelf tui
The program below combines everything so far: a spinner while the catalogue loads from a JSON file in a command, a
Bubbles list for the catalogue, a viewport for the details of the selected book, a table of loans, a text input as the
search box, mouse-wheel scrolling of the details, Lip Gloss borders around the panes, and the terminal state declared in
View. Keys: / focuses the search box (enter or esc leaves it), tab moves between the catalogue and the loans table,
pgup and pgdown scroll the details, q or ctrl+c quits.
First the data, in books.json:
[
{"title": "Dune", "author": "Frank Herbert", "isbn": "9780441013593",
"notes": "A desert planet, a spice, and a long political game.", "loan": "ana", "due": "2026-11-02"},
{"title": "The Left Hand of Darkness", "author": "Ursula K. Le Guin", "isbn": "9780441478125",
"notes": "On Gethen the people have no fixed sex; an envoy has to learn what that means for politics."},
{"title": "A Canticle for Leibowitz", "author": "Walter M. Miller Jr.", "isbn": "9780060892999",
"notes": "Monks preserve fragments of a lost technological age.", "loan": "ben", "due": "2026-11-09"},
{"title": "The Dispossessed", "author": "Ursula K. Le Guin", "isbn": "9780061054884",
"notes": "Two worlds, one anarchist and one capitalist, and a physicist who belongs to neither."}
]
Then main.go:
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"unicode"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/table"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// ---- data -----------------------------------------------------------------
type book struct {
Name string `json:"title"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Notes string `json:"notes"`
Loan string `json:"loan,omitempty"`
Due string `json:"due,omitempty"`
}
// clean drops control characters, so a hostile title cannot inject escape sequences.
func clean(s string) string {
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) && r != '\n' {
return -1
}
return r
}, s)
}
// book implements list.DefaultItem.
func (b book) Title() string { return clean(b.Name) }
func (b book) Description() string { return clean(b.Author) + " - " + b.ISBN }
func (b book) FilterValue() string { return b.Name + " " + b.Author }
// ---- messages and commands --------------------------------------------------
type loadedMsg []book
type failedMsg struct{ err error }
func loadBooks(path string) tea.Cmd {
return func() tea.Msg {
data, err := os.ReadFile(path)
if err != nil {
return failedMsg{err}
}
var books []book
if err := json.Unmarshal(data, &books); err != nil {
return failedMsg{err}
}
return loadedMsg(books)
}
}
// ---- model ------------------------------------------------------------------
type pane int
const (
paneCatalogue pane = iota
paneSearch
paneLoans
)
type model struct {
path string
all []book
list list.Model
search textinput.Model
loans table.Model
detail viewport.Model
spin spinner.Model
focus pane
loading bool
err error
width, height int
}
var (
box = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240"))
activeBox = box.BorderForeground(lipgloss.Color("63"))
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
)
func newModel(path string) model {
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.Title = "Catalogue"
l.SetFilteringEnabled(false)
l.SetShowHelp(false)
l.SetShowStatusBar(false)
ti := textinput.New()
ti.Prompt = "/ "
ti.Placeholder = "search title or author"
cols := []table.Column{{Title: "Title", Width: 26}, {Title: "Borrower", Width: 14}, {Title: "Due", Width: 12}}
t := table.New(table.WithColumns(cols), table.WithHeight(6))
vp := viewport.New(viewport.WithWidth(30), viewport.WithHeight(10))
vp.SoftWrap = true
return model{
path: path,
list: l,
search: ti,
loans: t,
detail: vp,
spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
loading: true,
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(m.spin.Tick, loadBooks(m.path))
}
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
m.layout()
return m, nil
case spinner.TickMsg:
if !m.loading {
return m, nil // stop the animation by not returning another tick
}
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
case loadedMsg:
m.loading = false
m.all = []book(msg)
m.applyFilter()
return m, nil
case failedMsg:
m.loading = false
m.err = msg.err
return m, nil
case tea.MouseWheelMsg:
var cmd tea.Cmd
m.detail, cmd = m.detail.Update(msg)
return m, cmd
case tea.KeyPressMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
key := msg.String()
if key == "ctrl+c" {
return m, tea.Quit
}
// While the search box has focus, it gets every key except the two that leave it.
if m.focus == paneSearch {
if key == "enter" || key == "esc" {
m.focus = paneCatalogue
m.search.Blur()
return m, nil
}
var cmd tea.Cmd
m.search, cmd = m.search.Update(msg)
m.applyFilter()
return m, cmd
}
switch key {
case "q":
return m, tea.Quit
case "/":
m.focus = paneSearch
return m, m.search.Focus()
case "tab":
if m.focus == paneCatalogue {
m.focus = paneLoans
m.loans.Focus()
} else {
m.focus = paneCatalogue
m.loans.Blur()
}
return m, nil
case "pgdown":
m.detail.ScrollDown(5)
return m, nil
case "pgup":
m.detail.ScrollUp(5)
return m, nil
}
var cmd tea.Cmd
if m.focus == paneLoans {
m.loans, cmd = m.loans.Update(msg)
} else {
m.list, cmd = m.list.Update(msg)
m.showSelected()
}
return m, cmd
}
// applyFilter rebuilds the list and the loans table from m.all and the search text.
func (m *model) applyFilter() {
q := strings.ToLower(strings.TrimSpace(m.search.Value()))
var items []list.Item
var rows []table.Row
for _, b := range m.all {
if q == "" || strings.Contains(strings.ToLower(b.FilterValue()), q) {
items = append(items, b)
}
if b.Loan != "" {
rows = append(rows, table.Row{clean(b.Name), clean(b.Loan), b.Due})
}
}
m.list.SetItems(items)
m.loans.SetRows(rows)
m.showSelected()
}
func (m *model) showSelected() {
b, ok := m.list.SelectedItem().(book)
if !ok {
m.detail.SetContent("No book selected.")
return
}
var sb strings.Builder
fmt.Fprintf(&sb, "%s\n%s\nISBN %s\n\n%s\n", clean(b.Name), clean(b.Author), b.ISBN, clean(b.Notes))
if b.Loan != "" {
fmt.Fprintf(&sb, "\nOn loan to %s until %s\n", clean(b.Loan), b.Due)
}
m.detail.SetContent(sb.String())
m.detail.GotoTop()
}
// layout sizes the components. The panes get a one-cell border on each side.
func (m *model) layout() {
if m.width < 40 || m.height < 16 {
return
}
leftW := m.width / 2
rightW := m.width - leftW
midH := m.height - 10 // search line + loans pane (8) + help line
m.list.SetSize(leftW-2, midH-2)
m.detail.SetWidth(rightW - 4) // border (2) and padding (2)
m.detail.SetHeight(midH - 2)
m.loans.SetWidth(m.width - 2)
m.loans.SetHeight(6)
m.search.SetWidth(m.width - 4)
}
func (m model) View() tea.View {
var body string
switch {
case m.err != nil:
body = "error: " + clean(m.err.Error()) + "\n"
case m.loading:
body = m.spin.View() + " loading " + m.path + "\n"
case m.width < 40 || m.height < 16:
body = "terminal too small (needs 40x16)\n"
default:
body = m.render()
}
v := tea.NewView(body)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.WindowTitle = "shelf tui"
return v
}
func (m model) render() string {
cat, loans := box, box
switch m.focus {
case paneCatalogue:
cat = activeBox
case paneLoans:
loans = activeBox
}
left := cat.Render(m.list.View())
right := box.Padding(0, 1).Render(m.detail.View())
middle := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
bottom := loans.Render(m.loans.View())
help := helpStyle.Render("/ search tab loans pgup/pgdn scroll q quit")
return lipgloss.JoinVertical(lipgloss.Left, m.search.View(), middle, bottom, help)
}
func main() {
path := "books.json"
if len(os.Args) > 1 {
path = os.Args[1]
}
if _, err := tea.NewProgram(newModel(path)).Run(); err != nil {
fmt.Fprintln(os.Stderr, "shelf:", err)
os.Exit(1)
}
}
Run it with go run . books.json. Points worth noting in the program:
-
One owner of state. Every change to
mhappens inUpdate(and the helpers it calls). The load happens in a command and reaches the model asloadedMsg. -
Sub-models are values.
m.list, cmd = m.list.Update(msg)stores the new component; forgetting the assignment silently discards its state, and forgetting to returncmddrops its blink, tick or animation messages. -
The spinner stops by omission. A spinner tick keeps the animation alive only if
Updatereturns the command thatspinner.Updateproduces; onceloadingis false the program stops returning it. -
Focus is explicit.
textinputandtableboth take keys only while focused, so the program decides who receives each key (thefocusfield) instead of broadcasting every message. -
Untrusted text is cleaned. Book titles and notes come from a file;
cleanremoves control characters so that a record cannot move the cursor or set a window title through the terminal. The wider rules are on Security. -
Layout is arithmetic on
WindowSizeMsg. Nothing is drawn at the real size until the first size message arrives, which is whyViewshows a loading line or a "too small" line first. -
The list keeps its own quit bindings (
qandescin its key map); the program interceptsqbefore forwarding keys, andescin the catalogue pane is handled by the list.
Huh: forms and prompts
Huh builds forms from groups (pages) of fields: Input, Text, Select[T], MultiSelect[T], Confirm, FilePicker and
Note. A form can run on its own, which is the right tool for shelf add when it is started interactively, or be embedded in a
Bubble Tea program because huh.Form implements tea.Model.
package main
import (
"errors"
"fmt"
"os"
"strings"
"charm.land/huh/v2"
)
func validISBN(s string) error {
digits := strings.NewReplacer("-", "", " ", "").Replace(s)
if len(digits) != 10 && len(digits) != 13 {
return errors.New("an ISBN has 10 or 13 digits")
}
return nil
}
func main() {
var title, author, isbn, section string
var confirm bool
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Title").Value(&title).
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return errors.New("the title is required")
}
return nil
}),
huh.NewInput().Title("Author").Value(&author),
huh.NewInput().Title("ISBN").Value(&isbn).Validate(validISBN),
),
huh.NewGroup(
huh.NewSelect[string]().
Title("Section").
Options(huh.NewOptions("fiction", "science", "history")...).
Value(§ion),
huh.NewConfirm().Title("Add this book?").Value(&confirm),
),
).WithAccessible(os.Getenv("ACCESSIBLE") != "")
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Fprintln(os.Stderr, "cancelled")
os.Exit(130) // the shell convention for "interrupted"
}
fmt.Fprintln(os.Stderr, "shelf:", err)
os.Exit(1)
}
if confirm {
fmt.Printf("added %q by %s (%s) to %s\n", title, author, isbn, section)
}
}
The details the documentation gives: fields are configured with chained methods (Title, Value(&x), Validate,
Options); results can also be read with form.GetString(key), GetInt and GetBool for fields that set a Key; the
sentinel errors are huh.ErrUserAborted, huh.ErrTimeout and huh.ErrTimeoutUnsupported; RunWithContext(ctx) and
WithTimeout bound the wait; five themes are provided (Charm, Dracula, Catppuccin, Base 16 and the base theme) and the theme
functions take an isDark argument in v2. Dynamic fields use the *Func variants (TitleFunc, OptionsFunc) with a
binding so they are recomputed only when that variable changes.
Accessible mode. form.WithAccessible(true) replaces the TUI by standard prompts that work better with screen readers
and dictation software. The README recommends driving it from an environment variable or a flag, as the program above
does; the broader accessibility rules for terminal programs are on
Accessibility & Internationalization, and other prompt
libraries are compared on Interactive Prompts & Line Editing.
Embedding. The README embeds a form by storing the *huh.Form in the parent model, calling its Init and Update, and
checking form.State == huh.StateCompleted before reading values. Its sample renders with a View() string method that
was written against the v1 tea.Model; in a v2 program the parent’s View must return tea.View, so wrap the form’s
view string with tea.NewView, and check the form’s own View signature in the version you depend on.
Source: Huh v2 package documentation and Huh README.
Glamour: Markdown in the terminal
Glamour renders Markdown with an ANSI style sheet. The book notes in the Bookshelf are Markdown, so shelf show can print
them formatted:
package main
import (
"fmt"
"os"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
)
func main() {
notes := "# Dune\n\nA desert planet, a spice, and a long political game.\n\n- **Author:** Frank Herbert\n- **Shelf:** fiction\n"
out, err := glamour.Render(notes, "dark")
if err != nil {
fmt.Fprintln(os.Stderr, "shelf:", err)
os.Exit(1)
}
lipgloss.Print(out) // Glamour does not downsample colours; Lip Gloss does
r, err := glamour.NewTermRenderer(glamour.WithWordWrap(60))
if err == nil {
if wrapped, err := r.Render(notes); err == nil {
lipgloss.Print(wrapped)
}
}
}
glamour.Render(markdown, style) takes a built-in style name (the README shows dark) or a style file path;
RenderWithEnvironmentConfig and WithEnvironmentConfig() read the style from the GLAMOUR_STYLE environment variable.
NewTermRenderer takes options: WithStandardStyle, WithStylePath, WithStyles, WithWordWrap (default 80),
WithTableWrap, WithInlineTableLinks, WithPreservedNewLines, WithEmoji, WithChromaFormatter, WithBaseURL. The
renderer does not look at the terminal, so it applies no colour downsampling; the README’s advice is to print its output
with a Lip Gloss writer. The v2 package documentation does not describe an automatic light/dark style: choose the style from
tea.BackgroundColorMsg.IsDark() or GLAMOUR_STYLE yourself. Inside a Bubble Tea program the usual pattern is to render the
Markdown to a string in a command and show it in a viewport, as the pager example does.
Source: Glamour v2 package documentation, Glamour README and the Bubble Tea pager example.
Testing Bubble Tea programs
Most of a Bubble Tea program can be tested without a terminal because Update is a function: build a message, call
Update, assert on the returned model. This test for the shelf tui types text into the search box and checks the list:
package main
import (
"testing"
tea "charm.land/bubbletea/v2"
)
func key(s string) tea.KeyPressMsg {
r := []rune(s)[0]
return tea.KeyPressMsg{Code: r, Text: s}
}
func TestSearchFiltersCatalogue(t *testing.T) {
var m tea.Model = newModel("books.json")
m, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m, _ = m.Update(loadedMsg{
{Name: "Dune", Author: "Frank Herbert", ISBN: "9780441013593"},
{Name: "The Dispossessed", Author: "Ursula K. Le Guin", ISBN: "9780061054884"},
})
if n := len(m.(model).list.Items()); n != 2 {
t.Fatalf("expected 2 books, got %d", n)
}
for _, s := range []string{"/", "d", "u", "n", "e"} {
m, _ = m.Update(key(s))
}
if n := len(m.(model).list.Items()); n != 1 {
t.Fatalf("expected 1 book after searching for dune, got %d", n)
}
}
Command messages are tested the same way: call the command function and inspect the message it returns
(msg := loadBooks("testdata/books.json")()).
teatest runs a whole program against a fake terminal. Its documented API: teatest.NewTestModel(t, model, opts...) with
WithInitialTermSize(w, h) and WithProgramOptions(...); tm.Send(msg) and tm.Type(text); tm.Output() and
tm.FinalOutput(t); tm.FinalModel(t); tm.WaitFinished(t, opts...); teatest.WaitFor(t, reader, condition, opts...)
with WithDuration and WithCheckInterval (defaults 1 s and 50 ms); and teatest.RequireEqualOutput(t, out) for golden
files, regenerated with the -update flag (it needs the system diff tool to show differences).
import (
"bytes"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/exp/teatest/v2"
)
func TestProgramQuits(t *testing.T) {
tm := teatest.NewTestModel(t, newModel("testdata/books.json"), teatest.WithInitialTermSize(100, 30))
teatest.WaitFor(t, tm.Output(), func(out []byte) bool {
return bytes.Contains(out, []byte("Dune"))
}, teatest.WithDuration(3*time.Second))
tm.Send(tea.KeyPressMsg{Code: 'q', Text: "q"})
tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second))
}
Two honest caveats. The package lives under x/exp, is explicitly experimental and has no tagged version, so its API can
change with a plain go get -u. And the example test in the Bubble Tea repository (examples/simple/main_test.go) is
commented out; its comments say it fails because the output now contains colour escape sequences when run against a
buffer rather than a terminal, and suggest passing tea.WithColorProfile(Ascii), and that an interactive variant is
flaky because the initial terminal size is not set reliably. Prefer Update-level tests for logic, use teatest for smoke
tests with generous timeouts, and run the program under a real pseudo-terminal in CI for the rest, as described in
Testing Terminal Apps & TUIs.
Source: teatest package documentation and the Bubble Tea simple example test.
VHS: demos and visual checks
VHS scripts a terminal session in a tape file and records it. It needs ttyd and ffmpeg on the PATH (the official Docker
image ghcr.io/charmbracelet/vhs bundles them) and is available through Homebrew, pacman, Nix, Scoop and winget, or as a
release binary or go install. A tape for the shelf tui:
Output shelf-tui.gif
Output shelf-tui.txt
Require go
Set Shell "bash"
Set FontSize 18
Set Width 1200
Set Height 700
Type "go run . books.json"
Enter
Sleep 2s
Type "/"
Type "dune"
Sleep 1s
Enter
Down
Sleep 500ms
Tab
Sleep 500ms
Type "q"
Sleep 500ms
The tape language has setup commands (Output, Require, Set, Env, Source), settings (Shell, FontSize, Width,
Height, Theme, TypingSpeed and more), input commands (Type, Enter, Tab, Space, the arrow keys, PageUp,
PageDown, Ctrl+<key>), timing and capture commands (Sleep, Wait, Hide, Show, Screenshot) and Copy and Paste.
The output formats are GIF, MP4 and WebM, a directory of PNG frames, and .txt / .ascii text; the README presents the
text output as golden files for integration tests, which turns a tape into a regression test of the rendered screen. Other
commands are vhs new, vhs record (writes a tape from your own session), vhs publish, vhs themes, vhs manual and
vhs serve (an SSH server). The charmbracelet/vhs-action GitHub Action regenerates GIFs in CI.
Source: VHS repository and README.
Pitfalls and security
-
Mixing generations. A v1 import (
github.com/charmbracelet/bubbletea) next to v2 packages compiles into two unrelated type systems. Search the whole module for the old paths when upgrading, and use the upgrade guide as the checklist. -
Printing to stdout. Any
fmt.Printwhile the program runs corrupts the frame. Log to a file, usetea.Printlnin inline programs, or send a message. -
Work in
Update. A blocking call inUpdateorViewfreezes the interface. Do I/O in a command and return a message. -
Lost commands. Dropping the command returned by a sub-model (
cmdfromlist.Update,textinput.Focus(), a spinnerTick) stops its animation or blink without any error. -
Size before first message.
WindowSizeMsgis the source of truth for layout; components created with a size of zero must be resized when it arrives. Terminals that are too small need a fallback view. -
Stale snippets. Many tutorials online use v1:
View() string,tea.KeyMsgwithmsg.Type,tea.WithAltScreen(),tea.EnterAltScreen. They will not compile against v2. -
Escape-sequence injection. Anything not typed by the user (titles, notes, loan names, server messages) must be stripped of control characters before it reaches
Viewor Glamour, or it can reposition the cursor, change the window title or fake UI. See Security. -
Secrets in the TUI. A password field needs an echo mode (
textinputhas one) and must not be written to the debug log file; remember that a log opened withtea.LogToFileis an ordinary file with the process’s umask. -
Dark-only defaults. Some Bubbles defaults use dark palettes. Detect the background with
tea.RequestBackgroundColorand build styles frommsg.IsDark(). -
Terminal left in raw mode. A hard kill (
SIGKILL) cannot be handled; the user needsreset. Make the TUI quit onSIGTERMby cancelling the context passed withWithContext. -
A TUI is not an API. Keep the data operations behind plain subcommands and structured output so scripts and agents never have to drive the interface.
What the official documentation does not provide
-
There is no platform support matrix and no statement of the minimum Windows console host for Bubble Tea; the platform section above is limited to facts in the source tree.
-
Bubble Tea’s package documentation lists
ProgramStatuswithout describing it, and several type declarations are only visible in the source; they are not relied on here beyond what is quoted. -
Huh’s README embeds a form with a v1-style
View() string; no v2 embedding sample is published, so verify theViewsignature against your Huh version. -
Glamour v2 documents named styles and
GLAMOUR_STYLEbut no automatic light/dark selection. -
teatest has no tagged release and the repository’s own example test is disabled; there is no official guide to testing Bubble Tea v2 programs.
-
None of the examples on this page were compiled or run; verify them with
go vetandgo testagainst the module versions in yourgo.sum.
Version notes
| Component | Note |
|---|---|
Bubble Tea v2.1.0 |
Published 2026-10-08; the v2 line reached general availability on 2026-02-24. The module declares |
Bubbles v2.2.1 |
Published 2026-08-24; |
Lip Gloss v2.0.6 |
Published 2026-08-11; |
Huh v2.0.3 |
Published 2026-03-10; theme functions take |
Glamour v2.0.1 |
Published 2026-06-12; declares |
teatest |
|
VHS v0.12.1 |
Declares |
References
-
Bubble Tea v2 package documentation and Bubble Tea repository (README, examples and tutorials)
-
Bubbles v2 (packages list, table, textinput, viewport, spinner, progress, paginator)
-
Huh v2 and Huh repository
-
Command-Line Programs in Go and TUI Architecture & Concepts on this site
-
Go & Rust CLI Frameworks and TUIs vs. CLIs on this site