From fa73748f553015eb876500c1f3198abded453fee Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Fri, 6 Feb 2026 21:30:33 +0800 Subject: [PATCH 1/2] feat(daemon): add Anthropic rate limit quota to cc statusline Display 5-hour and 7-day session quota utilization from the Anthropic OAuth usage API in the cc statusline output. The daemon fetches and caches this data (10-minute TTL) using the same lazy-fetch background refresh pattern as git branch and today cost. OAuth token is read fresh from macOS Keychain on each fetch; non-macOS platforms gracefully omit these fields. Co-Authored-By: Claude Opus 4.6 --- commands/cc_statusline.go | 56 +++++++++--- commands/cc_statusline_test.go | 107 +++++++++++++++++++++-- daemon/anthropic_ratelimit.go | 107 +++++++++++++++++++++++ daemon/anthropic_ratelimit_test.go | 133 +++++++++++++++++++++++++++++ daemon/cc_info_handler_test.go | 77 +++++++++++++++++ daemon/cc_info_timer.go | 84 ++++++++++++++++-- daemon/socket.go | 8 ++ 7 files changed, 548 insertions(+), 24 deletions(-) create mode 100644 daemon/anthropic_ratelimit.go create mode 100644 daemon/anthropic_ratelimit_test.go diff --git a/commands/cc_statusline.go b/commands/cc_statusline.go index bdd8fa9..2346adc 100644 --- a/commands/cc_statusline.go +++ b/commands/cc_statusline.go @@ -24,10 +24,12 @@ var CCStatuslineCommand = &cli.Command{ // ccStatuslineResult combines daily stats with git info from daemon type ccStatuslineResult struct { - Cost float64 - SessionSeconds int - GitBranch string - GitDirty bool + Cost float64 + SessionSeconds int + GitBranch string + GitDirty bool + FiveHourUtilization *float64 + SevenDayUtilization *float64 } func commandCCStatusline(c *cli.Context) error { @@ -60,7 +62,7 @@ func commandCCStatusline(c *cli.Context) error { } // Format and output - output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, result.Cost, result.SessionSeconds, contextPercent, result.GitBranch, result.GitDirty) + output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, result.Cost, result.SessionSeconds, contextPercent, result.GitBranch, result.GitDirty, result.FiveHourUtilization, result.SevenDayUtilization) fmt.Println(output) return nil @@ -116,7 +118,7 @@ func calculateContextPercent(cw model.CCStatuslineContextWindow) float64 { return float64(currentTokens) / float64(cw.ContextWindowSize) * 100 } -func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64, gitBranch string, gitDirty bool) string { +func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64, gitBranch string, gitDirty bool, fiveHourUtil, sevenDayUtil *float64) string { var parts []string // Git info FIRST (green) @@ -146,6 +148,9 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se parts = append(parts, color.Gray.Sprint("📊 -")) } + // Quota utilization + parts = append(parts, formatQuotaPart(fiveHourUtil, sevenDayUtil)) + // AI agent time (magenta) if sessionSeconds > 0 { timeStr := color.Magenta.Sprintf("⏱️ %s", formatSessionDuration(sessionSeconds)) @@ -169,8 +174,35 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se return strings.Join(parts, " | ") } +// 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 { + if fiveHourUtil == nil || sevenDayUtil == nil { + return color.Gray.Sprint("🚦 -") + } + + fh := *fiveHourUtil * 100 + sd := *sevenDayUtil * 100 + + text := fmt.Sprintf("🚦 5h:%.0f%% 7d:%.0f%%", fh, sd) + + maxUtil := fh + if sd > maxUtil { + maxUtil = sd + } + + switch { + case maxUtil >= 80: + return color.Red.Sprint(text) + case maxUtil >= 50: + return color.Yellow.Sprint(text) + default: + return color.Green.Sprint(text) + } +} + func outputFallback() { - fmt.Println(color.Gray.Sprint("🌿 - | 🤖 - | 💰 - | 📊 - | ⏱️ - | 📈 -%")) + fmt.Println(color.Gray.Sprint("🌿 - | 🤖 - | 💰 - | 📊 - | 🚦 - | ⏱️ - | 📈 -%")) } // formatSessionDuration formats seconds into a human-readable duration @@ -201,10 +233,12 @@ func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, workingDir, 50*time.Millisecond) if err == nil && resp != nil { return ccStatuslineResult{ - Cost: resp.TotalCostUSD, - SessionSeconds: resp.TotalSessionSeconds, - GitBranch: resp.GitBranch, - GitDirty: resp.GitDirty, + Cost: resp.TotalCostUSD, + SessionSeconds: resp.TotalSessionSeconds, + GitBranch: resp.GitBranch, + GitDirty: resp.GitDirty, + FiveHourUtilization: resp.FiveHourUtilization, + SevenDayUtilization: resp.SevenDayUtilization, } } } diff --git a/commands/cc_statusline_test.go b/commands/cc_statusline_test.go index 2dc29c1..e3c7bed 100644 --- a/commands/cc_statusline_test.go +++ b/commands/cc_statusline_test.go @@ -150,7 +150,7 @@ func (s *CCStatuslineTestSuite) TestGetDaemonInfo_UsesDefaultSocketPath() { // formatStatuslineOutput Tests func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_AllValues() { - output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false) + output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, nil, nil) // Should contain all components assert.Contains(s.T(), output, "🌿 main") @@ -162,7 +162,7 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_AllValues() { } func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithDirtyBranch() { - output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "feature/test", true) + output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "feature/test", true, nil, nil) // Should contain branch with asterisk for dirty assert.Contains(s.T(), output, "🌿 feature/test*") @@ -170,7 +170,7 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithDirtyBranch() { } func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_NoBranch() { - output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "", false) + output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "", false, nil, nil) // Should show "-" for no branch assert.Contains(s.T(), output, "🌿 -") @@ -178,7 +178,7 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_NoBranch() { } func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroDailyCost() { - output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 300, 50.0, "main", false) + output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 300, 50.0, "main", false, nil, nil) // Should show "-" for zero daily cost assert.Contains(s.T(), output, "📊 -") @@ -186,14 +186,14 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroDailyCost() { } func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroSessionSeconds() { - output := formatStatuslineOutput("claude-sonnet", 0.50, 1.0, 0, 50.0, "main", false) + output := formatStatuslineOutput("claude-sonnet", 0.50, 1.0, 0, 50.0, "main", false, nil, nil) // Should show "-" for zero session seconds assert.Contains(s.T(), output, "⏱️ -") } func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_HighContextPercentage() { - output := formatStatuslineOutput("test-model", 1.0, 1.0, 60, 85.0, "main", false) + output := formatStatuslineOutput("test-model", 1.0, 1.0, 60, 85.0, "main", false, nil, nil) // Should contain the percentage (color codes may vary) assert.Contains(s.T(), output, "85%") @@ -201,7 +201,7 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_HighContextPercentage } func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_LowContextPercentage() { - output := formatStatuslineOutput("test-model", 1.0, 1.0, 45, 25.0, "main", false) + output := formatStatuslineOutput("test-model", 1.0, 1.0, 45, 25.0, "main", false, nil, nil) // Should contain the percentage assert.Contains(s.T(), output, "25%") @@ -275,6 +275,99 @@ func (s *CCStatuslineTestSuite) TestCalculateContextPercent_WithoutCurrentUsage( assert.Equal(s.T(), float64(50), percent) } +// formatQuotaPart Tests + +func (s *CCStatuslineTestSuite) TestFormatQuotaPart_NilValues() { + result := formatQuotaPart(nil, nil) + assert.Contains(s.T(), result, "🚦 -") +} + +func (s *CCStatuslineTestSuite) TestFormatQuotaPart_OnlyFiveHourNil() { + sd := 0.23 + result := formatQuotaPart(nil, &sd) + assert.Contains(s.T(), result, "🚦 -") +} + +func (s *CCStatuslineTestSuite) TestFormatQuotaPart_LowUtilization() { + fh := 0.10 + sd := 0.20 + result := formatQuotaPart(&fh, &sd) + assert.Contains(s.T(), result, "5h:10%") + assert.Contains(s.T(), result, "7d:20%") +} + +func (s *CCStatuslineTestSuite) TestFormatQuotaPart_MediumUtilization() { + fh := 0.55 + sd := 0.30 + result := formatQuotaPart(&fh, &sd) + assert.Contains(s.T(), result, "5h:55%") + assert.Contains(s.T(), result, "7d:30%") +} + +func (s *CCStatuslineTestSuite) TestFormatQuotaPart_HighUtilization() { + fh := 0.45 + sd := 0.85 + result := formatQuotaPart(&fh, &sd) + assert.Contains(s.T(), result, "5h:45%") + assert.Contains(s.T(), result, "7d:85%") +} + +func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithQuota() { + fh := 0.45 + sd := 0.23 + output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, &fh, &sd) + + assert.Contains(s.T(), output, "5h:45%") + assert.Contains(s.T(), output, "7d:23%") + assert.Contains(s.T(), output, "🚦") +} + +func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithoutQuota() { + output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, nil, nil) + + assert.Contains(s.T(), output, "🚦 -") +} + +func (s *CCStatuslineTestSuite) TestGetDaemonInfo_PropagatesRateLimitFields() { + listener, err := net.Listen("unix", s.socketPath) + assert.NoError(s.T(), err) + s.listener = listener + + fh := 0.45 + sd := 0.23 + go func() { + conn, _ := listener.Accept() + defer conn.Close() + + var msg daemon.SocketMessage + json.NewDecoder(conn).Decode(&msg) + + response := daemon.CCInfoResponse{ + TotalCostUSD: 1.23, + TotalSessionSeconds: 100, + TimeRange: "today", + CachedAt: time.Now(), + GitBranch: "main", + FiveHourUtilization: &fh, + SevenDayUtilization: &sd, + } + json.NewEncoder(conn).Encode(response) + }() + + time.Sleep(10 * time.Millisecond) + + config := model.ShellTimeConfig{ + SocketPath: s.socketPath, + } + + result := getDaemonInfoWithFallback(context.Background(), config, "/some/path") + + assert.NotNil(s.T(), result.FiveHourUtilization) + assert.NotNil(s.T(), result.SevenDayUtilization) + assert.Equal(s.T(), 0.45, *result.FiveHourUtilization) + assert.Equal(s.T(), 0.23, *result.SevenDayUtilization) +} + func TestCCStatuslineTestSuite(t *testing.T) { suite.Run(t, new(CCStatuslineTestSuite)) } diff --git a/daemon/anthropic_ratelimit.go b/daemon/anthropic_ratelimit.go new file mode 100644 index 0000000..bf4d6f4 --- /dev/null +++ b/daemon/anthropic_ratelimit.go @@ -0,0 +1,107 @@ +package daemon + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "runtime" + "sync" + "time" +) + +const anthropicUsageCacheTTL = 10 * time.Minute + +// AnthropicRateLimitData holds the parsed rate limit utilization data +type AnthropicRateLimitData struct { + FiveHourUtilization float64 + FiveHourResetsAt string + SevenDayUtilization float64 + SevenDayResetsAt string +} + +type anthropicRateLimitCache struct { + mu sync.RWMutex + usage *AnthropicRateLimitData + fetchedAt time.Time + lastAttemptAt time.Time +} + +// anthropicUsageResponse maps the Anthropic API response +type anthropicUsageResponse struct { + FiveHour anthropicUsageBucket `json:"five_hour"` + SevenDay anthropicUsageBucket `json:"seven_day"` +} + +type anthropicUsageBucket struct { + Utilization float64 `json:"utilization"` + ResetsAt string `json:"resets_at"` +} + +// keychainCredentials maps the JSON stored in macOS Keychain for Claude Code +type keychainCredentials struct { + ClaudeAiOauth *keychainOAuthEntry `json:"claudeAiOauth"` +} + +type keychainOAuthEntry struct { + AccessToken string `json:"accessToken"` +} + +// fetchClaudeCodeOAuthToken reads the OAuth token from macOS Keychain. +// Returns ("", nil) on non-macOS platforms. +func fetchClaudeCodeOAuthToken() (string, error) { + if runtime.GOOS != "darwin" { + return "", nil + } + + out, err := exec.Command("security", "find-generic-password", "-s", "Claude Code-credentials", "-w").Output() + if err != nil { + return "", fmt.Errorf("keychain lookup failed: %w", err) + } + + var creds keychainCredentials + if err := json.Unmarshal(out, &creds); err != nil { + return "", fmt.Errorf("failed to parse keychain JSON: %w", err) + } + + if creds.ClaudeAiOauth == nil || creds.ClaudeAiOauth.AccessToken == "" { + return "", fmt.Errorf("no OAuth access token found in keychain") + } + + return creds.ClaudeAiOauth.AccessToken, nil +} + +// fetchAnthropicUsage calls the Anthropic OAuth usage API and returns rate limit data. +func fetchAnthropicUsage(ctx context.Context, token string) (*AnthropicRateLimitData, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.anthropic.com/api/oauth/usage", nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("anthropic-beta", "oauth-2025-04-20") + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("anthropic usage API returned status %d", resp.StatusCode) + } + + var usage anthropicUsageResponse + if err := json.NewDecoder(resp.Body).Decode(&usage); err != nil { + return nil, fmt.Errorf("failed to decode usage response: %w", err) + } + + return &AnthropicRateLimitData{ + FiveHourUtilization: usage.FiveHour.Utilization, + FiveHourResetsAt: usage.FiveHour.ResetsAt, + SevenDayUtilization: usage.SevenDay.Utilization, + SevenDayResetsAt: usage.SevenDay.ResetsAt, + }, nil +} diff --git a/daemon/anthropic_ratelimit_test.go b/daemon/anthropic_ratelimit_test.go new file mode 100644 index 0000000..18b6a3d --- /dev/null +++ b/daemon/anthropic_ratelimit_test.go @@ -0,0 +1,133 @@ +package daemon + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/malamtime/cli/model" + "github.com/stretchr/testify/assert" +) + +func TestFetchAnthropicUsage_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + assert.Equal(t, "oauth-2025-04-20", r.Header.Get("anthropic-beta")) + + resp := anthropicUsageResponse{ + FiveHour: anthropicUsageBucket{ + Utilization: 0.45, + ResetsAt: "2025-01-15T12:00:00Z", + }, + SevenDay: anthropicUsageBucket{ + Utilization: 0.23, + ResetsAt: "2025-01-20T00:00:00Z", + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // We need to test with the real function but override the URL. + // Since fetchAnthropicUsage uses a hardcoded URL, we test the parsing logic + // by calling the test server directly. + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("anthropic-beta", "oauth-2025-04-20") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + var usage anthropicUsageResponse + err = json.NewDecoder(resp.Body).Decode(&usage) + assert.NoError(t, err) + + assert.Equal(t, 0.45, usage.FiveHour.Utilization) + assert.Equal(t, "2025-01-15T12:00:00Z", usage.FiveHour.ResetsAt) + assert.Equal(t, 0.23, usage.SevenDay.Utilization) + assert.Equal(t, "2025-01-20T00:00:00Z", usage.SevenDay.ResetsAt) +} + +func TestFetchAnthropicUsage_NonOKStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + // Test that non-200 status is handled - we can't directly call fetchAnthropicUsage + // with a custom URL, so we verify the error handling pattern + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestParseKeychainJSON(t *testing.T) { + raw := `{"claudeAiOauth":{"accessToken":"sk-ant-test-token-123"}}` + + var creds keychainCredentials + err := json.Unmarshal([]byte(raw), &creds) + assert.NoError(t, err) + assert.NotNil(t, creds.ClaudeAiOauth) + assert.Equal(t, "sk-ant-test-token-123", creds.ClaudeAiOauth.AccessToken) +} + +func TestParseKeychainJSON_MissingOAuth(t *testing.T) { + raw := `{"someOtherKey":"value"}` + + var creds keychainCredentials + err := json.Unmarshal([]byte(raw), &creds) + assert.NoError(t, err) + assert.Nil(t, creds.ClaudeAiOauth) +} + +func TestParseKeychainJSON_EmptyAccessToken(t *testing.T) { + raw := `{"claudeAiOauth":{"accessToken":""}}` + + var creds keychainCredentials + err := json.Unmarshal([]byte(raw), &creds) + assert.NoError(t, err) + assert.NotNil(t, creds.ClaudeAiOauth) + assert.Empty(t, creds.ClaudeAiOauth.AccessToken) +} + +func TestAnthropicRateLimitCache_GetCachedRateLimit_Nil(t *testing.T) { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + result := service.GetCachedRateLimit() + assert.Nil(t, result) +} + +func TestAnthropicRateLimitCache_GetCachedRateLimit_ReturnsCopy(t *testing.T) { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + service.rateLimitCache.mu.Lock() + service.rateLimitCache.usage = &AnthropicRateLimitData{ + FiveHourUtilization: 0.5, + SevenDayUtilization: 0.3, + } + service.rateLimitCache.mu.Unlock() + + result := service.GetCachedRateLimit() + assert.NotNil(t, result) + assert.Equal(t, 0.5, result.FiveHourUtilization) + assert.Equal(t, 0.3, result.SevenDayUtilization) + + // Modify returned copy - original should be unchanged + result.FiveHourUtilization = 0.99 + + service.rateLimitCache.mu.RLock() + assert.Equal(t, 0.5, service.rateLimitCache.usage.FiveHourUtilization) + service.rateLimitCache.mu.RUnlock() +} diff --git a/daemon/cc_info_handler_test.go b/daemon/cc_info_handler_test.go index a470fab..e92f802 100644 --- a/daemon/cc_info_handler_test.go +++ b/daemon/cc_info_handler_test.go @@ -327,6 +327,83 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_SendsCorrectMessage() { assert.Equal(s.T(), CCInfoTimeRangeWeek, req.TimeRange) } +func (s *CCInfoHandlerTestSuite) TestHandleCCInfo_IncludesRateLimitWhenCached() { + config := &model.ShellTimeConfig{ + SocketPath: s.socketPath, + } + + ch := NewGoChannel(PubSubConfig{OutputChannelBuffer: 10}, nil) + defer ch.Close() + handler := NewSocketHandler(config, ch) + + // Pre-populate rate limit cache + handler.ccInfoTimer.rateLimitCache.mu.Lock() + handler.ccInfoTimer.rateLimitCache.usage = &AnthropicRateLimitData{ + FiveHourUtilization: 0.45, + SevenDayUtilization: 0.23, + } + handler.ccInfoTimer.rateLimitCache.fetchedAt = time.Now() + handler.ccInfoTimer.rateLimitCache.mu.Unlock() + + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + msg := SocketMessage{ + Type: SocketMessageTypeCCInfo, + Payload: map[string]interface{}{ + "timeRange": "today", + }, + } + + go func() { + handler.handleCCInfo(serverConn, msg) + }() + + var response CCInfoResponse + decoder := json.NewDecoder(clientConn) + err := decoder.Decode(&response) + + assert.NoError(s.T(), err) + assert.NotNil(s.T(), response.FiveHourUtilization) + assert.NotNil(s.T(), response.SevenDayUtilization) + assert.Equal(s.T(), 0.45, *response.FiveHourUtilization) + assert.Equal(s.T(), 0.23, *response.SevenDayUtilization) +} + +func (s *CCInfoHandlerTestSuite) TestHandleCCInfo_OmitsRateLimitWhenNotCached() { + config := &model.ShellTimeConfig{ + SocketPath: s.socketPath, + } + + ch := NewGoChannel(PubSubConfig{OutputChannelBuffer: 10}, nil) + defer ch.Close() + handler := NewSocketHandler(config, ch) + + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + msg := SocketMessage{ + Type: SocketMessageTypeCCInfo, + Payload: map[string]interface{}{ + "timeRange": "today", + }, + } + + go func() { + handler.handleCCInfo(serverConn, msg) + }() + + var response CCInfoResponse + decoder := json.NewDecoder(clientConn) + err := decoder.Decode(&response) + + assert.NoError(s.T(), err) + assert.Nil(s.T(), response.FiveHourUtilization) + assert.Nil(s.T(), response.SevenDayUtilization) +} + func TestCCInfoHandlerTestSuite(t *testing.T) { suite.Run(t, new(CCInfoHandlerTestSuite)) } diff --git a/daemon/cc_info_timer.go b/daemon/cc_info_timer.go index e1814fc..3ba5f81 100644 --- a/daemon/cc_info_timer.go +++ b/daemon/cc_info_timer.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" "path/filepath" + "runtime" "sync" "time" @@ -46,16 +47,20 @@ type CCInfoTimerService struct { // Git info cache (per working directory) gitCache map[string]*GitCacheEntry + + // Anthropic rate limit cache + rateLimitCache *anthropicRateLimitCache } // NewCCInfoTimerService creates a new CC info timer service func NewCCInfoTimerService(config *model.ShellTimeConfig) *CCInfoTimerService { return &CCInfoTimerService{ - config: config, - cache: make(map[CCInfoTimeRange]CCInfoCache), - activeRanges: make(map[CCInfoTimeRange]bool), - gitCache: make(map[string]*GitCacheEntry), - stopChan: make(chan struct{}), + config: config, + cache: make(map[CCInfoTimeRange]CCInfoCache), + activeRanges: make(map[CCInfoTimeRange]bool), + gitCache: make(map[string]*GitCacheEntry), + rateLimitCache: &anthropicRateLimitCache{}, + stopChan: make(chan struct{}), } } @@ -129,12 +134,18 @@ func (s *CCInfoTimerService) stopTimer() { s.ticker.Stop() s.timerRunning = false - // Clear active ranges and git cache when stopping + // Clear active ranges, git cache, and rate limit cache when stopping s.mu.Lock() s.activeRanges = make(map[CCInfoTimeRange]bool) s.gitCache = make(map[string]*GitCacheEntry) s.mu.Unlock() + s.rateLimitCache.mu.Lock() + s.rateLimitCache.usage = nil + s.rateLimitCache.fetchedAt = time.Time{} + s.rateLimitCache.lastAttemptAt = time.Time{} + s.rateLimitCache.mu.Unlock() + slog.Info("CC info timer stopped due to inactivity") } @@ -145,6 +156,7 @@ func (s *CCInfoTimerService) timerLoop() { // Fetch immediately on start s.fetchActiveRanges(context.Background()) s.fetchGitInfo() + go s.fetchRateLimit(context.Background()) for { select { @@ -158,6 +170,7 @@ func (s *CCInfoTimerService) timerLoop() { } s.fetchActiveRanges(context.Background()) s.fetchGitInfo() + go s.fetchRateLimit(context.Background()) case <-s.stopChan: return @@ -354,3 +367,62 @@ func (s *CCInfoTimerService) cleanupStaleGitCache() { } } } + +// fetchRateLimit fetches Anthropic rate limit data if cache is stale. +// Only runs on macOS where Keychain access is available. +func (s *CCInfoTimerService) fetchRateLimit(ctx context.Context) { + if runtime.GOOS != "darwin" { + return + } + + // Check cache TTL under read lock - skip if data is fresh or we attempted recently + s.rateLimitCache.mu.RLock() + sinceLastFetch := time.Since(s.rateLimitCache.fetchedAt) + sinceLastAttempt := time.Since(s.rateLimitCache.lastAttemptAt) + s.rateLimitCache.mu.RUnlock() + + if sinceLastFetch < anthropicUsageCacheTTL || sinceLastAttempt < anthropicUsageCacheTTL { + return + } + + // Record attempt time before fetching to avoid retrying on every tick + s.rateLimitCache.mu.Lock() + s.rateLimitCache.lastAttemptAt = time.Now() + s.rateLimitCache.mu.Unlock() + + // Read token fresh from Keychain (not cached) + token, err := fetchClaudeCodeOAuthToken() + if err != nil || token == "" { + slog.Debug("Failed to get Claude Code OAuth token", slog.Any("err", err)) + return + } + + usage, err := fetchAnthropicUsage(ctx, token) + if err != nil { + slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err)) + return + } + + s.rateLimitCache.mu.Lock() + s.rateLimitCache.usage = usage + s.rateLimitCache.fetchedAt = time.Now() + s.rateLimitCache.mu.Unlock() + + slog.Debug("Anthropic rate limit updated", + slog.Float64("5h", usage.FiveHourUtilization), + slog.Float64("7d", usage.SevenDayUtilization)) +} + +// GetCachedRateLimit returns a copy of the cached rate limit data, or nil if not available. +func (s *CCInfoTimerService) GetCachedRateLimit() *AnthropicRateLimitData { + s.rateLimitCache.mu.RLock() + defer s.rateLimitCache.mu.RUnlock() + + if s.rateLimitCache.usage == nil { + return nil + } + + // Return a copy + copy := *s.rateLimitCache.usage + return © +} diff --git a/daemon/socket.go b/daemon/socket.go index 0f85745..3996b65 100644 --- a/daemon/socket.go +++ b/daemon/socket.go @@ -43,6 +43,8 @@ type CCInfoResponse struct { CachedAt time.Time `json:"cachedAt"` GitBranch string `json:"gitBranch"` GitDirty bool `json:"gitDirty"` + FiveHourUtilization *float64 `json:"fiveHourUtilization,omitempty"` + SevenDayUtilization *float64 `json:"sevenDayUtilization,omitempty"` } // StatusResponse contains daemon status information @@ -228,6 +230,12 @@ func (p *SocketHandler) handleCCInfo(conn net.Conn, msg SocketMessage) { GitDirty: gitInfo.Dirty, } + // Populate rate limit fields if available + if rl := p.ccInfoTimer.GetCachedRateLimit(); rl != nil { + response.FiveHourUtilization = &rl.FiveHourUtilization + response.SevenDayUtilization = &rl.SevenDayUtilization + } + encoder := json.NewEncoder(conn) if err := encoder.Encode(response); err != nil { slog.Error("Error encoding cc_info response", slog.Any("err", err)) From 682d05a6d0b3fdddd80245daa0545f3b63ab6c07 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Fri, 6 Feb 2026 21:58:32 +0800 Subject: [PATCH 2/2] feat(commands): add clickable link to quota section in cc statusline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the quota section (🚦) in OSC 8 hyperlink escape sequences so it becomes a clickable link to https://claude.ai/settings/usage in terminals that support it (iTerm2, Kitty, WezTerm). Co-Authored-By: Claude Opus 4.6 --- commands/cc_statusline.go | 19 ++++++++++++++----- commands/cc_statusline_test.go | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/commands/cc_statusline.go b/commands/cc_statusline.go index 2346adc..c2454d0 100644 --- a/commands/cc_statusline.go +++ b/commands/cc_statusline.go @@ -16,6 +16,12 @@ import ( "github.com/urfave/cli/v2" ) +const claudeUsageURL = "https://claude.ai/settings/usage" + +func wrapOSC8Link(url, text string) string { + return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, text) +} + var CCStatuslineCommand = &cli.Command{ Name: "statusline", Usage: "Output statusline for Claude Code (reads JSON from stdin)", @@ -178,7 +184,7 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se // Color is based on the max utilization of both buckets. func formatQuotaPart(fiveHourUtil, sevenDayUtil *float64) string { if fiveHourUtil == nil || sevenDayUtil == nil { - return color.Gray.Sprint("🚦 -") + return wrapOSC8Link(claudeUsageURL, color.Gray.Sprint("🚦 -")) } fh := *fiveHourUtil * 100 @@ -191,18 +197,21 @@ func formatQuotaPart(fiveHourUtil, sevenDayUtil *float64) string { maxUtil = sd } + var colored string switch { case maxUtil >= 80: - return color.Red.Sprint(text) + colored = color.Red.Sprint(text) case maxUtil >= 50: - return color.Yellow.Sprint(text) + colored = color.Yellow.Sprint(text) default: - return color.Green.Sprint(text) + colored = color.Green.Sprint(text) } + return wrapOSC8Link(claudeUsageURL, colored) } func outputFallback() { - fmt.Println(color.Gray.Sprint("🌿 - | 🤖 - | 💰 - | 📊 - | 🚦 - | ⏱️ - | 📈 -%")) + quotaPart := wrapOSC8Link(claudeUsageURL, "🚦 -") + fmt.Println(color.Gray.Sprint("🌿 - | 🤖 - | 💰 - | 📊 - | " + quotaPart + " | ⏱️ - | 📈 -%")) } // formatSessionDuration formats seconds into a human-readable duration diff --git a/commands/cc_statusline_test.go b/commands/cc_statusline_test.go index e3c7bed..61ec708 100644 --- a/commands/cc_statusline_test.go +++ b/commands/cc_statusline_test.go @@ -312,6 +312,22 @@ func (s *CCStatuslineTestSuite) TestFormatQuotaPart_HighUtilization() { assert.Contains(s.T(), result, "7d:85%") } +func (s *CCStatuslineTestSuite) TestFormatQuotaPart_ContainsLink() { + // Nil case + result := formatQuotaPart(nil, nil) + assert.Contains(s.T(), result, "claude.ai/settings/usage") + assert.Contains(s.T(), result, "\033]8;;") + + // With values + fh := 0.45 + sd := 0.23 + 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) TestFormatStatuslineOutput_WithQuota() { fh := 0.45 sd := 0.23