Major rewrite of the Bubble Tea dashboard, adding live claude.ai integration and splitting the monolithic app.go into focused tab modules. App model (app.go): - Integrate claudeai.Client for live subscription/rate-limit data - Add SubDataMsg and async fetch with periodic refresh (every 5 min) - Add spinner for loading states (charmbracelet/bubbles spinner) - Integrate huh form library for in-TUI setup wizard - Rework tab routing to dispatch to dedicated tab renderers - Add compact layout detection for narrow terminals (<100 cols) TUI setup wizard (setup.go): - Full huh-based setup flow embedded in the TUI (not just CLI) - Three-step form: credentials, preferences (time range + theme), confirm - Pre-populates from existing config, validates session key prefix - Returns to dashboard on completion with config auto-saved New tab modules: - tab_overview.go: summary cards (sessions, prompts, cost, time), daily activity sparkline, rate-limit progress bars from live subscription data - tab_breakdown.go: per-model usage table with calls, input/output tokens, cost, and share percentage; compact mode for narrow terminals - tab_costs.go: cost analysis with daily cost chart, model cost breakdown, cache efficiency metrics, and budget tracking with progress bar Rewritten tabs: - tab_sessions.go: paginated session browser with sort-by-cost/tokens/time, per-session detail view, model usage breakdown per session, improved navigation (j/k, enter/esc, n/p for pages) - tab_settings.go: updated to work with new theme struct and config fields
458 lines
13 KiB
Go
458 lines
13 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"cburn/internal/cli"
|
|
"cburn/internal/config"
|
|
"cburn/internal/model"
|
|
"cburn/internal/tui/components"
|
|
"cburn/internal/tui/theme"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// SessionsView modes — split is iota (0) so it's the default zero value.
|
|
const (
|
|
sessViewSplit = iota // List + full detail side by side (default)
|
|
sessViewDetail // Full-screen detail
|
|
)
|
|
|
|
// sessionsState holds the sessions tab state.
|
|
type sessionsState struct {
|
|
cursor int
|
|
viewMode int
|
|
offset int // scroll offset for the list
|
|
detailScroll int // scroll offset for the detail pane
|
|
}
|
|
|
|
func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) string {
|
|
t := theme.Active
|
|
ss := a.sessState
|
|
|
|
if len(filtered) == 0 {
|
|
return components.ContentCard("Sessions", lipgloss.NewStyle().Foreground(t.TextMuted).Render("No sessions found"), cw)
|
|
}
|
|
|
|
// Force single-pane detail mode in compact layouts.
|
|
if cw < compactWidth {
|
|
return a.renderSessionDetail(filtered, cw, h)
|
|
}
|
|
|
|
switch ss.viewMode {
|
|
case sessViewDetail:
|
|
return a.renderSessionDetail(filtered, cw, h)
|
|
default:
|
|
return a.renderSessionsSplit(filtered, cw, h)
|
|
}
|
|
}
|
|
|
|
func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) string {
|
|
t := theme.Active
|
|
ss := a.sessState
|
|
|
|
if ss.cursor >= len(sessions) {
|
|
return ""
|
|
}
|
|
|
|
leftW := cw / 4
|
|
if leftW < 36 {
|
|
leftW = 36
|
|
}
|
|
minRightW := 50
|
|
maxLeftW := cw - minRightW
|
|
if maxLeftW < 20 {
|
|
return a.renderSessionDetail(sessions, cw, h)
|
|
}
|
|
if leftW > maxLeftW {
|
|
leftW = maxLeftW
|
|
}
|
|
rightW := cw - leftW
|
|
|
|
// Left pane: condensed session list
|
|
leftInner := components.CardInnerWidth(leftW)
|
|
|
|
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
|
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
|
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface).Bold(true)
|
|
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
|
|
|
var leftBody strings.Builder
|
|
visible := h - 6 // card border (2) + header row (2) + footer hint (2)
|
|
if visible < 5 {
|
|
visible = 5
|
|
}
|
|
|
|
offset := ss.offset
|
|
if ss.cursor < offset {
|
|
offset = ss.cursor
|
|
}
|
|
if ss.cursor >= offset+visible {
|
|
offset = ss.cursor - visible + 1
|
|
}
|
|
|
|
end := offset + visible
|
|
if end > len(sessions) {
|
|
end = len(sessions)
|
|
}
|
|
|
|
for i := offset; i < end; i++ {
|
|
s := sessions[i]
|
|
startStr := ""
|
|
if !s.StartTime.IsZero() {
|
|
startStr = s.StartTime.Local().Format("Jan 02 15:04")
|
|
}
|
|
dur := cli.FormatDuration(s.DurationSecs)
|
|
costStr := cli.FormatCost(s.EstimatedCost)
|
|
|
|
// Build left portion (date + duration) and right-align cost
|
|
leftPart := fmt.Sprintf("%-13s %s", startStr, dur)
|
|
padN := leftInner - len(leftPart) - len(costStr)
|
|
if padN < 1 {
|
|
padN = 1
|
|
}
|
|
|
|
if i == ss.cursor {
|
|
fullLine := leftPart + strings.Repeat(" ", padN) + costStr
|
|
// Pad to full width for continuous highlight background
|
|
if len(fullLine) < leftInner {
|
|
fullLine += strings.Repeat(" ", leftInner-len(fullLine))
|
|
}
|
|
leftBody.WriteString(selectedStyle.Render(fullLine))
|
|
} else {
|
|
leftBody.WriteString(
|
|
mutedStyle.Render(fmt.Sprintf("%-13s", startStr)) + " " +
|
|
rowStyle.Render(dur) +
|
|
strings.Repeat(" ", padN) +
|
|
mutedStyle.Render(costStr))
|
|
}
|
|
leftBody.WriteString("\n")
|
|
}
|
|
|
|
leftCard := components.ContentCard(fmt.Sprintf("Sessions [%dd]", a.days), leftBody.String(), leftW)
|
|
|
|
// Right pane: full session detail with scroll support
|
|
sel := sessions[ss.cursor]
|
|
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle)
|
|
|
|
// Apply detail scroll offset
|
|
rightBody = a.applyDetailScroll(rightBody, h-4) // card border (2) + title (1) + gap (1)
|
|
|
|
titleStr := "Session " + shortID(sel.SessionID)
|
|
rightCard := components.ContentCard(titleStr, rightBody, rightW)
|
|
|
|
return components.CardRow([]string{leftCard, rightCard})
|
|
}
|
|
|
|
func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) string {
|
|
t := theme.Active
|
|
ss := a.sessState
|
|
|
|
if ss.cursor >= len(sessions) {
|
|
return ""
|
|
}
|
|
sel := sessions[ss.cursor]
|
|
|
|
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
|
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
|
|
|
body := a.renderDetailBody(sel, cw, headerStyle, mutedStyle)
|
|
body = a.applyDetailScroll(body, h-4)
|
|
|
|
title := "Session " + shortID(sel.SessionID)
|
|
return components.ContentCard(title, body, cw)
|
|
}
|
|
|
|
// renderDetailBody generates the full detail content for a session.
|
|
// Used by both the split right pane and the full-screen detail view.
|
|
func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedStyle lipgloss.Style) string {
|
|
t := theme.Active
|
|
innerW := components.CardInnerWidth(w)
|
|
|
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
|
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
|
greenStyle := lipgloss.NewStyle().Foreground(t.Green)
|
|
|
|
var body strings.Builder
|
|
body.WriteString(mutedStyle.Render(sel.Project))
|
|
body.WriteString("\n")
|
|
body.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
|
|
body.WriteString("\n\n")
|
|
|
|
// Duration line
|
|
if !sel.StartTime.IsZero() {
|
|
durStr := cli.FormatDuration(sel.DurationSecs)
|
|
timeStr := sel.StartTime.Local().Format("15:04:05")
|
|
if !sel.EndTime.IsZero() {
|
|
timeStr += " - " + sel.EndTime.Local().Format("15:04:05")
|
|
}
|
|
timeStr += " " + sel.StartTime.Local().Format("MST")
|
|
fmt.Fprintf(&body, "%s %s (%s)\n",
|
|
labelStyle.Render("Duration:"),
|
|
valueStyle.Render(durStr),
|
|
mutedStyle.Render(timeStr))
|
|
}
|
|
|
|
ratio := 0.0
|
|
if sel.UserMessages > 0 {
|
|
ratio = float64(sel.APICalls) / float64(sel.UserMessages)
|
|
}
|
|
fmt.Fprintf(&body, "%s %s %s %s %s %.1fx\n\n",
|
|
labelStyle.Render("Prompts:"), valueStyle.Render(cli.FormatNumber(int64(sel.UserMessages))),
|
|
labelStyle.Render("API Calls:"), valueStyle.Render(cli.FormatNumber(int64(sel.APICalls))),
|
|
labelStyle.Render("Ratio:"), ratio)
|
|
|
|
// Token breakdown table
|
|
body.WriteString(headerStyle.Render("TOKEN BREAKDOWN"))
|
|
body.WriteString("\n")
|
|
typeW, tokW, costW, tableW := tokenTableLayout(innerW)
|
|
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %*s %*s", typeW, "Type", tokW, "Tokens", costW, "Cost")))
|
|
body.WriteString("\n")
|
|
body.WriteString(mutedStyle.Render(strings.Repeat("─", tableW)))
|
|
body.WriteString("\n")
|
|
|
|
// Calculate per-type costs (aggregate across models)
|
|
inputCost := 0.0
|
|
outputCost := 0.0
|
|
cache5mCost := 0.0
|
|
cache1hCost := 0.0
|
|
cacheReadCost := 0.0
|
|
savings := 0.0
|
|
|
|
for modelName, mu := range sel.Models {
|
|
p, ok := config.LookupPricing(modelName)
|
|
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)
|
|
}
|
|
}
|
|
|
|
rows := []struct {
|
|
typ string
|
|
tokens int64
|
|
cost float64
|
|
}{
|
|
{"Input", sel.InputTokens, inputCost},
|
|
{"Output", sel.OutputTokens, outputCost},
|
|
{"Cache Write (5m)", sel.CacheCreation5mTokens, cache5mCost},
|
|
{"Cache Write (1h)", sel.CacheCreation1hTokens, cache1hCost},
|
|
{"Cache Read", sel.CacheReadTokens, cacheReadCost},
|
|
}
|
|
|
|
for _, r := range rows {
|
|
if r.tokens == 0 {
|
|
continue
|
|
}
|
|
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %*s %*s",
|
|
typeW,
|
|
truncStr(r.typ, typeW),
|
|
tokW,
|
|
cli.FormatTokens(r.tokens),
|
|
costW,
|
|
cli.FormatCost(r.cost))))
|
|
body.WriteString("\n")
|
|
}
|
|
|
|
body.WriteString(mutedStyle.Render(strings.Repeat("─", tableW)))
|
|
body.WriteString("\n")
|
|
fmt.Fprintf(&body, "%-*s %*s %*s\n",
|
|
typeW,
|
|
valueStyle.Render("Net Cost"),
|
|
tokW,
|
|
"",
|
|
costW,
|
|
greenStyle.Render(cli.FormatCost(sel.EstimatedCost)))
|
|
fmt.Fprintf(&body, "%-*s %*s %*s\n",
|
|
typeW,
|
|
labelStyle.Render("Cache Savings"),
|
|
tokW,
|
|
"",
|
|
costW,
|
|
greenStyle.Render(cli.FormatCost(savings)))
|
|
|
|
// Model breakdown
|
|
if len(sel.Models) > 0 {
|
|
body.WriteString("\n")
|
|
body.WriteString(headerStyle.Render("API CALLS BY MODEL"))
|
|
body.WriteString("\n")
|
|
compactModelTable := innerW < 60
|
|
if compactModelTable {
|
|
modelW := innerW - 7 - 1 - 8
|
|
if modelW < 8 {
|
|
modelW = 8
|
|
}
|
|
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %8s", modelW, "Model", "Calls", "Cost")))
|
|
body.WriteString("\n")
|
|
body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+8+2)))
|
|
} else {
|
|
modelW := 14
|
|
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", modelW, "Model", "Calls", "Input", "Output", "Cost")))
|
|
body.WriteString("\n")
|
|
body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+10+10+8+4)))
|
|
}
|
|
body.WriteString("\n")
|
|
|
|
// Sort model names for deterministic display order
|
|
modelNames := make([]string, 0, len(sel.Models))
|
|
for name := range sel.Models {
|
|
modelNames = append(modelNames, name)
|
|
}
|
|
sort.Strings(modelNames)
|
|
|
|
for _, modelName := range modelNames {
|
|
mu := sel.Models[modelName]
|
|
if innerW < 60 {
|
|
modelW := innerW - 7 - 1 - 8
|
|
if modelW < 8 {
|
|
modelW = 8
|
|
}
|
|
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %8s",
|
|
modelW,
|
|
truncStr(shortModel(modelName), modelW),
|
|
cli.FormatNumber(int64(mu.APICalls)),
|
|
cli.FormatCost(mu.EstimatedCost))))
|
|
} else {
|
|
modelW := 14
|
|
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s",
|
|
modelW,
|
|
truncStr(shortModel(modelName), modelW),
|
|
cli.FormatNumber(int64(mu.APICalls)),
|
|
cli.FormatTokens(mu.InputTokens),
|
|
cli.FormatTokens(mu.OutputTokens),
|
|
cli.FormatCost(mu.EstimatedCost))))
|
|
}
|
|
body.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
if sel.IsSubagent {
|
|
body.WriteString("\n")
|
|
body.WriteString(mutedStyle.Render("(subagent session)"))
|
|
body.WriteString("\n")
|
|
}
|
|
|
|
// Subagent drill-down
|
|
if subs := a.subagentMap[sel.SessionID]; len(subs) > 0 {
|
|
body.WriteString("\n")
|
|
body.WriteString(headerStyle.Render(fmt.Sprintf("SUBAGENTS (%d)", len(subs))))
|
|
body.WriteString("\n")
|
|
|
|
nameW := innerW - 8 - 10 - 2
|
|
if nameW < 10 {
|
|
nameW = 10
|
|
}
|
|
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s", nameW, "Agent", "Duration", "Cost")))
|
|
body.WriteString("\n")
|
|
body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2)))
|
|
body.WriteString("\n")
|
|
|
|
var totalSubCost float64
|
|
var totalSubDur int64
|
|
for _, sub := range subs {
|
|
// Extract short agent name from session ID (e.g., "uuid/agent-acompact-7b10e8" -> "acompact-7b10e8")
|
|
agentName := sub.SessionID
|
|
if idx := strings.LastIndex(agentName, "/"); idx >= 0 {
|
|
agentName = agentName[idx+1:]
|
|
}
|
|
agentName = strings.TrimPrefix(agentName, "agent-")
|
|
|
|
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s",
|
|
nameW,
|
|
truncStr(agentName, nameW),
|
|
cli.FormatDuration(sub.DurationSecs),
|
|
cli.FormatCost(sub.EstimatedCost))))
|
|
body.WriteString("\n")
|
|
totalSubCost += sub.EstimatedCost
|
|
totalSubDur += sub.DurationSecs
|
|
}
|
|
|
|
body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2)))
|
|
body.WriteString("\n")
|
|
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s",
|
|
nameW,
|
|
"Combined",
|
|
cli.FormatDuration(totalSubDur),
|
|
cli.FormatCost(totalSubCost))))
|
|
body.WriteString("\n")
|
|
}
|
|
|
|
body.WriteString("\n")
|
|
if w < compactWidth {
|
|
body.WriteString(mutedStyle.Render("[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"))
|
|
}
|
|
|
|
return body.String()
|
|
}
|
|
|
|
func shortID(id string) string {
|
|
if len(id) > 8 {
|
|
return id[:8]
|
|
}
|
|
return id
|
|
}
|
|
|
|
// applyDetailScroll applies the detail pane scroll offset to a rendered body string.
|
|
// visibleH is the number of lines that fit in the card body area.
|
|
func (a App) applyDetailScroll(body string, visibleH int) string {
|
|
if visibleH < 5 {
|
|
visibleH = 5
|
|
}
|
|
|
|
lines := strings.Split(body, "\n")
|
|
if len(lines) <= visibleH {
|
|
return body
|
|
}
|
|
|
|
scrollOff := a.sessState.detailScroll
|
|
maxScroll := len(lines) - visibleH
|
|
if maxScroll < 0 {
|
|
maxScroll = 0
|
|
}
|
|
if scrollOff > maxScroll {
|
|
scrollOff = maxScroll
|
|
}
|
|
if scrollOff < 0 {
|
|
scrollOff = 0
|
|
}
|
|
|
|
endIdx := scrollOff + visibleH
|
|
if endIdx > len(lines) {
|
|
endIdx = len(lines)
|
|
}
|
|
visible := lines[scrollOff:endIdx]
|
|
|
|
// Add scroll indicator if content continues below.
|
|
// Count includes the line we're replacing + lines past the viewport.
|
|
if endIdx < len(lines) {
|
|
unseen := len(lines) - endIdx + 1
|
|
dimStyle := lipgloss.NewStyle().Foreground(theme.Active.TextDim)
|
|
visible[len(visible)-1] = dimStyle.Render(fmt.Sprintf("... %d more", unseen))
|
|
}
|
|
|
|
return strings.Join(visible, "\n")
|
|
}
|
|
|
|
func tokenTableLayout(innerW int) (typeW, tokenW, costW, tableW int) {
|
|
tokenW = 12
|
|
costW = 10
|
|
typeW = innerW - tokenW - costW - 2
|
|
if typeW < 8 {
|
|
tokenW = 8
|
|
costW = 8
|
|
typeW = innerW - tokenW - costW - 2
|
|
}
|
|
if typeW < 6 {
|
|
typeW = 6
|
|
}
|
|
tableW = typeW + tokenW + costW + 2
|
|
return
|
|
}
|