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))