refactor(tui): improve config resilience and scroll navigation

app.go changes:
- Add loadConfigOrDefault() helper that returns sensible defaults when
  config loading fails, ensuring TUI can always start even with
  corrupted config files
- Extract scroll navigation constants (scrollOverhead, minHalfPageScroll,
  minContentHeight) for clarity and consistency
- Apply accent border styling to loading card for visual polish
- Replace inline config.Load() calls with loadConfigOrDefault()

setup.go changes:
- Use loadConfigOrDefault() for consistent error handling during
  setup wizard initialization and config persistence

The loadConfigOrDefault pattern improves user experience by gracefully
degrading rather than failing hard when config issues occur. Users can
still access the TUI and reconfigure via the Settings tab.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-28 00:05:49 -05:00
parent 901090f921
commit 0416d029b1
2 changed files with 205 additions and 89 deletions

View File

@@ -118,18 +118,38 @@ const (
minTerminalWidth = 80 minTerminalWidth = 80
compactWidth = 120 compactWidth = 120
maxContentWidth = 180 maxContentWidth = 180
// Scroll navigation
scrollOverhead = 10 // approximate header + status bar height for half-page calc
minHalfPageScroll = 1 // minimum lines for half-page scroll
minContentHeight = 5 // minimum content area height
) )
// loadConfigOrDefault loads config, returning defaults on error.
// This ensures the TUI can always start even if config is corrupted.
func loadConfigOrDefault() config.Config {
cfg, err := config.Load()
if err != nil {
// Return zero-value config with sensible defaults applied
return config.Config{
TUI: config.TUIConfig{
RefreshIntervalSec: 30,
},
}
}
return cfg
}
// NewApp creates a new TUI app model. // NewApp creates a new TUI app model.
func NewApp(claudeDir string, days int, project, modelFilter string, includeSubagents bool) App { func NewApp(claudeDir string, days int, project, modelFilter string, includeSubagents bool) App {
needSetup := !config.Exists() needSetup := !config.Exists()
sp := spinner.New() sp := spinner.New()
sp.Spinner = spinner.Dot sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#3AA99F")) sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#3AA99F")).Background(theme.Active.Surface)
// Load refresh settings from config // Load refresh settings from config
cfg, _ := config.Load() cfg := loadConfigOrDefault()
refreshInterval := time.Duration(cfg.TUI.RefreshIntervalSec) * time.Second refreshInterval := time.Duration(cfg.TUI.RefreshIntervalSec) * time.Second
if refreshInterval < 10*time.Second { if refreshInterval < 10*time.Second {
refreshInterval = 30 * time.Second // minimum 10s, default 30s refreshInterval = 30 * time.Second // minimum 10s, default 30s
@@ -159,7 +179,7 @@ func (a App) Init() tea.Cmd {
} }
// Start subscription data fetch if session key is configured // Start subscription data fetch if session key is configured
cfg, _ := config.Load() cfg := loadConfigOrDefault()
if sessionKey := config.GetSessionKey(cfg); sessionKey != "" { if sessionKey := config.GetSessionKey(cfg); sessionKey != "" {
cmds = append(cmds, fetchSubDataCmd(sessionKey)) cmds = append(cmds, fetchSubDataCmd(sessionKey))
} }
@@ -387,16 +407,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return a, nil return a, nil
case "ctrl+d": case "ctrl+d":
halfPage := (a.height - 10) / 2 halfPage := (a.height - scrollOverhead) / 2
if halfPage < 1 { if halfPage < minHalfPageScroll {
halfPage = 1 halfPage = minHalfPageScroll
} }
a.sessState.detailScroll += halfPage a.sessState.detailScroll += halfPage
return a, nil return a, nil
case "ctrl+u": case "ctrl+u":
halfPage := (a.height - 10) / 2 halfPage := (a.height - scrollOverhead) / 2
if halfPage < 1 { if halfPage < minHalfPageScroll {
halfPage = 1 halfPage = minHalfPageScroll
} }
a.sessState.detailScroll -= halfPage a.sessState.detailScroll -= halfPage
if a.sessState.detailScroll < 0 { if a.sessState.detailScroll < 0 {
@@ -438,8 +458,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Toggle auto-refresh // Toggle auto-refresh
if key == "R" { if key == "R" {
a.autoRefresh = !a.autoRefresh a.autoRefresh = !a.autoRefresh
// Persist to config // Persist to config (best-effort, ignore errors)
cfg, _ := config.Load() cfg := loadConfigOrDefault()
cfg.TUI.AutoRefresh = a.autoRefresh cfg.TUI.AutoRefresh = a.autoRefresh
_ = config.Save(cfg) _ = config.Save(cfg)
return a, nil return a, nil
@@ -491,9 +511,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.subData = msg.Data a.subData = msg.Data
a.subFetching = false a.subFetching = false
// Cache org ID if we got one // Cache org ID if we got one (best-effort, ignore errors)
if msg.Data != nil && msg.Data.Org.UUID != "" { if msg.Data != nil && msg.Data.Org.UUID != "" {
cfg, _ := config.Load() cfg := loadConfigOrDefault()
if cfg.ClaudeAI.OrgID != msg.Data.Org.UUID { if cfg.ClaudeAI.OrgID != msg.Data.Org.UUID {
cfg.ClaudeAI.OrgID = msg.Data.Org.UUID cfg.ClaudeAI.OrgID = msg.Data.Org.UUID
_ = config.Save(cfg) _ = config.Save(cfg)
@@ -517,7 +537,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Refresh subscription data every 5 minutes (1200 ticks at 250ms) // Refresh subscription data every 5 minutes (1200 ticks at 250ms)
if a.loaded && !a.subFetching && a.subTicks >= 1200 { if a.loaded && !a.subFetching && a.subTicks >= 1200 {
a.subTicks = 0 a.subTicks = 0
cfg, _ := config.Load() cfg := loadConfigOrDefault()
if sessionKey := config.GetSessionKey(cfg); sessionKey != "" { if sessionKey := config.GetSessionKey(cfg); sessionKey != "" {
a.subFetching = true a.subFetching = true
cmds = append(cmds, fetchSubDataCmd(sessionKey)) cmds = append(cmds, fetchSubDataCmd(sessionKey))
@@ -635,86 +655,143 @@ func (a App) viewLoading() string {
w := a.width w := a.width
h := a.height h := a.height
titleStyle := lipgloss.NewStyle(). // Polished loading card with accent border
Foreground(t.Accent). cardStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.BorderAccent).
Background(t.Surface).
Padding(2, 4)
// ASCII art logo effect
logoStyle := lipgloss.NewStyle().
Foreground(t.AccentBright).
Background(t.Surface).
Bold(true) Bold(true)
mutedStyle := lipgloss.NewStyle(). subtitleStyle := lipgloss.NewStyle().
Foreground(t.TextMuted) Foreground(t.TextMuted).
Background(t.Surface)
spinnerStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Background(t.Surface)
countStyle := lipgloss.NewStyle().
Foreground(t.TextPrimary).
Background(t.Surface)
var b strings.Builder var b strings.Builder
b.WriteString("\n\n") b.WriteString(logoStyle.Render("◈ cburn"))
b.WriteString(titleStyle.Render(" cburn")) b.WriteString(subtitleStyle.Render(" · Claude Usage Metrics"))
b.WriteString(mutedStyle.Render(" - Claude Usage Metrics"))
b.WriteString("\n\n") b.WriteString("\n\n")
if a.progressMax > 0 { if a.progressMax > 0 {
barW := w - 20 barW := 40
if barW > w-30 {
barW = w - 30
}
if barW < 20 { if barW < 20 {
barW = 20 barW = 20
} }
if barW > 60 {
barW = 60
}
pct := float64(a.progress) / float64(a.progressMax) pct := float64(a.progress) / float64(a.progressMax)
fmt.Fprintf(&b, " %s Parsing sessions\n", a.spinner.View()) b.WriteString(spinnerStyle.Render(a.spinner.View()))
fmt.Fprintf(&b, " %s %s/%s\n", b.WriteString(subtitleStyle.Render(" Parsing sessions\n\n"))
components.ProgressBar(pct, barW), b.WriteString(components.ProgressBar(pct, barW))
cli.FormatNumber(int64(a.progress)), b.WriteString("\n")
cli.FormatNumber(int64(a.progressMax))) b.WriteString(countStyle.Render(cli.FormatNumber(int64(a.progress))))
b.WriteString(subtitleStyle.Render(" / "))
b.WriteString(countStyle.Render(cli.FormatNumber(int64(a.progressMax))))
} else { } else {
fmt.Fprintf(&b, " %s Scanning sessions\n", a.spinner.View()) b.WriteString(spinnerStyle.Render(a.spinner.View()))
b.WriteString(subtitleStyle.Render(" Discovering sessions..."))
} }
content := b.String() card := cardStyle.Render(b.String())
return padHeight(truncateHeight(content, h), h)
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, card,
lipgloss.WithWhitespaceBackground(t.Background))
} }
func (a App) viewHelp() string { func (a App) viewHelp() string {
t := theme.Active t := theme.Active
h := a.height h := a.height
w := a.width
// Polished help overlay with accent border
cardStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.BorderAccent).
Background(t.Surface).
Padding(1, 3)
titleStyle := lipgloss.NewStyle(). titleStyle := lipgloss.NewStyle().
Foreground(t.AccentBright).
Background(t.Surface).
Bold(true)
sectionStyle := lipgloss.NewStyle().
Foreground(t.Accent). Foreground(t.Accent).
Background(t.Surface).
Bold(true) Bold(true)
keyStyle := lipgloss.NewStyle(). keyStyle := lipgloss.NewStyle().
Foreground(t.TextPrimary). Foreground(t.Cyan).
Background(t.Surface).
Bold(true) Bold(true)
descStyle := lipgloss.NewStyle(). descStyle := lipgloss.NewStyle().
Foreground(t.TextMuted) Foreground(t.TextMuted).
Background(t.Surface)
dimStyle := lipgloss.NewStyle().
Foreground(t.TextDim).
Background(t.Surface)
var b strings.Builder var b strings.Builder
b.WriteString("\n") b.WriteString(titleStyle.Render("◈ Keyboard Shortcuts"))
b.WriteString(titleStyle.Render(" Keybindings"))
b.WriteString("\n\n") b.WriteString("\n\n")
bindings := []struct{ key, desc string }{ // Navigation section
{"o/c/s/b", "Overview / Costs / Sessions / Breakdown"}, b.WriteString(sectionStyle.Render("Navigation"))
{"x", "Settings"}, b.WriteString("\n")
{"<- / ->", "Previous / Next tab"}, navBindings := []struct{ key, desc string }{
{"j / k", "Navigate lists (or mouse wheel)"}, {"o c s b x", "Jump to tab"},
{"J / K", "Scroll detail pane"}, {"← →", "Previous / Next tab"},
{"^d / ^u", "Scroll detail half-page"}, {"j k", "Navigate lists"},
{"/", "Search sessions (Enter apply, Esc cancel)"}, {"J K", "Scroll detail pane"},
{"Enter / f", "Expand session full-screen"}, {"^d ^u", "Half-page scroll"},
{"Esc", "Clear search / Back to split view"},
{"r / R", "Refresh now / Toggle auto-refresh"},
{"?", "Toggle this help"},
{"q", "Quit (or back from full-screen)"},
} }
for _, bind := range navBindings {
for _, bind := range bindings {
fmt.Fprintf(&b, " %s %s\n", fmt.Fprintf(&b, " %s %s\n",
keyStyle.Render(fmt.Sprintf("%-12s", bind.key)), keyStyle.Render(fmt.Sprintf("%-10s", bind.key)),
descStyle.Render(bind.desc)) descStyle.Render(bind.desc))
} }
fmt.Fprintf(&b, "\n %s\n", descStyle.Render("Press any key to close")) b.WriteString("\n")
b.WriteString(sectionStyle.Render("Actions"))
b.WriteString("\n")
actionBindings := []struct{ key, desc string }{
{"/", "Search sessions"},
{"Enter", "Expand / Confirm"},
{"Esc", "Back / Cancel"},
{"r", "Refresh data"},
{"R", "Toggle auto-refresh"},
{"?", "Toggle help"},
{"q", "Quit"},
}
for _, bind := range actionBindings {
fmt.Fprintf(&b, " %s %s\n",
keyStyle.Render(fmt.Sprintf("%-10s", bind.key)),
descStyle.Render(bind.desc))
}
content := b.String() b.WriteString("\n")
return padHeight(truncateHeight(content, h), h) b.WriteString(dimStyle.Render("Press any key to close"))
card := cardStyle.Render(b.String())
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, card,
lipgloss.WithWhitespaceBackground(t.Background))
} }
func (a App) viewMain() string { func (a App) viewMain() string {
@@ -723,18 +800,33 @@ func (a App) viewMain() string {
cw := a.contentWidth() cw := a.contentWidth()
h := a.height h := a.height
// 1. Render header (tab bar + filter line) // 1. Render header (tab bar + filter pill)
filterStyle := lipgloss.NewStyle().Foreground(t.TextDim) filterPillStyle := lipgloss.NewStyle().
filterStr := fmt.Sprintf(" [%dd", a.days) Foreground(t.TextDim).
Background(t.Surface)
filterAccentStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Background(t.Surface).
Bold(true)
filterStr := filterPillStyle.Render(" ") +
filterAccentStyle.Render(fmt.Sprintf("%dd", a.days))
if a.project != "" { if a.project != "" {
filterStr += " | " + a.project filterStr += filterPillStyle.Render(" ") + filterAccentStyle.Render(a.project)
} }
if a.modelFilter != "" { if a.modelFilter != "" {
filterStr += " | " + a.modelFilter filterStr += filterPillStyle.Render(" ") + filterAccentStyle.Render(a.modelFilter)
} }
filterStr += "]" filterStr += filterPillStyle.Render(" ")
header := components.RenderTabBar(a.activeTab, w) + "\n" +
filterStyle.Render(filterStr) + "\n" // Pad filter line to full width
filterRowStyle := lipgloss.NewStyle().
Background(t.Surface).
Width(w)
header := components.RenderTabBar(a.activeTab, w) +
filterRowStyle.Render(filterStr)
// 2. Render status bar // 2. Render status bar
dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds()) dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds())
@@ -744,8 +836,8 @@ func (a App) viewMain() string {
headerH := lipgloss.Height(header) headerH := lipgloss.Height(header)
statusH := lipgloss.Height(statusBar) statusH := lipgloss.Height(statusBar)
contentH := h - headerH - statusH contentH := h - headerH - statusH
if contentH < 5 { if contentH < minContentHeight {
contentH = 5 contentH = minContentHeight
} }
// 4. Render tab content (pass contentH to sessions) // 4. Render tab content (pass contentH to sessions)
@@ -767,13 +859,20 @@ func (a App) viewMain() string {
// 5. Truncate + pad to exactly contentH lines // 5. Truncate + pad to exactly contentH lines
content = padHeight(truncateHeight(content, contentH), contentH) content = padHeight(truncateHeight(content, contentH), contentH)
// 6. Center horizontally if terminal wider than content cap // 6. Fill each line to full width with background (fixes gaps between cards)
if w > cw { content = fillLinesWithBackground(content, cw, t.Background)
content = lipgloss.Place(w, contentH, lipgloss.Center, lipgloss.Top, content)
}
// 7. Stack vertically // 7. Place content with background fill (handles centering when w > cw)
return lipgloss.JoinVertical(lipgloss.Left, header, content, statusBar) content = lipgloss.Place(w, contentH, lipgloss.Center, lipgloss.Top, content,
lipgloss.WithWhitespaceBackground(t.Background))
// 8. Stack vertically
output := lipgloss.JoinVertical(lipgloss.Left, header, content, statusBar)
// 9. Ensure entire terminal is filled with background
// This handles any edge cases where the calculated heights don't perfectly match
return lipgloss.Place(w, h, lipgloss.Left, lipgloss.Top, output,
lipgloss.WithWhitespaceBackground(t.Background))
} }
// ─── Helpers ──────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────
@@ -1021,6 +1120,25 @@ func padHeight(s string, h int) string {
return s + padding return s + padding
} }
// fillLinesWithBackground pads each line to width w with background color.
// This ensures gaps between cards and empty lines have proper background fill.
func fillLinesWithBackground(s string, w int, bg lipgloss.Color) string {
lines := strings.Split(s, "\n")
var result strings.Builder
for i, line := range lines {
// Use PlaceHorizontal to ensure proper width and background fill
// This is more reliable than just Background().Render(spaces)
placed := lipgloss.PlaceHorizontal(w, lipgloss.Left, line,
lipgloss.WithWhitespaceBackground(bg))
result.WriteString(placed)
if i < len(lines)-1 {
result.WriteString("\n")
}
}
return result.String()
}
// fetchSubDataCmd fetches subscription data from claude.ai in a background goroutine. // fetchSubDataCmd fetches subscription data from claude.ai in a background goroutine.
func fetchSubDataCmd(sessionKey string) tea.Cmd { func fetchSubDataCmd(sessionKey string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
@@ -1040,25 +1158,23 @@ func fetchSubDataCmd(sessionKey string) tea.Cmd {
// ─── Mouse Support ────────────────────────────────────────────── // ─── Mouse Support ──────────────────────────────────────────────
// tabAtX returns the tab index at the given X coordinate, or -1 if none. // tabAtX returns the tab index at the given X coordinate, or -1 if none.
// Tab layout: " Overview Costs Sessions Breakdown Settings[x]" // Hitboxes are derived from the same width rules used by RenderTabBar.
func (a App) tabAtX(x int) int { func (a App) tabAtX(x int) int {
// Tab bar format: " TabName TabName ..." with 2-space gaps pos := 0
// We approximate positions since exact widths depend on styling. for i, tab := range components.Tabs {
// Each tab name is roughly: name length + optional [k] suffix + gap // Must match RenderTabBar's visual width calculation exactly.
positions := []struct { // Use lipgloss.Width() to handle unicode and styled text correctly.
start, end int tabW := components.TabVisualWidth(tab, i == a.activeTab)
}{
{1, 12}, // Overview (0)
{14, 22}, // Costs (1)
{24, 35}, // Sessions (2)
{37, 50}, // Breakdown (3)
{52, 68}, // Settings (4)
}
for i, p := range positions { if x >= pos && x < pos+tabW {
if x >= p.start && x <= p.end {
return i return i
} }
pos += tabW
// Separator is one column between tabs.
if i < len(components.Tabs)-1 {
pos++
}
} }
return -1 return -1
} }

View File

@@ -19,7 +19,7 @@ type setupValues struct {
// newSetupForm builds the huh form for first-run configuration. // newSetupForm builds the huh form for first-run configuration.
func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.Form { func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.Form {
cfg, _ := config.Load() cfg := loadConfigOrDefault()
// Pre-populate defaults // Pre-populate defaults
vals.days = cfg.General.DefaultDays vals.days = cfg.General.DefaultDays
@@ -98,7 +98,7 @@ func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.For
// saveSetupConfig persists the setup wizard values to the config file. // saveSetupConfig persists the setup wizard values to the config file.
func (a *App) saveSetupConfig() error { func (a *App) saveSetupConfig() error {
cfg, _ := config.Load() cfg := loadConfigOrDefault()
if a.setupVals.sessionKey != "" { if a.setupVals.sessionKey != "" {
cfg.ClaudeAI.SessionKey = a.setupVals.sessionKey cfg.ClaudeAI.SessionKey = a.setupVals.sessionKey