Files
fn_registry/functions/tui/parse_claude_tui.go
T
2026-06-04 23:44:39 +02:00

391 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"))
}