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 +}