-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cc): add statusline command for Claude Code integration #190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0cd5c4a
b994613
8dac5fb
fb6be48
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Comment on lines
+99
to
+102
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The calculation for 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) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current implementation for reading from stdin is more complex than necessary. You can simplify this by using
io.ReadAll(os.Stdin), which reads from the reader until an error or EOF and returns all the data. This makes the code more concise and easier to understand.