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
14 changes: 11 additions & 3 deletions commands/cc_statusline.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ type ccStatuslineResult struct {
GitDirty bool
FiveHourUtilization *float64
SevenDayUtilization *float64
UserLogin string
WebEndpoint string
}

func commandCCStatusline(c *cli.Context) error {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)

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 userLogin string 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:

url := fmt.Sprintf("%s/users/%s/coding-agent/claude-code", webEndpoint, userLogin)

The userLogin value 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 using url.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.

Suggested change
url := fmt.Sprintf("%s/users/%s/coding-agent/claude-code", webEndpoint, userLogin)
url := fmt.Sprintf("%s/users/%s/coding-agent/claude-code", webEndpoint, url.PathEscape(userLogin))
Open in Devin Review

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

dailyStr = wrapOSC8Link(url, dailyStr)
}
parts = append(parts, dailyStr)
} else {
parts = append(parts, color.Gray.Sprint("📊 -"))
Expand Down Expand Up @@ -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,
}
}
}
Expand Down
18 changes: 9 additions & 9 deletions commands/cc_statusline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "", "")
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

While the existing tests for formatStatuslineOutput have been updated to compile, they don't yet cover the new clickable link functionality. Adding a dedicated test case would ensure the OSC8 link is generated correctly when userLogin and webEndpoint are provided.

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")
Expand All @@ -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%")
Expand Down Expand Up @@ -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, "🚦 -")
}
Expand Down
40 changes: 40 additions & 0 deletions daemon/cc_info_timer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The current implementation of fetchUserProfile has a potential race condition. If it were ever called concurrently, multiple goroutines could pass the if fetched check and perform the network request, which is inefficient. Using sync.Once is the idiomatic Go way to ensure a piece of code is executed exactly once.

To apply this suggestion, you'll also need to modify the CCInfoTimerService struct by replacing userLoginFetched bool with userProfileOnce sync.Once.

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()
Expand Down
2 changes: 2 additions & 0 deletions daemon/socket.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions model/user_profile_service.go
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
}