Skip to content

Commit 0ab5086

Browse files
AnnatarHeclaude
andcommitted
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 <noreply@anthropic.com>
1 parent ee1737c commit 0ab5086

5 files changed

Lines changed: 109 additions & 12 deletions

File tree

commands/cc_statusline.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ type ccStatuslineResult struct {
3636
GitDirty bool
3737
FiveHourUtilization *float64
3838
SevenDayUtilization *float64
39+
UserLogin string
40+
WebEndpoint string
3941
}
4042

4143
func commandCCStatusline(c *cli.Context) error {
@@ -68,7 +70,7 @@ func commandCCStatusline(c *cli.Context) error {
6870
}
6971

7072
// Format and output
71-
output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, result.Cost, result.SessionSeconds, contextPercent, result.GitBranch, result.GitDirty, result.FiveHourUtilization, result.SevenDayUtilization)
73+
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)
7274
fmt.Println(output)
7375

7476
return nil
@@ -124,7 +126,7 @@ func calculateContextPercent(cw model.CCStatuslineContextWindow) float64 {
124126
return float64(currentTokens) / float64(cw.ContextWindowSize) * 100
125127
}
126128

127-
func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64, gitBranch string, gitDirty bool, fiveHourUtil, sevenDayUtil *float64) string {
129+
func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64, gitBranch string, gitDirty bool, fiveHourUtil, sevenDayUtil *float64, userLogin, webEndpoint string) string {
128130
var parts []string
129131

130132
// Git info FIRST (green)
@@ -146,9 +148,13 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se
146148
sessionStr := color.Cyan.Sprintf("💰 $%.2f", sessionCost)
147149
parts = append(parts, sessionStr)
148150

149-
// Daily cost (yellow)
151+
// Daily cost (yellow) - clickable link to coding agent page when user login is available
150152
if dailyCost > 0 {
151153
dailyStr := color.Yellow.Sprintf("📊 $%.2f", dailyCost)
154+
if userLogin != "" && webEndpoint != "" {
155+
url := fmt.Sprintf("%s/users/%s/coding-agent/claude-code", webEndpoint, userLogin)
156+
dailyStr = wrapOSC8Link(url, dailyStr)
157+
}
152158
parts = append(parts, dailyStr)
153159
} else {
154160
parts = append(parts, color.Gray.Sprint("📊 -"))
@@ -248,6 +254,8 @@ func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig
248254
GitDirty: resp.GitDirty,
249255
FiveHourUtilization: resp.FiveHourUtilization,
250256
SevenDayUtilization: resp.SevenDayUtilization,
257+
UserLogin: resp.UserLogin,
258+
WebEndpoint: config.WebEndpoint,
251259
}
252260
}
253261
}

commands/cc_statusline_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ func (s *CCStatuslineTestSuite) TestGetDaemonInfo_UsesDefaultSocketPath() {
150150
// formatStatuslineOutput Tests
151151

152152
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_AllValues() {
153-
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, nil, nil)
153+
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, nil, nil, "", "")
154154

155155
// Should contain all components
156156
assert.Contains(s.T(), output, "🌿 main")
@@ -162,46 +162,46 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_AllValues() {
162162
}
163163

164164
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithDirtyBranch() {
165-
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "feature/test", true, nil, nil)
165+
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "feature/test", true, nil, nil, "", "")
166166

167167
// Should contain branch with asterisk for dirty
168168
assert.Contains(s.T(), output, "🌿 feature/test*")
169169
assert.Contains(s.T(), output, "🤖 claude-opus-4")
170170
}
171171

172172
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_NoBranch() {
173-
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "", false, nil, nil)
173+
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "", false, nil, nil, "", "")
174174

175175
// Should show "-" for no branch
176176
assert.Contains(s.T(), output, "🌿 -")
177177
assert.Contains(s.T(), output, "🤖 claude-opus-4")
178178
}
179179

180180
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroDailyCost() {
181-
output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 300, 50.0, "main", false, nil, nil)
181+
output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 300, 50.0, "main", false, nil, nil, "", "")
182182

183183
// Should show "-" for zero daily cost
184184
assert.Contains(s.T(), output, "📊 -")
185185
assert.Contains(s.T(), output, "5m0s") // Session time (300 seconds = 5m)
186186
}
187187

188188
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroSessionSeconds() {
189-
output := formatStatuslineOutput("claude-sonnet", 0.50, 1.0, 0, 50.0, "main", false, nil, nil)
189+
output := formatStatuslineOutput("claude-sonnet", 0.50, 1.0, 0, 50.0, "main", false, nil, nil, "", "")
190190

191191
// Should show "-" for zero session seconds
192192
assert.Contains(s.T(), output, "⏱️ -")
193193
}
194194

195195
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_HighContextPercentage() {
196-
output := formatStatuslineOutput("test-model", 1.0, 1.0, 60, 85.0, "main", false, nil, nil)
196+
output := formatStatuslineOutput("test-model", 1.0, 1.0, 60, 85.0, "main", false, nil, nil, "", "")
197197

198198
// Should contain the percentage (color codes may vary)
199199
assert.Contains(s.T(), output, "85%")
200200
assert.Contains(s.T(), output, "1m0s")
201201
}
202202

203203
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_LowContextPercentage() {
204-
output := formatStatuslineOutput("test-model", 1.0, 1.0, 45, 25.0, "main", false, nil, nil)
204+
output := formatStatuslineOutput("test-model", 1.0, 1.0, 45, 25.0, "main", false, nil, nil, "", "")
205205

206206
// Should contain the percentage
207207
assert.Contains(s.T(), output, "25%")
@@ -331,15 +331,15 @@ func (s *CCStatuslineTestSuite) TestFormatQuotaPart_ContainsLink() {
331331
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithQuota() {
332332
fh := 0.45
333333
sd := 0.23
334-
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, &fh, &sd)
334+
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, &fh, &sd, "", "")
335335

336336
assert.Contains(s.T(), output, "5h:45%")
337337
assert.Contains(s.T(), output, "7d:23%")
338338
assert.Contains(s.T(), output, "🚦")
339339
}
340340

341341
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithoutQuota() {
342-
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, nil, nil)
342+
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, nil, nil, "", "")
343343

344344
assert.Contains(s.T(), output, "🚦 -")
345345
}

daemon/cc_info_timer.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ type CCInfoTimerService struct {
5050

5151
// Anthropic rate limit cache
5252
rateLimitCache *anthropicRateLimitCache
53+
54+
// User profile cache (permanent for daemon lifetime)
55+
userLogin string
56+
userLoginFetched bool
5357
}
5458

5559
// NewCCInfoTimerService creates a new CC info timer service
@@ -157,6 +161,7 @@ func (s *CCInfoTimerService) timerLoop() {
157161
s.fetchActiveRanges(context.Background())
158162
s.fetchGitInfo()
159163
go s.fetchRateLimit(context.Background())
164+
go s.fetchUserProfile(context.Background())
160165

161166
for {
162167
select {
@@ -413,6 +418,41 @@ func (s *CCInfoTimerService) fetchRateLimit(ctx context.Context) {
413418
slog.Float64("7d", usage.SevenDayUtilization))
414419
}
415420

421+
// GetCachedUserLogin returns the cached user login, or empty string if not yet fetched.
422+
func (s *CCInfoTimerService) GetCachedUserLogin() string {
423+
s.mu.RLock()
424+
defer s.mu.RUnlock()
425+
return s.userLogin
426+
}
427+
428+
// fetchUserProfile fetches the current user's login once per daemon lifetime.
429+
func (s *CCInfoTimerService) fetchUserProfile(ctx context.Context) {
430+
if s.config.Token == "" {
431+
return
432+
}
433+
434+
s.mu.RLock()
435+
fetched := s.userLoginFetched
436+
s.mu.RUnlock()
437+
438+
if fetched {
439+
return
440+
}
441+
442+
profile, err := model.FetchCurrentUserProfile(ctx, *s.config)
443+
if err != nil {
444+
slog.Warn("Failed to fetch user profile", slog.Any("err", err))
445+
return
446+
}
447+
448+
s.mu.Lock()
449+
s.userLogin = profile.FetchUser.Login
450+
s.userLoginFetched = true
451+
s.mu.Unlock()
452+
453+
slog.Debug("User profile fetched", slog.String("login", profile.FetchUser.Login))
454+
}
455+
416456
// GetCachedRateLimit returns a copy of the cached rate limit data, or nil if not available.
417457
func (s *CCInfoTimerService) GetCachedRateLimit() *AnthropicRateLimitData {
418458
s.rateLimitCache.mu.RLock()

daemon/socket.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type CCInfoResponse struct {
4545
GitDirty bool `json:"gitDirty"`
4646
FiveHourUtilization *float64 `json:"fiveHourUtilization,omitempty"`
4747
SevenDayUtilization *float64 `json:"sevenDayUtilization,omitempty"`
48+
UserLogin string `json:"userLogin,omitempty"`
4849
}
4950

5051
// StatusResponse contains daemon status information
@@ -228,6 +229,7 @@ func (p *SocketHandler) handleCCInfo(conn net.Conn, msg SocketMessage) {
228229
CachedAt: cache.FetchedAt,
229230
GitBranch: gitInfo.Branch,
230231
GitDirty: gitInfo.Dirty,
232+
UserLogin: p.ccInfoTimer.GetCachedUserLogin(),
231233
}
232234

233235
// Populate rate limit fields if available

model/user_profile_service.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package model
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
// FetchCurrentUserProfileQuery is the GraphQL query for fetching the current user's profile
9+
const FetchCurrentUserProfileQuery = `query fetchCurrentUserProfile {
10+
fetchUser {
11+
id
12+
login
13+
}
14+
}`
15+
16+
// UserProfileResponse is the GraphQL response structure for user profile
17+
type UserProfileResponse struct {
18+
FetchUser struct {
19+
ID int `json:"id"`
20+
Login string `json:"login"`
21+
} `json:"fetchUser"`
22+
}
23+
24+
// FetchCurrentUserProfile fetches the current user's profile from the GraphQL API
25+
func FetchCurrentUserProfile(ctx context.Context, config ShellTimeConfig) (UserProfileResponse, error) {
26+
ctx, span := modelTracer.Start(ctx, "userProfile.fetch")
27+
defer span.End()
28+
29+
var result GraphQLResponse[UserProfileResponse]
30+
31+
err := SendGraphQLRequest(GraphQLRequestOptions[GraphQLResponse[UserProfileResponse]]{
32+
Context: ctx,
33+
Endpoint: Endpoint{
34+
Token: config.Token,
35+
APIEndpoint: config.APIEndpoint,
36+
},
37+
Query: FetchCurrentUserProfileQuery,
38+
Response: &result,
39+
Timeout: 5 * time.Second,
40+
})
41+
42+
if err != nil {
43+
return UserProfileResponse{}, err
44+
}
45+
46+
return result.Data, nil
47+
}

0 commit comments

Comments
 (0)