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.
This commit is contained in:
221
internal/tui/tab_settings.go
Normal file
221
internal/tui/tab_settings.go
Normal file
@@ -0,0 +1,221 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user