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
51 changes: 38 additions & 13 deletions commands/cc_statusline.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ var CCStatuslineCommand = &cli.Command{
Action: commandCCStatusline,
}

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

func commandCCStatusline(c *cli.Context) error {
// Hard timeout for entire operation - statusline must be fast
ctx, cancel := context.WithTimeout(c.Context, 100*time.Millisecond)
Expand All @@ -44,15 +52,15 @@ func commandCCStatusline(c *cli.Context) error {
// Calculate context percentage
contextPercent := calculateContextPercent(data.ContextWindow)

// Get daily stats - try daemon first, fallback to direct API
var dailyStats model.CCStatuslineDailyStats
// Get daily stats and git info - try daemon first, fallback to direct API
var result ccStatuslineResult
config, err := configService.ReadConfigFile(ctx)
if err == nil {
dailyStats = getDailyStatsWithDaemonFallback(ctx, config)
result = getDaemonInfoWithFallback(ctx, config, data.WorkingDirectory)
}

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

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

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

// Git info FIRST (green)
if gitBranch != "" {
gitStr := gitBranch
if gitDirty {
gitStr += "*"
}
parts = append(parts, color.Green.Sprintf("🌿 %s", gitStr))
} else {
parts = append(parts, color.Gray.Sprint("🌿 -"))
}

// Model name
modelStr := fmt.Sprintf("🤖 %s", modelName)
parts = append(parts, modelStr)
Expand Down Expand Up @@ -151,7 +170,7 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se
}

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

// formatSessionDuration formats seconds into a human-readable duration
Expand All @@ -169,25 +188,31 @@ func formatSessionDuration(totalSeconds int) string {
return fmt.Sprintf("%ds", seconds)
}

// getDailyStatsWithDaemonFallback tries to get daily stats from daemon first,
// falls back to direct API if daemon is unavailable
func getDailyStatsWithDaemonFallback(ctx context.Context, config model.ShellTimeConfig) model.CCStatuslineDailyStats {
// getDaemonInfoWithFallback tries to get daily stats and git info from daemon first,
// falls back to direct API for stats if daemon is unavailable (git info only from daemon)
func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig, workingDir string) ccStatuslineResult {
socketPath := config.SocketPath
if socketPath == "" {
socketPath = model.DefaultSocketPath
}

// Try daemon first (50ms timeout for fast path)
if daemon.IsSocketReady(ctx, socketPath) {
resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, 50*time.Millisecond)
resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, workingDir, 50*time.Millisecond)
if err == nil && resp != nil {
return model.CCStatuslineDailyStats{
return ccStatuslineResult{
Cost: resp.TotalCostUSD,
SessionSeconds: resp.TotalSessionSeconds,
GitBranch: resp.GitBranch,
GitDirty: resp.GitDirty,
}
}
}

// Fallback to direct API (existing behavior)
return model.FetchDailyStatsCached(ctx, config)
// Fallback to direct API for stats (no git info available without daemon)
stats := model.FetchDailyStatsCached(ctx, config)
return ccStatuslineResult{
Cost: stats.Cost,
SessionSeconds: stats.SessionSeconds,
}
}
73 changes: 50 additions & 23 deletions commands/cc_statusline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,18 @@ func (s *CCStatuslineTestSuite) TearDownTest() {
os.Remove(s.socketPath)
}

// getDailyCostWithDaemonFallback Tests
// getDaemonInfoWithFallback Tests

func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDaemonWhenAvailable() {
func (s *CCStatuslineTestSuite) TestGetDaemonInfo_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
expectedBranch := "main"
expectedDirty := true
go func() {
conn, _ := listener.Accept()
defer conn.Close()
Expand All @@ -62,6 +64,8 @@ func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDaemonWhenAvailable() {
TotalSessionSeconds: expectedSessionSeconds,
TimeRange: "today",
CachedAt: time.Now(),
GitBranch: expectedBranch,
GitDirty: expectedDirty,
}
json.NewEncoder(conn).Encode(response)
}()
Expand All @@ -72,27 +76,31 @@ func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDaemonWhenAvailable() {
SocketPath: s.socketPath,
}

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

assert.Equal(s.T(), expectedCost, stats.Cost)
assert.Equal(s.T(), expectedSessionSeconds, stats.SessionSeconds)
assert.Equal(s.T(), expectedCost, result.Cost)
assert.Equal(s.T(), expectedSessionSeconds, result.SessionSeconds)
assert.Equal(s.T(), expectedBranch, result.GitBranch)
assert.Equal(s.T(), expectedDirty, result.GitDirty)
}

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

stats := getDailyStatsWithDaemonFallback(context.Background(), config)
result := getDaemonInfoWithFallback(context.Background(), config, "")

// 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)
assert.Equal(s.T(), float64(0), result.Cost)
assert.Equal(s.T(), 0, result.SessionSeconds)
assert.Empty(s.T(), result.GitBranch)
assert.False(s.T(), result.GitDirty)
}

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

stats := getDailyStatsWithDaemonFallback(context.Background(), config)
result := getDaemonInfoWithFallback(context.Background(), config, "")

// Should fall back and return zero values
assert.Equal(s.T(), float64(0), stats.Cost)
assert.Equal(s.T(), 0, stats.SessionSeconds)
assert.Equal(s.T(), float64(0), result.Cost)
assert.Equal(s.T(), 0, result.SessionSeconds)
assert.Empty(s.T(), result.GitBranch)
assert.False(s.T(), result.GitDirty)
}

func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDefaultSocketPath() {
func (s *CCStatuslineTestSuite) TestGetDaemonInfo_UsesDefaultSocketPath() {
// Test that default socket path is used when config is empty
config := model.ShellTimeConfig{
SocketPath: "", // Empty path - should use model.DefaultSocketPath
Expand All @@ -127,54 +137,71 @@ func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDefaultSocketPath() {

// This should use model.DefaultSocketPath internally
// 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)
// The function should not panic and should return a valid result struct
result := getDaemonInfoWithFallback(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)
assert.GreaterOrEqual(s.T(), result.Cost, float64(0))
assert.GreaterOrEqual(s.T(), result.SessionSeconds, 0)
}

// formatStatuslineOutput Tests

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

// Should contain all components
assert.Contains(s.T(), output, "🌿 main")
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, "1h1m") // Session time (3661 seconds = 1h 1m 1s)
assert.Contains(s.T(), output, "75%") // Context percentage
}

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

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

// 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)
output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 300, 50.0, "main", false)

// 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)
output := formatStatuslineOutput("claude-sonnet", 0.50, 1.0, 0, 50.0, "main", false)

// 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)
output := formatStatuslineOutput("test-model", 1.0, 1.0, 60, 85.0, "main", false)

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

// Should contain the percentage
assert.Contains(s.T(), output, "25%")
Expand Down
10 changes: 5 additions & 5 deletions daemon/cc_info_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_Success() {
// Give server time to start
time.Sleep(10 * time.Millisecond)

response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, 1*time.Second)
response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", 1*time.Second)

assert.NoError(s.T(), err)
assert.NotNil(s.T(), response)
Expand All @@ -255,14 +255,14 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_Timeout() {

time.Sleep(10 * time.Millisecond)

response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, 50*time.Millisecond)
response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", 50*time.Millisecond)

assert.Error(s.T(), err)
assert.Nil(s.T(), response)
}

func (s *CCInfoClientTestSuite) TestRequestCCInfo_SocketNotFound() {
response, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, 100*time.Millisecond)
response, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, "", 100*time.Millisecond)

assert.Error(s.T(), err)
assert.Nil(s.T(), response)
Expand All @@ -287,7 +287,7 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_InvalidResponse() {

time.Sleep(10 * time.Millisecond)

response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, 1*time.Second)
response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", 1*time.Second)

assert.Error(s.T(), err)
assert.Nil(s.T(), response)
Expand All @@ -312,7 +312,7 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_SendsCorrectMessage() {

time.Sleep(10 * time.Millisecond)

RequestCCInfo(s.socketPath, CCInfoTimeRangeWeek, 1*time.Second)
RequestCCInfo(s.socketPath, CCInfoTimeRangeWeek, "", 1*time.Second)

assert.Equal(s.T(), SocketMessageTypeCCInfo, receivedMsg.Type)

Expand Down
7 changes: 4 additions & 3 deletions daemon/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ func SendLocalDataToSocket(
return nil
}

// RequestCCInfo requests CC info (cost data) from the daemon
func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, timeout time.Duration) (*CCInfoResponse, error) {
// RequestCCInfo requests CC info (cost data and git info) from the daemon
func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, workingDir string, timeout time.Duration) (*CCInfoResponse, error) {
conn, err := net.DialTimeout("unix", socketPath, timeout)
if err != nil {
return nil, err
Expand All @@ -66,7 +66,8 @@ func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, timeout time.Du
msg := SocketMessage{
Type: SocketMessageTypeCCInfo,
Payload: CCInfoRequest{
TimeRange: timeRange,
TimeRange: timeRange,
WorkingDirectory: workingDir,
},
}

Expand Down
8 changes: 4 additions & 4 deletions daemon/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ func TestRequestCCInfo(t *testing.T) {
// Give server time to start
time.Sleep(50 * time.Millisecond)

response, err := RequestCCInfo(socketPath, CCInfoTimeRangeToday, 5*time.Second)
response, err := RequestCCInfo(socketPath, CCInfoTimeRangeToday, "", 5*time.Second)
if err != nil {
t.Fatalf("RequestCCInfo failed: %v", err)
}
Expand Down Expand Up @@ -200,14 +200,14 @@ func TestRequestCCInfo_Timeout(t *testing.T) {
// Give server time to start
time.Sleep(50 * time.Millisecond)

_, err = RequestCCInfo(socketPath, CCInfoTimeRangeToday, 100*time.Millisecond)
_, err = RequestCCInfo(socketPath, CCInfoTimeRangeToday, "", 100*time.Millisecond)
if err == nil {
t.Error("Expected timeout error")
}
}

func TestRequestCCInfo_SocketNotExists(t *testing.T) {
_, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, 1*time.Second)
_, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, "", 1*time.Second)
if err == nil {
t.Error("Expected error when socket doesn't exist")
}
Expand Down Expand Up @@ -261,7 +261,7 @@ func TestRequestCCInfo_AllTimeRanges(t *testing.T) {

time.Sleep(50 * time.Millisecond)

response, err := RequestCCInfo(socketPath, timeRange, 5*time.Second)
response, err := RequestCCInfo(socketPath, timeRange, "", 5*time.Second)
if err != nil {
t.Fatalf("RequestCCInfo failed: %v", err)
}
Expand Down
Loading