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.
222 lines
5.8 KiB
Go
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()
|
|
}
|