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
1 change: 1 addition & 0 deletions commands/cc.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ var CCCommand = &cli.Command{
Subcommands: []*cli.Command{
CCInstallCommand,
CCUninstallCommand,
CCStatuslineCommand,
},
}

Expand Down
167 changes: 167 additions & 0 deletions commands/cc_statusline.go
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
Comment on lines +66 to +79
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

data, err := io.ReadAll(os.Stdin)
if err != nil {
	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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The calculation for currentTokens appears to be missing cw.CurrentUsage.OutputTokens. The context window is typically consumed by both input and output tokens. The fallback logic on line 105 correctly includes both total input and output tokens, suggesting that OutputTokens from current_usage should also be included here for an accurate percentage.

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)
}
210 changes: 210 additions & 0 deletions commands/cc_statusline_test.go
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))
}
Loading
Loading