From a04a0065a01997cb9646c935a3d2e5faf2488547 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 19 Feb 2026 15:01:47 -0500 Subject: [PATCH] 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. --- internal/tui/app.go | 1157 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1157 insertions(+) create mode 100644 internal/tui/app.go diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..fad3eae --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,1157 @@ +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 +}