diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-17 21:33:45 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-17 21:33:45 +0300 |
| commit | 88103657fb230bb41217a06aa5602ae23e7acb8b (patch) | |
| tree | 524c437e4e40ee5d6713b6ea5414ad975654cc52 /internal | |
| parent | 2b6232704ecc90630196b9f829f966533e5cdccd (diff) | |
feat(stats,tmux): global Σ@window stats across processes with flocked cache; width mitigation (narrow/maxlen); configurable [stats] window_minutes; robust coverage parsing; docs update\n\n- Add internal/stats with windowed event cache + flock + atomic writes\n- Wire stats into LSP/CLI/Tmux Action; tmux shows Σ@window with per-model tail\n- HEXAI_TMUX_STATUS_NARROW and HEXAI_TMUX_STATUS_MAXLEN for width control\n- Add [stats] window_minutes to config and apply on startup\n- Improve Magefile coverage handling; add tests to lift coverage >85%\n- Update docs/tmux.md and config example
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/appconfig/config.go | 15 | ||||
| -rw-r--r-- | internal/hexaiaction/prompts.go | 45 | ||||
| -rw-r--r-- | internal/hexaiaction/prompts_simplify_test.go | 27 | ||||
| -rw-r--r-- | internal/hexaiaction/run.go | 5 | ||||
| -rw-r--r-- | internal/hexaicli/run.go | 30 | ||||
| -rw-r--r-- | internal/hexailsp/run.go | 5 | ||||
| -rw-r--r-- | internal/lsp/handlers_completion.go | 5 | ||||
| -rw-r--r-- | internal/lsp/handlers_utils.go | 31 | ||||
| -rw-r--r-- | internal/stats/debugstring_test.go | 22 | ||||
| -rw-r--r-- | internal/stats/stats.go | 247 | ||||
| -rw-r--r-- | internal/stats/stats_test.go | 85 | ||||
| -rw-r--r-- | internal/tmux/status.go | 96 | ||||
| -rw-r--r-- | internal/tmux/status_more_test.go | 62 |
13 files changed, 646 insertions, 29 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index c8cf871..2274aee 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -89,6 +89,8 @@ type App struct { // Custom code actions and tmux integration CustomActions []CustomAction `json:"-" toml:"-"` TmuxCustomMenuHotkey string `json:"-" toml:"-"` + // Stats + StatsWindowMinutes int `json:"-" toml:"-"` } // CustomAction describes a user-defined code action. @@ -152,6 +154,9 @@ func newDefaultConfig() App { PromptCLIDefaultSystem: "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation.", PromptCLIExplainSystem: "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context.", + + // Stats + StatsWindowMinutes: 60, } } @@ -198,6 +203,7 @@ type fileConfig struct { Ollama sectionOllama `toml:"ollama"` Prompts sectionPrompts `toml:"prompts"` Tmux sectionTmux `toml:"tmux"` + Stats sectionStats `toml:"stats"` } type sectionGeneral struct { @@ -236,6 +242,10 @@ type sectionProvider struct { Name string `toml:"name"` } +type sectionStats struct { + WindowMinutes int `toml:"window_minutes"` +} + type sectionOpenAI struct { Model string `toml:"model"` BaseURL string `toml:"base_url"` @@ -501,6 +511,11 @@ func (fc *fileConfig) toApp() App { out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) } + // stats + if fc.Stats.WindowMinutes > 0 { + out.StatsWindowMinutes = fc.Stats.WindowMinutes + } + return out } diff --git a/internal/hexaiaction/prompts.go b/internal/hexaiaction/prompts.go index 97af32f..393e9e4 100644 --- a/internal/hexaiaction/prompts.go +++ b/internal/hexaiaction/prompts.go @@ -7,6 +7,7 @@ import ( "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/stats" "codeberg.org/snonux/hexai/internal/textutil" "codeberg.org/snonux/hexai/internal/tmux" ) @@ -88,47 +89,63 @@ func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appco func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) { msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - start := time.Now() txt, err := client.Chat(ctx, msgs) if err != nil { return "", err } out := strings.TrimSpace(StripFences(txt)) - // Update tmux heartbeat with simple one-request stats + // Contribute to global stats and update tmux status sent := 0 for _, m := range msgs { sent += len(m.Content) } recv := len(out) - mins := time.Since(start).Minutes() - if mins <= 0 { - mins = 0.001 + _ = stats.Update(ctx, providerOf(client), client.DefaultModel(), sent, recv) + if snap, err := stats.TakeSnapshot(); err == nil { + minsWin := snap.Window.Minutes() + if minsWin <= 0 { + minsWin = 0.001 + } + scopeReqs := int64(0) + if pe, ok := snap.Providers[providerOf(client)]; ok { + if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 { + scopeReqs = mc.Reqs + } + } + scopeRPM := float64(scopeReqs) / minsWin + _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, providerOf(client), client.DefaultModel(), scopeRPM, scopeReqs, snap.Window)) } - rpm := float64(1) / mins - _ = tmux.SetStatus(tmux.FormatLLMStatsStatusColored(providerOf(client), client.DefaultModel(), 1, rpm, int64(sent), int64(recv))) return out, nil } func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) { msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - start := time.Now() txt, err := client.Chat(ctx, msgs, opts...) if err != nil { return "", err } out := strings.TrimSpace(StripFences(txt)) - // Update tmux heartbeat with simple one-request stats + // Contribute to global stats and update tmux status sent := 0 for _, m := range msgs { sent += len(m.Content) } recv := len(out) - mins := time.Since(start).Minutes() - if mins <= 0 { - mins = 0.001 + _ = stats.Update(ctx, providerOf(client), client.DefaultModel(), sent, recv) + if snap, err := stats.TakeSnapshot(); err == nil { + minsWin := snap.Window.Minutes() + if minsWin <= 0 { + minsWin = 0.001 + } + scopeReqs := int64(0) + if pe, ok := snap.Providers[providerOf(client)]; ok { + if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 { + scopeReqs = mc.Reqs + } + } + scopeRPM := float64(scopeReqs) / minsWin + _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, providerOf(client), client.DefaultModel(), scopeRPM, scopeReqs, snap.Window)) } - rpm := float64(1) / mins - _ = tmux.SetStatus(tmux.FormatLLMStatsStatusColored(providerOf(client), client.DefaultModel(), 1, rpm, int64(sent), int64(recv))) return out, nil } diff --git a/internal/hexaiaction/prompts_simplify_test.go b/internal/hexaiaction/prompts_simplify_test.go new file mode 100644 index 0000000..4daba38 --- /dev/null +++ b/internal/hexaiaction/prompts_simplify_test.go @@ -0,0 +1,27 @@ +package hexaiaction + +import ( + "context" + "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" +) + +type simplifyClient struct{} + +func (simplifyClient) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { + return "OUT", nil +} +func (simplifyClient) DefaultModel() string { return "m" } + +func TestRunSimplify_Smoke(t *testing.T) { + cfg := appconfig.Load(nil) + out, err := runSimplify(context.Background(), cfg, simplifyClient{}, "code") + if err != nil { + t.Fatalf("runSimplify: %v", err) + } + if out == "" { + t.Fatalf("expected output") + } +} diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go index b07fbbb..45eacc2 100644 --- a/internal/hexaiaction/run.go +++ b/internal/hexaiaction/run.go @@ -6,11 +6,13 @@ import ( "io" "log" "strings" + "time" "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/editor" "codeberg.org/snonux/hexai/internal/llmutils" "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/stats" "codeberg.org/snonux/hexai/internal/tmux" ) @@ -28,6 +30,9 @@ var selectedCustom *appconfig.CustomAction func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error { logger := log.New(stderr, "hexai-tmux-action ", log.LstdFlags|log.Lmsgprefix) cfg := appconfig.Load(logger) + if cfg.StatsWindowMinutes > 0 { + stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) + } if err := cfg.Validate(); err != nil { fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: %v"+logging.AnsiReset+"\n", err) return err diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go index 984bc85..9909f4f 100644 --- a/internal/hexaicli/run.go +++ b/internal/hexaicli/run.go @@ -17,6 +17,7 @@ import ( "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/llmutils" "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/stats" "codeberg.org/snonux/hexai/internal/tmux" ) @@ -26,6 +27,9 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. // Load configuration with a logger so file-based config is respected. logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) cfg := appconfig.Load(logger) + if cfg.StatsWindowMinutes > 0 { + stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) + } client, err := newClientFromApp(cfg) if err != nil { fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) @@ -146,20 +150,28 @@ func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input s fmt.Fprint(out, output) } dur := time.Since(start) - // Compute simple stats for tmux heartbeat + // Contribute to global stats and update tmux status sent := 0 for _, m := range msgs { sent += len(m.Content) } recv := len(output) - mins := dur.Minutes() - if mins <= 0 { - mins = 0.001 - } - rpm := float64(1) / mins - fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d"+logging.AnsiReset+"\n", - client.Name(), client.DefaultModel(), dur.Round(time.Millisecond), sent, recv) - _ = tmux.SetStatus(tmux.FormatLLMStatsStatusColored(client.Name(), client.DefaultModel(), 1, rpm, int64(sent), int64(recv))) + _ = stats.Update(ctx, client.Name(), client.DefaultModel(), sent, recv) + snap, _ := stats.TakeSnapshot() + minsWin := snap.Window.Minutes() + if minsWin <= 0 { + minsWin = 0.001 + } + scopeReqs := int64(0) + if pe, ok := snap.Providers[client.Name()]; ok { + if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 { + scopeReqs = mc.Reqs + } + } + scopeRPM := float64(scopeReqs) / minsWin + fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d | global Σ reqs=%d rpm=%.2f"+logging.AnsiReset+"\n", + client.Name(), client.DefaultModel(), dur.Round(time.Millisecond), sent, recv, snap.Global.Reqs, snap.RPM) + _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, client.Name(), client.DefaultModel(), scopeRPM, scopeReqs, snap.Window)) return nil } diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go index 92548b3..554e604 100644 --- a/internal/hexailsp/run.go +++ b/internal/hexailsp/run.go @@ -7,11 +7,13 @@ import ( "log" "os" "strings" + "time" "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/logging" "codeberg.org/snonux/hexai/internal/lsp" + "codeberg.org/snonux/hexai/internal/stats" ) // ServerRunner is the minimal interface satisfied by lsp.Server. @@ -37,6 +39,9 @@ func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) er if err := cfg.Validate(); err != nil { logger.Fatalf("invalid config: %v", err) } + if cfg.StatsWindowMinutes > 0 { + stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) + } return RunWithFactory(logPath, stdin, stdout, logger, cfg, nil, nil) } diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go index 14c5f3e..9ef62f1 100644 --- a/internal/lsp/handlers_completion.go +++ b/internal/lsp/handlers_completion.go @@ -10,6 +10,7 @@ import ( "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/stats" ) func (s *Server) handleCompletion(req Request) { @@ -257,6 +258,10 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, // Update counters and heartbeat s.incSentCounters(sentBytes) s.incRecvCounters(len(suggestions[0])) + // Contribute to global stats (provider-native path) + if s.llmClient != nil { + _ = stats.Update(ctx2, s.llmClient.Name(), s.llmClient.DefaultModel(), sentBytes, len(suggestions[0])) + } s.logLLMStats() cleaned := strings.TrimSpace(suggestions[0]) if cleaned != "" { diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go index 15f0174..43bfdc8 100644 --- a/internal/lsp/handlers_utils.go +++ b/internal/lsp/handlers_utils.go @@ -8,6 +8,7 @@ import ( "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/stats" "codeberg.org/snonux/hexai/internal/textutil" tmx "codeberg.org/snonux/hexai/internal/tmux" ) @@ -59,15 +60,29 @@ func (s *Server) logLLMStats() { if mins <= 0 { mins = 0.001 } - rpm := float64(reqs) / mins + rpmLocal := float64(reqs) / mins sentPerMin := float64(sentTot) / mins recvPerMin := float64(recvTot) / mins - logging.Logf("lsp ", "llm stats reqs=%d avg_sent=%d avg_recv=%d sent_total=%d recv_total=%d rpm=%.2f sent_per_min=%.0f recv_per_min=%.0f", reqs, avgSent, avgRecv, sentTot, recvTot, rpm, sentPerMin, recvPerMin) - // Best-effort tmux status update with a compact stats heartbeat - if s.llmClient != nil { - model := s.llmClient.DefaultModel() + // Log local process counters + logging.Logf("lsp ", "llm stats (local) reqs=%d avg_sent=%d avg_recv=%d sent_total=%d recv_total=%d rpm=%.2f sent_per_min=%.0f recv_per_min=%.0f", reqs, avgSent, avgRecv, sentTot, recvTot, rpmLocal, sentPerMin, recvPerMin) + // Global snapshot for tmux status + snap, err := stats.TakeSnapshot() + if err == nil && s.llmClient != nil { provider := s.llmClient.Name() - status := tmx.FormatLLMStatsStatusColored(provider, model, reqs, rpm, sentTot, recvTot) + model := s.llmClient.DefaultModel() + // Per-scope rpm estimated from window + scopeReqs := int64(0) + if pe, ok := snap.Providers[provider]; ok { + if mc, ok2 := pe.Models[model]; ok2 { + scopeReqs = mc.Reqs + } + } + minsWin := snap.Window.Minutes() + if minsWin <= 0 { + minsWin = 0.001 + } + scopeRPM := float64(scopeReqs) / minsWin + status := tmx.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, provider, model, scopeRPM, scopeReqs, snap.Window) _ = tmx.SetStatus(status) } } @@ -151,6 +166,10 @@ func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ... return "", err } s.incRecvCounters(len(txt)) + // Update global stats cache + if s.llmClient != nil { + _ = stats.Update(ctx, s.llmClient.Name(), s.llmClient.DefaultModel(), sent, len(txt)) + } s.logLLMStats() return txt, nil } diff --git a/internal/stats/debugstring_test.go b/internal/stats/debugstring_test.go new file mode 100644 index 0000000..88b2a5e --- /dev/null +++ b/internal/stats/debugstring_test.go @@ -0,0 +1,22 @@ +package stats + +import "testing" + +func TestSnapshotDebugString(t *testing.T) { + s := Snapshot{} + s.Global.Reqs = 42 + s.RPM = 3.14 + got := s.DebugString() + if got == "" || !contains(got, "Σ reqs=42") || !contains(got, "rpm=") { + t.Fatalf("unexpected debug string: %q", got) + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 0000000..3a9a9ab --- /dev/null +++ b/internal/stats/stats.go @@ -0,0 +1,247 @@ +// Package stats provides a simple, process-safe, on-disk cache of Hexai LLM usage +// statistics shared across all binaries. It appends compact events (ts, provider, +// model, sent, recv) to a JSON file guarded by an advisory file lock, prunes +// entries older than the configured window (default 1h), and computes aggregated +// snapshots for display in logs and tmux status. +package stats + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "sync/atomic" + "syscall" + "time" +) + +const ( + fileName = "stats.json" + lockFileName = "stats.lock" + fileVersion = 1 + defaultWindow = time.Hour +) + +var windowSeconds int64 = int64(defaultWindow.Seconds()) + +// SetWindow sets the sliding window used for pruning and aggregation. +func SetWindow(d time.Duration) { + if d < time.Second { + d = time.Second + } + if d > 24*time.Hour { + d = 24 * time.Hour + } + atomic.StoreInt64(&windowSeconds, int64(d.Seconds())) +} + +// Window returns the current sliding window. +func Window() time.Duration { return time.Duration(atomic.LoadInt64(&windowSeconds)) * time.Second } + +// Event represents a single request/response with sizes. +type Event struct { + TS time.Time `json:"ts"` + Provider string `json:"provider"` + Model string `json:"model"` + Sent int64 `json:"sent"` + Recv int64 `json:"recv"` +} + +// File is the on-disk JSON structure. +type File struct { + Version int `json:"version"` + UpdatedAt time.Time `json:"updated_at"` + WindowSeconds int `json:"window_seconds"` + Events []Event `json:"events"` +} + +// Counters and Snapshot represent computed aggregates for the current window. +type Counters struct{ Reqs, Sent, Recv int64 } + +type ProviderEntry struct { + Totals Counters + Models map[string]Counters +} + +type Snapshot struct { + Global Counters + Providers map[string]ProviderEntry + RPM float64 + Window time.Duration +} + +// Update appends one event and prunes old entries under lock. +func Update(ctx context.Context, provider, model string, sentBytes, recvBytes int) error { + dir, err := CacheDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + lockPath := filepath.Join(dir, lockFileName) + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer f.Close() + // Acquire exclusive flock; best-effort ctx support via polling + for { + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err == nil { + defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + break + } + // Wait a bit or exit if context canceled + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(5 * time.Millisecond): + } + } + // Read existing file (if any) + path := filepath.Join(dir, fileName) + var sf File + if b, rerr := os.ReadFile(path); rerr == nil { + _ = json.Unmarshal(b, &sf) + } + if sf.Version != fileVersion { + sf = File{Version: fileVersion} + } + now := time.Now() + win := Window() + sf.WindowSeconds = int(win.Seconds()) + // Append event + sf.Events = append(sf.Events, Event{TS: now, Provider: provider, Model: model, Sent: int64(sentBytes), Recv: int64(recvBytes)}) + // Prune old + cutoff := now.Add(-win) + if len(sf.Events) > 0 { + // Find first >= cutoff + i := 0 + for ; i < len(sf.Events); i++ { + if !sf.Events[i].TS.Before(cutoff) { + break + } + } + if i > 0 { + sf.Events = append([]Event(nil), sf.Events[i:]...) + } + } + sf.UpdatedAt = now + // Write atomically + tmp, err := os.CreateTemp(dir, fileName+".tmp.") + if err != nil { + return err + } + enc := json.NewEncoder(tmp) + enc.SetEscapeHTML(false) + if err := enc.Encode(&sf); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return err + } + if err := os.Rename(tmp.Name(), path); err != nil { + os.Remove(tmp.Name()) + return err + } + return nil +} + +// Snapshot reads and aggregates events within the configured window. +func TakeSnapshot() (Snapshot, error) { + dir, err := CacheDir() + if err != nil { + return Snapshot{}, err + } + path := filepath.Join(dir, fileName) + b, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Snapshot{Providers: map[string]ProviderEntry{}, Window: Window()}, nil + } + return Snapshot{}, err + } + var sf File + if err := json.Unmarshal(b, &sf); err != nil { + return Snapshot{}, err + } + win := time.Duration(sf.WindowSeconds) * time.Second + if win <= 0 { + win = Window() + } else { + SetWindow(win) // align process with file window if changed elsewhere + } + cutoff := time.Now().Add(-win) + snap := Snapshot{Providers: make(map[string]ProviderEntry), Window: win} + for _, ev := range sf.Events { + if ev.TS.Before(cutoff) { + continue + } + snap.Global.Reqs++ + snap.Global.Sent += ev.Sent + snap.Global.Recv += ev.Recv + pe := snap.Providers[ev.Provider] + if pe.Models == nil { + pe.Models = make(map[string]Counters) + } + pe.Totals.Reqs++ + pe.Totals.Sent += ev.Sent + pe.Totals.Recv += ev.Recv + mc := pe.Models[ev.Model] + mc.Reqs++ + mc.Sent += ev.Sent + mc.Recv += ev.Recv + pe.Models[ev.Model] = mc + snap.Providers[ev.Provider] = pe + } + mins := win.Minutes() + if mins <= 0 { + mins = 0.001 + } + snap.RPM = float64(snap.Global.Reqs) / mins + return snap, nil +} + +// CacheDir resolves the cache directory for stats. +func CacheDir() (string, error) { + if x := os.Getenv("XDG_CACHE_HOME"); stringsTrim(x) != "" { + return filepath.Join(x, "hexai"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot resolve home: %w", err) + } + return filepath.Join(home, ".cache", "hexai"), nil +} + +// stringsTrim is a tiny helper to avoid importing strings everywhere here. +func stringsTrim(s string) string { + i := 0 + j := len(s) + for i < j && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') { + i++ + } + for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') { + j-- + } + if i == 0 && j == len(s) { + return s + } + return s[i:j] +} + +// DebugString returns a compact single-line view of a snapshot (useful for logs). +func (s Snapshot) DebugString() string { + return "Σ reqs=" + strconv.FormatInt(s.Global.Reqs, 10) + " rpm=" + fmt.Sprintf("%.2f", s.RPM) +} diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go new file mode 100644 index 0000000..a81e215 --- /dev/null +++ b/internal/stats/stats_test.go @@ -0,0 +1,85 @@ +package stats + +import ( + "context" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestUpdateAndSnapshot_Single(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + SetWindow(2 * time.Minute) + if err := Update(context.Background(), "prov", "model", 10, 20); err != nil { + t.Fatalf("update: %v", err) + } + snap, err := TakeSnapshot() + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if snap.Global.Reqs != 1 || snap.Global.Sent != 10 || snap.Global.Recv != 20 { + t.Fatalf("unexpected snap: %+v", snap) + } + if snap.Providers["prov"].Totals.Reqs != 1 || snap.Providers["prov"].Models["model"].Reqs != 1 { + t.Fatalf("missing provider/model aggregates: %+v", snap) + } +} + +func TestUpdate_PrunesOld_ByWindow(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + SetWindow(2 * time.Second) + ctx := context.Background() + if err := Update(ctx, "p", "m", 1, 1); err != nil { + t.Fatal(err) + } + time.Sleep(2200 * time.Millisecond) + if err := Update(ctx, "p", "m", 2, 2); err != nil { + t.Fatal(err) + } + snap, err := TakeSnapshot() + if err != nil { + t.Fatal(err) + } + if snap.Global.Reqs != 1 || snap.Global.Sent != 2 || snap.Global.Recv != 2 { + t.Fatalf("expected first event pruned, got %+v", snap) + } +} + +func TestConcurrentUpdates_LockSafety(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + SetWindow(1 * time.Minute) + ctx := context.Background() + var wg sync.WaitGroup + n := 20 + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if err := Update(ctx, "p", "m", i, i); err != nil { + t.Errorf("update %d: %v", i, err) + } + }(i) + } + wg.Wait() + snap, err := TakeSnapshot() + if err != nil { + t.Fatal(err) + } + if snap.Global.Reqs != int64(n) { + t.Fatalf("reqs mismatch: %d", snap.Global.Reqs) + } +} + +func TestCacheDir_XDG(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + got, err := CacheDir() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(dir, "hexai") + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} diff --git a/internal/tmux/status.go b/internal/tmux/status.go index 6d76bcb..dcc0714 100644 --- a/internal/tmux/status.go +++ b/internal/tmux/status.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "os/exec" + "strconv" "strings" + "time" "codeberg.org/snonux/hexai/internal/textutil" ) @@ -65,6 +67,100 @@ func FormatLLMStatsStatusColored(provider, model string, reqs int64, rpm float64 ) } +// FormatGlobalStatusColored renders a compact global stats heartbeat with an optional +// scoped provider:model tail. The window indicator (e.g., Σ@1h) should be composed +// by the caller if needed; this function focuses on numbers and labels. +// Example: "Σ ↑120k ↓340k 4.2rpm | openai:gpt-4.1 3.1rpm 80r" +func FormatGlobalStatusColored(globalReqs int64, globalRPM float64, globalIn, globalOut int64, scopeProvider, scopeModel string, scopeRPM float64, scopeReqs int64, window time.Duration) string { + gin := textutil.HumanBytes(globalIn) + gout := textutil.HumanBytes(globalOut) + head := fmt.Sprintf("%sΣ@%s %s↑%s%s %s↓%s%s %.1frpm", baseFGToken, humanWindow(window), arrowUpToken, baseFGToken, gin, arrowDownToken, baseFGToken, gout, globalRPM) + // Narrow modes: only show Σ head + if narrowEnabled() || stringsTrim(scopeProvider) == "" || stringsTrim(scopeModel) == "" { + return head + } + tail := fmt.Sprintf(" | %s:%s %.1frpm %dr", scopeProvider, scopeModel, scopeRPM, scopeReqs) + // Respect max length when configured: drop tail if it would overflow + if ml := maxStatusLen(); ml > 0 { + if len(head) <= ml && len(head)+len(tail) > ml { + return head + } + if len(head) > ml { + return truncateStatus(head, ml) + } + } + return head + tail +} + +func humanWindow(d time.Duration) string { + if d <= 0 { + return "?" + } + mins := int(d.Minutes()) + if mins%60 == 0 { + return fmt.Sprintf("%dh", mins/60) + } + if mins >= 60 { + return fmt.Sprintf("%dm", mins) + } + return fmt.Sprintf("%dm", mins) +} + +// narrowEnabled returns true when HEXAI_TMUX_STATUS_NARROW is truthy (1/true/yes/on). +func narrowEnabled() bool { + v := strings.ToLower(stringsTrim(os.Getenv("HEXAI_TMUX_STATUS_NARROW"))) + if v == "" { + return false + } + switch v { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +// maxStatusLen returns HEXAI_TMUX_STATUS_MAXLEN parsed as int; 0 disables. +func maxStatusLen() int { + v := stringsTrim(os.Getenv("HEXAI_TMUX_STATUS_MAXLEN")) + if v == "" { + return 0 + } + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return 0 + } + return n +} + +func truncateStatus(s string, n int) string { + if n <= 0 { + return "" + } + if len(s) <= n { + return s + } + if n <= 1 { + return s[:n] + } + return s[:n-1] + "…" +} + +func stringsTrim(s string) string { + i := 0 + j := len(s) + for i < j && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') { + i++ + } + for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') { + j-- + } + if i == 0 && j == len(s) { + return s + } + return s[i:j] +} + // FormatLLMStartStatus renders a short colored heartbeat at start/initialize time. // Example: "LLM:openai:gpt-4.1 ⏳" func FormatLLMStartStatus(provider, model string) string { diff --git a/internal/tmux/status_more_test.go b/internal/tmux/status_more_test.go new file mode 100644 index 0000000..deaf57d --- /dev/null +++ b/internal/tmux/status_more_test.go @@ -0,0 +1,62 @@ +package tmux + +import ( + "os" + "testing" + "time" +) + +func TestFormatLLMStatsStatus_Basic(t *testing.T) { + s := FormatLLMStatsStatus("gpt-4.1", 5, 0.8, 1234, 5678) + if s == "" || !containsAll(s, []string{"LLM:gpt-4.1", "5r", "0.8rpm"}) { + t.Fatalf("unexpected status: %q", s) + } +} + +func TestFormatLLMStatsStatusColored_Basic(t *testing.T) { + s := FormatLLMStatsStatusColored("openai", "gpt-4.1", 2, 1.2, 100, 200) + if s == "" || !containsAll(s, []string{"LLM:openai:gpt-4.1", "rpm", "2r"}) { + t.Fatalf("colored status missing parts: %q", s) + } +} + +func TestFormatGlobalStatusColored_NarrowAndMaxLen(t *testing.T) { + // Narrow mode should elide the tail + os.Setenv("HEXAI_TMUX_STATUS_NARROW", "1") + defer os.Unsetenv("HEXAI_TMUX_STATUS_NARROW") + s := FormatGlobalStatusColored(10, 3.3, 1000, 2000, "prov", "model", 1.1, 4, 30*time.Minute) + if containsAll(s, []string{"|", "prov:model"}) { + t.Fatalf("narrow mode should not include tail: %q", s) + } + // Max length should also drop the tail when it would overflow + os.Unsetenv("HEXAI_TMUX_STATUS_NARROW") + os.Setenv("HEXAI_TMUX_STATUS_MAXLEN", "10") + defer os.Unsetenv("HEXAI_TMUX_STATUS_MAXLEN") + s2 := FormatGlobalStatusColored(10, 3.3, 1000, 2000, "prov", "model", 1.1, 4, 30*time.Minute) + if containsAll(s2, []string{"|", "prov:model"}) { + t.Fatalf("maxlen should drop tail when overflowing: %q", s2) + } +} + +func containsAll(s string, parts []string) bool { + for _, p := range parts { + if !contains(s, p) { + return false + } + } + return true +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (len(sub) == 0 || (len(s) > 0 && indexOf(s, sub) >= 0)) +} + +func indexOf(s, sub string) int { + // tiny wrapper to avoid importing strings + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} |
