Compare commits

..

25 Commits

Author SHA1 Message Date
teernisse
302f34ff85 test(tui): add mouse navigation and card rendering tests
app_mouse_test.go:
- TestTabAtXMatchesTabWidths: Verifies that tabAtX() correctly maps
  X coordinates to tab indices for all active tab states
- Uses tabWidthForTest() helper that mirrors TabVisualWidth() logic
- Catches regressions where tab hit detection drifts from rendering

card_fix_test.go:
- TestCardRowBackgroundFill: Verifies that CardRow() properly equalizes
  card heights and fills backgrounds on shorter cards
- Forces TrueColor profile in init() to ensure ANSI codes generate
- Validates that padding lines contain ANSI escape sequences,
  confirming background styling isn't stripped

These tests guard against visual regressions that are hard to catch
in manual testing, particularly the subtle issue where short cards
in a row would have "punched out" gaps where their backgrounds
didn't extend to match taller neighbors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:06:06 -05:00
teernisse
0416d029b1 refactor(tui): improve config resilience and scroll navigation
app.go changes:
- Add loadConfigOrDefault() helper that returns sensible defaults when
  config loading fails, ensuring TUI can always start even with
  corrupted config files
- Extract scroll navigation constants (scrollOverhead, minHalfPageScroll,
  minContentHeight) for clarity and consistency
- Apply accent border styling to loading card for visual polish
- Replace inline config.Load() calls with loadConfigOrDefault()

setup.go changes:
- Use loadConfigOrDefault() for consistent error handling during
  setup wizard initialization and config persistence

The loadConfigOrDefault pattern improves user experience by gracefully
degrading rather than failing hard when config issues occur. Users can
still access the TUI and reconfigure via the Settings tab.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:05:57 -05:00
teernisse
901090f921 feat(tui): apply visual polish to dashboard tabs
Update all tab renderers to leverage the expanded theme palette and
polished components:

tab_overview.go:
- Use PanelCard (accent-bordered variant) for daily token chart
- Multi-color model bars: BlueBright, Cyan, Magenta, Yellow, Green
  for visual distinction between models
- Pre-compute styles outside loops for better performance
- Use Cyan for today's hourly chart, Magenta for last-hour chart

tab_breakdown.go:
- Apply consistent background styling
- Use new accent variants for visual hierarchy

tab_costs.go:
- Proper background fill on cost tables
- Accent coloring for cost highlights

tab_sessions.go:
- Background continuity in session list
- Visual improvements to session detail view

tab_settings.go:
- Consistent styling with other tabs

The result is a dashboard where each tab feels visually cohesive,
with color providing semantic meaning (different colors for different
models, metrics, and states) rather than arbitrary decoration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:05:49 -05:00
teernisse
c15dc8b487 feat(tui): polish components with icons, gradients, and proper backgrounds
Comprehensive visual refresh of all TUI components:

card.go:
- Add semantic icons based on metric type (tokens=◈, sessions=◉,
  cost=◆, cache=◇)
- Color-code metric values using new theme colors (Cyan, Magenta,
  Green, Blue) for visual variety
- Add CardRow() helper that properly height-equalizes cards and
  fills background on shorter cards to prevent "punched out" gaps
- Set explicit background on all style components

chart.go:
- Add background styling to sparkline renderer
- Ensure bar chart respects theme.Active.Surface background

progress.go:
- Add color gradient based on progress (Cyan→Accent→AccentBright)
- Style percentage text with bold and matching color
- Fix background fill on empty bar segments

statusbar.go:
- Complete redesign with SurfaceHover background
- Style keyboard hints: dim brackets, bright keys, muted labels
- Proper spacing and background continuity across sections
- Styled refresh indicator with AccentBright

tabbar.go:
- Add TabVisualWidth() for accurate mouse hit detection
- Modern underline-style active indicator using ━ characters
- AccentBright for active tab, proper dim styling for inactive
- Consistent Surface background across all tab elements

These changes create a cohesive visual language where every element
respects the dark background, icons add visual interest without
clutter, and color coding provides semantic meaning.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:05:39 -05:00
teernisse
19b8bab5d8 fix(tui): force TrueColor profile for consistent background rendering
Set lipgloss.SetColorProfile(termenv.TrueColor) before launching the
Bubble Tea program to ensure ANSI escape codes are always generated
for background styling.

Without this fix, lipgloss may default to the Ascii profile on some
terminals, causing all background colors to be stripped. This manifests
as cards with missing backgrounds and inconsistent visual appearance
where borders render but fills don't.

This is particularly important for the visual polish work where
components like cards, status bars, and tab bars rely heavily on
background colors to create depth and visual hierarchy.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:05:26 -05:00
teernisse
e1e322f4c9 feat(theme): expand color palette for richer UI semantics
Add new theme colors across all four color schemes (Flexoki Dark,
Catppuccin Mocha, Tokyo Night, Terminal) to enable more nuanced
visual states:

Surface variants:
- SurfaceHover: Highlighted surface for active tabs, selected rows
- SurfaceBright: Extra emphasis for focused elements

Border variants:
- BorderBright: Prominent borders for cards and focus states
- BorderAccent: Accent-colored borders for active/selected elements

Accent variants:
- AccentBright: Higher contrast accent for emphasis
- AccentDim: Muted accent for subtle backgrounds

New semantic colors:
- Magenta: Session-related metrics
- Cyan: Token-related metrics

These additions provide the foundation for visual polish work across
components and tabs, enabling icon coloring, gradient effects, and
proper background fill without relying solely on the base accent color.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:05:20 -05:00
teernisse
0e80a6c1d1 docs(readme): document daemon mode and HTTP/SSE API
Update README with daemon usage:
- Add daemon to quick start examples
- Add daemon row to CLI commands table
- Document all HTTP endpoints with usage examples
- Add internal/daemon to package map

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:05:11 -05:00
teernisse
8c1beb7a8a feat(daemon): add background usage monitor with HTTP/SSE API
Implement a long-running daemon service that continuously polls Claude
Code session logs and exposes usage data via local HTTP endpoints.

Architecture:
- internal/daemon/service.go: Core Service struct managing poll loop,
  snapshot computation, event buffering, and HTTP handlers
- cmd/daemon.go: Cobra commands for start/status/stop with detach mode

HTTP Endpoints (default 127.0.0.1:8787):
- GET /healthz         - Liveness probe for orchestration
- GET /v1/status       - Current aggregate snapshot + daemon runtime info
- GET /v1/events       - Recent event buffer as JSON array
- GET /v1/stream       - Server-Sent Events for real-time updates

Snapshot model captures:
- Session/prompt/API call counts
- Token totals and estimated cost
- Cache hit rate
- Rolling daily averages (cost/day, tokens/day, sessions/day)

Delta detection emits events only when usage actually changes, keeping
the event stream lean for downstream consumers.

Detach mode (-d, --detach):
- Forks a child process with stdout/stderr redirected to log file
- Writes PID file for process management
- Parent exits after confirming child is running

This daemon serves as the foundation for planned capabilities like
incident replay, runaway detection, and session classification.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:05:06 -05:00
teernisse
7157886546 docs: add product roadmap and deferred ideas tracker
Add two planning documents:

- BACKBURNER.md: Tracks deferred ideas including the observation that
  subscription vs API pricing semantics should be modeled separately.
  This acknowledges that Admin Cost API integration is valuable but
  shouldn't be the canonical source for subscription usage analysis.

- CEO_PITCH_DECKS.md: Detailed product pitch for four daemon-enabled
  capabilities: Cognitive Flight Recorder (incident replay), Runaway
  Loop Quencher (active cost containment), Session Archeology Engine
  (behavioral pattern classification), and Latent Tool ROI Scanner
  (tool-level efficiency analysis). Each includes utility analysis,
  feasibility assessment, implementation roadmap, and kill criteria.

These documents capture strategic direction for the daemon infrastructure
being built in parallel.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:04:55 -05:00
teernisse
3668ae7f70 docs: improve installation guide with PATH setup
README changes:
- Add go install github.com/...@latest as primary install method
- Add git clone instructions as alternative
- Add PATH setup instructions for bash/zsh and fish shells
- Fix cache database filename (sessions.db -> metrics_v2.db)

CLAUDE.md:
- Fix cache database filename to match actual implementation

New users on fresh Go installations often miss that ~/go/bin needs
to be in PATH. This was causing "command not found" errors after
successful installation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:09:37 -05:00
teernisse
083e7d40ce refactor!: rename module to github.com/theirongolddev/cburn
Change module path from 'cburn' to 'github.com/theirongolddev/cburn'
to enable standard Go remote installation:

  go install github.com/theirongolddev/cburn@latest

This is a BREAKING CHANGE for any external code importing this module
(though as a CLI tool, this is unlikely to affect anyone).

All internal imports updated to use the new module path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:09:26 -05:00
teernisse
16cc4d4737 feat: add mouse navigation and session search to TUI
Mouse support:
- Wheel up/down scrolls session list in Sessions tab
- Left click on tab bar switches tabs
- Works alongside existing keyboard navigation

Session search:
- Press '/' to enter search mode with live preview
- Filters sessions by project name substring matching
- Shows match count as you type
- Enter to apply filter, Esc to cancel
- Search indicator shown in card title when active
- Esc clears active search filter

Cost integration:
- Use centralized AggregateCostBreakdown for model costs
- Consistent cost calculations between Overview and Costs tabs

Also fixes cursor clamping to prevent out-of-bounds access when
search results change the filtered session count.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:09:13 -05:00
teernisse
9bb0fd6b73 feat: centralize cost breakdown calculations in pipeline
Extract token-type and per-model cost calculations from cmd/costs.go
into a dedicated pipeline.AggregateCostBreakdown() function. This
eliminates duplicate cost calculation logic between CLI and TUI.

New types:
- TokenTypeCosts: aggregate costs by input/output/cache types
- ModelCostBreakdown: per-model cost components

Benefits:
- Single source of truth for cost calculations
- Uses LookupPricingAt() for historical accuracy
- Both CLI and TUI now share the same cost logic
- Easier to maintain and extend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:09:01 -05:00
teernisse
baa88efe75 feat: add time-based pricing lookup for historical accuracy
Introduces LookupPricingAt() to resolve model pricing at a specific
timestamp instead of always using current prices. This is important
for accurate cost calculations when analyzing historical sessions
where pricing may have changed.

Changes:
- Add modelPricingVersion struct with EffectiveFrom timestamp
- Add defaultPricingHistory map for versioned pricing entries
- Update LookupPricing to delegate to LookupPricingAt(model, time.Now())
- Add comprehensive tests for time-windowed pricing lookup

The infrastructure supports future pricing changes by adding entries
to defaultPricingHistory with their effective dates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:08:55 -05:00
teernisse
a386d95959 build: use portable Go path detection in Makefile
Replace hardcoded /usr/local/go/bin/go with dynamic PATH lookup.
The previous hardcoded path failed on any system where Go is
installed elsewhere (e.g., /usr/bin/go, ~/go/bin/go).

Uses conditional assignment (GO ?=) so users can still override
with GO=/custom/path make build when needed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 10:08:46 -05:00
teernisse
74c9905dbf docs: add README with CLI reference, TUI guide, and architecture overview
Comprehensive project README covering installation, quick start,
full CLI command table with global flags and examples, TUI dashboard
keybindings and tab descriptions, theme options, configuration format
(TOML + env vars), session key setup instructions, caching behavior,
development commands (make targets), and package architecture diagram.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

10
BACKBURNER.md Normal file
View 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
View 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.

View File

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

View File

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

View File

@@ -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 != "" {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

234
internal/claudeai/client.go Normal file
View File

@@ -0,0 +1,234 @@
// Package claudeai provides a client for fetching subscription and usage data from claude.ai.
package claudeai
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const (
baseURL = "https://claude.ai/api"
requestTimeout = 10 * time.Second
maxBodySize = 1 << 20 // 1 MB
keyPrefix = "sk-ant-sid"
)
var (
// ErrUnauthorized indicates the session key is expired or invalid.
ErrUnauthorized = errors.New("claudeai: unauthorized (session key expired or invalid)")
// ErrRateLimited indicates the API rate limit was hit.
ErrRateLimited = errors.New("claudeai: rate limited")
)
// Client fetches subscription data from the claude.ai web API.
type Client struct {
sessionKey string
http *http.Client
}
// NewClient creates a client for the given session key.
// Returns nil if the key is empty or has the wrong prefix.
func NewClient(sessionKey string) *Client {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
return nil
}
if !strings.HasPrefix(sessionKey, keyPrefix) {
return nil
}
return &Client{
sessionKey: sessionKey,
http: &http.Client{},
}
}
// FetchAll fetches orgs, usage, and overage for the first organization.
// Partial data is returned even if some requests fail.
func (c *Client) FetchAll(ctx context.Context) *SubscriptionData {
result := &SubscriptionData{FetchedAt: time.Now()}
orgs, err := c.FetchOrganizations(ctx)
if err != nil {
result.Error = err
return result
}
if len(orgs) == 0 {
result.Error = errors.New("claudeai: no organizations found")
return result
}
result.Org = orgs[0]
orgID := orgs[0].UUID
// Fetch usage and overage independently — partial results are fine
usage, usageErr := c.FetchUsage(ctx, orgID)
if usageErr == nil {
result.Usage = usage
}
overage, overageErr := c.FetchOverageLimit(ctx, orgID)
if overageErr == nil {
result.Overage = overage
}
// Surface first non-nil error for status display
if usageErr != nil {
result.Error = usageErr
} else if overageErr != nil {
result.Error = overageErr
}
return result
}
// FetchOrganizations returns the list of organizations for this session.
func (c *Client) FetchOrganizations(ctx context.Context) ([]Organization, error) {
body, err := c.get(ctx, "/organizations")
if err != nil {
return nil, err
}
var orgs []Organization
if err := json.Unmarshal(body, &orgs); err != nil {
return nil, fmt.Errorf("claudeai: parsing organizations: %w", err)
}
return orgs, nil
}
// FetchUsage returns parsed usage windows for the given organization.
func (c *Client) FetchUsage(ctx context.Context, orgID string) (*ParsedUsage, error) {
body, err := c.get(ctx, fmt.Sprintf("/organizations/%s/usage", orgID))
if err != nil {
return nil, err
}
var raw UsageResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("claudeai: parsing usage: %w", err)
}
return &ParsedUsage{
FiveHour: parseWindow(raw.FiveHour),
SevenDay: parseWindow(raw.SevenDay),
SevenDayOpus: parseWindow(raw.SevenDayOpus),
SevenDaySonnet: parseWindow(raw.SevenDaySonnet),
}, nil
}
// FetchOverageLimit returns overage spend limit data for the given organization.
func (c *Client) FetchOverageLimit(ctx context.Context, orgID string) (*OverageLimit, error) {
body, err := c.get(ctx, fmt.Sprintf("/organizations/%s/overage_spend_limit", orgID))
if err != nil {
return nil, err
}
var ol OverageLimit
if err := json.Unmarshal(body, &ol); err != nil {
return nil, fmt.Errorf("claudeai: parsing overage limit: %w", err)
}
return &ol, nil
}
// get performs an authenticated GET request and returns the response body.
func (c *Client) get(ctx context.Context, path string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil)
if err != nil {
return nil, fmt.Errorf("claudeai: creating request: %w", err)
}
req.Header.Set("Cookie", "sessionKey="+c.sessionKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "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
}

View File

@@ -0,0 +1,59 @@
package claudeai
import (
"encoding/json"
"time"
)
// Organization represents a claude.ai organization.
type Organization struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Capabilities []string `json:"capabilities"`
}
// UsageResponse is the raw API response from the usage endpoint.
type UsageResponse struct {
FiveHour *UsageWindow `json:"five_hour"`
SevenDay *UsageWindow `json:"seven_day"`
SevenDayOpus *UsageWindow `json:"seven_day_opus"`
SevenDaySonnet *UsageWindow `json:"seven_day_sonnet"`
}
// UsageWindow is a single rate-limit window from the API.
// Utilization can be int, float, or string — kept as raw JSON for defensive parsing.
type UsageWindow struct {
Utilization json.RawMessage `json:"utilization"`
ResetsAt *string `json:"resets_at"`
}
// OverageLimit is the raw API response from the overage spend limit endpoint.
type OverageLimit struct {
IsEnabled bool `json:"isEnabled"`
UsedCredits float64 `json:"usedCredits"`
MonthlyCreditLimit float64 `json:"monthlyCreditLimit"`
Currency string `json:"currency"`
}
// SubscriptionData is the parsed, TUI-ready aggregate of all claude.ai API data.
type SubscriptionData struct {
Org Organization
Usage *ParsedUsage
Overage *OverageLimit
FetchedAt time.Time
Error error
}
// ParsedUsage holds normalized usage windows.
type ParsedUsage struct {
FiveHour *ParsedWindow
SevenDay *ParsedWindow
SevenDayOpus *ParsedWindow
SevenDaySonnet *ParsedWindow
}
// ParsedWindow is a single rate-limit window, normalized for display.
type ParsedWindow struct {
Pct float64 // 0.0-1.0
ResetsAt time.Time
}

View File

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

View File

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

View File

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

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

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

View File

@@ -92,3 +92,9 @@ type PeriodComparison struct {
Current SummaryStats Current SummaryStats
Previous SummaryStats Previous SummaryStats
} }
// MinuteStats holds token counts for a 5-minute bucket.
type MinuteStats struct {
Minute int // 0-11 (bucket index within the hour)
Tokens int64
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// Package theme defines color themes for the cburn TUI dashboard.
package theme package theme
import "github.com/charmbracelet/lipgloss" import "github.com/charmbracelet/lipgloss"
@@ -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.

View File

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