Files
cburn/internal/tui/tab_costs.go
teernisse 35fae37ba4 feat: overhaul TUI dashboard with subscription data, new tabs, and setup wizard
Major rewrite of the Bubble Tea dashboard, adding live claude.ai
integration and splitting the monolithic app.go into focused tab modules.

App model (app.go):
- Integrate claudeai.Client for live subscription/rate-limit data
- Add SubDataMsg and async fetch with periodic refresh (every 5 min)
- Add spinner for loading states (charmbracelet/bubbles spinner)
- Integrate huh form library for in-TUI setup wizard
- Rework tab routing to dispatch to dedicated tab renderers
- Add compact layout detection for narrow terminals (<100 cols)

TUI setup wizard (setup.go):
- Full huh-based setup flow embedded in the TUI (not just CLI)
- Three-step form: credentials, preferences (time range + theme), confirm
- Pre-populates from existing config, validates session key prefix
- Returns to dashboard on completion with config auto-saved

New tab modules:
- tab_overview.go: summary cards (sessions, prompts, cost, time), daily
  activity sparkline, rate-limit progress bars from live subscription data
- tab_breakdown.go: per-model usage table with calls, input/output tokens,
  cost, and share percentage; compact mode for narrow terminals
- tab_costs.go: cost analysis with daily cost chart, model cost breakdown,
  cache efficiency metrics, and budget tracking with progress bar

Rewritten tabs:
- tab_sessions.go: paginated session browser with sort-by-cost/tokens/time,
  per-session detail view, model usage breakdown per session, improved
  navigation (j/k, enter/esc, n/p for pages)
- tab_settings.go: updated to work with new theme struct and config fields
2026-02-20 16:08:26 -05:00

316 lines
9.7 KiB
Go

package tui
import (
"fmt"
"sort"
"strings"
"time"
"cburn/internal/claudeai"
"cburn/internal/cli"
"cburn/internal/config"
"cburn/internal/model"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/lipgloss"
)
func (a App) renderCostsTab(cw int) string {
t := theme.Active
stats := a.stats
models := a.models
days := a.dailyStats
var b strings.Builder
// Row 0: Subscription rate limits (live data from claude.ai)
b.WriteString(a.renderSubscriptionCard(cw))
// Row 1: Cost metric cards
savingsMultiplier := 0.0
if stats.EstimatedCost > 0 {
savingsMultiplier = stats.CacheSavings / stats.EstimatedCost
}
costCards := []struct{ Label, Value, Delta string }{
{"Total Cost", cli.FormatCost(stats.EstimatedCost), cli.FormatCost(stats.CostPerDay) + "/day"},
{"Cache Savings", cli.FormatCost(stats.CacheSavings), fmt.Sprintf("%.1fx cost", savingsMultiplier)},
{"Projected", cli.FormatCost(stats.CostPerDay*30) + "/mo", cli.FormatCost(stats.CostPerDay) + "/day"},
{"Cache Rate", cli.FormatPercent(stats.CacheHitRate), ""},
}
b.WriteString(components.MetricCardRow(costCards, cw))
b.WriteString("\n")
// Row 2: Cost breakdown table
innerW := components.CardInnerWidth(cw)
fixedCols := 10 + 10 + 10 + 10
gaps := 4
nameW := innerW - fixedCols - gaps
if nameW < 14 {
nameW = 14
}
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
var tableBody strings.Builder
if a.isCompactLayout() {
totalW := 10
nameW = innerW - totalW - 1
if nameW < 10 {
nameW = 10
}
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %10s", nameW, "Model", "Total")))
tableBody.WriteString("\n")
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
tableBody.WriteString("\n")
for _, ms := range models {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s",
nameW,
truncStr(shortModel(ms.Model), nameW),
cli.FormatCost(ms.EstimatedCost))))
tableBody.WriteString("\n")
}
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
} else {
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s", nameW, "Model", "Input", "Output", "Cache", "Total")))
tableBody.WriteString("\n")
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
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))))
tableBody.WriteString("\n")
}
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
}
title := fmt.Sprintf("Cost Breakdown %s (%dd)", cli.FormatCost(stats.EstimatedCost), a.days)
b.WriteString(components.ContentCard(title, tableBody.String(), cw))
b.WriteString("\n")
// Row 3: Budget progress + Top Spend Days
halves := components.LayoutRow(cw, 2)
// Use real overage data if available, otherwise show placeholder
var progressCard string
if a.subData != nil && a.subData.Overage != nil && a.subData.Overage.IsEnabled {
ol := a.subData.Overage
pct := 0.0
if ol.MonthlyCreditLimit > 0 {
pct = ol.UsedCredits / ol.MonthlyCreditLimit
}
barW := components.CardInnerWidth(halves[0]) - 10
if barW < 10 {
barW = 10
}
bar := progress.New(
progress.WithSolidFill(components.ColorForPct(pct)),
progress.WithWidth(barW),
progress.WithoutPercentage(),
)
bar.EmptyColor = string(t.TextDim)
var body strings.Builder
body.WriteString(bar.ViewAs(pct))
fmt.Fprintf(&body, " %.0f%%\n", pct*100)
fmt.Fprintf(&body, "%s %s / %s %s",
labelStyle.Render("Used"),
valueStyle.Render(fmt.Sprintf("$%.2f", ol.UsedCredits)),
valueStyle.Render(fmt.Sprintf("$%.2f", ol.MonthlyCreditLimit)),
labelStyle.Render(ol.Currency))
progressCard = components.ContentCard("Overage Spend", body.String(), halves[0])
} else {
ceiling := 200.0
pct := stats.EstimatedCost / ceiling
progressInnerW := components.CardInnerWidth(halves[0])
progressBody := components.ProgressBar(pct, progressInnerW-10) + "\n" +
labelStyle.Render("flat-rate plan ceiling")
progressCard = components.ContentCard("Budget Progress", progressBody, halves[0])
}
var spendBody strings.Builder
if len(days) > 0 {
spendLimit := 5
if len(days) < spendLimit {
spendLimit = len(days)
}
sorted := make([]model.DailyStats, len(days))
copy(sorted, days)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Date.After(sorted[j].Date)
})
for _, d := range sorted[:spendLimit] {
fmt.Fprintf(&spendBody, "%s %s\n",
valueStyle.Render(d.Date.Format("Jan 02")),
lipgloss.NewStyle().Foreground(t.Green).Render(cli.FormatCost(d.EstimatedCost)))
}
} else {
spendBody.WriteString("No data\n")
}
spendCard := components.ContentCard("Top Spend Days", spendBody.String(), halves[1])
if a.isCompactLayout() {
b.WriteString(progressCard)
b.WriteString("\n")
b.WriteString(components.ContentCard("Top Spend Days", spendBody.String(), cw))
} else {
b.WriteString(components.CardRow([]string{progressCard, spendCard}))
}
b.WriteString("\n")
// Row 4: Efficiency metrics
tokPerPrompt := int64(0)
outPerPrompt := int64(0)
if stats.TotalPrompts > 0 {
tokPerPrompt = (stats.InputTokens + stats.OutputTokens) / int64(stats.TotalPrompts)
outPerPrompt = stats.OutputTokens / int64(stats.TotalPrompts)
}
promptsPerSess := 0.0
if stats.TotalSessions > 0 {
promptsPerSess = float64(stats.TotalPrompts) / float64(stats.TotalSessions)
}
effMetrics := []struct{ name, value string }{
{"Tokens/Prompt", cli.FormatTokens(tokPerPrompt)},
{"Output/Prompt", cli.FormatTokens(outPerPrompt)},
{"Prompts/Session", fmt.Sprintf("%.1f", promptsPerSess)},
{"Minutes/Day", fmt.Sprintf("%.0f", stats.MinutesPerDay)},
}
var effBody strings.Builder
for _, m := range effMetrics {
effBody.WriteString(rowStyle.Render(fmt.Sprintf("%-20s %10s", m.name, m.value)))
effBody.WriteString("\n")
}
b.WriteString(components.ContentCard("Efficiency", effBody.String(), cw))
return b.String()
}
// renderSubscriptionCard renders the rate limit + overage card at the top of the costs tab.
func (a App) renderSubscriptionCard(cw int) string {
t := theme.Active
hintStyle := lipgloss.NewStyle().Foreground(t.TextDim)
// No session key configured
if a.subData == nil && !a.subFetching {
cfg, _ := config.Load()
if config.GetSessionKey(cfg) == "" {
return components.ContentCard("Subscription",
hintStyle.Render("Configure session key in Settings to see rate limits"),
cw) + "\n"
}
// Key configured but no data yet (initial fetch in progress)
return components.ContentCard("Subscription",
hintStyle.Render("Fetching rate limits..."),
cw) + "\n"
}
// Still fetching
if a.subData == nil {
return components.ContentCard("Subscription",
hintStyle.Render("Fetching rate limits..."),
cw) + "\n"
}
// Error with no usable data
if a.subData.Usage == nil && a.subData.Error != nil {
warnStyle := lipgloss.NewStyle().Foreground(t.Orange)
return components.ContentCard("Subscription",
warnStyle.Render(fmt.Sprintf("Error: %s", a.subData.Error)),
cw) + "\n"
}
// No usage data at all
if a.subData.Usage == nil {
return ""
}
innerW := components.CardInnerWidth(cw)
labelW := 13 // enough for "Weekly Sonnet"
barW := innerW - labelW - 16 // label + bar + pct(5) + countdown(~10) + gaps
if barW < 10 {
barW = 10
}
var body strings.Builder
type windowRow struct {
label string
window *claudeai.ParsedWindow
}
rows := []windowRow{}
if w := a.subData.Usage.FiveHour; w != nil {
rows = append(rows, windowRow{"5-hour", w})
}
if w := a.subData.Usage.SevenDay; w != nil {
rows = append(rows, windowRow{"Weekly", w})
}
if w := a.subData.Usage.SevenDayOpus; w != nil {
rows = append(rows, windowRow{"Weekly Opus", w})
}
if w := a.subData.Usage.SevenDaySonnet; w != nil {
rows = append(rows, windowRow{"Weekly Sonnet", w})
}
for i, r := range rows {
body.WriteString(components.RateLimitBar(r.label, r.window.Pct, r.window.ResetsAt, labelW, barW))
if i < len(rows)-1 {
body.WriteString("\n")
}
}
// Overage line if enabled
if ol := a.subData.Overage; ol != nil && ol.IsEnabled && ol.MonthlyCreditLimit > 0 {
pct := ol.UsedCredits / ol.MonthlyCreditLimit
body.WriteString("\n")
body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Render(strings.Repeat("─", innerW)))
body.WriteString("\n")
body.WriteString(components.RateLimitBar("Overage",
pct, time.Time{}, labelW, barW))
spendStyle := lipgloss.NewStyle().Foreground(t.TextDim)
body.WriteString(spendStyle.Render(
fmt.Sprintf(" $%.2f / $%.2f", ol.UsedCredits, ol.MonthlyCreditLimit)))
}
// Fetch timestamp
if !a.subData.FetchedAt.IsZero() {
body.WriteString("\n")
tsStyle := lipgloss.NewStyle().Foreground(t.TextDim)
body.WriteString(tsStyle.Render("Updated " + a.subData.FetchedAt.Format("3:04 PM")))
}
title := "Subscription"
if a.subData.Org.Name != "" {
title = "Subscription — " + a.subData.Org.Name
}
return components.ContentCard(title, body.String(), cw) + "\n"
}