Files
cburn/internal/tui/app.go
teernisse 35fae37ba4 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
2026-02-20 16:08:26 -05:00

878 lines
21 KiB
Go

// 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"
"cburn/internal/pipeline"
"cburn/internal/store"
"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
LoadTime time.Duration
}
// ProgressMsg reports file parsing progress.
type ProgressMsg struct {
Current int
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
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
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
height int
activeTab int
showHelp bool
// Filter state
days int
project string
modelFilter string
// Per-tab state
sessState sessionsState
settings settingsState
// First-run setup (huh form)
setupForm *huh.Form
setupVals setupValues
needSetup bool
// Loading — channel-based progress subscription
spinner spinner.Model
progress int
progressMax int
loadSub chan tea.Msg // progress + completion messages from loader goroutine
// Data dir for pipeline
claudeDir string
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,
project: project,
modelFilter: modelFilter,
includeSubagents: includeSubagents,
spinner: sp,
loadSub: make(chan tea.Msg, 1),
}
}
// Init implements tea.Model.
func (a App) Init() tea.Cmd {
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() {
now := time.Now()
since := now.AddDate(0, 0, -a.days)
filtered := a.sessions
if a.project != "" {
filtered = pipeline.FilterByProject(filtered, a.project)
}
if a.modelFilter != "" {
filtered = pipeline.FilterByModel(filtered, a.modelFilter)
}
timeFiltered := pipeline.FilterByTime(filtered, since, now)
a.stats = pipeline.Aggregate(filtered, since, now)
a.dailyStats = pipeline.AggregateDays(filtered, since, now)
a.models = pipeline.AggregateModels(filtered, since, now)
a.projects = pipeline.AggregateProjects(filtered, since, now)
// Previous period for comparison (same duration, immediately before)
prevSince := since.AddDate(0, 0, -a.days)
a.prevStats = pipeline.Aggregate(filtered, prevSince, since)
// 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)
})
// Clamp sessions cursor to the new filtered list bounds
if a.sessState.cursor >= len(a.filtered) {
a.sessState.cursor = len(a.filtered) - 1
}
if a.sessState.cursor < 0 {
a.sessState.cursor = 0
}
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:
key := msg.String()
// Global: quit
if key == "ctrl+c" {
return a, tea.Quit
}
if !a.loaded {
return a, nil
}
// First-run setup wizard intercepts all keys
if a.needSetup && a.setupForm != nil {
return a.updateSetupForm(msg)
}
// Settings tab has its own keybindings (text input)
if a.activeTab == 4 && a.settings.editing {
return a.updateSettingsInput(msg)
}
// Help toggle
if key == "?" {
a.showHelp = !a.showHelp
return a, nil
}
// Dismiss help
if a.showHelp {
a.showHelp = false
return a, nil
}
// Sessions tab has its own keybindings
if a.activeTab == 2 {
compactSessions := a.isCompactLayout()
switch key {
case "q":
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
}
return a, nil
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 == 4 {
switch key {
case "j", "down":
if a.settings.cursor < settingsFieldCount-1 {
a.settings.cursor++
}
return a, nil
case "k", "up":
if a.settings.cursor > 0 {
a.settings.cursor--
}
return a, nil
case "enter":
return a.settingsStartEdit()
}
}
// Global quit from non-sessions tabs
if key == "q" {
return a, tea.Quit
}
// Tab navigation
switch key {
case "o":
a.activeTab = 0
case "c":
a.activeTab = 1
case "s":
a.activeTab = 2
case "b":
a.activeTab = 3
case "x":
a.activeTab = 4
case "left":
a.activeTab = (a.activeTab - 1 + len(components.Tabs)) % len(components.Tabs)
case "right":
a.activeTab = (a.activeTab + 1) % len(components.Tabs)
}
return a, nil
case DataLoadedMsg:
a.sessions = msg.Sessions
a.loaded = true
a.loadTime = msg.LoadTime
a.recompute()
// Activate first-run setup after data loads
if a.needSetup {
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
case ProgressMsg:
a.progress = msg.Current
a.progressMax = msg.Total
return a, waitForLoadMsg(a.loadSub)
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) updateSetupForm(msg tea.Msg) (tea.Model, tea.Cmd) {
form, cmd := a.setupForm.Update(msg)
if f, ok := form.(*huh.Form); ok {
a.setupForm = f
}
if a.setupForm.State == huh.StateCompleted {
_ = a.saveSetupConfig()
a.recompute()
a.needSetup = false
a.setupForm = 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 > 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.setupForm != nil {
return a.setupForm.View()
}
if a.showHelp {
return a.viewHelp()
}
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
h := a.height
titleStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Bold(true)
mutedStyle := lipgloss.NewStyle().
Foreground(t.TextMuted)
var b strings.Builder
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" cburn"))
b.WriteString(mutedStyle.Render(" - Claude Usage Metrics"))
b.WriteString("\n\n")
if a.progressMax > 0 {
barW := w - 20
if barW < 20 {
barW = 20
}
if barW > 60 {
barW = 60
}
pct := float64(a.progress) / float64(a.progressMax)
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)))
} else {
fmt.Fprintf(&b, " %s Scanning sessions\n", a.spinner.View())
}
content := b.String()
return padHeight(truncateHeight(content, h), h)
}
func (a App) viewHelp() string {
t := theme.Active
h := a.height
titleStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Bold(true)
keyStyle := lipgloss.NewStyle().
Foreground(t.TextPrimary).
Bold(true)
descStyle := lipgloss.NewStyle().
Foreground(t.TextMuted)
var b strings.Builder
b.WriteString("\n")
b.WriteString(titleStyle.Render(" Keybindings"))
b.WriteString("\n\n")
bindings := []struct{ key, desc string }{
{"o/c/s/b", "Overview / Costs / Sessions / Breakdown"},
{"x", "Settings"},
{"<- / ->", "Previous / Next tab"},
{"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"},
{"q", "Quit (or back from full-screen)"},
}
for _, bind := range bindings {
fmt.Fprintf(&b, " %s %s\n",
keyStyle.Render(fmt.Sprintf("%-12s", bind.key)),
descStyle.Render(bind.desc))
}
fmt.Fprintf(&b, "\n %s\n", descStyle.Render("Press any key to close"))
content := b.String()
return padHeight(truncateHeight(content, h), h)
}
func (a App) viewMain() string {
t := theme.Active
w := a.width
cw := a.contentWidth()
h := a.height
// 1. Render header (tab bar + filter line)
filterStyle := lipgloss.NewStyle().Foreground(t.TextDim)
filterStr := fmt.Sprintf(" [%dd", a.days)
if a.project != "" {
filterStr += " | " + a.project
}
if a.modelFilter != "" {
filterStr += " | " + a.modelFilter
}
filterStr += "]"
header := components.RenderTabBar(a.activeTab, w) + "\n" +
filterStyle.Render(filterStr) + "\n"
// 2. Render status bar
dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds())
statusBar := components.RenderStatusBar(w, dataAge, a.subData)
// 3. Calculate content zone height
headerH := lipgloss.Height(header)
statusH := lipgloss.Height(statusBar)
contentH := h - headerH - statusH
if contentH < 5 {
contentH = 5
}
// 4. Render tab content (pass contentH to sessions)
var content string
switch a.activeTab {
case 0:
content = a.renderOverviewTab(cw)
case 1:
content = a.renderCostsTab(cw)
case 2:
content = a.renderSessionsContent(a.filtered, cw, contentH)
case 3:
content = a.renderBreakdownTab(cw)
case 4:
content = a.renderSettingsTab(cw)
}
// 5. Truncate + pad to exactly contentH lines
content = padHeight(truncateHeight(content, contentH), contentH)
// 6. Center horizontally if terminal wider than content cap
if w > cw {
content = lipgloss.Place(w, contentH, lipgloss.Center, lipgloss.Top, content)
}
// 7. Stack vertically
return lipgloss.JoinVertical(lipgloss.Left, header, content, statusBar)
}
// ─── 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(time.Time) tea.Msg {
return tickMsg{}
})
}
// loadDataCmd starts the data loading pipeline in a background goroutine.
// It streams ProgressMsg updates and a final DataLoadedMsg through sub.
func loadDataCmd(claudeDir string, includeSubagents bool, sub chan tea.Msg) tea.Cmd {
return func() tea.Msg {
go func() {
start := time.Now()
// Progress callback: non-blocking send so workers aren't stalled.
// If the channel is full, we skip this update — the next one catches up.
progressFn := func(current, total int) {
select {
case sub <- ProgressMsg{Current: current, Total: total}:
default:
}
}
// Try cached load
cache, err := storeOpen()
if err == nil {
cr, loadErr := pipeline.LoadWithCache(claudeDir, includeSubagents, cache, progressFn)
_ = cache.Close()
if loadErr == nil {
sub <- DataLoadedMsg{
Sessions: cr.Sessions,
LoadTime: time.Since(start),
}
return
}
}
// Fallback: uncached load
result, err := pipeline.Load(claudeDir, includeSubagents, progressFn)
if err != nil {
sub <- DataLoadedMsg{LoadTime: time.Since(start)}
return
}
sub <- DataLoadedMsg{
Sessions: result.Sessions,
LoadTime: time.Since(start),
}
}()
// Block until the first message (either ProgressMsg or DataLoadedMsg)
return <-sub
}
}
// waitForLoadMsg blocks until the next message arrives from the loader goroutine.
func waitForLoadMsg(sub chan tea.Msg) tea.Cmd {
return func() tea.Msg {
return <-sub
}
}
func storeOpen() (*store.Cache, error) {
return store.Open(pipeline.CachePath())
}
// chartDateLabels builds compact X-axis labels for a chronological date series.
// First label: month abbreviation (e.g. "Jan"). Month boundaries: "Feb 1".
// Everything else (including last): just the day number.
// days is sorted newest-first; labels are returned oldest-left.
func chartDateLabels(days []model.DailyStats) []string {
n := len(days)
labels := make([]string, n)
// Build chronological date list (oldest first)
dates := make([]time.Time, n)
for i, d := range days {
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
}
return labels
}
func shortModel(name string) string {
if len(name) > 7 && name[:7] == "claude-" {
return name[7:]
}
return name
}
func truncStr(s string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= limit {
return s
}
return string(runes[:limit-1]) + "…"
}
func truncateHeight(s string, limit int) string {
lines := strings.Split(s, "\n")
if len(lines) <= limit {
return s
}
return strings.Join(lines[:limit], "\n")
}
func padHeight(s string, h int) string {
lines := strings.Split(s, "\n")
if len(lines) >= h {
return s
}
padding := strings.Repeat("\n", h-len(lines))
return s + padding
}
// 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)}
}
}