-
Notifications
You must be signed in to change notification settings - Fork 0
feat(commands): make daily cost clickable in cc statusline #215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, "", "") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While the existing tests for For example, you could add a new test like this: func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithClickableLink() {
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, nil, nil, "testuser", "https://example.com")
expectedURL := "https://example.com/users/testuser/coding-agent/claude-code"
dailyStr := color.Yellow.Sprintf("📊 $%.2f", 4.56)
expectedLink := wrapOSC8Link(expectedURL, dailyStr)
assert.Contains(s.T(), output, expectedLink)
}Since a code suggestion can't add a new function, you could also adapt an existing test to include these assertions. |
||
|
|
||
| // Should contain all components | ||
| assert.Contains(s.T(), output, "🌿 main") | ||
|
|
@@ -162,46 +162,46 @@ 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*") | ||
| assert.Contains(s.T(), output, "🤖 claude-opus-4") | ||
| } | ||
|
|
||
| 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, "🌿 -") | ||
| assert.Contains(s.T(), output, "🤖 claude-opus-4") | ||
| } | ||
|
|
||
| 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, "📊 -") | ||
| assert.Contains(s.T(), output, "5m0s") // Session time (300 seconds = 5m) | ||
| } | ||
|
|
||
| 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%") | ||
| assert.Contains(s.T(), output, "1m0s") | ||
| } | ||
|
|
||
| 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,15 +331,15 @@ 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%") | ||
| 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) | ||
| output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, nil, nil, "", "") | ||
|
|
||
| assert.Contains(s.T(), output, "🚦 -") | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)) | ||
| } | ||
|
Comment on lines
+429
to
+454
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation of To apply this suggestion, you'll also need to modify the func (s *CCInfoTimerService) fetchUserProfile(ctx context.Context) {
s.userProfileOnce.Do(func() {
if s.config.Token == "" {
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.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() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 User login not URL-encoded when constructing clickable link URL
The
userLoginstring is directly interpolated into the URL path without URL encoding. If a user's login contains special characters (spaces,@,#,%, or non-ASCII characters), the generated URL would be malformed.Root Cause
At
commands/cc_statusline.go:155, the URL is constructed as:The
userLoginvalue comes directly from the GraphQL API response (model/user_profile_service.go:21) and is used without sanitization. While most login systems restrict usernames to safe characters, this is not guaranteed, and URL path segments should be properly encoded usingurl.PathEscape()to handle edge cases.Impact: Users with special characters in their login would see broken clickable links in their terminal statusline, leading to 404 errors or incorrect navigation when clicked.
Was this helpful? React with 👍 or 👎 to provide feedback.