Files
cburn/internal/tui/tab_settings.go
teernisse b69b24c107 feat: add TUI feature modules for setup wizard, session browser, and settings
Add three self-contained feature modules that plug into the root App
model via shared state structs and render methods.

setup.go -- First-run wizard with a 5-step flow: welcome screen, API
key entry (password-masked text input), default time range selector
(radio-style j/k navigation over 7/30/90 day options), theme picker
(radio-style over all registered themes), and a completion screen
that persists choices to ~/.config/cburn/config.toml. Gracefully
handles save failures by noting the settings apply for the current
session only.

tab_sessions.go -- Session browser with two view modes: split view
(1/3 condensed list + 2/3 detail pane, scrollable with offset
tracking) and full-screen detail. The detail body shows duration,
prompt/API-call ratio, per-type token breakdown with cache cost
attribution, per-model API call table, and subagent indicator.

tab_settings.go -- Runtime settings editor with 4 configurable
fields (API key, theme, default days, monthly budget). Supports
inline text input editing with Enter/Esc save/cancel flow, immediate
config persistence, flash "Saved!" confirmation, and error display
on save failure. Theme changes apply instantly without restart.
2026-02-19 15:01:47 -05:00

222 lines
5.8 KiB
Go

package tui
import (
"fmt"
"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
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 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(fmt.Sprintf("%d", 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 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 = "****"
}
}
fields := []field{
{"Admin API Key", apiKeyDisplay},
{"Theme", cfg.Appearance.Theme},
{"Default Days", fmt.Sprintf("%d", 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.ConfigPath()))
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()
}