Files
cburn/internal/tui/app.go
teernisse a04a0065a0 feat: add root TUI app model with 10-tab dashboard and async data loading
Add the core App Bubble Tea model that orchestrates the entire TUI
dashboard. The model manages:

- 10 navigable tabs: Dashboard, Costs, Sessions, Models, Projects,
  Trends, Efficiency, Activity, Budget, and Settings. Each tab is
  accessible via single-key shortcuts (d/c/s/m/p/t/e/a/b/x) or
  left/right arrows for sequential navigation.

- Async data pipeline: launches the JSONL loader in a goroutine and
  receives progress updates via a channel subscription, displaying
  an animated loading screen with a spinner and file count. Data
  loads once on startup and recomputes aggregates when filters change.

- Filter state: supports configurable time range (7/30/90 days),
  project filter, and model filter. Changing any filter triggers
  recomputation of all derived stats (summary, daily, model, project
  breakdowns) including a comparison period for delta calculations.

- First-run detection: if no config file exists when data finishes
  loading, automatically enters the setup wizard flow before showing
  the dashboard.

- Tab-specific rendering: Dashboard shows metric cards with period-
  over-period deltas, daily token/cost bar charts, model pie-style
  breakdown, and top projects. Costs shows a per-model cost table.
  Trends renders daily tokens, cost, and session bar charts.
  Efficiency shows cache hit rate and savings metrics. Activity
  renders an hourly heatmap. Budget tracks spend against plan limits
  with a burn-rate projection chart.

- Help overlay: toggleable help panel listing all keyboard shortcuts,
  rendered as a centered overlay above the active tab content.
2026-02-19 15:38:43 -05:00

1158 lines
31 KiB
Go

package tui
import (
"fmt"
"sort"
"strings"
"time"
"cburn/internal/cli"
"cburn/internal/config"
"cburn/internal/model"
"cburn/internal/pipeline"
"cburn/internal/store"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// DataLoadedMsg is sent when the data pipeline finishes.
type DataLoadedMsg struct {
Sessions []model.SessionStats
ProjectCount int
LoadTime time.Duration
FileErrors int
}
// ProgressMsg reports file parsing progress.
type ProgressMsg struct {
Current int
Total int
}
// App is the root Bubble Tea model.
type App struct {
// Data
sessions []model.SessionStats
projectCount int
loaded bool
loadTime time.Duration
// Pre-computed for current filter
filtered []model.SessionStats
stats model.SummaryStats
prevStats model.SummaryStats // previous period for comparison
days_ []model.DailyStats
models []model.ModelStats
projects []model.ProjectStats
// UI state
width int
height int
activeTab int
showHelp bool
// Filter state
days int
project string
model_ string
// Per-tab state
sessState sessionsState
settings settingsState
// First-run setup
setup setupState
needSetup bool
// Loading — channel-based progress subscription
loadingDots int
progress int
progressMax int
loadSub chan tea.Msg // progress + completion messages from loader goroutine
// Data dir for pipeline
claudeDir string
includeSubagents bool
}
// NewApp creates a new TUI app model.
func NewApp(claudeDir string, days int, project, modelFilter string, includeSubagents bool) App {
needSetup := !config.Exists()
return App{
claudeDir: claudeDir,
days: days,
needSetup: needSetup,
setup: newSetupState(),
project: project,
model_: modelFilter,
includeSubagents: includeSubagents,
loadSub: make(chan tea.Msg, 1),
}
}
func (a App) Init() tea.Cmd {
return tea.Batch(
loadDataCmd(a.claudeDir, a.includeSubagents, a.loadSub),
tickCmd(),
)
}
func (a *App) recompute() {
now := time.Now()
since := now.AddDate(0, 0, -a.days)
filtered := a.sessions
if a.project != "" {
filtered = pipeline.FilterByProject(filtered, a.project)
}
if a.model_ != "" {
filtered = pipeline.FilterByModel(filtered, a.model_)
}
a.filtered = pipeline.FilterByTime(filtered, since, now)
a.stats = pipeline.Aggregate(filtered, since, now)
a.days_ = pipeline.AggregateDays(filtered, since, now)
a.models = pipeline.AggregateModels(filtered, since, now)
a.projects = pipeline.AggregateProjects(filtered, since, now)
// Previous period for comparison (same duration, immediately before)
prevSince := since.AddDate(0, 0, -a.days)
a.prevStats = pipeline.Aggregate(filtered, prevSince, since)
// Sort filtered sessions for the sessions tab (most recent first)
sort.Slice(a.filtered, func(i, j int) bool {
return a.filtered[i].StartTime.After(a.filtered[j].StartTime)
})
// Clamp sessions cursor to the new filtered list bounds
if a.sessState.cursor >= len(a.filtered) {
a.sessState.cursor = len(a.filtered) - 1
}
if a.sessState.cursor < 0 {
a.sessState.cursor = 0
}
}
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.width = msg.Width
a.height = msg.Height
return a, nil
case tea.KeyMsg:
key := msg.String()
// Global: quit
if key == "ctrl+c" {
return a, tea.Quit
}
if !a.loaded {
return a, nil
}
// First-run setup wizard intercepts all keys
if a.needSetup && a.setup.active {
return a.updateSetup(msg)
}
// Settings tab has its own keybindings (text input)
if a.activeTab == 9 && a.settings.editing {
return a.updateSettingsInput(msg)
}
// Help toggle
if key == "?" {
a.showHelp = !a.showHelp
return a, nil
}
// Dismiss help
if a.showHelp {
a.showHelp = false
return a, nil
}
// Sessions tab has its own keybindings
if a.activeTab == 2 {
switch key {
case "q":
if a.sessState.viewMode == sessViewDetail {
a.sessState.viewMode = sessViewSplit
return a, nil
}
return a, tea.Quit
case "enter", "f":
if a.sessState.viewMode == sessViewSplit {
a.sessState.viewMode = sessViewDetail
}
return a, nil
case "esc":
if a.sessState.viewMode == sessViewDetail {
a.sessState.viewMode = sessViewSplit
}
return a, nil
case "j", "down":
if a.sessState.cursor < len(a.filtered)-1 {
a.sessState.cursor++
}
return a, nil
case "k", "up":
if a.sessState.cursor > 0 {
a.sessState.cursor--
}
return a, nil
case "g":
a.sessState.cursor = 0
a.sessState.offset = 0
return a, nil
case "G":
a.sessState.cursor = len(a.filtered) - 1
return a, nil
}
}
// Settings tab navigation (non-editing mode)
if a.activeTab == 9 {
switch key {
case "j", "down":
if a.settings.cursor < settingsFieldCount-1 {
a.settings.cursor++
}
return a, nil
case "k", "up":
if a.settings.cursor > 0 {
a.settings.cursor--
}
return a, nil
case "enter":
return a.settingsStartEdit()
}
}
// Global quit from non-sessions tabs
if key == "q" {
return a, tea.Quit
}
// Tab navigation
switch key {
case "d":
a.activeTab = 0
case "c":
a.activeTab = 1
case "s":
a.activeTab = 2
case "m":
a.activeTab = 3
case "p":
a.activeTab = 4
case "t":
a.activeTab = 5
case "e":
a.activeTab = 6
case "a":
a.activeTab = 7
case "b":
a.activeTab = 8
case "x":
a.activeTab = 9
case "left":
a.activeTab = (a.activeTab - 1 + len(components.Tabs)) % len(components.Tabs)
case "right":
a.activeTab = (a.activeTab + 1) % len(components.Tabs)
}
return a, nil
case DataLoadedMsg:
a.sessions = msg.Sessions
a.projectCount = msg.ProjectCount
a.loaded = true
a.loadTime = msg.LoadTime
a.recompute()
// Activate first-run setup after data loads
if a.needSetup {
a.setup.active = true
}
return a, nil
case ProgressMsg:
a.progress = msg.Current
a.progressMax = msg.Total
return a, waitForLoadMsg(a.loadSub)
case tickMsg:
a.loadingDots = (a.loadingDots + 1) % 4
if !a.loaded {
return a, tickCmd()
}
return a, nil
}
return a, nil
}
func (a App) updateSetup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
switch a.setup.step {
case 0: // Welcome
if key == "enter" {
a.setup.step = 1
a.setup.apiKeyIn.Focus()
return a, a.setup.apiKeyIn.Cursor.BlinkCmd()
}
case 1: // API key input
if key == "enter" {
a.setup.step = 2
a.setup.apiKeyIn.Blur()
return a, nil
}
// Forward to text input
var cmd tea.Cmd
a.setup.apiKeyIn, cmd = a.setup.apiKeyIn.Update(msg)
return a, cmd
case 2: // Days choice
switch key {
case "j", "down":
if a.setup.daysChoice < len(daysOptions)-1 {
a.setup.daysChoice++
}
case "k", "up":
if a.setup.daysChoice > 0 {
a.setup.daysChoice--
}
case "enter":
a.setup.step = 3
}
return a, nil
case 3: // Theme choice
switch key {
case "j", "down":
if a.setup.themeChoice < len(theme.All)-1 {
a.setup.themeChoice++
}
case "k", "up":
if a.setup.themeChoice > 0 {
a.setup.themeChoice--
}
case "enter":
// Save and show done
a.saveSetupConfig()
a.setup.step = 4
a.recompute()
}
return a, nil
case 4: // Done
if key == "enter" {
a.needSetup = false
a.setup.active = false
}
return a, nil
}
return a, nil
}
func (a App) contentWidth() int {
cw := a.width
if cw > 200 {
cw = 200
}
return cw
}
func (a App) View() string {
if a.width == 0 {
return ""
}
if !a.loaded {
return a.viewLoading()
}
// First-run setup wizard
if a.needSetup && a.setup.active {
return a.renderSetup()
}
if a.showHelp {
return a.viewHelp()
}
return a.viewMain()
}
func (a App) viewLoading() string {
t := theme.Active
w := a.width
h := a.height
titleStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Bold(true)
mutedStyle := lipgloss.NewStyle().
Foreground(t.TextMuted)
var b strings.Builder
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" cburn"))
b.WriteString(mutedStyle.Render(" - Claude Usage Metrics"))
b.WriteString("\n\n")
dots := strings.Repeat(".", a.loadingDots)
if a.progressMax > 0 {
barW := w - 20
if barW < 20 {
barW = 20
}
if barW > 60 {
barW = 60
}
pct := float64(a.progress) / float64(a.progressMax)
b.WriteString(fmt.Sprintf(" Parsing sessions%s\n", dots))
b.WriteString(fmt.Sprintf(" %s %s/%s\n",
components.ProgressBar(pct, barW),
cli.FormatNumber(int64(a.progress)),
cli.FormatNumber(int64(a.progressMax))))
} else {
b.WriteString(fmt.Sprintf(" Scanning sessions%s\n", dots))
}
content := b.String()
return padHeight(truncateHeight(content, h), h)
}
func (a App) viewHelp() string {
t := theme.Active
h := a.height
titleStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Bold(true)
keyStyle := lipgloss.NewStyle().
Foreground(t.TextPrimary).
Bold(true)
descStyle := lipgloss.NewStyle().
Foreground(t.TextMuted)
var b strings.Builder
b.WriteString("\n")
b.WriteString(titleStyle.Render(" Keybindings"))
b.WriteString("\n\n")
bindings := []struct{ key, desc string }{
{"d/c/s/m/p", "Dashboard / Costs / Sessions / Models / Projects"},
{"t/e/a/b/x", "Trends / Efficiency / Activity / Budget / Settings"},
{"<- / ->", "Previous / Next tab"},
{"j / k", "Navigate sessions"},
{"Enter / f", "Expand session full-screen"},
{"Esc", "Back to split view"},
{"?", "Toggle this help"},
{"q", "Quit (or back from full-screen)"},
}
for _, bind := range bindings {
b.WriteString(fmt.Sprintf(" %s %s\n",
keyStyle.Render(fmt.Sprintf("%-12s", bind.key)),
descStyle.Render(bind.desc)))
}
b.WriteString(fmt.Sprintf("\n %s\n", descStyle.Render("Press any key to close")))
content := b.String()
return padHeight(truncateHeight(content, h), h)
}
func (a App) viewMain() string {
t := theme.Active
w := a.width
cw := a.contentWidth()
h := a.height
// 1. Render header (tab bar + filter line)
filterStyle := lipgloss.NewStyle().Foreground(t.TextDim)
filterStr := fmt.Sprintf(" [%dd", a.days)
if a.project != "" {
filterStr += " | " + a.project
}
if a.model_ != "" {
filterStr += " | " + a.model_
}
filterStr += "]"
header := components.RenderTabBar(a.activeTab, w) + "\n" +
filterStyle.Render(filterStr) + "\n"
// 2. Render status bar
dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds())
statusBar := components.RenderStatusBar(w, dataAge)
// 3. Calculate content zone height
headerH := lipgloss.Height(header)
statusH := lipgloss.Height(statusBar)
contentH := h - headerH - statusH
if contentH < 5 {
contentH = 5
}
// 4. Render tab content (pass contentH to sessions)
var content string
switch a.activeTab {
case 0:
content = a.renderDashboardTab(cw)
case 1:
content = a.renderCostsTab(cw)
case 2:
content = a.renderSessionsContent(a.filtered, cw, contentH)
case 3:
content = a.renderModelsTab(cw)
case 4:
content = a.renderProjectsTab(cw)
case 5:
content = a.renderTrendsTab(cw)
case 6:
content = a.renderEfficiencyTab(cw)
case 7:
content = a.renderActivityTab(cw)
case 8:
content = a.renderBudgetTab(cw)
case 9:
content = a.renderSettingsTab(cw)
}
// 5. Truncate + pad to exactly contentH lines
content = padHeight(truncateHeight(content, contentH), contentH)
// 6. Center horizontally if terminal wider than content cap
if w > cw {
content = lipgloss.Place(w, contentH, lipgloss.Center, lipgloss.Top, content)
}
// 7. Stack vertically
return lipgloss.JoinVertical(lipgloss.Left, header, content, statusBar)
}
// ─── Dashboard Tab ──────────────────────────────────────────────
func (a App) renderDashboardTab(cw int) string {
t := theme.Active
stats := a.stats
prev := a.prevStats
days := a.days_
models := a.models
projects := a.projects
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 = fmt.Sprintf("%s/day", cli.FormatCost(stats.CostPerDay))
}
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 = fmt.Sprintf("saved %s", cli.FormatCost(stats.CacheSavings))
}
cards := []struct{ Label, Value, Delta string }{
{"Tokens", cli.FormatTokens(stats.TotalBilledTokens), fmt.Sprintf("%s/day", cli.FormatTokens(stats.TokensPerDay))},
{"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 + Top Projects
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 < 5 {
barMaxLen = 5
}
for _, ms := range models[:limit] {
barLen := 0
if maxShare > 0 {
barLen = int(ms.SharePercent / maxShare * float64(barMaxLen))
}
modelBody.WriteString(fmt.Sprintf("%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))))
}
var projBody strings.Builder
projLimit := 5
if len(projects) < projLimit {
projLimit = len(projects)
}
projNameW := innerW / 3
if projNameW < 10 {
projNameW = 10
}
for i, ps := range projects[:projLimit] {
projBody.WriteString(fmt.Sprintf("%s %s %s %s\n",
pctStyle.Render(fmt.Sprintf("%d.", i+1)),
nameStyle.Render(fmt.Sprintf("%-*s", projNameW, truncStr(ps.Project, projNameW))),
lipgloss.NewStyle().Foreground(t.Blue).Render(cli.FormatTokens(ps.TotalTokens)),
lipgloss.NewStyle().Foreground(t.Green).Render(cli.FormatCost(ps.EstimatedCost))))
}
b.WriteString(components.CardRow([]string{
components.ContentCard("Model Split", modelBody.String(), halves[0]),
components.ContentCard("Top Projects", projBody.String(), halves[1]),
}))
return b.String()
}
// ─── Costs Tab ──────────────────────────────────────────────────
func (a App) renderCostsTab(cw int) string {
t := theme.Active
stats := a.stats
models := a.models
innerW := components.CardInnerWidth(cw)
// Flex model name column: total - fixed numeric cols - gaps
fixedCols := 10 + 10 + 10 + 10 // Input, Output, Cache, Total
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)
var tableBody strings.Builder
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,
shortModel(ms.Model),
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)
var b strings.Builder
b.WriteString(components.ContentCard(title, tableBody.String(), cw))
b.WriteString("\n")
// Row 2: Cache Savings + Cost Rate summary cards
halves := components.LayoutRow(cw, 2)
savingsBody := fmt.Sprintf("%s\n%s",
lipgloss.NewStyle().Foreground(t.Green).Bold(true).Render(cli.FormatCost(stats.CacheSavings)),
lipgloss.NewStyle().Foreground(t.TextMuted).Render("cache read savings"))
rateBody := fmt.Sprintf("%s/day\n%s/mo projected",
lipgloss.NewStyle().Foreground(t.TextPrimary).Bold(true).Render(cli.FormatCost(stats.CostPerDay)),
lipgloss.NewStyle().Foreground(t.TextMuted).Render(cli.FormatCost(stats.CostPerDay*30)))
b.WriteString(components.CardRow([]string{
components.ContentCard("Cache Savings", savingsBody, halves[0]),
components.ContentCard("Cost Rate", rateBody, halves[1]),
}))
return b.String()
}
// ─── Models Tab ─────────────────────────────────────────────────
func (a App) renderModelsTab(cw int) string {
t := theme.Active
models := a.models
innerW := components.CardInnerWidth(cw)
fixedCols := 8 + 10 + 10 + 10 + 6 // Calls, Input, Output, Cost, Share
gaps := 5
nameW := innerW - fixedCols - gaps
if nameW < 14 {
nameW = 14
}
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
var tableBody strings.Builder
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s %10s %10s %6s", nameW, "Model", "Calls", "Input", "Output", "Cost", "Share")))
tableBody.WriteString("\n")
for _, ms := range models {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %8s %10s %10s %10s %5.1f%%",
nameW,
shortModel(ms.Model),
cli.FormatNumber(int64(ms.APICalls)),
cli.FormatTokens(ms.InputTokens),
cli.FormatTokens(ms.OutputTokens),
cli.FormatCost(ms.EstimatedCost),
ms.SharePercent)))
tableBody.WriteString("\n")
}
return components.ContentCard("Model Usage", tableBody.String(), cw)
}
// ─── Projects Tab ───────────────────────────────────────────────
func (a App) renderProjectsTab(cw int) string {
t := theme.Active
projects := a.projects
innerW := components.CardInnerWidth(cw)
fixedCols := 6 + 8 + 10 + 10 // Sess, Prompts, Tokens, Cost
gaps := 4
nameW := innerW - fixedCols - gaps
if nameW < 18 {
nameW = 18
}
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
var tableBody strings.Builder
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %6s %8s %10s %10s", nameW, "Project", "Sess.", "Prompts", "Tokens", "Cost")))
tableBody.WriteString("\n")
for _, ps := range projects {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %6d %8s %10s %10s",
nameW,
truncStr(ps.Project, nameW),
ps.Sessions,
cli.FormatNumber(int64(ps.Prompts)),
cli.FormatTokens(ps.TotalTokens),
cli.FormatCost(ps.EstimatedCost))))
tableBody.WriteString("\n")
}
return components.ContentCard("Projects", tableBody.String(), cw)
}
// ─── Trends Tab ─────────────────────────────────────────────────
func (a App) renderTrendsTab(cw int) string {
t := theme.Active
days := a.days_
if len(days) == 0 {
return components.ContentCard("Trends", "No data", cw)
}
halves := components.LayoutRow(cw, 2)
// Build shared date labels (chronological: oldest left, newest right)
dateLabels := chartDateLabels(days)
// Row 1: Daily Tokens + Daily Cost side by side
tokVals := make([]float64, len(days))
for i, d := range days {
tokVals[len(days)-1-i] = float64(d.InputTokens + d.OutputTokens + d.CacheCreation5m + d.CacheCreation1h)
}
halfInnerW := components.CardInnerWidth(halves[0])
tokCard := components.ContentCard("Daily Tokens", components.BarChart(tokVals, dateLabels, t.Blue, halfInnerW, 8), halves[0])
costVals := make([]float64, len(days))
for i, d := range days {
costVals[len(days)-1-i] = d.EstimatedCost
}
costInnerW := components.CardInnerWidth(halves[1])
costCard := components.ContentCard("Daily Cost", components.BarChart(costVals, dateLabels, t.Green, costInnerW, 8), halves[1])
// Row 2: Daily Sessions full width
sessVals := make([]float64, len(days))
for i, d := range days {
sessVals[len(days)-1-i] = float64(d.Sessions)
}
sessInnerW := components.CardInnerWidth(cw)
sessCard := components.ContentCard("Daily Sessions", components.BarChart(sessVals, dateLabels, t.Accent, sessInnerW, 8), cw)
var b strings.Builder
b.WriteString(components.CardRow([]string{tokCard, costCard}))
b.WriteString("\n")
b.WriteString(sessCard)
return b.String()
}
// ─── Efficiency Tab ─────────────────────────────────────────────
func (a App) renderEfficiencyTab(cw int) string {
t := theme.Active
stats := a.stats
var b strings.Builder
// Row 1: Metric cards
savingsMultiplier := 0.0
if stats.EstimatedCost > 0 {
savingsMultiplier = stats.CacheSavings / stats.EstimatedCost
}
cards := []struct{ Label, Value, Delta string }{
{"Cache Rate", cli.FormatPercent(stats.CacheHitRate), ""},
{"Savings", cli.FormatCost(stats.CacheSavings), fmt.Sprintf("%.1fx cost", savingsMultiplier)},
{"Net Cost", cli.FormatCost(stats.EstimatedCost), fmt.Sprintf("%s/day", cli.FormatCost(stats.CostPerDay))},
}
b.WriteString(components.MetricCardRow(cards, cw))
b.WriteString("\n")
// Row 2: Efficiency metrics card
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
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)
}
metrics := []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 metricsBody strings.Builder
for _, m := range metrics {
metricsBody.WriteString(rowStyle.Render(fmt.Sprintf("%-20s %10s", m.name, m.value)))
metricsBody.WriteString("\n")
}
b.WriteString(components.ContentCard("Efficiency Metrics", metricsBody.String(), cw))
return b.String()
}
// ─── Activity Tab ───────────────────────────────────────────────
func (a App) renderActivityTab(cw int) string {
t := theme.Active
now := time.Now()
since := now.AddDate(0, 0, -a.days)
hours := pipeline.AggregateHourly(a.filtered, since, now)
maxPrompts := 0
for _, h := range hours {
if h.Prompts > maxPrompts {
maxPrompts = h.Prompts
}
}
innerW := components.CardInnerWidth(cw)
barWidth := innerW - 15 // space for "HH:00 NNNNN "
if barWidth < 20 {
barWidth = 20
}
numStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
var body strings.Builder
for _, h := range hours {
barLen := 0
if maxPrompts > 0 {
barLen = h.Prompts * barWidth / maxPrompts
}
var barColor lipgloss.Color
switch {
case h.Hour >= 9 && h.Hour < 17:
barColor = t.Green
case h.Hour >= 6 && h.Hour < 22:
barColor = t.Yellow
default:
barColor = t.Red
}
bar := lipgloss.NewStyle().Foreground(barColor).Render(strings.Repeat("█", barLen))
body.WriteString(fmt.Sprintf("%s %s %s\n",
numStyle.Render(fmt.Sprintf("%02d:00", h.Hour)),
numStyle.Render(fmt.Sprintf("%5s", cli.FormatNumber(int64(h.Prompts)))),
bar))
}
return components.ContentCard("Activity Patterns", body.String(), cw)
}
// ─── Budget Tab ─────────────────────────────────────────────────
func (a App) renderBudgetTab(cw int) string {
t := theme.Active
stats := a.stats
days := a.days_
halves := components.LayoutRow(cw, 2)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
// Row 1: Plan Info + Progress
planBody := fmt.Sprintf("%s %s\n%s %s",
labelStyle.Render("Plan:"),
valueStyle.Render("Max ($200/mo unlimited)"),
labelStyle.Render("API Equivalent:"),
lipgloss.NewStyle().Foreground(t.Green).Bold(true).Render(cli.FormatCost(stats.EstimatedCost)))
planCard := components.ContentCard("Plan Info", planBody, halves[0])
ceiling := 200.0
pct := stats.EstimatedCost / ceiling
progressInnerW := components.CardInnerWidth(halves[1])
progressBody := components.ProgressBar(pct, progressInnerW-10) + "\n" +
labelStyle.Render("(of plan ceiling - flat rate)")
progressCard := components.ContentCard("Progress", progressBody, halves[1])
// Row 2: Burn Rate + Top Spend Days
burnBody := fmt.Sprintf("%s %s/day\n%s %s/mo",
labelStyle.Render("Daily:"),
valueStyle.Render(cli.FormatCost(stats.CostPerDay)),
labelStyle.Render("Projected:"),
valueStyle.Render(cli.FormatCost(stats.CostPerDay*30)))
burnCard := components.ContentCard("Burn Rate", burnBody, halves[0])
var spendBody strings.Builder
if len(days) > 0 {
limit := 5
if len(days) < limit {
limit = 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
})
for _, d := range sorted[:limit] {
spendBody.WriteString(fmt.Sprintf("%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])
var b strings.Builder
b.WriteString(components.CardRow([]string{planCard, progressCard}))
b.WriteString("\n")
b.WriteString(components.CardRow([]string{burnCard, spendCard}))
return b.String()
}
// ─── Helpers ────────────────────────────────────────────────────
type tickMsg struct{}
func tickCmd() tea.Cmd {
return tea.Tick(250*time.Millisecond, func(t time.Time) tea.Msg {
return tickMsg{}
})
}
// loadDataCmd starts the data loading pipeline in a background goroutine.
// It streams ProgressMsg updates and a final DataLoadedMsg through sub.
func loadDataCmd(claudeDir string, includeSubagents bool, sub chan tea.Msg) tea.Cmd {
return func() tea.Msg {
go func() {
start := time.Now()
// Progress callback: non-blocking send so workers aren't stalled.
// If the channel is full, we skip this update — the next one catches up.
progressFn := func(current, total int) {
select {
case sub <- ProgressMsg{Current: current, Total: total}:
default:
}
}
// Try cached load
cache, err := storeOpen()
if err == nil {
cr, loadErr := pipeline.LoadWithCache(claudeDir, includeSubagents, cache, progressFn)
cache.Close()
if loadErr == nil {
sub <- DataLoadedMsg{
Sessions: cr.Sessions,
ProjectCount: cr.ProjectCount,
LoadTime: time.Since(start),
FileErrors: cr.FileErrors,
}
return
}
}
// Fallback: uncached load
result, err := pipeline.Load(claudeDir, includeSubagents, progressFn)
if err != nil {
sub <- DataLoadedMsg{LoadTime: time.Since(start)}
return
}
sub <- DataLoadedMsg{
Sessions: result.Sessions,
ProjectCount: result.ProjectCount,
LoadTime: time.Since(start),
FileErrors: result.FileErrors,
}
}()
// Block until the first message (either ProgressMsg or DataLoadedMsg)
return <-sub
}
}
// waitForLoadMsg blocks until the next message arrives from the loader goroutine.
func waitForLoadMsg(sub chan tea.Msg) tea.Cmd {
return func() tea.Msg {
return <-sub
}
}
func storeOpen() (*store.Cache, error) {
return store.Open(pipeline.CachePath())
}
// chartDateLabels builds compact X-axis labels for a chronological date series.
// First label: month abbreviation (e.g. "Jan"). Month boundaries: "Feb 1".
// Everything else (including last): just the day number.
func chartDateLabels(days []model.DailyStats) []string {
n := len(days)
labels := make([]string, n)
prevMonth := time.Month(0)
for i, d := range days {
idx := n - 1 - i // reverse: days are newest-first, labels are oldest-left
m := d.Date.Month()
day := d.Date.Day()
if idx == 0 {
// First label: just the month name
labels[idx] = d.Date.Format("Jan")
} else if m != prevMonth {
// Month boundary: "Feb 1"
labels[idx] = fmt.Sprintf("%s %d", d.Date.Format("Jan"), day)
} else {
labels[idx] = fmt.Sprintf("%d", day)
}
prevMonth = m
}
return labels
}
func shortModel(name string) string {
if len(name) > 7 && name[:7] == "claude-" {
return name[7:]
}
return name
}
func truncStr(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-1]) + "…"
}
func truncateHeight(s string, max int) string {
lines := strings.Split(s, "\n")
if len(lines) <= max {
return s
}
return strings.Join(lines[:max], "\n")
}
func padHeight(s string, h int) string {
lines := strings.Split(s, "\n")
if len(lines) >= h {
return s
}
padding := strings.Repeat("\n", h-len(lines))
return s + padding
}