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: // // // ❯ (empty prompt) // // // // 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")) }