diff --git a/commands/cc_statusline.go b/commands/cc_statusline.go index 12dd721..668f84b 100644 --- a/commands/cc_statusline.go +++ b/commands/cc_statusline.go @@ -37,6 +37,7 @@ type ccStatuslineResult struct { GitDirty bool FiveHourUtilization *float64 SevenDayUtilization *float64 + QuotaError string UserLogin string WebEndpoint string } @@ -99,6 +100,7 @@ func commandCCStatusline(c *cli.Context) error { GitDirty: result.GitDirty, FiveHourUtil: result.FiveHourUtilization, SevenDayUtil: result.SevenDayUtilization, + QuotaError: result.QuotaError, UserLogin: result.UserLogin, WebEndpoint: result.WebEndpoint, SessionID: data.SessionID, @@ -168,6 +170,7 @@ type statuslineParams struct { GitDirty bool FiveHourUtil *float64 SevenDayUtil *float64 + QuotaError string UserLogin string WebEndpoint string SessionID string @@ -213,7 +216,7 @@ func formatStatuslineOutput(p statuslineParams) string { // Quota utilization (macOS only - requires Keychain for OAuth token) if runtime.GOOS == "darwin" { - parts = append(parts, formatQuotaPart(p.FiveHourUtil, p.SevenDayUtil)) + parts = append(parts, formatQuotaPart(p.FiveHourUtil, p.SevenDayUtil, p.QuotaError)) } // AI agent time (magenta) - clickable link to user profile @@ -245,8 +248,11 @@ func formatStatuslineOutput(p statuslineParams) string { // formatQuotaPart formats the rate limit quota section of the statusline. // Color is based on the max utilization of both buckets. -func formatQuotaPart(fiveHourUtil, sevenDayUtil *float64) string { +func formatQuotaPart(fiveHourUtil, sevenDayUtil *float64, quotaError string) string { if fiveHourUtil == nil || sevenDayUtil == nil { + if quotaError != "" { + return wrapOSC8Link(claudeUsageURL, color.Red.Sprintf("🚦 err:%s", quotaError)) + } return wrapOSC8Link(claudeUsageURL, color.Gray.Sprint("🚦 -")) } @@ -315,6 +321,7 @@ func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig GitDirty: resp.GitDirty, FiveHourUtilization: resp.FiveHourUtilization, SevenDayUtilization: resp.SevenDayUtilization, + QuotaError: resp.QuotaError, UserLogin: resp.UserLogin, WebEndpoint: config.WebEndpoint, } diff --git a/commands/cc_statusline_test.go b/commands/cc_statusline_test.go index 5758ed1..a9376e5 100644 --- a/commands/cc_statusline_test.go +++ b/commands/cc_statusline_test.go @@ -326,20 +326,20 @@ func (s *CCStatuslineTestSuite) TestCalculateContextPercent_WithoutCurrentUsage( // formatQuotaPart Tests func (s *CCStatuslineTestSuite) TestFormatQuotaPart_NilValues() { - result := formatQuotaPart(nil, nil) + result := formatQuotaPart(nil, nil, "") assert.Contains(s.T(), result, "🚦 -") } func (s *CCStatuslineTestSuite) TestFormatQuotaPart_OnlyFiveHourNil() { sd := 0.23 - result := formatQuotaPart(nil, &sd) + result := formatQuotaPart(nil, &sd, "") assert.Contains(s.T(), result, "🚦 -") } func (s *CCStatuslineTestSuite) TestFormatQuotaPart_LowUtilization() { fh := 10.0 sd := 20.0 - result := formatQuotaPart(&fh, &sd) + result := formatQuotaPart(&fh, &sd, "") assert.Contains(s.T(), result, "5h:10%") assert.Contains(s.T(), result, "7d:20%") } @@ -347,7 +347,7 @@ func (s *CCStatuslineTestSuite) TestFormatQuotaPart_LowUtilization() { func (s *CCStatuslineTestSuite) TestFormatQuotaPart_MediumUtilization() { fh := 55.0 sd := 30.0 - result := formatQuotaPart(&fh, &sd) + result := formatQuotaPart(&fh, &sd, "") assert.Contains(s.T(), result, "5h:55%") assert.Contains(s.T(), result, "7d:30%") } @@ -355,27 +355,47 @@ func (s *CCStatuslineTestSuite) TestFormatQuotaPart_MediumUtilization() { func (s *CCStatuslineTestSuite) TestFormatQuotaPart_HighUtilization() { fh := 45.0 sd := 85.0 - result := formatQuotaPart(&fh, &sd) + result := formatQuotaPart(&fh, &sd, "") assert.Contains(s.T(), result, "5h:45%") assert.Contains(s.T(), result, "7d:85%") } func (s *CCStatuslineTestSuite) TestFormatQuotaPart_ContainsLink() { // Nil case - result := formatQuotaPart(nil, nil) + result := formatQuotaPart(nil, nil, "") assert.Contains(s.T(), result, "claude.ai/settings/usage") assert.Contains(s.T(), result, "\033]8;;") // With values fh := 45.0 sd := 23.0 - result = formatQuotaPart(&fh, &sd) + result = formatQuotaPart(&fh, &sd, "") assert.Contains(s.T(), result, "claude.ai/settings/usage") assert.Contains(s.T(), result, "\033]8;;") assert.Contains(s.T(), result, "5h:45%") assert.Contains(s.T(), result, "7d:23%") } +func (s *CCStatuslineTestSuite) TestFormatQuotaPart_WithError() { + result := formatQuotaPart(nil, nil, "oauth") + assert.Contains(s.T(), result, "🚦 err:oauth") + assert.Contains(s.T(), result, "claude.ai/settings/usage") +} + +func (s *CCStatuslineTestSuite) TestFormatQuotaPart_WithAPIError() { + result := formatQuotaPart(nil, nil, "api:403") + assert.Contains(s.T(), result, "🚦 err:api:403") +} + +func (s *CCStatuslineTestSuite) TestFormatQuotaPart_ErrorIgnoredWhenDataPresent() { + fh := 10.0 + sd := 20.0 + // When data is present, error should not appear (data takes priority) + result := formatQuotaPart(&fh, &sd, "oauth") + assert.Contains(s.T(), result, "5h:10%") + assert.NotContains(s.T(), result, "err:") +} + func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_SessionCostWithLink() { output := formatStatuslineOutput(statuslineParams{ ModelName: "claude-opus-4", diff --git a/daemon/anthropic_ratelimit.go b/daemon/anthropic_ratelimit.go index 730e9bf..88ef051 100644 --- a/daemon/anthropic_ratelimit.go +++ b/daemon/anthropic_ratelimit.go @@ -26,6 +26,7 @@ type anthropicRateLimitCache struct { usage *AnthropicRateLimitData fetchedAt time.Time lastAttemptAt time.Time + lastError string // short error description for statusline display } // anthropicUsageResponse maps the Anthropic API response diff --git a/daemon/anthropic_ratelimit_test.go b/daemon/anthropic_ratelimit_test.go index 18b6a3d..1f9564d 100644 --- a/daemon/anthropic_ratelimit_test.go +++ b/daemon/anthropic_ratelimit_test.go @@ -3,6 +3,7 @@ package daemon import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -101,6 +102,38 @@ func TestParseKeychainJSON_EmptyAccessToken(t *testing.T) { assert.Empty(t, creds.ClaudeAiOauth.AccessToken) } +func TestShortenAPIError_HTTPStatus(t *testing.T) { + err := fmt.Errorf("anthropic usage API returned status %d", 403) + assert.Equal(t, "api:403", shortenAPIError(err)) +} + +func TestShortenAPIError_DecodeError(t *testing.T) { + err := fmt.Errorf("failed to decode usage response: unexpected EOF") + assert.Equal(t, "api:decode", shortenAPIError(err)) +} + +func TestShortenAPIError_NetworkError(t *testing.T) { + err := fmt.Errorf("dial tcp: connection refused") + assert.Equal(t, "network", shortenAPIError(err)) +} + +func TestGetCachedRateLimitError_Empty(t *testing.T) { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + assert.Empty(t, service.GetCachedRateLimitError()) +} + +func TestGetCachedRateLimitError_WithError(t *testing.T) { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + service.rateLimitCache.mu.Lock() + service.rateLimitCache.lastError = "oauth" + service.rateLimitCache.mu.Unlock() + + assert.Equal(t, "oauth", service.GetCachedRateLimitError()) +} + func TestAnthropicRateLimitCache_GetCachedRateLimit_Nil(t *testing.T) { config := &model.ShellTimeConfig{} service := NewCCInfoTimerService(config) diff --git a/daemon/cc_info_timer.go b/daemon/cc_info_timer.go index a9f866e..29448e9 100644 --- a/daemon/cc_info_timer.go +++ b/daemon/cc_info_timer.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "fmt" "log/slog" "path/filepath" "runtime" @@ -417,18 +418,25 @@ func (s *CCInfoTimerService) fetchRateLimit(ctx context.Context) { token, err := fetchClaudeCodeOAuthToken() if err != nil || token == "" { slog.Debug("Failed to get Claude Code OAuth token", slog.Any("err", err)) + s.rateLimitCache.mu.Lock() + s.rateLimitCache.lastError = "oauth" + s.rateLimitCache.mu.Unlock() return } usage, err := fetchAnthropicUsage(ctx, token) if err != nil { slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err)) + s.rateLimitCache.mu.Lock() + s.rateLimitCache.lastError = shortenAPIError(err) + s.rateLimitCache.mu.Unlock() return } s.rateLimitCache.mu.Lock() s.rateLimitCache.usage = usage s.rateLimitCache.fetchedAt = time.Now() + s.rateLimitCache.lastError = "" s.rateLimitCache.mu.Unlock() // Send usage data to server for push notification scheduling (fire-and-forget) @@ -535,3 +543,28 @@ func (s *CCInfoTimerService) GetCachedRateLimit() *AnthropicRateLimitData { copy := *s.rateLimitCache.usage return © } + +// GetCachedRateLimitError returns the last error from rate limit fetching, or empty string if none. +func (s *CCInfoTimerService) GetCachedRateLimitError() string { + s.rateLimitCache.mu.RLock() + defer s.rateLimitCache.mu.RUnlock() + return s.rateLimitCache.lastError +} + +// shortenAPIError converts an Anthropic usage API error into a short string for statusline display. +func shortenAPIError(err error) string { + msg := err.Error() + + // Check for HTTP status code pattern + var status int + if _, scanErr := fmt.Sscanf(msg, "anthropic usage API returned status %d", &status); scanErr == nil { + return fmt.Sprintf("api:%d", status) + } + + // Decode errors + if len(msg) >= 6 && msg[:6] == "failed" { + return "api:decode" + } + + return "network" +} diff --git a/daemon/socket.go b/daemon/socket.go index 25874f8..132ddb7 100644 --- a/daemon/socket.go +++ b/daemon/socket.go @@ -52,6 +52,7 @@ type CCInfoResponse struct { GitDirty bool `json:"gitDirty"` FiveHourUtilization *float64 `json:"fiveHourUtilization,omitempty"` SevenDayUtilization *float64 `json:"sevenDayUtilization,omitempty"` + QuotaError string `json:"quotaError,omitempty"` UserLogin string `json:"userLogin,omitempty"` } @@ -249,10 +250,12 @@ func (p *SocketHandler) handleCCInfo(conn net.Conn, msg SocketMessage) { UserLogin: p.ccInfoTimer.GetCachedUserLogin(), } - // Populate rate limit fields if available + // Populate rate limit fields if available, otherwise surface error if rl := p.ccInfoTimer.GetCachedRateLimit(); rl != nil { response.FiveHourUtilization = &rl.FiveHourUtilization response.SevenDayUtilization = &rl.SevenDayUtilization + } else { + response.QuotaError = p.ccInfoTimer.GetCachedRateLimitError() } encoder := json.NewEncoder(conn)