Files
cburn/internal/tui/app.go
teernisse 0416d029b1 refactor(tui): improve config resilience and scroll navigation
app.go changes:
- Add loadConfigOrDefault() helper that returns sensible defaults when
  config loading fails, ensuring TUI can always start even with
  corrupted config files
- Extract scroll navigation constants (scrollOverhead, minHalfPageScroll,
  minContentHeight) for clarity and consistency
- Apply accent border styling to loading card for visual polish
- Replace inline config.Load() calls with loadConfigOrDefault()

setup.go changes:
- Use loadConfigOrDefault() for consistent error handling during
  setup wizard initialization and config persistence

The loadConfigOrDefault pattern improves user experience by gracefully
degrading rather than failing hard when config issues occur. Users can
still access the TUI and reconfigure via the Settings tab.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:05:57 -05:00

1217 lines
31 KiB
Go

// Package tui provides the interactive Bubble Tea dashboard for cburn.
package tui
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/theirongolddev/cburn/internal/claudeai"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/model"
"github.com/theirongolddev/cburn/internal/pipeline"
"github.com/theirongolddev/cburn/internal/store"
"github.com/theirongolddev/cburn/internal/tui/components"
"github.com/theirongolddev/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
}
// RefreshDataMsg is sent when a background data refresh completes.
type RefreshDataMsg struct {
Sessions []model.SessionStats
LoadTime time.Duration
}
// App is the root Bubble Tea model.
type App struct {
// Data
sessions []model.SessionStats
loaded bool
loadTime time.Duration
// Auto-refresh state
autoRefresh bool
refreshInterval time.Duration
lastRefresh time.Time
refreshing bool
// 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
costByType pipeline.TokenTypeCosts
modelCosts []pipeline.ModelCostBreakdown
// Live activity charts (today + last hour)
todayHourly []model.HourlyStats
lastHour []model.MinuteStats
// 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
// Scroll navigation
scrollOverhead = 10 // approximate header + status bar height for half-page calc
minHalfPageScroll = 1 // minimum lines for half-page scroll
minContentHeight = 5 // minimum content area height
)
// loadConfigOrDefault loads config, returning defaults on error.
// This ensures the TUI can always start even if config is corrupted.
func loadConfigOrDefault() config.Config {
cfg, err := config.Load()
if err != nil {
// Return zero-value config with sensible defaults applied
return config.Config{
TUI: config.TUIConfig{
RefreshIntervalSec: 30,
},
}
}
return cfg
}
// 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")).Background(theme.Active.Surface)
// Load refresh settings from config
cfg := loadConfigOrDefault()
refreshInterval := time.Duration(cfg.TUI.RefreshIntervalSec) * time.Second
if refreshInterval < 10*time.Second {
refreshInterval = 30 * time.Second // minimum 10s, default 30s
}
return App{
claudeDir: claudeDir,
days: days,
needSetup: needSetup,
project: project,
modelFilter: modelFilter,
includeSubagents: includeSubagents,
autoRefresh: cfg.TUI.AutoRefresh,
refreshInterval: refreshInterval,
spinner: sp,
loadSub: make(chan tea.Msg, 1),
}
}
// Init implements tea.Model.
func (a App) Init() tea.Cmd {
cmds := []tea.Cmd{
tea.EnableMouseCellMotion, // Enable mouse support
loadDataCmd(a.claudeDir, a.includeSubagents, a.loadSub),
a.spinner.Tick,
tickCmd(),
}
// Start subscription data fetch if session key is configured
cfg := loadConfigOrDefault()
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)
a.costByType, a.modelCosts = pipeline.AggregateCostBreakdown(filtered, since, now)
// Live activity charts
a.todayHourly = pipeline.AggregateTodayHourly(filtered)
a.lastHour = pipeline.AggregateLastHour(filtered)
// 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.MouseMsg:
if !a.loaded || a.showHelp || (a.needSetup && a.setupForm != nil) {
return a, nil
}
switch msg.Button {
case tea.MouseButtonWheelUp:
// Scroll up in sessions tab
if a.activeTab == 2 && !a.sessState.searching {
if a.sessState.cursor > 0 {
a.sessState.cursor--
a.sessState.detailScroll = 0
}
}
return a, nil
case tea.MouseButtonWheelDown:
// Scroll down in sessions tab
if a.activeTab == 2 && !a.sessState.searching {
searchFiltered := a.getSearchFilteredSessions()
if a.sessState.cursor < len(searchFiltered)-1 {
a.sessState.cursor++
a.sessState.detailScroll = 0
}
}
return a, nil
case tea.MouseButtonLeft:
// Check if click is in tab bar area (first 2 lines)
if msg.Y <= 1 {
if tab := a.tabAtX(msg.X); tab >= 0 && tab < len(components.Tabs) {
a.activeTab = tab
}
}
return a, nil
}
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)
}
// Sessions search mode intercepts all keys when active
if a.activeTab == 2 && a.sessState.searching {
return a.updateSessionsSearch(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()
searchFiltered := a.getSearchFilteredSessions()
switch key {
case "/":
// Start search mode
a.sessState.searching = true
a.sessState.searchInput = newSearchInput()
a.sessState.searchInput.Focus()
return a, a.sessState.searchInput.Cursor.BlinkCmd()
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":
// Clear search if active, otherwise exit detail view
if a.sessState.searchQuery != "" {
a.sessState.searchQuery = ""
a.sessState.cursor = 0
a.sessState.offset = 0
return a, nil
}
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(searchFiltered)-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(searchFiltered) - 1
if a.sessState.cursor < 0 {
a.sessState.cursor = 0
}
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 - scrollOverhead) / 2
if halfPage < minHalfPageScroll {
halfPage = minHalfPageScroll
}
a.sessState.detailScroll += halfPage
return a, nil
case "ctrl+u":
halfPage := (a.height - scrollOverhead) / 2
if halfPage < minHalfPageScroll {
halfPage = minHalfPageScroll
}
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
}
// Manual refresh
if key == "r" && !a.refreshing {
a.refreshing = true
return a, refreshDataCmd(a.claudeDir, a.includeSubagents)
}
// Toggle auto-refresh
if key == "R" {
a.autoRefresh = !a.autoRefresh
// Persist to config (best-effort, ignore errors)
cfg := loadConfigOrDefault()
cfg.TUI.AutoRefresh = a.autoRefresh
_ = config.Save(cfg)
return a, nil
}
// 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.lastRefresh = time.Now()
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 (best-effort, ignore errors)
if msg.Data != nil && msg.Data.Org.UUID != "" {
cfg := loadConfigOrDefault()
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 := loadConfigOrDefault()
if sessionKey := config.GetSessionKey(cfg); sessionKey != "" {
a.subFetching = true
cmds = append(cmds, fetchSubDataCmd(sessionKey))
}
}
// Auto-refresh session data
if a.loaded && a.autoRefresh && !a.refreshing {
if time.Since(a.lastRefresh) >= a.refreshInterval {
a.refreshing = true
cmds = append(cmds, refreshDataCmd(a.claudeDir, a.includeSubagents))
}
}
return a, tea.Batch(cmds...)
case RefreshDataMsg:
a.refreshing = false
a.lastRefresh = time.Now()
if msg.Sessions != nil {
a.sessions = msg.Sessions
a.loadTime = msg.LoadTime
a.recompute()
}
return a, nil
}
// 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
// Polished loading card with accent border
cardStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.BorderAccent).
Background(t.Surface).
Padding(2, 4)
// ASCII art logo effect
logoStyle := lipgloss.NewStyle().
Foreground(t.AccentBright).
Background(t.Surface).
Bold(true)
subtitleStyle := lipgloss.NewStyle().
Foreground(t.TextMuted).
Background(t.Surface)
spinnerStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Background(t.Surface)
countStyle := lipgloss.NewStyle().
Foreground(t.TextPrimary).
Background(t.Surface)
var b strings.Builder
b.WriteString(logoStyle.Render("◈ cburn"))
b.WriteString(subtitleStyle.Render(" · Claude Usage Metrics"))
b.WriteString("\n\n")
if a.progressMax > 0 {
barW := 40
if barW > w-30 {
barW = w - 30
}
if barW < 20 {
barW = 20
}
pct := float64(a.progress) / float64(a.progressMax)
b.WriteString(spinnerStyle.Render(a.spinner.View()))
b.WriteString(subtitleStyle.Render(" Parsing sessions\n\n"))
b.WriteString(components.ProgressBar(pct, barW))
b.WriteString("\n")
b.WriteString(countStyle.Render(cli.FormatNumber(int64(a.progress))))
b.WriteString(subtitleStyle.Render(" / "))
b.WriteString(countStyle.Render(cli.FormatNumber(int64(a.progressMax))))
} else {
b.WriteString(spinnerStyle.Render(a.spinner.View()))
b.WriteString(subtitleStyle.Render(" Discovering sessions..."))
}
card := cardStyle.Render(b.String())
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, card,
lipgloss.WithWhitespaceBackground(t.Background))
}
func (a App) viewHelp() string {
t := theme.Active
h := a.height
w := a.width
// Polished help overlay with accent border
cardStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.BorderAccent).
Background(t.Surface).
Padding(1, 3)
titleStyle := lipgloss.NewStyle().
Foreground(t.AccentBright).
Background(t.Surface).
Bold(true)
sectionStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Background(t.Surface).
Bold(true)
keyStyle := lipgloss.NewStyle().
Foreground(t.Cyan).
Background(t.Surface).
Bold(true)
descStyle := lipgloss.NewStyle().
Foreground(t.TextMuted).
Background(t.Surface)
dimStyle := lipgloss.NewStyle().
Foreground(t.TextDim).
Background(t.Surface)
var b strings.Builder
b.WriteString(titleStyle.Render("◈ Keyboard Shortcuts"))
b.WriteString("\n\n")
// Navigation section
b.WriteString(sectionStyle.Render("Navigation"))
b.WriteString("\n")
navBindings := []struct{ key, desc string }{
{"o c s b x", "Jump to tab"},
{"← →", "Previous / Next tab"},
{"j k", "Navigate lists"},
{"J K", "Scroll detail pane"},
{"^d ^u", "Half-page scroll"},
}
for _, bind := range navBindings {
fmt.Fprintf(&b, " %s %s\n",
keyStyle.Render(fmt.Sprintf("%-10s", bind.key)),
descStyle.Render(bind.desc))
}
b.WriteString("\n")
b.WriteString(sectionStyle.Render("Actions"))
b.WriteString("\n")
actionBindings := []struct{ key, desc string }{
{"/", "Search sessions"},
{"Enter", "Expand / Confirm"},
{"Esc", "Back / Cancel"},
{"r", "Refresh data"},
{"R", "Toggle auto-refresh"},
{"?", "Toggle help"},
{"q", "Quit"},
}
for _, bind := range actionBindings {
fmt.Fprintf(&b, " %s %s\n",
keyStyle.Render(fmt.Sprintf("%-10s", bind.key)),
descStyle.Render(bind.desc))
}
b.WriteString("\n")
b.WriteString(dimStyle.Render("Press any key to close"))
card := cardStyle.Render(b.String())
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, card,
lipgloss.WithWhitespaceBackground(t.Background))
}
func (a App) viewMain() string {
t := theme.Active
w := a.width
cw := a.contentWidth()
h := a.height
// 1. Render header (tab bar + filter pill)
filterPillStyle := lipgloss.NewStyle().
Foreground(t.TextDim).
Background(t.Surface)
filterAccentStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Background(t.Surface).
Bold(true)
filterStr := filterPillStyle.Render(" ") +
filterAccentStyle.Render(fmt.Sprintf("%dd", a.days))
if a.project != "" {
filterStr += filterPillStyle.Render(" │ ") + filterAccentStyle.Render(a.project)
}
if a.modelFilter != "" {
filterStr += filterPillStyle.Render(" │ ") + filterAccentStyle.Render(a.modelFilter)
}
filterStr += filterPillStyle.Render(" ")
// Pad filter line to full width
filterRowStyle := lipgloss.NewStyle().
Background(t.Surface).
Width(w)
header := components.RenderTabBar(a.activeTab, w) +
filterRowStyle.Render(filterStr)
// 2. Render status bar
dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds())
statusBar := components.RenderStatusBar(w, dataAge, a.subData, a.refreshing, a.autoRefresh)
// 3. Calculate content zone height
headerH := lipgloss.Height(header)
statusH := lipgloss.Height(statusBar)
contentH := h - headerH - statusH
if contentH < minContentHeight {
contentH = minContentHeight
}
// 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:
searchFiltered := a.getSearchFilteredSessions()
content = a.renderSessionsContent(searchFiltered, 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. Fill each line to full width with background (fixes gaps between cards)
content = fillLinesWithBackground(content, cw, t.Background)
// 7. Place content with background fill (handles centering when w > cw)
content = lipgloss.Place(w, contentH, lipgloss.Center, lipgloss.Top, content,
lipgloss.WithWhitespaceBackground(t.Background))
// 8. Stack vertically
output := lipgloss.JoinVertical(lipgloss.Left, header, content, statusBar)
// 9. Ensure entire terminal is filled with background
// This handles any edge cases where the calculated heights don't perfectly match
return lipgloss.Place(w, h, lipgloss.Left, lipgloss.Top, output,
lipgloss.WithWhitespaceBackground(t.Background))
}
// ─── 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())
}
// refreshDataCmd refreshes session data in the background (no progress UI).
func refreshDataCmd(claudeDir string, includeSubagents bool) tea.Cmd {
return func() tea.Msg {
start := time.Now()
cache, err := storeOpen()
if err == nil {
cr, loadErr := pipeline.LoadWithCache(claudeDir, includeSubagents, cache, nil)
_ = cache.Close()
if loadErr == nil {
return RefreshDataMsg{
Sessions: cr.Sessions,
LoadTime: time.Since(start),
}
}
}
// Fallback: uncached load
result, err := pipeline.Load(claudeDir, includeSubagents, nil)
if err != nil {
return RefreshDataMsg{LoadTime: time.Since(start)}
}
return RefreshDataMsg{
Sessions: result.Sessions,
LoadTime: time.Since(start),
}
}
}
// 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
}
// fillLinesWithBackground pads each line to width w with background color.
// This ensures gaps between cards and empty lines have proper background fill.
func fillLinesWithBackground(s string, w int, bg lipgloss.Color) string {
lines := strings.Split(s, "\n")
var result strings.Builder
for i, line := range lines {
// Use PlaceHorizontal to ensure proper width and background fill
// This is more reliable than just Background().Render(spaces)
placed := lipgloss.PlaceHorizontal(w, lipgloss.Left, line,
lipgloss.WithWhitespaceBackground(bg))
result.WriteString(placed)
if i < len(lines)-1 {
result.WriteString("\n")
}
}
return result.String()
}
// 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)}
}
}
// ─── Mouse Support ──────────────────────────────────────────────
// tabAtX returns the tab index at the given X coordinate, or -1 if none.
// Hitboxes are derived from the same width rules used by RenderTabBar.
func (a App) tabAtX(x int) int {
pos := 0
for i, tab := range components.Tabs {
// Must match RenderTabBar's visual width calculation exactly.
// Use lipgloss.Width() to handle unicode and styled text correctly.
tabW := components.TabVisualWidth(tab, i == a.activeTab)
if x >= pos && x < pos+tabW {
return i
}
pos += tabW
// Separator is one column between tabs.
if i < len(components.Tabs)-1 {
pos++
}
}
return -1
}
// ─── Session Search ─────────────────────────────────────────────
// updateSessionsSearch handles key events while in search mode.
func (a App) updateSessionsSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
switch key {
case "enter":
// Apply search and exit search mode
a.sessState.searchQuery = strings.TrimSpace(a.sessState.searchInput.Value())
a.sessState.searching = false
a.sessState.cursor = 0
a.sessState.offset = 0
a.sessState.detailScroll = 0
return a, nil
case "esc":
// Cancel search mode without applying
a.sessState.searching = false
return a, nil
}
// Forward other keys to the text input
var cmd tea.Cmd
a.sessState.searchInput, cmd = a.sessState.searchInput.Update(msg)
return a, cmd
}
// getSearchFilteredSessions returns sessions filtered by the current search query.
func (a App) getSearchFilteredSessions() []model.SessionStats {
if a.sessState.searchQuery == "" {
return a.filtered
}
return filterSessionsBySearch(a.filtered, a.sessState.searchQuery)
}