Files
cburn/internal/tui/tab_settings.go
teernisse 901090f921 feat(tui): apply visual polish to dashboard tabs
Update all tab renderers to leverage the expanded theme palette and
polished components:

tab_overview.go:
- Use PanelCard (accent-bordered variant) for daily token chart
- Multi-color model bars: BlueBright, Cyan, Magenta, Yellow, Green
  for visual distinction between models
- Pre-compute styles outside loops for better performance
- Use Cyan for today's hourly chart, Magenta for last-hour chart

tab_breakdown.go:
- Apply consistent background styling
- Use new accent variants for visual hierarchy

tab_costs.go:
- Proper background fill on cost tables
- Accent coloring for cost highlights

tab_sessions.go:
- Background continuity in session list
- Visual improvements to session detail view

tab_settings.go:
- Consistent styling with other tabs

The result is a dashboard where each tab feels visually cohesive,
with color providing semantic meaning (different colors for different
models, metrics, and states) rather than arbitrary decoration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:05:49 -05:00

297 lines
8.8 KiB
Go

package tui
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/theirongolddev/cburn/internal/cli"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/tui/components"
"github.com/theirongolddev/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
settingsFieldAutoRefresh
settingsFieldRefreshInterval
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 := loadConfigOrDefault()
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
case settingsFieldAutoRefresh:
ti.Placeholder = "true or false"
ti.SetValue(strconv.FormatBool(a.autoRefresh))
ti.EchoMode = textinput.EchoNormal
case settingsFieldRefreshInterval:
ti.Placeholder = "30 (seconds, minimum 10)"
// Use effective value from App state to match display
intervalSec := int(a.refreshInterval.Seconds())
if intervalSec < 10 {
intervalSec = 30
}
ti.SetValue(strconv.Itoa(intervalSec))
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 := loadConfigOrDefault()
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
}
}
case settingsFieldAutoRefresh:
cfg.TUI.AutoRefresh = val == "true" || val == "1" || val == "yes"
a.autoRefresh = cfg.TUI.AutoRefresh
case settingsFieldRefreshInterval:
var interval int
if _, err := fmt.Sscanf(val, "%d", &interval); err == nil && interval >= 10 {
cfg.TUI.RefreshIntervalSec = interval
a.refreshInterval = time.Duration(interval) * time.Second
}
}
a.settings.saveErr = config.Save(cfg)
}
func (a App) renderSettingsTab(cw int) string {
t := theme.Active
cfg := loadConfigOrDefault()
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.SurfaceBright).Bold(true)
selectedLabelStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.SurfaceBright).Bold(true)
accentStyle := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.Surface)
greenStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface)
markerStyle := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.SurfaceBright)
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 = "****"
}
}
// Use live App state for TUI-specific settings (auto-refresh, interval)
// to ensure display matches actual behavior after R toggle
refreshIntervalSec := int(a.refreshInterval.Seconds())
if refreshIntervalSec < 10 {
refreshIntervalSec = 30 // match the effective default
}
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)"
}()},
{"Auto Refresh", strconv.FormatBool(a.autoRefresh)},
{"Refresh Interval", fmt.Sprintf("%ds", refreshIntervalSec)},
}
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(markerStyle.Render("▸ "))
formBody.WriteString(accentStyle.Render(fmt.Sprintf("%-18s ", f.label)))
formBody.WriteString(a.settings.input.View())
formBody.WriteString("\n")
continue
}
if i == a.settings.cursor {
// Selected row with marker and highlight
marker := markerStyle.Render("▸ ")
label := selectedLabelStyle.Render(fmt.Sprintf("%-18s ", f.label+":"))
value := selectedStyle.Render(f.value)
formBody.WriteString(marker)
formBody.WriteString(label)
formBody.WriteString(value)
// Use lipgloss.Width() for correct visual width calculation
usedWidth := lipgloss.Width(marker) + lipgloss.Width(label) + lipgloss.Width(value)
innerW := components.CardInnerWidth(cw)
padLen := innerW - usedWidth
if padLen > 0 {
formBody.WriteString(lipgloss.NewStyle().Background(t.SurfaceBright).Render(strings.Repeat(" ", padLen)))
}
} else {
// Normal row
formBody.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(" "))
formBody.WriteString(labelStyle.Render(fmt.Sprintf("%-18s ", f.label+":")))
formBody.WriteString(valueStyle.Render(f.value))
}
formBody.WriteString("\n")
}
if a.settings.saveErr != nil {
warnStyle := lipgloss.NewStyle().Foreground(t.Orange).Background(t.Surface)
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()
}