Compare commits

...

9 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
22 changed files with 2262 additions and 508 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

@@ -35,6 +35,7 @@ cburn # Summary of usage metrics
cburn tui # Interactive dashboard cburn tui # Interactive dashboard
cburn costs # Cost breakdown by token type cburn costs # Cost breakdown by token type
cburn status # Claude.ai subscription status cburn status # Claude.ai subscription status
cburn daemon --detach # Background usage daemon + local API
``` ```
## CLI Commands ## CLI Commands
@@ -50,6 +51,7 @@ cburn status # Claude.ai subscription status
| `cburn models` | Model usage breakdown | | `cburn models` | Model usage breakdown |
| `cburn projects` | Project usage ranking | | `cburn projects` | Project usage ranking |
| `cburn status` | Claude.ai subscription status and rate limits | | `cburn status` | Claude.ai subscription status and rate limits |
| `cburn daemon` | Background daemon with JSON/SSE usage API |
| `cburn config` | Show current configuration | | `cburn config` | Show current configuration |
| `cburn setup` | Interactive first-time setup wizard | | `cburn setup` | Interactive first-time setup wizard |
| `cburn tui` | Interactive dashboard | | `cburn tui` | Interactive dashboard |
@@ -73,6 +75,27 @@ cburn -n 7 # Last 7 days
cburn costs -p myproject # Costs for a specific project cburn costs -p myproject # Costs for a specific project
cburn sessions -m opus # Sessions using Opus models cburn sessions -m opus # Sessions using Opus models
cburn daily --no-subagents # Exclude spawned agents 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 ## TUI Dashboard
@@ -194,6 +217,7 @@ make clean # Remove binary and test cache
| `internal/store` | SQLite cache layer | | `internal/store` | SQLite cache layer |
| `internal/model` | Domain types | | `internal/model` | Domain types |
| `internal/config` | TOML config and pricing tables | | `internal/config` | TOML config and pricing tables |
| `internal/daemon` | Background polling daemon + local HTTP/SSE API |
| `internal/cli` | Terminal formatting | | `internal/cli` | Terminal formatting |
| `internal/claudeai` | Claude.ai API client | | `internal/claudeai` | Claude.ai API client |
| `internal/tui` | Bubble Tea dashboard | | `internal/tui` | Bubble Tea dashboard |

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

@@ -8,6 +8,8 @@ import (
"github.com/theirongolddev/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())

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

@@ -118,18 +118,38 @@ const (
minTerminalWidth = 80 minTerminalWidth = 80
compactWidth = 120 compactWidth = 120
maxContentWidth = 180 maxContentWidth = 180
// Scroll navigation
scrollOverhead = 10 // approximate header + status bar height for half-page calc
minHalfPageScroll = 1 // minimum lines for half-page scroll
minContentHeight = 5 // minimum content area height
) )
// loadConfigOrDefault loads config, returning defaults on error.
// This ensures the TUI can always start even if config is corrupted.
func loadConfigOrDefault() config.Config {
cfg, err := config.Load()
if err != nil {
// Return zero-value config with sensible defaults applied
return config.Config{
TUI: config.TUIConfig{
RefreshIntervalSec: 30,
},
}
}
return cfg
}
// NewApp creates a new TUI app model. // NewApp creates a new TUI app model.
func NewApp(claudeDir string, days int, project, modelFilter string, includeSubagents bool) App { func NewApp(claudeDir string, days int, project, modelFilter string, includeSubagents bool) App {
needSetup := !config.Exists() needSetup := !config.Exists()
sp := spinner.New() sp := spinner.New()
sp.Spinner = spinner.Dot sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#3AA99F")) sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#3AA99F")).Background(theme.Active.Surface)
// Load refresh settings from config // Load refresh settings from config
cfg, _ := config.Load() cfg := loadConfigOrDefault()
refreshInterval := time.Duration(cfg.TUI.RefreshIntervalSec) * time.Second refreshInterval := time.Duration(cfg.TUI.RefreshIntervalSec) * time.Second
if refreshInterval < 10*time.Second { if refreshInterval < 10*time.Second {
refreshInterval = 30 * time.Second // minimum 10s, default 30s refreshInterval = 30 * time.Second // minimum 10s, default 30s
@@ -159,7 +179,7 @@ func (a App) Init() tea.Cmd {
} }
// Start subscription data fetch if session key is configured // Start subscription data fetch if session key is configured
cfg, _ := config.Load() cfg := loadConfigOrDefault()
if sessionKey := config.GetSessionKey(cfg); sessionKey != "" { if sessionKey := config.GetSessionKey(cfg); sessionKey != "" {
cmds = append(cmds, fetchSubDataCmd(sessionKey)) cmds = append(cmds, fetchSubDataCmd(sessionKey))
} }
@@ -387,16 +407,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return a, nil return a, nil
case "ctrl+d": case "ctrl+d":
halfPage := (a.height - 10) / 2 halfPage := (a.height - scrollOverhead) / 2
if halfPage < 1 { if halfPage < minHalfPageScroll {
halfPage = 1 halfPage = minHalfPageScroll
} }
a.sessState.detailScroll += halfPage a.sessState.detailScroll += halfPage
return a, nil return a, nil
case "ctrl+u": case "ctrl+u":
halfPage := (a.height - 10) / 2 halfPage := (a.height - scrollOverhead) / 2
if halfPage < 1 { if halfPage < minHalfPageScroll {
halfPage = 1 halfPage = minHalfPageScroll
} }
a.sessState.detailScroll -= halfPage a.sessState.detailScroll -= halfPage
if a.sessState.detailScroll < 0 { if a.sessState.detailScroll < 0 {
@@ -438,8 +458,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Toggle auto-refresh // Toggle auto-refresh
if key == "R" { if key == "R" {
a.autoRefresh = !a.autoRefresh a.autoRefresh = !a.autoRefresh
// Persist to config // Persist to config (best-effort, ignore errors)
cfg, _ := config.Load() cfg := loadConfigOrDefault()
cfg.TUI.AutoRefresh = a.autoRefresh cfg.TUI.AutoRefresh = a.autoRefresh
_ = config.Save(cfg) _ = config.Save(cfg)
return a, nil return a, nil
@@ -491,9 +511,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.subData = msg.Data a.subData = msg.Data
a.subFetching = false a.subFetching = false
// Cache org ID if we got one // Cache org ID if we got one (best-effort, ignore errors)
if msg.Data != nil && msg.Data.Org.UUID != "" { if msg.Data != nil && msg.Data.Org.UUID != "" {
cfg, _ := config.Load() cfg := loadConfigOrDefault()
if cfg.ClaudeAI.OrgID != msg.Data.Org.UUID { if cfg.ClaudeAI.OrgID != msg.Data.Org.UUID {
cfg.ClaudeAI.OrgID = msg.Data.Org.UUID cfg.ClaudeAI.OrgID = msg.Data.Org.UUID
_ = config.Save(cfg) _ = config.Save(cfg)
@@ -517,7 +537,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Refresh subscription data every 5 minutes (1200 ticks at 250ms) // Refresh subscription data every 5 minutes (1200 ticks at 250ms)
if a.loaded && !a.subFetching && a.subTicks >= 1200 { if a.loaded && !a.subFetching && a.subTicks >= 1200 {
a.subTicks = 0 a.subTicks = 0
cfg, _ := config.Load() cfg := loadConfigOrDefault()
if sessionKey := config.GetSessionKey(cfg); sessionKey != "" { if sessionKey := config.GetSessionKey(cfg); sessionKey != "" {
a.subFetching = true a.subFetching = true
cmds = append(cmds, fetchSubDataCmd(sessionKey)) cmds = append(cmds, fetchSubDataCmd(sessionKey))
@@ -635,86 +655,143 @@ func (a App) viewLoading() string {
w := a.width w := a.width
h := a.height h := a.height
titleStyle := lipgloss.NewStyle(). // Polished loading card with accent border
Foreground(t.Accent). cardStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.BorderAccent).
Background(t.Surface).
Padding(2, 4)
// ASCII art logo effect
logoStyle := lipgloss.NewStyle().
Foreground(t.AccentBright).
Background(t.Surface).
Bold(true) Bold(true)
mutedStyle := lipgloss.NewStyle(). subtitleStyle := lipgloss.NewStyle().
Foreground(t.TextMuted) Foreground(t.TextMuted).
Background(t.Surface)
spinnerStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Background(t.Surface)
countStyle := lipgloss.NewStyle().
Foreground(t.TextPrimary).
Background(t.Surface)
var b strings.Builder var b strings.Builder
b.WriteString("\n\n") b.WriteString(logoStyle.Render("◈ cburn"))
b.WriteString(titleStyle.Render(" cburn")) b.WriteString(subtitleStyle.Render(" · Claude Usage Metrics"))
b.WriteString(mutedStyle.Render(" - Claude Usage Metrics"))
b.WriteString("\n\n") b.WriteString("\n\n")
if a.progressMax > 0 { if a.progressMax > 0 {
barW := w - 20 barW := 40
if barW > w-30 {
barW = w - 30
}
if barW < 20 { if barW < 20 {
barW = 20 barW = 20
} }
if barW > 60 {
barW = 60
}
pct := float64(a.progress) / float64(a.progressMax) pct := float64(a.progress) / float64(a.progressMax)
fmt.Fprintf(&b, " %s Parsing sessions\n", a.spinner.View()) b.WriteString(spinnerStyle.Render(a.spinner.View()))
fmt.Fprintf(&b, " %s %s/%s\n", b.WriteString(subtitleStyle.Render(" Parsing sessions\n\n"))
components.ProgressBar(pct, barW), b.WriteString(components.ProgressBar(pct, barW))
cli.FormatNumber(int64(a.progress)), b.WriteString("\n")
cli.FormatNumber(int64(a.progressMax))) b.WriteString(countStyle.Render(cli.FormatNumber(int64(a.progress))))
b.WriteString(subtitleStyle.Render(" / "))
b.WriteString(countStyle.Render(cli.FormatNumber(int64(a.progressMax))))
} else { } else {
fmt.Fprintf(&b, " %s Scanning sessions\n", a.spinner.View()) b.WriteString(spinnerStyle.Render(a.spinner.View()))
b.WriteString(subtitleStyle.Render(" Discovering sessions..."))
} }
content := b.String() card := cardStyle.Render(b.String())
return padHeight(truncateHeight(content, h), h)
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, card,
lipgloss.WithWhitespaceBackground(t.Background))
} }
func (a App) viewHelp() string { func (a App) viewHelp() string {
t := theme.Active t := theme.Active
h := a.height h := a.height
w := a.width
// Polished help overlay with accent border
cardStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.BorderAccent).
Background(t.Surface).
Padding(1, 3)
titleStyle := lipgloss.NewStyle(). titleStyle := lipgloss.NewStyle().
Foreground(t.AccentBright).
Background(t.Surface).
Bold(true)
sectionStyle := lipgloss.NewStyle().
Foreground(t.Accent). Foreground(t.Accent).
Background(t.Surface).
Bold(true) Bold(true)
keyStyle := lipgloss.NewStyle(). keyStyle := lipgloss.NewStyle().
Foreground(t.TextPrimary). Foreground(t.Cyan).
Background(t.Surface).
Bold(true) Bold(true)
descStyle := lipgloss.NewStyle(). descStyle := lipgloss.NewStyle().
Foreground(t.TextMuted) Foreground(t.TextMuted).
Background(t.Surface)
dimStyle := lipgloss.NewStyle().
Foreground(t.TextDim).
Background(t.Surface)
var b strings.Builder var b strings.Builder
b.WriteString("\n") b.WriteString(titleStyle.Render("◈ Keyboard Shortcuts"))
b.WriteString(titleStyle.Render(" Keybindings"))
b.WriteString("\n\n") b.WriteString("\n\n")
bindings := []struct{ key, desc string }{ // Navigation section
{"o/c/s/b", "Overview / Costs / Sessions / Breakdown"}, b.WriteString(sectionStyle.Render("Navigation"))
{"x", "Settings"}, b.WriteString("\n")
{"<- / ->", "Previous / Next tab"}, navBindings := []struct{ key, desc string }{
{"j / k", "Navigate lists (or mouse wheel)"}, {"o c s b x", "Jump to tab"},
{"J / K", "Scroll detail pane"}, {"← →", "Previous / Next tab"},
{"^d / ^u", "Scroll detail half-page"}, {"j k", "Navigate lists"},
{"/", "Search sessions (Enter apply, Esc cancel)"}, {"J K", "Scroll detail pane"},
{"Enter / f", "Expand session full-screen"}, {"^d ^u", "Half-page scroll"},
{"Esc", "Clear search / Back to split view"},
{"r / R", "Refresh now / Toggle auto-refresh"},
{"?", "Toggle this help"},
{"q", "Quit (or back from full-screen)"},
} }
for _, bind := range navBindings {
for _, bind := range bindings {
fmt.Fprintf(&b, " %s %s\n", fmt.Fprintf(&b, " %s %s\n",
keyStyle.Render(fmt.Sprintf("%-12s", bind.key)), keyStyle.Render(fmt.Sprintf("%-10s", bind.key)),
descStyle.Render(bind.desc)) descStyle.Render(bind.desc))
} }
fmt.Fprintf(&b, "\n %s\n", descStyle.Render("Press any key to close")) b.WriteString("\n")
b.WriteString(sectionStyle.Render("Actions"))
b.WriteString("\n")
actionBindings := []struct{ key, desc string }{
{"/", "Search sessions"},
{"Enter", "Expand / Confirm"},
{"Esc", "Back / Cancel"},
{"r", "Refresh data"},
{"R", "Toggle auto-refresh"},
{"?", "Toggle help"},
{"q", "Quit"},
}
for _, bind := range actionBindings {
fmt.Fprintf(&b, " %s %s\n",
keyStyle.Render(fmt.Sprintf("%-10s", bind.key)),
descStyle.Render(bind.desc))
}
content := b.String() b.WriteString("\n")
return padHeight(truncateHeight(content, h), h) b.WriteString(dimStyle.Render("Press any key to close"))
card := cardStyle.Render(b.String())
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, card,
lipgloss.WithWhitespaceBackground(t.Background))
} }
func (a App) viewMain() string { func (a App) viewMain() string {
@@ -723,18 +800,33 @@ func (a App) viewMain() string {
cw := a.contentWidth() cw := a.contentWidth()
h := a.height h := a.height
// 1. Render header (tab bar + filter line) // 1. Render header (tab bar + filter pill)
filterStyle := lipgloss.NewStyle().Foreground(t.TextDim) filterPillStyle := lipgloss.NewStyle().
filterStr := fmt.Sprintf(" [%dd", a.days) Foreground(t.TextDim).
Background(t.Surface)
filterAccentStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Background(t.Surface).
Bold(true)
filterStr := filterPillStyle.Render(" ") +
filterAccentStyle.Render(fmt.Sprintf("%dd", a.days))
if a.project != "" { if a.project != "" {
filterStr += " | " + a.project filterStr += filterPillStyle.Render(" ") + filterAccentStyle.Render(a.project)
} }
if a.modelFilter != "" { if a.modelFilter != "" {
filterStr += " | " + a.modelFilter filterStr += filterPillStyle.Render(" ") + filterAccentStyle.Render(a.modelFilter)
} }
filterStr += "]" filterStr += filterPillStyle.Render(" ")
header := components.RenderTabBar(a.activeTab, w) + "\n" +
filterStyle.Render(filterStr) + "\n" // Pad filter line to full width
filterRowStyle := lipgloss.NewStyle().
Background(t.Surface).
Width(w)
header := components.RenderTabBar(a.activeTab, w) +
filterRowStyle.Render(filterStr)
// 2. Render status bar // 2. Render status bar
dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds()) dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds())
@@ -744,8 +836,8 @@ func (a App) viewMain() string {
headerH := lipgloss.Height(header) headerH := lipgloss.Height(header)
statusH := lipgloss.Height(statusBar) statusH := lipgloss.Height(statusBar)
contentH := h - headerH - statusH contentH := h - headerH - statusH
if contentH < 5 { if contentH < minContentHeight {
contentH = 5 contentH = minContentHeight
} }
// 4. Render tab content (pass contentH to sessions) // 4. Render tab content (pass contentH to sessions)
@@ -767,13 +859,20 @@ func (a App) viewMain() string {
// 5. Truncate + pad to exactly contentH lines // 5. Truncate + pad to exactly contentH lines
content = padHeight(truncateHeight(content, contentH), contentH) content = padHeight(truncateHeight(content, contentH), contentH)
// 6. Center horizontally if terminal wider than content cap // 6. Fill each line to full width with background (fixes gaps between cards)
if w > cw { content = fillLinesWithBackground(content, cw, t.Background)
content = lipgloss.Place(w, contentH, lipgloss.Center, lipgloss.Top, content)
}
// 7. Stack vertically // 7. Place content with background fill (handles centering when w > cw)
return lipgloss.JoinVertical(lipgloss.Left, header, content, statusBar) content = lipgloss.Place(w, contentH, lipgloss.Center, lipgloss.Top, content,
lipgloss.WithWhitespaceBackground(t.Background))
// 8. Stack vertically
output := lipgloss.JoinVertical(lipgloss.Left, header, content, statusBar)
// 9. Ensure entire terminal is filled with background
// This handles any edge cases where the calculated heights don't perfectly match
return lipgloss.Place(w, h, lipgloss.Left, lipgloss.Top, output,
lipgloss.WithWhitespaceBackground(t.Background))
} }
// ─── Helpers ──────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────
@@ -1021,6 +1120,25 @@ func padHeight(s string, h int) string {
return s + padding return s + padding
} }
// fillLinesWithBackground pads each line to width w with background color.
// This ensures gaps between cards and empty lines have proper background fill.
func fillLinesWithBackground(s string, w int, bg lipgloss.Color) string {
lines := strings.Split(s, "\n")
var result strings.Builder
for i, line := range lines {
// Use PlaceHorizontal to ensure proper width and background fill
// This is more reliable than just Background().Render(spaces)
placed := lipgloss.PlaceHorizontal(w, lipgloss.Left, line,
lipgloss.WithWhitespaceBackground(bg))
result.WriteString(placed)
if i < len(lines)-1 {
result.WriteString("\n")
}
}
return result.String()
}
// fetchSubDataCmd fetches subscription data from claude.ai in a background goroutine. // fetchSubDataCmd fetches subscription data from claude.ai in a background goroutine.
func fetchSubDataCmd(sessionKey string) tea.Cmd { func fetchSubDataCmd(sessionKey string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
@@ -1040,25 +1158,23 @@ func fetchSubDataCmd(sessionKey string) tea.Cmd {
// ─── Mouse Support ────────────────────────────────────────────── // ─── Mouse Support ──────────────────────────────────────────────
// tabAtX returns the tab index at the given X coordinate, or -1 if none. // tabAtX returns the tab index at the given X coordinate, or -1 if none.
// Tab layout: " Overview Costs Sessions Breakdown Settings[x]" // Hitboxes are derived from the same width rules used by RenderTabBar.
func (a App) tabAtX(x int) int { func (a App) tabAtX(x int) int {
// Tab bar format: " TabName TabName ..." with 2-space gaps pos := 0
// We approximate positions since exact widths depend on styling. for i, tab := range components.Tabs {
// Each tab name is roughly: name length + optional [k] suffix + gap // Must match RenderTabBar's visual width calculation exactly.
positions := []struct { // Use lipgloss.Width() to handle unicode and styled text correctly.
start, end int tabW := components.TabVisualWidth(tab, i == a.activeTab)
}{
{1, 12}, // Overview (0)
{14, 22}, // Costs (1)
{24, 35}, // Sessions (2)
{37, 50}, // Breakdown (3)
{52, 68}, // Settings (4)
}
for i, p := range positions { if x >= pos && x < pos+tabW {
if x >= p.start && x <= p.end {
return i return i
} }
pos += tabW
// Separator is one column between tabs.
if i < len(components.Tabs)-1 {
pos++
}
} }
return -1 return -1
} }

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

@@ -2,6 +2,8 @@
package components package components
import ( import (
"strings"
"github.com/theirongolddev/cburn/internal/tui/theme" "github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -25,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
@@ -35,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)
@@ -74,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.
@@ -87,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 := ""
@@ -106,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
@@ -123,3 +249,10 @@ func CardInnerWidth(outerWidth int) int {
} }
return w return w
} }
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

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

@@ -15,6 +15,7 @@ func Sparkline(values []float64, color lipgloss.Color) string {
if len(values) == 0 { if len(values) == 0 {
return "" return ""
} }
t := theme.Active
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
@@ -28,7 +29,7 @@ func Sparkline(values []float64, color lipgloss.Color) string {
peak = 1 peak = 1
} }
style := lipgloss.NewStyle().Foreground(color) style := lipgloss.NewStyle().Foreground(color).Background(t.Surface)
var buf strings.Builder var buf strings.Builder
buf.Grow(len(values) * 4) // UTF-8 block chars are up to 3 bytes buf.Grow(len(values) * 4) // UTF-8 block chars are up to 3 bytes
@@ -46,9 +47,7 @@ func Sparkline(values []float64, color lipgloss.Color) string {
return style.Render(buf.String()) return style.Render(buf.String())
} }
// BarChart renders a multi-row bar chart with anchored Y-axis and optional X-axis labels. // BarChart renders a visually polished bar chart with gradient-style coloring.
// 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 { func BarChart(values []float64, labels []string, color lipgloss.Color, width, height int) string {
if len(values) == 0 { if len(values) == 0 {
return "" return ""
@@ -70,10 +69,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
maxVal = 1 maxVal = 1
} }
// Y-axis: compute tick step and ceiling, then fit within requested height. // Y-axis: compute tick step and ceiling
// 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) tickStep := chartTickStep(maxVal)
maxIntervals := height / 2 maxIntervals := height / 2
if maxIntervals < 2 { if maxIntervals < 2 {
@@ -92,14 +88,13 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
numIntervals = 1 numIntervals = 1
} }
// Each interval gets the same number of rows; chart height is an exact multiple.
rowsPerTick := height / numIntervals rowsPerTick := height / numIntervals
if rowsPerTick < 2 { if rowsPerTick < 2 {
rowsPerTick = 2 rowsPerTick = 2
} }
chartH := rowsPerTick * numIntervals chartH := rowsPerTick * numIntervals
// Pre-compute tick labels at evenly-spaced row positions // Pre-compute tick labels
yLabelW := len(formatChartLabel(ceiling)) + 1 yLabelW := len(formatChartLabel(ceiling)) + 1
if yLabelW < 4 { if yLabelW < 4 {
yLabelW = 4 yLabelW = 4
@@ -110,7 +105,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
tickLabels[row] = formatChartLabel(tickStep * float64(i)) tickLabels[row] = formatChartLabel(tickStep * float64(i))
} }
// Chart area width (excluding y-axis label and axis line char) // Chart area width
chartW := width - yLabelW - 1 chartW := width - yLabelW - 1
if chartW < 5 { if chartW < 5 {
chartW = 5 chartW = 5
@@ -118,8 +113,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
n := len(values) n := len(values)
// Bar sizing: always use 1-char gaps, target barW >= 2. // Bar sizing
// If bars don't fit at width 2, subsample to fewer bars.
gap := 1 gap := 1
if n <= 1 { if n <= 1 {
gap = 0 gap = 0
@@ -131,8 +125,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
barW = chartW barW = chartW
} }
if barW < 2 && n > 1 { if barW < 2 && n > 1 {
// Subsample so bars fit at width 2 with 1-char gaps maxN := (chartW + 1) / 3
maxN := (chartW + 1) / 3 // each bar = 2 chars + 1 gap (last bar no gap)
if maxN < 2 { if maxN < 2 {
maxN = 2 maxN = 2
} }
@@ -159,15 +152,29 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
axisLen := n*barW + max(0, n-1)*gap axisLen := n*barW + max(0, n-1)*gap
blocks := []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} blocks := []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
barStyle := lipgloss.NewStyle().Foreground(color)
axisStyle := lipgloss.NewStyle().Foreground(t.TextDim) // Multi-color gradient for bars based on height
axisStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
var b strings.Builder var b strings.Builder
// Render rows top to bottom using chartH (aligned to tick intervals) // Render rows top to bottom
for row := chartH; row >= 1; row-- { for row := chartH; row >= 1; row-- {
rowTop := ceiling * float64(row) / float64(chartH) rowTop := ceiling * float64(row) / float64(chartH)
rowBottom := ceiling * float64(row-1) / 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] label := tickLabels[row]
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, label))) b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, label)))
@@ -175,11 +182,11 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
for i, v := range values { for i, v := range values {
if i > 0 && gap > 0 { if i > 0 && gap > 0 {
b.WriteString(strings.Repeat(" ", gap)) b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", gap)))
} }
switch { switch {
case v >= rowTop: case v >= rowTop:
b.WriteString(barStyle.Render(strings.Repeat("\u2588", barW))) b.WriteString(barStyle.Render(strings.Repeat("", barW)))
case v > rowBottom: case v > rowBottom:
frac := (v - rowBottom) / (rowTop - rowBottom) frac := (v - rowBottom) / (rowTop - rowBottom)
idx := int(frac * 8) idx := int(frac * 8)
@@ -191,7 +198,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
} }
b.WriteString(barStyle.Render(strings.Repeat(string(blocks[idx]), barW))) b.WriteString(barStyle.Render(strings.Repeat(string(blocks[idx]), barW)))
default: default:
b.WriteString(strings.Repeat(" ", barW)) b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", barW)))
} }
} }
b.WriteString("\n") b.WriteString("\n")
@@ -209,7 +216,6 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
buf[i] = ' ' buf[i] = ' '
} }
// Place labels at bar start positions, skip overlaps
minSpacing := 8 minSpacing := 8
labelStep := max(1, (n*minSpacing)/(axisLen+1)) labelStep := max(1, (n*minSpacing)/(axisLen+1))
@@ -231,14 +237,11 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
copy(buf[pos:end], lbl) copy(buf[pos:end], lbl)
lastEnd = end + 1 lastEnd = end + 1
} }
// Always attempt the last label (the loop may skip it due to labelStep).
// Right-align to axis edge if it would overflow.
if n > 1 { if n > 1 {
lbl := labels[n-1] lbl := labels[n-1]
pos := (n - 1) * (barW + gap) pos := (n - 1) * (barW + gap)
end := pos + len(lbl) end := pos + len(lbl)
if end > axisLen { if end > axisLen {
// Right-align: shift left so it ends at the axis edge
pos = axisLen - len(lbl) pos = axisLen - len(lbl)
end = axisLen end = axisLen
} }
@@ -251,8 +254,9 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(strings.Repeat(" ", yLabelW+1)) labelStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
b.WriteString(axisStyle.Render(strings.TrimRight(string(buf), " "))) b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", yLabelW+1)))
b.WriteString(labelStyle.Render(strings.TrimRight(string(buf), " ")))
} }
return b.String() return b.String()

View File

@@ -11,7 +11,7 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
// ProgressBar renders a colored progress bar. // ProgressBar renders a visually appealing progress bar with percentage.
func ProgressBar(pct float64, width int) string { func ProgressBar(pct float64, width int) string {
t := theme.Active t := theme.Active
filled := int(pct * float64(width)) filled := int(pct * float64(width))
@@ -22,36 +22,45 @@ func ProgressBar(pct float64, width int) string {
filled = 0 filled = 0
} }
filledStyle := lipgloss.NewStyle().Foreground(t.Accent) // Color gradient based on progress
emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim) 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 var b strings.Builder
for i := 0; i < filled; i++ { b.WriteString(filledStyle.Render(strings.Repeat("█", filled)))
b.WriteString(filledStyle.Render("\u2588")) b.WriteString(emptyStyle.Render(strings.Repeat("░", width-filled)))
}
for i := filled; i < width; i++ { return b.String() + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%.0f%%", pct*100))
b.WriteString(emptyStyle.Render("\u2591"))
} }
return fmt.Sprintf("%s %.1f%%", b.String(), pct*100) // ColorForPct returns green/yellow/orange/red based on utilization level.
}
// ColorForPct returns green/yellow/red based on utilization level.
func ColorForPct(pct float64) string { func ColorForPct(pct float64) string {
t := theme.Active t := theme.Active
switch { switch {
case pct >= 0.8: case pct >= 0.9:
return string(t.Red) return string(t.Red)
case pct >= 0.5: case pct >= 0.7:
return string(t.Orange) return string(t.Orange)
case pct >= 0.5:
return string(t.Yellow)
default: default:
return string(t.Green) return string(t.Green)
} }
} }
// RateLimitBar renders a labeled progress bar with percentage and countdown. // RateLimitBar renders a labeled progress bar with percentage and countdown.
// label: "5-hour", "Weekly", etc. pct: 0.0-1.0. resetsAt: zero means no countdown.
// barWidth: width allocated for the progress bar portion only.
func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidth int) string { func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidth int) string {
t := theme.Active t := theme.Active
@@ -69,9 +78,10 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt
) )
bar.EmptyColor = string(t.TextDim) bar.EmptyColor = string(t.TextDim)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
pctStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Bold(true) pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true)
countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim) countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
pctStr := fmt.Sprintf("%3.0f%%", pct*100) pctStr := fmt.Sprintf("%3.0f%%", pct*100)
countdown := "" countdown := ""
@@ -84,16 +94,16 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt
} }
} }
return fmt.Sprintf("%s %s %s %s", return labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)) +
labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)), spaceStyle.Render(" ") +
bar.ViewAs(pct), bar.ViewAs(pct) +
pctStyle.Render(pctStr), spaceStyle.Render(" ") +
countdownStyle.Render(countdown), pctStyle.Render(pctStr) +
) spaceStyle.Render(" ") +
countdownStyle.Render(countdown)
} }
// CompactRateBar renders a tiny status-bar-sized rate indicator. // CompactRateBar renders a tiny status-bar-sized rate indicator.
// Example output: "5h ████░░░░ 42%"
func CompactRateBar(label string, pct float64, width int) string { func CompactRateBar(label string, pct float64, width int) string {
t := theme.Active t := theme.Active
@@ -104,7 +114,6 @@ func CompactRateBar(label string, pct float64, width int) string {
pct = 1 pct = 1
} }
// label + space + bar + space + pct(4 chars)
barW := width - lipgloss.Width(label) - 6 barW := width - lipgloss.Width(label) - 6
if barW < 4 { if barW < 4 {
barW = 4 barW = 4
@@ -117,14 +126,15 @@ func CompactRateBar(label string, pct float64, width int) string {
) )
bar.EmptyColor = string(t.TextDim) bar.EmptyColor = string(t.TextDim)
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))) pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
return fmt.Sprintf("%s %s %s", return labelStyle.Render(label) +
labelStyle.Render(label), spaceStyle.Render(" ") +
bar.ViewAs(pct), bar.ViewAs(pct) +
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)), spaceStyle.Render(" ") +
) pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100))
} }
func formatCountdown(d time.Duration) string { func formatCountdown(d time.Duration) string {

View File

@@ -7,80 +7,119 @@ import (
"github.com/theirongolddev/cburn/internal/claudeai" "github.com/theirongolddev/cburn/internal/claudeai"
"github.com/theirongolddev/cburn/internal/tui/theme" "github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
// RenderStatusBar renders the bottom status bar with optional rate limit indicators. // RenderStatusBar renders a polished bottom status bar with rate limits and controls.
func RenderStatusBar(width int, dataAge string, subData *claudeai.SubscriptionData, refreshing, autoRefresh bool) 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 [r]efresh [q]uit" // Build left section: keyboard hints
keyStyle := lipgloss.NewStyle().
Foreground(t.AccentBright).
Background(t.SurfaceHover).
Bold(true)
// Build rate limit indicators for the middle section hintStyle := lipgloss.NewStyle().
ratePart := renderStatusRateLimits(subData) Foreground(t.TextMuted).
Background(t.SurfaceHover)
// Build right side with refresh status 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 var right string
if refreshing { if refreshing {
refreshStyle := lipgloss.NewStyle().Foreground(t.Accent) spinnerStyle := lipgloss.NewStyle().
right = refreshStyle.Render("↻ refreshing ") Foreground(t.AccentBright).
Background(t.SurfaceHover).
Bold(true)
right = spinnerStyle.Render("↻ refreshing")
} else if dataAge != "" { } else if dataAge != "" {
autoStr := "" refreshIcon := ""
if autoRefresh { if autoRefresh {
autoStr = "↻ " refreshIcon = lipgloss.NewStyle().
Foreground(t.Green).
Background(t.SurfaceHover).
Render("↻ ")
} }
right = fmt.Sprintf("%sData: %s ", autoStr, dataAge) dataStyle := lipgloss.NewStyle().
Foreground(t.TextMuted).
Background(t.SurfaceHover)
right = refreshIcon + dataStyle.Render("Data: "+dataAge)
} }
right += spaceStyle.Render(" ")
// Layout: left + ratePart + right, with padding distributed // Calculate padding
usedWidth := lipgloss.Width(left) + lipgloss.Width(ratePart) + lipgloss.Width(right) leftWidth := lipgloss.Width(left)
padding := width - usedWidth 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
} }
// Split padding: more on the left side of rate indicators
leftPad := padding / 2 leftPad := padding / 2
rightPad := padding - leftPad rightPad := padding - leftPad
bar := left + strings.Repeat(" ", leftPad) + ratePart + strings.Repeat(" ", rightPad) + right paddingStyle := lipgloss.NewStyle().Background(t.SurfaceHover)
bar := left +
paddingStyle.Render(strings.Repeat(" ", leftPad)) +
middle +
paddingStyle.Render(strings.Repeat(" ", rightPad)) +
right
return style.Render(bar) return barStyle.Render(bar)
} }
// renderStatusRateLimits renders compact rate limit bars for the status bar. // renderStatusRateLimits renders compact rate limit pills for the status bar.
func renderStatusRateLimits(subData *claudeai.SubscriptionData) string { func renderStatusRateLimits(subData *claudeai.SubscriptionData) string {
if subData == nil || subData.Usage == nil { if subData == nil || subData.Usage == nil {
return "" return ""
} }
t := theme.Active t := theme.Active
sepStyle := lipgloss.NewStyle().Foreground(t.TextDim)
var parts []string var parts []string
if w := subData.Usage.FiveHour; w != nil { if w := subData.Usage.FiveHour; w != nil {
parts = append(parts, compactStatusBar("5h", w.Pct)) parts = append(parts, renderRatePill("5h", w.Pct))
} }
if w := subData.Usage.SevenDay; w != nil { if w := subData.Usage.SevenDay; w != nil {
parts = append(parts, compactStatusBar("Wk", w.Pct)) parts = append(parts, renderRatePill("Wk", w.Pct))
} }
if len(parts) == 0 { if len(parts) == 0 {
return "" return ""
} }
return strings.Join(parts, sepStyle.Render(" | ")) sepStyle := lipgloss.NewStyle().
Foreground(t.TextDim).
Background(t.SurfaceHover)
return strings.Join(parts, sepStyle.Render(" │ "))
} }
// compactStatusBar renders a tiny inline progress indicator for the status bar. // renderRatePill renders a compact, colored rate indicator pill.
// Format: "5h ████░░░░ 42%" func renderRatePill(label string, pct float64) string {
func compactStatusBar(label string, pct float64) string {
t := theme.Active t := theme.Active
if pct < 0 { if pct < 0 {
@@ -90,20 +129,52 @@ func compactStatusBar(label string, pct float64) string {
pct = 1 pct = 1
} }
barW := 8 // Choose color based on usage level
bar := progress.New( var barColor, pctColor lipgloss.Color
progress.WithSolidFill(ColorForPct(pct)), switch {
progress.WithWidth(barW), case pct >= 0.9:
progress.WithoutPercentage(), barColor = t.Red
) pctColor = t.Red
bar.EmptyColor = string(t.TextDim) case pct >= 0.7:
barColor = t.Orange
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) pctColor = t.Orange
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))) case pct >= 0.5:
barColor = t.Yellow
return fmt.Sprintf("%s %s %s", pctColor = t.Yellow
labelStyle.Render(label), default:
bar.ViewAs(pct), barColor = t.Green
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)), 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

@@ -24,50 +24,113 @@ var Tabs = []Tab{
{Name: "Settings", Key: 'x', KeyPos: -1}, {Name: "Settings", Key: 'x', KeyPos: -1},
} }
// 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
parts := make([]string, 0, len(Tabs))
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)))
} }
bar := " " + strings.Join(parts, " ") tabParts = append(tabParts, tabContent)
if lipgloss.Width(bar) <= width { underlineParts = append(underlineParts, underline)
return bar
// Add separator between tabs (not after last)
if i < len(Tabs)-1 {
tabParts = append(tabParts, sepStyle.Render(" "))
underlineParts = append(underlineParts, sepStyle.Render(" "))
} }
return lipgloss.NewStyle().MaxWidth(width).Render(bar) }
// Combine tab row and underline row
tabRow := strings.Join(tabParts, "")
underlineRow := strings.Join(underlineParts, "")
// Fill remaining width with border
tabRowWidth := lipgloss.Width(tabRow)
if tabRowWidth < width {
padding := width - tabRowWidth
tabRow += lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", padding))
underlineRow += lipgloss.NewStyle().Foreground(t.Border).Background(t.Surface).Render(strings.Repeat("─", padding))
}
return barStyle.Render(tabRow + "\n" + underlineRow)
} }

View File

@@ -19,7 +19,7 @@ type setupValues struct {
// newSetupForm builds the huh form for first-run configuration. // newSetupForm builds the huh form for first-run configuration.
func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.Form { func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.Form {
cfg, _ := config.Load() cfg := loadConfigOrDefault()
// Pre-populate defaults // Pre-populate defaults
vals.days = cfg.General.DefaultDays vals.days = cfg.General.DefaultDays
@@ -98,7 +98,7 @@ func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.For
// saveSetupConfig persists the setup wizard values to the config file. // saveSetupConfig persists the setup wizard values to the config file.
func (a *App) saveSetupConfig() error { func (a *App) saveSetupConfig() error {
cfg, _ := config.Load() cfg := loadConfigOrDefault()
if a.setupVals.sessionKey != "" { if a.setupVals.sessionKey != "" {
cfg.ClaudeAI.SessionKey = a.setupVals.sessionKey cfg.ClaudeAI.SessionKey = a.setupVals.sessionKey

View File

@@ -23,8 +23,18 @@ func (a App) renderModelsTab(cw int) string {
nameW = 14 nameW = 14
} }
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true)
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) 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 var tableBody strings.Builder
if a.isCompactLayout() { if a.isCompactLayout() {
@@ -37,29 +47,30 @@ func (a App) renderModelsTab(cw int) string {
} }
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s %6s", nameW, "Model", "Calls", "Cost", "Share"))) tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s %6s", nameW, "Model", "Calls", "Cost", "Share")))
tableBody.WriteString("\n") tableBody.WriteString("\n")
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+shareW+costW+callW+3)))
tableBody.WriteString("\n")
for _, ms := range models { for i, ms := range models {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %8s %10s %5.1f%%", tableBody.WriteString(nameStyles[i%len(modelColors)].Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(ms.Model), nameW))))
nameW, tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %8s", cli.FormatNumber(int64(ms.APICalls)))))
truncStr(shortModel(ms.Model), nameW), tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ms.EstimatedCost))))
cli.FormatNumber(int64(ms.APICalls)), tableBody.WriteString(shareStyle.Render(fmt.Sprintf(" %5.1f%%", ms.SharePercent)))
cli.FormatCost(ms.EstimatedCost),
ms.SharePercent)))
tableBody.WriteString("\n") tableBody.WriteString("\n")
} }
} else { } else {
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s %10s %10s %6s", nameW, "Model", "Calls", "Input", "Output", "Cost", "Share"))) tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s %10s %10s %6s", nameW, "Model", "Calls", "Input", "Output", "Cost", "Share")))
tableBody.WriteString("\n") tableBody.WriteString("\n")
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
tableBody.WriteString("\n")
for _, ms := range models { for i, ms := range models {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %8s %10s %10s %10s %5.1f%%", tableBody.WriteString(nameStyles[i%len(modelColors)].Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(ms.Model), nameW))))
nameW, tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %8s %10s %10s",
truncStr(shortModel(ms.Model), nameW),
cli.FormatNumber(int64(ms.APICalls)), cli.FormatNumber(int64(ms.APICalls)),
cli.FormatTokens(ms.InputTokens), cli.FormatTokens(ms.InputTokens),
cli.FormatTokens(ms.OutputTokens), cli.FormatTokens(ms.OutputTokens))))
cli.FormatCost(ms.EstimatedCost), tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ms.EstimatedCost))))
ms.SharePercent))) tableBody.WriteString(shareStyle.Render(fmt.Sprintf(" %5.1f%%", ms.SharePercent)))
tableBody.WriteString("\n") tableBody.WriteString("\n")
} }
} }
@@ -79,8 +90,11 @@ func (a App) renderProjectsTab(cw int) string {
nameW = 18 nameW = 18
} }
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true)
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) 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 var tableBody strings.Builder
if a.isCompactLayout() { if a.isCompactLayout() {
@@ -92,27 +106,28 @@ func (a App) renderProjectsTab(cw int) string {
} }
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %6s %10s", nameW, "Project", "Sess.", "Cost"))) tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %6s %10s", nameW, "Project", "Sess.", "Cost")))
tableBody.WriteString("\n") tableBody.WriteString("\n")
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+costW+sessW+2)))
tableBody.WriteString("\n")
for _, ps := range projects { for _, ps := range projects {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %6d %10s", tableBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(ps.Project, nameW))))
nameW, tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %6d", ps.Sessions)))
truncStr(ps.Project, nameW), tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ps.EstimatedCost))))
ps.Sessions,
cli.FormatCost(ps.EstimatedCost))))
tableBody.WriteString("\n") tableBody.WriteString("\n")
} }
} else { } else {
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %6s %8s %10s %10s", nameW, "Project", "Sess.", "Prompts", "Tokens", "Cost"))) tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %6s %8s %10s %10s", nameW, "Project", "Sess.", "Prompts", "Tokens", "Cost")))
tableBody.WriteString("\n") tableBody.WriteString("\n")
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
tableBody.WriteString("\n")
for _, ps := range projects { for _, ps := range projects {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %6d %8s %10s %10s", tableBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(ps.Project, nameW))))
nameW, tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %6d %8s %10s",
truncStr(ps.Project, nameW),
ps.Sessions, ps.Sessions,
cli.FormatNumber(int64(ps.Prompts)), cli.FormatNumber(int64(ps.Prompts)),
cli.FormatTokens(ps.TotalTokens), cli.FormatTokens(ps.TotalTokens))))
cli.FormatCost(ps.EstimatedCost)))) tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ps.EstimatedCost))))
tableBody.WriteString("\n") tableBody.WriteString("\n")
} }
} }

View File

@@ -51,11 +51,14 @@ func (a App) renderCostsTab(cw int) string {
nameW = 14 nameW = 14
} }
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true)
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted) labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) 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 var tableBody strings.Builder
if a.isCompactLayout() { if a.isCompactLayout() {
@@ -70,10 +73,8 @@ func (a App) renderCostsTab(cw int) string {
tableBody.WriteString("\n") tableBody.WriteString("\n")
for _, mc := range modelCosts { for _, mc := range modelCosts {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s", tableBody.WriteString(modelNameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(mc.Model), nameW))))
nameW, tableBody.WriteString(costValueStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(mc.TotalCost))))
truncStr(shortModel(mc.Model), nameW),
cli.FormatCost(mc.TotalCost))))
tableBody.WriteString("\n") tableBody.WriteString("\n")
} }
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1))) tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
@@ -84,13 +85,12 @@ func (a App) renderCostsTab(cw int) string {
tableBody.WriteString("\n") tableBody.WriteString("\n")
for _, mc := range modelCosts { for _, mc := range modelCosts {
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s", tableBody.WriteString(modelNameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(mc.Model), nameW))))
nameW, tableBody.WriteString(tokenCostStyle.Render(fmt.Sprintf(" %10s %10s %10s",
truncStr(shortModel(mc.Model), nameW),
cli.FormatCost(mc.InputCost), cli.FormatCost(mc.InputCost),
cli.FormatCost(mc.OutputCost), cli.FormatCost(mc.OutputCost),
cli.FormatCost(mc.CacheCost), cli.FormatCost(mc.CacheCost))))
cli.FormatCost(mc.TotalCost)))) tableBody.WriteString(costValueStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(mc.TotalCost))))
tableBody.WriteString("\n") tableBody.WriteString("\n")
} }
@@ -126,12 +126,16 @@ func (a App) renderCostsTab(cw int) string {
var body strings.Builder var body strings.Builder
body.WriteString(bar.ViewAs(pct)) body.WriteString(bar.ViewAs(pct))
fmt.Fprintf(&body, " %.0f%%\n", pct*100) body.WriteString(spaceStyle.Render(" "))
fmt.Fprintf(&body, "%s %s / %s %s", body.WriteString(valueStyle.Render(fmt.Sprintf("%.0f%%", pct*100)))
labelStyle.Render("Used"), body.WriteString("\n")
valueStyle.Render(fmt.Sprintf("$%.2f", ol.UsedCredits)), body.WriteString(labelStyle.Render("Used"))
valueStyle.Render(fmt.Sprintf("$%.2f", ol.MonthlyCreditLimit)), body.WriteString(spaceStyle.Render(" "))
labelStyle.Render(ol.Currency)) 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]) progressCard = components.ContentCard("Overage Spend", body.String(), halves[0])
} else { } else {
@@ -159,12 +163,14 @@ func (a App) renderCostsTab(cw int) string {
return topDays[i].Date.After(topDays[j].Date) return topDays[i].Date.After(topDays[j].Date)
}) })
for _, d := range topDays { for _, d := range topDays {
fmt.Fprintf(&spendBody, "%s %s\n", spendBody.WriteString(valueStyle.Render(d.Date.Format("Jan 02")))
valueStyle.Render(d.Date.Format("Jan 02")), spendBody.WriteString(spaceStyle.Render(" "))
lipgloss.NewStyle().Foreground(t.Green).Render(cli.FormatCost(d.EstimatedCost))) spendBody.WriteString(lipgloss.NewStyle().Foreground(t.Green).Background(t.Surface).Render(cli.FormatCost(d.EstimatedCost)))
spendBody.WriteString("\n")
} }
} else { } else {
spendBody.WriteString("No data\n") spendBody.WriteString(labelStyle.Render("No data"))
spendBody.WriteString("\n")
} }
spendCard := components.ContentCard("Top Spend Days", spendBody.String(), halves[1]) spendCard := components.ContentCard("Top Spend Days", spendBody.String(), halves[1])
@@ -189,16 +195,21 @@ func (a App) renderCostsTab(cw int) string {
promptsPerSess = float64(stats.TotalPrompts) / float64(stats.TotalSessions) promptsPerSess = float64(stats.TotalPrompts) / float64(stats.TotalSessions)
} }
effMetrics := []struct{ name, value string }{ effMetrics := []struct {
{"Tokens/Prompt", cli.FormatTokens(tokPerPrompt)}, name string
{"Output/Prompt", cli.FormatTokens(outPerPrompt)}, value string
{"Prompts/Session", fmt.Sprintf("%.1f", promptsPerSess)}, color lipgloss.Color
{"Minutes/Day", fmt.Sprintf("%.0f", stats.MinutesPerDay)}, }{
{"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 var effBody strings.Builder
for _, m := range effMetrics { for _, m := range effMetrics {
effBody.WriteString(rowStyle.Render(fmt.Sprintf("%-20s %10s", m.name, m.value))) 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") effBody.WriteString("\n")
} }
@@ -210,11 +221,11 @@ func (a App) renderCostsTab(cw int) string {
// renderSubscriptionCard renders the rate limit + overage card at the top of the costs tab. // renderSubscriptionCard renders the rate limit + overage card at the top of the costs tab.
func (a App) renderSubscriptionCard(cw int) string { func (a App) renderSubscriptionCard(cw int) string {
t := theme.Active t := theme.Active
hintStyle := lipgloss.NewStyle().Foreground(t.TextDim) hintStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
// No session key configured // No session key configured
if a.subData == nil && !a.subFetching { if a.subData == nil && !a.subFetching {
cfg, _ := config.Load() cfg := loadConfigOrDefault()
if config.GetSessionKey(cfg) == "" { if config.GetSessionKey(cfg) == "" {
return components.ContentCard("Subscription", return components.ContentCard("Subscription",
hintStyle.Render("Configure session key in Settings to see rate limits"), hintStyle.Render("Configure session key in Settings to see rate limits"),
@@ -235,7 +246,7 @@ func (a App) renderSubscriptionCard(cw int) string {
// Error with no usable data // Error with no usable data
if a.subData.Usage == nil && a.subData.Error != nil { if a.subData.Usage == nil && a.subData.Error != nil {
warnStyle := lipgloss.NewStyle().Foreground(t.Orange) warnStyle := lipgloss.NewStyle().Foreground(t.Orange).Background(t.Surface)
return components.ContentCard("Subscription", return components.ContentCard("Subscription",
warnStyle.Render(fmt.Sprintf("Error: %s", a.subData.Error)), warnStyle.Render(fmt.Sprintf("Error: %s", a.subData.Error)),
cw) + "\n" cw) + "\n"
@@ -285,12 +296,12 @@ func (a App) renderSubscriptionCard(cw int) string {
if ol := a.subData.Overage; ol != nil && ol.IsEnabled && ol.MonthlyCreditLimit > 0 { if ol := a.subData.Overage; ol != nil && ol.IsEnabled && ol.MonthlyCreditLimit > 0 {
pct := ol.UsedCredits / ol.MonthlyCreditLimit pct := ol.UsedCredits / ol.MonthlyCreditLimit
body.WriteString("\n") body.WriteString("\n")
body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Render(strings.Repeat("─", innerW))) body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface).Render(strings.Repeat("─", innerW)))
body.WriteString("\n") body.WriteString("\n")
body.WriteString(components.RateLimitBar("Overage", body.WriteString(components.RateLimitBar("Overage",
pct, time.Time{}, labelW, barW)) pct, time.Time{}, labelW, barW))
spendStyle := lipgloss.NewStyle().Foreground(t.TextDim) spendStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
body.WriteString(spendStyle.Render( body.WriteString(spendStyle.Render(
fmt.Sprintf(" $%.2f / $%.2f", ol.UsedCredits, ol.MonthlyCreditLimit))) fmt.Sprintf(" $%.2f / $%.2f", ol.UsedCredits, ol.MonthlyCreditLimit)))
} }
@@ -298,7 +309,7 @@ func (a App) renderSubscriptionCard(cw int) string {
// Fetch timestamp // Fetch timestamp
if !a.subData.FetchedAt.IsZero() { if !a.subData.FetchedAt.IsZero() {
body.WriteString("\n") body.WriteString("\n")
tsStyle := lipgloss.NewStyle().Foreground(t.TextDim) tsStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
body.WriteString(tsStyle.Render("Updated " + a.subData.FetchedAt.Format("3:04 PM"))) body.WriteString(tsStyle.Render("Updated " + a.subData.FetchedAt.Format("3:04 PM")))
} }

View File

@@ -21,7 +21,7 @@ func (a App) renderOverviewTab(cw int) string {
models := a.models models := a.models
var b strings.Builder var b strings.Builder
// Row 1: Metric cards // Row 1: Metric cards with colored values
costDelta := "" costDelta := ""
if prev.CostPerDay > 0 { if prev.CostPerDay > 0 {
costDelta = fmt.Sprintf("%s/day (%s)", cli.FormatCost(stats.CostPerDay), cli.FormatDelta(stats.CostPerDay, prev.CostPerDay)) costDelta = fmt.Sprintf("%s/day (%s)", cli.FormatCost(stats.CostPerDay), cli.FormatDelta(stats.CostPerDay, prev.CostPerDay))
@@ -54,7 +54,7 @@ func (a App) renderOverviewTab(cw int) string {
b.WriteString(components.MetricCardRow(cards, cw)) b.WriteString(components.MetricCardRow(cards, cw))
b.WriteString("\n") b.WriteString("\n")
// Row 2: Daily token usage chart // Row 2: Daily token usage chart - use PanelCard for emphasis
if len(days) > 0 { if len(days) > 0 {
chartVals := make([]float64, len(days)) chartVals := make([]float64, len(days))
chartLabels := chartDateLabels(days) chartLabels := chartDateLabels(days)
@@ -62,9 +62,9 @@ func (a App) renderOverviewTab(cw int) string {
chartVals[len(days)-1-i] = float64(d.InputTokens + d.OutputTokens + d.CacheCreation5m + d.CacheCreation1h) chartVals[len(days)-1-i] = float64(d.InputTokens + d.OutputTokens + d.CacheCreation5m + d.CacheCreation1h)
} }
chartInnerW := components.CardInnerWidth(cw) chartInnerW := components.CardInnerWidth(cw)
b.WriteString(components.ContentCard( b.WriteString(components.PanelCard(
fmt.Sprintf("Daily Token Usage (%dd)", a.days), fmt.Sprintf("Daily Token Usage (%dd)", a.days),
components.BarChart(chartVals, chartLabels, t.Blue, chartInnerW, 10), components.BarChart(chartVals, chartLabels, t.BlueBright, chartInnerW, 10),
cw, cw,
)) ))
b.WriteString("\n") b.WriteString("\n")
@@ -88,7 +88,7 @@ func (a App) renderOverviewTab(cw int) string {
} }
todayCard = components.ContentCard( todayCard = components.ContentCard(
fmt.Sprintf("Today (%s)", cli.FormatTokens(todayTotal)), fmt.Sprintf("Today (%s)", cli.FormatTokens(todayTotal)),
components.BarChart(hourVals, hourLabels24(), t.Blue, components.CardInnerWidth(liveHalves[0]), liveChartH), components.BarChart(hourVals, hourLabels24(), t.Cyan, components.CardInnerWidth(liveHalves[0]), liveChartH),
liveHalves[0], liveHalves[0],
) )
} }
@@ -104,7 +104,7 @@ func (a App) renderOverviewTab(cw int) string {
} }
lastHourCard = components.ContentCard( lastHourCard = components.ContentCard(
fmt.Sprintf("Last Hour (%s)", cli.FormatTokens(hourTotal)), fmt.Sprintf("Last Hour (%s)", cli.FormatTokens(hourTotal)),
components.BarChart(minVals, minuteLabels(), t.Accent, components.CardInnerWidth(liveHalves[1]), liveChartH), components.BarChart(minVals, minuteLabels(), t.Magenta, components.CardInnerWidth(liveHalves[1]), liveChartH),
liveHalves[1], liveHalves[1],
) )
} }
@@ -127,10 +127,7 @@ func (a App) renderOverviewTab(cw int) string {
halves := components.LayoutRow(cw, 2) halves := components.LayoutRow(cw, 2)
innerW := components.CardInnerWidth(halves[0]) innerW := components.CardInnerWidth(halves[0])
nameStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) // Model split with colored bars per model
barStyle := lipgloss.NewStyle().Foreground(t.Accent)
pctStyle := lipgloss.NewStyle().Foreground(t.TextDim)
var modelBody strings.Builder var modelBody strings.Builder
limit := 5 limit := 5
if len(models) < limit { if len(models) < limit {
@@ -150,18 +147,36 @@ func (a App) renderOverviewTab(cw int) string {
if barMaxLen < 1 { if barMaxLen < 1 {
barMaxLen = 1 barMaxLen = 1
} }
for _, ms := range models[:limit] {
// 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 barLen := 0
if maxShare > 0 { if maxShare > 0 {
barLen = int(ms.SharePercent / maxShare * float64(barMaxLen)) barLen = int(ms.SharePercent / maxShare * float64(barMaxLen))
} }
fmt.Fprintf(&modelBody, "%s %s %s\n",
nameStyle.Render(fmt.Sprintf("%-*s", nameW, shortModel(ms.Model))), colorIdx := i % len(modelColors)
barStyle.Render(strings.Repeat("", barLen)), modelBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, shortModel(ms.Model))))
pctStyle.Render(fmt.Sprintf("%.0f%%", ms.SharePercent))) 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")
} }
// Compact activity: aggregate prompts into 4-hour buckets // Activity patterns with time-of-day coloring
now := time.Now() now := time.Now()
since := now.AddDate(0, 0, -a.days) since := now.AddDate(0, 0, -a.days)
hours := pipeline.AggregateHourly(a.filtered, since, now) hours := pipeline.AggregateHourly(a.filtered, since, now)
@@ -172,11 +187,11 @@ func (a App) renderOverviewTab(cw int) string {
color lipgloss.Color color lipgloss.Color
} }
buckets := []actBucket{ buckets := []actBucket{
{"Night 00-03", 0, t.Red}, {"Night 00-03", 0, t.Magenta},
{"Early 04-07", 0, t.Yellow}, {"Early 04-07", 0, t.Orange},
{"Morning 08-11", 0, t.Green}, {"Morning 08-11", 0, t.GreenBright},
{"Midday 12-15", 0, t.Green}, {"Midday 12-15", 0, t.Green},
{"Evening 16-19", 0, t.Green}, {"Evening 16-19", 0, t.Cyan},
{"Late 20-23", 0, t.Yellow}, {"Late 20-23", 0, t.Yellow},
} }
for _, h := range hours { for _, h := range hours {
@@ -196,31 +211,34 @@ func (a App) renderOverviewTab(cw int) string {
actInnerW := components.CardInnerWidth(halves[1]) actInnerW := components.CardInnerWidth(halves[1])
// Compute number column width from actual data so bars never overflow. // Compute number column width
maxNumW := 5 maxNumW := 5
for _, bk := range buckets { for _, bk := range buckets {
if nw := len(cli.FormatNumber(int64(bk.total))); nw > maxNumW { if nw := len(cli.FormatNumber(int64(bk.total))); nw > maxNumW {
maxNumW = nw maxNumW = nw
} }
} }
// prefix = 13 (label) + 1 (space) + maxNumW (number) + 1 (space)
actBarMax := actInnerW - 15 - maxNumW actBarMax := actInnerW - 15 - maxNumW
if actBarMax < 1 { if actBarMax < 1 {
actBarMax = 1 actBarMax = 1
} }
numStyle := lipgloss.NewStyle().Foreground(t.TextMuted) labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
numStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
var actBody strings.Builder var actBody strings.Builder
for _, bk := range buckets { for _, bk := range buckets {
bl := 0 bl := 0
if maxBucket > 0 { if maxBucket > 0 {
bl = bk.total * actBarMax / maxBucket bl = bk.total * actBarMax / maxBucket
} }
bar := lipgloss.NewStyle().Foreground(bk.color).Render(strings.Repeat("█", bl)) barStyle := lipgloss.NewStyle().Foreground(bk.color).Background(t.Surface)
fmt.Fprintf(&actBody, "%s %s %s\n", actBody.WriteString(labelStyle.Render(bk.label))
numStyle.Render(bk.label), actBody.WriteString(sepStyle.Render(" "))
numStyle.Render(fmt.Sprintf("%*s", maxNumW, cli.FormatNumber(int64(bk.total)))), actBody.WriteString(numStyle.Render(fmt.Sprintf("%*s", maxNumW, cli.FormatNumber(int64(bk.total)))))
bar) actBody.WriteString(sepStyle.Render(" "))
actBody.WriteString(barStyle.Render(strings.Repeat("█", bl)))
actBody.WriteString("\n")
} }
modelCard := components.ContentCard("Model Split", modelBody.String(), halves[0]) modelCard := components.ContentCard("Model Split", modelBody.String(), halves[0])
@@ -236,7 +254,7 @@ func (a App) renderOverviewTab(cw int) string {
return b.String() return b.String()
} }
// hourLabels24 returns X-axis labels for 24 hourly buckets (one per hour). // hourLabels24 returns X-axis labels for 24 hourly buckets.
func hourLabels24() []string { func hourLabels24() []string {
labels := make([]string, 24) labels := make([]string, 24)
for i := 0; i < 24; i++ { for i := 0; i < 24; i++ {
@@ -253,8 +271,7 @@ func hourLabels24() []string {
return labels return labels
} }
// minuteLabels returns X-axis labels for 12 five-minute buckets (one per bucket). // minuteLabels returns X-axis labels for 12 five-minute buckets.
// Bucket 0 is oldest (55-60 min ago), bucket 11 is newest (0-5 min ago).
func minuteLabels() []string { func minuteLabels() []string {
return []string{"-55", "-50", "-45", "-40", "-35", "-30", "-25", "-20", "-15", "-10", "-5", "now"} return []string{"-55", "-50", "-45", "-40", "-35", "-30", "-25", "-20", "-15", "-10", "-5", "now"}
} }

View File

@@ -21,6 +21,13 @@ 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
@@ -79,17 +86,21 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str
// Show search input when in search mode // Show search input when in search mode
if ss.searching { if ss.searching {
var b strings.Builder var b strings.Builder
searchStyle := lipgloss.NewStyle().Foreground(t.Accent) searchStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true)
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
b.WriteString(searchStyle.Render(" Search: ")) b.WriteString(searchStyle.Render(" Search: "))
b.WriteString(ss.searchInput.View()) b.WriteString(ss.searchInput.View())
b.WriteString("\n") b.WriteString("\n")
hintStyle := lipgloss.NewStyle().Foreground(t.TextDim) hintStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
b.WriteString(hintStyle.Render(" [Enter] apply [Esc] cancel")) 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") b.WriteString("\n\n")
// Show preview of filtered results // Show preview of filtered results
previewFiltered := filterSessionsBySearch(a.filtered, ss.searchInput.Value()) previewFiltered := filterSessionsBySearch(a.filtered, ss.searchInput.Value())
b.WriteString(hintStyle.Render(fmt.Sprintf(" %d sessions match", len(previewFiltered)))) countStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
b.WriteString(countStyle.Render(fmt.Sprintf(" %d sessions match", len(previewFiltered))))
return b.String() return b.String()
} }
@@ -102,10 +113,10 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str
if len(filtered) == 0 { if len(filtered) == 0 {
var body strings.Builder var body strings.Builder
body.WriteString(lipgloss.NewStyle().Foreground(t.TextMuted).Render("No sessions found")) body.WriteString(lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface).Render("No sessions found"))
if ss.searchQuery != "" { if ss.searchQuery != "" {
body.WriteString("\n\n") body.WriteString("\n\n")
body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Render("[Esc] clear search [/] new search")) body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface).Render("[Esc] clear search [/] new search"))
} }
return components.ContentCard(title, body.String(), cw) return components.ContentCard(title, body.String(), cw)
} }
@@ -157,15 +168,15 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
// 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
@@ -198,18 +209,22 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
} }
if i == cursor { if i == cursor {
fullLine := leftPart + strings.Repeat(" ", padN) + costStr // Selected row with bright background and accent marker
// Pad to full width for continuous highlight background selectedCostStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.SurfaceBright).Bold(true)
if len(fullLine) < leftInner { marker := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.SurfaceBright).Render("▸ ")
fullLine += strings.Repeat(" ", leftInner-len(fullLine)) leftBody.WriteString(marker + selectedStyle.Render(leftPart) +
} lipgloss.NewStyle().Background(t.SurfaceBright).Render(strings.Repeat(" ", max(1, padN-2))) +
leftBody.WriteString(selectedStyle.Render(fullLine)) selectedCostStyle.Render(costStr) +
lipgloss.NewStyle().Background(t.SurfaceBright).Render(strings.Repeat(" ", max(0, leftInner-len(leftPart)-padN-len(costStr)))))
} else { } else {
// Normal row
leftBody.WriteString( leftBody.WriteString(
mutedStyle.Render(fmt.Sprintf("%-13s", startStr)) + " " + lipgloss.NewStyle().Background(t.Surface).Render(" ") +
mutedStyle.Render(fmt.Sprintf("%-13s", startStr)) +
lipgloss.NewStyle().Background(t.Surface).Render(" ") +
rowStyle.Render(dur) + rowStyle.Render(dur) +
strings.Repeat(" ", padN) + lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", padN-2)) +
mutedStyle.Render(costStr)) costStyle.Render(costStr))
} }
leftBody.WriteString("\n") leftBody.WriteString("\n")
} }
@@ -223,10 +238,10 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
// Right pane: full session detail with scroll support // Right pane: full session detail with scroll support
sel := sessions[cursor] sel := sessions[cursor]
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle) rightBody := a.renderDetailBody(sel, rightW, mutedStyle)
// Apply detail scroll offset // Apply detail scroll offset
rightBody = a.applyDetailScroll(rightBody, h-4) // card border (2) + title (1) + gap (1) rightBody = a.applyDetailScroll(rightBody, h-sessDetailOverhead)
titleStr := "Session " + shortID(sel.SessionID) titleStr := "Session " + shortID(sel.SessionID)
rightCard := components.ContentCard(titleStr, rightBody, rightW) rightCard := components.ContentCard(titleStr, rightBody, rightW)
@@ -248,11 +263,10 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin
} }
sel := sessions[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-4) body = a.applyDetailScroll(body, h-sessDetailOverhead)
title := "Session " + shortID(sel.SessionID) title := "Session " + shortID(sel.SessionID)
return components.ContentCard(title, body, cw) return components.ContentCard(title, body, cw)
@@ -260,21 +274,28 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin
// 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")
@@ -282,28 +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")
fmt.Fprintf(&body, "%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)
} }
fmt.Fprintf(&body, "%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")
typeW, tokW, costW, tableW := tokenTableLayout(innerW) typeW, tokW, costW, tableW := tokenTableLayout(innerW)
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %*s %*s", typeW, "Type", tokW, "Tokens", costW, "Cost"))) 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("─", tableW))) 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)
@@ -342,37 +377,35 @@ 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("%-*s %*s %*s", body.WriteString(labelStyle.Render(fmt.Sprintf("%-*s", typeW, truncStr(r.typ, typeW))))
typeW, body.WriteString(dimStyle.Render(" "))
truncStr(r.typ, typeW), body.WriteString(tokenStyle.Render(fmt.Sprintf("%*s", tokW, cli.FormatTokens(r.tokens))))
tokW, body.WriteString(dimStyle.Render(" "))
cli.FormatTokens(r.tokens), body.WriteString(costStyle.Render(fmt.Sprintf("%*s", costW, cli.FormatCost(r.cost))))
costW,
cli.FormatCost(r.cost))))
body.WriteString("\n") body.WriteString("\n")
} }
body.WriteString(mutedStyle.Render(strings.Repeat("─", tableW))) 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")
fmt.Fprintf(&body, "%-*s %*s %*s\n",
typeW,
valueStyle.Render("Net Cost"),
tokW,
"",
costW,
greenStyle.Render(cli.FormatCost(sel.EstimatedCost)))
fmt.Fprintf(&body, "%-*s %*s %*s\n",
typeW,
labelStyle.Render("Cache Savings"),
tokW,
"",
costW,
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")
compactModelTable := innerW < 60 compactModelTable := innerW < 60
if compactModelTable { if compactModelTable {
@@ -380,14 +413,14 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
if modelW < 8 { if modelW < 8 {
modelW = 8 modelW = 8
} }
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %8s", modelW, "Model", "Calls", "Cost"))) 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("─", modelW+7+8+2))) body.WriteString(dimStyle.Render(strings.Repeat("─", modelW+7+8+2)))
} else { } else {
modelW := 14 modelW := 14
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", modelW, "Model", "Calls", "Input", "Output", "Cost"))) body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", modelW, "Model", "Calls", "Input", "Output", "Cost")))
body.WriteString("\n") body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+10+10+8+4))) body.WriteString(dimStyle.Render(strings.Repeat("─", modelW+7+10+10+8+4)))
} }
body.WriteString("\n") body.WriteString("\n")
@@ -405,20 +438,22 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
if modelW < 8 { if modelW < 8 {
modelW = 8 modelW = 8
} }
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %8s", body.WriteString(modelStyle.Render(fmt.Sprintf("%-*s", modelW, truncStr(shortModel(modelName), modelW))))
modelW, body.WriteString(dimStyle.Render(" "))
truncStr(shortModel(modelName), modelW), body.WriteString(valueStyle.Render(fmt.Sprintf("%7s", cli.FormatNumber(int64(mu.APICalls)))))
cli.FormatNumber(int64(mu.APICalls)), body.WriteString(dimStyle.Render(" "))
cli.FormatCost(mu.EstimatedCost)))) body.WriteString(costStyle.Render(fmt.Sprintf("%8s", cli.FormatCost(mu.EstimatedCost))))
} else { } else {
modelW := 14 modelW := 14
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", body.WriteString(modelStyle.Render(fmt.Sprintf("%-*s", modelW, truncStr(shortModel(modelName), modelW))))
modelW, body.WriteString(dimStyle.Render(" "))
truncStr(shortModel(modelName), modelW), body.WriteString(valueStyle.Render(fmt.Sprintf("%7s", cli.FormatNumber(int64(mu.APICalls)))))
cli.FormatNumber(int64(mu.APICalls)), body.WriteString(dimStyle.Render(" "))
cli.FormatTokens(mu.InputTokens), body.WriteString(tokenStyle.Render(fmt.Sprintf("%10s", cli.FormatTokens(mu.InputTokens))))
cli.FormatTokens(mu.OutputTokens), body.WriteString(dimStyle.Render(" "))
cli.FormatCost(mu.EstimatedCost)))) 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")
} }
@@ -426,23 +461,23 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
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 // Subagent drill-down with colors
if subs := a.subagentMap[sel.SessionID]; len(subs) > 0 { if subs := a.subagentMap[sel.SessionID]; len(subs) > 0 {
body.WriteString("\n") body.WriteString("\n")
body.WriteString(headerStyle.Render(fmt.Sprintf("SUBAGENTS (%d)", len(subs)))) body.WriteString(sectionStyle.Render(fmt.Sprintf("SUBAGENTS (%d)", len(subs))))
body.WriteString("\n") body.WriteString("\n")
nameW := innerW - 8 - 10 - 2 nameW := innerW - 8 - 10 - 2
if nameW < 10 { if nameW < 10 {
nameW = 10 nameW = 10
} }
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s", nameW, "Agent", "Duration", "Cost"))) body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%-*s %8s %10s", nameW, "Agent", "Duration", "Cost")))
body.WriteString("\n") body.WriteString("\n")
body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2))) body.WriteString(dimStyle.Render(strings.Repeat("─", nameW+8+10+2)))
body.WriteString("\n") body.WriteString("\n")
var totalSubCost float64 var totalSubCost float64
@@ -455,31 +490,41 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
} }
agentName = strings.TrimPrefix(agentName, "agent-") agentName = strings.TrimPrefix(agentName, "agent-")
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s", body.WriteString(modelStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(agentName, nameW))))
nameW, body.WriteString(dimStyle.Render(" "))
truncStr(agentName, nameW), body.WriteString(timeStyle.Render(fmt.Sprintf("%8s", cli.FormatDuration(sub.DurationSecs))))
cli.FormatDuration(sub.DurationSecs), body.WriteString(dimStyle.Render(" "))
cli.FormatCost(sub.EstimatedCost)))) body.WriteString(costStyle.Render(fmt.Sprintf("%10s", cli.FormatCost(sub.EstimatedCost))))
body.WriteString("\n") body.WriteString("\n")
totalSubCost += sub.EstimatedCost totalSubCost += sub.EstimatedCost
totalSubDur += sub.DurationSecs totalSubDur += sub.DurationSecs
} }
body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2))) body.WriteString(dimStyle.Render(strings.Repeat("─", nameW+8+10+2)))
body.WriteString("\n") body.WriteString("\n")
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s", body.WriteString(accentStyle.Render(fmt.Sprintf("%-*s", nameW, "Combined")))
nameW, body.WriteString(dimStyle.Render(" "))
"Combined", body.WriteString(timeStyle.Render(fmt.Sprintf("%8s", cli.FormatDuration(totalSubDur))))
cli.FormatDuration(totalSubDur), body.WriteString(dimStyle.Render(" "))
cli.FormatCost(totalSubCost)))) body.WriteString(savingsStyle.Render(fmt.Sprintf("%10s", cli.FormatCost(totalSubCost))))
body.WriteString("\n") body.WriteString("\n")
} }
// Footer hints with styled keys
body.WriteString("\n") body.WriteString("\n")
hintKeyStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface)
hintTextStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
if w < compactWidth { if w < compactWidth {
body.WriteString(mutedStyle.Render("[/] search [j/k] navigate [J/K] scroll [q] quit")) 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 { } else {
body.WriteString(mutedStyle.Render("[/] search [Enter] expand [j/k] navigate [J/K/^d/^u] scroll [q] quit")) 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()
@@ -495,8 +540,8 @@ func shortID(id string) string {
// applyDetailScroll applies the detail pane scroll offset to a rendered body string. // 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. // visibleH is the number of lines that fit in the card body area.
func (a App) applyDetailScroll(body string, visibleH int) string { func (a App) applyDetailScroll(body string, visibleH int) string {
if visibleH < 5 { if visibleH < sessMinVisible {
visibleH = 5 visibleH = sessMinVisible
} }
lines := strings.Split(body, "\n") lines := strings.Split(body, "\n")
@@ -526,7 +571,7 @@ func (a App) applyDetailScroll(body string, visibleH int) string {
// Count includes the line we're replacing + lines past the viewport. // Count includes the line we're replacing + lines past the viewport.
if endIdx < len(lines) { if endIdx < len(lines) {
unseen := len(lines) - endIdx + 1 unseen := len(lines) - endIdx + 1
dimStyle := lipgloss.NewStyle().Foreground(theme.Active.TextDim) dimStyle := lipgloss.NewStyle().Foreground(theme.Active.TextDim).Background(theme.Active.Surface)
visible[len(visible)-1] = dimStyle.Render(fmt.Sprintf("... %d more", unseen)) visible[len(visible)-1] = dimStyle.Render(fmt.Sprintf("... %d more", unseen))
} }

View File

@@ -46,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
@@ -123,7 +123,7 @@ 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 {
@@ -176,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
@@ -235,23 +237,39 @@ func (a App) renderSettingsTab(cw int) string {
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 {

View File

@@ -6,89 +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)
TextDim lipgloss.Color SurfaceBright lipgloss.Color // Extra bright surface for emphasis
TextMuted lipgloss.Color Border lipgloss.Color // Subtle borders
TextPrimary lipgloss.Color BorderBright lipgloss.Color // Prominent borders (cards, focus)
Accent lipgloss.Color BorderAccent lipgloss.Color // Accent-colored borders for focus states
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
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"),
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"),
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"),
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"),
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"),
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"),
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"),
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"),
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.