diff --git a/internal/tui/setup.go b/internal/tui/setup.go new file mode 100644 index 0000000..7d4f3d5 --- /dev/null +++ b/internal/tui/setup.go @@ -0,0 +1,152 @@ +package tui + +import ( + "fmt" + "strings" + + "cburn/internal/config" + "cburn/internal/tui/theme" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/lipgloss" +) + +// setupState tracks the first-run setup wizard state. +type setupState struct { + active bool + step int // 0=welcome, 1=api key, 2=days, 3=theme, 4=done + apiKeyIn textinput.Model + daysChoice int // index into daysOptions + themeChoice int // index into theme.All + saveErr error // non-nil if config save failed +} + +var daysOptions = []struct { + label string + 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() + + apiKey := strings.TrimSpace(a.setup.apiKeyIn.Value()) + if apiKey != "" { + cfg.AdminAPI.APIKey = apiKey + } + + if a.setup.daysChoice >= 0 && a.setup.daysChoice < len(daysOptions) { + cfg.General.DefaultDays = daysOptions[a.setup.daysChoice].value + a.days = cfg.General.DefaultDays + } + + if a.setup.themeChoice >= 0 && a.setup.themeChoice < len(theme.All) { + cfg.Appearance.Theme = theme.All[a.setup.themeChoice].Name + theme.SetActive(cfg.Appearance.Theme) + } + + a.setup.saveErr = config.Save(cfg) +} diff --git a/internal/tui/tab_sessions.go b/internal/tui/tab_sessions.go new file mode 100644 index 0000000..40e47e2 --- /dev/null +++ b/internal/tui/tab_sessions.go @@ -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 +} diff --git a/internal/tui/tab_settings.go b/internal/tui/tab_settings.go new file mode 100644 index 0000000..0897068 --- /dev/null +++ b/internal/tui/tab_settings.go @@ -0,0 +1,221 @@ +package tui + +import ( + "fmt" + "strings" + + "cburn/internal/cli" + "cburn/internal/config" + "cburn/internal/tui/components" + "cburn/internal/tui/theme" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + settingsFieldAPIKey = iota + settingsFieldTheme + settingsFieldDays + settingsFieldBudget + settingsFieldCount // sentinel +) + +// settingsFieldCount is used by app.go for cursor bounds checking + +// settingsState tracks the settings tab state. +type settingsState struct { + cursor int + editing bool + input textinput.Model + saved bool // flash "saved" message briefly + saveErr error // non-nil if last save failed +} + +func newSettingsInput() textinput.Model { + ti := textinput.New() + ti.CharLimit = 256 + ti.Width = 50 + return ti +} + +func (a App) settingsStartEdit() (tea.Model, tea.Cmd) { + cfg, _ := config.Load() + a.settings.editing = true + a.settings.saved = false + + ti := newSettingsInput() + + switch a.settings.cursor { + case settingsFieldAPIKey: + ti.Placeholder = "sk-ant-admin-..." + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '*' + existing := config.GetAdminAPIKey(cfg) + if existing != "" { + ti.SetValue(existing) + } + case settingsFieldTheme: + ti.Placeholder = "flexoki-dark, catppuccin-mocha, tokyo-night, terminal" + ti.SetValue(cfg.Appearance.Theme) + ti.EchoMode = textinput.EchoNormal + case settingsFieldDays: + ti.Placeholder = "30" + ti.SetValue(fmt.Sprintf("%d", cfg.General.DefaultDays)) + ti.EchoMode = textinput.EchoNormal + case settingsFieldBudget: + ti.Placeholder = "500 (monthly USD, leave empty to clear)" + if cfg.Budget.MonthlyUSD != nil { + ti.SetValue(fmt.Sprintf("%.0f", *cfg.Budget.MonthlyUSD)) + } + ti.EchoMode = textinput.EchoNormal + } + + ti.Focus() + a.settings.input = ti + return a, ti.Cursor.BlinkCmd() +} + +func (a App) updateSettingsInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + switch key { + case "enter": + a.settingsSave() + a.settings.editing = false + a.settings.saved = a.settings.saveErr == nil + return a, nil + case "esc": + a.settings.editing = false + return a, nil + } + + var cmd tea.Cmd + a.settings.input, cmd = a.settings.input.Update(msg) + return a, cmd +} + +func (a *App) settingsSave() { + cfg, _ := config.Load() + val := strings.TrimSpace(a.settings.input.Value()) + + switch a.settings.cursor { + case settingsFieldAPIKey: + cfg.AdminAPI.APIKey = val + case settingsFieldTheme: + // Validate theme name + found := false + for _, t := range theme.All { + if t.Name == val { + found = true + break + } + } + if found { + cfg.Appearance.Theme = val + theme.SetActive(val) + } + case settingsFieldDays: + var d int + if _, err := fmt.Sscanf(val, "%d", &d); err == nil && d > 0 { + cfg.General.DefaultDays = d + a.days = d + a.recompute() + } + case settingsFieldBudget: + if val == "" { + cfg.Budget.MonthlyUSD = nil + } else { + var b float64 + if _, err := fmt.Sscanf(val, "%f", &b); err == nil && b > 0 { + cfg.Budget.MonthlyUSD = &b + } + } + } + + a.settings.saveErr = config.Save(cfg) +} + +func (a App) renderSettingsTab(cw int) string { + t := theme.Active + cfg, _ := config.Load() + + labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) + valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) + selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface).Bold(true) + accentStyle := lipgloss.NewStyle().Foreground(t.Accent) + greenStyle := lipgloss.NewStyle().Foreground(t.Green) + + type field struct { + label string + value string + } + + apiKeyDisplay := "(not set)" + existingKey := config.GetAdminAPIKey(cfg) + if existingKey != "" { + if len(existingKey) > 12 { + apiKeyDisplay = existingKey[:8] + "..." + existingKey[len(existingKey)-4:] + } else { + apiKeyDisplay = "****" + } + } + + fields := []field{ + {"Admin API Key", apiKeyDisplay}, + {"Theme", cfg.Appearance.Theme}, + {"Default Days", fmt.Sprintf("%d", cfg.General.DefaultDays)}, + {"Monthly Budget", func() string { + if cfg.Budget.MonthlyUSD != nil { + return fmt.Sprintf("$%.0f", *cfg.Budget.MonthlyUSD) + } + return "(not set)" + }()}, + } + + var formBody strings.Builder + for i, f := range fields { + // Show text input if currently editing this field + if a.settings.editing && i == a.settings.cursor { + formBody.WriteString(accentStyle.Render(fmt.Sprintf("> %-18s ", f.label))) + formBody.WriteString(a.settings.input.View()) + formBody.WriteString("\n") + continue + } + + line := fmt.Sprintf("%-20s %s", f.label+":", f.value) + if i == a.settings.cursor { + formBody.WriteString(selectedStyle.Render(line)) + } else { + formBody.WriteString(labelStyle.Render(fmt.Sprintf("%-20s ", f.label+":")) + valueStyle.Render(f.value)) + } + formBody.WriteString("\n") + } + + if a.settings.saveErr != nil { + warnStyle := lipgloss.NewStyle().Foreground(t.Orange) + formBody.WriteString("\n") + formBody.WriteString(warnStyle.Render(fmt.Sprintf("Save failed: %s", a.settings.saveErr))) + } else if a.settings.saved { + formBody.WriteString("\n") + formBody.WriteString(greenStyle.Render("Saved!")) + } + + formBody.WriteString("\n") + formBody.WriteString(labelStyle.Render("[j/k] navigate [Enter] edit [Esc] cancel")) + + // General info card + var infoBody strings.Builder + 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("Load time: ") + valueStyle.Render(fmt.Sprintf("%.1fs", a.loadTime.Seconds())) + "\n") + infoBody.WriteString(labelStyle.Render("Config file: ") + valueStyle.Render(config.ConfigPath())) + + var b strings.Builder + b.WriteString(components.ContentCard("Settings", formBody.String(), cw)) + b.WriteString("\n") + b.WriteString(components.ContentCard("General", infoBody.String(), cw)) + + return b.String() +}