iop/apps/node/internal/adapters/cli/status/parser.go

376 lines
12 KiB
Go

package status
import (
"fmt"
"regexp"
"strconv"
"strings"
"unicode"
)
var (
dailyLimitRegex = regexp.MustCompile(`5h limit:.*?\]\s*([^%\n]+%)[^\(]*\(resets\s+([^\)]+)\)`)
weeklyLimitRegex = regexp.MustCompile(`Weekly limit:.*?\]\s*([^%\n]+%)[^\(]*\(resets\s+([^\)]+)\)`)
// Section header locators — they only locate the start of each block; they
// do NOT span forward into other blocks. This prevents the previous lazy
// `.*?` regex from accidentally pairing a session header with the week's
// "X% used / Resets Y" payload when cursor positioning in the raw PTY
// stream interleaves the two sections out of order.
claudeSessionHeaderRegex = regexp.MustCompile(`(?i)Current\s+session\b`)
claudeWeekHeaderRegex = regexp.MustCompile(`(?i)Current\s+week\b(?:\s*\(all\s+models\))?`)
// Payload extractor used *within* a single section slice.
claudePayloadRegex = regexp.MustCompile(`(?is)(\d+(?:\.\d+)?)%\s+used.*?Resets\s+([^\n]+)`)
antigravityUsedQuotaRegex = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)%\s+used\s+\(Limit resets in ([^)]+)\)`)
antigravitySimpleQuotaRegex = regexp.MustCompile(`(?is)\bquota\s+(\d+(?:\.\d+)?)%\s+used\b`)
antigravityLimitReachedRegex = regexp.MustCompile(`(?i)Limit reached,\s*resets in ([^\n]+)`)
antigravityUsageLimitRegex = regexp.MustCompile(`(?i)Usage limit:\s*([0-9][0-9,]*)`)
antigravityModelLineRegex = regexp.MustCompile(`(?i)^(.*)\s+(\d+(?:\.\d+)?)%\s+Resets:\s*(.*)$`)
)
// ParseStatusOutput attempts to parse Claude or Codex usage limits from a raw
// PTY stream. Claude's /usage TUI uses cursor positioning and erase sequences
// to repaint the same panel multiple times, so the stream-append order can
// differ from what is actually visible on screen. We render the raw bytes
// through a terminal screen model first (renderVisibleScreen) and try Claude
// parsing against that; if it fails or only partially succeeds we fall back to
// the line-oriented cleanANSI which handles the simpler Codex output and
// non-cursor-repainted Claude streams.
func ParseStatusOutput(raw string) (*UsageStatus, error) {
visible := renderVisibleScreen(raw, 60, 200)
cleanRaw := cleanANSI(raw)
// Prefer whichever input yields a more complete Claude parse: a full
// session+week match beats a partial one, which beats nothing.
visStatus := &UsageStatus{RawOutput: visible}
rawStatus := &UsageStatus{RawOutput: cleanRaw}
visStatus.parseClaudeUsage(visible)
rawStatus.parseClaudeUsage(cleanRaw)
if pick := pickMoreComplete(visStatus, rawStatus); pick != nil {
return pick, nil
}
if rawStatus.parseClaudeStatus(cleanRaw) || rawStatus.parseClaudeStatus(visible) {
return rawStatus, nil
}
// Neither input produced Claude data — try Antigravity, then Codex.
parsedAntigravity := rawStatus.parseAntigravityQuota(cleanRaw)
if !rawStatus.parseAntigravityModelUsage(cleanRaw) {
rawStatus.parseAntigravityModelUsage(visible)
}
if parsedAntigravity || rawStatus.Metadata["model_usage_count"] != "" {
return rawStatus, nil
}
rawStatus.parseCodexUsage(cleanRaw)
return rawStatus, nil
}
// parseAntigravityQuota parses Antigravity `/usage` daily quota output.
// `0% used (Limit resets in 24h)` and `Usage limit: 200` populate
// DailyLimit (remaining percent), DailyResetTime, and metadata. Real
// screen-reader output may also expose the header as `quota` followed by
// `0% used` without reset or limit details; in that case only remaining and
// used percentages are populated.
// `Limit reached, resets in 5h` is treated as 100% used.
func (s *UsageStatus) parseAntigravityQuota(cleanRaw string) bool {
used := ""
reset := ""
if m := antigravityUsedQuotaRegex.FindStringSubmatch(cleanRaw); len(m) >= 3 {
used = m[1] + "%"
reset = strings.TrimSpace(m[2])
} else if m := antigravityLimitReachedRegex.FindStringSubmatch(cleanRaw); len(m) >= 2 {
used = "100%"
reset = strings.TrimSpace(m[1])
} else if m := antigravitySimpleQuotaRegex.FindStringSubmatch(cleanRaw); len(m) >= 2 {
used = m[1] + "%"
}
limitNumeric := ""
if m := antigravityUsageLimitRegex.FindStringSubmatch(cleanRaw); len(m) >= 2 {
limitNumeric = strings.TrimSpace(m[1])
}
if used == "" {
return false
}
if s.Metadata == nil {
s.Metadata = make(map[string]string)
}
s.Metadata["daily_label"] = "Daily quota"
s.DailyLimit = remainingPercent(used)
s.DailyResetTime = reset
s.Metadata["used_percent"] = used
if limitNumeric != "" {
s.Metadata["usage_limit"] = limitNumeric
}
return true
}
// parseAntigravityModelUsage parses the `/model` screen-reader "Model usage" area,
// storing per-model used percentages and reset times in metadata.
func (s *UsageStatus) parseAntigravityModelUsage(cleanRaw string) bool {
lower := strings.ToLower(cleanRaw)
start := strings.LastIndex(lower, "model usage")
if start < 0 {
return false
}
block := cleanRaw[start+len("model usage"):]
blockLower := strings.ToLower(block)
if end := strings.Index(blockLower, "(press esc"); end >= 0 {
block = block[:end]
}
type modelUsage struct {
name string
usedPercent string
reset string
}
var models []modelUsage
modelIndex := make(map[string]int)
lines := strings.Split(block, "\n")
for i := 0; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == "" || !strings.Contains(line, "%") || !strings.Contains(strings.ToLower(line), "resets:") {
continue
}
m := antigravityModelLineRegex.FindStringSubmatch(line)
if len(m) < 4 {
continue
}
name := antigravityModelNameFromUsagePrefix(m[1])
if name == "" {
continue
}
used := strings.TrimSpace(m[2]) + "%"
reset := strings.TrimSpace(m[3])
if !strings.Contains(reset, ")") && i+1 < len(lines) {
next := strings.TrimSpace(lines[i+1])
if strings.HasPrefix(next, "(") {
reset = strings.TrimSpace(reset + " " + next)
i++
}
}
entry := modelUsage{name: name, usedPercent: used, reset: reset}
if idx, ok := modelIndex[name]; ok {
models[idx] = entry
continue
}
modelIndex[name] = len(models)
models = append(models, entry)
}
if len(models) == 0 {
return false
}
if s.Metadata == nil {
s.Metadata = make(map[string]string)
}
s.Metadata["model_usage_count"] = strconv.Itoa(len(models))
for i, model := range models {
prefix := fmt.Sprintf("model_usage_%d", i)
s.Metadata[prefix+"_name"] = model.name
s.Metadata[prefix+"_used_percent"] = model.usedPercent
if model.reset != "" {
s.Metadata[prefix+"_reset"] = model.reset
}
}
return true
}
func antigravityModelNameFromUsagePrefix(prefix string) string {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return ""
}
if idx := strings.IndexAny(prefix, "▬━─█░#▉▊▋▌▍▎▏■"); idx >= 0 {
return strings.TrimSpace(prefix[:idx])
}
return prefix
}
// pickMoreComplete returns the status with more fields populated, or nil if
// neither has any Claude limit data.
func pickMoreComplete(a, b *UsageStatus) *UsageStatus {
score := func(s *UsageStatus) int {
n := 0
if s.DailyLimit != "" {
n++
}
if s.WeeklyLimit != "" {
n++
}
if s.DailyResetTime != "" {
n++
}
if s.WeeklyResetTime != "" {
n++
}
return n
}
sa, sb := score(a), score(b)
if sa == 0 && sb == 0 {
return nil
}
if sa >= sb {
return a
}
return b
}
// parseClaudeUsage attempts to parse Claude /usage output format.
//
// We locate each section header (Current session / Current week) and extract
// the "X% used … Resets Y" payload that immediately follows it, stopping at
// the next opposing header so the session header is never paired with the
// week's payload. Each header may appear multiple times in the raw stream
// (initial loading repaint then data repaint); we walk the occurrences from
// last to first and take the first one whose section contains a payload, so
// the most recent render wins.
func (s *UsageStatus) parseClaudeUsage(cleanRaw string) bool {
sessionLocs := claudeSessionHeaderRegex.FindAllStringIndex(cleanRaw, -1)
weekLocs := claudeWeekHeaderRegex.FindAllStringIndex(cleanRaw, -1)
extract := func(headerLocs [][]int, oppositeLocs [][]int) (string, string, bool) {
for i := len(headerLocs) - 1; i >= 0; i-- {
start := headerLocs[i][1]
end := len(cleanRaw)
for _, opp := range oppositeLocs {
if opp[0] > start && opp[0] < end {
end = opp[0]
}
}
if end <= start {
continue
}
section := cleanRaw[start:end]
if m := claudePayloadRegex.FindStringSubmatch(section); len(m) >= 3 {
return m[1], strings.TrimSpace(m[2]), true
}
}
return "", "", false
}
if used, reset, ok := extract(sessionLocs, weekLocs); ok {
if s.Metadata == nil {
s.Metadata = make(map[string]string)
}
s.Metadata["daily_label"] = "Current session"
s.DailyLimit = remainingPercent(used)
s.DailyResetTime = reset
}
if used, reset, ok := extract(weekLocs, sessionLocs); ok {
if s.Metadata == nil {
s.Metadata = make(map[string]string)
}
s.Metadata["weekly_label"] = "Current week"
s.WeeklyLimit = remainingPercent(used)
s.WeeklyResetTime = reset
}
return s.DailyLimit != "" || s.WeeklyLimit != ""
}
func (s *UsageStatus) parseClaudeStatus(cleanRaw string) bool {
lower := strings.ToLower(cleanRaw)
if !strings.Contains(lower, "claudecode") && !strings.Contains(lower, "claude code") {
return false
}
fields := map[string]string{
"version": "claude_status_version",
"sessionname": "claude_status_session_name",
"sessionid": "claude_status_session_id",
"cwd": "claude_status_cwd",
"loginmethod": "claude_status_login_method",
"organization": "claude_status_organization",
"email": "claude_status_email",
"model": "claude_status_model",
"mcpservers": "claude_status_mcp_servers",
}
found := 0
for _, line := range strings.Split(cleanRaw, "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
normalized := normalizeClaudeStatusKey(key)
metadataKey, ok := fields[normalized]
if !ok {
continue
}
value = strings.TrimSpace(value)
if value == "" {
continue
}
if s.Metadata == nil {
s.Metadata = make(map[string]string)
}
s.Metadata[metadataKey] = value
found++
}
if found == 0 {
return false
}
s.Metadata["status_kind"] = "claude"
return true
}
func normalizeClaudeStatusKey(key string) string {
var b strings.Builder
for _, r := range strings.ToLower(key) {
if unicode.IsLetter(r) {
b.WriteRune(r)
}
}
return b.String()
}
// parseCodexUsage attempts to parse Codex output format and sets metadata labels.
func (s *UsageStatus) parseCodexUsage(cleanRaw string) {
if match := dailyLimitRegex.FindStringSubmatch(cleanRaw); len(match) > 2 {
s.DailyLimit = strings.TrimSpace(match[1])
s.DailyResetTime = strings.TrimSpace(match[2])
}
if match := weeklyLimitRegex.FindStringSubmatch(cleanRaw); len(match) > 2 {
s.WeeklyLimit = strings.TrimSpace(match[1])
s.WeeklyResetTime = strings.TrimSpace(match[2])
}
if s.DailyLimit != "" || s.WeeklyLimit != "" {
if s.Metadata == nil {
s.Metadata = make(map[string]string)
}
if s.DailyLimit != "" {
s.Metadata["daily_label"] = "5h limit"
}
if s.WeeklyLimit != "" {
s.Metadata["weekly_label"] = "Weekly limit"
}
}
}
// remainingPercent converts a "used" percentage string to remaining percentage.
func remainingPercent(usedStr string) string {
usedStr = strings.TrimSuffix(usedStr, "%")
usedStr = strings.TrimSpace(usedStr)
usedVal, err := strconv.ParseFloat(usedStr, 64)
if err != nil {
return usedStr
}
remaining := 100 - usedVal
if remaining < 0 {
remaining = 0
}
if usedVal == float64(int(usedVal)) {
return fmt.Sprintf("%d%%", int(remaining))
}
return fmt.Sprintf("%.1f%%", remaining)
}