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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/claudeai"
|
"github.com/theirongolddev/cburn/internal/claudeai"
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
"cburn/internal/store"
|
"github.com/theirongolddev/cburn/internal/store"
|
||||||
"cburn/internal/tui/components"
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -74,6 +73,8 @@ type App struct {
|
|||||||
dailyStats []model.DailyStats
|
dailyStats []model.DailyStats
|
||||||
models []model.ModelStats
|
models []model.ModelStats
|
||||||
projects []model.ProjectStats
|
projects []model.ProjectStats
|
||||||
|
costByType pipeline.TokenTypeCosts
|
||||||
|
modelCosts []pipeline.ModelCostBreakdown
|
||||||
|
|
||||||
// Live activity charts (today + last hour)
|
// Live activity charts (today + last hour)
|
||||||
todayHourly []model.HourlyStats
|
todayHourly []model.HourlyStats
|
||||||
@@ -183,6 +184,7 @@ func (a *App) recompute() {
|
|||||||
a.dailyStats = pipeline.AggregateDays(filtered, since, now)
|
a.dailyStats = pipeline.AggregateDays(filtered, since, now)
|
||||||
a.models = pipeline.AggregateModels(filtered, since, now)
|
a.models = pipeline.AggregateModels(filtered, since, now)
|
||||||
a.projects = pipeline.AggregateProjects(filtered, since, now)
|
a.projects = pipeline.AggregateProjects(filtered, since, now)
|
||||||
|
a.costByType, a.modelCosts = pipeline.AggregateCostBreakdown(filtered, since, now)
|
||||||
|
|
||||||
// Live activity charts
|
// Live activity charts
|
||||||
a.todayHourly = pipeline.AggregateTodayHourly(filtered)
|
a.todayHourly = pipeline.AggregateTodayHourly(filtered)
|
||||||
@@ -234,6 +236,44 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return a, nil
|
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:
|
case tea.KeyMsg:
|
||||||
key := msg.String()
|
key := msg.String()
|
||||||
|
|
||||||
@@ -256,6 +296,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return a.updateSettingsInput(msg)
|
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
|
// Help toggle
|
||||||
if key == "?" {
|
if key == "?" {
|
||||||
a.showHelp = !a.showHelp
|
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
|
// Sessions tab has its own keybindings
|
||||||
if a.activeTab == 2 {
|
if a.activeTab == 2 {
|
||||||
compactSessions := a.isCompactLayout()
|
compactSessions := a.isCompactLayout()
|
||||||
|
searchFiltered := a.getSearchFilteredSessions()
|
||||||
|
|
||||||
switch key {
|
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":
|
case "q":
|
||||||
if !compactSessions && a.sessState.viewMode == sessViewDetail {
|
if !compactSessions && a.sessState.viewMode == sessViewDetail {
|
||||||
a.sessState.viewMode = sessViewSplit
|
a.sessState.viewMode = sessViewSplit
|
||||||
@@ -287,6 +340,13 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
case "esc":
|
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 {
|
if compactSessions {
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
@@ -295,7 +355,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
case "j", "down":
|
case "j", "down":
|
||||||
if a.sessState.cursor < len(a.filtered)-1 {
|
if a.sessState.cursor < len(searchFiltered)-1 {
|
||||||
a.sessState.cursor++
|
a.sessState.cursor++
|
||||||
a.sessState.detailScroll = 0
|
a.sessState.detailScroll = 0
|
||||||
}
|
}
|
||||||
@@ -312,7 +372,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
a.sessState.detailScroll = 0
|
a.sessState.detailScroll = 0
|
||||||
return a, nil
|
return a, nil
|
||||||
case "G":
|
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
|
a.sessState.detailScroll = 0
|
||||||
return a, nil
|
return a, nil
|
||||||
case "J":
|
case "J":
|
||||||
@@ -631,11 +694,12 @@ func (a App) viewHelp() string {
|
|||||||
{"o/c/s/b", "Overview / Costs / Sessions / Breakdown"},
|
{"o/c/s/b", "Overview / Costs / Sessions / Breakdown"},
|
||||||
{"x", "Settings"},
|
{"x", "Settings"},
|
||||||
{"<- / ->", "Previous / Next tab"},
|
{"<- / ->", "Previous / Next tab"},
|
||||||
{"j / k", "Navigate lists"},
|
{"j / k", "Navigate lists (or mouse wheel)"},
|
||||||
{"J / K", "Scroll detail pane"},
|
{"J / K", "Scroll detail pane"},
|
||||||
{"^d / ^u", "Scroll detail half-page"},
|
{"^d / ^u", "Scroll detail half-page"},
|
||||||
|
{"/", "Search sessions (Enter apply, Esc cancel)"},
|
||||||
{"Enter / f", "Expand session full-screen"},
|
{"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"},
|
{"r / R", "Refresh now / Toggle auto-refresh"},
|
||||||
{"?", "Toggle this help"},
|
{"?", "Toggle this help"},
|
||||||
{"q", "Quit (or back from full-screen)"},
|
{"q", "Quit (or back from full-screen)"},
|
||||||
@@ -692,7 +756,8 @@ func (a App) viewMain() string {
|
|||||||
case 1:
|
case 1:
|
||||||
content = a.renderCostsTab(cw)
|
content = a.renderCostsTab(cw)
|
||||||
case 2:
|
case 2:
|
||||||
content = a.renderSessionsContent(a.filtered, cw, contentH)
|
searchFiltered := a.getSearchFilteredSessions()
|
||||||
|
content = a.renderSessionsContent(searchFiltered, cw, contentH)
|
||||||
case 3:
|
case 3:
|
||||||
content = a.renderBreakdownTab(cw)
|
content = a.renderBreakdownTab(cw)
|
||||||
case 4:
|
case 4:
|
||||||
@@ -971,3 +1036,65 @@ func fetchSubDataCmd(sessionKey string) tea.Cmd {
|
|||||||
return SubDataMsg{Data: client.FetchAll(ctx)}
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/claudeai"
|
"github.com/theirongolddev/cburn/internal/claudeai"
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
"cburn/internal/tui/components"
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/progress"
|
"github.com/charmbracelet/bubbles/progress"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -20,8 +20,8 @@ import (
|
|||||||
func (a App) renderCostsTab(cw int) string {
|
func (a App) renderCostsTab(cw int) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
stats := a.stats
|
stats := a.stats
|
||||||
models := a.models
|
|
||||||
days := a.dailyStats
|
days := a.dailyStats
|
||||||
|
modelCosts := a.modelCosts
|
||||||
|
|
||||||
var b strings.Builder
|
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(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
|
||||||
tableBody.WriteString("\n")
|
tableBody.WriteString("\n")
|
||||||
|
|
||||||
for _, ms := range models {
|
for _, mc := range modelCosts {
|
||||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s",
|
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s",
|
||||||
nameW,
|
nameW,
|
||||||
truncStr(shortModel(ms.Model), nameW),
|
truncStr(shortModel(mc.Model), nameW),
|
||||||
cli.FormatCost(ms.EstimatedCost))))
|
cli.FormatCost(mc.TotalCost))))
|
||||||
tableBody.WriteString("\n")
|
tableBody.WriteString("\n")
|
||||||
}
|
}
|
||||||
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
|
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(mutedStyle.Render(strings.Repeat("─", innerW)))
|
||||||
tableBody.WriteString("\n")
|
tableBody.WriteString("\n")
|
||||||
|
|
||||||
for _, ms := range models {
|
for _, mc := range modelCosts {
|
||||||
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
|
|
||||||
|
|
||||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s",
|
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s",
|
||||||
nameW,
|
nameW,
|
||||||
truncStr(shortModel(ms.Model), nameW),
|
truncStr(shortModel(mc.Model), nameW),
|
||||||
cli.FormatCost(inputCost),
|
cli.FormatCost(mc.InputCost),
|
||||||
cli.FormatCost(outputCost),
|
cli.FormatCost(mc.OutputCost),
|
||||||
cli.FormatCost(cacheCost),
|
cli.FormatCost(mc.CacheCost),
|
||||||
cli.FormatCost(ms.EstimatedCost))))
|
cli.FormatCost(mc.TotalCost))))
|
||||||
tableBody.WriteString("\n")
|
tableBody.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
"cburn/internal/tui/components"
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -76,8 +76,38 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str
|
|||||||
t := theme.Active
|
t := theme.Active
|
||||||
ss := a.sessState
|
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 {
|
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.
|
// 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
|
t := theme.Active
|
||||||
ss := a.sessState
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,11 +169,11 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
offset := ss.offset
|
offset := ss.offset
|
||||||
if ss.cursor < offset {
|
if cursor < offset {
|
||||||
offset = ss.cursor
|
offset = cursor
|
||||||
}
|
}
|
||||||
if ss.cursor >= offset+visible {
|
if cursor >= offset+visible {
|
||||||
offset = ss.cursor - visible + 1
|
offset = cursor - visible + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
end := offset + visible
|
end := offset + visible
|
||||||
@@ -158,7 +197,7 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
|
|||||||
padN = 1
|
padN = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if i == ss.cursor {
|
if i == cursor {
|
||||||
fullLine := leftPart + strings.Repeat(" ", padN) + costStr
|
fullLine := leftPart + strings.Repeat(" ", padN) + costStr
|
||||||
// Pad to full width for continuous highlight background
|
// Pad to full width for continuous highlight background
|
||||||
if len(fullLine) < leftInner {
|
if len(fullLine) < leftInner {
|
||||||
@@ -175,10 +214,15 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
|
|||||||
leftBody.WriteString("\n")
|
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
|
// Right pane: full session detail with scroll support
|
||||||
sel := sessions[ss.cursor]
|
sel := sessions[cursor]
|
||||||
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle)
|
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle)
|
||||||
|
|
||||||
// Apply detail scroll offset
|
// Apply detail scroll offset
|
||||||
@@ -194,10 +238,15 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin
|
|||||||
t := theme.Active
|
t := theme.Active
|
||||||
ss := a.sessState
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
sel := sessions[ss.cursor]
|
sel := sessions[cursor]
|
||||||
|
|
||||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
||||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||||
@@ -266,14 +315,14 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
|
|||||||
savings := 0.0
|
savings := 0.0
|
||||||
|
|
||||||
for modelName, mu := range sel.Models {
|
for modelName, mu := range sel.Models {
|
||||||
p, ok := config.LookupPricing(modelName)
|
p, ok := config.LookupPricingAt(modelName, sel.StartTime)
|
||||||
if ok {
|
if ok {
|
||||||
inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1e6
|
inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1e6
|
||||||
outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1e6
|
outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1e6
|
||||||
cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1e6
|
cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1e6
|
||||||
cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1e6
|
cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1e6
|
||||||
cacheReadCost += float64(mu.CacheReadTokens) * p.CacheReadPerMTok / 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")
|
body.WriteString("\n")
|
||||||
if w < compactWidth {
|
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 {
|
} 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()
|
return body.String()
|
||||||
|
|||||||
Reference in New Issue
Block a user