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:
teernisse
2026-02-19 15:01:34 -05:00
parent 04abdafa9a
commit b69b24c107
3 changed files with 658 additions and 0 deletions

152
internal/tui/setup.go Normal file
View File

@@ -0,0 +1,152 @@
package tui
import (
"fmt"
"strings"
"cburn/internal/config"
"cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
)
// setupState tracks the first-run setup wizard state.
type setupState struct {
active bool
step int // 0=welcome, 1=api key, 2=days, 3=theme, 4=done
apiKeyIn textinput.Model
daysChoice int // index into daysOptions
themeChoice int // index into theme.All
saveErr error // non-nil if config save failed
}
var daysOptions = []struct {
label string
value int
}{
{"7 days", 7},
{"30 days", 30},
{"90 days", 90},
}
func newSetupState() setupState {
ti := textinput.New()
ti.Placeholder = "sk-ant-admin-... (or press Enter to skip)"
ti.CharLimit = 256
ti.Width = 50
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '*'
return setupState{
apiKeyIn: ti,
daysChoice: 1, // default 30 days
}
}
func (a App) renderSetup() string {
t := theme.Active
ss := a.setup
titleStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
accentStyle := lipgloss.NewStyle().Foreground(t.Accent)
greenStyle := lipgloss.NewStyle().Foreground(t.Green)
var b strings.Builder
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" Welcome to cburn!"))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render(fmt.Sprintf(" Found %s sessions in %s",
valueStyle.Render(fmt.Sprintf("%d", len(a.sessions))),
valueStyle.Render(a.claudeDir))))
b.WriteString("\n\n")
switch ss.step {
case 0: // Welcome
b.WriteString(valueStyle.Render(" Let's set up a few things."))
b.WriteString("\n\n")
b.WriteString(accentStyle.Render(" Press Enter to continue"))
case 1: // API key
b.WriteString(valueStyle.Render(" 1. Anthropic Admin API key"))
b.WriteString("\n")
b.WriteString(labelStyle.Render(" For real cost data from the billing API."))
b.WriteString("\n")
b.WriteString(labelStyle.Render(" Get one at console.anthropic.com > Settings > Admin API keys"))
b.WriteString("\n\n")
b.WriteString(" ")
b.WriteString(ss.apiKeyIn.View())
b.WriteString("\n\n")
b.WriteString(labelStyle.Render(" Press Enter to continue (leave blank to skip)"))
case 2: // Default days
b.WriteString(valueStyle.Render(" 2. Default time range"))
b.WriteString("\n\n")
for i, opt := range daysOptions {
if i == ss.daysChoice {
b.WriteString(accentStyle.Render(fmt.Sprintf(" (o) %s", opt.label)))
} else {
b.WriteString(labelStyle.Render(fmt.Sprintf(" ( ) %s", opt.label)))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(labelStyle.Render(" j/k to select, Enter to confirm"))
case 3: // Theme
b.WriteString(valueStyle.Render(" 3. Color theme"))
b.WriteString("\n\n")
for i, th := range theme.All {
if i == ss.themeChoice {
b.WriteString(accentStyle.Render(fmt.Sprintf(" (o) %s", th.Name)))
} else {
b.WriteString(labelStyle.Render(fmt.Sprintf(" ( ) %s", th.Name)))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(labelStyle.Render(" j/k to select, Enter to confirm"))
case 4: // Done
if ss.saveErr != nil {
warnStyle := lipgloss.NewStyle().Foreground(t.Orange)
b.WriteString(warnStyle.Render(fmt.Sprintf(" Could not save config: %s", ss.saveErr)))
b.WriteString("\n")
b.WriteString(labelStyle.Render(" Settings will apply for this session only."))
} else {
b.WriteString(greenStyle.Render(" All set!"))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render(" Saved to ~/.config/cburn/config.toml"))
b.WriteString("\n")
b.WriteString(labelStyle.Render(" Run `cburn setup` anytime to reconfigure."))
}
b.WriteString("\n\n")
b.WriteString(accentStyle.Render(" Press Enter to launch the dashboard"))
}
return b.String()
}
func (a *App) saveSetupConfig() {
cfg, _ := config.Load()
apiKey := strings.TrimSpace(a.setup.apiKeyIn.Value())
if apiKey != "" {
cfg.AdminAPI.APIKey = apiKey
}
if a.setup.daysChoice >= 0 && a.setup.daysChoice < len(daysOptions) {
cfg.General.DefaultDays = daysOptions[a.setup.daysChoice].value
a.days = cfg.General.DefaultDays
}
if a.setup.themeChoice >= 0 && a.setup.themeChoice < len(theme.All) {
cfg.Appearance.Theme = theme.All[a.setup.themeChoice].Name
theme.SetActive(cfg.Appearance.Theme)
}
a.setup.saveErr = config.Save(cfg)
}

View File

@@ -0,0 +1,285 @@
package tui
import (
"fmt"
"sort"
"strings"
"cburn/internal/cli"
"cburn/internal/config"
"cburn/internal/model"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
)
// SessionsView modes — split is iota (0) so it's the default zero value.
const (
sessViewSplit = iota // List + full detail side by side (default)
sessViewDetail // Full-screen detail
)
// sessionsState holds the sessions tab state.
type sessionsState struct {
cursor int
viewMode int
offset int // scroll offset for the list
}
func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) string {
t := theme.Active
ss := a.sessState
if len(filtered) == 0 {
return components.ContentCard("Sessions", lipgloss.NewStyle().Foreground(t.TextMuted).Render("No sessions found"), cw)
}
switch ss.viewMode {
case sessViewDetail:
return a.renderSessionDetail(filtered, cw, h)
default:
return a.renderSessionsSplit(filtered, cw, h)
}
}
func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) string {
t := theme.Active
ss := a.sessState
if ss.cursor >= len(sessions) {
return ""
}
leftW := cw / 3
if leftW < 30 {
leftW = 30
}
rightW := cw - leftW
// Left pane: condensed session list
leftInner := components.CardInnerWidth(leftW)
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface).Bold(true)
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
var leftBody strings.Builder
visible := h - 6 // card border (2) + header row (2) + footer hint (2)
if visible < 5 {
visible = 5
}
offset := ss.offset
if ss.cursor < offset {
offset = ss.cursor
}
if ss.cursor >= offset+visible {
offset = ss.cursor - visible + 1
}
end := offset + visible
if end > len(sessions) {
end = len(sessions)
}
for i := offset; i < end; i++ {
s := sessions[i]
startStr := ""
if !s.StartTime.IsZero() {
startStr = s.StartTime.Local().Format("Jan 02 15:04")
}
dur := cli.FormatDuration(s.DurationSecs)
line := fmt.Sprintf("%-13s %s", startStr, dur)
if len(line) > leftInner {
line = line[:leftInner]
}
if i == ss.cursor {
leftBody.WriteString(selectedStyle.Render(line))
} else {
leftBody.WriteString(rowStyle.Render(line))
}
leftBody.WriteString("\n")
}
leftCard := components.ContentCard(fmt.Sprintf("Sessions [%dd]", a.days), leftBody.String(), leftW)
// Right pane: full session detail
sel := sessions[ss.cursor]
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle)
titleStr := fmt.Sprintf("Session %s", shortID(sel.SessionID))
rightCard := components.ContentCard(titleStr, rightBody, rightW)
return components.CardRow([]string{leftCard, rightCard})
}
func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) string {
t := theme.Active
ss := a.sessState
if ss.cursor >= len(sessions) {
return ""
}
sel := sessions[ss.cursor]
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
body := a.renderDetailBody(sel, cw, headerStyle, mutedStyle)
title := fmt.Sprintf("Session %s", shortID(sel.SessionID))
return components.ContentCard(title, body, cw)
}
// renderDetailBody generates the full detail content for a session.
// Used by both the split right pane and the full-screen detail view.
func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedStyle lipgloss.Style) string {
t := theme.Active
innerW := components.CardInnerWidth(w)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
greenStyle := lipgloss.NewStyle().Foreground(t.Green)
var body strings.Builder
body.WriteString(mutedStyle.Render(sel.Project))
body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
body.WriteString("\n\n")
// Duration line
if !sel.StartTime.IsZero() {
durStr := cli.FormatDuration(sel.DurationSecs)
timeStr := sel.StartTime.Local().Format("15:04:05")
if !sel.EndTime.IsZero() {
timeStr += " - " + sel.EndTime.Local().Format("15:04:05")
}
timeStr += " " + sel.StartTime.Local().Format("MST")
body.WriteString(fmt.Sprintf("%s %s (%s)\n",
labelStyle.Render("Duration:"),
valueStyle.Render(durStr),
mutedStyle.Render(timeStr)))
}
ratio := 0.0
if sel.UserMessages > 0 {
ratio = float64(sel.APICalls) / float64(sel.UserMessages)
}
body.WriteString(fmt.Sprintf("%s %s %s %s %s %.1fx\n\n",
labelStyle.Render("Prompts:"), valueStyle.Render(cli.FormatNumber(int64(sel.UserMessages))),
labelStyle.Render("API Calls:"), valueStyle.Render(cli.FormatNumber(int64(sel.APICalls))),
labelStyle.Render("Ratio:"), ratio))
// Token breakdown table
body.WriteString(headerStyle.Render("TOKEN BREAKDOWN"))
body.WriteString("\n")
body.WriteString(headerStyle.Render(fmt.Sprintf("%-20s %12s %10s", "Type", "Tokens", "Cost")))
body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", 44)))
body.WriteString("\n")
// Calculate per-type costs (aggregate across models)
inputCost := 0.0
outputCost := 0.0
cache5mCost := 0.0
cache1hCost := 0.0
cacheReadCost := 0.0
savings := 0.0
for modelName, mu := range sel.Models {
p, ok := config.LookupPricing(modelName)
if ok {
inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1e6
outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1e6
cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1e6
cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1e6
cacheReadCost += float64(mu.CacheReadTokens) * p.CacheReadPerMTok / 1e6
savings += config.CalculateCacheSavings(modelName, mu.CacheReadTokens)
}
}
rows := []struct {
typ string
tokens int64
cost float64
}{
{"Input", sel.InputTokens, inputCost},
{"Output", sel.OutputTokens, outputCost},
{"Cache Write (5m)", sel.CacheCreation5mTokens, cache5mCost},
{"Cache Write (1h)", sel.CacheCreation1hTokens, cache1hCost},
{"Cache Read", sel.CacheReadTokens, cacheReadCost},
}
for _, r := range rows {
if r.tokens == 0 {
continue
}
body.WriteString(valueStyle.Render(fmt.Sprintf("%-20s %12s %10s",
r.typ,
cli.FormatTokens(r.tokens),
cli.FormatCost(r.cost))))
body.WriteString("\n")
}
body.WriteString(mutedStyle.Render(strings.Repeat("─", 44)))
body.WriteString("\n")
body.WriteString(fmt.Sprintf("%-20s %12s %10s\n",
valueStyle.Render("Net Cost"),
"",
greenStyle.Render(cli.FormatCost(sel.EstimatedCost))))
body.WriteString(fmt.Sprintf("%-20s %12s %10s\n",
labelStyle.Render("Cache Savings"),
"",
greenStyle.Render(cli.FormatCost(savings))))
// Model breakdown
if len(sel.Models) > 0 {
body.WriteString("\n")
body.WriteString(headerStyle.Render("API CALLS BY MODEL"))
body.WriteString("\n")
body.WriteString(headerStyle.Render(fmt.Sprintf("%-14s %7s %10s %10s %8s", "Model", "Calls", "Input", "Output", "Cost")))
body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", 52)))
body.WriteString("\n")
// Sort model names for deterministic display order
modelNames := make([]string, 0, len(sel.Models))
for name := range sel.Models {
modelNames = append(modelNames, name)
}
sort.Strings(modelNames)
for _, modelName := range modelNames {
mu := sel.Models[modelName]
body.WriteString(valueStyle.Render(fmt.Sprintf("%-14s %7s %10s %10s %8s",
shortModel(modelName),
cli.FormatNumber(int64(mu.APICalls)),
cli.FormatTokens(mu.InputTokens),
cli.FormatTokens(mu.OutputTokens),
cli.FormatCost(mu.EstimatedCost))))
body.WriteString("\n")
}
}
if sel.IsSubagent {
body.WriteString("\n")
body.WriteString(mutedStyle.Render("(subagent session)"))
body.WriteString("\n")
}
body.WriteString("\n")
body.WriteString(mutedStyle.Render("[Enter] expand [j/k] navigate [q] quit"))
return body.String()
}
func shortID(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}

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