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