Cuando construyes herramientas de línea de comandos, la gestión de la salida es la diferencia entre una utilidad profesional y un script ruidoso. No se trata solo de imprimir texto; se trata de gestionar flujos de datos y estados visuales.
Para entender esto, debemos separar los flujos. Todo programa debería usar os.Stdout para los datos que el usuario quiere procesar (el “resultado” de la herramienta) y os.Stderr para logs, errores o estados de progreso. Si un usuario ejecuta mi-herramienta > salida.txt, solo quiere los datos en el archivo, no el mensaje de “Cargando…” o un error. Para decidir si debes mostrar colores o barras de progreso, debes detectar si la salida es una TTY (una terminal real) o un pipe/archivo; de lo contrario, enviarás secuencias de escape ANSI (códigos de colores) que ensuciarán los archivos de log.
Si el requerimiento es solo texto con algo de estilo, usamos lipgloss para manejar estilos (colores, bordes, padding) mediante secuencias ANSI de forma declarativa. Pero cuando la herramienta requiere interactividad —como un menú, un buscador o un dashboard que se actualiza en tiempo real— entramos en el terreno de las TUI (Terminal User Interfaces) mediante el patrón Model-View-Update (MVU) de bubbletea. Este patrón separa el estado de la aplicación (Model), la lógica de eventos (Update) y la representación visual (View), permitiendo que la interfaz reaccione a eventos del teclado o temporizadores sin corromper la terminal. Para componentes complejos (como un spinner o una barra de progreso), usamos bubbles, que son componentes pre-construidos para integrarse en nuestro bucle de Bubble Tea.
Si intentas gestionar el estado de una interfaz interactiva usando solo fmt.Println y variables globales, terminarás con una terminal rota o con parpadeos constantes. Por eso, la arquitectura de mensajería de Bubble Tea es esencial para aplicaciones complejas.
package main
import (
"fmt"
"os"
"time"
"github.com/charmbracelet/bubbles/progress"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Estilos de lipgloss definidos fuera para reutilización
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
Underline()
statusStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("10")).
Italic(true)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("9")).
Bold(true)
)
// model define el estado de nuestra TUI
type model struct {
progress float64
percent int
ready bool
progressW progress.Model
}
func (m model) Init() tea.Cmd {
// Iniciamos un comando que genera ticks de tiempo para la animación
return tick()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// Si el usuario presiona 'q' o 'Ctrl+C', terminamos
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
case tickMsg:
// Actualizamos el estado según el mensaje de tiempo
if m.percent >= 100 {
m.ready = true
return m, tea.Quit
}
m.percent += 5
m.progress = float64(m.percent) / 100
return m, tick()
}
return m, nil
}
func (m model) View() string {
// Construcción de la vista usando lipgloss para el layout
s := titleStyle.Render("🚀 Desplegando infraestructura...") + "\n\n"
s += fmt.Sprintf("Estado: %s\n\n", statusStyle.Render("Procesando paquetes..."))
// Renderizado de la barra de progreso de 'bubbles'
s += fmt.Sprintf("%s %d%%\n\n", m.progressW.ViewAsProgress(), m.percent)
if m.ready {
s += errorStyle.Render("¡Despliegue completado con éxito!")
} else {
s += "Presiona 'q' para cancelar"
}
return s
}
// Mensajes personalizados para el loop de eventos
type tickMsg time.Time
func tick() tea.Cmd {
return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func main() {
// 1. Output básico: Información inicial en Stderr (limpio para pipes)
fmt.Fprintf(os.Stderr, "[INFO] Iniciando proceso de despliegue...\n")
// 2. Preparación de la TUI
// Creamos el modelo inicial
p := progress.New(progress.Config{
Width: 40,
Height: 2,
Color: lipgloss.Color("12"),
Bar: progress.UltaBar{},
PulseColor: lipgloss.Color("39"),
})
m := model{
progressW: p,
}
// 3. Ejecución del programa Bubble Tea
// tea.NewProgram gestiona el ciclo de vida de la terminal
p_tea := tea.NewProgram(m)
if _, err := p_tea.Run(); err != nil {
fmt.Fprintf(os.Stderr, errorStyle.String("Error crítico: %v\n", err))
os.Exit(1)
}
// 4. Output final estilizado con lipgloss
fmt.Println(titleStyle.Render("✔ Tarea finalizada"))
fmt.Fprintf(os.Stdout, "Resumen: El despliegue se completó correctamente.\n")
}
Desglose del funcionamiento
- Separación de flujos en
main: Observa cómo usamosfmt.Fprintf(os.Stderr, ...)para el log de inicio. Si alguien redirige la salida de este binario a un archivo, los mensajes de log no contaminarán ese archivo. - El Ciclo de Vida (MVU):
Init(): Devuelve untea.Cmd(un comando asíncrono). Aquí usamostick()para lanzar una secuencia de mensajes de tiempo que dispararán las actualizaciones.Update(): Es el corazón de la lógica. Recibe un mensaje (tea.Msg) y devuelve un nuevo estado. Es un “state machine” puro: no modificas el estado directamente de forma descontrolada, sino que retornas una copia actualizada.View(): Es una función pura que transforma el estado actual en unastring. Fíjate cómo usamosm.progressW.ViewAsProgress(): estamos delegando la renderización de la barra de progreso al componente debubbles.
- Estilizado con
lipgloss: En lugar de insertar códigos ANSI manualmente con\033[31m, definimos estilos comotitleStyle. Esto hace que el código sea mantenible y permite cambiar el tema de toda la CLI en un solo lugar. - Gestión de la terminal:
tea.NewProgram(m)es quien realmente se encarga de entrar en “modo alternativo” de la terminal para que, al terminar el programa, la pantalla vuelva a su estado original sin dejar el texto de la barra de progreso flotando en la terminal del usuario.
El error frecuente
Un error clásico cuando empiezas a usar bubbletea es intentar usar fmt.Println() dentro de la función Update() o View() para debugear.
// ERROR: Esto corromperá la interfaz
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg != nil {
fmt.Println("Debug:", msg) // ¡ESTO ROMPERÁ LA UI!
}
return m, nil
}
Por qué falla: bubbletea tiene el control absoluto de la posición del cursor y el buffer de la terminal. Cuando haces un fmt.Println, escribes directamente en os.Stdout fuera del control del motor de renderizado, lo que causa que el texto aparezca en medio de la interfaz, rompa el layout o deje basura visual. Para debugear en TUI, usa tea.Log() o envía mensajes de error a os.Stderr.
N° 206