Add cache efficiency, burn rate, and fix context calculation#18
Add cache efficiency, burn rate, and fix context calculation#18
Conversation
…culation - Fix legacy context calculation to exclude CacheReadTokens (they don't consume additional context window space) - Extend SessionContext with token data fields (input, output, cache creation, cache read, context window size) - Add cache efficiency indicator (⌁XX%) showing cache read hit percentage - Add session burn rate (~$X.XX/h) with configurable display - Add burnrate package for snapshot-based cost velocity tracking - Clean up burn rate snapshot files on session end - Update README with new API billing display features and config options Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves status/cost reporting by fixing legacy context usage math around cache tokens, exposing token-level fields to plugins, and adding two new API-billing indicators (cache efficiency and session burn rate) with supporting snapshot persistence.
Changes:
- Fix legacy context % calculation by excluding
CacheReadTokensfrom the denominator. - Extend
plugin.SessionContextwith token usage fields and context window size. - Add cache efficiency (
⌁XX%) and burn rate (~$X.XX/h) display, plus a newinternal/burnratepackage and session-end cleanup.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/statusline/statusline.go | Fix legacy context math; add burn rate + cache indicator to built-in cost; expose token fields to plugins. |
| internal/statusline/statusline_test.go | Add tests for legacy context cache exclusion and cache indicator/ratio. |
| internal/statusline/burnrate_test.go | Add tests for burn rate rendering and snapshot creation behavior. |
| internal/plugins/usage.go | Add show_cache / show_burn_rate config and cache indicator/burn rate suffix in API billing cost rendering. |
| internal/plugins/usage_test.go | Expand config parsing tests and add cache-indicator + cacheRatio tests. |
| internal/plugin/types.go | Add token-related fields to SessionContext JSON payload for plugins. |
| internal/hooks/hooks.go | Remove burn rate snapshot file on session end. |
| internal/hooks/hooks_test.go | Add test ensuring burn rate snapshot cleanup on session end. |
| internal/burnrate/burnrate.go | New package for snapshot persistence + burn rate calculation/formatting. |
| internal/burnrate/burnrate_test.go | Tests for snapshot lifecycle, rate calculation thresholds, formatting, cleanup. |
| README.md | Document new usage display format and config toggles. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func TestHandleSessionEnd_CleansBurnRateFile(t *testing.T) { | ||
| sessionID := "test-session-end-burn" | ||
|
|
There was a problem hiding this comment.
The test uses a fixed sessionID (and thus fixed temp file names). This can become flaky when multiple go test runs overlap on the same machine/user (e.g., CI retries) because they share os.TempDir(). Prefer generating a unique session ID per test (timestamp + random suffix) to avoid collisions.
internal/statusline/burnrate_test.go
Outdated
| data, _ := json.Marshal(snap) | ||
| os.WriteFile(path, data, 0644) |
There was a problem hiding this comment.
Test setup ignores errors from json.Marshal / os.WriteFile. If either fails, the assertions later can produce misleading results. Please handle these errors and fail fast to make the test output actionable.
| data, _ := json.Marshal(snap) | |
| os.WriteFile(path, data, 0644) | |
| data, err := json.Marshal(snap) | |
| if err != nil { | |
| t.Fatalf("failed to marshal burn rate snapshot: %v", err) | |
| } | |
| if err := os.WriteFile(path, data, 0644); err != nil { | |
| t.Fatalf("failed to write burn rate snapshot file: %v", err) | |
| } |
internal/burnrate/burnrate_test.go
Outdated
| data, _ := json.Marshal(original) | ||
| os.WriteFile(path, data, 0644) |
There was a problem hiding this comment.
This test writes the snapshot file without checking the json.Marshal / os.WriteFile errors, which can hide test setup problems and cause confusing failures downstream. Please handle and assert these errors.
| data, _ := json.Marshal(original) | |
| os.WriteFile(path, data, 0644) | |
| data, err := json.Marshal(original) | |
| if err != nil { | |
| t.Fatalf("failed to marshal original snapshot: %v", err) | |
| } | |
| if err := os.WriteFile(path, data, 0644); err != nil { | |
| t.Fatalf("failed to write snapshot file: %v", err) | |
| } |
internal/plugins/usage.go
Outdated
| // Append burn rate if enabled and session ID is available | ||
| if cfg.showBurnRate && input.Prism.SessionID != "" { | ||
| costStr += p.getBurnRateSuffix(input.Prism.SessionID, cost) | ||
| } | ||
|
|
||
| // Append cache efficiency indicator if enabled | ||
| if cfg.showCache { | ||
| if ratio, ok := cacheRatio(input.Session.InputTokens, input.Session.CacheCreationTokens, input.Session.CacheReadTokens); ok { | ||
| costStr += fmt.Sprintf(" ⌁%d%%", ratio) | ||
| } | ||
| } |
There was a problem hiding this comment.
Output ordering doesn’t match the README/example format: this appends burn rate before the cache indicator, producing $X.XX ~$Y.YY/h ⌁ZZ% rather than $X.XX ⌁ZZ% ~$Y.YY/h. Either swap the append order in code or adjust the documented format so users get what they expect.
internal/statusline/statusline.go
Outdated
| costStr := fmt.Sprintf("$%.2f", cost) | ||
|
|
||
| // Compute burn rate if session ID is available | ||
| if sl.input.SessionID != "" { | ||
| snap, existed, err := burnrate.LoadOrCreateSnapshot(sl.input.SessionID, cost) | ||
| if err == nil && existed { | ||
| if rate, show := burnrate.CalculateRate(snap, cost, time.Now()); show { | ||
| costStr += " " + burnrate.FormatRate(rate) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Add cache efficiency indicator | ||
| usage := sl.input.Context.CurrentUsage | ||
| if ratio, ok := cacheRatio(usage.InputTokens, usage.CacheCreationTokens, usage.CacheReadTokens); ok { | ||
| costStr += fmt.Sprintf(" ⌁%d%%", ratio) | ||
| } |
There was a problem hiding this comment.
The README/example format shows cost, then cache indicator, then burn rate, but this builds the string as cost → burn rate → cache. Align the order (or update docs) so the status line output matches the documented display format.
internal/burnrate/burnrate.go
Outdated
| if err := os.WriteFile(path, data, 0644); err != nil { | ||
| return nil, false, err | ||
| } | ||
|
|
There was a problem hiding this comment.
LoadOrCreateSnapshotAt writes the snapshot JSON directly to the target file. With concurrent renders/processes, this can lead to torn writes and JSON unmarshal failures. Consider writing to a temp file and os.Rename for atomic replacement (and/or using a per-session lock) to make snapshot persistence robust.
| if err := os.WriteFile(path, data, 0644); err != nil { | |
| return nil, false, err | |
| } | |
| // Write to a temporary file and atomically replace the target to avoid torn writes. | |
| dir := filepath.Dir(path) | |
| tmpFile, err := os.CreateTemp(dir, "prism-burn-*.tmp") | |
| if err != nil { | |
| return nil, false, err | |
| } | |
| tmpName := tmpFile.Name() | |
| if _, err := tmpFile.Write(data); err != nil { | |
| tmpFile.Close() | |
| os.Remove(tmpName) | |
| return nil, false, err | |
| } | |
| if err := tmpFile.Sync(); err != nil { | |
| tmpFile.Close() | |
| os.Remove(tmpName) | |
| return nil, false, err | |
| } | |
| if err := tmpFile.Close(); err != nil { | |
| os.Remove(tmpName) | |
| return nil, false, err | |
| } | |
| if err := os.Chmod(tmpName, 0644); err != nil { | |
| os.Remove(tmpName) | |
| return nil, false, err | |
| } | |
| if err := os.Rename(tmpName, path); err != nil { | |
| os.Remove(tmpName) | |
| return nil, false, err | |
| } |
internal/hooks/hooks_test.go
Outdated
|
|
||
| // Also create idle file (normal cleanup) | ||
| idleFile := filepath.Join(os.TempDir(), fmt.Sprintf("prism-idle-%s", sessionID)) | ||
| os.WriteFile(idleFile, []byte{}, 0644) |
There was a problem hiding this comment.
This test ignores the error from os.WriteFile when creating the idle file, which can mask failures on restricted/readonly temp directories. Please check and fail the test on write errors (similar to the burn-rate file setup above).
| os.WriteFile(idleFile, []byte{}, 0644) | |
| if err := os.WriteFile(idleFile, []byte{}, 0644); err != nil { | |
| t.Fatalf("failed to create idle file: %v", err) | |
| } |
internal/statusline/statusline.go
Outdated
| if sl.input.SessionID != "" { | ||
| snap, existed, err := burnrate.LoadOrCreateSnapshot(sl.input.SessionID, cost) | ||
| if err == nil && existed { | ||
| if rate, show := burnrate.CalculateRate(snap, cost, time.Now()); show { | ||
| costStr += " " + burnrate.FormatRate(rate) | ||
| } | ||
| } |
There was a problem hiding this comment.
Status line sections are rendered concurrently (goroutines in renderLine). If a user config includes both cost and usage sections, burn-rate snapshot file reads/writes can happen at the same time for the same session, risking corrupted/partial JSON reads and flickering output. Consider adding synchronization/atomic file writes in the burnrate package or ensuring only one renderer touches the snapshot per session.
internal/statusline/statusline.go
Outdated
| // Add cache efficiency indicator | ||
| usage := sl.input.Context.CurrentUsage | ||
| if ratio, ok := cacheRatio(usage.InputTokens, usage.CacheCreationTokens, usage.CacheReadTokens); ok { | ||
| costStr += fmt.Sprintf(" ⌁%d%%", ratio) | ||
| } |
There was a problem hiding this comment.
Cache efficiency is appended unconditionally for the built-in cost section, but the PR description/README describe show_cache as a configurable toggle. Either wire show_cache into this code path (similar to the usage plugin), or keep cache efficiency confined to the usage plugin to avoid undocumented behavior differences.
internal/hooks/hooks.go
Outdated
| // Clean up burn rate snapshot file | ||
| burnRateFile := filepath.Join(os.TempDir(), fmt.Sprintf("prism-burn-%s", input.SessionID)) | ||
| os.Remove(burnRateFile) |
There was a problem hiding this comment.
This duplicates burn-rate path construction logic instead of calling burnrate.Cleanup(sessionID). Using the package helper keeps the cleanup behavior consistent if the burnrate file naming/sanitization changes and reduces the chance of drift.
- Sanitize sessionID in burnrate.FilePath() to prevent path traversal - Use atomic writes (tmp + rename) in LoadOrCreateSnapshotAt() - Fix burn rate never appearing: switch to LoadOrCreateSnapshot in usage plugin - Fix output ordering: cache indicator (⌁XX%) now appears before burn rate (~$X/h) - Wire show_cache and show_burn_rate config in statusline (skip I/O when disabled) - Replace manual burn rate path cleanup in hooks with burnrate.Cleanup() - Add error checks for json.Marshal/os.WriteFile in all test files - Use unique session IDs per test to prevent collisions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Sanitize idle marker file paths to prevent directory traversal (hooks, statusline) - Clamp costDecimals config to 0-10 range for safe fmt.Sprintf formatting - Cache burn rate snapshots in memory via sync.Map to avoid per-render disk I/O - Fix calculateContextPct to derive used% from RemainingPercentage when UsedPercentage is zero - Add corrupt/invalid JSON snapshot recovery with Timestamp.IsZero() validation - Fix unchecked errors in statusline_test.go test setup code - Extract duplicate cacheRatio logic to shared internal/tokens.CacheEfficiency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
CacheReadTokensfrom the context percentage computation — cache reads don't consume additional context window space, so including them inflated the reported usageInputTokens,OutputTokens,CacheCreationTokens,CacheReadTokens,ContextWindowSize) to make token-level data available to plugins⌁XX%): Shows the percentage of input tokens served from cache, giving visibility into cache hit rates (~10x cheaper tokens)~$X.XX/h): Tracks cost velocity using snapshot-based calculation; only appears after 60+ seconds of data and when rate exceeds $0.01/hburnratepackage: Manages snapshot persistence in temp files for cost velocity tracking across status line rendersshow_cacheandshow_burn_ratein theapi_billingconfig sectionTest Plan
go test ./...passes (all new and existing tests)CacheReadTokensis 0CacheReadTokensis large⌁XX%) appears when cache read tokens are presentshow_cache: falseor no cache readsshow_burn_rateandshow_cacheoptions🤖 Generated with Claude Code