Files
cburn/internal/tui/tab_costs.go
teernisse 901090f921 feat(tui): apply visual polish to dashboard tabs
Update all tab renderers to leverage the expanded theme palette and
polished components:

tab_overview.go:
- Use PanelCard (accent-bordered variant) for daily token chart
- Multi-color model bars: BlueBright, Cyan, Magenta, Yellow, Green
  for visual distinction between models
- Pre-compute styles outside loops for better performance
- Use Cyan for today's hourly chart, Magenta for last-hour chart

tab_breakdown.go:
- Apply consistent background styling
- Use new accent variants for visual hierarchy

tab_costs.go:
- Proper background fill on cost tables
- Accent coloring for cost highlights

tab_sessions.go:
- Background continuity in session list
- Visual improvements to session detail view

tab_settings.go:
- Consistent styling with other tabs

The result is a dashboard where each tab feels visually cohesive,
with color providing semantic meaning (different colors for different
models, metrics, and states) rather than arbitrary decoration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:05:49 -05:00

323 lines
11 KiB
Go

package tui
import (
"fmt"
"sort"
"strings"
"time"
"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"
)
func (a App) renderCostsTab(cw int) string {
t := theme.Active
stats := a.stats
days := a.dailyStats
modelCosts := a.modelCosts
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).Background(t.Surface).Bold(true)
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
costValueStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface)
modelNameStyle := lipgloss.NewStyle().Foreground(t.BlueBright).Background(t.Surface)
tokenCostStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface)
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
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 _, mc := range modelCosts {
tableBody.WriteString(modelNameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(mc.Model), nameW))))
tableBody.WriteString(costValueStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(mc.TotalCost))))
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 _, mc := range modelCosts {
tableBody.WriteString(modelNameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(mc.Model), nameW))))
tableBody.WriteString(tokenCostStyle.Render(fmt.Sprintf(" %10s %10s %10s",
cli.FormatCost(mc.InputCost),
cli.FormatCost(mc.OutputCost),
cli.FormatCost(mc.CacheCost))))
tableBody.WriteString(costValueStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(mc.TotalCost))))
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))
body.WriteString(spaceStyle.Render(" "))
body.WriteString(valueStyle.Render(fmt.Sprintf("%.0f%%", pct*100)))
body.WriteString("\n")
body.WriteString(labelStyle.Render("Used"))
body.WriteString(spaceStyle.Render(" "))
body.WriteString(valueStyle.Render(fmt.Sprintf("$%.2f", ol.UsedCredits)))
body.WriteString(spaceStyle.Render(" / "))
body.WriteString(valueStyle.Render(fmt.Sprintf("$%.2f", ol.MonthlyCreditLimit)))
body.WriteString(spaceStyle.Render(" "))
body.WriteString(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].EstimatedCost > sorted[j].EstimatedCost
})
topDays := sorted[:spendLimit]
sort.Slice(topDays, func(i, j int) bool {
return topDays[i].Date.After(topDays[j].Date)
})
for _, d := range topDays {
spendBody.WriteString(valueStyle.Render(d.Date.Format("Jan 02")))
spendBody.WriteString(spaceStyle.Render(" "))
spendBody.WriteString(lipgloss.NewStyle().Foreground(t.Green).Background(t.Surface).Render(cli.FormatCost(d.EstimatedCost)))
spendBody.WriteString("\n")
}
} else {
spendBody.WriteString(labelStyle.Render("No data"))
spendBody.WriteString("\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 string
value string
color lipgloss.Color
}{
{"Tokens/Prompt", cli.FormatTokens(tokPerPrompt), t.Cyan},
{"Output/Prompt", cli.FormatTokens(outPerPrompt), t.Cyan},
{"Prompts/Session", fmt.Sprintf("%.1f", promptsPerSess), t.Magenta},
{"Minutes/Day", fmt.Sprintf("%.0f", stats.MinutesPerDay), t.Yellow},
}
var effBody strings.Builder
for _, m := range effMetrics {
effBody.WriteString(labelStyle.Render(fmt.Sprintf("%-20s", m.name)))
effBody.WriteString(lipgloss.NewStyle().Foreground(m.color).Background(t.Surface).Render(fmt.Sprintf(" %10s", 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).Background(t.Surface)
// No session key configured
if a.subData == nil && !a.subFetching {
cfg := loadConfigOrDefault()
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).Background(t.Surface)
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).Background(t.Surface).Render(strings.Repeat("─", innerW)))
body.WriteString("\n")
body.WriteString(components.RateLimitBar("Overage",
pct, time.Time{}, labelW, barW))
spendStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
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).Background(t.Surface)
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"
}