Skip to content

Commit eeeb896

Browse files
saar120claude
andauthored
Context and Cost data from stdin (#17)
* feat(status): use Claude Code context_window and cost data from stdin Prefer pre-calculated context_window.used_percentage from Claude Code stdin over transcript JSONL parsing, fixing inaccurate context % display. Add cost/duration fields and fmtCost/fmtDuration template functions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: remove unused cost metrics and exceeds 200k tokens flag from data structures * refactor: address PR review feedback for context window and cost features Centralize auto-compact threshold as tokens.AutoCompactThreshold constant, inline populateCostMetrics into Build(), add hours support to fmtDuration, add CostLinesAdded/CostLinesRemoved fields, and document per-call vs session-cumulative token semantics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e1f085c commit eeeb896

File tree

7 files changed

+466
-15
lines changed

7 files changed

+466
-15
lines changed

internal/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ const DefaultTemplate = `{{if .Prefix}}{{.PrefixColor}}{{.Prefix}}{{reset}} | {{
1616
// Usage: set "template" in config.json to this value.
1717
const TemplateWithTokens = `{{cyan}}[{{.Model}}]{{reset}} | {{blue}}📁 {{.Dir}}{{reset}}{{if .GitBranch}} | {{green}}🌿 {{.GitBranch}}{{if .GitStatus}} {{.GitStatus}}{{end}}{{reset}}{{end}}{{if .TokensTotal}} | {{gray}}📈 In:{{fmtTokens .TokensInput}} Out:{{fmtTokens .TokensOutput}} Cache:{{fmtTokens .TokensCached}}{{reset}}{{end}}{{if .ContextPctUse}} | {{ctxColor .ContextPctUse}}📊 {{fmtPct .ContextPctUse}}{{reset}}{{end}}`
1818

19+
// TemplateWithCost is an example template that shows session cost and duration.
20+
// Usage: set "template" in config.json to this value.
21+
const TemplateWithCost = `{{cyan}}[{{.Model}}]{{reset}} | {{blue}}📁 {{.Dir}}{{reset}}{{if .ContextPctUse}} | {{ctxColor .ContextPctUse}}📊 {{fmtPct .ContextPctUse}}{{reset}}{{end}}{{if .CostUSD}} | {{yellow}}💰 {{fmtCost .CostUSD}}{{reset}}{{end}}{{if .DurationMS}} | ⏱️ {{fmtDuration .DurationMS}}{{end}}`
22+
1923
// TemplateWithTasks is an example template that shows task stats (beads/tk/kt).
2024
// Usage: set "template" in config.json to this value.
2125
const TemplateWithTasks = `{{cyan}}[{{.Model}}]{{reset}} | {{blue}}📁 {{.Dir}}{{reset}}{{if .GitBranch}} | {{green}}🌿 {{.GitBranch}}{{if .GitStatus}} {{.GitStatus}}{{end}}{{reset}}{{end}}{{if .ContextPctUse}} | {{ctxColor .ContextPctUse}}📊 {{fmtPct .ContextPctUse}}{{reset}}{{end}}{{if .TasksReady}} | {{yellow}}📋 {{.TaskProvider}}: {{.TasksReady}} ready{{reset}}{{if .TasksBlocked}}, {{red}}{{.TasksBlocked}} blocked{{reset}}{{end}}{{if .TasksNextTask}}. Next Up: {{.TasksNextTask}}{{end}}{{end}}`

internal/status/status.go

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ import (
2424

2525
// Input represents the JSON input from stdin.
2626
type Input struct {
27-
Model ModelInfo `json:"model"`
28-
Workspace WorkspaceInfo `json:"workspace"`
29-
Version string `json:"version"`
30-
SessionID string `json:"session_id"`
31-
TranscriptPath string `json:"transcript_path"`
27+
Model ModelInfo `json:"model"`
28+
Workspace WorkspaceInfo `json:"workspace"`
29+
Version string `json:"version"`
30+
SessionID string `json:"session_id"`
31+
TranscriptPath string `json:"transcript_path"`
32+
ContextWindow *ContextWindowInfo `json:"context_window"`
33+
Cost *CostInfo `json:"cost"`
3234
}
3335

3436
// ModelInfo contains information about the model.
@@ -42,6 +44,33 @@ type WorkspaceInfo struct {
4244
CurrentDir string `json:"current_dir"`
4345
}
4446

47+
// ContextWindowInfo contains context window data provided by Claude Code.
48+
type ContextWindowInfo struct {
49+
UsedPercentage *float64 `json:"used_percentage"`
50+
RemainingPercentage *float64 `json:"remaining_percentage"`
51+
ContextWindowSize int64 `json:"context_window_size"`
52+
TotalInputTokens int64 `json:"total_input_tokens"`
53+
TotalOutputTokens int64 `json:"total_output_tokens"`
54+
CurrentUsage *CurrentUsageInfo `json:"current_usage"`
55+
}
56+
57+
// CurrentUsageInfo contains token counts from the last API call.
58+
type CurrentUsageInfo struct {
59+
InputTokens int64 `json:"input_tokens"`
60+
OutputTokens int64 `json:"output_tokens"`
61+
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
62+
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
63+
}
64+
65+
// CostInfo contains session cost data provided by Claude Code.
66+
type CostInfo struct {
67+
TotalCostUSD float64 `json:"total_cost_usd"`
68+
TotalDurationMS int64 `json:"total_duration_ms"`
69+
TotalAPIDurationMS int64 `json:"total_api_duration_ms"`
70+
TotalLinesAdded int `json:"total_lines_added"`
71+
TotalLinesRemoved int `json:"total_lines_removed"`
72+
}
73+
4574
// GitProvider is an interface for git operations.
4675
type GitProvider interface {
4776
Branch() (string, error)
@@ -145,6 +174,15 @@ func (b *Builder) Build(input Input) template.StatusData {
145174
// Parse token metrics from transcript
146175
b.populateTokenMetrics(&data, input)
147176

177+
// Populate cost metrics from Claude Code stdin
178+
if input.Cost != nil {
179+
data.CostUSD = input.Cost.TotalCostUSD
180+
data.DurationMS = input.Cost.TotalDurationMS
181+
data.APIDurationMS = input.Cost.TotalAPIDurationMS
182+
data.CostLinesAdded = input.Cost.TotalLinesAdded
183+
data.CostLinesRemoved = input.Cost.TotalLinesRemoved
184+
}
185+
148186
// Get task stats (cached with TTL) - independent of git
149187
b.fetchTaskStats(&data)
150188

@@ -178,8 +216,43 @@ func (b *Builder) Build(input Input) template.StatusData {
178216
return data
179217
}
180218

181-
// populateTokenMetrics parses the transcript and populates token metrics.
219+
// populateTokenMetrics populates token metrics from context_window (preferred) or transcript (fallback).
182220
func (b *Builder) populateTokenMetrics(data *template.StatusData, input Input) {
221+
// Prefer context_window data from Claude Code stdin when available
222+
if input.ContextWindow != nil && input.ContextWindow.UsedPercentage != nil {
223+
b.populateFromContextWindow(data, input)
224+
return
225+
}
226+
227+
// Fall back to transcript parsing
228+
b.populateFromTranscript(data, input)
229+
}
230+
231+
// populateFromContextWindow uses pre-calculated context data from Claude Code.
232+
func (b *Builder) populateFromContextWindow(data *template.StatusData, input Input) {
233+
cw := input.ContextWindow
234+
235+
data.ContextPct = *cw.UsedPercentage
236+
data.ContextWindowSize = cw.ContextWindowSize
237+
238+
// Scale to usable context (auto-compact threshold)
239+
data.ContextPctUse = min(*cw.UsedPercentage/tokens.AutoCompactThreshold, 100)
240+
241+
// Populate token metrics from current_usage if available
242+
if cw.CurrentUsage != nil {
243+
u := cw.CurrentUsage
244+
data.TokensInput = u.InputTokens
245+
data.TokensOutput = u.OutputTokens
246+
data.TokensCached = u.CacheReadInputTokens + u.CacheCreationInputTokens
247+
data.ContextLength = u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens
248+
}
249+
250+
// Use cumulative totals for total tokens (session-cumulative, not per-call)
251+
data.TokensTotal = cw.TotalInputTokens + cw.TotalOutputTokens
252+
}
253+
254+
// populateFromTranscript parses the transcript JSONL file for token metrics.
255+
func (b *Builder) populateFromTranscript(data *template.StatusData, input Input) {
183256
if input.TranscriptPath == "" {
184257
return
185258
}

internal/status/status_test.go

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,222 @@ func TestSetPrefix_Empty(t *testing.T) {
667667
}
668668
}
669669

670+
func TestBuild_ContextWindowFromStdin(t *testing.T) {
671+
tests := []struct {
672+
name string
673+
contextWindow *ContextWindowInfo
674+
wantPct float64
675+
wantPctUse float64
676+
wantInput int64
677+
wantOutput int64
678+
wantCached int64
679+
wantCtxSize int64
680+
}{
681+
{
682+
name: "full context_window data",
683+
contextWindow: &ContextWindowInfo{
684+
UsedPercentage: ptrFloat64(14.1),
685+
RemainingPercentage: ptrFloat64(85.9),
686+
ContextWindowSize: 1_000_000,
687+
TotalInputTokens: 15234,
688+
TotalOutputTokens: 4521,
689+
CurrentUsage: &CurrentUsageInfo{
690+
InputTokens: 8500,
691+
OutputTokens: 1200,
692+
CacheCreationInputTokens: 5000,
693+
CacheReadInputTokens: 2000,
694+
},
695+
},
696+
wantPct: 14.1,
697+
wantPctUse: 17.625, // 14.1 * 1.25
698+
wantInput: 8500,
699+
wantOutput: 1200,
700+
wantCached: 7000, // 5000 + 2000
701+
wantCtxSize: 1_000_000,
702+
},
703+
{
704+
name: "200k context window",
705+
contextWindow: &ContextWindowInfo{
706+
UsedPercentage: ptrFloat64(50.0),
707+
ContextWindowSize: 200_000,
708+
},
709+
wantPct: 50.0,
710+
wantPctUse: 62.5, // 50 * 1.25
711+
wantCtxSize: 200_000,
712+
},
713+
{
714+
name: "no current_usage (null before first API call)",
715+
contextWindow: &ContextWindowInfo{
716+
UsedPercentage: ptrFloat64(8.0),
717+
ContextWindowSize: 200_000,
718+
CurrentUsage: nil,
719+
},
720+
wantPct: 8.0,
721+
wantPctUse: 10.0, // 8 * 1.25
722+
wantCtxSize: 200_000,
723+
},
724+
{
725+
name: "high usage caps usable at 100",
726+
contextWindow: &ContextWindowInfo{
727+
UsedPercentage: ptrFloat64(90.0),
728+
ContextWindowSize: 200_000,
729+
},
730+
wantPct: 90.0,
731+
wantPctUse: 100.0, // 90 * 1.25 = 112.5, capped at 100
732+
wantCtxSize: 200_000,
733+
},
734+
}
735+
736+
for _, tt := range tests {
737+
t.Run(tt.name, func(t *testing.T) {
738+
cfg := config.Default()
739+
cache := &mockCacheProvider{}
740+
builder := NewBuilderWithDeps(&cfg, cache, nil, nil, nil, "")
741+
742+
input := Input{
743+
Model: ModelInfo{DisplayName: "Claude"},
744+
Workspace: WorkspaceInfo{CurrentDir: "/project"},
745+
ContextWindow: tt.contextWindow,
746+
}
747+
748+
data := builder.Build(input)
749+
750+
if data.ContextPct != tt.wantPct {
751+
t.Errorf("ContextPct = %v, want %v", data.ContextPct, tt.wantPct)
752+
}
753+
if data.ContextPctUse != tt.wantPctUse {
754+
t.Errorf("ContextPctUse = %v, want %v", data.ContextPctUse, tt.wantPctUse)
755+
}
756+
if data.TokensInput != tt.wantInput {
757+
t.Errorf("TokensInput = %d, want %d", data.TokensInput, tt.wantInput)
758+
}
759+
if data.TokensOutput != tt.wantOutput {
760+
t.Errorf("TokensOutput = %d, want %d", data.TokensOutput, tt.wantOutput)
761+
}
762+
if data.TokensCached != tt.wantCached {
763+
t.Errorf("TokensCached = %d, want %d", data.TokensCached, tt.wantCached)
764+
}
765+
if data.ContextWindowSize != tt.wantCtxSize {
766+
t.Errorf("ContextWindowSize = %d, want %d", data.ContextWindowSize, tt.wantCtxSize)
767+
}
768+
})
769+
}
770+
}
771+
772+
func TestBuild_ContextWindowNullPercentage(t *testing.T) {
773+
cfg := config.Default()
774+
cache := &mockCacheProvider{}
775+
builder := NewBuilderWithDeps(&cfg, cache, nil, nil, nil, "")
776+
777+
// context_window present but used_percentage is null (early in session)
778+
input := Input{
779+
Model: ModelInfo{DisplayName: "Claude"},
780+
Workspace: WorkspaceInfo{CurrentDir: "/project"},
781+
ContextWindow: &ContextWindowInfo{
782+
UsedPercentage: nil,
783+
ContextWindowSize: 200_000,
784+
},
785+
}
786+
787+
data := builder.Build(input)
788+
789+
// Should fall through to transcript parsing (which returns 0 with no transcript)
790+
if data.ContextPct != 0 {
791+
t.Errorf("ContextPct = %v, want 0 (null percentage should fallback)", data.ContextPct)
792+
}
793+
}
794+
795+
func TestBuild_ContextWindowFallback(t *testing.T) {
796+
cfg := config.Default()
797+
cache := &mockCacheProvider{}
798+
builder := NewBuilderWithDeps(&cfg, cache, nil, nil, nil, "")
799+
800+
// Create a transcript file for fallback
801+
tmpDir := t.TempDir()
802+
transcriptPath := tmpDir + "/transcript.jsonl"
803+
jsonlContent := `{"type":"summary","summary":"Test session"}
804+
{"parentUuid":"123","isSidechain":false,"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":10000,"output_tokens":5000,"cache_read_input_tokens":30000,"cache_creation_input_tokens":5000}}}
805+
`
806+
if err := writeTestFile(transcriptPath, jsonlContent); err != nil {
807+
t.Fatalf("Failed to write test file: %v", err)
808+
}
809+
810+
// No context_window — should fall back to transcript
811+
input := Input{
812+
Model: ModelInfo{ID: "claude-opus-4-5-20251101", DisplayName: "Claude"},
813+
Workspace: WorkspaceInfo{CurrentDir: "/project"},
814+
TranscriptPath: transcriptPath,
815+
}
816+
817+
data := builder.Build(input)
818+
819+
// Should have token data from transcript
820+
if data.TokensInput != 10000 {
821+
t.Errorf("TokensInput = %d, want 10000 (from transcript fallback)", data.TokensInput)
822+
}
823+
if data.ContextPct == 0 {
824+
t.Error("ContextPct should not be zero (transcript has data)")
825+
}
826+
}
827+
828+
func TestBuild_CostMetrics(t *testing.T) {
829+
tests := []struct {
830+
name string
831+
cost *CostInfo
832+
wantCostUSD float64
833+
wantDuration int64
834+
wantAPIDur int64
835+
}{
836+
{
837+
name: "full cost data",
838+
cost: &CostInfo{
839+
TotalCostUSD: 0.05,
840+
TotalDurationMS: 125000,
841+
TotalAPIDurationMS: 2300,
842+
},
843+
wantCostUSD: 0.05,
844+
wantDuration: 125000,
845+
wantAPIDur: 2300,
846+
},
847+
{
848+
name: "nil cost (not provided)",
849+
cost: nil,
850+
wantCostUSD: 0,
851+
wantDuration: 0,
852+
},
853+
}
854+
855+
for _, tt := range tests {
856+
t.Run(tt.name, func(t *testing.T) {
857+
cfg := config.Default()
858+
cache := &mockCacheProvider{}
859+
builder := NewBuilderWithDeps(&cfg, cache, nil, nil, nil, "")
860+
861+
input := Input{
862+
Model: ModelInfo{DisplayName: "Claude"},
863+
Workspace: WorkspaceInfo{CurrentDir: "/project"},
864+
Cost: tt.cost,
865+
}
866+
867+
data := builder.Build(input)
868+
869+
if data.CostUSD != tt.wantCostUSD {
870+
t.Errorf("CostUSD = %v, want %v", data.CostUSD, tt.wantCostUSD)
871+
}
872+
if data.DurationMS != tt.wantDuration {
873+
t.Errorf("DurationMS = %d, want %d", data.DurationMS, tt.wantDuration)
874+
}
875+
if data.APIDurationMS != tt.wantAPIDur {
876+
t.Errorf("APIDurationMS = %d, want %d", data.APIDurationMS, tt.wantAPIDur)
877+
}
878+
})
879+
}
880+
}
881+
882+
func ptrFloat64(f float64) *float64 {
883+
return &f
884+
}
885+
670886
func TestBuild_TasksZeroValues(t *testing.T) {
671887
cfg := config.Default()
672888

0 commit comments

Comments
 (0)