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:
1020
internal/tui/app.go
1020
internal/tui/app.go
File diff suppressed because it is too large
Load Diff
@@ -2,151 +2,125 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"cburn/internal/config"
|
"cburn/internal/config"
|
||||||
"cburn/internal/tui/theme"
|
"cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupState tracks the first-run setup wizard state.
|
// setupValues holds the form-bound variables for the setup wizard.
|
||||||
type setupState struct {
|
type setupValues struct {
|
||||||
active bool
|
sessionKey string
|
||||||
step int // 0=welcome, 1=api key, 2=days, 3=theme, 4=done
|
adminKey string
|
||||||
apiKeyIn textinput.Model
|
days int
|
||||||
daysChoice int // index into daysOptions
|
theme string
|
||||||
themeChoice int // index into theme.All
|
|
||||||
saveErr error // non-nil if config save failed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var daysOptions = []struct {
|
// newSetupForm builds the huh form for first-run configuration.
|
||||||
label string
|
func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.Form {
|
||||||
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() {
|
|
||||||
cfg, _ := config.Load()
|
cfg, _ := config.Load()
|
||||||
|
|
||||||
apiKey := strings.TrimSpace(a.setup.apiKeyIn.Value())
|
// Pre-populate defaults
|
||||||
if apiKey != "" {
|
vals.days = cfg.General.DefaultDays
|
||||||
cfg.AdminAPI.APIKey = apiKey
|
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) {
|
// Build welcome text
|
||||||
cfg.General.DefaultDays = daysOptions[a.setup.daysChoice].value
|
welcomeDesc := "Let's configure your dashboard."
|
||||||
a.days = cfg.General.DefaultDays
|
if numSessions > 0 {
|
||||||
|
welcomeDesc = fmt.Sprintf("Found %d sessions in %s.", numSessions, claudeDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.setup.themeChoice >= 0 && a.setup.themeChoice < len(theme.All) {
|
// Placeholder text for key fields
|
||||||
cfg.Appearance.Theme = theme.All[a.setup.themeChoice].Name
|
sessionPlaceholder := "sk-ant-sid... (Enter to skip)"
|
||||||
theme.SetActive(cfg.Appearance.Theme)
|
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 "****"
|
||||||
}
|
}
|
||||||
|
|||||||
129
internal/tui/tab_breakdown.go
Normal file
129
internal/tui/tab_breakdown.go
Normal 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
315
internal/tui/tab_costs.go
Normal 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"
|
||||||
|
}
|
||||||
184
internal/tui/tab_overview.go
Normal file
184
internal/tui/tab_overview.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -22,9 +22,10 @@ const (
|
|||||||
|
|
||||||
// sessionsState holds the sessions tab state.
|
// sessionsState holds the sessions tab state.
|
||||||
type sessionsState struct {
|
type sessionsState struct {
|
||||||
cursor int
|
cursor int
|
||||||
viewMode int
|
viewMode int
|
||||||
offset int // scroll offset for the list
|
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 {
|
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)
|
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 {
|
switch ss.viewMode {
|
||||||
case sessViewDetail:
|
case sessViewDetail:
|
||||||
return a.renderSessionDetail(filtered, cw, h)
|
return a.renderSessionDetail(filtered, cw, h)
|
||||||
@@ -51,9 +57,17 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
leftW := cw / 3
|
leftW := cw / 4
|
||||||
if leftW < 30 {
|
if leftW < 36 {
|
||||||
leftW = 30
|
leftW = 36
|
||||||
|
}
|
||||||
|
minRightW := 50
|
||||||
|
maxLeftW := cw - minRightW
|
||||||
|
if maxLeftW < 20 {
|
||||||
|
return a.renderSessionDetail(sessions, cw, h)
|
||||||
|
}
|
||||||
|
if leftW > maxLeftW {
|
||||||
|
leftW = maxLeftW
|
||||||
}
|
}
|
||||||
rightW := cw - leftW
|
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")
|
startStr = s.StartTime.Local().Format("Jan 02 15:04")
|
||||||
}
|
}
|
||||||
dur := cli.FormatDuration(s.DurationSecs)
|
dur := cli.FormatDuration(s.DurationSecs)
|
||||||
|
costStr := cli.FormatCost(s.EstimatedCost)
|
||||||
|
|
||||||
line := fmt.Sprintf("%-13s %s", startStr, dur)
|
// Build left portion (date + duration) and right-align cost
|
||||||
if len(line) > leftInner {
|
leftPart := fmt.Sprintf("%-13s %s", startStr, dur)
|
||||||
line = line[:leftInner]
|
padN := leftInner - len(leftPart) - len(costStr)
|
||||||
|
if padN < 1 {
|
||||||
|
padN = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if i == ss.cursor {
|
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 {
|
} 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")
|
leftBody.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
leftCard := components.ContentCard(fmt.Sprintf("Sessions [%dd]", a.days), leftBody.String(), leftW)
|
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]
|
sel := sessions[ss.cursor]
|
||||||
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle)
|
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)
|
rightCard := components.ContentCard(titleStr, rightBody, rightW)
|
||||||
|
|
||||||
return components.CardRow([]string{leftCard, rightCard})
|
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)
|
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||||
|
|
||||||
body := a.renderDetailBody(sel, cw, headerStyle, mutedStyle)
|
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)
|
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.EndTime.Local().Format("15:04:05")
|
||||||
}
|
}
|
||||||
timeStr += " " + sel.StartTime.Local().Format("MST")
|
timeStr += " " + sel.StartTime.Local().Format("MST")
|
||||||
body.WriteString(fmt.Sprintf("%s %s (%s)\n",
|
fmt.Fprintf(&body, "%s %s (%s)\n",
|
||||||
labelStyle.Render("Duration:"),
|
labelStyle.Render("Duration:"),
|
||||||
valueStyle.Render(durStr),
|
valueStyle.Render(durStr),
|
||||||
mutedStyle.Render(timeStr)))
|
mutedStyle.Render(timeStr))
|
||||||
}
|
}
|
||||||
|
|
||||||
ratio := 0.0
|
ratio := 0.0
|
||||||
if sel.UserMessages > 0 {
|
if sel.UserMessages > 0 {
|
||||||
ratio = float64(sel.APICalls) / float64(sel.UserMessages)
|
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("Prompts:"), valueStyle.Render(cli.FormatNumber(int64(sel.UserMessages))),
|
||||||
labelStyle.Render("API Calls:"), valueStyle.Render(cli.FormatNumber(int64(sel.APICalls))),
|
labelStyle.Render("API Calls:"), valueStyle.Render(cli.FormatNumber(int64(sel.APICalls))),
|
||||||
labelStyle.Render("Ratio:"), ratio))
|
labelStyle.Render("Ratio:"), ratio)
|
||||||
|
|
||||||
// Token breakdown table
|
// Token breakdown table
|
||||||
body.WriteString(headerStyle.Render("TOKEN BREAKDOWN"))
|
body.WriteString(headerStyle.Render("TOKEN BREAKDOWN"))
|
||||||
body.WriteString("\n")
|
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("\n")
|
||||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", 44)))
|
body.WriteString(mutedStyle.Render(strings.Repeat("─", tableW)))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
|
|
||||||
// Calculate per-type costs (aggregate across models)
|
// 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 {
|
if r.tokens == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
body.WriteString(valueStyle.Render(fmt.Sprintf("%-20s %12s %10s",
|
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %*s %*s",
|
||||||
r.typ,
|
typeW,
|
||||||
|
truncStr(r.typ, typeW),
|
||||||
|
tokW,
|
||||||
cli.FormatTokens(r.tokens),
|
cli.FormatTokens(r.tokens),
|
||||||
|
costW,
|
||||||
cli.FormatCost(r.cost))))
|
cli.FormatCost(r.cost))))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", 44)))
|
body.WriteString(mutedStyle.Render(strings.Repeat("─", tableW)))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
body.WriteString(fmt.Sprintf("%-20s %12s %10s\n",
|
fmt.Fprintf(&body, "%-*s %*s %*s\n",
|
||||||
|
typeW,
|
||||||
valueStyle.Render("Net Cost"),
|
valueStyle.Render("Net Cost"),
|
||||||
|
tokW,
|
||||||
"",
|
"",
|
||||||
greenStyle.Render(cli.FormatCost(sel.EstimatedCost))))
|
costW,
|
||||||
body.WriteString(fmt.Sprintf("%-20s %12s %10s\n",
|
greenStyle.Render(cli.FormatCost(sel.EstimatedCost)))
|
||||||
|
fmt.Fprintf(&body, "%-*s %*s %*s\n",
|
||||||
|
typeW,
|
||||||
labelStyle.Render("Cache Savings"),
|
labelStyle.Render("Cache Savings"),
|
||||||
|
tokW,
|
||||||
"",
|
"",
|
||||||
greenStyle.Render(cli.FormatCost(savings))))
|
costW,
|
||||||
|
greenStyle.Render(cli.FormatCost(savings)))
|
||||||
|
|
||||||
// Model breakdown
|
// Model breakdown
|
||||||
if len(sel.Models) > 0 {
|
if len(sel.Models) > 0 {
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
body.WriteString(headerStyle.Render("API CALLS BY MODEL"))
|
body.WriteString(headerStyle.Render("API CALLS BY MODEL"))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
body.WriteString(headerStyle.Render(fmt.Sprintf("%-14s %7s %10s %10s %8s", "Model", "Calls", "Input", "Output", "Cost")))
|
compactModelTable := innerW < 60
|
||||||
body.WriteString("\n")
|
if compactModelTable {
|
||||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", 52)))
|
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")
|
body.WriteString("\n")
|
||||||
|
|
||||||
// Sort model names for deterministic display order
|
// 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 {
|
for _, modelName := range modelNames {
|
||||||
mu := sel.Models[modelName]
|
mu := sel.Models[modelName]
|
||||||
body.WriteString(valueStyle.Render(fmt.Sprintf("%-14s %7s %10s %10s %8s",
|
if innerW < 60 {
|
||||||
shortModel(modelName),
|
modelW := innerW - 7 - 1 - 8
|
||||||
cli.FormatNumber(int64(mu.APICalls)),
|
if modelW < 8 {
|
||||||
cli.FormatTokens(mu.InputTokens),
|
modelW = 8
|
||||||
cli.FormatTokens(mu.OutputTokens),
|
}
|
||||||
cli.FormatCost(mu.EstimatedCost))))
|
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")
|
body.WriteString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,8 +337,57 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
|
|||||||
body.WriteString("\n")
|
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("\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()
|
return body.String()
|
||||||
}
|
}
|
||||||
@@ -283,3 +398,60 @@ func shortID(id string) string {
|
|||||||
}
|
}
|
||||||
return id
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"cburn/internal/cli"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
settingsFieldAPIKey = iota
|
settingsFieldAPIKey = iota
|
||||||
|
settingsFieldSessionKey
|
||||||
settingsFieldTheme
|
settingsFieldTheme
|
||||||
settingsFieldDays
|
settingsFieldDays
|
||||||
settingsFieldBudget
|
settingsFieldBudget
|
||||||
@@ -56,13 +58,21 @@ func (a App) settingsStartEdit() (tea.Model, tea.Cmd) {
|
|||||||
if existing != "" {
|
if existing != "" {
|
||||||
ti.SetValue(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:
|
case settingsFieldTheme:
|
||||||
ti.Placeholder = "flexoki-dark, catppuccin-mocha, tokyo-night, terminal"
|
ti.Placeholder = "flexoki-dark, catppuccin-mocha, tokyo-night, terminal"
|
||||||
ti.SetValue(cfg.Appearance.Theme)
|
ti.SetValue(cfg.Appearance.Theme)
|
||||||
ti.EchoMode = textinput.EchoNormal
|
ti.EchoMode = textinput.EchoNormal
|
||||||
case settingsFieldDays:
|
case settingsFieldDays:
|
||||||
ti.Placeholder = "30"
|
ti.Placeholder = "30"
|
||||||
ti.SetValue(fmt.Sprintf("%d", cfg.General.DefaultDays))
|
ti.SetValue(strconv.Itoa(cfg.General.DefaultDays))
|
||||||
ti.EchoMode = textinput.EchoNormal
|
ti.EchoMode = textinput.EchoNormal
|
||||||
case settingsFieldBudget:
|
case settingsFieldBudget:
|
||||||
ti.Placeholder = "500 (monthly USD, leave empty to clear)"
|
ti.Placeholder = "500 (monthly USD, leave empty to clear)"
|
||||||
@@ -103,6 +113,8 @@ func (a *App) settingsSave() {
|
|||||||
switch a.settings.cursor {
|
switch a.settings.cursor {
|
||||||
case settingsFieldAPIKey:
|
case settingsFieldAPIKey:
|
||||||
cfg.AdminAPI.APIKey = val
|
cfg.AdminAPI.APIKey = val
|
||||||
|
case settingsFieldSessionKey:
|
||||||
|
cfg.ClaudeAI.SessionKey = val
|
||||||
case settingsFieldTheme:
|
case settingsFieldTheme:
|
||||||
// Validate theme name
|
// Validate theme name
|
||||||
found := false
|
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{
|
fields := []field{
|
||||||
{"Admin API Key", apiKeyDisplay},
|
{"Admin API Key", apiKeyDisplay},
|
||||||
|
{"Session Key", sessionKeyDisplay},
|
||||||
{"Theme", cfg.Appearance.Theme},
|
{"Theme", cfg.Appearance.Theme},
|
||||||
{"Default Days", fmt.Sprintf("%d", cfg.General.DefaultDays)},
|
{"Default Days", strconv.Itoa(cfg.General.DefaultDays)},
|
||||||
{"Monthly Budget", func() string {
|
{"Monthly Budget", func() string {
|
||||||
if cfg.Budget.MonthlyUSD != nil {
|
if cfg.Budget.MonthlyUSD != nil {
|
||||||
return fmt.Sprintf("$%.0f", *cfg.Budget.MonthlyUSD)
|
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("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("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("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
|
var b strings.Builder
|
||||||
b.WriteString(components.ContentCard("Settings", formBody.String(), cw))
|
b.WriteString(components.ContentCard("Settings", formBody.String(), cw))
|
||||||
|
|||||||
Reference in New Issue
Block a user