Compare commits

..

10 Commits

Author SHA1 Message Date
teernisse
74c9905dbf docs: add README with CLI reference, TUI guide, and architecture overview
Comprehensive project README covering installation, quick start,
full CLI command table with global flags and examples, TUI dashboard
keybindings and tab descriptions, theme options, configuration format
(TOML + env vars), session key setup instructions, caching behavior,
development commands (make targets), and package architecture diagram.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:39:28 -05:00
teernisse
96d464a2c0 docs: add architectural insights for ANSI width and JSON type detection
Document two hard-won patterns in CLAUDE.md's new Architectural
Insights section:

- ANSI Width Calculation: lipgloss.Width() must be used instead of
  len() for styled strings, since ANSI escape codes add ~20 bytes per
  color code. fmt.Sprintf padding is similarly affected.

- JSON Top-Level Type Detection: bytes.Contains matches nested strings
  in JSONL with embedded JSON. Correct approach tracks brace depth and
  skips quoted strings to find actual top-level fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:38:18 -05:00
teernisse
9b1554d72c fix: sort top-spending days by date after cost-based limiting
The top-spending-days card first sorts all days by cost descending to
find the N highest-cost days, then was iterating over the full sorted
slice instead of the limited subset. Additionally, the limited days
appeared in cost order rather than chronological order, making the
timeline hard to scan.

Fix: after slicing to the top N, re-sort that subset by date
(most recent first) before rendering, and iterate over the correct
limited slice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:37:05 -05:00
teernisse
4c649610c9 feat: add live activity charts to TUI overview tab
Add a new "Row 2.5" to the overview tab between the trend chart and
model/activity panels, showing two side-by-side live activity charts:

- Today: 24-bar hourly token histogram with 12h-format labels
  (12a, 1a, ... 11p). Header shows total tokens consumed today.

- Last Hour: 12-bar five-minute token histogram with relative-time
  labels (-55, -50, ... -5, now). Header shows tokens in the last
  60 minutes.

Both charts use BarChart with theme-colored bars (Blue for today,
Accent for last hour) and adapt height in compact layouts.

Helper functions hourLabels24() and minuteLabels() generate the
X-axis label arrays.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:36:59 -05:00
teernisse
93e343f657 feat: add TUI auto-refresh with configurable interval and manual refresh
Introduce background data refresh so the dashboard stays current without
restarting. This touches four layers:

Config (config.go):
- New TUIConfig struct with AutoRefresh (bool) and RefreshIntervalSec
  (int) fields, defaulting to enabled at 30-second intervals.
- Minimum interval floor of 10 seconds enforced at load time.

App core (app.go):
- RefreshDataMsg type for background refresh completion signaling.
- Auto-refresh state: interval timer, refreshing flag, lastRefresh
  timestamp. Checked on every tick; fires refreshDataCmd when elapsed.
- refreshDataCmd: background goroutine that loads session data via cache
  (with uncached fallback) and posts RefreshDataMsg on completion.
- Manual refresh keybind: 'r' triggers immediate refresh.
- Auto-refresh toggle keybind: 'R' toggles auto-refresh and persists
  the preference to config.toml.
- Help text updated with r/R keybind documentation.

Status bar (statusbar.go):
- Shows spinning refresh indicator during active refresh.
- Shows auto-refresh icon when auto-refresh is enabled.

Settings tab (tab_settings.go):
- Two new editable fields: Auto Refresh (bool) and Refresh Interval
  (seconds with 10s minimum).
- Settings display reads live App state to stay consistent with the
  R toggle keybind (avoids stale config-file reads).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:36:50 -05:00
teernisse
5b9edc7702 feat: add live activity aggregation with today-hourly and last-hour bucketing
Add data layer support for real-time usage visualization:

- MinuteStats type: holds token counts for 5-minute buckets, enabling
  granular recent-activity views (12 buckets covering the last hour).

- AggregateTodayHourly(): computes 24 hourly token buckets for the
  current local day by filtering sessions to today's date boundary and
  slotting each into the correct hour index. Tracks prompts, sessions,
  and total tokens per hour.

- AggregateLastHour(): computes 12 five-minute token buckets for the
  last 60 minutes using reverse-offset bucketing (bucket 11 = most
  recent 5 minutes, bucket 0 = 55-60 minutes ago). Bounds-clamped to
  prevent off-by-one at the edges.

Both functions filter on StartTime locality and skip zero-time sessions,
consistent with existing aggregation patterns in the pipeline package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:36:38 -05:00
teernisse
35fae37ba4 feat: overhaul TUI dashboard with subscription data, new tabs, and setup wizard
Major rewrite of the Bubble Tea dashboard, adding live claude.ai
integration and splitting the monolithic app.go into focused tab modules.

App model (app.go):
- Integrate claudeai.Client for live subscription/rate-limit data
- Add SubDataMsg and async fetch with periodic refresh (every 5 min)
- Add spinner for loading states (charmbracelet/bubbles spinner)
- Integrate huh form library for in-TUI setup wizard
- Rework tab routing to dispatch to dedicated tab renderers
- Add compact layout detection for narrow terminals (<100 cols)

TUI setup wizard (setup.go):
- Full huh-based setup flow embedded in the TUI (not just CLI)
- Three-step form: credentials, preferences (time range + theme), confirm
- Pre-populates from existing config, validates session key prefix
- Returns to dashboard on completion with config auto-saved

New tab modules:
- tab_overview.go: summary cards (sessions, prompts, cost, time), daily
  activity sparkline, rate-limit progress bars from live subscription data
- tab_breakdown.go: per-model usage table with calls, input/output tokens,
  cost, and share percentage; compact mode for narrow terminals
- tab_costs.go: cost analysis with daily cost chart, model cost breakdown,
  cache efficiency metrics, and budget tracking with progress bar

Rewritten tabs:
- tab_sessions.go: paginated session browser with sort-by-cost/tokens/time,
  per-session detail view, model usage breakdown per session, improved
  navigation (j/k, enter/esc, n/p for pages)
- tab_settings.go: updated to work with new theme struct and config fields
2026-02-20 16:08:26 -05:00
teernisse
2be7b5e193 refactor: simplify TUI theme struct and extract chart/progress components
Theme simplification (theme/theme.go):
- Remove Purple and BorderHover fields from Theme struct — neither was
  referenced in the new tab renderers, reducing per-theme boilerplate
  from 14 to 12 color definitions

Component extraction (components/):
- Move Sparkline() and BarChart() from card.go to new chart.go, giving
  visualization components their own file as complexity grows
- card.go retains MetricCard, ContentCard, LayoutRow, and CardInnerWidth
  which are layout-focused
- New chart.go: Sparkline (unicode block chars) and BarChart (multi-row
  with anchored Y-axis, optional X-axis labels, dynamic height/width)
- New progress.go: ProgressBar component with customizable width, color,
  and percentage display — used by rate-limit and budget views

Status bar and tab bar updates:
- statusbar.go: adapt to simplified theme struct
- tabbar.go: adapt to simplified theme struct
2026-02-20 16:08:06 -05:00
teernisse
e241ee3966 feat: add CLI status command, rewrite setup wizard, and modernize table renderer
Three interconnected CLI improvements:

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

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

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

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

Dependencies (go.mod):
- Add charmbracelet/huh v0.8.0 for form-based TUI wizards
- Upgrade golang.org/x/text v0.3.8 -> v0.23.0
- Add transitive deps: catppuccin/go, harmonica, hashstructure, etc.
2026-02-20 16:07:55 -05:00
teernisse
547d402578 feat: add claude.ai API client and session key configuration
Introduce a client for fetching subscription data from the claude.ai web
API, enabling rate-limit monitoring and overage tracking in the dashboard.

New package internal/claudeai:
- Client authenticates via session cookie (sk-ant-sid... prefix validated)
- FetchAll() retrieves orgs, usage windows, and overage in one call,
  returning partial data when individual requests fail
- FetchOrganizations/FetchUsage/FetchOverageLimit for granular access
- Defensive utilization parsing handles polymorphic API responses: int
  (75), float (0.75 or 75.0), and string ("75%" or "0.75"), normalizing
  all to 0.0-1.0 range
- 10s request timeout, 1MB body limit, proper status code handling
  (401/403 -> ErrUnauthorized, 429 -> ErrRateLimited)

Types (claudeai/types.go):
- Organization, UsageResponse, UsageWindow (raw), OverageLimit
- SubscriptionData (TUI-ready aggregate), ParsedUsage, ParsedWindow

Config changes (config/config.go):
- Add ClaudeAIConfig struct with session_key and org_id fields
- Add GetSessionKey() with CLAUDE_SESSION_KEY env var fallback
- Fix directory permissions 0o755 -> 0o750 (gosec G301)
- Fix Save() to propagate encoder errors before closing file
2026-02-20 16:07:40 -05:00
26 changed files with 3129 additions and 1424 deletions

View File

@@ -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
View 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

View File

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

View File

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

198
cmd/status.go Normal file
View File

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

7
go.mod
View File

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

30
go.sum
View File

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

234
internal/claudeai/client.go Normal file
View 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
}

View 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
}

View File

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

View File

@@ -1,3 +1,4 @@
// Package config handles cburn configuration loading, saving, and pricing.
package config package config
import ( import (
@@ -10,11 +11,13 @@ import (
// Config holds all cburn configuration. // Config holds all cburn configuration.
type Config struct { type Config struct {
General GeneralConfig `toml:"general"` General GeneralConfig `toml:"general"`
AdminAPI AdminAPIConfig `toml:"admin_api"` AdminAPI AdminAPIConfig `toml:"admin_api"`
Budget BudgetConfig `toml:"budget"` ClaudeAI ClaudeAIConfig `toml:"claude_ai"`
Budget BudgetConfig `toml:"budget"`
Appearance AppearanceConfig `toml:"appearance"` Appearance AppearanceConfig `toml:"appearance"`
Pricing PricingOverrides `toml:"pricing"` TUI TUIConfig `toml:"tui"`
Pricing PricingOverrides `toml:"pricing"`
} }
// GeneralConfig holds general preferences. // GeneralConfig holds general preferences.
@@ -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
} }

View File

@@ -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
}

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}

View 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)
}
}

View 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)
}

View File

@@ -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)),
)
}

View File

@@ -10,23 +10,18 @@ import (
// Tab represents a single tab in the tab bar. // Tab represents a single tab in the tab bar.
type Tab struct { type Tab struct {
Name string Name string
Key rune Key rune
KeyPos int // position of the shortcut letter in the name (-1 if not in name) KeyPos int // position of the shortcut letter in the name (-1 if not in name)
} }
// 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
} }

View File

@@ -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 "****"
} }

View 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
View 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"
}

View 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"}
}

View File

@@ -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"
) )
@@ -22,9 +23,53 @@ const (
// sessionsState holds the sessions tab state. // sessionsState holds the sessions tab state.
type sessionsState struct { type sessionsState struct {
cursor int cursor int
viewMode int viewMode int
offset int // scroll offset for the list offset int // scroll offset for the list
detailScroll int // scroll offset for the detail pane
// 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
body.WriteString("\n") if compactModelTable {
body.WriteString(mutedStyle.Render(strings.Repeat("─", 52))) modelW := innerW - 7 - 1 - 8
if modelW < 8 {
modelW = 8
}
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %8s", modelW, "Model", "Calls", "Cost")))
body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+8+2)))
} else {
modelW := 14
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", modelW, "Model", "Calls", "Input", "Output", "Cost")))
body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+10+10+8+4)))
}
body.WriteString("\n") body.WriteString("\n")
// Sort model names for deterministic display order // Sort model names for deterministic display order
@@ -255,12 +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
cli.FormatNumber(int64(mu.APICalls)), if modelW < 8 {
cli.FormatTokens(mu.InputTokens), modelW = 8
cli.FormatTokens(mu.OutputTokens), }
cli.FormatCost(mu.EstimatedCost)))) body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %8s",
modelW,
truncStr(shortModel(modelName), modelW),
cli.FormatNumber(int64(mu.APICalls)),
cli.FormatCost(mu.EstimatedCost))))
} else {
modelW := 14
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s",
modelW,
truncStr(shortModel(modelName), modelW),
cli.FormatNumber(int64(mu.APICalls)),
cli.FormatTokens(mu.InputTokens),
cli.FormatTokens(mu.OutputTokens),
cli.FormatCost(mu.EstimatedCost))))
}
body.WriteString("\n") body.WriteString("\n")
} }
} }
@@ -271,8 +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(headerStyle.Render(fmt.Sprintf("SUBAGENTS (%d)", len(subs))))
body.WriteString("\n")
nameW := innerW - 8 - 10 - 2
if nameW < 10 {
nameW = 10
}
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s", nameW, "Agent", "Duration", "Cost")))
body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2)))
body.WriteString("\n")
var totalSubCost float64
var totalSubDur int64
for _, sub := range subs {
// Extract short agent name from session ID (e.g., "uuid/agent-acompact-7b10e8" -> "acompact-7b10e8")
agentName := sub.SessionID
if idx := strings.LastIndex(agentName, "/"); idx >= 0 {
agentName = agentName[idx+1:]
}
agentName = strings.TrimPrefix(agentName, "agent-")
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s",
nameW,
truncStr(agentName, nameW),
cli.FormatDuration(sub.DurationSecs),
cli.FormatCost(sub.EstimatedCost))))
body.WriteString("\n")
totalSubCost += sub.EstimatedCost
totalSubDur += sub.DurationSecs
}
body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2)))
body.WriteString("\n")
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s",
nameW,
"Combined",
cli.FormatDuration(totalSubDur),
cli.FormatCost(totalSubCost))))
body.WriteString("\n")
}
body.WriteString("\n") body.WriteString("\n")
body.WriteString(mutedStyle.Render("[Enter] expand [j/k] navigate [q] quit")) if w < compactWidth {
body.WriteString(mutedStyle.Render("[j/k] navigate [J/K] scroll [q] quit"))
} else {
body.WriteString(mutedStyle.Render("[Enter] expand [j/k] navigate [J/K/^d/^u] scroll [q] quit"))
}
return body.String() return body.String()
} }
@@ -283,3 +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
}

View File

@@ -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))

View File

@@ -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"),
} }