Skip to content

Commit ee1737c

Browse files
authored
Merge pull request #213 from shelltime/feat/cc-statusline-anthropic-quota
feat(daemon): add Anthropic rate limit quota to cc statusline
2 parents f0e3761 + 682d05a commit ee1737c

7 files changed

Lines changed: 573 additions & 24 deletions

File tree

commands/cc_statusline.go

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import (
1616
"github.com/urfave/cli/v2"
1717
)
1818

19+
const claudeUsageURL = "https://claude.ai/settings/usage"
20+
21+
func wrapOSC8Link(url, text string) string {
22+
return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, text)
23+
}
24+
1925
var CCStatuslineCommand = &cli.Command{
2026
Name: "statusline",
2127
Usage: "Output statusline for Claude Code (reads JSON from stdin)",
@@ -24,10 +30,12 @@ var CCStatuslineCommand = &cli.Command{
2430

2531
// ccStatuslineResult combines daily stats with git info from daemon
2632
type ccStatuslineResult struct {
27-
Cost float64
28-
SessionSeconds int
29-
GitBranch string
30-
GitDirty bool
33+
Cost float64
34+
SessionSeconds int
35+
GitBranch string
36+
GitDirty bool
37+
FiveHourUtilization *float64
38+
SevenDayUtilization *float64
3139
}
3240

3341
func commandCCStatusline(c *cli.Context) error {
@@ -60,7 +68,7 @@ func commandCCStatusline(c *cli.Context) error {
6068
}
6169

6270
// Format and output
63-
output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, result.Cost, result.SessionSeconds, contextPercent, result.GitBranch, result.GitDirty)
71+
output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, result.Cost, result.SessionSeconds, contextPercent, result.GitBranch, result.GitDirty, result.FiveHourUtilization, result.SevenDayUtilization)
6472
fmt.Println(output)
6573

6674
return nil
@@ -116,7 +124,7 @@ func calculateContextPercent(cw model.CCStatuslineContextWindow) float64 {
116124
return float64(currentTokens) / float64(cw.ContextWindowSize) * 100
117125
}
118126

119-
func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64, gitBranch string, gitDirty bool) string {
127+
func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64, gitBranch string, gitDirty bool, fiveHourUtil, sevenDayUtil *float64) string {
120128
var parts []string
121129

122130
// Git info FIRST (green)
@@ -146,6 +154,9 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se
146154
parts = append(parts, color.Gray.Sprint("📊 -"))
147155
}
148156

157+
// Quota utilization
158+
parts = append(parts, formatQuotaPart(fiveHourUtil, sevenDayUtil))
159+
149160
// AI agent time (magenta)
150161
if sessionSeconds > 0 {
151162
timeStr := color.Magenta.Sprintf("⏱️ %s", formatSessionDuration(sessionSeconds))
@@ -169,8 +180,38 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se
169180
return strings.Join(parts, " | ")
170181
}
171182

183+
// formatQuotaPart formats the rate limit quota section of the statusline.
184+
// Color is based on the max utilization of both buckets.
185+
func formatQuotaPart(fiveHourUtil, sevenDayUtil *float64) string {
186+
if fiveHourUtil == nil || sevenDayUtil == nil {
187+
return wrapOSC8Link(claudeUsageURL, color.Gray.Sprint("🚦 -"))
188+
}
189+
190+
fh := *fiveHourUtil * 100
191+
sd := *sevenDayUtil * 100
192+
193+
text := fmt.Sprintf("🚦 5h:%.0f%% 7d:%.0f%%", fh, sd)
194+
195+
maxUtil := fh
196+
if sd > maxUtil {
197+
maxUtil = sd
198+
}
199+
200+
var colored string
201+
switch {
202+
case maxUtil >= 80:
203+
colored = color.Red.Sprint(text)
204+
case maxUtil >= 50:
205+
colored = color.Yellow.Sprint(text)
206+
default:
207+
colored = color.Green.Sprint(text)
208+
}
209+
return wrapOSC8Link(claudeUsageURL, colored)
210+
}
211+
172212
func outputFallback() {
173-
fmt.Println(color.Gray.Sprint("🌿 - | 🤖 - | 💰 - | 📊 - | ⏱️ - | 📈 -%"))
213+
quotaPart := wrapOSC8Link(claudeUsageURL, "🚦 -")
214+
fmt.Println(color.Gray.Sprint("🌿 - | 🤖 - | 💰 - | 📊 - | " + quotaPart + " | ⏱️ - | 📈 -%"))
174215
}
175216

176217
// formatSessionDuration formats seconds into a human-readable duration
@@ -201,10 +242,12 @@ func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig
201242
resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, workingDir, 50*time.Millisecond)
202243
if err == nil && resp != nil {
203244
return ccStatuslineResult{
204-
Cost: resp.TotalCostUSD,
205-
SessionSeconds: resp.TotalSessionSeconds,
206-
GitBranch: resp.GitBranch,
207-
GitDirty: resp.GitDirty,
245+
Cost: resp.TotalCostUSD,
246+
SessionSeconds: resp.TotalSessionSeconds,
247+
GitBranch: resp.GitBranch,
248+
GitDirty: resp.GitDirty,
249+
FiveHourUtilization: resp.FiveHourUtilization,
250+
SevenDayUtilization: resp.SevenDayUtilization,
208251
}
209252
}
210253
}

commands/cc_statusline_test.go

Lines changed: 116 additions & 7 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)
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)
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)
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)
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)
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)
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)
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%")
@@ -275,6 +275,115 @@ func (s *CCStatuslineTestSuite) TestCalculateContextPercent_WithoutCurrentUsage(
275275
assert.Equal(s.T(), float64(50), percent)
276276
}
277277

278+
// formatQuotaPart Tests
279+
280+
func (s *CCStatuslineTestSuite) TestFormatQuotaPart_NilValues() {
281+
result := formatQuotaPart(nil, nil)
282+
assert.Contains(s.T(), result, "🚦 -")
283+
}
284+
285+
func (s *CCStatuslineTestSuite) TestFormatQuotaPart_OnlyFiveHourNil() {
286+
sd := 0.23
287+
result := formatQuotaPart(nil, &sd)
288+
assert.Contains(s.T(), result, "🚦 -")
289+
}
290+
291+
func (s *CCStatuslineTestSuite) TestFormatQuotaPart_LowUtilization() {
292+
fh := 0.10
293+
sd := 0.20
294+
result := formatQuotaPart(&fh, &sd)
295+
assert.Contains(s.T(), result, "5h:10%")
296+
assert.Contains(s.T(), result, "7d:20%")
297+
}
298+
299+
func (s *CCStatuslineTestSuite) TestFormatQuotaPart_MediumUtilization() {
300+
fh := 0.55
301+
sd := 0.30
302+
result := formatQuotaPart(&fh, &sd)
303+
assert.Contains(s.T(), result, "5h:55%")
304+
assert.Contains(s.T(), result, "7d:30%")
305+
}
306+
307+
func (s *CCStatuslineTestSuite) TestFormatQuotaPart_HighUtilization() {
308+
fh := 0.45
309+
sd := 0.85
310+
result := formatQuotaPart(&fh, &sd)
311+
assert.Contains(s.T(), result, "5h:45%")
312+
assert.Contains(s.T(), result, "7d:85%")
313+
}
314+
315+
func (s *CCStatuslineTestSuite) TestFormatQuotaPart_ContainsLink() {
316+
// Nil case
317+
result := formatQuotaPart(nil, nil)
318+
assert.Contains(s.T(), result, "claude.ai/settings/usage")
319+
assert.Contains(s.T(), result, "\033]8;;")
320+
321+
// With values
322+
fh := 0.45
323+
sd := 0.23
324+
result = formatQuotaPart(&fh, &sd)
325+
assert.Contains(s.T(), result, "claude.ai/settings/usage")
326+
assert.Contains(s.T(), result, "\033]8;;")
327+
assert.Contains(s.T(), result, "5h:45%")
328+
assert.Contains(s.T(), result, "7d:23%")
329+
}
330+
331+
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithQuota() {
332+
fh := 0.45
333+
sd := 0.23
334+
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, &fh, &sd)
335+
336+
assert.Contains(s.T(), output, "5h:45%")
337+
assert.Contains(s.T(), output, "7d:23%")
338+
assert.Contains(s.T(), output, "🚦")
339+
}
340+
341+
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithoutQuota() {
342+
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false, nil, nil)
343+
344+
assert.Contains(s.T(), output, "🚦 -")
345+
}
346+
347+
func (s *CCStatuslineTestSuite) TestGetDaemonInfo_PropagatesRateLimitFields() {
348+
listener, err := net.Listen("unix", s.socketPath)
349+
assert.NoError(s.T(), err)
350+
s.listener = listener
351+
352+
fh := 0.45
353+
sd := 0.23
354+
go func() {
355+
conn, _ := listener.Accept()
356+
defer conn.Close()
357+
358+
var msg daemon.SocketMessage
359+
json.NewDecoder(conn).Decode(&msg)
360+
361+
response := daemon.CCInfoResponse{
362+
TotalCostUSD: 1.23,
363+
TotalSessionSeconds: 100,
364+
TimeRange: "today",
365+
CachedAt: time.Now(),
366+
GitBranch: "main",
367+
FiveHourUtilization: &fh,
368+
SevenDayUtilization: &sd,
369+
}
370+
json.NewEncoder(conn).Encode(response)
371+
}()
372+
373+
time.Sleep(10 * time.Millisecond)
374+
375+
config := model.ShellTimeConfig{
376+
SocketPath: s.socketPath,
377+
}
378+
379+
result := getDaemonInfoWithFallback(context.Background(), config, "/some/path")
380+
381+
assert.NotNil(s.T(), result.FiveHourUtilization)
382+
assert.NotNil(s.T(), result.SevenDayUtilization)
383+
assert.Equal(s.T(), 0.45, *result.FiveHourUtilization)
384+
assert.Equal(s.T(), 0.23, *result.SevenDayUtilization)
385+
}
386+
278387
func TestCCStatuslineTestSuite(t *testing.T) {
279388
suite.Run(t, new(CCStatuslineTestSuite))
280389
}

0 commit comments

Comments
 (0)