From 35fae37ba45f084119227d146a0ee8ccbe242f1c Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 20 Feb 2026 16:08:06 -0500 Subject: [PATCH] feat: overhaul TUI dashboard with subscription data, new tabs, and setup wizard Major rewrite of the Bubble Tea dashboard, adding live claude.ai integration and splitting the monolithic app.go into focused tab modules. App model (app.go): - Integrate claudeai.Client for live subscription/rate-limit data - Add SubDataMsg and async fetch with periodic refresh (every 5 min) - Add spinner for loading states (charmbracelet/bubbles spinner) - Integrate huh form library for in-TUI setup wizard - Rework tab routing to dispatch to dedicated tab renderers - Add compact layout detection for narrow terminals (<100 cols) TUI setup wizard (setup.go): - Full huh-based setup flow embedded in the TUI (not just CLI) - Three-step form: credentials, preferences (time range + theme), confirm - Pre-populates from existing config, validates session key prefix - Returns to dashboard on completion with config auto-saved New tab modules: - tab_overview.go: summary cards (sessions, prompts, cost, time), daily activity sparkline, rate-limit progress bars from live subscription data - tab_breakdown.go: per-model usage table with calls, input/output tokens, cost, and share percentage; compact mode for narrow terminals - tab_costs.go: cost analysis with daily cost chart, model cost breakdown, cache efficiency metrics, and budget tracking with progress bar Rewritten tabs: - tab_sessions.go: paginated session browser with sort-by-cost/tokens/time, per-session detail view, model usage breakdown per session, improved navigation (j/k, enter/esc, n/p for pages) - tab_settings.go: updated to work with new theme struct and config fields --- internal/tui/app.go | 1020 ++++++++++++--------------------- internal/tui/setup.go | 236 ++++---- internal/tui/tab_breakdown.go | 129 +++++ internal/tui/tab_costs.go | 315 ++++++++++ internal/tui/tab_overview.go | 184 ++++++ internal/tui/tab_sessions.go | 246 ++++++-- internal/tui/tab_settings.go | 29 +- 7 files changed, 1338 insertions(+), 821 deletions(-) create mode 100644 internal/tui/tab_breakdown.go create mode 100644 internal/tui/tab_costs.go create mode 100644 internal/tui/tab_overview.go diff --git a/internal/tui/app.go b/internal/tui/app.go index fad3eae..f78dcc8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1,11 +1,16 @@ +// Package tui provides the interactive Bubble Tea dashboard for cburn. package tui import ( + "context" + "errors" "fmt" "sort" + "strconv" "strings" "time" + "cburn/internal/claudeai" "cburn/internal/cli" "cburn/internal/config" "cburn/internal/model" @@ -14,16 +19,16 @@ import ( "cburn/internal/tui/components" "cburn/internal/tui/theme" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "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 + Sessions []model.SessionStats + LoadTime time.Duration } // ProgressMsg reports file parsing progress. @@ -32,21 +37,33 @@ type ProgressMsg struct { Total int } +// SubDataMsg is sent when the claude.ai subscription data fetch completes. +type SubDataMsg struct { + Data *claudeai.SubscriptionData +} + // App is the root Bubble Tea model. type App struct { // Data - sessions []model.SessionStats - projectCount int - loaded bool - loadTime time.Duration + sessions []model.SessionStats + loaded bool + loadTime time.Duration + + // Subscription data from claude.ai + subData *claudeai.SubscriptionData + subFetching bool + subTicks int // counts ticks for periodic refresh // 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 + filtered []model.SessionStats + stats model.SummaryStats + prevStats model.SummaryStats // previous period for comparison + dailyStats []model.DailyStats + models []model.ModelStats + projects []model.ProjectStats + + // Subagent grouping: parent session ID -> subagent sessions + subagentMap map[string][]model.SessionStats // UI state width int @@ -55,20 +72,21 @@ type App struct { showHelp bool // Filter state - days int - project string - model_ string + days int + project string + modelFilter string // Per-tab state sessState sessionsState settings settingsState - // First-run setup - setup setupState + // First-run setup (huh form) + setupForm *huh.Form + setupVals setupValues needSetup bool // Loading — channel-based progress subscription - loadingDots int + spinner spinner.Model progress int progressMax int loadSub chan tea.Msg // progress + completion messages from loader goroutine @@ -78,27 +96,47 @@ type App struct { includeSubagents bool } +const ( + minTerminalWidth = 80 + compactWidth = 120 + maxContentWidth = 180 +) + // NewApp creates a new TUI app model. func NewApp(claudeDir string, days int, project, modelFilter string, includeSubagents bool) App { needSetup := !config.Exists() + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#3AA99F")) + return App{ claudeDir: claudeDir, days: days, needSetup: needSetup, - setup: newSetupState(), project: project, - model_: modelFilter, + modelFilter: modelFilter, includeSubagents: includeSubagents, + spinner: sp, loadSub: make(chan tea.Msg, 1), } } +// Init implements tea.Model. func (a App) Init() tea.Cmd { - return tea.Batch( + cmds := []tea.Cmd{ loadDataCmd(a.claudeDir, a.includeSubagents, a.loadSub), + a.spinner.Tick, tickCmd(), - ) + } + + // Start subscription data fetch if session key is configured + cfg, _ := config.Load() + if sessionKey := config.GetSessionKey(cfg); sessionKey != "" { + cmds = append(cmds, fetchSubDataCmd(sessionKey)) + } + + return tea.Batch(cmds...) } func (a *App) recompute() { @@ -109,13 +147,13 @@ func (a *App) recompute() { if a.project != "" { filtered = pipeline.FilterByProject(filtered, a.project) } - if a.model_ != "" { - filtered = pipeline.FilterByModel(filtered, a.model_) + if a.modelFilter != "" { + filtered = pipeline.FilterByModel(filtered, a.modelFilter) } - a.filtered = pipeline.FilterByTime(filtered, since, now) + timeFiltered := pipeline.FilterByTime(filtered, since, now) a.stats = pipeline.Aggregate(filtered, since, now) - a.days_ = pipeline.AggregateDays(filtered, since, now) + a.dailyStats = pipeline.AggregateDays(filtered, since, now) a.models = pipeline.AggregateModels(filtered, since, now) a.projects = pipeline.AggregateProjects(filtered, since, now) @@ -123,6 +161,20 @@ func (a *App) recompute() { prevSince := since.AddDate(0, 0, -a.days) a.prevStats = pipeline.Aggregate(filtered, prevSince, since) + // Group subagents under their parent sessions for the sessions tab. + // Other tabs (overview, costs, breakdown) still use full aggregations above. + a.filtered, a.subagentMap = groupSubagents(timeFiltered) + + // Filter out empty sessions (0 API calls — user started Claude but did nothing) + n := 0 + for _, s := range a.filtered { + if s.APICalls > 0 { + a.filtered[n] = s + n++ + } + } + a.filtered = a.filtered[:n] + // 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) @@ -135,14 +187,20 @@ func (a *App) recompute() { if a.sessState.cursor < 0 { a.sessState.cursor = 0 } + a.sessState.detailScroll = 0 } +// Update implements tea.Model. 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 + // Forward to setup form if active + if a.setupForm != nil { + a.setupForm = a.setupForm.WithWidth(msg.Width).WithHeight(msg.Height) + } return a, nil case tea.KeyMsg: @@ -158,12 +216,12 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // First-run setup wizard intercepts all keys - if a.needSetup && a.setup.active { - return a.updateSetup(msg) + if a.needSetup && a.setupForm != nil { + return a.updateSetupForm(msg) } // Settings tab has its own keybindings (text input) - if a.activeTab == 9 && a.settings.editing { + if a.activeTab == 4 && a.settings.editing { return a.updateSettingsInput(msg) } @@ -181,19 +239,26 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Sessions tab has its own keybindings if a.activeTab == 2 { + compactSessions := a.isCompactLayout() switch key { case "q": - if a.sessState.viewMode == sessViewDetail { + if !compactSessions && a.sessState.viewMode == sessViewDetail { a.sessState.viewMode = sessViewSplit return a, nil } return a, tea.Quit case "enter", "f": + if compactSessions { + return a, nil + } if a.sessState.viewMode == sessViewSplit { a.sessState.viewMode = sessViewDetail } return a, nil case "esc": + if compactSessions { + return a, nil + } if a.sessState.viewMode == sessViewDetail { a.sessState.viewMode = sessViewSplit } @@ -201,25 +266,54 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "j", "down": if a.sessState.cursor < len(a.filtered)-1 { a.sessState.cursor++ + a.sessState.detailScroll = 0 } return a, nil case "k", "up": if a.sessState.cursor > 0 { a.sessState.cursor-- + a.sessState.detailScroll = 0 } return a, nil case "g": a.sessState.cursor = 0 a.sessState.offset = 0 + a.sessState.detailScroll = 0 return a, nil case "G": a.sessState.cursor = len(a.filtered) - 1 + a.sessState.detailScroll = 0 + return a, nil + case "J": + a.sessState.detailScroll++ + return a, nil + case "K": + if a.sessState.detailScroll > 0 { + a.sessState.detailScroll-- + } + return a, nil + case "ctrl+d": + halfPage := (a.height - 10) / 2 + if halfPage < 1 { + halfPage = 1 + } + a.sessState.detailScroll += halfPage + return a, nil + case "ctrl+u": + halfPage := (a.height - 10) / 2 + if halfPage < 1 { + halfPage = 1 + } + a.sessState.detailScroll -= halfPage + if a.sessState.detailScroll < 0 { + a.sessState.detailScroll = 0 + } return a, nil } } // Settings tab navigation (non-editing mode) - if a.activeTab == 9 { + if a.activeTab == 4 { switch key { case "j", "down": if a.settings.cursor < settingsFieldCount-1 { @@ -243,26 +337,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Tab navigation switch key { - case "d": + case "o": 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 + a.activeTab = 3 case "x": - a.activeTab = 9 + a.activeTab = 4 case "left": a.activeTab = (a.activeTab - 1 + len(components.Tabs)) % len(components.Tabs) case "right": @@ -272,14 +356,17 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 + a.setupForm = newSetupForm(len(a.sessions), a.claudeDir, &a.setupVals) + if a.width > 0 { + a.setupForm = a.setupForm.WithWidth(a.width).WithHeight(a.height) + } + return a, a.setupForm.Init() } return a, nil @@ -289,103 +376,106 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.progressMax = msg.Total return a, waitForLoadMsg(a.loadSub) - case tickMsg: - a.loadingDots = (a.loadingDots + 1) % 4 - if !a.loaded { - return a, tickCmd() + case SubDataMsg: + a.subData = msg.Data + a.subFetching = false + + // Cache org ID if we got one + if msg.Data != nil && msg.Data.Org.UUID != "" { + cfg, _ := config.Load() + if cfg.ClaudeAI.OrgID != msg.Data.Org.UUID { + cfg.ClaudeAI.OrgID = msg.Data.Org.UUID + _ = config.Save(cfg) + } } return a, nil + + case spinner.TickMsg: + if !a.loaded { + var cmd tea.Cmd + a.spinner, cmd = a.spinner.Update(msg) + return a, cmd + } + return a, nil + + case tickMsg: + a.subTicks++ + + cmds := []tea.Cmd{tickCmd()} + + // Refresh subscription data every 5 minutes (1200 ticks at 250ms) + if a.loaded && !a.subFetching && a.subTicks >= 1200 { + a.subTicks = 0 + cfg, _ := config.Load() + if sessionKey := config.GetSessionKey(cfg); sessionKey != "" { + a.subFetching = true + cmds = append(cmds, fetchSubDataCmd(sessionKey)) + } + } + + return a, tea.Batch(cmds...) + } + + // Forward unhandled messages to the setup form (cursor blinks, etc.) + if a.needSetup && a.setupForm != nil { + return a.updateSetupForm(msg) } return a, nil } -func (a App) updateSetup(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() +func (a App) updateSetupForm(msg tea.Msg) (tea.Model, tea.Cmd) { + form, cmd := a.setupForm.Update(msg) + if f, ok := form.(*huh.Form); ok { + a.setupForm = f + } - 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 - } + if a.setupForm.State == huh.StateCompleted { + _ = a.saveSetupConfig() + a.recompute() + a.needSetup = false + a.setupForm = nil return a, nil } - return a, nil + if a.setupForm.State == huh.StateAborted { + a.needSetup = false + a.setupForm = nil + return a, nil + } + + return a, cmd } func (a App) contentWidth() int { cw := a.width - if cw > 200 { - cw = 200 + if cw > maxContentWidth { + cw = maxContentWidth } return cw } +func (a App) isCompactLayout() bool { + return a.contentWidth() < compactWidth +} + +// View implements tea.Model. func (a App) View() string { if a.width == 0 { return "" } + if a.width < minTerminalWidth { + return a.viewTooNarrow() + } + if !a.loaded { return a.viewLoading() } // First-run setup wizard - if a.needSetup && a.setup.active { - return a.renderSetup() + if a.needSetup && a.setupForm != nil { + return a.setupForm.View() } if a.showHelp { @@ -395,6 +485,22 @@ func (a App) View() string { return a.viewMain() } +func (a App) viewTooNarrow() string { + h := a.height + if h < 5 { + h = 5 + } + + msg := fmt.Sprintf( + "\n Terminal too narrow (%d cols)\n\n cburn needs at least %d columns.\n Current width: %d\n", + a.width, + minTerminalWidth, + a.width, + ) + + return padHeight(truncateHeight(msg, h), h) +} + func (a App) viewLoading() string { t := theme.Active w := a.width @@ -413,7 +519,6 @@ func (a App) viewLoading() string { 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 { @@ -423,13 +528,13 @@ func (a App) viewLoading() string { 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", + fmt.Fprintf(&b, " %s Parsing sessions\n", a.spinner.View()) + fmt.Fprintf(&b, " %s %s/%s\n", components.ProgressBar(pct, barW), cli.FormatNumber(int64(a.progress)), - cli.FormatNumber(int64(a.progressMax)))) + cli.FormatNumber(int64(a.progressMax))) } else { - b.WriteString(fmt.Sprintf(" Scanning sessions%s\n", dots)) + fmt.Fprintf(&b, " %s Scanning sessions\n", a.spinner.View()) } content := b.String() @@ -457,10 +562,12 @@ func (a App) viewHelp() string { 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"}, + {"o/c/s/b", "Overview / Costs / Sessions / Breakdown"}, + {"x", "Settings"}, {"<- / ->", "Previous / Next tab"}, - {"j / k", "Navigate sessions"}, + {"j / k", "Navigate lists"}, + {"J / K", "Scroll detail pane"}, + {"^d / ^u", "Scroll detail half-page"}, {"Enter / f", "Expand session full-screen"}, {"Esc", "Back to split view"}, {"?", "Toggle this help"}, @@ -468,12 +575,12 @@ func (a App) viewHelp() string { } for _, bind := range bindings { - b.WriteString(fmt.Sprintf(" %s %s\n", + fmt.Fprintf(&b, " %s %s\n", keyStyle.Render(fmt.Sprintf("%-12s", bind.key)), - descStyle.Render(bind.desc))) + descStyle.Render(bind.desc)) } - b.WriteString(fmt.Sprintf("\n %s\n", descStyle.Render("Press any key to close"))) + fmt.Fprintf(&b, "\n %s\n", descStyle.Render("Press any key to close")) content := b.String() return padHeight(truncateHeight(content, h), h) @@ -491,8 +598,8 @@ func (a App) viewMain() string { if a.project != "" { filterStr += " | " + a.project } - if a.model_ != "" { - filterStr += " | " + a.model_ + if a.modelFilter != "" { + filterStr += " | " + a.modelFilter } filterStr += "]" header := components.RenderTabBar(a.activeTab, w) + "\n" + @@ -500,7 +607,7 @@ func (a App) viewMain() string { // 2. Render status bar dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds()) - statusBar := components.RenderStatusBar(w, dataAge) + statusBar := components.RenderStatusBar(w, dataAge, a.subData) // 3. Calculate content zone height headerH := lipgloss.Height(header) @@ -514,24 +621,14 @@ func (a App) viewMain() string { var content string switch a.activeTab { case 0: - content = a.renderDashboardTab(cw) + content = a.renderOverviewTab(cw) case 1: content = a.renderCostsTab(cw) case 2: content = a.renderSessionsContent(a.filtered, cw, contentH) case 3: - content = a.renderModelsTab(cw) + content = a.renderBreakdownTab(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) } @@ -547,492 +644,94 @@ func (a App) viewMain() string { 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 ──────────────────────────────────────────────────── +// groupSubagents partitions sessions into parent sessions (with combined metrics) +// and a lookup map of parent ID -> original subagent sessions. +// Subagent tokens, costs, and model breakdowns are merged into their parent. +// Orphaned subagents (no matching parent in the list) are kept as standalone entries. +func groupSubagents(sessions []model.SessionStats) ([]model.SessionStats, map[string][]model.SessionStats) { + subMap := make(map[string][]model.SessionStats) + + // Identify parent IDs + parentIDs := make(map[string]struct{}) + for _, s := range sessions { + if !s.IsSubagent { + parentIDs[s.SessionID] = struct{}{} + } + } + + // Partition: collect subagents under their parent, keep orphans standalone + var parents []model.SessionStats + for _, s := range sessions { + if s.IsSubagent { + if _, ok := parentIDs[s.ParentSession]; ok { + subMap[s.ParentSession] = append(subMap[s.ParentSession], s) + } else { + parents = append(parents, s) // orphan — show standalone + } + } else { + parents = append(parents, s) + } + } + + // Merge subagent metrics into each parent (copy to avoid mutating originals) + for i, p := range parents { + subs, ok := subMap[p.SessionID] + if !ok { + continue + } + + enriched := p + enriched.Models = make(map[string]*model.ModelUsage, len(p.Models)) + for k, v := range p.Models { + cp := *v + enriched.Models[k] = &cp + } + + for _, sub := range subs { + enriched.APICalls += sub.APICalls + enriched.InputTokens += sub.InputTokens + enriched.OutputTokens += sub.OutputTokens + enriched.CacheCreation5mTokens += sub.CacheCreation5mTokens + enriched.CacheCreation1hTokens += sub.CacheCreation1hTokens + enriched.CacheReadTokens += sub.CacheReadTokens + enriched.EstimatedCost += sub.EstimatedCost + + for modelName, mu := range sub.Models { + existing, exists := enriched.Models[modelName] + if !exists { + cp := *mu + enriched.Models[modelName] = &cp + } else { + existing.APICalls += mu.APICalls + existing.InputTokens += mu.InputTokens + existing.OutputTokens += mu.OutputTokens + existing.CacheCreation5mTokens += mu.CacheCreation5mTokens + existing.CacheCreation1hTokens += mu.CacheCreation1hTokens + existing.CacheReadTokens += mu.CacheReadTokens + existing.EstimatedCost += mu.EstimatedCost + } + } + } + + // Recalculate cache hit rate from combined totals + totalCacheInput := enriched.CacheReadTokens + enriched.CacheCreation5mTokens + + enriched.CacheCreation1hTokens + enriched.InputTokens + if totalCacheInput > 0 { + enriched.CacheHitRate = float64(enriched.CacheReadTokens) / float64(totalCacheInput) + } + + parents[i] = enriched + } + + return parents, subMap +} + type tickMsg struct{} func tickCmd() tea.Cmd { - return tea.Tick(250*time.Millisecond, func(t time.Time) tea.Msg { + return tea.Tick(250*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} }) } @@ -1057,13 +756,11 @@ func loadDataCmd(claudeDir string, includeSubagents bool, sub chan tea.Msg) tea. cache, err := storeOpen() if err == nil { cr, loadErr := pipeline.LoadWithCache(claudeDir, includeSubagents, cache, progressFn) - cache.Close() + _ = cache.Close() if loadErr == nil { sub <- DataLoadedMsg{ - Sessions: cr.Sessions, - ProjectCount: cr.ProjectCount, - LoadTime: time.Since(start), - FileErrors: cr.FileErrors, + Sessions: cr.Sessions, + LoadTime: time.Since(start), } return } @@ -1076,10 +773,8 @@ func loadDataCmd(claudeDir string, includeSubagents bool, sub chan tea.Msg) tea. return } sub <- DataLoadedMsg{ - Sessions: result.Sessions, - ProjectCount: result.ProjectCount, - LoadTime: time.Since(start), - FileErrors: result.FileErrors, + Sessions: result.Sessions, + LoadTime: time.Since(start), } }() @@ -1102,22 +797,28 @@ func storeOpen() (*store.Cache, error) { // 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. +// days is sorted newest-first; labels are returned oldest-left. func chartDateLabels(days []model.DailyStats) []string { n := len(days) labels := make([]string, n) - prevMonth := time.Month(0) + // Build chronological date list (oldest first) + dates := make([]time.Time, n) 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) + dates[n-1-i] = d.Date + } + prevMonth := time.Month(0) + for i, dt := range dates { + m := dt.Month() + day := dt.Day() + switch { + case i == 0: + labels[i] = dt.Format("Jan") + case i == n-1: + labels[i] = strconv.Itoa(day) + case m != prevMonth: + labels[i] = dt.Format("Jan") + default: + labels[i] = strconv.Itoa(day) } prevMonth = m } @@ -1131,20 +832,23 @@ func shortModel(name string) string { return name } -func truncStr(s string, max int) string { +func truncStr(s string, limit int) string { + if limit <= 0 { + return "" + } runes := []rune(s) - if len(runes) <= max { + if len(runes) <= limit { return s } - return string(runes[:max-1]) + "…" + return string(runes[:limit-1]) + "…" } -func truncateHeight(s string, max int) string { +func truncateHeight(s string, limit int) string { lines := strings.Split(s, "\n") - if len(lines) <= max { + if len(lines) <= limit { return s } - return strings.Join(lines[:max], "\n") + return strings.Join(lines[:limit], "\n") } func padHeight(s string, h int) string { @@ -1155,3 +859,19 @@ func padHeight(s string, h int) string { padding := strings.Repeat("\n", h-len(lines)) return s + padding } + +// fetchSubDataCmd fetches subscription data from claude.ai in a background goroutine. +func fetchSubDataCmd(sessionKey string) tea.Cmd { + return func() tea.Msg { + client := claudeai.NewClient(sessionKey) + if client == nil { + return SubDataMsg{Data: &claudeai.SubscriptionData{ + FetchedAt: time.Now(), + Error: errors.New("invalid session key format"), + }} + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return SubDataMsg{Data: client.FetchAll(ctx)} + } +} diff --git a/internal/tui/setup.go b/internal/tui/setup.go index 7d4f3d5..ce054f4 100644 --- a/internal/tui/setup.go +++ b/internal/tui/setup.go @@ -2,151 +2,125 @@ package tui import ( "fmt" - "strings" "cburn/internal/config" "cburn/internal/tui/theme" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/huh" ) -// setupState tracks the first-run setup wizard state. -type setupState struct { - active bool - step int // 0=welcome, 1=api key, 2=days, 3=theme, 4=done - apiKeyIn textinput.Model - daysChoice int // index into daysOptions - themeChoice int // index into theme.All - saveErr error // non-nil if config save failed +// setupValues holds the form-bound variables for the setup wizard. +type setupValues struct { + sessionKey string + adminKey string + days int + theme string } -var daysOptions = []struct { - label string - value int -}{ - {"7 days", 7}, - {"30 days", 30}, - {"90 days", 90}, -} - -func newSetupState() setupState { - ti := textinput.New() - ti.Placeholder = "sk-ant-admin-... (or press Enter to skip)" - ti.CharLimit = 256 - ti.Width = 50 - ti.EchoMode = textinput.EchoPassword - ti.EchoCharacter = '*' - - return setupState{ - apiKeyIn: ti, - daysChoice: 1, // default 30 days - } -} - -func (a App) renderSetup() string { - t := theme.Active - ss := a.setup - - titleStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) - labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) - valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) - accentStyle := lipgloss.NewStyle().Foreground(t.Accent) - greenStyle := lipgloss.NewStyle().Foreground(t.Green) - - var b strings.Builder - b.WriteString("\n\n") - b.WriteString(titleStyle.Render(" Welcome to cburn!")) - b.WriteString("\n\n") - - b.WriteString(labelStyle.Render(fmt.Sprintf(" Found %s sessions in %s", - valueStyle.Render(fmt.Sprintf("%d", len(a.sessions))), - valueStyle.Render(a.claudeDir)))) - b.WriteString("\n\n") - - switch ss.step { - case 0: // Welcome - b.WriteString(valueStyle.Render(" Let's set up a few things.")) - b.WriteString("\n\n") - b.WriteString(accentStyle.Render(" Press Enter to continue")) - - case 1: // API key - b.WriteString(valueStyle.Render(" 1. Anthropic Admin API key")) - b.WriteString("\n") - b.WriteString(labelStyle.Render(" For real cost data from the billing API.")) - b.WriteString("\n") - b.WriteString(labelStyle.Render(" Get one at console.anthropic.com > Settings > Admin API keys")) - b.WriteString("\n\n") - b.WriteString(" ") - b.WriteString(ss.apiKeyIn.View()) - b.WriteString("\n\n") - b.WriteString(labelStyle.Render(" Press Enter to continue (leave blank to skip)")) - - case 2: // Default days - b.WriteString(valueStyle.Render(" 2. Default time range")) - b.WriteString("\n\n") - for i, opt := range daysOptions { - if i == ss.daysChoice { - b.WriteString(accentStyle.Render(fmt.Sprintf(" (o) %s", opt.label))) - } else { - b.WriteString(labelStyle.Render(fmt.Sprintf(" ( ) %s", opt.label))) - } - b.WriteString("\n") - } - b.WriteString("\n") - b.WriteString(labelStyle.Render(" j/k to select, Enter to confirm")) - - case 3: // Theme - b.WriteString(valueStyle.Render(" 3. Color theme")) - b.WriteString("\n\n") - for i, th := range theme.All { - if i == ss.themeChoice { - b.WriteString(accentStyle.Render(fmt.Sprintf(" (o) %s", th.Name))) - } else { - b.WriteString(labelStyle.Render(fmt.Sprintf(" ( ) %s", th.Name))) - } - b.WriteString("\n") - } - b.WriteString("\n") - b.WriteString(labelStyle.Render(" j/k to select, Enter to confirm")) - - case 4: // Done - if ss.saveErr != nil { - warnStyle := lipgloss.NewStyle().Foreground(t.Orange) - b.WriteString(warnStyle.Render(fmt.Sprintf(" Could not save config: %s", ss.saveErr))) - b.WriteString("\n") - b.WriteString(labelStyle.Render(" Settings will apply for this session only.")) - } else { - b.WriteString(greenStyle.Render(" All set!")) - b.WriteString("\n\n") - b.WriteString(labelStyle.Render(" Saved to ~/.config/cburn/config.toml")) - b.WriteString("\n") - b.WriteString(labelStyle.Render(" Run `cburn setup` anytime to reconfigure.")) - } - b.WriteString("\n\n") - b.WriteString(accentStyle.Render(" Press Enter to launch the dashboard")) - } - - return b.String() -} - -func (a *App) saveSetupConfig() { +// newSetupForm builds the huh form for first-run configuration. +func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.Form { cfg, _ := config.Load() - apiKey := strings.TrimSpace(a.setup.apiKeyIn.Value()) - if apiKey != "" { - cfg.AdminAPI.APIKey = apiKey + // Pre-populate defaults + vals.days = cfg.General.DefaultDays + if vals.days == 0 { + vals.days = 30 + } + vals.theme = cfg.Appearance.Theme + if vals.theme == "" { + vals.theme = "flexoki-dark" } - if a.setup.daysChoice >= 0 && a.setup.daysChoice < len(daysOptions) { - cfg.General.DefaultDays = daysOptions[a.setup.daysChoice].value - a.days = cfg.General.DefaultDays + // Build welcome text + welcomeDesc := "Let's configure your dashboard." + if numSessions > 0 { + welcomeDesc = fmt.Sprintf("Found %d sessions in %s.", numSessions, claudeDir) } - if a.setup.themeChoice >= 0 && a.setup.themeChoice < len(theme.All) { - cfg.Appearance.Theme = theme.All[a.setup.themeChoice].Name - theme.SetActive(cfg.Appearance.Theme) + // Placeholder text for key fields + sessionPlaceholder := "sk-ant-sid... (Enter to skip)" + if key := config.GetSessionKey(cfg); key != "" { + sessionPlaceholder = maskKey(key) + " (Enter to keep)" + } + adminPlaceholder := "sk-ant-admin-... (Enter to skip)" + if key := config.GetAdminAPIKey(cfg); key != "" { + adminPlaceholder = maskKey(key) + " (Enter to keep)" } - a.setup.saveErr = config.Save(cfg) + // Build theme options from the registered theme list + themeOpts := make([]huh.Option[string], len(theme.All)) + for i, t := range theme.All { + themeOpts[i] = huh.NewOption(t.Name, t.Name) + } + + return huh.NewForm( + huh.NewGroup( + huh.NewNote(). + Title("Welcome to cburn"). + Description(welcomeDesc). + Next(true). + NextLabel("Start"), + ), + + huh.NewGroup( + huh.NewInput(). + Title("Claude.ai session key"). + Description("For rate-limit and subscription data.\nclaude.ai > DevTools > Application > Cookies > sessionKey"). + Placeholder(sessionPlaceholder). + EchoMode(huh.EchoModePassword). + Value(&vals.sessionKey), + + huh.NewInput(). + Title("Anthropic Admin API key"). + Description("For real cost data from the billing API."). + Placeholder(adminPlaceholder). + EchoMode(huh.EchoModePassword). + Value(&vals.adminKey), + ), + + huh.NewGroup( + huh.NewSelect[int](). + Title("Default time range"). + Options( + huh.NewOption("7 days", 7), + huh.NewOption("30 days", 30), + huh.NewOption("90 days", 90), + ). + Value(&vals.days), + + huh.NewSelect[string](). + Title("Color theme"). + Options(themeOpts...). + Value(&vals.theme), + ), + ).WithTheme(huh.ThemeDracula()).WithShowHelp(false) +} + +// saveSetupConfig persists the setup wizard values to the config file. +func (a *App) saveSetupConfig() error { + cfg, _ := config.Load() + + if a.setupVals.sessionKey != "" { + cfg.ClaudeAI.SessionKey = a.setupVals.sessionKey + } + if a.setupVals.adminKey != "" { + cfg.AdminAPI.APIKey = a.setupVals.adminKey + } + cfg.General.DefaultDays = a.setupVals.days + a.days = a.setupVals.days + + cfg.Appearance.Theme = a.setupVals.theme + theme.SetActive(a.setupVals.theme) + + return config.Save(cfg) +} + +func maskKey(key string) string { + if len(key) > 16 { + return key[:8] + "..." + key[len(key)-4:] + } + if len(key) > 4 { + return key[:4] + "..." + } + return "****" } diff --git a/internal/tui/tab_breakdown.go b/internal/tui/tab_breakdown.go new file mode 100644 index 0000000..6554b53 --- /dev/null +++ b/internal/tui/tab_breakdown.go @@ -0,0 +1,129 @@ +package tui + +import ( + "fmt" + "strings" + + "cburn/internal/cli" + "cburn/internal/tui/components" + "cburn/internal/tui/theme" + + "github.com/charmbracelet/lipgloss" +) + +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 + if a.isCompactLayout() { + shareW := 6 + costW := 10 + callW := 8 + nameW = innerW - shareW - costW - callW - 3 + if nameW < 10 { + nameW = 10 + } + tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s %6s", nameW, "Model", "Calls", "Cost", "Share"))) + tableBody.WriteString("\n") + + for _, ms := range models { + tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %8s %10s %5.1f%%", + nameW, + truncStr(shortModel(ms.Model), nameW), + cli.FormatNumber(int64(ms.APICalls)), + cli.FormatCost(ms.EstimatedCost), + ms.SharePercent))) + tableBody.WriteString("\n") + } + } else { + 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, + truncStr(shortModel(ms.Model), nameW), + 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) +} + +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 + if a.isCompactLayout() { + costW := 10 + sessW := 6 + nameW = innerW - costW - sessW - 2 + if nameW < 12 { + nameW = 12 + } + tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %6s %10s", nameW, "Project", "Sess.", "Cost"))) + tableBody.WriteString("\n") + + for _, ps := range projects { + tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %6d %10s", + nameW, + truncStr(ps.Project, nameW), + ps.Sessions, + cli.FormatCost(ps.EstimatedCost)))) + tableBody.WriteString("\n") + } + } else { + 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) +} + +func (a App) renderBreakdownTab(cw int) string { + var b strings.Builder + b.WriteString(a.renderModelsTab(cw)) + b.WriteString("\n") + b.WriteString(a.renderProjectsTab(cw)) + return b.String() +} diff --git a/internal/tui/tab_costs.go b/internal/tui/tab_costs.go new file mode 100644 index 0000000..19ab6de --- /dev/null +++ b/internal/tui/tab_costs.go @@ -0,0 +1,315 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + "cburn/internal/claudeai" + "cburn/internal/cli" + "cburn/internal/config" + "cburn/internal/model" + "cburn/internal/tui/components" + "cburn/internal/tui/theme" + + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/lipgloss" +) + +func (a App) renderCostsTab(cw int) string { + t := theme.Active + stats := a.stats + models := a.models + days := a.dailyStats + + var b strings.Builder + + // Row 0: Subscription rate limits (live data from claude.ai) + b.WriteString(a.renderSubscriptionCard(cw)) + + // Row 1: Cost metric cards + savingsMultiplier := 0.0 + if stats.EstimatedCost > 0 { + savingsMultiplier = stats.CacheSavings / stats.EstimatedCost + } + costCards := []struct{ Label, Value, Delta string }{ + {"Total Cost", cli.FormatCost(stats.EstimatedCost), cli.FormatCost(stats.CostPerDay) + "/day"}, + {"Cache Savings", cli.FormatCost(stats.CacheSavings), fmt.Sprintf("%.1fx cost", savingsMultiplier)}, + {"Projected", cli.FormatCost(stats.CostPerDay*30) + "/mo", cli.FormatCost(stats.CostPerDay) + "/day"}, + {"Cache Rate", cli.FormatPercent(stats.CacheHitRate), ""}, + } + b.WriteString(components.MetricCardRow(costCards, cw)) + b.WriteString("\n") + + // Row 2: Cost breakdown table + innerW := components.CardInnerWidth(cw) + fixedCols := 10 + 10 + 10 + 10 + gaps := 4 + nameW := innerW - fixedCols - gaps + if nameW < 14 { + nameW = 14 + } + + headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) + rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) + mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted) + labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) + valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) + + var tableBody strings.Builder + if a.isCompactLayout() { + totalW := 10 + nameW = innerW - totalW - 1 + if nameW < 10 { + nameW = 10 + } + tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %10s", nameW, "Model", "Total"))) + tableBody.WriteString("\n") + tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1))) + tableBody.WriteString("\n") + + for _, ms := range models { + tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s", + nameW, + truncStr(shortModel(ms.Model), nameW), + cli.FormatCost(ms.EstimatedCost)))) + tableBody.WriteString("\n") + } + tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1))) + } else { + tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s", nameW, "Model", "Input", "Output", "Cache", "Total"))) + tableBody.WriteString("\n") + tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW))) + tableBody.WriteString("\n") + + for _, 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, + truncStr(shortModel(ms.Model), nameW), + 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) + b.WriteString(components.ContentCard(title, tableBody.String(), cw)) + b.WriteString("\n") + + // Row 3: Budget progress + Top Spend Days + halves := components.LayoutRow(cw, 2) + + // Use real overage data if available, otherwise show placeholder + var progressCard string + if a.subData != nil && a.subData.Overage != nil && a.subData.Overage.IsEnabled { + ol := a.subData.Overage + pct := 0.0 + if ol.MonthlyCreditLimit > 0 { + pct = ol.UsedCredits / ol.MonthlyCreditLimit + } + + barW := components.CardInnerWidth(halves[0]) - 10 + if barW < 10 { + barW = 10 + } + bar := progress.New( + progress.WithSolidFill(components.ColorForPct(pct)), + progress.WithWidth(barW), + progress.WithoutPercentage(), + ) + bar.EmptyColor = string(t.TextDim) + + var body strings.Builder + body.WriteString(bar.ViewAs(pct)) + fmt.Fprintf(&body, " %.0f%%\n", pct*100) + fmt.Fprintf(&body, "%s %s / %s %s", + labelStyle.Render("Used"), + valueStyle.Render(fmt.Sprintf("$%.2f", ol.UsedCredits)), + valueStyle.Render(fmt.Sprintf("$%.2f", ol.MonthlyCreditLimit)), + labelStyle.Render(ol.Currency)) + + progressCard = components.ContentCard("Overage Spend", body.String(), halves[0]) + } else { + ceiling := 200.0 + pct := stats.EstimatedCost / ceiling + progressInnerW := components.CardInnerWidth(halves[0]) + progressBody := components.ProgressBar(pct, progressInnerW-10) + "\n" + + labelStyle.Render("flat-rate plan ceiling") + progressCard = components.ContentCard("Budget Progress", progressBody, halves[0]) + } + + var spendBody strings.Builder + if len(days) > 0 { + spendLimit := 5 + if len(days) < spendLimit { + spendLimit = len(days) + } + sorted := make([]model.DailyStats, len(days)) + copy(sorted, days) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Date.After(sorted[j].Date) + }) + for _, d := range sorted[:spendLimit] { + fmt.Fprintf(&spendBody, "%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]) + + if a.isCompactLayout() { + b.WriteString(progressCard) + b.WriteString("\n") + b.WriteString(components.ContentCard("Top Spend Days", spendBody.String(), cw)) + } else { + b.WriteString(components.CardRow([]string{progressCard, spendCard})) + } + b.WriteString("\n") + + // Row 4: Efficiency metrics + tokPerPrompt := int64(0) + outPerPrompt := int64(0) + if stats.TotalPrompts > 0 { + tokPerPrompt = (stats.InputTokens + stats.OutputTokens) / int64(stats.TotalPrompts) + outPerPrompt = stats.OutputTokens / int64(stats.TotalPrompts) + } + promptsPerSess := 0.0 + if stats.TotalSessions > 0 { + promptsPerSess = float64(stats.TotalPrompts) / float64(stats.TotalSessions) + } + + effMetrics := []struct{ name, 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 effBody strings.Builder + for _, m := range effMetrics { + effBody.WriteString(rowStyle.Render(fmt.Sprintf("%-20s %10s", m.name, m.value))) + effBody.WriteString("\n") + } + + b.WriteString(components.ContentCard("Efficiency", effBody.String(), cw)) + + return b.String() +} + +// renderSubscriptionCard renders the rate limit + overage card at the top of the costs tab. +func (a App) renderSubscriptionCard(cw int) string { + t := theme.Active + hintStyle := lipgloss.NewStyle().Foreground(t.TextDim) + + // No session key configured + if a.subData == nil && !a.subFetching { + cfg, _ := config.Load() + if config.GetSessionKey(cfg) == "" { + return components.ContentCard("Subscription", + hintStyle.Render("Configure session key in Settings to see rate limits"), + cw) + "\n" + } + // Key configured but no data yet (initial fetch in progress) + return components.ContentCard("Subscription", + hintStyle.Render("Fetching rate limits..."), + cw) + "\n" + } + + // Still fetching + if a.subData == nil { + return components.ContentCard("Subscription", + hintStyle.Render("Fetching rate limits..."), + cw) + "\n" + } + + // Error with no usable data + if a.subData.Usage == nil && a.subData.Error != nil { + warnStyle := lipgloss.NewStyle().Foreground(t.Orange) + return components.ContentCard("Subscription", + warnStyle.Render(fmt.Sprintf("Error: %s", a.subData.Error)), + cw) + "\n" + } + + // No usage data at all + if a.subData.Usage == nil { + return "" + } + + innerW := components.CardInnerWidth(cw) + labelW := 13 // enough for "Weekly Sonnet" + barW := innerW - labelW - 16 // label + bar + pct(5) + countdown(~10) + gaps + if barW < 10 { + barW = 10 + } + + var body strings.Builder + + type windowRow struct { + label string + window *claudeai.ParsedWindow + } + + rows := []windowRow{} + if w := a.subData.Usage.FiveHour; w != nil { + rows = append(rows, windowRow{"5-hour", w}) + } + if w := a.subData.Usage.SevenDay; w != nil { + rows = append(rows, windowRow{"Weekly", w}) + } + if w := a.subData.Usage.SevenDayOpus; w != nil { + rows = append(rows, windowRow{"Weekly Opus", w}) + } + if w := a.subData.Usage.SevenDaySonnet; w != nil { + rows = append(rows, windowRow{"Weekly Sonnet", w}) + } + + for i, r := range rows { + body.WriteString(components.RateLimitBar(r.label, r.window.Pct, r.window.ResetsAt, labelW, barW)) + if i < len(rows)-1 { + body.WriteString("\n") + } + } + + // Overage line if enabled + if ol := a.subData.Overage; ol != nil && ol.IsEnabled && ol.MonthlyCreditLimit > 0 { + pct := ol.UsedCredits / ol.MonthlyCreditLimit + body.WriteString("\n") + body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Render(strings.Repeat("─", innerW))) + body.WriteString("\n") + body.WriteString(components.RateLimitBar("Overage", + pct, time.Time{}, labelW, barW)) + + spendStyle := lipgloss.NewStyle().Foreground(t.TextDim) + body.WriteString(spendStyle.Render( + fmt.Sprintf(" $%.2f / $%.2f", ol.UsedCredits, ol.MonthlyCreditLimit))) + } + + // Fetch timestamp + if !a.subData.FetchedAt.IsZero() { + body.WriteString("\n") + tsStyle := lipgloss.NewStyle().Foreground(t.TextDim) + body.WriteString(tsStyle.Render("Updated " + a.subData.FetchedAt.Format("3:04 PM"))) + } + + title := "Subscription" + if a.subData.Org.Name != "" { + title = "Subscription — " + a.subData.Org.Name + } + + return components.ContentCard(title, body.String(), cw) + "\n" +} diff --git a/internal/tui/tab_overview.go b/internal/tui/tab_overview.go new file mode 100644 index 0000000..badb61b --- /dev/null +++ b/internal/tui/tab_overview.go @@ -0,0 +1,184 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "cburn/internal/cli" + "cburn/internal/pipeline" + "cburn/internal/tui/components" + "cburn/internal/tui/theme" + + "github.com/charmbracelet/lipgloss" +) + +func (a App) renderOverviewTab(cw int) string { + t := theme.Active + stats := a.stats + prev := a.prevStats + days := a.dailyStats + models := a.models + var b strings.Builder + + // Row 1: Metric cards + costDelta := "" + if prev.CostPerDay > 0 { + costDelta = fmt.Sprintf("%s/day (%s)", cli.FormatCost(stats.CostPerDay), cli.FormatDelta(stats.CostPerDay, prev.CostPerDay)) + } else { + costDelta = cli.FormatCost(stats.CostPerDay) + "/day" + } + + sessDelta := "" + if prev.SessionsPerDay > 0 { + pctChange := (stats.SessionsPerDay - prev.SessionsPerDay) / prev.SessionsPerDay * 100 + sessDelta = fmt.Sprintf("%.1f/day (%+.0f%%)", stats.SessionsPerDay, pctChange) + } else { + sessDelta = fmt.Sprintf("%.1f/day", stats.SessionsPerDay) + } + + cacheDelta := "" + if prev.CacheHitRate > 0 { + ppDelta := (stats.CacheHitRate - prev.CacheHitRate) * 100 + cacheDelta = fmt.Sprintf("saved %s (%+.1fpp)", cli.FormatCost(stats.CacheSavings), ppDelta) + } else { + cacheDelta = "saved " + cli.FormatCost(stats.CacheSavings) + } + + cards := []struct{ Label, Value, Delta string }{ + {"Tokens", cli.FormatTokens(stats.TotalBilledTokens), cli.FormatTokens(stats.TokensPerDay) + "/day"}, + {"Sessions", cli.FormatNumber(int64(stats.TotalSessions)), sessDelta}, + {"Cost", cli.FormatCost(stats.EstimatedCost), costDelta}, + {"Cache", cli.FormatPercent(stats.CacheHitRate), cacheDelta}, + } + b.WriteString(components.MetricCardRow(cards, cw)) + b.WriteString("\n") + + // Row 2: Daily token usage chart + if len(days) > 0 { + chartVals := make([]float64, len(days)) + chartLabels := chartDateLabels(days) + for i, d := range days { + chartVals[len(days)-1-i] = float64(d.InputTokens + d.OutputTokens + d.CacheCreation5m + d.CacheCreation1h) + } + chartInnerW := components.CardInnerWidth(cw) + b.WriteString(components.ContentCard( + fmt.Sprintf("Daily Token Usage (%dd)", a.days), + components.BarChart(chartVals, chartLabels, t.Blue, chartInnerW, 10), + cw, + )) + b.WriteString("\n") + } + + // Row 3: Model Split + Activity Patterns + halves := components.LayoutRow(cw, 2) + innerW := components.CardInnerWidth(halves[0]) + + nameStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) + barStyle := lipgloss.NewStyle().Foreground(t.Accent) + pctStyle := lipgloss.NewStyle().Foreground(t.TextDim) + + var modelBody strings.Builder + limit := 5 + if len(models) < limit { + limit = len(models) + } + maxShare := 0.0 + for _, ms := range models[:limit] { + if ms.SharePercent > maxShare { + maxShare = ms.SharePercent + } + } + nameW := innerW / 3 + if nameW < 10 { + nameW = 10 + } + barMaxLen := innerW - nameW - 8 + if barMaxLen < 1 { + barMaxLen = 1 + } + for _, ms := range models[:limit] { + barLen := 0 + if maxShare > 0 { + barLen = int(ms.SharePercent / maxShare * float64(barMaxLen)) + } + fmt.Fprintf(&modelBody, "%s %s %s\n", + nameStyle.Render(fmt.Sprintf("%-*s", nameW, shortModel(ms.Model))), + barStyle.Render(strings.Repeat("█", barLen)), + pctStyle.Render(fmt.Sprintf("%.0f%%", ms.SharePercent))) + } + + // Compact activity: aggregate prompts into 4-hour buckets + now := time.Now() + since := now.AddDate(0, 0, -a.days) + hours := pipeline.AggregateHourly(a.filtered, since, now) + + type actBucket struct { + label string + total int + color lipgloss.Color + } + buckets := []actBucket{ + {"Night 00-03", 0, t.Red}, + {"Early 04-07", 0, t.Yellow}, + {"Morning 08-11", 0, t.Green}, + {"Midday 12-15", 0, t.Green}, + {"Evening 16-19", 0, t.Green}, + {"Late 20-23", 0, t.Yellow}, + } + for _, h := range hours { + idx := h.Hour / 4 + if idx >= 6 { + idx = 5 + } + buckets[idx].total += h.Prompts + } + + maxBucket := 0 + for _, bk := range buckets { + if bk.total > maxBucket { + maxBucket = bk.total + } + } + + actInnerW := components.CardInnerWidth(halves[1]) + + // Compute number column width from actual data so bars never overflow. + maxNumW := 5 + for _, bk := range buckets { + if nw := len(cli.FormatNumber(int64(bk.total))); nw > maxNumW { + maxNumW = nw + } + } + // prefix = 13 (label) + 1 (space) + maxNumW (number) + 1 (space) + actBarMax := actInnerW - 15 - maxNumW + if actBarMax < 1 { + actBarMax = 1 + } + + numStyle := lipgloss.NewStyle().Foreground(t.TextMuted) + var actBody strings.Builder + for _, bk := range buckets { + bl := 0 + if maxBucket > 0 { + bl = bk.total * actBarMax / maxBucket + } + bar := lipgloss.NewStyle().Foreground(bk.color).Render(strings.Repeat("█", bl)) + fmt.Fprintf(&actBody, "%s %s %s\n", + numStyle.Render(bk.label), + numStyle.Render(fmt.Sprintf("%*s", maxNumW, cli.FormatNumber(int64(bk.total)))), + bar) + } + + modelCard := components.ContentCard("Model Split", modelBody.String(), halves[0]) + actCard := components.ContentCard("Activity", actBody.String(), halves[1]) + if a.isCompactLayout() { + b.WriteString(components.ContentCard("Model Split", modelBody.String(), cw)) + b.WriteString("\n") + b.WriteString(components.ContentCard("Activity", actBody.String(), cw)) + } else { + b.WriteString(components.CardRow([]string{modelCard, actCard})) + } + + return b.String() +} diff --git a/internal/tui/tab_sessions.go b/internal/tui/tab_sessions.go index 40e47e2..da1a4c4 100644 --- a/internal/tui/tab_sessions.go +++ b/internal/tui/tab_sessions.go @@ -22,9 +22,10 @@ const ( // sessionsState holds the sessions tab state. type sessionsState struct { - cursor int - viewMode int - offset int // scroll offset for the list + cursor int + viewMode int + offset int // scroll offset for the list + detailScroll int // scroll offset for the detail pane } func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) string { @@ -35,6 +36,11 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str return components.ContentCard("Sessions", lipgloss.NewStyle().Foreground(t.TextMuted).Render("No sessions found"), cw) } + // Force single-pane detail mode in compact layouts. + if cw < compactWidth { + return a.renderSessionDetail(filtered, cw, h) + } + switch ss.viewMode { case sessViewDetail: return a.renderSessionDetail(filtered, cw, h) @@ -51,9 +57,17 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin return "" } - leftW := cw / 3 - if leftW < 30 { - leftW = 30 + leftW := cw / 4 + if leftW < 36 { + leftW = 36 + } + minRightW := 50 + maxLeftW := cw - minRightW + if maxLeftW < 20 { + return a.renderSessionDetail(sessions, cw, h) + } + if leftW > maxLeftW { + leftW = maxLeftW } rightW := cw - leftW @@ -91,27 +105,42 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin startStr = s.StartTime.Local().Format("Jan 02 15:04") } dur := cli.FormatDuration(s.DurationSecs) + costStr := cli.FormatCost(s.EstimatedCost) - line := fmt.Sprintf("%-13s %s", startStr, dur) - if len(line) > leftInner { - line = line[:leftInner] + // Build left portion (date + duration) and right-align cost + leftPart := fmt.Sprintf("%-13s %s", startStr, dur) + padN := leftInner - len(leftPart) - len(costStr) + if padN < 1 { + padN = 1 } if i == ss.cursor { - leftBody.WriteString(selectedStyle.Render(line)) + fullLine := leftPart + strings.Repeat(" ", padN) + costStr + // Pad to full width for continuous highlight background + if len(fullLine) < leftInner { + fullLine += strings.Repeat(" ", leftInner-len(fullLine)) + } + leftBody.WriteString(selectedStyle.Render(fullLine)) } else { - leftBody.WriteString(rowStyle.Render(line)) + leftBody.WriteString( + mutedStyle.Render(fmt.Sprintf("%-13s", startStr)) + " " + + rowStyle.Render(dur) + + strings.Repeat(" ", padN) + + mutedStyle.Render(costStr)) } leftBody.WriteString("\n") } leftCard := components.ContentCard(fmt.Sprintf("Sessions [%dd]", a.days), leftBody.String(), leftW) - // Right pane: full session detail + // Right pane: full session detail with scroll support sel := sessions[ss.cursor] rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle) - titleStr := fmt.Sprintf("Session %s", shortID(sel.SessionID)) + // Apply detail scroll offset + rightBody = a.applyDetailScroll(rightBody, h-4) // card border (2) + title (1) + gap (1) + + titleStr := "Session " + shortID(sel.SessionID) rightCard := components.ContentCard(titleStr, rightBody, rightW) return components.CardRow([]string{leftCard, rightCard}) @@ -130,8 +159,9 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted) body := a.renderDetailBody(sel, cw, headerStyle, mutedStyle) + body = a.applyDetailScroll(body, h-4) - title := fmt.Sprintf("Session %s", shortID(sel.SessionID)) + title := "Session " + shortID(sel.SessionID) return components.ContentCard(title, body, cw) } @@ -159,27 +189,28 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS timeStr += " - " + sel.EndTime.Local().Format("15:04:05") } timeStr += " " + sel.StartTime.Local().Format("MST") - body.WriteString(fmt.Sprintf("%s %s (%s)\n", + fmt.Fprintf(&body, "%s %s (%s)\n", labelStyle.Render("Duration:"), valueStyle.Render(durStr), - mutedStyle.Render(timeStr))) + mutedStyle.Render(timeStr)) } ratio := 0.0 if sel.UserMessages > 0 { ratio = float64(sel.APICalls) / float64(sel.UserMessages) } - body.WriteString(fmt.Sprintf("%s %s %s %s %s %.1fx\n\n", + fmt.Fprintf(&body, "%s %s %s %s %s %.1fx\n\n", labelStyle.Render("Prompts:"), valueStyle.Render(cli.FormatNumber(int64(sel.UserMessages))), labelStyle.Render("API Calls:"), valueStyle.Render(cli.FormatNumber(int64(sel.APICalls))), - labelStyle.Render("Ratio:"), ratio)) + labelStyle.Render("Ratio:"), ratio) // Token breakdown table body.WriteString(headerStyle.Render("TOKEN BREAKDOWN")) body.WriteString("\n") - body.WriteString(headerStyle.Render(fmt.Sprintf("%-20s %12s %10s", "Type", "Tokens", "Cost"))) + typeW, tokW, costW, tableW := tokenTableLayout(innerW) + body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %*s %*s", typeW, "Type", tokW, "Tokens", costW, "Cost"))) body.WriteString("\n") - body.WriteString(mutedStyle.Render(strings.Repeat("─", 44))) + body.WriteString(mutedStyle.Render(strings.Repeat("─", tableW))) body.WriteString("\n") // Calculate per-type costs (aggregate across models) @@ -218,32 +249,53 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS if r.tokens == 0 { continue } - body.WriteString(valueStyle.Render(fmt.Sprintf("%-20s %12s %10s", - r.typ, + body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %*s %*s", + typeW, + truncStr(r.typ, typeW), + tokW, cli.FormatTokens(r.tokens), + costW, cli.FormatCost(r.cost)))) body.WriteString("\n") } - body.WriteString(mutedStyle.Render(strings.Repeat("─", 44))) + body.WriteString(mutedStyle.Render(strings.Repeat("─", tableW))) body.WriteString("\n") - body.WriteString(fmt.Sprintf("%-20s %12s %10s\n", + fmt.Fprintf(&body, "%-*s %*s %*s\n", + typeW, valueStyle.Render("Net Cost"), + tokW, "", - greenStyle.Render(cli.FormatCost(sel.EstimatedCost)))) - body.WriteString(fmt.Sprintf("%-20s %12s %10s\n", + costW, + greenStyle.Render(cli.FormatCost(sel.EstimatedCost))) + fmt.Fprintf(&body, "%-*s %*s %*s\n", + typeW, labelStyle.Render("Cache Savings"), + tokW, "", - greenStyle.Render(cli.FormatCost(savings)))) + costW, + greenStyle.Render(cli.FormatCost(savings))) // Model breakdown if len(sel.Models) > 0 { body.WriteString("\n") body.WriteString(headerStyle.Render("API CALLS BY MODEL")) body.WriteString("\n") - body.WriteString(headerStyle.Render(fmt.Sprintf("%-14s %7s %10s %10s %8s", "Model", "Calls", "Input", "Output", "Cost"))) - body.WriteString("\n") - body.WriteString(mutedStyle.Render(strings.Repeat("─", 52))) + compactModelTable := innerW < 60 + if compactModelTable { + modelW := innerW - 7 - 1 - 8 + if modelW < 8 { + modelW = 8 + } + body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %8s", modelW, "Model", "Calls", "Cost"))) + body.WriteString("\n") + body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+8+2))) + } else { + modelW := 14 + body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", modelW, "Model", "Calls", "Input", "Output", "Cost"))) + body.WriteString("\n") + body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+10+10+8+4))) + } body.WriteString("\n") // Sort model names for deterministic display order @@ -255,12 +307,26 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS for _, modelName := range modelNames { mu := sel.Models[modelName] - body.WriteString(valueStyle.Render(fmt.Sprintf("%-14s %7s %10s %10s %8s", - shortModel(modelName), - cli.FormatNumber(int64(mu.APICalls)), - cli.FormatTokens(mu.InputTokens), - cli.FormatTokens(mu.OutputTokens), - cli.FormatCost(mu.EstimatedCost)))) + if innerW < 60 { + modelW := innerW - 7 - 1 - 8 + if modelW < 8 { + modelW = 8 + } + body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %8s", + modelW, + truncStr(shortModel(modelName), modelW), + cli.FormatNumber(int64(mu.APICalls)), + cli.FormatCost(mu.EstimatedCost)))) + } else { + modelW := 14 + body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", + modelW, + truncStr(shortModel(modelName), modelW), + cli.FormatNumber(int64(mu.APICalls)), + cli.FormatTokens(mu.InputTokens), + cli.FormatTokens(mu.OutputTokens), + cli.FormatCost(mu.EstimatedCost)))) + } body.WriteString("\n") } } @@ -271,8 +337,57 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS body.WriteString("\n") } + // Subagent drill-down + if subs := a.subagentMap[sel.SessionID]; len(subs) > 0 { + body.WriteString("\n") + body.WriteString(headerStyle.Render(fmt.Sprintf("SUBAGENTS (%d)", len(subs)))) + body.WriteString("\n") + + nameW := innerW - 8 - 10 - 2 + if nameW < 10 { + nameW = 10 + } + body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s", nameW, "Agent", "Duration", "Cost"))) + body.WriteString("\n") + body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2))) + body.WriteString("\n") + + var totalSubCost float64 + var totalSubDur int64 + for _, sub := range subs { + // Extract short agent name from session ID (e.g., "uuid/agent-acompact-7b10e8" -> "acompact-7b10e8") + agentName := sub.SessionID + if idx := strings.LastIndex(agentName, "/"); idx >= 0 { + agentName = agentName[idx+1:] + } + agentName = strings.TrimPrefix(agentName, "agent-") + + body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s", + nameW, + truncStr(agentName, nameW), + cli.FormatDuration(sub.DurationSecs), + cli.FormatCost(sub.EstimatedCost)))) + body.WriteString("\n") + totalSubCost += sub.EstimatedCost + totalSubDur += sub.DurationSecs + } + + body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2))) + body.WriteString("\n") + body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s", + nameW, + "Combined", + cli.FormatDuration(totalSubDur), + cli.FormatCost(totalSubCost)))) + body.WriteString("\n") + } + body.WriteString("\n") - body.WriteString(mutedStyle.Render("[Enter] expand [j/k] navigate [q] quit")) + if w < compactWidth { + body.WriteString(mutedStyle.Render("[j/k] navigate [J/K] scroll [q] quit")) + } else { + body.WriteString(mutedStyle.Render("[Enter] expand [j/k] navigate [J/K/^d/^u] scroll [q] quit")) + } return body.String() } @@ -283,3 +398,60 @@ func shortID(id string) string { } return id } + +// applyDetailScroll applies the detail pane scroll offset to a rendered body string. +// visibleH is the number of lines that fit in the card body area. +func (a App) applyDetailScroll(body string, visibleH int) string { + if visibleH < 5 { + visibleH = 5 + } + + lines := strings.Split(body, "\n") + if len(lines) <= visibleH { + return body + } + + scrollOff := a.sessState.detailScroll + maxScroll := len(lines) - visibleH + if maxScroll < 0 { + maxScroll = 0 + } + if scrollOff > maxScroll { + scrollOff = maxScroll + } + if scrollOff < 0 { + scrollOff = 0 + } + + endIdx := scrollOff + visibleH + if endIdx > len(lines) { + endIdx = len(lines) + } + visible := lines[scrollOff:endIdx] + + // Add scroll indicator if content continues below. + // Count includes the line we're replacing + lines past the viewport. + if endIdx < len(lines) { + unseen := len(lines) - endIdx + 1 + dimStyle := lipgloss.NewStyle().Foreground(theme.Active.TextDim) + visible[len(visible)-1] = dimStyle.Render(fmt.Sprintf("... %d more", unseen)) + } + + return strings.Join(visible, "\n") +} + +func tokenTableLayout(innerW int) (typeW, tokenW, costW, tableW int) { + tokenW = 12 + costW = 10 + typeW = innerW - tokenW - costW - 2 + if typeW < 8 { + tokenW = 8 + costW = 8 + typeW = innerW - tokenW - costW - 2 + } + if typeW < 6 { + typeW = 6 + } + tableW = typeW + tokenW + costW + 2 + return +} diff --git a/internal/tui/tab_settings.go b/internal/tui/tab_settings.go index 0897068..09a6e21 100644 --- a/internal/tui/tab_settings.go +++ b/internal/tui/tab_settings.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strconv" "strings" "cburn/internal/cli" @@ -16,6 +17,7 @@ import ( const ( settingsFieldAPIKey = iota + settingsFieldSessionKey settingsFieldTheme settingsFieldDays settingsFieldBudget @@ -56,13 +58,21 @@ func (a App) settingsStartEdit() (tea.Model, tea.Cmd) { if existing != "" { ti.SetValue(existing) } + case settingsFieldSessionKey: + ti.Placeholder = "sk-ant-sid..." + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '*' + existing := config.GetSessionKey(cfg) + if existing != "" { + ti.SetValue(existing) + } case settingsFieldTheme: ti.Placeholder = "flexoki-dark, catppuccin-mocha, tokyo-night, terminal" ti.SetValue(cfg.Appearance.Theme) ti.EchoMode = textinput.EchoNormal case settingsFieldDays: ti.Placeholder = "30" - ti.SetValue(fmt.Sprintf("%d", cfg.General.DefaultDays)) + ti.SetValue(strconv.Itoa(cfg.General.DefaultDays)) ti.EchoMode = textinput.EchoNormal case settingsFieldBudget: ti.Placeholder = "500 (monthly USD, leave empty to clear)" @@ -103,6 +113,8 @@ func (a *App) settingsSave() { switch a.settings.cursor { case settingsFieldAPIKey: cfg.AdminAPI.APIKey = val + case settingsFieldSessionKey: + cfg.ClaudeAI.SessionKey = val case settingsFieldTheme: // Validate theme name found := false @@ -162,10 +174,21 @@ func (a App) renderSettingsTab(cw int) string { } } + sessionKeyDisplay := "(not set)" + existingSession := config.GetSessionKey(cfg) + if existingSession != "" { + if len(existingSession) > 16 { + sessionKeyDisplay = existingSession[:12] + "..." + existingSession[len(existingSession)-4:] + } else { + sessionKeyDisplay = "****" + } + } + fields := []field{ {"Admin API Key", apiKeyDisplay}, + {"Session Key", sessionKeyDisplay}, {"Theme", cfg.Appearance.Theme}, - {"Default Days", fmt.Sprintf("%d", cfg.General.DefaultDays)}, + {"Default Days", strconv.Itoa(cfg.General.DefaultDays)}, {"Monthly Budget", func() string { if cfg.Budget.MonthlyUSD != nil { return fmt.Sprintf("$%.0f", *cfg.Budget.MonthlyUSD) @@ -210,7 +233,7 @@ func (a App) renderSettingsTab(cw int) string { infoBody.WriteString(labelStyle.Render("Data directory: ") + valueStyle.Render(a.claudeDir) + "\n") infoBody.WriteString(labelStyle.Render("Sessions loaded: ") + valueStyle.Render(cli.FormatNumber(int64(len(a.sessions)))) + "\n") infoBody.WriteString(labelStyle.Render("Load time: ") + valueStyle.Render(fmt.Sprintf("%.1fs", a.loadTime.Seconds())) + "\n") - infoBody.WriteString(labelStyle.Render("Config file: ") + valueStyle.Render(config.ConfigPath())) + infoBody.WriteString(labelStyle.Render("Config file: ") + valueStyle.Render(config.Path())) var b strings.Builder b.WriteString(components.ContentCard("Settings", formBody.String(), cw))