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
65 changes: 54 additions & 11 deletions commands/cc_statusline.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import (
"github.com/urfave/cli/v2"
)

const claudeUsageURL = "https://claude.ai/settings/usage"

func wrapOSC8Link(url, text string) string {
return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, text)
}

var CCStatuslineCommand = &cli.Command{
Name: "statusline",
Usage: "Output statusline for Claude Code (reads JSON from stdin)",
Expand All @@ -24,10 +30,12 @@ var CCStatuslineCommand = &cli.Command{

// ccStatuslineResult combines daily stats with git info from daemon
type ccStatuslineResult struct {
Cost float64
SessionSeconds int
GitBranch string
GitDirty bool
Cost float64
SessionSeconds int
GitBranch string
GitDirty bool
FiveHourUtilization *float64
SevenDayUtilization *float64
}

func commandCCStatusline(c *cli.Context) error {
Expand Down Expand Up @@ -60,7 +68,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)
output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, result.Cost, result.SessionSeconds, contextPercent, result.GitBranch, result.GitDirty, result.FiveHourUtilization, result.SevenDayUtilization)
fmt.Println(output)

return nil
Expand Down Expand Up @@ -116,7 +124,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) string {
func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64, gitBranch string, gitDirty bool, fiveHourUtil, sevenDayUtil *float64) string {
var parts []string

// Git info FIRST (green)
Expand Down Expand Up @@ -146,6 +154,9 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se
parts = append(parts, color.Gray.Sprint("πŸ“Š -"))
}

// Quota utilization
parts = append(parts, formatQuotaPart(fiveHourUtil, sevenDayUtil))

// AI agent time (magenta)
if sessionSeconds > 0 {
timeStr := color.Magenta.Sprintf("⏱️ %s", formatSessionDuration(sessionSeconds))
Expand All @@ -169,8 +180,38 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se
return strings.Join(parts, " | ")
}

// formatQuotaPart formats the rate limit quota section of the statusline.
// Color is based on the max utilization of both buckets.
func formatQuotaPart(fiveHourUtil, sevenDayUtil *float64) string {
if fiveHourUtil == nil || sevenDayUtil == nil {
return wrapOSC8Link(claudeUsageURL, color.Gray.Sprint("🚦 -"))
}

fh := *fiveHourUtil * 100
sd := *sevenDayUtil * 100

text := fmt.Sprintf("🚦 5h:%.0f%% 7d:%.0f%%", fh, sd)

maxUtil := fh
if sd > maxUtil {
maxUtil = sd
}

var colored string
switch {
case maxUtil >= 80:
colored = color.Red.Sprint(text)
case maxUtil >= 50:
colored = color.Yellow.Sprint(text)
default:
colored = color.Green.Sprint(text)
}
return wrapOSC8Link(claudeUsageURL, colored)
}

func outputFallback() {
fmt.Println(color.Gray.Sprint("🌿 - | πŸ€– - | πŸ’° - | πŸ“Š - | ⏱️ - | πŸ“ˆ -%"))
quotaPart := wrapOSC8Link(claudeUsageURL, "🚦 -")
fmt.Println(color.Gray.Sprint("🌿 - | πŸ€– - | πŸ’° - | πŸ“Š - | " + quotaPart + " | ⏱️ - | πŸ“ˆ -%"))
}

// formatSessionDuration formats seconds into a human-readable duration
Expand Down Expand Up @@ -201,10 +242,12 @@ func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig
resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, workingDir, 50*time.Millisecond)
if err == nil && resp != nil {
return ccStatuslineResult{
Cost: resp.TotalCostUSD,
SessionSeconds: resp.TotalSessionSeconds,
GitBranch: resp.GitBranch,
GitDirty: resp.GitDirty,
Cost: resp.TotalCostUSD,
SessionSeconds: resp.TotalSessionSeconds,
GitBranch: resp.GitBranch,
GitDirty: resp.GitDirty,
FiveHourUtilization: resp.FiveHourUtilization,
SevenDayUtilization: resp.SevenDayUtilization,
}
}
}
Expand Down
123 changes: 116 additions & 7 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)
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")
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)
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)
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)
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)
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)
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)
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 @@ -275,6 +275,115 @@ func (s *CCStatuslineTestSuite) TestCalculateContextPercent_WithoutCurrentUsage(
assert.Equal(s.T(), float64(50), percent)
}

// formatQuotaPart Tests

func (s *CCStatuslineTestSuite) TestFormatQuotaPart_NilValues() {
result := formatQuotaPart(nil, nil)
assert.Contains(s.T(), result, "🚦 -")
}

func (s *CCStatuslineTestSuite) TestFormatQuotaPart_OnlyFiveHourNil() {
sd := 0.23
result := formatQuotaPart(nil, &sd)
assert.Contains(s.T(), result, "🚦 -")
}

func (s *CCStatuslineTestSuite) TestFormatQuotaPart_LowUtilization() {
fh := 0.10
sd := 0.20
result := formatQuotaPart(&fh, &sd)
assert.Contains(s.T(), result, "5h:10%")
assert.Contains(s.T(), result, "7d:20%")
}

func (s *CCStatuslineTestSuite) TestFormatQuotaPart_MediumUtilization() {
fh := 0.55
sd := 0.30
result := formatQuotaPart(&fh, &sd)
assert.Contains(s.T(), result, "5h:55%")
assert.Contains(s.T(), result, "7d:30%")
}

func (s *CCStatuslineTestSuite) TestFormatQuotaPart_HighUtilization() {
fh := 0.45
sd := 0.85
result := formatQuotaPart(&fh, &sd)
assert.Contains(s.T(), result, "5h:45%")
assert.Contains(s.T(), result, "7d:85%")
}

func (s *CCStatuslineTestSuite) TestFormatQuotaPart_ContainsLink() {
// Nil case
result := formatQuotaPart(nil, nil)
assert.Contains(s.T(), result, "claude.ai/settings/usage")
assert.Contains(s.T(), result, "\033]8;;")

// With values
fh := 0.45
sd := 0.23
result = formatQuotaPart(&fh, &sd)
assert.Contains(s.T(), result, "claude.ai/settings/usage")
assert.Contains(s.T(), result, "\033]8;;")
assert.Contains(s.T(), result, "5h:45%")
assert.Contains(s.T(), result, "7d:23%")
}

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)

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)

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

func (s *CCStatuslineTestSuite) TestGetDaemonInfo_PropagatesRateLimitFields() {
listener, err := net.Listen("unix", s.socketPath)
assert.NoError(s.T(), err)
s.listener = listener

fh := 0.45
sd := 0.23
go func() {
conn, _ := listener.Accept()
defer conn.Close()

var msg daemon.SocketMessage
json.NewDecoder(conn).Decode(&msg)

response := daemon.CCInfoResponse{
TotalCostUSD: 1.23,
TotalSessionSeconds: 100,
TimeRange: "today",
CachedAt: time.Now(),
GitBranch: "main",
FiveHourUtilization: &fh,
SevenDayUtilization: &sd,
}
json.NewEncoder(conn).Encode(response)
}()

time.Sleep(10 * time.Millisecond)

config := model.ShellTimeConfig{
SocketPath: s.socketPath,
}

result := getDaemonInfoWithFallback(context.Background(), config, "/some/path")

assert.NotNil(s.T(), result.FiveHourUtilization)
assert.NotNil(s.T(), result.SevenDayUtilization)
assert.Equal(s.T(), 0.45, *result.FiveHourUtilization)
assert.Equal(s.T(), 0.23, *result.SevenDayUtilization)
}

func TestCCStatuslineTestSuite(t *testing.T) {
suite.Run(t, new(CCStatuslineTestSuite))
}
Loading
Loading