← Voltar para o blog

Bubble Tea em Go: Interfaces de Terminal com TUI

Tutorial Bubble Tea Golang atualizado para v2: crie uma TUI em Go com Model-Update-View, teclado, comandos assíncronos, Lip Gloss e testes.

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.KeyMsg para tea.KeyPressMsg no tratamento comum de teclas e o retorno de View() de string para tea.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:

AbordagemMelhor paraVantagemPonto de atenção
flag da biblioteca padrãoUm comando pequenoZero dependência e API simplesPouca ergonomia para muitos subcomandos
CobraComandos, flags, help e completionsPadrão conhecido em CLIs GoNão cria uma interface interativa sozinho
Bubble TeaListas, formulários, dashboards e fluxos interativosEstado e eventos organizadosExige pensar em ciclo de atualização e terminal
Cobra + Bubble TeaProduto com modo scriptável e modo interativoCombina automação com boa UXPrecisa 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;
  • Update como 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/k para navegação;
  • / para busca;
  • enter para abrir ou confirmar uma ação reversível;
  • esc para voltar;
  • ? para ajuda;
  • q para sair quando o foco não está em um campo de texto;
  • ctrl+c como 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;
  • Update nã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;
  • Update e 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.