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
This commit is contained in:
184
internal/tui/tab_overview.go
Normal file
184
internal/tui/tab_overview.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cburn/internal/cli"
|
||||
"cburn/internal/pipeline"
|
||||
"cburn/internal/tui/components"
|
||||
"cburn/internal/tui/theme"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (a App) renderOverviewTab(cw int) string {
|
||||
t := theme.Active
|
||||
stats := a.stats
|
||||
prev := a.prevStats
|
||||
days := a.dailyStats
|
||||
models := a.models
|
||||
var b strings.Builder
|
||||
|
||||
// Row 1: Metric cards
|
||||
costDelta := ""
|
||||
if prev.CostPerDay > 0 {
|
||||
costDelta = fmt.Sprintf("%s/day (%s)", cli.FormatCost(stats.CostPerDay), cli.FormatDelta(stats.CostPerDay, prev.CostPerDay))
|
||||
} else {
|
||||
costDelta = cli.FormatCost(stats.CostPerDay) + "/day"
|
||||
}
|
||||
|
||||
sessDelta := ""
|
||||
if prev.SessionsPerDay > 0 {
|
||||
pctChange := (stats.SessionsPerDay - prev.SessionsPerDay) / prev.SessionsPerDay * 100
|
||||
sessDelta = fmt.Sprintf("%.1f/day (%+.0f%%)", stats.SessionsPerDay, pctChange)
|
||||
} else {
|
||||
sessDelta = fmt.Sprintf("%.1f/day", stats.SessionsPerDay)
|
||||
}
|
||||
|
||||
cacheDelta := ""
|
||||
if prev.CacheHitRate > 0 {
|
||||
ppDelta := (stats.CacheHitRate - prev.CacheHitRate) * 100
|
||||
cacheDelta = fmt.Sprintf("saved %s (%+.1fpp)", cli.FormatCost(stats.CacheSavings), ppDelta)
|
||||
} else {
|
||||
cacheDelta = "saved " + cli.FormatCost(stats.CacheSavings)
|
||||
}
|
||||
|
||||
cards := []struct{ Label, Value, Delta string }{
|
||||
{"Tokens", cli.FormatTokens(stats.TotalBilledTokens), cli.FormatTokens(stats.TokensPerDay) + "/day"},
|
||||
{"Sessions", cli.FormatNumber(int64(stats.TotalSessions)), sessDelta},
|
||||
{"Cost", cli.FormatCost(stats.EstimatedCost), costDelta},
|
||||
{"Cache", cli.FormatPercent(stats.CacheHitRate), cacheDelta},
|
||||
}
|
||||
b.WriteString(components.MetricCardRow(cards, cw))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Row 2: Daily token usage chart
|
||||
if len(days) > 0 {
|
||||
chartVals := make([]float64, len(days))
|
||||
chartLabels := chartDateLabels(days)
|
||||
for i, d := range days {
|
||||
chartVals[len(days)-1-i] = float64(d.InputTokens + d.OutputTokens + d.CacheCreation5m + d.CacheCreation1h)
|
||||
}
|
||||
chartInnerW := components.CardInnerWidth(cw)
|
||||
b.WriteString(components.ContentCard(
|
||||
fmt.Sprintf("Daily Token Usage (%dd)", a.days),
|
||||
components.BarChart(chartVals, chartLabels, t.Blue, chartInnerW, 10),
|
||||
cw,
|
||||
))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Row 3: Model Split + Activity Patterns
|
||||
halves := components.LayoutRow(cw, 2)
|
||||
innerW := components.CardInnerWidth(halves[0])
|
||||
|
||||
nameStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
||||
barStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
||||
pctStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||
|
||||
var modelBody strings.Builder
|
||||
limit := 5
|
||||
if len(models) < limit {
|
||||
limit = len(models)
|
||||
}
|
||||
maxShare := 0.0
|
||||
for _, ms := range models[:limit] {
|
||||
if ms.SharePercent > maxShare {
|
||||
maxShare = ms.SharePercent
|
||||
}
|
||||
}
|
||||
nameW := innerW / 3
|
||||
if nameW < 10 {
|
||||
nameW = 10
|
||||
}
|
||||
barMaxLen := innerW - nameW - 8
|
||||
if barMaxLen < 1 {
|
||||
barMaxLen = 1
|
||||
}
|
||||
for _, ms := range models[:limit] {
|
||||
barLen := 0
|
||||
if maxShare > 0 {
|
||||
barLen = int(ms.SharePercent / maxShare * float64(barMaxLen))
|
||||
}
|
||||
fmt.Fprintf(&modelBody, "%s %s %s\n",
|
||||
nameStyle.Render(fmt.Sprintf("%-*s", nameW, shortModel(ms.Model))),
|
||||
barStyle.Render(strings.Repeat("█", barLen)),
|
||||
pctStyle.Render(fmt.Sprintf("%.0f%%", ms.SharePercent)))
|
||||
}
|
||||
|
||||
// Compact activity: aggregate prompts into 4-hour buckets
|
||||
now := time.Now()
|
||||
since := now.AddDate(0, 0, -a.days)
|
||||
hours := pipeline.AggregateHourly(a.filtered, since, now)
|
||||
|
||||
type actBucket struct {
|
||||
label string
|
||||
total int
|
||||
color lipgloss.Color
|
||||
}
|
||||
buckets := []actBucket{
|
||||
{"Night 00-03", 0, t.Red},
|
||||
{"Early 04-07", 0, t.Yellow},
|
||||
{"Morning 08-11", 0, t.Green},
|
||||
{"Midday 12-15", 0, t.Green},
|
||||
{"Evening 16-19", 0, t.Green},
|
||||
{"Late 20-23", 0, t.Yellow},
|
||||
}
|
||||
for _, h := range hours {
|
||||
idx := h.Hour / 4
|
||||
if idx >= 6 {
|
||||
idx = 5
|
||||
}
|
||||
buckets[idx].total += h.Prompts
|
||||
}
|
||||
|
||||
maxBucket := 0
|
||||
for _, bk := range buckets {
|
||||
if bk.total > maxBucket {
|
||||
maxBucket = bk.total
|
||||
}
|
||||
}
|
||||
|
||||
actInnerW := components.CardInnerWidth(halves[1])
|
||||
|
||||
// Compute number column width from actual data so bars never overflow.
|
||||
maxNumW := 5
|
||||
for _, bk := range buckets {
|
||||
if nw := len(cli.FormatNumber(int64(bk.total))); nw > maxNumW {
|
||||
maxNumW = nw
|
||||
}
|
||||
}
|
||||
// prefix = 13 (label) + 1 (space) + maxNumW (number) + 1 (space)
|
||||
actBarMax := actInnerW - 15 - maxNumW
|
||||
if actBarMax < 1 {
|
||||
actBarMax = 1
|
||||
}
|
||||
|
||||
numStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
var actBody strings.Builder
|
||||
for _, bk := range buckets {
|
||||
bl := 0
|
||||
if maxBucket > 0 {
|
||||
bl = bk.total * actBarMax / maxBucket
|
||||
}
|
||||
bar := lipgloss.NewStyle().Foreground(bk.color).Render(strings.Repeat("█", bl))
|
||||
fmt.Fprintf(&actBody, "%s %s %s\n",
|
||||
numStyle.Render(bk.label),
|
||||
numStyle.Render(fmt.Sprintf("%*s", maxNumW, cli.FormatNumber(int64(bk.total)))),
|
||||
bar)
|
||||
}
|
||||
|
||||
modelCard := components.ContentCard("Model Split", modelBody.String(), halves[0])
|
||||
actCard := components.ContentCard("Activity", actBody.String(), halves[1])
|
||||
if a.isCompactLayout() {
|
||||
b.WriteString(components.ContentCard("Model Split", modelBody.String(), cw))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(components.ContentCard("Activity", actBody.String(), cw))
|
||||
} else {
|
||||
b.WriteString(components.CardRow([]string{modelCard, actCard}))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user