← Voltar para o blog

Bubble Tea em Go: Interfaces de Terminal com TUI

Aprenda Bubble Tea em Go: Model-Update-View, comandos, teclado, listas, Lip Gloss, testes e arquitetura completa de uma TUI robusta pronta para produção.

Se você quer criar uma ferramenta de terminal em Go com listas navegáveis, atalhos de teclado, filtros, progresso e telas que atualizam sem imprimir linhas infinitas, Bubble Tea é uma das opções mais maduras do ecossistema. O framework da Charmbracelet usa a arquitetura Model-Update-View: o estado fica em um modelo, eventos chegam como mensagens, uma função atualiza esse estado e outra renderiza a tela como texto.

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.

Neste guia, 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 "github.com/charmbracelet/bubbletea"
)

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.KeyMsg:
        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() string {
    return fmt.Sprintf(
        "Contador: %d\n\n↑/k aumenta • ↓/j diminui • q sai\n",
        m.count,
    )
}

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 github.com/charmbracelet/bubbletea
go run .

Quando uma tecla é pressionada, Bubble Tea produz uma tea.KeyMsg. Update interpreta a mensagem, altera uma cópia do modelo e devolve o próximo estado. Em seguida, o runtime chama View novamente.

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.KeyMsg:
        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() string {
    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 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. A View devolve texto e sequências ANSI. Lip Gloss oferece uma API para cores, bordas, margens, padding, largura e alinhamento:

go get github.com/charmbracelet/lipgloss

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.KeyMsg{Type: 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 renderiza esse estado como texto. 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.

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.