Compare commits
6 Commits
74c9905dbf
...
3668ae7f70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3668ae7f70 | ||
|
|
083e7d40ce | ||
|
|
16cc4d4737 | ||
|
|
9bb0fd6b73 | ||
|
|
baa88efe75 | ||
|
|
a386d95959 |
@@ -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).
|
- **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).
|
- **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.
|
- **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).
|
- **Pricing**: Hardcoded in `internal/config/pricing.go` with user overrides in config TOML. Model names are normalized (date suffixes stripped).
|
||||||
|
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -1,4 +1,4 @@
|
|||||||
GO := /usr/local/go/bin/go
|
GO ?= $(shell command -v go)
|
||||||
BIN := cburn
|
BIN := cburn
|
||||||
|
|
||||||
.PHONY: build install lint test test-race bench fuzz clean
|
.PHONY: build install lint test test-race bench fuzz clean
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -5,15 +5,29 @@ A CLI and TUI dashboard for analyzing Claude Code usage metrics. Parses JSONL se
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build from source
|
go install github.com/theirongolddev/cburn@latest
|
||||||
make build
|
```
|
||||||
|
|
||||||
# Install to ~/go/bin
|
Or build from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/theirongolddev/cburn.git
|
||||||
|
cd cburn
|
||||||
make install
|
make install
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.24+.
|
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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -145,7 +159,7 @@ To get your session key:
|
|||||||
|
|
||||||
## Caching
|
## 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`.
|
Force a full reparse with `--no-cache`.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
62
cmd/costs.go
62
cmd/costs.go
@@ -3,9 +3,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
"cburn/internal/pipeline"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -32,7 +31,7 @@ func runCosts(_ *cobra.Command, _ []string) error {
|
|||||||
|
|
||||||
filtered, since, until := applyFilters(result.Sessions)
|
filtered, since, until := applyFilters(result.Sessions)
|
||||||
stats := pipeline.Aggregate(filtered, since, until)
|
stats := pipeline.Aggregate(filtered, since, until)
|
||||||
models := pipeline.AggregateModels(filtered, since, until)
|
tokenCosts, modelCosts := pipeline.AggregateCostBreakdown(filtered, since, until)
|
||||||
|
|
||||||
if stats.TotalSessions == 0 {
|
if stats.TotalSessions == 0 {
|
||||||
fmt.Println("\n No sessions in the selected time range.")
|
fmt.Println("\n No sessions in the selected time range.")
|
||||||
@@ -54,30 +53,14 @@ func runCosts(_ *cobra.Command, _ []string) error {
|
|||||||
cost float64
|
cost float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate costs per token type from raw token counts using canonical pricing
|
totalCost := tokenCosts.TotalCost
|
||||||
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
|
|
||||||
|
|
||||||
costs := []tokenCost{
|
costs := []tokenCost{
|
||||||
{"Output", outputCost},
|
{"Output", tokenCosts.OutputCost},
|
||||||
{"Cache Write (1h)", cache1hCost},
|
{"Cache Write (1h)", tokenCosts.Cache1hCost},
|
||||||
{"Input", inputCost},
|
{"Input", tokenCosts.InputCost},
|
||||||
{"Cache Write (5m)", cache5mCost},
|
{"Cache Write (5m)", tokenCosts.Cache5mCost},
|
||||||
{"Cache Read", cacheReadCost},
|
{"Cache Read", tokenCosts.CacheReadCost},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by cost descending (already in expected order, but ensure)
|
// Sort by cost descending (already in expected order, but ensure)
|
||||||
@@ -116,29 +99,22 @@ func runCosts(_ *cobra.Command, _ []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cost by model
|
// Cost by model
|
||||||
modelRows := make([][]string, 0, len(models)+2)
|
modelRows := make([][]string, 0, len(modelCosts)+2)
|
||||||
for _, ms := range models {
|
for _, mc := range modelCosts {
|
||||||
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 = append(modelRows, []string{
|
modelRows = append(modelRows, []string{
|
||||||
shortModel(ms.Model),
|
shortModel(mc.Model),
|
||||||
cli.FormatCost(mInput),
|
cli.FormatCost(mc.InputCost),
|
||||||
cli.FormatCost(mOutput),
|
cli.FormatCost(mc.OutputCost),
|
||||||
cli.FormatCost(mCache),
|
cli.FormatCost(mc.CacheCost),
|
||||||
cli.FormatCost(ms.EstimatedCost),
|
cli.FormatCost(mc.TotalCost),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
modelRows = append(modelRows, []string{"---"})
|
modelRows = append(modelRows, []string{"---"})
|
||||||
modelRows = append(modelRows, []string{
|
modelRows = append(modelRows, []string{
|
||||||
"TOTAL",
|
"TOTAL",
|
||||||
cli.FormatCost(inputCost),
|
cli.FormatCost(tokenCosts.InputCost),
|
||||||
cli.FormatCost(outputCost),
|
cli.FormatCost(tokenCosts.OutputCost),
|
||||||
cli.FormatCost(cache5mCost + cache1hCost + cacheReadCost),
|
cli.FormatCost(tokenCosts.CacheCost),
|
||||||
cli.FormatCost(totalCost),
|
cli.FormatCost(totalCost),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
"cburn/internal/store"
|
"github.com/theirongolddev/cburn/internal/store"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/source"
|
"github.com/theirongolddev/cburn/internal/source"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/claudeai"
|
"github.com/theirongolddev/cburn/internal/claudeai"
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/tui"
|
"github.com/theirongolddev/cburn/internal/tui"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module cburn
|
module github.com/theirongolddev/cburn
|
||||||
|
|
||||||
go 1.24.2
|
go 1.24.2
|
||||||
|
|
||||||
|
|||||||
@@ -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("Cookie", "sessionKey="+c.sessionKey)
|
||||||
req.Header.Set("Accept", "application/json")
|
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
|
//nolint:gosec // URL is constructed from const baseURL
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// ModelPricing holds per-million-token prices for a model.
|
// ModelPricing holds per-million-token prices for a model.
|
||||||
type ModelPricing struct {
|
type ModelPricing struct {
|
||||||
@@ -14,6 +17,11 @@ type ModelPricing struct {
|
|||||||
LongOutputPerMTok float64
|
LongOutputPerMTok float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type modelPricingVersion struct {
|
||||||
|
EffectiveFrom time.Time
|
||||||
|
Pricing ModelPricing
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultPricing maps model base names to their pricing.
|
// DefaultPricing maps model base names to their pricing.
|
||||||
var DefaultPricing = map[string]ModelPricing{
|
var DefaultPricing = map[string]ModelPricing{
|
||||||
"claude-opus-4-6": {
|
"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.
|
// NormalizeModelName strips date suffixes from model identifiers.
|
||||||
// e.g., "claude-opus-4-5-20251101" -> "claude-opus-4-5"
|
// e.g., "claude-opus-4-5-20251101" -> "claude-opus-4-5"
|
||||||
func NormalizeModelName(raw string) string {
|
func NormalizeModelName(raw string) string {
|
||||||
// Models can have date suffixes like -20251101 (8 digits)
|
// Models can have date suffixes like -20251101 (8 digits)
|
||||||
// Strategy: try progressively shorter prefixes against the pricing table
|
// Strategy: try progressively shorter prefixes against the pricing table
|
||||||
if _, ok := DefaultPricing[raw]; ok {
|
if hasPricingModel(raw) {
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +108,7 @@ func NormalizeModelName(raw string) string {
|
|||||||
last := parts[len(parts)-1]
|
last := parts[len(parts)-1]
|
||||||
if isAllDigits(last) && len(last) >= 8 {
|
if isAllDigits(last) && len(last) >= 8 {
|
||||||
candidate := strings.Join(parts[:len(parts)-1], "-")
|
candidate := strings.Join(parts[:len(parts)-1], "-")
|
||||||
if _, ok := DefaultPricing[candidate]; ok {
|
if hasPricingModel(candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,14 +129,51 @@ func isAllDigits(s string) bool {
|
|||||||
// LookupPricing returns the pricing for a model, normalizing the name first.
|
// LookupPricing returns the pricing for a model, normalizing the name first.
|
||||||
// Returns zero pricing and false if the model is unknown.
|
// Returns zero pricing and false if the model is unknown.
|
||||||
func LookupPricing(model string) (ModelPricing, bool) {
|
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)
|
normalized := NormalizeModelName(model)
|
||||||
p, ok := DefaultPricing[normalized]
|
versions, ok := defaultPricingHistory[normalized]
|
||||||
return p, ok
|
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.
|
// CalculateCost computes the estimated cost in USD for a single API call.
|
||||||
func CalculateCost(model string, inputTokens, outputTokens, cache5m, cache1h, cacheRead int64) float64 {
|
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 {
|
if !ok {
|
||||||
return 0
|
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.
|
// CalculateCacheSavings computes how much the cache reads saved vs full input pricing.
|
||||||
func CalculateCacheSavings(model string, cacheReadTokens int64) float64 {
|
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 {
|
if !ok {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
81
internal/config/pricing_test.go
Normal file
81
internal/config/pricing_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Aggregate computes summary statistics from a slice of session stats,
|
// 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)
|
// Cache savings (sum across all models found in sessions)
|
||||||
for _, s := range filtered {
|
for _, s := range filtered {
|
||||||
for modelName, mu := range s.Models {
|
for modelName, mu := range s.Models {
|
||||||
stats.CacheSavings += config.CalculateCacheSavings(modelName, mu.CacheReadTokens)
|
stats.CacheSavings += config.CalculateCacheSavingsAt(modelName, s.StartTime, mu.CacheReadTokens)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cburn/internal/source"
|
"github.com/theirongolddev/cburn/internal/source"
|
||||||
"cburn/internal/store"
|
"github.com/theirongolddev/cburn/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BenchmarkLoad(b *testing.B) {
|
func BenchmarkLoad(b *testing.B) {
|
||||||
|
|||||||
93
internal/pipeline/costs.go
Normal file
93
internal/pipeline/costs.go
Normal 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
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"cburn/internal/source"
|
"github.com/theirongolddev/cburn/internal/source"
|
||||||
"cburn/internal/store"
|
"github.com/theirongolddev/cburn/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CachedLoadResult extends LoadResult with cache metadata.
|
// CachedLoadResult extends LoadResult with cache metadata.
|
||||||
@@ -173,5 +173,6 @@ func CacheDir() string {
|
|||||||
|
|
||||||
// CachePath returns the full path to the cache database.
|
// CachePath returns the full path to the cache database.
|
||||||
func CachePath() string {
|
func CachePath() string {
|
||||||
return filepath.Join(CacheDir(), "metrics.db")
|
// v2 includes historical pricing-aware cost calculations.
|
||||||
|
return filepath.Join(CacheDir(), "metrics_v2.db")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
"cburn/internal/source"
|
"github.com/theirongolddev/cburn/internal/source"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadResult holds the output of the full data loading pipeline.
|
// LoadResult holds the output of the full data loading pipeline.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Byte patterns for field extraction.
|
// Byte patterns for field extraction.
|
||||||
@@ -173,8 +173,9 @@ func ParseFile(df DiscoveredFile) ParseResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, call := range calls {
|
for _, call := range calls {
|
||||||
call.EstimatedCost = config.CalculateCost(
|
call.EstimatedCost = config.CalculateCostAt(
|
||||||
call.Model,
|
call.Model,
|
||||||
|
call.Timestamp,
|
||||||
call.InputTokens,
|
call.InputTokens,
|
||||||
call.OutputTokens,
|
call.OutputTokens,
|
||||||
call.CacheCreation5mTokens,
|
call.CacheCreation5mTokens,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // register sqlite driver
|
_ "modernc.org/sqlite" // register sqlite driver
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,17 +10,16 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/claudeai"
|
"github.com/theirongolddev/cburn/internal/claudeai"
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
"cburn/internal/store"
|
"github.com/theirongolddev/cburn/internal/store"
|
||||||
"cburn/internal/tui/components"
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -74,6 +73,8 @@ type App struct {
|
|||||||
dailyStats []model.DailyStats
|
dailyStats []model.DailyStats
|
||||||
models []model.ModelStats
|
models []model.ModelStats
|
||||||
projects []model.ProjectStats
|
projects []model.ProjectStats
|
||||||
|
costByType pipeline.TokenTypeCosts
|
||||||
|
modelCosts []pipeline.ModelCostBreakdown
|
||||||
|
|
||||||
// Live activity charts (today + last hour)
|
// Live activity charts (today + last hour)
|
||||||
todayHourly []model.HourlyStats
|
todayHourly []model.HourlyStats
|
||||||
@@ -183,6 +184,7 @@ func (a *App) recompute() {
|
|||||||
a.dailyStats = pipeline.AggregateDays(filtered, since, now)
|
a.dailyStats = pipeline.AggregateDays(filtered, since, now)
|
||||||
a.models = pipeline.AggregateModels(filtered, since, now)
|
a.models = pipeline.AggregateModels(filtered, since, now)
|
||||||
a.projects = pipeline.AggregateProjects(filtered, since, now)
|
a.projects = pipeline.AggregateProjects(filtered, since, now)
|
||||||
|
a.costByType, a.modelCosts = pipeline.AggregateCostBreakdown(filtered, since, now)
|
||||||
|
|
||||||
// Live activity charts
|
// Live activity charts
|
||||||
a.todayHourly = pipeline.AggregateTodayHourly(filtered)
|
a.todayHourly = pipeline.AggregateTodayHourly(filtered)
|
||||||
@@ -234,6 +236,44 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return a, nil
|
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:
|
case tea.KeyMsg:
|
||||||
key := msg.String()
|
key := msg.String()
|
||||||
|
|
||||||
@@ -256,6 +296,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return a.updateSettingsInput(msg)
|
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
|
// Help toggle
|
||||||
if key == "?" {
|
if key == "?" {
|
||||||
a.showHelp = !a.showHelp
|
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
|
// Sessions tab has its own keybindings
|
||||||
if a.activeTab == 2 {
|
if a.activeTab == 2 {
|
||||||
compactSessions := a.isCompactLayout()
|
compactSessions := a.isCompactLayout()
|
||||||
|
searchFiltered := a.getSearchFilteredSessions()
|
||||||
|
|
||||||
switch key {
|
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":
|
case "q":
|
||||||
if !compactSessions && a.sessState.viewMode == sessViewDetail {
|
if !compactSessions && a.sessState.viewMode == sessViewDetail {
|
||||||
a.sessState.viewMode = sessViewSplit
|
a.sessState.viewMode = sessViewSplit
|
||||||
@@ -287,6 +340,13 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
case "esc":
|
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 {
|
if compactSessions {
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
@@ -295,7 +355,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
case "j", "down":
|
case "j", "down":
|
||||||
if a.sessState.cursor < len(a.filtered)-1 {
|
if a.sessState.cursor < len(searchFiltered)-1 {
|
||||||
a.sessState.cursor++
|
a.sessState.cursor++
|
||||||
a.sessState.detailScroll = 0
|
a.sessState.detailScroll = 0
|
||||||
}
|
}
|
||||||
@@ -312,7 +372,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
a.sessState.detailScroll = 0
|
a.sessState.detailScroll = 0
|
||||||
return a, nil
|
return a, nil
|
||||||
case "G":
|
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
|
a.sessState.detailScroll = 0
|
||||||
return a, nil
|
return a, nil
|
||||||
case "J":
|
case "J":
|
||||||
@@ -631,11 +694,12 @@ func (a App) viewHelp() string {
|
|||||||
{"o/c/s/b", "Overview / Costs / Sessions / Breakdown"},
|
{"o/c/s/b", "Overview / Costs / Sessions / Breakdown"},
|
||||||
{"x", "Settings"},
|
{"x", "Settings"},
|
||||||
{"<- / ->", "Previous / Next tab"},
|
{"<- / ->", "Previous / Next tab"},
|
||||||
{"j / k", "Navigate lists"},
|
{"j / k", "Navigate lists (or mouse wheel)"},
|
||||||
{"J / K", "Scroll detail pane"},
|
{"J / K", "Scroll detail pane"},
|
||||||
{"^d / ^u", "Scroll detail half-page"},
|
{"^d / ^u", "Scroll detail half-page"},
|
||||||
|
{"/", "Search sessions (Enter apply, Esc cancel)"},
|
||||||
{"Enter / f", "Expand session full-screen"},
|
{"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"},
|
{"r / R", "Refresh now / Toggle auto-refresh"},
|
||||||
{"?", "Toggle this help"},
|
{"?", "Toggle this help"},
|
||||||
{"q", "Quit (or back from full-screen)"},
|
{"q", "Quit (or back from full-screen)"},
|
||||||
@@ -692,7 +756,8 @@ func (a App) viewMain() string {
|
|||||||
case 1:
|
case 1:
|
||||||
content = a.renderCostsTab(cw)
|
content = a.renderCostsTab(cw)
|
||||||
case 2:
|
case 2:
|
||||||
content = a.renderSessionsContent(a.filtered, cw, contentH)
|
searchFiltered := a.getSearchFilteredSessions()
|
||||||
|
content = a.renderSessionsContent(searchFiltered, cw, contentH)
|
||||||
case 3:
|
case 3:
|
||||||
content = a.renderBreakdownTab(cw)
|
content = a.renderBreakdownTab(cw)
|
||||||
case 4:
|
case 4:
|
||||||
@@ -971,3 +1036,65 @@ func fetchSubDataCmd(sessionKey string) tea.Cmd {
|
|||||||
return SubDataMsg{Data: client.FetchAll(ctx)}
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
package components
|
package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/progress"
|
"github.com/charmbracelet/bubbles/progress"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/claudeai"
|
"github.com/theirongolddev/cburn/internal/claudeai"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/progress"
|
"github.com/charmbracelet/bubbles/progress"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package components
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/tui/components"
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/claudeai"
|
"github.com/theirongolddev/cburn/internal/claudeai"
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
"cburn/internal/tui/components"
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/progress"
|
"github.com/charmbracelet/bubbles/progress"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -20,8 +20,8 @@ import (
|
|||||||
func (a App) renderCostsTab(cw int) string {
|
func (a App) renderCostsTab(cw int) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
stats := a.stats
|
stats := a.stats
|
||||||
models := a.models
|
|
||||||
days := a.dailyStats
|
days := a.dailyStats
|
||||||
|
modelCosts := a.modelCosts
|
||||||
|
|
||||||
var b strings.Builder
|
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(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
|
||||||
tableBody.WriteString("\n")
|
tableBody.WriteString("\n")
|
||||||
|
|
||||||
for _, ms := range models {
|
for _, mc := range modelCosts {
|
||||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s",
|
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s",
|
||||||
nameW,
|
nameW,
|
||||||
truncStr(shortModel(ms.Model), nameW),
|
truncStr(shortModel(mc.Model), nameW),
|
||||||
cli.FormatCost(ms.EstimatedCost))))
|
cli.FormatCost(mc.TotalCost))))
|
||||||
tableBody.WriteString("\n")
|
tableBody.WriteString("\n")
|
||||||
}
|
}
|
||||||
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
|
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(mutedStyle.Render(strings.Repeat("─", innerW)))
|
||||||
tableBody.WriteString("\n")
|
tableBody.WriteString("\n")
|
||||||
|
|
||||||
for _, ms := range models {
|
for _, mc := range modelCosts {
|
||||||
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
|
|
||||||
|
|
||||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s",
|
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s",
|
||||||
nameW,
|
nameW,
|
||||||
truncStr(shortModel(ms.Model), nameW),
|
truncStr(shortModel(mc.Model), nameW),
|
||||||
cli.FormatCost(inputCost),
|
cli.FormatCost(mc.InputCost),
|
||||||
cli.FormatCost(outputCost),
|
cli.FormatCost(mc.OutputCost),
|
||||||
cli.FormatCost(cacheCost),
|
cli.FormatCost(mc.CacheCost),
|
||||||
cli.FormatCost(ms.EstimatedCost))))
|
cli.FormatCost(mc.TotalCost))))
|
||||||
tableBody.WriteString("\n")
|
tableBody.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
"cburn/internal/tui/components"
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
"cburn/internal/tui/components"
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -76,8 +76,38 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str
|
|||||||
t := theme.Active
|
t := theme.Active
|
||||||
ss := a.sessState
|
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 {
|
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.
|
// 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
|
t := theme.Active
|
||||||
ss := a.sessState
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,11 +169,11 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
offset := ss.offset
|
offset := ss.offset
|
||||||
if ss.cursor < offset {
|
if cursor < offset {
|
||||||
offset = ss.cursor
|
offset = cursor
|
||||||
}
|
}
|
||||||
if ss.cursor >= offset+visible {
|
if cursor >= offset+visible {
|
||||||
offset = ss.cursor - visible + 1
|
offset = cursor - visible + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
end := offset + visible
|
end := offset + visible
|
||||||
@@ -158,7 +197,7 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
|
|||||||
padN = 1
|
padN = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if i == ss.cursor {
|
if i == cursor {
|
||||||
fullLine := leftPart + strings.Repeat(" ", padN) + costStr
|
fullLine := leftPart + strings.Repeat(" ", padN) + costStr
|
||||||
// Pad to full width for continuous highlight background
|
// Pad to full width for continuous highlight background
|
||||||
if len(fullLine) < leftInner {
|
if len(fullLine) < leftInner {
|
||||||
@@ -175,10 +214,15 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
|
|||||||
leftBody.WriteString("\n")
|
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
|
// Right pane: full session detail with scroll support
|
||||||
sel := sessions[ss.cursor]
|
sel := sessions[cursor]
|
||||||
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle)
|
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle)
|
||||||
|
|
||||||
// Apply detail scroll offset
|
// Apply detail scroll offset
|
||||||
@@ -194,10 +238,15 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin
|
|||||||
t := theme.Active
|
t := theme.Active
|
||||||
ss := a.sessState
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
sel := sessions[ss.cursor]
|
sel := sessions[cursor]
|
||||||
|
|
||||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
||||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||||
@@ -266,14 +315,14 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
|
|||||||
savings := 0.0
|
savings := 0.0
|
||||||
|
|
||||||
for modelName, mu := range sel.Models {
|
for modelName, mu := range sel.Models {
|
||||||
p, ok := config.LookupPricing(modelName)
|
p, ok := config.LookupPricingAt(modelName, sel.StartTime)
|
||||||
if ok {
|
if ok {
|
||||||
inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1e6
|
inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1e6
|
||||||
outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1e6
|
outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1e6
|
||||||
cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1e6
|
cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1e6
|
||||||
cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1e6
|
cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1e6
|
||||||
cacheReadCost += float64(mu.CacheReadTokens) * p.CacheReadPerMTok / 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")
|
body.WriteString("\n")
|
||||||
if w < compactWidth {
|
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 {
|
} 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()
|
return body.String()
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/tui/components"
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -1,7 +1,7 @@
|
|||||||
// cburn analyzes Claude Code usage from local JSONL session logs.
|
// cburn analyzes Claude Code usage from local JSONL session logs.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "cburn/cmd"
|
import "github.com/theirongolddev/cburn/cmd"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cmd.Execute()
|
cmd.Execute()
|
||||||
|
|||||||
Reference in New Issue
Block a user