feat: add mouse navigation and session search to TUI

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>
This commit is contained in:
teernisse
2026-02-23 10:09:01 -05:00
parent 9bb0fd6b73
commit 16cc4d4737
3 changed files with 226 additions and 58 deletions

View File

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

View File

@@ -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")
}

View File

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