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:
152
internal/tui/setup.go
Normal file
152
internal/tui/setup.go
Normal 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)
|
||||||
|
}
|
||||||
285
internal/tui/tab_sessions.go
Normal file
285
internal/tui/tab_sessions.go
Normal 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
|
||||||
|
}
|
||||||
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