729921e16e
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
391 lines
12 KiB
Go
391 lines
12 KiB
Go
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"))
|
||
}
|