diff --git a/commands/cc.go b/commands/cc.go index 381f7b8..706f796 100644 --- a/commands/cc.go +++ b/commands/cc.go @@ -12,6 +12,7 @@ var CCCommand = &cli.Command{ Subcommands: []*cli.Command{ CCInstallCommand, CCUninstallCommand, + CCStatuslineCommand, }, } diff --git a/commands/cc_statusline.go b/commands/cc_statusline.go new file mode 100644 index 0000000..03291f6 --- /dev/null +++ b/commands/cc_statusline.go @@ -0,0 +1,167 @@ +package commands + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/gookit/color" + "github.com/malamtime/cli/daemon" + "github.com/malamtime/cli/model" + "github.com/urfave/cli/v2" +) + +var CCStatuslineCommand = &cli.Command{ + Name: "statusline", + Usage: "Output statusline for Claude Code (reads JSON from stdin)", + Action: commandCCStatusline, +} + +func commandCCStatusline(c *cli.Context) error { + // Hard timeout for entire operation - statusline must be fast + ctx, cancel := context.WithTimeout(c.Context, 100*time.Millisecond) + defer cancel() + + // Read from stdin + input, err := readStdinWithTimeout(ctx) + if err != nil { + outputFallback() + return nil + } + + // Parse input + var data model.CCStatuslineInput + if err := json.Unmarshal(input, &data); err != nil { + outputFallback() + return nil + } + + // Calculate context percentage + contextPercent := calculateContextPercent(data.ContextWindow) + + // Get daily cost - try daemon first, fallback to direct API + var dailyCost float64 + config, err := configService.ReadConfigFile(ctx) + if err == nil { + dailyCost = getDailyCostWithDaemonFallback(ctx, config) + } + + // Format and output + output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, dailyCost, contextPercent) + fmt.Println(output) + + return nil +} + +func readStdinWithTimeout(ctx context.Context) ([]byte, error) { + resultCh := make(chan []byte, 1) + errCh := make(chan error, 1) + + go func() { + reader := bufio.NewReader(os.Stdin) + var data []byte + for { + line, err := reader.ReadBytes('\n') + data = append(data, line...) + if err != nil { + if err == io.EOF { + break + } + errCh <- err + return + } + } + resultCh <- data + }() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case err := <-errCh: + return nil, err + case data := <-resultCh: + return data, nil + } +} + +func calculateContextPercent(cw model.CCStatuslineContextWindow) float64 { + if cw.ContextWindowSize == 0 { + return 0 + } + + // Use current_usage if available for accurate context window state + if cw.CurrentUsage != nil { + currentTokens := cw.CurrentUsage.InputTokens + + cw.CurrentUsage.OutputTokens + + cw.CurrentUsage.CacheCreationInputTokens + + cw.CurrentUsage.CacheReadInputTokens + return float64(currentTokens) / float64(cw.ContextWindowSize) * 100 + } + + // Fallback to total tokens + currentTokens := cw.TotalInputTokens + cw.TotalOutputTokens + return float64(currentTokens) / float64(cw.ContextWindowSize) * 100 +} + +func formatStatuslineOutput(modelName string, sessionCost, dailyCost, contextPercent float64) string { + var parts []string + + // Model name + modelStr := fmt.Sprintf("🤖 %s", modelName) + parts = append(parts, modelStr) + + // Session cost (cyan) + sessionStr := color.Cyan.Sprintf("💰 $%.2f", sessionCost) + parts = append(parts, sessionStr) + + // Daily cost (yellow) + if dailyCost > 0 { + dailyStr := color.Yellow.Sprintf("📊 $%.2f", dailyCost) + parts = append(parts, dailyStr) + } else { + parts = append(parts, color.Gray.Sprint("📊 -")) + } + + // Context percentage with color coding + var contextStr string + switch { + case contextPercent >= 80: + contextStr = color.Red.Sprintf("📈 %.0f%%", contextPercent) + case contextPercent >= 50: + contextStr = color.Yellow.Sprintf("📈 %.0f%%", contextPercent) + default: + contextStr = color.Green.Sprintf("📈 %.0f%%", contextPercent) + } + parts = append(parts, contextStr) + + return strings.Join(parts, " | ") +} + +func outputFallback() { + fmt.Println(color.Gray.Sprint("🤖 - | 💰 - | 📊 - | 📈 -%")) +} + +// getDailyCostWithDaemonFallback tries to get daily cost from daemon first, +// falls back to direct API if daemon is unavailable +func getDailyCostWithDaemonFallback(ctx context.Context, config model.ShellTimeConfig) float64 { + 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) + if err == nil && resp != nil { + return resp.TotalCostUSD + } + } + + // Fallback to direct API (existing behavior) + return model.FetchDailyCostCached(ctx, config) +} diff --git a/commands/cc_statusline_test.go b/commands/cc_statusline_test.go new file mode 100644 index 0000000..e5e4051 --- /dev/null +++ b/commands/cc_statusline_test.go @@ -0,0 +1,210 @@ +package commands + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/malamtime/cli/daemon" + "github.com/malamtime/cli/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type CCStatuslineTestSuite struct { + suite.Suite + mockConfig *model.MockConfigService + origConfig model.ConfigService + socketPath string + listener net.Listener +} + +func (s *CCStatuslineTestSuite) SetupTest() { + s.origConfig = configService + s.mockConfig = model.NewMockConfigService(s.T()) + configService = s.mockConfig + + s.socketPath = filepath.Join(os.TempDir(), "test-statusline.sock") + os.Remove(s.socketPath) +} + +func (s *CCStatuslineTestSuite) TearDownTest() { + configService = s.origConfig + if s.listener != nil { + s.listener.Close() + } + os.Remove(s.socketPath) +} + +// getDailyCostWithDaemonFallback Tests + +func (s *CCStatuslineTestSuite) TestGetDailyCost_UsesDaemonWhenAvailable() { + // Start mock daemon socket + listener, err := net.Listen("unix", s.socketPath) + assert.NoError(s.T(), err) + s.listener = listener + + expectedCost := 15.67 + go func() { + conn, _ := listener.Accept() + defer conn.Close() + + var msg daemon.SocketMessage + json.NewDecoder(conn).Decode(&msg) + + response := daemon.CCInfoResponse{ + TotalCostUSD: expectedCost, + TimeRange: "today", + CachedAt: time.Now(), + } + json.NewEncoder(conn).Encode(response) + }() + + time.Sleep(10 * time.Millisecond) + + config := model.ShellTimeConfig{ + SocketPath: s.socketPath, + } + + cost := getDailyCostWithDaemonFallback(context.Background(), config) + + assert.Equal(s.T(), expectedCost, cost) +} + +func (s *CCStatuslineTestSuite) TestGetDailyCost_FallbackWhenDaemonUnavailable() { + // No socket exists, should fall back to cached API + config := model.ShellTimeConfig{ + SocketPath: "/nonexistent/socket.sock", + Token: "", // No token means FetchDailyCostCached returns 0 + } + + cost := getDailyCostWithDaemonFallback(context.Background(), config) + + // Should return 0 (from cache fallback with no token) + assert.Equal(s.T(), float64(0), cost) +} + +func (s *CCStatuslineTestSuite) TestGetDailyCost_FallbackOnDaemonError() { + // Start mock daemon that returns error + listener, err := net.Listen("unix", s.socketPath) + assert.NoError(s.T(), err) + s.listener = listener + + go func() { + conn, _ := listener.Accept() + // Close immediately to cause error + conn.Close() + }() + + time.Sleep(10 * time.Millisecond) + + config := model.ShellTimeConfig{ + SocketPath: s.socketPath, + Token: "", // No token + } + + cost := getDailyCostWithDaemonFallback(context.Background(), config) + + // Should fall back and return 0 + assert.Equal(s.T(), float64(0), cost) +} + +func (s *CCStatuslineTestSuite) TestGetDailyCost_UsesDefaultSocketPath() { + // Test that default socket path is used when config is empty + config := model.ShellTimeConfig{ + SocketPath: "", // Empty path + 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) +} + +// formatStatuslineOutput Tests + +func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_AllValues() { + output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 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 +} + +func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroDailyCost() { + output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 50.0) + + // Should show "-" for zero daily cost + assert.Contains(s.T(), output, "📊 -") +} + +func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_HighContextPercentage() { + output := formatStatuslineOutput("test-model", 1.0, 1.0, 85.0) + + // Should contain the percentage (color codes may vary) + assert.Contains(s.T(), output, "85%") +} + +func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_LowContextPercentage() { + output := formatStatuslineOutput("test-model", 1.0, 1.0, 25.0) + + // Should contain the percentage + assert.Contains(s.T(), output, "25%") +} + +// calculateContextPercent Tests + +func (s *CCStatuslineTestSuite) TestCalculateContextPercent_ZeroContextWindowSize() { + cw := model.CCStatuslineContextWindow{ + ContextWindowSize: 0, + TotalInputTokens: 1000, + TotalOutputTokens: 500, + } + + percent := calculateContextPercent(cw) + + assert.Equal(s.T(), float64(0), percent) +} + +func (s *CCStatuslineTestSuite) TestCalculateContextPercent_WithCurrentUsage() { + cw := model.CCStatuslineContextWindow{ + ContextWindowSize: 100000, + CurrentUsage: &model.CCStatuslineContextUsage{ + InputTokens: 10000, + OutputTokens: 5000, + CacheCreationInputTokens: 2000, + CacheReadInputTokens: 3000, + }, + } + + percent := calculateContextPercent(cw) + + // (10000 + 5000 + 2000 + 3000) / 100000 * 100 = 20% + assert.Equal(s.T(), float64(20), percent) +} + +func (s *CCStatuslineTestSuite) TestCalculateContextPercent_WithoutCurrentUsage() { + cw := model.CCStatuslineContextWindow{ + ContextWindowSize: 100000, + TotalInputTokens: 30000, + TotalOutputTokens: 20000, + CurrentUsage: nil, + } + + percent := calculateContextPercent(cw) + + // (30000 + 20000) / 100000 * 100 = 50% + assert.Equal(s.T(), float64(50), percent) +} + +func TestCCStatuslineTestSuite(t *testing.T) { + suite.Run(t, new(CCStatuslineTestSuite)) +} diff --git a/daemon/cc_info_handler_test.go b/daemon/cc_info_handler_test.go new file mode 100644 index 0000000..1ac2ad1 --- /dev/null +++ b/daemon/cc_info_handler_test.go @@ -0,0 +1,336 @@ +package daemon + +import ( + "bytes" + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/malamtime/cli/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type CCInfoHandlerTestSuite struct { + suite.Suite + socketPath string + listener net.Listener +} + +func (s *CCInfoHandlerTestSuite) SetupTest() { + // Create temp socket path + s.socketPath = filepath.Join(os.TempDir(), "test-cc-info-handler.sock") + os.Remove(s.socketPath) // Clean up any existing socket +} + +func (s *CCInfoHandlerTestSuite) TearDownTest() { + if s.listener != nil { + s.listener.Close() + } + os.Remove(s.socketPath) +} + +func (s *CCInfoHandlerTestSuite) TestHandleCCInfo_DefaultsToToday() { + config := &model.ShellTimeConfig{ + SocketPath: s.socketPath, + } + + // Create socket handler + ch := NewGoChannel(PubSubConfig{OutputChannelBuffer: 10}, nil) + defer ch.Close() + handler := NewSocketHandler(config, ch) + + // Create a pipe to simulate connection + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + // Send message without timeRange + msg := SocketMessage{ + Type: SocketMessageTypeCCInfo, + Payload: map[string]interface{}{}, + } + + go func() { + handler.handleCCInfo(serverConn, msg) + }() + + // Read response + var response CCInfoResponse + decoder := json.NewDecoder(clientConn) + err := decoder.Decode(&response) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), "today", response.TimeRange) +} + +func (s *CCInfoHandlerTestSuite) TestHandleCCInfo_ParsesTimeRange() { + config := &model.ShellTimeConfig{ + SocketPath: s.socketPath, + } + + ch := NewGoChannel(PubSubConfig{OutputChannelBuffer: 10}, nil) + defer ch.Close() + handler := NewSocketHandler(config, ch) + + // Create a pipe + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + // Send message with week timeRange + msg := SocketMessage{ + Type: SocketMessageTypeCCInfo, + Payload: map[string]interface{}{ + "timeRange": "week", + }, + } + + go func() { + handler.handleCCInfo(serverConn, msg) + }() + + var response CCInfoResponse + decoder := json.NewDecoder(clientConn) + err := decoder.Decode(&response) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), "week", response.TimeRange) +} + +func (s *CCInfoHandlerTestSuite) TestHandleCCInfo_ReturnsCorrectResponseStructure() { + config := &model.ShellTimeConfig{ + SocketPath: s.socketPath, + } + + ch := NewGoChannel(PubSubConfig{OutputChannelBuffer: 10}, nil) + defer ch.Close() + handler := NewSocketHandler(config, ch) + + // Pre-populate cache + handler.ccInfoTimer.mu.Lock() + handler.ccInfoTimer.cache[CCInfoTimeRangeToday] = CCInfoCache{ + TotalCostUSD: 12.34, + FetchedAt: time.Now(), + } + handler.ccInfoTimer.mu.Unlock() + + // Create a pipe + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + msg := SocketMessage{ + Type: SocketMessageTypeCCInfo, + Payload: map[string]interface{}{ + "timeRange": "today", + }, + } + + go func() { + handler.handleCCInfo(serverConn, msg) + }() + + var response CCInfoResponse + decoder := json.NewDecoder(clientConn) + err := decoder.Decode(&response) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), 12.34, response.TotalCostUSD) + assert.Equal(s.T(), "today", response.TimeRange) + assert.False(s.T(), response.CachedAt.IsZero()) +} + +func (s *CCInfoHandlerTestSuite) TestHandleCCInfo_NotifiesActivity() { + config := &model.ShellTimeConfig{ + SocketPath: s.socketPath, + } + + ch := NewGoChannel(PubSubConfig{OutputChannelBuffer: 10}, nil) + defer ch.Close() + handler := NewSocketHandler(config, ch) + defer handler.ccInfoTimer.Stop() + + // Create a pipe + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + msg := SocketMessage{ + Type: SocketMessageTypeCCInfo, + Payload: map[string]interface{}{}, + } + + before := time.Now() + + go func() { + handler.handleCCInfo(serverConn, msg) + }() + + // Wait for response + var response CCInfoResponse + json.NewDecoder(clientConn).Decode(&response) + + after := time.Now() + + // Check lastActivity was updated + handler.ccInfoTimer.mu.RLock() + lastActivity := handler.ccInfoTimer.lastActivity + handler.ccInfoTimer.mu.RUnlock() + + assert.True(s.T(), lastActivity.After(before) || lastActivity.Equal(before)) + assert.True(s.T(), lastActivity.Before(after) || lastActivity.Equal(after)) +} + +// Test RequestCCInfo client function + +type CCInfoClientTestSuite struct { + suite.Suite + socketPath string + listener net.Listener +} + +func (s *CCInfoClientTestSuite) SetupTest() { + s.socketPath = filepath.Join(os.TempDir(), "test-cc-info-client.sock") + os.Remove(s.socketPath) +} + +func (s *CCInfoClientTestSuite) TearDownTest() { + if s.listener != nil { + s.listener.Close() + } + os.Remove(s.socketPath) +} + +func (s *CCInfoClientTestSuite) TestRequestCCInfo_Success() { + // Start mock server + listener, err := net.Listen("unix", s.socketPath) + assert.NoError(s.T(), err) + s.listener = listener + + expectedResponse := CCInfoResponse{ + TotalCostUSD: 7.89, + TimeRange: "today", + CachedAt: time.Now(), + } + + go func() { + conn, _ := listener.Accept() + defer conn.Close() + + // Read request + var msg SocketMessage + json.NewDecoder(conn).Decode(&msg) + + // Send response + json.NewEncoder(conn).Encode(expectedResponse) + }() + + // Give server time to start + time.Sleep(10 * time.Millisecond) + + response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, 1*time.Second) + + assert.NoError(s.T(), err) + assert.NotNil(s.T(), response) + assert.Equal(s.T(), expectedResponse.TotalCostUSD, response.TotalCostUSD) + assert.Equal(s.T(), expectedResponse.TimeRange, response.TimeRange) +} + +func (s *CCInfoClientTestSuite) TestRequestCCInfo_Timeout() { + // Start mock server that doesn't respond + listener, err := net.Listen("unix", s.socketPath) + assert.NoError(s.T(), err) + s.listener = listener + + go func() { + conn, _ := listener.Accept() + defer conn.Close() + // Don't respond, let it timeout + time.Sleep(1 * time.Second) + }() + + time.Sleep(10 * 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) + + assert.Error(s.T(), err) + assert.Nil(s.T(), response) +} + +func (s *CCInfoClientTestSuite) TestRequestCCInfo_InvalidResponse() { + listener, err := net.Listen("unix", s.socketPath) + assert.NoError(s.T(), err) + s.listener = listener + + go func() { + conn, _ := listener.Accept() + defer conn.Close() + + // Read request + var msg SocketMessage + json.NewDecoder(conn).Decode(&msg) + + // Send invalid JSON + conn.Write([]byte("not valid json")) + }() + + time.Sleep(10 * time.Millisecond) + + response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, 1*time.Second) + + assert.Error(s.T(), err) + assert.Nil(s.T(), response) +} + +func (s *CCInfoClientTestSuite) TestRequestCCInfo_SendsCorrectMessage() { + listener, err := net.Listen("unix", s.socketPath) + assert.NoError(s.T(), err) + s.listener = listener + + var receivedMsg SocketMessage + go func() { + conn, _ := listener.Accept() + defer conn.Close() + + // Read and capture request + json.NewDecoder(conn).Decode(&receivedMsg) + + // Send response + json.NewEncoder(conn).Encode(CCInfoResponse{}) + }() + + time.Sleep(10 * time.Millisecond) + + RequestCCInfo(s.socketPath, CCInfoTimeRangeWeek, 1*time.Second) + + assert.Equal(s.T(), SocketMessageTypeCCInfo, receivedMsg.Type) + + // Check payload + payload, ok := receivedMsg.Payload.(map[string]interface{}) + assert.True(s.T(), ok) + + // Decode the payload properly + payloadBytes, _ := json.Marshal(payload) + var req CCInfoRequest + json.NewDecoder(bytes.NewReader(payloadBytes)).Decode(&req) + assert.Equal(s.T(), CCInfoTimeRangeWeek, req.TimeRange) +} + +func TestCCInfoHandlerTestSuite(t *testing.T) { + suite.Run(t, new(CCInfoHandlerTestSuite)) +} + +func TestCCInfoClientTestSuite(t *testing.T) { + suite.Run(t, new(CCInfoClientTestSuite)) +} diff --git a/daemon/cc_info_timer.go b/daemon/cc_info_timer.go new file mode 100644 index 0000000..9dd3363 --- /dev/null +++ b/daemon/cc_info_timer.go @@ -0,0 +1,247 @@ +package daemon + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/malamtime/cli/model" +) + +var ( + CCInfoFetchInterval = 3 * time.Second + CCInfoInactivityTimeout = 3 * time.Minute +) + +// CCInfoCache holds the cached cost data for a time range +type CCInfoCache struct { + TotalCostUSD float64 + FetchedAt time.Time +} + +// CCInfoTimerService manages lazy-fetching of CC info data +type CCInfoTimerService struct { + config *model.ShellTimeConfig + + mu sync.RWMutex + cache map[CCInfoTimeRange]CCInfoCache + activeRanges map[CCInfoTimeRange]bool + lastActivity time.Time + + timerMu sync.Mutex + timerRunning bool + ticker *time.Ticker + stopChan chan struct{} + wg sync.WaitGroup +} + +// NewCCInfoTimerService creates a new CC info timer service +func NewCCInfoTimerService(config *model.ShellTimeConfig) *CCInfoTimerService { + return &CCInfoTimerService{ + config: config, + cache: make(map[CCInfoTimeRange]CCInfoCache), + activeRanges: make(map[CCInfoTimeRange]bool), + stopChan: make(chan struct{}), + } +} + +// GetCachedCost returns the cached cost for the given time range +// It also marks the range as active and starts the timer if not running +func (s *CCInfoTimerService) GetCachedCost(timeRange CCInfoTimeRange) CCInfoCache { + s.mu.Lock() + s.activeRanges[timeRange] = true + cache := s.cache[timeRange] + s.mu.Unlock() + + return cache +} + +// NotifyActivity signals that a client has requested data +// This starts the timer if not running, or resets the inactivity timeout +func (s *CCInfoTimerService) NotifyActivity() { + s.mu.Lock() + s.lastActivity = time.Now() + s.mu.Unlock() + + s.timerMu.Lock() + defer s.timerMu.Unlock() + + if !s.timerRunning { + s.startTimer() + } +} + +// Stop gracefully stops the timer service +func (s *CCInfoTimerService) Stop() { + s.timerMu.Lock() + if s.timerRunning { + s.ticker.Stop() + s.timerRunning = false + } + s.timerMu.Unlock() + + select { + case <-s.stopChan: + // Already closed + default: + close(s.stopChan) + } + + s.wg.Wait() + slog.Info("CC info timer service stopped") +} + +// startTimer starts the timer loop (must be called with timerMu held) +func (s *CCInfoTimerService) startTimer() { + if s.timerRunning { + return + } + + s.timerRunning = true + s.ticker = time.NewTicker(CCInfoFetchInterval) + s.wg.Add(1) + + go s.timerLoop() + + slog.Info("CC info timer started") +} + +// stopTimer stops the timer (must be called with timerMu held) +func (s *CCInfoTimerService) stopTimer() { + if !s.timerRunning { + return + } + + s.ticker.Stop() + s.timerRunning = false + + // Clear active ranges when stopping + s.mu.Lock() + s.activeRanges = make(map[CCInfoTimeRange]bool) + s.mu.Unlock() + + slog.Info("CC info timer stopped due to inactivity") +} + +// timerLoop runs the timer loop +func (s *CCInfoTimerService) timerLoop() { + defer s.wg.Done() + + // Fetch immediately on start + s.fetchActiveRanges(context.Background()) + + for { + select { + case <-s.ticker.C: + // Check inactivity before fetching + if s.checkInactivity() { + s.timerMu.Lock() + s.stopTimer() + s.timerMu.Unlock() + return + } + s.fetchActiveRanges(context.Background()) + + case <-s.stopChan: + return + } + } +} + +// checkInactivity returns true if the service has been inactive for too long +func (s *CCInfoTimerService) checkInactivity() bool { + s.mu.RLock() + lastActivity := s.lastActivity + s.mu.RUnlock() + + return time.Since(lastActivity) > CCInfoInactivityTimeout +} + +// fetchActiveRanges fetches data for all active time ranges +func (s *CCInfoTimerService) fetchActiveRanges(ctx context.Context) { + if s.config.Token == "" { + return + } + + // Get active ranges + s.mu.RLock() + ranges := make([]CCInfoTimeRange, 0, len(s.activeRanges)) + for r := range s.activeRanges { + ranges = append(ranges, r) + } + s.mu.RUnlock() + + // Fetch each active range + for _, timeRange := range ranges { + cost, err := s.fetchCost(ctx, timeRange) + if err != nil { + slog.Warn("Failed to fetch CC info cost", + slog.String("timeRange", string(timeRange)), + slog.Any("err", err)) + continue + } + + s.mu.Lock() + s.cache[timeRange] = CCInfoCache{ + TotalCostUSD: cost, + FetchedAt: time.Now(), + } + s.mu.Unlock() + + slog.Debug("CC info cost updated", + slog.String("timeRange", string(timeRange)), + slog.Float64("cost", cost)) + } +} + +// fetchCost fetches the cost for a specific time range +func (s *CCInfoTimerService) fetchCost(ctx context.Context, timeRange CCInfoTimeRange) (float64, error) { + now := time.Now() + var since time.Time + + switch timeRange { + case CCInfoTimeRangeToday: + since = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + case CCInfoTimeRangeWeek: + // Start of current week (Monday) + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 // Sunday is 7 + } + since = time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location()) + case CCInfoTimeRangeMonth: + since = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + default: + // Default to today + since = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + } + + variables := map[string]interface{}{ + "filter": map[string]interface{}{ + "since": since.Format(time.RFC3339), + "until": now.Format(time.RFC3339), + "clientType": "claude_code", + }, + } + + var result model.GraphQLResponse[model.CCStatuslineDailyCostResponse] + + err := model.SendGraphQLRequest(model.GraphQLRequestOptions[model.GraphQLResponse[model.CCStatuslineDailyCostResponse]]{ + Context: ctx, + Endpoint: model.Endpoint{ + Token: s.config.Token, + APIEndpoint: s.config.APIEndpoint, + }, + Query: model.CCStatuslineDailyCostQuery, + Variables: variables, + Response: &result, + Timeout: 5 * time.Second, + }) + + if err != nil { + return 0, err + } + + return result.Data.FetchUser.AICodeOtel.Analytics.TotalCostUsd, nil +} diff --git a/daemon/cc_info_timer_test.go b/daemon/cc_info_timer_test.go new file mode 100644 index 0000000..75dbf20 --- /dev/null +++ b/daemon/cc_info_timer_test.go @@ -0,0 +1,459 @@ +package daemon + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/malamtime/cli/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type CCInfoTimerTestSuite struct { + suite.Suite + server *httptest.Server + originalFetchInterval time.Duration + originalInactivityTimeout time.Duration +} + +func (s *CCInfoTimerTestSuite) SetupSuite() { + // Save original values + s.originalFetchInterval = CCInfoFetchInterval + s.originalInactivityTimeout = CCInfoInactivityTimeout + + // Setup mock GraphQL server + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/graphql" { + response := map[string]interface{}{ + "data": map[string]interface{}{ + "fetchUser": map[string]interface{}{ + "aiCodeOtel": map[string]interface{}{ + "analytics": map[string]interface{}{ + "totalCostUsd": 5.42, + }, + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + } + })) +} + +func (s *CCInfoTimerTestSuite) TearDownSuite() { + s.server.Close() +} + +func (s *CCInfoTimerTestSuite) SetupTest() { + // Use short intervals for fast tests + CCInfoFetchInterval = 10 * time.Millisecond + CCInfoInactivityTimeout = 50 * time.Millisecond +} + +func (s *CCInfoTimerTestSuite) TearDownTest() { + // Restore original values + CCInfoFetchInterval = s.originalFetchInterval + CCInfoInactivityTimeout = s.originalInactivityTimeout +} + +// Constructor Tests + +func (s *CCInfoTimerTestSuite) TestNewCCInfoTimerService() { + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: s.server.URL, + } + + service := NewCCInfoTimerService(config) + + assert.NotNil(s.T(), service) + assert.NotNil(s.T(), service.cache) + assert.NotNil(s.T(), service.activeRanges) + assert.Empty(s.T(), service.cache) + assert.Empty(s.T(), service.activeRanges) + assert.False(s.T(), service.timerRunning) +} + +// Cache Tests + +func (s *CCInfoTimerTestSuite) TestGetCachedCost_EmptyCache() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + cache := service.GetCachedCost(CCInfoTimeRangeToday) + + assert.Equal(s.T(), float64(0), cache.TotalCostUSD) + assert.True(s.T(), cache.FetchedAt.IsZero()) +} + +func (s *CCInfoTimerTestSuite) TestGetCachedCost_MarksRangeAsActive() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + service.GetCachedCost(CCInfoTimeRangeToday) + + service.mu.RLock() + defer service.mu.RUnlock() + assert.True(s.T(), service.activeRanges[CCInfoTimeRangeToday]) +} + +func (s *CCInfoTimerTestSuite) TestGetCachedCost_ReturnsCachedValue() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + // Manually set cache + expectedCost := 10.50 + expectedTime := time.Now() + service.mu.Lock() + service.cache[CCInfoTimeRangeToday] = CCInfoCache{ + TotalCostUSD: expectedCost, + FetchedAt: expectedTime, + } + service.mu.Unlock() + + cache := service.GetCachedCost(CCInfoTimeRangeToday) + + assert.Equal(s.T(), expectedCost, cache.TotalCostUSD) + assert.Equal(s.T(), expectedTime, cache.FetchedAt) +} + +func (s *CCInfoTimerTestSuite) TestGetCachedCost_MultipleRanges() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + // Set different values for different ranges + service.mu.Lock() + service.cache[CCInfoTimeRangeToday] = CCInfoCache{TotalCostUSD: 1.0} + service.cache[CCInfoTimeRangeWeek] = CCInfoCache{TotalCostUSD: 7.0} + service.cache[CCInfoTimeRangeMonth] = CCInfoCache{TotalCostUSD: 30.0} + service.mu.Unlock() + + todayCache := service.GetCachedCost(CCInfoTimeRangeToday) + weekCache := service.GetCachedCost(CCInfoTimeRangeWeek) + monthCache := service.GetCachedCost(CCInfoTimeRangeMonth) + + assert.Equal(s.T(), 1.0, todayCache.TotalCostUSD) + assert.Equal(s.T(), 7.0, weekCache.TotalCostUSD) + assert.Equal(s.T(), 30.0, monthCache.TotalCostUSD) +} + +// Timer Lifecycle Tests + +func (s *CCInfoTimerTestSuite) TestNotifyActivity_StartsTimer() { + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: s.server.URL, + } + service := NewCCInfoTimerService(config) + defer service.Stop() + + assert.False(s.T(), service.timerRunning) + + service.NotifyActivity() + + // Timer should start + service.timerMu.Lock() + running := service.timerRunning + service.timerMu.Unlock() + assert.True(s.T(), running) +} + +func (s *CCInfoTimerTestSuite) TestNotifyActivity_UpdatesLastActivity() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + defer service.Stop() + + before := time.Now() + service.NotifyActivity() + after := time.Now() + + service.mu.RLock() + lastActivity := service.lastActivity + service.mu.RUnlock() + + assert.True(s.T(), lastActivity.After(before) || lastActivity.Equal(before)) + assert.True(s.T(), lastActivity.Before(after) || lastActivity.Equal(after)) +} + +func (s *CCInfoTimerTestSuite) TestNotifyActivity_DoesNotRestartRunningTimer() { + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: s.server.URL, + } + service := NewCCInfoTimerService(config) + defer service.Stop() + + // Start timer + service.NotifyActivity() + + // Get the ticker reference + service.timerMu.Lock() + originalTicker := service.ticker + service.timerMu.Unlock() + + // Call again + service.NotifyActivity() + + // Ticker should be the same instance + service.timerMu.Lock() + currentTicker := service.ticker + service.timerMu.Unlock() + + assert.Same(s.T(), originalTicker, currentTicker) +} + +func (s *CCInfoTimerTestSuite) TestStop_GracefulShutdown() { + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: s.server.URL, + } + service := NewCCInfoTimerService(config) + + // Start timer + service.NotifyActivity() + time.Sleep(20 * time.Millisecond) // Let timer loop start + + // Stop should complete without hanging + done := make(chan struct{}) + go func() { + service.Stop() + close(done) + }() + + select { + case <-done: + // Success + case <-time.After(1 * time.Second): + s.T().Fatal("Stop() did not complete in time") + } + + service.timerMu.Lock() + running := service.timerRunning + service.timerMu.Unlock() + assert.False(s.T(), running) +} + +func (s *CCInfoTimerTestSuite) TestStop_Idempotent() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + // Start and stop + service.NotifyActivity() + service.Stop() + + // Second stop should not panic + assert.NotPanics(s.T(), func() { + service.Stop() + }) +} + +// Inactivity Tests + +func (s *CCInfoTimerTestSuite) TestCheckInactivity_ReturnsFalseWhenActive() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + service.mu.Lock() + service.lastActivity = time.Now() + service.mu.Unlock() + + assert.False(s.T(), service.checkInactivity()) +} + +func (s *CCInfoTimerTestSuite) TestCheckInactivity_ReturnsTrueWhenInactive() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + // Set last activity to beyond timeout + service.mu.Lock() + service.lastActivity = time.Now().Add(-CCInfoInactivityTimeout - time.Second) + service.mu.Unlock() + + assert.True(s.T(), service.checkInactivity()) +} + +func (s *CCInfoTimerTestSuite) TestTimerStopsAfterInactivity() { + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: s.server.URL, + } + service := NewCCInfoTimerService(config) + + // Start timer with activity + service.GetCachedCost(CCInfoTimeRangeToday) // Mark range as active + service.NotifyActivity() + + // Wait for inactivity timeout plus a buffer + time.Sleep(CCInfoInactivityTimeout + 30*time.Millisecond) + + // Timer should have stopped + service.timerMu.Lock() + running := service.timerRunning + service.timerMu.Unlock() + assert.False(s.T(), running) +} + +// Fetch Tests + +func (s *CCInfoTimerTestSuite) TestFetchActiveRanges_NoToken() { + config := &model.ShellTimeConfig{ + Token: "", // No token + } + service := NewCCInfoTimerService(config) + + // Mark range as active + service.mu.Lock() + service.activeRanges[CCInfoTimeRangeToday] = true + service.mu.Unlock() + + service.fetchActiveRanges(context.Background()) + + // Cache should remain empty + service.mu.RLock() + cache := service.cache[CCInfoTimeRangeToday] + service.mu.RUnlock() + assert.Equal(s.T(), float64(0), cache.TotalCostUSD) +} + +func (s *CCInfoTimerTestSuite) TestFetchActiveRanges_UpdatesCache() { + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: s.server.URL, + } + service := NewCCInfoTimerService(config) + + // Mark range as active + service.mu.Lock() + service.activeRanges[CCInfoTimeRangeToday] = true + service.mu.Unlock() + + service.fetchActiveRanges(context.Background()) + + // Cache should be updated + service.mu.RLock() + cache := service.cache[CCInfoTimeRangeToday] + service.mu.RUnlock() + assert.Equal(s.T(), 5.42, cache.TotalCostUSD) + assert.False(s.T(), cache.FetchedAt.IsZero()) +} + +func (s *CCInfoTimerTestSuite) TestFetchActiveRanges_APIError() { + // Create server that returns error + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer errorServer.Close() + + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: errorServer.URL, + } + service := NewCCInfoTimerService(config) + + // Set initial cache value + service.mu.Lock() + service.activeRanges[CCInfoTimeRangeToday] = true + service.cache[CCInfoTimeRangeToday] = CCInfoCache{TotalCostUSD: 1.23} + service.mu.Unlock() + + service.fetchActiveRanges(context.Background()) + + // Original cache value should be preserved + service.mu.RLock() + cache := service.cache[CCInfoTimeRangeToday] + service.mu.RUnlock() + assert.Equal(s.T(), 1.23, cache.TotalCostUSD) +} + +func (s *CCInfoTimerTestSuite) TestFetchCost_TodayRange() { + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: s.server.URL, + } + service := NewCCInfoTimerService(config) + + cost, err := service.fetchCost(context.Background(), CCInfoTimeRangeToday) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), 5.42, cost) +} + +func (s *CCInfoTimerTestSuite) TestFetchCost_WeekRange() { + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: s.server.URL, + } + service := NewCCInfoTimerService(config) + + cost, err := service.fetchCost(context.Background(), CCInfoTimeRangeWeek) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), 5.42, cost) +} + +func (s *CCInfoTimerTestSuite) TestFetchCost_MonthRange() { + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: s.server.URL, + } + service := NewCCInfoTimerService(config) + + cost, err := service.fetchCost(context.Background(), CCInfoTimeRangeMonth) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), 5.42, cost) +} + +// Concurrency Tests + +func (s *CCInfoTimerTestSuite) TestConcurrentGetCachedCost() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + var wg sync.WaitGroup + numGoroutines := 100 + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + service.GetCachedCost(CCInfoTimeRangeToday) + }() + } + + // Should complete without race conditions + wg.Wait() +} + +func (s *CCInfoTimerTestSuite) TestConcurrentNotifyActivity() { + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: s.server.URL, + } + service := NewCCInfoTimerService(config) + defer service.Stop() + + var wg sync.WaitGroup + numGoroutines := 100 + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + service.NotifyActivity() + }() + } + + // Should complete without race conditions + wg.Wait() +} + +func TestCCInfoTimerTestSuite(t *testing.T) { + suite.Run(t, new(CCInfoTimerTestSuite)) +} diff --git a/daemon/client.go b/daemon/client.go index af5460a..c053f1e 100644 --- a/daemon/client.go +++ b/daemon/client.go @@ -50,3 +50,37 @@ func SendLocalDataToSocket( return nil } + +// RequestCCInfo requests CC info (cost data) from the daemon +func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, timeout time.Duration) (*CCInfoResponse, error) { + conn, err := net.DialTimeout("unix", socketPath, timeout) + if err != nil { + return nil, err + } + defer conn.Close() + + // Set read/write deadline + conn.SetDeadline(time.Now().Add(timeout)) + + // Send request + msg := SocketMessage{ + Type: SocketMessageTypeCCInfo, + Payload: CCInfoRequest{ + TimeRange: timeRange, + }, + } + + encoder := json.NewEncoder(conn) + if err := encoder.Encode(msg); err != nil { + return nil, err + } + + // Read response + var response CCInfoResponse + decoder := json.NewDecoder(conn) + if err := decoder.Decode(&response); err != nil { + return nil, err + } + + return &response, nil +} diff --git a/daemon/socket.go b/daemon/socket.go index a436ba8..607f242 100644 --- a/daemon/socket.go +++ b/daemon/socket.go @@ -20,8 +20,27 @@ const ( SocketMessageTypeSync SocketMessageType = "sync" SocketMessageTypeHeartbeat SocketMessageType = "heartbeat" SocketMessageTypeStatus SocketMessageType = "status" + SocketMessageTypeCCInfo SocketMessageType = "cc_info" ) +type CCInfoTimeRange string + +const ( + CCInfoTimeRangeToday CCInfoTimeRange = "today" + CCInfoTimeRangeWeek CCInfoTimeRange = "week" + CCInfoTimeRangeMonth CCInfoTimeRange = "month" +) + +type CCInfoRequest struct { + TimeRange CCInfoTimeRange `json:"timeRange"` +} + +type CCInfoResponse struct { + TotalCostUSD float64 `json:"totalCostUsd"` + TimeRange string `json:"timeRange"` + CachedAt time.Time `json:"cachedAt"` +} + // StatusResponse contains daemon status information type StatusResponse struct { Version string `json:"version"` @@ -41,15 +60,17 @@ type SocketHandler struct { config *model.ShellTimeConfig listener net.Listener - channel *GoChannel - stopChan chan struct{} + channel *GoChannel + stopChan chan struct{} + ccInfoTimer *CCInfoTimerService } func NewSocketHandler(config *model.ShellTimeConfig, ch *GoChannel) *SocketHandler { return &SocketHandler{ - config: config, - channel: ch, - stopChan: make(chan struct{}), + config: config, + channel: ch, + stopChan: make(chan struct{}), + ccInfoTimer: NewCCInfoTimerService(config), } } @@ -80,6 +101,9 @@ func (p *SocketHandler) Start() error { func (p *SocketHandler) Stop() { p.channel.Close() close(p.stopChan) + if p.ccInfoTimer != nil { + p.ccInfoTimer.Stop() + } if p.listener != nil { p.listener.Close() } @@ -146,6 +170,8 @@ func (p *SocketHandler) handleConnection(conn net.Conn) { // Send acknowledgment to client encoder := json.NewEncoder(conn) encoder.Encode(map[string]string{"status": "ok"}) + case SocketMessageTypeCCInfo: + p.handleCCInfo(conn, msg) default: slog.Error("Unknown message type:", slog.String("messageType", string(msg.Type))) } @@ -167,6 +193,31 @@ func (p *SocketHandler) handleStatus(conn net.Conn) { } } +func (p *SocketHandler) handleCCInfo(conn net.Conn, msg SocketMessage) { + // Parse time range from payload, default to "today" + timeRange := CCInfoTimeRangeToday + if payload, ok := msg.Payload.(map[string]interface{}); ok { + if tr, ok := payload["timeRange"].(string); ok { + timeRange = CCInfoTimeRange(tr) + } + } + + // Notify activity and get cached cost + p.ccInfoTimer.NotifyActivity() + cache := p.ccInfoTimer.GetCachedCost(timeRange) + + response := CCInfoResponse{ + TotalCostUSD: cache.TotalCostUSD, + TimeRange: string(timeRange), + CachedAt: cache.FetchedAt, + } + + encoder := json.NewEncoder(conn) + if err := encoder.Encode(response); err != nil { + slog.Error("Error encoding cc_info response", slog.Any("err", err)) + } +} + func formatDuration(d time.Duration) string { days := int(d.Hours() / 24) hours := int(d.Hours()) % 24 diff --git a/docs/CC_STATUSLINE.md b/docs/CC_STATUSLINE.md new file mode 100644 index 0000000..5075d77 --- /dev/null +++ b/docs/CC_STATUSLINE.md @@ -0,0 +1,152 @@ +# Claude Code Statusline Integration + +Display real-time cost and context usage in Claude Code's status bar using ShellTime. + +## Overview + +The `shelltime cc statusline` command provides a custom status line for Claude Code that shows: + +- Current model name +- Session cost (current conversation) +- Today's total cost (from ShellTime API) +- Context window usage percentage + +## Quick Start + +### 1. Configure Claude Code + +Add to your Claude Code settings (`~/.claude/settings.json`): + +```json +{ + "statusLine": { + "type": "command", + "command": "shelltime cc statusline" + } +} +``` + +### 2. That's It! + +The status line will appear at the bottom of Claude Code: + +``` +🤖 Opus | 💰 $0.12 | 📊 $3.45 | 📈 45% +``` + +--- + +## Output Format + +| Section | Emoji | Description | Color | +|---------|-------|-------------|-------| +| Model | 🤖 | Current model display name | Default | +| Session | 💰 | Current session cost in USD | Cyan | +| Today | 📊 | Today's total cost from API | Yellow | +| Context | 📈 | Context window usage % | Green/Yellow/Red | + +### Context Color Coding + +| Usage | Color | Meaning | +|-------|-------|---------| +| < 50% | Green | Plenty of context remaining | +| 50-80% | Yellow | Context getting full | +| > 80% | Red | Context nearly exhausted | + +--- + +## How It Works + +1. **Claude Code** passes session data as JSON via stdin +2. **shelltime cc statusline** parses the JSON and extracts: + - Model name from `model.display_name` + - Session cost from `cost.total_cost_usd` + - Context usage from `context_window` +3. **Daily cost** is fetched from ShellTime GraphQL API (cached for 5 minutes) +4. **Output** is a single formatted line with ANSI colors + +### JSON Input (from Claude Code) + +```json +{ + "model": { + "id": "claude-opus-4-1", + "display_name": "Opus" + }, + "cost": { + "total_cost_usd": 0.12, + "total_duration_ms": 45000 + }, + "context_window": { + "total_input_tokens": 15234, + "total_output_tokens": 4521, + "context_window_size": 200000, + "current_usage": { + "input_tokens": 8500, + "output_tokens": 1200, + "cache_creation_input_tokens": 5000, + "cache_read_input_tokens": 2000 + } + } +} +``` + +--- + +## Requirements + +### For Session Cost & Context + +No additional setup required - data comes directly from Claude Code. + +### For Today's Cost + +Requires ShellTime configuration: + +```yaml +# ~/.shelltime/config.yaml +token: "your-api-token" +apiEndpoint: "https://api.shelltime.xyz" + +# Enable OTEL collection to track costs +aiCodeOtel: + enabled: true +``` + +If no token is configured, the daily cost will show as `-`. + +--- + +## Performance + +- **Hard timeout:** 100ms for entire operation +- **API caching:** 5-minute TTL to minimize API calls +- **Non-blocking:** Background API fetches don't delay output +- **Graceful degradation:** Shows available data even if API fails + +--- + +## Troubleshooting + +### Status line not appearing + +1. Check Claude Code settings path: `~/.claude/settings.json` +2. Verify shelltime is in your PATH: `which shelltime` +3. Test manually: `echo '{}' | shelltime cc statusline` + +### Daily cost shows `-` + +1. Verify your token is configured: `shelltime doctor` +2. Check AICodeOtel is enabled in your config +3. Ensure the daemon is running: `shelltime daemon status` + +### Colors not displaying + +Your terminal may not support ANSI colors. Check terminal settings or try a different terminal emulator. + +--- + +## Related + +- [Configuration Guide](./CONFIG.md) - Full configuration reference +- [Claude Code Integration](./CONFIG.md#claude-code-integration) - AICodeOtel setup diff --git a/model/cc_statusline_cache.go b/model/cc_statusline_cache.go new file mode 100644 index 0000000..569583e --- /dev/null +++ b/model/cc_statusline_cache.go @@ -0,0 +1,102 @@ +package model + +import ( + "sync" + "time" +) + +const ( + // DefaultStatuslineCacheTTL is the default cache TTL for statusline daily cost + DefaultStatuslineCacheTTL = 5 * time.Minute +) + +// ccStatuslineCacheEntry represents a cached daily cost entry +type ccStatuslineCacheEntry struct { + Date string + CostUsd float64 + FetchedAt time.Time + TTL time.Duration +} + +// IsValid returns true if the cache entry is still valid +func (e *ccStatuslineCacheEntry) IsValid() bool { + if e == nil { + return false + } + // Check if date matches today + today := time.Now().Format("2006-01-02") + if e.Date != today { + return false + } + // Check TTL + return time.Since(e.FetchedAt) < e.TTL +} + +// ccStatuslineCache manages caching for statusline daily cost +type ccStatuslineCache struct { + mu sync.RWMutex + entry *ccStatuslineCacheEntry + ttl time.Duration + fetching bool +} + +// Global cache instance (package-level singleton) +var statuslineCache = &ccStatuslineCache{ + ttl: DefaultStatuslineCacheTTL, +} + +// CCStatuslineCacheGet returns cached value and whether it's valid +func CCStatuslineCacheGet() (float64, bool) { + statuslineCache.mu.RLock() + defer statuslineCache.mu.RUnlock() + + if statuslineCache.entry != nil && statuslineCache.entry.IsValid() { + return statuslineCache.entry.CostUsd, true + } + return 0, false +} + +// CCStatuslineCacheGetLastValue returns the last cached value even if expired +func CCStatuslineCacheGetLastValue() float64 { + statuslineCache.mu.RLock() + defer statuslineCache.mu.RUnlock() + + if statuslineCache.entry != nil { + return statuslineCache.entry.CostUsd + } + return 0 +} + +// CCStatuslineCacheSet updates the cache with a new value +func CCStatuslineCacheSet(costUsd float64) { + statuslineCache.mu.Lock() + defer statuslineCache.mu.Unlock() + + statuslineCache.entry = &ccStatuslineCacheEntry{ + Date: time.Now().Format("2006-01-02"), + CostUsd: costUsd, + FetchedAt: time.Now(), + TTL: statuslineCache.ttl, + } + statuslineCache.fetching = false +} + +// CCStatuslineCacheStartFetch marks that a fetch is in progress +// Returns true if fetch can start, false if already fetching +func CCStatuslineCacheStartFetch() bool { + statuslineCache.mu.Lock() + defer statuslineCache.mu.Unlock() + + if statuslineCache.fetching { + return false + } + statuslineCache.fetching = true + return true +} + +// CCStatuslineCacheEndFetch marks that fetch has completed (used on error) +func CCStatuslineCacheEndFetch() { + statuslineCache.mu.Lock() + defer statuslineCache.mu.Unlock() + statuslineCache.fetching = false +} diff --git a/model/cc_statusline_service.go b/model/cc_statusline_service.go new file mode 100644 index 0000000..f74e431 --- /dev/null +++ b/model/cc_statusline_service.go @@ -0,0 +1,85 @@ +package model + +import ( + "context" + "time" +) + +// FetchDailyCostCached returns today's cost from cache or fetches from API +// This function is non-blocking - if cache is invalid, it returns cached/zero value +// and triggers a background fetch +func FetchDailyCostCached(ctx context.Context, config ShellTimeConfig) float64 { + // Try cache first + if cost, valid := CCStatuslineCacheGet(); valid { + return cost + } + + // Try to start background fetch + go fetchDailyCostAsync(context.Background(), config) + + // Return last known value or 0 + return CCStatuslineCacheGetLastValue() +} + +// fetchDailyCostAsync fetches daily cost from API in the background +func fetchDailyCostAsync(ctx context.Context, config ShellTimeConfig) { + // Check if already fetching + if !CCStatuslineCacheStartFetch() { + return + } + + // Ensure we mark fetch as complete on any exit + defer CCStatuslineCacheEndFetch() + + // Check if we have a token + if config.Token == "" { + return + } + + // Fetch from API + cost, err := FetchDailyCost(ctx, config) + if err != nil { + return + } + + // Update cache + CCStatuslineCacheSet(cost) +} + +// FetchDailyCost fetches today's cost from the GraphQL API +func FetchDailyCost(ctx context.Context, config ShellTimeConfig) (float64, error) { + ctx, span := modelTracer.Start(ctx, "statusline.fetchDailyCost") + defer span.End() + + // Prepare time filter for today + now := time.Now() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + variables := map[string]interface{}{ + "filter": map[string]interface{}{ + "since": startOfDay.Format(time.RFC3339), + "until": now.Format(time.RFC3339), + "clientType": "claude_code", + }, + } + + var result GraphQLResponse[CCStatuslineDailyCostResponse] + + err := SendGraphQLRequest(GraphQLRequestOptions[GraphQLResponse[CCStatuslineDailyCostResponse]]{ + Context: ctx, + Endpoint: Endpoint{ + Token: config.Token, + APIEndpoint: config.APIEndpoint, + }, + Query: CCStatuslineDailyCostQuery, + Variables: variables, + Response: &result, + Timeout: 5 * time.Second, + }) + + if err != nil { + return 0, err + } + + return result.Data.FetchUser.AICodeOtel.Analytics.TotalCostUsd, nil +} diff --git a/model/cc_statusline_types.go b/model/cc_statusline_types.go new file mode 100644 index 0000000..633f376 --- /dev/null +++ b/model/cc_statusline_types.go @@ -0,0 +1,58 @@ +package model + +// CCStatuslineInput represents the JSON input from Claude Code statusline +type CCStatuslineInput struct { + Model CCStatuslineModel `json:"model"` + Cost CCStatuslineCost `json:"cost"` + ContextWindow CCStatuslineContextWindow `json:"context_window"` +} + +// CCStatuslineModel represents model information +type CCStatuslineModel struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` +} + +// CCStatuslineCost represents session cost information +type CCStatuslineCost struct { + TotalCostUSD float64 `json:"total_cost_usd"` + TotalDurationMS int64 `json:"total_duration_ms"` +} + +// CCStatuslineContextWindow represents context window usage +type CCStatuslineContextWindow struct { + TotalInputTokens int `json:"total_input_tokens"` + TotalOutputTokens int `json:"total_output_tokens"` + ContextWindowSize int `json:"context_window_size"` + CurrentUsage *CCStatuslineContextUsage `json:"current_usage"` +} + +// CCStatuslineContextUsage represents current context usage details +type CCStatuslineContextUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` +} + +// CCStatuslineDailyCostResponse is the GraphQL response structure for daily cost +type CCStatuslineDailyCostResponse struct { + FetchUser struct { + AICodeOtel struct { + Analytics struct { + TotalCostUsd float64 `json:"totalCostUsd"` + } `json:"analytics"` + } `json:"aiCodeOtel"` + } `json:"fetchUser"` +} + +// CCStatuslineDailyCostQuery is the GraphQL query for fetching daily cost +const CCStatuslineDailyCostQuery = `query fetchAICodeOtelAnalytics($filter: AICodeAnalyticsFilter!) { + fetchUser { + aiCodeOtel { + analytics(filter: $filter) { + totalCostUsd + } + } + } +}`