feat: add TUI feature modules for setup wizard, session browser, and settings

Add three self-contained feature modules that plug into the root App
model via shared state structs and render methods.

setup.go -- First-run wizard with a 5-step flow: welcome screen, API
key entry (password-masked text input), default time range selector
(radio-style j/k navigation over 7/30/90 day options), theme picker
(radio-style over all registered themes), and a completion screen
that persists choices to ~/.config/cburn/config.toml. Gracefully
handles save failures by noting the settings apply for the current
session only.

tab_sessions.go -- Session browser with two view modes: split view
(1/3 condensed list + 2/3 detail pane, scrollable with offset
tracking) and full-screen detail. The detail body shows duration,
prompt/API-call ratio, per-type token breakdown with cache cost
attribution, per-model API call table, and subagent indicator.

tab_settings.go -- Runtime settings editor with 4 configurable
fields (API key, theme, default days, monthly budget). Supports
inline text input editing with Enter/Esc save/cancel flow, immediate
config persistence, flash "Saved!" confirmation, and error display
on save failure. Theme changes apply instantly without restart.
This commit is contained in:
teernisse
2026-02-19 15:01:34 -05:00
parent 04abdafa9a
commit b69b24c107
3 changed files with 658 additions and 0 deletions

View File

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