Files
cburn/internal/tui/tab_settings.go
teernisse 35fae37ba4 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
2026-02-20 16:08:26 -05:00

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()
}