Compare commits
9 Commits
3668ae7f70
...
master
| 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 costs # Cost breakdown by token type
|
||||
cburn status # Claude.ai subscription status
|
||||
cburn daemon --detach # Background usage daemon + local API
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
@@ -50,6 +51,7 @@ cburn status # Claude.ai subscription status
|
||||
| `cburn models` | Model usage breakdown |
|
||||
| `cburn projects` | Project usage ranking |
|
||||
| `cburn status` | Claude.ai subscription status and rate limits |
|
||||
| `cburn daemon` | Background daemon with JSON/SSE usage API |
|
||||
| `cburn config` | Show current configuration |
|
||||
| `cburn setup` | Interactive first-time setup wizard |
|
||||
| `cburn tui` | Interactive dashboard |
|
||||
@@ -73,6 +75,27 @@ cburn -n 7 # Last 7 days
|
||||
cburn costs -p myproject # Costs for a specific project
|
||||
cburn sessions -m opus # Sessions using Opus models
|
||||
cburn daily --no-subagents # Exclude spawned agents
|
||||
cburn daemon --detach # Start daemon in background
|
||||
cburn daemon status # Check daemon health and latest totals
|
||||
cburn daemon stop # Stop daemon
|
||||
```
|
||||
|
||||
## Daemon Mode
|
||||
|
||||
`cburn daemon` runs a long-lived polling service that keeps usage snapshots warm and exposes local endpoints for downstream tools.
|
||||
|
||||
Default endpoint: `http://127.0.0.1:8787`
|
||||
|
||||
- `GET /healthz` - liveness probe
|
||||
- `GET /v1/status` - current aggregate snapshot and daemon runtime status
|
||||
- `GET /v1/events` - recent event buffer (JSON array)
|
||||
- `GET /v1/stream` - Server-Sent Events stream (`snapshot`, `usage_delta`)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
cburn daemon --detach --interval 10s
|
||||
curl -s http://127.0.0.1:8787/v1/status | jq
|
||||
```
|
||||
|
||||
## TUI Dashboard
|
||||
@@ -194,6 +217,7 @@ make clean # Remove binary and test cache
|
||||
| `internal/store` | SQLite cache layer |
|
||||
| `internal/model` | Domain types |
|
||||
| `internal/config` | TOML config and pricing tables |
|
||||
| `internal/daemon` | Background polling daemon + local HTTP/SSE API |
|
||||
| `internal/cli` | Terminal formatting |
|
||||
| `internal/claudeai` | Claude.ai API client |
|
||||
| `internal/tui` | Bubble Tea dashboard |
|
||||
|
||||
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"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,10 @@ func runTUI(_ *cobra.Command, _ []string) error {
|
||||
cfg, _ := config.Load()
|
||||
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)
|
||||
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
|
||||
compactWidth = 120
|
||||
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.
|
||||
func NewApp(claudeDir string, days int, project, modelFilter string, includeSubagents bool) App {
|
||||
needSetup := !config.Exists()
|
||||
|
||||
sp := spinner.New()
|
||||
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
|
||||
cfg, _ := config.Load()
|
||||
cfg := loadConfigOrDefault()
|
||||
refreshInterval := time.Duration(cfg.TUI.RefreshIntervalSec) * time.Second
|
||||
if refreshInterval < 10*time.Second {
|
||||
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
|
||||
cfg, _ := config.Load()
|
||||
cfg := loadConfigOrDefault()
|
||||
if sessionKey := config.GetSessionKey(cfg); sessionKey != "" {
|
||||
cmds = append(cmds, fetchSubDataCmd(sessionKey))
|
||||
}
|
||||
@@ -387,16 +407,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return a, nil
|
||||
case "ctrl+d":
|
||||
halfPage := (a.height - 10) / 2
|
||||
if halfPage < 1 {
|
||||
halfPage = 1
|
||||
halfPage := (a.height - scrollOverhead) / 2
|
||||
if halfPage < minHalfPageScroll {
|
||||
halfPage = minHalfPageScroll
|
||||
}
|
||||
a.sessState.detailScroll += halfPage
|
||||
return a, nil
|
||||
case "ctrl+u":
|
||||
halfPage := (a.height - 10) / 2
|
||||
if halfPage < 1 {
|
||||
halfPage = 1
|
||||
halfPage := (a.height - scrollOverhead) / 2
|
||||
if halfPage < minHalfPageScroll {
|
||||
halfPage = minHalfPageScroll
|
||||
}
|
||||
a.sessState.detailScroll -= halfPage
|
||||
if a.sessState.detailScroll < 0 {
|
||||
@@ -438,8 +458,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Toggle auto-refresh
|
||||
if key == "R" {
|
||||
a.autoRefresh = !a.autoRefresh
|
||||
// Persist to config
|
||||
cfg, _ := config.Load()
|
||||
// Persist to config (best-effort, ignore errors)
|
||||
cfg := loadConfigOrDefault()
|
||||
cfg.TUI.AutoRefresh = a.autoRefresh
|
||||
_ = config.Save(cfg)
|
||||
return a, nil
|
||||
@@ -491,9 +511,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
a.subData = msg.Data
|
||||
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 != "" {
|
||||
cfg, _ := config.Load()
|
||||
cfg := loadConfigOrDefault()
|
||||
if cfg.ClaudeAI.OrgID != msg.Data.Org.UUID {
|
||||
cfg.ClaudeAI.OrgID = msg.Data.Org.UUID
|
||||
_ = 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)
|
||||
if a.loaded && !a.subFetching && a.subTicks >= 1200 {
|
||||
a.subTicks = 0
|
||||
cfg, _ := config.Load()
|
||||
cfg := loadConfigOrDefault()
|
||||
if sessionKey := config.GetSessionKey(cfg); sessionKey != "" {
|
||||
a.subFetching = true
|
||||
cmds = append(cmds, fetchSubDataCmd(sessionKey))
|
||||
@@ -635,86 +655,143 @@ func (a App) viewLoading() string {
|
||||
w := a.width
|
||||
h := a.height
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(t.Accent).
|
||||
// Polished loading card with accent border
|
||||
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)
|
||||
|
||||
mutedStyle := lipgloss.NewStyle().
|
||||
Foreground(t.TextMuted)
|
||||
subtitleStyle := lipgloss.NewStyle().
|
||||
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
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(titleStyle.Render(" cburn"))
|
||||
b.WriteString(mutedStyle.Render(" - Claude Usage Metrics"))
|
||||
b.WriteString(logoStyle.Render("◈ cburn"))
|
||||
b.WriteString(subtitleStyle.Render(" · Claude Usage Metrics"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if a.progressMax > 0 {
|
||||
barW := w - 20
|
||||
barW := 40
|
||||
if barW > w-30 {
|
||||
barW = w - 30
|
||||
}
|
||||
if barW < 20 {
|
||||
barW = 20
|
||||
}
|
||||
if barW > 60 {
|
||||
barW = 60
|
||||
}
|
||||
pct := float64(a.progress) / float64(a.progressMax)
|
||||
fmt.Fprintf(&b, " %s Parsing sessions\n", a.spinner.View())
|
||||
fmt.Fprintf(&b, " %s %s/%s\n",
|
||||
components.ProgressBar(pct, barW),
|
||||
cli.FormatNumber(int64(a.progress)),
|
||||
cli.FormatNumber(int64(a.progressMax)))
|
||||
b.WriteString(spinnerStyle.Render(a.spinner.View()))
|
||||
b.WriteString(subtitleStyle.Render(" Parsing sessions\n\n"))
|
||||
b.WriteString(components.ProgressBar(pct, barW))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(countStyle.Render(cli.FormatNumber(int64(a.progress))))
|
||||
b.WriteString(subtitleStyle.Render(" / "))
|
||||
b.WriteString(countStyle.Render(cli.FormatNumber(int64(a.progressMax))))
|
||||
} 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()
|
||||
return padHeight(truncateHeight(content, h), h)
|
||||
card := cardStyle.Render(b.String())
|
||||
|
||||
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, card,
|
||||
lipgloss.WithWhitespaceBackground(t.Background))
|
||||
}
|
||||
|
||||
func (a App) viewHelp() string {
|
||||
t := theme.Active
|
||||
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().
|
||||
Foreground(t.AccentBright).
|
||||
Background(t.Surface).
|
||||
Bold(true)
|
||||
|
||||
sectionStyle := lipgloss.NewStyle().
|
||||
Foreground(t.Accent).
|
||||
Background(t.Surface).
|
||||
Bold(true)
|
||||
|
||||
keyStyle := lipgloss.NewStyle().
|
||||
Foreground(t.TextPrimary).
|
||||
Foreground(t.Cyan).
|
||||
Background(t.Surface).
|
||||
Bold(true)
|
||||
|
||||
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
|
||||
b.WriteString("\n")
|
||||
b.WriteString(titleStyle.Render(" Keybindings"))
|
||||
b.WriteString(titleStyle.Render("◈ Keyboard Shortcuts"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
bindings := []struct{ key, desc string }{
|
||||
{"o/c/s/b", "Overview / Costs / Sessions / Breakdown"},
|
||||
{"x", "Settings"},
|
||||
{"<- / ->", "Previous / Next tab"},
|
||||
{"j / k", "Navigate lists (or mouse wheel)"},
|
||||
{"J / K", "Scroll detail pane"},
|
||||
{"^d / ^u", "Scroll detail half-page"},
|
||||
{"/", "Search sessions (Enter apply, Esc cancel)"},
|
||||
{"Enter / f", "Expand session full-screen"},
|
||||
{"Esc", "Clear search / Back to split view"},
|
||||
{"r / R", "Refresh now / Toggle auto-refresh"},
|
||||
{"?", "Toggle this help"},
|
||||
{"q", "Quit (or back from full-screen)"},
|
||||
// Navigation section
|
||||
b.WriteString(sectionStyle.Render("Navigation"))
|
||||
b.WriteString("\n")
|
||||
navBindings := []struct{ key, desc string }{
|
||||
{"o c s b x", "Jump to tab"},
|
||||
{"← →", "Previous / Next tab"},
|
||||
{"j k", "Navigate lists"},
|
||||
{"J K", "Scroll detail pane"},
|
||||
{"^d ^u", "Half-page scroll"},
|
||||
}
|
||||
|
||||
for _, bind := range bindings {
|
||||
for _, bind := range navBindings {
|
||||
fmt.Fprintf(&b, " %s %s\n",
|
||||
keyStyle.Render(fmt.Sprintf("%-12s", bind.key)),
|
||||
keyStyle.Render(fmt.Sprintf("%-10s", bind.key)),
|
||||
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()
|
||||
return padHeight(truncateHeight(content, h), h)
|
||||
b.WriteString("\n")
|
||||
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 {
|
||||
@@ -723,18 +800,33 @@ func (a App) viewMain() string {
|
||||
cw := a.contentWidth()
|
||||
h := a.height
|
||||
|
||||
// 1. Render header (tab bar + filter line)
|
||||
filterStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||
filterStr := fmt.Sprintf(" [%dd", a.days)
|
||||
// 1. Render header (tab bar + filter pill)
|
||||
filterPillStyle := lipgloss.NewStyle().
|
||||
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 != "" {
|
||||
filterStr += " | " + a.project
|
||||
filterStr += filterPillStyle.Render(" │ ") + filterAccentStyle.Render(a.project)
|
||||
}
|
||||
if a.modelFilter != "" {
|
||||
filterStr += " | " + a.modelFilter
|
||||
filterStr += filterPillStyle.Render(" │ ") + filterAccentStyle.Render(a.modelFilter)
|
||||
}
|
||||
filterStr += "]"
|
||||
header := components.RenderTabBar(a.activeTab, w) + "\n" +
|
||||
filterStyle.Render(filterStr) + "\n"
|
||||
filterStr += filterPillStyle.Render(" ")
|
||||
|
||||
// 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
|
||||
dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds())
|
||||
@@ -744,8 +836,8 @@ func (a App) viewMain() string {
|
||||
headerH := lipgloss.Height(header)
|
||||
statusH := lipgloss.Height(statusBar)
|
||||
contentH := h - headerH - statusH
|
||||
if contentH < 5 {
|
||||
contentH = 5
|
||||
if contentH < minContentHeight {
|
||||
contentH = minContentHeight
|
||||
}
|
||||
|
||||
// 4. Render tab content (pass contentH to sessions)
|
||||
@@ -767,13 +859,20 @@ func (a App) viewMain() string {
|
||||
// 5. Truncate + pad to exactly contentH lines
|
||||
content = padHeight(truncateHeight(content, contentH), contentH)
|
||||
|
||||
// 6. Center horizontally if terminal wider than content cap
|
||||
if w > cw {
|
||||
content = lipgloss.Place(w, contentH, lipgloss.Center, lipgloss.Top, content)
|
||||
}
|
||||
// 6. Fill each line to full width with background (fixes gaps between cards)
|
||||
content = fillLinesWithBackground(content, cw, t.Background)
|
||||
|
||||
// 7. Stack vertically
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, content, statusBar)
|
||||
// 7. Place content with background fill (handles centering when w > cw)
|
||||
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 ────────────────────────────────────────────────────
|
||||
@@ -1021,6 +1120,25 @@ func padHeight(s string, h int) string {
|
||||
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.
|
||||
func fetchSubDataCmd(sessionKey string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
@@ -1040,25 +1158,23 @@ func fetchSubDataCmd(sessionKey string) tea.Cmd {
|
||||
// ─── Mouse Support ──────────────────────────────────────────────
|
||||
|
||||
// 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 {
|
||||
// Tab bar format: " TabName TabName ..." with 2-space gaps
|
||||
// We approximate positions since exact widths depend on styling.
|
||||
// Each tab name is roughly: name length + optional [k] suffix + gap
|
||||
positions := []struct {
|
||||
start, end int
|
||||
}{
|
||||
{1, 12}, // Overview (0)
|
||||
{14, 22}, // Costs (1)
|
||||
{24, 35}, // Sessions (2)
|
||||
{37, 50}, // Breakdown (3)
|
||||
{52, 68}, // Settings (4)
|
||||
}
|
||||
pos := 0
|
||||
for i, tab := range components.Tabs {
|
||||
// Must match RenderTabBar's visual width calculation exactly.
|
||||
// Use lipgloss.Width() to handle unicode and styled text correctly.
|
||||
tabW := components.TabVisualWidth(tab, i == a.activeTab)
|
||||
|
||||
for i, p := range positions {
|
||||
if x >= p.start && x <= p.end {
|
||||
if x >= pos && x < pos+tabW {
|
||||
return i
|
||||
}
|
||||
pos += tabW
|
||||
|
||||
// Separator is one column between tabs.
|
||||
if i < len(components.Tabs)-1 {
|
||||
pos++
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -25,7 +27,7 @@ func LayoutRow(totalWidth, n int) []int {
|
||||
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.
|
||||
func MetricCard(label, value, delta string, outerWidth int) string {
|
||||
t := theme.Active
|
||||
@@ -35,23 +37,56 @@ func MetricCard(label, value, delta string, outerWidth int) string {
|
||||
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().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(t.Border).
|
||||
BorderBackground(t.Background).
|
||||
Background(t.Surface).
|
||||
Width(contentWidth).
|
||||
Padding(0, 1)
|
||||
|
||||
iconStyle := lipgloss.NewStyle().
|
||||
Foreground(valueColor).
|
||||
Background(t.Surface)
|
||||
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Foreground(t.TextMuted)
|
||||
Foreground(t.TextMuted).
|
||||
Background(t.Surface)
|
||||
|
||||
valueStyle := lipgloss.NewStyle().
|
||||
Foreground(t.TextPrimary).
|
||||
Foreground(valueColor).
|
||||
Background(t.Surface).
|
||||
Bold(true)
|
||||
|
||||
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)
|
||||
if 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]))
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -87,14 +123,59 @@ func ContentCard(title, body string, outerWidth int) string {
|
||||
contentWidth = 10
|
||||
}
|
||||
|
||||
// Use accent border for titled cards, subtle for untitled
|
||||
borderColor := t.Border
|
||||
if title != "" {
|
||||
borderColor = t.BorderBright
|
||||
}
|
||||
|
||||
cardStyle := lipgloss.NewStyle().
|
||||
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).
|
||||
Padding(0, 1)
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(t.TextMuted).
|
||||
Foreground(t.AccentBright).
|
||||
Background(t.Surface).
|
||||
Bold(true)
|
||||
|
||||
content := ""
|
||||
@@ -106,12 +187,57 @@ func ContentCard(title, body string, outerWidth int) string {
|
||||
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 {
|
||||
if len(cards) == 0 {
|
||||
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
|
||||
@@ -123,3 +249,10 @@ func CardInnerWidth(outerWidth int) int {
|
||||
}
|
||||
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 {
|
||||
return ""
|
||||
}
|
||||
t := theme.Active
|
||||
|
||||
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||
|
||||
@@ -28,7 +29,7 @@ func Sparkline(values []float64, color lipgloss.Color) string {
|
||||
peak = 1
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().Foreground(color)
|
||||
style := lipgloss.NewStyle().Foreground(color).Background(t.Surface)
|
||||
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(values) * 4) // UTF-8 block chars are up to 3 bytes
|
||||
@@ -46,9 +47,7 @@ func Sparkline(values []float64, color lipgloss.Color) string {
|
||||
return style.Render(buf.String())
|
||||
}
|
||||
|
||||
// BarChart renders a multi-row bar chart with anchored Y-axis and optional X-axis labels.
|
||||
// labels (if non-nil) should correspond 1:1 with values for x-axis display.
|
||||
// height is a target; actual height adjusts slightly so Y-axis ticks are evenly spaced.
|
||||
// BarChart renders a visually polished bar chart with gradient-style coloring.
|
||||
func BarChart(values []float64, labels []string, color lipgloss.Color, width, height int) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
@@ -70,10 +69,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
||||
maxVal = 1
|
||||
}
|
||||
|
||||
// Y-axis: compute tick step and ceiling, then fit within requested height.
|
||||
// Each interval needs at least 2 rows for readable spacing, so
|
||||
// maxIntervals = height/2. If the initial step gives too many intervals,
|
||||
// double it until they fit.
|
||||
// Y-axis: compute tick step and ceiling
|
||||
tickStep := chartTickStep(maxVal)
|
||||
maxIntervals := height / 2
|
||||
if maxIntervals < 2 {
|
||||
@@ -92,14 +88,13 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
||||
numIntervals = 1
|
||||
}
|
||||
|
||||
// Each interval gets the same number of rows; chart height is an exact multiple.
|
||||
rowsPerTick := height / numIntervals
|
||||
if rowsPerTick < 2 {
|
||||
rowsPerTick = 2
|
||||
}
|
||||
chartH := rowsPerTick * numIntervals
|
||||
|
||||
// Pre-compute tick labels at evenly-spaced row positions
|
||||
// Pre-compute tick labels
|
||||
yLabelW := len(formatChartLabel(ceiling)) + 1
|
||||
if 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))
|
||||
}
|
||||
|
||||
// Chart area width (excluding y-axis label and axis line char)
|
||||
// Chart area width
|
||||
chartW := width - yLabelW - 1
|
||||
if chartW < 5 {
|
||||
chartW = 5
|
||||
@@ -118,8 +113,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
||||
|
||||
n := len(values)
|
||||
|
||||
// Bar sizing: always use 1-char gaps, target barW >= 2.
|
||||
// If bars don't fit at width 2, subsample to fewer bars.
|
||||
// Bar sizing
|
||||
gap := 1
|
||||
if n <= 1 {
|
||||
gap = 0
|
||||
@@ -131,8 +125,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
||||
barW = chartW
|
||||
}
|
||||
if barW < 2 && n > 1 {
|
||||
// Subsample so bars fit at width 2 with 1-char gaps
|
||||
maxN := (chartW + 1) / 3 // each bar = 2 chars + 1 gap (last bar no gap)
|
||||
maxN := (chartW + 1) / 3
|
||||
if 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
|
||||
|
||||
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
|
||||
|
||||
// Render rows top to bottom using chartH (aligned to tick intervals)
|
||||
// Render rows top to bottom
|
||||
for row := chartH; row >= 1; row-- {
|
||||
rowTop := ceiling * float64(row) / float64(chartH)
|
||||
rowBottom := ceiling * float64(row-1) / float64(chartH)
|
||||
rowPct := float64(row) / float64(chartH) // How high in the chart (0=bottom, 1=top)
|
||||
|
||||
// Choose bar color based on row height (gradient effect)
|
||||
var barColor lipgloss.Color
|
||||
switch {
|
||||
case rowPct > 0.8:
|
||||
barColor = t.AccentBright
|
||||
case rowPct > 0.5:
|
||||
barColor = color
|
||||
default:
|
||||
barColor = t.Accent
|
||||
}
|
||||
barStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface)
|
||||
|
||||
label := tickLabels[row]
|
||||
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, label)))
|
||||
@@ -175,11 +182,11 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
||||
|
||||
for i, v := range values {
|
||||
if i > 0 && gap > 0 {
|
||||
b.WriteString(strings.Repeat(" ", gap))
|
||||
b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", gap)))
|
||||
}
|
||||
switch {
|
||||
case v >= rowTop:
|
||||
b.WriteString(barStyle.Render(strings.Repeat("\u2588", barW)))
|
||||
b.WriteString(barStyle.Render(strings.Repeat("█", barW)))
|
||||
case v > rowBottom:
|
||||
frac := (v - rowBottom) / (rowTop - rowBottom)
|
||||
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)))
|
||||
default:
|
||||
b.WriteString(strings.Repeat(" ", barW))
|
||||
b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", barW)))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
@@ -209,7 +216,6 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
||||
buf[i] = ' '
|
||||
}
|
||||
|
||||
// Place labels at bar start positions, skip overlaps
|
||||
minSpacing := 8
|
||||
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)
|
||||
lastEnd = end + 1
|
||||
}
|
||||
// Always attempt the last label (the loop may skip it due to labelStep).
|
||||
// Right-align to axis edge if it would overflow.
|
||||
if n > 1 {
|
||||
lbl := labels[n-1]
|
||||
pos := (n - 1) * (barW + gap)
|
||||
end := pos + len(lbl)
|
||||
if end > axisLen {
|
||||
// Right-align: shift left so it ends at the axis edge
|
||||
pos = axisLen - len(lbl)
|
||||
end = axisLen
|
||||
}
|
||||
@@ -251,8 +254,9 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(strings.Repeat(" ", yLabelW+1))
|
||||
b.WriteString(axisStyle.Render(strings.TrimRight(string(buf), " ")))
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||
b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", yLabelW+1)))
|
||||
b.WriteString(labelStyle.Render(strings.TrimRight(string(buf), " ")))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"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 {
|
||||
t := theme.Active
|
||||
filled := int(pct * float64(width))
|
||||
@@ -22,36 +22,45 @@ func ProgressBar(pct float64, width int) string {
|
||||
filled = 0
|
||||
}
|
||||
|
||||
filledStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
||||
emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||
// Color gradient based on progress
|
||||
var barColor lipgloss.Color
|
||||
switch {
|
||||
case pct >= 0.8:
|
||||
barColor = t.AccentBright
|
||||
case pct >= 0.5:
|
||||
barColor = t.Accent
|
||||
default:
|
||||
barColor = t.Cyan
|
||||
}
|
||||
|
||||
filledStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface)
|
||||
emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||
pctStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface).Bold(true)
|
||||
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||
|
||||
var b strings.Builder
|
||||
for i := 0; i < filled; i++ {
|
||||
b.WriteString(filledStyle.Render("\u2588"))
|
||||
}
|
||||
for i := filled; i < width; i++ {
|
||||
b.WriteString(emptyStyle.Render("\u2591"))
|
||||
}
|
||||
b.WriteString(filledStyle.Render(strings.Repeat("█", filled)))
|
||||
b.WriteString(emptyStyle.Render(strings.Repeat("░", width-filled)))
|
||||
|
||||
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 {
|
||||
t := theme.Active
|
||||
switch {
|
||||
case pct >= 0.8:
|
||||
case pct >= 0.9:
|
||||
return string(t.Red)
|
||||
case pct >= 0.5:
|
||||
case pct >= 0.7:
|
||||
return string(t.Orange)
|
||||
case pct >= 0.5:
|
||||
return string(t.Yellow)
|
||||
default:
|
||||
return string(t.Green)
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitBar renders a labeled progress bar with percentage and countdown.
|
||||
// label: "5-hour", "Weekly", etc. pct: 0.0-1.0. resetsAt: zero means no countdown.
|
||||
// barWidth: width allocated for the progress bar portion only.
|
||||
func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidth int) string {
|
||||
t := theme.Active
|
||||
|
||||
@@ -69,9 +78,10 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt
|
||||
)
|
||||
bar.EmptyColor = string(t.TextDim)
|
||||
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
pctStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Bold(true)
|
||||
countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true)
|
||||
countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||
|
||||
pctStr := fmt.Sprintf("%3.0f%%", pct*100)
|
||||
countdown := ""
|
||||
@@ -84,16 +94,16 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s %s %s %s",
|
||||
labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)),
|
||||
bar.ViewAs(pct),
|
||||
pctStyle.Render(pctStr),
|
||||
countdownStyle.Render(countdown),
|
||||
)
|
||||
return labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)) +
|
||||
spaceStyle.Render(" ") +
|
||||
bar.ViewAs(pct) +
|
||||
spaceStyle.Render(" ") +
|
||||
pctStyle.Render(pctStr) +
|
||||
spaceStyle.Render(" ") +
|
||||
countdownStyle.Render(countdown)
|
||||
}
|
||||
|
||||
// CompactRateBar renders a tiny status-bar-sized rate indicator.
|
||||
// Example output: "5h ████░░░░ 42%"
|
||||
func CompactRateBar(label string, pct float64, width int) string {
|
||||
t := theme.Active
|
||||
|
||||
@@ -104,7 +114,6 @@ func CompactRateBar(label string, pct float64, width int) string {
|
||||
pct = 1
|
||||
}
|
||||
|
||||
// label + space + bar + space + pct(4 chars)
|
||||
barW := width - lipgloss.Width(label) - 6
|
||||
if barW < 4 {
|
||||
barW = 4
|
||||
@@ -117,14 +126,15 @@ func CompactRateBar(label string, pct float64, width int) string {
|
||||
)
|
||||
bar.EmptyColor = string(t.TextDim)
|
||||
|
||||
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||
|
||||
return fmt.Sprintf("%s %s %s",
|
||||
labelStyle.Render(label),
|
||||
bar.ViewAs(pct),
|
||||
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
|
||||
)
|
||||
return labelStyle.Render(label) +
|
||||
spaceStyle.Render(" ") +
|
||||
bar.ViewAs(pct) +
|
||||
spaceStyle.Render(" ") +
|
||||
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100))
|
||||
}
|
||||
|
||||
func formatCountdown(d time.Duration) string {
|
||||
|
||||
@@ -7,80 +7,119 @@ import (
|
||||
"github.com/theirongolddev/cburn/internal/claudeai"
|
||||
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"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 {
|
||||
t := theme.Active
|
||||
|
||||
style := lipgloss.NewStyle().
|
||||
Foreground(t.TextMuted).
|
||||
// Main container
|
||||
barStyle := lipgloss.NewStyle().
|
||||
Background(t.SurfaceHover).
|
||||
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
|
||||
ratePart := renderStatusRateLimits(subData)
|
||||
hintStyle := lipgloss.NewStyle().
|
||||
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
|
||||
if refreshing {
|
||||
refreshStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
||||
right = refreshStyle.Render("↻ refreshing ")
|
||||
spinnerStyle := lipgloss.NewStyle().
|
||||
Foreground(t.AccentBright).
|
||||
Background(t.SurfaceHover).
|
||||
Bold(true)
|
||||
right = spinnerStyle.Render("↻ refreshing")
|
||||
} else if dataAge != "" {
|
||||
autoStr := ""
|
||||
refreshIcon := ""
|
||||
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
|
||||
usedWidth := lipgloss.Width(left) + lipgloss.Width(ratePart) + lipgloss.Width(right)
|
||||
padding := width - usedWidth
|
||||
// Calculate padding
|
||||
leftWidth := lipgloss.Width(left)
|
||||
middleWidth := lipgloss.Width(middle)
|
||||
rightWidth := lipgloss.Width(right)
|
||||
|
||||
totalUsed := leftWidth + middleWidth + rightWidth
|
||||
padding := width - totalUsed
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
|
||||
// Split padding: more on the left side of rate indicators
|
||||
leftPad := padding / 2
|
||||
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 {
|
||||
if subData == nil || subData.Usage == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
t := theme.Active
|
||||
sepStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||
|
||||
var parts []string
|
||||
|
||||
if w := subData.Usage.FiveHour; w != nil {
|
||||
parts = append(parts, compactStatusBar("5h", w.Pct))
|
||||
parts = append(parts, renderRatePill("5h", w.Pct))
|
||||
}
|
||||
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 {
|
||||
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.
|
||||
// Format: "5h ████░░░░ 42%"
|
||||
func compactStatusBar(label string, pct float64) string {
|
||||
// renderRatePill renders a compact, colored rate indicator pill.
|
||||
func renderRatePill(label string, pct float64) string {
|
||||
t := theme.Active
|
||||
|
||||
if pct < 0 {
|
||||
@@ -90,20 +129,52 @@ func compactStatusBar(label string, pct float64) string {
|
||||
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
|
||||
bar := progress.New(
|
||||
progress.WithSolidFill(ColorForPct(pct)),
|
||||
progress.WithWidth(barW),
|
||||
progress.WithoutPercentage(),
|
||||
)
|
||||
bar.EmptyColor = string(t.TextDim)
|
||||
filled := int(pct * float64(barW))
|
||||
if filled > barW {
|
||||
filled = barW
|
||||
}
|
||||
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
|
||||
bar := barStyle.Render(strings.Repeat("█", filled)) +
|
||||
emptyStyle.Render(strings.Repeat("░", barW-filled))
|
||||
|
||||
return fmt.Sprintf("%s %s %s",
|
||||
labelStyle.Render(label),
|
||||
bar.ViewAs(pct),
|
||||
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
|
||||
)
|
||||
spaceStyle := lipgloss.NewStyle().
|
||||
Background(t.SurfaceHover)
|
||||
|
||||
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},
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t := theme.Active
|
||||
|
||||
activeStyle := lipgloss.NewStyle().
|
||||
Foreground(t.Accent).
|
||||
Bold(true)
|
||||
// Container with bottom border
|
||||
barStyle := lipgloss.NewStyle().
|
||||
Background(t.Surface).
|
||||
Width(width)
|
||||
|
||||
inactiveStyle := lipgloss.NewStyle().
|
||||
Foreground(t.TextMuted)
|
||||
// Active tab: bright text with accent underline
|
||||
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().
|
||||
Foreground(t.Accent).
|
||||
Bold(true)
|
||||
Background(t.Surface)
|
||||
|
||||
dimKeyStyle := lipgloss.NewStyle().
|
||||
Foreground(t.TextDim)
|
||||
dimStyle := lipgloss.NewStyle().
|
||||
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 {
|
||||
var rendered string
|
||||
var tabContent string
|
||||
var underline string
|
||||
|
||||
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 {
|
||||
// Render with highlighted shortcut key
|
||||
// Inactive tab - show key hint
|
||||
if tab.KeyPos >= 0 && tab.KeyPos < len(tab.Name) {
|
||||
before := tab.Name[:tab.KeyPos]
|
||||
key := string(tab.Name[tab.KeyPos])
|
||||
after := tab.Name[tab.KeyPos+1:]
|
||||
rendered = inactiveStyle.Render(before) +
|
||||
dimKeyStyle.Render("[") + keyStyle.Render(key) + dimKeyStyle.Render("]") +
|
||||
inactiveStyle.Render(after)
|
||||
tabContent = lipgloss.NewStyle().Padding(0, 1).Background(t.Surface).Render(
|
||||
dimStyle.Render(before) + keyStyle.Render(key) + dimStyle.Render(after))
|
||||
} else {
|
||||
// Key not in name (e.g., "Settings" with 'x')
|
||||
rendered = inactiveStyle.Render(tab.Name) +
|
||||
dimKeyStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimKeyStyle.Render("]")
|
||||
tabContent = inactiveTabStyle.Render(tab.Name) +
|
||||
dimStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimStyle.Render("]")
|
||||
}
|
||||
}
|
||||
parts = append(parts, rendered)
|
||||
// Dim underline
|
||||
underline = lipgloss.NewStyle().
|
||||
Foreground(t.Border).
|
||||
Background(t.Surface).
|
||||
Render(strings.Repeat("─", lipgloss.Width(tabContent)))
|
||||
}
|
||||
|
||||
bar := " " + strings.Join(parts, " ")
|
||||
if lipgloss.Width(bar) <= width {
|
||||
return bar
|
||||
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(" "))
|
||||
}
|
||||
return lipgloss.NewStyle().MaxWidth(width).Render(bar)
|
||||
}
|
||||
|
||||
// Combine tab row and underline row
|
||||
tabRow := strings.Join(tabParts, "")
|
||||
underlineRow := strings.Join(underlineParts, "")
|
||||
|
||||
// Fill remaining width with border
|
||||
tabRowWidth := lipgloss.Width(tabRow)
|
||||
if tabRowWidth < width {
|
||||
padding := width - tabRowWidth
|
||||
tabRow += lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", padding))
|
||||
underlineRow += lipgloss.NewStyle().Foreground(t.Border).Background(t.Surface).Render(strings.Repeat("─", padding))
|
||||
}
|
||||
|
||||
return barStyle.Render(tabRow + "\n" + underlineRow)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ type setupValues struct {
|
||||
|
||||
// newSetupForm builds the huh form for first-run configuration.
|
||||
func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.Form {
|
||||
cfg, _ := config.Load()
|
||||
cfg := loadConfigOrDefault()
|
||||
|
||||
// Pre-populate defaults
|
||||
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.
|
||||
func (a *App) saveSetupConfig() error {
|
||||
cfg, _ := config.Load()
|
||||
cfg := loadConfigOrDefault()
|
||||
|
||||
if a.setupVals.sessionKey != "" {
|
||||
cfg.ClaudeAI.SessionKey = a.setupVals.sessionKey
|
||||
|
||||
@@ -23,8 +23,18 @@ func (a App) renderModelsTab(cw int) string {
|
||||
nameW = 14
|
||||
}
|
||||
|
||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
||||
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true)
|
||||
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||
costStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface)
|
||||
shareStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface)
|
||||
|
||||
// Model colors for visual interest - pre-compute styles to avoid allocation in loops
|
||||
modelColors := []lipgloss.Color{t.BlueBright, t.Cyan, t.Magenta, t.Yellow, t.Green}
|
||||
nameStyles := make([]lipgloss.Style, len(modelColors))
|
||||
for i, color := range modelColors {
|
||||
nameStyles[i] = lipgloss.NewStyle().Foreground(color).Background(t.Surface)
|
||||
}
|
||||
|
||||
var tableBody strings.Builder
|
||||
if a.isCompactLayout() {
|
||||
@@ -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("\n")
|
||||
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+shareW+costW+callW+3)))
|
||||
tableBody.WriteString("\n")
|
||||
|
||||
for _, ms := range models {
|
||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %8s %10s %5.1f%%",
|
||||
nameW,
|
||||
truncStr(shortModel(ms.Model), nameW),
|
||||
cli.FormatNumber(int64(ms.APICalls)),
|
||||
cli.FormatCost(ms.EstimatedCost),
|
||||
ms.SharePercent)))
|
||||
for i, ms := range models {
|
||||
tableBody.WriteString(nameStyles[i%len(modelColors)].Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(ms.Model), nameW))))
|
||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %8s", cli.FormatNumber(int64(ms.APICalls)))))
|
||||
tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ms.EstimatedCost))))
|
||||
tableBody.WriteString(shareStyle.Render(fmt.Sprintf(" %5.1f%%", ms.SharePercent)))
|
||||
tableBody.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s %10s %10s %6s", nameW, "Model", "Calls", "Input", "Output", "Cost", "Share")))
|
||||
tableBody.WriteString("\n")
|
||||
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
|
||||
tableBody.WriteString("\n")
|
||||
|
||||
for _, ms := range models {
|
||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %8s %10s %10s %10s %5.1f%%",
|
||||
nameW,
|
||||
truncStr(shortModel(ms.Model), nameW),
|
||||
for i, ms := range models {
|
||||
tableBody.WriteString(nameStyles[i%len(modelColors)].Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(ms.Model), nameW))))
|
||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %8s %10s %10s",
|
||||
cli.FormatNumber(int64(ms.APICalls)),
|
||||
cli.FormatTokens(ms.InputTokens),
|
||||
cli.FormatTokens(ms.OutputTokens),
|
||||
cli.FormatCost(ms.EstimatedCost),
|
||||
ms.SharePercent)))
|
||||
cli.FormatTokens(ms.OutputTokens))))
|
||||
tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ms.EstimatedCost))))
|
||||
tableBody.WriteString(shareStyle.Render(fmt.Sprintf(" %5.1f%%", ms.SharePercent)))
|
||||
tableBody.WriteString("\n")
|
||||
}
|
||||
}
|
||||
@@ -79,8 +90,11 @@ func (a App) renderProjectsTab(cw int) string {
|
||||
nameW = 18
|
||||
}
|
||||
|
||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
||||
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true)
|
||||
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||
nameStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface)
|
||||
costStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface)
|
||||
|
||||
var tableBody strings.Builder
|
||||
if a.isCompactLayout() {
|
||||
@@ -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("\n")
|
||||
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+costW+sessW+2)))
|
||||
tableBody.WriteString("\n")
|
||||
|
||||
for _, ps := range projects {
|
||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %6d %10s",
|
||||
nameW,
|
||||
truncStr(ps.Project, nameW),
|
||||
ps.Sessions,
|
||||
cli.FormatCost(ps.EstimatedCost))))
|
||||
tableBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(ps.Project, nameW))))
|
||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %6d", ps.Sessions)))
|
||||
tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ps.EstimatedCost))))
|
||||
tableBody.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %6s %8s %10s %10s", nameW, "Project", "Sess.", "Prompts", "Tokens", "Cost")))
|
||||
tableBody.WriteString("\n")
|
||||
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
|
||||
tableBody.WriteString("\n")
|
||||
|
||||
for _, ps := range projects {
|
||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %6d %8s %10s %10s",
|
||||
nameW,
|
||||
truncStr(ps.Project, nameW),
|
||||
tableBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(ps.Project, nameW))))
|
||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %6d %8s %10s",
|
||||
ps.Sessions,
|
||||
cli.FormatNumber(int64(ps.Prompts)),
|
||||
cli.FormatTokens(ps.TotalTokens),
|
||||
cli.FormatCost(ps.EstimatedCost))))
|
||||
cli.FormatTokens(ps.TotalTokens))))
|
||||
tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ps.EstimatedCost))))
|
||||
tableBody.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +51,14 @@ func (a App) renderCostsTab(cw int) string {
|
||||
nameW = 14
|
||||
}
|
||||
|
||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
||||
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true)
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||
costValueStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface)
|
||||
modelNameStyle := lipgloss.NewStyle().Foreground(t.BlueBright).Background(t.Surface)
|
||||
tokenCostStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface)
|
||||
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||
|
||||
var tableBody strings.Builder
|
||||
if a.isCompactLayout() {
|
||||
@@ -70,10 +73,8 @@ func (a App) renderCostsTab(cw int) string {
|
||||
tableBody.WriteString("\n")
|
||||
|
||||
for _, mc := range modelCosts {
|
||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s",
|
||||
nameW,
|
||||
truncStr(shortModel(mc.Model), nameW),
|
||||
cli.FormatCost(mc.TotalCost))))
|
||||
tableBody.WriteString(modelNameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(mc.Model), nameW))))
|
||||
tableBody.WriteString(costValueStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(mc.TotalCost))))
|
||||
tableBody.WriteString("\n")
|
||||
}
|
||||
tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1)))
|
||||
@@ -84,13 +85,12 @@ func (a App) renderCostsTab(cw int) string {
|
||||
tableBody.WriteString("\n")
|
||||
|
||||
for _, mc := range modelCosts {
|
||||
tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s",
|
||||
nameW,
|
||||
truncStr(shortModel(mc.Model), nameW),
|
||||
tableBody.WriteString(modelNameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(mc.Model), nameW))))
|
||||
tableBody.WriteString(tokenCostStyle.Render(fmt.Sprintf(" %10s %10s %10s",
|
||||
cli.FormatCost(mc.InputCost),
|
||||
cli.FormatCost(mc.OutputCost),
|
||||
cli.FormatCost(mc.CacheCost),
|
||||
cli.FormatCost(mc.TotalCost))))
|
||||
cli.FormatCost(mc.CacheCost))))
|
||||
tableBody.WriteString(costValueStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(mc.TotalCost))))
|
||||
tableBody.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -126,12 +126,16 @@ func (a App) renderCostsTab(cw int) string {
|
||||
|
||||
var body strings.Builder
|
||||
body.WriteString(bar.ViewAs(pct))
|
||||
fmt.Fprintf(&body, " %.0f%%\n", pct*100)
|
||||
fmt.Fprintf(&body, "%s %s / %s %s",
|
||||
labelStyle.Render("Used"),
|
||||
valueStyle.Render(fmt.Sprintf("$%.2f", ol.UsedCredits)),
|
||||
valueStyle.Render(fmt.Sprintf("$%.2f", ol.MonthlyCreditLimit)),
|
||||
labelStyle.Render(ol.Currency))
|
||||
body.WriteString(spaceStyle.Render(" "))
|
||||
body.WriteString(valueStyle.Render(fmt.Sprintf("%.0f%%", pct*100)))
|
||||
body.WriteString("\n")
|
||||
body.WriteString(labelStyle.Render("Used"))
|
||||
body.WriteString(spaceStyle.Render(" "))
|
||||
body.WriteString(valueStyle.Render(fmt.Sprintf("$%.2f", ol.UsedCredits)))
|
||||
body.WriteString(spaceStyle.Render(" / "))
|
||||
body.WriteString(valueStyle.Render(fmt.Sprintf("$%.2f", ol.MonthlyCreditLimit)))
|
||||
body.WriteString(spaceStyle.Render(" "))
|
||||
body.WriteString(labelStyle.Render(ol.Currency))
|
||||
|
||||
progressCard = components.ContentCard("Overage Spend", body.String(), halves[0])
|
||||
} else {
|
||||
@@ -159,12 +163,14 @@ func (a App) renderCostsTab(cw int) string {
|
||||
return topDays[i].Date.After(topDays[j].Date)
|
||||
})
|
||||
for _, d := range topDays {
|
||||
fmt.Fprintf(&spendBody, "%s %s\n",
|
||||
valueStyle.Render(d.Date.Format("Jan 02")),
|
||||
lipgloss.NewStyle().Foreground(t.Green).Render(cli.FormatCost(d.EstimatedCost)))
|
||||
spendBody.WriteString(valueStyle.Render(d.Date.Format("Jan 02")))
|
||||
spendBody.WriteString(spaceStyle.Render(" "))
|
||||
spendBody.WriteString(lipgloss.NewStyle().Foreground(t.Green).Background(t.Surface).Render(cli.FormatCost(d.EstimatedCost)))
|
||||
spendBody.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
spendBody.WriteString("No data\n")
|
||||
spendBody.WriteString(labelStyle.Render("No data"))
|
||||
spendBody.WriteString("\n")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
effMetrics := []struct{ name, value string }{
|
||||
{"Tokens/Prompt", cli.FormatTokens(tokPerPrompt)},
|
||||
{"Output/Prompt", cli.FormatTokens(outPerPrompt)},
|
||||
{"Prompts/Session", fmt.Sprintf("%.1f", promptsPerSess)},
|
||||
{"Minutes/Day", fmt.Sprintf("%.0f", stats.MinutesPerDay)},
|
||||
effMetrics := []struct {
|
||||
name string
|
||||
value string
|
||||
color lipgloss.Color
|
||||
}{
|
||||
{"Tokens/Prompt", cli.FormatTokens(tokPerPrompt), t.Cyan},
|
||||
{"Output/Prompt", cli.FormatTokens(outPerPrompt), t.Cyan},
|
||||
{"Prompts/Session", fmt.Sprintf("%.1f", promptsPerSess), t.Magenta},
|
||||
{"Minutes/Day", fmt.Sprintf("%.0f", stats.MinutesPerDay), t.Yellow},
|
||||
}
|
||||
|
||||
var effBody strings.Builder
|
||||
for _, m := range effMetrics {
|
||||
effBody.WriteString(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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
func (a App) renderSubscriptionCard(cw int) string {
|
||||
t := theme.Active
|
||||
hintStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||
hintStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||
|
||||
// No session key configured
|
||||
if a.subData == nil && !a.subFetching {
|
||||
cfg, _ := config.Load()
|
||||
cfg := loadConfigOrDefault()
|
||||
if config.GetSessionKey(cfg) == "" {
|
||||
return components.ContentCard("Subscription",
|
||||
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
|
||||
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",
|
||||
warnStyle.Render(fmt.Sprintf("Error: %s", a.subData.Error)),
|
||||
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 {
|
||||
pct := ol.UsedCredits / ol.MonthlyCreditLimit
|
||||
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(components.RateLimitBar("Overage",
|
||||
pct, time.Time{}, labelW, barW))
|
||||
|
||||
spendStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||
spendStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||
body.WriteString(spendStyle.Render(
|
||||
fmt.Sprintf(" $%.2f / $%.2f", ol.UsedCredits, ol.MonthlyCreditLimit)))
|
||||
}
|
||||
@@ -298,7 +309,7 @@ func (a App) renderSubscriptionCard(cw int) string {
|
||||
// Fetch timestamp
|
||||
if !a.subData.FetchedAt.IsZero() {
|
||||
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")))
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ func (a App) renderOverviewTab(cw int) string {
|
||||
models := a.models
|
||||
var b strings.Builder
|
||||
|
||||
// Row 1: Metric cards
|
||||
// Row 1: Metric cards with colored values
|
||||
costDelta := ""
|
||||
if prev.CostPerDay > 0 {
|
||||
costDelta = fmt.Sprintf("%s/day (%s)", cli.FormatCost(stats.CostPerDay), cli.FormatDelta(stats.CostPerDay, prev.CostPerDay))
|
||||
@@ -54,7 +54,7 @@ func (a App) renderOverviewTab(cw int) string {
|
||||
b.WriteString(components.MetricCardRow(cards, cw))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Row 2: Daily token usage chart
|
||||
// Row 2: Daily token usage chart - use PanelCard for emphasis
|
||||
if len(days) > 0 {
|
||||
chartVals := make([]float64, len(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)
|
||||
}
|
||||
chartInnerW := components.CardInnerWidth(cw)
|
||||
b.WriteString(components.ContentCard(
|
||||
b.WriteString(components.PanelCard(
|
||||
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,
|
||||
))
|
||||
b.WriteString("\n")
|
||||
@@ -88,7 +88,7 @@ func (a App) renderOverviewTab(cw int) string {
|
||||
}
|
||||
todayCard = components.ContentCard(
|
||||
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],
|
||||
)
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func (a App) renderOverviewTab(cw int) string {
|
||||
}
|
||||
lastHourCard = components.ContentCard(
|
||||
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],
|
||||
)
|
||||
}
|
||||
@@ -127,10 +127,7 @@ func (a App) renderOverviewTab(cw int) string {
|
||||
halves := components.LayoutRow(cw, 2)
|
||||
innerW := components.CardInnerWidth(halves[0])
|
||||
|
||||
nameStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
||||
barStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
||||
pctStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||
|
||||
// Model split with colored bars per model
|
||||
var modelBody strings.Builder
|
||||
limit := 5
|
||||
if len(models) < limit {
|
||||
@@ -150,18 +147,36 @@ func (a App) renderOverviewTab(cw int) string {
|
||||
if 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
|
||||
if maxShare > 0 {
|
||||
barLen = int(ms.SharePercent / maxShare * float64(barMaxLen))
|
||||
}
|
||||
fmt.Fprintf(&modelBody, "%s %s %s\n",
|
||||
nameStyle.Render(fmt.Sprintf("%-*s", nameW, shortModel(ms.Model))),
|
||||
barStyle.Render(strings.Repeat("█", barLen)),
|
||||
pctStyle.Render(fmt.Sprintf("%.0f%%", ms.SharePercent)))
|
||||
|
||||
colorIdx := i % len(modelColors)
|
||||
modelBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, shortModel(ms.Model))))
|
||||
modelBody.WriteString(sepStyle.Render(" "))
|
||||
modelBody.WriteString(barStyles[colorIdx].Render(strings.Repeat("█", barLen)))
|
||||
modelBody.WriteString(sepStyle.Render(" "))
|
||||
modelBody.WriteString(pctStyles[colorIdx].Render(fmt.Sprintf("%3.0f%%", ms.SharePercent)))
|
||||
modelBody.WriteString("\n")
|
||||
}
|
||||
|
||||
// Compact activity: aggregate prompts into 4-hour buckets
|
||||
// Activity patterns with time-of-day coloring
|
||||
now := time.Now()
|
||||
since := now.AddDate(0, 0, -a.days)
|
||||
hours := pipeline.AggregateHourly(a.filtered, since, now)
|
||||
@@ -172,11 +187,11 @@ func (a App) renderOverviewTab(cw int) string {
|
||||
color lipgloss.Color
|
||||
}
|
||||
buckets := []actBucket{
|
||||
{"Night 00-03", 0, t.Red},
|
||||
{"Early 04-07", 0, t.Yellow},
|
||||
{"Morning 08-11", 0, t.Green},
|
||||
{"Night 00-03", 0, t.Magenta},
|
||||
{"Early 04-07", 0, t.Orange},
|
||||
{"Morning 08-11", 0, t.GreenBright},
|
||||
{"Midday 12-15", 0, t.Green},
|
||||
{"Evening 16-19", 0, t.Green},
|
||||
{"Evening 16-19", 0, t.Cyan},
|
||||
{"Late 20-23", 0, t.Yellow},
|
||||
}
|
||||
for _, h := range hours {
|
||||
@@ -196,31 +211,34 @@ func (a App) renderOverviewTab(cw int) string {
|
||||
|
||||
actInnerW := components.CardInnerWidth(halves[1])
|
||||
|
||||
// Compute number column width from actual data so bars never overflow.
|
||||
// Compute number column width
|
||||
maxNumW := 5
|
||||
for _, bk := range buckets {
|
||||
if nw := len(cli.FormatNumber(int64(bk.total))); nw > maxNumW {
|
||||
maxNumW = nw
|
||||
}
|
||||
}
|
||||
// prefix = 13 (label) + 1 (space) + maxNumW (number) + 1 (space)
|
||||
actBarMax := actInnerW - 15 - maxNumW
|
||||
if actBarMax < 1 {
|
||||
actBarMax = 1
|
||||
}
|
||||
|
||||
numStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||
numStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||
|
||||
var actBody strings.Builder
|
||||
for _, bk := range buckets {
|
||||
bl := 0
|
||||
if maxBucket > 0 {
|
||||
bl = bk.total * actBarMax / maxBucket
|
||||
}
|
||||
bar := lipgloss.NewStyle().Foreground(bk.color).Render(strings.Repeat("█", bl))
|
||||
fmt.Fprintf(&actBody, "%s %s %s\n",
|
||||
numStyle.Render(bk.label),
|
||||
numStyle.Render(fmt.Sprintf("%*s", maxNumW, cli.FormatNumber(int64(bk.total)))),
|
||||
bar)
|
||||
barStyle := lipgloss.NewStyle().Foreground(bk.color).Background(t.Surface)
|
||||
actBody.WriteString(labelStyle.Render(bk.label))
|
||||
actBody.WriteString(sepStyle.Render(" "))
|
||||
actBody.WriteString(numStyle.Render(fmt.Sprintf("%*s", maxNumW, cli.FormatNumber(int64(bk.total)))))
|
||||
actBody.WriteString(sepStyle.Render(" "))
|
||||
actBody.WriteString(barStyle.Render(strings.Repeat("█", bl)))
|
||||
actBody.WriteString("\n")
|
||||
}
|
||||
|
||||
modelCard := components.ContentCard("Model Split", modelBody.String(), halves[0])
|
||||
@@ -236,7 +254,7 @@ func (a App) renderOverviewTab(cw int) 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 {
|
||||
labels := make([]string, 24)
|
||||
for i := 0; i < 24; i++ {
|
||||
@@ -253,8 +271,7 @@ func hourLabels24() []string {
|
||||
return labels
|
||||
}
|
||||
|
||||
// minuteLabels returns X-axis labels for 12 five-minute buckets (one per bucket).
|
||||
// Bucket 0 is oldest (55-60 min ago), bucket 11 is newest (0-5 min ago).
|
||||
// minuteLabels returns X-axis labels for 12 five-minute buckets.
|
||||
func minuteLabels() []string {
|
||||
return []string{"-55", "-50", "-45", "-40", "-35", "-30", "-25", "-20", "-15", "-10", "-5", "now"}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,13 @@ const (
|
||||
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.
|
||||
type sessionsState struct {
|
||||
cursor int
|
||||
@@ -79,17 +86,21 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str
|
||||
// Show search input when in search mode
|
||||
if ss.searching {
|
||||
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(ss.searchInput.View())
|
||||
b.WriteString("\n")
|
||||
hintStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
||||
b.WriteString(hintStyle.Render(" [Enter] apply [Esc] cancel"))
|
||||
hintStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||
keyStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface)
|
||||
b.WriteString(spaceStyle.Render(" ") + hintStyle.Render("[") + keyStyle.Render("Enter") + hintStyle.Render("] apply [") +
|
||||
keyStyle.Render("Esc") + hintStyle.Render("] cancel"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Show preview of filtered results
|
||||
previewFiltered := filterSessionsBySearch(a.filtered, ss.searchInput.Value())
|
||||
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()
|
||||
}
|
||||
@@ -102,10 +113,10 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str
|
||||
|
||||
if len(filtered) == 0 {
|
||||
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 != "" {
|
||||
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)
|
||||
}
|
||||
@@ -157,15 +168,15 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
|
||||
// Left pane: condensed session list
|
||||
leftInner := components.CardInnerWidth(leftW)
|
||||
|
||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
||||
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
||||
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface).Bold(true)
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.SurfaceBright).Bold(true)
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||
costStyle := lipgloss.NewStyle().Foreground(t.Green).Background(t.Surface)
|
||||
|
||||
var leftBody strings.Builder
|
||||
visible := h - 6 // card border (2) + header row (2) + footer hint (2)
|
||||
if visible < 5 {
|
||||
visible = 5
|
||||
visible := h - sessListOverhead
|
||||
if visible < sessMinVisible {
|
||||
visible = sessMinVisible
|
||||
}
|
||||
|
||||
offset := ss.offset
|
||||
@@ -198,18 +209,22 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin
|
||||
}
|
||||
|
||||
if i == cursor {
|
||||
fullLine := leftPart + strings.Repeat(" ", padN) + costStr
|
||||
// Pad to full width for continuous highlight background
|
||||
if len(fullLine) < leftInner {
|
||||
fullLine += strings.Repeat(" ", leftInner-len(fullLine))
|
||||
}
|
||||
leftBody.WriteString(selectedStyle.Render(fullLine))
|
||||
// Selected row with bright background and accent marker
|
||||
selectedCostStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.SurfaceBright).Bold(true)
|
||||
marker := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.SurfaceBright).Render("▸ ")
|
||||
leftBody.WriteString(marker + selectedStyle.Render(leftPart) +
|
||||
lipgloss.NewStyle().Background(t.SurfaceBright).Render(strings.Repeat(" ", max(1, padN-2))) +
|
||||
selectedCostStyle.Render(costStr) +
|
||||
lipgloss.NewStyle().Background(t.SurfaceBright).Render(strings.Repeat(" ", max(0, leftInner-len(leftPart)-padN-len(costStr)))))
|
||||
} else {
|
||||
// Normal row
|
||||
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) +
|
||||
strings.Repeat(" ", padN) +
|
||||
mutedStyle.Render(costStr))
|
||||
lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", padN-2)) +
|
||||
costStyle.Render(costStr))
|
||||
}
|
||||
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
|
||||
sel := sessions[cursor]
|
||||
rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle)
|
||||
rightBody := a.renderDetailBody(sel, rightW, mutedStyle)
|
||||
|
||||
// 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)
|
||||
rightCard := components.ContentCard(titleStr, rightBody, rightW)
|
||||
@@ -248,11 +263,10 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin
|
||||
}
|
||||
sel := sessions[cursor]
|
||||
|
||||
headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||
|
||||
body := a.renderDetailBody(sel, cw, headerStyle, mutedStyle)
|
||||
body = a.applyDetailScroll(body, h-4)
|
||||
body := a.renderDetailBody(sel, cw, mutedStyle)
|
||||
body = a.applyDetailScroll(body, h-sessDetailOverhead)
|
||||
|
||||
title := "Session " + shortID(sel.SessionID)
|
||||
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.
|
||||
// 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
|
||||
innerW := components.CardInnerWidth(w)
|
||||
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
||||
greenStyle := lipgloss.NewStyle().Foreground(t.Green)
|
||||
// Rich color palette for different data types
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||
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
|
||||
body.WriteString(mutedStyle.Render(sel.Project))
|
||||
body.WriteString(accentStyle.Render(sel.Project))
|
||||
body.WriteString("\n")
|
||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", innerW)))
|
||||
body.WriteString(dimStyle.Render(strings.Repeat("─", innerW)))
|
||||
body.WriteString("\n\n")
|
||||
|
||||
// Duration line
|
||||
// Duration line with colored values
|
||||
if !sel.StartTime.IsZero() {
|
||||
durStr := cli.FormatDuration(sel.DurationSecs)
|
||||
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.StartTime.Local().Format("MST")
|
||||
fmt.Fprintf(&body, "%s %s (%s)\n",
|
||||
labelStyle.Render("Duration:"),
|
||||
valueStyle.Render(durStr),
|
||||
mutedStyle.Render(timeStr))
|
||||
body.WriteString(labelStyle.Render("Duration: "))
|
||||
body.WriteString(timeStyle.Render(durStr))
|
||||
body.WriteString(dimStyle.Render(" ("))
|
||||
body.WriteString(mutedStyle.Render(timeStr))
|
||||
body.WriteString(dimStyle.Render(")"))
|
||||
body.WriteString("\n")
|
||||
}
|
||||
|
||||
ratio := 0.0
|
||||
if sel.UserMessages > 0 {
|
||||
ratio = float64(sel.APICalls) / float64(sel.UserMessages)
|
||||
}
|
||||
fmt.Fprintf(&body, "%s %s %s %s %s %.1fx\n\n",
|
||||
labelStyle.Render("Prompts:"), valueStyle.Render(cli.FormatNumber(int64(sel.UserMessages))),
|
||||
labelStyle.Render("API Calls:"), valueStyle.Render(cli.FormatNumber(int64(sel.APICalls))),
|
||||
labelStyle.Render("Ratio:"), ratio)
|
||||
body.WriteString(labelStyle.Render("Prompts: "))
|
||||
body.WriteString(valueStyle.Render(cli.FormatNumber(int64(sel.UserMessages))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
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
|
||||
body.WriteString(headerStyle.Render("TOKEN BREAKDOWN"))
|
||||
// Token breakdown table with section header
|
||||
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")
|
||||
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(mutedStyle.Render(strings.Repeat("─", tableW)))
|
||||
body.WriteString(dimStyle.Render(strings.Repeat("─", tableW)))
|
||||
body.WriteString("\n")
|
||||
|
||||
// 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 {
|
||||
continue
|
||||
}
|
||||
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %*s %*s",
|
||||
typeW,
|
||||
truncStr(r.typ, typeW),
|
||||
tokW,
|
||||
cli.FormatTokens(r.tokens),
|
||||
costW,
|
||||
cli.FormatCost(r.cost))))
|
||||
body.WriteString(labelStyle.Render(fmt.Sprintf("%-*s", typeW, truncStr(r.typ, typeW))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(tokenStyle.Render(fmt.Sprintf("%*s", tokW, cli.FormatTokens(r.tokens))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(costStyle.Render(fmt.Sprintf("%*s", costW, cli.FormatCost(r.cost))))
|
||||
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")
|
||||
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 {
|
||||
body.WriteString("\n")
|
||||
body.WriteString(headerStyle.Render("API CALLS BY MODEL"))
|
||||
body.WriteString(sectionStyle.Render("API CALLS BY MODEL"))
|
||||
body.WriteString("\n")
|
||||
compactModelTable := innerW < 60
|
||||
if compactModelTable {
|
||||
@@ -380,14 +413,14 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
|
||||
if 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(mutedStyle.Render(strings.Repeat("─", modelW+7+8+2)))
|
||||
body.WriteString(dimStyle.Render(strings.Repeat("─", modelW+7+8+2)))
|
||||
} else {
|
||||
modelW := 14
|
||||
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", modelW, "Model", "Calls", "Input", "Output", "Cost")))
|
||||
body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", modelW, "Model", "Calls", "Input", "Output", "Cost")))
|
||||
body.WriteString("\n")
|
||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+10+10+8+4)))
|
||||
body.WriteString(dimStyle.Render(strings.Repeat("─", modelW+7+10+10+8+4)))
|
||||
}
|
||||
body.WriteString("\n")
|
||||
|
||||
@@ -405,20 +438,22 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
|
||||
if modelW < 8 {
|
||||
modelW = 8
|
||||
}
|
||||
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %8s",
|
||||
modelW,
|
||||
truncStr(shortModel(modelName), modelW),
|
||||
cli.FormatNumber(int64(mu.APICalls)),
|
||||
cli.FormatCost(mu.EstimatedCost))))
|
||||
body.WriteString(modelStyle.Render(fmt.Sprintf("%-*s", modelW, truncStr(shortModel(modelName), modelW))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(valueStyle.Render(fmt.Sprintf("%7s", cli.FormatNumber(int64(mu.APICalls)))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(costStyle.Render(fmt.Sprintf("%8s", cli.FormatCost(mu.EstimatedCost))))
|
||||
} else {
|
||||
modelW := 14
|
||||
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s",
|
||||
modelW,
|
||||
truncStr(shortModel(modelName), modelW),
|
||||
cli.FormatNumber(int64(mu.APICalls)),
|
||||
cli.FormatTokens(mu.InputTokens),
|
||||
cli.FormatTokens(mu.OutputTokens),
|
||||
cli.FormatCost(mu.EstimatedCost))))
|
||||
body.WriteString(modelStyle.Render(fmt.Sprintf("%-*s", modelW, truncStr(shortModel(modelName), modelW))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(valueStyle.Render(fmt.Sprintf("%7s", cli.FormatNumber(int64(mu.APICalls)))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(tokenStyle.Render(fmt.Sprintf("%10s", cli.FormatTokens(mu.InputTokens))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(tokenStyle.Render(fmt.Sprintf("%10s", cli.FormatTokens(mu.OutputTokens))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(costStyle.Render(fmt.Sprintf("%8s", cli.FormatCost(mu.EstimatedCost))))
|
||||
}
|
||||
body.WriteString("\n")
|
||||
}
|
||||
@@ -426,23 +461,23 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
|
||||
|
||||
if sel.IsSubagent {
|
||||
body.WriteString("\n")
|
||||
body.WriteString(mutedStyle.Render("(subagent session)"))
|
||||
body.WriteString(dimStyle.Render("(subagent session)"))
|
||||
body.WriteString("\n")
|
||||
}
|
||||
|
||||
// Subagent drill-down
|
||||
// Subagent drill-down with colors
|
||||
if subs := a.subagentMap[sel.SessionID]; len(subs) > 0 {
|
||||
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")
|
||||
|
||||
nameW := innerW - 8 - 10 - 2
|
||||
if nameW < 10 {
|
||||
nameW = 10
|
||||
}
|
||||
body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s", nameW, "Agent", "Duration", "Cost")))
|
||||
body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%-*s %8s %10s", nameW, "Agent", "Duration", "Cost")))
|
||||
body.WriteString("\n")
|
||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2)))
|
||||
body.WriteString(dimStyle.Render(strings.Repeat("─", nameW+8+10+2)))
|
||||
body.WriteString("\n")
|
||||
|
||||
var totalSubCost float64
|
||||
@@ -455,31 +490,41 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS
|
||||
}
|
||||
agentName = strings.TrimPrefix(agentName, "agent-")
|
||||
|
||||
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s",
|
||||
nameW,
|
||||
truncStr(agentName, nameW),
|
||||
cli.FormatDuration(sub.DurationSecs),
|
||||
cli.FormatCost(sub.EstimatedCost))))
|
||||
body.WriteString(modelStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(agentName, nameW))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(timeStyle.Render(fmt.Sprintf("%8s", cli.FormatDuration(sub.DurationSecs))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(costStyle.Render(fmt.Sprintf("%10s", cli.FormatCost(sub.EstimatedCost))))
|
||||
body.WriteString("\n")
|
||||
totalSubCost += sub.EstimatedCost
|
||||
totalSubDur += sub.DurationSecs
|
||||
}
|
||||
|
||||
body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2)))
|
||||
body.WriteString(dimStyle.Render(strings.Repeat("─", nameW+8+10+2)))
|
||||
body.WriteString("\n")
|
||||
body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s",
|
||||
nameW,
|
||||
"Combined",
|
||||
cli.FormatDuration(totalSubDur),
|
||||
cli.FormatCost(totalSubCost))))
|
||||
body.WriteString(accentStyle.Render(fmt.Sprintf("%-*s", nameW, "Combined")))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(timeStyle.Render(fmt.Sprintf("%8s", cli.FormatDuration(totalSubDur))))
|
||||
body.WriteString(dimStyle.Render(" "))
|
||||
body.WriteString(savingsStyle.Render(fmt.Sprintf("%10s", cli.FormatCost(totalSubCost))))
|
||||
body.WriteString("\n")
|
||||
}
|
||||
|
||||
// Footer hints with styled keys
|
||||
body.WriteString("\n")
|
||||
hintKeyStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface)
|
||||
hintTextStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||
if w < compactWidth {
|
||||
body.WriteString(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 {
|
||||
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()
|
||||
@@ -495,8 +540,8 @@ func shortID(id string) 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.
|
||||
func (a App) applyDetailScroll(body string, visibleH int) string {
|
||||
if visibleH < 5 {
|
||||
visibleH = 5
|
||||
if visibleH < sessMinVisible {
|
||||
visibleH = sessMinVisible
|
||||
}
|
||||
|
||||
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.
|
||||
if endIdx < len(lines) {
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func newSettingsInput() textinput.Model {
|
||||
}
|
||||
|
||||
func (a App) settingsStartEdit() (tea.Model, tea.Cmd) {
|
||||
cfg, _ := config.Load()
|
||||
cfg := loadConfigOrDefault()
|
||||
a.settings.editing = true
|
||||
a.settings.saved = false
|
||||
|
||||
@@ -123,7 +123,7 @@ func (a App) updateSettingsInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (a *App) settingsSave() {
|
||||
cfg, _ := config.Load()
|
||||
cfg := loadConfigOrDefault()
|
||||
val := strings.TrimSpace(a.settings.input.Value())
|
||||
|
||||
switch a.settings.cursor {
|
||||
@@ -176,13 +176,15 @@ func (a *App) settingsSave() {
|
||||
|
||||
func (a App) renderSettingsTab(cw int) string {
|
||||
t := theme.Active
|
||||
cfg, _ := config.Load()
|
||||
cfg := loadConfigOrDefault()
|
||||
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary)
|
||||
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface).Bold(true)
|
||||
accentStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
||||
greenStyle := lipgloss.NewStyle().Foreground(t.Green)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||
valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface)
|
||||
selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.SurfaceBright).Bold(true)
|
||||
selectedLabelStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.SurfaceBright).Bold(true)
|
||||
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 {
|
||||
label string
|
||||
@@ -235,23 +237,39 @@ func (a App) renderSettingsTab(cw int) string {
|
||||
for i, f := range fields {
|
||||
// Show text input if currently editing this field
|
||||
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("\n")
|
||||
continue
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%-20s %s", f.label+":", f.value)
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
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(warnStyle.Render(fmt.Sprintf("Save failed: %s", a.settings.saveErr)))
|
||||
} else if a.settings.saved {
|
||||
|
||||
@@ -6,89 +6,139 @@ import "github.com/charmbracelet/lipgloss"
|
||||
// Theme defines the color roles used throughout the TUI.
|
||||
type Theme struct {
|
||||
Name string
|
||||
Background lipgloss.Color
|
||||
Surface lipgloss.Color
|
||||
Border lipgloss.Color
|
||||
TextDim lipgloss.Color
|
||||
TextMuted lipgloss.Color
|
||||
TextPrimary lipgloss.Color
|
||||
Accent lipgloss.Color
|
||||
Background lipgloss.Color // Main app background
|
||||
Surface lipgloss.Color // Card/panel backgrounds
|
||||
SurfaceHover lipgloss.Color // Highlighted surface (active tab, selected row)
|
||||
SurfaceBright lipgloss.Color // Extra bright surface for emphasis
|
||||
Border lipgloss.Color // Subtle borders
|
||||
BorderBright lipgloss.Color // Prominent borders (cards, focus)
|
||||
BorderAccent lipgloss.Color // Accent-colored borders for focus states
|
||||
TextDim lipgloss.Color // Lowest contrast text (hints, disabled)
|
||||
TextMuted lipgloss.Color // Secondary text (labels, metadata)
|
||||
TextPrimary lipgloss.Color // Primary content text
|
||||
Accent lipgloss.Color // Primary accent (links, active states)
|
||||
AccentBright lipgloss.Color // Brighter accent for emphasis
|
||||
AccentDim lipgloss.Color // Dimmed accent for backgrounds
|
||||
Green lipgloss.Color
|
||||
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.
|
||||
var Active = FlexokiDark
|
||||
|
||||
// FlexokiDark is the default theme.
|
||||
// FlexokiDark is the default theme - warm, paper-inspired dark theme.
|
||||
var FlexokiDark = Theme{
|
||||
Name: "flexoki-dark",
|
||||
Background: lipgloss.Color("#100F0F"),
|
||||
Surface: lipgloss.Color("#1C1B1A"),
|
||||
Border: lipgloss.Color("#282726"),
|
||||
SurfaceHover: lipgloss.Color("#282726"),
|
||||
SurfaceBright: lipgloss.Color("#343331"),
|
||||
Border: lipgloss.Color("#403E3C"),
|
||||
BorderBright: lipgloss.Color("#575653"),
|
||||
BorderAccent: lipgloss.Color("#3AA99F"),
|
||||
TextDim: lipgloss.Color("#575653"),
|
||||
TextMuted: lipgloss.Color("#6F6E69"),
|
||||
TextMuted: lipgloss.Color("#878580"),
|
||||
TextPrimary: lipgloss.Color("#FFFCF0"),
|
||||
Accent: lipgloss.Color("#3AA99F"),
|
||||
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{
|
||||
Name: "catppuccin-mocha",
|
||||
Background: lipgloss.Color("#1E1E2E"),
|
||||
Surface: lipgloss.Color("#313244"),
|
||||
Border: lipgloss.Color("#45475A"),
|
||||
SurfaceHover: lipgloss.Color("#45475A"),
|
||||
SurfaceBright: lipgloss.Color("#585B70"),
|
||||
Border: lipgloss.Color("#585B70"),
|
||||
BorderBright: lipgloss.Color("#7F849C"),
|
||||
BorderAccent: lipgloss.Color("#89B4FA"),
|
||||
TextDim: lipgloss.Color("#6C7086"),
|
||||
TextMuted: lipgloss.Color("#A6ADC8"),
|
||||
TextPrimary: lipgloss.Color("#CDD6F4"),
|
||||
Accent: lipgloss.Color("#89B4FA"),
|
||||
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{
|
||||
Name: "tokyo-night",
|
||||
Background: lipgloss.Color("#1A1B26"),
|
||||
Surface: lipgloss.Color("#24283B"),
|
||||
Border: lipgloss.Color("#414868"),
|
||||
SurfaceHover: lipgloss.Color("#343A52"),
|
||||
SurfaceBright: lipgloss.Color("#414868"),
|
||||
Border: lipgloss.Color("#565F89"),
|
||||
BorderBright: lipgloss.Color("#7982A9"),
|
||||
BorderAccent: lipgloss.Color("#7AA2F7"),
|
||||
TextDim: lipgloss.Color("#565F89"),
|
||||
TextMuted: lipgloss.Color("#A9B1D6"),
|
||||
TextPrimary: lipgloss.Color("#C0CAF5"),
|
||||
Accent: lipgloss.Color("#7AA2F7"),
|
||||
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{
|
||||
Name: "terminal",
|
||||
Background: lipgloss.Color("0"),
|
||||
Surface: lipgloss.Color("0"),
|
||||
SurfaceHover: lipgloss.Color("8"),
|
||||
SurfaceBright: lipgloss.Color("8"),
|
||||
Border: lipgloss.Color("8"),
|
||||
BorderBright: lipgloss.Color("7"),
|
||||
BorderAccent: lipgloss.Color("6"),
|
||||
TextDim: lipgloss.Color("8"),
|
||||
TextMuted: lipgloss.Color("7"),
|
||||
TextPrimary: lipgloss.Color("15"),
|
||||
Accent: lipgloss.Color("6"),
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user