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"))
|
||||
}
|
||||
Reference in New Issue
Block a user