From 88103657fb230bb41217a06aa5602ae23e7acb8b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 17 Sep 2025 21:33:45 +0300 Subject: =?UTF-8?q?feat(stats,tmux):=20global=20=CE=A3@window=20stats=20ac?= =?UTF-8?q?ross=20processes=20with=20flocked=20cache;=20width=20mitigation?= =?UTF-8?q?=20(narrow/maxlen);=20configurable=20[stats]=20window=5Fminutes?= =?UTF-8?q?;=20robust=20coverage=20parsing;=20docs=20update\n\n-=20Add=20i?= =?UTF-8?q?nternal/stats=20with=20windowed=20event=20cache=20+=20flock=20+?= =?UTF-8?q?=20atomic=20writes\n-=20Wire=20stats=20into=20LSP/CLI/Tmux=20Ac?= =?UTF-8?q?tion;=20tmux=20shows=20=CE=A3@window=20with=20per-model=20tail\?= =?UTF-8?q?n-=20HEXAI=5FTMUX=5FSTATUS=5FNARROW=20and=20HEXAI=5FTMUX=5FSTAT?= =?UTF-8?q?US=5FMAXLEN=20for=20width=20control\n-=20Add=20[stats]=20window?= =?UTF-8?q?=5Fminutes=20to=20config=20and=20apply=20on=20startup\n-=20Impr?= =?UTF-8?q?ove=20Magefile=20coverage=20handling;=20add=20tests=20to=20lift?= =?UTF-8?q?=20coverage=20>85%\n-=20Update=20docs/tmux.md=20and=20config=20?= =?UTF-8?q?example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 8 + MAKEITSO.md | 131 + Magefile.go | 23 +- PROJECTSTATUS.md | 23 - SCRATCHPAD.md | 10 +- config.toml.example | 5 +- docs/coverage.html | 1215 +- docs/coverage.out | 29544 ++++++++++++++---------- docs/tmux.md | 20 +- internal/appconfig/config.go | 15 + internal/hexaiaction/prompts.go | 45 +- internal/hexaiaction/prompts_simplify_test.go | 27 + internal/hexaiaction/run.go | 5 + internal/hexaicli/run.go | 30 +- internal/hexailsp/run.go | 5 + internal/lsp/handlers_completion.go | 5 + internal/lsp/handlers_utils.go | 31 +- internal/stats/debugstring_test.go | 22 + internal/stats/stats.go | 247 + internal/stats/stats_test.go | 85 + internal/tmux/status.go | 96 + internal/tmux/status_more_test.go | 62 + 22 files changed, 18520 insertions(+), 13134 deletions(-) create mode 100644 MAKEITSO.md delete mode 100644 PROJECTSTATUS.md create mode 100644 internal/hexaiaction/prompts_simplify_test.go create mode 100644 internal/stats/debugstring_test.go create mode 100644 internal/stats/stats.go create mode 100644 internal/stats/stats_test.go create mode 100644 internal/tmux/status_more_test.go diff --git a/AGENTS.md b/AGENTS.md index 3247667..a81a2ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,3 +18,11 @@ - Filenames: docs use `lowercase-with-dashes.md`; images use kebab‑case with size/purpose suffix (e.g., `hexai-small.png`). - Code (when added): follow language idioms - Any type with more than 3 methods should be in it's own source code file, whereas the filename contains the name of the type. + + +## Incrementing version + +- Never draft a changelog entry +- Whenever incrementing the version, update the version number in the project, commit to git, tag the version and push to git. +- When a major feature was introduced, increment ?.X.? +- When only minor changes were done or only bugs were fixed, increment the version as ?.?.X diff --git a/MAKEITSO.md b/MAKEITSO.md new file mode 100644 index 0000000..81c68c7 --- /dev/null +++ b/MAKEITSO.md @@ -0,0 +1,131 @@ +## Global Hexai LLM Stats (Plan) + +### Goals +- Unify LLM usage stats across all Hexai processes: `hexai-lsp`, `hexai` (CLI), and `hexai-tmux-action`). +- Persist stats on disk so concurrent processes contribute to a single, shared view. +- Show consistent stats in logs and in the tmux status line regardless of which binary triggered the last request. +- Track both per-provider:model and global totals; include request count and total bytes sent/received; compute RPM. +- Always display stats for a sliding recent window (default: last 1 hour). + +### Non-Goals (for this iteration) +- No networked metrics backends, no long-term history beyond recent minutes needed to compute RPM. +- No user-facing commands to reset/export stats (can be a follow-up). + +### Cache Location and Layout +- Directory: `XDG_CACHE_HOME/hexai` (fallback to `~/.cache/hexai` when `XDG_CACHE_HOME` is unset). +- File: `stats.json` (atomically written via temp file + rename). +- Schema (v1): + { + "version": 1, + "updated_at": "RFC3339", + "window_seconds": 3600, + "events": [ + { "ts": "RFC3339Nano", "provider": "openai", "model": "gpt-4.1", "sent": 1234, "recv": 5678 } + ] + } +- Notes: + - Array-like append-only event list with periodic compaction: on update, drop entries older than `window_seconds` (default 3600 seconds = 1h). + - Aggregations (global totals, per provider/model, RPM) are computed on read from events within the current window only. + - Keep file size bounded: compact (prune + optionally coalesce older sub-minute events into minute buckets) when length exceeds a threshold (e.g., 10k events) or on a time basis. + +### Concurrency & File Locking +- Use advisory file locks for Unix-like systems. + - Create/`open` lock file `stats.lock` in the same cache directory. + - Apply `flock(LOCK_EX)` (via `syscall`/`golang.org/x/sys/unix`) around the read-modify-write cycle. + - Ensure the lock file is held for the shortest duration (milliseconds). +- Atomic update: + - Read existing `stats.json` (if missing, start with empty events and default window). + - Append one event for the just-finished request; prune entries older than `window_seconds` (relative to now). + - Write to `stats.json.tmp`, `fsync`, then `rename` to `stats.json`. +- Retry strategy: + - Bounded retries with small backoff if lock acquisition or IO fails; log a single warning and continue without crashing. + +### Package Design +- New package: `internal/stats` + - `func Update(ctx, provider, model string, sentBytes, recvBytes int) error` (append event, prune old). + - `func Snapshot(ctx context.Context) (S, error)` to read current state (aggregate from events within window). + - `func RPM(s S) float64` computes requests/minute over the configured window. + - `func SetWindow(d time.Duration)` and `func Window() time.Duration` to configure the window (default 1h; read from `config.toml`). + - `func CacheDir() (string, error)` honoring XDG; `func Path() string` for `stats.json`. + - Careful with allocations and zero/empty-state handling. +- Types: + - `type Event struct { TS time.Time; Provider, Model string; Sent, Recv int64 }` + - `type StatFile struct { Version int; UpdatedAt time.Time; WindowSeconds int; Events []Event }` + - Aggregated snapshot (in-memory): + - `type Counters struct { Reqs int64; Sent int64; 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 }` + +### Integration Points +- Common approach: update stats exactly where we already compute per-process counters. + +1) LSP (`internal/lsp`) +- Hook at the end of: + - `chatWithStats`: after successful Chat, call `stats.Update(provider, model, sentBytes, recvBytes)`. + - Provider-native completion path: when we get suggestions, also update using `sentBytes` and received bytes of first suggestion (consistent with current local counters). +- After update, read a `Snapshot` (window-aware by design) and compute: + - Per current provider:model totals (for context), and global totals over the last window (default 1h). + - RPM computed from events in the current window. +- Display: + - Logs: extend existing LLM stats line to include Σ (global) view. + - tmux: replace current status with a compact global view, e.g.: + - `⏳ Σ reqs=123 rpm=4.2 ↑1.2MB ↓3.4MB | openai:gpt-4.1 reqs=80 rpm=3.1`. + - Use `tmux.FormatLLMStatsStatusColoredGlobal(...)` (new) to render. + +2) CLI (`cmd/hexai`/`internal/hexaicli`) +- Where Chat is invoked (current CLI flow calls LLM directly): wrap the LLM client or count bytes and call `stats.Update` after each request. +- Print a one-line summary to stderr (consistent with LSP logging format). + +3) Tmux Action (`cmd/hexai-tmux-action` / `internal/hexaiaction`) +- In the code paths that call `client.Chat` (runOnce / runOnceWithOpts), after success call `stats.Update`. +- Update tmux status the same way as LSP by reusing the same formatter function in `internal/tmux`. + +### Tmux Status API +- Extend `internal/tmux` with a new helper: + - `func FormatGlobalStatsStatusColored(s stats.Snapshot, preferProvider, preferModel string) string` (include window indicator like `Σ@1h`). + - Or a smaller data struct extracted from snapshot to avoid leaking types. +- Keep existing `FormatLLMStatsStatusColored` for backward compatibility; LSP/CLI/TUI all switch to the new global formatter. + +### Logging +- Reuse existing logging but compute and append global counters: + - `llm stats reqs=local avg_sent=... rpm_local=... | Σ reqs=... rpm=... sent_total=... recv_total=...` +- Keep logs short to avoid noise; gate with existing log level. + +### Configuration +- New section in `config.toml`: + - `[stats] window_minutes = 60` (default 60; min 1, max 1440) + - All displays and RPM calculations operate over this sliding window. + +### Error Handling +- Stats update failures must never fail the user-facing operation. +- Log at `info` once per process when disk write fails and then mute repeated errors for a cooldown period. + +- Unit tests for `internal/stats`: + - Cache dir resolution (XDG vs HOME). + - Locking: concurrent goroutines updating stats in a temp XDG cache dir; assert totals match expected; ensure no partial writes. + - Event pruning (older than window) and RPM calculation over the configured window. + - JSON round-trip and version field. +- Integration tests (lightweight): + - Override `XDG_CACHE_HOME` to a temp directory. + - Simulate 2 processes: spawn subtests that call `stats.Update` interleaved; assert final snapshot. + - LSP and hexaiaction: hook fakes that perform `Chat` and then verify `stats.Snapshot` reflects the calls. + +### Migration / Backward Compatibility +- On first run, create cache dir and empty stats file lazily under lock. +- If file is invalid JSON or version mismatch, start from zero and overwrite. + +### Rollout Plan +- [x] Scaffold `internal/stats` with types, JSON read/write, cache dir, and lock helpers (Unix). +- [x] Implement `Update()` with lock → read → mutate → write (atomic) and pruning. +- [x] Implement `Snapshot()` and helpers to compute aggregates and RPM over the configured window (pruning done; optional compaction TBD). +- [x] Add tmux formatter in `internal/tmux` to display global stats (compact view). +- [x] Integrate LSP: update stats in `chatWithStats` and provider-native path; use global snapshot for tmux status. +- [x] Integrate CLI and Tmux Action: update stats after each Chat; stderr/tmux show global view. +- [x] Add tests for `internal/stats` (window pruning, concurrency, XDG path). +- [x] Run mage Coverage and update docs/screenshots if needed. +- [x] Verify all LLM call paths contribute to the new stats mechanism. + +### Estimation & Risks +- Est. 4–6 hours including tests and integration. +- Risk: file locking portability (Linux/macOS OK with flock). Mitigation: implement Unix only now; detect/disable gracefully elsewhere. +- Risk: tmux status width. Mitigation: show Σ-only by default and elide per-model when narrow (or truncate labels). diff --git a/Magefile.go b/Magefile.go index b297a9d..fdf5389 100644 --- a/Magefile.go +++ b/Magefile.go @@ -109,8 +109,8 @@ func RunTmuxAction() error { // printCoverage prints a warning if an existing coverage profile shows total < coverateThreshold. func printCoverage() { - // Ensure the top-level coverage profile is refreshed at least once per day. - ensureDailyCoverage(24 * time.Hour) + // Ensure the top-level coverage profile is refreshed at least once per day. + ensureDailyCoverage(24 * time.Hour) select { case coveragePrinted <- struct{}{}: default: @@ -126,11 +126,20 @@ func printCoverage() { fmt.Println("[coverage] No coverage profile found (run 'mage cover' or 'mage coverall').") return } - pct, ok := totalCoveragePercent(profile) - if !ok { - fmt.Println("[coverage] Could not parse total coverage from", profile) - return - } + pct, ok := totalCoveragePercent(profile) + if !ok { + // Attempt a one-time regen if the profile is malformed + if err := Coverage(); err == nil { + if p2, ok2 := totalCoveragePercent(profile); ok2 { + pct = p2 + ok = true + } + } + } + if !ok { + fmt.Println("[coverage] Could not parse total coverage from", profile) + return + } if pct < coverageThreshold { fmt.Printf("[coverage] WARNING: total test coverage is %.1f%% (< %.1f%%)\n", pct, coverageThreshold) } else { diff --git a/PROJECTSTATUS.md b/PROJECTSTATUS.md deleted file mode 100644 index 230193d..0000000 --- a/PROJECTSTATUS.md +++ /dev/null @@ -1,23 +0,0 @@ -# Project status - -This document shows future items and items in progress. Already completed ones are deleted from this document as updates occur. - -## Features - -* [ ] In-editor chat triggers should be context aware of the current file, buffer and function! -* [ ] Kagi FastGPT for in-editor search - - Think about an in-editor chat trigger, maybe with S> for search! -* [ ] Test whethe GitHub Copilot support actually works now, and if not, fix it! -* [ ] Be able to re-configure the temperature in-editor -* [ ] Be able to switch LLMs. - -## More - -* [ ] Review documentation -* [ ] Manual review the code -* [ ] Useful: https://deepwiki.com/helix-editor/helix/4.3-language-server-protocol` - - - - - diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index afea5aa..d9874bc 100644 --- a/SCRATCHPAD.md +++ b/SCRATCHPAD.md @@ -4,9 +4,12 @@ This document shows future items and items in progress. Already completed ones a ## Features -* [ ] Keep global stats about LLM usage for the tmux pane! -* [ ] No a feature, but verify my OpenAI API account so I can use GPT-5 via the API. -* [ ] In-editor chat triggers should be context aware of the current file, buffer and function! +* [/] Keep global stats about LLM usage for the tmux pane! +* [/] No a feature, but verify my OpenAI API account so I can use GPT-5 via the API. + * [ ] Temperature must by default be 1 for GPT-5 + * [ ] Answers aren't streamed ot th eCLI anymore? + * [ ] GPT-5 is timing out on large responses? + * [ ] Any more tweaks for GPT-5 API? * [ ] Kagi FastGPT for in-editor search - Think about an in-editor chat trigger, maybe with S> for search! * [ ] Test whethe GitHub Copilot support actually works now, and if not, fix it! @@ -25,6 +28,7 @@ This document shows future items and items in progress. Already completed ones a * [/] Review documentation * [/] Manual review the code * [ ] Useful: https://deepwiki.com/helix-editor/helix/4.3-language-server-protocol +* [/] Code review with another LLM ## Additional Ideas (Nice to Have) diff --git a/config.toml.example b/config.toml.example index c237d5b..9ac6f51 100644 --- a/config.toml.example +++ b/config.toml.example @@ -12,7 +12,7 @@ coding_temperature = 0.2 # single knob for LSP calls (optional) log_preview_limit = 100 # chars shown in log previews [completion] -completion_debounce_ms = 200 # idle ms before sending a request +completion_debounce_ms = 800 # idle ms before sending a request completion_throttle_ms = 0 # min ms between requests (0 disables) manual_invoke_min_prefix = 0 # required identifier chars for manual invoke @@ -100,3 +100,6 @@ temperature = 0.2 [tmux] # custom_menu_hotkey = "a" # hotkey to open the custom actions submenu in hexai-tmux-action + +[stats] +# window_minutes = 60 # sliding window for global stats (Σ@window); min 1, max 1440 diff --git a/docs/coverage.html b/docs/coverage.html index 2d72d59..4c7532e 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -61,7 +61,7 @@ - + @@ -69,9 +69,9 @@ - + - + @@ -79,9 +79,9 @@ - + - + @@ -99,37 +99,39 @@ - + - + - + - + - + - + - + - + - + - + - + + + @@ -328,6 +330,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. @@ -343,7 +347,7 @@ type CustomAction struct { } // Constructor: defaults for App (kept first among functions) -func newDefaultConfig() App { +func newDefaultConfig() App { // Coding-friendly default temperature across providers // Users can override per provider in config.toml (including 0.0). t := 0.2 @@ -358,7 +362,7 @@ func newDefaultConfig() App { OllamaTemperature: &t, CopilotTemperature: &t, ManualInvokeMinPrefix: 0, - CompletionDebounceMs: 200, + CompletionDebounceMs: 800, CompletionThrottleMs: 0, // Inline/chat trigger defaults InlineOpen: ">", @@ -391,14 +395,17 @@ 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, } } // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. -func Load(logger *log.Logger) App { +func Load(logger *log.Logger) App { cfg := newDefaultConfig() - if logger == nil { + if logger == nil { return cfg // Return defaults if no logger is provided (e.g. in tests) } @@ -407,7 +414,7 @@ func Load(logger *log.Logger) App { logger.Printf("%v", err) // Even if config path cannot be resolved, still allow env overrides below. } else { - if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { + if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { cfg.mergeWith(fileCfg) } // When the config file is missing or invalid, we keep defaults and still @@ -415,7 +422,7 @@ func Load(logger *log.Logger) App { } // Environment overrides (take precedence over file) - if envCfg := loadFromEnv(logger); envCfg != nil { + if envCfg := loadFromEnv(logger); envCfg != nil { cfg.mergeWith(envCfg) } return cfg @@ -437,6 +444,7 @@ type fileConfig struct { Ollama sectionOllama `toml:"ollama"` Prompts sectionPrompts `toml:"prompts"` Tmux sectionTmux `toml:"tmux"` + Stats sectionStats `toml:"stats"` } type sectionGeneral struct { @@ -475,6 +483,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"` @@ -553,7 +565,7 @@ type sectionTmux struct { CustomMenuHotkey string `toml:"custom_menu_hotkey"` } -func (fc *fileConfig) toApp() App { +func (fc *fileConfig) toApp() App { out := App{} // Merge section: general @@ -569,13 +581,13 @@ func (fc *fileConfig) toApp() App { } // logging - if (fc.Logging != sectionLogging{}) { + if (fc.Logging != sectionLogging{}) { tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit} out.mergeBasics(&tmp) } // completion - if (fc.Completion != sectionCompletion{}) { + if (fc.Completion != sectionCompletion{}) { tmp := App{ CompletionDebounceMs: fc.Completion.CompletionDebounceMs, CompletionThrottleMs: fc.Completion.CompletionThrottleMs, @@ -585,31 +597,31 @@ func (fc *fileConfig) toApp() App { } // triggers - if len(fc.Triggers.TriggerCharacters) > 0 { + if len(fc.Triggers.TriggerCharacters) > 0 { tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters} out.mergeBasics(&tmp) } // inline - if (fc.Inline != sectionInline{}) { + if (fc.Inline != sectionInline{}) { tmp := App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose} out.mergeBasics(&tmp) } // chat - if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 { + if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 { tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes} out.mergeBasics(&tmp) } // provider - if strings.TrimSpace(fc.Provider.Name) != "" { + if strings.TrimSpace(fc.Provider.Name) != "" { tmp := App{Provider: fc.Provider.Name} out.mergeBasics(&tmp) } // openai - if (fc.OpenAI != sectionOpenAI{}) || fc.OpenAI.Temperature != nil { + if (fc.OpenAI != sectionOpenAI{}) || fc.OpenAI.Temperature != nil { tmp := App{ OpenAIBaseURL: fc.OpenAI.BaseURL, OpenAIModel: fc.OpenAI.Model, @@ -619,7 +631,7 @@ func (fc *fileConfig) toApp() App { } // copilot - if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil { + if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil { tmp := App{ CopilotBaseURL: fc.Copilot.BaseURL, CopilotModel: fc.Copilot.Model, @@ -629,7 +641,7 @@ func (fc *fileConfig) toApp() App { } // ollama - if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil { + if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil { tmp := App{ OllamaBaseURL: fc.Ollama.BaseURL, OllamaModel: fc.Ollama.Model, @@ -640,7 +652,7 @@ func (fc *fileConfig) toApp() App { // prompts // completion - if (fc.Prompts.Completion != sectionPromptsCompletion{}) { + if (fc.Prompts.Completion != sectionPromptsCompletion{}) { if strings.TrimSpace(fc.Prompts.Completion.SystemGeneral) != "" { out.PromptCompletionSystemGeneral = fc.Prompts.Completion.SystemGeneral } @@ -661,11 +673,11 @@ func (fc *fileConfig) toApp() App { } } // chat - if strings.TrimSpace(fc.Prompts.Chat.System) != "" { + if strings.TrimSpace(fc.Prompts.Chat.System) != "" { out.PromptChatSystem = fc.Prompts.Chat.System } // code action - if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" || + if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" || strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" || strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" || strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" || @@ -675,39 +687,39 @@ func (fc *fileConfig) toApp() App { strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" || strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" || strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" || - len(fc.Prompts.CodeAction.Custom) > 0 { + len(fc.Prompts.CodeAction.Custom) > 0 { if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" { out.PromptCodeActionRewriteSystem = fc.Prompts.CodeAction.RewriteSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" { out.PromptCodeActionDiagnosticsSystem = fc.Prompts.CodeAction.DiagnosticsSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" { out.PromptCodeActionDocumentSystem = fc.Prompts.CodeAction.DocumentSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" { out.PromptCodeActionRewriteUser = fc.Prompts.CodeAction.RewriteUser } - if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" { out.PromptCodeActionDiagnosticsUser = fc.Prompts.CodeAction.DiagnosticsUser } - if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" { out.PromptCodeActionDocumentUser = fc.Prompts.CodeAction.DocumentUser } - if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" { out.PromptCodeActionGoTestSystem = fc.Prompts.CodeAction.GoTestSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" { out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser } - if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" { out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem } - if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" { out.PromptCodeActionSimplifyUser = fc.Prompts.CodeAction.SimplifyUser } - if len(fc.Prompts.CodeAction.Custom) > 0 { - for _, ca := range fc.Prompts.CodeAction.Custom { + if len(fc.Prompts.CodeAction.Custom) > 0 { + for _, ca := range fc.Prompts.CodeAction.Custom { out.CustomActions = append(out.CustomActions, CustomAction{ ID: strings.TrimSpace(ca.ID), Title: strings.TrimSpace(ca.Title), @@ -722,7 +734,7 @@ func (fc *fileConfig) toApp() App { } } // cli - if (fc.Prompts.CLI != sectionPromptsCLI{}) { + if (fc.Prompts.CLI != sectionPromptsCLI{}) { if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" { out.PromptCLIDefaultSystem = fc.Prompts.CLI.DefaultSystem } @@ -731,28 +743,33 @@ func (fc *fileConfig) toApp() App { } } // provider-native - if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" { + if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" { out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion } // tmux - if (fc.Tmux != sectionTmux{}) { + if (fc.Tmux != sectionTmux{}) { out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) } - return out + // stats + if fc.Stats.WindowMinutes > 0 { + out.StatsWindowMinutes = fc.Stats.WindowMinutes + } + + return out } func loadFromFile(path string, logger *log.Logger) (*App, error) { b, err := os.ReadFile(path) - if err != nil { + if err != nil { if !os.IsNotExist(err) && logger != nil { logger.Printf("cannot open TOML config file %s: %v", path, err) } - return nil, err + return nil, err } - var tables fileConfig + var tables fileConfig errTables := toml.NewDecoder(strings.NewReader(string(b))).Decode(&tables) // Raw map for validation/presence checks var raw map[string]any @@ -765,7 +782,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) legacy := map[string]struct{}{ + legacy := map[string]struct{}{ "max_tokens": {}, "context_mode": {}, "context_window_lines": {}, "max_context_tokens": {}, "log_preview_limit": {}, "completion_debounce_ms": {}, "completion_throttle_ms": {}, "manual_invoke_min_prefix": {}, "trigger_characters": {}, "inline_open": {}, "inline_close": {}, @@ -774,8 +791,8 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) { - if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable { + for k := range raw { + if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable { continue } if _, isLegacy := legacy[k]; isLegacy { @@ -783,13 +800,13 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) if logger != nil { + if logger != nil { logger.Printf("loaded configuration from %s (TOML)", path) } // Merge order: flat first, then tables (so tables win over zero flat values) // Build App from tables only - tab := tables.toApp() + tab := tables.toApp() // Ensure explicit values from raw map are respected (defensive for ints) if t, ok := raw["completion"].(map[string]any); ok { if v, present := t["manual_invoke_min_prefix"]; present { @@ -803,7 +820,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) if t, ok := raw["logging"].(map[string]any); ok { + if t, ok := raw["logging"].(map[string]any); ok { if v, present := t["log_preview_limit"]; present { switch vv := v.(type) { case int64: @@ -815,136 +832,136 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) return &tab, nil + return &tab, nil } -func (a *App) mergeWith(other *App) { +func (a *App) mergeWith(other *App) { a.mergeBasics(other) a.mergeProviderFields(other) a.mergePrompts(other) } // mergeBasics merges general (non-provider) fields. -func (a *App) mergeBasics(other *App) { +func (a *App) mergeBasics(other *App) { if other.MaxTokens > 0 { a.MaxTokens = other.MaxTokens } - if s := strings.TrimSpace(other.ContextMode); s != "" { + if s := strings.TrimSpace(other.ContextMode); s != "" { a.ContextMode = s } - if other.ContextWindowLines > 0 { + if other.ContextWindowLines > 0 { a.ContextWindowLines = other.ContextWindowLines } - if other.MaxContextTokens > 0 { + if other.MaxContextTokens > 0 { a.MaxContextTokens = other.MaxContextTokens } - if other.LogPreviewLimit >= 0 { + if other.LogPreviewLimit >= 0 { a.LogPreviewLimit = other.LogPreviewLimit } - if other.CodingTemperature != nil { // allow explicit 0.0 + if other.CodingTemperature != nil { // allow explicit 0.0 a.CodingTemperature = other.CodingTemperature } - if other.ManualInvokeMinPrefix >= 0 { + if other.ManualInvokeMinPrefix >= 0 { a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix } - if other.CompletionDebounceMs > 0 { + if other.CompletionDebounceMs > 0 { a.CompletionDebounceMs = other.CompletionDebounceMs } - if other.CompletionThrottleMs > 0 { + if other.CompletionThrottleMs > 0 { a.CompletionThrottleMs = other.CompletionThrottleMs } - if len(other.TriggerCharacters) > 0 { + if len(other.TriggerCharacters) > 0 { a.TriggerCharacters = slices.Clone(other.TriggerCharacters) } - if s := strings.TrimSpace(other.InlineOpen); s != "" { + if s := strings.TrimSpace(other.InlineOpen); s != "" { a.InlineOpen = s } - if s := strings.TrimSpace(other.InlineClose); s != "" { + if s := strings.TrimSpace(other.InlineClose); s != "" { a.InlineClose = s } - if s := strings.TrimSpace(other.ChatSuffix); s != "" { + if s := strings.TrimSpace(other.ChatSuffix); s != "" { a.ChatSuffix = s } - if len(other.ChatPrefixes) > 0 { + if len(other.ChatPrefixes) > 0 { a.ChatPrefixes = slices.Clone(other.ChatPrefixes) } - if s := strings.TrimSpace(other.Provider); s != "" { + if s := strings.TrimSpace(other.Provider); s != "" { a.Provider = s } } // mergePrompts copies non-empty prompt templates from other. -func (a *App) mergePrompts(other *App) { +func (a *App) mergePrompts(other *App) { // Completion if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" { a.PromptCompletionSystemGeneral = other.PromptCompletionSystemGeneral } - if strings.TrimSpace(other.PromptCompletionSystemParams) != "" { + if strings.TrimSpace(other.PromptCompletionSystemParams) != "" { a.PromptCompletionSystemParams = other.PromptCompletionSystemParams } - if strings.TrimSpace(other.PromptCompletionSystemInline) != "" { + if strings.TrimSpace(other.PromptCompletionSystemInline) != "" { a.PromptCompletionSystemInline = other.PromptCompletionSystemInline } - if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" { + if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" { a.PromptCompletionUserGeneral = other.PromptCompletionUserGeneral } - if strings.TrimSpace(other.PromptCompletionUserParams) != "" { + if strings.TrimSpace(other.PromptCompletionUserParams) != "" { a.PromptCompletionUserParams = other.PromptCompletionUserParams } - if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" { + if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" { a.PromptCompletionExtraHeader = other.PromptCompletionExtraHeader } // Provider-native - if strings.TrimSpace(other.PromptNativeCompletion) != "" { + if strings.TrimSpace(other.PromptNativeCompletion) != "" { a.PromptNativeCompletion = other.PromptNativeCompletion } // Chat - if strings.TrimSpace(other.PromptChatSystem) != "" { + if strings.TrimSpace(other.PromptChatSystem) != "" { a.PromptChatSystem = other.PromptChatSystem } // Code actions - if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" { a.PromptCodeActionRewriteSystem = other.PromptCodeActionRewriteSystem } - if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" { a.PromptCodeActionDiagnosticsSystem = other.PromptCodeActionDiagnosticsSystem } - if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" { a.PromptCodeActionDocumentSystem = other.PromptCodeActionDocumentSystem } - if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" { + if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" { a.PromptCodeActionRewriteUser = other.PromptCodeActionRewriteUser } - if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" { + if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" { a.PromptCodeActionDiagnosticsUser = other.PromptCodeActionDiagnosticsUser } - if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" { + if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" { a.PromptCodeActionDocumentUser = other.PromptCodeActionDocumentUser } - if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" { a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem } - if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { + if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser } - if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { + if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem } - if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { + if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser } // CLI - if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { + if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem } - if strings.TrimSpace(other.PromptCLIExplainSystem) != "" { + if strings.TrimSpace(other.PromptCLIExplainSystem) != "" { a.PromptCLIExplainSystem = other.PromptCLIExplainSystem } // Custom actions - if len(other.CustomActions) > 0 { + if len(other.CustomActions) > 0 { a.CustomActions = append([]CustomAction{}, other.CustomActions...) } - if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" { + if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" { a.TmuxCustomMenuHotkey = other.TmuxCustomMenuHotkey } } @@ -954,42 +971,42 @@ func (a App) Validate() error { // Normalize and check duplicates for IDs and hotkeys seenID := make(map[string]struct{}) seenHK := make(map[string]struct{}) - for _, ca := range a.CustomActions { + for _, ca := range a.CustomActions { id := strings.ToLower(strings.TrimSpace(ca.ID)) if id == "" { return fmt.Errorf("config: custom action missing required field id") } - if _, ok := seenID[id]; ok { + if _, ok := seenID[id]; ok { return fmt.Errorf("config: duplicate custom action id: %s", ca.ID) } - seenID[id] = struct{}{} + seenID[id] = struct{}{} if strings.TrimSpace(ca.Title) == "" { return fmt.Errorf("config: custom action %s missing required field title", ca.ID) } // Validate scope - scope := strings.TrimSpace(ca.Scope) + scope := strings.TrimSpace(ca.Scope) if scope != "" && scope != "selection" && scope != "diagnostics" { return fmt.Errorf("config: custom action %s has invalid scope: %s", ca.ID, ca.Scope) } // Instruction vs user - hasInstr := strings.TrimSpace(ca.Instruction) != "" + hasInstr := strings.TrimSpace(ca.Instruction) != "" hasUser := strings.TrimSpace(ca.User) != "" if hasInstr && hasUser { return fmt.Errorf("config: custom action %s must set either instruction or user, not both", ca.ID) } - if !hasInstr && !hasUser { + if !hasInstr && !hasUser { return fmt.Errorf("config: custom action %s requires instruction or user", ca.ID) } // Hotkey unique (case-insensitive), one rune if provided - if hk := strings.TrimSpace(ca.Hotkey); hk != "" { + if hk := strings.TrimSpace(ca.Hotkey); hk != "" { if []rune(hk) == nil || len([]rune(hk)) != 1 { return fmt.Errorf("config: custom action %s hotkey must be a single character", ca.ID) } - lhk := strings.ToLower(hk) + lhk := strings.ToLower(hk) if _, ok := seenHK[lhk]; ok { return fmt.Errorf("config: duplicate custom action hotkey: %s", hk) } - seenHK[lhk] = struct{}{} + seenHK[lhk] = struct{}{} } } // Tmux custom menu hotkey validation @@ -1007,46 +1024,46 @@ func (a App) Validate() error { } // mergeProviderFields merges per-provider configuration. -func (a *App) mergeProviderFields(other *App) { +func (a *App) mergeProviderFields(other *App) { if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" { a.OpenAIBaseURL = s } - if s := strings.TrimSpace(other.OpenAIModel); s != "" { + if s := strings.TrimSpace(other.OpenAIModel); s != "" { a.OpenAIModel = s } - if other.OpenAITemperature != nil { // allow explicit 0.0 + if other.OpenAITemperature != nil { // allow explicit 0.0 a.OpenAITemperature = other.OpenAITemperature } - if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { + if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { a.OllamaBaseURL = s } - if s := strings.TrimSpace(other.OllamaModel); s != "" { + if s := strings.TrimSpace(other.OllamaModel); s != "" { a.OllamaModel = s } - if other.OllamaTemperature != nil { // allow explicit 0.0 + if other.OllamaTemperature != nil { // allow explicit 0.0 a.OllamaTemperature = other.OllamaTemperature } - if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { + if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { a.CopilotBaseURL = s } - if s := strings.TrimSpace(other.CopilotModel); s != "" { + if s := strings.TrimSpace(other.CopilotModel); s != "" { a.CopilotModel = s } - if other.CopilotTemperature != nil { // allow explicit 0.0 + if other.CopilotTemperature != nil { // allow explicit 0.0 a.CopilotTemperature = other.CopilotTemperature } } func getConfigPath() (string, error) { var configPath string - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") - } else { + } else { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("cannot find user home directory: %v", err) } - configPath = filepath.Join(home, ".config", "hexai", "config.toml") + configPath = filepath.Join(home, ".config", "hexai", "config.toml") } return configPath, nil } @@ -1077,17 +1094,17 @@ func loadFromEnv(logger *log.Logger) *App { } parseFloatPtr := func(k string) (*float64, bool) { v := getenv(k) - if v == "" { + if v == "" { return nil, false } - f, err := strconv.ParseFloat(v, 64) + f, err := strconv.ParseFloat(v, 64) if err != nil { if logger != nil { logger.Printf("invalid %s: %v", k, err) } return nil, false } - return &f, true + return &f, true } if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok { @@ -1168,11 +1185,11 @@ func loadFromEnv(logger *log.Logger) *App { out.OpenAIBaseURL = s any = true } - if s := getenv("HEXAI_OPENAI_MODEL"); s != "" { + if s := getenv("HEXAI_OPENAI_MODEL"); s != "" { out.OpenAIModel = s any = true } - if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { out.OpenAITemperature = f any = true } @@ -1203,10 +1220,10 @@ func loadFromEnv(logger *log.Logger) *App { any = true } - if !any { + if !any { return nil } - return &out + return &out } @@ -1547,15 +1564,16 @@ 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" ) // Render performs simple {{var}} replacement like LSP. -func Render(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } +func Render(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } // StripFences removes surrounding markdown code fences. -func StripFences(s string) string { return textutil.StripCodeFences(s) } +func StripFences(s string) string { return textutil.StripCodeFences(s) } type chatDoer interface { Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) @@ -1564,11 +1582,11 @@ type chatDoer interface { type providerNamer interface{ Name() string } -func providerOf(c any) string { - if n, ok := c.(providerNamer); ok { +func providerOf(c any) string { + if n, ok := c.(providerNamer); ok { return n.Name() } - return "llm" + return "llm" } func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) { @@ -1599,7 +1617,7 @@ func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, select return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } -func runSimplify(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) { +func runSimplify(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) { sys := cfg.PromptCodeActionSimplifySystem user := Render(cfg.PromptCodeActionSimplifyUser, map[string]string{"selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) @@ -1628,61 +1646,77 @@ 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 - } - rpm := float64(1) / mins - _ = tmux.SetStatus(tmux.FormatLLMStatsStatusColored(providerOf(client), client.DefaultModel(), 1, rpm, int64(sent), int64(recv))) - return out, nil + _ = 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)) + } + return out, nil } -func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) { +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 + out := strings.TrimSpace(StripFences(txt)) + // Contribute to global stats and update tmux status sent := 0 - for _, m := range msgs { + for _, m := range msgs { sent += len(m.Content) } - recv := len(out) - mins := time.Since(start).Minutes() - if mins <= 0 { - mins = 0.001 - } - rpm := float64(1) / mins - _ = tmux.SetStatus(tmux.FormatLLMStatsStatusColored(providerOf(client), client.DefaultModel(), 1, rpm, int64(sent), int64(recv))) - return out, nil + recv := len(out) + _ = 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)) + } + return out, nil } // reqOptsFrom builds LLM request options similar to LSP behavior. -func reqOptsFrom(cfg appconfig.App) []llm.RequestOption { +func reqOptsFrom(cfg appconfig.App) []llm.RequestOption { opts := []llm.RequestOption{llm.WithMaxTokens(cfg.MaxTokens)} - if cfg.CodingTemperature != nil { + if cfg.CodingTemperature != nil { opts = append(opts, llm.WithTemperature(*cfg.CodingTemperature)) } - return opts + return opts } // Timeout helpers to mirror LSP behavior. -func timeout10s(parent context.Context) (context.Context, context.CancelFunc) { +func timeout10s(parent context.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(parent, 10*time.Second) } @@ -1699,11 +1733,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" ) @@ -1721,12 +1757,15 @@ 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 err := cfg.Validate(); err != nil { + 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 } // Enable custom action submenu with configurable hotkey - if len(cfg.CustomActions) > 0 { + if len(cfg.CustomActions) > 0 { chooseActionFn = func() (ActionKind, error) { return RunTUIWithCustom(cfg.CustomActions, cfg.TmuxCustomMenuHotkey) } } cli, err := newClientFromApp(cfg) @@ -1785,7 +1824,7 @@ func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg a cctx, cancel := timeout10s(ctx) defer cancel() return runSimplify(cctx, cfg, client, parts.Selection) - case ActionCustom: + case ActionCustom: cctx, cancel := timeout10s(ctx) defer cancel() if selectedCustom != nil { @@ -1794,8 +1833,13 @@ func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg a selectedCustom = nil // clear after use return out, err } - // Fallback: open editor for free-form instruction - prompt, err := editor.OpenTempAndEdit(nil) + // No selected custom; treat as no-op + return parts.Selection, nil + case ActionCustomPrompt: + cctx, cancel := timeout10s(ctx) + defer cancel() + // Open editor for free-form instruction + prompt, err := editor.OpenTempAndEdit(nil) if err != nil || strings.TrimSpace(prompt) == "" { fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset) return parts.Selection, nil @@ -1842,7 +1886,7 @@ func newModel() model { item{title: "Simplify and improve", desc: "", kind: ActionSimplify, hotkey: 'i'}, item{title: "Document code", desc: "", kind: ActionDocument, hotkey: 'c'}, item{title: "Generate Go unit test(s)", desc: "", kind: ActionGoTest, hotkey: 't'}, - item{title: "Custom prompt", desc: "", kind: ActionCustom, hotkey: 'p'}, + item{title: "Custom prompt", desc: "", kind: ActionCustomPrompt, hotkey: 'p'}, item{title: "Skip", desc: "", kind: ActionSkip, hotkey: 's'}, } l := list.New(items, oneLineDelegate{}, 0, 0) @@ -1971,6 +2015,7 @@ func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (Acti return ActionSkip, err } if mm, ok := md.(model); ok { + // If user chose built-in items (including Custom prompt), return immediately. if mm.chosen != ActionCustom { return mm.chosen, nil } @@ -2069,6 +2114,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" ) @@ -2078,7 +2124,10 @@ 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) - client, err := newClientFromApp(cfg) + 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) return err @@ -2198,20 +2247,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 } @@ -2236,11 +2293,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. @@ -2266,6 +2325,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) } @@ -2343,9 +2405,9 @@ func ensureFactory(factory ServerFactory) ServerFactory { // Map custom actions from appconfig to lsp type var customs []lsp.CustomAction - if len(cfg.CustomActions) > 0 { + if len(cfg.CustomActions) > 0 { customs = make([]lsp.CustomAction, 0, len(cfg.CustomActions)) - for _, ca := range cfg.CustomActions { + for _, ca := range cfg.CustomActions { customs = append(customs, lsp.CustomAction{ ID: ca.ID, Title: ca.Title, @@ -3092,7 +3154,7 @@ func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client { baseURL = "https://api.openai.com/v1" } - if strings.TrimSpace(model) == "" { + if strings.TrimSpace(model) == "" { model = "gpt-4.1" } return openAIClient{ @@ -3372,8 +3434,8 @@ type Options struct { type RequestOption func(*Options) func WithModel(model string) RequestOption { return func(o *Options) { o.Model = model } } -func WithTemperature(t float64) RequestOption { return func(o *Options) { o.Temperature = t } } -func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } +func WithTemperature(t float64) RequestOption { return func(o *Options) { o.Temperature = t } } +func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } func WithStop(stop ...string) RequestOption { return func(o *Options) { o.Stop = append([]string{}, stop...) } } @@ -3398,12 +3460,12 @@ type Config struct { // NewFromConfig creates an LLM client using only the supplied configuration. // The OpenAI API key is supplied separately and may be read from the environment // by the caller; other environment-based configuration is not used. -func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) { +func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) { p := strings.ToLower(strings.TrimSpace(cfg.Provider)) - if p == "" { + if p == "" { p = "openai" } - switch p { + switch p { case "openai": if strings.TrimSpace(openAIAPIKey) == "" { return nil, errors.New("missing OPENAI_API_KEY for provider openai") @@ -3539,8 +3601,8 @@ var std *log.Logger func Bind(l *log.Logger) { std = l } // Logf prints a formatted message with a module prefix and base ANSI style. -func Logf(prefix, format string, args ...any) { - if std == nil { +func Logf(prefix, format string, args ...any) { + if std == nil { return } msg := fmt.Sprintf(format, args...) @@ -3581,63 +3643,63 @@ import ( // - window: include a window of lines around the cursor // - file-on-new-func: include full file only when defining a new function // - always-full: always include the full file -func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) { +func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) { mode := s.contextMode switch mode { - case "minimal": + case "minimal": return "", false - case "window": + case "window": return s.windowContext(uri, pos), true - case "file-on-new-func": - if newFunc { + case "file-on-new-func": + if newFunc { return s.fullFileContext(uri), true } - return "", false - case "always-full": + return "", false + case "always-full": return s.fullFileContext(uri), true - default: + default: // fallback to minimal if unknown return "", false } } -func (s *Server) windowContext(uri string, pos Position) string { +func (s *Server) windowContext(uri string, pos Position) string { d := s.getDocument(uri) if d == nil || len(d.lines) == 0 { logging.Logf("lsp ", "context: window requested but document not open; skipping uri=%s", uri) return "" } - n := len(d.lines) + n := len(d.lines) half := s.windowLines / 2 start := pos.Line - half if start < 0 { start = 0 } - end := pos.Line + half + 1 + end := pos.Line + half + 1 if end > n { end = n } - text := strings.Join(d.lines[start:end], "\n") + text := strings.Join(d.lines[start:end], "\n") return truncateToApproxTokens(text, s.maxContextTokens) } -func (s *Server) fullFileContext(uri string) string { +func (s *Server) fullFileContext(uri string) string { d := s.getDocument(uri) if d == nil { logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri) return "" } - return truncateToApproxTokens(d.text, s.maxContextTokens) + return truncateToApproxTokens(d.text, s.maxContextTokens) } // truncateToApproxTokens naively truncates the input to fit approx N tokens. // Uses 4 chars/token heuristic for speed and determinism. -func truncateToApproxTokens(text string, maxTokens int) string { +func truncateToApproxTokens(text string, maxTokens int) string { if maxTokens <= 0 { return "" } - maxChars := maxTokens * 4 - if len(text) <= maxChars { + maxChars := maxTokens * 4 + if len(text) <= maxChars { return text } // try to cut on a line boundary near maxChars @@ -3666,7 +3728,7 @@ type document struct { lines []string } -func (s *Server) setDocument(uri, text string) { +func (s *Server) setDocument(uri, text string) { s.mu.Lock() defer s.mu.Unlock() s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)} @@ -3678,86 +3740,86 @@ func (s *Server) deleteDocument(uri string) { delete(s.docs, uri) } -func (s *Server) markActivity() { +func (s *Server) markActivity() { s.mu.Lock() s.lastInput = time.Now() s.mu.Unlock() } -func (s *Server) getDocument(uri string) *document { +func (s *Server) getDocument(uri string) *document { s.mu.RLock() defer s.mu.RUnlock() return s.docs[uri] } // splitLines splits the input string into lines, normalizing line endings to '\n'. -func splitLines(sx string) []string { +func splitLines(sx string) []string { sx = strings.ReplaceAll(sx, "\r\n", "\n") return strings.Split(sx, "\n") } -func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) { +func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) { d := s.getDocument(uri) if d == nil || len(d.lines) == 0 { return "", "", "", "" } - idx := pos.Line + idx := pos.Line if idx < 0 { idx = 0 } - if idx >= len(d.lines) { + if idx >= len(d.lines) { idx = len(d.lines) - 1 } - current = d.lines[idx] - if idx-1 >= 0 { + current = d.lines[idx] + if idx-1 >= 0 { above = d.lines[idx-1] } - if idx+1 < len(d.lines) { + if idx+1 < len(d.lines) { below = d.lines[idx+1] } - for i := idx; i >= 0; i-- { + for i := idx; i >= 0; i-- { line := strings.TrimSpace(d.lines[i]) - if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) { + if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) { funcCtx = line break } } - return above, current, below, funcCtx + return above, current, below, funcCtx } // isDefiningNewFunction returns true when the cursor appears to be within // a function declaration/signature and before the opening '{' of the body. // Heuristic: find nearest preceding line containing "func "; ensure no '{' // appears before the cursor across those lines. -func (s *Server) isDefiningNewFunction(uri string, pos Position) bool { +func (s *Server) isDefiningNewFunction(uri string, pos Position) bool { d := s.getDocument(uri) if d == nil || len(d.lines) == 0 { return false } - idx := pos.Line + idx := pos.Line if idx < 0 { idx = 0 } - if idx >= len(d.lines) { + if idx >= len(d.lines) { idx = len(d.lines) - 1 } // Find signature start - sigStart := -1 - for i := idx; i >= 0; i-- { - if strings.Contains(d.lines[i], "func ") { + sigStart := -1 + for i := idx; i >= 0; i-- { + if strings.Contains(d.lines[i], "func ") { sigStart = i break } // stop if we hit a closing brace which likely ends a previous block - if strings.Contains(d.lines[i], "}") { + if strings.Contains(d.lines[i], "}") { break } } - if sigStart == -1 { + if sigStart == -1 { return false } // Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body - for i := sigStart; i <= idx; i++ { + for i := sigStart; i <= idx; i++ { line := d.lines[i] brace := strings.Index(line, "{") if brace >= 0 { @@ -3770,29 +3832,29 @@ func (s *Server) isDefiningNewFunction(uri string, pos Position) bool } } - return true + return true } func hasAny(s string, needles []string) bool { - for _, n := range needles { - if strings.Contains(s, n) { + for _, n := range needles { + if strings.Contains(s, n) { return true } } return false } -func trimLen(s string) string { +func trimLen(s string) string { s = strings.TrimSpace(s) if len(s) > 200 { return s[:200] + "…" } - return s + return s } -func firstLine(s string) string { +func firstLine(s string) string { s = strings.ReplaceAll(s, "\r\n", "\n") - if idx := strings.IndexByte(s, '\n'); idx >= 0 { + if idx := strings.IndexByte(s, '\n'); idx >= 0 { return s[:idx] } return s @@ -4926,6 +4988,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) { @@ -5062,8 +5125,8 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun } // parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion. -func parseManualInvoke(ctx any) bool { - if ctx == nil { +func parseManualInvoke(ctx any) bool { + if ctx == nil { return false } var c struct { @@ -5108,7 +5171,7 @@ func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p Comp idx = len(current) } allowNoPrefix := inlinePrompt - if idx > 0 { + if idx > 0 { ch := current[idx-1] if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' { allowNoPrefix = true @@ -5129,19 +5192,19 @@ func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p Comp } start := computeWordStart(current, j) min := 1 - if manualInvoke && s.manualInvokeMinPrefix >= 0 { + if manualInvoke && s.manualInvokeMinPrefix >= 0 { min = s.manualInvokeMinPrefix } return j-start >= min } // tryProviderNativeCompletion attempts provider-native completion and returns items when successful. -func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) { +func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) { cc, ok := s.llmClient.(llm.CodeCompleter) if !ok { return nil, false } - before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) + before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) path := strings.TrimPrefix(p.TextDocument.URI, "file://") // Build provider-native prompt from template prompt := renderTemplate(s.promptNativeCompletion, map[string]string{ @@ -5153,11 +5216,11 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, if s.codingTemperature != nil { temp = *s.codingTemperature } - prov := "" - if s.llmClient != nil { + prov := "" + if s.llmClient != nil { prov = s.llmClient.Name() } - logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) + logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second) defer cancel2() @@ -5167,13 +5230,17 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, return nil, false } // Count approximate payload sizes: prompt+after sent; first suggestion received - sentBytes := len(prompt) + len(after) + sentBytes := len(prompt) + len(after) suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) if err == nil && len(suggestions) > 0 { // Update counters and heartbeat s.incSentCounters(sentBytes) s.incRecvCounters(len(suggestions[0])) - s.logLLMStats() + // 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 != "" { cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) @@ -5203,9 +5270,9 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, // waitForDebounce sleeps until there has been no input activity for at least // completionDebounce. If debounce is zero or ctx is done, it returns promptly. -func (s *Server) waitForDebounce(ctx context.Context) { +func (s *Server) waitForDebounce(ctx context.Context) { d := s.completionDebounce - if d <= 0 { + if d <= 0 { return } for { @@ -5233,13 +5300,13 @@ func (s *Server) waitForDebounce(ctx context.Context) { +func (s *Server) waitForThrottle(ctx context.Context) bool { interval := s.throttleInterval - if interval <= 0 { + if interval <= 0 { return true } var wait time.Duration - for { + for { s.mu.Lock() next := s.lastLLMCall.Add(interval) now := time.Now() @@ -5369,33 +5436,33 @@ func (s *Server) handleDidClose(req Request) { // docBeforeAfter returns the full document text split at the given position. // The returned strings are the text before the cursor (inclusive of anything // left of the position) and the text after the cursor. -func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { +func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { d := s.getDocument(uri) - if d == nil { + if d == nil { return "", "" } // Clamp indices - line := pos.Line + line := pos.Line if line < 0 { line = 0 } - if line >= len(d.lines) { + if line >= len(d.lines) { line = len(d.lines) - 1 } - col := pos.Character + col := pos.Character if col < 0 { col = 0 } - if col > len(d.lines[line]) { + if col > len(d.lines[line]) { col = len(d.lines[line]) } // Build before - var b strings.Builder - for i := 0; i < line; i++ { + var b strings.Builder + for i := 0; i < line; i++ { b.WriteString(d.lines[i]) b.WriteByte('\n') } - b.WriteString(d.lines[line][:col]) + b.WriteString(d.lines[line][:col]) before := b.String() // Build after var a strings.Builder @@ -5404,7 +5471,7 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) - return before, a.String() + return before, a.String() } // --- in-editor chat (";C ...") --- @@ -5412,74 +5479,73 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { +func (s *Server) detectAndHandleChat(uri string) { if s.llmClient == nil { return } - d := s.getDocument(uri) + d := s.getDocument(uri) if d == nil || len(d.lines) == 0 { return } - for i, raw := range d.lines { + for i, raw := range d.lines { // Find last non-space character index j := len(raw) - 1 - for j >= 0 { + for j >= 0 { if raw[j] == ' ' || raw[j] == '\t' { j-- continue } - break + break } - if j < 0 { + if j < 0 { continue } // Check suffix/prefix according to configuration - if s.chatSuffix == "" { + if s.chatSuffix == "" { continue } // Last non-space must equal suffix - if string(raw[j]) != s.chatSuffix { + if string(raw[j]) != s.chatSuffix { continue } // Require at least one char before suffix and that char must be in chatPrefixes - if j < 1 { + if j < 1 { continue } - prev := string(raw[j-1]) + prev := string(raw[j-1]) isTrigger := false - for _, pfx := range s.chatPrefixes { - if prev == pfx { + for _, pfx := range s.chatPrefixes { + if prev == pfx { isTrigger = true break } } - if !isTrigger { + if !isTrigger { continue } // Avoid double-answering: if the next non-empty line starts with '>' we skip. - k := i + 1 - for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { + k := i + 1 + for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { k++ } - if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { + if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { continue } // Derive prompt by removing only the trailing '>' - removeCount := len(s.chatSuffix) + removeCount := len(s.chatSuffix) base := raw[:j+1-removeCount] prompt := strings.TrimSpace(base) if prompt == "" { continue } - lineIdx := i + lineIdx := i lastIdx := j - go func(prompt string, remove int) { + go func(prompt string, remove int) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - sys := s.promptChatSystem - // Build short conversation history from the document above this line - history := s.buildChatHistory(uri, lineIdx, prompt) - msgs := append([]llm.Message{{Role: "system", Content: sys}}, history...) + // Build messages with history and context_mode aware extras. + pos := Position{Line: lineIdx, Character: lastIdx + 1} + msgs := s.buildChatMessages(uri, pos, prompt) opts := s.llmRequestOpts() logging.Logf("lsp ", "chat llm=requesting model=%s", s.llmClient.DefaultModel()) text, err := s.chatWithStats(ctx, msgs, opts...) @@ -5487,26 +5553,26 @@ func (s *Server) detectAndHandleChat(uri string) { logging.Logf("lsp ", "chat llm error: %v", err) return } - out := strings.TrimSpace(stripCodeFences(text)) + out := strings.TrimSpace(stripCodeFences(text)) if out == "" { return } - s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out) + s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out) }(prompt, removeCount) // Only handle one per change tick to avoid flooding - break + break } } // applyChatEdits removes the triggering punctuation at end of the line and // inserts two newlines followed by a new line with the response prefixed. -func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) { +func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) { d := s.getDocument(uri) if d == nil { return } // 1) Delete the trailing punctuation (1 or 2 chars) - delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} + delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} delEnd := Position{Line: lineIdx, Character: lastNonSpace + 1} // 2) Insert two newlines and the response at end-of-line, then one extra blank line insPos := Position{Line: lineIdx, Character: len(d.lines[lineIdx])} @@ -5522,26 +5588,26 @@ func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, remov // buildChatHistory walks upwards from the current line to collect the most recent // Q/A pairs in the in-editor transcript. Returns messages ending with current prompt. -func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message { +func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message { d := s.getDocument(uri) if d == nil { return []llm.Message{{Role: "user", Content: currentPrompt}} } - type pair struct{ q, a string } + type pair struct{ q, a string } pairs := []pair{} i := lineIdx - 1 - for i >= 0 && len(pairs) < 3 { + for i >= 0 && len(pairs) < 3 { for i >= 0 && strings.TrimSpace(d.lines[i]) == "" { i-- } - if i < 0 { + if i < 0 { break } - if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") { + if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") { break } var replyLines []string - for i >= 0 { + for i >= 0 { line := strings.TrimSpace(d.lines[i]) if strings.HasPrefix(line, ">") { replyLines = append([]string{strings.TrimSpace(strings.TrimPrefix(line, ">"))}, replyLines...) @@ -5561,7 +5627,7 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) i-- } - msgs := make([]llm.Message, 0, len(pairs)*2+1) + msgs := make([]llm.Message, 0, len(pairs)*2+1) for _, p := range pairs { if strings.TrimSpace(p.q) != "" { msgs = append(msgs, llm.Message{Role: "user", Content: p.q}) @@ -5570,27 +5636,27 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) } } - msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) + msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) return msgs } // stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. -func stripTrailingTrigger(sx string) string { +func stripTrailingTrigger(sx string) string { s := strings.TrimRight(sx, " \t") if len(s) == 0 { return sx } // Configurable suffix removal when preceded by configured prefixes - if len(s) >= 2 && s[len(s)-1] == chatSuffixChar { + if len(s) >= 2 && s[len(s)-1] == chatSuffixChar { prev := string(s[len(s)-2]) - for _, pf := range chatPrefixSingles { - if prev == pf { + for _, pf := range chatPrefixSingles { + if prev == pf { return strings.TrimRight(s[:len(s)-1], " \t") } } } // Legacy: remove one trailing punctuation (?, !, :) to build history nicely - last := s[len(s)-1] + last := s[len(s)-1] switch last { case '?', '!', ':': return strings.TrimRight(s[:len(s)-1], " \t") @@ -5599,8 +5665,35 @@ func stripTrailingTrigger(sx string) string { } } +// buildChatMessages assembles the chat request messages using: +// - system from prompts.chat.system +// - rolling in-editor history up to current prompt +// - optional extra context per general.context_mode (window/full-file/new-func) +func (s *Server) buildChatMessages(uri string, pos Position, prompt string) []llm.Message { + // Base system and history + sys := s.promptChatSystem + // Determine line index for history from position + lineIdx := pos.Line + history := s.buildChatHistory(uri, lineIdx, prompt) + // Start with system + msgs := []llm.Message{{Role: "system", Content: sys}} + // Optional additional context like completion path (insert before history so last remains the prompt) + newFunc := s.isDefiningNewFunction(uri, pos) + if extra, has := s.buildAdditionalContext(newFunc, uri, pos); has && strings.TrimSpace(extra) != "" { + // Reuse completion's extra header template to avoid duplication + header := renderTemplate(s.promptCompExtraHeader, map[string]string{"context": extra}) + if strings.TrimSpace(header) == "" { + header = extra + } + msgs = append(msgs, llm.Message{Role: "user", Content: header}) + } + // Then add history (which ends with the current prompt) + msgs = append(msgs, history...) + return msgs +} + // clientApplyEdit sends a workspace/applyEdit request to the client. -func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { +func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { params := ApplyWorkspaceEditParams{Label: label, Edit: edit} id := s.nextReqID() req := Request{JSONRPC: "2.0", ID: id, Method: "workspace/applyEdit"} @@ -5610,7 +5703,7 @@ func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { +func (s *Server) nextReqID() json.RawMessage { s.mu.Lock() s.nextID++ idNum := s.nextID @@ -5620,7 +5713,7 @@ func (s *Server) nextReqID() json.RawMessage { } // clientShowDocument asks the client to open/focus a document and select a range. -func (s *Server) clientShowDocument(uri string, sel *Range) { +func (s *Server) clientShowDocument(uri string, sel *Range) { var params struct { URI string `json:"uri"` External bool `json:"external,omitempty"` @@ -5741,6 +5834,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" ) @@ -5753,56 +5847,70 @@ var ( ) // llmRequestOpts builds request options from server settings. -func (s *Server) llmRequestOpts() []llm.RequestOption { +func (s *Server) llmRequestOpts() []llm.RequestOption { opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} if s.codingTemperature != nil { opts = append(opts, llm.WithTemperature(*s.codingTemperature)) } - return opts + return opts } // small helpers for LLM traffic stats -func (s *Server) incSentCounters(n int) { +func (s *Server) incSentCounters(n int) { s.mu.Lock() s.llmReqTotal++ s.llmSentBytesTotal += int64(n) s.mu.Unlock() } -func (s *Server) incRecvCounters(n int) { +func (s *Server) incRecvCounters(n int) { s.mu.Lock() s.llmRespTotal++ s.llmRespBytesTotal += int64(n) s.mu.Unlock() } -func (s *Server) logLLMStats() { +func (s *Server) logLLMStats() { s.mu.RLock() avgSent := int64(0) - if s.llmReqTotal > 0 { + if s.llmReqTotal > 0 { avgSent = s.llmSentBytesTotal / s.llmReqTotal } - avgRecv := int64(0) - if s.llmRespTotal > 0 { + avgRecv := int64(0) + if s.llmRespTotal > 0 { avgRecv = s.llmRespBytesTotal / s.llmRespTotal } - reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal + reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal s.mu.RUnlock() mins := time.Since(s.startTime).Minutes() 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) - _ = tmx.SetStatus(status) - } + 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) + } } // Completion prompt builders and filters @@ -5816,7 +5924,7 @@ func inParamList(current string, cursor int) bool } // renderTemplate performs simple {{var}} replacement in a template string. -func renderTemplate(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } +func renderTemplate(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) { if inParams { @@ -5865,26 +5973,30 @@ func isIdentChar(ch byte) bool { } // chatWithStats wraps llmClient.Chat to increment counters and emit a tmux heartbeat. -func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) { +func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) { // Count bytes sent sent := 0 - for _, m := range msgs { + for _, m := range msgs { sent += len(m.Content) } - s.incSentCounters(sent) + s.incSentCounters(sent) // Debounce/throttle if configured (reuse completion gates) s.waitForDebounce(ctx) if !s.waitForThrottle(ctx) { return "", context.Canceled } // Perform request - txt, err := s.llmClient.Chat(ctx, msgs, opts...) + txt, err := s.llmClient.Chat(ctx, msgs, opts...) if err != nil { s.logLLMStats() return "", err } - s.incRecvCounters(len(txt)) - s.logLLMStats() + 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 } @@ -6058,7 +6170,7 @@ func isIdentBoundary(ch byte) bool { } // stripCodeFences removes surrounding Markdown code fences from a model response. -func stripCodeFences(s string) string { return textutil.StripCodeFences(s) } +func stripCodeFences(s string) string { return textutil.StripCodeFences(s) } // stripInlineCodeSpan returns the contents of the first inline backtick code span if present. func stripInlineCodeSpan(s string) string { @@ -6455,7 +6567,7 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) s.promptSimplifySystem = opts.PromptSimplifySystem s.promptSimplifyUser = opts.PromptSimplifyUser - if len(opts.CustomActions) > 0 { + if len(opts.CustomActions) > 0 { s.customActions = append([]CustomAction{}, opts.CustomActions...) } @@ -6529,7 +6641,7 @@ import ( "codeberg.org/snonux/hexai/internal/logging" ) -func (s *Server) readMessage() ([]byte, error) { +func (s *Server) readMessage() ([]byte, error) { tp := textproto.NewReader(s.in) var contentLength int for { @@ -6537,7 +6649,7 @@ func (s *Server) readMessage() ([]byte, error) { if err != nil { return nil, err } - if line == "" { // end of headers + if line == "" { // end of headers break } parts := strings.SplitN(line, ":", 2) @@ -6565,25 +6677,274 @@ func (s *Server) readMessage() ([]byte, error) { return buf, nil } -func (s *Server) writeMessage(v any) { +func (s *Server) writeMessage(v any) { data, err := json.Marshal(v) if err != nil { logging.Logf("lsp ", "marshal error: %v", err) return } - header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) if _, err := io.WriteString(s.out, header); err != nil { logging.Logf("lsp ", "write header error: %v", err) return } - if _, err := s.out.Write(data); err != nil { + if _, err := s.out.Write(data); err != nil { logging.Logf("lsp ", "write body error: %v", err) return } } -