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.
286 lines
8.0 KiB
Go
286 lines
8.0 KiB
Go
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
|
|
}
|