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
245 lines
6.4 KiB
Go
245 lines
6.4 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"cburn/internal/cli"
|
|
"cburn/internal/config"
|
|
"cburn/internal/tui/components"
|
|
"cburn/internal/tui/theme"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
const (
|
|
settingsFieldAPIKey = iota
|
|
settingsFieldSessionKey
|
|
settingsFieldTheme
|
|
settingsFieldDays
|
|
settingsFieldBudget
|
|
settingsFieldCount // sentinel
|
|
)
|
|
|
|
// settingsFieldCount is used by app.go for cursor bounds checking
|
|
|
|
// settingsState tracks the settings tab state.
|
|
type settingsState struct {
|
|
cursor int
|
|
editing bool
|
|
input textinput.Model
|
|
saved bool // flash "saved" message briefly
|
|
saveErr error // non-nil if last save failed
|
|
}
|
|
|
|
func newSettingsInput() textinput.Model {
|
|
ti := textinput.New()
|
|
ti.CharLimit = 256
|
|
ti.Width = 50
|
|
return ti
|
|
}
|
|
|
|
func (a App) settingsStartEdit() (tea.Model, tea.Cmd) {
|
|
cfg, _ := config.Load()
|
|
a.settings.editing = true
|
|
a.settings.saved = false
|
|
|
|
ti := newSettingsInput()
|
|
|
|
switch a.settings.cursor {
|
|
case settingsFieldAPIKey:
|
|
ti.Placeholder = "sk-ant-admin-..."
|
|
ti.EchoMode = textinput.EchoPassword
|
|
ti.EchoCharacter = '*'
|
|
existing := config.GetAdminAPIKey(cfg)
|
|
if existing != "" {
|
|
ti.SetValue(existing)
|
|
}
|
|
case settingsFieldSessionKey:
|
|
ti.Placeholder = "sk-ant-sid..."
|
|
ti.EchoMode = textinput.EchoPassword
|
|
ti.EchoCharacter = '*'
|
|
existing := config.GetSessionKey(cfg)
|
|
if existing != "" {
|
|
ti.SetValue(existing)
|
|
}
|
|
case settingsFieldTheme:
|
|
ti.Placeholder = "flexoki-dark, catppuccin-mocha, tokyo-night, terminal"
|
|
ti.SetValue(cfg.Appearance.Theme)
|
|
ti.EchoMode = textinput.EchoNormal
|
|
case settingsFieldDays:
|
|
ti.Placeholder = "30"
|
|
ti.SetValue(strconv.Itoa(cfg.General.DefaultDays))
|
|
ti.EchoMode = textinput.EchoNormal
|
|
case settingsFieldBudget:
|
|
ti.Placeholder = "500 (monthly USD, leave empty to clear)"
|
|
if cfg.Budget.MonthlyUSD != nil {
|
|
ti.SetValue(fmt.Sprintf("%.0f", *cfg.Budget.MonthlyUSD))
|
|
}
|
|
ti.EchoMode = textinput.EchoNormal
|
|
}
|
|
|
|
ti.Focus()
|
|
a.settings.input = ti
|
|
return a, ti.Cursor.BlinkCmd()
|
|
}
|
|
|
|
func (a App) updateSettingsInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
key := msg.String()
|
|
|
|
switch key {
|
|
case "enter":
|
|
a.settingsSave()
|
|
a.settings.editing = false
|
|
a.settings.saved = a.settings.saveErr == nil
|
|
return a, nil
|
|
case "esc":
|
|
a.settings.editing = false
|
|
return a, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
a.settings.input, cmd = a.settings.input.Update(msg)
|
|
return a, cmd
|
|
}
|
|
|
|
func (a *App) settingsSave() {
|
|
cfg, _ := config.Load()
|
|
val := strings.TrimSpace(a.settings.input.Value())
|
|
|
|
switch a.settings.cursor {
|
|
case settingsFieldAPIKey:
|
|
cfg.AdminAPI.APIKey = val
|
|
case settingsFieldSessionKey:
|
|
cfg.ClaudeAI.SessionKey = val
|
|
case settingsFieldTheme:
|
|
// Validate theme name
|
|
found := false
|
|
for _, t := range theme.All {
|
|
if t.Name == val {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if found {
|
|
cfg.Appearance.Theme = val
|
|
theme.SetActive(val)
|
|
}
|
|
case settingsFieldDays:
|
|
var d int
|
|
if _, err := fmt.Sscanf(val, "%d", &d); err == nil && d > 0 {
|
|
cfg.General.DefaultDays = d
|
|
a.days = d
|
|
a.recompute()
|
|
}
|
|
case settingsFieldBudget:
|
|
if val == "" {
|
|
cfg.Budget.MonthlyUSD = nil
|
|
} else {
|
|
var b float64
|
|
if _, err := fmt.Sscanf(val, "%f", &b); err == nil && b > 0 {
|
|
cfg.Budget.MonthlyUSD = &b
|
|
}
|
|
}
|
|
}
|
|
|
|
a.settings.saveErr = config.Save(cfg)
|
|
}
|
|
|
|
func (a App) renderSettingsTab(cw int) string {
|
|
t := theme.Active
|
|
cfg, _ := config.Load()
|
|
|
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
|
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
|
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface).Bold(true)
|
|
accentStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
|
greenStyle := lipgloss.NewStyle().Foreground(t.Green)
|
|
|
|
type field struct {
|
|
label string
|
|
value string
|
|
}
|
|
|
|
apiKeyDisplay := "(not set)"
|
|
existingKey := config.GetAdminAPIKey(cfg)
|
|
if existingKey != "" {
|
|
if len(existingKey) > 12 {
|
|
apiKeyDisplay = existingKey[:8] + "..." + existingKey[len(existingKey)-4:]
|
|
} else {
|
|
apiKeyDisplay = "****"
|
|
}
|
|
}
|
|
|
|
sessionKeyDisplay := "(not set)"
|
|
existingSession := config.GetSessionKey(cfg)
|
|
if existingSession != "" {
|
|
if len(existingSession) > 16 {
|
|
sessionKeyDisplay = existingSession[:12] + "..." + existingSession[len(existingSession)-4:]
|
|
} else {
|
|
sessionKeyDisplay = "****"
|
|
}
|
|
}
|
|
|
|
fields := []field{
|
|
{"Admin API Key", apiKeyDisplay},
|
|
{"Session Key", sessionKeyDisplay},
|
|
{"Theme", cfg.Appearance.Theme},
|
|
{"Default Days", strconv.Itoa(cfg.General.DefaultDays)},
|
|
{"Monthly Budget", func() string {
|
|
if cfg.Budget.MonthlyUSD != nil {
|
|
return fmt.Sprintf("$%.0f", *cfg.Budget.MonthlyUSD)
|
|
}
|
|
return "(not set)"
|
|
}()},
|
|
}
|
|
|
|
var formBody strings.Builder
|
|
for i, f := range fields {
|
|
// Show text input if currently editing this field
|
|
if a.settings.editing && i == a.settings.cursor {
|
|
formBody.WriteString(accentStyle.Render(fmt.Sprintf("> %-18s ", f.label)))
|
|
formBody.WriteString(a.settings.input.View())
|
|
formBody.WriteString("\n")
|
|
continue
|
|
}
|
|
|
|
line := fmt.Sprintf("%-20s %s", f.label+":", f.value)
|
|
if i == a.settings.cursor {
|
|
formBody.WriteString(selectedStyle.Render(line))
|
|
} else {
|
|
formBody.WriteString(labelStyle.Render(fmt.Sprintf("%-20s ", f.label+":")) + valueStyle.Render(f.value))
|
|
}
|
|
formBody.WriteString("\n")
|
|
}
|
|
|
|
if a.settings.saveErr != nil {
|
|
warnStyle := lipgloss.NewStyle().Foreground(t.Orange)
|
|
formBody.WriteString("\n")
|
|
formBody.WriteString(warnStyle.Render(fmt.Sprintf("Save failed: %s", a.settings.saveErr)))
|
|
} else if a.settings.saved {
|
|
formBody.WriteString("\n")
|
|
formBody.WriteString(greenStyle.Render("Saved!"))
|
|
}
|
|
|
|
formBody.WriteString("\n")
|
|
formBody.WriteString(labelStyle.Render("[j/k] navigate [Enter] edit [Esc] cancel"))
|
|
|
|
// General info card
|
|
var infoBody strings.Builder
|
|
infoBody.WriteString(labelStyle.Render("Data directory: ") + valueStyle.Render(a.claudeDir) + "\n")
|
|
infoBody.WriteString(labelStyle.Render("Sessions loaded: ") + valueStyle.Render(cli.FormatNumber(int64(len(a.sessions)))) + "\n")
|
|
infoBody.WriteString(labelStyle.Render("Load time: ") + valueStyle.Render(fmt.Sprintf("%.1fs", a.loadTime.Seconds())) + "\n")
|
|
infoBody.WriteString(labelStyle.Render("Config file: ") + valueStyle.Render(config.Path()))
|
|
|
|
var b strings.Builder
|
|
b.WriteString(components.ContentCard("Settings", formBody.String(), cw))
|
|
b.WriteString("\n")
|
|
b.WriteString(components.ContentCard("General", infoBody.String(), cw))
|
|
|
|
return b.String()
|
|
}
|