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) }