feat: add CLI status command, rewrite setup wizard, and modernize table renderer

Three interconnected CLI improvements:

New status command (cmd/status.go):
- Fetches live claude.ai subscription data using the claudeai client
- Renders rate-limit windows (5-hour, 7-day all/Opus/Sonnet) with
  color-coded progress bars: green <50%, orange 50-80%, red >80%
- Shows overage spend limits and usage percentage
- Handles partial data gracefully (renders what's available)
- Clear onboarding guidance when no session key is configured

Setup wizard rewrite (cmd/setup.go):
- Replace raw bufio.Reader prompts with charmbracelet/huh multi-step form
- Three form groups: welcome screen, credentials (session key + admin key
  with password echo mode), and preferences (time range + theme select)
- Pre-populates from existing config, preserves existing keys on empty input
- Dracula theme for the form UI
- Graceful Ctrl+C handling via huh.ErrUserAborted

Table renderer modernization (internal/cli/render.go):
- Replace 120-line manual box-drawing table renderer with lipgloss/table
- Automatic column width calculation, rounded borders, right-aligned
  numeric columns (all except first)
- Filter out "---" separator sentinels (not supported by lipgloss/table)
- Remove unused style variables (valueStyle, costStyle, tokenStyle,
  warnStyle) and Table.Widths field

Config display update (cmd/config_cmd.go):
- Show claude.ai session key and org ID in config output

Dependencies (go.mod):
- Add charmbracelet/huh v0.8.0 for form-based TUI wizards
- Upgrade golang.org/x/text v0.3.8 -> v0.23.0
- Add transitive deps: catppuccin/go, harmonica, hashstructure, etc.
This commit is contained in:
teernisse
2026-02-20 16:07:40 -05:00
parent 547d402578
commit e241ee3966
6 changed files with 389 additions and 217 deletions

View File

@@ -1,3 +1,4 @@
// Package cmd implements the cburn CLI commands.
package cmd package cmd
import ( import (
@@ -24,7 +25,7 @@ func runConfig(_ *cobra.Command, _ []string) error {
return err return err
} }
fmt.Printf(" Config file: %s\n", config.ConfigPath()) fmt.Printf(" Config file: %s\n", config.Path())
if config.Exists() { if config.Exists() {
fmt.Println(" Status: loaded") fmt.Println(" Status: loaded")
} else { } else {
@@ -40,6 +41,18 @@ func runConfig(_ *cobra.Command, _ []string) error {
} }
fmt.Println() fmt.Println()
fmt.Println(" [Claude.ai]")
sessionKey := config.GetSessionKey(cfg)
if sessionKey != "" {
fmt.Printf(" Session key: %s\n", maskAPIKey(sessionKey))
} else {
fmt.Println(" Session key: not configured")
}
if cfg.ClaudeAI.OrgID != "" {
fmt.Printf(" Org ID: %s\n", cfg.ClaudeAI.OrgID)
}
fmt.Println()
fmt.Println(" [Admin API]") fmt.Println(" [Admin API]")
apiKey := config.GetAdminAPIKey(cfg) apiKey := config.GetAdminAPIKey(cfg)
if apiKey != "" { if apiKey != "" {

View File

@@ -1,14 +1,15 @@
package cmd package cmd
import ( import (
"bufio" "errors"
"fmt" "fmt"
"os"
"strings" "strings"
"cburn/internal/config" "cburn/internal/config"
"cburn/internal/source" "cburn/internal/source"
"cburn/internal/tui/theme"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -23,89 +24,119 @@ func init() {
} }
func runSetup(_ *cobra.Command, _ []string) error { func runSetup(_ *cobra.Command, _ []string) error {
reader := bufio.NewReader(os.Stdin)
// Load existing config or defaults
cfg, _ := config.Load() cfg, _ := config.Load()
// Count sessions
files, _ := source.ScanDir(flagDataDir) files, _ := source.ScanDir(flagDataDir)
projectCount := source.CountProjects(files) projectCount := source.CountProjects(files)
fmt.Println() // Pre-populate from existing config
fmt.Println(" Welcome to cburn!") var sessionKey, adminKey string
fmt.Println() days := cfg.General.DefaultDays
if days == 0 {
days = 30
}
themeName := cfg.Appearance.Theme
if themeName == "" {
themeName = "flexoki-dark"
}
// Build welcome description
welcomeDesc := "Let's configure your dashboard."
if len(files) > 0 { if len(files) > 0 {
fmt.Printf(" Found %s sessions in %s (%d projects)\n\n", welcomeDesc = fmt.Sprintf("Found %d sessions across %d projects in %s.",
formatNumber(int64(len(files))), flagDataDir, projectCount) len(files), projectCount, flagDataDir)
} }
// 1. API key // Build placeholder text showing masked existing values
fmt.Println(" 1. Anthropic Admin API key") sessionPlaceholder := "sk-ant-sid... (Enter to skip)"
fmt.Println(" For real cost data from the billing API.") if key := config.GetSessionKey(cfg); key != "" {
existing := config.GetAdminAPIKey(cfg) sessionPlaceholder = maskAPIKey(key) + " (Enter to keep)"
if existing != "" {
fmt.Printf(" Current: %s\n", maskAPIKey(existing))
} }
fmt.Print(" > ") adminPlaceholder := "sk-ant-admin-... (Enter to skip)"
apiKey, _ := reader.ReadString('\n') if key := config.GetAdminAPIKey(cfg); key != "" {
apiKey = strings.TrimSpace(apiKey) adminPlaceholder = maskAPIKey(key) + " (Enter to keep)"
if apiKey != "" {
cfg.AdminAPI.APIKey = apiKey
}
fmt.Println()
// 2. Default time range
fmt.Println(" 2. Default time range")
fmt.Println(" (1) 7 days")
fmt.Println(" (2) 30 days [default]")
fmt.Println(" (3) 90 days")
fmt.Print(" > ")
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(choice)
switch choice {
case "1":
cfg.General.DefaultDays = 7
case "3":
cfg.General.DefaultDays = 90
default:
cfg.General.DefaultDays = 30
}
fmt.Println()
// 3. Theme
fmt.Println(" 3. Color theme")
fmt.Println(" (1) Flexoki Dark [default]")
fmt.Println(" (2) Catppuccin Mocha")
fmt.Println(" (3) Tokyo Night")
fmt.Println(" (4) Terminal (ANSI 16)")
fmt.Print(" > ")
themeChoice, _ := reader.ReadString('\n')
themeChoice = strings.TrimSpace(themeChoice)
switch themeChoice {
case "2":
cfg.Appearance.Theme = "catppuccin-mocha"
case "3":
cfg.Appearance.Theme = "tokyo-night"
case "4":
cfg.Appearance.Theme = "terminal"
default:
cfg.Appearance.Theme = "flexoki-dark"
} }
// Save form := 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(&sessionKey),
huh.NewInput().
Title("Anthropic Admin API key").
Description("For real cost data from the billing API.").
Placeholder(adminPlaceholder).
EchoMode(huh.EchoModePassword).
Value(&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(&days),
huh.NewSelect[string]().
Title("Color theme").
Options(themeOpts()...).
Value(&themeName),
),
).WithTheme(huh.ThemeDracula())
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n Setup cancelled.")
return nil
}
return fmt.Errorf("setup form: %w", err)
}
// Only overwrite keys if the user typed new ones
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey != "" {
cfg.ClaudeAI.SessionKey = sessionKey
}
adminKey = strings.TrimSpace(adminKey)
if adminKey != "" {
cfg.AdminAPI.APIKey = adminKey
}
cfg.General.DefaultDays = days
cfg.Appearance.Theme = themeName
if err := config.Save(cfg); err != nil { if err := config.Save(cfg); err != nil {
return fmt.Errorf("saving config: %w", err) return fmt.Errorf("saving config: %w", err)
} }
fmt.Println() fmt.Printf("\n Saved to %s\n", config.Path())
fmt.Printf(" Saved to %s\n", config.ConfigPath())
fmt.Println(" Run `cburn setup` anytime to reconfigure.") fmt.Println(" Run `cburn setup` anytime to reconfigure.")
fmt.Println() fmt.Println()
return nil return nil
} }
func themeOpts() []huh.Option[string] {
opts := make([]huh.Option[string], len(theme.All))
for i, t := range theme.All {
opts[i] = huh.NewOption(t.Name, t.Name)
}
return opts
}
func maskAPIKey(key string) string { func maskAPIKey(key string) string {
if len(key) > 16 { if len(key) > 16 {
return key[:8] + "..." + key[len(key)-4:] return key[:8] + "..." + key[len(key)-4:]

198
cmd/status.go Normal file
View File

@@ -0,0 +1,198 @@
package cmd
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"cburn/internal/claudeai"
"cburn/internal/cli"
"cburn/internal/config"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show claude.ai subscription status and rate limits",
RunE: runStatus,
}
func init() {
rootCmd.AddCommand(statusCmd)
}
func runStatus(_ *cobra.Command, _ []string) error {
cfg, _ := config.Load()
sessionKey := config.GetSessionKey(cfg)
if sessionKey == "" {
fmt.Println()
fmt.Println(" No session key configured.")
fmt.Println()
fmt.Println(" To get your session key:")
fmt.Println(" 1. Open claude.ai in your browser")
fmt.Println(" 2. DevTools (F12) > Application > Cookies > claude.ai")
fmt.Println(" 3. Copy the 'sessionKey' value (starts with sk-ant-sid...)")
fmt.Println()
fmt.Println(" Then configure it:")
fmt.Println(" cburn setup (interactive)")
fmt.Println(" CLAUDE_SESSION_KEY=sk-ant-sid... cburn status (one-shot)")
fmt.Println()
return nil
}
client := claudeai.NewClient(sessionKey)
if client == nil {
return errors.New("invalid session key format (expected sk-ant-sid... prefix)")
}
if !flagQuiet {
fmt.Fprintf(os.Stderr, " Fetching subscription data...\n")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
data := client.FetchAll(ctx)
if data.Error != nil {
if errors.Is(data.Error, claudeai.ErrUnauthorized) {
return errors.New("session key expired or invalid — grab a fresh one from claude.ai cookies")
}
if errors.Is(data.Error, claudeai.ErrRateLimited) {
return errors.New("rate limited by claude.ai — try again in a minute")
}
// Partial data may still be available, continue rendering
if data.Usage == nil && data.Overage == nil {
return fmt.Errorf("fetch failed: %w", data.Error)
}
}
fmt.Println()
fmt.Println(cli.RenderTitle("CLAUDE.AI STATUS"))
fmt.Println()
// Organization info
if data.Org.UUID != "" {
fmt.Printf(" Organization: %s\n", data.Org.Name)
if len(data.Org.Capabilities) > 0 {
fmt.Printf(" Capabilities: %s\n", strings.Join(data.Org.Capabilities, ", "))
}
fmt.Println()
}
// Rate limits
if data.Usage != nil {
rows := [][]string{}
if w := data.Usage.FiveHour; w != nil {
rows = append(rows, rateLimitRow("5-hour window", w))
}
if w := data.Usage.SevenDay; w != nil {
rows = append(rows, rateLimitRow("7-day (all)", w))
}
if w := data.Usage.SevenDayOpus; w != nil {
rows = append(rows, rateLimitRow("7-day Opus", w))
}
if w := data.Usage.SevenDaySonnet; w != nil {
rows = append(rows, rateLimitRow("7-day Sonnet", w))
}
if len(rows) > 0 {
fmt.Print(cli.RenderTable(cli.Table{
Title: "Rate Limits",
Headers: []string{"Window", "Used", "Bar", "Resets"},
Rows: rows,
}))
}
}
// Overage
if data.Overage != nil {
ol := data.Overage
status := "disabled"
if ol.IsEnabled {
status = "enabled"
}
rows := [][]string{
{"Overage", status},
{"Used Credits", fmt.Sprintf("%.2f %s", ol.UsedCredits, ol.Currency)},
{"Monthly Limit", fmt.Sprintf("%.2f %s", ol.MonthlyCreditLimit, ol.Currency)},
}
if ol.IsEnabled && ol.MonthlyCreditLimit > 0 {
pct := ol.UsedCredits / ol.MonthlyCreditLimit
rows = append(rows, []string{"Usage", fmt.Sprintf("%.1f%%", pct*100)})
}
fmt.Print(cli.RenderTable(cli.Table{
Title: "Overage Spend",
Headers: []string{"Setting", "Value"},
Rows: rows,
}))
}
// Partial error warning
if data.Error != nil {
warnStyle := lipgloss.NewStyle().Foreground(cli.ColorOrange)
fmt.Printf(" %s\n\n", warnStyle.Render(fmt.Sprintf("Partial data — %s", data.Error)))
}
fmt.Printf(" Fetched at %s\n\n", data.FetchedAt.Format("3:04:05 PM"))
return nil
}
func rateLimitRow(label string, w *claudeai.ParsedWindow) []string {
pctStr := fmt.Sprintf("%.0f%%", w.Pct*100)
bar := renderMiniBar(w.Pct, 20)
resets := ""
if !w.ResetsAt.IsZero() {
dur := time.Until(w.ResetsAt)
if dur > 0 {
resets = formatCountdown(dur)
} else {
resets = "now"
}
}
return []string{label, pctStr, bar, resets}
}
func renderMiniBar(pct float64, width int) string {
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(pct * float64(width))
empty := width - filled
// Color based on usage level
color := cli.ColorGreen
if pct >= 0.8 {
color = cli.ColorRed
} else if pct >= 0.5 {
color = cli.ColorOrange
}
barStyle := lipgloss.NewStyle().Foreground(color)
dimStyle := lipgloss.NewStyle().Foreground(cli.ColorTextDim)
return barStyle.Render(strings.Repeat("█", filled)) +
dimStyle.Render(strings.Repeat("░", empty))
}
func formatCountdown(d time.Duration) string {
h := int(d.Hours())
m := int(d.Minutes()) % 60
if h > 0 {
return fmt.Sprintf("%dh %dm", h, m)
}
return fmt.Sprintf("%dm", m)
}

7
go.mod
View File

@@ -6,6 +6,7 @@ require (
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
modernc.org/sqlite v1.46.1 modernc.org/sqlite v1.46.1
@@ -14,9 +15,12 @@ require (
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
@@ -29,6 +33,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
@@ -39,7 +44,7 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/text v0.23.0 // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

30
go.sum
View File

@@ -1,23 +1,45 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
@@ -25,6 +47,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -45,6 +69,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -74,8 +100,8 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -5,23 +5,24 @@ import (
"strings" "strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
) )
// Theme colors (Flexoki Dark) // Theme colors (Flexoki Dark)
var ( var (
ColorBg = lipgloss.Color("#100F0F") ColorBg = lipgloss.Color("#100F0F")
ColorSurface = lipgloss.Color("#1C1B1A") ColorSurface = lipgloss.Color("#1C1B1A")
ColorBorder = lipgloss.Color("#282726") ColorBorder = lipgloss.Color("#282726")
ColorTextDim = lipgloss.Color("#575653") ColorTextDim = lipgloss.Color("#575653")
ColorTextMuted = lipgloss.Color("#6F6E69") ColorTextMuted = lipgloss.Color("#6F6E69")
ColorText = lipgloss.Color("#FFFCF0") ColorText = lipgloss.Color("#FFFCF0")
ColorAccent = lipgloss.Color("#3AA99F") ColorAccent = lipgloss.Color("#3AA99F")
ColorGreen = lipgloss.Color("#879A39") ColorGreen = lipgloss.Color("#879A39")
ColorOrange = lipgloss.Color("#DA702C") ColorOrange = lipgloss.Color("#DA702C")
ColorRed = lipgloss.Color("#D14D41") ColorRed = lipgloss.Color("#D14D41")
ColorBlue = lipgloss.Color("#4385BE") ColorBlue = lipgloss.Color("#4385BE")
ColorPurple = lipgloss.Color("#8B7EC8") ColorPurple = lipgloss.Color("#8B7EC8")
ColorYellow = lipgloss.Color("#D0A215") ColorYellow = lipgloss.Color("#D0A215")
) )
// Styles // Styles
@@ -35,21 +36,9 @@ var (
Bold(true). Bold(true).
Foreground(ColorAccent) Foreground(ColorAccent)
valueStyle = lipgloss.NewStyle().
Foreground(ColorText)
mutedStyle = lipgloss.NewStyle(). mutedStyle = lipgloss.NewStyle().
Foreground(ColorTextMuted) Foreground(ColorTextMuted)
costStyle = lipgloss.NewStyle().
Foreground(ColorGreen)
tokenStyle = lipgloss.NewStyle().
Foreground(ColorBlue)
warnStyle = lipgloss.NewStyle().
Foreground(ColorOrange)
dimStyle = lipgloss.NewStyle(). dimStyle = lipgloss.NewStyle().
Foreground(ColorTextDim) Foreground(ColorTextDim)
) )
@@ -59,7 +48,6 @@ type Table struct {
Title string Title string
Headers []string Headers []string
Rows [][]string Rows [][]string
Widths []int // optional column widths, auto-calculated if nil
} }
// RenderTitle renders a centered title bar in a bordered box. // RenderTitle renders a centered title bar in a bordered box.
@@ -75,136 +63,47 @@ func RenderTitle(title string) string {
return border.Render(titleStyle.Render(title)) return border.Render(titleStyle.Render(title))
} }
// RenderTable renders a bordered table with headers and rows. // RenderTable renders a bordered table with headers and rows using lipgloss/table.
func RenderTable(t Table) string { func RenderTable(t Table) string {
if len(t.Rows) == 0 && len(t.Headers) == 0 { if len(t.Rows) == 0 && len(t.Headers) == 0 {
return "" return ""
} }
// Calculate column widths // Filter out "---" separator sentinels (not supported by lipgloss/table).
numCols := len(t.Headers) rows := make([][]string, 0, len(t.Rows))
if numCols == 0 && len(t.Rows) > 0 { for _, row := range t.Rows {
numCols = len(t.Rows[0]) if len(row) == 1 && row[0] == "---" {
continue
}
rows = append(rows, row)
} }
widths := make([]int, numCols) tbl := table.New().
if t.Widths != nil { Border(lipgloss.RoundedBorder()).
copy(widths, t.Widths) BorderStyle(dimStyle).
} else { BorderColumn(true).
for i, h := range t.Headers { BorderHeader(true).
if len(h) > widths[i] { Headers(t.Headers...).
widths[i] = len(h) Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
s := lipgloss.NewStyle().Padding(0, 1)
if row == table.HeaderRow {
return s.Bold(true).Foreground(ColorAccent)
} }
} s = s.Foreground(ColorText)
for _, row := range t.Rows { if col > 0 {
for i, cell := range row { s = s.Align(lipgloss.Right)
if i < numCols && len(cell) > widths[i] {
widths[i] = len(cell)
}
} }
} return s
} })
var b strings.Builder var b strings.Builder
// Title above table if present
if t.Title != "" { if t.Title != "" {
b.WriteString(" ") b.WriteString(" ")
b.WriteString(headerStyle.Render(t.Title)) b.WriteString(headerStyle.Render(t.Title))
b.WriteString("\n") b.WriteString("\n")
} }
b.WriteString(tbl.Render())
totalWidth := 1 // left border
for _, w := range widths {
totalWidth += w + 3 // padding + separator
}
// Top border
b.WriteString(dimStyle.Render("╭"))
for i, w := range widths {
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
if i < numCols-1 {
b.WriteString(dimStyle.Render("┬"))
}
}
b.WriteString(dimStyle.Render("╮"))
b.WriteString("\n")
// Header row
if len(t.Headers) > 0 {
b.WriteString(dimStyle.Render("│"))
for i, h := range t.Headers {
w := widths[i]
padded := fmt.Sprintf(" %-*s ", w, h)
b.WriteString(headerStyle.Render(padded))
if i < numCols-1 {
b.WriteString(dimStyle.Render("│"))
}
}
b.WriteString(dimStyle.Render("│"))
b.WriteString("\n")
// Header separator
b.WriteString(dimStyle.Render("├"))
for i, w := range widths {
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
if i < numCols-1 {
b.WriteString(dimStyle.Render("┼"))
}
}
b.WriteString(dimStyle.Render("┤"))
b.WriteString("\n")
}
// Data rows
for _, row := range t.Rows {
if len(row) == 1 && row[0] == "---" {
// Separator row
b.WriteString(dimStyle.Render("├"))
for i, w := range widths {
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
if i < numCols-1 {
b.WriteString(dimStyle.Render("┼"))
}
}
b.WriteString(dimStyle.Render("┤"))
b.WriteString("\n")
continue
}
b.WriteString(dimStyle.Render("│"))
for i := 0; i < numCols; i++ {
w := widths[i]
cell := ""
if i < len(row) {
cell = row[i]
}
// Right-align numeric columns (all except first)
var padded string
if i == 0 {
padded = fmt.Sprintf(" %-*s ", w, cell)
} else {
padded = fmt.Sprintf(" %*s ", w, cell)
}
b.WriteString(valueStyle.Render(padded))
if i < numCols-1 {
b.WriteString(dimStyle.Render("│"))
}
}
b.WriteString(dimStyle.Render("│"))
b.WriteString("\n")
}
// Bottom border
b.WriteString(dimStyle.Render("╰"))
for i, w := range widths {
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
if i < numCols-1 {
b.WriteString(dimStyle.Render("┴"))
}
}
b.WriteString(dimStyle.Render("╯"))
b.WriteString("\n") b.WriteString("\n")
return b.String() return b.String()
@@ -242,26 +141,26 @@ func RenderSparkline(values []float64) string {
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
max := values[0] maxVal := values[0]
for _, v := range values[1:] { for _, v := range values[1:] {
if v > max { if v > maxVal {
max = v maxVal = v
} }
} }
if max == 0 { if maxVal == 0 {
max = 1 maxVal = 1
} }
var b strings.Builder var b strings.Builder
for _, v := range values { for _, v := range values {
idx := int(v / max * float64(len(blocks)-1)) idx := int(v / maxVal * float64(len(blocks)-1))
if idx >= len(blocks) { if idx >= len(blocks) {
idx = len(blocks) - 1 idx = len(blocks) - 1
} }
if idx < 0 { if idx < 0 {
idx = 0 idx = 0
} }
b.WriteRune(blocks[idx]) b.WriteRune(blocks[idx]) //nolint:gosec // bounds checked above
} }
return b.String() return b.String()
@@ -270,12 +169,12 @@ func RenderSparkline(values []float64) string {
// RenderHorizontalBar renders a horizontal bar chart entry. // RenderHorizontalBar renders a horizontal bar chart entry.
func RenderHorizontalBar(label string, value, maxValue float64, maxWidth int) string { func RenderHorizontalBar(label string, value, maxValue float64, maxWidth int) string {
if maxValue <= 0 { if maxValue <= 0 {
return fmt.Sprintf(" %s", label) return " " + label
} }
barLen := int(value / maxValue * float64(maxWidth)) barLen := int(value / maxValue * float64(maxWidth))
if barLen < 0 { if barLen < 0 {
barLen = 0 barLen = 0
} }
bar := strings.Repeat("█", barLen) bar := strings.Repeat("█", barLen)
return fmt.Sprintf(" %s", bar) return " " + bar
} }