Skip to content

Commit ae9df90

Browse files
committed
feat: show 5h usage window remaining in statusline
Parse rate_limits.five_hour from Claude Code's statusline input to display remaining capacity as a battery indicator: 🔋 94% (1.5/5h). Emoji switches to 🪫 below 25%. Auto-hidden for API billing users (field absent). Add --no-5h flag to opt out.
1 parent 5ea4b0b commit ae9df90

File tree

6 files changed

+208
-18
lines changed

6 files changed

+208
-18
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ One API call produces multiple JSONL entries sharing the same `requestId`. `inpu
104104
- **MCP sources** — six detection paths: `mcpServers` in settings.json, marketplace `enabledPlugins` with `.mcp.json` walk, project-level `.mcp.json` via `cwd` from transcript, `settings.local.json` in project `.claude/`, top-level `mcpServers` in `~/.claude.json`, and per-project `mcpServers` in `~/.claude.json`
105105
- **Config file**`~/.goccc.json` stores currency code, cached rate, timestamp, and cost thresholds (`warn_threshold`/`alert_threshold`). `initConfig()` loads everything once: thresholds first (with swap-correction if misordered), then currency. Exchange rates auto-fetched and cached for 24h. `-currency-symbol` and `-currency-rate` flags override config (both required together). JSON output cost fields always in USD
106106
- **Session end hook**`-session-end` reads `SessionEnd` JSON from stdin, parses the session transcript, and outputs a one-line cost summary. Uses `os.Exit(2)` + ANSI escape to overwrite Claude Code's "hook failed" prefix. Silently exits on any error — never breaks session teardown
107+
- **5h usage window** — statusline parses `rate_limits.five_hour` from Claude Code's stdin JSON (`used_percentage` + `resets_at` unix timestamp). Displayed as remaining percentage with elapsed time: `🔋 94% (1.5/5h)`. Uses pointer field (`*struct`) — nil when absent (API billing users), so the segment is auto-hidden. Emoji switches to 🪫 at ≤25% remaining. Color thresholds are inverted vs cost (yellow ≤50%, red ≤25% remaining). `-no-5h` flag opts out
107108

108109
## Don't
109110

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
A fast, zero-dependency CLI cost calculator and [statusline provider](#claude-code-statusline) for [Claude Code](https://code.claude.com/docs/en/overview) — single binary, no runtime needed.
88

9-
Parses JSONL logs from `~/.claude/projects/`, deduplicates streaming responses, and breaks down spending by model, day, project, and month — with accurate per-model pricing including cache write tiers, long-context premiums, and web search costs.
9+
Parses JSONL conversation logs and subagent sessions from `~/.claude/projects/`, deduplicates streaming responses, and breaks down spending by model, day, project, and branch — with accurate cache-tier and web search pricing.
1010

1111
![demo](https://github.com/user-attachments/assets/a65fc389-951d-47bc-9a69-5f498f3c1d32)
1212

@@ -89,16 +89,17 @@ JSON output always reports costs in USD for backward compatibility, with a `curr
8989
goccc can serve as a [Claude Code statusline](https://code.claude.com/docs/en/statusline) provider — a live cost dashboard right in your terminal prompt.
9090

9191
```text
92-
💸 $1.23 session · 💰 $5.67 today · 💭 45% ctx · 🔌 2 MCPs (confluence, jira) · 🤖 Opus 4.6
92+
💸 $1.23 session · 💰 $5.67 today · 💭 45% ctx · 🔌 2 MCPs (confluence, jira) · 🔋 94% (1.5/5h) · 🤖 Opus 4.6
9393
```
9494

9595
- **💸 Session cost** — parsed from the current session's JSONL files using goccc's pricing table
9696
- **💰 Today's total** — aggregated across all sessions today (shown only when higher than session cost)
9797
- **💭 Context %** — context window usage percentage
9898
- **🔌 MCPs** — active MCP servers (from settings, marketplace plugins, and project config; respects per-project disables)
99+
- **🔋 5h window** — remaining percentage of the 5-hour usage window with elapsed time (subscription users only; hidden for API billing). Emoji switches to 🪫 below 25%
99100
- **🤖 Model** — current model
100101

101-
Cost and context values are color-coded yellow → red as they increase.
102+
Cost and context values are color-coded yellow → red as they increase. The 5h window is color-coded in reverse — yellow below 50%, red below 25%.
102103

103104
### Setup
104105

@@ -126,7 +127,7 @@ Add to `~/.claude/settings.json`:
126127
}
127128
```
128129

129-
To hide the MCP indicator, add `-no-mcp`.
130+
To hide the MCP indicator, add `-no-mcp`. To hide the 5-hour usage window, add `-no-5h`.
130131

131132
## Session Exit Hook
132133

@@ -191,6 +192,7 @@ Thresholds are in USD (before currency conversion). They apply to the terminal o
191192
| `-session-end` | | `false` | Session exit hook mode (reads SessionEnd JSON from stdin) |
192193
| `-statusline` | | `false` | Statusline mode for Claude Code (reads session JSON from stdin) |
193194
| `-no-mcp` | | `false` | Hide MCP servers from statusline output |
195+
| `-no-5h` | | `false` | Hide 5-hour usage window from statusline output |
194196
| `-currency-symbol` | | | Override currency symbol (requires `-currency-rate`) |
195197
| `-currency-rate` | | `0` | Override exchange rate from USD (requires `-currency-symbol`) |
196198
| `-version` | `-V` | | Print version and exit |

main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ func main() {
5656
statusline := flag.Bool("statusline", false, "Statusline mode: read session JSON from stdin, output formatted cost line")
5757
sessionEnd := flag.Bool("session-end", false, "Session end hook mode: read SessionEnd JSON from stdin, print cost summary")
5858
noMCP := flag.Bool("no-mcp", false, "Hide MCP servers from statusline output")
59+
no5h := flag.Bool("no-5h", false, "Hide 5-hour usage window from statusline output")
5960
currencySymbolFlag := flag.String("currency-symbol", "", "Override currency symbol (requires -currency-rate)")
6061
currencyRateFlag := flag.Float64("currency-rate", 0, "Override exchange rate from USD (requires -currency-symbol)")
6162

@@ -100,7 +101,7 @@ func main() {
100101
initPricing()
101102

102103
if *statusline {
103-
runStatusline(*baseDir, *noMCP)
104+
runStatusline(*baseDir, *noMCP, *no5h)
104105
return
105106
}
106107

pricing.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ import (
1111
)
1212

1313
type ModelPricing struct {
14-
Input float64 `json:"input"`
15-
Output float64 `json:"output"`
16-
CacheRead float64 `json:"cache_read,omitempty"`
17-
CacheWrite5m float64 `json:"cache_write_5m,omitempty"`
18-
CacheWrite1h float64 `json:"cache_write_1h,omitempty"`
19-
LongCtxInput float64 `json:"long_ctx_input,omitempty"`
20-
LongCtxOutput float64 `json:"long_ctx_output,omitempty"`
14+
Input float64 `json:"input"`
15+
Output float64 `json:"output"`
16+
CacheRead float64 `json:"cache_read,omitempty"`
17+
CacheWrite5m float64 `json:"cache_write_5m,omitempty"`
18+
CacheWrite1h float64 `json:"cache_write_1h,omitempty"`
19+
LongCtxInput float64 `json:"long_ctx_input,omitempty"`
20+
LongCtxOutput float64 `json:"long_ctx_output,omitempty"`
2121
LongCtxCacheRead float64 `json:"long_ctx_cache_read,omitempty"`
2222
LongCtxCacheWrite5m float64 `json:"long_ctx_cache_write_5m,omitempty"`
2323
LongCtxCacheWrite1h float64 `json:"long_ctx_cache_write_1h,omitempty"`
@@ -46,11 +46,11 @@ type PricingDisplayName struct {
4646
}
4747

4848
var (
49-
pricingTable map[string]ModelPricing
50-
familyPrefixes []PricingFamily
51-
defaultPricing ModelPricing
52-
displayNames []PricingDisplayName
53-
longCtxThreshold = 200_000
49+
pricingTable map[string]ModelPricing
50+
familyPrefixes []PricingFamily
51+
defaultPricing ModelPricing
52+
displayNames []PricingDisplayName
53+
longCtxThreshold = 200_000
5454
webSearchCostPerSearch = 0.01
5555
)
5656

statusline.go

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import (
1414
const (
1515
ctxThresholdRed = 70.0
1616
ctxThresholdYellow = 50.0
17+
18+
fiveHourWindow = 5 * time.Hour
19+
fiveHourThresholdRed = 25.0
20+
fiveHourThresholdYellow = 50.0
21+
fiveHourLowBattery = 25.0
1722
)
1823

1924
type StatuslineInput struct {
@@ -27,9 +32,55 @@ type StatuslineInput struct {
2732
ContextWindow struct {
2833
UsedPercentage float64 `json:"used_percentage"`
2934
} `json:"context_window"`
35+
RateLimits struct {
36+
FiveHour *struct {
37+
UsedPercentage float64 `json:"used_percentage"`
38+
ResetsAt int64 `json:"resets_at"`
39+
} `json:"five_hour"`
40+
} `json:"rate_limits"`
3041
TranscriptPath string `json:"transcript_path"`
3142
}
3243

44+
func formatFiveHourUsage(usedPct float64, resetsAt int64, now time.Time) string {
45+
remainPct := 100 - usedPct
46+
if remainPct < 0 {
47+
remainPct = 0
48+
}
49+
50+
resetTime := time.Unix(resetsAt, 0)
51+
remaining := resetTime.Sub(now)
52+
if remaining < 0 {
53+
remaining = 0
54+
}
55+
elapsed := fiveHourWindow - remaining
56+
if elapsed > fiveHourWindow {
57+
elapsed = fiveHourWindow
58+
}
59+
60+
hours := elapsed.Hours()
61+
var elapsedStr string
62+
if hours == float64(int(hours)) {
63+
elapsedStr = fmt.Sprintf("%d/5h", int(hours))
64+
} else {
65+
elapsedStr = fmt.Sprintf("%.1f/5h", hours)
66+
}
67+
68+
emoji := "🔋"
69+
if remainPct <= fiveHourLowBattery {
70+
emoji = "🪫"
71+
}
72+
73+
pctStr := fmt.Sprintf("%.0f%%", remainPct)
74+
switch {
75+
case remainPct <= fiveHourThresholdRed:
76+
pctStr = redString(pctStr)
77+
case remainPct <= fiveHourThresholdYellow:
78+
pctStr = yellowString(pctStr)
79+
}
80+
81+
return fmt.Sprintf("%s %s (%s)", emoji, pctStr, elapsedStr)
82+
}
83+
3384
func readStatuslineInput(r io.Reader) (*StatuslineInput, error) {
3485
var input StatuslineInput
3586
if err := json.NewDecoder(r).Decode(&input); err != nil {
@@ -114,12 +165,19 @@ func formatStatusline(sCost, tCost float64, input *StatuslineInput, mcpNames []s
114165
}
115166
parts = append(parts, fmt.Sprintf("🔌 %d %s (%s)", len(mcpNames), label, list))
116167
}
168+
if input.RateLimits.FiveHour != nil {
169+
parts = append(parts, formatFiveHourUsage(
170+
input.RateLimits.FiveHour.UsedPercentage,
171+
input.RateLimits.FiveHour.ResetsAt,
172+
time.Now(),
173+
))
174+
}
117175
parts = append(parts, "🤖 "+modelStr)
118176

119177
return strings.Join(parts, " · ")
120178
}
121179

122-
func runStatusline(baseDir string, noMCP bool) {
180+
func runStatusline(baseDir string, noMCP, no5h bool) {
123181
input, err := readStatuslineInput(os.Stdin)
124182
if err != nil {
125183
fmt.Fprintf(os.Stderr, "goccc: %v\n", err)
@@ -144,6 +202,10 @@ func runStatusline(baseDir string, noMCP bool) {
144202
tCost = todayData.Totals().Cost
145203
}
146204

205+
if no5h {
206+
input.RateLimits.FiveHour = nil
207+
}
208+
147209
var mcpNames []string
148210
if !noMCP {
149211
mcpNames = detectMCPs(baseDir, input.TranscriptPath)

statusline_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ func TestReadStatuslineInput_Valid(t *testing.T) {
1414
"model": {"id": "claude-opus-4-6", "display_name": "Opus"},
1515
"cost": {"total_cost_usd": 1.23},
1616
"context_window": {"used_percentage": 45.2},
17+
"rate_limits": {"five_hour": {"used_percentage": 42, "resets_at": 1774468800}},
1718
"transcript_path": "/home/user/.claude/projects/my-project/abc123.jsonl",
1819
"session_id": "abc123"
1920
}`
@@ -33,6 +34,15 @@ func TestReadStatuslineInput_Valid(t *testing.T) {
3334
if input.TranscriptPath != "/home/user/.claude/projects/my-project/abc123.jsonl" {
3435
t.Errorf("TranscriptPath = %q", input.TranscriptPath)
3536
}
37+
if input.RateLimits.FiveHour == nil {
38+
t.Fatal("expected non-nil FiveHour")
39+
}
40+
if input.RateLimits.FiveHour.UsedPercentage != 42 {
41+
t.Errorf("FiveHour.UsedPercentage = %f, want 42", input.RateLimits.FiveHour.UsedPercentage)
42+
}
43+
if input.RateLimits.FiveHour.ResetsAt != 1774468800 {
44+
t.Errorf("FiveHour.ResetsAt = %d, want 1774468800", input.RateLimits.FiveHour.ResetsAt)
45+
}
3646
}
3747

3848
func TestReadStatuslineInput_InvalidJSON(t *testing.T) {
@@ -57,6 +67,81 @@ func TestReadStatuslineInput_MissingFields(t *testing.T) {
5767
if input.TranscriptPath != "" {
5868
t.Errorf("expected empty TranscriptPath, got %q", input.TranscriptPath)
5969
}
70+
if input.RateLimits.FiveHour != nil {
71+
t.Errorf("expected nil FiveHour, got %+v", input.RateLimits.FiveHour)
72+
}
73+
}
74+
75+
func TestFormatFiveHourUsage(t *testing.T) {
76+
noColorFlag = true
77+
defer func() { noColorFlag = false }()
78+
79+
tests := []struct {
80+
name string
81+
pct float64
82+
resetsAt int64
83+
now time.Time
84+
want string
85+
}{
86+
{
87+
name: "mid window low usage",
88+
pct: 6,
89+
resetsAt: time.Date(2026, 3, 25, 15, 0, 0, 0, time.UTC).Unix(),
90+
now: time.Date(2026, 3, 25, 11, 30, 0, 0, time.UTC),
91+
want: "🔋 94% (1.5/5h)",
92+
},
93+
{
94+
name: "full hour no decimal",
95+
pct: 20,
96+
resetsAt: time.Date(2026, 3, 25, 15, 0, 0, 0, time.UTC).Unix(),
97+
now: time.Date(2026, 3, 25, 12, 0, 0, 0, time.UTC),
98+
want: "🔋 80% (2/5h)",
99+
},
100+
{
101+
name: "just started full battery",
102+
pct: 0,
103+
resetsAt: time.Date(2026, 3, 25, 15, 0, 0, 0, time.UTC).Unix(),
104+
now: time.Date(2026, 3, 25, 10, 0, 0, 0, time.UTC),
105+
want: "🔋 100% (0/5h)",
106+
},
107+
{
108+
name: "heavy usage low remaining",
109+
pct: 85,
110+
resetsAt: time.Date(2026, 3, 25, 15, 0, 0, 0, time.UTC).Unix(),
111+
now: time.Date(2026, 3, 25, 14, 42, 0, 0, time.UTC),
112+
want: "🪫 15% (4.7/5h)",
113+
},
114+
{
115+
name: "fully depleted clamps to 0%",
116+
pct: 100,
117+
resetsAt: time.Date(2026, 3, 25, 10, 0, 0, 0, time.UTC).Unix(),
118+
now: time.Date(2026, 3, 25, 11, 0, 0, 0, time.UTC),
119+
want: "🪫 0% (5/5h)",
120+
},
121+
{
122+
name: "at 25% remaining switches to low battery",
123+
pct: 75,
124+
resetsAt: time.Date(2026, 3, 25, 15, 0, 0, 0, time.UTC).Unix(),
125+
now: time.Date(2026, 3, 25, 13, 0, 0, 0, time.UTC),
126+
want: "🪫 25% (3/5h)",
127+
},
128+
{
129+
name: "at 26% remaining stays full battery",
130+
pct: 74,
131+
resetsAt: time.Date(2026, 3, 25, 15, 0, 0, 0, time.UTC).Unix(),
132+
now: time.Date(2026, 3, 25, 13, 0, 0, 0, time.UTC),
133+
want: "🔋 26% (3/5h)",
134+
},
135+
}
136+
137+
for _, tt := range tests {
138+
t.Run(tt.name, func(t *testing.T) {
139+
got := formatFiveHourUsage(tt.pct, tt.resetsAt, tt.now)
140+
if got != tt.want {
141+
t.Errorf("got %q, want %q", got, tt.want)
142+
}
143+
})
144+
}
60145
}
61146

62147
func TestParseSession_MainOnly(t *testing.T) {
@@ -231,6 +316,45 @@ func TestFormatStatusline(t *testing.T) {
231316
}
232317
}
233318

319+
func TestFormatStatusline_With5h(t *testing.T) {
320+
noColorFlag = true
321+
defer func() { noColorFlag = false }()
322+
323+
input := &StatuslineInput{}
324+
input.Model.ID = "claude-opus-4-6"
325+
input.ContextWindow.UsedPercentage = 34.0
326+
input.RateLimits.FiveHour = &struct {
327+
UsedPercentage float64 `json:"used_percentage"`
328+
ResetsAt int64 `json:"resets_at"`
329+
}{UsedPercentage: 6, ResetsAt: time.Now().Add(2 * time.Hour).Unix()}
330+
331+
result := formatStatusline(1.35, 1.98, input, nil)
332+
if !strings.Contains(result, "🔋 94%") {
333+
t.Errorf("output %q missing 5h battery segment", result)
334+
}
335+
// 5h segment should appear before model
336+
batteryIdx := strings.Index(result, "🔋")
337+
modelIdx := strings.Index(result, "🤖")
338+
if batteryIdx >= modelIdx {
339+
t.Errorf("5h segment should appear before model: %q", result)
340+
}
341+
}
342+
343+
func TestFormatStatusline_No5h(t *testing.T) {
344+
noColorFlag = true
345+
defer func() { noColorFlag = false }()
346+
347+
input := &StatuslineInput{}
348+
input.Model.ID = "claude-opus-4-6"
349+
input.ContextWindow.UsedPercentage = 45.0
350+
// RateLimits.FiveHour is nil (API billing)
351+
352+
result := formatStatusline(0.50, 2.00, input, nil)
353+
if strings.Contains(result, "🔋") || strings.Contains(result, "🪫") {
354+
t.Errorf("output %q should not contain battery when FiveHour is nil", result)
355+
}
356+
}
357+
234358
func TestFormatStatusline_WithMCPs(t *testing.T) {
235359
noColorFlag = true
236360
defer func() { noColorFlag = false }()

0 commit comments

Comments
 (0)