diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index 5066e92..b04cc61 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -1,3 +1,4 @@ +// Package cmd implements the cburn CLI commands. package cmd import ( @@ -24,7 +25,7 @@ func runConfig(_ *cobra.Command, _ []string) error { return err } - fmt.Printf(" Config file: %s\n", config.ConfigPath()) + fmt.Printf(" Config file: %s\n", config.Path()) if config.Exists() { fmt.Println(" Status: loaded") } else { @@ -40,6 +41,18 @@ func runConfig(_ *cobra.Command, _ []string) error { } 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]") apiKey := config.GetAdminAPIKey(cfg) if apiKey != "" { diff --git a/cmd/setup.go b/cmd/setup.go index 8430b35..6d423ea 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1,14 +1,15 @@ package cmd import ( - "bufio" + "errors" "fmt" - "os" "strings" "cburn/internal/config" "cburn/internal/source" + "cburn/internal/tui/theme" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) @@ -23,89 +24,119 @@ func init() { } func runSetup(_ *cobra.Command, _ []string) error { - reader := bufio.NewReader(os.Stdin) - - // Load existing config or defaults cfg, _ := config.Load() - - // Count sessions files, _ := source.ScanDir(flagDataDir) projectCount := source.CountProjects(files) - fmt.Println() - fmt.Println(" Welcome to cburn!") - fmt.Println() + // Pre-populate from existing config + var sessionKey, adminKey string + 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 { - fmt.Printf(" Found %s sessions in %s (%d projects)\n\n", - formatNumber(int64(len(files))), flagDataDir, projectCount) + welcomeDesc = fmt.Sprintf("Found %d sessions across %d projects in %s.", + len(files), projectCount, flagDataDir) } - // 1. API key - fmt.Println(" 1. Anthropic Admin API key") - fmt.Println(" For real cost data from the billing API.") - existing := config.GetAdminAPIKey(cfg) - if existing != "" { - fmt.Printf(" Current: %s\n", maskAPIKey(existing)) + // Build placeholder text showing masked existing values + sessionPlaceholder := "sk-ant-sid... (Enter to skip)" + if key := config.GetSessionKey(cfg); key != "" { + sessionPlaceholder = maskAPIKey(key) + " (Enter to keep)" } - fmt.Print(" > ") - apiKey, _ := reader.ReadString('\n') - apiKey = strings.TrimSpace(apiKey) - 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" + adminPlaceholder := "sk-ant-admin-... (Enter to skip)" + if key := config.GetAdminAPIKey(cfg); key != "" { + adminPlaceholder = maskAPIKey(key) + " (Enter to keep)" } - // 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 { return fmt.Errorf("saving config: %w", err) } - fmt.Println() - fmt.Printf(" Saved to %s\n", config.ConfigPath()) + fmt.Printf("\n Saved to %s\n", config.Path()) fmt.Println(" Run `cburn setup` anytime to reconfigure.") fmt.Println() 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 { if len(key) > 16 { return key[:8] + "..." + key[len(key)-4:] diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..be671f9 --- /dev/null +++ b/cmd/status.go @@ -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) +} diff --git a/go.mod b/go.mod index 6c01239..09d4bcd 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.9.1 modernc.org/sqlite v1.46.1 @@ -14,9 +15,12 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // 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/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // 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/clipperhouse/displaywidth v0.9.0 // 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-localereader v0.0.1 // 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/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -39,7 +44,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // 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/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index fb0a480..8b37856 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,45 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= 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/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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-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/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 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/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/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/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= 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/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/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/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= 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/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 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/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +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/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/render.go b/internal/cli/render.go index 2269329..ec41bd1 100644 --- a/internal/cli/render.go +++ b/internal/cli/render.go @@ -5,23 +5,24 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" ) // Theme colors (Flexoki Dark) var ( - ColorBg = lipgloss.Color("#100F0F") - ColorSurface = lipgloss.Color("#1C1B1A") - ColorBorder = lipgloss.Color("#282726") - ColorTextDim = lipgloss.Color("#575653") + ColorBg = lipgloss.Color("#100F0F") + ColorSurface = lipgloss.Color("#1C1B1A") + ColorBorder = lipgloss.Color("#282726") + ColorTextDim = lipgloss.Color("#575653") ColorTextMuted = lipgloss.Color("#6F6E69") - ColorText = lipgloss.Color("#FFFCF0") - ColorAccent = lipgloss.Color("#3AA99F") - ColorGreen = lipgloss.Color("#879A39") - ColorOrange = lipgloss.Color("#DA702C") - ColorRed = lipgloss.Color("#D14D41") - ColorBlue = lipgloss.Color("#4385BE") - ColorPurple = lipgloss.Color("#8B7EC8") - ColorYellow = lipgloss.Color("#D0A215") + ColorText = lipgloss.Color("#FFFCF0") + ColorAccent = lipgloss.Color("#3AA99F") + ColorGreen = lipgloss.Color("#879A39") + ColorOrange = lipgloss.Color("#DA702C") + ColorRed = lipgloss.Color("#D14D41") + ColorBlue = lipgloss.Color("#4385BE") + ColorPurple = lipgloss.Color("#8B7EC8") + ColorYellow = lipgloss.Color("#D0A215") ) // Styles @@ -35,21 +36,9 @@ var ( Bold(true). Foreground(ColorAccent) - valueStyle = lipgloss.NewStyle(). - Foreground(ColorText) - mutedStyle = lipgloss.NewStyle(). Foreground(ColorTextMuted) - costStyle = lipgloss.NewStyle(). - Foreground(ColorGreen) - - tokenStyle = lipgloss.NewStyle(). - Foreground(ColorBlue) - - warnStyle = lipgloss.NewStyle(). - Foreground(ColorOrange) - dimStyle = lipgloss.NewStyle(). Foreground(ColorTextDim) ) @@ -59,7 +48,6 @@ type Table struct { Title string Headers []string Rows [][]string - Widths []int // optional column widths, auto-calculated if nil } // 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)) } -// 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 { if len(t.Rows) == 0 && len(t.Headers) == 0 { return "" } - // Calculate column widths - numCols := len(t.Headers) - if numCols == 0 && len(t.Rows) > 0 { - numCols = len(t.Rows[0]) + // Filter out "---" separator sentinels (not supported by lipgloss/table). + rows := make([][]string, 0, len(t.Rows)) + for _, row := range t.Rows { + if len(row) == 1 && row[0] == "---" { + continue + } + rows = append(rows, row) } - widths := make([]int, numCols) - if t.Widths != nil { - copy(widths, t.Widths) - } else { - for i, h := range t.Headers { - if len(h) > widths[i] { - widths[i] = len(h) + tbl := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(dimStyle). + BorderColumn(true). + BorderHeader(true). + Headers(t.Headers...). + 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) } - } - for _, row := range t.Rows { - for i, cell := range row { - if i < numCols && len(cell) > widths[i] { - widths[i] = len(cell) - } + s = s.Foreground(ColorText) + if col > 0 { + s = s.Align(lipgloss.Right) } - } - } + return s + }) var b strings.Builder - - // Title above table if present if t.Title != "" { b.WriteString(" ") b.WriteString(headerStyle.Render(t.Title)) b.WriteString("\n") } - - 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(tbl.Render()) b.WriteString("\n") return b.String() @@ -242,26 +141,26 @@ func RenderSparkline(values []float64) string { blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} - max := values[0] + maxVal := values[0] for _, v := range values[1:] { - if v > max { - max = v + if v > maxVal { + maxVal = v } } - if max == 0 { - max = 1 + if maxVal == 0 { + maxVal = 1 } var b strings.Builder for _, v := range values { - idx := int(v / max * float64(len(blocks)-1)) + idx := int(v / maxVal * float64(len(blocks)-1)) if idx >= len(blocks) { idx = len(blocks) - 1 } if idx < 0 { idx = 0 } - b.WriteRune(blocks[idx]) + b.WriteRune(blocks[idx]) //nolint:gosec // bounds checked above } return b.String() @@ -270,12 +169,12 @@ func RenderSparkline(values []float64) string { // RenderHorizontalBar renders a horizontal bar chart entry. func RenderHorizontalBar(label string, value, maxValue float64, maxWidth int) string { if maxValue <= 0 { - return fmt.Sprintf(" %s", label) + return " " + label } barLen := int(value / maxValue * float64(maxWidth)) if barLen < 0 { barLen = 0 } bar := strings.Repeat("█", barLen) - return fmt.Sprintf(" %s", bar) + return " " + bar }