feat(cybersecurity): auto-commit con 48 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user