376 lines
12 KiB
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)
|
|
}
|