Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions commands/cc_statusline.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type ccStatuslineResult struct {
GitDirty bool
FiveHourUtilization *float64
SevenDayUtilization *float64
QuotaError string
UserLogin string
WebEndpoint string
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -168,6 +170,7 @@ type statuslineParams struct {
GitDirty bool
FiveHourUtil *float64
SevenDayUtil *float64
QuotaError string
UserLogin string
WebEndpoint string
SessionID string
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("🚦 -"))
}

Expand Down Expand Up @@ -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,
}
Expand Down
34 changes: 27 additions & 7 deletions commands/cc_statusline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,56 +326,76 @@ 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%")
}

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

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",
Expand Down
1 change: 1 addition & 0 deletions daemon/anthropic_ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions daemon/anthropic_ratelimit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package daemon
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions daemon/cc_info_timer.go
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 lastError not cleared in stopTimer, causing stale error display after inactivity restart

The stopTimer() method at daemon/cc_info_timer.go:150-154 clears usage, fetchedAt, and lastAttemptAt from the rate limit cache, but does not clear the newly added lastError field. After the timer stops due to inactivity and a new client triggers a restart, GetCachedRateLimit() returns nil (usage was cleared) while GetCachedRateLimitError() returns a stale error string from the previous session. This causes handleCCInfo at daemon/socket.go:257-258 to surface a stale error in the statusline, even though the previous error may no longer be relevant.

(Refers to lines 150-154)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package daemon

import (
"context"
"fmt"
"log/slog"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -535,3 +543,28 @@ func (s *CCInfoTimerService) GetCachedRateLimit() *AnthropicRateLimitData {
copy := *s.rateLimitCache.usage
return &copy
}

// 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"
}
Comment on lines +565 to +567
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While parsing error strings works, it can be brittle if the error message format changes. Using strings.HasPrefix is more idiomatic and slightly more robust than checking the length and slicing. Making the prefix check more specific also reduces the chance of false positives.

For a more robust long-term solution, consider introducing typed errors. This would allow you to use errors.As for type-safe error checking, avoiding string parsing altogether.

You will need to add import "strings" at the top of the file for the suggestion to work.

Suggested change
if len(msg) >= 6 && msg[:6] == "failed" {
return "api:decode"
}
if strings.HasPrefix(msg, "failed to decode") {
return "api:decode"
}


return "network"
}
5 changes: 4 additions & 1 deletion daemon/socket.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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)
Expand Down