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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user