diff --git a/internal/tui/app.go b/internal/tui/app.go index 49b0b61..c7af4d8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -10,17 +10,16 @@ import ( "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/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" - "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" @@ -74,6 +73,8 @@ type App struct { dailyStats []model.DailyStats models []model.ModelStats projects []model.ProjectStats + costByType pipeline.TokenTypeCosts + modelCosts []pipeline.ModelCostBreakdown // Live activity charts (today + last hour) todayHourly []model.HourlyStats @@ -183,6 +184,7 @@ func (a *App) recompute() { 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) @@ -234,6 +236,44 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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() @@ -256,6 +296,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -271,7 +316,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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 @@ -287,6 +340,13 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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 } @@ -295,7 +355,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil case "j", "down": - if a.sessState.cursor < len(a.filtered)-1 { + if a.sessState.cursor < len(searchFiltered)-1 { a.sessState.cursor++ a.sessState.detailScroll = 0 } @@ -312,7 +372,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.sessState.detailScroll = 0 return a, nil case "G": - a.sessState.cursor = len(a.filtered) - 1 + a.sessState.cursor = len(searchFiltered) - 1 + if a.sessState.cursor < 0 { + a.sessState.cursor = 0 + } a.sessState.detailScroll = 0 return a, nil case "J": @@ -631,11 +694,12 @@ func (a App) viewHelp() string { {"o/c/s/b", "Overview / Costs / Sessions / Breakdown"}, {"x", "Settings"}, {"<- / ->", "Previous / Next tab"}, - {"j / k", "Navigate lists"}, + {"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", "Back to split view"}, + {"Esc", "Clear search / Back to split view"}, {"r / R", "Refresh now / Toggle auto-refresh"}, {"?", "Toggle this help"}, {"q", "Quit (or back from full-screen)"}, @@ -692,7 +756,8 @@ func (a App) viewMain() string { case 1: content = a.renderCostsTab(cw) case 2: - content = a.renderSessionsContent(a.filtered, cw, contentH) + searchFiltered := a.getSearchFilteredSessions() + content = a.renderSessionsContent(searchFiltered, cw, contentH) case 3: content = a.renderBreakdownTab(cw) case 4: @@ -971,3 +1036,65 @@ func fetchSubDataCmd(sessionKey string) tea.Cmd { 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) +} diff --git a/internal/tui/tab_costs.go b/internal/tui/tab_costs.go index 1d3cfb9..a395eb9 100644 --- a/internal/tui/tab_costs.go +++ b/internal/tui/tab_costs.go @@ -6,12 +6,12 @@ import ( "strings" "time" - "cburn/internal/claudeai" - "cburn/internal/cli" - "cburn/internal/config" - "cburn/internal/model" - "cburn/internal/tui/components" - "cburn/internal/tui/theme" + "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/tui/components" + "github.com/theirongolddev/cburn/internal/tui/theme" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/lipgloss" @@ -20,8 +20,8 @@ import ( func (a App) renderCostsTab(cw int) string { t := theme.Active stats := a.stats - models := a.models days := a.dailyStats + modelCosts := a.modelCosts var b strings.Builder @@ -69,11 +69,11 @@ func (a App) renderCostsTab(cw int) string { tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1))) tableBody.WriteString("\n") - for _, ms := range models { + for _, mc := range modelCosts { tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s", nameW, - truncStr(shortModel(ms.Model), nameW), - cli.FormatCost(ms.EstimatedCost)))) + truncStr(shortModel(mc.Model), nameW), + cli.FormatCost(mc.TotalCost)))) tableBody.WriteString("\n") } tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1))) @@ -83,22 +83,14 @@ func (a App) renderCostsTab(cw int) string { 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 - + for _, mc := range modelCosts { 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)))) + truncStr(shortModel(mc.Model), nameW), + cli.FormatCost(mc.InputCost), + cli.FormatCost(mc.OutputCost), + cli.FormatCost(mc.CacheCost), + cli.FormatCost(mc.TotalCost)))) tableBody.WriteString("\n") } diff --git a/internal/tui/tab_sessions.go b/internal/tui/tab_sessions.go index 7132845..cb11e09 100644 --- a/internal/tui/tab_sessions.go +++ b/internal/tui/tab_sessions.go @@ -5,11 +5,11 @@ import ( "sort" "strings" - "cburn/internal/cli" - "cburn/internal/config" - "cburn/internal/model" - "cburn/internal/tui/components" - "cburn/internal/tui/theme" + "github.com/theirongolddev/cburn/internal/cli" + "github.com/theirongolddev/cburn/internal/config" + "github.com/theirongolddev/cburn/internal/model" + "github.com/theirongolddev/cburn/internal/tui/components" + "github.com/theirongolddev/cburn/internal/tui/theme" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/lipgloss" @@ -76,8 +76,38 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str t := theme.Active ss := a.sessState + // Show search input when in search mode + if ss.searching { + var b strings.Builder + searchStyle := lipgloss.NewStyle().Foreground(t.Accent) + b.WriteString(searchStyle.Render(" Search: ")) + b.WriteString(ss.searchInput.View()) + b.WriteString("\n") + hintStyle := lipgloss.NewStyle().Foreground(t.TextDim) + b.WriteString(hintStyle.Render(" [Enter] apply [Esc] cancel")) + b.WriteString("\n\n") + + // Show preview of filtered results + previewFiltered := filterSessionsBySearch(a.filtered, ss.searchInput.Value()) + b.WriteString(hintStyle.Render(fmt.Sprintf(" %d sessions match", len(previewFiltered)))) + + return b.String() + } + + // Build title with search indicator + title := fmt.Sprintf("Sessions [%dd]", a.days) + if ss.searchQuery != "" { + title = fmt.Sprintf("Sessions [%dd] / %q (%d)", a.days, ss.searchQuery, len(filtered)) + } + if len(filtered) == 0 { - return components.ContentCard("Sessions", lipgloss.NewStyle().Foreground(t.TextMuted).Render("No sessions found"), cw) + var body strings.Builder + body.WriteString(lipgloss.NewStyle().Foreground(t.TextMuted).Render("No sessions found")) + if ss.searchQuery != "" { + body.WriteString("\n\n") + body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Render("[Esc] clear search [/] new search")) + } + return components.ContentCard(title, body.String(), cw) } // Force single-pane detail mode in compact layouts. @@ -97,7 +127,16 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin t := theme.Active ss := a.sessState - if ss.cursor >= len(sessions) { + // Clamp cursor to valid range + cursor := ss.cursor + if cursor >= len(sessions) { + cursor = len(sessions) - 1 + } + if cursor < 0 { + cursor = 0 + } + + if len(sessions) == 0 { return "" } @@ -130,11 +169,11 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin } offset := ss.offset - if ss.cursor < offset { - offset = ss.cursor + if cursor < offset { + offset = cursor } - if ss.cursor >= offset+visible { - offset = ss.cursor - visible + 1 + if cursor >= offset+visible { + offset = cursor - visible + 1 } end := offset + visible @@ -158,7 +197,7 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin padN = 1 } - if i == ss.cursor { + if i == cursor { fullLine := leftPart + strings.Repeat(" ", padN) + costStr // Pad to full width for continuous highlight background if len(fullLine) < leftInner { @@ -175,10 +214,15 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin leftBody.WriteString("\n") } - leftCard := components.ContentCard(fmt.Sprintf("Sessions [%dd]", a.days), leftBody.String(), leftW) + // Build title with search indicator + leftTitle := fmt.Sprintf("Sessions [%dd]", a.days) + if ss.searchQuery != "" { + leftTitle = fmt.Sprintf("Search: %q (%d)", ss.searchQuery, len(sessions)) + } + leftCard := components.ContentCard(leftTitle, leftBody.String(), leftW) // Right pane: full session detail with scroll support - sel := sessions[ss.cursor] + sel := sessions[cursor] rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle) // Apply detail scroll offset @@ -194,10 +238,15 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin t := theme.Active ss := a.sessState - if ss.cursor >= len(sessions) { + // Clamp cursor to valid range + cursor := ss.cursor + if cursor >= len(sessions) { + cursor = len(sessions) - 1 + } + if cursor < 0 || len(sessions) == 0 { return "" } - sel := sessions[ss.cursor] + sel := sessions[cursor] headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted) @@ -266,14 +315,14 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS savings := 0.0 for modelName, mu := range sel.Models { - p, ok := config.LookupPricing(modelName) + p, ok := config.LookupPricingAt(modelName, sel.StartTime) if ok { inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1e6 outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1e6 cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1e6 cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1e6 cacheReadCost += float64(mu.CacheReadTokens) * p.CacheReadPerMTok / 1e6 - savings += config.CalculateCacheSavings(modelName, mu.CacheReadTokens) + savings += config.CalculateCacheSavingsAt(modelName, sel.StartTime, mu.CacheReadTokens) } } @@ -428,9 +477,9 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS body.WriteString("\n") if w < compactWidth { - body.WriteString(mutedStyle.Render("[j/k] navigate [J/K] scroll [q] quit")) + body.WriteString(mutedStyle.Render("[/] search [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")) + body.WriteString(mutedStyle.Render("[/] search [Enter] expand [j/k] navigate [J/K/^d/^u] scroll [q] quit")) } return body.String()