Compare commits
10 Commits
892f578565
...
74c9905dbf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c9905dbf | ||
|
|
96d464a2c0 | ||
|
|
9b1554d72c | ||
|
|
4c649610c9 | ||
|
|
93e343f657 | ||
|
|
5b9edc7702 | ||
|
|
35fae37ba4 | ||
|
|
2be7b5e193 | ||
|
|
e241ee3966 | ||
|
|
547d402578 |
10
CLAUDE.md
10
CLAUDE.md
@@ -83,3 +83,13 @@ Run `cburn setup` for interactive configuration.
|
|||||||
- `components.CardInnerWidth(w)` computes usable width inside a card border.
|
- `components.CardInnerWidth(w)` computes usable width inside a card border.
|
||||||
- `components.LayoutRow(w, n)` splits width into n columns accounting for gaps.
|
- `components.LayoutRow(w, n)` splits width into n columns accounting for gaps.
|
||||||
- When rendering inline bars (like Activity panel), dynamically compute column widths from actual data to prevent line wrapping.
|
- When rendering inline bars (like Activity panel), dynamically compute column widths from actual data to prevent line wrapping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Insights (Learned Patterns)
|
||||||
|
|
||||||
|
### ANSI Width Calculation
|
||||||
|
**Always use `lipgloss.Width()`, never `len()`** for styled strings. `len()` counts raw bytes including ANSI escape sequences (~20 bytes per color code). A 20-char bar with two color codes becomes ~60+ bytes, breaking column layouts. For padding, use custom width-aware padding since `fmt.Sprintf("%*s")` also pads by byte count.
|
||||||
|
|
||||||
|
### JSON Top-Level Type Detection
|
||||||
|
When parsing JSONL with nested JSON content (like Claude Code sessions), `bytes.Contains(line, pattern)` matches nested strings too. For top-level field detection, track brace depth and skip quoted strings to find the actual top-level `"type"` field.
|
||||||
|
|||||||
191
README.md
Normal file
191
README.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# cburn
|
||||||
|
|
||||||
|
A CLI and TUI dashboard for analyzing Claude Code usage metrics. Parses JSONL session logs from `~/.claude/projects/`, computes token usage, costs, cache efficiency, and activity patterns.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build from source
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Install to ~/go/bin
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Go 1.24+.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cburn # Summary of usage metrics
|
||||||
|
cburn tui # Interactive dashboard
|
||||||
|
cburn costs # Cost breakdown by token type
|
||||||
|
cburn status # Claude.ai subscription status
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `cburn` | Usage summary (default) |
|
||||||
|
| `cburn summary` | Detailed usage summary with costs |
|
||||||
|
| `cburn costs` | Cost breakdown by token type and model |
|
||||||
|
| `cburn daily` | Daily usage table |
|
||||||
|
| `cburn hourly` | Activity by hour of day |
|
||||||
|
| `cburn sessions` | Session list with details |
|
||||||
|
| `cburn models` | Model usage breakdown |
|
||||||
|
| `cburn projects` | Project usage ranking |
|
||||||
|
| `cburn status` | Claude.ai subscription status and rate limits |
|
||||||
|
| `cburn config` | Show current configuration |
|
||||||
|
| `cburn setup` | Interactive first-time setup wizard |
|
||||||
|
| `cburn tui` | Interactive dashboard |
|
||||||
|
|
||||||
|
## Global Flags
|
||||||
|
|
||||||
|
```
|
||||||
|
-n, --days INT Time window in days (default: 30)
|
||||||
|
-p, --project STRING Filter to project (substring match)
|
||||||
|
-m, --model STRING Filter to model (substring match)
|
||||||
|
-d, --data-dir PATH Claude data directory (default: ~/.claude)
|
||||||
|
-q, --quiet Suppress progress output
|
||||||
|
--no-cache Skip SQLite cache, reparse everything
|
||||||
|
--no-subagents Exclude subagent sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cburn -n 7 # Last 7 days
|
||||||
|
cburn costs -p myproject # Costs for a specific project
|
||||||
|
cburn sessions -m opus # Sessions using Opus models
|
||||||
|
cburn daily --no-subagents # Exclude spawned agents
|
||||||
|
```
|
||||||
|
|
||||||
|
## TUI Dashboard
|
||||||
|
|
||||||
|
Launch with `cburn tui`. Navigate with keyboard:
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `o` / `c` / `s` / `b` / `x` | Jump to Overview / Costs / Sessions / Breakdown / Settings |
|
||||||
|
| `<-` / `->` | Previous / Next tab |
|
||||||
|
| `j` / `k` | Navigate lists |
|
||||||
|
| `J` / `K` | Scroll detail pane |
|
||||||
|
| `Ctrl+d` / `Ctrl+u` | Scroll half-page |
|
||||||
|
| `Enter` / `f` | Expand session full-screen |
|
||||||
|
| `Esc` | Back to split view |
|
||||||
|
| `r` | Refresh data |
|
||||||
|
| `R` | Toggle auto-refresh |
|
||||||
|
| `?` | Help overlay |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
- **Overview** - Summary stats, daily activity chart, live hourly/minute charts
|
||||||
|
- **Costs** - Cost breakdown by token type and model, cache savings
|
||||||
|
- **Sessions** - Browseable session list with detail pane
|
||||||
|
- **Breakdown** - Model and project rankings
|
||||||
|
- **Settings** - Configuration management
|
||||||
|
|
||||||
|
### Themes
|
||||||
|
|
||||||
|
Four color themes are available:
|
||||||
|
|
||||||
|
- `flexoki-dark` (default) - Warm earth tones
|
||||||
|
- `catppuccin-mocha` - Pastel colors
|
||||||
|
- `tokyo-night` - Cool blue/purple
|
||||||
|
- `terminal` - ANSI 16 colors only
|
||||||
|
|
||||||
|
Change via `cburn setup` or edit `~/.config/cburn/config.toml`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Config file: `~/.config/cburn/config.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[general]
|
||||||
|
default_days = 30
|
||||||
|
include_subagents = true
|
||||||
|
|
||||||
|
[claude_ai]
|
||||||
|
session_key = "sk-ant-sid..." # For subscription/rate limit data
|
||||||
|
|
||||||
|
[admin_api]
|
||||||
|
api_key = "sk-ant-admin-..." # For billing API (optional)
|
||||||
|
|
||||||
|
[appearance]
|
||||||
|
theme = "flexoki-dark"
|
||||||
|
|
||||||
|
[budget]
|
||||||
|
monthly_usd = 100 # Optional spending cap
|
||||||
|
|
||||||
|
[tui]
|
||||||
|
auto_refresh = true
|
||||||
|
refresh_interval_sec = 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `CLAUDE_SESSION_KEY` | Claude.ai session key (overrides config) |
|
||||||
|
| `ANTHROPIC_ADMIN_KEY` | Admin API key (overrides config) |
|
||||||
|
|
||||||
|
### Claude.ai Session Key
|
||||||
|
|
||||||
|
The session key enables:
|
||||||
|
- Real-time rate limit monitoring (5-hour and 7-day windows)
|
||||||
|
- Overage spend tracking
|
||||||
|
- Organization info
|
||||||
|
|
||||||
|
To get your session key:
|
||||||
|
1. Open claude.ai in your browser
|
||||||
|
2. DevTools (F12) > Application > Cookies > claude.ai
|
||||||
|
3. Copy the `sessionKey` value (starts with `sk-ant-sid...`)
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
Session data is cached in SQLite at `~/.cache/cburn/sessions.db`. The cache uses mtime-based diffing - unchanged files are not reparsed.
|
||||||
|
|
||||||
|
Force a full reparse with `--no-cache`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build # Build ./cburn binary
|
||||||
|
make install # Install to ~/go/bin
|
||||||
|
make lint # Run golangci-lint
|
||||||
|
make test # Run unit tests
|
||||||
|
make test-race # Tests with race detector
|
||||||
|
make bench # Pipeline benchmarks
|
||||||
|
make fuzz # Fuzz the JSONL parser (30s default)
|
||||||
|
make clean # Remove binary and test cache
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.claude/projects/**/*.jsonl
|
||||||
|
-> source.ScanDir() + source.ParseFile() (parallel parsing)
|
||||||
|
-> store.Cache (SQLite, mtime-based incremental)
|
||||||
|
-> pipeline.Aggregate*() functions
|
||||||
|
-> CLI renderers (cmd/) or TUI tabs (internal/tui/)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Package | Role |
|
||||||
|
|---------|------|
|
||||||
|
| `cmd/` | Cobra CLI commands |
|
||||||
|
| `internal/source` | File discovery and JSONL parsing |
|
||||||
|
| `internal/pipeline` | ETL orchestration and aggregation |
|
||||||
|
| `internal/store` | SQLite cache layer |
|
||||||
|
| `internal/model` | Domain types |
|
||||||
|
| `internal/config` | TOML config and pricing tables |
|
||||||
|
| `internal/cli` | Terminal formatting |
|
||||||
|
| `internal/claudeai` | Claude.ai API client |
|
||||||
|
| `internal/tui` | Bubble Tea dashboard |
|
||||||
|
| `internal/tui/components` | Reusable TUI components |
|
||||||
|
| `internal/tui/theme` | Color schemes |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -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 != "" {
|
||||||
|
|||||||
161
cmd/setup.go
161
cmd/setup.go
@@ -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
198
cmd/status.go
Normal 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
7
go.mod
@@ -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
30
go.sum
@@ -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=
|
||||||
|
|||||||
234
internal/claudeai/client.go
Normal file
234
internal/claudeai/client.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
// Package claudeai provides a client for fetching subscription and usage data from claude.ai.
|
||||||
|
package claudeai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
baseURL = "https://claude.ai/api"
|
||||||
|
requestTimeout = 10 * time.Second
|
||||||
|
maxBodySize = 1 << 20 // 1 MB
|
||||||
|
keyPrefix = "sk-ant-sid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrUnauthorized indicates the session key is expired or invalid.
|
||||||
|
ErrUnauthorized = errors.New("claudeai: unauthorized (session key expired or invalid)")
|
||||||
|
// ErrRateLimited indicates the API rate limit was hit.
|
||||||
|
ErrRateLimited = errors.New("claudeai: rate limited")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client fetches subscription data from the claude.ai web API.
|
||||||
|
type Client struct {
|
||||||
|
sessionKey string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a client for the given session key.
|
||||||
|
// Returns nil if the key is empty or has the wrong prefix.
|
||||||
|
func NewClient(sessionKey string) *Client {
|
||||||
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
|
if sessionKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(sessionKey, keyPrefix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
http: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAll fetches orgs, usage, and overage for the first organization.
|
||||||
|
// Partial data is returned even if some requests fail.
|
||||||
|
func (c *Client) FetchAll(ctx context.Context) *SubscriptionData {
|
||||||
|
result := &SubscriptionData{FetchedAt: time.Now()}
|
||||||
|
|
||||||
|
orgs, err := c.FetchOrganizations(ctx)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if len(orgs) == 0 {
|
||||||
|
result.Error = errors.New("claudeai: no organizations found")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Org = orgs[0]
|
||||||
|
orgID := orgs[0].UUID
|
||||||
|
|
||||||
|
// Fetch usage and overage independently — partial results are fine
|
||||||
|
usage, usageErr := c.FetchUsage(ctx, orgID)
|
||||||
|
if usageErr == nil {
|
||||||
|
result.Usage = usage
|
||||||
|
}
|
||||||
|
|
||||||
|
overage, overageErr := c.FetchOverageLimit(ctx, orgID)
|
||||||
|
if overageErr == nil {
|
||||||
|
result.Overage = overage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface first non-nil error for status display
|
||||||
|
if usageErr != nil {
|
||||||
|
result.Error = usageErr
|
||||||
|
} else if overageErr != nil {
|
||||||
|
result.Error = overageErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchOrganizations returns the list of organizations for this session.
|
||||||
|
func (c *Client) FetchOrganizations(ctx context.Context) ([]Organization, error) {
|
||||||
|
body, err := c.get(ctx, "/organizations")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var orgs []Organization
|
||||||
|
if err := json.Unmarshal(body, &orgs); err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: parsing organizations: %w", err)
|
||||||
|
}
|
||||||
|
return orgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchUsage returns parsed usage windows for the given organization.
|
||||||
|
func (c *Client) FetchUsage(ctx context.Context, orgID string) (*ParsedUsage, error) {
|
||||||
|
body, err := c.get(ctx, fmt.Sprintf("/organizations/%s/usage", orgID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw UsageResponse
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: parsing usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ParsedUsage{
|
||||||
|
FiveHour: parseWindow(raw.FiveHour),
|
||||||
|
SevenDay: parseWindow(raw.SevenDay),
|
||||||
|
SevenDayOpus: parseWindow(raw.SevenDayOpus),
|
||||||
|
SevenDaySonnet: parseWindow(raw.SevenDaySonnet),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchOverageLimit returns overage spend limit data for the given organization.
|
||||||
|
func (c *Client) FetchOverageLimit(ctx context.Context, orgID string) (*OverageLimit, error) {
|
||||||
|
body, err := c.get(ctx, fmt.Sprintf("/organizations/%s/overage_spend_limit", orgID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ol OverageLimit
|
||||||
|
if err := json.Unmarshal(body, &ol); err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: parsing overage limit: %w", err)
|
||||||
|
}
|
||||||
|
return &ol, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get performs an authenticated GET request and returns the response body.
|
||||||
|
func (c *Client) get(ctx context.Context, path string) ([]byte, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: creating request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Cookie", "sessionKey="+c.sessionKey)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "cburn/1.0")
|
||||||
|
|
||||||
|
//nolint:gosec // URL is constructed from const baseURL
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
case http.StatusTooManyRequests:
|
||||||
|
return nil, ErrRateLimited
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("claudeai: unexpected status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: reading response: %w", err)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWindow converts a raw UsageWindow into a normalized ParsedWindow.
|
||||||
|
// Returns nil if the input is nil or unparseable.
|
||||||
|
func parseWindow(w *UsageWindow) *ParsedWindow {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pct, ok := parseUtilization(w.Utilization)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pw := &ParsedWindow{Pct: pct}
|
||||||
|
|
||||||
|
if w.ResetsAt != nil {
|
||||||
|
if t, err := time.Parse(time.RFC3339, *w.ResetsAt); err == nil {
|
||||||
|
pw.ResetsAt = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pw
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUtilization defensively parses the polymorphic utilization field.
|
||||||
|
// Handles int (75), float (0.75 or 75.0), and string ("75%" or "0.75").
|
||||||
|
// Returns value normalized to 0.0-1.0 range.
|
||||||
|
func parseUtilization(raw json.RawMessage) (float64, bool) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try number first (covers both int and float JSON)
|
||||||
|
var f float64
|
||||||
|
if err := json.Unmarshal(raw, &f); err == nil {
|
||||||
|
return normalizeUtilization(f), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try string
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw, &s); err == nil {
|
||||||
|
s = strings.TrimSuffix(strings.TrimSpace(s), "%")
|
||||||
|
if v, err := strconv.ParseFloat(s, 64); err == nil {
|
||||||
|
return normalizeUtilization(v), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeUtilization converts a value to 0.0-1.0 range.
|
||||||
|
// Values > 1.0 are assumed to be percentages (0-100 scale).
|
||||||
|
func normalizeUtilization(v float64) float64 {
|
||||||
|
if v > 1.0 {
|
||||||
|
return v / 100.0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
59
internal/claudeai/types.go
Normal file
59
internal/claudeai/types.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package claudeai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Organization represents a claude.ai organization.
|
||||||
|
type Organization struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageResponse is the raw API response from the usage endpoint.
|
||||||
|
type UsageResponse struct {
|
||||||
|
FiveHour *UsageWindow `json:"five_hour"`
|
||||||
|
SevenDay *UsageWindow `json:"seven_day"`
|
||||||
|
SevenDayOpus *UsageWindow `json:"seven_day_opus"`
|
||||||
|
SevenDaySonnet *UsageWindow `json:"seven_day_sonnet"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageWindow is a single rate-limit window from the API.
|
||||||
|
// Utilization can be int, float, or string — kept as raw JSON for defensive parsing.
|
||||||
|
type UsageWindow struct {
|
||||||
|
Utilization json.RawMessage `json:"utilization"`
|
||||||
|
ResetsAt *string `json:"resets_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverageLimit is the raw API response from the overage spend limit endpoint.
|
||||||
|
type OverageLimit struct {
|
||||||
|
IsEnabled bool `json:"isEnabled"`
|
||||||
|
UsedCredits float64 `json:"usedCredits"`
|
||||||
|
MonthlyCreditLimit float64 `json:"monthlyCreditLimit"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionData is the parsed, TUI-ready aggregate of all claude.ai API data.
|
||||||
|
type SubscriptionData struct {
|
||||||
|
Org Organization
|
||||||
|
Usage *ParsedUsage
|
||||||
|
Overage *OverageLimit
|
||||||
|
FetchedAt time.Time
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedUsage holds normalized usage windows.
|
||||||
|
type ParsedUsage struct {
|
||||||
|
FiveHour *ParsedWindow
|
||||||
|
SevenDay *ParsedWindow
|
||||||
|
SevenDayOpus *ParsedWindow
|
||||||
|
SevenDaySonnet *ParsedWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedWindow is a single rate-limit window, normalized for display.
|
||||||
|
type ParsedWindow struct {
|
||||||
|
Pct float64 // 0.0-1.0
|
||||||
|
ResetsAt time.Time
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/lipgloss/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Theme colors (Flexoki Dark)
|
// Theme colors (Flexoki Dark)
|
||||||
@@ -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)
|
||||||
for _, row := range t.Rows {
|
if row == table.HeaderRow {
|
||||||
for i, cell := range row {
|
return s.Bold(true).Foreground(ColorAccent)
|
||||||
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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package config handles cburn configuration loading, saving, and pricing.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,8 +13,10 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
General GeneralConfig `toml:"general"`
|
General GeneralConfig `toml:"general"`
|
||||||
AdminAPI AdminAPIConfig `toml:"admin_api"`
|
AdminAPI AdminAPIConfig `toml:"admin_api"`
|
||||||
|
ClaudeAI ClaudeAIConfig `toml:"claude_ai"`
|
||||||
Budget BudgetConfig `toml:"budget"`
|
Budget BudgetConfig `toml:"budget"`
|
||||||
Appearance AppearanceConfig `toml:"appearance"`
|
Appearance AppearanceConfig `toml:"appearance"`
|
||||||
|
TUI TUIConfig `toml:"tui"`
|
||||||
Pricing PricingOverrides `toml:"pricing"`
|
Pricing PricingOverrides `toml:"pricing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,10 +29,16 @@ type GeneralConfig struct {
|
|||||||
|
|
||||||
// AdminAPIConfig holds Anthropic Admin API settings.
|
// AdminAPIConfig holds Anthropic Admin API settings.
|
||||||
type AdminAPIConfig struct {
|
type AdminAPIConfig struct {
|
||||||
APIKey string `toml:"api_key,omitempty"`
|
APIKey string `toml:"api_key,omitempty"` //nolint:gosec // config field, not a secret
|
||||||
BaseURL string `toml:"base_url,omitempty"`
|
BaseURL string `toml:"base_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClaudeAIConfig holds claude.ai session key settings for subscription data.
|
||||||
|
type ClaudeAIConfig struct {
|
||||||
|
SessionKey string `toml:"session_key,omitempty"` //nolint:gosec // config field, not a secret
|
||||||
|
OrgID string `toml:"org_id,omitempty"` // auto-cached after first fetch
|
||||||
|
}
|
||||||
|
|
||||||
// BudgetConfig holds budget tracking settings.
|
// BudgetConfig holds budget tracking settings.
|
||||||
type BudgetConfig struct {
|
type BudgetConfig struct {
|
||||||
MonthlyUSD *float64 `toml:"monthly_usd,omitempty"`
|
MonthlyUSD *float64 `toml:"monthly_usd,omitempty"`
|
||||||
@@ -40,6 +49,12 @@ type AppearanceConfig struct {
|
|||||||
Theme string `toml:"theme"`
|
Theme string `toml:"theme"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TUIConfig holds TUI-specific settings.
|
||||||
|
type TUIConfig struct {
|
||||||
|
AutoRefresh bool `toml:"auto_refresh"`
|
||||||
|
RefreshIntervalSec int `toml:"refresh_interval_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
// PricingOverrides allows user-defined pricing for specific models.
|
// PricingOverrides allows user-defined pricing for specific models.
|
||||||
type PricingOverrides struct {
|
type PricingOverrides struct {
|
||||||
Overrides map[string]ModelPricingOverride `toml:"overrides,omitempty"`
|
Overrides map[string]ModelPricingOverride `toml:"overrides,omitempty"`
|
||||||
@@ -64,11 +79,15 @@ func DefaultConfig() Config {
|
|||||||
Appearance: AppearanceConfig{
|
Appearance: AppearanceConfig{
|
||||||
Theme: "flexoki-dark",
|
Theme: "flexoki-dark",
|
||||||
},
|
},
|
||||||
|
TUI: TUIConfig{
|
||||||
|
AutoRefresh: true,
|
||||||
|
RefreshIntervalSec: 30,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigDir returns the XDG-compliant config directory.
|
// Dir returns the XDG-compliant config directory.
|
||||||
func ConfigDir() string {
|
func Dir() string {
|
||||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||||
return filepath.Join(xdg, "cburn")
|
return filepath.Join(xdg, "cburn")
|
||||||
}
|
}
|
||||||
@@ -76,16 +95,16 @@ func ConfigDir() string {
|
|||||||
return filepath.Join(home, ".config", "cburn")
|
return filepath.Join(home, ".config", "cburn")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigPath returns the full path to the config file.
|
// Path returns the full path to the config file.
|
||||||
func ConfigPath() string {
|
func Path() string {
|
||||||
return filepath.Join(ConfigDir(), "config.toml")
|
return filepath.Join(Dir(), "config.toml")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads the config file, returning defaults if it doesn't exist.
|
// Load reads the config file, returning defaults if it doesn't exist.
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
data, err := os.ReadFile(ConfigPath())
|
data, err := os.ReadFile(Path())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -102,19 +121,21 @@ func Load() (Config, error) {
|
|||||||
|
|
||||||
// Save writes the config to disk.
|
// Save writes the config to disk.
|
||||||
func Save(cfg Config) error {
|
func Save(cfg Config) error {
|
||||||
dir := ConfigDir()
|
dir := Dir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return fmt.Errorf("creating config dir: %w", err)
|
return fmt.Errorf("creating config dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(ConfigPath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
f, err := os.OpenFile(Path(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating config file: %w", err)
|
return fmt.Errorf("creating config file: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
enc := toml.NewEncoder(f)
|
enc := toml.NewEncoder(f)
|
||||||
return enc.Encode(cfg)
|
if err := enc.Encode(cfg); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAdminAPIKey returns the API key from env var or config, in that order.
|
// GetAdminAPIKey returns the API key from env var or config, in that order.
|
||||||
@@ -125,8 +146,16 @@ func GetAdminAPIKey(cfg Config) string {
|
|||||||
return cfg.AdminAPI.APIKey
|
return cfg.AdminAPI.APIKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSessionKey returns the session key from env var or config, in that order.
|
||||||
|
func GetSessionKey(cfg Config) string {
|
||||||
|
if key := os.Getenv("CLAUDE_SESSION_KEY"); key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return cfg.ClaudeAI.SessionKey
|
||||||
|
}
|
||||||
|
|
||||||
// Exists returns true if a config file exists on disk.
|
// Exists returns true if a config file exists on disk.
|
||||||
func Exists() bool {
|
func Exists() bool {
|
||||||
_, err := os.Stat(ConfigPath())
|
_, err := os.Stat(Path())
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,3 +92,9 @@ type PeriodComparison struct {
|
|||||||
Current SummaryStats
|
Current SummaryStats
|
||||||
Previous SummaryStats
|
Previous SummaryStats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MinuteStats holds token counts for a 5-minute bucket.
|
||||||
|
type MinuteStats struct {
|
||||||
|
Minute int // 0-11 (bucket index within the hour)
|
||||||
|
Tokens int64
|
||||||
|
}
|
||||||
|
|||||||
@@ -271,3 +271,62 @@ func FilterByModel(sessions []model.SessionStats, modelFilter string) []model.Se
|
|||||||
func containsIgnoreCase(s, substr string) bool {
|
func containsIgnoreCase(s, substr string) bool {
|
||||||
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AggregateTodayHourly computes 24 hourly token buckets for today (local time).
|
||||||
|
func AggregateTodayHourly(sessions []model.SessionStats) []model.HourlyStats {
|
||||||
|
now := time.Now()
|
||||||
|
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||||
|
todayEnd := todayStart.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
hours := make([]model.HourlyStats, 24)
|
||||||
|
for i := range hours {
|
||||||
|
hours[i].Hour = i
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.StartTime.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
local := s.StartTime.Local()
|
||||||
|
if local.Before(todayStart) || !local.Before(todayEnd) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := local.Hour()
|
||||||
|
hours[h].Prompts += s.UserMessages
|
||||||
|
hours[h].Sessions++
|
||||||
|
hours[h].Tokens += s.InputTokens + s.OutputTokens
|
||||||
|
}
|
||||||
|
return hours
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateLastHour computes 12 five-minute token buckets for the last 60 minutes.
|
||||||
|
func AggregateLastHour(sessions []model.SessionStats) []model.MinuteStats {
|
||||||
|
now := time.Now()
|
||||||
|
hourAgo := now.Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
buckets := make([]model.MinuteStats, 12)
|
||||||
|
for i := range buckets {
|
||||||
|
buckets[i].Minute = i
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.StartTime.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
local := s.StartTime.Local()
|
||||||
|
if local.Before(hourAgo) || !local.Before(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Compute which 5-minute bucket (0-11) this falls into
|
||||||
|
minutesAgo := int(now.Sub(local).Minutes())
|
||||||
|
bucketIdx := 11 - (minutesAgo / 5) // 11 = most recent, 0 = oldest
|
||||||
|
if bucketIdx < 0 {
|
||||||
|
bucketIdx = 0
|
||||||
|
}
|
||||||
|
if bucketIdx > 11 {
|
||||||
|
bucketIdx = 11
|
||||||
|
}
|
||||||
|
buckets[bucketIdx].Tokens += s.InputTokens + s.OutputTokens
|
||||||
|
}
|
||||||
|
return buckets
|
||||||
|
}
|
||||||
|
|||||||
1072
internal/tui/app.go
1072
internal/tui/app.go
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,7 @@
|
|||||||
|
// Package components provides reusable TUI widgets for the cburn dashboard.
|
||||||
package components
|
package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"cburn/internal/tui/theme"
|
"cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -126,312 +123,3 @@ func CardInnerWidth(outerWidth int) int {
|
|||||||
}
|
}
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sparkline renders a unicode sparkline from values.
|
|
||||||
func Sparkline(values []float64, color lipgloss.Color) string {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
|
||||||
|
|
||||||
max := values[0]
|
|
||||||
for _, v := range values[1:] {
|
|
||||||
if v > max {
|
|
||||||
max = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if max == 0 {
|
|
||||||
max = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
style := lipgloss.NewStyle().Foreground(color)
|
|
||||||
|
|
||||||
var result string
|
|
||||||
for _, v := range values {
|
|
||||||
idx := int(v / max * float64(len(blocks)-1))
|
|
||||||
if idx >= len(blocks) {
|
|
||||||
idx = len(blocks) - 1
|
|
||||||
}
|
|
||||||
if idx < 0 {
|
|
||||||
idx = 0
|
|
||||||
}
|
|
||||||
result += string(blocks[idx])
|
|
||||||
}
|
|
||||||
|
|
||||||
return style.Render(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BarChart renders a multi-row bar chart with anchored Y-axis and optional X-axis labels.
|
|
||||||
// labels (if non-nil) should correspond 1:1 with values for x-axis display.
|
|
||||||
// height is a target; actual height adjusts slightly so Y-axis ticks are evenly spaced.
|
|
||||||
func BarChart(values []float64, labels []string, color lipgloss.Color, width, height int) string {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if width < 15 || height < 3 {
|
|
||||||
return Sparkline(values, color)
|
|
||||||
}
|
|
||||||
|
|
||||||
t := theme.Active
|
|
||||||
|
|
||||||
// Find max value
|
|
||||||
maxVal := 0.0
|
|
||||||
for _, v := range values {
|
|
||||||
if v > maxVal {
|
|
||||||
maxVal = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if maxVal == 0 {
|
|
||||||
maxVal = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Y-axis: compute tick step and ceiling, then fit within requested height.
|
|
||||||
// Each interval needs at least 2 rows for readable spacing, so
|
|
||||||
// maxIntervals = height/2. If the initial step gives too many intervals,
|
|
||||||
// double it until they fit.
|
|
||||||
tickStep := chartTickStep(maxVal)
|
|
||||||
maxIntervals := height / 2
|
|
||||||
if maxIntervals < 2 {
|
|
||||||
maxIntervals = 2
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
n := int(math.Ceil(maxVal / tickStep))
|
|
||||||
if n <= maxIntervals {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
tickStep *= 2
|
|
||||||
}
|
|
||||||
ceiling := math.Ceil(maxVal/tickStep) * tickStep
|
|
||||||
numIntervals := int(math.Round(ceiling / tickStep))
|
|
||||||
if numIntervals < 1 {
|
|
||||||
numIntervals = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each interval gets the same number of rows; chart height is an exact multiple.
|
|
||||||
rowsPerTick := height / numIntervals
|
|
||||||
if rowsPerTick < 2 {
|
|
||||||
rowsPerTick = 2
|
|
||||||
}
|
|
||||||
chartH := rowsPerTick * numIntervals
|
|
||||||
|
|
||||||
// Pre-compute tick labels at evenly-spaced row positions
|
|
||||||
yLabelW := len(formatChartLabel(ceiling)) + 1
|
|
||||||
if yLabelW < 4 {
|
|
||||||
yLabelW = 4
|
|
||||||
}
|
|
||||||
tickLabels := make(map[int]string)
|
|
||||||
for i := 1; i <= numIntervals; i++ {
|
|
||||||
row := i * rowsPerTick
|
|
||||||
tickLabels[row] = formatChartLabel(tickStep * float64(i))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chart area width (excluding y-axis label and axis line char)
|
|
||||||
chartW := width - yLabelW - 1
|
|
||||||
if chartW < 5 {
|
|
||||||
chartW = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
n := len(values)
|
|
||||||
|
|
||||||
// Bar sizing: always use 1-char gaps, target barW >= 2.
|
|
||||||
// If bars don't fit at width 2, subsample to fewer bars.
|
|
||||||
gap := 1
|
|
||||||
if n <= 1 {
|
|
||||||
gap = 0
|
|
||||||
}
|
|
||||||
barW := 2
|
|
||||||
if n > 1 {
|
|
||||||
barW = (chartW - (n - 1)) / n
|
|
||||||
} else if n == 1 {
|
|
||||||
barW = chartW
|
|
||||||
}
|
|
||||||
if barW < 2 && n > 1 {
|
|
||||||
// Subsample so bars fit at width 2 with 1-char gaps
|
|
||||||
maxN := (chartW + 1) / 3 // each bar = 2 chars + 1 gap (last bar no gap)
|
|
||||||
if maxN < 2 {
|
|
||||||
maxN = 2
|
|
||||||
}
|
|
||||||
sampled := make([]float64, maxN)
|
|
||||||
var sampledLabels []string
|
|
||||||
if len(labels) == n {
|
|
||||||
sampledLabels = make([]string, maxN)
|
|
||||||
}
|
|
||||||
for i := range sampled {
|
|
||||||
srcIdx := i * (n - 1) / (maxN - 1)
|
|
||||||
sampled[i] = values[srcIdx]
|
|
||||||
if sampledLabels != nil {
|
|
||||||
sampledLabels[i] = labels[srcIdx]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
values = sampled
|
|
||||||
labels = sampledLabels
|
|
||||||
n = maxN
|
|
||||||
barW = 2
|
|
||||||
}
|
|
||||||
if barW > 6 {
|
|
||||||
barW = 6
|
|
||||||
}
|
|
||||||
axisLen := n*barW + max(0, n-1)*gap
|
|
||||||
|
|
||||||
blocks := []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
|
||||||
barStyle := lipgloss.NewStyle().Foreground(color)
|
|
||||||
axisStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
// Render rows top to bottom using chartH (aligned to tick intervals)
|
|
||||||
for row := chartH; row >= 1; row-- {
|
|
||||||
rowTop := ceiling * float64(row) / float64(chartH)
|
|
||||||
rowBottom := ceiling * float64(row-1) / float64(chartH)
|
|
||||||
|
|
||||||
label := tickLabels[row]
|
|
||||||
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, label)))
|
|
||||||
b.WriteString(axisStyle.Render("│"))
|
|
||||||
|
|
||||||
for i, v := range values {
|
|
||||||
if i > 0 && gap > 0 {
|
|
||||||
b.WriteString(strings.Repeat(" ", gap))
|
|
||||||
}
|
|
||||||
if v >= rowTop {
|
|
||||||
b.WriteString(barStyle.Render(strings.Repeat("█", barW)))
|
|
||||||
} else if v > rowBottom {
|
|
||||||
frac := (v - rowBottom) / (rowTop - rowBottom)
|
|
||||||
idx := int(frac * 8)
|
|
||||||
if idx > 8 {
|
|
||||||
idx = 8
|
|
||||||
}
|
|
||||||
if idx < 1 {
|
|
||||||
idx = 1
|
|
||||||
}
|
|
||||||
b.WriteString(barStyle.Render(strings.Repeat(string(blocks[idx]), barW)))
|
|
||||||
} else {
|
|
||||||
b.WriteString(strings.Repeat(" ", barW))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// X-axis line with 0 label
|
|
||||||
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, "0")))
|
|
||||||
b.WriteString(axisStyle.Render("└"))
|
|
||||||
b.WriteString(axisStyle.Render(strings.Repeat("─", axisLen)))
|
|
||||||
|
|
||||||
// X-axis labels
|
|
||||||
if len(labels) == n && n > 0 {
|
|
||||||
buf := make([]byte, axisLen)
|
|
||||||
for i := range buf {
|
|
||||||
buf[i] = ' '
|
|
||||||
}
|
|
||||||
|
|
||||||
// Place labels at bar start positions, skip overlaps
|
|
||||||
minSpacing := 8
|
|
||||||
labelStep := max(1, (n*minSpacing)/(axisLen+1))
|
|
||||||
|
|
||||||
lastEnd := -1
|
|
||||||
for i := 0; i < n; i += labelStep {
|
|
||||||
pos := i * (barW + gap)
|
|
||||||
lbl := labels[i]
|
|
||||||
end := pos + len(lbl)
|
|
||||||
if pos <= lastEnd {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if end > axisLen {
|
|
||||||
end = axisLen
|
|
||||||
if end-pos < 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lbl = lbl[:end-pos]
|
|
||||||
}
|
|
||||||
copy(buf[pos:end], lbl)
|
|
||||||
lastEnd = end + 1
|
|
||||||
}
|
|
||||||
// Always place the last label, right-aligned to axis edge if needed.
|
|
||||||
// Overwrites any truncated label underneath.
|
|
||||||
if n > 1 && len(labels[n-1]) <= axisLen {
|
|
||||||
lbl := labels[n-1]
|
|
||||||
pos := axisLen - len(lbl)
|
|
||||||
end := axisLen
|
|
||||||
// Clear the area first in case a truncated label is there
|
|
||||||
for j := pos; j < end; j++ {
|
|
||||||
buf[j] = ' '
|
|
||||||
}
|
|
||||||
copy(buf[pos:end], lbl)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(strings.Repeat(" ", yLabelW+1))
|
|
||||||
b.WriteString(axisStyle.Render(strings.TrimRight(string(buf), " ")))
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// chartTickStep computes a nice tick interval targeting ~5 ticks.
|
|
||||||
func chartTickStep(maxVal float64) float64 {
|
|
||||||
if maxVal <= 0 {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
rough := maxVal / 5
|
|
||||||
exp := math.Floor(math.Log10(rough))
|
|
||||||
base := math.Pow(10, exp)
|
|
||||||
frac := rough / base
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case frac < 1.5:
|
|
||||||
return base
|
|
||||||
case frac < 3.5:
|
|
||||||
return 2 * base
|
|
||||||
default:
|
|
||||||
return 5 * base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatChartLabel(v float64) string {
|
|
||||||
switch {
|
|
||||||
case v >= 1e9:
|
|
||||||
if v == math.Trunc(v/1e9)*1e9 {
|
|
||||||
return fmt.Sprintf("%.0fB", v/1e9)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1fB", v/1e9)
|
|
||||||
case v >= 1e6:
|
|
||||||
if v == math.Trunc(v/1e6)*1e6 {
|
|
||||||
return fmt.Sprintf("%.0fM", v/1e6)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1fM", v/1e6)
|
|
||||||
case v >= 1e3:
|
|
||||||
if v == math.Trunc(v/1e3)*1e3 {
|
|
||||||
return fmt.Sprintf("%.0fk", v/1e3)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1fk", v/1e3)
|
|
||||||
case v >= 1:
|
|
||||||
return fmt.Sprintf("%.0f", v)
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%.2f", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProgressBar renders a colored progress bar.
|
|
||||||
func ProgressBar(pct float64, width int) string {
|
|
||||||
t := theme.Active
|
|
||||||
filled := int(pct * float64(width))
|
|
||||||
if filled > width {
|
|
||||||
filled = width
|
|
||||||
}
|
|
||||||
if filled < 0 {
|
|
||||||
filled = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
filledStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
|
||||||
emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
|
||||||
|
|
||||||
bar := ""
|
|
||||||
for i := 0; i < filled; i++ {
|
|
||||||
bar += filledStyle.Render("█")
|
|
||||||
}
|
|
||||||
for i := filled; i < width; i++ {
|
|
||||||
bar += emptyStyle.Render("░")
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s %.1f%%", bar, pct*100)
|
|
||||||
}
|
|
||||||
|
|||||||
303
internal/tui/components/chart.go
Normal file
303
internal/tui/components/chart.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cburn/internal/tui/theme"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sparkline renders a unicode sparkline from values.
|
||||||
|
func Sparkline(values []float64, color lipgloss.Color) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||||
|
|
||||||
|
peak := values[0]
|
||||||
|
for _, v := range values[1:] {
|
||||||
|
if v > peak {
|
||||||
|
peak = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if peak == 0 {
|
||||||
|
peak = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
style := lipgloss.NewStyle().Foreground(color)
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
buf.Grow(len(values) * 4) // UTF-8 block chars are up to 3 bytes
|
||||||
|
for _, v := range values {
|
||||||
|
idx := int(v / peak * float64(len(blocks)-1))
|
||||||
|
if idx >= len(blocks) {
|
||||||
|
idx = len(blocks) - 1
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
buf.WriteRune(blocks[idx]) //nolint:gosec // bounds checked above
|
||||||
|
}
|
||||||
|
|
||||||
|
return style.Render(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// BarChart renders a multi-row bar chart with anchored Y-axis and optional X-axis labels.
|
||||||
|
// labels (if non-nil) should correspond 1:1 with values for x-axis display.
|
||||||
|
// height is a target; actual height adjusts slightly so Y-axis ticks are evenly spaced.
|
||||||
|
func BarChart(values []float64, labels []string, color lipgloss.Color, width, height int) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if width < 15 || height < 3 {
|
||||||
|
return Sparkline(values, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := theme.Active
|
||||||
|
|
||||||
|
// Find max value
|
||||||
|
maxVal := 0.0
|
||||||
|
for _, v := range values {
|
||||||
|
if v > maxVal {
|
||||||
|
maxVal = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxVal == 0 {
|
||||||
|
maxVal = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y-axis: compute tick step and ceiling, then fit within requested height.
|
||||||
|
// Each interval needs at least 2 rows for readable spacing, so
|
||||||
|
// maxIntervals = height/2. If the initial step gives too many intervals,
|
||||||
|
// double it until they fit.
|
||||||
|
tickStep := chartTickStep(maxVal)
|
||||||
|
maxIntervals := height / 2
|
||||||
|
if maxIntervals < 2 {
|
||||||
|
maxIntervals = 2
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
n := int(math.Ceil(maxVal / tickStep))
|
||||||
|
if n <= maxIntervals {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tickStep *= 2
|
||||||
|
}
|
||||||
|
ceiling := math.Ceil(maxVal/tickStep) * tickStep
|
||||||
|
numIntervals := int(math.Round(ceiling / tickStep))
|
||||||
|
if numIntervals < 1 {
|
||||||
|
numIntervals = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each interval gets the same number of rows; chart height is an exact multiple.
|
||||||
|
rowsPerTick := height / numIntervals
|
||||||
|
if rowsPerTick < 2 {
|
||||||
|
rowsPerTick = 2
|
||||||
|
}
|
||||||
|
chartH := rowsPerTick * numIntervals
|
||||||
|
|
||||||
|
// Pre-compute tick labels at evenly-spaced row positions
|
||||||
|
yLabelW := len(formatChartLabel(ceiling)) + 1
|
||||||
|
if yLabelW < 4 {
|
||||||
|
yLabelW = 4
|
||||||
|
}
|
||||||
|
tickLabels := make(map[int]string)
|
||||||
|
for i := 1; i <= numIntervals; i++ {
|
||||||
|
row := i * rowsPerTick
|
||||||
|
tickLabels[row] = formatChartLabel(tickStep * float64(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart area width (excluding y-axis label and axis line char)
|
||||||
|
chartW := width - yLabelW - 1
|
||||||
|
if chartW < 5 {
|
||||||
|
chartW = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
n := len(values)
|
||||||
|
|
||||||
|
// Bar sizing: always use 1-char gaps, target barW >= 2.
|
||||||
|
// If bars don't fit at width 2, subsample to fewer bars.
|
||||||
|
gap := 1
|
||||||
|
if n <= 1 {
|
||||||
|
gap = 0
|
||||||
|
}
|
||||||
|
barW := 2
|
||||||
|
if n > 1 {
|
||||||
|
barW = (chartW - (n - 1)) / n
|
||||||
|
} else if n == 1 {
|
||||||
|
barW = chartW
|
||||||
|
}
|
||||||
|
if barW < 2 && n > 1 {
|
||||||
|
// Subsample so bars fit at width 2 with 1-char gaps
|
||||||
|
maxN := (chartW + 1) / 3 // each bar = 2 chars + 1 gap (last bar no gap)
|
||||||
|
if maxN < 2 {
|
||||||
|
maxN = 2
|
||||||
|
}
|
||||||
|
sampled := make([]float64, maxN)
|
||||||
|
var sampledLabels []string
|
||||||
|
if len(labels) == n {
|
||||||
|
sampledLabels = make([]string, maxN)
|
||||||
|
}
|
||||||
|
for i := range sampled {
|
||||||
|
srcIdx := i * (n - 1) / (maxN - 1)
|
||||||
|
sampled[i] = values[srcIdx]
|
||||||
|
if sampledLabels != nil {
|
||||||
|
sampledLabels[i] = labels[srcIdx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values = sampled
|
||||||
|
labels = sampledLabels
|
||||||
|
n = maxN
|
||||||
|
barW = 2
|
||||||
|
}
|
||||||
|
if barW > 6 {
|
||||||
|
barW = 6
|
||||||
|
}
|
||||||
|
axisLen := n*barW + max(0, n-1)*gap
|
||||||
|
|
||||||
|
blocks := []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||||
|
barStyle := lipgloss.NewStyle().Foreground(color)
|
||||||
|
axisStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Render rows top to bottom using chartH (aligned to tick intervals)
|
||||||
|
for row := chartH; row >= 1; row-- {
|
||||||
|
rowTop := ceiling * float64(row) / float64(chartH)
|
||||||
|
rowBottom := ceiling * float64(row-1) / float64(chartH)
|
||||||
|
|
||||||
|
label := tickLabels[row]
|
||||||
|
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, label)))
|
||||||
|
b.WriteString(axisStyle.Render("│"))
|
||||||
|
|
||||||
|
for i, v := range values {
|
||||||
|
if i > 0 && gap > 0 {
|
||||||
|
b.WriteString(strings.Repeat(" ", gap))
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v >= rowTop:
|
||||||
|
b.WriteString(barStyle.Render(strings.Repeat("\u2588", barW)))
|
||||||
|
case v > rowBottom:
|
||||||
|
frac := (v - rowBottom) / (rowTop - rowBottom)
|
||||||
|
idx := int(frac * 8)
|
||||||
|
if idx > 8 {
|
||||||
|
idx = 8
|
||||||
|
}
|
||||||
|
if idx < 1 {
|
||||||
|
idx = 1
|
||||||
|
}
|
||||||
|
b.WriteString(barStyle.Render(strings.Repeat(string(blocks[idx]), barW)))
|
||||||
|
default:
|
||||||
|
b.WriteString(strings.Repeat(" ", barW))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-axis line with 0 label
|
||||||
|
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, "0")))
|
||||||
|
b.WriteString(axisStyle.Render("└"))
|
||||||
|
b.WriteString(axisStyle.Render(strings.Repeat("─", axisLen)))
|
||||||
|
|
||||||
|
// X-axis labels
|
||||||
|
if len(labels) == n && n > 0 {
|
||||||
|
buf := make([]byte, axisLen)
|
||||||
|
for i := range buf {
|
||||||
|
buf[i] = ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place labels at bar start positions, skip overlaps
|
||||||
|
minSpacing := 8
|
||||||
|
labelStep := max(1, (n*minSpacing)/(axisLen+1))
|
||||||
|
|
||||||
|
lastEnd := -1
|
||||||
|
for i := 0; i < n; i += labelStep {
|
||||||
|
pos := i * (barW + gap)
|
||||||
|
lbl := labels[i]
|
||||||
|
end := pos + len(lbl)
|
||||||
|
if pos <= lastEnd {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if end > axisLen {
|
||||||
|
end = axisLen
|
||||||
|
if end-pos < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lbl = lbl[:end-pos]
|
||||||
|
}
|
||||||
|
copy(buf[pos:end], lbl)
|
||||||
|
lastEnd = end + 1
|
||||||
|
}
|
||||||
|
// Always attempt the last label (the loop may skip it due to labelStep).
|
||||||
|
// Right-align to axis edge if it would overflow.
|
||||||
|
if n > 1 {
|
||||||
|
lbl := labels[n-1]
|
||||||
|
pos := (n - 1) * (barW + gap)
|
||||||
|
end := pos + len(lbl)
|
||||||
|
if end > axisLen {
|
||||||
|
// Right-align: shift left so it ends at the axis edge
|
||||||
|
pos = axisLen - len(lbl)
|
||||||
|
end = axisLen
|
||||||
|
}
|
||||||
|
if pos >= 0 && pos > lastEnd {
|
||||||
|
for j := pos; j < end; j++ {
|
||||||
|
buf[j] = ' '
|
||||||
|
}
|
||||||
|
copy(buf[pos:end], lbl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(strings.Repeat(" ", yLabelW+1))
|
||||||
|
b.WriteString(axisStyle.Render(strings.TrimRight(string(buf), " ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// chartTickStep computes a nice tick interval targeting ~5 ticks.
|
||||||
|
func chartTickStep(maxVal float64) float64 {
|
||||||
|
if maxVal <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
rough := maxVal / 5
|
||||||
|
exp := math.Floor(math.Log10(rough))
|
||||||
|
base := math.Pow(10, exp)
|
||||||
|
frac := rough / base
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case frac < 1.5:
|
||||||
|
return base
|
||||||
|
case frac < 3.5:
|
||||||
|
return 2 * base
|
||||||
|
default:
|
||||||
|
return 5 * base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatChartLabel(v float64) string {
|
||||||
|
switch {
|
||||||
|
case v >= 1e9:
|
||||||
|
if v == math.Trunc(v/1e9)*1e9 {
|
||||||
|
return fmt.Sprintf("%.0fB", v/1e9)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1fB", v/1e9)
|
||||||
|
case v >= 1e6:
|
||||||
|
if v == math.Trunc(v/1e6)*1e6 {
|
||||||
|
return fmt.Sprintf("%.0fM", v/1e6)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1fM", v/1e6)
|
||||||
|
case v >= 1e3:
|
||||||
|
if v == math.Trunc(v/1e3)*1e3 {
|
||||||
|
return fmt.Sprintf("%.0fk", v/1e3)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1fk", v/1e3)
|
||||||
|
case v >= 1:
|
||||||
|
return fmt.Sprintf("%.0f", v)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%.2f", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
142
internal/tui/components/progress.go
Normal file
142
internal/tui/components/progress.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cburn/internal/tui/theme"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/progress"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProgressBar renders a colored progress bar.
|
||||||
|
func ProgressBar(pct float64, width int) string {
|
||||||
|
t := theme.Active
|
||||||
|
filled := int(pct * float64(width))
|
||||||
|
if filled > width {
|
||||||
|
filled = width
|
||||||
|
}
|
||||||
|
if filled < 0 {
|
||||||
|
filled = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
filledStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
||||||
|
emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < filled; i++ {
|
||||||
|
b.WriteString(filledStyle.Render("\u2588"))
|
||||||
|
}
|
||||||
|
for i := filled; i < width; i++ {
|
||||||
|
b.WriteString(emptyStyle.Render("\u2591"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s %.1f%%", b.String(), pct*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorForPct returns green/yellow/red based on utilization level.
|
||||||
|
func ColorForPct(pct float64) string {
|
||||||
|
t := theme.Active
|
||||||
|
switch {
|
||||||
|
case pct >= 0.8:
|
||||||
|
return string(t.Red)
|
||||||
|
case pct >= 0.5:
|
||||||
|
return string(t.Orange)
|
||||||
|
default:
|
||||||
|
return string(t.Green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitBar renders a labeled progress bar with percentage and countdown.
|
||||||
|
// label: "5-hour", "Weekly", etc. pct: 0.0-1.0. resetsAt: zero means no countdown.
|
||||||
|
// barWidth: width allocated for the progress bar portion only.
|
||||||
|
func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidth int) string {
|
||||||
|
t := theme.Active
|
||||||
|
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
}
|
||||||
|
if pct > 1 {
|
||||||
|
pct = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
bar := progress.New(
|
||||||
|
progress.WithSolidFill(ColorForPct(pct)),
|
||||||
|
progress.WithWidth(barWidth),
|
||||||
|
progress.WithoutPercentage(),
|
||||||
|
)
|
||||||
|
bar.EmptyColor = string(t.TextDim)
|
||||||
|
|
||||||
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||||
|
pctStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Bold(true)
|
||||||
|
countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||||
|
|
||||||
|
pctStr := fmt.Sprintf("%3.0f%%", pct*100)
|
||||||
|
countdown := ""
|
||||||
|
if !resetsAt.IsZero() {
|
||||||
|
dur := time.Until(resetsAt)
|
||||||
|
if dur > 0 {
|
||||||
|
countdown = formatCountdown(dur)
|
||||||
|
} else {
|
||||||
|
countdown = "now"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s %s %s %s",
|
||||||
|
labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)),
|
||||||
|
bar.ViewAs(pct),
|
||||||
|
pctStyle.Render(pctStr),
|
||||||
|
countdownStyle.Render(countdown),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompactRateBar renders a tiny status-bar-sized rate indicator.
|
||||||
|
// Example output: "5h ████░░░░ 42%"
|
||||||
|
func CompactRateBar(label string, pct float64, width int) string {
|
||||||
|
t := theme.Active
|
||||||
|
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
}
|
||||||
|
if pct > 1 {
|
||||||
|
pct = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// label + space + bar + space + pct(4 chars)
|
||||||
|
barW := width - lipgloss.Width(label) - 6
|
||||||
|
if barW < 4 {
|
||||||
|
barW = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
bar := progress.New(
|
||||||
|
progress.WithSolidFill(ColorForPct(pct)),
|
||||||
|
progress.WithWidth(barW),
|
||||||
|
progress.WithoutPercentage(),
|
||||||
|
)
|
||||||
|
bar.EmptyColor = string(t.TextDim)
|
||||||
|
|
||||||
|
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
|
||||||
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s %s %s",
|
||||||
|
labelStyle.Render(label),
|
||||||
|
bar.ViewAs(pct),
|
||||||
|
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCountdown(d time.Duration) string {
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(d.Minutes()) % 60
|
||||||
|
if h >= 24 {
|
||||||
|
days := h / 24
|
||||||
|
hours := h % 24
|
||||||
|
return fmt.Sprintf("%dd %dh", days, hours)
|
||||||
|
}
|
||||||
|
if h > 0 {
|
||||||
|
return fmt.Sprintf("%dh %dm", h, m)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm", m)
|
||||||
|
}
|
||||||
@@ -2,37 +2,108 @@ package components
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cburn/internal/claudeai"
|
||||||
"cburn/internal/tui/theme"
|
"cburn/internal/tui/theme"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/progress"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderStatusBar renders the bottom status bar.
|
// RenderStatusBar renders the bottom status bar with optional rate limit indicators.
|
||||||
func RenderStatusBar(width int, dataAge string) string {
|
func RenderStatusBar(width int, dataAge string, subData *claudeai.SubscriptionData, refreshing, autoRefresh bool) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
|
|
||||||
style := lipgloss.NewStyle().
|
style := lipgloss.NewStyle().
|
||||||
Foreground(t.TextMuted).
|
Foreground(t.TextMuted).
|
||||||
Width(width)
|
Width(width)
|
||||||
|
|
||||||
left := " [?]help [q]uit"
|
left := " [?]help [r]efresh [q]uit"
|
||||||
right := ""
|
|
||||||
if dataAge != "" {
|
// Build rate limit indicators for the middle section
|
||||||
right = fmt.Sprintf("Data: %s ", dataAge)
|
ratePart := renderStatusRateLimits(subData)
|
||||||
|
|
||||||
|
// Build right side with refresh status
|
||||||
|
var right string
|
||||||
|
if refreshing {
|
||||||
|
refreshStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
||||||
|
right = refreshStyle.Render("↻ refreshing ")
|
||||||
|
} else if dataAge != "" {
|
||||||
|
autoStr := ""
|
||||||
|
if autoRefresh {
|
||||||
|
autoStr = "↻ "
|
||||||
|
}
|
||||||
|
right = fmt.Sprintf("%sData: %s ", autoStr, dataAge)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pad middle
|
// Layout: left + ratePart + right, with padding distributed
|
||||||
padding := width - lipgloss.Width(left) - lipgloss.Width(right)
|
usedWidth := lipgloss.Width(left) + lipgloss.Width(ratePart) + lipgloss.Width(right)
|
||||||
|
padding := width - usedWidth
|
||||||
if padding < 0 {
|
if padding < 0 {
|
||||||
padding = 0
|
padding = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
bar := left
|
// Split padding: more on the left side of rate indicators
|
||||||
for i := 0; i < padding; i++ {
|
leftPad := padding / 2
|
||||||
bar += " "
|
rightPad := padding - leftPad
|
||||||
}
|
|
||||||
bar += right
|
bar := left + strings.Repeat(" ", leftPad) + ratePart + strings.Repeat(" ", rightPad) + right
|
||||||
|
|
||||||
return style.Render(bar)
|
return style.Render(bar)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderStatusRateLimits renders compact rate limit bars for the status bar.
|
||||||
|
func renderStatusRateLimits(subData *claudeai.SubscriptionData) string {
|
||||||
|
if subData == nil || subData.Usage == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
t := theme.Active
|
||||||
|
sepStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
if w := subData.Usage.FiveHour; w != nil {
|
||||||
|
parts = append(parts, compactStatusBar("5h", w.Pct))
|
||||||
|
}
|
||||||
|
if w := subData.Usage.SevenDay; w != nil {
|
||||||
|
parts = append(parts, compactStatusBar("Wk", w.Pct))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, sepStyle.Render(" | "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// compactStatusBar renders a tiny inline progress indicator for the status bar.
|
||||||
|
// Format: "5h ████░░░░ 42%"
|
||||||
|
func compactStatusBar(label string, pct float64) string {
|
||||||
|
t := theme.Active
|
||||||
|
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
}
|
||||||
|
if pct > 1 {
|
||||||
|
pct = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
barW := 8
|
||||||
|
bar := progress.New(
|
||||||
|
progress.WithSolidFill(ColorForPct(pct)),
|
||||||
|
progress.WithWidth(barW),
|
||||||
|
progress.WithoutPercentage(),
|
||||||
|
)
|
||||||
|
bar.EmptyColor = string(t.TextDim)
|
||||||
|
|
||||||
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||||
|
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s %s %s",
|
||||||
|
labelStyle.Render(label),
|
||||||
|
bar.ViewAs(pct),
|
||||||
|
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,16 +17,11 @@ type Tab struct {
|
|||||||
|
|
||||||
// Tabs defines all available tabs.
|
// Tabs defines all available tabs.
|
||||||
var Tabs = []Tab{
|
var Tabs = []Tab{
|
||||||
{Name: "Dashboard", Key: 'd', KeyPos: 0},
|
{Name: "Overview", Key: 'o', KeyPos: 0},
|
||||||
{Name: "Costs", Key: 'c', KeyPos: 0},
|
{Name: "Costs", Key: 'c', KeyPos: 0},
|
||||||
{Name: "Sessions", Key: 's', KeyPos: 0},
|
{Name: "Sessions", Key: 's', KeyPos: 0},
|
||||||
{Name: "Models", Key: 'm', KeyPos: 0},
|
{Name: "Breakdown", Key: 'b', KeyPos: 0},
|
||||||
{Name: "Projects", Key: 'p', KeyPos: 0},
|
{Name: "Settings", Key: 'x', KeyPos: -1},
|
||||||
{Name: "Trends", Key: 't', KeyPos: 0},
|
|
||||||
{Name: "Efficiency", Key: 'e', KeyPos: 0},
|
|
||||||
{Name: "Activity", Key: 'a', KeyPos: 0},
|
|
||||||
{Name: "Budget", Key: 'b', KeyPos: 0},
|
|
||||||
{Name: "Settings", Key: 'x', KeyPos: -1}, // x is not in "Settings"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderTabBar renders the tab bar with the given active index.
|
// RenderTabBar renders the tab bar with the given active index.
|
||||||
@@ -47,7 +42,7 @@ func RenderTabBar(activeIdx int, width int) string {
|
|||||||
dimKeyStyle := lipgloss.NewStyle().
|
dimKeyStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextDim)
|
Foreground(t.TextDim)
|
||||||
|
|
||||||
var parts []string
|
parts := make([]string, 0, len(Tabs))
|
||||||
for i, tab := range Tabs {
|
for i, tab := range Tabs {
|
||||||
var rendered string
|
var rendered string
|
||||||
if i == activeIdx {
|
if i == activeIdx {
|
||||||
@@ -70,25 +65,9 @@ func RenderTabBar(activeIdx int, width int) string {
|
|||||||
parts = append(parts, rendered)
|
parts = append(parts, rendered)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single row if all tabs fit
|
bar := " " + strings.Join(parts, " ")
|
||||||
full := " " + strings.Join(parts, " ")
|
if lipgloss.Width(bar) <= width {
|
||||||
if lipgloss.Width(full) <= width {
|
return bar
|
||||||
return full
|
|
||||||
}
|
}
|
||||||
|
return lipgloss.NewStyle().MaxWidth(width).Render(bar)
|
||||||
// Fall back to two rows
|
|
||||||
row1 := strings.Join(parts[:5], " ")
|
|
||||||
row2 := strings.Join(parts[5:], " ")
|
|
||||||
|
|
||||||
return " " + row1 + "\n " + row2
|
|
||||||
}
|
|
||||||
|
|
||||||
// TabIdxByKey returns the tab index for a given key press, or -1.
|
|
||||||
func TabIdxByKey(key rune) int {
|
|
||||||
for i, tab := range Tabs {
|
|
||||||
if tab.Key == key {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
319
internal/tui/tab_costs.go
Normal file
319
internal/tui/tab_costs.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
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].EstimatedCost > sorted[j].EstimatedCost
|
||||||
|
})
|
||||||
|
topDays := sorted[:spendLimit]
|
||||||
|
sort.Slice(topDays, func(i, j int) bool {
|
||||||
|
return topDays[i].Date.After(topDays[j].Date)
|
||||||
|
})
|
||||||
|
for _, d := range topDays {
|
||||||
|
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"
|
||||||
|
}
|
||||||
260
internal/tui/tab_overview.go
Normal file
260
internal/tui/tab_overview.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
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 2.5: Live Activity (Today + Last Hour)
|
||||||
|
liveHalves := components.LayoutRow(cw, 2)
|
||||||
|
liveChartH := 8
|
||||||
|
if a.isCompactLayout() {
|
||||||
|
liveChartH = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left: Today's hourly activity
|
||||||
|
var todayCard string
|
||||||
|
if len(a.todayHourly) > 0 {
|
||||||
|
hourVals := make([]float64, 24)
|
||||||
|
var todayTotal int64
|
||||||
|
for i, h := range a.todayHourly {
|
||||||
|
hourVals[i] = float64(h.Tokens)
|
||||||
|
todayTotal += h.Tokens
|
||||||
|
}
|
||||||
|
todayCard = components.ContentCard(
|
||||||
|
fmt.Sprintf("Today (%s)", cli.FormatTokens(todayTotal)),
|
||||||
|
components.BarChart(hourVals, hourLabels24(), t.Blue, components.CardInnerWidth(liveHalves[0]), liveChartH),
|
||||||
|
liveHalves[0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right: Last hour's 5-minute activity
|
||||||
|
var lastHourCard string
|
||||||
|
if len(a.lastHour) > 0 {
|
||||||
|
minVals := make([]float64, 12)
|
||||||
|
var hourTotal int64
|
||||||
|
for i, m := range a.lastHour {
|
||||||
|
minVals[i] = float64(m.Tokens)
|
||||||
|
hourTotal += m.Tokens
|
||||||
|
}
|
||||||
|
lastHourCard = components.ContentCard(
|
||||||
|
fmt.Sprintf("Last Hour (%s)", cli.FormatTokens(hourTotal)),
|
||||||
|
components.BarChart(minVals, minuteLabels(), t.Accent, components.CardInnerWidth(liveHalves[1]), liveChartH),
|
||||||
|
liveHalves[1],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.isCompactLayout() {
|
||||||
|
if todayCard != "" {
|
||||||
|
b.WriteString(todayCard)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if lastHourCard != "" {
|
||||||
|
b.WriteString(lastHourCard)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.WriteString(components.CardRow([]string{todayCard, lastHourCard}))
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hourLabels24 returns X-axis labels for 24 hourly buckets (one per hour).
|
||||||
|
func hourLabels24() []string {
|
||||||
|
labels := make([]string, 24)
|
||||||
|
for i := 0; i < 24; i++ {
|
||||||
|
h := i % 12
|
||||||
|
if h == 0 {
|
||||||
|
h = 12
|
||||||
|
}
|
||||||
|
suffix := "a"
|
||||||
|
if i >= 12 {
|
||||||
|
suffix = "p"
|
||||||
|
}
|
||||||
|
labels[i] = fmt.Sprintf("%d%s", h, suffix)
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
// minuteLabels returns X-axis labels for 12 five-minute buckets (one per bucket).
|
||||||
|
// Bucket 0 is oldest (55-60 min ago), bucket 11 is newest (0-5 min ago).
|
||||||
|
func minuteLabels() []string {
|
||||||
|
return []string{"-55", "-50", "-45", "-40", "-35", "-30", "-25", "-20", "-15", "-10", "-5", "now"}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"cburn/internal/tui/components"
|
"cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"cburn/internal/tui/theme"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +26,50 @@ 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
|
||||||
|
|
||||||
|
// Search/filter state
|
||||||
|
searching bool // true when search input is active
|
||||||
|
searchInput textinput.Model // the search text input
|
||||||
|
searchQuery string // the applied search filter
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSearchInput creates a configured text input for session search.
|
||||||
|
func newSearchInput() textinput.Model {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Placeholder = "search by project, cost, tokens..."
|
||||||
|
ti.CharLimit = 100
|
||||||
|
ti.Width = 40
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterSessionsBySearch returns sessions matching the search query.
|
||||||
|
// Matches against project name and formats cost/tokens for numeric searches.
|
||||||
|
func filterSessionsBySearch(sessions []model.SessionStats, query string) []model.SessionStats {
|
||||||
|
if query == "" {
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
query = strings.ToLower(query)
|
||||||
|
var result []model.SessionStats
|
||||||
|
for _, s := range sessions {
|
||||||
|
// Match project name
|
||||||
|
if strings.Contains(strings.ToLower(s.Project), query) {
|
||||||
|
result = append(result, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Match session ID prefix
|
||||||
|
if strings.Contains(strings.ToLower(s.SessionID), query) {
|
||||||
|
result = append(result, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Match cost (e.g., "$0.50" or "0.5")
|
||||||
|
costStr := cli.FormatCost(s.EstimatedCost)
|
||||||
|
if strings.Contains(strings.ToLower(costStr), query) {
|
||||||
|
result = append(result, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) string {
|
func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) string {
|
||||||
@@ -35,6 +80,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 +101,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 +149,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 +203,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 +233,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 +293,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
|
||||||
|
if compactModelTable {
|
||||||
|
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("\n")
|
||||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", 52)))
|
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 +351,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
|
||||||
|
if modelW < 8 {
|
||||||
|
modelW = 8
|
||||||
|
}
|
||||||
|
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.FormatNumber(int64(mu.APICalls)),
|
||||||
cli.FormatTokens(mu.InputTokens),
|
cli.FormatTokens(mu.InputTokens),
|
||||||
cli.FormatTokens(mu.OutputTokens),
|
cli.FormatTokens(mu.OutputTokens),
|
||||||
cli.FormatCost(mu.EstimatedCost))))
|
cli.FormatCost(mu.EstimatedCost))))
|
||||||
|
}
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,8 +381,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("\n")
|
||||||
body.WriteString(mutedStyle.Render("[Enter] expand [j/k] navigate [q] quit"))
|
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")
|
||||||
|
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 +442,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,7 +2,9 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"cburn/internal/config"
|
||||||
@@ -16,9 +18,12 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
settingsFieldAPIKey = iota
|
settingsFieldAPIKey = iota
|
||||||
|
settingsFieldSessionKey
|
||||||
settingsFieldTheme
|
settingsFieldTheme
|
||||||
settingsFieldDays
|
settingsFieldDays
|
||||||
settingsFieldBudget
|
settingsFieldBudget
|
||||||
|
settingsFieldAutoRefresh
|
||||||
|
settingsFieldRefreshInterval
|
||||||
settingsFieldCount // sentinel
|
settingsFieldCount // sentinel
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,13 +61,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)"
|
||||||
@@ -70,6 +83,19 @@ func (a App) settingsStartEdit() (tea.Model, tea.Cmd) {
|
|||||||
ti.SetValue(fmt.Sprintf("%.0f", *cfg.Budget.MonthlyUSD))
|
ti.SetValue(fmt.Sprintf("%.0f", *cfg.Budget.MonthlyUSD))
|
||||||
}
|
}
|
||||||
ti.EchoMode = textinput.EchoNormal
|
ti.EchoMode = textinput.EchoNormal
|
||||||
|
case settingsFieldAutoRefresh:
|
||||||
|
ti.Placeholder = "true or false"
|
||||||
|
ti.SetValue(strconv.FormatBool(a.autoRefresh))
|
||||||
|
ti.EchoMode = textinput.EchoNormal
|
||||||
|
case settingsFieldRefreshInterval:
|
||||||
|
ti.Placeholder = "30 (seconds, minimum 10)"
|
||||||
|
// Use effective value from App state to match display
|
||||||
|
intervalSec := int(a.refreshInterval.Seconds())
|
||||||
|
if intervalSec < 10 {
|
||||||
|
intervalSec = 30
|
||||||
|
}
|
||||||
|
ti.SetValue(strconv.Itoa(intervalSec))
|
||||||
|
ti.EchoMode = textinput.EchoNormal
|
||||||
}
|
}
|
||||||
|
|
||||||
ti.Focus()
|
ti.Focus()
|
||||||
@@ -103,6 +129,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
|
||||||
@@ -132,6 +160,15 @@ func (a *App) settingsSave() {
|
|||||||
cfg.Budget.MonthlyUSD = &b
|
cfg.Budget.MonthlyUSD = &b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case settingsFieldAutoRefresh:
|
||||||
|
cfg.TUI.AutoRefresh = val == "true" || val == "1" || val == "yes"
|
||||||
|
a.autoRefresh = cfg.TUI.AutoRefresh
|
||||||
|
case settingsFieldRefreshInterval:
|
||||||
|
var interval int
|
||||||
|
if _, err := fmt.Sscanf(val, "%d", &interval); err == nil && interval >= 10 {
|
||||||
|
cfg.TUI.RefreshIntervalSec = interval
|
||||||
|
a.refreshInterval = time.Duration(interval) * time.Second
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.settings.saveErr = config.Save(cfg)
|
a.settings.saveErr = config.Save(cfg)
|
||||||
@@ -162,16 +199,36 @@ 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 = "****"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use live App state for TUI-specific settings (auto-refresh, interval)
|
||||||
|
// to ensure display matches actual behavior after R toggle
|
||||||
|
refreshIntervalSec := int(a.refreshInterval.Seconds())
|
||||||
|
if refreshIntervalSec < 10 {
|
||||||
|
refreshIntervalSec = 30 // match the effective default
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
return "(not set)"
|
return "(not set)"
|
||||||
}()},
|
}()},
|
||||||
|
{"Auto Refresh", strconv.FormatBool(a.autoRefresh)},
|
||||||
|
{"Refresh Interval", fmt.Sprintf("%ds", refreshIntervalSec)},
|
||||||
}
|
}
|
||||||
|
|
||||||
var formBody strings.Builder
|
var formBody strings.Builder
|
||||||
@@ -210,7 +267,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))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package theme defines color themes for the cburn TUI dashboard.
|
||||||
package theme
|
package theme
|
||||||
|
|
||||||
import "github.com/charmbracelet/lipgloss"
|
import "github.com/charmbracelet/lipgloss"
|
||||||
@@ -8,7 +9,6 @@ type Theme struct {
|
|||||||
Background lipgloss.Color
|
Background lipgloss.Color
|
||||||
Surface lipgloss.Color
|
Surface lipgloss.Color
|
||||||
Border lipgloss.Color
|
Border lipgloss.Color
|
||||||
BorderHover lipgloss.Color
|
|
||||||
TextDim lipgloss.Color
|
TextDim lipgloss.Color
|
||||||
TextMuted lipgloss.Color
|
TextMuted lipgloss.Color
|
||||||
TextPrimary lipgloss.Color
|
TextPrimary lipgloss.Color
|
||||||
@@ -17,7 +17,6 @@ type Theme struct {
|
|||||||
Orange lipgloss.Color
|
Orange lipgloss.Color
|
||||||
Red lipgloss.Color
|
Red lipgloss.Color
|
||||||
Blue lipgloss.Color
|
Blue lipgloss.Color
|
||||||
Purple lipgloss.Color
|
|
||||||
Yellow lipgloss.Color
|
Yellow lipgloss.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +29,6 @@ var FlexokiDark = Theme{
|
|||||||
Background: lipgloss.Color("#100F0F"),
|
Background: lipgloss.Color("#100F0F"),
|
||||||
Surface: lipgloss.Color("#1C1B1A"),
|
Surface: lipgloss.Color("#1C1B1A"),
|
||||||
Border: lipgloss.Color("#282726"),
|
Border: lipgloss.Color("#282726"),
|
||||||
BorderHover: lipgloss.Color("#343331"),
|
|
||||||
TextDim: lipgloss.Color("#575653"),
|
TextDim: lipgloss.Color("#575653"),
|
||||||
TextMuted: lipgloss.Color("#6F6E69"),
|
TextMuted: lipgloss.Color("#6F6E69"),
|
||||||
TextPrimary: lipgloss.Color("#FFFCF0"),
|
TextPrimary: lipgloss.Color("#FFFCF0"),
|
||||||
@@ -39,7 +37,6 @@ var FlexokiDark = Theme{
|
|||||||
Orange: lipgloss.Color("#DA702C"),
|
Orange: lipgloss.Color("#DA702C"),
|
||||||
Red: lipgloss.Color("#D14D41"),
|
Red: lipgloss.Color("#D14D41"),
|
||||||
Blue: lipgloss.Color("#4385BE"),
|
Blue: lipgloss.Color("#4385BE"),
|
||||||
Purple: lipgloss.Color("#8B7EC8"),
|
|
||||||
Yellow: lipgloss.Color("#D0A215"),
|
Yellow: lipgloss.Color("#D0A215"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +46,6 @@ var CatppuccinMocha = Theme{
|
|||||||
Background: lipgloss.Color("#1E1E2E"),
|
Background: lipgloss.Color("#1E1E2E"),
|
||||||
Surface: lipgloss.Color("#313244"),
|
Surface: lipgloss.Color("#313244"),
|
||||||
Border: lipgloss.Color("#45475A"),
|
Border: lipgloss.Color("#45475A"),
|
||||||
BorderHover: lipgloss.Color("#585B70"),
|
|
||||||
TextDim: lipgloss.Color("#6C7086"),
|
TextDim: lipgloss.Color("#6C7086"),
|
||||||
TextMuted: lipgloss.Color("#A6ADC8"),
|
TextMuted: lipgloss.Color("#A6ADC8"),
|
||||||
TextPrimary: lipgloss.Color("#CDD6F4"),
|
TextPrimary: lipgloss.Color("#CDD6F4"),
|
||||||
@@ -58,7 +54,6 @@ var CatppuccinMocha = Theme{
|
|||||||
Orange: lipgloss.Color("#FAB387"),
|
Orange: lipgloss.Color("#FAB387"),
|
||||||
Red: lipgloss.Color("#F38BA8"),
|
Red: lipgloss.Color("#F38BA8"),
|
||||||
Blue: lipgloss.Color("#89B4FA"),
|
Blue: lipgloss.Color("#89B4FA"),
|
||||||
Purple: lipgloss.Color("#CBA6F7"),
|
|
||||||
Yellow: lipgloss.Color("#F9E2AF"),
|
Yellow: lipgloss.Color("#F9E2AF"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +63,6 @@ var TokyoNight = Theme{
|
|||||||
Background: lipgloss.Color("#1A1B26"),
|
Background: lipgloss.Color("#1A1B26"),
|
||||||
Surface: lipgloss.Color("#24283B"),
|
Surface: lipgloss.Color("#24283B"),
|
||||||
Border: lipgloss.Color("#414868"),
|
Border: lipgloss.Color("#414868"),
|
||||||
BorderHover: lipgloss.Color("#565F89"),
|
|
||||||
TextDim: lipgloss.Color("#565F89"),
|
TextDim: lipgloss.Color("#565F89"),
|
||||||
TextMuted: lipgloss.Color("#A9B1D6"),
|
TextMuted: lipgloss.Color("#A9B1D6"),
|
||||||
TextPrimary: lipgloss.Color("#C0CAF5"),
|
TextPrimary: lipgloss.Color("#C0CAF5"),
|
||||||
@@ -77,7 +71,6 @@ var TokyoNight = Theme{
|
|||||||
Orange: lipgloss.Color("#FF9E64"),
|
Orange: lipgloss.Color("#FF9E64"),
|
||||||
Red: lipgloss.Color("#F7768E"),
|
Red: lipgloss.Color("#F7768E"),
|
||||||
Blue: lipgloss.Color("#7AA2F7"),
|
Blue: lipgloss.Color("#7AA2F7"),
|
||||||
Purple: lipgloss.Color("#BB9AF7"),
|
|
||||||
Yellow: lipgloss.Color("#E0AF68"),
|
Yellow: lipgloss.Color("#E0AF68"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +80,6 @@ var Terminal = Theme{
|
|||||||
Background: lipgloss.Color("0"),
|
Background: lipgloss.Color("0"),
|
||||||
Surface: lipgloss.Color("0"),
|
Surface: lipgloss.Color("0"),
|
||||||
Border: lipgloss.Color("8"),
|
Border: lipgloss.Color("8"),
|
||||||
BorderHover: lipgloss.Color("7"),
|
|
||||||
TextDim: lipgloss.Color("8"),
|
TextDim: lipgloss.Color("8"),
|
||||||
TextMuted: lipgloss.Color("7"),
|
TextMuted: lipgloss.Color("7"),
|
||||||
TextPrimary: lipgloss.Color("15"),
|
TextPrimary: lipgloss.Color("15"),
|
||||||
@@ -96,7 +88,6 @@ var Terminal = Theme{
|
|||||||
Orange: lipgloss.Color("3"),
|
Orange: lipgloss.Color("3"),
|
||||||
Red: lipgloss.Color("1"),
|
Red: lipgloss.Color("1"),
|
||||||
Blue: lipgloss.Color("4"),
|
Blue: lipgloss.Color("4"),
|
||||||
Purple: lipgloss.Color("5"),
|
|
||||||
Yellow: lipgloss.Color("3"),
|
Yellow: lipgloss.Color("3"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user