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
46 changes: 36 additions & 10 deletions commands/cc_statusline.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ func commandCCStatusline(c *cli.Context) error {
// Calculate context percentage
contextPercent := calculateContextPercent(data.ContextWindow)

// Get daily cost - try daemon first, fallback to direct API
var dailyCost float64
// Get daily stats - try daemon first, fallback to direct API
var dailyStats model.CCStatuslineDailyStats
config, err := configService.ReadConfigFile(ctx)
if err == nil {
dailyCost = getDailyCostWithDaemonFallback(ctx, config)
dailyStats = getDailyStatsWithDaemonFallback(ctx, config)
}

// Format and output
output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, dailyCost, contextPercent)
output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, dailyStats.Cost, dailyStats.SessionSeconds, contextPercent)
fmt.Println(output)

return nil
Expand Down Expand Up @@ -108,7 +108,7 @@ func calculateContextPercent(cw model.CCStatuslineContextWindow) float64 {
return float64(currentTokens) / float64(cw.ContextWindowSize) * 100
}

func formatStatuslineOutput(modelName string, sessionCost, dailyCost, contextPercent float64) string {
func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64) string {
var parts []string

// Model name
Expand All @@ -127,6 +127,14 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost, contextPer
parts = append(parts, color.Gray.Sprint("📊 -"))
}

// AI agent time (magenta)
if sessionSeconds > 0 {
timeStr := color.Magenta.Sprintf("⏱️ %s", formatSessionDuration(sessionSeconds))
parts = append(parts, timeStr)
} else {
parts = append(parts, color.Gray.Sprint("⏱️ -"))
}

// Context percentage with color coding
var contextStr string
switch {
Expand All @@ -143,12 +151,27 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost, contextPer
}

func outputFallback() {
fmt.Println(color.Gray.Sprint("🤖 - | 💰 - | 📊 - | 📈 -%"))
fmt.Println(color.Gray.Sprint("🤖 - | 💰 - | 📊 - | ⏱️ - | 📈 -%"))
}

// getDailyCostWithDaemonFallback tries to get daily cost from daemon first,
// formatSessionDuration formats seconds into a human-readable duration
func formatSessionDuration(totalSeconds int) string {
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
seconds := totalSeconds % 60

if hours > 0 {
return fmt.Sprintf("%dh%dm", hours, minutes)
}
if minutes > 0 {
return fmt.Sprintf("%dm%ds", minutes, seconds)
}
return fmt.Sprintf("%ds", seconds)
}

// getDailyStatsWithDaemonFallback tries to get daily stats from daemon first,
// falls back to direct API if daemon is unavailable
func getDailyCostWithDaemonFallback(ctx context.Context, config model.ShellTimeConfig) float64 {
func getDailyStatsWithDaemonFallback(ctx context.Context, config model.ShellTimeConfig) model.CCStatuslineDailyStats {
socketPath := config.SocketPath
if socketPath == "" {
socketPath = model.DefaultSocketPath
Expand All @@ -158,10 +181,13 @@ func getDailyCostWithDaemonFallback(ctx context.Context, config model.ShellTimeC
if daemon.IsSocketReady(ctx, socketPath) {
resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, 50*time.Millisecond)
if err == nil && resp != nil {
return resp.TotalCostUSD
return model.CCStatuslineDailyStats{
Cost: resp.TotalCostUSD,
SessionSeconds: resp.TotalSessionSeconds,
}
}
}

// Fallback to direct API (existing behavior)
return model.FetchDailyCostCached(ctx, config)
return model.FetchDailyStatsCached(ctx, config)
}
95 changes: 69 additions & 26 deletions commands/cc_statusline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,14 @@ func (s *CCStatuslineTestSuite) TearDownTest() {

// getDailyCostWithDaemonFallback Tests

func (s *CCStatuslineTestSuite) TestGetDailyCost_UsesDaemonWhenAvailable() {
func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDaemonWhenAvailable() {
// Start mock daemon socket
listener, err := net.Listen("unix", s.socketPath)
assert.NoError(s.T(), err)
s.listener = listener

expectedCost := 15.67
expectedSessionSeconds := 3600
go func() {
conn, _ := listener.Accept()
defer conn.Close()
Expand All @@ -57,9 +58,10 @@ func (s *CCStatuslineTestSuite) TestGetDailyCost_UsesDaemonWhenAvailable() {
json.NewDecoder(conn).Decode(&msg)

response := daemon.CCInfoResponse{
TotalCostUSD: expectedCost,
TimeRange: "today",
CachedAt: time.Now(),
TotalCostUSD: expectedCost,
TotalSessionSeconds: expectedSessionSeconds,
TimeRange: "today",
CachedAt: time.Now(),
}
json.NewEncoder(conn).Encode(response)
}()
Expand All @@ -70,25 +72,27 @@ func (s *CCStatuslineTestSuite) TestGetDailyCost_UsesDaemonWhenAvailable() {
SocketPath: s.socketPath,
}

cost := getDailyCostWithDaemonFallback(context.Background(), config)
stats := getDailyStatsWithDaemonFallback(context.Background(), config)

assert.Equal(s.T(), expectedCost, cost)
assert.Equal(s.T(), expectedCost, stats.Cost)
assert.Equal(s.T(), expectedSessionSeconds, stats.SessionSeconds)
}

func (s *CCStatuslineTestSuite) TestGetDailyCost_FallbackWhenDaemonUnavailable() {
func (s *CCStatuslineTestSuite) TestGetDailyStats_FallbackWhenDaemonUnavailable() {
// No socket exists, should fall back to cached API
config := model.ShellTimeConfig{
SocketPath: "/nonexistent/socket.sock",
Token: "", // No token means FetchDailyCostCached returns 0
Token: "", // No token means FetchDailyStatsCached returns zero values
}

cost := getDailyCostWithDaemonFallback(context.Background(), config)
stats := getDailyStatsWithDaemonFallback(context.Background(), config)

// Should return 0 (from cache fallback with no token)
assert.Equal(s.T(), float64(0), cost)
// Should return zero values (from cache fallback with no token)
assert.Equal(s.T(), float64(0), stats.Cost)
assert.Equal(s.T(), 0, stats.SessionSeconds)
}

func (s *CCStatuslineTestSuite) TestGetDailyCost_FallbackOnDaemonError() {
func (s *CCStatuslineTestSuite) TestGetDailyStats_FallbackOnDaemonError() {
// Start mock daemon that returns error
listener, err := net.Listen("unix", s.socketPath)
assert.NoError(s.T(), err)
Expand All @@ -107,57 +111,96 @@ func (s *CCStatuslineTestSuite) TestGetDailyCost_FallbackOnDaemonError() {
Token: "", // No token
}

cost := getDailyCostWithDaemonFallback(context.Background(), config)
stats := getDailyStatsWithDaemonFallback(context.Background(), config)

// Should fall back and return 0
assert.Equal(s.T(), float64(0), cost)
// Should fall back and return zero values
assert.Equal(s.T(), float64(0), stats.Cost)
assert.Equal(s.T(), 0, stats.SessionSeconds)
}

func (s *CCStatuslineTestSuite) TestGetDailyCost_UsesDefaultSocketPath() {
func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDefaultSocketPath() {
// Test that default socket path is used when config is empty
config := model.ShellTimeConfig{
SocketPath: "", // Empty path
SocketPath: "", // Empty path - should use model.DefaultSocketPath
Token: "",
}

// This should use model.DefaultSocketPath internally
// Since no daemon is running, it will fall back
cost := getDailyCostWithDaemonFallback(context.Background(), config)

assert.Equal(s.T(), float64(0), cost)
// Since no daemon is running at the default path, it will fall back to cached API
// The function should not panic and should return a valid stats struct
stats := getDailyStatsWithDaemonFallback(context.Background(), config)

// We can't assert on exact values since the global cache might have data
// from previous tests. Just verify the function returns without error
// and returns non-negative values
assert.GreaterOrEqual(s.T(), stats.Cost, float64(0))
assert.GreaterOrEqual(s.T(), stats.SessionSeconds, 0)
}

// formatStatuslineOutput Tests

func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_AllValues() {
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 75.0)
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0)

// Should contain all components
assert.Contains(s.T(), output, "🤖 claude-opus-4")
assert.Contains(s.T(), output, "$1.23")
assert.Contains(s.T(), output, "$4.56")
assert.Contains(s.T(), output, "75%") // Context percentage
assert.Contains(s.T(), output, "1h1m") // Session time (3661 seconds = 1h 1m 1s)
assert.Contains(s.T(), output, "75%") // Context percentage
}

func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroDailyCost() {
output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 50.0)
output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 300, 50.0)

// 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)

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

func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_HighContextPercentage() {
output := formatStatuslineOutput("test-model", 1.0, 1.0, 85.0)
output := formatStatuslineOutput("test-model", 1.0, 1.0, 60, 85.0)

// 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, 25.0)
output := formatStatuslineOutput("test-model", 1.0, 1.0, 45, 25.0)

// Should contain the percentage
assert.Contains(s.T(), output, "25%")
assert.Contains(s.T(), output, "45s")
}

// formatSessionDuration Tests

func (s *CCStatuslineTestSuite) TestFormatSessionDuration_Seconds() {
result := formatSessionDuration(45)
assert.Equal(s.T(), "45s", result)
}

func (s *CCStatuslineTestSuite) TestFormatSessionDuration_Minutes() {
result := formatSessionDuration(125) // 2m 5s
assert.Equal(s.T(), "2m5s", result)
}

func (s *CCStatuslineTestSuite) TestFormatSessionDuration_Hours() {
result := formatSessionDuration(3665) // 1h 1m 5s
assert.Equal(s.T(), "1h1m", result)
}

func (s *CCStatuslineTestSuite) TestFormatSessionDuration_Zero() {
result := formatSessionDuration(0)
assert.Equal(s.T(), "0s", result)
}

// calculateContextPercent Tests
Expand Down
37 changes: 25 additions & 12 deletions daemon/cc_info_timer.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ var (

// CCInfoCache holds the cached cost data for a time range
type CCInfoCache struct {
TotalCostUSD float64
FetchedAt time.Time
TotalCostUSD float64
TotalSessionSeconds int
FetchedAt time.Time
}

// CCInfoTimerService manages lazy-fetching of CC info data
Expand Down Expand Up @@ -174,29 +175,37 @@ func (s *CCInfoTimerService) fetchActiveRanges(ctx context.Context) {

// Fetch each active range
for _, timeRange := range ranges {
cost, err := s.fetchCost(ctx, timeRange)
info, err := s.fetchCCInfo(ctx, timeRange)
if err != nil {
slog.Warn("Failed to fetch CC info cost",
slog.Warn("Failed to fetch CC info",
slog.String("timeRange", string(timeRange)),
slog.Any("err", err))
continue
}

s.mu.Lock()
s.cache[timeRange] = CCInfoCache{
TotalCostUSD: cost,
FetchedAt: time.Now(),
TotalCostUSD: info.TotalCostUSD,
TotalSessionSeconds: info.TotalSessionSeconds,
FetchedAt: time.Now(),
}
s.mu.Unlock()

slog.Debug("CC info cost updated",
slog.Debug("CC info updated",
slog.String("timeRange", string(timeRange)),
slog.Float64("cost", cost))
slog.Float64("cost", info.TotalCostUSD),
slog.Int("sessionSeconds", info.TotalSessionSeconds))
}
}

// fetchCost fetches the cost for a specific time range
func (s *CCInfoTimerService) fetchCost(ctx context.Context, timeRange CCInfoTimeRange) (float64, error) {
// ccInfoFetchResult holds the fetched CC info data
type ccInfoFetchResult struct {
TotalCostUSD float64
TotalSessionSeconds int
}

// fetchCCInfo fetches the CC info for a specific time range
func (s *CCInfoTimerService) fetchCCInfo(ctx context.Context, timeRange CCInfoTimeRange) (ccInfoFetchResult, error) {
now := time.Now()
var since time.Time

Expand Down Expand Up @@ -244,8 +253,12 @@ func (s *CCInfoTimerService) fetchCost(ctx context.Context, timeRange CCInfoTime
})

if err != nil {
return 0, err
return ccInfoFetchResult{}, err
}

return result.Data.FetchUser.AICodeOtel.Analytics.TotalCostUsd, nil
analytics := result.Data.FetchUser.AICodeOtel.Analytics
return ccInfoFetchResult{
TotalCostUSD: analytics.TotalCostUsd,
TotalSessionSeconds: analytics.TotalSessionSeconds,
}, nil
}
Loading