Pulido de la UI
A estas alturas el harness ya es un REPL que funciona: habla con Claude, corre tres herramientas, pide permiso antes de tocar nada destructivo y cambia de proveedor LLM con una sola línea. Lo que le falta es textura. El prompt es un simple >. Mientras el modelo piensa no pasa nada en pantalla — solo silencio y un cursor parpadeando entre dos y cinco segundos. Y si reduces la terminal por debajo de las 80 columnas, la salida se descoloca.
Capítulo corto, entonces. Vamos a meter un banner, lograr que sobreviva a terminales estrechas y añadir un spinner de carga. Nada de esto es load-bearing; lo importante es aprender tres técnicas pequeñas que aparecen por todas partes cuando trabajas con CLIs.
El banner
ASCII art usando la fuente figlet "ANSI Shadow", que la mayoría de las herramientas terminal-AI (Claude Code, OpenCode) usan como su wordmark.
██████╗ ███████╗████████╗████████╗ █████╗ ████████╗███████╗ ██████╗██╗ ██╗
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝██╔════╝██║ ██║
██████╔╝█████╗ ██║ ██║ ███████║ ██║ █████╗ ██║ ███████║
██╔══██╗██╔══╝ ██║ ██║ ██╔══██║ ██║ ██╔══╝ ██║ ██╔══██║
██████╔╝███████╗ ██║ ██║ ██║ ██║ ██║ ███████╗╚██████╗██║ ██║
╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝
Más un subtítulo en gris tenue. Envuelve todo en \033[1;36m (cian negrita) y \033[0m (reset). Listo.
Terminales angostas
El banner tiene 75 columnas de ancho. En cualquier cosa menos a ~78 columnas se enrolla y se ve como basura. Así que detectamos el ancho del terminal y caemos a un wordmark de texto plano.
import "golang.org/x/term"
func TermWidth() int {
w, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil { return 0 }
return w
}
func PrintBanner() {
if TermWidth() >= 78 {
// banner grande
} else {
// wordmark de una línea: " BETTATECH · build your own coding agent"
}
}
Tres pequeñas cosas escondidas en ese patrón, vale la pena conocerlas porque aparecen en todas partes:
golang.org/x/termes la forma canónica de preguntar "¿stdout es un TTY, y qué ancho tiene?". La biblioteca estándar no lo expone.GetSizeda error en no-TTYs (con pipe, redirigido). Tratamos err como "0 cols", lo que cae en la rama del banner chico — lo correcto paraharness > log.txt.- 78 es margen para un banner de 75 de ancho. Elegir el ancho exacto es una trampa si alguna vez agregas un solo carácter.
El spinner
Mientras el agente espera a la API, no ves nada durante varios segundos. Eso es mala UX. Agregamos un pequeño spinner en braille que se sobreescribe en su lugar:
var spinnerFrames = []rune("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
type Spinner struct {
stop chan struct{}
done chan struct{}
}
func StartSpinner(label string) *Spinner {
s := &Spinner{stop: make(chan struct{}), done: make(chan struct{})}
go func() {
defer close(s.done)
ticker := time.NewTicker(80 * time.Millisecond)
defer ticker.Stop()
i := 0
for {
select {
case <-s.stop:
fmt.Print("\r\033[K") // limpia la línea
return
case <-ticker.C:
fmt.Printf("\r%c %s", spinnerFrames[i], label)
i = (i + 1) % len(spinnerFrames)
}
}
}()
return s
}
func (s *Spinner) Stop() {
close(s.stop)
<-s.done // bloquea hasta que la goroutine confirme que limpió la línea
}
Tres detalles, todos con su razón de existir:
\rdevuelve el cursor a la columna 0;\033[Klimpia hasta el fin de línea. Juntos sobreescriben el frame del spinner limpiamente. Sin el limpiado, pasar de una label larga a una corta deja basura al final.Stop()bloquea endone. Esta es la parte que sorprende. SiStop()devolviera de inmediato, la goroutine del spinner podría imprimir otro frame después de que ya nos hubiéramos movido a imprimir la respuesta del modelo. La sincronización garantiza que para cuandoStop()retorna, ya no hay más salida del spinner en vuelo.- Chequeo de no-TTY (no se muestra). Si stdout no es un terminal, el spinner simplemente devuelve un shell no-op. Spammear
\r⠋...a un archivo de log es peor que no tener spinner.
En el capítulo 12 reemplazamos esto completamente con bubbles/spinner dentro de un programa de Bubble Tea. La versión braille-sobre-stdout de arriba está bien para la era del REPL.
Dónde encaja
// agentLoop
sp := startSpinner("thinking...")
resp, err := provider.Send(ctx, messages, tools)
sp.Stop()
El spinner corre solo durante la llamada a la API. Apenas vuelve la respuesta, se detiene, y nosotros imprimimos lo que haya bajado — texto o líneas de log [tool] — en una línea limpia.
Tropiezos
Animación matando el prompt cache. No por el spinner en particular, pero sí por las secuencias ANSI: cualquier cosa que termine en el system prompt (timestamps, decoraciones animadas) destruye el prompt caching. Los banners están bien porque se imprimen una sola vez al inicio, no son parte del array de mensajes.
Ancho de Unicode. Algunos terminales no renderizan █ y braille a 1 celda de ancho. En macOS Terminal.app, bien. En unos pocos terminales minimalistas (configuraciones tempranas de kitty, algunas configuraciones de tmux), el banner se puede enrollar. No hay un fix perfecto; lo aceptamos.
En el repo actual. El código del banner (con la variante ANSI Shadow ancha y el fallback para terminales angostas) está en
internal/ui/banner.go. El spinner stand-alone — usado en modo REPL antes de que la TUI tomara el control — esinternal/ui/spinner.go. La versión TUI (capítulo 12) usabubbles/spinneren su lugar; ambos archivos sobreviven en el repo así puedes comparar los dos enfoques.
Ahora prueba
- Cambia el tamaño de tu terminal a 60 columnas de ancho. Reinicia el harness. Confirma que el banner de fallback entra en acción.
- Hazle pipe a la salida:
go run . > /tmp/out.txt. Abre el archivo. El banner debería ser el chico (porqueGetSizedevolvió err en el pipe no-TTY). - Cambia los frames del spinner a
|/-\. Compara la sensación. Mismo principio operativo, vibe muy distinto.
Siguiente: 05 · Comandos de barra.