Bubble Tea em Go (Golang) é um framework para criar interfaces de terminal, ou TUIs, com listas navegáveis, atalhos, formulários, progresso e telas atualizadas no lugar. Na versão 2, o pacote oficial é charm.land/bubbletea/v2 e a aplicação continua seguindo Model-Update-View: o modelo guarda estado, Update processa mensagens e View descreve o que o runtime deve exibir.
A recomendação direta é: use uma CLI convencional para comandos curtos, automação e pipelines; use Bubble Tea quando a interação faz parte do produto. Um deploy --env producao não precisa de uma TUI. Já um painel para escolher serviços, acompanhar jobs, inspecionar logs ou revisar migrações pode ficar muito mais útil com navegação interativa.
Atualizado em 7 de setembro de 2026: os exemplos deste tutorial usam Bubble Tea v2. A migração oficial mudou o caminho do módulo,
tea.KeyMsgparatea.KeyPressMsgno tratamento comum de teclas e o retorno deView()destringparatea.View. Consulte o guia oficial de upgrade para v2.
Neste tutorial de Bubble Tea para Golang, você vai construir a base de uma TUI em Go, entender Model, Update, View e Cmd, adicionar teclado, tarefas assíncronas, estilos com Lip Gloss e testes. Se ainda não conhece a estrutura de comandos e flags, leia também o tutorial de ferramentas CLI em Go e o guia de graceful shutdown em aplicações Go.
Bubble Tea, Cobra e uma CLI simples resolvem problemas diferentes
Antes de instalar uma dependência, escolha a experiência certa:
| Abordagem | Melhor para | Vantagem | Ponto de atenção |
|---|---|---|---|
flag da biblioteca padrão | Um comando pequeno | Zero dependência e API simples | Pouca ergonomia para muitos subcomandos |
| Cobra | Comandos, flags, help e completions | Padrão conhecido em CLIs Go | Não cria uma interface interativa sozinho |
| Bubble Tea | Listas, formulários, dashboards e fluxos interativos | Estado e eventos organizados | Exige pensar em ciclo de atualização e terminal |
| Cobra + Bubble Tea | Produto com modo scriptável e modo interativo | Combina automação com boa UX | Precisa separar bem as duas entradas |
Uma ferramenta de infraestrutura pode oferecer os dois modos:
minha-cli deploy --service api --env prod
minha-cli interactive
O primeiro funciona em CI e scripts. O segundo abre uma TUI para uma pessoa explorar serviços e confirmar ações. Essa separação preserva uma propriedade importante: toda operação crítica continua acessível sem interface interativa.
Como funciona Model-Update-View
Uma aplicação Bubble Tea gira em torno de três métodos:
Init()informa o primeiro comando assíncrono, se houver;Update(msg)recebe uma mensagem e devolve o novo modelo mais um comando;View()transforma o estado atual em uma string.
O modelo não deve representar a tela como um conjunto de widgets mutáveis. Ele representa o estado do domínio da interface: itens, cursor, carregamento, erro, largura, altura e modo atual. A tela é uma consequência desse estado.
Um contador mínimo deixa o fluxo visível:
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
)
type model struct {
count int
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "up", "k", "+":
m.count++
case "down", "j", "-":
m.count--
}
}
return m, nil
}
func (m model) View() tea.View {
content := fmt.Sprintf(
"Contador: %d\n\n↑/k aumenta • ↓/j diminui • q sai\n",
m.count,
)
return tea.NewView(content)
}
func main() {
program := tea.NewProgram(model{})
if _, err := program.Run(); err != nil {
fmt.Fprintln(os.Stderr, "erro ao executar TUI:", err)
os.Exit(1)
}
}
Instale o pacote e execute:
go mod init exemplo.com/contador-tui
go get charm.land/bubbletea/v2
go run .
Quando uma tecla é pressionada, Bubble Tea v2 produz uma tea.KeyPressMsg. Update interpreta a mensagem, altera uma cópia do modelo e devolve o próximo estado. Em seguida, o runtime chama View novamente e renderiza a tea.View retornada.
Essa arquitetura lembra um event loop, mas evita espalhar leitura de teclado, mutação de estado e desenho da tela por várias goroutines. Para entender como isso se relaciona com o runtime da linguagem, consulte o guia de goroutines e concorrência em Go e o artigo sobre channels em produção.
Construindo uma lista navegável sem esconder o estado
Agora podemos representar tarefas e um cursor:
type task struct {
Title string `json:"title"`
Done bool `json:"done"`
}
type model struct {
tasks []task
cursor int
width int
height int
err error
}
O tratamento de teclado fica explícito:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
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.tasks)-1 {
m.cursor++
}
case "enter", " ":
if len(m.tasks) > 0 {
m.tasks[m.cursor].Done = !m.tasks[m.cursor].Done
}
}
}
return m, nil
}
A mensagem tea.WindowSizeMsg é importante porque terminais mudam de tamanho. Guardar largura e altura no modelo permite adaptar tabelas, cortar texto e decidir quando exibir um painel lateral. Não presuma 80 colunas e não use largura fixa para conteúdo que pode crescer.
A View pode percorrer os itens:
func (m model) View() tea.View {
var out strings.Builder
out.WriteString("Tarefas do projeto\n\n")
for i, item := range m.tasks {
cursor := " "
if i == m.cursor {
cursor = ">"
}
mark := "[ ]"
if item.Done {
mark = "[x]"
}
fmt.Fprintf(&out, "%s %s %s\n", cursor, mark, item.Title)
}
out.WriteString("\n↑/↓ navega • espaço conclui • q sai\n")
return tea.NewView(out.String())
}
strings.Builder evita concatenações repetidas e deixa a renderização legível. Para listas muito grandes, renderize apenas a janela visível ou use o componente list do projeto Bubbles.
Cmd: trabalho assíncrono sem bloquear Update
Update deve ser rápido. Não faça requisição HTTP, consulta longa ou leitura de arquivo diretamente dentro dele. Enquanto a função estiver bloqueada, a interface não processa teclado nem redesenha.
No Bubble Tea, uma operação assíncrona é representada por tea.Cmd, que é uma função que produz uma mensagem:
type tasksLoadedMsg []task
type loadFailedMsg struct {
err error
}
func loadTasks(client *http.Client, url string) tea.Cmd {
return func() tea.Msg {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return loadFailedMsg{err: err}
}
resp, err := client.Do(req)
if err != nil {
return loadFailedMsg{err: err}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return loadFailedMsg{err: fmt.Errorf("status inesperado: %s", resp.Status)}
}
var tasks []task
if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {
return loadFailedMsg{err: err}
}
return tasksLoadedMsg(tasks)
}
}
O modelo pode começar carregando:
func (m model) Init() tea.Cmd {
return loadTasks(m.client, m.endpoint)
}
E Update trata o resultado como qualquer outra mensagem:
case tasksLoadedMsg:
m.loading = false
m.tasks = []task(msg)
case loadFailedMsg:
m.loading = false
m.err = msg.err
Isso mantém uma regra saudável: efeitos produzem mensagens; mensagens alteram estado; View apenas renderiza. Se a operação precisa de timeout e cancelamento, configure um http.Client com timeout e propague context.Context na camada responsável. O guia de context, timeout e cancelamento em Go mostra os padrões para evitar chamadas penduradas.
Não atualize o modelo a partir de uma goroutine solta
Um antipadrão é iniciar uma goroutine em Update e modificar m.tasks ou outro campo diretamente. Além de introduzir corrida de dados, essa mutação ocorre fora do ciclo do framework e pode não gerar nova renderização.
Prefira retornar um Cmd. Se várias tarefas independentes devem começar juntas, use tea.Batch:
return m, tea.Batch(
loadTasks(m.client, m.endpoint),
checkVersion(m.versionClient),
)
Cada comando devolve sua própria mensagem. Se os resultados precisam de uma ordem rígida, encadeie operações em vez de depender da ordem de conclusão. Use também o race detector e a cheatsheet de debug durante o desenvolvimento:
go test -race ./...
Estilos com Lip Gloss
Bubble Tea não obriga uma biblioteca visual. Em v2, o método View() devolve uma tea.View cujo conteúdo pode incluir texto e sequências ANSI. Lip Gloss oferece uma API para cores, bordas, margens, padding, largura e alinhamento:
go get charm.land/lipgloss/v2
Defina estilos fora do fluxo de atualização:
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("86"))
selectedStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("24")).
Padding(0, 1)
mutedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
)
Na View, aplique o estilo de acordo com o estado:
line := fmt.Sprintf("%s %s", mark, item.Title)
if i == m.cursor {
line = selectedStyle.Render(line)
}
out.WriteString(line + "\n")
Não use cor como único sinal. Mantenha >, [x], rótulos ou ícones textuais que comuniquem seleção e estado em terminais sem cor. Também ofereça uma saída não interativa, como --json ou --no-color, quando a ferramenta puder ser usada em scripts.
O próprio ecossistema Charmbracelet oferece componentes Bubbles para listas, tabelas, text inputs, spinners, viewport, progress e outros controles. Comece com os componentes de que realmente precisa. Uma TUI composta por dez abstrações antes de validar o fluxo fica tão difícil de manter quanto uma interface web superdimensionada.
Organizando uma TUI que cresceu
O exemplo de arquivo único é ótimo para aprender, mas um produto real precisa separar responsabilidades. Uma estrutura possível:
cmd/minha-cli/
main.go
internal/tui/
model.go
update.go
view.go
keys.go
styles.go
messages.go
internal/tasks/
service.go
repository.go
A divisão não precisa ser dogmática. O objetivo é manter:
- tipos de mensagens próximos ao domínio que representam;
- efeitos externos em serviços testáveis;
- atalhos centralizados e documentados;
- estilos separados da regra de negócio;
Updatecomo coordenador, não como arquivo de duas mil linhas.
Para múltiplas telas, use um campo de modo:
type screen int
const (
screenList screen = iota
screenDetails
screenConfirm
)
Update delega conforme a tela atual e View renderiza o componente correspondente. Outra abordagem é manter submodelos, por exemplo list.Model, textinput.Model e viewport.Model, cada um recebendo a mensagem e devolvendo o próprio comando.
Evite criar uma goroutine por componente ou uma arquitetura de microserviços dentro da TUI. A aplicação continua sendo um processo Go com uma máquina de estados. O artigo de Clean Architecture em Go sem overengineering ajuda a encontrar uma divisão proporcional ao tamanho do produto.
Teclas, ajuda e ações destrutivas
Atalhos previsíveis melhoram muito a usabilidade:
- setas e
j/kpara navegação; /para busca;enterpara abrir ou confirmar uma ação reversível;escpara voltar;?para ajuda;qpara sair quando o foco não está em um campo de texto;ctrl+ccomo saída de emergência.
Ações destrutivas precisam de confirmação explícita. Não associe d diretamente a apagar um recurso em produção. Abra uma tela de confirmação, mostre o alvo completo e, para operações críticas, exija a digitação do nome. A interface bonita não reduz a necessidade de idempotência, autorização, logs e validação no backend.
Em ferramentas que disparam deploys, publique progresso como mensagens e ofereça cancelamento. Para processos locais, trate sinais e encerre subprocessos. Para operações remotas, diferencie “parar de acompanhar” de “cancelar o trabalho no servidor”; são ações diferentes.
Como testar Update e View
A arquitetura torna Update testável sem abrir um terminal. Um teste de navegação pode criar um modelo e enviar uma tecla:
func TestUpdateMovesCursorDown(t *testing.T) {
initial := model{
tasks: []task{
{Title: "primeira"},
{Title: "segunda"},
},
}
updated, cmd := initial.Update(tea.KeyPressMsg{Code: tea.KeyDown})
got := updated.(model)
if cmd != nil {
t.Fatal("comando inesperado")
}
if got.cursor != 1 {
t.Fatalf("cursor = %d; queríamos 1", got.cursor)
}
}
Teste também limites: seta para cima no primeiro item, lista vazia, remoção do último item e resize para largura pequena. Para uma mensagem de domínio:
func TestTasksLoadedReplacesLoadingState(t *testing.T) {
initial := model{loading: true}
msg := tasksLoadedMsg{{Title: "revisar PR"}}
updated, _ := initial.Update(msg)
got := updated.(model)
if got.loading {
t.Fatal("loading deveria ser false")
}
if len(got.tasks) != 1 {
t.Fatalf("len(tasks) = %d; queríamos 1", len(got.tasks))
}
}
Para View, prefira asserções sobre conteúdo importante a snapshots gigantes e frágeis:
view := model{tasks: []task{{Title: "revisar PR"}}}.View()
if !strings.Contains(view, "revisar PR") {
t.Fatal("a tarefa não apareceu na tela")
}
Snapshots são úteis para layouts estáveis, mas códigos ANSI, largura e pequenas mudanças de espaçamento podem gerar muito ruído. Separe uma função de renderização sem cor ou normalize ANSI antes da comparação.
Combine esses testes com table-driven tests em Go para cobrir várias teclas e estados sem duplicar a estrutura da suíte. Depois execute go test ./..., go vet ./... e sua política de Staticcheck e golangci-lint.
Checklist para uma TUI de produção
Antes de distribuir o binário, verifique:
- existe modo não interativo para automação, quando aplicável;
-
Updatenão executa I/O bloqueante; - efeitos externos retornam mensagens por
tea.Cmd; - resize do terminal não quebra a tela;
- lista vazia, loading e erro têm estados visíveis;
- teclado funciona sem depender apenas de cor;
- ações destrutivas exigem confirmação;
- erros vão para uma área da UI ou para logs, não misturados ao desenho;
- chamadas de rede têm timeout e cancelamento;
-
Updatee transições principais têm testes; - o binário é testado em Linux, macOS e Windows, se esses sistemas forem suportados;
- versões de dependências e processo de release estão fixados.
Para distribuir em múltiplas plataformas, o guia de GoReleaser, checksums e SBOM mostra como gerar artefatos reproduzíveis. Se a TUI chama APIs, aplique também logging estruturado com slog e não exiba tokens, headers ou respostas sensíveis na tela.
Quando não usar Bubble Tea
Bubble Tea não é automaticamente melhor porque a tela se move. Evite uma TUI quando:
- o comando só recebe argumentos e imprime uma resposta;
- a saída precisa ser consumida por
jq, pipes ou outro programa; - um formulário web seria mais acessível e compartilhável;
- a equipe não quer manter comportamento específico de terminal;
- a interação esconde uma operação que deveria ser declarativa e reproduzível.
Nesses casos, uma CLI com saída estável, códigos de retorno corretos e formatos json ou text é uma solução superior. A melhor ferramenta de terminal é aquela que respeita tanto a pessoa usando o teclado quanto o script executando às três da manhã.
Perguntas frequentes
O que é Bubble Tea em Go?
Bubble Tea é um framework para interfaces de terminal baseado em Model-Update-View. O modelo guarda o estado, Update processa teclado e mensagens, e View descreve a tela em uma tea.View. Ele é útil para listas, seletores, dashboards, formulários e fluxos interativos.
Bubble Tea substitui Cobra?
Não. Cobra organiza comandos, subcomandos e flags. Bubble Tea organiza uma interface interativa. Eles podem ser usados separadamente ou no mesmo binário: Cobra recebe minha-cli interactive, por exemplo, e esse subcomando inicia o programa Bubble Tea.
Preciso usar Lip Gloss?
Não. Você pode renderizar strings simples e ainda ter uma TUI funcional. Lip Gloss ajuda a controlar estilos, bordas e layout, mas deve melhorar a leitura sem esconder informação de terminais sem cor.
É possível testar sem abrir um terminal?
Sim. A maior parte da lógica pode ser testada enviando mensagens diretamente para Update e verificando o modelo resultante. View pode ser testada por trechos essenciais ou snapshots normalizados. Testes end-to-end em terminal ficam reservados para os fluxos mais importantes.
Quando uma TUI vale a pena?
Ela vale a pena quando navegação, exploração ou acompanhamento em tempo real fazem parte da tarefa. Para comandos atômicos e automação, prefira uma CLI convencional. Se os dois públicos existem, ofereça uma operação scriptável e um modo interativo sobre a mesma camada de aplicação.
Próximos passos
Comece pequeno: implemente uma lista com cursor, loading, erro e uma ação. Depois extraia o acesso externo para um serviço, transforme efeitos em Cmd, teste as transições e só então adicione componentes visuais. A documentação e os exemplos oficiais estão nos repositórios do Bubble Tea, Bubbles e Lip Gloss, com a API publicada também no pkg.go.dev de Bubble Tea v2.
Para continuar no ecossistema de ferramentas Go, veja projetos de Go para portfólio, o tutorial de go:embed para incluir arquivos no binário e as vagas de Go no Brasil. Uma TUI pequena, bem testada e distribuída como binário multiplataforma é um excelente projeto para demonstrar domínio de interfaces, concorrência, testes e experiência de desenvolvedor.