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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user