feat(cybersecurity): auto-commit con 48 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 23:44:39 +02:00
parent efc9911925
commit 729921e16e
48 changed files with 3765 additions and 8 deletions
+390
View File
@@ -0,0 +1,390 @@
package tui
import (
"regexp"
"strings"
"unicode"
)
// Unicode markers used by the Claude Code TUI.
const (
markerUser = '' // U+276F — user prompt
markerAssistant = '●' // U+25CF — assistant response / tool call
markerToolResult = '⎿' // U+23BF — tool result
markerProgress = '✻' // U+273B — progress indicator (ignore)
markerBoxTL = '╭' // U+256D — top-left box corner (banner start)
markerBoxBL = '╰' // U+2570 — bottom-left box corner (banner end)
markerBoxBR = '╯' // U+256F — bottom-right box corner (banner end)
markerHRule = '─' // U+2500 — horizontal rule
)
// reToolUse matches "Identifier(anything)" — a tool_use line.
var reToolUse = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\((.*)\)\s*$`)
// reProgress matches Claude's generation status/spinner line by its stable
// signature: "(Ns … tokens" or "esc to interrupt". Used when the line still
// carries that suffix, e.g. "✽ Whatchamacalliting… (2s · ↓ 1 tokens · esc to interrupt)".
var reProgress = regexp.MustCompile(`\(\d+s\b[^)]*tokens?\b|esc to interrupt`)
// reSpinner matches the spinner line by STRUCTURE rather than by its (infinite,
// ever-changing) gerund word: a non-alphanumeric glyph (✻ ✽ ✢ ✶ ✺ …) followed by
// a single word and a horizontal ellipsis, e.g. "✽ Forging…" or "✶ Puzzling…".
// This catches early frames that don't yet show the "(Ns · tokens)" suffix. The
// caller guards known turn markers (●//⎿) so a legitimate answer ending in "…"
// is not misclassified.
var reSpinner = regexp.MustCompile(`^\s*[^\p{L}\p{N}\s]\s+\p{L}[\p{L}'\-]*…`)
// ClaudeTurnRole classifies each turn/block extracted from the screen.
type ClaudeTurnRole string
const (
// ClaudeTurnUser is a message typed by the user (line starting with " ").
ClaudeTurnUser ClaudeTurnRole = "user"
// ClaudeTurnAssistant is a response block from the assistant.
ClaudeTurnAssistant ClaudeTurnRole = "assistant"
// ClaudeTurnToolUse is a tool invocation "● ToolName(args)".
ClaudeTurnToolUse ClaudeTurnRole = "tool_use"
// ClaudeTurnToolResult is a result line "⎿ ..." following a tool_use.
ClaudeTurnToolResult ClaudeTurnRole = "tool_result"
)
// ClaudeTurn is a single conversation block extracted from the rendered screen.
type ClaudeTurn struct {
Role ClaudeTurnRole `json:"role"`
Text string `json:"text"` // textual content (multiline joined with \n)
ToolName string `json:"tool_name,omitempty"` // only for tool_use
}
// ClaudeTUIParse is the result of parsing one captured Claude TUI screen.
type ClaudeTUIParse struct {
Turns []ClaudeTurn `json:"turns"` // all visible turns in order
Answer string `json:"answer"` // assistant reply to the last user turn (like `claude -p`)
}
// ParseClaudeTUI parses the rendered text of a Claude Code TUI screen and
// extracts the conversation turns and the final assistant answer.
//
// The screen is expected to be the output of VTRender applied to a PTY
// capture of the claude CLI. Heuristics handle the welcome banner, status
// bar, progress lines and multi-line continuations.
func ParseClaudeTUI(screen string) ClaudeTUIParse {
lines := strings.Split(screen, "\n")
// --- Step 1: strip the welcome banner (box drawn with ╭...╰/╯) ---
lines = stripBanner(lines)
// --- Step 2: strip the status bar at the bottom ---
lines = stripStatusBar(lines)
// --- Step 3: collect turns from the remaining lines ---
turns := extractTurns(lines)
// --- Step 4: compute Answer from turns ---
answer := computeAnswer(turns)
return ClaudeTUIParse{Turns: turns, Answer: answer}
}
// stripBanner removes the welcome banner block from the top of the lines
// slice. The banner is a Unicode box that starts with a line containing ╭
// and ends with a line containing ╰ or ╯.
func stripBanner(lines []string) []string {
// Find a banner start (╭) in the first ~15 lines.
startIdx := -1
for i := 0; i < len(lines) && i < 15; i++ {
if strings.ContainsRune(lines[i], markerBoxTL) {
startIdx = i
break
}
}
if startIdx < 0 {
return lines
}
// Find the matching close (╰ or ╯) after the start.
for i := startIdx; i < len(lines); i++ {
if strings.ContainsRune(lines[i], markerBoxBL) || strings.ContainsRune(lines[i], markerBoxBR) {
return lines[i+1:]
}
}
return lines
}
// isHRule returns true when the line consists mostly of ─ (U+2500) characters
// — at least 40 of them and the line has no other significant content.
func isHRule(line string) bool {
count := 0
for _, r := range line {
if r == markerHRule {
count++
}
}
return count >= 40
}
// isStatusBarLine returns true for lines that belong to the Claude status bar
// (CTX:, IN:, OUT:, Total:, Limits:, $, "← for agents", etc.).
func isStatusBarLine(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return false
}
prefixes := []string{
"CTX:", "IN:", "OUT:", "Total:", "Limits:", "$", "← for agents",
}
for _, p := range prefixes {
if strings.Contains(trimmed, p) {
return true
}
}
return false
}
// stripStatusBar removes the status bar at the bottom of the lines slice.
// Strategy: scan from the bottom upward. The footer looks like:
//
// <hrule>
// (empty prompt)
// <hrule>
// <status lines with CTX: / $0.xxx / ← for agents ...>
//
// We look for the LAST hrule that is followed by an empty-prompt line and
// another hrule, and discard everything from that hrule onward.
// Additionally, any trailing status-bar-flavored lines are dropped first.
func stripStatusBar(lines []string) []string {
if len(lines) == 0 {
return lines
}
// Trim trailing blank lines first.
end := len(lines)
for end > 0 && strings.TrimSpace(lines[end-1]) == "" {
end--
}
lines = lines[:end]
// Remove explicit status-bar lines from the bottom.
for len(lines) > 0 && isStatusBarLine(lines[len(lines)-1]) {
lines = lines[:len(lines)-1]
}
// Now find the pattern: hrule / empty- / hrule and cut there.
// Scan from the bottom upward.
for i := len(lines) - 1; i >= 2; i-- {
if !isHRule(lines[i]) {
continue
}
// Check that lines[i-1] is the empty prompt "" (optional surrounding spaces).
mid := strings.TrimSpace(lines[i-1])
if mid != string([]rune{markerUser}) && mid != string([]rune{markerUser, ' '}) {
// Also allow a completely empty line (prompt area can be blank).
if mid != "" {
continue
}
}
// Check lines[i-2] is also an hrule.
if isHRule(lines[i-2]) {
// Cut from lines[i-2] onward.
lines = lines[:i-2]
break
}
}
// Trim trailing blank lines again after stripping.
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
return lines
}
// firstRune returns the first non-space rune in s, or 0 if s is blank.
func firstRune(s string) rune {
for _, r := range s {
if !unicode.IsSpace(r) {
return r
}
}
return 0
}
// isMarkerLine returns true when the line starts with one of the recognised
// turn markers (, ●, ⎿, ✻).
func isMarkerLine(line string) bool {
r := firstRune(line)
return r == markerUser || r == markerAssistant || r == markerToolResult || r == markerProgress
}
// isProgressLine reports whether the line is a Claude generation status/spinner
// line (the animated "✻/✽ Word… (Ns · ↓ N tokens · esc to interrupt)" indicator).
// The glyph and the gerund word change on every frame, so it is detected by
// structure/signature, never by the specific word. These lines are noise and must
// never be folded into an assistant answer — critical when capturing frames
// mid-generation (streaming), where a different "loading" word appears each tick.
func isProgressLine(line string) bool {
r := firstRune(line)
if r == markerProgress {
return true
}
// Known turn markers are never progress, even if they end in "…".
if r == markerUser || r == markerAssistant || r == markerToolResult {
return false
}
return reProgress.MatchString(line) || reSpinner.MatchString(line)
}
// isBreakLine reports whether the line should end an assistant/user/tool
// continuation: either a turn marker or a progress/spinner line.
func isBreakLine(line string) bool {
return isMarkerLine(line) || isProgressLine(line)
}
// textAfterMarker returns the text that follows the first occurrence of
// marker in line, trimmed of leading spaces.
func textAfterMarker(line string, marker rune) string {
idx := strings.IndexRune(line, marker)
if idx < 0 {
return ""
}
rest := line[idx+len(string(marker)):]
return strings.TrimLeft(rest, " ")
}
// extractTurns scans lines and groups them into ClaudeTurn slices.
func extractTurns(lines []string) []ClaudeTurn {
var turns []ClaudeTurn
i := 0
for i < len(lines) {
line := lines[i]
// Progress/spinner lines are noise in any position — skip early so they
// are never folded into an assistant continuation (matters for streaming).
if isProgressLine(line) {
i++
continue
}
r := firstRune(line)
switch r {
case markerProgress:
// ✻ lines are noise — skip (also covered by isProgressLine above).
i++
case markerUser:
text := textAfterMarker(line, markerUser)
if text == "" {
// Empty prompt — skip.
i++
continue
}
// Collect continuation lines (indented, non-marker, non-empty).
i++
for i < len(lines) {
cont := lines[i]
if isBreakLine(cont) || strings.TrimSpace(cont) == "" {
break
}
text += "\n" + strings.TrimRight(cont, " ")
i++
}
turns = append(turns, ClaudeTurn{Role: ClaudeTurnUser, Text: strings.TrimRight(text, " ")})
case markerAssistant:
body := textAfterMarker(line, markerAssistant)
i++
// Determine if this is a tool_use or assistant text.
if m := reToolUse.FindStringSubmatch(body); m != nil {
// tool_use — do NOT collect continuation lines.
turns = append(turns, ClaudeTurn{
Role: ClaudeTurnToolUse,
Text: body,
ToolName: m[1],
})
} else {
// assistant text — collect continuation lines.
for i < len(lines) {
cont := lines[i]
if isBreakLine(cont) {
break
}
trimmed := strings.TrimSpace(cont)
if trimmed == "" {
// A single blank line may separate paragraphs; peek ahead.
// If the next non-blank line is also a continuation, keep it.
j := i + 1
for j < len(lines) && strings.TrimSpace(lines[j]) == "" {
j++
}
if j < len(lines) && !isBreakLine(lines[j]) {
// Include the blank line(s) as paragraph separator.
body += "\n"
i = j
continue
}
break
}
body += "\n" + strings.TrimRight(cont, " ")
i++
}
turns = append(turns, ClaudeTurn{
Role: ClaudeTurnAssistant,
Text: strings.TrimRight(body, " \n"),
})
}
case markerToolResult:
text := textAfterMarker(line, markerToolResult)
// Also accept └ as alias (some terminals substitute).
if text == "" {
text = textAfterMarker(line, '└')
}
i++
// Collect continuation lines for the tool result.
for i < len(lines) {
cont := lines[i]
if isBreakLine(cont) || strings.TrimSpace(cont) == "" {
break
}
text += "\n" + strings.TrimRight(cont, " ")
i++
}
turns = append(turns, ClaudeTurn{
Role: ClaudeTurnToolResult,
Text: strings.TrimRight(text, " "),
})
default:
// Blank or unrecognised line — skip.
i++
}
}
return turns
}
// computeAnswer finds the last user turn and concatenates all assistant
// (non-tool_use, non-tool_result) turns that follow it.
// If there is no user turn, concatenates all assistant turns.
func computeAnswer(turns []ClaudeTurn) string {
lastUserIdx := -1
for i, t := range turns {
if t.Role == ClaudeTurnUser {
lastUserIdx = i
}
}
var parts []string
start := 0
if lastUserIdx >= 0 {
start = lastUserIdx + 1
}
for _, t := range turns[start:] {
if t.Role == ClaudeTurnAssistant {
parts = append(parts, t.Text)
}
}
return strings.TrimSpace(strings.Join(parts, "\n"))
}
+67
View File
@@ -0,0 +1,67 @@
---
name: parse_claude_tui
kind: function
lang: go
domain: tui
version: "1.0.0"
purity: pure
signature: "func ParseClaudeTUI(screen string) ClaudeTUIParse"
description: "Parsea el texto renderizado de la pantalla de la TUI de Claude Code y extrae los turnos de la conversación (user, assistant, tool_use, tool_result) y la respuesta final del asistente. Equivalente a lo que devolvería `claude -p` pero operando sobre el render visual."
tags: [terminal-capture, claude, tui, parse, conversation]
uses_functions:
- vt_render_go_tui
uses_types:
- claude_turn_go_tui
- claude_tui_parse_go_tui
returns:
- claude_tui_parse_go_tui
returns_optional: false
error_type: ""
imports: []
params:
- name: screen
desc: "Texto renderizado de la pantalla de la TUI de Claude Code, producido por VTRender(raw, rows, cols). Debe incluir el contenido visible completo: banner opcional, conversación y status bar opcional."
output: "ClaudeTUIParse con los turnos visibles en orden (Role, Text, ToolName) y Answer — la concatenación de los bloques assistant que siguen al último turno user, equivalente al output de `claude -p`."
tested: true
tests:
- "golden screen — banner + status bar + single Q&A"
- "multiline assistant response"
- "tool_use + tool_result + final assistant text"
- "multi-turn — answer from last user only"
- "no banner no status bar — minimal screen"
- "determinism — same input produces same output"
test_file_path: "functions/tui/parse_claude_tui_test.go"
file_path: "functions/tui/parse_claude_tui.go"
---
## Ejemplo
```go
// Pipeline completo: PTY capture → VTRender → ParseClaudeTUI → usar .Answer
import (
"fmt"
"fn-registry/functions/infra"
"fn-registry/functions/tui"
)
raw, _ := infra.PtyCaptureIdle("claude", []string{}, 40, 220, 8000)
screen := tui.VTRender(raw, 40, 220)
result := tui.ParseClaudeTUI(screen)
fmt.Println(result.Answer) // imprime la respuesta final del asistente
for _, turn := range result.Turns {
fmt.Printf("[%s] %s\n", turn.Role, turn.Text)
}
```
## Cuando usarla
Cuando captures la TUI de `claude` con `pty_capture_idle_go_infra` + `vt_render_go_tui` y necesites extraer la respuesta como dato estructurado (equivalente a `claude -p`) en vez de procesar el render visual crudo. Úsala para agentes que lanzan `claude` como subproceso TUI y quieren leer la respuesta sin requerir modo headless.
## Gotchas
- **Heurístico y dependiente del layout de la TUI de Claude Code**: si Claude cambia los marcadores (``, `●`, `⎿`, `✻`) o el formato del banner/status-bar, el parser puede dejar de funcionar sin aviso.
- **Solo ve lo visible en el grid**: `VTRender` reconstruye únicamente lo que cabe en el terminal emulado (rows × cols). Respuestas largas que hacen scroll hacia arriba se truncan por arriba — no hay scrollback. Para respuestas largas, aumentar `rows` en `VTRender` o usar `claude -p` directamente.
- **tool_use/tool_result best-effort**: la TUI colapsa algunos bloques de herramientas. Los `ToolName` y textos de `tool_result` pueden quedar incompletos si la TUI los trunca con `…`.
- **Answer asume captura post-respuesta**: `PtyCaptureIdle` debe haberse disparado DESPUÉS de que la respuesta terminó de renderizarse (el spinner `✻` desapareció). Si se captura durante el streaming, `Answer` puede estar incompleto.
+214
View File
@@ -0,0 +1,214 @@
package tui
import (
"testing"
)
// goldenScreen is the exact sample screen from the spec.
const goldenScreen = `╭─── Claude Code v2.1.161 ─────────────────────────────────────────────────────────────────────────────────────────────╮
│ │ Tips for getting started │
│ Welcome back Enmanuel! │ Run /init to create a CLAUDE.md file with instructions for Cla… │
│ │ ─────────────────────────────────────────────────────────────── │
│ ▐▛███▜▌ │ What's new │
│ ▝▜█████▛▘ │ ` + "`OTEL_RESOURCE_ATTRIBUTES`" + ` values are now included as labels o… │
│ ▘▘ ▝▝ │ ` + "`claude agents`" + ` rows now show ` + "`done/total`" + ` before the detail w… │
│ Opus 4.8 (1M context) with xh… · Claude Max · │ ` + "`/mcp`" + ` now collapses claude.ai connectors you've never signed … │
│ gutierenmanuel15@gmail.com's Organization │ /release-notes for more │
│ ~/fn_registry │ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
responde unicamente con la palabra PONG, sin explicaciones
● PONG
✻ Crunched for 2s
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Opus 4.8 (1M context) │ CTX: █░░░░░░░░░ 11% (107k/1.0M) │ IN:6k OUT:5 (cache:17k) │ ⎇ master [~4 ?28 ↑1] │ 22:26
$0.565 │ +0/-0 │ Total: ↓107k/↑5 │ Limits: 5h:6% →02:40 │ 7d:11% →Sun 17:00 │ ⏱ 7s │ ~/fn_registry
← for agents`
func TestParseClaudeTUI(t *testing.T) {
t.Run("golden screen — banner + status bar + single Q&A", func(t *testing.T) {
got := ParseClaudeTUI(goldenScreen)
if got.Answer != "PONG" {
t.Errorf("Answer = %q, want %q", got.Answer, "PONG")
}
if len(got.Turns) != 2 {
t.Errorf("len(Turns) = %d, want 2", len(got.Turns))
for i, turn := range got.Turns {
t.Logf(" Turns[%d]: role=%s text=%q", i, turn.Role, turn.Text)
}
return
}
if got.Turns[0].Role != ClaudeTurnUser {
t.Errorf("Turns[0].Role = %q, want %q", got.Turns[0].Role, ClaudeTurnUser)
}
wantUserText := "responde unicamente con la palabra PONG, sin explicaciones"
if got.Turns[0].Text != wantUserText {
t.Errorf("Turns[0].Text = %q, want %q", got.Turns[0].Text, wantUserText)
}
if got.Turns[1].Role != ClaudeTurnAssistant {
t.Errorf("Turns[1].Role = %q, want %q", got.Turns[1].Role, ClaudeTurnAssistant)
}
if got.Turns[1].Text != "PONG" {
t.Errorf("Turns[1].Text = %q, want %q", got.Turns[1].Text, "PONG")
}
})
t.Run("multiline assistant response", func(t *testing.T) {
screen := ` explica brevemente
● linea uno
linea dos`
got := ParseClaudeTUI(screen)
if len(got.Turns) != 2 {
t.Fatalf("len(Turns) = %d, want 2; turns: %+v", len(got.Turns), got.Turns)
}
wantText := "linea uno\nlinea dos"
if got.Turns[1].Text != wantText {
t.Errorf("Turns[1].Text = %q, want %q", got.Turns[1].Text, wantText)
}
if !contains(got.Answer, "linea uno") || !contains(got.Answer, "linea dos") {
t.Errorf("Answer %q should contain both continuation lines", got.Answer)
}
})
t.Run("tool_use + tool_result + final assistant text", func(t *testing.T) {
screen := ` pregunta
● Read(main.go)
⎿ Read 50 lines
● aqui esta el resumen`
got := ParseClaudeTUI(screen)
if len(got.Turns) != 4 {
t.Fatalf("len(Turns) = %d, want 4; turns: %+v", len(got.Turns), got.Turns)
}
if got.Turns[0].Role != ClaudeTurnUser {
t.Errorf("Turns[0].Role = %q", got.Turns[0].Role)
}
if got.Turns[1].Role != ClaudeTurnToolUse {
t.Errorf("Turns[1].Role = %q, want tool_use", got.Turns[1].Role)
}
if got.Turns[1].ToolName != "Read" {
t.Errorf("Turns[1].ToolName = %q, want Read", got.Turns[1].ToolName)
}
if got.Turns[2].Role != ClaudeTurnToolResult {
t.Errorf("Turns[2].Role = %q, want tool_result", got.Turns[2].Role)
}
if got.Turns[3].Role != ClaudeTurnAssistant {
t.Errorf("Turns[3].Role = %q, want assistant", got.Turns[3].Role)
}
// Answer must be ONLY the assistant text, not the tool_use.
if got.Answer != "aqui esta el resumen" {
t.Errorf("Answer = %q, want %q", got.Answer, "aqui esta el resumen")
}
})
t.Run("multi-turn — answer from last user only", func(t *testing.T) {
screen := ` primera pregunta
● primera respuesta
segunda pregunta
● segunda respuesta`
got := ParseClaudeTUI(screen)
if len(got.Turns) != 4 {
t.Fatalf("len(Turns) = %d, want 4; turns: %+v", len(got.Turns), got.Turns)
}
if got.Answer != "segunda respuesta" {
t.Errorf("Answer = %q, want %q", got.Answer, "segunda respuesta")
}
})
t.Run("no banner no status bar — minimal screen", func(t *testing.T) {
screen := " hola\n\n● mundo"
got := ParseClaudeTUI(screen)
if len(got.Turns) != 2 {
t.Fatalf("len(Turns) = %d, want 2; turns: %+v", len(got.Turns), got.Turns)
}
if got.Answer != "mundo" {
t.Errorf("Answer = %q, want %q", got.Answer, "mundo")
}
})
t.Run("determinism — same input produces same output", func(t *testing.T) {
first := ParseClaudeTUI(goldenScreen)
second := ParseClaudeTUI(goldenScreen)
if first.Answer != second.Answer {
t.Errorf("non-deterministic: %q != %q", first.Answer, second.Answer)
}
if len(first.Turns) != len(second.Turns) {
t.Errorf("non-deterministic turns count: %d != %d", len(first.Turns), len(second.Turns))
}
})
}
// TestParseClaudeTUI_Spinner verifies that the generation spinner — which shows a
// DIFFERENT random gerund word on every frame ("Whatchamacalliting", "Forging",
// "Puzzling", "Crunched"...) — is never folded into the answer, regardless of the
// word, the glyph, or whether the "(Ns · tokens)" suffix is present yet.
func TestParseClaudeTUI_Spinner(t *testing.T) {
cases := []struct {
name string
screen string
want string
}{
{
name: "spinner with tokens suffix glued after answer",
screen: " di PONG\n\n● PONG\n\n✽ Whatchamacalliting… (2s · ↓ 1 tokens · esc to interrupt)\n",
want: "PONG",
},
{
name: "spinner early frame, no suffix yet, different word",
screen: " di HOLA\n\n● HOLA\n\n✶ Puzzling…\n",
want: "HOLA",
},
{
name: "classic crunched line",
screen: " x\n\n● respuesta\n\n✻ Crunched for 4s\n",
want: "respuesta",
},
{
name: "spinner BEFORE the answer block (mid-generation snapshot)",
screen: " pregunta\n\n✽ Forging… (1s · ↑ 3 tokens · esc to interrupt)\n\n● respuesta parcial\n",
want: "respuesta parcial",
},
{
name: "assistant line ending in ellipsis is NOT treated as spinner",
screen: " x\n\n● la historia continua…\n",
want: "la historia continua…",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ParseClaudeTUI(tc.screen)
if got.Answer != tc.want {
t.Errorf("Answer = %q, want %q", got.Answer, tc.want)
}
})
}
}
func contains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && (s == sub ||
len(s) > 0 && containsStr(s, sub)))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+54
View File
@@ -0,0 +1,54 @@
package tui
import (
"strings"
"github.com/hinshun/vt10x"
)
// VTRender emulates a terminal of size cols×rows, feeds raw into it,
// and returns the resulting screen as plain text preserving the visual layout.
//
// Unlike strip_ansi which removes escape sequences from sequential output,
// VTRender correctly handles TUIs that use absolute cursor positioning
// (ESC[row;colH, ESC[colG, etc.) by maintaining a 2D grid and reconstructing
// real spaces between columns.
//
// Defaults: rows<=0 → 40, cols<=0 → 120.
// Trailing spaces on each line are trimmed. Trailing empty lines are removed.
func VTRender(raw string, rows, cols int) string {
if rows <= 0 {
rows = 40
}
if cols <= 0 {
cols = 120
}
// Create a fresh terminal emulator for each call — no shared state.
term := vt10x.New(vt10x.WithSize(cols, rows))
term.Write([]byte(raw)) //nolint:errcheck // Write on vt10x never returns a meaningful error
// String() returns all rows joined by '\n', one row per terminal line.
// Each row is exactly `cols` runes wide (padded with NUL/space for empty cells).
raw = term.String()
lines := strings.Split(raw, "\n")
// Trim trailing spaces from every line (cells that were never written
// contain NUL '\x00' in some versions, so we trim both NUL and space).
for i, line := range lines {
// Replace NUL characters (unwritten cells) with spaces first.
line = strings.ReplaceAll(line, "\x00", " ")
lines[i] = strings.TrimRight(line, " ")
}
// Remove trailing empty lines — the TUI probably only used the top portion
// of the grid. Keep intermediate empty lines (real visual separators).
last := len(lines) - 1
for last >= 0 && lines[last] == "" {
last--
}
lines = lines[:last+1]
return strings.Join(lines, "\n")
}
+64
View File
@@ -0,0 +1,64 @@
---
name: vt_render
kind: function
lang: go
domain: tui
version: "1.0.0"
purity: pure
signature: "func VTRender(raw string, rows, cols int) string"
description: "Emula un terminal virtual de tamaño cols×rows, alimenta raw (stream con secuencias ANSI/VT100 incluyendo posicionamiento absoluto de cursor) y devuelve el estado final de la pantalla como texto plano que preserva el layout visual. A diferencia de strip_ansi, reconstruye espacios reales entre columnas posicionadas con movimientos de cursor absolutos."
tags: ["terminal", "vt100", "tui", "render", "ansi", "screen", "terminal-capture"]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports:
- "github.com/hinshun/vt10x"
- "strings"
tested: true
tests:
- "layout absoluto basico A y B separados por movimiento de cursor"
- "dos palabras separadas por movimiento de columna no aparecen pegadas"
- "texto multilinea simple con CRLF"
- "trim de filas vacias al final de grid grande"
- "determinismo misma entrada misma salida"
- "defaults rows y cols al pasar cero"
test_file_path: "functions/tui/vt_render_test.go"
file_path: "functions/tui/vt_render.go"
params:
- name: raw
desc: "Stream crudo de bytes de terminal, con secuencias de escape ANSI/VT100 intactas (colores, cursor moves, borrados de línea, scroll). Típicamente la salida de pty_capture_idle_go_infra."
- name: rows
desc: "Número de filas del terminal virtual. Debe coincidir con el tamaño de PTY usado al capturar. Si <=0 usa 40 como default."
- name: cols
desc: "Número de columnas del terminal virtual. Debe coincidir con el ancho de PTY usado al capturar. Si <=0 usa 120 como default."
output: "Texto plano multilínea con el layout visual de la pantalla: espacios reales entre columnas, sin trailing spaces por línea, sin filas vacías finales. Las líneas vacías intermedias se conservan (son separación visual real)."
---
## Ejemplo
```go
// Capturar output crudo de una TUI (ej. claude CLI) con el PTY del mismo tamaño.
raw, _ := pty_capture_idle("claude", []string{"--help"}, 40, 120, 2*time.Second, 10*time.Second)
// Renderizar el grid final como texto plano.
screen := tui.VTRender(raw, 40, 120)
fmt.Println(screen)
// Salida: texto con columnas alineadas, igual a lo que se vería en pantalla.
// Ejemplo real: "foo bar" si foo y bar estaban separados por ESC[10G.
```
## Cuando usarla
Úsala cuando captures el output crudo de una TUI con layout absoluto (claude CLI, htop, dialog, ncurses) y `strip_ansi_go_core` te deje las palabras pegadas (ej. "2newMCPservers"). Contrasta con `strip_ansi_go_core` y `strip_ansi_go_tui`, que sirven para output secuencial tipo logs donde no hay movimientos de cursor absolutos. Si el stream tiene `ESC[row;colH` o `ESC[colG`, este es el correcto.
Librería emuladora usada: `github.com/hinshun/vt10x` (vt10x v0.0.0-20220301184237-5011da428d02). Implementa VT10x completo sin CGO. API: `vt10x.New(vt10x.WithSize(cols, rows))` + `Write([]byte)` + `String()`.
## Gotchas
- **Tamaño debe coincidir**: rows×cols deben ser iguales a los que se usaron al capturar (pty_capture_idle usa 40×120 por defecto). Si no coinciden, el wrapping del texto no cuadra y las columnas se descuadran.
- **Solo texto, sin color**: la función vuelca únicamente los caracteres (rune de cada celda). Los atributos de color se pierden — es texto plano.
- **Solo estado final del grid**: si la TUI hizo scroll durante su ejecución, solo se ve el estado final de las 40 filas visibles. El historial de scroll no está disponible.
- **Emojis y caracteres de doble ancho**: algunos caracteres Unicode (emojis, CJK) ocupan 2 columnas visualmente pero solo 1 celda en el grid de vt10x, lo que puede descuadrar columnas en TUIs que los usan.
- **NUL en celdas vacías**: las celdas no escritas contienen `\x00` en algunas versiones del emulador. La función los reemplaza por espacio antes del trim, pero si el raw contiene NUL intencional, se trataría como espacio.
+114
View File
@@ -0,0 +1,114 @@
package tui
import (
"strings"
"testing"
)
func TestVTRender(t *testing.T) {
t.Run("layout absoluto basico A y B separados por movimiento de cursor", func(t *testing.T) {
// ESC[1;5H mueve el cursor a fila 1 columna 5 (1-indexed).
// Resultado esperado: 'A' en col 1, espacios, 'B' en col 5.
out := VTRender("A\x1b[1;5HB", 2, 10)
lines := strings.Split(out, "\n")
if len(lines) == 0 {
t.Fatalf("resultado vacio")
}
first := lines[0]
if len(first) < 5 {
t.Fatalf("linea demasiado corta: %q", first)
}
if first[0] != 'A' {
t.Errorf("esperaba 'A' en columna 0, got %q en linea %q", string(first[0]), first)
}
if first[4] != 'B' {
t.Errorf("esperaba 'B' en columna 4 (0-indexed), got %q en linea %q", string(first[4]), first)
}
// Verificar que hay espacios entre A y B (no están pegadas).
if strings.Contains(first, "AB") {
t.Errorf("A y B estan pegadas en %q, deberían estar separadas", first)
}
})
t.Run("dos palabras separadas por movimiento de columna no aparecen pegadas", func(t *testing.T) {
// ESC[10G mueve el cursor a la columna 10 (1-indexed) de la línea actual.
out := VTRender("foo\x1b[10Gbar", 2, 20)
lines := strings.Split(out, "\n")
if len(lines) == 0 {
t.Fatalf("resultado vacio")
}
first := lines[0]
if strings.Contains(first, "foobar") {
t.Errorf("foo y bar estan pegadas: %q — esperaba espacios entre ellas", first)
}
if !strings.Contains(first, "foo") {
t.Errorf("no encontre 'foo' en %q", first)
}
if !strings.Contains(first, "bar") {
t.Errorf("no encontre 'bar' en %q", first)
}
// foo en col 0-2, bar en col 9-11 (columna 10 es 0-indexed 9).
if len(first) < 12 {
t.Fatalf("linea demasiado corta para verificar: %q", first)
}
// Debe haber al menos un espacio entre foo y bar.
fooEnd := strings.Index(first, "foo") + 3
barStart := strings.Index(first, "bar")
if barStart <= fooEnd {
t.Errorf("bar empieza en %d pero foo termina en %d — sin separacion en %q", barStart, fooEnd, first)
}
})
t.Run("texto multilinea simple con CRLF", func(t *testing.T) {
out := VTRender("linea1\r\nlinea2", 5, 40)
if !strings.Contains(out, "linea1") {
t.Errorf("no encontre 'linea1' en %q", out)
}
if !strings.Contains(out, "linea2") {
t.Errorf("no encontre 'linea2' en %q", out)
}
lines := strings.Split(out, "\n")
// linea1 y linea2 deben estar en líneas distintas.
found1, found2 := -1, -1
for i, l := range lines {
if strings.Contains(l, "linea1") {
found1 = i
}
if strings.Contains(l, "linea2") {
found2 = i
}
}
if found1 == found2 {
t.Errorf("linea1 y linea2 estan en la misma linea (%d) de la salida: %q", found1, out)
}
})
t.Run("trim de filas vacias al final de grid grande", func(t *testing.T) {
// Input corto en un grid de 40 filas — no debe producir 40 lineas.
out := VTRender("hola", 40, 120)
count := strings.Count(out, "\n")
if count >= 3 {
t.Errorf("demasiadas lineas (%d) para 'hola' en grid de 40 filas: %q", count, out)
}
if !strings.Contains(out, "hola") {
t.Errorf("no encontre 'hola' en %q", out)
}
})
t.Run("determinismo misma entrada misma salida", func(t *testing.T) {
input := "foo\x1b[10Gbar\r\n\x1b[2;1Hbaz"
out1 := VTRender(input, 10, 40)
out2 := VTRender(input, 10, 40)
if out1 != out2 {
t.Errorf("resultados distintos:\nout1=%q\nout2=%q", out1, out2)
}
})
t.Run("defaults rows y cols al pasar cero", func(t *testing.T) {
// Verificar que no entra en pánico con valores <= 0.
out := VTRender("test", 0, 0)
if !strings.Contains(out, "test") {
t.Errorf("no encontre 'test' con defaults (rows=0,cols=0): %q", out)
}
})
}