Mouse support: - Wheel up/down scrolls session list in Sessions tab - Left click on tab bar switches tabs - Works alongside existing keyboard navigation Session search: - Press '/' to enter search mode with live preview - Filters sessions by project name substring matching - Shows match count as you type - Enter to apply filter, Esc to cancel - Search indicator shown in card title when active - Esc clears active search filter Cost integration: - Use centralized AggregateCostBreakdown for model costs - Consistent cost calculations between Overview and Costs tabs Also fixes cursor clamping to prevent out-of-bounds access when search results change the filtered session count. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1101 lines
27 KiB
Go
1101 lines
27 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
|
|
)
|
|
|
|
// 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"))
|
|
|
|
// Load refresh settings from config
|
|
cfg, _ := config.Load()
|
|
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, _ := 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)
|
|
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 - 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
|
|
}
|
|
|
|
// 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
|
|
cfg, _ := config.Load()
|
|
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
|
|
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))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
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 (or mouse wheel)"},
|
|
{"J / K", "Scroll detail pane"},
|
|
{"^d / ^u", "Scroll detail half-page"},
|
|
{"/", "Search sessions (Enter apply, Esc cancel)"},
|
|
{"Enter / f", "Expand session full-screen"},
|
|
{"Esc", "Clear search / Back to split view"},
|
|
{"r / R", "Refresh now / Toggle auto-refresh"},
|
|
{"?", "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, a.refreshing, a.autoRefresh)
|
|
|
|
// 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:
|
|
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. 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())
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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.
|
|
// Tab layout: " Overview Costs Sessions Breakdown Settings[x]"
|
|
func (a App) tabAtX(x int) int {
|
|
// Tab bar format: " TabName TabName ..." with 2-space gaps
|
|
// We approximate positions since exact widths depend on styling.
|
|
// Each tab name is roughly: name length + optional [k] suffix + gap
|
|
positions := []struct {
|
|
start, end int
|
|
}{
|
|
{1, 12}, // Overview (0)
|
|
{14, 22}, // Costs (1)
|
|
{24, 35}, // Sessions (2)
|
|
{37, 50}, // Breakdown (3)
|
|
{52, 68}, // Settings (4)
|
|
}
|
|
|
|
for i, p := range positions {
|
|
if x >= p.start && x <= p.end {
|
|
return i
|
|
}
|
|
}
|
|
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)
|
|
}
|