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 +}