Compare commits

..

6 Commits

Author SHA1 Message Date
teernisse
3668ae7f70 docs: improve installation guide with PATH setup
README changes:
- Add go install github.com/...@latest as primary install method
- Add git clone instructions as alternative
- Add PATH setup instructions for bash/zsh and fish shells
- Fix cache database filename (sessions.db -> metrics_v2.db)

CLAUDE.md:
- Fix cache database filename to match actual implementation

New users on fresh Go installations often miss that ~/go/bin needs
to be in PATH. This was causing "command not found" errors after
successful installation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:09:37 -05:00
teernisse
083e7d40ce refactor!: rename module to github.com/theirongolddev/cburn
Change module path from 'cburn' to 'github.com/theirongolddev/cburn'
to enable standard Go remote installation:

  go install github.com/theirongolddev/cburn@latest

This is a BREAKING CHANGE for any external code importing this module
(though as a CLI tool, this is unlikely to affect anyone).

All internal imports updated to use the new module path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:09:26 -05:00
teernisse
16cc4d4737 feat: add mouse navigation and session search to TUI
Mouse support:
- Wheel up/down scrolls session list in Sessions tab
- Left click on tab bar switches tabs
- Works alongside existing keyboard navigation

Session search:
- Press '/' to enter search mode with live preview
- Filters sessions by project name substring matching
- Shows match count as you type
- Enter to apply filter, Esc to cancel
- Search indicator shown in card title when active
- Esc clears active search filter

Cost integration:
- Use centralized AggregateCostBreakdown for model costs
- Consistent cost calculations between Overview and Costs tabs

Also fixes cursor clamping to prevent out-of-bounds access when
search results change the filtered session count.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:09:13 -05:00
teernisse
9bb0fd6b73 feat: centralize cost breakdown calculations in pipeline
Extract token-type and per-model cost calculations from cmd/costs.go
into a dedicated pipeline.AggregateCostBreakdown() function. This
eliminates duplicate cost calculation logic between CLI and TUI.

New types:
- TokenTypeCosts: aggregate costs by input/output/cache types
- ModelCostBreakdown: per-model cost components

Benefits:
- Single source of truth for cost calculations
- Uses LookupPricingAt() for historical accuracy
- Both CLI and TUI now share the same cost logic
- Easier to maintain and extend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:09:01 -05:00
teernisse
baa88efe75 feat: add time-based pricing lookup for historical accuracy
Introduces LookupPricingAt() to resolve model pricing at a specific
timestamp instead of always using current prices. This is important
for accurate cost calculations when analyzing historical sessions
where pricing may have changed.

Changes:
- Add modelPricingVersion struct with EffectiveFrom timestamp
- Add defaultPricingHistory map for versioned pricing entries
- Update LookupPricing to delegate to LookupPricingAt(model, time.Now())
- Add comprehensive tests for time-windowed pricing lookup

The infrastructure supports future pricing changes by adding entries
to defaultPricingHistory with their effective dates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:08:55 -05:00
teernisse
a386d95959 build: use portable Go path detection in Makefile
Replace hardcoded /usr/local/go/bin/go with dynamic PATH lookup.
The previous hardcoded path failed on any system where Go is
installed elsewhere (e.g., /usr/bin/go, ~/go/bin/go).

Uses conditional assignment (GO ?=) so users can still override
with GO=/custom/path make build when needed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:08:46 -05:00
39 changed files with 582 additions and 176 deletions

View File

@@ -66,7 +66,7 @@ Tests live alongside the code they test (`*_test.go`). The parser has both unit
- **Parsing strategy**: User/system entries use byte-level extraction for speed; only assistant entries get full JSON parse (they carry token/cost data).
- **Deduplication**: Messages are keyed by message ID; the final state wins (handles edits/retries).
- **Cache**: SQLite at `~/.cache/cburn/sessions.db`. Mtime+size diffing means unchanged files aren't reparsed.
- **Cache**: SQLite at `~/.cache/cburn/metrics_v2.db`. Mtime+size diffing means unchanged files aren't reparsed.
- **TUI async loading**: Data loads via goroutines posting `tea.Msg`; the UI remains responsive during parse.
- **Pricing**: Hardcoded in `internal/config/pricing.go` with user overrides in config TOML. Model names are normalized (date suffixes stripped).

View File

@@ -1,4 +1,4 @@
GO := /usr/local/go/bin/go
GO ?= $(shell command -v go)
BIN := cburn
.PHONY: build install lint test test-race bench fuzz clean

View File

@@ -5,15 +5,29 @@ A CLI and TUI dashboard for analyzing Claude Code usage metrics. Parses JSONL se
## Installation
```bash
# Build from source
make build
go install github.com/theirongolddev/cburn@latest
```
# Install to ~/go/bin
Or build from source:
```bash
git clone https://github.com/theirongolddev/cburn.git
cd cburn
make install
```
Requires Go 1.24+.
**Note:** Ensure `~/go/bin` is in your PATH:
```bash
# bash/zsh: add to ~/.bashrc or ~/.zshrc
export PATH="$HOME/go/bin:$PATH"
# fish: add to ~/.config/fish/config.fish
fish_add_path ~/go/bin
```
## Quick Start
```bash
@@ -145,7 +159,7 @@ To get your session key:
## Caching
Session data is cached in SQLite at `~/.cache/cburn/sessions.db`. The cache uses mtime-based diffing - unchanged files are not reparsed.
Session data is cached in SQLite at `~/.cache/cburn/metrics_v2.db`. The cache uses mtime-based diffing - unchanged files are not reparsed.
Force a full reparse with `--no-cache`.

View File

@@ -4,7 +4,7 @@ package cmd
import (
"fmt"
"cburn/internal/config"
"github.com/theirongolddev/cburn/internal/config"
"github.com/spf13/cobra"
)

View File

@@ -3,9 +3,8 @@ package cmd
import (
"fmt"
"cburn/internal/cli"
"cburn/internal/config"
"cburn/internal/pipeline"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/pipeline"
"github.com/spf13/cobra"
)
@@ -32,7 +31,7 @@ func runCosts(_ *cobra.Command, _ []string) error {
filtered, since, until := applyFilters(result.Sessions)
stats := pipeline.Aggregate(filtered, since, until)
models := pipeline.AggregateModels(filtered, since, until)
tokenCosts, modelCosts := pipeline.AggregateCostBreakdown(filtered, since, until)
if stats.TotalSessions == 0 {
fmt.Println("\n No sessions in the selected time range.")
@@ -54,30 +53,14 @@ func runCosts(_ *cobra.Command, _ []string) error {
cost float64
}
// Calculate costs per token type from raw token counts using canonical pricing
var inputCost, outputCost, cache5mCost, cache1hCost, cacheReadCost float64
for _, s := range pipeline.FilterByTime(filtered, since, until) {
for modelName, mu := range s.Models {
p, ok := config.LookupPricing(modelName)
if !ok {
continue
}
inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1_000_000
outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1_000_000
cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1_000_000
cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1_000_000
cacheReadCost += float64(mu.CacheReadTokens) * p.CacheReadPerMTok / 1_000_000
}
}
totalCost := inputCost + outputCost + cache5mCost + cache1hCost + cacheReadCost
totalCost := tokenCosts.TotalCost
costs := []tokenCost{
{"Output", outputCost},
{"Cache Write (1h)", cache1hCost},
{"Input", inputCost},
{"Cache Write (5m)", cache5mCost},
{"Cache Read", cacheReadCost},
{"Output", tokenCosts.OutputCost},
{"Cache Write (1h)", tokenCosts.Cache1hCost},
{"Input", tokenCosts.InputCost},
{"Cache Write (5m)", tokenCosts.Cache5mCost},
{"Cache Read", tokenCosts.CacheReadCost},
}
// Sort by cost descending (already in expected order, but ensure)
@@ -116,29 +99,22 @@ func runCosts(_ *cobra.Command, _ []string) error {
}
// Cost by model
modelRows := make([][]string, 0, len(models)+2)
for _, ms := range models {
p, _ := config.LookupPricing(ms.Model)
mInput := float64(ms.InputTokens) * p.InputPerMTok / 1_000_000
mOutput := float64(ms.OutputTokens) * p.OutputPerMTok / 1_000_000
mCache := float64(ms.CacheCreation5m)*p.CacheWrite5mPerMTok/1_000_000 +
float64(ms.CacheCreation1h)*p.CacheWrite1hPerMTok/1_000_000 +
float64(ms.CacheReadTokens)*p.CacheReadPerMTok/1_000_000
modelRows := make([][]string, 0, len(modelCosts)+2)
for _, mc := range modelCosts {
modelRows = append(modelRows, []string{
shortModel(ms.Model),
cli.FormatCost(mInput),
cli.FormatCost(mOutput),
cli.FormatCost(mCache),
cli.FormatCost(ms.EstimatedCost),
shortModel(mc.Model),
cli.FormatCost(mc.InputCost),
cli.FormatCost(mc.OutputCost),
cli.FormatCost(mc.CacheCost),
cli.FormatCost(mc.TotalCost),
})
}
modelRows = append(modelRows, []string{"---"})
modelRows = append(modelRows, []string{
"TOTAL",
cli.FormatCost(inputCost),
cli.FormatCost(outputCost),
cli.FormatCost(cache5mCost + cache1hCost + cacheReadCost),
cli.FormatCost(tokenCosts.InputCost),
cli.FormatCost(tokenCosts.OutputCost),
cli.FormatCost(tokenCosts.CacheCost),
cli.FormatCost(totalCost),
})

View File

@@ -3,8 +3,8 @@ package cmd
import (
"fmt"
"cburn/internal/cli"
"cburn/internal/pipeline"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/pipeline"
"github.com/spf13/cobra"
)

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"strings"
"cburn/internal/cli"
"cburn/internal/pipeline"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/pipeline"
"github.com/spf13/cobra"
)

View File

@@ -3,8 +3,8 @@ package cmd
import (
"fmt"
"cburn/internal/cli"
"cburn/internal/pipeline"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/pipeline"
"github.com/spf13/cobra"
)

View File

@@ -3,8 +3,8 @@ package cmd
import (
"fmt"
"cburn/internal/cli"
"cburn/internal/pipeline"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/pipeline"
"github.com/spf13/cobra"
)

View File

@@ -6,10 +6,10 @@ import (
"path/filepath"
"time"
"cburn/internal/cli"
"cburn/internal/model"
"cburn/internal/pipeline"
"cburn/internal/store"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/model"
"github.com/theirongolddev/cburn/internal/pipeline"
"github.com/theirongolddev/cburn/internal/store"
"github.com/spf13/cobra"
)

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"sort"
"cburn/internal/cli"
"cburn/internal/pipeline"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/pipeline"
"github.com/spf13/cobra"
)

View File

@@ -5,9 +5,9 @@ import (
"fmt"
"strings"
"cburn/internal/config"
"cburn/internal/source"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/source"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"

View File

@@ -8,9 +8,9 @@ import (
"strings"
"time"
"cburn/internal/claudeai"
"cburn/internal/cli"
"cburn/internal/config"
"github.com/theirongolddev/cburn/internal/claudeai"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/config"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"os"
"cburn/internal/cli"
"cburn/internal/pipeline"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/pipeline"
"github.com/spf13/cobra"
)

View File

@@ -3,9 +3,9 @@ package cmd
import (
"fmt"
"cburn/internal/config"
"cburn/internal/tui"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/tui"
"github.com/theirongolddev/cburn/internal/tui/theme"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"

2
go.mod
View File

@@ -1,4 +1,4 @@
module cburn
module github.com/theirongolddev/cburn
go 1.24.2

View File

@@ -148,7 +148,7 @@ func (c *Client) get(ctx context.Context, path string) ([]byte, error) {
req.Header.Set("Cookie", "sessionKey="+c.sessionKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "cburn/1.0")
req.Header.Set("User-Agent", "github.com/theirongolddev/cburn/1.0")
//nolint:gosec // URL is constructed from const baseURL
resp, err := c.http.Do(req)

View File

@@ -1,6 +1,9 @@
package config
import "strings"
import (
"strings"
"time"
)
// ModelPricing holds per-million-token prices for a model.
type ModelPricing struct {
@@ -14,6 +17,11 @@ type ModelPricing struct {
LongOutputPerMTok float64
}
type modelPricingVersion struct {
EffectiveFrom time.Time
Pricing ModelPricing
}
// DefaultPricing maps model base names to their pricing.
var DefaultPricing = map[string]ModelPricing{
"claude-opus-4-6": {
@@ -63,12 +71,34 @@ var DefaultPricing = map[string]ModelPricing{
},
}
// defaultPricingHistory stores effective-dated prices for each model.
// Entries must be sorted by EffectiveFrom ascending.
var defaultPricingHistory = makeDefaultPricingHistory(DefaultPricing)
func makeDefaultPricingHistory(base map[string]ModelPricing) map[string][]modelPricingVersion {
history := make(map[string][]modelPricingVersion, len(base))
for modelName, pricing := range base {
history[modelName] = []modelPricingVersion{
{Pricing: pricing},
}
}
return history
}
func hasPricingModel(model string) bool {
if _, ok := defaultPricingHistory[model]; ok {
return true
}
_, ok := DefaultPricing[model]
return ok
}
// NormalizeModelName strips date suffixes from model identifiers.
// e.g., "claude-opus-4-5-20251101" -> "claude-opus-4-5"
func NormalizeModelName(raw string) string {
// Models can have date suffixes like -20251101 (8 digits)
// Strategy: try progressively shorter prefixes against the pricing table
if _, ok := DefaultPricing[raw]; ok {
if hasPricingModel(raw) {
return raw
}
@@ -78,7 +108,7 @@ func NormalizeModelName(raw string) string {
last := parts[len(parts)-1]
if isAllDigits(last) && len(last) >= 8 {
candidate := strings.Join(parts[:len(parts)-1], "-")
if _, ok := DefaultPricing[candidate]; ok {
if hasPricingModel(candidate) {
return candidate
}
}
@@ -99,14 +129,51 @@ func isAllDigits(s string) bool {
// LookupPricing returns the pricing for a model, normalizing the name first.
// Returns zero pricing and false if the model is unknown.
func LookupPricing(model string) (ModelPricing, bool) {
return LookupPricingAt(model, time.Now())
}
// LookupPricingAt returns the pricing for a model at the given timestamp.
// If at is zero, the latest known pricing entry is used.
func LookupPricingAt(model string, at time.Time) (ModelPricing, bool) {
normalized := NormalizeModelName(model)
p, ok := DefaultPricing[normalized]
return p, ok
versions, ok := defaultPricingHistory[normalized]
if !ok || len(versions) == 0 {
p, fallback := DefaultPricing[normalized]
return p, fallback
}
if at.IsZero() {
return versions[len(versions)-1].Pricing, true
}
at = at.UTC()
selected := versions[0].Pricing
for _, v := range versions {
if v.EffectiveFrom.IsZero() || !at.Before(v.EffectiveFrom.UTC()) {
selected = v.Pricing
continue
}
break
}
return selected, true
}
// CalculateCost computes the estimated cost in USD for a single API call.
func CalculateCost(model string, inputTokens, outputTokens, cache5m, cache1h, cacheRead int64) float64 {
pricing, ok := LookupPricing(model)
return CalculateCostAt(model, time.Now(), inputTokens, outputTokens, cache5m, cache1h, cacheRead)
}
// CalculateCostAt computes the estimated cost in USD for a single API call at a point in time.
func CalculateCostAt(
model string,
at time.Time,
inputTokens,
outputTokens,
cache5m,
cache1h,
cacheRead int64,
) float64 {
pricing, ok := LookupPricingAt(model, at)
if !ok {
return 0
}
@@ -124,7 +191,12 @@ func CalculateCost(model string, inputTokens, outputTokens, cache5m, cache1h, ca
// CalculateCacheSavings computes how much the cache reads saved vs full input pricing.
func CalculateCacheSavings(model string, cacheReadTokens int64) float64 {
pricing, ok := LookupPricing(model)
return CalculateCacheSavingsAt(model, time.Now(), cacheReadTokens)
}
// CalculateCacheSavingsAt computes how much cache reads saved at a point in time.
func CalculateCacheSavingsAt(model string, at time.Time, cacheReadTokens int64) float64 {
pricing, ok := LookupPricingAt(model, at)
if !ok {
return 0
}

View File

@@ -0,0 +1,81 @@
package config
import (
"testing"
"time"
)
func mustDate(t *testing.T, s string) time.Time {
t.Helper()
d, err := time.Parse("2006-01-02", s)
if err != nil {
t.Fatalf("parse date %q: %v", s, err)
}
return d
}
func TestLookupPricingAt_UsesEffectiveDate(t *testing.T) {
model := "test-model-windowed"
orig, had := defaultPricingHistory[model]
if had {
defer func() { defaultPricingHistory[model] = orig }()
} else {
defer delete(defaultPricingHistory, model)
}
defaultPricingHistory[model] = []modelPricingVersion{
{
EffectiveFrom: mustDate(t, "2025-01-01"),
Pricing: ModelPricing{InputPerMTok: 1.0},
},
{
EffectiveFrom: mustDate(t, "2025-07-01"),
Pricing: ModelPricing{InputPerMTok: 2.0},
},
}
aprPrice, ok := LookupPricingAt(model, mustDate(t, "2025-04-15"))
if !ok {
t.Fatal("LookupPricingAt returned !ok for historical model")
}
if aprPrice.InputPerMTok != 1.0 {
t.Fatalf("April price InputPerMTok = %.2f, want 1.0", aprPrice.InputPerMTok)
}
augPrice, ok := LookupPricingAt(model, mustDate(t, "2025-08-15"))
if !ok {
t.Fatal("LookupPricingAt returned !ok for historical model in later window")
}
if augPrice.InputPerMTok != 2.0 {
t.Fatalf("August price InputPerMTok = %.2f, want 2.0", augPrice.InputPerMTok)
}
}
func TestLookupPricingAt_UsesLatestWhenTimeZero(t *testing.T) {
model := "test-model-latest"
orig, had := defaultPricingHistory[model]
if had {
defer func() { defaultPricingHistory[model] = orig }()
} else {
defer delete(defaultPricingHistory, model)
}
defaultPricingHistory[model] = []modelPricingVersion{
{
EffectiveFrom: mustDate(t, "2025-01-01"),
Pricing: ModelPricing{InputPerMTok: 1.0},
},
{
EffectiveFrom: mustDate(t, "2025-09-01"),
Pricing: ModelPricing{InputPerMTok: 3.0},
},
}
price, ok := LookupPricingAt(model, time.Time{})
if !ok {
t.Fatal("LookupPricingAt returned !ok for model with pricing history")
}
if price.InputPerMTok != 3.0 {
t.Fatalf("zero-time lookup InputPerMTok = %.2f, want 3.0", price.InputPerMTok)
}
}

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
"cburn/internal/config"
"cburn/internal/model"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/model"
)
// Aggregate computes summary statistics from a slice of session stats,
@@ -51,7 +51,7 @@ func Aggregate(sessions []model.SessionStats, since, until time.Time) model.Summ
// Cache savings (sum across all models found in sessions)
for _, s := range filtered {
for modelName, mu := range s.Models {
stats.CacheSavings += config.CalculateCacheSavings(modelName, mu.CacheReadTokens)
stats.CacheSavings += config.CalculateCacheSavingsAt(modelName, s.StartTime, mu.CacheReadTokens)
}
}

View File

@@ -5,8 +5,8 @@ import (
"path/filepath"
"testing"
"cburn/internal/source"
"cburn/internal/store"
"github.com/theirongolddev/cburn/internal/source"
"github.com/theirongolddev/cburn/internal/store"
)
func BenchmarkLoad(b *testing.B) {

View File

@@ -0,0 +1,93 @@
package pipeline
import (
"sort"
"time"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/model"
)
// TokenTypeCosts holds aggregate costs split by token type.
type TokenTypeCosts struct {
InputCost float64
OutputCost float64
Cache5mCost float64
Cache1hCost float64
CacheReadCost float64
CacheCost float64
TotalCost float64
}
// ModelCostBreakdown holds cost components for one model.
type ModelCostBreakdown struct {
Model string
InputCost float64
OutputCost float64
Cache5mCost float64
Cache1hCost float64
CacheReadCost float64
CacheCost float64
TotalCost float64
}
// AggregateCostBreakdown computes token-type and model cost splits.
// Pricing is resolved at each session timestamp.
func AggregateCostBreakdown(
sessions []model.SessionStats,
since time.Time,
until time.Time,
) (TokenTypeCosts, []ModelCostBreakdown) {
filtered := FilterByTime(sessions, since, until)
var totals TokenTypeCosts
byModel := make(map[string]*ModelCostBreakdown)
for _, s := range filtered {
for modelName, usage := range s.Models {
pricing, ok := config.LookupPricingAt(modelName, s.StartTime)
if !ok {
continue
}
inputCost := float64(usage.InputTokens) * pricing.InputPerMTok / 1_000_000
outputCost := float64(usage.OutputTokens) * pricing.OutputPerMTok / 1_000_000
cache5mCost := float64(usage.CacheCreation5mTokens) * pricing.CacheWrite5mPerMTok / 1_000_000
cache1hCost := float64(usage.CacheCreation1hTokens) * pricing.CacheWrite1hPerMTok / 1_000_000
cacheReadCost := float64(usage.CacheReadTokens) * pricing.CacheReadPerMTok / 1_000_000
totals.InputCost += inputCost
totals.OutputCost += outputCost
totals.Cache5mCost += cache5mCost
totals.Cache1hCost += cache1hCost
totals.CacheReadCost += cacheReadCost
row, exists := byModel[modelName]
if !exists {
row = &ModelCostBreakdown{Model: modelName}
byModel[modelName] = row
}
row.InputCost += inputCost
row.OutputCost += outputCost
row.Cache5mCost += cache5mCost
row.Cache1hCost += cache1hCost
row.CacheReadCost += cacheReadCost
}
}
totals.CacheCost = totals.Cache5mCost + totals.Cache1hCost + totals.CacheReadCost
totals.TotalCost = totals.InputCost + totals.OutputCost + totals.CacheCost
modelRows := make([]ModelCostBreakdown, 0, len(byModel))
for _, row := range byModel {
row.CacheCost = row.Cache5mCost + row.Cache1hCost + row.CacheReadCost
row.TotalCost = row.InputCost + row.OutputCost + row.CacheCost
modelRows = append(modelRows, *row)
}
sort.Slice(modelRows, func(i, j int) bool {
return modelRows[i].TotalCost > modelRows[j].TotalCost
})
return totals, modelRows
}

View File

@@ -8,8 +8,8 @@ import (
"sync"
"sync/atomic"
"cburn/internal/source"
"cburn/internal/store"
"github.com/theirongolddev/cburn/internal/source"
"github.com/theirongolddev/cburn/internal/store"
)
// CachedLoadResult extends LoadResult with cache metadata.
@@ -173,5 +173,6 @@ func CacheDir() string {
// CachePath returns the full path to the cache database.
func CachePath() string {
return filepath.Join(CacheDir(), "metrics.db")
// v2 includes historical pricing-aware cost calculations.
return filepath.Join(CacheDir(), "metrics_v2.db")
}

View File

@@ -6,8 +6,8 @@ import (
"sync"
"sync/atomic"
"cburn/internal/model"
"cburn/internal/source"
"github.com/theirongolddev/cburn/internal/model"
"github.com/theirongolddev/cburn/internal/source"
)
// LoadResult holds the output of the full data loading pipeline.

View File

@@ -8,8 +8,8 @@ import (
"os"
"time"
"cburn/internal/config"
"cburn/internal/model"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/model"
)
// Byte patterns for field extraction.
@@ -173,8 +173,9 @@ func ParseFile(df DiscoveredFile) ParseResult {
}
for _, call := range calls {
call.EstimatedCost = config.CalculateCost(
call.EstimatedCost = config.CalculateCostAt(
call.Model,
call.Timestamp,
call.InputTokens,
call.OutputTokens,
call.CacheCreation5mTokens,

View File

@@ -8,7 +8,7 @@ import (
"path/filepath"
"time"
"cburn/internal/model"
"github.com/theirongolddev/cburn/internal/model"
_ "modernc.org/sqlite" // register sqlite driver
)

View File

@@ -10,17 +10,16 @@ import (
"strings"
"time"
"cburn/internal/claudeai"
"cburn/internal/cli"
"cburn/internal/config"
"cburn/internal/model"
"cburn/internal/pipeline"
"cburn/internal/store"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/claudeai"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/model"
"github.com/theirongolddev/cburn/internal/pipeline"
"github.com/theirongolddev/cburn/internal/store"
"github.com/theirongolddev/cburn/internal/tui/components"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
@@ -74,6 +73,8 @@ type App struct {
dailyStats []model.DailyStats
models []model.ModelStats
projects []model.ProjectStats
costByType pipeline.TokenTypeCosts
modelCosts []pipeline.ModelCostBreakdown
// Live activity charts (today + last hour)
todayHourly []model.HourlyStats
@@ -183,6 +184,7 @@ func (a *App) recompute() {
a.dailyStats = pipeline.AggregateDays(filtered, since, now)
a.models = pipeline.AggregateModels(filtered, since, now)
a.projects = pipeline.AggregateProjects(filtered, since, now)
a.costByType, a.modelCosts = pipeline.AggregateCostBreakdown(filtered, since, now)
// Live activity charts
a.todayHourly = pipeline.AggregateTodayHourly(filtered)
@@ -234,6 +236,44 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return a, nil
case tea.MouseMsg:
if !a.loaded || a.showHelp || (a.needSetup && a.setupForm != nil) {
return a, nil
}
switch msg.Button {
case tea.MouseButtonWheelUp:
// Scroll up in sessions tab
if a.activeTab == 2 && !a.sessState.searching {
if a.sessState.cursor > 0 {
a.sessState.cursor--
a.sessState.detailScroll = 0
}
}
return a, nil
case tea.MouseButtonWheelDown:
// Scroll down in sessions tab
if a.activeTab == 2 && !a.sessState.searching {
searchFiltered := a.getSearchFilteredSessions()
if a.sessState.cursor < len(searchFiltered)-1 {
a.sessState.cursor++
a.sessState.detailScroll = 0
}
}
return a, nil
case tea.MouseButtonLeft:
// Check if click is in tab bar area (first 2 lines)
if msg.Y <= 1 {
if tab := a.tabAtX(msg.X); tab >= 0 && tab < len(components.Tabs) {
a.activeTab = tab
}
}
return a, nil
}
return a, nil
case tea.KeyMsg:
key := msg.String()
@@ -256,6 +296,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a.updateSettingsInput(msg)
}
// Sessions search mode intercepts all keys when active
if a.activeTab == 2 && a.sessState.searching {
return a.updateSessionsSearch(msg)
}
// Help toggle
if key == "?" {
a.showHelp = !a.showHelp
@@ -271,7 +316,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Sessions tab has its own keybindings
if a.activeTab == 2 {
compactSessions := a.isCompactLayout()
searchFiltered := a.getSearchFilteredSessions()
switch key {
case "/":
// Start search mode
a.sessState.searching = true
a.sessState.searchInput = newSearchInput()
a.sessState.searchInput.Focus()
return a, a.sessState.searchInput.Cursor.BlinkCmd()
case "q":
if !compactSessions && a.sessState.viewMode == sessViewDetail {
a.sessState.viewMode = sessViewSplit
@@ -287,6 +340,13 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return a, nil
case "esc":
// Clear search if active, otherwise exit detail view
if a.sessState.searchQuery != "" {
a.sessState.searchQuery = ""
a.sessState.cursor = 0
a.sessState.offset = 0
return a, nil
}
if compactSessions {
return a, nil
}
@@ -295,7 +355,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return a, nil
case "j", "down":
if a.sessState.cursor < len(a.filtered)-1 {
if a.sessState.cursor < len(searchFiltered)-1 {
a.sessState.cursor++
a.sessState.detailScroll = 0
}
@@ -312,7 +372,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.sessState.detailScroll = 0
return a, nil
case "G":
a.sessState.cursor = len(a.filtered) - 1
a.sessState.cursor = len(searchFiltered) - 1
if a.sessState.cursor < 0 {
a.sessState.cursor = 0
}
a.sessState.detailScroll = 0
return a, nil
case "J":
@@ -631,11 +694,12 @@ func (a App) viewHelp() string {
{"o/c/s/b", "Overview / Costs / Sessions / Breakdown"},
{"x", "Settings"},
{"<- / ->", "Previous / Next tab"},
{"j / k", "Navigate lists"},
{"j / k", "Navigate lists (or mouse wheel)"},
{"J / K", "Scroll detail pane"},
{"^d / ^u", "Scroll detail half-page"},
{"/", "Search sessions (Enter apply, Esc cancel)"},
{"Enter / f", "Expand session full-screen"},
{"Esc", "Back to split view"},
{"Esc", "Clear search / Back to split view"},
{"r / R", "Refresh now / Toggle auto-refresh"},
{"?", "Toggle this help"},
{"q", "Quit (or back from full-screen)"},
@@ -692,7 +756,8 @@ func (a App) viewMain() string {
case 1:
content = a.renderCostsTab(cw)
case 2:
content = a.renderSessionsContent(a.filtered, cw, contentH)
searchFiltered := a.getSearchFilteredSessions()
content = a.renderSessionsContent(searchFiltered, cw, contentH)
case 3:
content = a.renderBreakdownTab(cw)
case 4:
@@ -971,3 +1036,65 @@ func fetchSubDataCmd(sessionKey string) tea.Cmd {
return SubDataMsg{Data: client.FetchAll(ctx)}
}
}
// ─── Mouse Support ──────────────────────────────────────────────
// tabAtX returns the tab index at the given X coordinate, or -1 if none.
// Tab layout: " Overview Costs Sessions Breakdown Settings[x]"
func (a App) tabAtX(x int) int {
// Tab bar format: " TabName TabName ..." with 2-space gaps
// We approximate positions since exact widths depend on styling.
// Each tab name is roughly: name length + optional [k] suffix + gap
positions := []struct {
start, end int
}{
{1, 12}, // Overview (0)
{14, 22}, // Costs (1)
{24, 35}, // Sessions (2)
{37, 50}, // Breakdown (3)
{52, 68}, // Settings (4)
}
for i, p := range positions {
if x >= p.start && x <= p.end {
return i
}
}
return -1
}
// ─── Session Search ─────────────────────────────────────────────
// updateSessionsSearch handles key events while in search mode.
func (a App) updateSessionsSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
switch key {
case "enter":
// Apply search and exit search mode
a.sessState.searchQuery = strings.TrimSpace(a.sessState.searchInput.Value())
a.sessState.searching = false
a.sessState.cursor = 0
a.sessState.offset = 0
a.sessState.detailScroll = 0
return a, nil
case "esc":
// Cancel search mode without applying
a.sessState.searching = false
return a, nil
}
// Forward other keys to the text input
var cmd tea.Cmd
a.sessState.searchInput, cmd = a.sessState.searchInput.Update(msg)
return a, cmd
}
// getSearchFilteredSessions returns sessions filtered by the current search query.
func (a App) getSearchFilteredSessions() []model.SessionStats {
if a.sessState.searchQuery == "" {
return a.filtered
}
return filterSessionsBySearch(a.filtered, a.sessState.searchQuery)
}

View File

@@ -2,7 +2,7 @@
package components
import (
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
)

View File

@@ -5,7 +5,7 @@ import (
"math"
"strings"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
)

View File

@@ -5,7 +5,7 @@ import (
"strings"
"time"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/lipgloss"

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"strings"
"cburn/internal/claudeai"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/claudeai"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/lipgloss"

View File

@@ -3,7 +3,7 @@ package components
import (
"strings"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
)

View File

@@ -3,8 +3,8 @@ package tui
import (
"fmt"
"cburn/internal/config"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/huh"
)

View File

@@ -4,9 +4,9 @@ import (
"fmt"
"strings"
"cburn/internal/cli"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/tui/components"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
)

View File

@@ -6,12 +6,12 @@ import (
"strings"
"time"
"cburn/internal/claudeai"
"cburn/internal/cli"
"cburn/internal/config"
"cburn/internal/model"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/claudeai"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/model"
"github.com/theirongolddev/cburn/internal/tui/components"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/lipgloss"
@@ -20,8 +20,8 @@ import (
func (a App) renderCostsTab(cw int) string {
t := theme.Active
stats := a.stats
models := a.models
days := a.dailyStats
modelCosts := a.modelCosts
var b strings.Builder
@@ -69,11 +69,11 @@ func (a App) renderCostsTab(cw int) string {
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
tableBody.WriteString("\n")
for _, ms := range models {
for _, mc := range modelCosts {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s",
nameW,
truncStr(shortModel(ms.Model), nameW),
cli.FormatCost(ms.EstimatedCost))))
truncStr(shortModel(mc.Model), nameW),
cli.FormatCost(mc.TotalCost))))
tableBody.WriteString("\n")
}
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
@@ -83,22 +83,14 @@ func (a App) renderCostsTab(cw int) string {
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
tableBody.WriteString("\n")
for _, ms := range models {
inputCost := 0.0
outputCost := 0.0
if p, ok := config.LookupPricing(ms.Model); ok {
inputCost = float64(ms.InputTokens) * p.InputPerMTok / 1e6
outputCost = float64(ms.OutputTokens) * p.OutputPerMTok / 1e6
}
cacheCost := ms.EstimatedCost - inputCost - outputCost
for _, mc := range modelCosts {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s",
nameW,
truncStr(shortModel(ms.Model), nameW),
cli.FormatCost(inputCost),
cli.FormatCost(outputCost),
cli.FormatCost(cacheCost),
cli.FormatCost(ms.EstimatedCost))))
truncStr(shortModel(mc.Model), nameW),
cli.FormatCost(mc.InputCost),
cli.FormatCost(mc.OutputCost),
cli.FormatCost(mc.CacheCost),
cli.FormatCost(mc.TotalCost))))
tableBody.WriteString("\n")
}

View File

@@ -5,10 +5,10 @@ import (
"strings"
"time"
"cburn/internal/cli"
"cburn/internal/pipeline"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/pipeline"
"github.com/theirongolddev/cburn/internal/tui/components"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
)

View File

@@ -5,11 +5,11 @@ import (
"sort"
"strings"
"cburn/internal/cli"
"cburn/internal/config"
"cburn/internal/model"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/model"
"github.com/theirongolddev/cburn/internal/tui/components"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
@@ -76,8 +76,38 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str
t := theme.Active
ss := a.sessState
// Show search input when in search mode
if ss.searching {
var b strings.Builder
searchStyle := lipgloss.NewStyle().Foreground(t.Accent)
b.WriteString(searchStyle.Render(" Search: "))
b.WriteString(ss.searchInput.View())
b.WriteString("\n")
hintStyle := lipgloss.NewStyle().Foreground(t.TextDim)
b.WriteString(hintStyle.Render(" [Enter] apply [Esc] cancel"))
b.WriteString("\n\n")
// Show preview of filtered results
previewFiltered := filterSessionsBySearch(a.filtered, ss.searchInput.Value())
b.WriteString(hintStyle.Render(fmt.Sprintf(" %d sessions match", len(previewFiltered))))
return b.String()
}
// Build title with search indicator
title := fmt.Sprintf("Sessions [%dd]", a.days)
if ss.searchQuery != "" {
title = fmt.Sprintf("Sessions [%dd] / %q (%d)", a.days, ss.searchQuery, len(filtered))
}
if len(filtered) == 0 {
return components.ContentCard("Sessions", lipgloss.NewStyle().Foreground(t.TextMuted).Render("No sessions found"), cw)
var body strings.Builder
body.WriteString(lipgloss.NewStyle().Foreground(t.TextMuted).Render("No sessions found"))
if ss.searchQuery != "" {
body.WriteString("\n\n")
body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Render("[Esc] clear search [/] new search"))
}
return components.ContentCard(title, body.String(), cw)
}
// Force single-pane detail mode in compact layouts.
@@ -97,7 +127,16 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
t := theme.Active
ss := a.sessState
if ss.cursor >= len(sessions) {
// Clamp cursor to valid range
cursor := ss.cursor
if cursor >= len(sessions) {
cursor = len(sessions) - 1
}
if cursor < 0 {
cursor = 0
}
if len(sessions) == 0 {
return ""
}
@@ -130,11 +169,11 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
}
offset := ss.offset
if ss.cursor < offset {
offset = ss.cursor
if cursor < offset {
offset = cursor
}
if ss.cursor >= offset+visible {
offset = ss.cursor - visible + 1
if cursor >= offset+visible {
offset = cursor - visible + 1
}
end := offset + visible
@@ -158,7 +197,7 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
padN = 1
}
if i == ss.cursor {
if i == cursor {
fullLine := leftPart + strings.Repeat(" ", padN) + costStr
// Pad to full width for continuous highlight background
if len(fullLine) < leftInner {
@@ -175,10 +214,15 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
leftBody.WriteString("\n")
}
leftCard := components.ContentCard(fmt.Sprintf("Sessions [%dd]", a.days), leftBody.String(), leftW)
// Build title with search indicator
leftTitle := fmt.Sprintf("Sessions [%dd]", a.days)
if ss.searchQuery != "" {
leftTitle = fmt.Sprintf("Search: %q (%d)", ss.searchQuery, len(sessions))
}
leftCard := components.ContentCard(leftTitle, leftBody.String(), leftW)
// Right pane: full session detail with scroll support
sel := sessions[ss.cursor]
sel := sessions[cursor]
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle)
// Apply detail scroll offset
@@ -194,10 +238,15 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin
t := theme.Active
ss := a.sessState
if ss.cursor >= len(sessions) {
// Clamp cursor to valid range
cursor := ss.cursor
if cursor >= len(sessions) {
cursor = len(sessions) - 1
}
if cursor < 0 || len(sessions) == 0 {
return ""
}
sel := sessions[ss.cursor]
sel := sessions[cursor]
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
@@ -266,14 +315,14 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
savings := 0.0
for modelName, mu := range sel.Models {
p, ok := config.LookupPricing(modelName)
p, ok := config.LookupPricingAt(modelName, sel.StartTime)
if ok {
inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1e6
outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1e6
cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1e6
cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1e6
cacheReadCost += float64(mu.CacheReadTokens) * p.CacheReadPerMTok / 1e6
savings += config.CalculateCacheSavings(modelName, mu.CacheReadTokens)
savings += config.CalculateCacheSavingsAt(modelName, sel.StartTime, mu.CacheReadTokens)
}
}
@@ -428,9 +477,9 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
body.WriteString("\n")
if w < compactWidth {
body.WriteString(mutedStyle.Render("[j/k] navigate [J/K] scroll [q] quit"))
body.WriteString(mutedStyle.Render("[/] search [j/k] navigate [J/K] scroll [q] quit"))
} else {
body.WriteString(mutedStyle.Render("[Enter] expand [j/k] navigate [J/K/^d/^u] scroll [q] quit"))
body.WriteString(mutedStyle.Render("[/] search [Enter] expand [j/k] navigate [J/K/^d/^u] scroll [q] quit"))
}
return body.String()

View File

@@ -6,10 +6,10 @@ import (
"strings"
"time"
"cburn/internal/cli"
"cburn/internal/config"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/tui/components"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"

View File

@@ -1,7 +1,7 @@
// cburn analyzes Claude Code usage from local JSONL session logs.
package main
import "cburn/cmd"
import "github.com/theirongolddev/cburn/cmd"
func main() {
cmd.Execute()