feat: overhaul TUI dashboard with subscription data, new tabs, and setup wizard
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
This commit is contained in:
@@ -2,151 +2,125 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cburn/internal/config"
|
||||
"cburn/internal/tui/theme"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
// setupState tracks the first-run setup wizard state.
|
||||
type setupState struct {
|
||||
active bool
|
||||
step int // 0=welcome, 1=api key, 2=days, 3=theme, 4=done
|
||||
apiKeyIn textinput.Model
|
||||
daysChoice int // index into daysOptions
|
||||
themeChoice int // index into theme.All
|
||||
saveErr error // non-nil if config save failed
|
||||
// setupValues holds the form-bound variables for the setup wizard.
|
||||
type setupValues struct {
|
||||
sessionKey string
|
||||
adminKey string
|
||||
days int
|
||||
theme string
|
||||
}
|
||||
|
||||
var daysOptions = []struct {
|
||||
label string
|
||||
value int
|
||||
}{
|
||||
{"7 days", 7},
|
||||
{"30 days", 30},
|
||||
{"90 days", 90},
|
||||
}
|
||||
|
||||
func newSetupState() setupState {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "sk-ant-admin-... (or press Enter to skip)"
|
||||
ti.CharLimit = 256
|
||||
ti.Width = 50
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.EchoCharacter = '*'
|
||||
|
||||
return setupState{
|
||||
apiKeyIn: ti,
|
||||
daysChoice: 1, // default 30 days
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) renderSetup() string {
|
||||
t := theme.Active
|
||||
ss := a.setup
|
||||
|
||||
titleStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
||||
accentStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
||||
greenStyle := lipgloss.NewStyle().Foreground(t.Green)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(titleStyle.Render(" Welcome to cburn!"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(labelStyle.Render(fmt.Sprintf(" Found %s sessions in %s",
|
||||
valueStyle.Render(fmt.Sprintf("%d", len(a.sessions))),
|
||||
valueStyle.Render(a.claudeDir))))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
switch ss.step {
|
||||
case 0: // Welcome
|
||||
b.WriteString(valueStyle.Render(" Let's set up a few things."))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(accentStyle.Render(" Press Enter to continue"))
|
||||
|
||||
case 1: // API key
|
||||
b.WriteString(valueStyle.Render(" 1. Anthropic Admin API key"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(labelStyle.Render(" For real cost data from the billing API."))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(labelStyle.Render(" Get one at console.anthropic.com > Settings > Admin API keys"))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(" ")
|
||||
b.WriteString(ss.apiKeyIn.View())
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(labelStyle.Render(" Press Enter to continue (leave blank to skip)"))
|
||||
|
||||
case 2: // Default days
|
||||
b.WriteString(valueStyle.Render(" 2. Default time range"))
|
||||
b.WriteString("\n\n")
|
||||
for i, opt := range daysOptions {
|
||||
if i == ss.daysChoice {
|
||||
b.WriteString(accentStyle.Render(fmt.Sprintf(" (o) %s", opt.label)))
|
||||
} else {
|
||||
b.WriteString(labelStyle.Render(fmt.Sprintf(" ( ) %s", opt.label)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(labelStyle.Render(" j/k to select, Enter to confirm"))
|
||||
|
||||
case 3: // Theme
|
||||
b.WriteString(valueStyle.Render(" 3. Color theme"))
|
||||
b.WriteString("\n\n")
|
||||
for i, th := range theme.All {
|
||||
if i == ss.themeChoice {
|
||||
b.WriteString(accentStyle.Render(fmt.Sprintf(" (o) %s", th.Name)))
|
||||
} else {
|
||||
b.WriteString(labelStyle.Render(fmt.Sprintf(" ( ) %s", th.Name)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(labelStyle.Render(" j/k to select, Enter to confirm"))
|
||||
|
||||
case 4: // Done
|
||||
if ss.saveErr != nil {
|
||||
warnStyle := lipgloss.NewStyle().Foreground(t.Orange)
|
||||
b.WriteString(warnStyle.Render(fmt.Sprintf(" Could not save config: %s", ss.saveErr)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(labelStyle.Render(" Settings will apply for this session only."))
|
||||
} else {
|
||||
b.WriteString(greenStyle.Render(" All set!"))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(labelStyle.Render(" Saved to ~/.config/cburn/config.toml"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(labelStyle.Render(" Run `cburn setup` anytime to reconfigure."))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(accentStyle.Render(" Press Enter to launch the dashboard"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (a *App) saveSetupConfig() {
|
||||
// newSetupForm builds the huh form for first-run configuration.
|
||||
func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.Form {
|
||||
cfg, _ := config.Load()
|
||||
|
||||
apiKey := strings.TrimSpace(a.setup.apiKeyIn.Value())
|
||||
if apiKey != "" {
|
||||
cfg.AdminAPI.APIKey = apiKey
|
||||
// Pre-populate defaults
|
||||
vals.days = cfg.General.DefaultDays
|
||||
if vals.days == 0 {
|
||||
vals.days = 30
|
||||
}
|
||||
vals.theme = cfg.Appearance.Theme
|
||||
if vals.theme == "" {
|
||||
vals.theme = "flexoki-dark"
|
||||
}
|
||||
|
||||
if a.setup.daysChoice >= 0 && a.setup.daysChoice < len(daysOptions) {
|
||||
cfg.General.DefaultDays = daysOptions[a.setup.daysChoice].value
|
||||
a.days = cfg.General.DefaultDays
|
||||
// Build welcome text
|
||||
welcomeDesc := "Let's configure your dashboard."
|
||||
if numSessions > 0 {
|
||||
welcomeDesc = fmt.Sprintf("Found %d sessions in %s.", numSessions, claudeDir)
|
||||
}
|
||||
|
||||
if a.setup.themeChoice >= 0 && a.setup.themeChoice < len(theme.All) {
|
||||
cfg.Appearance.Theme = theme.All[a.setup.themeChoice].Name
|
||||
theme.SetActive(cfg.Appearance.Theme)
|
||||
// Placeholder text for key fields
|
||||
sessionPlaceholder := "sk-ant-sid... (Enter to skip)"
|
||||
if key := config.GetSessionKey(cfg); key != "" {
|
||||
sessionPlaceholder = maskKey(key) + " (Enter to keep)"
|
||||
}
|
||||
adminPlaceholder := "sk-ant-admin-... (Enter to skip)"
|
||||
if key := config.GetAdminAPIKey(cfg); key != "" {
|
||||
adminPlaceholder = maskKey(key) + " (Enter to keep)"
|
||||
}
|
||||
|
||||
a.setup.saveErr = config.Save(cfg)
|
||||
// Build theme options from the registered theme list
|
||||
themeOpts := make([]huh.Option[string], len(theme.All))
|
||||
for i, t := range theme.All {
|
||||
themeOpts[i] = huh.NewOption(t.Name, t.Name)
|
||||
}
|
||||
|
||||
return huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("Welcome to cburn").
|
||||
Description(welcomeDesc).
|
||||
Next(true).
|
||||
NextLabel("Start"),
|
||||
),
|
||||
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Claude.ai session key").
|
||||
Description("For rate-limit and subscription data.\nclaude.ai > DevTools > Application > Cookies > sessionKey").
|
||||
Placeholder(sessionPlaceholder).
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&vals.sessionKey),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Anthropic Admin API key").
|
||||
Description("For real cost data from the billing API.").
|
||||
Placeholder(adminPlaceholder).
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&vals.adminKey),
|
||||
),
|
||||
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Default time range").
|
||||
Options(
|
||||
huh.NewOption("7 days", 7),
|
||||
huh.NewOption("30 days", 30),
|
||||
huh.NewOption("90 days", 90),
|
||||
).
|
||||
Value(&vals.days),
|
||||
|
||||
huh.NewSelect[string]().
|
||||
Title("Color theme").
|
||||
Options(themeOpts...).
|
||||
Value(&vals.theme),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula()).WithShowHelp(false)
|
||||
}
|
||||
|
||||
// saveSetupConfig persists the setup wizard values to the config file.
|
||||
func (a *App) saveSetupConfig() error {
|
||||
cfg, _ := config.Load()
|
||||
|
||||
if a.setupVals.sessionKey != "" {
|
||||
cfg.ClaudeAI.SessionKey = a.setupVals.sessionKey
|
||||
}
|
||||
if a.setupVals.adminKey != "" {
|
||||
cfg.AdminAPI.APIKey = a.setupVals.adminKey
|
||||
}
|
||||
cfg.General.DefaultDays = a.setupVals.days
|
||||
a.days = a.setupVals.days
|
||||
|
||||
cfg.Appearance.Theme = a.setupVals.theme
|
||||
theme.SetActive(a.setupVals.theme)
|
||||
|
||||
return config.Save(cfg)
|
||||
}
|
||||
|
||||
func maskKey(key string) string {
|
||||
if len(key) > 16 {
|
||||
return key[:8] + "..." + key[len(key)-4:]
|
||||
}
|
||||
if len(key) > 4 {
|
||||
return key[:4] + "..."
|
||||
}
|
||||
return "****"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user