Skip to content

Commit a972290

Browse files
authored
Merge pull request #202 from shelltime/feat/cc-statusline-git-info
feat(cc): add git branch and dirty status to statusline
2 parents fde361e + 5c38a67 commit a972290

11 files changed

Lines changed: 380 additions & 53 deletions

File tree

commands/cc_statusline.go

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ var CCStatuslineCommand = &cli.Command{
2222
Action: commandCCStatusline,
2323
}
2424

25+
// ccStatuslineResult combines daily stats with git info from daemon
26+
type ccStatuslineResult struct {
27+
Cost float64
28+
SessionSeconds int
29+
GitBranch string
30+
GitDirty bool
31+
}
32+
2533
func commandCCStatusline(c *cli.Context) error {
2634
// Hard timeout for entire operation - statusline must be fast
2735
ctx, cancel := context.WithTimeout(c.Context, 100*time.Millisecond)
@@ -44,15 +52,15 @@ func commandCCStatusline(c *cli.Context) error {
4452
// Calculate context percentage
4553
contextPercent := calculateContextPercent(data.ContextWindow)
4654

47-
// Get daily stats - try daemon first, fallback to direct API
48-
var dailyStats model.CCStatuslineDailyStats
55+
// Get daily stats and git info - try daemon first, fallback to direct API
56+
var result ccStatuslineResult
4957
config, err := configService.ReadConfigFile(ctx)
5058
if err == nil {
51-
dailyStats = getDailyStatsWithDaemonFallback(ctx, config)
59+
result = getDaemonInfoWithFallback(ctx, config, data.WorkingDirectory)
5260
}
5361

5462
// Format and output
55-
output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, dailyStats.Cost, dailyStats.SessionSeconds, contextPercent)
63+
output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, result.Cost, result.SessionSeconds, contextPercent, result.GitBranch, result.GitDirty)
5664
fmt.Println(output)
5765

5866
return nil
@@ -108,9 +116,20 @@ func calculateContextPercent(cw model.CCStatuslineContextWindow) float64 {
108116
return float64(currentTokens) / float64(cw.ContextWindowSize) * 100
109117
}
110118

111-
func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64) string {
119+
func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64, gitBranch string, gitDirty bool) string {
112120
var parts []string
113121

122+
// Git info FIRST (green)
123+
if gitBranch != "" {
124+
gitStr := gitBranch
125+
if gitDirty {
126+
gitStr += "*"
127+
}
128+
parts = append(parts, color.Green.Sprintf("🌿 %s", gitStr))
129+
} else {
130+
parts = append(parts, color.Gray.Sprint("🌿 -"))
131+
}
132+
114133
// Model name
115134
modelStr := fmt.Sprintf("🤖 %s", modelName)
116135
parts = append(parts, modelStr)
@@ -151,7 +170,7 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se
151170
}
152171

153172
func outputFallback() {
154-
fmt.Println(color.Gray.Sprint("🤖 - | 💰 - | 📊 - | ⏱️ - | 📈 -%"))
173+
fmt.Println(color.Gray.Sprint("🌿 - | 🤖 - | 💰 - | 📊 - | ⏱️ - | 📈 -%"))
155174
}
156175

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

172-
// getDailyStatsWithDaemonFallback tries to get daily stats from daemon first,
173-
// falls back to direct API if daemon is unavailable
174-
func getDailyStatsWithDaemonFallback(ctx context.Context, config model.ShellTimeConfig) model.CCStatuslineDailyStats {
191+
// getDaemonInfoWithFallback tries to get daily stats and git info from daemon first,
192+
// falls back to direct API for stats if daemon is unavailable (git info only from daemon)
193+
func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig, workingDir string) ccStatuslineResult {
175194
socketPath := config.SocketPath
176195
if socketPath == "" {
177196
socketPath = model.DefaultSocketPath
178197
}
179198

180199
// Try daemon first (50ms timeout for fast path)
181200
if daemon.IsSocketReady(ctx, socketPath) {
182-
resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, 50*time.Millisecond)
201+
resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, workingDir, 50*time.Millisecond)
183202
if err == nil && resp != nil {
184-
return model.CCStatuslineDailyStats{
203+
return ccStatuslineResult{
185204
Cost: resp.TotalCostUSD,
186205
SessionSeconds: resp.TotalSessionSeconds,
206+
GitBranch: resp.GitBranch,
207+
GitDirty: resp.GitDirty,
187208
}
188209
}
189210
}
190211

191-
// Fallback to direct API (existing behavior)
192-
return model.FetchDailyStatsCached(ctx, config)
212+
// Fallback to direct API for stats (no git info available without daemon)
213+
stats := model.FetchDailyStatsCached(ctx, config)
214+
return ccStatuslineResult{
215+
Cost: stats.Cost,
216+
SessionSeconds: stats.SessionSeconds,
217+
}
193218
}

commands/cc_statusline_test.go

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,18 @@ func (s *CCStatuslineTestSuite) TearDownTest() {
4040
os.Remove(s.socketPath)
4141
}
4242

43-
// getDailyCostWithDaemonFallback Tests
43+
// getDaemonInfoWithFallback Tests
4444

45-
func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDaemonWhenAvailable() {
45+
func (s *CCStatuslineTestSuite) TestGetDaemonInfo_UsesDaemonWhenAvailable() {
4646
// Start mock daemon socket
4747
listener, err := net.Listen("unix", s.socketPath)
4848
assert.NoError(s.T(), err)
4949
s.listener = listener
5050

5151
expectedCost := 15.67
5252
expectedSessionSeconds := 3600
53+
expectedBranch := "main"
54+
expectedDirty := true
5355
go func() {
5456
conn, _ := listener.Accept()
5557
defer conn.Close()
@@ -62,6 +64,8 @@ func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDaemonWhenAvailable() {
6264
TotalSessionSeconds: expectedSessionSeconds,
6365
TimeRange: "today",
6466
CachedAt: time.Now(),
67+
GitBranch: expectedBranch,
68+
GitDirty: expectedDirty,
6569
}
6670
json.NewEncoder(conn).Encode(response)
6771
}()
@@ -72,27 +76,31 @@ func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDaemonWhenAvailable() {
7276
SocketPath: s.socketPath,
7377
}
7478

75-
stats := getDailyStatsWithDaemonFallback(context.Background(), config)
79+
result := getDaemonInfoWithFallback(context.Background(), config, "/some/path")
7680

77-
assert.Equal(s.T(), expectedCost, stats.Cost)
78-
assert.Equal(s.T(), expectedSessionSeconds, stats.SessionSeconds)
81+
assert.Equal(s.T(), expectedCost, result.Cost)
82+
assert.Equal(s.T(), expectedSessionSeconds, result.SessionSeconds)
83+
assert.Equal(s.T(), expectedBranch, result.GitBranch)
84+
assert.Equal(s.T(), expectedDirty, result.GitDirty)
7985
}
8086

81-
func (s *CCStatuslineTestSuite) TestGetDailyStats_FallbackWhenDaemonUnavailable() {
87+
func (s *CCStatuslineTestSuite) TestGetDaemonInfo_FallbackWhenDaemonUnavailable() {
8288
// No socket exists, should fall back to cached API
8389
config := model.ShellTimeConfig{
8490
SocketPath: "/nonexistent/socket.sock",
8591
Token: "", // No token means FetchDailyStatsCached returns zero values
8692
}
8793

88-
stats := getDailyStatsWithDaemonFallback(context.Background(), config)
94+
result := getDaemonInfoWithFallback(context.Background(), config, "")
8995

9096
// Should return zero values (from cache fallback with no token)
91-
assert.Equal(s.T(), float64(0), stats.Cost)
92-
assert.Equal(s.T(), 0, stats.SessionSeconds)
97+
assert.Equal(s.T(), float64(0), result.Cost)
98+
assert.Equal(s.T(), 0, result.SessionSeconds)
99+
assert.Empty(s.T(), result.GitBranch)
100+
assert.False(s.T(), result.GitDirty)
93101
}
94102

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

114-
stats := getDailyStatsWithDaemonFallback(context.Background(), config)
122+
result := getDaemonInfoWithFallback(context.Background(), config, "")
115123

116124
// Should fall back and return zero values
117-
assert.Equal(s.T(), float64(0), stats.Cost)
118-
assert.Equal(s.T(), 0, stats.SessionSeconds)
125+
assert.Equal(s.T(), float64(0), result.Cost)
126+
assert.Equal(s.T(), 0, result.SessionSeconds)
127+
assert.Empty(s.T(), result.GitBranch)
128+
assert.False(s.T(), result.GitDirty)
119129
}
120130

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

128138
// This should use model.DefaultSocketPath internally
129139
// Since no daemon is running at the default path, it will fall back to cached API
130-
// The function should not panic and should return a valid stats struct
131-
stats := getDailyStatsWithDaemonFallback(context.Background(), config)
140+
// The function should not panic and should return a valid result struct
141+
result := getDaemonInfoWithFallback(context.Background(), config, "")
132142

133143
// We can't assert on exact values since the global cache might have data
134144
// from previous tests. Just verify the function returns without error
135145
// and returns non-negative values
136-
assert.GreaterOrEqual(s.T(), stats.Cost, float64(0))
137-
assert.GreaterOrEqual(s.T(), stats.SessionSeconds, 0)
146+
assert.GreaterOrEqual(s.T(), result.Cost, float64(0))
147+
assert.GreaterOrEqual(s.T(), result.SessionSeconds, 0)
138148
}
139149

140150
// formatStatuslineOutput Tests
141151

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

145155
// Should contain all components
156+
assert.Contains(s.T(), output, "🌿 main")
146157
assert.Contains(s.T(), output, "🤖 claude-opus-4")
147158
assert.Contains(s.T(), output, "$1.23")
148159
assert.Contains(s.T(), output, "$4.56")
149160
assert.Contains(s.T(), output, "1h1m") // Session time (3661 seconds = 1h 1m 1s)
150161
assert.Contains(s.T(), output, "75%") // Context percentage
151162
}
152163

164+
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithDirtyBranch() {
165+
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "feature/test", true)
166+
167+
// Should contain branch with asterisk for dirty
168+
assert.Contains(s.T(), output, "🌿 feature/test*")
169+
assert.Contains(s.T(), output, "🤖 claude-opus-4")
170+
}
171+
172+
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_NoBranch() {
173+
output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "", false)
174+
175+
// Should show "-" for no branch
176+
assert.Contains(s.T(), output, "🌿 -")
177+
assert.Contains(s.T(), output, "🤖 claude-opus-4")
178+
}
179+
153180
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroDailyCost() {
154-
output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 300, 50.0)
181+
output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 300, 50.0, "main", false)
155182

156183
// Should show "-" for zero daily cost
157184
assert.Contains(s.T(), output, "📊 -")
158185
assert.Contains(s.T(), output, "5m0s") // Session time (300 seconds = 5m)
159186
}
160187

161188
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroSessionSeconds() {
162-
output := formatStatuslineOutput("claude-sonnet", 0.50, 1.0, 0, 50.0)
189+
output := formatStatuslineOutput("claude-sonnet", 0.50, 1.0, 0, 50.0, "main", false)
163190

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

168195
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_HighContextPercentage() {
169-
output := formatStatuslineOutput("test-model", 1.0, 1.0, 60, 85.0)
196+
output := formatStatuslineOutput("test-model", 1.0, 1.0, 60, 85.0, "main", false)
170197

171198
// Should contain the percentage (color codes may vary)
172199
assert.Contains(s.T(), output, "85%")
173200
assert.Contains(s.T(), output, "1m0s")
174201
}
175202

176203
func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_LowContextPercentage() {
177-
output := formatStatuslineOutput("test-model", 1.0, 1.0, 45, 25.0)
204+
output := formatStatuslineOutput("test-model", 1.0, 1.0, 45, 25.0, "main", false)
178205

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

daemon/cc_info_handler_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_Success() {
232232
// Give server time to start
233233
time.Sleep(10 * time.Millisecond)
234234

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

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

256256
time.Sleep(10 * time.Millisecond)
257257

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

260260
assert.Error(s.T(), err)
261261
assert.Nil(s.T(), response)
262262
}
263263

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

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

288288
time.Sleep(10 * time.Millisecond)
289289

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

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

313313
time.Sleep(10 * time.Millisecond)
314314

315-
RequestCCInfo(s.socketPath, CCInfoTimeRangeWeek, 1*time.Second)
315+
RequestCCInfo(s.socketPath, CCInfoTimeRangeWeek, "", 1*time.Second)
316316

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

daemon/client.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ func SendLocalDataToSocket(
5151
return nil
5252
}
5353

54-
// RequestCCInfo requests CC info (cost data) from the daemon
55-
func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, timeout time.Duration) (*CCInfoResponse, error) {
54+
// RequestCCInfo requests CC info (cost data and git info) from the daemon
55+
func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, workingDir string, timeout time.Duration) (*CCInfoResponse, error) {
5656
conn, err := net.DialTimeout("unix", socketPath, timeout)
5757
if err != nil {
5858
return nil, err
@@ -66,7 +66,8 @@ func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, timeout time.Du
6666
msg := SocketMessage{
6767
Type: SocketMessageTypeCCInfo,
6868
Payload: CCInfoRequest{
69-
TimeRange: timeRange,
69+
TimeRange: timeRange,
70+
WorkingDirectory: workingDir,
7071
},
7172
}
7273

daemon/client_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ func TestRequestCCInfo(t *testing.T) {
157157
// Give server time to start
158158
time.Sleep(50 * time.Millisecond)
159159

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

203-
_, err = RequestCCInfo(socketPath, CCInfoTimeRangeToday, 100*time.Millisecond)
203+
_, err = RequestCCInfo(socketPath, CCInfoTimeRangeToday, "", 100*time.Millisecond)
204204
if err == nil {
205205
t.Error("Expected timeout error")
206206
}
207207
}
208208

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

262262
time.Sleep(50 * time.Millisecond)
263263

264-
response, err := RequestCCInfo(socketPath, timeRange, 5*time.Second)
264+
response, err := RequestCCInfo(socketPath, timeRange, "", 5*time.Second)
265265
if err != nil {
266266
t.Fatalf("RequestCCInfo failed: %v", err)
267267
}

0 commit comments

Comments
 (0)