Compare commits
9 Commits
3668ae7f70
...
302f34ff85
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
302f34ff85 | ||
|
|
0416d029b1 | ||
|
|
901090f921 | ||
|
|
c15dc8b487 | ||
|
|
19b8bab5d8 | ||
|
|
e1e322f4c9 | ||
|
|
0e80a6c1d1 | ||
|
|
8c1beb7a8a | ||
|
|
7157886546 |
10
BACKBURNER.md
Normal file
10
BACKBURNER.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Backburner Ideas
|
||||||
|
|
||||||
|
Last updated: February 23, 2026
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Separate pricing semantics for subscription vs API usage:
|
||||||
|
- `cburn` should model Claude subscription economics independently from Admin/API token pricing.
|
||||||
|
- Admin Cost API integration is still valuable for API-key workflows, but should not be treated as the canonical source for subscription usage.
|
||||||
|
- Revisit after daemon/event infrastructure work stabilizes.
|
||||||
236
CEO_PITCH_DECKS.md
Normal file
236
CEO_PITCH_DECKS.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# CEO Meeting Script: Four Daemon-Enabled Product Bets
|
||||||
|
|
||||||
|
## Opening
|
||||||
|
Today I want to walk through four product bets that become possible because we now have a continuously running daemon that can observe usage as it happens, not just after the fact.
|
||||||
|
|
||||||
|
I am not presenting speculative AI magic. I am presenting four concrete products, each with a hard-nosed view of utility, feasibility, risk, and build path.
|
||||||
|
|
||||||
|
The four bets are:
|
||||||
|
1. Cognitive Flight Recorder
|
||||||
|
2. Runaway Loop Quencher
|
||||||
|
3. Session Archeology Engine
|
||||||
|
4. Latent Tool ROI Scanner
|
||||||
|
|
||||||
|
My recommendation is not “build all at once.” My recommendation is staged execution with clear kill criteria.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Cognitive Flight Recorder
|
||||||
|
|
||||||
|
### The pitch
|
||||||
|
If our AI spend spikes tomorrow, leadership will ask two questions immediately: what happened, and why did it happen. Right now, we can answer “how much,” but we cannot reliably answer “why.”
|
||||||
|
|
||||||
|
The Cognitive Flight Recorder solves that. It turns a costly session into a replayable incident timeline. Not a dashboard snapshot. A sequence: where cost accelerated, where model behavior changed, where cache efficiency collapsed, and where the session crossed from productive to expensive.
|
||||||
|
|
||||||
|
This product creates operational trust. When AI systems are expensive, trust depends on explainability under stress.
|
||||||
|
|
||||||
|
### Why we need it
|
||||||
|
Postmortems are currently slow and anecdotal. Engineers reconstruct stories manually. Finance gets numbers without causality. Leadership gets noise.
|
||||||
|
|
||||||
|
A Flight Recorder makes AI spend investigable the same way we investigate reliability incidents. That is a major enterprise unlock.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
The daemon continuously captures session telemetry and emits timeline events. The recorder layers on top:
|
||||||
|
- It builds per-session event streams with timestamps, token deltas, model transitions, and cache transitions.
|
||||||
|
- It detects inflection points where cost trajectory changes materially.
|
||||||
|
- It generates a concise incident report: “what changed,” “likely causes,” and “which preventive policy would have helped.”
|
||||||
|
|
||||||
|
The output is not just visual. It is operational: replay + evidence + recommended guardrail.
|
||||||
|
|
||||||
|
### Downstream effects
|
||||||
|
If we execute this well, we get:
|
||||||
|
- Faster incident resolution for spend spikes.
|
||||||
|
- Better policy tuning because we can pinpoint the moment of failure.
|
||||||
|
- Stronger executive confidence in scaling agent usage.
|
||||||
|
- A compelling enterprise story: “we can explain every anomaly.”
|
||||||
|
|
||||||
|
### Skeptical view
|
||||||
|
Is it actually useful? Only if it leads to action. A pretty timeline that no one uses is dead weight.
|
||||||
|
|
||||||
|
Is it feasible with current harnesses? Partially. We can do strong metadata-level replay now. Deep semantic replay depends on richer telemetry and raises privacy concerns.
|
||||||
|
|
||||||
|
The critical risk is false causality: users may confuse sequence with cause. We mitigate this by attaching confidence levels and explicit evidence for every claim.
|
||||||
|
|
||||||
|
### Implementation roadmap
|
||||||
|
Phase 1 (2-3 weeks): metadata replay and “Top Cost Incidents” report.
|
||||||
|
Phase 2 (3-5 weeks): inflection detection and root-cause ranking with confidence scoring.
|
||||||
|
Phase 3 (4+ weeks): optional deep replay, privacy controls, and incident workflow integrations.
|
||||||
|
|
||||||
|
### Decision rule
|
||||||
|
Proceed if incident reports result in measurable policy changes. Kill or narrow if they remain passive observability artifacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Runaway Loop Quencher
|
||||||
|
|
||||||
|
### The pitch
|
||||||
|
Most bad AI spend is not one bad call. It is a loop: repeated expensive behavior with little progress. If we only detect this after the session, we are too late.
|
||||||
|
|
||||||
|
The Runaway Loop Quencher is an active safety layer. It watches live telemetry, identifies likely runaway patterns, and intervenes before the burn compounds.
|
||||||
|
|
||||||
|
This is the direct path to cost containment at runtime.
|
||||||
|
|
||||||
|
### Why we need it
|
||||||
|
Without active containment, scaling agent autonomy is financially unsafe. Teams become conservative. Leaders reduce usage. Innovation slows.
|
||||||
|
|
||||||
|
If we can intervene mid-flight, we convert catastrophic sessions into manageable sessions.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
The daemon computes rolling risk signals:
|
||||||
|
- accelerating cost per minute
|
||||||
|
- repetitive call signatures
|
||||||
|
- degrading cache performance
|
||||||
|
- high token growth with weak progress proxies
|
||||||
|
|
||||||
|
A policy engine converts those signals into action tiers:
|
||||||
|
- Soft: alert and suggest a reset strategy
|
||||||
|
- Guarded: require confirmation before continuing expensive patterns
|
||||||
|
- Hard: stop execution for supported harnesses
|
||||||
|
|
||||||
|
We start advisory-first, then move toward control where integrations allow.
|
||||||
|
|
||||||
|
### Downstream effects
|
||||||
|
If successful:
|
||||||
|
- fewer runaway incidents
|
||||||
|
- lower variance in daily spend
|
||||||
|
- greater confidence in letting agents run longer on valuable tasks
|
||||||
|
- ability to define budget safety SLOs
|
||||||
|
|
||||||
|
### Skeptical view
|
||||||
|
Is it actually useful? Yes, but only if precision is good. High false positives will cause immediate distrust and disablement.
|
||||||
|
|
||||||
|
Is it feasible given harness reality? Detection and alerting are feasible now. Hard-stop control is integration-dependent and not universally available.
|
||||||
|
|
||||||
|
The hard technical challenge is “progress.” We can estimate risk, but progress is not always machine-observable. That means we should not over-automate too early.
|
||||||
|
|
||||||
|
### Implementation roadmap
|
||||||
|
Phase 1 (2-4 weeks): risk scoring, alerting, and daemon risk endpoint.
|
||||||
|
Phase 2 (4-6 weeks): human confirmation gates and cooldown policies.
|
||||||
|
Phase 3 (6+ weeks): optional hard-stop integrations and policy simulation.
|
||||||
|
|
||||||
|
### Decision rule
|
||||||
|
Ship only if we can keep false positives low enough that teams keep it enabled. If intervention is frequently wrong, this product should remain advisory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Session Archeology Engine
|
||||||
|
|
||||||
|
### The pitch
|
||||||
|
Right now we can tell teams they spent too much. We cannot tell them which recurring behavior patterns caused it.
|
||||||
|
|
||||||
|
The Session Archeology Engine classifies sessions into behavioral archetypes and ties each archetype to practical intervention playbooks.
|
||||||
|
|
||||||
|
This turns raw telemetry into behavior change.
|
||||||
|
|
||||||
|
### Why we need it
|
||||||
|
People do not improve from aggregate numbers. They improve from named patterns and concrete alternatives.
|
||||||
|
|
||||||
|
If we can say, “These two session archetypes account for most avoidable spend, and here is exactly how to run them differently,” we create durable cost literacy.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
We extract session-level feature vectors:
|
||||||
|
- session shape and duration profile
|
||||||
|
- token composition and burstiness
|
||||||
|
- cache behavior
|
||||||
|
- model mix and switch behavior
|
||||||
|
- retry and repetition patterns
|
||||||
|
|
||||||
|
We cluster sessions and assign human-readable archetypes, then connect each archetype to:
|
||||||
|
- likely waste mechanism
|
||||||
|
- recommended policy/routing pattern
|
||||||
|
- suggested prompt and workflow changes
|
||||||
|
|
||||||
|
The output is both analytical and prescriptive.
|
||||||
|
|
||||||
|
### Downstream effects
|
||||||
|
If this works:
|
||||||
|
- managers coach with evidence instead of intuition
|
||||||
|
- teams adopt archetype-specific best practices
|
||||||
|
- routing policies improve faster because they target behaviors, not averages
|
||||||
|
- executives get clean narrative reporting on spend dynamics
|
||||||
|
|
||||||
|
### Skeptical view
|
||||||
|
Is it actually useful? It is useful only if archetypes stay stable and map to actions. Otherwise it becomes taxonomy theater.
|
||||||
|
|
||||||
|
Is it feasible? Yes, baseline version is feasible with existing metadata. Advanced value improves with richer tool and outcome signals.
|
||||||
|
|
||||||
|
Main risk: labels can drift as models and workflows change. We mitigate with periodic retraining, versioned labels, and strict “action attached” requirements.
|
||||||
|
|
||||||
|
### Implementation roadmap
|
||||||
|
Phase 1 (2-3 weeks): clustering baseline and weekly archetype report.
|
||||||
|
Phase 2 (3-5 weeks): intervention playbooks and policy recommendations per archetype.
|
||||||
|
Phase 3 (4+ weeks): team benchmarking and archetype drift alerts.
|
||||||
|
|
||||||
|
### Decision rule
|
||||||
|
Keep investing only if archetypes produce measurable behavior and cost improvements, not just better reporting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13) Latent Tool ROI Scanner
|
||||||
|
|
||||||
|
### The pitch
|
||||||
|
Model choice is not the only cost lever. Tool behavior often dominates spend efficiency, and today that layer is mostly invisible.
|
||||||
|
|
||||||
|
The Latent Tool ROI Scanner identifies which tools and workflows consume disproportionate cost relative to useful outcome, and recommends what to constrain, replace, or redesign.
|
||||||
|
|
||||||
|
This is potentially the highest upside concept, but also the highest epistemic risk.
|
||||||
|
|
||||||
|
### Why we need it
|
||||||
|
Optimization efforts usually target visible levers. Hidden tool-level waste can remain untouched for months.
|
||||||
|
|
||||||
|
If we can reveal negative-ROI tool patterns, we unlock savings without reducing strategic AI adoption.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
The scanner combines daemon telemetry with richer tool-event instrumentation:
|
||||||
|
- per-tool invocation frequency and cost footprint
|
||||||
|
- failure and retry signatures
|
||||||
|
- outcome proxies from delivery systems (tests, merges, ticket transitions)
|
||||||
|
|
||||||
|
It then computes conservative ROI scores and counterfactual scenarios:
|
||||||
|
- “If we reduce this pattern by 30%, estimated impact is X with confidence band Y.”
|
||||||
|
|
||||||
|
Recommendations are always evidence-backed and confidence-scored.
|
||||||
|
|
||||||
|
### Downstream effects
|
||||||
|
If accurate:
|
||||||
|
- identifies hidden spend sinks
|
||||||
|
- informs platform/tooling investments
|
||||||
|
- enables high-leverage policy changes with limited developer friction
|
||||||
|
- strengthens unit economics of agent operations
|
||||||
|
|
||||||
|
### Skeptical view
|
||||||
|
Is it actually useful today? Not fully. Without stronger outcome labeling, ROI claims can become fragile or misleading.
|
||||||
|
|
||||||
|
Is it feasible with current harnesses? Partially. We can pilot scoring frameworks, but high-confidence production decisions require instrumentation we do not yet have.
|
||||||
|
|
||||||
|
This is exactly where we should avoid overclaiming.
|
||||||
|
|
||||||
|
### Implementation roadmap
|
||||||
|
Phase 0 (1-2 weeks): instrumentation gap audit and schema design.
|
||||||
|
Phase 1 (3-4 weeks): tool-event ingestion and normalization pipeline.
|
||||||
|
Phase 2 (4-6 weeks): conservative ROI scoring + confidence intervals.
|
||||||
|
Phase 3 (4+ weeks): recommendation engine and controlled experiments.
|
||||||
|
|
||||||
|
### Decision rule
|
||||||
|
Treat as pilot until precision is validated against human review and external outcomes. If precision is weak, keep this as exploratory analytics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Portfolio recommendation and sequencing
|
||||||
|
|
||||||
|
If we prioritize for impact times feasibility:
|
||||||
|
1. Cognitive Flight Recorder
|
||||||
|
2. Session Archeology Engine
|
||||||
|
3. Runaway Loop Quencher (advisory first, control later)
|
||||||
|
4. Latent Tool ROI Scanner (pilot behind instrumentation gate)
|
||||||
|
|
||||||
|
This sequencing gives us near-term value while building the telemetry foundation needed for the harder products.
|
||||||
|
|
||||||
|
The overarching principle: every insight must be tied to an action, every action must be measurable, and every high-stakes claim must carry confidence.
|
||||||
|
|
||||||
|
## Closing
|
||||||
|
The daemon turns our system from retrospective analytics into a live control surface. These four products are how we monetize and operationalize that shift.
|
||||||
|
|
||||||
|
The question is not whether these ideas are interesting. The question is whether we can ship them with enough truthfulness that teams trust them.
|
||||||
|
|
||||||
|
With staged delivery and strict kill criteria, we can.
|
||||||
24
README.md
24
README.md
@@ -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
344
cmd/daemon.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/theirongolddev/cburn/internal/daemon"
|
||||||
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type daemonRuntimeState struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
flagDaemonAddr string
|
||||||
|
flagDaemonInterval time.Duration
|
||||||
|
flagDaemonDetach bool
|
||||||
|
flagDaemonPIDFile string
|
||||||
|
flagDaemonLogFile string
|
||||||
|
flagDaemonEventsBuffer int
|
||||||
|
flagDaemonChild bool
|
||||||
|
)
|
||||||
|
|
||||||
|
var daemonCmd = &cobra.Command{
|
||||||
|
Use: "daemon",
|
||||||
|
Short: "Run a background usage daemon with HTTP/SSE endpoints",
|
||||||
|
RunE: runDaemon,
|
||||||
|
}
|
||||||
|
|
||||||
|
var daemonStatusCmd = &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show daemon process and API status",
|
||||||
|
RunE: runDaemonStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
var daemonStopCmd = &cobra.Command{
|
||||||
|
Use: "stop",
|
||||||
|
Short: "Stop the running daemon",
|
||||||
|
RunE: runDaemonStop,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
defaultPID := filepath.Join(pipeline.CacheDir(), "cburnd.pid")
|
||||||
|
defaultLog := filepath.Join(pipeline.CacheDir(), "cburnd.log")
|
||||||
|
|
||||||
|
daemonCmd.PersistentFlags().StringVar(&flagDaemonAddr, "addr", "127.0.0.1:8787", "HTTP listen address")
|
||||||
|
daemonCmd.PersistentFlags().DurationVar(&flagDaemonInterval, "interval", 15*time.Second, "Polling interval")
|
||||||
|
daemonCmd.PersistentFlags().StringVar(&flagDaemonPIDFile, "pid-file", defaultPID, "PID file path")
|
||||||
|
daemonCmd.PersistentFlags().StringVar(&flagDaemonLogFile, "log-file", defaultLog, "Log file path for detached mode")
|
||||||
|
daemonCmd.PersistentFlags().IntVar(&flagDaemonEventsBuffer, "events-buffer", 200, "Max in-memory events retained")
|
||||||
|
|
||||||
|
daemonCmd.Flags().BoolVar(&flagDaemonDetach, "detach", false, "Run daemon as a background process")
|
||||||
|
daemonCmd.Flags().BoolVar(&flagDaemonChild, "child", false, "Internal: mark detached child process")
|
||||||
|
_ = daemonCmd.Flags().MarkHidden("child")
|
||||||
|
|
||||||
|
daemonCmd.AddCommand(daemonStatusCmd)
|
||||||
|
daemonCmd.AddCommand(daemonStopCmd)
|
||||||
|
rootCmd.AddCommand(daemonCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemon(_ *cobra.Command, _ []string) error {
|
||||||
|
if flagDaemonDetach && flagDaemonChild {
|
||||||
|
return errors.New("invalid daemon launch mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagDaemonDetach {
|
||||||
|
return startDaemonDetached()
|
||||||
|
}
|
||||||
|
|
||||||
|
return runDaemonForeground()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startDaemonDetached() error {
|
||||||
|
if err := ensureDaemonNotRunning(flagDaemonPIDFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := filterDetachArg(os.Args[1:])
|
||||||
|
args = append(args, "--child")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(flagDaemonPIDFile), 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create daemon directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(flagDaemonLogFile), 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create daemon log directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // daemon log path is configured by the local user
|
||||||
|
logf, err := os.OpenFile(flagDaemonLogFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open daemon log file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = logf.Close() }()
|
||||||
|
|
||||||
|
cmd := exec.Command(exe, args...) //nolint:gosec // exe/args come from current process invocation
|
||||||
|
cmd.Stdout = logf
|
||||||
|
cmd.Stderr = logf
|
||||||
|
cmd.Stdin = nil
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start detached daemon: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Started daemon (pid %d)\n", cmd.Process.Pid)
|
||||||
|
fmt.Printf(" PID file: %s\n", flagDaemonPIDFile)
|
||||||
|
fmt.Printf(" API: http://%s/v1/status\n", flagDaemonAddr)
|
||||||
|
fmt.Printf(" Log: %s\n", flagDaemonLogFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemonForeground() error {
|
||||||
|
if err := ensureDaemonNotRunning(flagDaemonPIDFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(flagDaemonPIDFile), 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create daemon directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pid := os.Getpid()
|
||||||
|
if err := writePID(flagDaemonPIDFile, pid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(flagDaemonPIDFile) }()
|
||||||
|
|
||||||
|
state := daemonRuntimeState{
|
||||||
|
PID: pid,
|
||||||
|
Addr: flagDaemonAddr,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
DataDir: flagDataDir,
|
||||||
|
}
|
||||||
|
_ = writeState(statePath(flagDaemonPIDFile), state)
|
||||||
|
defer func() { _ = os.Remove(statePath(flagDaemonPIDFile)) }()
|
||||||
|
|
||||||
|
cfg := daemon.Config{
|
||||||
|
DataDir: flagDataDir,
|
||||||
|
Days: flagDays,
|
||||||
|
ProjectFilter: flagProject,
|
||||||
|
ModelFilter: flagModel,
|
||||||
|
IncludeSubagents: !flagNoSubagents,
|
||||||
|
UseCache: !flagNoCache,
|
||||||
|
Interval: flagDaemonInterval,
|
||||||
|
Addr: flagDaemonAddr,
|
||||||
|
EventsBuffer: flagDaemonEventsBuffer,
|
||||||
|
}
|
||||||
|
svc := daemon.New(cfg)
|
||||||
|
|
||||||
|
fmt.Printf(" cburn daemon listening on http://%s\n", flagDaemonAddr)
|
||||||
|
fmt.Printf(" Polling every %s from %s\n", flagDaemonInterval, flagDataDir)
|
||||||
|
fmt.Printf(" Stop with: cburn daemon stop --pid-file %s\n", flagDaemonPIDFile)
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemonStatus(_ *cobra.Command, _ []string) error {
|
||||||
|
pid, err := readPID(flagDaemonPIDFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Daemon: not running (pid file not found)\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
alive := processAlive(pid)
|
||||||
|
if !alive {
|
||||||
|
fmt.Printf(" Daemon: stale pid file (pid %d not alive)\n", pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := flagDaemonAddr
|
||||||
|
if st, err := readState(statePath(flagDaemonPIDFile)); err == nil && st.Addr != "" {
|
||||||
|
addr = st.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Daemon PID: %d\n", pid)
|
||||||
|
fmt.Printf(" Address: http://%s\n", addr)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
resp, err := client.Get("http://" + addr + "/v1/status") //nolint:noctx // short status probe
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" API status: unreachable (%v)\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
fmt.Printf(" API status: HTTP %d\n", resp.StatusCode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var st daemon.Status
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
|
||||||
|
fmt.Printf(" API status: malformed response (%v)\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.LastPollAt.IsZero() {
|
||||||
|
fmt.Printf(" Last poll: pending\n")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Last poll: %s\n", st.LastPollAt.Local().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
fmt.Printf(" Poll count: %d\n", st.PollCount)
|
||||||
|
fmt.Printf(" Sessions: %d\n", st.Summary.Sessions)
|
||||||
|
fmt.Printf(" Tokens: %d\n", st.Summary.Tokens)
|
||||||
|
fmt.Printf(" Cost: $%.2f\n", st.Summary.EstimatedCostUSD)
|
||||||
|
if st.LastError != "" {
|
||||||
|
fmt.Printf(" Last error: %s\n", st.LastError)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemonStop(_ *cobra.Command, _ []string) error {
|
||||||
|
pid, err := readPID(flagDaemonPIDFile)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("daemon is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find daemon process: %w", err)
|
||||||
|
}
|
||||||
|
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||||
|
return fmt.Errorf("signal daemon process: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(8 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !processAlive(pid) {
|
||||||
|
_ = os.Remove(flagDaemonPIDFile)
|
||||||
|
_ = os.Remove(statePath(flagDaemonPIDFile))
|
||||||
|
fmt.Printf(" Stopped daemon (pid %d)\n", pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("daemon (pid %d) did not exit in time", pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterDetachArg(args []string) []string {
|
||||||
|
out := make([]string, 0, len(args))
|
||||||
|
for _, a := range args {
|
||||||
|
if a == "--detach" || strings.HasPrefix(a, "--detach=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDaemonNotRunning(pidFile string) error {
|
||||||
|
pid, err := readPID(pidFile)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if processAlive(pid) {
|
||||||
|
return fmt.Errorf("daemon already running (pid %d)", pid)
|
||||||
|
}
|
||||||
|
_ = os.Remove(pidFile)
|
||||||
|
_ = os.Remove(statePath(pidFile))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePID(path string, pid int) error {
|
||||||
|
return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPID(path string) (int, error) {
|
||||||
|
//nolint:gosec // daemon pid path is configured by the local user
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
pidStr := strings.TrimSpace(string(data))
|
||||||
|
pid, err := strconv.Atoi(pidStr)
|
||||||
|
if err != nil || pid <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid pid in %s", path)
|
||||||
|
}
|
||||||
|
return pid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processAlive(pid int) bool {
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
err = proc.Signal(syscall.Signal(0))
|
||||||
|
return err == nil || errors.Is(err, syscall.EPERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statePath(pidFile string) string {
|
||||||
|
return pidFile + ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeState(path string, st daemonRuntimeState) error {
|
||||||
|
data, err := json.MarshalIndent(st, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, append(data, '\n'), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readState(path string) (daemonRuntimeState, error) {
|
||||||
|
var st daemonRuntimeState
|
||||||
|
//nolint:gosec // daemon state path is configured by the local user
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &st); err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
@@ -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
398
internal/daemon/service.go
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
// Package daemon provides the long-running background usage monitor service.
|
||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/theirongolddev/cburn/internal/model"
|
||||||
|
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||||
|
"github.com/theirongolddev/cburn/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config controls the daemon runtime behavior.
|
||||||
|
type Config struct {
|
||||||
|
DataDir string
|
||||||
|
Days int
|
||||||
|
ProjectFilter string
|
||||||
|
ModelFilter string
|
||||||
|
IncludeSubagents bool
|
||||||
|
UseCache bool
|
||||||
|
Interval time.Duration
|
||||||
|
Addr string
|
||||||
|
EventsBuffer int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is a compact usage state for status/event payloads.
|
||||||
|
type Snapshot struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Sessions int `json:"sessions"`
|
||||||
|
Prompts int `json:"prompts"`
|
||||||
|
APICalls int `json:"api_calls"`
|
||||||
|
Tokens int64 `json:"tokens"`
|
||||||
|
EstimatedCostUSD float64 `json:"estimated_cost_usd"`
|
||||||
|
CacheHitRate float64 `json:"cache_hit_rate"`
|
||||||
|
CostPerDayUSD float64 `json:"cost_per_day_usd"`
|
||||||
|
TokensPerDay int64 `json:"tokens_per_day"`
|
||||||
|
SessionsPerDay float64 `json:"sessions_per_day"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delta captures snapshot deltas between polls.
|
||||||
|
type Delta struct {
|
||||||
|
Sessions int `json:"sessions"`
|
||||||
|
Prompts int `json:"prompts"`
|
||||||
|
APICalls int `json:"api_calls"`
|
||||||
|
Tokens int64 `json:"tokens"`
|
||||||
|
EstimatedCostUSD float64 `json:"estimated_cost_usd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Delta) isZero() bool {
|
||||||
|
return d.Sessions == 0 &&
|
||||||
|
d.Prompts == 0 &&
|
||||||
|
d.APICalls == 0 &&
|
||||||
|
d.Tokens == 0 &&
|
||||||
|
d.EstimatedCostUSD == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is emitted whenever usage snapshot updates.
|
||||||
|
type Event struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Snapshot Snapshot `json:"snapshot"`
|
||||||
|
Delta Delta `json:"delta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is served at /v1/status.
|
||||||
|
type Status struct {
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
LastPollAt time.Time `json:"last_poll_at"`
|
||||||
|
PollIntervalSec int `json:"poll_interval_sec"`
|
||||||
|
PollCount int64 `json:"poll_count"`
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
Days int `json:"days"`
|
||||||
|
ProjectFilter string `json:"project_filter,omitempty"`
|
||||||
|
ModelFilter string `json:"model_filter,omitempty"`
|
||||||
|
Summary Snapshot `json:"summary"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
EventCount int `json:"event_count"`
|
||||||
|
SubscriberCount int `json:"subscriber_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service provides the daemon runtime and HTTP API.
|
||||||
|
type Service struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
startedAt time.Time
|
||||||
|
lastPollAt time.Time
|
||||||
|
pollCount int64
|
||||||
|
lastError string
|
||||||
|
hasSnapshot bool
|
||||||
|
snapshot Snapshot
|
||||||
|
nextEventID int64
|
||||||
|
events []Event
|
||||||
|
|
||||||
|
nextSubID int
|
||||||
|
subs map[int]chan Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a new daemon service with the provided config.
|
||||||
|
func New(cfg Config) *Service {
|
||||||
|
if cfg.Interval < 2*time.Second {
|
||||||
|
cfg.Interval = 10 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.EventsBuffer < 1 {
|
||||||
|
cfg.EventsBuffer = 200
|
||||||
|
}
|
||||||
|
if cfg.Addr == "" {
|
||||||
|
cfg.Addr = "127.0.0.1:8787"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
cfg: cfg,
|
||||||
|
startedAt: time.Now(),
|
||||||
|
subs: make(map[int]chan Event),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts HTTP endpoints and polling until ctx is canceled.
|
||||||
|
func (s *Service) Run(ctx context.Context) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", s.handleHealth)
|
||||||
|
mux.HandleFunc("/v1/status", s.handleStatus)
|
||||||
|
mux.HandleFunc("/v1/events", s.handleEvents)
|
||||||
|
mux.HandleFunc("/v1/stream", s.handleStream)
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: s.cfg.Addr,
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Seed initial snapshot so status is useful immediately.
|
||||||
|
s.pollOnce()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(s.cfg.Interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return server.Shutdown(shutdownCtx)
|
||||||
|
case <-ticker.C:
|
||||||
|
s.pollOnce()
|
||||||
|
case err := <-errCh:
|
||||||
|
return fmt.Errorf("daemon http server: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) pollOnce() {
|
||||||
|
start := time.Now()
|
||||||
|
sessions, err := s.loadSessions()
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastError = err.Error()
|
||||||
|
s.lastPollAt = time.Now()
|
||||||
|
s.pollCount++
|
||||||
|
s.mu.Unlock()
|
||||||
|
log.Printf("cburn daemon poll error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
since := now.AddDate(0, 0, -s.cfg.Days)
|
||||||
|
|
||||||
|
filtered := sessions
|
||||||
|
if s.cfg.ProjectFilter != "" {
|
||||||
|
filtered = pipeline.FilterByProject(filtered, s.cfg.ProjectFilter)
|
||||||
|
}
|
||||||
|
if s.cfg.ModelFilter != "" {
|
||||||
|
filtered = pipeline.FilterByModel(filtered, s.cfg.ModelFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := pipeline.Aggregate(filtered, since, now)
|
||||||
|
snap := snapshotFromSummary(stats, now)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ev Event
|
||||||
|
publish bool
|
||||||
|
)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
prev := s.snapshot
|
||||||
|
prevExists := s.hasSnapshot
|
||||||
|
|
||||||
|
s.hasSnapshot = true
|
||||||
|
s.snapshot = snap
|
||||||
|
s.lastPollAt = now
|
||||||
|
s.pollCount++
|
||||||
|
s.lastError = ""
|
||||||
|
|
||||||
|
if !prevExists {
|
||||||
|
s.nextEventID++
|
||||||
|
ev = Event{
|
||||||
|
ID: s.nextEventID,
|
||||||
|
Type: "snapshot",
|
||||||
|
Timestamp: now,
|
||||||
|
Snapshot: snap,
|
||||||
|
Delta: Delta{},
|
||||||
|
}
|
||||||
|
publish = true
|
||||||
|
} else {
|
||||||
|
delta := diffSnapshots(prev, snap)
|
||||||
|
if !delta.isZero() {
|
||||||
|
s.nextEventID++
|
||||||
|
ev = Event{
|
||||||
|
ID: s.nextEventID,
|
||||||
|
Type: "usage_delta",
|
||||||
|
Timestamp: now,
|
||||||
|
Snapshot: snap,
|
||||||
|
Delta: delta,
|
||||||
|
}
|
||||||
|
publish = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if publish {
|
||||||
|
s.publishEvent(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = start
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadSessions() ([]model.SessionStats, error) {
|
||||||
|
if s.cfg.UseCache {
|
||||||
|
cache, err := store.Open(pipeline.CachePath())
|
||||||
|
if err == nil {
|
||||||
|
defer func() { _ = cache.Close() }()
|
||||||
|
cr, loadErr := pipeline.LoadWithCache(s.cfg.DataDir, s.cfg.IncludeSubagents, cache, nil)
|
||||||
|
if loadErr == nil {
|
||||||
|
return cr.Sessions, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := pipeline.Load(s.cfg.DataDir, s.cfg.IncludeSubagents, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Sessions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotFromSummary(stats model.SummaryStats, at time.Time) Snapshot {
|
||||||
|
return Snapshot{
|
||||||
|
At: at,
|
||||||
|
Sessions: stats.TotalSessions,
|
||||||
|
Prompts: stats.TotalPrompts,
|
||||||
|
APICalls: stats.TotalAPICalls,
|
||||||
|
Tokens: stats.TotalBilledTokens,
|
||||||
|
EstimatedCostUSD: stats.EstimatedCost,
|
||||||
|
CacheHitRate: stats.CacheHitRate,
|
||||||
|
CostPerDayUSD: stats.CostPerDay,
|
||||||
|
TokensPerDay: stats.TokensPerDay,
|
||||||
|
SessionsPerDay: stats.SessionsPerDay,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffSnapshots(prev, curr Snapshot) Delta {
|
||||||
|
return Delta{
|
||||||
|
Sessions: curr.Sessions - prev.Sessions,
|
||||||
|
Prompts: curr.Prompts - prev.Prompts,
|
||||||
|
APICalls: curr.APICalls - prev.APICalls,
|
||||||
|
Tokens: curr.Tokens - prev.Tokens,
|
||||||
|
EstimatedCostUSD: curr.EstimatedCostUSD - prev.EstimatedCostUSD,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) publishEvent(ev Event) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.events = append(s.events, ev)
|
||||||
|
if len(s.events) > s.cfg.EventsBuffer {
|
||||||
|
s.events = s.events[len(s.events)-s.cfg.EventsBuffer:]
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ch := range s.subs {
|
||||||
|
select {
|
||||||
|
case ch <- ev:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) snapshotStatus() Status {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
return Status{
|
||||||
|
StartedAt: s.startedAt,
|
||||||
|
LastPollAt: s.lastPollAt,
|
||||||
|
PollIntervalSec: int(s.cfg.Interval.Seconds()),
|
||||||
|
PollCount: s.pollCount,
|
||||||
|
DataDir: s.cfg.DataDir,
|
||||||
|
Days: s.cfg.Days,
|
||||||
|
ProjectFilter: s.cfg.ProjectFilter,
|
||||||
|
ModelFilter: s.cfg.ModelFilter,
|
||||||
|
Summary: s.snapshot,
|
||||||
|
LastError: s.lastError,
|
||||||
|
EventCount: len(s.events),
|
||||||
|
SubscriberCount: len(s.subs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte("ok\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleStatus(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(s.snapshotStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleEvents(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
s.mu.RLock()
|
||||||
|
events := make([]Event, len(s.events))
|
||||||
|
copy(events, s.events)
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
ch := make(chan Event, 16)
|
||||||
|
id := s.addSubscriber(ch)
|
||||||
|
defer s.removeSubscriber(id)
|
||||||
|
|
||||||
|
// Send current snapshot immediately.
|
||||||
|
current := Event{
|
||||||
|
Type: "snapshot",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Snapshot: s.snapshotStatus().Summary,
|
||||||
|
}
|
||||||
|
writeSSE(w, current)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case ev := <-ch:
|
||||||
|
writeSSE(w, ev)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSSE(w http.ResponseWriter, ev Event) {
|
||||||
|
data, err := json.Marshal(ev)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, "event: %s\n", ev.Type)
|
||||||
|
_, _ = fmt.Fprintf(w, "data: %s\n\n", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) addSubscriber(ch chan Event) int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.nextSubID++
|
||||||
|
id := s.nextSubID
|
||||||
|
s.subs[id] = ch
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) removeSubscriber(id int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.subs, id)
|
||||||
|
}
|
||||||
66
internal/daemon/service_test.go
Normal file
66
internal/daemon/service_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiffSnapshots(t *testing.T) {
|
||||||
|
prev := Snapshot{
|
||||||
|
Sessions: 10,
|
||||||
|
Prompts: 100,
|
||||||
|
APICalls: 120,
|
||||||
|
Tokens: 1_000_000,
|
||||||
|
EstimatedCostUSD: 10.5,
|
||||||
|
}
|
||||||
|
curr := Snapshot{
|
||||||
|
Sessions: 12,
|
||||||
|
Prompts: 112,
|
||||||
|
APICalls: 136,
|
||||||
|
Tokens: 1_250_000,
|
||||||
|
EstimatedCostUSD: 13.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := diffSnapshots(prev, curr)
|
||||||
|
if delta.Sessions != 2 {
|
||||||
|
t.Fatalf("Sessions delta = %d, want 2", delta.Sessions)
|
||||||
|
}
|
||||||
|
if delta.Prompts != 12 {
|
||||||
|
t.Fatalf("Prompts delta = %d, want 12", delta.Prompts)
|
||||||
|
}
|
||||||
|
if delta.APICalls != 16 {
|
||||||
|
t.Fatalf("APICalls delta = %d, want 16", delta.APICalls)
|
||||||
|
}
|
||||||
|
if delta.Tokens != 250_000 {
|
||||||
|
t.Fatalf("Tokens delta = %d, want 250000", delta.Tokens)
|
||||||
|
}
|
||||||
|
if math.Abs(delta.EstimatedCostUSD-2.6) > 1e-9 {
|
||||||
|
t.Fatalf("Cost delta = %.2f, want 2.60", delta.EstimatedCostUSD)
|
||||||
|
}
|
||||||
|
if delta.isZero() {
|
||||||
|
t.Fatal("delta unexpectedly reported as zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishEventRingBuffer(t *testing.T) {
|
||||||
|
s := New(Config{
|
||||||
|
DataDir: ".",
|
||||||
|
Interval: 10 * time.Second,
|
||||||
|
EventsBuffer: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
s.publishEvent(Event{ID: 1})
|
||||||
|
s.publishEvent(Event{ID: 2})
|
||||||
|
s.publishEvent(Event{ID: 3})
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(s.events) != 2 {
|
||||||
|
t.Fatalf("events len = %d, want 2", len(s.events))
|
||||||
|
}
|
||||||
|
if s.events[0].ID != 2 || s.events[1].ID != 3 {
|
||||||
|
t.Fatalf("events ring contains IDs [%d, %d], want [2, 3]", s.events[0].ID, s.events[1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
38
internal/tui/app_mouse_test.go
Normal file
38
internal/tui/app_mouse_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTabAtXMatchesTabWidths(t *testing.T) {
|
||||||
|
for active := 0; active < 5; active++ {
|
||||||
|
a := App{activeTab: active}
|
||||||
|
pos := 0
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
w := tabWidthForTest(i, active)
|
||||||
|
x := pos + w/2 // midpoint inside this tab
|
||||||
|
if got := a.tabAtX(x); got != i {
|
||||||
|
t.Fatalf("active=%d x=%d -> tab=%d, want %d", active, x, got, i)
|
||||||
|
}
|
||||||
|
pos += w
|
||||||
|
if i < 4 {
|
||||||
|
pos++ // separator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tabWidthForTest(tabIdx, activeIdx int) int {
|
||||||
|
nameWidths := []int{
|
||||||
|
len("Overview"),
|
||||||
|
len("Costs"),
|
||||||
|
len("Sessions"),
|
||||||
|
len("Breakdown"),
|
||||||
|
len("Settings"),
|
||||||
|
}
|
||||||
|
|
||||||
|
w := nameWidths[tabIdx] + 2 // horizontal padding in tab renderer
|
||||||
|
if tabIdx != activeIdx && tabIdx == 4 {
|
||||||
|
w += 3 // inactive Settings adds "[x]"
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
79
internal/tui/components/card_fix_test.go
Normal file
79
internal/tui/components/card_fix_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Force TrueColor output so ANSI codes are generated in tests
|
||||||
|
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardRowBackgroundFill(t *testing.T) {
|
||||||
|
// Initialize theme
|
||||||
|
theme.SetActive("flexoki-dark")
|
||||||
|
|
||||||
|
shortCard := ContentCard("Short", "Content", 22)
|
||||||
|
tallCard := ContentCard("Tall", "Line 1\nLine 2\nLine 3\nLine 4\nLine 5", 22)
|
||||||
|
|
||||||
|
shortLines := len(strings.Split(shortCard, "\n"))
|
||||||
|
tallLines := len(strings.Split(tallCard, "\n"))
|
||||||
|
|
||||||
|
t.Logf("Short card lines: %d", shortLines)
|
||||||
|
t.Logf("Tall card lines: %d", tallLines)
|
||||||
|
|
||||||
|
if shortLines >= tallLines {
|
||||||
|
t.Fatal("Test setup error: short card should be shorter than tall card")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the fixed CardRow
|
||||||
|
joined := CardRow([]string{tallCard, shortCard})
|
||||||
|
lines := strings.Split(joined, "\n")
|
||||||
|
t.Logf("Joined lines: %d", len(lines))
|
||||||
|
|
||||||
|
if len(lines) != tallLines {
|
||||||
|
t.Errorf("Joined height should match tallest card: got %d, want %d", len(lines), tallLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all lines have ANSI codes (indicating background styling)
|
||||||
|
for i, line := range lines {
|
||||||
|
hasESC := strings.Contains(line, "\x1b[")
|
||||||
|
// After the short card ends, the padding should still have ANSI codes
|
||||||
|
if i >= shortLines {
|
||||||
|
t.Logf("Line %d (padding): hasANSI=%v, raw=%q", i, hasESC, line)
|
||||||
|
if !hasESC {
|
||||||
|
t.Errorf("Line %d has NO ANSI codes - will show as black squares", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardRowWidthConsistency(t *testing.T) {
|
||||||
|
// Verify all lines have consistent width
|
||||||
|
theme.SetActive("flexoki-dark")
|
||||||
|
|
||||||
|
shortCard := ContentCard("Short", "A", 30)
|
||||||
|
tallCard := ContentCard("Tall", "A\nB\nC\nD\nE\nF", 20)
|
||||||
|
|
||||||
|
joined := CardRow([]string{tallCard, shortCard})
|
||||||
|
lines := strings.Split(joined, "\n")
|
||||||
|
|
||||||
|
// All lines should have the same visual width
|
||||||
|
for i, line := range lines {
|
||||||
|
w := len(line) // Raw byte length - will differ if ANSI codes vary
|
||||||
|
// Visual width should be consistent (tall card width + short card width)
|
||||||
|
// Using lipgloss.Width would be better but we're checking raw structure
|
||||||
|
t.Logf("Line %d: byteLen=%d", i, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the joined output has expected number of lines
|
||||||
|
tallLines := len(strings.Split(tallCard, "\n"))
|
||||||
|
if len(lines) != tallLines {
|
||||||
|
t.Errorf("Joined should have %d lines (tallest), got %d", tallLines, len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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++ {
|
|
||||||
b.WriteString(emptyStyle.Render("\u2591"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s %.1f%%", b.String(), pct*100)
|
return b.String() + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%.0f%%", pct*100))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ColorForPct returns green/yellow/red based on utilization level.
|
// ColorForPct returns green/yellow/orange/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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Choose color based on usage level
|
||||||
|
var barColor, pctColor lipgloss.Color
|
||||||
|
switch {
|
||||||
|
case pct >= 0.9:
|
||||||
|
barColor = t.Red
|
||||||
|
pctColor = t.Red
|
||||||
|
case pct >= 0.7:
|
||||||
|
barColor = t.Orange
|
||||||
|
pctColor = t.Orange
|
||||||
|
case pct >= 0.5:
|
||||||
|
barColor = t.Yellow
|
||||||
|
pctColor = t.Yellow
|
||||||
|
default:
|
||||||
|
barColor = t.Green
|
||||||
|
pctColor = t.Green
|
||||||
|
}
|
||||||
|
|
||||||
|
labelStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.TextMuted).
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
|
||||||
|
barStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(barColor).
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
|
||||||
|
emptyStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.TextDim).
|
||||||
|
Background(t.SurfaceHover)
|
||||||
|
|
||||||
|
pctStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(pctColor).
|
||||||
|
Background(t.SurfaceHover).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
// Render mini bar (8 chars)
|
||||||
barW := 8
|
barW := 8
|
||||||
bar := progress.New(
|
filled := int(pct * float64(barW))
|
||||||
progress.WithSolidFill(ColorForPct(pct)),
|
if filled > barW {
|
||||||
progress.WithWidth(barW),
|
filled = barW
|
||||||
progress.WithoutPercentage(),
|
}
|
||||||
)
|
|
||||||
bar.EmptyColor = string(t.TextDim)
|
|
||||||
|
|
||||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
bar := barStyle.Render(strings.Repeat("█", filled)) +
|
||||||
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
|
emptyStyle.Render(strings.Repeat("░", barW-filled))
|
||||||
|
|
||||||
return fmt.Sprintf("%s %s %s",
|
spaceStyle := lipgloss.NewStyle().
|
||||||
labelStyle.Render(label),
|
Background(t.SurfaceHover)
|
||||||
bar.ViewAs(pct),
|
|
||||||
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
|
return labelStyle.Render(label+" ") + bar + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
underline = lipgloss.NewStyle().
|
||||||
|
Foreground(t.Border).
|
||||||
|
Background(t.Surface).
|
||||||
|
Render(strings.Repeat("─", lipgloss.Width(tabContent)))
|
||||||
|
}
|
||||||
|
|
||||||
|
tabParts = append(tabParts, tabContent)
|
||||||
|
underlineParts = append(underlineParts, underline)
|
||||||
|
|
||||||
|
// Add separator between tabs (not after last)
|
||||||
|
if i < len(Tabs)-1 {
|
||||||
|
tabParts = append(tabParts, sepStyle.Render(" "))
|
||||||
|
underlineParts = append(underlineParts, sepStyle.Render(" "))
|
||||||
}
|
}
|
||||||
parts = append(parts, rendered)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bar := " " + strings.Join(parts, " ")
|
// Combine tab row and underline row
|
||||||
if lipgloss.Width(bar) <= width {
|
tabRow := strings.Join(tabParts, "")
|
||||||
return bar
|
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 lipgloss.NewStyle().MaxWidth(width).Render(bar)
|
|
||||||
|
return barStyle.Render(tabRow + "\n" + underlineRow)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -5,90 +5,140 @@ 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
|
||||||
Green lipgloss.Color
|
TextDim lipgloss.Color // Lowest contrast text (hints, disabled)
|
||||||
Orange lipgloss.Color
|
TextMuted lipgloss.Color // Secondary text (labels, metadata)
|
||||||
Red lipgloss.Color
|
TextPrimary lipgloss.Color // Primary content text
|
||||||
Blue lipgloss.Color
|
Accent lipgloss.Color // Primary accent (links, active states)
|
||||||
Yellow lipgloss.Color
|
AccentBright lipgloss.Color // Brighter accent for emphasis
|
||||||
|
AccentDim lipgloss.Color // Dimmed accent for backgrounds
|
||||||
|
Green lipgloss.Color
|
||||||
|
GreenBright lipgloss.Color
|
||||||
|
Orange lipgloss.Color
|
||||||
|
Red lipgloss.Color
|
||||||
|
Blue lipgloss.Color
|
||||||
|
BlueBright 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"),
|
||||||
TextDim: lipgloss.Color("#575653"),
|
SurfaceBright: lipgloss.Color("#343331"),
|
||||||
TextMuted: lipgloss.Color("#6F6E69"),
|
Border: lipgloss.Color("#403E3C"),
|
||||||
TextPrimary: lipgloss.Color("#FFFCF0"),
|
BorderBright: lipgloss.Color("#575653"),
|
||||||
Accent: lipgloss.Color("#3AA99F"),
|
BorderAccent: lipgloss.Color("#3AA99F"),
|
||||||
Green: lipgloss.Color("#879A39"),
|
TextDim: lipgloss.Color("#575653"),
|
||||||
Orange: lipgloss.Color("#DA702C"),
|
TextMuted: lipgloss.Color("#878580"),
|
||||||
Red: lipgloss.Color("#D14D41"),
|
TextPrimary: lipgloss.Color("#FFFCF0"),
|
||||||
Blue: lipgloss.Color("#4385BE"),
|
Accent: lipgloss.Color("#3AA99F"),
|
||||||
Yellow: lipgloss.Color("#D0A215"),
|
AccentBright: lipgloss.Color("#5BC8BE"),
|
||||||
|
AccentDim: lipgloss.Color("#1A3533"),
|
||||||
|
Green: lipgloss.Color("#879A39"),
|
||||||
|
GreenBright: lipgloss.Color("#A3B859"),
|
||||||
|
Orange: lipgloss.Color("#DA702C"),
|
||||||
|
Red: lipgloss.Color("#D14D41"),
|
||||||
|
Blue: lipgloss.Color("#4385BE"),
|
||||||
|
BlueBright: lipgloss.Color("#6BA3D6"),
|
||||||
|
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"),
|
||||||
TextDim: lipgloss.Color("#6C7086"),
|
SurfaceBright: lipgloss.Color("#585B70"),
|
||||||
TextMuted: lipgloss.Color("#A6ADC8"),
|
Border: lipgloss.Color("#585B70"),
|
||||||
TextPrimary: lipgloss.Color("#CDD6F4"),
|
BorderBright: lipgloss.Color("#7F849C"),
|
||||||
Accent: lipgloss.Color("#89B4FA"),
|
BorderAccent: lipgloss.Color("#89B4FA"),
|
||||||
Green: lipgloss.Color("#A6E3A1"),
|
TextDim: lipgloss.Color("#6C7086"),
|
||||||
Orange: lipgloss.Color("#FAB387"),
|
TextMuted: lipgloss.Color("#A6ADC8"),
|
||||||
Red: lipgloss.Color("#F38BA8"),
|
TextPrimary: lipgloss.Color("#CDD6F4"),
|
||||||
Blue: lipgloss.Color("#89B4FA"),
|
Accent: lipgloss.Color("#89B4FA"),
|
||||||
Yellow: lipgloss.Color("#F9E2AF"),
|
AccentBright: lipgloss.Color("#B4D0FB"),
|
||||||
|
AccentDim: lipgloss.Color("#293147"),
|
||||||
|
Green: lipgloss.Color("#A6E3A1"),
|
||||||
|
GreenBright: lipgloss.Color("#C6F6C1"),
|
||||||
|
Orange: lipgloss.Color("#FAB387"),
|
||||||
|
Red: lipgloss.Color("#F38BA8"),
|
||||||
|
Blue: lipgloss.Color("#89B4FA"),
|
||||||
|
BlueBright: lipgloss.Color("#B4D0FB"),
|
||||||
|
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"),
|
||||||
TextDim: lipgloss.Color("#565F89"),
|
SurfaceBright: lipgloss.Color("#414868"),
|
||||||
TextMuted: lipgloss.Color("#A9B1D6"),
|
Border: lipgloss.Color("#565F89"),
|
||||||
TextPrimary: lipgloss.Color("#C0CAF5"),
|
BorderBright: lipgloss.Color("#7982A9"),
|
||||||
Accent: lipgloss.Color("#7AA2F7"),
|
BorderAccent: lipgloss.Color("#7AA2F7"),
|
||||||
Green: lipgloss.Color("#9ECE6A"),
|
TextDim: lipgloss.Color("#565F89"),
|
||||||
Orange: lipgloss.Color("#FF9E64"),
|
TextMuted: lipgloss.Color("#A9B1D6"),
|
||||||
Red: lipgloss.Color("#F7768E"),
|
TextPrimary: lipgloss.Color("#C0CAF5"),
|
||||||
Blue: lipgloss.Color("#7AA2F7"),
|
Accent: lipgloss.Color("#7AA2F7"),
|
||||||
Yellow: lipgloss.Color("#E0AF68"),
|
AccentBright: lipgloss.Color("#A9C1FF"),
|
||||||
|
AccentDim: lipgloss.Color("#252B3F"),
|
||||||
|
Green: lipgloss.Color("#9ECE6A"),
|
||||||
|
GreenBright: lipgloss.Color("#B9E87A"),
|
||||||
|
Orange: lipgloss.Color("#FF9E64"),
|
||||||
|
Red: lipgloss.Color("#F7768E"),
|
||||||
|
Blue: lipgloss.Color("#7AA2F7"),
|
||||||
|
BlueBright: lipgloss.Color("#A9C1FF"),
|
||||||
|
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"),
|
||||||
Border: lipgloss.Color("8"),
|
SurfaceHover: lipgloss.Color("8"),
|
||||||
TextDim: lipgloss.Color("8"),
|
SurfaceBright: lipgloss.Color("8"),
|
||||||
TextMuted: lipgloss.Color("7"),
|
Border: lipgloss.Color("8"),
|
||||||
TextPrimary: lipgloss.Color("15"),
|
BorderBright: lipgloss.Color("7"),
|
||||||
Accent: lipgloss.Color("6"),
|
BorderAccent: lipgloss.Color("6"),
|
||||||
Green: lipgloss.Color("2"),
|
TextDim: lipgloss.Color("8"),
|
||||||
Orange: lipgloss.Color("3"),
|
TextMuted: lipgloss.Color("7"),
|
||||||
Red: lipgloss.Color("1"),
|
TextPrimary: lipgloss.Color("15"),
|
||||||
Blue: lipgloss.Color("4"),
|
Accent: lipgloss.Color("6"),
|
||||||
Yellow: lipgloss.Color("3"),
|
AccentBright: lipgloss.Color("14"),
|
||||||
|
AccentDim: lipgloss.Color("0"),
|
||||||
|
Green: lipgloss.Color("2"),
|
||||||
|
GreenBright: lipgloss.Color("10"),
|
||||||
|
Orange: lipgloss.Color("3"),
|
||||||
|
Red: lipgloss.Color("1"),
|
||||||
|
Blue: lipgloss.Color("4"),
|
||||||
|
BlueBright: lipgloss.Color("12"),
|
||||||
|
Yellow: lipgloss.Color("3"),
|
||||||
|
Magenta: lipgloss.Color("5"),
|
||||||
|
Cyan: lipgloss.Color("6"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// All available themes.
|
// All available themes.
|
||||||
|
|||||||
Reference in New Issue
Block a user