Compare commits
25 Commits
892f578565
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
302f34ff85 | ||
|
|
0416d029b1 | ||
|
|
901090f921 | ||
|
|
c15dc8b487 | ||
|
|
19b8bab5d8 | ||
|
|
e1e322f4c9 | ||
|
|
0e80a6c1d1 | ||
|
|
8c1beb7a8a | ||
|
|
7157886546 | ||
|
|
3668ae7f70 | ||
|
|
083e7d40ce | ||
|
|
16cc4d4737 | ||
|
|
9bb0fd6b73 | ||
|
|
baa88efe75 | ||
|
|
a386d95959 | ||
|
|
74c9905dbf | ||
|
|
96d464a2c0 | ||
|
|
9b1554d72c | ||
|
|
4c649610c9 | ||
|
|
93e343f657 | ||
|
|
5b9edc7702 | ||
|
|
35fae37ba4 | ||
|
|
2be7b5e193 | ||
|
|
e241ee3966 | ||
|
|
547d402578 |
10
BACKBURNER.md
Normal file
10
BACKBURNER.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Backburner Ideas
|
||||||
|
|
||||||
|
Last updated: February 23, 2026
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Separate pricing semantics for subscription vs API usage:
|
||||||
|
- `cburn` should model Claude subscription economics independently from Admin/API token pricing.
|
||||||
|
- Admin Cost API integration is still valuable for API-key workflows, but should not be treated as the canonical source for subscription usage.
|
||||||
|
- Revisit after daemon/event infrastructure work stabilizes.
|
||||||
236
CEO_PITCH_DECKS.md
Normal file
236
CEO_PITCH_DECKS.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# CEO Meeting Script: Four Daemon-Enabled Product Bets
|
||||||
|
|
||||||
|
## Opening
|
||||||
|
Today I want to walk through four product bets that become possible because we now have a continuously running daemon that can observe usage as it happens, not just after the fact.
|
||||||
|
|
||||||
|
I am not presenting speculative AI magic. I am presenting four concrete products, each with a hard-nosed view of utility, feasibility, risk, and build path.
|
||||||
|
|
||||||
|
The four bets are:
|
||||||
|
1. Cognitive Flight Recorder
|
||||||
|
2. Runaway Loop Quencher
|
||||||
|
3. Session Archeology Engine
|
||||||
|
4. Latent Tool ROI Scanner
|
||||||
|
|
||||||
|
My recommendation is not “build all at once.” My recommendation is staged execution with clear kill criteria.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Cognitive Flight Recorder
|
||||||
|
|
||||||
|
### The pitch
|
||||||
|
If our AI spend spikes tomorrow, leadership will ask two questions immediately: what happened, and why did it happen. Right now, we can answer “how much,” but we cannot reliably answer “why.”
|
||||||
|
|
||||||
|
The Cognitive Flight Recorder solves that. It turns a costly session into a replayable incident timeline. Not a dashboard snapshot. A sequence: where cost accelerated, where model behavior changed, where cache efficiency collapsed, and where the session crossed from productive to expensive.
|
||||||
|
|
||||||
|
This product creates operational trust. When AI systems are expensive, trust depends on explainability under stress.
|
||||||
|
|
||||||
|
### Why we need it
|
||||||
|
Postmortems are currently slow and anecdotal. Engineers reconstruct stories manually. Finance gets numbers without causality. Leadership gets noise.
|
||||||
|
|
||||||
|
A Flight Recorder makes AI spend investigable the same way we investigate reliability incidents. That is a major enterprise unlock.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
The daemon continuously captures session telemetry and emits timeline events. The recorder layers on top:
|
||||||
|
- It builds per-session event streams with timestamps, token deltas, model transitions, and cache transitions.
|
||||||
|
- It detects inflection points where cost trajectory changes materially.
|
||||||
|
- It generates a concise incident report: “what changed,” “likely causes,” and “which preventive policy would have helped.”
|
||||||
|
|
||||||
|
The output is not just visual. It is operational: replay + evidence + recommended guardrail.
|
||||||
|
|
||||||
|
### Downstream effects
|
||||||
|
If we execute this well, we get:
|
||||||
|
- Faster incident resolution for spend spikes.
|
||||||
|
- Better policy tuning because we can pinpoint the moment of failure.
|
||||||
|
- Stronger executive confidence in scaling agent usage.
|
||||||
|
- A compelling enterprise story: “we can explain every anomaly.”
|
||||||
|
|
||||||
|
### Skeptical view
|
||||||
|
Is it actually useful? Only if it leads to action. A pretty timeline that no one uses is dead weight.
|
||||||
|
|
||||||
|
Is it feasible with current harnesses? Partially. We can do strong metadata-level replay now. Deep semantic replay depends on richer telemetry and raises privacy concerns.
|
||||||
|
|
||||||
|
The critical risk is false causality: users may confuse sequence with cause. We mitigate this by attaching confidence levels and explicit evidence for every claim.
|
||||||
|
|
||||||
|
### Implementation roadmap
|
||||||
|
Phase 1 (2-3 weeks): metadata replay and “Top Cost Incidents” report.
|
||||||
|
Phase 2 (3-5 weeks): inflection detection and root-cause ranking with confidence scoring.
|
||||||
|
Phase 3 (4+ weeks): optional deep replay, privacy controls, and incident workflow integrations.
|
||||||
|
|
||||||
|
### Decision rule
|
||||||
|
Proceed if incident reports result in measurable policy changes. Kill or narrow if they remain passive observability artifacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Runaway Loop Quencher
|
||||||
|
|
||||||
|
### The pitch
|
||||||
|
Most bad AI spend is not one bad call. It is a loop: repeated expensive behavior with little progress. If we only detect this after the session, we are too late.
|
||||||
|
|
||||||
|
The Runaway Loop Quencher is an active safety layer. It watches live telemetry, identifies likely runaway patterns, and intervenes before the burn compounds.
|
||||||
|
|
||||||
|
This is the direct path to cost containment at runtime.
|
||||||
|
|
||||||
|
### Why we need it
|
||||||
|
Without active containment, scaling agent autonomy is financially unsafe. Teams become conservative. Leaders reduce usage. Innovation slows.
|
||||||
|
|
||||||
|
If we can intervene mid-flight, we convert catastrophic sessions into manageable sessions.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
The daemon computes rolling risk signals:
|
||||||
|
- accelerating cost per minute
|
||||||
|
- repetitive call signatures
|
||||||
|
- degrading cache performance
|
||||||
|
- high token growth with weak progress proxies
|
||||||
|
|
||||||
|
A policy engine converts those signals into action tiers:
|
||||||
|
- Soft: alert and suggest a reset strategy
|
||||||
|
- Guarded: require confirmation before continuing expensive patterns
|
||||||
|
- Hard: stop execution for supported harnesses
|
||||||
|
|
||||||
|
We start advisory-first, then move toward control where integrations allow.
|
||||||
|
|
||||||
|
### Downstream effects
|
||||||
|
If successful:
|
||||||
|
- fewer runaway incidents
|
||||||
|
- lower variance in daily spend
|
||||||
|
- greater confidence in letting agents run longer on valuable tasks
|
||||||
|
- ability to define budget safety SLOs
|
||||||
|
|
||||||
|
### Skeptical view
|
||||||
|
Is it actually useful? Yes, but only if precision is good. High false positives will cause immediate distrust and disablement.
|
||||||
|
|
||||||
|
Is it feasible given harness reality? Detection and alerting are feasible now. Hard-stop control is integration-dependent and not universally available.
|
||||||
|
|
||||||
|
The hard technical challenge is “progress.” We can estimate risk, but progress is not always machine-observable. That means we should not over-automate too early.
|
||||||
|
|
||||||
|
### Implementation roadmap
|
||||||
|
Phase 1 (2-4 weeks): risk scoring, alerting, and daemon risk endpoint.
|
||||||
|
Phase 2 (4-6 weeks): human confirmation gates and cooldown policies.
|
||||||
|
Phase 3 (6+ weeks): optional hard-stop integrations and policy simulation.
|
||||||
|
|
||||||
|
### Decision rule
|
||||||
|
Ship only if we can keep false positives low enough that teams keep it enabled. If intervention is frequently wrong, this product should remain advisory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Session Archeology Engine
|
||||||
|
|
||||||
|
### The pitch
|
||||||
|
Right now we can tell teams they spent too much. We cannot tell them which recurring behavior patterns caused it.
|
||||||
|
|
||||||
|
The Session Archeology Engine classifies sessions into behavioral archetypes and ties each archetype to practical intervention playbooks.
|
||||||
|
|
||||||
|
This turns raw telemetry into behavior change.
|
||||||
|
|
||||||
|
### Why we need it
|
||||||
|
People do not improve from aggregate numbers. They improve from named patterns and concrete alternatives.
|
||||||
|
|
||||||
|
If we can say, “These two session archetypes account for most avoidable spend, and here is exactly how to run them differently,” we create durable cost literacy.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
We extract session-level feature vectors:
|
||||||
|
- session shape and duration profile
|
||||||
|
- token composition and burstiness
|
||||||
|
- cache behavior
|
||||||
|
- model mix and switch behavior
|
||||||
|
- retry and repetition patterns
|
||||||
|
|
||||||
|
We cluster sessions and assign human-readable archetypes, then connect each archetype to:
|
||||||
|
- likely waste mechanism
|
||||||
|
- recommended policy/routing pattern
|
||||||
|
- suggested prompt and workflow changes
|
||||||
|
|
||||||
|
The output is both analytical and prescriptive.
|
||||||
|
|
||||||
|
### Downstream effects
|
||||||
|
If this works:
|
||||||
|
- managers coach with evidence instead of intuition
|
||||||
|
- teams adopt archetype-specific best practices
|
||||||
|
- routing policies improve faster because they target behaviors, not averages
|
||||||
|
- executives get clean narrative reporting on spend dynamics
|
||||||
|
|
||||||
|
### Skeptical view
|
||||||
|
Is it actually useful? It is useful only if archetypes stay stable and map to actions. Otherwise it becomes taxonomy theater.
|
||||||
|
|
||||||
|
Is it feasible? Yes, baseline version is feasible with existing metadata. Advanced value improves with richer tool and outcome signals.
|
||||||
|
|
||||||
|
Main risk: labels can drift as models and workflows change. We mitigate with periodic retraining, versioned labels, and strict “action attached” requirements.
|
||||||
|
|
||||||
|
### Implementation roadmap
|
||||||
|
Phase 1 (2-3 weeks): clustering baseline and weekly archetype report.
|
||||||
|
Phase 2 (3-5 weeks): intervention playbooks and policy recommendations per archetype.
|
||||||
|
Phase 3 (4+ weeks): team benchmarking and archetype drift alerts.
|
||||||
|
|
||||||
|
### Decision rule
|
||||||
|
Keep investing only if archetypes produce measurable behavior and cost improvements, not just better reporting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13) Latent Tool ROI Scanner
|
||||||
|
|
||||||
|
### The pitch
|
||||||
|
Model choice is not the only cost lever. Tool behavior often dominates spend efficiency, and today that layer is mostly invisible.
|
||||||
|
|
||||||
|
The Latent Tool ROI Scanner identifies which tools and workflows consume disproportionate cost relative to useful outcome, and recommends what to constrain, replace, or redesign.
|
||||||
|
|
||||||
|
This is potentially the highest upside concept, but also the highest epistemic risk.
|
||||||
|
|
||||||
|
### Why we need it
|
||||||
|
Optimization efforts usually target visible levers. Hidden tool-level waste can remain untouched for months.
|
||||||
|
|
||||||
|
If we can reveal negative-ROI tool patterns, we unlock savings without reducing strategic AI adoption.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
The scanner combines daemon telemetry with richer tool-event instrumentation:
|
||||||
|
- per-tool invocation frequency and cost footprint
|
||||||
|
- failure and retry signatures
|
||||||
|
- outcome proxies from delivery systems (tests, merges, ticket transitions)
|
||||||
|
|
||||||
|
It then computes conservative ROI scores and counterfactual scenarios:
|
||||||
|
- “If we reduce this pattern by 30%, estimated impact is X with confidence band Y.”
|
||||||
|
|
||||||
|
Recommendations are always evidence-backed and confidence-scored.
|
||||||
|
|
||||||
|
### Downstream effects
|
||||||
|
If accurate:
|
||||||
|
- identifies hidden spend sinks
|
||||||
|
- informs platform/tooling investments
|
||||||
|
- enables high-leverage policy changes with limited developer friction
|
||||||
|
- strengthens unit economics of agent operations
|
||||||
|
|
||||||
|
### Skeptical view
|
||||||
|
Is it actually useful today? Not fully. Without stronger outcome labeling, ROI claims can become fragile or misleading.
|
||||||
|
|
||||||
|
Is it feasible with current harnesses? Partially. We can pilot scoring frameworks, but high-confidence production decisions require instrumentation we do not yet have.
|
||||||
|
|
||||||
|
This is exactly where we should avoid overclaiming.
|
||||||
|
|
||||||
|
### Implementation roadmap
|
||||||
|
Phase 0 (1-2 weeks): instrumentation gap audit and schema design.
|
||||||
|
Phase 1 (3-4 weeks): tool-event ingestion and normalization pipeline.
|
||||||
|
Phase 2 (4-6 weeks): conservative ROI scoring + confidence intervals.
|
||||||
|
Phase 3 (4+ weeks): recommendation engine and controlled experiments.
|
||||||
|
|
||||||
|
### Decision rule
|
||||||
|
Treat as pilot until precision is validated against human review and external outcomes. If precision is weak, keep this as exploratory analytics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Portfolio recommendation and sequencing
|
||||||
|
|
||||||
|
If we prioritize for impact times feasibility:
|
||||||
|
1. Cognitive Flight Recorder
|
||||||
|
2. Session Archeology Engine
|
||||||
|
3. Runaway Loop Quencher (advisory first, control later)
|
||||||
|
4. Latent Tool ROI Scanner (pilot behind instrumentation gate)
|
||||||
|
|
||||||
|
This sequencing gives us near-term value while building the telemetry foundation needed for the harder products.
|
||||||
|
|
||||||
|
The overarching principle: every insight must be tied to an action, every action must be measurable, and every high-stakes claim must carry confidence.
|
||||||
|
|
||||||
|
## Closing
|
||||||
|
The daemon turns our system from retrospective analytics into a live control surface. These four products are how we monetize and operationalize that shift.
|
||||||
|
|
||||||
|
The question is not whether these ideas are interesting. The question is whether we can ship them with enough truthfulness that teams trust them.
|
||||||
|
|
||||||
|
With staged delivery and strict kill criteria, we can.
|
||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -66,7 +66,7 @@ Tests live alongside the code they test (`*_test.go`). The parser has both unit
|
|||||||
|
|
||||||
- **Parsing strategy**: User/system entries use byte-level extraction for speed; only assistant entries get full JSON parse (they carry token/cost data).
|
- **Parsing strategy**: User/system entries use byte-level extraction for speed; only assistant entries get full JSON parse (they carry token/cost data).
|
||||||
- **Deduplication**: Messages are keyed by message ID; the final state wins (handles edits/retries).
|
- **Deduplication**: Messages are keyed by message ID; the final state wins (handles edits/retries).
|
||||||
- **Cache**: SQLite at `~/.cache/cburn/sessions.db`. Mtime+size diffing means unchanged files aren't reparsed.
|
- **Cache**: SQLite at `~/.cache/cburn/metrics_v2.db`. Mtime+size diffing means unchanged files aren't reparsed.
|
||||||
- **TUI async loading**: Data loads via goroutines posting `tea.Msg`; the UI remains responsive during parse.
|
- **TUI async loading**: Data loads via goroutines posting `tea.Msg`; the UI remains responsive during parse.
|
||||||
- **Pricing**: Hardcoded in `internal/config/pricing.go` with user overrides in config TOML. Model names are normalized (date suffixes stripped).
|
- **Pricing**: Hardcoded in `internal/config/pricing.go` with user overrides in config TOML. Model names are normalized (date suffixes stripped).
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -1,4 +1,4 @@
|
|||||||
GO := /usr/local/go/bin/go
|
GO ?= $(shell command -v go)
|
||||||
BIN := cburn
|
BIN := cburn
|
||||||
|
|
||||||
.PHONY: build install lint test test-race bench fuzz clean
|
.PHONY: build install lint test test-race bench fuzz clean
|
||||||
|
|||||||
229
README.md
Normal file
229
README.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# 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
|
||||||
|
go install github.com/theirongolddev/cburn@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/theirongolddev/cburn.git
|
||||||
|
cd cburn
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Go 1.24+.
|
||||||
|
|
||||||
|
**Note:** Ensure `~/go/bin` is in your PATH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# bash/zsh: add to ~/.bashrc or ~/.zshrc
|
||||||
|
export PATH="$HOME/go/bin:$PATH"
|
||||||
|
|
||||||
|
# fish: add to ~/.config/fish/config.fish
|
||||||
|
fish_add_path ~/go/bin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
cburn daemon --detach # Background usage daemon + local API
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 daemon` | Background daemon with JSON/SSE usage API |
|
||||||
|
| `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
|
||||||
|
cburn daemon --detach # Start daemon in background
|
||||||
|
cburn daemon status # Check daemon health and latest totals
|
||||||
|
cburn daemon stop # Stop daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
## Daemon Mode
|
||||||
|
|
||||||
|
`cburn daemon` runs a long-lived polling service that keeps usage snapshots warm and exposes local endpoints for downstream tools.
|
||||||
|
|
||||||
|
Default endpoint: `http://127.0.0.1:8787`
|
||||||
|
|
||||||
|
- `GET /healthz` - liveness probe
|
||||||
|
- `GET /v1/status` - current aggregate snapshot and daemon runtime status
|
||||||
|
- `GET /v1/events` - recent event buffer (JSON array)
|
||||||
|
- `GET /v1/stream` - Server-Sent Events stream (`snapshot`, `usage_delta`)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cburn daemon --detach --interval 10s
|
||||||
|
curl -s http://127.0.0.1:8787/v1/status | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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/metrics_v2.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/daemon` | Background polling daemon + local HTTP/SSE API |
|
||||||
|
| `internal/cli` | Terminal formatting |
|
||||||
|
| `internal/claudeai` | Claude.ai API client |
|
||||||
|
| `internal/tui` | Bubble Tea dashboard |
|
||||||
|
| `internal/tui/components` | Reusable TUI components |
|
||||||
|
| `internal/tui/theme` | Color schemes |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
// Package cmd implements the cburn CLI commands.
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -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 != "" {
|
||||||
|
|||||||
62
cmd/costs.go
62
cmd/costs.go
@@ -3,9 +3,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
"cburn/internal/pipeline"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -32,7 +31,7 @@ func runCosts(_ *cobra.Command, _ []string) error {
|
|||||||
|
|
||||||
filtered, since, until := applyFilters(result.Sessions)
|
filtered, since, until := applyFilters(result.Sessions)
|
||||||
stats := pipeline.Aggregate(filtered, since, until)
|
stats := pipeline.Aggregate(filtered, since, until)
|
||||||
models := pipeline.AggregateModels(filtered, since, until)
|
tokenCosts, modelCosts := pipeline.AggregateCostBreakdown(filtered, since, until)
|
||||||
|
|
||||||
if stats.TotalSessions == 0 {
|
if stats.TotalSessions == 0 {
|
||||||
fmt.Println("\n No sessions in the selected time range.")
|
fmt.Println("\n No sessions in the selected time range.")
|
||||||
@@ -54,30 +53,14 @@ func runCosts(_ *cobra.Command, _ []string) error {
|
|||||||
cost float64
|
cost float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate costs per token type from raw token counts using canonical pricing
|
totalCost := tokenCosts.TotalCost
|
||||||
var inputCost, outputCost, cache5mCost, cache1hCost, cacheReadCost float64
|
|
||||||
for _, s := range pipeline.FilterByTime(filtered, since, until) {
|
|
||||||
for modelName, mu := range s.Models {
|
|
||||||
p, ok := config.LookupPricing(modelName)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1_000_000
|
|
||||||
outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1_000_000
|
|
||||||
cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1_000_000
|
|
||||||
cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1_000_000
|
|
||||||
cacheReadCost += float64(mu.CacheReadTokens) * p.CacheReadPerMTok / 1_000_000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalCost := inputCost + outputCost + cache5mCost + cache1hCost + cacheReadCost
|
|
||||||
|
|
||||||
costs := []tokenCost{
|
costs := []tokenCost{
|
||||||
{"Output", outputCost},
|
{"Output", tokenCosts.OutputCost},
|
||||||
{"Cache Write (1h)", cache1hCost},
|
{"Cache Write (1h)", tokenCosts.Cache1hCost},
|
||||||
{"Input", inputCost},
|
{"Input", tokenCosts.InputCost},
|
||||||
{"Cache Write (5m)", cache5mCost},
|
{"Cache Write (5m)", tokenCosts.Cache5mCost},
|
||||||
{"Cache Read", cacheReadCost},
|
{"Cache Read", tokenCosts.CacheReadCost},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by cost descending (already in expected order, but ensure)
|
// Sort by cost descending (already in expected order, but ensure)
|
||||||
@@ -116,29 +99,22 @@ func runCosts(_ *cobra.Command, _ []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cost by model
|
// Cost by model
|
||||||
modelRows := make([][]string, 0, len(models)+2)
|
modelRows := make([][]string, 0, len(modelCosts)+2)
|
||||||
for _, ms := range models {
|
for _, mc := range modelCosts {
|
||||||
p, _ := config.LookupPricing(ms.Model)
|
|
||||||
mInput := float64(ms.InputTokens) * p.InputPerMTok / 1_000_000
|
|
||||||
mOutput := float64(ms.OutputTokens) * p.OutputPerMTok / 1_000_000
|
|
||||||
mCache := float64(ms.CacheCreation5m)*p.CacheWrite5mPerMTok/1_000_000 +
|
|
||||||
float64(ms.CacheCreation1h)*p.CacheWrite1hPerMTok/1_000_000 +
|
|
||||||
float64(ms.CacheReadTokens)*p.CacheReadPerMTok/1_000_000
|
|
||||||
|
|
||||||
modelRows = append(modelRows, []string{
|
modelRows = append(modelRows, []string{
|
||||||
shortModel(ms.Model),
|
shortModel(mc.Model),
|
||||||
cli.FormatCost(mInput),
|
cli.FormatCost(mc.InputCost),
|
||||||
cli.FormatCost(mOutput),
|
cli.FormatCost(mc.OutputCost),
|
||||||
cli.FormatCost(mCache),
|
cli.FormatCost(mc.CacheCost),
|
||||||
cli.FormatCost(ms.EstimatedCost),
|
cli.FormatCost(mc.TotalCost),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
modelRows = append(modelRows, []string{"---"})
|
modelRows = append(modelRows, []string{"---"})
|
||||||
modelRows = append(modelRows, []string{
|
modelRows = append(modelRows, []string{
|
||||||
"TOTAL",
|
"TOTAL",
|
||||||
cli.FormatCost(inputCost),
|
cli.FormatCost(tokenCosts.InputCost),
|
||||||
cli.FormatCost(outputCost),
|
cli.FormatCost(tokenCosts.OutputCost),
|
||||||
cli.FormatCost(cache5mCost + cache1hCost + cacheReadCost),
|
cli.FormatCost(tokenCosts.CacheCost),
|
||||||
cli.FormatCost(totalCost),
|
cli.FormatCost(totalCost),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
344
cmd/daemon.go
Normal file
344
cmd/daemon.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/theirongolddev/cburn/internal/daemon"
|
||||||
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type daemonRuntimeState struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
flagDaemonAddr string
|
||||||
|
flagDaemonInterval time.Duration
|
||||||
|
flagDaemonDetach bool
|
||||||
|
flagDaemonPIDFile string
|
||||||
|
flagDaemonLogFile string
|
||||||
|
flagDaemonEventsBuffer int
|
||||||
|
flagDaemonChild bool
|
||||||
|
)
|
||||||
|
|
||||||
|
var daemonCmd = &cobra.Command{
|
||||||
|
Use: "daemon",
|
||||||
|
Short: "Run a background usage daemon with HTTP/SSE endpoints",
|
||||||
|
RunE: runDaemon,
|
||||||
|
}
|
||||||
|
|
||||||
|
var daemonStatusCmd = &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show daemon process and API status",
|
||||||
|
RunE: runDaemonStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
var daemonStopCmd = &cobra.Command{
|
||||||
|
Use: "stop",
|
||||||
|
Short: "Stop the running daemon",
|
||||||
|
RunE: runDaemonStop,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
defaultPID := filepath.Join(pipeline.CacheDir(), "cburnd.pid")
|
||||||
|
defaultLog := filepath.Join(pipeline.CacheDir(), "cburnd.log")
|
||||||
|
|
||||||
|
daemonCmd.PersistentFlags().StringVar(&flagDaemonAddr, "addr", "127.0.0.1:8787", "HTTP listen address")
|
||||||
|
daemonCmd.PersistentFlags().DurationVar(&flagDaemonInterval, "interval", 15*time.Second, "Polling interval")
|
||||||
|
daemonCmd.PersistentFlags().StringVar(&flagDaemonPIDFile, "pid-file", defaultPID, "PID file path")
|
||||||
|
daemonCmd.PersistentFlags().StringVar(&flagDaemonLogFile, "log-file", defaultLog, "Log file path for detached mode")
|
||||||
|
daemonCmd.PersistentFlags().IntVar(&flagDaemonEventsBuffer, "events-buffer", 200, "Max in-memory events retained")
|
||||||
|
|
||||||
|
daemonCmd.Flags().BoolVar(&flagDaemonDetach, "detach", false, "Run daemon as a background process")
|
||||||
|
daemonCmd.Flags().BoolVar(&flagDaemonChild, "child", false, "Internal: mark detached child process")
|
||||||
|
_ = daemonCmd.Flags().MarkHidden("child")
|
||||||
|
|
||||||
|
daemonCmd.AddCommand(daemonStatusCmd)
|
||||||
|
daemonCmd.AddCommand(daemonStopCmd)
|
||||||
|
rootCmd.AddCommand(daemonCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemon(_ *cobra.Command, _ []string) error {
|
||||||
|
if flagDaemonDetach && flagDaemonChild {
|
||||||
|
return errors.New("invalid daemon launch mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagDaemonDetach {
|
||||||
|
return startDaemonDetached()
|
||||||
|
}
|
||||||
|
|
||||||
|
return runDaemonForeground()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startDaemonDetached() error {
|
||||||
|
if err := ensureDaemonNotRunning(flagDaemonPIDFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := filterDetachArg(os.Args[1:])
|
||||||
|
args = append(args, "--child")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(flagDaemonPIDFile), 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create daemon directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(flagDaemonLogFile), 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create daemon log directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // daemon log path is configured by the local user
|
||||||
|
logf, err := os.OpenFile(flagDaemonLogFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open daemon log file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = logf.Close() }()
|
||||||
|
|
||||||
|
cmd := exec.Command(exe, args...) //nolint:gosec // exe/args come from current process invocation
|
||||||
|
cmd.Stdout = logf
|
||||||
|
cmd.Stderr = logf
|
||||||
|
cmd.Stdin = nil
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start detached daemon: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Started daemon (pid %d)\n", cmd.Process.Pid)
|
||||||
|
fmt.Printf(" PID file: %s\n", flagDaemonPIDFile)
|
||||||
|
fmt.Printf(" API: http://%s/v1/status\n", flagDaemonAddr)
|
||||||
|
fmt.Printf(" Log: %s\n", flagDaemonLogFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemonForeground() error {
|
||||||
|
if err := ensureDaemonNotRunning(flagDaemonPIDFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(flagDaemonPIDFile), 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create daemon directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pid := os.Getpid()
|
||||||
|
if err := writePID(flagDaemonPIDFile, pid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(flagDaemonPIDFile) }()
|
||||||
|
|
||||||
|
state := daemonRuntimeState{
|
||||||
|
PID: pid,
|
||||||
|
Addr: flagDaemonAddr,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
DataDir: flagDataDir,
|
||||||
|
}
|
||||||
|
_ = writeState(statePath(flagDaemonPIDFile), state)
|
||||||
|
defer func() { _ = os.Remove(statePath(flagDaemonPIDFile)) }()
|
||||||
|
|
||||||
|
cfg := daemon.Config{
|
||||||
|
DataDir: flagDataDir,
|
||||||
|
Days: flagDays,
|
||||||
|
ProjectFilter: flagProject,
|
||||||
|
ModelFilter: flagModel,
|
||||||
|
IncludeSubagents: !flagNoSubagents,
|
||||||
|
UseCache: !flagNoCache,
|
||||||
|
Interval: flagDaemonInterval,
|
||||||
|
Addr: flagDaemonAddr,
|
||||||
|
EventsBuffer: flagDaemonEventsBuffer,
|
||||||
|
}
|
||||||
|
svc := daemon.New(cfg)
|
||||||
|
|
||||||
|
fmt.Printf(" cburn daemon listening on http://%s\n", flagDaemonAddr)
|
||||||
|
fmt.Printf(" Polling every %s from %s\n", flagDaemonInterval, flagDataDir)
|
||||||
|
fmt.Printf(" Stop with: cburn daemon stop --pid-file %s\n", flagDaemonPIDFile)
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemonStatus(_ *cobra.Command, _ []string) error {
|
||||||
|
pid, err := readPID(flagDaemonPIDFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Daemon: not running (pid file not found)\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
alive := processAlive(pid)
|
||||||
|
if !alive {
|
||||||
|
fmt.Printf(" Daemon: stale pid file (pid %d not alive)\n", pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := flagDaemonAddr
|
||||||
|
if st, err := readState(statePath(flagDaemonPIDFile)); err == nil && st.Addr != "" {
|
||||||
|
addr = st.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Daemon PID: %d\n", pid)
|
||||||
|
fmt.Printf(" Address: http://%s\n", addr)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
resp, err := client.Get("http://" + addr + "/v1/status") //nolint:noctx // short status probe
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" API status: unreachable (%v)\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
fmt.Printf(" API status: HTTP %d\n", resp.StatusCode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var st daemon.Status
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
|
||||||
|
fmt.Printf(" API status: malformed response (%v)\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.LastPollAt.IsZero() {
|
||||||
|
fmt.Printf(" Last poll: pending\n")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Last poll: %s\n", st.LastPollAt.Local().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
fmt.Printf(" Poll count: %d\n", st.PollCount)
|
||||||
|
fmt.Printf(" Sessions: %d\n", st.Summary.Sessions)
|
||||||
|
fmt.Printf(" Tokens: %d\n", st.Summary.Tokens)
|
||||||
|
fmt.Printf(" Cost: $%.2f\n", st.Summary.EstimatedCostUSD)
|
||||||
|
if st.LastError != "" {
|
||||||
|
fmt.Printf(" Last error: %s\n", st.LastError)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemonStop(_ *cobra.Command, _ []string) error {
|
||||||
|
pid, err := readPID(flagDaemonPIDFile)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("daemon is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find daemon process: %w", err)
|
||||||
|
}
|
||||||
|
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||||
|
return fmt.Errorf("signal daemon process: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(8 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !processAlive(pid) {
|
||||||
|
_ = os.Remove(flagDaemonPIDFile)
|
||||||
|
_ = os.Remove(statePath(flagDaemonPIDFile))
|
||||||
|
fmt.Printf(" Stopped daemon (pid %d)\n", pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("daemon (pid %d) did not exit in time", pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterDetachArg(args []string) []string {
|
||||||
|
out := make([]string, 0, len(args))
|
||||||
|
for _, a := range args {
|
||||||
|
if a == "--detach" || strings.HasPrefix(a, "--detach=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDaemonNotRunning(pidFile string) error {
|
||||||
|
pid, err := readPID(pidFile)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if processAlive(pid) {
|
||||||
|
return fmt.Errorf("daemon already running (pid %d)", pid)
|
||||||
|
}
|
||||||
|
_ = os.Remove(pidFile)
|
||||||
|
_ = os.Remove(statePath(pidFile))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePID(path string, pid int) error {
|
||||||
|
return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPID(path string) (int, error) {
|
||||||
|
//nolint:gosec // daemon pid path is configured by the local user
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
pidStr := strings.TrimSpace(string(data))
|
||||||
|
pid, err := strconv.Atoi(pidStr)
|
||||||
|
if err != nil || pid <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid pid in %s", path)
|
||||||
|
}
|
||||||
|
return pid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processAlive(pid int) bool {
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
err = proc.Signal(syscall.Signal(0))
|
||||||
|
return err == nil || errors.Is(err, syscall.EPERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statePath(pidFile string) string {
|
||||||
|
return pidFile + ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeState(path string, st daemonRuntimeState) error {
|
||||||
|
data, err := json.MarshalIndent(st, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, append(data, '\n'), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readState(path string) (daemonRuntimeState, error) {
|
||||||
|
var st daemonRuntimeState
|
||||||
|
//nolint:gosec // daemon state path is configured by the local user
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &st); err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
"cburn/internal/store"
|
"github.com/theirongolddev/cburn/internal/store"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
165
cmd/setup.go
165
cmd/setup.go
@@ -1,14 +1,15 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/source"
|
"github.com/theirongolddev/cburn/internal/source"
|
||||||
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,89 +24,119 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runSetup(_ *cobra.Command, _ []string) error {
|
func runSetup(_ *cobra.Command, _ []string) error {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
|
|
||||||
// Load existing config or defaults
|
|
||||||
cfg, _ := config.Load()
|
cfg, _ := config.Load()
|
||||||
|
|
||||||
// Count sessions
|
|
||||||
files, _ := source.ScanDir(flagDataDir)
|
files, _ := source.ScanDir(flagDataDir)
|
||||||
projectCount := source.CountProjects(files)
|
projectCount := source.CountProjects(files)
|
||||||
|
|
||||||
fmt.Println()
|
// Pre-populate from existing config
|
||||||
fmt.Println(" Welcome to cburn!")
|
var sessionKey, adminKey string
|
||||||
fmt.Println()
|
days := cfg.General.DefaultDays
|
||||||
|
if days == 0 {
|
||||||
|
days = 30
|
||||||
|
}
|
||||||
|
themeName := cfg.Appearance.Theme
|
||||||
|
if themeName == "" {
|
||||||
|
themeName = "flexoki-dark"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build welcome description
|
||||||
|
welcomeDesc := "Let's configure your dashboard."
|
||||||
if len(files) > 0 {
|
if len(files) > 0 {
|
||||||
fmt.Printf(" Found %s sessions in %s (%d projects)\n\n",
|
welcomeDesc = fmt.Sprintf("Found %d sessions across %d projects in %s.",
|
||||||
formatNumber(int64(len(files))), flagDataDir, projectCount)
|
len(files), projectCount, flagDataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. API key
|
// Build placeholder text showing masked existing values
|
||||||
fmt.Println(" 1. Anthropic Admin API key")
|
sessionPlaceholder := "sk-ant-sid... (Enter to skip)"
|
||||||
fmt.Println(" For real cost data from the billing API.")
|
if key := config.GetSessionKey(cfg); key != "" {
|
||||||
existing := config.GetAdminAPIKey(cfg)
|
sessionPlaceholder = maskAPIKey(key) + " (Enter to keep)"
|
||||||
if existing != "" {
|
|
||||||
fmt.Printf(" Current: %s\n", maskAPIKey(existing))
|
|
||||||
}
|
}
|
||||||
fmt.Print(" > ")
|
adminPlaceholder := "sk-ant-admin-... (Enter to skip)"
|
||||||
apiKey, _ := reader.ReadString('\n')
|
if key := config.GetAdminAPIKey(cfg); key != "" {
|
||||||
apiKey = strings.TrimSpace(apiKey)
|
adminPlaceholder = maskAPIKey(key) + " (Enter to keep)"
|
||||||
if apiKey != "" {
|
|
||||||
cfg.AdminAPI.APIKey = apiKey
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// 2. Default time range
|
|
||||||
fmt.Println(" 2. Default time range")
|
|
||||||
fmt.Println(" (1) 7 days")
|
|
||||||
fmt.Println(" (2) 30 days [default]")
|
|
||||||
fmt.Println(" (3) 90 days")
|
|
||||||
fmt.Print(" > ")
|
|
||||||
choice, _ := reader.ReadString('\n')
|
|
||||||
choice = strings.TrimSpace(choice)
|
|
||||||
switch choice {
|
|
||||||
case "1":
|
|
||||||
cfg.General.DefaultDays = 7
|
|
||||||
case "3":
|
|
||||||
cfg.General.DefaultDays = 90
|
|
||||||
default:
|
|
||||||
cfg.General.DefaultDays = 30
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// 3. Theme
|
|
||||||
fmt.Println(" 3. Color theme")
|
|
||||||
fmt.Println(" (1) Flexoki Dark [default]")
|
|
||||||
fmt.Println(" (2) Catppuccin Mocha")
|
|
||||||
fmt.Println(" (3) Tokyo Night")
|
|
||||||
fmt.Println(" (4) Terminal (ANSI 16)")
|
|
||||||
fmt.Print(" > ")
|
|
||||||
themeChoice, _ := reader.ReadString('\n')
|
|
||||||
themeChoice = strings.TrimSpace(themeChoice)
|
|
||||||
switch themeChoice {
|
|
||||||
case "2":
|
|
||||||
cfg.Appearance.Theme = "catppuccin-mocha"
|
|
||||||
case "3":
|
|
||||||
cfg.Appearance.Theme = "tokyo-night"
|
|
||||||
case "4":
|
|
||||||
cfg.Appearance.Theme = "terminal"
|
|
||||||
default:
|
|
||||||
cfg.Appearance.Theme = "flexoki-dark"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewNote().
|
||||||
|
Title("Welcome to cburn").
|
||||||
|
Description(welcomeDesc).
|
||||||
|
Next(true).
|
||||||
|
NextLabel("Start"),
|
||||||
|
),
|
||||||
|
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Claude.ai session key").
|
||||||
|
Description("For rate-limit and subscription data.\nclaude.ai > DevTools > Application > Cookies > sessionKey").
|
||||||
|
Placeholder(sessionPlaceholder).
|
||||||
|
EchoMode(huh.EchoModePassword).
|
||||||
|
Value(&sessionKey),
|
||||||
|
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Anthropic Admin API key").
|
||||||
|
Description("For real cost data from the billing API.").
|
||||||
|
Placeholder(adminPlaceholder).
|
||||||
|
EchoMode(huh.EchoModePassword).
|
||||||
|
Value(&adminKey),
|
||||||
|
),
|
||||||
|
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[int]().
|
||||||
|
Title("Default time range").
|
||||||
|
Options(
|
||||||
|
huh.NewOption("7 days", 7),
|
||||||
|
huh.NewOption("30 days", 30),
|
||||||
|
huh.NewOption("90 days", 90),
|
||||||
|
).
|
||||||
|
Value(&days),
|
||||||
|
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Color theme").
|
||||||
|
Options(themeOpts()...).
|
||||||
|
Value(&themeName),
|
||||||
|
),
|
||||||
|
).WithTheme(huh.ThemeDracula())
|
||||||
|
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
if errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
fmt.Println("\n Setup cancelled.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("setup form: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only overwrite keys if the user typed new ones
|
||||||
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
|
if sessionKey != "" {
|
||||||
|
cfg.ClaudeAI.SessionKey = sessionKey
|
||||||
|
}
|
||||||
|
adminKey = strings.TrimSpace(adminKey)
|
||||||
|
if adminKey != "" {
|
||||||
|
cfg.AdminAPI.APIKey = adminKey
|
||||||
|
}
|
||||||
|
cfg.General.DefaultDays = days
|
||||||
|
cfg.Appearance.Theme = themeName
|
||||||
|
|
||||||
if err := config.Save(cfg); err != nil {
|
if err := config.Save(cfg); err != nil {
|
||||||
return fmt.Errorf("saving config: %w", err)
|
return fmt.Errorf("saving config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Printf("\n Saved to %s\n", config.Path())
|
||||||
fmt.Printf(" Saved to %s\n", config.ConfigPath())
|
|
||||||
fmt.Println(" Run `cburn setup` anytime to reconfigure.")
|
fmt.Println(" Run `cburn setup` anytime to reconfigure.")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func themeOpts() []huh.Option[string] {
|
||||||
|
opts := make([]huh.Option[string], len(theme.All))
|
||||||
|
for i, t := range theme.All {
|
||||||
|
opts[i] = huh.NewOption(t.Name, t.Name)
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
func maskAPIKey(key string) string {
|
func maskAPIKey(key string) string {
|
||||||
if len(key) > 16 {
|
if len(key) > 16 {
|
||||||
return key[:8] + "..." + key[len(key)-4:]
|
return key[:8] + "..." + key[len(key)-4:]
|
||||||
|
|||||||
198
cmd/status.go
Normal file
198
cmd/status.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/theirongolddev/cburn/internal/claudeai"
|
||||||
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
|
"github.com/theirongolddev/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)
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/pipeline"
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
12
cmd/tui.go
12
cmd/tui.go
@@ -3,11 +3,13 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/tui"
|
"github.com/theirongolddev/cburn/internal/tui"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +28,10 @@ func runTUI(_ *cobra.Command, _ []string) error {
|
|||||||
cfg, _ := config.Load()
|
cfg, _ := config.Load()
|
||||||
theme.SetActive(cfg.Appearance.Theme)
|
theme.SetActive(cfg.Appearance.Theme)
|
||||||
|
|
||||||
|
// Force TrueColor profile so all background styling produces ANSI codes
|
||||||
|
// Without this, lipgloss may default to Ascii profile (no colors)
|
||||||
|
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||||
|
|
||||||
app := tui.NewApp(flagDataDir, flagDays, flagProject, flagModel, !flagNoSubagents)
|
app := tui.NewApp(flagDataDir, flagDays, flagProject, flagModel, !flagNoSubagents)
|
||||||
p := tea.NewProgram(app, tea.WithAltScreen())
|
p := tea.NewProgram(app, tea.WithAltScreen())
|
||||||
|
|
||||||
|
|||||||
9
go.mod
9
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module cburn
|
module github.com/theirongolddev/cburn
|
||||||
|
|
||||||
go 1.24.2
|
go 1.24.2
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/huh v0.8.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
modernc.org/sqlite v1.46.1
|
modernc.org/sqlite v1.46.1
|
||||||
@@ -14,9 +15,12 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
@@ -29,6 +33,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
@@ -39,7 +44,7 @@ require (
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
30
go.sum
30
go.sum
@@ -1,23 +1,45 @@
|
|||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
|
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||||
|
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
|
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||||
|
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
|
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||||
|
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
@@ -25,6 +47,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
|
|||||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
@@ -45,6 +69,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
|
|||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
@@ -74,8 +100,8 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
234
internal/claudeai/client.go
Normal file
234
internal/claudeai/client.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
// Package claudeai provides a client for fetching subscription and usage data from claude.ai.
|
||||||
|
package claudeai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
baseURL = "https://claude.ai/api"
|
||||||
|
requestTimeout = 10 * time.Second
|
||||||
|
maxBodySize = 1 << 20 // 1 MB
|
||||||
|
keyPrefix = "sk-ant-sid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrUnauthorized indicates the session key is expired or invalid.
|
||||||
|
ErrUnauthorized = errors.New("claudeai: unauthorized (session key expired or invalid)")
|
||||||
|
// ErrRateLimited indicates the API rate limit was hit.
|
||||||
|
ErrRateLimited = errors.New("claudeai: rate limited")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client fetches subscription data from the claude.ai web API.
|
||||||
|
type Client struct {
|
||||||
|
sessionKey string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a client for the given session key.
|
||||||
|
// Returns nil if the key is empty or has the wrong prefix.
|
||||||
|
func NewClient(sessionKey string) *Client {
|
||||||
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
|
if sessionKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(sessionKey, keyPrefix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
http: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAll fetches orgs, usage, and overage for the first organization.
|
||||||
|
// Partial data is returned even if some requests fail.
|
||||||
|
func (c *Client) FetchAll(ctx context.Context) *SubscriptionData {
|
||||||
|
result := &SubscriptionData{FetchedAt: time.Now()}
|
||||||
|
|
||||||
|
orgs, err := c.FetchOrganizations(ctx)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if len(orgs) == 0 {
|
||||||
|
result.Error = errors.New("claudeai: no organizations found")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Org = orgs[0]
|
||||||
|
orgID := orgs[0].UUID
|
||||||
|
|
||||||
|
// Fetch usage and overage independently — partial results are fine
|
||||||
|
usage, usageErr := c.FetchUsage(ctx, orgID)
|
||||||
|
if usageErr == nil {
|
||||||
|
result.Usage = usage
|
||||||
|
}
|
||||||
|
|
||||||
|
overage, overageErr := c.FetchOverageLimit(ctx, orgID)
|
||||||
|
if overageErr == nil {
|
||||||
|
result.Overage = overage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface first non-nil error for status display
|
||||||
|
if usageErr != nil {
|
||||||
|
result.Error = usageErr
|
||||||
|
} else if overageErr != nil {
|
||||||
|
result.Error = overageErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchOrganizations returns the list of organizations for this session.
|
||||||
|
func (c *Client) FetchOrganizations(ctx context.Context) ([]Organization, error) {
|
||||||
|
body, err := c.get(ctx, "/organizations")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var orgs []Organization
|
||||||
|
if err := json.Unmarshal(body, &orgs); err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: parsing organizations: %w", err)
|
||||||
|
}
|
||||||
|
return orgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchUsage returns parsed usage windows for the given organization.
|
||||||
|
func (c *Client) FetchUsage(ctx context.Context, orgID string) (*ParsedUsage, error) {
|
||||||
|
body, err := c.get(ctx, fmt.Sprintf("/organizations/%s/usage", orgID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw UsageResponse
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: parsing usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ParsedUsage{
|
||||||
|
FiveHour: parseWindow(raw.FiveHour),
|
||||||
|
SevenDay: parseWindow(raw.SevenDay),
|
||||||
|
SevenDayOpus: parseWindow(raw.SevenDayOpus),
|
||||||
|
SevenDaySonnet: parseWindow(raw.SevenDaySonnet),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchOverageLimit returns overage spend limit data for the given organization.
|
||||||
|
func (c *Client) FetchOverageLimit(ctx context.Context, orgID string) (*OverageLimit, error) {
|
||||||
|
body, err := c.get(ctx, fmt.Sprintf("/organizations/%s/overage_spend_limit", orgID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ol OverageLimit
|
||||||
|
if err := json.Unmarshal(body, &ol); err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: parsing overage limit: %w", err)
|
||||||
|
}
|
||||||
|
return &ol, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get performs an authenticated GET request and returns the response body.
|
||||||
|
func (c *Client) get(ctx context.Context, path string) ([]byte, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: creating request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Cookie", "sessionKey="+c.sessionKey)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "github.com/theirongolddev/cburn/1.0")
|
||||||
|
|
||||||
|
//nolint:gosec // URL is constructed from const baseURL
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
case http.StatusTooManyRequests:
|
||||||
|
return nil, ErrRateLimited
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("claudeai: unexpected status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claudeai: reading response: %w", err)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWindow converts a raw UsageWindow into a normalized ParsedWindow.
|
||||||
|
// Returns nil if the input is nil or unparseable.
|
||||||
|
func parseWindow(w *UsageWindow) *ParsedWindow {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pct, ok := parseUtilization(w.Utilization)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pw := &ParsedWindow{Pct: pct}
|
||||||
|
|
||||||
|
if w.ResetsAt != nil {
|
||||||
|
if t, err := time.Parse(time.RFC3339, *w.ResetsAt); err == nil {
|
||||||
|
pw.ResetsAt = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pw
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUtilization defensively parses the polymorphic utilization field.
|
||||||
|
// Handles int (75), float (0.75 or 75.0), and string ("75%" or "0.75").
|
||||||
|
// Returns value normalized to 0.0-1.0 range.
|
||||||
|
func parseUtilization(raw json.RawMessage) (float64, bool) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try number first (covers both int and float JSON)
|
||||||
|
var f float64
|
||||||
|
if err := json.Unmarshal(raw, &f); err == nil {
|
||||||
|
return normalizeUtilization(f), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try string
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw, &s); err == nil {
|
||||||
|
s = strings.TrimSuffix(strings.TrimSpace(s), "%")
|
||||||
|
if v, err := strconv.ParseFloat(s, 64); err == nil {
|
||||||
|
return normalizeUtilization(v), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeUtilization converts a value to 0.0-1.0 range.
|
||||||
|
// Values > 1.0 are assumed to be percentages (0-100 scale).
|
||||||
|
func normalizeUtilization(v float64) float64 {
|
||||||
|
if v > 1.0 {
|
||||||
|
return v / 100.0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
59
internal/claudeai/types.go
Normal file
59
internal/claudeai/types.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package claudeai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Organization represents a claude.ai organization.
|
||||||
|
type Organization struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageResponse is the raw API response from the usage endpoint.
|
||||||
|
type UsageResponse struct {
|
||||||
|
FiveHour *UsageWindow `json:"five_hour"`
|
||||||
|
SevenDay *UsageWindow `json:"seven_day"`
|
||||||
|
SevenDayOpus *UsageWindow `json:"seven_day_opus"`
|
||||||
|
SevenDaySonnet *UsageWindow `json:"seven_day_sonnet"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageWindow is a single rate-limit window from the API.
|
||||||
|
// Utilization can be int, float, or string — kept as raw JSON for defensive parsing.
|
||||||
|
type UsageWindow struct {
|
||||||
|
Utilization json.RawMessage `json:"utilization"`
|
||||||
|
ResetsAt *string `json:"resets_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverageLimit is the raw API response from the overage spend limit endpoint.
|
||||||
|
type OverageLimit struct {
|
||||||
|
IsEnabled bool `json:"isEnabled"`
|
||||||
|
UsedCredits float64 `json:"usedCredits"`
|
||||||
|
MonthlyCreditLimit float64 `json:"monthlyCreditLimit"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionData is the parsed, TUI-ready aggregate of all claude.ai API data.
|
||||||
|
type SubscriptionData struct {
|
||||||
|
Org Organization
|
||||||
|
Usage *ParsedUsage
|
||||||
|
Overage *OverageLimit
|
||||||
|
FetchedAt time.Time
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedUsage holds normalized usage windows.
|
||||||
|
type ParsedUsage struct {
|
||||||
|
FiveHour *ParsedWindow
|
||||||
|
SevenDay *ParsedWindow
|
||||||
|
SevenDayOpus *ParsedWindow
|
||||||
|
SevenDaySonnet *ParsedWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedWindow is a single rate-limit window, normalized for display.
|
||||||
|
type ParsedWindow struct {
|
||||||
|
Pct float64 // 0.0-1.0
|
||||||
|
ResetsAt time.Time
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/lipgloss/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Theme colors (Flexoki Dark)
|
// Theme colors (Flexoki Dark)
|
||||||
@@ -35,21 +36,9 @@ var (
|
|||||||
Bold(true).
|
Bold(true).
|
||||||
Foreground(ColorAccent)
|
Foreground(ColorAccent)
|
||||||
|
|
||||||
valueStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(ColorText)
|
|
||||||
|
|
||||||
mutedStyle = lipgloss.NewStyle().
|
mutedStyle = lipgloss.NewStyle().
|
||||||
Foreground(ColorTextMuted)
|
Foreground(ColorTextMuted)
|
||||||
|
|
||||||
costStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(ColorGreen)
|
|
||||||
|
|
||||||
tokenStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(ColorBlue)
|
|
||||||
|
|
||||||
warnStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(ColorOrange)
|
|
||||||
|
|
||||||
dimStyle = lipgloss.NewStyle().
|
dimStyle = lipgloss.NewStyle().
|
||||||
Foreground(ColorTextDim)
|
Foreground(ColorTextDim)
|
||||||
)
|
)
|
||||||
@@ -59,7 +48,6 @@ type Table struct {
|
|||||||
Title string
|
Title string
|
||||||
Headers []string
|
Headers []string
|
||||||
Rows [][]string
|
Rows [][]string
|
||||||
Widths []int // optional column widths, auto-calculated if nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderTitle renders a centered title bar in a bordered box.
|
// RenderTitle renders a centered title bar in a bordered box.
|
||||||
@@ -75,136 +63,47 @@ func RenderTitle(title string) string {
|
|||||||
return border.Render(titleStyle.Render(title))
|
return border.Render(titleStyle.Render(title))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderTable renders a bordered table with headers and rows.
|
// RenderTable renders a bordered table with headers and rows using lipgloss/table.
|
||||||
func RenderTable(t Table) string {
|
func RenderTable(t Table) string {
|
||||||
if len(t.Rows) == 0 && len(t.Headers) == 0 {
|
if len(t.Rows) == 0 && len(t.Headers) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate column widths
|
// Filter out "---" separator sentinels (not supported by lipgloss/table).
|
||||||
numCols := len(t.Headers)
|
rows := make([][]string, 0, len(t.Rows))
|
||||||
if numCols == 0 && len(t.Rows) > 0 {
|
for _, row := range t.Rows {
|
||||||
numCols = len(t.Rows[0])
|
if len(row) == 1 && row[0] == "---" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
widths := make([]int, numCols)
|
tbl := table.New().
|
||||||
if t.Widths != nil {
|
Border(lipgloss.RoundedBorder()).
|
||||||
copy(widths, t.Widths)
|
BorderStyle(dimStyle).
|
||||||
} else {
|
BorderColumn(true).
|
||||||
for i, h := range t.Headers {
|
BorderHeader(true).
|
||||||
if len(h) > widths[i] {
|
Headers(t.Headers...).
|
||||||
widths[i] = len(h)
|
Rows(rows...).
|
||||||
}
|
StyleFunc(func(row, col int) lipgloss.Style {
|
||||||
}
|
s := lipgloss.NewStyle().Padding(0, 1)
|
||||||
for _, row := range t.Rows {
|
if row == table.HeaderRow {
|
||||||
for i, cell := range row {
|
return s.Bold(true).Foreground(ColorAccent)
|
||||||
if i < numCols && len(cell) > widths[i] {
|
|
||||||
widths[i] = len(cell)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
s = s.Foreground(ColorText)
|
||||||
|
if col > 0 {
|
||||||
|
s = s.Align(lipgloss.Right)
|
||||||
}
|
}
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
// Title above table if present
|
|
||||||
if t.Title != "" {
|
if t.Title != "" {
|
||||||
b.WriteString(" ")
|
b.WriteString(" ")
|
||||||
b.WriteString(headerStyle.Render(t.Title))
|
b.WriteString(headerStyle.Render(t.Title))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
b.WriteString(tbl.Render())
|
||||||
totalWidth := 1 // left border
|
|
||||||
for _, w := range widths {
|
|
||||||
totalWidth += w + 3 // padding + separator
|
|
||||||
}
|
|
||||||
|
|
||||||
// Top border
|
|
||||||
b.WriteString(dimStyle.Render("╭"))
|
|
||||||
for i, w := range widths {
|
|
||||||
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
|
|
||||||
if i < numCols-1 {
|
|
||||||
b.WriteString(dimStyle.Render("┬"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(dimStyle.Render("╮"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Header row
|
|
||||||
if len(t.Headers) > 0 {
|
|
||||||
b.WriteString(dimStyle.Render("│"))
|
|
||||||
for i, h := range t.Headers {
|
|
||||||
w := widths[i]
|
|
||||||
padded := fmt.Sprintf(" %-*s ", w, h)
|
|
||||||
b.WriteString(headerStyle.Render(padded))
|
|
||||||
if i < numCols-1 {
|
|
||||||
b.WriteString(dimStyle.Render("│"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(dimStyle.Render("│"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Header separator
|
|
||||||
b.WriteString(dimStyle.Render("├"))
|
|
||||||
for i, w := range widths {
|
|
||||||
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
|
|
||||||
if i < numCols-1 {
|
|
||||||
b.WriteString(dimStyle.Render("┼"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(dimStyle.Render("┤"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data rows
|
|
||||||
for _, row := range t.Rows {
|
|
||||||
if len(row) == 1 && row[0] == "---" {
|
|
||||||
// Separator row
|
|
||||||
b.WriteString(dimStyle.Render("├"))
|
|
||||||
for i, w := range widths {
|
|
||||||
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
|
|
||||||
if i < numCols-1 {
|
|
||||||
b.WriteString(dimStyle.Render("┼"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(dimStyle.Render("┤"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(dimStyle.Render("│"))
|
|
||||||
for i := 0; i < numCols; i++ {
|
|
||||||
w := widths[i]
|
|
||||||
cell := ""
|
|
||||||
if i < len(row) {
|
|
||||||
cell = row[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right-align numeric columns (all except first)
|
|
||||||
var padded string
|
|
||||||
if i == 0 {
|
|
||||||
padded = fmt.Sprintf(" %-*s ", w, cell)
|
|
||||||
} else {
|
|
||||||
padded = fmt.Sprintf(" %*s ", w, cell)
|
|
||||||
}
|
|
||||||
b.WriteString(valueStyle.Render(padded))
|
|
||||||
if i < numCols-1 {
|
|
||||||
b.WriteString(dimStyle.Render("│"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(dimStyle.Render("│"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bottom border
|
|
||||||
b.WriteString(dimStyle.Render("╰"))
|
|
||||||
for i, w := range widths {
|
|
||||||
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
|
|
||||||
if i < numCols-1 {
|
|
||||||
b.WriteString(dimStyle.Render("┴"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(dimStyle.Render("╯"))
|
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
@@ -242,26 +141,26 @@ func RenderSparkline(values []float64) string {
|
|||||||
|
|
||||||
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||||
|
|
||||||
max := values[0]
|
maxVal := values[0]
|
||||||
for _, v := range values[1:] {
|
for _, v := range values[1:] {
|
||||||
if v > max {
|
if v > maxVal {
|
||||||
max = v
|
maxVal = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if max == 0 {
|
if maxVal == 0 {
|
||||||
max = 1
|
maxVal = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, v := range values {
|
for _, v := range values {
|
||||||
idx := int(v / max * float64(len(blocks)-1))
|
idx := int(v / maxVal * float64(len(blocks)-1))
|
||||||
if idx >= len(blocks) {
|
if idx >= len(blocks) {
|
||||||
idx = len(blocks) - 1
|
idx = len(blocks) - 1
|
||||||
}
|
}
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
idx = 0
|
idx = 0
|
||||||
}
|
}
|
||||||
b.WriteRune(blocks[idx])
|
b.WriteRune(blocks[idx]) //nolint:gosec // bounds checked above
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
@@ -270,12 +169,12 @@ func RenderSparkline(values []float64) string {
|
|||||||
// RenderHorizontalBar renders a horizontal bar chart entry.
|
// RenderHorizontalBar renders a horizontal bar chart entry.
|
||||||
func RenderHorizontalBar(label string, value, maxValue float64, maxWidth int) string {
|
func RenderHorizontalBar(label string, value, maxValue float64, maxWidth int) string {
|
||||||
if maxValue <= 0 {
|
if maxValue <= 0 {
|
||||||
return fmt.Sprintf(" %s", label)
|
return " " + label
|
||||||
}
|
}
|
||||||
barLen := int(value / maxValue * float64(maxWidth))
|
barLen := int(value / maxValue * float64(maxWidth))
|
||||||
if barLen < 0 {
|
if barLen < 0 {
|
||||||
barLen = 0
|
barLen = 0
|
||||||
}
|
}
|
||||||
bar := strings.Repeat("█", barLen)
|
bar := strings.Repeat("█", barLen)
|
||||||
return fmt.Sprintf(" %s", bar)
|
return " " + bar
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package config handles cburn configuration loading, saving, and pricing.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,8 +13,10 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
General GeneralConfig `toml:"general"`
|
General GeneralConfig `toml:"general"`
|
||||||
AdminAPI AdminAPIConfig `toml:"admin_api"`
|
AdminAPI AdminAPIConfig `toml:"admin_api"`
|
||||||
|
ClaudeAI ClaudeAIConfig `toml:"claude_ai"`
|
||||||
Budget BudgetConfig `toml:"budget"`
|
Budget BudgetConfig `toml:"budget"`
|
||||||
Appearance AppearanceConfig `toml:"appearance"`
|
Appearance AppearanceConfig `toml:"appearance"`
|
||||||
|
TUI TUIConfig `toml:"tui"`
|
||||||
Pricing PricingOverrides `toml:"pricing"`
|
Pricing PricingOverrides `toml:"pricing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,10 +29,16 @@ type GeneralConfig struct {
|
|||||||
|
|
||||||
// AdminAPIConfig holds Anthropic Admin API settings.
|
// AdminAPIConfig holds Anthropic Admin API settings.
|
||||||
type AdminAPIConfig struct {
|
type AdminAPIConfig struct {
|
||||||
APIKey string `toml:"api_key,omitempty"`
|
APIKey string `toml:"api_key,omitempty"` //nolint:gosec // config field, not a secret
|
||||||
BaseURL string `toml:"base_url,omitempty"`
|
BaseURL string `toml:"base_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClaudeAIConfig holds claude.ai session key settings for subscription data.
|
||||||
|
type ClaudeAIConfig struct {
|
||||||
|
SessionKey string `toml:"session_key,omitempty"` //nolint:gosec // config field, not a secret
|
||||||
|
OrgID string `toml:"org_id,omitempty"` // auto-cached after first fetch
|
||||||
|
}
|
||||||
|
|
||||||
// BudgetConfig holds budget tracking settings.
|
// BudgetConfig holds budget tracking settings.
|
||||||
type BudgetConfig struct {
|
type BudgetConfig struct {
|
||||||
MonthlyUSD *float64 `toml:"monthly_usd,omitempty"`
|
MonthlyUSD *float64 `toml:"monthly_usd,omitempty"`
|
||||||
@@ -40,6 +49,12 @@ type AppearanceConfig struct {
|
|||||||
Theme string `toml:"theme"`
|
Theme string `toml:"theme"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TUIConfig holds TUI-specific settings.
|
||||||
|
type TUIConfig struct {
|
||||||
|
AutoRefresh bool `toml:"auto_refresh"`
|
||||||
|
RefreshIntervalSec int `toml:"refresh_interval_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
// PricingOverrides allows user-defined pricing for specific models.
|
// PricingOverrides allows user-defined pricing for specific models.
|
||||||
type PricingOverrides struct {
|
type PricingOverrides struct {
|
||||||
Overrides map[string]ModelPricingOverride `toml:"overrides,omitempty"`
|
Overrides map[string]ModelPricingOverride `toml:"overrides,omitempty"`
|
||||||
@@ -64,11 +79,15 @@ func DefaultConfig() Config {
|
|||||||
Appearance: AppearanceConfig{
|
Appearance: AppearanceConfig{
|
||||||
Theme: "flexoki-dark",
|
Theme: "flexoki-dark",
|
||||||
},
|
},
|
||||||
|
TUI: TUIConfig{
|
||||||
|
AutoRefresh: true,
|
||||||
|
RefreshIntervalSec: 30,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigDir returns the XDG-compliant config directory.
|
// Dir returns the XDG-compliant config directory.
|
||||||
func ConfigDir() string {
|
func Dir() string {
|
||||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||||
return filepath.Join(xdg, "cburn")
|
return filepath.Join(xdg, "cburn")
|
||||||
}
|
}
|
||||||
@@ -76,16 +95,16 @@ func ConfigDir() string {
|
|||||||
return filepath.Join(home, ".config", "cburn")
|
return filepath.Join(home, ".config", "cburn")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigPath returns the full path to the config file.
|
// Path returns the full path to the config file.
|
||||||
func ConfigPath() string {
|
func Path() string {
|
||||||
return filepath.Join(ConfigDir(), "config.toml")
|
return filepath.Join(Dir(), "config.toml")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads the config file, returning defaults if it doesn't exist.
|
// Load reads the config file, returning defaults if it doesn't exist.
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
data, err := os.ReadFile(ConfigPath())
|
data, err := os.ReadFile(Path())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -102,19 +121,21 @@ func Load() (Config, error) {
|
|||||||
|
|
||||||
// Save writes the config to disk.
|
// Save writes the config to disk.
|
||||||
func Save(cfg Config) error {
|
func Save(cfg Config) error {
|
||||||
dir := ConfigDir()
|
dir := Dir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return fmt.Errorf("creating config dir: %w", err)
|
return fmt.Errorf("creating config dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(ConfigPath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
f, err := os.OpenFile(Path(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating config file: %w", err)
|
return fmt.Errorf("creating config file: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
enc := toml.NewEncoder(f)
|
enc := toml.NewEncoder(f)
|
||||||
return enc.Encode(cfg)
|
if err := enc.Encode(cfg); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAdminAPIKey returns the API key from env var or config, in that order.
|
// GetAdminAPIKey returns the API key from env var or config, in that order.
|
||||||
@@ -125,8 +146,16 @@ func GetAdminAPIKey(cfg Config) string {
|
|||||||
return cfg.AdminAPI.APIKey
|
return cfg.AdminAPI.APIKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSessionKey returns the session key from env var or config, in that order.
|
||||||
|
func GetSessionKey(cfg Config) string {
|
||||||
|
if key := os.Getenv("CLAUDE_SESSION_KEY"); key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return cfg.ClaudeAI.SessionKey
|
||||||
|
}
|
||||||
|
|
||||||
// Exists returns true if a config file exists on disk.
|
// Exists returns true if a config file exists on disk.
|
||||||
func Exists() bool {
|
func Exists() bool {
|
||||||
_, err := os.Stat(ConfigPath())
|
_, err := os.Stat(Path())
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// ModelPricing holds per-million-token prices for a model.
|
// ModelPricing holds per-million-token prices for a model.
|
||||||
type ModelPricing struct {
|
type ModelPricing struct {
|
||||||
@@ -14,6 +17,11 @@ type ModelPricing struct {
|
|||||||
LongOutputPerMTok float64
|
LongOutputPerMTok float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type modelPricingVersion struct {
|
||||||
|
EffectiveFrom time.Time
|
||||||
|
Pricing ModelPricing
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultPricing maps model base names to their pricing.
|
// DefaultPricing maps model base names to their pricing.
|
||||||
var DefaultPricing = map[string]ModelPricing{
|
var DefaultPricing = map[string]ModelPricing{
|
||||||
"claude-opus-4-6": {
|
"claude-opus-4-6": {
|
||||||
@@ -63,12 +71,34 @@ var DefaultPricing = map[string]ModelPricing{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultPricingHistory stores effective-dated prices for each model.
|
||||||
|
// Entries must be sorted by EffectiveFrom ascending.
|
||||||
|
var defaultPricingHistory = makeDefaultPricingHistory(DefaultPricing)
|
||||||
|
|
||||||
|
func makeDefaultPricingHistory(base map[string]ModelPricing) map[string][]modelPricingVersion {
|
||||||
|
history := make(map[string][]modelPricingVersion, len(base))
|
||||||
|
for modelName, pricing := range base {
|
||||||
|
history[modelName] = []modelPricingVersion{
|
||||||
|
{Pricing: pricing},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPricingModel(model string) bool {
|
||||||
|
if _, ok := defaultPricingHistory[model]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, ok := DefaultPricing[model]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// NormalizeModelName strips date suffixes from model identifiers.
|
// NormalizeModelName strips date suffixes from model identifiers.
|
||||||
// e.g., "claude-opus-4-5-20251101" -> "claude-opus-4-5"
|
// e.g., "claude-opus-4-5-20251101" -> "claude-opus-4-5"
|
||||||
func NormalizeModelName(raw string) string {
|
func NormalizeModelName(raw string) string {
|
||||||
// Models can have date suffixes like -20251101 (8 digits)
|
// Models can have date suffixes like -20251101 (8 digits)
|
||||||
// Strategy: try progressively shorter prefixes against the pricing table
|
// Strategy: try progressively shorter prefixes against the pricing table
|
||||||
if _, ok := DefaultPricing[raw]; ok {
|
if hasPricingModel(raw) {
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +108,7 @@ func NormalizeModelName(raw string) string {
|
|||||||
last := parts[len(parts)-1]
|
last := parts[len(parts)-1]
|
||||||
if isAllDigits(last) && len(last) >= 8 {
|
if isAllDigits(last) && len(last) >= 8 {
|
||||||
candidate := strings.Join(parts[:len(parts)-1], "-")
|
candidate := strings.Join(parts[:len(parts)-1], "-")
|
||||||
if _, ok := DefaultPricing[candidate]; ok {
|
if hasPricingModel(candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,14 +129,51 @@ func isAllDigits(s string) bool {
|
|||||||
// LookupPricing returns the pricing for a model, normalizing the name first.
|
// LookupPricing returns the pricing for a model, normalizing the name first.
|
||||||
// Returns zero pricing and false if the model is unknown.
|
// Returns zero pricing and false if the model is unknown.
|
||||||
func LookupPricing(model string) (ModelPricing, bool) {
|
func LookupPricing(model string) (ModelPricing, bool) {
|
||||||
|
return LookupPricingAt(model, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupPricingAt returns the pricing for a model at the given timestamp.
|
||||||
|
// If at is zero, the latest known pricing entry is used.
|
||||||
|
func LookupPricingAt(model string, at time.Time) (ModelPricing, bool) {
|
||||||
normalized := NormalizeModelName(model)
|
normalized := NormalizeModelName(model)
|
||||||
p, ok := DefaultPricing[normalized]
|
versions, ok := defaultPricingHistory[normalized]
|
||||||
return p, ok
|
if !ok || len(versions) == 0 {
|
||||||
|
p, fallback := DefaultPricing[normalized]
|
||||||
|
return p, fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if at.IsZero() {
|
||||||
|
return versions[len(versions)-1].Pricing, true
|
||||||
|
}
|
||||||
|
|
||||||
|
at = at.UTC()
|
||||||
|
selected := versions[0].Pricing
|
||||||
|
for _, v := range versions {
|
||||||
|
if v.EffectiveFrom.IsZero() || !at.Before(v.EffectiveFrom.UTC()) {
|
||||||
|
selected = v.Pricing
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return selected, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateCost computes the estimated cost in USD for a single API call.
|
// CalculateCost computes the estimated cost in USD for a single API call.
|
||||||
func CalculateCost(model string, inputTokens, outputTokens, cache5m, cache1h, cacheRead int64) float64 {
|
func CalculateCost(model string, inputTokens, outputTokens, cache5m, cache1h, cacheRead int64) float64 {
|
||||||
pricing, ok := LookupPricing(model)
|
return CalculateCostAt(model, time.Now(), inputTokens, outputTokens, cache5m, cache1h, cacheRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateCostAt computes the estimated cost in USD for a single API call at a point in time.
|
||||||
|
func CalculateCostAt(
|
||||||
|
model string,
|
||||||
|
at time.Time,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
cache5m,
|
||||||
|
cache1h,
|
||||||
|
cacheRead int64,
|
||||||
|
) float64 {
|
||||||
|
pricing, ok := LookupPricingAt(model, at)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -124,7 +191,12 @@ func CalculateCost(model string, inputTokens, outputTokens, cache5m, cache1h, ca
|
|||||||
|
|
||||||
// CalculateCacheSavings computes how much the cache reads saved vs full input pricing.
|
// CalculateCacheSavings computes how much the cache reads saved vs full input pricing.
|
||||||
func CalculateCacheSavings(model string, cacheReadTokens int64) float64 {
|
func CalculateCacheSavings(model string, cacheReadTokens int64) float64 {
|
||||||
pricing, ok := LookupPricing(model)
|
return CalculateCacheSavingsAt(model, time.Now(), cacheReadTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateCacheSavingsAt computes how much cache reads saved at a point in time.
|
||||||
|
func CalculateCacheSavingsAt(model string, at time.Time, cacheReadTokens int64) float64 {
|
||||||
|
pricing, ok := LookupPricingAt(model, at)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
81
internal/config/pricing_test.go
Normal file
81
internal/config/pricing_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustDate(t *testing.T, s string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
d, err := time.Parse("2006-01-02", s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse date %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupPricingAt_UsesEffectiveDate(t *testing.T) {
|
||||||
|
model := "test-model-windowed"
|
||||||
|
orig, had := defaultPricingHistory[model]
|
||||||
|
if had {
|
||||||
|
defer func() { defaultPricingHistory[model] = orig }()
|
||||||
|
} else {
|
||||||
|
defer delete(defaultPricingHistory, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultPricingHistory[model] = []modelPricingVersion{
|
||||||
|
{
|
||||||
|
EffectiveFrom: mustDate(t, "2025-01-01"),
|
||||||
|
Pricing: ModelPricing{InputPerMTok: 1.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
EffectiveFrom: mustDate(t, "2025-07-01"),
|
||||||
|
Pricing: ModelPricing{InputPerMTok: 2.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
aprPrice, ok := LookupPricingAt(model, mustDate(t, "2025-04-15"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("LookupPricingAt returned !ok for historical model")
|
||||||
|
}
|
||||||
|
if aprPrice.InputPerMTok != 1.0 {
|
||||||
|
t.Fatalf("April price InputPerMTok = %.2f, want 1.0", aprPrice.InputPerMTok)
|
||||||
|
}
|
||||||
|
|
||||||
|
augPrice, ok := LookupPricingAt(model, mustDate(t, "2025-08-15"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("LookupPricingAt returned !ok for historical model in later window")
|
||||||
|
}
|
||||||
|
if augPrice.InputPerMTok != 2.0 {
|
||||||
|
t.Fatalf("August price InputPerMTok = %.2f, want 2.0", augPrice.InputPerMTok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupPricingAt_UsesLatestWhenTimeZero(t *testing.T) {
|
||||||
|
model := "test-model-latest"
|
||||||
|
orig, had := defaultPricingHistory[model]
|
||||||
|
if had {
|
||||||
|
defer func() { defaultPricingHistory[model] = orig }()
|
||||||
|
} else {
|
||||||
|
defer delete(defaultPricingHistory, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultPricingHistory[model] = []modelPricingVersion{
|
||||||
|
{
|
||||||
|
EffectiveFrom: mustDate(t, "2025-01-01"),
|
||||||
|
Pricing: ModelPricing{InputPerMTok: 1.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
EffectiveFrom: mustDate(t, "2025-09-01"),
|
||||||
|
Pricing: ModelPricing{InputPerMTok: 3.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
price, ok := LookupPricingAt(model, time.Time{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("LookupPricingAt returned !ok for model with pricing history")
|
||||||
|
}
|
||||||
|
if price.InputPerMTok != 3.0 {
|
||||||
|
t.Fatalf("zero-time lookup InputPerMTok = %.2f, want 3.0", price.InputPerMTok)
|
||||||
|
}
|
||||||
|
}
|
||||||
398
internal/daemon/service.go
Normal file
398
internal/daemon/service.go
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
// Package daemon provides the long-running background usage monitor service.
|
||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
"github.com/theirongolddev/cburn/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config controls the daemon runtime behavior.
|
||||||
|
type Config struct {
|
||||||
|
DataDir string
|
||||||
|
Days int
|
||||||
|
ProjectFilter string
|
||||||
|
ModelFilter string
|
||||||
|
IncludeSubagents bool
|
||||||
|
UseCache bool
|
||||||
|
Interval time.Duration
|
||||||
|
Addr string
|
||||||
|
EventsBuffer int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is a compact usage state for status/event payloads.
|
||||||
|
type Snapshot struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Sessions int `json:"sessions"`
|
||||||
|
Prompts int `json:"prompts"`
|
||||||
|
APICalls int `json:"api_calls"`
|
||||||
|
Tokens int64 `json:"tokens"`
|
||||||
|
EstimatedCostUSD float64 `json:"estimated_cost_usd"`
|
||||||
|
CacheHitRate float64 `json:"cache_hit_rate"`
|
||||||
|
CostPerDayUSD float64 `json:"cost_per_day_usd"`
|
||||||
|
TokensPerDay int64 `json:"tokens_per_day"`
|
||||||
|
SessionsPerDay float64 `json:"sessions_per_day"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delta captures snapshot deltas between polls.
|
||||||
|
type Delta struct {
|
||||||
|
Sessions int `json:"sessions"`
|
||||||
|
Prompts int `json:"prompts"`
|
||||||
|
APICalls int `json:"api_calls"`
|
||||||
|
Tokens int64 `json:"tokens"`
|
||||||
|
EstimatedCostUSD float64 `json:"estimated_cost_usd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Delta) isZero() bool {
|
||||||
|
return d.Sessions == 0 &&
|
||||||
|
d.Prompts == 0 &&
|
||||||
|
d.APICalls == 0 &&
|
||||||
|
d.Tokens == 0 &&
|
||||||
|
d.EstimatedCostUSD == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is emitted whenever usage snapshot updates.
|
||||||
|
type Event struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Snapshot Snapshot `json:"snapshot"`
|
||||||
|
Delta Delta `json:"delta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is served at /v1/status.
|
||||||
|
type Status struct {
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
LastPollAt time.Time `json:"last_poll_at"`
|
||||||
|
PollIntervalSec int `json:"poll_interval_sec"`
|
||||||
|
PollCount int64 `json:"poll_count"`
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
Days int `json:"days"`
|
||||||
|
ProjectFilter string `json:"project_filter,omitempty"`
|
||||||
|
ModelFilter string `json:"model_filter,omitempty"`
|
||||||
|
Summary Snapshot `json:"summary"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
EventCount int `json:"event_count"`
|
||||||
|
SubscriberCount int `json:"subscriber_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service provides the daemon runtime and HTTP API.
|
||||||
|
type Service struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
startedAt time.Time
|
||||||
|
lastPollAt time.Time
|
||||||
|
pollCount int64
|
||||||
|
lastError string
|
||||||
|
hasSnapshot bool
|
||||||
|
snapshot Snapshot
|
||||||
|
nextEventID int64
|
||||||
|
events []Event
|
||||||
|
|
||||||
|
nextSubID int
|
||||||
|
subs map[int]chan Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a new daemon service with the provided config.
|
||||||
|
func New(cfg Config) *Service {
|
||||||
|
if cfg.Interval < 2*time.Second {
|
||||||
|
cfg.Interval = 10 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.EventsBuffer < 1 {
|
||||||
|
cfg.EventsBuffer = 200
|
||||||
|
}
|
||||||
|
if cfg.Addr == "" {
|
||||||
|
cfg.Addr = "127.0.0.1:8787"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
cfg: cfg,
|
||||||
|
startedAt: time.Now(),
|
||||||
|
subs: make(map[int]chan Event),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts HTTP endpoints and polling until ctx is canceled.
|
||||||
|
func (s *Service) Run(ctx context.Context) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", s.handleHealth)
|
||||||
|
mux.HandleFunc("/v1/status", s.handleStatus)
|
||||||
|
mux.HandleFunc("/v1/events", s.handleEvents)
|
||||||
|
mux.HandleFunc("/v1/stream", s.handleStream)
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: s.cfg.Addr,
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Seed initial snapshot so status is useful immediately.
|
||||||
|
s.pollOnce()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(s.cfg.Interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return server.Shutdown(shutdownCtx)
|
||||||
|
case <-ticker.C:
|
||||||
|
s.pollOnce()
|
||||||
|
case err := <-errCh:
|
||||||
|
return fmt.Errorf("daemon http server: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) pollOnce() {
|
||||||
|
start := time.Now()
|
||||||
|
sessions, err := s.loadSessions()
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastError = err.Error()
|
||||||
|
s.lastPollAt = time.Now()
|
||||||
|
s.pollCount++
|
||||||
|
s.mu.Unlock()
|
||||||
|
log.Printf("cburn daemon poll error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
since := now.AddDate(0, 0, -s.cfg.Days)
|
||||||
|
|
||||||
|
filtered := sessions
|
||||||
|
if s.cfg.ProjectFilter != "" {
|
||||||
|
filtered = pipeline.FilterByProject(filtered, s.cfg.ProjectFilter)
|
||||||
|
}
|
||||||
|
if s.cfg.ModelFilter != "" {
|
||||||
|
filtered = pipeline.FilterByModel(filtered, s.cfg.ModelFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := pipeline.Aggregate(filtered, since, now)
|
||||||
|
snap := snapshotFromSummary(stats, now)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ev Event
|
||||||
|
publish bool
|
||||||
|
)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
prev := s.snapshot
|
||||||
|
prevExists := s.hasSnapshot
|
||||||
|
|
||||||
|
s.hasSnapshot = true
|
||||||
|
s.snapshot = snap
|
||||||
|
s.lastPollAt = now
|
||||||
|
s.pollCount++
|
||||||
|
s.lastError = ""
|
||||||
|
|
||||||
|
if !prevExists {
|
||||||
|
s.nextEventID++
|
||||||
|
ev = Event{
|
||||||
|
ID: s.nextEventID,
|
||||||
|
Type: "snapshot",
|
||||||
|
Timestamp: now,
|
||||||
|
Snapshot: snap,
|
||||||
|
Delta: Delta{},
|
||||||
|
}
|
||||||
|
publish = true
|
||||||
|
} else {
|
||||||
|
delta := diffSnapshots(prev, snap)
|
||||||
|
if !delta.isZero() {
|
||||||
|
s.nextEventID++
|
||||||
|
ev = Event{
|
||||||
|
ID: s.nextEventID,
|
||||||
|
Type: "usage_delta",
|
||||||
|
Timestamp: now,
|
||||||
|
Snapshot: snap,
|
||||||
|
Delta: delta,
|
||||||
|
}
|
||||||
|
publish = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if publish {
|
||||||
|
s.publishEvent(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = start
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadSessions() ([]model.SessionStats, error) {
|
||||||
|
if s.cfg.UseCache {
|
||||||
|
cache, err := store.Open(pipeline.CachePath())
|
||||||
|
if err == nil {
|
||||||
|
defer func() { _ = cache.Close() }()
|
||||||
|
cr, loadErr := pipeline.LoadWithCache(s.cfg.DataDir, s.cfg.IncludeSubagents, cache, nil)
|
||||||
|
if loadErr == nil {
|
||||||
|
return cr.Sessions, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := pipeline.Load(s.cfg.DataDir, s.cfg.IncludeSubagents, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Sessions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotFromSummary(stats model.SummaryStats, at time.Time) Snapshot {
|
||||||
|
return Snapshot{
|
||||||
|
At: at,
|
||||||
|
Sessions: stats.TotalSessions,
|
||||||
|
Prompts: stats.TotalPrompts,
|
||||||
|
APICalls: stats.TotalAPICalls,
|
||||||
|
Tokens: stats.TotalBilledTokens,
|
||||||
|
EstimatedCostUSD: stats.EstimatedCost,
|
||||||
|
CacheHitRate: stats.CacheHitRate,
|
||||||
|
CostPerDayUSD: stats.CostPerDay,
|
||||||
|
TokensPerDay: stats.TokensPerDay,
|
||||||
|
SessionsPerDay: stats.SessionsPerDay,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffSnapshots(prev, curr Snapshot) Delta {
|
||||||
|
return Delta{
|
||||||
|
Sessions: curr.Sessions - prev.Sessions,
|
||||||
|
Prompts: curr.Prompts - prev.Prompts,
|
||||||
|
APICalls: curr.APICalls - prev.APICalls,
|
||||||
|
Tokens: curr.Tokens - prev.Tokens,
|
||||||
|
EstimatedCostUSD: curr.EstimatedCostUSD - prev.EstimatedCostUSD,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) publishEvent(ev Event) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.events = append(s.events, ev)
|
||||||
|
if len(s.events) > s.cfg.EventsBuffer {
|
||||||
|
s.events = s.events[len(s.events)-s.cfg.EventsBuffer:]
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ch := range s.subs {
|
||||||
|
select {
|
||||||
|
case ch <- ev:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) snapshotStatus() Status {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
return Status{
|
||||||
|
StartedAt: s.startedAt,
|
||||||
|
LastPollAt: s.lastPollAt,
|
||||||
|
PollIntervalSec: int(s.cfg.Interval.Seconds()),
|
||||||
|
PollCount: s.pollCount,
|
||||||
|
DataDir: s.cfg.DataDir,
|
||||||
|
Days: s.cfg.Days,
|
||||||
|
ProjectFilter: s.cfg.ProjectFilter,
|
||||||
|
ModelFilter: s.cfg.ModelFilter,
|
||||||
|
Summary: s.snapshot,
|
||||||
|
LastError: s.lastError,
|
||||||
|
EventCount: len(s.events),
|
||||||
|
SubscriberCount: len(s.subs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte("ok\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleStatus(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(s.snapshotStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleEvents(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
s.mu.RLock()
|
||||||
|
events := make([]Event, len(s.events))
|
||||||
|
copy(events, s.events)
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
ch := make(chan Event, 16)
|
||||||
|
id := s.addSubscriber(ch)
|
||||||
|
defer s.removeSubscriber(id)
|
||||||
|
|
||||||
|
// Send current snapshot immediately.
|
||||||
|
current := Event{
|
||||||
|
Type: "snapshot",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Snapshot: s.snapshotStatus().Summary,
|
||||||
|
}
|
||||||
|
writeSSE(w, current)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case ev := <-ch:
|
||||||
|
writeSSE(w, ev)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSSE(w http.ResponseWriter, ev Event) {
|
||||||
|
data, err := json.Marshal(ev)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, "event: %s\n", ev.Type)
|
||||||
|
_, _ = fmt.Fprintf(w, "data: %s\n\n", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) addSubscriber(ch chan Event) int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.nextSubID++
|
||||||
|
id := s.nextSubID
|
||||||
|
s.subs[id] = ch
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) removeSubscriber(id int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.subs, id)
|
||||||
|
}
|
||||||
66
internal/daemon/service_test.go
Normal file
66
internal/daemon/service_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiffSnapshots(t *testing.T) {
|
||||||
|
prev := Snapshot{
|
||||||
|
Sessions: 10,
|
||||||
|
Prompts: 100,
|
||||||
|
APICalls: 120,
|
||||||
|
Tokens: 1_000_000,
|
||||||
|
EstimatedCostUSD: 10.5,
|
||||||
|
}
|
||||||
|
curr := Snapshot{
|
||||||
|
Sessions: 12,
|
||||||
|
Prompts: 112,
|
||||||
|
APICalls: 136,
|
||||||
|
Tokens: 1_250_000,
|
||||||
|
EstimatedCostUSD: 13.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := diffSnapshots(prev, curr)
|
||||||
|
if delta.Sessions != 2 {
|
||||||
|
t.Fatalf("Sessions delta = %d, want 2", delta.Sessions)
|
||||||
|
}
|
||||||
|
if delta.Prompts != 12 {
|
||||||
|
t.Fatalf("Prompts delta = %d, want 12", delta.Prompts)
|
||||||
|
}
|
||||||
|
if delta.APICalls != 16 {
|
||||||
|
t.Fatalf("APICalls delta = %d, want 16", delta.APICalls)
|
||||||
|
}
|
||||||
|
if delta.Tokens != 250_000 {
|
||||||
|
t.Fatalf("Tokens delta = %d, want 250000", delta.Tokens)
|
||||||
|
}
|
||||||
|
if math.Abs(delta.EstimatedCostUSD-2.6) > 1e-9 {
|
||||||
|
t.Fatalf("Cost delta = %.2f, want 2.60", delta.EstimatedCostUSD)
|
||||||
|
}
|
||||||
|
if delta.isZero() {
|
||||||
|
t.Fatal("delta unexpectedly reported as zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishEventRingBuffer(t *testing.T) {
|
||||||
|
s := New(Config{
|
||||||
|
DataDir: ".",
|
||||||
|
Interval: 10 * time.Second,
|
||||||
|
EventsBuffer: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
s.publishEvent(Event{ID: 1})
|
||||||
|
s.publishEvent(Event{ID: 2})
|
||||||
|
s.publishEvent(Event{ID: 3})
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(s.events) != 2 {
|
||||||
|
t.Fatalf("events len = %d, want 2", len(s.events))
|
||||||
|
}
|
||||||
|
if s.events[0].ID != 2 || s.events[1].ID != 3 {
|
||||||
|
t.Fatalf("events ring contains IDs [%d, %d], want [2, 3]", s.events[0].ID, s.events[1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Aggregate computes summary statistics from a slice of session stats,
|
// Aggregate computes summary statistics from a slice of session stats,
|
||||||
@@ -51,7 +51,7 @@ func Aggregate(sessions []model.SessionStats, since, until time.Time) model.Summ
|
|||||||
// Cache savings (sum across all models found in sessions)
|
// Cache savings (sum across all models found in sessions)
|
||||||
for _, s := range filtered {
|
for _, s := range filtered {
|
||||||
for modelName, mu := range s.Models {
|
for modelName, mu := range s.Models {
|
||||||
stats.CacheSavings += config.CalculateCacheSavings(modelName, mu.CacheReadTokens)
|
stats.CacheSavings += config.CalculateCacheSavingsAt(modelName, s.StartTime, mu.CacheReadTokens)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cburn/internal/source"
|
"github.com/theirongolddev/cburn/internal/source"
|
||||||
"cburn/internal/store"
|
"github.com/theirongolddev/cburn/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BenchmarkLoad(b *testing.B) {
|
func BenchmarkLoad(b *testing.B) {
|
||||||
|
|||||||
93
internal/pipeline/costs.go
Normal file
93
internal/pipeline/costs.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenTypeCosts holds aggregate costs split by token type.
|
||||||
|
type TokenTypeCosts struct {
|
||||||
|
InputCost float64
|
||||||
|
OutputCost float64
|
||||||
|
Cache5mCost float64
|
||||||
|
Cache1hCost float64
|
||||||
|
CacheReadCost float64
|
||||||
|
CacheCost float64
|
||||||
|
TotalCost float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelCostBreakdown holds cost components for one model.
|
||||||
|
type ModelCostBreakdown struct {
|
||||||
|
Model string
|
||||||
|
InputCost float64
|
||||||
|
OutputCost float64
|
||||||
|
Cache5mCost float64
|
||||||
|
Cache1hCost float64
|
||||||
|
CacheReadCost float64
|
||||||
|
CacheCost float64
|
||||||
|
TotalCost float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateCostBreakdown computes token-type and model cost splits.
|
||||||
|
// Pricing is resolved at each session timestamp.
|
||||||
|
func AggregateCostBreakdown(
|
||||||
|
sessions []model.SessionStats,
|
||||||
|
since time.Time,
|
||||||
|
until time.Time,
|
||||||
|
) (TokenTypeCosts, []ModelCostBreakdown) {
|
||||||
|
filtered := FilterByTime(sessions, since, until)
|
||||||
|
|
||||||
|
var totals TokenTypeCosts
|
||||||
|
byModel := make(map[string]*ModelCostBreakdown)
|
||||||
|
|
||||||
|
for _, s := range filtered {
|
||||||
|
for modelName, usage := range s.Models {
|
||||||
|
pricing, ok := config.LookupPricingAt(modelName, s.StartTime)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
inputCost := float64(usage.InputTokens) * pricing.InputPerMTok / 1_000_000
|
||||||
|
outputCost := float64(usage.OutputTokens) * pricing.OutputPerMTok / 1_000_000
|
||||||
|
cache5mCost := float64(usage.CacheCreation5mTokens) * pricing.CacheWrite5mPerMTok / 1_000_000
|
||||||
|
cache1hCost := float64(usage.CacheCreation1hTokens) * pricing.CacheWrite1hPerMTok / 1_000_000
|
||||||
|
cacheReadCost := float64(usage.CacheReadTokens) * pricing.CacheReadPerMTok / 1_000_000
|
||||||
|
|
||||||
|
totals.InputCost += inputCost
|
||||||
|
totals.OutputCost += outputCost
|
||||||
|
totals.Cache5mCost += cache5mCost
|
||||||
|
totals.Cache1hCost += cache1hCost
|
||||||
|
totals.CacheReadCost += cacheReadCost
|
||||||
|
|
||||||
|
row, exists := byModel[modelName]
|
||||||
|
if !exists {
|
||||||
|
row = &ModelCostBreakdown{Model: modelName}
|
||||||
|
byModel[modelName] = row
|
||||||
|
}
|
||||||
|
row.InputCost += inputCost
|
||||||
|
row.OutputCost += outputCost
|
||||||
|
row.Cache5mCost += cache5mCost
|
||||||
|
row.Cache1hCost += cache1hCost
|
||||||
|
row.CacheReadCost += cacheReadCost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totals.CacheCost = totals.Cache5mCost + totals.Cache1hCost + totals.CacheReadCost
|
||||||
|
totals.TotalCost = totals.InputCost + totals.OutputCost + totals.CacheCost
|
||||||
|
|
||||||
|
modelRows := make([]ModelCostBreakdown, 0, len(byModel))
|
||||||
|
for _, row := range byModel {
|
||||||
|
row.CacheCost = row.Cache5mCost + row.Cache1hCost + row.CacheReadCost
|
||||||
|
row.TotalCost = row.InputCost + row.OutputCost + row.CacheCost
|
||||||
|
modelRows = append(modelRows, *row)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(modelRows, func(i, j int) bool {
|
||||||
|
return modelRows[i].TotalCost > modelRows[j].TotalCost
|
||||||
|
})
|
||||||
|
|
||||||
|
return totals, modelRows
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"cburn/internal/source"
|
"github.com/theirongolddev/cburn/internal/source"
|
||||||
"cburn/internal/store"
|
"github.com/theirongolddev/cburn/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CachedLoadResult extends LoadResult with cache metadata.
|
// CachedLoadResult extends LoadResult with cache metadata.
|
||||||
@@ -173,5 +173,6 @@ func CacheDir() string {
|
|||||||
|
|
||||||
// CachePath returns the full path to the cache database.
|
// CachePath returns the full path to the cache database.
|
||||||
func CachePath() string {
|
func CachePath() string {
|
||||||
return filepath.Join(CacheDir(), "metrics.db")
|
// v2 includes historical pricing-aware cost calculations.
|
||||||
|
return filepath.Join(CacheDir(), "metrics_v2.db")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
"cburn/internal/source"
|
"github.com/theirongolddev/cburn/internal/source"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadResult holds the output of the full data loading pipeline.
|
// LoadResult holds the output of the full data loading pipeline.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Byte patterns for field extraction.
|
// Byte patterns for field extraction.
|
||||||
@@ -173,8 +173,9 @@ func ParseFile(df DiscoveredFile) ParseResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, call := range calls {
|
for _, call := range calls {
|
||||||
call.EstimatedCost = config.CalculateCost(
|
call.EstimatedCost = config.CalculateCostAt(
|
||||||
call.Model,
|
call.Model,
|
||||||
|
call.Timestamp,
|
||||||
call.InputTokens,
|
call.InputTokens,
|
||||||
call.OutputTokens,
|
call.OutputTokens,
|
||||||
call.CacheCreation5mTokens,
|
call.CacheCreation5mTokens,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // register sqlite driver
|
_ "modernc.org/sqlite" // register sqlite driver
|
||||||
)
|
)
|
||||||
|
|||||||
1423
internal/tui/app.go
1423
internal/tui/app.go
File diff suppressed because it is too large
Load Diff
38
internal/tui/app_mouse_test.go
Normal file
38
internal/tui/app_mouse_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTabAtXMatchesTabWidths(t *testing.T) {
|
||||||
|
for active := 0; active < 5; active++ {
|
||||||
|
a := App{activeTab: active}
|
||||||
|
pos := 0
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
w := tabWidthForTest(i, active)
|
||||||
|
x := pos + w/2 // midpoint inside this tab
|
||||||
|
if got := a.tabAtX(x); got != i {
|
||||||
|
t.Fatalf("active=%d x=%d -> tab=%d, want %d", active, x, got, i)
|
||||||
|
}
|
||||||
|
pos += w
|
||||||
|
if i < 4 {
|
||||||
|
pos++ // separator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tabWidthForTest(tabIdx, activeIdx int) int {
|
||||||
|
nameWidths := []int{
|
||||||
|
len("Overview"),
|
||||||
|
len("Costs"),
|
||||||
|
len("Sessions"),
|
||||||
|
len("Breakdown"),
|
||||||
|
len("Settings"),
|
||||||
|
}
|
||||||
|
|
||||||
|
w := nameWidths[tabIdx] + 2 // horizontal padding in tab renderer
|
||||||
|
if tabIdx != activeIdx && tabIdx == 4 {
|
||||||
|
w += 3 // inactive Settings adds "[x]"
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
|
// Package components provides reusable TUI widgets for the cburn dashboard.
|
||||||
package components
|
package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
@@ -28,7 +27,7 @@ func LayoutRow(totalWidth, n int) []int {
|
|||||||
return widths
|
return widths
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetricCard renders a small metric card with label, value, and delta.
|
// MetricCard renders a visually striking metric card with icon, colored value, and delta.
|
||||||
// outerWidth is the total rendered width including border.
|
// outerWidth is the total rendered width including border.
|
||||||
func MetricCard(label, value, delta string, outerWidth int) string {
|
func MetricCard(label, value, delta string, outerWidth int) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
@@ -38,23 +37,56 @@ func MetricCard(label, value, delta string, outerWidth int) string {
|
|||||||
contentWidth = 10
|
contentWidth = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine accent color based on label for variety
|
||||||
|
var valueColor lipgloss.Color
|
||||||
|
var icon string
|
||||||
|
switch {
|
||||||
|
case strings.Contains(strings.ToLower(label), "token"):
|
||||||
|
valueColor = t.Cyan
|
||||||
|
icon = "◈"
|
||||||
|
case strings.Contains(strings.ToLower(label), "session"):
|
||||||
|
valueColor = t.Magenta
|
||||||
|
icon = "◉"
|
||||||
|
case strings.Contains(strings.ToLower(label), "cost"):
|
||||||
|
valueColor = t.Green
|
||||||
|
icon = "◆"
|
||||||
|
case strings.Contains(strings.ToLower(label), "cache"):
|
||||||
|
valueColor = t.Blue
|
||||||
|
icon = "◇"
|
||||||
|
default:
|
||||||
|
valueColor = t.Accent
|
||||||
|
icon = "●"
|
||||||
|
}
|
||||||
|
|
||||||
cardStyle := lipgloss.NewStyle().
|
cardStyle := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(t.Border).
|
BorderForeground(t.Border).
|
||||||
|
BorderBackground(t.Background).
|
||||||
|
Background(t.Surface).
|
||||||
Width(contentWidth).
|
Width(contentWidth).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
|
iconStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(valueColor).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
labelStyle := lipgloss.NewStyle().
|
labelStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextMuted)
|
Foreground(t.TextMuted).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
valueStyle := lipgloss.NewStyle().
|
valueStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextPrimary).
|
Foreground(valueColor).
|
||||||
|
Background(t.Surface).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
deltaStyle := lipgloss.NewStyle().
|
deltaStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextDim)
|
Foreground(t.TextDim).
|
||||||
|
Background(t.Surface)
|
||||||
|
spaceStyle := lipgloss.NewStyle().
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
content := labelStyle.Render(label) + "\n" +
|
// Build content with icon
|
||||||
|
content := iconStyle.Render(icon) + spaceStyle.Render(" ") + labelStyle.Render(label) + "\n" +
|
||||||
valueStyle.Render(value)
|
valueStyle.Render(value)
|
||||||
if delta != "" {
|
if delta != "" {
|
||||||
content += "\n" + deltaStyle.Render(delta)
|
content += "\n" + deltaStyle.Render(delta)
|
||||||
@@ -77,7 +109,8 @@ func MetricCardRow(cards []struct{ Label, Value, Delta string }, totalWidth int)
|
|||||||
rendered = append(rendered, MetricCard(c.Label, c.Value, c.Delta, widths[i]))
|
rendered = append(rendered, MetricCard(c.Label, c.Value, c.Delta, widths[i]))
|
||||||
}
|
}
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
|
// Use CardRow instead of JoinHorizontal to ensure proper background fill
|
||||||
|
return CardRow(rendered)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentCard renders a bordered content card with an optional title.
|
// ContentCard renders a bordered content card with an optional title.
|
||||||
@@ -90,14 +123,59 @@ func ContentCard(title, body string, outerWidth int) string {
|
|||||||
contentWidth = 10
|
contentWidth = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use accent border for titled cards, subtle for untitled
|
||||||
|
borderColor := t.Border
|
||||||
|
if title != "" {
|
||||||
|
borderColor = t.BorderBright
|
||||||
|
}
|
||||||
|
|
||||||
cardStyle := lipgloss.NewStyle().
|
cardStyle := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(t.Border).
|
BorderForeground(borderColor).
|
||||||
|
BorderBackground(t.Background).
|
||||||
|
Background(t.Surface).
|
||||||
|
Width(contentWidth).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
// Title with accent color and underline effect
|
||||||
|
titleStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.Accent).
|
||||||
|
Background(t.Surface).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
content := ""
|
||||||
|
if title != "" {
|
||||||
|
// Title with subtle separator
|
||||||
|
titleLine := titleStyle.Render(title)
|
||||||
|
separatorStyle := lipgloss.NewStyle().Foreground(t.Border).Background(t.Surface)
|
||||||
|
separator := separatorStyle.Render(strings.Repeat("─", minInt(len(title)+2, contentWidth-2)))
|
||||||
|
content = titleLine + "\n" + separator + "\n"
|
||||||
|
}
|
||||||
|
content += body
|
||||||
|
|
||||||
|
return cardStyle.Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PanelCard renders a full-width panel with prominent styling - used for main chart areas.
|
||||||
|
func PanelCard(title, body string, outerWidth int) string {
|
||||||
|
t := theme.Active
|
||||||
|
|
||||||
|
contentWidth := outerWidth - 2
|
||||||
|
if contentWidth < 10 {
|
||||||
|
contentWidth = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
cardStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(t.BorderAccent).
|
||||||
|
BorderBackground(t.Background).
|
||||||
|
Background(t.Surface).
|
||||||
Width(contentWidth).
|
Width(contentWidth).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
titleStyle := lipgloss.NewStyle().
|
titleStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextMuted).
|
Foreground(t.AccentBright).
|
||||||
|
Background(t.Surface).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
content := ""
|
content := ""
|
||||||
@@ -109,12 +187,57 @@ func ContentCard(title, body string, outerWidth int) string {
|
|||||||
return cardStyle.Render(content)
|
return cardStyle.Render(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CardRow joins pre-rendered card strings horizontally.
|
// CardRow joins pre-rendered card strings horizontally with matched heights.
|
||||||
|
// This manually joins cards line-by-line to ensure shorter cards are padded
|
||||||
|
// with proper background fill, avoiding black square artifacts.
|
||||||
func CardRow(cards []string) string {
|
func CardRow(cards []string) string {
|
||||||
if len(cards) == 0 {
|
if len(cards) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, cards...)
|
|
||||||
|
t := theme.Active
|
||||||
|
|
||||||
|
// Split each card into lines and track widths
|
||||||
|
cardLines := make([][]string, len(cards))
|
||||||
|
cardWidths := make([]int, len(cards))
|
||||||
|
maxHeight := 0
|
||||||
|
|
||||||
|
for i, card := range cards {
|
||||||
|
lines := strings.Split(card, "\n")
|
||||||
|
cardLines[i] = lines
|
||||||
|
if len(lines) > maxHeight {
|
||||||
|
maxHeight = len(lines)
|
||||||
|
}
|
||||||
|
// Determine card width from the first line (cards have consistent width)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
cardWidths[i] = lipgloss.Width(lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build background-filled padding style
|
||||||
|
bgStyle := lipgloss.NewStyle().Background(t.Background)
|
||||||
|
|
||||||
|
// Pad shorter cards with background-filled lines
|
||||||
|
for i := range cardLines {
|
||||||
|
for len(cardLines[i]) < maxHeight {
|
||||||
|
// Add a line of spaces with the proper background
|
||||||
|
padding := bgStyle.Render(strings.Repeat(" ", cardWidths[i]))
|
||||||
|
cardLines[i] = append(cardLines[i], padding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join cards line by line
|
||||||
|
var result strings.Builder
|
||||||
|
for row := 0; row < maxHeight; row++ {
|
||||||
|
for i := range cardLines {
|
||||||
|
result.WriteString(cardLines[i][row])
|
||||||
|
}
|
||||||
|
if row < maxHeight-1 {
|
||||||
|
result.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CardInnerWidth returns the usable text width inside a ContentCard
|
// CardInnerWidth returns the usable text width inside a ContentCard
|
||||||
@@ -127,311 +250,9 @@ func CardInnerWidth(outerWidth int) int {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sparkline renders a unicode sparkline from values.
|
func minInt(a, b int) int {
|
||||||
func Sparkline(values []float64, color lipgloss.Color) string {
|
if a < b {
|
||||||
if len(values) == 0 {
|
return a
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
return b
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
79
internal/tui/components/card_fix_test.go
Normal file
79
internal/tui/components/card_fix_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Force TrueColor output so ANSI codes are generated in tests
|
||||||
|
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardRowBackgroundFill(t *testing.T) {
|
||||||
|
// Initialize theme
|
||||||
|
theme.SetActive("flexoki-dark")
|
||||||
|
|
||||||
|
shortCard := ContentCard("Short", "Content", 22)
|
||||||
|
tallCard := ContentCard("Tall", "Line 1\nLine 2\nLine 3\nLine 4\nLine 5", 22)
|
||||||
|
|
||||||
|
shortLines := len(strings.Split(shortCard, "\n"))
|
||||||
|
tallLines := len(strings.Split(tallCard, "\n"))
|
||||||
|
|
||||||
|
t.Logf("Short card lines: %d", shortLines)
|
||||||
|
t.Logf("Tall card lines: %d", tallLines)
|
||||||
|
|
||||||
|
if shortLines >= tallLines {
|
||||||
|
t.Fatal("Test setup error: short card should be shorter than tall card")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the fixed CardRow
|
||||||
|
joined := CardRow([]string{tallCard, shortCard})
|
||||||
|
lines := strings.Split(joined, "\n")
|
||||||
|
t.Logf("Joined lines: %d", len(lines))
|
||||||
|
|
||||||
|
if len(lines) != tallLines {
|
||||||
|
t.Errorf("Joined height should match tallest card: got %d, want %d", len(lines), tallLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all lines have ANSI codes (indicating background styling)
|
||||||
|
for i, line := range lines {
|
||||||
|
hasESC := strings.Contains(line, "\x1b[")
|
||||||
|
// After the short card ends, the padding should still have ANSI codes
|
||||||
|
if i >= shortLines {
|
||||||
|
t.Logf("Line %d (padding): hasANSI=%v, raw=%q", i, hasESC, line)
|
||||||
|
if !hasESC {
|
||||||
|
t.Errorf("Line %d has NO ANSI codes - will show as black squares", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardRowWidthConsistency(t *testing.T) {
|
||||||
|
// Verify all lines have consistent width
|
||||||
|
theme.SetActive("flexoki-dark")
|
||||||
|
|
||||||
|
shortCard := ContentCard("Short", "A", 30)
|
||||||
|
tallCard := ContentCard("Tall", "A\nB\nC\nD\nE\nF", 20)
|
||||||
|
|
||||||
|
joined := CardRow([]string{tallCard, shortCard})
|
||||||
|
lines := strings.Split(joined, "\n")
|
||||||
|
|
||||||
|
// All lines should have the same visual width
|
||||||
|
for i, line := range lines {
|
||||||
|
w := len(line) // Raw byte length - will differ if ANSI codes vary
|
||||||
|
// Visual width should be consistent (tall card width + short card width)
|
||||||
|
// Using lipgloss.Width would be better but we're checking raw structure
|
||||||
|
t.Logf("Line %d: byteLen=%d", i, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the joined output has expected number of lines
|
||||||
|
tallLines := len(strings.Split(tallCard, "\n"))
|
||||||
|
if len(lines) != tallLines {
|
||||||
|
t.Errorf("Joined should have %d lines (tallest), got %d", tallLines, len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
307
internal/tui/components/chart.go
Normal file
307
internal/tui/components/chart.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/theirongolddev/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 ""
|
||||||
|
}
|
||||||
|
t := theme.Active
|
||||||
|
|
||||||
|
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).Background(t.Surface)
|
||||||
|
|
||||||
|
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 visually polished bar chart with gradient-style coloring.
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsPerTick := height / numIntervals
|
||||||
|
if rowsPerTick < 2 {
|
||||||
|
rowsPerTick = 2
|
||||||
|
}
|
||||||
|
chartH := rowsPerTick * numIntervals
|
||||||
|
|
||||||
|
// Pre-compute tick labels
|
||||||
|
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
|
||||||
|
chartW := width - yLabelW - 1
|
||||||
|
if chartW < 5 {
|
||||||
|
chartW = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
n := len(values)
|
||||||
|
|
||||||
|
// Bar sizing
|
||||||
|
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 {
|
||||||
|
maxN := (chartW + 1) / 3
|
||||||
|
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{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||||
|
|
||||||
|
// Multi-color gradient for bars based on height
|
||||||
|
axisStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Render rows top to bottom
|
||||||
|
for row := chartH; row >= 1; row-- {
|
||||||
|
rowTop := ceiling * float64(row) / float64(chartH)
|
||||||
|
rowBottom := ceiling * float64(row-1) / float64(chartH)
|
||||||
|
rowPct := float64(row) / float64(chartH) // How high in the chart (0=bottom, 1=top)
|
||||||
|
|
||||||
|
// Choose bar color based on row height (gradient effect)
|
||||||
|
var barColor lipgloss.Color
|
||||||
|
switch {
|
||||||
|
case rowPct > 0.8:
|
||||||
|
barColor = t.AccentBright
|
||||||
|
case rowPct > 0.5:
|
||||||
|
barColor = color
|
||||||
|
default:
|
||||||
|
barColor = t.Accent
|
||||||
|
}
|
||||||
|
barStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface)
|
||||||
|
|
||||||
|
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(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", gap)))
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v >= rowTop:
|
||||||
|
b.WriteString(barStyle.Render(strings.Repeat("█", 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(lipgloss.NewStyle().Background(t.Surface).Render(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] = ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if n > 1 {
|
||||||
|
lbl := labels[n-1]
|
||||||
|
pos := (n - 1) * (barW + gap)
|
||||||
|
end := pos + len(lbl)
|
||||||
|
if end > axisLen {
|
||||||
|
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")
|
||||||
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||||
|
b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", yLabelW+1)))
|
||||||
|
b.WriteString(labelStyle.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
152
internal/tui/components/progress.go
Normal file
152
internal/tui/components/progress.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/progress"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProgressBar renders a visually appealing progress bar with percentage.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color gradient based on progress
|
||||||
|
var barColor lipgloss.Color
|
||||||
|
switch {
|
||||||
|
case pct >= 0.8:
|
||||||
|
barColor = t.AccentBright
|
||||||
|
case pct >= 0.5:
|
||||||
|
barColor = t.Accent
|
||||||
|
default:
|
||||||
|
barColor = t.Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
filledStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface)
|
||||||
|
emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||||
|
pctStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface).Bold(true)
|
||||||
|
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(filledStyle.Render(strings.Repeat("█", filled)))
|
||||||
|
b.WriteString(emptyStyle.Render(strings.Repeat("░", width-filled)))
|
||||||
|
|
||||||
|
return b.String() + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%.0f%%", pct*100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorForPct returns green/yellow/orange/red based on utilization level.
|
||||||
|
func ColorForPct(pct float64) string {
|
||||||
|
t := theme.Active
|
||||||
|
switch {
|
||||||
|
case pct >= 0.9:
|
||||||
|
return string(t.Red)
|
||||||
|
case pct >= 0.7:
|
||||||
|
return string(t.Orange)
|
||||||
|
case pct >= 0.5:
|
||||||
|
return string(t.Yellow)
|
||||||
|
default:
|
||||||
|
return string(t.Green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitBar renders a labeled progress bar with percentage and countdown.
|
||||||
|
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).Background(t.Surface)
|
||||||
|
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true)
|
||||||
|
countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||||
|
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||||
|
|
||||||
|
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 labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)) +
|
||||||
|
spaceStyle.Render(" ") +
|
||||||
|
bar.ViewAs(pct) +
|
||||||
|
spaceStyle.Render(" ") +
|
||||||
|
pctStyle.Render(pctStr) +
|
||||||
|
spaceStyle.Render(" ") +
|
||||||
|
countdownStyle.Render(countdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompactRateBar renders a tiny status-bar-sized rate indicator.
|
||||||
|
func CompactRateBar(label string, pct float64, width int) string {
|
||||||
|
t := theme.Active
|
||||||
|
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
}
|
||||||
|
if pct > 1 {
|
||||||
|
pct = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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))).Background(t.Surface).Bold(true)
|
||||||
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
|
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||||
|
|
||||||
|
return labelStyle.Render(label) +
|
||||||
|
spaceStyle.Render(" ") +
|
||||||
|
bar.ViewAs(pct) +
|
||||||
|
spaceStyle.Render(" ") +
|
||||||
|
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCountdown(d time.Duration) string {
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(d.Minutes()) % 60
|
||||||
|
if h >= 24 {
|
||||||
|
days := h / 24
|
||||||
|
hours := h % 24
|
||||||
|
return fmt.Sprintf("%dd %dh", days, hours)
|
||||||
|
}
|
||||||
|
if h > 0 {
|
||||||
|
return fmt.Sprintf("%dh %dm", h, m)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm", m)
|
||||||
|
}
|
||||||
@@ -2,37 +2,179 @@ package components
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/claudeai"
|
||||||
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderStatusBar renders the bottom status bar.
|
// RenderStatusBar renders a polished bottom status bar with rate limits and controls.
|
||||||
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().
|
// Main container
|
||||||
Foreground(t.TextMuted).
|
barStyle := lipgloss.NewStyle().
|
||||||
|
Background(t.SurfaceHover).
|
||||||
Width(width)
|
Width(width)
|
||||||
|
|
||||||
left := " [?]help [q]uit"
|
// Build left section: keyboard hints
|
||||||
right := ""
|
keyStyle := lipgloss.NewStyle().
|
||||||
if dataAge != "" {
|
Foreground(t.AccentBright).
|
||||||
right = fmt.Sprintf("Data: %s ", dataAge)
|
Background(t.SurfaceHover).
|
||||||
}
|
Bold(true)
|
||||||
|
|
||||||
// Pad middle
|
hintStyle := lipgloss.NewStyle().
|
||||||
padding := width - lipgloss.Width(left) - lipgloss.Width(right)
|
Foreground(t.TextMuted).
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
|
||||||
|
bracketStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.TextDim).
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
spaceStyle := lipgloss.NewStyle().
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
|
||||||
|
left := spaceStyle.Render(" ") +
|
||||||
|
bracketStyle.Render("[") + keyStyle.Render("?") + bracketStyle.Render("]") + hintStyle.Render("help") + spaceStyle.Render(" ") +
|
||||||
|
bracketStyle.Render("[") + keyStyle.Render("r") + bracketStyle.Render("]") + hintStyle.Render("efresh") + spaceStyle.Render(" ") +
|
||||||
|
bracketStyle.Render("[") + keyStyle.Render("q") + bracketStyle.Render("]") + hintStyle.Render("uit")
|
||||||
|
|
||||||
|
// Build middle section: rate limit indicators
|
||||||
|
middle := renderStatusRateLimits(subData)
|
||||||
|
|
||||||
|
// Build right section: refresh status
|
||||||
|
var right string
|
||||||
|
if refreshing {
|
||||||
|
spinnerStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.AccentBright).
|
||||||
|
Background(t.SurfaceHover).
|
||||||
|
Bold(true)
|
||||||
|
right = spinnerStyle.Render("↻ refreshing")
|
||||||
|
} else if dataAge != "" {
|
||||||
|
refreshIcon := ""
|
||||||
|
if autoRefresh {
|
||||||
|
refreshIcon = lipgloss.NewStyle().
|
||||||
|
Foreground(t.Green).
|
||||||
|
Background(t.SurfaceHover).
|
||||||
|
Render("↻ ")
|
||||||
|
}
|
||||||
|
dataStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.TextMuted).
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
right = refreshIcon + dataStyle.Render("Data: "+dataAge)
|
||||||
|
}
|
||||||
|
right += spaceStyle.Render(" ")
|
||||||
|
|
||||||
|
// Calculate padding
|
||||||
|
leftWidth := lipgloss.Width(left)
|
||||||
|
middleWidth := lipgloss.Width(middle)
|
||||||
|
rightWidth := lipgloss.Width(right)
|
||||||
|
|
||||||
|
totalUsed := leftWidth + middleWidth + rightWidth
|
||||||
|
padding := width - totalUsed
|
||||||
if padding < 0 {
|
if padding < 0 {
|
||||||
padding = 0
|
padding = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
bar := left
|
leftPad := padding / 2
|
||||||
for i := 0; i < padding; i++ {
|
rightPad := padding - leftPad
|
||||||
bar += " "
|
|
||||||
}
|
|
||||||
bar += right
|
|
||||||
|
|
||||||
return style.Render(bar)
|
paddingStyle := lipgloss.NewStyle().Background(t.SurfaceHover)
|
||||||
|
bar := left +
|
||||||
|
paddingStyle.Render(strings.Repeat(" ", leftPad)) +
|
||||||
|
middle +
|
||||||
|
paddingStyle.Render(strings.Repeat(" ", rightPad)) +
|
||||||
|
right
|
||||||
|
|
||||||
|
return barStyle.Render(bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderStatusRateLimits renders compact rate limit pills for the status bar.
|
||||||
|
func renderStatusRateLimits(subData *claudeai.SubscriptionData) string {
|
||||||
|
if subData == nil || subData.Usage == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
t := theme.Active
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
if w := subData.Usage.FiveHour; w != nil {
|
||||||
|
parts = append(parts, renderRatePill("5h", w.Pct))
|
||||||
|
}
|
||||||
|
if w := subData.Usage.SevenDay; w != nil {
|
||||||
|
parts = append(parts, renderRatePill("Wk", w.Pct))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
sepStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.TextDim).
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
|
||||||
|
return strings.Join(parts, sepStyle.Render(" │ "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderRatePill renders a compact, colored rate indicator pill.
|
||||||
|
func renderRatePill(label string, pct float64) string {
|
||||||
|
t := theme.Active
|
||||||
|
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
}
|
||||||
|
if pct > 1 {
|
||||||
|
pct = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose color based on usage level
|
||||||
|
var barColor, pctColor lipgloss.Color
|
||||||
|
switch {
|
||||||
|
case pct >= 0.9:
|
||||||
|
barColor = t.Red
|
||||||
|
pctColor = t.Red
|
||||||
|
case pct >= 0.7:
|
||||||
|
barColor = t.Orange
|
||||||
|
pctColor = t.Orange
|
||||||
|
case pct >= 0.5:
|
||||||
|
barColor = t.Yellow
|
||||||
|
pctColor = t.Yellow
|
||||||
|
default:
|
||||||
|
barColor = t.Green
|
||||||
|
pctColor = t.Green
|
||||||
|
}
|
||||||
|
|
||||||
|
labelStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.TextMuted).
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
|
||||||
|
barStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(barColor).
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
|
||||||
|
emptyStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.TextDim).
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
|
||||||
|
pctStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(pctColor).
|
||||||
|
Background(t.SurfaceHover).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
// Render mini bar (8 chars)
|
||||||
|
barW := 8
|
||||||
|
filled := int(pct * float64(barW))
|
||||||
|
if filled > barW {
|
||||||
|
filled = barW
|
||||||
|
}
|
||||||
|
|
||||||
|
bar := barStyle.Render(strings.Repeat("█", filled)) +
|
||||||
|
emptyStyle.Render(strings.Repeat("░", barW-filled))
|
||||||
|
|
||||||
|
spaceStyle := lipgloss.NewStyle().
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
|
||||||
|
return labelStyle.Render(label+" ") + bar + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package components
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
@@ -17,78 +17,120 @@ type Tab struct {
|
|||||||
|
|
||||||
// Tabs defines all available tabs.
|
// Tabs defines all available tabs.
|
||||||
var Tabs = []Tab{
|
var Tabs = []Tab{
|
||||||
{Name: "Dashboard", Key: 'd', KeyPos: 0},
|
{Name: "Overview", Key: 'o', KeyPos: 0},
|
||||||
{Name: "Costs", Key: 'c', KeyPos: 0},
|
{Name: "Costs", Key: 'c', KeyPos: 0},
|
||||||
{Name: "Sessions", Key: 's', KeyPos: 0},
|
{Name: "Sessions", Key: 's', KeyPos: 0},
|
||||||
{Name: "Models", Key: 'm', KeyPos: 0},
|
{Name: "Breakdown", Key: 'b', KeyPos: 0},
|
||||||
{Name: "Projects", Key: 'p', KeyPos: 0},
|
{Name: "Settings", Key: 'x', KeyPos: -1},
|
||||||
{Name: "Trends", Key: 't', KeyPos: 0},
|
|
||||||
{Name: "Efficiency", Key: 'e', KeyPos: 0},
|
|
||||||
{Name: "Activity", Key: 'a', KeyPos: 0},
|
|
||||||
{Name: "Budget", Key: 'b', KeyPos: 0},
|
|
||||||
{Name: "Settings", Key: 'x', KeyPos: -1}, // x is not in "Settings"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderTabBar renders the tab bar with the given active index.
|
// TabVisualWidth returns the rendered visual width of a tab.
|
||||||
|
// This must match RenderTabBar's rendering logic exactly for mouse hit detection.
|
||||||
|
func TabVisualWidth(tab Tab, isActive bool) int {
|
||||||
|
// Active tabs: just the name with padding (1 on each side)
|
||||||
|
if isActive {
|
||||||
|
return len(tab.Name) + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inactive tabs: name with padding, plus "[k]" suffix if shortcut not in name
|
||||||
|
w := len(tab.Name) + 2
|
||||||
|
if tab.KeyPos < 0 {
|
||||||
|
w += 3 // "[k]"
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderTabBar renders a modern tab bar with underline-style active indicator.
|
||||||
func RenderTabBar(activeIdx int, width int) string {
|
func RenderTabBar(activeIdx int, width int) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
|
|
||||||
activeStyle := lipgloss.NewStyle().
|
// Container with bottom border
|
||||||
Foreground(t.Accent).
|
barStyle := lipgloss.NewStyle().
|
||||||
Bold(true)
|
Background(t.Surface).
|
||||||
|
Width(width)
|
||||||
|
|
||||||
inactiveStyle := lipgloss.NewStyle().
|
// Active tab: bright text with accent underline
|
||||||
Foreground(t.TextMuted)
|
activeTabStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.AccentBright).
|
||||||
|
Background(t.Surface).
|
||||||
|
Bold(true).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
// Inactive tab: muted text
|
||||||
|
inactiveTabStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.TextMuted).
|
||||||
|
Background(t.Surface).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
// Key highlight style
|
||||||
keyStyle := lipgloss.NewStyle().
|
keyStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.Accent).
|
Foreground(t.Accent).
|
||||||
Bold(true)
|
Background(t.Surface)
|
||||||
|
|
||||||
dimKeyStyle := lipgloss.NewStyle().
|
dimStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextDim)
|
Foreground(t.TextDim).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
|
// Separator between tabs
|
||||||
|
sepStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.Border).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
|
var tabParts []string
|
||||||
|
var underlineParts []string
|
||||||
|
|
||||||
var parts []string
|
|
||||||
for i, tab := range Tabs {
|
for i, tab := range Tabs {
|
||||||
var rendered string
|
var tabContent string
|
||||||
|
var underline string
|
||||||
|
|
||||||
if i == activeIdx {
|
if i == activeIdx {
|
||||||
rendered = activeStyle.Render(tab.Name)
|
// Active tab - full name, bright
|
||||||
|
tabContent = activeTabStyle.Render(tab.Name)
|
||||||
|
// Accent underline
|
||||||
|
underline = lipgloss.NewStyle().
|
||||||
|
Foreground(t.AccentBright).
|
||||||
|
Background(t.Surface).
|
||||||
|
Render(strings.Repeat("━", lipgloss.Width(tabContent)))
|
||||||
} else {
|
} else {
|
||||||
// Render with highlighted shortcut key
|
// Inactive tab - show key hint
|
||||||
if tab.KeyPos >= 0 && tab.KeyPos < len(tab.Name) {
|
if tab.KeyPos >= 0 && tab.KeyPos < len(tab.Name) {
|
||||||
before := tab.Name[:tab.KeyPos]
|
before := tab.Name[:tab.KeyPos]
|
||||||
key := string(tab.Name[tab.KeyPos])
|
key := string(tab.Name[tab.KeyPos])
|
||||||
after := tab.Name[tab.KeyPos+1:]
|
after := tab.Name[tab.KeyPos+1:]
|
||||||
rendered = inactiveStyle.Render(before) +
|
tabContent = lipgloss.NewStyle().Padding(0, 1).Background(t.Surface).Render(
|
||||||
dimKeyStyle.Render("[") + keyStyle.Render(key) + dimKeyStyle.Render("]") +
|
dimStyle.Render(before) + keyStyle.Render(key) + dimStyle.Render(after))
|
||||||
inactiveStyle.Render(after)
|
|
||||||
} else {
|
} else {
|
||||||
// Key not in name (e.g., "Settings" with 'x')
|
tabContent = inactiveTabStyle.Render(tab.Name) +
|
||||||
rendered = inactiveStyle.Render(tab.Name) +
|
dimStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimStyle.Render("]")
|
||||||
dimKeyStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimKeyStyle.Render("]")
|
|
||||||
}
|
}
|
||||||
}
|
// Dim underline
|
||||||
parts = append(parts, rendered)
|
underline = lipgloss.NewStyle().
|
||||||
|
Foreground(t.Border).
|
||||||
|
Background(t.Surface).
|
||||||
|
Render(strings.Repeat("─", lipgloss.Width(tabContent)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single row if all tabs fit
|
tabParts = append(tabParts, tabContent)
|
||||||
full := " " + strings.Join(parts, " ")
|
underlineParts = append(underlineParts, underline)
|
||||||
if lipgloss.Width(full) <= width {
|
|
||||||
return full
|
// Add separator between tabs (not after last)
|
||||||
|
if i < len(Tabs)-1 {
|
||||||
|
tabParts = append(tabParts, sepStyle.Render(" "))
|
||||||
|
underlineParts = append(underlineParts, sepStyle.Render(" "))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to two rows
|
// Combine tab row and underline row
|
||||||
row1 := strings.Join(parts[:5], " ")
|
tabRow := strings.Join(tabParts, "")
|
||||||
row2 := strings.Join(parts[5:], " ")
|
underlineRow := strings.Join(underlineParts, "")
|
||||||
|
|
||||||
return " " + row1 + "\n " + row2
|
// Fill remaining width with border
|
||||||
}
|
tabRowWidth := lipgloss.Width(tabRow)
|
||||||
|
if tabRowWidth < width {
|
||||||
// TabIdxByKey returns the tab index for a given key press, or -1.
|
padding := width - tabRowWidth
|
||||||
func TabIdxByKey(key rune) int {
|
tabRow += lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", padding))
|
||||||
for i, tab := range Tabs {
|
underlineRow += lipgloss.NewStyle().Foreground(t.Border).Background(t.Surface).Render(strings.Repeat("─", padding))
|
||||||
if tab.Key == key {
|
}
|
||||||
return i
|
|
||||||
}
|
return barStyle.Render(tabRow + "\n" + underlineRow)
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,151 +2,125 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/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
|
cfg := loadConfigOrDefault()
|
||||||
}{
|
|
||||||
{"7 days", 7},
|
// Pre-populate defaults
|
||||||
{"30 days", 30},
|
vals.days = cfg.General.DefaultDays
|
||||||
{"90 days", 90},
|
if vals.days == 0 {
|
||||||
|
vals.days = 30
|
||||||
|
}
|
||||||
|
vals.theme = cfg.Appearance.Theme
|
||||||
|
if vals.theme == "" {
|
||||||
|
vals.theme = "flexoki-dark"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build welcome text
|
||||||
|
welcomeDesc := "Let's configure your dashboard."
|
||||||
|
if numSessions > 0 {
|
||||||
|
welcomeDesc = fmt.Sprintf("Found %d sessions in %s.", numSessions, claudeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder text for key fields
|
||||||
|
sessionPlaceholder := "sk-ant-sid... (Enter to skip)"
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSetupState() setupState {
|
// saveSetupConfig persists the setup wizard values to the config file.
|
||||||
ti := textinput.New()
|
func (a *App) saveSetupConfig() error {
|
||||||
ti.Placeholder = "sk-ant-admin-... (or press Enter to skip)"
|
cfg := loadConfigOrDefault()
|
||||||
ti.CharLimit = 256
|
|
||||||
ti.Width = 50
|
|
||||||
ti.EchoMode = textinput.EchoPassword
|
|
||||||
ti.EchoCharacter = '*'
|
|
||||||
|
|
||||||
return setupState{
|
if a.setupVals.sessionKey != "" {
|
||||||
apiKeyIn: ti,
|
cfg.ClaudeAI.SessionKey = a.setupVals.sessionKey
|
||||||
daysChoice: 1, // default 30 days
|
|
||||||
}
|
}
|
||||||
|
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 (a App) renderSetup() string {
|
func maskKey(key string) string {
|
||||||
t := theme.Active
|
if len(key) > 16 {
|
||||||
ss := a.setup
|
return key[:8] + "..." + key[len(key)-4:]
|
||||||
|
|
||||||
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")
|
if len(key) > 4 {
|
||||||
|
return key[:4] + "..."
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
return "****"
|
||||||
b.WriteString(labelStyle.Render(" j/k to select, Enter to confirm"))
|
|
||||||
|
|
||||||
case 3: // Theme
|
|
||||||
b.WriteString(valueStyle.Render(" 3. Color theme"))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
for i, th := range theme.All {
|
|
||||||
if i == ss.themeChoice {
|
|
||||||
b.WriteString(accentStyle.Render(fmt.Sprintf(" (o) %s", th.Name)))
|
|
||||||
} else {
|
|
||||||
b.WriteString(labelStyle.Render(fmt.Sprintf(" ( ) %s", th.Name)))
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(labelStyle.Render(" j/k to select, Enter to confirm"))
|
|
||||||
|
|
||||||
case 4: // Done
|
|
||||||
if ss.saveErr != nil {
|
|
||||||
warnStyle := lipgloss.NewStyle().Foreground(t.Orange)
|
|
||||||
b.WriteString(warnStyle.Render(fmt.Sprintf(" Could not save config: %s", ss.saveErr)))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(labelStyle.Render(" Settings will apply for this session only."))
|
|
||||||
} else {
|
|
||||||
b.WriteString(greenStyle.Render(" All set!"))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString(labelStyle.Render(" Saved to ~/.config/cburn/config.toml"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(labelStyle.Render(" Run `cburn setup` anytime to reconfigure."))
|
|
||||||
}
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString(accentStyle.Render(" Press Enter to launch the dashboard"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) saveSetupConfig() {
|
|
||||||
cfg, _ := config.Load()
|
|
||||||
|
|
||||||
apiKey := strings.TrimSpace(a.setup.apiKeyIn.Value())
|
|
||||||
if apiKey != "" {
|
|
||||||
cfg.AdminAPI.APIKey = apiKey
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.setup.daysChoice >= 0 && a.setup.daysChoice < len(daysOptions) {
|
|
||||||
cfg.General.DefaultDays = daysOptions[a.setup.daysChoice].value
|
|
||||||
a.days = cfg.General.DefaultDays
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.setup.themeChoice >= 0 && a.setup.themeChoice < len(theme.All) {
|
|
||||||
cfg.Appearance.Theme = theme.All[a.setup.themeChoice].Name
|
|
||||||
theme.SetActive(cfg.Appearance.Theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.setup.saveErr = config.Save(cfg)
|
|
||||||
}
|
}
|
||||||
|
|||||||
144
internal/tui/tab_breakdown.go
Normal file
144
internal/tui/tab_breakdown.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
|
"github.com/theirongolddev/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).Background(t.Surface).Bold(true)
|
||||||
|
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||||
|
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
|
costStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface)
|
||||||
|
shareStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface)
|
||||||
|
|
||||||
|
// Model colors for visual interest - pre-compute styles to avoid allocation in loops
|
||||||
|
modelColors := []lipgloss.Color{t.BlueBright, t.Cyan, t.Magenta, t.Yellow, t.Green}
|
||||||
|
nameStyles := make([]lipgloss.Style, len(modelColors))
|
||||||
|
for i, color := range modelColors {
|
||||||
|
nameStyles[i] = lipgloss.NewStyle().Foreground(color).Background(t.Surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+shareW+costW+callW+3)))
|
||||||
|
tableBody.WriteString("\n")
|
||||||
|
|
||||||
|
for i, ms := range models {
|
||||||
|
tableBody.WriteString(nameStyles[i%len(modelColors)].Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(ms.Model), nameW))))
|
||||||
|
tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %8s", cli.FormatNumber(int64(ms.APICalls)))))
|
||||||
|
tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ms.EstimatedCost))))
|
||||||
|
tableBody.WriteString(shareStyle.Render(fmt.Sprintf(" %5.1f%%", 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")
|
||||||
|
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
|
||||||
|
tableBody.WriteString("\n")
|
||||||
|
|
||||||
|
for i, ms := range models {
|
||||||
|
tableBody.WriteString(nameStyles[i%len(modelColors)].Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(ms.Model), nameW))))
|
||||||
|
tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %8s %10s %10s",
|
||||||
|
cli.FormatNumber(int64(ms.APICalls)),
|
||||||
|
cli.FormatTokens(ms.InputTokens),
|
||||||
|
cli.FormatTokens(ms.OutputTokens))))
|
||||||
|
tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ms.EstimatedCost))))
|
||||||
|
tableBody.WriteString(shareStyle.Render(fmt.Sprintf(" %5.1f%%", 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).Background(t.Surface).Bold(true)
|
||||||
|
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||||
|
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
|
nameStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface)
|
||||||
|
costStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface)
|
||||||
|
|
||||||
|
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")
|
||||||
|
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+costW+sessW+2)))
|
||||||
|
tableBody.WriteString("\n")
|
||||||
|
|
||||||
|
for _, ps := range projects {
|
||||||
|
tableBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(ps.Project, nameW))))
|
||||||
|
tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %6d", ps.Sessions)))
|
||||||
|
tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", 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")
|
||||||
|
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
|
||||||
|
tableBody.WriteString("\n")
|
||||||
|
|
||||||
|
for _, ps := range projects {
|
||||||
|
tableBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(ps.Project, nameW))))
|
||||||
|
tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %6d %8s %10s",
|
||||||
|
ps.Sessions,
|
||||||
|
cli.FormatNumber(int64(ps.Prompts)),
|
||||||
|
cli.FormatTokens(ps.TotalTokens))))
|
||||||
|
tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", 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()
|
||||||
|
}
|
||||||
322
internal/tui/tab_costs.go
Normal file
322
internal/tui/tab_costs.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/theirongolddev/cburn/internal/claudeai"
|
||||||
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
|
"github.com/theirongolddev/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
|
||||||
|
days := a.dailyStats
|
||||||
|
modelCosts := a.modelCosts
|
||||||
|
|
||||||
|
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).Background(t.Surface).Bold(true)
|
||||||
|
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
|
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||||
|
costValueStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface)
|
||||||
|
modelNameStyle := lipgloss.NewStyle().Foreground(t.BlueBright).Background(t.Surface)
|
||||||
|
tokenCostStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface)
|
||||||
|
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||||
|
|
||||||
|
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 _, mc := range modelCosts {
|
||||||
|
tableBody.WriteString(modelNameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(mc.Model), nameW))))
|
||||||
|
tableBody.WriteString(costValueStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(mc.TotalCost))))
|
||||||
|
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 _, mc := range modelCosts {
|
||||||
|
tableBody.WriteString(modelNameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(mc.Model), nameW))))
|
||||||
|
tableBody.WriteString(tokenCostStyle.Render(fmt.Sprintf(" %10s %10s %10s",
|
||||||
|
cli.FormatCost(mc.InputCost),
|
||||||
|
cli.FormatCost(mc.OutputCost),
|
||||||
|
cli.FormatCost(mc.CacheCost))))
|
||||||
|
tableBody.WriteString(costValueStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(mc.TotalCost))))
|
||||||
|
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))
|
||||||
|
body.WriteString(spaceStyle.Render(" "))
|
||||||
|
body.WriteString(valueStyle.Render(fmt.Sprintf("%.0f%%", pct*100)))
|
||||||
|
body.WriteString("\n")
|
||||||
|
body.WriteString(labelStyle.Render("Used"))
|
||||||
|
body.WriteString(spaceStyle.Render(" "))
|
||||||
|
body.WriteString(valueStyle.Render(fmt.Sprintf("$%.2f", ol.UsedCredits)))
|
||||||
|
body.WriteString(spaceStyle.Render(" / "))
|
||||||
|
body.WriteString(valueStyle.Render(fmt.Sprintf("$%.2f", ol.MonthlyCreditLimit)))
|
||||||
|
body.WriteString(spaceStyle.Render(" "))
|
||||||
|
body.WriteString(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 {
|
||||||
|
spendBody.WriteString(valueStyle.Render(d.Date.Format("Jan 02")))
|
||||||
|
spendBody.WriteString(spaceStyle.Render(" "))
|
||||||
|
spendBody.WriteString(lipgloss.NewStyle().Foreground(t.Green).Background(t.Surface).Render(cli.FormatCost(d.EstimatedCost)))
|
||||||
|
spendBody.WriteString("\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
spendBody.WriteString(labelStyle.Render("No data"))
|
||||||
|
spendBody.WriteString("\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 string
|
||||||
|
value string
|
||||||
|
color lipgloss.Color
|
||||||
|
}{
|
||||||
|
{"Tokens/Prompt", cli.FormatTokens(tokPerPrompt), t.Cyan},
|
||||||
|
{"Output/Prompt", cli.FormatTokens(outPerPrompt), t.Cyan},
|
||||||
|
{"Prompts/Session", fmt.Sprintf("%.1f", promptsPerSess), t.Magenta},
|
||||||
|
{"Minutes/Day", fmt.Sprintf("%.0f", stats.MinutesPerDay), t.Yellow},
|
||||||
|
}
|
||||||
|
|
||||||
|
var effBody strings.Builder
|
||||||
|
for _, m := range effMetrics {
|
||||||
|
effBody.WriteString(labelStyle.Render(fmt.Sprintf("%-20s", m.name)))
|
||||||
|
effBody.WriteString(lipgloss.NewStyle().Foreground(m.color).Background(t.Surface).Render(fmt.Sprintf(" %10s", 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).Background(t.Surface)
|
||||||
|
|
||||||
|
// No session key configured
|
||||||
|
if a.subData == nil && !a.subFetching {
|
||||||
|
cfg := loadConfigOrDefault()
|
||||||
|
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).Background(t.Surface)
|
||||||
|
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).Background(t.Surface).Render(strings.Repeat("─", innerW)))
|
||||||
|
body.WriteString("\n")
|
||||||
|
body.WriteString(components.RateLimitBar("Overage",
|
||||||
|
pct, time.Time{}, labelW, barW))
|
||||||
|
|
||||||
|
spendStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||||
|
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).Background(t.Surface)
|
||||||
|
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"
|
||||||
|
}
|
||||||
277
internal/tui/tab_overview.go
Normal file
277
internal/tui/tab_overview.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
|
"github.com/theirongolddev/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 with colored values
|
||||||
|
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 - use PanelCard for emphasis
|
||||||
|
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.PanelCard(
|
||||||
|
fmt.Sprintf("Daily Token Usage (%dd)", a.days),
|
||||||
|
components.BarChart(chartVals, chartLabels, t.BlueBright, 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.Cyan, 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.Magenta, 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])
|
||||||
|
|
||||||
|
// Model split with colored bars per model
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color palette for models - pre-compute styles to avoid allocation in loop
|
||||||
|
modelColors := []lipgloss.Color{t.BlueBright, t.Cyan, t.Magenta, t.Yellow, t.Green}
|
||||||
|
sepStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||||
|
nameStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||||
|
|
||||||
|
// Pre-compute bar and percent styles for each color
|
||||||
|
barStyles := make([]lipgloss.Style, len(modelColors))
|
||||||
|
pctStyles := make([]lipgloss.Style, len(modelColors))
|
||||||
|
for i, color := range modelColors {
|
||||||
|
barStyles[i] = lipgloss.NewStyle().Foreground(color).Background(t.Surface)
|
||||||
|
pctStyles[i] = lipgloss.NewStyle().Foreground(color).Background(t.Surface).Bold(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, ms := range models[:limit] {
|
||||||
|
barLen := 0
|
||||||
|
if maxShare > 0 {
|
||||||
|
barLen = int(ms.SharePercent / maxShare * float64(barMaxLen))
|
||||||
|
}
|
||||||
|
|
||||||
|
colorIdx := i % len(modelColors)
|
||||||
|
modelBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, shortModel(ms.Model))))
|
||||||
|
modelBody.WriteString(sepStyle.Render(" "))
|
||||||
|
modelBody.WriteString(barStyles[colorIdx].Render(strings.Repeat("█", barLen)))
|
||||||
|
modelBody.WriteString(sepStyle.Render(" "))
|
||||||
|
modelBody.WriteString(pctStyles[colorIdx].Render(fmt.Sprintf("%3.0f%%", ms.SharePercent)))
|
||||||
|
modelBody.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity patterns with time-of-day coloring
|
||||||
|
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.Magenta},
|
||||||
|
{"Early 04-07", 0, t.Orange},
|
||||||
|
{"Morning 08-11", 0, t.GreenBright},
|
||||||
|
{"Midday 12-15", 0, t.Green},
|
||||||
|
{"Evening 16-19", 0, t.Cyan},
|
||||||
|
{"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
|
||||||
|
maxNumW := 5
|
||||||
|
for _, bk := range buckets {
|
||||||
|
if nw := len(cli.FormatNumber(int64(bk.total))); nw > maxNumW {
|
||||||
|
maxNumW = nw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actBarMax := actInnerW - 15 - maxNumW
|
||||||
|
if actBarMax < 1 {
|
||||||
|
actBarMax = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
|
numStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||||
|
|
||||||
|
var actBody strings.Builder
|
||||||
|
for _, bk := range buckets {
|
||||||
|
bl := 0
|
||||||
|
if maxBucket > 0 {
|
||||||
|
bl = bk.total * actBarMax / maxBucket
|
||||||
|
}
|
||||||
|
barStyle := lipgloss.NewStyle().Foreground(bk.color).Background(t.Surface)
|
||||||
|
actBody.WriteString(labelStyle.Render(bk.label))
|
||||||
|
actBody.WriteString(sepStyle.Render(" "))
|
||||||
|
actBody.WriteString(numStyle.Render(fmt.Sprintf("%*s", maxNumW, cli.FormatNumber(int64(bk.total)))))
|
||||||
|
actBody.WriteString(sepStyle.Render(" "))
|
||||||
|
actBody.WriteString(barStyle.Render(strings.Repeat("█", bl)))
|
||||||
|
actBody.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
func minuteLabels() []string {
|
||||||
|
return []string{"-55", "-50", "-45", "-40", "-35", "-30", "-25", "-20", "-15", "-10", "-5", "now"}
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/model"
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
"cburn/internal/tui/components"
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,19 +21,109 @@ const (
|
|||||||
sessViewDetail // Full-screen detail
|
sessViewDetail // Full-screen detail
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Layout constants for sessions tab height calculations.
|
||||||
|
const (
|
||||||
|
sessListOverhead = 6 // card border (2) + header row (2) + footer hint (2)
|
||||||
|
sessDetailOverhead = 4 // card border (2) + title (1) + gap (1)
|
||||||
|
sessMinVisible = 5 // minimum visible rows in any pane
|
||||||
|
)
|
||||||
|
|
||||||
// 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 {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
ss := a.sessState
|
ss := a.sessState
|
||||||
|
|
||||||
|
// Show search input when in search mode
|
||||||
|
if ss.searching {
|
||||||
|
var b strings.Builder
|
||||||
|
searchStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true)
|
||||||
|
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||||
|
b.WriteString(searchStyle.Render(" Search: "))
|
||||||
|
b.WriteString(ss.searchInput.View())
|
||||||
|
b.WriteString("\n")
|
||||||
|
hintStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||||
|
keyStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface)
|
||||||
|
b.WriteString(spaceStyle.Render(" ") + hintStyle.Render("[") + keyStyle.Render("Enter") + hintStyle.Render("] apply [") +
|
||||||
|
keyStyle.Render("Esc") + hintStyle.Render("] cancel"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Show preview of filtered results
|
||||||
|
previewFiltered := filterSessionsBySearch(a.filtered, ss.searchInput.Value())
|
||||||
|
countStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
|
b.WriteString(countStyle.Render(fmt.Sprintf(" %d sessions match", len(previewFiltered))))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build title with search indicator
|
||||||
|
title := fmt.Sprintf("Sessions [%dd]", a.days)
|
||||||
|
if ss.searchQuery != "" {
|
||||||
|
title = fmt.Sprintf("Sessions [%dd] / %q (%d)", a.days, ss.searchQuery, len(filtered))
|
||||||
|
}
|
||||||
|
|
||||||
if len(filtered) == 0 {
|
if len(filtered) == 0 {
|
||||||
return components.ContentCard("Sessions", lipgloss.NewStyle().Foreground(t.TextMuted).Render("No sessions found"), cw)
|
var body strings.Builder
|
||||||
|
body.WriteString(lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface).Render("No sessions found"))
|
||||||
|
if ss.searchQuery != "" {
|
||||||
|
body.WriteString("\n\n")
|
||||||
|
body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface).Render("[Esc] clear search [/] new search"))
|
||||||
|
}
|
||||||
|
return components.ContentCard(title, body.String(), cw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force single-pane detail mode in compact layouts.
|
||||||
|
if cw < compactWidth {
|
||||||
|
return a.renderSessionDetail(filtered, cw, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch ss.viewMode {
|
switch ss.viewMode {
|
||||||
@@ -47,36 +138,53 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
|
|||||||
t := theme.Active
|
t := theme.Active
|
||||||
ss := a.sessState
|
ss := a.sessState
|
||||||
|
|
||||||
if ss.cursor >= len(sessions) {
|
// Clamp cursor to valid range
|
||||||
|
cursor := ss.cursor
|
||||||
|
if cursor >= len(sessions) {
|
||||||
|
cursor = len(sessions) - 1
|
||||||
|
}
|
||||||
|
if cursor < 0 {
|
||||||
|
cursor = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sessions) == 0 {
|
||||||
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
|
||||||
|
|
||||||
// Left pane: condensed session list
|
// Left pane: condensed session list
|
||||||
leftInner := components.CardInnerWidth(leftW)
|
leftInner := components.CardInnerWidth(leftW)
|
||||||
|
|
||||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||||
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.SurfaceBright).Bold(true)
|
||||||
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface).Bold(true)
|
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
costStyle := lipgloss.NewStyle().Foreground(t.Green).Background(t.Surface)
|
||||||
|
|
||||||
var leftBody strings.Builder
|
var leftBody strings.Builder
|
||||||
visible := h - 6 // card border (2) + header row (2) + footer hint (2)
|
visible := h - sessListOverhead
|
||||||
if visible < 5 {
|
if visible < sessMinVisible {
|
||||||
visible = 5
|
visible = sessMinVisible
|
||||||
}
|
}
|
||||||
|
|
||||||
offset := ss.offset
|
offset := ss.offset
|
||||||
if ss.cursor < offset {
|
if cursor < offset {
|
||||||
offset = ss.cursor
|
offset = cursor
|
||||||
}
|
}
|
||||||
if ss.cursor >= offset+visible {
|
if cursor >= offset+visible {
|
||||||
offset = ss.cursor - visible + 1
|
offset = cursor - visible + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
end := offset + visible
|
end := offset + visible
|
||||||
@@ -91,27 +199,51 @@ 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 == cursor {
|
||||||
leftBody.WriteString(selectedStyle.Render(line))
|
// Selected row with bright background and accent marker
|
||||||
|
selectedCostStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.SurfaceBright).Bold(true)
|
||||||
|
marker := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.SurfaceBright).Render("▸ ")
|
||||||
|
leftBody.WriteString(marker + selectedStyle.Render(leftPart) +
|
||||||
|
lipgloss.NewStyle().Background(t.SurfaceBright).Render(strings.Repeat(" ", max(1, padN-2))) +
|
||||||
|
selectedCostStyle.Render(costStr) +
|
||||||
|
lipgloss.NewStyle().Background(t.SurfaceBright).Render(strings.Repeat(" ", max(0, leftInner-len(leftPart)-padN-len(costStr)))))
|
||||||
} else {
|
} else {
|
||||||
leftBody.WriteString(rowStyle.Render(line))
|
// Normal row
|
||||||
|
leftBody.WriteString(
|
||||||
|
lipgloss.NewStyle().Background(t.Surface).Render(" ") +
|
||||||
|
mutedStyle.Render(fmt.Sprintf("%-13s", startStr)) +
|
||||||
|
lipgloss.NewStyle().Background(t.Surface).Render(" ") +
|
||||||
|
rowStyle.Render(dur) +
|
||||||
|
lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", padN-2)) +
|
||||||
|
costStyle.Render(costStr))
|
||||||
}
|
}
|
||||||
leftBody.WriteString("\n")
|
leftBody.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
leftCard := components.ContentCard(fmt.Sprintf("Sessions [%dd]", a.days), leftBody.String(), leftW)
|
// Build title with search indicator
|
||||||
|
leftTitle := fmt.Sprintf("Sessions [%dd]", a.days)
|
||||||
|
if ss.searchQuery != "" {
|
||||||
|
leftTitle = fmt.Sprintf("Search: %q (%d)", ss.searchQuery, len(sessions))
|
||||||
|
}
|
||||||
|
leftCard := components.ContentCard(leftTitle, leftBody.String(), leftW)
|
||||||
|
|
||||||
// Right pane: full session detail
|
// Right pane: full session detail with scroll support
|
||||||
sel := sessions[ss.cursor]
|
sel := sessions[cursor]
|
||||||
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle)
|
rightBody := a.renderDetailBody(sel, rightW, mutedStyle)
|
||||||
|
|
||||||
titleStr := fmt.Sprintf("Session %s", shortID(sel.SessionID))
|
// Apply detail scroll offset
|
||||||
|
rightBody = a.applyDetailScroll(rightBody, h-sessDetailOverhead)
|
||||||
|
|
||||||
|
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})
|
||||||
@@ -121,37 +253,49 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin
|
|||||||
t := theme.Active
|
t := theme.Active
|
||||||
ss := a.sessState
|
ss := a.sessState
|
||||||
|
|
||||||
if ss.cursor >= len(sessions) {
|
// Clamp cursor to valid range
|
||||||
|
cursor := ss.cursor
|
||||||
|
if cursor >= len(sessions) {
|
||||||
|
cursor = len(sessions) - 1
|
||||||
|
}
|
||||||
|
if cursor < 0 || len(sessions) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
sel := sessions[ss.cursor]
|
sel := sessions[cursor]
|
||||||
|
|
||||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
|
||||||
|
|
||||||
body := a.renderDetailBody(sel, cw, headerStyle, mutedStyle)
|
body := a.renderDetailBody(sel, cw, mutedStyle)
|
||||||
|
body = a.applyDetailScroll(body, h-sessDetailOverhead)
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDetailBody generates the full detail content for a session.
|
// renderDetailBody generates the full detail content for a session.
|
||||||
// Used by both the split right pane and the full-screen detail view.
|
// Used by both the split right pane and the full-screen detail view.
|
||||||
func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedStyle lipgloss.Style) string {
|
func (a App) renderDetailBody(sel model.SessionStats, w int, mutedStyle lipgloss.Style) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
innerW := components.CardInnerWidth(w)
|
innerW := components.CardInnerWidth(w)
|
||||||
|
|
||||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
// Rich color palette for different data types
|
||||||
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
greenStyle := lipgloss.NewStyle().Foreground(t.Green)
|
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||||
|
tokenStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface)
|
||||||
|
costStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface)
|
||||||
|
savingsStyle := lipgloss.NewStyle().Foreground(t.Green).Background(t.Surface).Bold(true)
|
||||||
|
timeStyle := lipgloss.NewStyle().Foreground(t.Magenta).Background(t.Surface)
|
||||||
|
modelStyle := lipgloss.NewStyle().Foreground(t.BlueBright).Background(t.Surface)
|
||||||
|
accentStyle := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.Surface).Bold(true)
|
||||||
|
dimStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||||
|
|
||||||
var body strings.Builder
|
var body strings.Builder
|
||||||
body.WriteString(mutedStyle.Render(sel.Project))
|
body.WriteString(accentStyle.Render(sel.Project))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
|
body.WriteString(dimStyle.Render(strings.Repeat("─", innerW)))
|
||||||
body.WriteString("\n\n")
|
body.WriteString("\n\n")
|
||||||
|
|
||||||
// Duration line
|
// Duration line with colored values
|
||||||
if !sel.StartTime.IsZero() {
|
if !sel.StartTime.IsZero() {
|
||||||
durStr := cli.FormatDuration(sel.DurationSecs)
|
durStr := cli.FormatDuration(sel.DurationSecs)
|
||||||
timeStr := sel.StartTime.Local().Format("15:04:05")
|
timeStr := sel.StartTime.Local().Format("15:04:05")
|
||||||
@@ -159,27 +303,42 @@ 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",
|
body.WriteString(labelStyle.Render("Duration: "))
|
||||||
labelStyle.Render("Duration:"),
|
body.WriteString(timeStyle.Render(durStr))
|
||||||
valueStyle.Render(durStr),
|
body.WriteString(dimStyle.Render(" ("))
|
||||||
mutedStyle.Render(timeStr)))
|
body.WriteString(mutedStyle.Render(timeStr))
|
||||||
|
body.WriteString(dimStyle.Render(")"))
|
||||||
|
body.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
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",
|
body.WriteString(labelStyle.Render("Prompts: "))
|
||||||
labelStyle.Render("Prompts:"), valueStyle.Render(cli.FormatNumber(int64(sel.UserMessages))),
|
body.WriteString(valueStyle.Render(cli.FormatNumber(int64(sel.UserMessages))))
|
||||||
labelStyle.Render("API Calls:"), valueStyle.Render(cli.FormatNumber(int64(sel.APICalls))),
|
body.WriteString(dimStyle.Render(" "))
|
||||||
labelStyle.Render("Ratio:"), ratio))
|
body.WriteString(labelStyle.Render("API Calls: "))
|
||||||
|
body.WriteString(tokenStyle.Render(cli.FormatNumber(int64(sel.APICalls))))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(labelStyle.Render("Ratio: "))
|
||||||
|
body.WriteString(accentStyle.Render(fmt.Sprintf("%.1fx", ratio)))
|
||||||
|
body.WriteString("\n\n")
|
||||||
|
|
||||||
// Token breakdown table
|
// Token breakdown table with section header
|
||||||
body.WriteString(headerStyle.Render("TOKEN BREAKDOWN"))
|
sectionStyle := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.Surface).Bold(true)
|
||||||
|
tableHeaderStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface)
|
||||||
|
|
||||||
|
body.WriteString(sectionStyle.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(tableHeaderStyle.Render(fmt.Sprintf("%-*s", typeW, "Type")))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%*s", tokW, "Tokens")))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%*s", costW, "Cost")))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", 44)))
|
body.WriteString(dimStyle.Render(strings.Repeat("─", tableW)))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
|
|
||||||
// Calculate per-type costs (aggregate across models)
|
// Calculate per-type costs (aggregate across models)
|
||||||
@@ -191,14 +350,14 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
|
|||||||
savings := 0.0
|
savings := 0.0
|
||||||
|
|
||||||
for modelName, mu := range sel.Models {
|
for modelName, mu := range sel.Models {
|
||||||
p, ok := config.LookupPricing(modelName)
|
p, ok := config.LookupPricingAt(modelName, sel.StartTime)
|
||||||
if ok {
|
if ok {
|
||||||
inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1e6
|
inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1e6
|
||||||
outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1e6
|
outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1e6
|
||||||
cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1e6
|
cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1e6
|
||||||
cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1e6
|
cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1e6
|
||||||
cacheReadCost += float64(mu.CacheReadTokens) * p.CacheReadPerMTok / 1e6
|
cacheReadCost += float64(mu.CacheReadTokens) * p.CacheReadPerMTok / 1e6
|
||||||
savings += config.CalculateCacheSavings(modelName, mu.CacheReadTokens)
|
savings += config.CalculateCacheSavingsAt(modelName, sel.StartTime, mu.CacheReadTokens)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,32 +377,51 @@ 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(labelStyle.Render(fmt.Sprintf("%-*s", typeW, truncStr(r.typ, typeW))))
|
||||||
r.typ,
|
body.WriteString(dimStyle.Render(" "))
|
||||||
cli.FormatTokens(r.tokens),
|
body.WriteString(tokenStyle.Render(fmt.Sprintf("%*s", tokW, cli.FormatTokens(r.tokens))))
|
||||||
cli.FormatCost(r.cost))))
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(costStyle.Render(fmt.Sprintf("%*s", costW, cli.FormatCost(r.cost))))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", 44)))
|
body.WriteString(dimStyle.Render(strings.Repeat("─", tableW)))
|
||||||
|
body.WriteString("\n")
|
||||||
|
// Net Cost row - highlighted
|
||||||
|
body.WriteString(accentStyle.Render(fmt.Sprintf("%-*s", typeW, "Net Cost")))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(dimStyle.Render(fmt.Sprintf("%*s", tokW, "")))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(savingsStyle.Render(fmt.Sprintf("%*s", costW, cli.FormatCost(sel.EstimatedCost))))
|
||||||
|
body.WriteString("\n")
|
||||||
|
// Cache Savings row
|
||||||
|
body.WriteString(labelStyle.Render(fmt.Sprintf("%-*s", typeW, "Cache Savings")))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(dimStyle.Render(fmt.Sprintf("%*s", tokW, "")))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(savingsStyle.Render(fmt.Sprintf("%*s", costW, cli.FormatCost(savings))))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
body.WriteString(fmt.Sprintf("%-20s %12s %10s\n",
|
|
||||||
valueStyle.Render("Net Cost"),
|
|
||||||
"",
|
|
||||||
greenStyle.Render(cli.FormatCost(sel.EstimatedCost))))
|
|
||||||
body.WriteString(fmt.Sprintf("%-20s %12s %10s\n",
|
|
||||||
labelStyle.Render("Cache Savings"),
|
|
||||||
"",
|
|
||||||
greenStyle.Render(cli.FormatCost(savings))))
|
|
||||||
|
|
||||||
// Model breakdown
|
// Model breakdown with colored data
|
||||||
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(sectionStyle.Render("API CALLS BY MODEL"))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
body.WriteString(headerStyle.Render(fmt.Sprintf("%-14s %7s %10s %10s %8s", "Model", "Calls", "Input", "Output", "Cost")))
|
compactModelTable := innerW < 60
|
||||||
|
if compactModelTable {
|
||||||
|
modelW := innerW - 7 - 1 - 8
|
||||||
|
if modelW < 8 {
|
||||||
|
modelW = 8
|
||||||
|
}
|
||||||
|
body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%-*s %7s %8s", modelW, "Model", "Calls", "Cost")))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", 52)))
|
body.WriteString(dimStyle.Render(strings.Repeat("─", modelW+7+8+2)))
|
||||||
|
} else {
|
||||||
|
modelW := 14
|
||||||
|
body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", modelW, "Model", "Calls", "Input", "Output", "Cost")))
|
||||||
|
body.WriteString("\n")
|
||||||
|
body.WriteString(dimStyle.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,24 +433,99 @@ 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(modelStyle.Render(fmt.Sprintf("%-*s", modelW, truncStr(shortModel(modelName), modelW))))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(valueStyle.Render(fmt.Sprintf("%7s", cli.FormatNumber(int64(mu.APICalls)))))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(costStyle.Render(fmt.Sprintf("%8s", cli.FormatCost(mu.EstimatedCost))))
|
||||||
|
} else {
|
||||||
|
modelW := 14
|
||||||
|
body.WriteString(modelStyle.Render(fmt.Sprintf("%-*s", modelW, truncStr(shortModel(modelName), modelW))))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(valueStyle.Render(fmt.Sprintf("%7s", cli.FormatNumber(int64(mu.APICalls)))))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(tokenStyle.Render(fmt.Sprintf("%10s", cli.FormatTokens(mu.InputTokens))))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(tokenStyle.Render(fmt.Sprintf("%10s", cli.FormatTokens(mu.OutputTokens))))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(costStyle.Render(fmt.Sprintf("%8s", cli.FormatCost(mu.EstimatedCost))))
|
||||||
|
}
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if sel.IsSubagent {
|
if sel.IsSubagent {
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
body.WriteString(mutedStyle.Render("(subagent session)"))
|
body.WriteString(dimStyle.Render("(subagent session)"))
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subagent drill-down with colors
|
||||||
|
if subs := a.subagentMap[sel.SessionID]; len(subs) > 0 {
|
||||||
body.WriteString("\n")
|
body.WriteString("\n")
|
||||||
body.WriteString(mutedStyle.Render("[Enter] expand [j/k] navigate [q] quit"))
|
body.WriteString(sectionStyle.Render(fmt.Sprintf("SUBAGENTS (%d)", len(subs))))
|
||||||
|
body.WriteString("\n")
|
||||||
|
|
||||||
|
nameW := innerW - 8 - 10 - 2
|
||||||
|
if nameW < 10 {
|
||||||
|
nameW = 10
|
||||||
|
}
|
||||||
|
body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%-*s %8s %10s", nameW, "Agent", "Duration", "Cost")))
|
||||||
|
body.WriteString("\n")
|
||||||
|
body.WriteString(dimStyle.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(modelStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(agentName, nameW))))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(timeStyle.Render(fmt.Sprintf("%8s", cli.FormatDuration(sub.DurationSecs))))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(costStyle.Render(fmt.Sprintf("%10s", cli.FormatCost(sub.EstimatedCost))))
|
||||||
|
body.WriteString("\n")
|
||||||
|
totalSubCost += sub.EstimatedCost
|
||||||
|
totalSubDur += sub.DurationSecs
|
||||||
|
}
|
||||||
|
|
||||||
|
body.WriteString(dimStyle.Render(strings.Repeat("─", nameW+8+10+2)))
|
||||||
|
body.WriteString("\n")
|
||||||
|
body.WriteString(accentStyle.Render(fmt.Sprintf("%-*s", nameW, "Combined")))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(timeStyle.Render(fmt.Sprintf("%8s", cli.FormatDuration(totalSubDur))))
|
||||||
|
body.WriteString(dimStyle.Render(" "))
|
||||||
|
body.WriteString(savingsStyle.Render(fmt.Sprintf("%10s", cli.FormatCost(totalSubCost))))
|
||||||
|
body.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer hints with styled keys
|
||||||
|
body.WriteString("\n")
|
||||||
|
hintKeyStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface)
|
||||||
|
hintTextStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||||
|
if w < compactWidth {
|
||||||
|
body.WriteString(hintTextStyle.Render("[") + hintKeyStyle.Render("/") + hintTextStyle.Render("] search [") +
|
||||||
|
hintKeyStyle.Render("j/k") + hintTextStyle.Render("] navigate [") +
|
||||||
|
hintKeyStyle.Render("J/K") + hintTextStyle.Render("] scroll [") +
|
||||||
|
hintKeyStyle.Render("q") + hintTextStyle.Render("] quit"))
|
||||||
|
} else {
|
||||||
|
body.WriteString(hintTextStyle.Render("[") + hintKeyStyle.Render("/") + hintTextStyle.Render("] search [") +
|
||||||
|
hintKeyStyle.Render("Enter") + hintTextStyle.Render("] expand [") +
|
||||||
|
hintKeyStyle.Render("j/k") + hintTextStyle.Render("] navigate [") +
|
||||||
|
hintKeyStyle.Render("J/K/^d/^u") + hintTextStyle.Render("] scroll [") +
|
||||||
|
hintKeyStyle.Render("q") + hintTextStyle.Render("] quit"))
|
||||||
|
}
|
||||||
|
|
||||||
return body.String()
|
return body.String()
|
||||||
}
|
}
|
||||||
@@ -283,3 +536,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 < sessMinVisible {
|
||||||
|
visibleH = sessMinVisible
|
||||||
|
}
|
||||||
|
|
||||||
|
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).Background(theme.Active.Surface)
|
||||||
|
visible[len(visible)-1] = dimStyle.Render(fmt.Sprintf("... %d more", unseen))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(visible, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenTableLayout(innerW int) (typeW, tokenW, costW, tableW int) {
|
||||||
|
tokenW = 12
|
||||||
|
costW = 10
|
||||||
|
typeW = innerW - tokenW - costW - 2
|
||||||
|
if typeW < 8 {
|
||||||
|
tokenW = 8
|
||||||
|
costW = 8
|
||||||
|
typeW = innerW - tokenW - costW - 2
|
||||||
|
}
|
||||||
|
if typeW < 6 {
|
||||||
|
typeW = 6
|
||||||
|
}
|
||||||
|
tableW = typeW + tokenW + costW + 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"github.com/theirongolddev/cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"github.com/theirongolddev/cburn/internal/config"
|
||||||
"cburn/internal/tui/components"
|
"github.com/theirongolddev/cburn/internal/tui/components"
|
||||||
"cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -16,9 +18,12 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
settingsFieldAPIKey = iota
|
settingsFieldAPIKey = iota
|
||||||
|
settingsFieldSessionKey
|
||||||
settingsFieldTheme
|
settingsFieldTheme
|
||||||
settingsFieldDays
|
settingsFieldDays
|
||||||
settingsFieldBudget
|
settingsFieldBudget
|
||||||
|
settingsFieldAutoRefresh
|
||||||
|
settingsFieldRefreshInterval
|
||||||
settingsFieldCount // sentinel
|
settingsFieldCount // sentinel
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,7 +46,7 @@ func newSettingsInput() textinput.Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a App) settingsStartEdit() (tea.Model, tea.Cmd) {
|
func (a App) settingsStartEdit() (tea.Model, tea.Cmd) {
|
||||||
cfg, _ := config.Load()
|
cfg := loadConfigOrDefault()
|
||||||
a.settings.editing = true
|
a.settings.editing = true
|
||||||
a.settings.saved = false
|
a.settings.saved = false
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -97,12 +123,14 @@ func (a App) updateSettingsInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) settingsSave() {
|
func (a *App) settingsSave() {
|
||||||
cfg, _ := config.Load()
|
cfg := loadConfigOrDefault()
|
||||||
val := strings.TrimSpace(a.settings.input.Value())
|
val := strings.TrimSpace(a.settings.input.Value())
|
||||||
|
|
||||||
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)
|
||||||
@@ -139,13 +176,15 @@ func (a *App) settingsSave() {
|
|||||||
|
|
||||||
func (a App) renderSettingsTab(cw int) string {
|
func (a App) renderSettingsTab(cw int) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
cfg, _ := config.Load()
|
cfg := loadConfigOrDefault()
|
||||||
|
|
||||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||||
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface).Bold(true)
|
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.SurfaceBright).Bold(true)
|
||||||
accentStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
selectedLabelStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.SurfaceBright).Bold(true)
|
||||||
greenStyle := lipgloss.NewStyle().Foreground(t.Green)
|
accentStyle := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.Surface)
|
||||||
|
greenStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface)
|
||||||
|
markerStyle := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.SurfaceBright)
|
||||||
|
|
||||||
type field struct {
|
type field struct {
|
||||||
label string
|
label string
|
||||||
@@ -162,39 +201,75 @@ 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
|
||||||
for i, f := range fields {
|
for i, f := range fields {
|
||||||
// Show text input if currently editing this field
|
// Show text input if currently editing this field
|
||||||
if a.settings.editing && i == a.settings.cursor {
|
if a.settings.editing && i == a.settings.cursor {
|
||||||
formBody.WriteString(accentStyle.Render(fmt.Sprintf("> %-18s ", f.label)))
|
formBody.WriteString(markerStyle.Render("▸ "))
|
||||||
|
formBody.WriteString(accentStyle.Render(fmt.Sprintf("%-18s ", f.label)))
|
||||||
formBody.WriteString(a.settings.input.View())
|
formBody.WriteString(a.settings.input.View())
|
||||||
formBody.WriteString("\n")
|
formBody.WriteString("\n")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
line := fmt.Sprintf("%-20s %s", f.label+":", f.value)
|
|
||||||
if i == a.settings.cursor {
|
if i == a.settings.cursor {
|
||||||
formBody.WriteString(selectedStyle.Render(line))
|
// Selected row with marker and highlight
|
||||||
|
marker := markerStyle.Render("▸ ")
|
||||||
|
label := selectedLabelStyle.Render(fmt.Sprintf("%-18s ", f.label+":"))
|
||||||
|
value := selectedStyle.Render(f.value)
|
||||||
|
formBody.WriteString(marker)
|
||||||
|
formBody.WriteString(label)
|
||||||
|
formBody.WriteString(value)
|
||||||
|
// Use lipgloss.Width() for correct visual width calculation
|
||||||
|
usedWidth := lipgloss.Width(marker) + lipgloss.Width(label) + lipgloss.Width(value)
|
||||||
|
innerW := components.CardInnerWidth(cw)
|
||||||
|
padLen := innerW - usedWidth
|
||||||
|
if padLen > 0 {
|
||||||
|
formBody.WriteString(lipgloss.NewStyle().Background(t.SurfaceBright).Render(strings.Repeat(" ", padLen)))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
formBody.WriteString(labelStyle.Render(fmt.Sprintf("%-20s ", f.label+":")) + valueStyle.Render(f.value))
|
// Normal row
|
||||||
|
formBody.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(" "))
|
||||||
|
formBody.WriteString(labelStyle.Render(fmt.Sprintf("%-18s ", f.label+":")))
|
||||||
|
formBody.WriteString(valueStyle.Render(f.value))
|
||||||
}
|
}
|
||||||
formBody.WriteString("\n")
|
formBody.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.settings.saveErr != nil {
|
if a.settings.saveErr != nil {
|
||||||
warnStyle := lipgloss.NewStyle().Foreground(t.Orange)
|
warnStyle := lipgloss.NewStyle().Foreground(t.Orange).Background(t.Surface)
|
||||||
formBody.WriteString("\n")
|
formBody.WriteString("\n")
|
||||||
formBody.WriteString(warnStyle.Render(fmt.Sprintf("Save failed: %s", a.settings.saveErr)))
|
formBody.WriteString(warnStyle.Render(fmt.Sprintf("Save failed: %s", a.settings.saveErr)))
|
||||||
} else if a.settings.saved {
|
} else if a.settings.saved {
|
||||||
@@ -210,7 +285,7 @@ func (a App) renderSettingsTab(cw int) string {
|
|||||||
infoBody.WriteString(labelStyle.Render("Data directory: ") + valueStyle.Render(a.claudeDir) + "\n")
|
infoBody.WriteString(labelStyle.Render("Data directory: ") + valueStyle.Render(a.claudeDir) + "\n")
|
||||||
infoBody.WriteString(labelStyle.Render("Sessions loaded: ") + valueStyle.Render(cli.FormatNumber(int64(len(a.sessions)))) + "\n")
|
infoBody.WriteString(labelStyle.Render("Sessions loaded: ") + valueStyle.Render(cli.FormatNumber(int64(len(a.sessions)))) + "\n")
|
||||||
infoBody.WriteString(labelStyle.Render("Load time: ") + valueStyle.Render(fmt.Sprintf("%.1fs", a.loadTime.Seconds())) + "\n")
|
infoBody.WriteString(labelStyle.Render("Load time: ") + valueStyle.Render(fmt.Sprintf("%.1fs", a.loadTime.Seconds())) + "\n")
|
||||||
infoBody.WriteString(labelStyle.Render("Config file: ") + valueStyle.Render(config.ConfigPath()))
|
infoBody.WriteString(labelStyle.Render("Config file: ") + valueStyle.Render(config.Path()))
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(components.ContentCard("Settings", formBody.String(), cw))
|
b.WriteString(components.ContentCard("Settings", formBody.String(), cw))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package theme defines color themes for the cburn TUI dashboard.
|
||||||
package theme
|
package theme
|
||||||
|
|
||||||
import "github.com/charmbracelet/lipgloss"
|
import "github.com/charmbracelet/lipgloss"
|
||||||
@@ -5,99 +6,139 @@ import "github.com/charmbracelet/lipgloss"
|
|||||||
// Theme defines the color roles used throughout the TUI.
|
// Theme defines the color roles used throughout the TUI.
|
||||||
type Theme struct {
|
type Theme struct {
|
||||||
Name string
|
Name string
|
||||||
Background lipgloss.Color
|
Background lipgloss.Color // Main app background
|
||||||
Surface lipgloss.Color
|
Surface lipgloss.Color // Card/panel backgrounds
|
||||||
Border lipgloss.Color
|
SurfaceHover lipgloss.Color // Highlighted surface (active tab, selected row)
|
||||||
BorderHover lipgloss.Color
|
SurfaceBright lipgloss.Color // Extra bright surface for emphasis
|
||||||
TextDim lipgloss.Color
|
Border lipgloss.Color // Subtle borders
|
||||||
TextMuted lipgloss.Color
|
BorderBright lipgloss.Color // Prominent borders (cards, focus)
|
||||||
TextPrimary lipgloss.Color
|
BorderAccent lipgloss.Color // Accent-colored borders for focus states
|
||||||
Accent lipgloss.Color
|
TextDim lipgloss.Color // Lowest contrast text (hints, disabled)
|
||||||
|
TextMuted lipgloss.Color // Secondary text (labels, metadata)
|
||||||
|
TextPrimary lipgloss.Color // Primary content text
|
||||||
|
Accent lipgloss.Color // Primary accent (links, active states)
|
||||||
|
AccentBright lipgloss.Color // Brighter accent for emphasis
|
||||||
|
AccentDim lipgloss.Color // Dimmed accent for backgrounds
|
||||||
Green lipgloss.Color
|
Green lipgloss.Color
|
||||||
|
GreenBright lipgloss.Color
|
||||||
Orange lipgloss.Color
|
Orange lipgloss.Color
|
||||||
Red lipgloss.Color
|
Red lipgloss.Color
|
||||||
Blue lipgloss.Color
|
Blue lipgloss.Color
|
||||||
Purple lipgloss.Color
|
BlueBright lipgloss.Color
|
||||||
Yellow lipgloss.Color
|
Yellow lipgloss.Color
|
||||||
|
Magenta lipgloss.Color
|
||||||
|
Cyan lipgloss.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active is the currently selected theme.
|
// Active is the currently selected theme.
|
||||||
var Active = FlexokiDark
|
var Active = FlexokiDark
|
||||||
|
|
||||||
// FlexokiDark is the default theme.
|
// FlexokiDark is the default theme - warm, paper-inspired dark theme.
|
||||||
var FlexokiDark = Theme{
|
var FlexokiDark = Theme{
|
||||||
Name: "flexoki-dark",
|
Name: "flexoki-dark",
|
||||||
Background: lipgloss.Color("#100F0F"),
|
Background: lipgloss.Color("#100F0F"),
|
||||||
Surface: lipgloss.Color("#1C1B1A"),
|
Surface: lipgloss.Color("#1C1B1A"),
|
||||||
Border: lipgloss.Color("#282726"),
|
SurfaceHover: lipgloss.Color("#282726"),
|
||||||
BorderHover: lipgloss.Color("#343331"),
|
SurfaceBright: lipgloss.Color("#343331"),
|
||||||
|
Border: lipgloss.Color("#403E3C"),
|
||||||
|
BorderBright: lipgloss.Color("#575653"),
|
||||||
|
BorderAccent: lipgloss.Color("#3AA99F"),
|
||||||
TextDim: lipgloss.Color("#575653"),
|
TextDim: lipgloss.Color("#575653"),
|
||||||
TextMuted: lipgloss.Color("#6F6E69"),
|
TextMuted: lipgloss.Color("#878580"),
|
||||||
TextPrimary: lipgloss.Color("#FFFCF0"),
|
TextPrimary: lipgloss.Color("#FFFCF0"),
|
||||||
Accent: lipgloss.Color("#3AA99F"),
|
Accent: lipgloss.Color("#3AA99F"),
|
||||||
|
AccentBright: lipgloss.Color("#5BC8BE"),
|
||||||
|
AccentDim: lipgloss.Color("#1A3533"),
|
||||||
Green: lipgloss.Color("#879A39"),
|
Green: lipgloss.Color("#879A39"),
|
||||||
|
GreenBright: lipgloss.Color("#A3B859"),
|
||||||
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"),
|
BlueBright: lipgloss.Color("#6BA3D6"),
|
||||||
Yellow: lipgloss.Color("#D0A215"),
|
Yellow: lipgloss.Color("#D0A215"),
|
||||||
|
Magenta: lipgloss.Color("#CE5D97"),
|
||||||
|
Cyan: lipgloss.Color("#24837B"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// CatppuccinMocha is a warm pastel theme.
|
// CatppuccinMocha is a warm pastel theme with soft, soothing colors.
|
||||||
var CatppuccinMocha = Theme{
|
var CatppuccinMocha = Theme{
|
||||||
Name: "catppuccin-mocha",
|
Name: "catppuccin-mocha",
|
||||||
Background: lipgloss.Color("#1E1E2E"),
|
Background: lipgloss.Color("#1E1E2E"),
|
||||||
Surface: lipgloss.Color("#313244"),
|
Surface: lipgloss.Color("#313244"),
|
||||||
Border: lipgloss.Color("#45475A"),
|
SurfaceHover: lipgloss.Color("#45475A"),
|
||||||
BorderHover: lipgloss.Color("#585B70"),
|
SurfaceBright: lipgloss.Color("#585B70"),
|
||||||
|
Border: lipgloss.Color("#585B70"),
|
||||||
|
BorderBright: lipgloss.Color("#7F849C"),
|
||||||
|
BorderAccent: lipgloss.Color("#89B4FA"),
|
||||||
TextDim: lipgloss.Color("#6C7086"),
|
TextDim: lipgloss.Color("#6C7086"),
|
||||||
TextMuted: lipgloss.Color("#A6ADC8"),
|
TextMuted: lipgloss.Color("#A6ADC8"),
|
||||||
TextPrimary: lipgloss.Color("#CDD6F4"),
|
TextPrimary: lipgloss.Color("#CDD6F4"),
|
||||||
Accent: lipgloss.Color("#89B4FA"),
|
Accent: lipgloss.Color("#89B4FA"),
|
||||||
|
AccentBright: lipgloss.Color("#B4D0FB"),
|
||||||
|
AccentDim: lipgloss.Color("#293147"),
|
||||||
Green: lipgloss.Color("#A6E3A1"),
|
Green: lipgloss.Color("#A6E3A1"),
|
||||||
|
GreenBright: lipgloss.Color("#C6F6C1"),
|
||||||
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"),
|
BlueBright: lipgloss.Color("#B4D0FB"),
|
||||||
Yellow: lipgloss.Color("#F9E2AF"),
|
Yellow: lipgloss.Color("#F9E2AF"),
|
||||||
|
Magenta: lipgloss.Color("#F5C2E7"),
|
||||||
|
Cyan: lipgloss.Color("#94E2D5"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokyoNight is a cool blue/purple theme.
|
// TokyoNight is a cool blue/purple theme inspired by Tokyo city lights.
|
||||||
var TokyoNight = Theme{
|
var TokyoNight = Theme{
|
||||||
Name: "tokyo-night",
|
Name: "tokyo-night",
|
||||||
Background: lipgloss.Color("#1A1B26"),
|
Background: lipgloss.Color("#1A1B26"),
|
||||||
Surface: lipgloss.Color("#24283B"),
|
Surface: lipgloss.Color("#24283B"),
|
||||||
Border: lipgloss.Color("#414868"),
|
SurfaceHover: lipgloss.Color("#343A52"),
|
||||||
BorderHover: lipgloss.Color("#565F89"),
|
SurfaceBright: lipgloss.Color("#414868"),
|
||||||
|
Border: lipgloss.Color("#565F89"),
|
||||||
|
BorderBright: lipgloss.Color("#7982A9"),
|
||||||
|
BorderAccent: lipgloss.Color("#7AA2F7"),
|
||||||
TextDim: lipgloss.Color("#565F89"),
|
TextDim: lipgloss.Color("#565F89"),
|
||||||
TextMuted: lipgloss.Color("#A9B1D6"),
|
TextMuted: lipgloss.Color("#A9B1D6"),
|
||||||
TextPrimary: lipgloss.Color("#C0CAF5"),
|
TextPrimary: lipgloss.Color("#C0CAF5"),
|
||||||
Accent: lipgloss.Color("#7AA2F7"),
|
Accent: lipgloss.Color("#7AA2F7"),
|
||||||
|
AccentBright: lipgloss.Color("#A9C1FF"),
|
||||||
|
AccentDim: lipgloss.Color("#252B3F"),
|
||||||
Green: lipgloss.Color("#9ECE6A"),
|
Green: lipgloss.Color("#9ECE6A"),
|
||||||
|
GreenBright: lipgloss.Color("#B9E87A"),
|
||||||
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"),
|
BlueBright: lipgloss.Color("#A9C1FF"),
|
||||||
Yellow: lipgloss.Color("#E0AF68"),
|
Yellow: lipgloss.Color("#E0AF68"),
|
||||||
|
Magenta: lipgloss.Color("#BB9AF7"),
|
||||||
|
Cyan: lipgloss.Color("#7DCFFF"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal uses ANSI 16 colors only.
|
// Terminal uses ANSI 16 colors only - maximum compatibility.
|
||||||
var Terminal = Theme{
|
var Terminal = Theme{
|
||||||
Name: "terminal",
|
Name: "terminal",
|
||||||
Background: lipgloss.Color("0"),
|
Background: lipgloss.Color("0"),
|
||||||
Surface: lipgloss.Color("0"),
|
Surface: lipgloss.Color("0"),
|
||||||
|
SurfaceHover: lipgloss.Color("8"),
|
||||||
|
SurfaceBright: lipgloss.Color("8"),
|
||||||
Border: lipgloss.Color("8"),
|
Border: lipgloss.Color("8"),
|
||||||
BorderHover: lipgloss.Color("7"),
|
BorderBright: lipgloss.Color("7"),
|
||||||
|
BorderAccent: lipgloss.Color("6"),
|
||||||
TextDim: lipgloss.Color("8"),
|
TextDim: lipgloss.Color("8"),
|
||||||
TextMuted: lipgloss.Color("7"),
|
TextMuted: lipgloss.Color("7"),
|
||||||
TextPrimary: lipgloss.Color("15"),
|
TextPrimary: lipgloss.Color("15"),
|
||||||
Accent: lipgloss.Color("6"),
|
Accent: lipgloss.Color("6"),
|
||||||
|
AccentBright: lipgloss.Color("14"),
|
||||||
|
AccentDim: lipgloss.Color("0"),
|
||||||
Green: lipgloss.Color("2"),
|
Green: lipgloss.Color("2"),
|
||||||
|
GreenBright: lipgloss.Color("10"),
|
||||||
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"),
|
BlueBright: lipgloss.Color("12"),
|
||||||
Yellow: lipgloss.Color("3"),
|
Yellow: lipgloss.Color("3"),
|
||||||
|
Magenta: lipgloss.Color("5"),
|
||||||
|
Cyan: lipgloss.Color("6"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// All available themes.
|
// All available themes.
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -1,7 +1,7 @@
|
|||||||
// cburn analyzes Claude Code usage from local JSONL session logs.
|
// cburn analyzes Claude Code usage from local JSONL session logs.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "cburn/cmd"
|
import "github.com/theirongolddev/cburn/cmd"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cmd.Execute()
|
cmd.Execute()
|
||||||
|
|||||||
Reference in New Issue
Block a user