feat: overhaul TUI dashboard with subscription data, new tabs, and setup wizard

Major rewrite of the Bubble Tea dashboard, adding live claude.ai
integration and splitting the monolithic app.go into focused tab modules.

App model (app.go):
- Integrate claudeai.Client for live subscription/rate-limit data
- Add SubDataMsg and async fetch with periodic refresh (every 5 min)
- Add spinner for loading states (charmbracelet/bubbles spinner)
- Integrate huh form library for in-TUI setup wizard
- Rework tab routing to dispatch to dedicated tab renderers
- Add compact layout detection for narrow terminals (<100 cols)

TUI setup wizard (setup.go):
- Full huh-based setup flow embedded in the TUI (not just CLI)
- Three-step form: credentials, preferences (time range + theme), confirm
- Pre-populates from existing config, validates session key prefix
- Returns to dashboard on completion with config auto-saved

New tab modules:
- tab_overview.go: summary cards (sessions, prompts, cost, time), daily
  activity sparkline, rate-limit progress bars from live subscription data
- tab_breakdown.go: per-model usage table with calls, input/output tokens,
  cost, and share percentage; compact mode for narrow terminals
- tab_costs.go: cost analysis with daily cost chart, model cost breakdown,
  cache efficiency metrics, and budget tracking with progress bar

Rewritten tabs:
- tab_sessions.go: paginated session browser with sort-by-cost/tokens/time,
  per-session detail view, model usage breakdown per session, improved
  navigation (j/k, enter/esc, n/p for pages)
- tab_settings.go: updated to work with new theme struct and config fields
This commit is contained in:
teernisse
2026-02-20 16:08:06 -05:00
parent 2be7b5e193
commit 35fae37ba4
7 changed files with 1338 additions and 821 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,151 +2,125 @@ package tui
import (
"fmt"
"strings"
"cburn/internal/config"
"cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/huh"
)
// 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
// setupValues holds the form-bound variables for the setup wizard.
type setupValues struct {
sessionKey string
adminKey string
days int
theme string
}
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() {
// newSetupForm builds the huh form for first-run configuration.
func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.Form {
cfg, _ := config.Load()
apiKey := strings.TrimSpace(a.setup.apiKeyIn.Value())
if apiKey != "" {
cfg.AdminAPI.APIKey = apiKey
// Pre-populate defaults
vals.days = cfg.General.DefaultDays
if vals.days == 0 {
vals.days = 30
}
vals.theme = cfg.Appearance.Theme
if vals.theme == "" {
vals.theme = "flexoki-dark"
}
if a.setup.daysChoice >= 0 && a.setup.daysChoice < len(daysOptions) {
cfg.General.DefaultDays = daysOptions[a.setup.daysChoice].value
a.days = cfg.General.DefaultDays
// Build welcome text
welcomeDesc := "Let's configure your dashboard."
if numSessions > 0 {
welcomeDesc = fmt.Sprintf("Found %d sessions in %s.", numSessions, claudeDir)
}
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)
// Placeholder text for key fields
sessionPlaceholder := "sk-ant-sid... (Enter to skip)"
if key := config.GetSessionKey(cfg); key != "" {
sessionPlaceholder = maskKey(key) + " (Enter to keep)"
}
adminPlaceholder := "sk-ant-admin-... (Enter to skip)"
if key := config.GetAdminAPIKey(cfg); key != "" {
adminPlaceholder = maskKey(key) + " (Enter to keep)"
}
a.setup.saveErr = config.Save(cfg)
// Build theme options from the registered theme list
themeOpts := make([]huh.Option[string], len(theme.All))
for i, t := range theme.All {
themeOpts[i] = huh.NewOption(t.Name, t.Name)
}
return huh.NewForm(
huh.NewGroup(
huh.NewNote().
Title("Welcome to cburn").
Description(welcomeDesc).
Next(true).
NextLabel("Start"),
),
huh.NewGroup(
huh.NewInput().
Title("Claude.ai session key").
Description("For rate-limit and subscription data.\nclaude.ai > DevTools > Application > Cookies > sessionKey").
Placeholder(sessionPlaceholder).
EchoMode(huh.EchoModePassword).
Value(&vals.sessionKey),
huh.NewInput().
Title("Anthropic Admin API key").
Description("For real cost data from the billing API.").
Placeholder(adminPlaceholder).
EchoMode(huh.EchoModePassword).
Value(&vals.adminKey),
),
huh.NewGroup(
huh.NewSelect[int]().
Title("Default time range").
Options(
huh.NewOption("7 days", 7),
huh.NewOption("30 days", 30),
huh.NewOption("90 days", 90),
).
Value(&vals.days),
huh.NewSelect[string]().
Title("Color theme").
Options(themeOpts...).
Value(&vals.theme),
),
).WithTheme(huh.ThemeDracula()).WithShowHelp(false)
}
// saveSetupConfig persists the setup wizard values to the config file.
func (a *App) saveSetupConfig() error {
cfg, _ := config.Load()
if a.setupVals.sessionKey != "" {
cfg.ClaudeAI.SessionKey = a.setupVals.sessionKey
}
if a.setupVals.adminKey != "" {
cfg.AdminAPI.APIKey = a.setupVals.adminKey
}
cfg.General.DefaultDays = a.setupVals.days
a.days = a.setupVals.days
cfg.Appearance.Theme = a.setupVals.theme
theme.SetActive(a.setupVals.theme)
return config.Save(cfg)
}
func maskKey(key string) string {
if len(key) > 16 {
return key[:8] + "..." + key[len(key)-4:]
}
if len(key) > 4 {
return key[:4] + "..."
}
return "****"
}

View File

@@ -0,0 +1,129 @@
package tui
import (
"fmt"
"strings"
"cburn/internal/cli"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
)
func (a App) renderModelsTab(cw int) string {
t := theme.Active
models := a.models
innerW := components.CardInnerWidth(cw)
fixedCols := 8 + 10 + 10 + 10 + 6 // Calls, Input, Output, Cost, Share
gaps := 5
nameW := innerW - fixedCols - gaps
if nameW < 14 {
nameW = 14
}
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
var tableBody strings.Builder
if a.isCompactLayout() {
shareW := 6
costW := 10
callW := 8
nameW = innerW - shareW - costW - callW - 3
if nameW < 10 {
nameW = 10
}
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s %6s", nameW, "Model", "Calls", "Cost", "Share")))
tableBody.WriteString("\n")
for _, ms := range models {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %8s %10s %5.1f%%",
nameW,
truncStr(shortModel(ms.Model), nameW),
cli.FormatNumber(int64(ms.APICalls)),
cli.FormatCost(ms.EstimatedCost),
ms.SharePercent)))
tableBody.WriteString("\n")
}
} else {
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s %10s %10s %6s", nameW, "Model", "Calls", "Input", "Output", "Cost", "Share")))
tableBody.WriteString("\n")
for _, ms := range models {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %8s %10s %10s %10s %5.1f%%",
nameW,
truncStr(shortModel(ms.Model), nameW),
cli.FormatNumber(int64(ms.APICalls)),
cli.FormatTokens(ms.InputTokens),
cli.FormatTokens(ms.OutputTokens),
cli.FormatCost(ms.EstimatedCost),
ms.SharePercent)))
tableBody.WriteString("\n")
}
}
return components.ContentCard("Model Usage", tableBody.String(), cw)
}
func (a App) renderProjectsTab(cw int) string {
t := theme.Active
projects := a.projects
innerW := components.CardInnerWidth(cw)
fixedCols := 6 + 8 + 10 + 10 // Sess, Prompts, Tokens, Cost
gaps := 4
nameW := innerW - fixedCols - gaps
if nameW < 18 {
nameW = 18
}
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
var tableBody strings.Builder
if a.isCompactLayout() {
costW := 10
sessW := 6
nameW = innerW - costW - sessW - 2
if nameW < 12 {
nameW = 12
}
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %6s %10s", nameW, "Project", "Sess.", "Cost")))
tableBody.WriteString("\n")
for _, ps := range projects {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %6d %10s",
nameW,
truncStr(ps.Project, nameW),
ps.Sessions,
cli.FormatCost(ps.EstimatedCost))))
tableBody.WriteString("\n")
}
} else {
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %6s %8s %10s %10s", nameW, "Project", "Sess.", "Prompts", "Tokens", "Cost")))
tableBody.WriteString("\n")
for _, ps := range projects {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %6d %8s %10s %10s",
nameW,
truncStr(ps.Project, nameW),
ps.Sessions,
cli.FormatNumber(int64(ps.Prompts)),
cli.FormatTokens(ps.TotalTokens),
cli.FormatCost(ps.EstimatedCost))))
tableBody.WriteString("\n")
}
}
return components.ContentCard("Projects", tableBody.String(), cw)
}
func (a App) renderBreakdownTab(cw int) string {
var b strings.Builder
b.WriteString(a.renderModelsTab(cw))
b.WriteString("\n")
b.WriteString(a.renderProjectsTab(cw))
return b.String()
}

315
internal/tui/tab_costs.go Normal file
View File

@@ -0,0 +1,315 @@
package tui
import (
"fmt"
"sort"
"strings"
"time"
"cburn/internal/claudeai"
"cburn/internal/cli"
"cburn/internal/config"
"cburn/internal/model"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/lipgloss"
)
func (a App) renderCostsTab(cw int) string {
t := theme.Active
stats := a.stats
models := a.models
days := a.dailyStats
var b strings.Builder
// Row 0: Subscription rate limits (live data from claude.ai)
b.WriteString(a.renderSubscriptionCard(cw))
// Row 1: Cost metric cards
savingsMultiplier := 0.0
if stats.EstimatedCost > 0 {
savingsMultiplier = stats.CacheSavings / stats.EstimatedCost
}
costCards := []struct{ Label, Value, Delta string }{
{"Total Cost", cli.FormatCost(stats.EstimatedCost), cli.FormatCost(stats.CostPerDay) + "/day"},
{"Cache Savings", cli.FormatCost(stats.CacheSavings), fmt.Sprintf("%.1fx cost", savingsMultiplier)},
{"Projected", cli.FormatCost(stats.CostPerDay*30) + "/mo", cli.FormatCost(stats.CostPerDay) + "/day"},
{"Cache Rate", cli.FormatPercent(stats.CacheHitRate), ""},
}
b.WriteString(components.MetricCardRow(costCards, cw))
b.WriteString("\n")
// Row 2: Cost breakdown table
innerW := components.CardInnerWidth(cw)
fixedCols := 10 + 10 + 10 + 10
gaps := 4
nameW := innerW - fixedCols - gaps
if nameW < 14 {
nameW = 14
}
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
var tableBody strings.Builder
if a.isCompactLayout() {
totalW := 10
nameW = innerW - totalW - 1
if nameW < 10 {
nameW = 10
}
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %10s", nameW, "Model", "Total")))
tableBody.WriteString("\n")
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
tableBody.WriteString("\n")
for _, ms := range models {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s",
nameW,
truncStr(shortModel(ms.Model), nameW),
cli.FormatCost(ms.EstimatedCost))))
tableBody.WriteString("\n")
}
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
} else {
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s", nameW, "Model", "Input", "Output", "Cache", "Total")))
tableBody.WriteString("\n")
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
tableBody.WriteString("\n")
for _, ms := range models {
inputCost := 0.0
outputCost := 0.0
if p, ok := config.LookupPricing(ms.Model); ok {
inputCost = float64(ms.InputTokens) * p.InputPerMTok / 1e6
outputCost = float64(ms.OutputTokens) * p.OutputPerMTok / 1e6
}
cacheCost := ms.EstimatedCost - inputCost - outputCost
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s",
nameW,
truncStr(shortModel(ms.Model), nameW),
cli.FormatCost(inputCost),
cli.FormatCost(outputCost),
cli.FormatCost(cacheCost),
cli.FormatCost(ms.EstimatedCost))))
tableBody.WriteString("\n")
}
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
}
title := fmt.Sprintf("Cost Breakdown %s (%dd)", cli.FormatCost(stats.EstimatedCost), a.days)
b.WriteString(components.ContentCard(title, tableBody.String(), cw))
b.WriteString("\n")
// Row 3: Budget progress + Top Spend Days
halves := components.LayoutRow(cw, 2)
// Use real overage data if available, otherwise show placeholder
var progressCard string
if a.subData != nil && a.subData.Overage != nil && a.subData.Overage.IsEnabled {
ol := a.subData.Overage
pct := 0.0
if ol.MonthlyCreditLimit > 0 {
pct = ol.UsedCredits / ol.MonthlyCreditLimit
}
barW := components.CardInnerWidth(halves[0]) - 10
if barW < 10 {
barW = 10
}
bar := progress.New(
progress.WithSolidFill(components.ColorForPct(pct)),
progress.WithWidth(barW),
progress.WithoutPercentage(),
)
bar.EmptyColor = string(t.TextDim)
var body strings.Builder
body.WriteString(bar.ViewAs(pct))
fmt.Fprintf(&body, " %.0f%%\n", pct*100)
fmt.Fprintf(&body, "%s %s / %s %s",
labelStyle.Render("Used"),
valueStyle.Render(fmt.Sprintf("$%.2f", ol.UsedCredits)),
valueStyle.Render(fmt.Sprintf("$%.2f", ol.MonthlyCreditLimit)),
labelStyle.Render(ol.Currency))
progressCard = components.ContentCard("Overage Spend", body.String(), halves[0])
} else {
ceiling := 200.0
pct := stats.EstimatedCost / ceiling
progressInnerW := components.CardInnerWidth(halves[0])
progressBody := components.ProgressBar(pct, progressInnerW-10) + "\n" +
labelStyle.Render("flat-rate plan ceiling")
progressCard = components.ContentCard("Budget Progress", progressBody, halves[0])
}
var spendBody strings.Builder
if len(days) > 0 {
spendLimit := 5
if len(days) < spendLimit {
spendLimit = len(days)
}
sorted := make([]model.DailyStats, len(days))
copy(sorted, days)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Date.After(sorted[j].Date)
})
for _, d := range sorted[:spendLimit] {
fmt.Fprintf(&spendBody, "%s %s\n",
valueStyle.Render(d.Date.Format("Jan 02")),
lipgloss.NewStyle().Foreground(t.Green).Render(cli.FormatCost(d.EstimatedCost)))
}
} else {
spendBody.WriteString("No data\n")
}
spendCard := components.ContentCard("Top Spend Days", spendBody.String(), halves[1])
if a.isCompactLayout() {
b.WriteString(progressCard)
b.WriteString("\n")
b.WriteString(components.ContentCard("Top Spend Days", spendBody.String(), cw))
} else {
b.WriteString(components.CardRow([]string{progressCard, spendCard}))
}
b.WriteString("\n")
// Row 4: Efficiency metrics
tokPerPrompt := int64(0)
outPerPrompt := int64(0)
if stats.TotalPrompts > 0 {
tokPerPrompt = (stats.InputTokens + stats.OutputTokens) / int64(stats.TotalPrompts)
outPerPrompt = stats.OutputTokens / int64(stats.TotalPrompts)
}
promptsPerSess := 0.0
if stats.TotalSessions > 0 {
promptsPerSess = float64(stats.TotalPrompts) / float64(stats.TotalSessions)
}
effMetrics := []struct{ name, value string }{
{"Tokens/Prompt", cli.FormatTokens(tokPerPrompt)},
{"Output/Prompt", cli.FormatTokens(outPerPrompt)},
{"Prompts/Session", fmt.Sprintf("%.1f", promptsPerSess)},
{"Minutes/Day", fmt.Sprintf("%.0f", stats.MinutesPerDay)},
}
var effBody strings.Builder
for _, m := range effMetrics {
effBody.WriteString(rowStyle.Render(fmt.Sprintf("%-20s %10s", m.name, m.value)))
effBody.WriteString("\n")
}
b.WriteString(components.ContentCard("Efficiency", effBody.String(), cw))
return b.String()
}
// renderSubscriptionCard renders the rate limit + overage card at the top of the costs tab.
func (a App) renderSubscriptionCard(cw int) string {
t := theme.Active
hintStyle := lipgloss.NewStyle().Foreground(t.TextDim)
// No session key configured
if a.subData == nil && !a.subFetching {
cfg, _ := config.Load()
if config.GetSessionKey(cfg) == "" {
return components.ContentCard("Subscription",
hintStyle.Render("Configure session key in Settings to see rate limits"),
cw) + "\n"
}
// Key configured but no data yet (initial fetch in progress)
return components.ContentCard("Subscription",
hintStyle.Render("Fetching rate limits..."),
cw) + "\n"
}
// Still fetching
if a.subData == nil {
return components.ContentCard("Subscription",
hintStyle.Render("Fetching rate limits..."),
cw) + "\n"
}
// Error with no usable data
if a.subData.Usage == nil && a.subData.Error != nil {
warnStyle := lipgloss.NewStyle().Foreground(t.Orange)
return components.ContentCard("Subscription",
warnStyle.Render(fmt.Sprintf("Error: %s", a.subData.Error)),
cw) + "\n"
}
// No usage data at all
if a.subData.Usage == nil {
return ""
}
innerW := components.CardInnerWidth(cw)
labelW := 13 // enough for "Weekly Sonnet"
barW := innerW - labelW - 16 // label + bar + pct(5) + countdown(~10) + gaps
if barW < 10 {
barW = 10
}
var body strings.Builder
type windowRow struct {
label string
window *claudeai.ParsedWindow
}
rows := []windowRow{}
if w := a.subData.Usage.FiveHour; w != nil {
rows = append(rows, windowRow{"5-hour", w})
}
if w := a.subData.Usage.SevenDay; w != nil {
rows = append(rows, windowRow{"Weekly", w})
}
if w := a.subData.Usage.SevenDayOpus; w != nil {
rows = append(rows, windowRow{"Weekly Opus", w})
}
if w := a.subData.Usage.SevenDaySonnet; w != nil {
rows = append(rows, windowRow{"Weekly Sonnet", w})
}
for i, r := range rows {
body.WriteString(components.RateLimitBar(r.label, r.window.Pct, r.window.ResetsAt, labelW, barW))
if i < len(rows)-1 {
body.WriteString("\n")
}
}
// Overage line if enabled
if ol := a.subData.Overage; ol != nil && ol.IsEnabled && ol.MonthlyCreditLimit > 0 {
pct := ol.UsedCredits / ol.MonthlyCreditLimit
body.WriteString("\n")
body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Render(strings.Repeat("─", innerW)))
body.WriteString("\n")
body.WriteString(components.RateLimitBar("Overage",
pct, time.Time{}, labelW, barW))
spendStyle := lipgloss.NewStyle().Foreground(t.TextDim)
body.WriteString(spendStyle.Render(
fmt.Sprintf(" $%.2f / $%.2f", ol.UsedCredits, ol.MonthlyCreditLimit)))
}
// Fetch timestamp
if !a.subData.FetchedAt.IsZero() {
body.WriteString("\n")
tsStyle := lipgloss.NewStyle().Foreground(t.TextDim)
body.WriteString(tsStyle.Render("Updated " + a.subData.FetchedAt.Format("3:04 PM")))
}
title := "Subscription"
if a.subData.Org.Name != "" {
title = "Subscription — " + a.subData.Org.Name
}
return components.ContentCard(title, body.String(), cw) + "\n"
}

View File

@@ -0,0 +1,184 @@
package tui
import (
"fmt"
"strings"
"time"
"cburn/internal/cli"
"cburn/internal/pipeline"
"cburn/internal/tui/components"
"cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
)
func (a App) renderOverviewTab(cw int) string {
t := theme.Active
stats := a.stats
prev := a.prevStats
days := a.dailyStats
models := a.models
var b strings.Builder
// Row 1: Metric cards
costDelta := ""
if prev.CostPerDay > 0 {
costDelta = fmt.Sprintf("%s/day (%s)", cli.FormatCost(stats.CostPerDay), cli.FormatDelta(stats.CostPerDay, prev.CostPerDay))
} else {
costDelta = cli.FormatCost(stats.CostPerDay) + "/day"
}
sessDelta := ""
if prev.SessionsPerDay > 0 {
pctChange := (stats.SessionsPerDay - prev.SessionsPerDay) / prev.SessionsPerDay * 100
sessDelta = fmt.Sprintf("%.1f/day (%+.0f%%)", stats.SessionsPerDay, pctChange)
} else {
sessDelta = fmt.Sprintf("%.1f/day", stats.SessionsPerDay)
}
cacheDelta := ""
if prev.CacheHitRate > 0 {
ppDelta := (stats.CacheHitRate - prev.CacheHitRate) * 100
cacheDelta = fmt.Sprintf("saved %s (%+.1fpp)", cli.FormatCost(stats.CacheSavings), ppDelta)
} else {
cacheDelta = "saved " + cli.FormatCost(stats.CacheSavings)
}
cards := []struct{ Label, Value, Delta string }{
{"Tokens", cli.FormatTokens(stats.TotalBilledTokens), cli.FormatTokens(stats.TokensPerDay) + "/day"},
{"Sessions", cli.FormatNumber(int64(stats.TotalSessions)), sessDelta},
{"Cost", cli.FormatCost(stats.EstimatedCost), costDelta},
{"Cache", cli.FormatPercent(stats.CacheHitRate), cacheDelta},
}
b.WriteString(components.MetricCardRow(cards, cw))
b.WriteString("\n")
// Row 2: Daily token usage chart
if len(days) > 0 {
chartVals := make([]float64, len(days))
chartLabels := chartDateLabels(days)
for i, d := range days {
chartVals[len(days)-1-i] = float64(d.InputTokens + d.OutputTokens + d.CacheCreation5m + d.CacheCreation1h)
}
chartInnerW := components.CardInnerWidth(cw)
b.WriteString(components.ContentCard(
fmt.Sprintf("Daily Token Usage (%dd)", a.days),
components.BarChart(chartVals, chartLabels, t.Blue, chartInnerW, 10),
cw,
))
b.WriteString("\n")
}
// Row 3: Model Split + Activity Patterns
halves := components.LayoutRow(cw, 2)
innerW := components.CardInnerWidth(halves[0])
nameStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
barStyle := lipgloss.NewStyle().Foreground(t.Accent)
pctStyle := lipgloss.NewStyle().Foreground(t.TextDim)
var modelBody strings.Builder
limit := 5
if len(models) < limit {
limit = len(models)
}
maxShare := 0.0
for _, ms := range models[:limit] {
if ms.SharePercent > maxShare {
maxShare = ms.SharePercent
}
}
nameW := innerW / 3
if nameW < 10 {
nameW = 10
}
barMaxLen := innerW - nameW - 8
if barMaxLen < 1 {
barMaxLen = 1
}
for _, ms := range models[:limit] {
barLen := 0
if maxShare > 0 {
barLen = int(ms.SharePercent / maxShare * float64(barMaxLen))
}
fmt.Fprintf(&modelBody, "%s %s %s\n",
nameStyle.Render(fmt.Sprintf("%-*s", nameW, shortModel(ms.Model))),
barStyle.Render(strings.Repeat("█", barLen)),
pctStyle.Render(fmt.Sprintf("%.0f%%", ms.SharePercent)))
}
// Compact activity: aggregate prompts into 4-hour buckets
now := time.Now()
since := now.AddDate(0, 0, -a.days)
hours := pipeline.AggregateHourly(a.filtered, since, now)
type actBucket struct {
label string
total int
color lipgloss.Color
}
buckets := []actBucket{
{"Night 00-03", 0, t.Red},
{"Early 04-07", 0, t.Yellow},
{"Morning 08-11", 0, t.Green},
{"Midday 12-15", 0, t.Green},
{"Evening 16-19", 0, t.Green},
{"Late 20-23", 0, t.Yellow},
}
for _, h := range hours {
idx := h.Hour / 4
if idx >= 6 {
idx = 5
}
buckets[idx].total += h.Prompts
}
maxBucket := 0
for _, bk := range buckets {
if bk.total > maxBucket {
maxBucket = bk.total
}
}
actInnerW := components.CardInnerWidth(halves[1])
// Compute number column width from actual data so bars never overflow.
maxNumW := 5
for _, bk := range buckets {
if nw := len(cli.FormatNumber(int64(bk.total))); nw > maxNumW {
maxNumW = nw
}
}
// prefix = 13 (label) + 1 (space) + maxNumW (number) + 1 (space)
actBarMax := actInnerW - 15 - maxNumW
if actBarMax < 1 {
actBarMax = 1
}
numStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
var actBody strings.Builder
for _, bk := range buckets {
bl := 0
if maxBucket > 0 {
bl = bk.total * actBarMax / maxBucket
}
bar := lipgloss.NewStyle().Foreground(bk.color).Render(strings.Repeat("█", bl))
fmt.Fprintf(&actBody, "%s %s %s\n",
numStyle.Render(bk.label),
numStyle.Render(fmt.Sprintf("%*s", maxNumW, cli.FormatNumber(int64(bk.total)))),
bar)
}
modelCard := components.ContentCard("Model Split", modelBody.String(), halves[0])
actCard := components.ContentCard("Activity", actBody.String(), halves[1])
if a.isCompactLayout() {
b.WriteString(components.ContentCard("Model Split", modelBody.String(), cw))
b.WriteString("\n")
b.WriteString(components.ContentCard("Activity", actBody.String(), cw))
} else {
b.WriteString(components.CardRow([]string{modelCard, actCard}))
}
return b.String()
}

View File

@@ -22,9 +22,10 @@ const (
// sessionsState holds the sessions tab state.
type sessionsState struct {
cursor int
viewMode int
offset int // scroll offset for the list
cursor int
viewMode int
offset int // scroll offset for the list
detailScroll int // scroll offset for the detail pane
}
func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) string {
@@ -35,6 +36,11 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str
return components.ContentCard("Sessions", lipgloss.NewStyle().Foreground(t.TextMuted).Render("No sessions found"), cw)
}
// Force single-pane detail mode in compact layouts.
if cw < compactWidth {
return a.renderSessionDetail(filtered, cw, h)
}
switch ss.viewMode {
case sessViewDetail:
return a.renderSessionDetail(filtered, cw, h)
@@ -51,9 +57,17 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
return ""
}
leftW := cw / 3
if leftW < 30 {
leftW = 30
leftW := cw / 4
if leftW < 36 {
leftW = 36
}
minRightW := 50
maxLeftW := cw - minRightW
if maxLeftW < 20 {
return a.renderSessionDetail(sessions, cw, h)
}
if leftW > maxLeftW {
leftW = maxLeftW
}
rightW := cw - leftW
@@ -91,27 +105,42 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
startStr = s.StartTime.Local().Format("Jan 02 15:04")
}
dur := cli.FormatDuration(s.DurationSecs)
costStr := cli.FormatCost(s.EstimatedCost)
line := fmt.Sprintf("%-13s %s", startStr, dur)
if len(line) > leftInner {
line = line[:leftInner]
// Build left portion (date + duration) and right-align cost
leftPart := fmt.Sprintf("%-13s %s", startStr, dur)
padN := leftInner - len(leftPart) - len(costStr)
if padN < 1 {
padN = 1
}
if i == ss.cursor {
leftBody.WriteString(selectedStyle.Render(line))
fullLine := leftPart + strings.Repeat(" ", padN) + costStr
// Pad to full width for continuous highlight background
if len(fullLine) < leftInner {
fullLine += strings.Repeat(" ", leftInner-len(fullLine))
}
leftBody.WriteString(selectedStyle.Render(fullLine))
} else {
leftBody.WriteString(rowStyle.Render(line))
leftBody.WriteString(
mutedStyle.Render(fmt.Sprintf("%-13s", startStr)) + " " +
rowStyle.Render(dur) +
strings.Repeat(" ", padN) +
mutedStyle.Render(costStr))
}
leftBody.WriteString("\n")
}
leftCard := components.ContentCard(fmt.Sprintf("Sessions [%dd]", a.days), leftBody.String(), leftW)
// Right pane: full session detail
// Right pane: full session detail with scroll support
sel := sessions[ss.cursor]
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle)
titleStr := fmt.Sprintf("Session %s", shortID(sel.SessionID))
// Apply detail scroll offset
rightBody = a.applyDetailScroll(rightBody, h-4) // card border (2) + title (1) + gap (1)
titleStr := "Session " + shortID(sel.SessionID)
rightCard := components.ContentCard(titleStr, rightBody, rightW)
return components.CardRow([]string{leftCard, rightCard})
@@ -130,8 +159,9 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
body := a.renderDetailBody(sel, cw, headerStyle, mutedStyle)
body = a.applyDetailScroll(body, h-4)
title := fmt.Sprintf("Session %s", shortID(sel.SessionID))
title := "Session " + shortID(sel.SessionID)
return components.ContentCard(title, body, cw)
}
@@ -159,27 +189,28 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
timeStr += " - " + sel.EndTime.Local().Format("15:04:05")
}
timeStr += " " + sel.StartTime.Local().Format("MST")
body.WriteString(fmt.Sprintf("%s %s (%s)\n",
fmt.Fprintf(&body, "%s %s (%s)\n",
labelStyle.Render("Duration:"),
valueStyle.Render(durStr),
mutedStyle.Render(timeStr)))
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",
fmt.Fprintf(&body, "%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))
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")))
typeW, tokW, costW, tableW := tokenTableLayout(innerW)
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %*s %*s", typeW, "Type", tokW, "Tokens", costW, "Cost")))
body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", 44)))
body.WriteString(mutedStyle.Render(strings.Repeat("─", tableW)))
body.WriteString("\n")
// Calculate per-type costs (aggregate across models)
@@ -218,32 +249,53 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
if r.tokens == 0 {
continue
}
body.WriteString(valueStyle.Render(fmt.Sprintf("%-20s %12s %10s",
r.typ,
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %*s %*s",
typeW,
truncStr(r.typ, typeW),
tokW,
cli.FormatTokens(r.tokens),
costW,
cli.FormatCost(r.cost))))
body.WriteString("\n")
}
body.WriteString(mutedStyle.Render(strings.Repeat("─", 44)))
body.WriteString(mutedStyle.Render(strings.Repeat("─", tableW)))
body.WriteString("\n")
body.WriteString(fmt.Sprintf("%-20s %12s %10s\n",
fmt.Fprintf(&body, "%-*s %*s %*s\n",
typeW,
valueStyle.Render("Net Cost"),
tokW,
"",
greenStyle.Render(cli.FormatCost(sel.EstimatedCost))))
body.WriteString(fmt.Sprintf("%-20s %12s %10s\n",
costW,
greenStyle.Render(cli.FormatCost(sel.EstimatedCost)))
fmt.Fprintf(&body, "%-*s %*s %*s\n",
typeW,
labelStyle.Render("Cache Savings"),
tokW,
"",
greenStyle.Render(cli.FormatCost(savings))))
costW,
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)))
compactModelTable := innerW < 60
if compactModelTable {
modelW := innerW - 7 - 1 - 8
if modelW < 8 {
modelW = 8
}
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %8s", modelW, "Model", "Calls", "Cost")))
body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+8+2)))
} else {
modelW := 14
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", modelW, "Model", "Calls", "Input", "Output", "Cost")))
body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+10+10+8+4)))
}
body.WriteString("\n")
// Sort model names for deterministic display order
@@ -255,12 +307,26 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
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))))
if innerW < 60 {
modelW := innerW - 7 - 1 - 8
if modelW < 8 {
modelW = 8
}
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %8s",
modelW,
truncStr(shortModel(modelName), modelW),
cli.FormatNumber(int64(mu.APICalls)),
cli.FormatCost(mu.EstimatedCost))))
} else {
modelW := 14
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s",
modelW,
truncStr(shortModel(modelName), modelW),
cli.FormatNumber(int64(mu.APICalls)),
cli.FormatTokens(mu.InputTokens),
cli.FormatTokens(mu.OutputTokens),
cli.FormatCost(mu.EstimatedCost))))
}
body.WriteString("\n")
}
}
@@ -271,8 +337,57 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
body.WriteString("\n")
}
// Subagent drill-down
if subs := a.subagentMap[sel.SessionID]; len(subs) > 0 {
body.WriteString("\n")
body.WriteString(headerStyle.Render(fmt.Sprintf("SUBAGENTS (%d)", len(subs))))
body.WriteString("\n")
nameW := innerW - 8 - 10 - 2
if nameW < 10 {
nameW = 10
}
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s", nameW, "Agent", "Duration", "Cost")))
body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2)))
body.WriteString("\n")
var totalSubCost float64
var totalSubDur int64
for _, sub := range subs {
// Extract short agent name from session ID (e.g., "uuid/agent-acompact-7b10e8" -> "acompact-7b10e8")
agentName := sub.SessionID
if idx := strings.LastIndex(agentName, "/"); idx >= 0 {
agentName = agentName[idx+1:]
}
agentName = strings.TrimPrefix(agentName, "agent-")
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s",
nameW,
truncStr(agentName, nameW),
cli.FormatDuration(sub.DurationSecs),
cli.FormatCost(sub.EstimatedCost))))
body.WriteString("\n")
totalSubCost += sub.EstimatedCost
totalSubDur += sub.DurationSecs
}
body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2)))
body.WriteString("\n")
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s",
nameW,
"Combined",
cli.FormatDuration(totalSubDur),
cli.FormatCost(totalSubCost))))
body.WriteString("\n")
}
body.WriteString("\n")
body.WriteString(mutedStyle.Render("[Enter] expand [j/k] navigate [q] quit"))
if w < compactWidth {
body.WriteString(mutedStyle.Render("[j/k] navigate [J/K] scroll [q] quit"))
} else {
body.WriteString(mutedStyle.Render("[Enter] expand [j/k] navigate [J/K/^d/^u] scroll [q] quit"))
}
return body.String()
}
@@ -283,3 +398,60 @@ func shortID(id string) string {
}
return id
}
// applyDetailScroll applies the detail pane scroll offset to a rendered body string.
// visibleH is the number of lines that fit in the card body area.
func (a App) applyDetailScroll(body string, visibleH int) string {
if visibleH < 5 {
visibleH = 5
}
lines := strings.Split(body, "\n")
if len(lines) <= visibleH {
return body
}
scrollOff := a.sessState.detailScroll
maxScroll := len(lines) - visibleH
if maxScroll < 0 {
maxScroll = 0
}
if scrollOff > maxScroll {
scrollOff = maxScroll
}
if scrollOff < 0 {
scrollOff = 0
}
endIdx := scrollOff + visibleH
if endIdx > len(lines) {
endIdx = len(lines)
}
visible := lines[scrollOff:endIdx]
// Add scroll indicator if content continues below.
// Count includes the line we're replacing + lines past the viewport.
if endIdx < len(lines) {
unseen := len(lines) - endIdx + 1
dimStyle := lipgloss.NewStyle().Foreground(theme.Active.TextDim)
visible[len(visible)-1] = dimStyle.Render(fmt.Sprintf("... %d more", unseen))
}
return strings.Join(visible, "\n")
}
func tokenTableLayout(innerW int) (typeW, tokenW, costW, tableW int) {
tokenW = 12
costW = 10
typeW = innerW - tokenW - costW - 2
if typeW < 8 {
tokenW = 8
costW = 8
typeW = innerW - tokenW - costW - 2
}
if typeW < 6 {
typeW = 6
}
tableW = typeW + tokenW + costW + 2
return
}

View File

@@ -2,6 +2,7 @@ package tui
import (
"fmt"
"strconv"
"strings"
"cburn/internal/cli"
@@ -16,6 +17,7 @@ import (
const (
settingsFieldAPIKey = iota
settingsFieldSessionKey
settingsFieldTheme
settingsFieldDays
settingsFieldBudget
@@ -56,13 +58,21 @@ func (a App) settingsStartEdit() (tea.Model, tea.Cmd) {
if existing != "" {
ti.SetValue(existing)
}
case settingsFieldSessionKey:
ti.Placeholder = "sk-ant-sid..."
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '*'
existing := config.GetSessionKey(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.SetValue(strconv.Itoa(cfg.General.DefaultDays))
ti.EchoMode = textinput.EchoNormal
case settingsFieldBudget:
ti.Placeholder = "500 (monthly USD, leave empty to clear)"
@@ -103,6 +113,8 @@ func (a *App) settingsSave() {
switch a.settings.cursor {
case settingsFieldAPIKey:
cfg.AdminAPI.APIKey = val
case settingsFieldSessionKey:
cfg.ClaudeAI.SessionKey = val
case settingsFieldTheme:
// Validate theme name
found := false
@@ -162,10 +174,21 @@ func (a App) renderSettingsTab(cw int) string {
}
}
sessionKeyDisplay := "(not set)"
existingSession := config.GetSessionKey(cfg)
if existingSession != "" {
if len(existingSession) > 16 {
sessionKeyDisplay = existingSession[:12] + "..." + existingSession[len(existingSession)-4:]
} else {
sessionKeyDisplay = "****"
}
}
fields := []field{
{"Admin API Key", apiKeyDisplay},
{"Session Key", sessionKeyDisplay},
{"Theme", cfg.Appearance.Theme},
{"Default Days", fmt.Sprintf("%d", cfg.General.DefaultDays)},
{"Default Days", strconv.Itoa(cfg.General.DefaultDays)},
{"Monthly Budget", func() string {
if cfg.Budget.MonthlyUSD != nil {
return fmt.Sprintf("$%.0f", *cfg.Budget.MonthlyUSD)
@@ -210,7 +233,7 @@ func (a App) renderSettingsTab(cw int) string {
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()))
infoBody.WriteString(labelStyle.Render("Config file: ") + valueStyle.Render(config.Path()))
var b strings.Builder
b.WriteString(components.ContentCard("Settings", formBody.String(), cw))