From 0ab50860d03062d6111a9131b08dac0cedf142cf Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Fri, 6 Feb 2026 22:40:00 +0800 Subject: [PATCH] feat(commands): make daily cost clickable in cc statusline Add user profile GraphQL service to fetch current user's login, cache it in the daemon timer (once per lifetime), and use it to wrap the daily cost section with an OSC8 clickable link pointing to the coding agent page at {webEndpoint}/users/{login}/coding-agent/claude-code. Co-Authored-By: Claude Opus 4.6 --- commands/cc_statusline.go | 14 +++++++--- commands/cc_statusline_test.go | 18 ++++++------- daemon/cc_info_timer.go | 40 +++++++++++++++++++++++++++++ daemon/socket.go | 2 ++ model/user_profile_service.go | 47 ++++++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 model/user_profile_service.go diff --git a/commands/cc_statusline.go b/commands/cc_statusline.go index c2454d0..1266f5c 100644 --- a/commands/cc_statusline.go +++ b/commands/cc_statusline.go @@ -36,6 +36,8 @@ type ccStatuslineResult struct { GitDirty bool FiveHourUtilization *float64 SevenDayUtilization *float64 + UserLogin string + WebEndpoint string } func commandCCStatusline(c *cli.Context) error { @@ -68,7 +70,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, result.FiveHourUtilization, result.SevenDayUtilization) + output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, result.Cost, result.SessionSeconds, contextPercent, result.GitBranch, result.GitDirty, result.FiveHourUtilization, result.SevenDayUtilization, result.UserLogin, result.WebEndpoint) fmt.Println(output) return nil @@ -124,7 +126,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, fiveHourUtil, sevenDayUtil *float64) string { +func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64, gitBranch string, gitDirty bool, fiveHourUtil, sevenDayUtil *float64, userLogin, webEndpoint string) string { var parts []string // Git info FIRST (green) @@ -146,9 +148,13 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se sessionStr := color.Cyan.Sprintf("💰 $%.2f", sessionCost) parts = append(parts, sessionStr) - // Daily cost (yellow) + // Daily cost (yellow) - clickable link to coding agent page when user login is available if dailyCost > 0 { dailyStr := color.Yellow.Sprintf("📊 $%.2f", dailyCost) + if userLogin != "" && webEndpoint != "" { + url := fmt.Sprintf("%s/users/%s/coding-agent/claude-code", webEndpoint, userLogin) + dailyStr = wrapOSC8Link(url, dailyStr) + } parts = append(parts, dailyStr) } else { parts = append(parts, color.Gray.Sprint("📊 -")) @@ -248,6 +254,8 @@ func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig GitDirty: resp.GitDirty, FiveHourUtilization: resp.FiveHourUtilization, SevenDayUtilization: resp.SevenDayUtilization, + UserLogin: resp.UserLogin, + WebEndpoint: config.WebEndpoint, } } } diff --git a/commands/cc_statusline_test.go b/commands/cc_statusline_test.go index 61ec708..63ccdc5 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, nil, nil) + 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, nil, nil) + 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, nil, nil) + 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, nil, nil) + 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, nil, nil) + 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, nil, nil) + 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, nil, nil) + 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%") @@ -331,7 +331,7 @@ func (s *CCStatuslineTestSuite) TestFormatQuotaPart_ContainsLink() { 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) + 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%") @@ -339,7 +339,7 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithQuota() { } func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithoutQuota() { - output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, nil, nil) + output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, nil, nil, "", "") assert.Contains(s.T(), output, "🚦 -") } diff --git a/daemon/cc_info_timer.go b/daemon/cc_info_timer.go index 3ba5f81..31c0f13 100644 --- a/daemon/cc_info_timer.go +++ b/daemon/cc_info_timer.go @@ -50,6 +50,10 @@ type CCInfoTimerService struct { // Anthropic rate limit cache rateLimitCache *anthropicRateLimitCache + + // User profile cache (permanent for daemon lifetime) + userLogin string + userLoginFetched bool } // NewCCInfoTimerService creates a new CC info timer service @@ -157,6 +161,7 @@ func (s *CCInfoTimerService) timerLoop() { s.fetchActiveRanges(context.Background()) s.fetchGitInfo() go s.fetchRateLimit(context.Background()) + go s.fetchUserProfile(context.Background()) for { select { @@ -413,6 +418,41 @@ func (s *CCInfoTimerService) fetchRateLimit(ctx context.Context) { slog.Float64("7d", usage.SevenDayUtilization)) } +// GetCachedUserLogin returns the cached user login, or empty string if not yet fetched. +func (s *CCInfoTimerService) GetCachedUserLogin() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.userLogin +} + +// fetchUserProfile fetches the current user's login once per daemon lifetime. +func (s *CCInfoTimerService) fetchUserProfile(ctx context.Context) { + if s.config.Token == "" { + return + } + + s.mu.RLock() + fetched := s.userLoginFetched + s.mu.RUnlock() + + if fetched { + return + } + + profile, err := model.FetchCurrentUserProfile(ctx, *s.config) + if err != nil { + slog.Warn("Failed to fetch user profile", slog.Any("err", err)) + return + } + + s.mu.Lock() + s.userLogin = profile.FetchUser.Login + s.userLoginFetched = true + s.mu.Unlock() + + slog.Debug("User profile fetched", slog.String("login", profile.FetchUser.Login)) +} + // GetCachedRateLimit returns a copy of the cached rate limit data, or nil if not available. func (s *CCInfoTimerService) GetCachedRateLimit() *AnthropicRateLimitData { s.rateLimitCache.mu.RLock() diff --git a/daemon/socket.go b/daemon/socket.go index 3996b65..17ada67 100644 --- a/daemon/socket.go +++ b/daemon/socket.go @@ -45,6 +45,7 @@ type CCInfoResponse struct { GitDirty bool `json:"gitDirty"` FiveHourUtilization *float64 `json:"fiveHourUtilization,omitempty"` SevenDayUtilization *float64 `json:"sevenDayUtilization,omitempty"` + UserLogin string `json:"userLogin,omitempty"` } // StatusResponse contains daemon status information @@ -228,6 +229,7 @@ func (p *SocketHandler) handleCCInfo(conn net.Conn, msg SocketMessage) { CachedAt: cache.FetchedAt, GitBranch: gitInfo.Branch, GitDirty: gitInfo.Dirty, + UserLogin: p.ccInfoTimer.GetCachedUserLogin(), } // Populate rate limit fields if available diff --git a/model/user_profile_service.go b/model/user_profile_service.go new file mode 100644 index 0000000..328ea54 --- /dev/null +++ b/model/user_profile_service.go @@ -0,0 +1,47 @@ +package model + +import ( + "context" + "time" +) + +// FetchCurrentUserProfileQuery is the GraphQL query for fetching the current user's profile +const FetchCurrentUserProfileQuery = `query fetchCurrentUserProfile { + fetchUser { + id + login + } +}` + +// UserProfileResponse is the GraphQL response structure for user profile +type UserProfileResponse struct { + FetchUser struct { + ID int `json:"id"` + Login string `json:"login"` + } `json:"fetchUser"` +} + +// FetchCurrentUserProfile fetches the current user's profile from the GraphQL API +func FetchCurrentUserProfile(ctx context.Context, config ShellTimeConfig) (UserProfileResponse, error) { + ctx, span := modelTracer.Start(ctx, "userProfile.fetch") + defer span.End() + + var result GraphQLResponse[UserProfileResponse] + + err := SendGraphQLRequest(GraphQLRequestOptions[GraphQLResponse[UserProfileResponse]]{ + Context: ctx, + Endpoint: Endpoint{ + Token: config.Token, + APIEndpoint: config.APIEndpoint, + }, + Query: FetchCurrentUserProfileQuery, + Response: &result, + Timeout: 5 * time.Second, + }) + + if err != nil { + return UserProfileResponse{}, err + } + + return result.Data, nil +}