Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5.7 KiB
terminal-capture
Automatizar una CLI/TUI interactiva y capturar su texto, de forma headless, a través de un pseudo-terminal (PTY). Cubre el ciclo completo: lanzar el proceso con un TTY real, inyectarle input scripteado, esperar a que el render se estabilice, y convertir el stream crudo de bytes a texto plano — bien reconstruyendo el layout 2D (TUIs con cursor absoluto), bien limpiando ANSI de output secuencial.
Existe porque muchas CLIs (sobre todo la CLI claude) solo entran en su modo interactivo rico
cuando detectan un TTY; un pipe normal las degrada. El PTY es virtual, en memoria: nunca abre
una ventana de terminal.
Funciones
| ID | Firma | Qué hace |
|---|---|---|
pty_capture_idle_go_infra |
func PTYCaptureIdle(ctx, name string, args []string, warmup time.Duration, inputs []string, stepDelay, idle, maxDur time.Duration) (string, error) |
Lanza name args en un PTY (40×120), espera warmup, escribe cada inputs separado por stepDelay, y captura todos los bytes hasta que pasa idle sin output nuevo o se alcanza maxDur. Devuelve el stream crudo (ANSI intacto). One-shot. |
pty_capture_stream_go_infra |
func PTYCaptureStream(ctx, name string, args []string, warmup time.Duration, inputs []string, stepDelay, snapshotInterval, idle, maxDur time.Duration) (<-chan string, error) |
Igual que pty_capture_idle pero emite snapshots acumulativos del buffer por un canal cada snapshotInterval — para hacer streaming de la TUI mientras renderiza. El consumidor renderiza/parsea cada snapshot. |
text_prefix_delta_go_core |
func PrefixDelta(prev, curr string) string |
Devuelve la parte de curr que sigue al prefijo común con prev (delta de streaming por snapshots). Pura, compara por runas. Heurística ante reflow. |
vt_render_go_tui |
func VTRender(raw string, rows, cols int) string |
Emula un terminal VT100 de rows×cols, alimenta raw, y devuelve el estado final de la pantalla como texto plano con el layout reconstruido (espacios reales donde el stream tenía movimientos de cursor). Pura. |
strip_ansi_go_core |
func StripANSI(s string) string |
Elimina secuencias ANSI/VT100 y caracteres de control de un stream secuencial (logs), preservando \n, \t, \r. Pura. NO reconstruye layout 2D. |
parse_claude_tui_go_tui |
func ParseClaudeTUI(screen string) ClaudeTUIParse |
Parsea la pantalla renderizada de la TUI de claude (salida de vt_render) y extrae los turnos (user/assistant/tool_use/tool_result) + la respuesta final (Answer), equivalente a lo que devolvería claude -p. Pura, heurística, específica de la TUI de claude. |
Cuándo usar cada limpiador
El corazón del grupo es pty_capture_idle (la captura). Lo que cambia es cómo conviertes el raw a texto:
| Si la salida es… | Usa | Porque |
|---|---|---|
Una TUI con posicionamiento absoluto (claude, htop, dialog) |
vt_render_go_tui (modo screen) |
Los "espacios" entre columnas eran movimientos de cursor; sin emular el grid las palabras se pegan (2newMCPservers). |
| Output secuencial línea a línea (logs, builds) | strip_ansi_go_core (modo stream) |
No hay layout 2D que reconstruir; basta quitar los escape codes. |
| Quieres procesar los escape codes tú mismo | (ninguno — usa el raw) | El raw de pty_capture_idle ya los conserva. |
Ejemplo canónico (end-to-end)
Capturar la respuesta de la CLI claude como texto con layout, en Go:
import (
"context"
"time"
"fn-registry/functions/infra"
"fn-registry/functions/tui"
)
func main() {
ctx := context.Background()
// Teclear el prompt y pulsar Enter como pasos separados: un "\r" pegado al
// texto lo trata claude como newline literal, no como submit.
inputs := []string{"resume el README en 3 lineas", "\r"}
raw, _ := infra.PTYCaptureIdle(ctx, "claude", nil,
4*time.Second, // warmup: deja cargar la TUI
inputs, 600*time.Millisecond,
4*time.Second, // idle: corta tras 4s de silencio
60*time.Second) // maxDur: tope duro
screen := tui.VTRender(raw, 40, 120) // reconstruye el layout 2D
print(screen)
}
La app claude_extract (apps/claude_extract) empaqueta exactamente este flujo como CLI, con
modos screen|stream|raw, --exec para pipear a otro proceso, y --cwd para saltar el diálogo
de arranque de claude. Es el consumidor de referencia del grupo.
La app claude_pipe (apps/claude_pipe) va un paso más allá: añade parse_claude_tui_go_tui
al final del pipeline para devolver la respuesta de claude como dato con el mismo shape que
claude -p --output-format json (--format json|text|turns). Es la alternativa "parsea la TUI"
a claude -p, para cuando se quiere expresamente ir a través de la TUI en vez del stream-json.
Fronteras
- No es
claude -p: este grupo captura la TUI real (lo que se ve). Para interacción programática limpia con la CLIclaude, usaclaude_stream_go_core(claude -p --output-format stream-json). - Linux/Unix only: PTY POSIX (
creack/pty). No Windows. - Sin color:
vt_renderreconstruye texto y layout, no atributos de color. - Idle es heurístico: TUIs con render periódico (spinners, relojes) no disparan el idle y caen
al
maxDur. Paraclaudeel spinner se detiene al terminar la respuesta, así que corta bien. - Dimensiones fijas 40×120: el render debe usar el mismo tamaño que la captura o el wrapping no cuadra.
Notas
- Las dos funciones de limpieza son puras; solo
pty_capture_idlees impura (lanza procesos). Puras en los bordes, impura en el centro de la captura. pty_capture_idleno fija el cwd del hijo: para controlarlo, cambia el cwd del proceso que la invoca antes de llamarla (lo que haceclaude_extract --cwd).