diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-24 23:21:43 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-24 23:21:43 +0300 |
| commit | c3c71345db9086392cd9b7529c7f5287009c226e (patch) | |
| tree | d227894ab900d6050cbe1418984526088a692db5 /docs/coverage.html | |
| parent | 127844a4ee481590ef53b6777d34bf2114cb3ab1 (diff) | |
Add runtime config store and reload command
Diffstat (limited to 'docs/coverage.html')
| -rw-r--r-- | docs/coverage.html | 2192 |
1 files changed, 1265 insertions, 927 deletions
diff --git a/docs/coverage.html b/docs/coverage.html index 6828b9a..4a3153b 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -61,7 +61,7 @@ <option value="file2">codeberg.org/snonux/hexai/cmd/hexai/main.go (71.4%)</option> - <option value="file3">codeberg.org/snonux/hexai/internal/appconfig/config.go (88.8%)</option> + <option value="file3">codeberg.org/snonux/hexai/internal/appconfig/config.go (88.9%)</option> <option value="file4">codeberg.org/snonux/hexai/internal/editor/editor.go (58.3%)</option> @@ -71,7 +71,7 @@ <option value="file7">codeberg.org/snonux/hexai/internal/hexaiaction/prompts.go (92.0%)</option> - <option value="file8">codeberg.org/snonux/hexai/internal/hexaiaction/run.go (71.0%)</option> + <option value="file8">codeberg.org/snonux/hexai/internal/hexaiaction/run.go (76.8%)</option> <option value="file9">codeberg.org/snonux/hexai/internal/hexaiaction/tui.go (65.5%)</option> @@ -81,13 +81,13 @@ <option value="file12">codeberg.org/snonux/hexai/internal/hexaicli/run.go (90.0%)</option> - <option value="file13">codeberg.org/snonux/hexai/internal/hexailsp/run.go (90.2%)</option> + <option value="file13">codeberg.org/snonux/hexai/internal/hexailsp/run.go (90.8%)</option> <option value="file14">codeberg.org/snonux/hexai/internal/llm/copilot.go (82.4%)</option> <option value="file15">codeberg.org/snonux/hexai/internal/llm/ollama.go (89.8%)</option> - <option value="file16">codeberg.org/snonux/hexai/internal/llm/openai.go (87.1%)</option> + <option value="file16">codeberg.org/snonux/hexai/internal/llm/openai.go (86.4%)</option> <option value="file17">codeberg.org/snonux/hexai/internal/llm/provider.go (100.0%)</option> @@ -99,41 +99,45 @@ <option value="file21">codeberg.org/snonux/hexai/internal/logging/logging.go (90.9%)</option> - <option value="file22">codeberg.org/snonux/hexai/internal/lsp/context.go (76.9%)</option> + <option value="file22">codeberg.org/snonux/hexai/internal/lsp/chat_commands.go (68.0%)</option> - <option value="file23">codeberg.org/snonux/hexai/internal/lsp/document.go (91.5%)</option> + <option value="file23">codeberg.org/snonux/hexai/internal/lsp/context.go (74.4%)</option> - <option value="file24">codeberg.org/snonux/hexai/internal/lsp/handlers.go (92.9%)</option> + <option value="file24">codeberg.org/snonux/hexai/internal/lsp/document.go (91.5%)</option> - <option value="file25">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (82.3%)</option> + <option value="file25">codeberg.org/snonux/hexai/internal/lsp/handlers.go (92.2%)</option> - <option value="file26">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (87.2%)</option> + <option value="file26">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (84.1%)</option> - <option value="file27">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (90.1%)</option> + <option value="file27">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (88.8%)</option> - <option value="file28">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (75.0%)</option> + <option value="file28">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (87.6%)</option> - <option value="file29">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (63.6%)</option> + <option value="file29">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (75.0%)</option> - <option value="file30">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (90.0%)</option> + <option value="file30">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (66.7%)</option> - <option value="file31">codeberg.org/snonux/hexai/internal/lsp/server.go (79.8%)</option> + <option value="file31">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (89.9%)</option> - <option value="file32">codeberg.org/snonux/hexai/internal/lsp/transport.go (73.0%)</option> + <option value="file32">codeberg.org/snonux/hexai/internal/lsp/server.go (85.4%)</option> - <option value="file33">codeberg.org/snonux/hexai/internal/stats/lock_posix.go (83.3%)</option> + <option value="file33">codeberg.org/snonux/hexai/internal/lsp/transport.go (73.0%)</option> - <option value="file34">codeberg.org/snonux/hexai/internal/stats/stats.go (75.8%)</option> + <option value="file34">codeberg.org/snonux/hexai/internal/runtimeconfig/store.go (85.5%)</option> - <option value="file35">codeberg.org/snonux/hexai/internal/testutil/fixtures.go (100.0%)</option> + <option value="file35">codeberg.org/snonux/hexai/internal/stats/lock_posix.go (83.3%)</option> - <option value="file36">codeberg.org/snonux/hexai/internal/textutil/human.go (92.3%)</option> + <option value="file36">codeberg.org/snonux/hexai/internal/stats/stats.go (75.8%)</option> - <option value="file37">codeberg.org/snonux/hexai/internal/textutil/textutil.go (90.4%)</option> + <option value="file37">codeberg.org/snonux/hexai/internal/testutil/fixtures.go (100.0%)</option> - <option value="file38">codeberg.org/snonux/hexai/internal/tmux/status.go (73.8%)</option> + <option value="file38">codeberg.org/snonux/hexai/internal/textutil/human.go (92.3%)</option> - <option value="file39">codeberg.org/snonux/hexai/internal/tmux/tmux.go (88.6%)</option> + <option value="file39">codeberg.org/snonux/hexai/internal/textutil/textutil.go (90.4%)</option> + + <option value="file40">codeberg.org/snonux/hexai/internal/tmux/status.go (76.7%)</option> + + <option value="file41">codeberg.org/snonux/hexai/internal/tmux/tmux.go (88.6%)</option> </select> </div> @@ -349,7 +353,7 @@ type CustomAction struct { } // Constructor: defaults for App (kept first among functions) -func newDefaultConfig() App <span class="cov5" title="37">{ +func newDefaultConfig() App <span class="cov6" title="45">{ // Coding-friendly default temperature across providers // Users can override per provider in config.toml (including 0.0). t := 0.2 @@ -405,29 +409,40 @@ func newDefaultConfig() App <span class="cov5" title="37">{ // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. -func Load(logger *log.Logger) App <span class="cov5" title="36">{ +func Load(logger *log.Logger) App <span class="cov6" title="42">{ return LoadWithOptions(logger, LoadOptions{}) }</span> + +// LoadOptions tune how configuration is loaded at runtime. +type LoadOptions struct { + // IgnoreEnv skips applying environment overrides when true. + IgnoreEnv bool +} + +// LoadWithOptions reads configuration and applies the requested loading options. +func LoadWithOptions(logger *log.Logger, opts LoadOptions) App <span class="cov6" title="44">{ cfg := newDefaultConfig() - if logger == nil </span><span class="cov4" title="9">{ + if logger == nil </span><span class="cov4" title="13">{ return cfg // Return defaults if no logger is provided (e.g. in tests) }</span> - <span class="cov5" title="27">configPath, err := getConfigPath() + <span class="cov5" title="31">configPath, err := getConfigPath() if err != nil </span><span class="cov0" title="0">{ logger.Printf("%v", err) - // Even if config path cannot be resolved, still allow env overrides below. - }</span> else<span class="cov5" title="27"> { - if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil </span><span class="cov5" title="22">{ + // Even if config path cannot be resolved, keep defaults and optionally apply env overrides below. + }</span> else<span class="cov5" title="31"> { + if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil </span><span class="cov5" title="26">{ cfg.mergeWith(fileCfg) }</span> // When the config file is missing or invalid, we keep defaults and still - // apply any environment overrides below. + // apply any environment overrides below (unless disabled). } - // Environment overrides (take precedence over file) - <span class="cov5" title="27">if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov3" title="5">{ - cfg.mergeWith(envCfg) - }</span> - <span class="cov5" title="27">return cfg</span> + <span class="cov5" title="31">if !opts.IgnoreEnv </span><span class="cov5" title="29">{ + // Environment overrides (take precedence over file) + if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov3" title="7">{ + cfg.mergeWith(envCfg) + }</span> + } + <span class="cov5" title="31">return cfg</span> } // Private helpers @@ -496,7 +511,7 @@ type sectionOpenAI struct { Presets map[string]string `toml:"presets"` } -func (s sectionOpenAI) isZero() bool <span class="cov5" title="22">{ +func (s sectionOpenAI) isZero() bool <span class="cov5" title="26">{ return strings.TrimSpace(s.Model) == "" && strings.TrimSpace(s.BaseURL) == "" && s.Temperature == nil && len(s.Presets) == 0 }</span> @@ -594,11 +609,11 @@ type sectionTmux struct { CustomMenuHotkey string `toml:"custom_menu_hotkey"` } -func (fc *fileConfig) toApp() App <span class="cov5" title="22">{ +func (fc *fileConfig) toApp() App <span class="cov5" title="26">{ out := App{} // Merge section: general - if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil </span><span class="cov2" title="3">{ + if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil </span><span class="cov3" title="7">{ tmp := App{ MaxTokens: fc.General.MaxTokens, ContextMode: fc.General.ContextMode, @@ -610,13 +625,13 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="22">{ }</span> // logging - <span class="cov5" title="22">if (fc.Logging != sectionLogging{}) </span><span class="cov1" title="1">{ + <span class="cov5" title="26">if (fc.Logging != sectionLogging{}) </span><span class="cov1" title="1">{ tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit} out.mergeBasics(&tmp) }</span> // completion - <span class="cov5" title="22">if (fc.Completion != sectionCompletion{}) </span><span class="cov2" title="3">{ + <span class="cov5" title="26">if (fc.Completion != sectionCompletion{}) </span><span class="cov2" title="3">{ tmp := App{ CompletionDebounceMs: fc.Completion.CompletionDebounceMs, CompletionThrottleMs: fc.Completion.CompletionThrottleMs, @@ -626,31 +641,31 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="22">{ }</span> // triggers - <span class="cov5" title="22">if len(fc.Triggers.TriggerCharacters) > 0 </span><span class="cov2" title="3">{ + <span class="cov5" title="26">if len(fc.Triggers.TriggerCharacters) > 0 </span><span class="cov2" title="3">{ tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters} out.mergeBasics(&tmp) }</span> // inline - <span class="cov5" title="22">if (fc.Inline != sectionInline{}) </span><span class="cov1" title="1">{ + <span class="cov5" title="26">if (fc.Inline != sectionInline{}) </span><span class="cov1" title="1">{ tmp := App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose} out.mergeBasics(&tmp) }</span> // chat - <span class="cov5" title="22">if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 </span><span class="cov1" title="1">{ + <span class="cov5" title="26">if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 </span><span class="cov1" title="1">{ tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes} out.mergeBasics(&tmp) }</span> // provider - <span class="cov5" title="22">if strings.TrimSpace(fc.Provider.Name) != "" </span><span class="cov2" title="4">{ + <span class="cov5" title="26">if strings.TrimSpace(fc.Provider.Name) != "" </span><span class="cov2" title="4">{ tmp := App{Provider: fc.Provider.Name} out.mergeBasics(&tmp) }</span> // openai - <span class="cov5" title="22">if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil </span><span class="cov2" title="4">{ + <span class="cov5" title="26">if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil </span><span class="cov2" title="4">{ tmp := App{ OpenAIBaseURL: fc.OpenAI.BaseURL, OpenAIModel: fc.OpenAI.resolvedModel(), @@ -660,7 +675,7 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="22">{ }</span> // copilot - <span class="cov5" title="22">if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil </span><span class="cov2" title="3">{ + <span class="cov5" title="26">if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil </span><span class="cov2" title="3">{ tmp := App{ CopilotBaseURL: fc.Copilot.BaseURL, CopilotModel: fc.Copilot.Model, @@ -670,7 +685,7 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="22">{ }</span> // ollama - <span class="cov5" title="22">if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil </span><span class="cov2" title="3">{ + <span class="cov5" title="26">if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil </span><span class="cov2" title="3">{ tmp := App{ OllamaBaseURL: fc.Ollama.BaseURL, OllamaModel: fc.Ollama.Model, @@ -681,7 +696,7 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="22">{ // prompts // completion - <span class="cov5" title="22">if (fc.Prompts.Completion != sectionPromptsCompletion{}) </span><span class="cov1" title="1">{ + <span class="cov5" title="26">if (fc.Prompts.Completion != sectionPromptsCompletion{}) </span><span class="cov1" title="1">{ if strings.TrimSpace(fc.Prompts.Completion.SystemGeneral) != "" </span><span class="cov1" title="1">{ out.PromptCompletionSystemGeneral = fc.Prompts.Completion.SystemGeneral }</span> @@ -702,11 +717,11 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="22">{ }</span> } // chat - <span class="cov5" title="22">if strings.TrimSpace(fc.Prompts.Chat.System) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="26">if strings.TrimSpace(fc.Prompts.Chat.System) != "" </span><span class="cov1" title="1">{ out.PromptChatSystem = fc.Prompts.Chat.System }</span> // code action - <span class="cov5" title="22">if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" || + <span class="cov5" title="26">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) != "" || @@ -763,7 +778,7 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="22">{ } } // cli - <span class="cov5" title="22">if (fc.Prompts.CLI != sectionPromptsCLI{}) </span><span class="cov1" title="1">{ + <span class="cov5" title="26">if (fc.Prompts.CLI != sectionPromptsCLI{}) </span><span class="cov1" title="1">{ if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" </span><span class="cov1" title="1">{ out.PromptCLIDefaultSystem = fc.Prompts.CLI.DefaultSystem }</span> @@ -772,24 +787,24 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="22">{ }</span> } // provider-native - <span class="cov5" title="22">if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="26">if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" </span><span class="cov1" title="1">{ out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion }</span> // tmux - <span class="cov5" title="22">if (fc.Tmux != sectionTmux{}) </span><span class="cov2" title="3">{ + <span class="cov5" title="26">if (fc.Tmux != sectionTmux{}) </span><span class="cov2" title="3">{ out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) }</span> // stats - <span class="cov5" title="22">if fc.Stats.WindowMinutes > 0 </span><span class="cov0" title="0">{ + <span class="cov5" title="26">if fc.Stats.WindowMinutes > 0 </span><span class="cov0" title="0">{ out.StatsWindowMinutes = fc.Stats.WindowMinutes }</span> - <span class="cov5" title="22">return out</span> + <span class="cov5" title="26">return out</span> } -func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov5" title="28">{ +func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov5" title="32">{ b, err := os.ReadFile(path) if err != nil </span><span class="cov2" title="4">{ if !os.IsNotExist(err) && logger != nil </span><span class="cov0" title="0">{ @@ -798,7 +813,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co <span class="cov2" title="4">return nil, err</span> } - <span class="cov5" title="24">var tables fileConfig + <span class="cov5" title="28">var tables fileConfig errTables := toml.NewDecoder(strings.NewReader(string(b))).Decode(&tables) // Raw map for validation/presence checks var raw map[string]any @@ -811,7 +826,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co } // Reject legacy flat keys at top-level (sectioned-only config is allowed) - <span class="cov5" title="22">legacy := map[string]struct{}{ + <span class="cov5" title="26">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": {}, @@ -820,8 +835,8 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co "ollama_model": {}, "ollama_base_url": {}, "ollama_temperature": {}, "copilot_model": {}, "copilot_base_url": {}, "copilot_temperature": {}, } - for k := range raw </span><span class="cov6" title="48">{ - if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable </span><span class="cov6" title="45">{ + for k := range raw </span><span class="cov6" title="52">{ + if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable </span><span class="cov6" title="49">{ continue</span> } <span class="cov2" title="3">if _, isLegacy := legacy[k]; isLegacy </span><span class="cov0" title="0">{ @@ -829,13 +844,13 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co }</span> } - <span class="cov5" title="22">if logger != nil </span><span class="cov5" title="22">{ + <span class="cov5" title="26">if logger != nil </span><span class="cov5" title="26">{ logger.Printf("loaded configuration from %s (TOML)", path) }</span> // Merge order: flat first, then tables (so tables win over zero flat values) // Build App from tables only - <span class="cov5" title="22">tab := tables.toApp() + <span class="cov5" title="26">tab := tables.toApp() // Ensure explicit values from raw map are respected (defensive for ints) if t, ok := raw["completion"].(map[string]any); ok </span><span class="cov2" title="3">{ if v, present := t["manual_invoke_min_prefix"]; present </span><span class="cov2" title="3">{ @@ -849,7 +864,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co } } } - <span class="cov5" title="22">if t, ok := raw["logging"].(map[string]any); ok </span><span class="cov2" title="3">{ + <span class="cov5" title="26">if t, ok := raw["logging"].(map[string]any); ok </span><span class="cov2" title="3">{ if v, present := t["log_preview_limit"]; present </span><span class="cov2" title="3">{ switch vv := v.(type) </span>{ case int64:<span class="cov2" title="3"> @@ -861,142 +876,142 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co } } } - <span class="cov5" title="22">return &tab, nil</span> + <span class="cov5" title="26">return &tab, nil</span> } -func (a *App) mergeWith(other *App) <span class="cov5" title="27">{ +func (a *App) mergeWith(other *App) <span class="cov5" title="33">{ a.mergeBasics(other) a.mergeProviderFields(other) a.mergePrompts(other) }</span> // mergeBasics merges general (non-provider) fields. -func (a *App) mergeBasics(other *App) <span class="cov6" title="43">{ - if other.MaxTokens > 0 </span><span class="cov3" title="7">{ +func (a *App) mergeBasics(other *App) <span class="cov6" title="53">{ + if other.MaxTokens > 0 </span><span class="cov4" title="17">{ a.MaxTokens = other.MaxTokens }</span> - <span class="cov6" title="43">if s := strings.TrimSpace(other.ContextMode); s != "" </span><span class="cov3" title="7">{ + <span class="cov6" title="53">if s := strings.TrimSpace(other.ContextMode); s != "" </span><span class="cov3" title="7">{ a.ContextMode = s }</span> - <span class="cov6" title="43">if other.ContextWindowLines > 0 </span><span class="cov3" title="7">{ + <span class="cov6" title="53">if other.ContextWindowLines > 0 </span><span class="cov3" title="7">{ a.ContextWindowLines = other.ContextWindowLines }</span> - <span class="cov6" title="43">if other.MaxContextTokens > 0 </span><span class="cov3" title="7">{ + <span class="cov6" title="53">if other.MaxContextTokens > 0 </span><span class="cov3" title="7">{ a.MaxContextTokens = other.MaxContextTokens }</span> - <span class="cov6" title="43">if other.LogPreviewLimit >= 0 </span><span class="cov6" title="43">{ + <span class="cov6" title="53">if other.LogPreviewLimit >= 0 </span><span class="cov6" title="53">{ a.LogPreviewLimit = other.LogPreviewLimit }</span> - <span class="cov6" title="43">if other.CodingTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0 + <span class="cov6" title="53">if other.CodingTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0 a.CodingTemperature = other.CodingTemperature }</span> - <span class="cov6" title="43">if other.ManualInvokeMinPrefix >= 0 </span><span class="cov6" title="43">{ + <span class="cov6" title="53">if other.ManualInvokeMinPrefix >= 0 </span><span class="cov6" title="53">{ a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix }</span> - <span class="cov6" title="43">if other.CompletionDebounceMs > 0 </span><span class="cov3" title="7">{ + <span class="cov6" title="53">if other.CompletionDebounceMs > 0 </span><span class="cov3" title="7">{ a.CompletionDebounceMs = other.CompletionDebounceMs }</span> - <span class="cov6" title="43">if other.CompletionThrottleMs > 0 </span><span class="cov3" title="7">{ + <span class="cov6" title="53">if other.CompletionThrottleMs > 0 </span><span class="cov3" title="7">{ a.CompletionThrottleMs = other.CompletionThrottleMs }</span> - <span class="cov6" title="43">if len(other.TriggerCharacters) > 0 </span><span class="cov3" title="7">{ + <span class="cov6" title="53">if len(other.TriggerCharacters) > 0 </span><span class="cov3" title="7">{ a.TriggerCharacters = slices.Clone(other.TriggerCharacters) }</span> - <span class="cov6" title="43">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov1" title="2">{ + <span class="cov6" title="53">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov1" title="2">{ a.InlineOpen = s }</span> - <span class="cov6" title="43">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov1" title="2">{ + <span class="cov6" title="53">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov1" title="2">{ a.InlineClose = s }</span> - <span class="cov6" title="43">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov1" title="2">{ + <span class="cov6" title="53">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov1" title="2">{ a.ChatSuffix = s }</span> - <span class="cov6" title="43">if len(other.ChatPrefixes) > 0 </span><span class="cov1" title="2">{ + <span class="cov6" title="53">if len(other.ChatPrefixes) > 0 </span><span class="cov1" title="2">{ a.ChatPrefixes = slices.Clone(other.ChatPrefixes) }</span> - <span class="cov6" title="43">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov4" title="13">{ + <span class="cov6" title="53">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov4" title="13">{ a.Provider = s }</span> } // mergePrompts copies non-empty prompt templates from other. -func (a *App) mergePrompts(other *App) <span class="cov5" title="27">{ +func (a *App) mergePrompts(other *App) <span class="cov5" title="33">{ // Completion if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" </span><span class="cov1" title="1">{ a.PromptCompletionSystemGeneral = other.PromptCompletionSystemGeneral }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCompletionSystemParams) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCompletionSystemParams) != "" </span><span class="cov1" title="1">{ a.PromptCompletionSystemParams = other.PromptCompletionSystemParams }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCompletionSystemInline) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCompletionSystemInline) != "" </span><span class="cov1" title="1">{ a.PromptCompletionSystemInline = other.PromptCompletionSystemInline }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" </span><span class="cov1" title="1">{ a.PromptCompletionUserGeneral = other.PromptCompletionUserGeneral }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCompletionUserParams) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCompletionUserParams) != "" </span><span class="cov1" title="1">{ a.PromptCompletionUserParams = other.PromptCompletionUserParams }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" </span><span class="cov1" title="1">{ a.PromptCompletionExtraHeader = other.PromptCompletionExtraHeader }</span> // Provider-native - <span class="cov5" title="27">if strings.TrimSpace(other.PromptNativeCompletion) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptNativeCompletion) != "" </span><span class="cov1" title="1">{ a.PromptNativeCompletion = other.PromptNativeCompletion }</span> // Chat - <span class="cov5" title="27">if strings.TrimSpace(other.PromptChatSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptChatSystem) != "" </span><span class="cov1" title="1">{ a.PromptChatSystem = other.PromptChatSystem }</span> // Code actions - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionRewriteSystem = other.PromptCodeActionRewriteSystem }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionDiagnosticsSystem = other.PromptCodeActionDiagnosticsSystem }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionDocumentSystem = other.PromptCodeActionDocumentSystem }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionRewriteUser = other.PromptCodeActionRewriteUser }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionDiagnosticsUser = other.PromptCodeActionDiagnosticsUser }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionDocumentUser = other.PromptCodeActionDocumentUser }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" </span><span class="cov0" title="0">{ a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" </span><span class="cov0" title="0">{ a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser }</span> // CLI - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" </span><span class="cov1" title="1">{ a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.PromptCLIExplainSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if strings.TrimSpace(other.PromptCLIExplainSystem) != "" </span><span class="cov1" title="1">{ a.PromptCLIExplainSystem = other.PromptCLIExplainSystem }</span> // Custom actions - <span class="cov5" title="27">if len(other.CustomActions) > 0 </span><span class="cov4" title="16">{ + <span class="cov5" title="33">if len(other.CustomActions) > 0 </span><span class="cov4" title="16">{ a.CustomActions = append([]CustomAction{}, other.CustomActions...) }</span> - <span class="cov5" title="27">if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" </span><span class="cov2" title="3">{ + <span class="cov5" title="33">if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" </span><span class="cov2" title="3">{ a.TmuxCustomMenuHotkey = other.TmuxCustomMenuHotkey }</span> } // Validate checks custom actions and tmux settings for duplicates and consistency. -func (a App) Validate() error <span class="cov5" title="19">{ +func (a App) Validate() error <span class="cov5" title="22">{ // Normalize and check duplicates for IDs and hotkeys seenID := make(map[string]struct{}) seenHK := make(map[string]struct{}) @@ -1039,7 +1054,7 @@ func (a App) Validate() error <span class="cov5" title="19">{ } } // Tmux custom menu hotkey validation - <span class="cov4" title="14">if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" </span><span class="cov1" title="2">{ + <span class="cov4" title="17">if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" </span><span class="cov1" title="2">{ if len([]rune(hk)) != 1 </span><span class="cov0" title="0">{ return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s", hk) }</span> @@ -1049,43 +1064,43 @@ func (a App) Validate() error <span class="cov5" title="19">{ return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s (clashes with built-in)", hk)</span> } } - <span class="cov4" title="13">return nil</span> + <span class="cov4" title="16">return nil</span> } // mergeProviderFields merges per-provider configuration. -func (a *App) mergeProviderFields(other *App) <span class="cov5" title="37">{ +func (a *App) mergeProviderFields(other *App) <span class="cov6" title="43">{ if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" </span><span class="cov3" title="7">{ a.OpenAIBaseURL = s }</span> - <span class="cov5" title="37">if s := strings.TrimSpace(other.OpenAIModel); s != "" </span><span class="cov4" title="13">{ + <span class="cov6" title="43">if s := strings.TrimSpace(other.OpenAIModel); s != "" </span><span class="cov4" title="13">{ a.OpenAIModel = s }</span> - <span class="cov5" title="37">if other.OpenAITemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0 + <span class="cov6" title="43">if other.OpenAITemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0 a.OpenAITemperature = other.OpenAITemperature }</span> - <span class="cov5" title="37">if s := strings.TrimSpace(other.OllamaBaseURL); s != "" </span><span class="cov3" title="7">{ + <span class="cov6" title="43">if s := strings.TrimSpace(other.OllamaBaseURL); s != "" </span><span class="cov3" title="7">{ a.OllamaBaseURL = s }</span> - <span class="cov5" title="37">if s := strings.TrimSpace(other.OllamaModel); s != "" </span><span class="cov3" title="7">{ + <span class="cov6" title="43">if s := strings.TrimSpace(other.OllamaModel); s != "" </span><span class="cov3" title="7">{ a.OllamaModel = s }</span> - <span class="cov5" title="37">if other.OllamaTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0 + <span class="cov6" title="43">if other.OllamaTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0 a.OllamaTemperature = other.OllamaTemperature }</span> - <span class="cov5" title="37">if s := strings.TrimSpace(other.CopilotBaseURL); s != "" </span><span class="cov3" title="7">{ + <span class="cov6" title="43">if s := strings.TrimSpace(other.CopilotBaseURL); s != "" </span><span class="cov3" title="7">{ a.CopilotBaseURL = s }</span> - <span class="cov5" title="37">if s := strings.TrimSpace(other.CopilotModel); s != "" </span><span class="cov3" title="7">{ + <span class="cov6" title="43">if s := strings.TrimSpace(other.CopilotModel); s != "" </span><span class="cov3" title="7">{ a.CopilotModel = s }</span> - <span class="cov5" title="37">if other.CopilotTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0 + <span class="cov6" title="43">if other.CopilotTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0 a.CopilotTemperature = other.CopilotTemperature }</span> } -func getConfigPath() (string, error) <span class="cov5" title="28">{ +func getConfigPath() (string, error) <span class="cov5" title="32">{ var configPath string - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov4" title="18">{ + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov5" title="22">{ configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") }</span> else<span class="cov4" title="10"> { home, err := os.UserHomeDir() @@ -1094,36 +1109,36 @@ func getConfigPath() (string, error) <span class="cov5" title="28">{ }</span> <span class="cov4" title="10">configPath = filepath.Join(home, ".config", "hexai", "config.toml")</span> } - <span class="cov5" title="28">return configPath, nil</span> + <span class="cov5" title="32">return configPath, nil</span> } // --- Environment overrides --- // loadFromEnv constructs an App containing only fields set via HEXAI_* env vars. // These values should take precedence over file config when merged. -func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="27">{ +func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="29">{ var out App var any bool // helpers - getenv := func(k string) string </span><span class="cov10" title="702">{ return strings.TrimSpace(os.Getenv(k)) }</span> - <span class="cov5" title="27">parseInt := func(k string) (int, bool) </span><span class="cov8" title="189">{ + getenv := func(k string) string </span><span class="cov10" title="754">{ return strings.TrimSpace(os.Getenv(k)) }</span> + <span class="cov5" title="29">parseInt := func(k string) (int, bool) </span><span class="cov8" title="203">{ v := getenv(k) - if v == "" </span><span class="cov8" title="182">{ + if v == "" </span><span class="cov8" title="194">{ return 0, false }</span> - <span class="cov3" title="7">n, err := strconv.Atoi(v) + <span class="cov3" title="9">n, err := strconv.Atoi(v) if err != nil </span><span class="cov0" title="0">{ if logger != nil </span><span class="cov0" title="0">{ logger.Printf("invalid %s: %v", k, err) }</span> <span class="cov0" title="0">return 0, false</span> } - <span class="cov3" title="7">return n, true</span> + <span class="cov3" title="9">return n, true</span> } - <span class="cov5" title="27">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov7" title="108">{ + <span class="cov5" title="29">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov7" title="116">{ v := getenv(k) - if v == "" </span><span class="cov7" title="104">{ + if v == "" </span><span class="cov7" title="112">{ return nil, false }</span> <span class="cov2" title="4">f, err := strconv.ParseFloat(v, 64) @@ -1136,43 +1151,43 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="27">{ <span class="cov2" title="4">return &f, true</span> } - <span class="cov5" title="27">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov2" title="3">{ out.MaxTokens = n any = true }</span> - <span class="cov5" title="27">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{ out.ContextMode = s any = true }</span> - <span class="cov5" title="27">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov1" title="1">{ out.ContextWindowLines = n any = true }</span> - <span class="cov5" title="27">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov1" title="1">{ out.MaxContextTokens = n any = true }</span> - <span class="cov5" title="27">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov1" title="1">{ out.LogPreviewLimit = n any = true }</span> - <span class="cov5" title="27">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov1" title="1">{ out.ManualInvokeMinPrefix = n any = true }</span> - <span class="cov5" title="27">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov1" title="1">{ out.CompletionDebounceMs = n any = true }</span> - <span class="cov5" title="27">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov1" title="1">{ out.CompletionThrottleMs = n any = true }</span> - <span class="cov5" title="27">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.CodingTemperature = f any = true }</span> - <span class="cov5" title="27">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov1" title="1">{ parts := strings.Split(s, ",") out.TriggerCharacters = nil for _, p := range parts </span><span class="cov2" title="3">{ @@ -1182,19 +1197,19 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="27">{ } <span class="cov1" title="1">any = true</span> } - <span class="cov5" title="27">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="29">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ out.InlineOpen = s any = true }</span> - <span class="cov5" title="27">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="29">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ out.InlineClose = s any = true }</span> - <span class="cov5" title="27">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="29">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ out.ChatSuffix = s any = true }</span> - <span class="cov5" title="27">if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="29">if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" </span><span class="cov0" title="0">{ parts := strings.Split(s, ",") out.ChatPrefixes = nil for _, p := range parts </span><span class="cov0" title="0">{ @@ -1204,17 +1219,17 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="27">{ } <span class="cov0" title="0">any = true</span> } - <span class="cov5" title="27">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov3" title="5">{ + <span class="cov5" title="29">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov3" title="5">{ out.Provider = s any = true }</span> - <span class="cov5" title="27">modelForce := strings.TrimSpace(getenv("HEXAI_MODEL_FORCE")) + <span class="cov5" title="29">modelForce := strings.TrimSpace(getenv("HEXAI_MODEL_FORCE")) modelGeneric := strings.TrimSpace(getenv("HEXAI_MODEL")) providerLower := strings.ToLower(strings.TrimSpace(out.Provider)) forceUsed := false genericUsed := false - pickModel := func(providerName, specific string) (string, bool) </span><span class="cov7" title="81">{ + pickModel := func(providerName, specific string) (string, bool) </span><span class="cov7" title="87">{ specific = strings.TrimSpace(specific) nameLower := strings.ToLower(strings.TrimSpace(providerName)) if modelForce != "" </span><span class="cov2" title="3">{ @@ -1227,10 +1242,10 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="27">{ return modelForce, true }</span> } - <span class="cov7" title="80">if specific != "" </span><span class="cov2" title="4">{ + <span class="cov7" title="86">if specific != "" </span><span class="cov2" title="4">{ return specific, true }</span> - <span class="cov6" title="76">if modelGeneric != "" </span><span class="cov3" title="8">{ + <span class="cov6" title="82">if modelGeneric != "" </span><span class="cov3" title="8">{ if providerLower == nameLower </span><span class="cov1" title="2">{ return modelGeneric, true }</span> @@ -1239,53 +1254,53 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="27">{ return modelGeneric, true }</span> } - <span class="cov6" title="74">return "", false</span> + <span class="cov6" title="80">return "", false</span> } // Provider-specific - <span class="cov5" title="27">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{ out.OpenAIBaseURL = s any = true }</span> - <span class="cov5" title="27">if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok </span><span class="cov3" title="5">{ + <span class="cov5" title="29">if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok </span><span class="cov3" title="5">{ out.OpenAIModel = model any = true }</span> - <span class="cov5" title="27">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.OpenAITemperature = f any = true }</span> - <span class="cov5" title="27">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov1" title="1">{ out.OllamaBaseURL = s any = true }</span> - <span class="cov5" title="27">if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok </span><span class="cov1" title="1">{ out.OllamaModel = model any = true }</span> - <span class="cov5" title="27">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.OllamaTemperature = f any = true }</span> - <span class="cov5" title="27">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov1" title="1">{ out.CopilotBaseURL = s any = true }</span> - <span class="cov5" title="27">if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok </span><span class="cov1" title="1">{ out.CopilotModel = model any = true }</span> - <span class="cov5" title="27">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="29">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.CopilotTemperature = f any = true }</span> - <span class="cov5" title="27">if !any </span><span class="cov5" title="22">{ + <span class="cov5" title="29">if !any </span><span class="cov5" title="22">{ return nil }</span> - <span class="cov3" title="5">return &out</span> + <span class="cov3" title="7">return &out</span> } </pre> @@ -1632,10 +1647,10 @@ import ( ) // Render performs simple {{var}} replacement like LSP. -func Render(t string, vars map[string]string) string <span class="cov7" title="15">{ return textutil.RenderTemplate(t, vars) }</span> +func Render(t string, vars map[string]string) string <span class="cov7" title="18">{ return textutil.RenderTemplate(t, vars) }</span> // StripFences removes surrounding markdown code fences. -func StripFences(s string) string <span class="cov7" title="16">{ return textutil.StripCodeFences(s) }</span> +func StripFences(s string) string <span class="cov7" title="19">{ return textutil.StripCodeFences(s) }</span> type chatDoer interface { Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) @@ -1644,31 +1659,31 @@ type chatDoer interface { type providerNamer interface{ Name() string } -func providerOf(c any) string <span class="cov10" title="45">{ +func providerOf(c any) string <span class="cov10" title="54">{ if n, ok := c.(providerNamer); ok </span><span class="cov5" title="6">{ return n.Name() }</span> - <span class="cov9" title="39">return "llm"</span> + <span class="cov9" title="48">return "llm"</span> } -func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) <span class="cov5" title="6">{ +func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) <span class="cov5" title="7">{ sys := cfg.PromptCodeActionRewriteSystem user := Render(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": instruction, "selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) }</span> -func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) <span class="cov1" title="1">{ +func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) <span class="cov2" title="2">{ var b strings.Builder - for i, d := range diags </span><span class="cov2" title="2">{ + for i, d := range diags </span><span class="cov3" title="3">{ if strings.TrimSpace(d) == "" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov2" title="2">b.WriteString(strings.TrimSpace(d)) + <span class="cov3" title="3">b.WriteString(strings.TrimSpace(d)) if i < len(diags)-1 </span><span class="cov1" title="1">{ b.WriteString("\n") }</span> } - <span class="cov1" title="1">sys := cfg.PromptCodeActionDiagnosticsSystem + <span class="cov2" title="2">sys := cfg.PromptCodeActionDiagnosticsSystem user := Render(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": b.String(), "selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))</span> } @@ -1679,7 +1694,7 @@ func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, select return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) }</span> -func runSimplify(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) <span class="cov1" title="1">{ +func runSimplify(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) <span class="cov2" title="2">{ sys := cfg.PromptCodeActionSimplifySystem user := Render(cfg.PromptCodeActionSimplifyUser, map[string]string{"selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) @@ -1691,7 +1706,7 @@ func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) }</span> -func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appconfig.CustomAction, parts InputParts) (string, error) <span class="cov4" title="4">{ +func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appconfig.CustomAction, parts InputParts) (string, error) <span class="cov4" title="5">{ // If user template is provided, prefer it and optional system if strings.TrimSpace(ca.User) != "" </span><span class="cov2" title="2">{ sys := cfg.PromptCodeActionRewriteSystem @@ -1703,7 +1718,7 @@ func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appco return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))</span> } // Else, use fixed instruction through rewrite template - <span class="cov2" title="2">return runRewrite(ctx, cfg, client, ca.Instruction, parts.Selection)</span> + <span class="cov3" title="3">return runRewrite(ctx, cfg, client, ca.Instruction, parts.Selection)</span> } func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) <span class="cov1" title="1">{ @@ -1737,55 +1752,55 @@ func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, er <span class="cov1" title="1">return out, nil</span> } -func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) <span class="cov7" title="14">{ +func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) <span class="cov7" title="17">{ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} txt, err := client.Chat(ctx, msgs, opts...) if err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov7" title="14">out := strings.TrimSpace(StripFences(txt)) + <span class="cov7" title="17">out := strings.TrimSpace(StripFences(txt)) // Contribute to global stats and update tmux status sent := 0 - for _, m := range msgs </span><span class="cov8" title="28">{ + for _, m := range msgs </span><span class="cov8" title="34">{ sent += len(m.Content) }</span> - <span class="cov7" title="14">recv := len(out) + <span class="cov7" title="17">recv := len(out) _ = stats.Update(ctx, providerOf(client), client.DefaultModel(), sent, recv) - if snap, err := stats.TakeSnapshot(); err == nil </span><span class="cov7" title="14">{ + if snap, err := stats.TakeSnapshot(); err == nil </span><span class="cov7" title="17">{ minsWin := snap.Window.Minutes() if minsWin <= 0 </span><span class="cov0" title="0">{ minsWin = 0.001 }</span> - <span class="cov7" title="14">scopeReqs := int64(0) - if pe, ok := snap.Providers[providerOf(client)]; ok </span><span class="cov7" title="14">{ - if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 </span><span class="cov7" title="14">{ + <span class="cov7" title="17">scopeReqs := int64(0) + if pe, ok := snap.Providers[providerOf(client)]; ok </span><span class="cov7" title="17">{ + if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 </span><span class="cov7" title="17">{ scopeReqs = mc.Reqs }</span> } - <span class="cov7" title="14">scopeRPM := float64(scopeReqs) / minsWin + <span class="cov7" title="17">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))</span> } - <span class="cov7" title="14">return out, nil</span> + <span class="cov7" title="17">return out, nil</span> } // reqOptsFrom builds LLM request options similar to LSP behavior. -func reqOptsFrom(cfg appconfig.App) []llm.RequestOption <span class="cov7" title="14">{ +func reqOptsFrom(cfg appconfig.App) []llm.RequestOption <span class="cov7" title="17">{ opts := []llm.RequestOption{llm.WithMaxTokens(cfg.MaxTokens)} // Apply temperature, with special-case for gpt-5 (default temp must be 1.0) - if cfg.CodingTemperature != nil </span><span class="cov6" title="10">{ + if cfg.CodingTemperature != nil </span><span class="cov6" title="13">{ temp := *cfg.CodingTemperature prov := strings.ToLower(strings.TrimSpace(cfg.Provider)) model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel)) if prov == "openai" && strings.HasPrefix(model, "gpt-5") </span><span class="cov0" title="0">{ temp = 1.0 }</span> - <span class="cov6" title="10">opts = append(opts, llm.WithTemperature(temp))</span> + <span class="cov6" title="13">opts = append(opts, llm.WithTemperature(temp))</span> } - <span class="cov7" title="14">return opts</span> + <span class="cov7" title="17">return opts</span> } // Timeout helpers to mirror LSP behavior. -func timeout10s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov5" title="7">{ +func timeout10s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov6" title="10">{ return context.WithTimeout(parent, 20*time.Second) }</span> @@ -1864,7 +1879,7 @@ func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error < return nil</span> } -func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov10" title="10">{ +func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov9" title="10">{ switch kind </span>{ case ActionSkip:<span class="cov3" title="2"> return parts.Selection, nil</span> @@ -1898,8 +1913,8 @@ func handleRewriteAction(ctx context.Context, parts InputParts, cfg appconfig.Ap }</span>) } -func handleDiagnosticsAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov0" title="0">{ - return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov0" title="0">{ +func handleDiagnosticsAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov1" title="1">{ + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov1" title="1">{ return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection) }</span>) } @@ -1916,17 +1931,17 @@ func handleGoTestAction(ctx context.Context, parts InputParts, cfg appconfig.App }</span>) } -func handleSimplifyAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov0" title="0">{ - return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov0" title="0">{ +func handleSimplifyAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov1" title="1">{ + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov1" title="1">{ return runSimplify(cctx, cfg, client, parts.Selection) }</span>) } -func handleCustomAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov5" title="3">{ +func handleCustomAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov6" title="4">{ if selectedCustom == nil </span><span class="cov0" title="0">{ return parts.Selection, nil }</span> - <span class="cov5" title="3">return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov5" title="3">{ + <span class="cov6" title="4">return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov6" title="4">{ out, err := runCustom(cctx, cfg, client, *selectedCustom, parts) selectedCustom = nil return out, err @@ -1944,7 +1959,7 @@ func handleCustomPromptAction(ctx context.Context, parts InputParts, cfg appconf }</span>) } -func runWithTimeout(ctx context.Context, timeout func(context.Context) (context.Context, context.CancelFunc), fn func(context.Context) (string, error)) (string, error) <span class="cov9" title="8">{ +func runWithTimeout(ctx context.Context, timeout func(context.Context) (context.Context, context.CancelFunc), fn func(context.Context) (string, error)) (string, error) <span class="cov10" title="11">{ innerCtx, cancel := timeout(ctx) defer cancel() return fn(innerCtx) @@ -2400,6 +2415,7 @@ import ( "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/logging" "codeberg.org/snonux/hexai/internal/lsp" + "codeberg.org/snonux/hexai/internal/runtimeconfig" "codeberg.org/snonux/hexai/internal/stats" ) @@ -2434,36 +2450,54 @@ func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) er // RunWithFactory is the testable entrypoint. When client is nil, it is built from cfg+env. // When factory is nil, lsp.NewServer is used. -func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error <span class="cov10" title="7">{ +func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error <span class="cov9" title="8">{ normalizeLoggingConfig(&cfg) if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{ logger.Fatalf("invalid config: %v", err) }</span> - <span class="cov10" title="7">client = buildClientIfNil(cfg, client) + <span class="cov9" title="8">client = buildClientIfNil(cfg, client) factory = ensureFactory(factory) - opts := makeServerOptions(cfg, strings.TrimSpace(logPath) != "", client) + store := runtimeconfig.New(cfg) + logContext := strings.TrimSpace(logPath) != "" + opts := makeServerOptions(cfg, logContext, client) + opts.ConfigStore = store server := factory(stdin, stdout, logger, opts) - if err := server.Run(); err != nil </span><span class="cov0" title="0">{ + if configurable, ok := server.(interface{ ApplyOptions(lsp.ServerOptions) }); ok </span><span class="cov3" title="2">{ + store.Subscribe(func(oldCfg, newCfg appconfig.App) </span><span class="cov1" title="1">{ + updated := newCfg + normalizeLoggingConfig(&updated) + if updated.StatsWindowMinutes > 0 </span><span class="cov0" title="0">{ + stats.SetWindow(time.Duration(updated.StatsWindowMinutes) * time.Minute) + }</span> + <span class="cov1" title="1">if newClient := buildClientIfNil(updated, nil); newClient != nil </span><span class="cov0" title="0">{ + client = newClient + }</span> + <span class="cov1" title="1">opts := makeServerOptions(updated, logContext, client) + opts.ConfigStore = store + configurable.ApplyOptions(opts)</span> + }) + } + <span class="cov9" title="8">if err := server.Run(); err != nil </span><span class="cov0" title="0">{ logger.Fatalf("server error: %v", err) }</span> - <span class="cov10" title="7">return nil</span> + <span class="cov9" title="8">return nil</span> } // --- helpers to keep RunWithFactory small --- -func normalizeLoggingConfig(cfg *appconfig.App) <span class="cov10" title="7">{ +func normalizeLoggingConfig(cfg *appconfig.App) <span class="cov10" title="9">{ cfg.ContextMode = strings.ToLower(strings.TrimSpace(cfg.ContextMode)) - if cfg.LogPreviewLimit >= 0 </span><span class="cov10" title="7">{ + if cfg.LogPreviewLimit >= 0 </span><span class="cov10" title="9">{ logging.SetLogPreviewLimit(cfg.LogPreviewLimit) }</span> } -func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client <span class="cov10" title="7">{ - if client != nil </span><span class="cov0" title="0">{ +func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client <span class="cov10" title="9">{ + if client != nil </span><span class="cov1" title="1">{ return client }</span> - <span class="cov10" title="7">llmCfg := llm.Config{ + <span class="cov9" title="8">llmCfg := llm.Config{ Provider: cfg.Provider, OpenAIBaseURL: cfg.OpenAIBaseURL, OpenAIModel: cfg.OpenAIModel, @@ -2477,25 +2511,25 @@ func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client <span cla } // Prefer HEXAI_OPENAI_API_KEY; fall back to OPENAI_API_KEY oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") - if strings.TrimSpace(oaKey) == "" </span><span class="cov10" title="7">{ + if strings.TrimSpace(oaKey) == "" </span><span class="cov9" title="8">{ oaKey = os.Getenv("OPENAI_API_KEY") }</span> // Prefer HEXAI_COPILOT_API_KEY; fall back to COPILOT_API_KEY - <span class="cov10" title="7">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") - if strings.TrimSpace(cpKey) == "" </span><span class="cov10" title="7">{ + <span class="cov9" title="8">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") + if strings.TrimSpace(cpKey) == "" </span><span class="cov9" title="8">{ cpKey = os.Getenv("COPILOT_API_KEY") }</span> - <span class="cov10" title="7">if c, err := llm.NewFromConfig(llmCfg, oaKey, cpKey); err != nil </span><span class="cov1" title="1">{ + <span class="cov9" title="8">if c, err := llm.NewFromConfig(llmCfg, oaKey, cpKey); err != nil </span><span class="cov8" title="6">{ logging.Logf("lsp ", "llm disabled: %v", err) return nil - }</span> else<span class="cov9" title="6"> { + }</span> else<span class="cov3" title="2"> { logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel()) return c }</span> } -func ensureFactory(factory ServerFactory) ServerFactory <span class="cov10" title="7">{ - if factory != nil </span><span class="cov9" title="6">{ +func ensureFactory(factory ServerFactory) ServerFactory <span class="cov9" title="8">{ + if factory != nil </span><span class="cov8" title="7">{ return factory }</span> <span class="cov1" title="1">return func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner </span><span class="cov1" title="1">{ @@ -2503,12 +2537,12 @@ func ensureFactory(factory ServerFactory) ServerFactory <span class="cov10" titl }</span> } -func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) lsp.ServerOptions <span class="cov10" title="7">{ +func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) lsp.ServerOptions <span class="cov10" title="9">{ // Map custom actions from appconfig to lsp type var customs []lsp.CustomAction - if len(cfg.CustomActions) > 0 </span><span class="cov4" title="2">{ + if len(cfg.CustomActions) > 0 </span><span class="cov3" title="2">{ customs = make([]lsp.CustomAction, 0, len(cfg.CustomActions)) - for _, ca := range cfg.CustomActions </span><span class="cov7" title="4">{ + for _, ca := range cfg.CustomActions </span><span class="cov6" title="4">{ customs = append(customs, lsp.CustomAction{ ID: ca.ID, Title: ca.Title, @@ -2520,8 +2554,10 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) ls }) }</span> } - <span class="cov10" title="7">return lsp.ServerOptions{ + <span class="cov10" title="9">return lsp.ServerOptions{ LogContext: logContext, + ConfigStore: nil, + Config: &cfg, MaxTokens: cfg.MaxTokens, ContextMode: cfg.ContextMode, WindowLines: cfg.ContextWindowLines, @@ -3004,7 +3040,7 @@ func newOllama(baseURL, model string, defaultTemp *float64) Client <span class=" baseURL = "http://localhost:11434" }</span> <span class="cov10" title="13">if strings.TrimSpace(model) == "" </span><span class="cov3" title="2">{ - model = "qwen3-coder:30b-a3b-q4_K_M`" + model = "qwen3-coder:30b-a3b-q4_K_M" }</span> <span class="cov10" title="13">return ollamaClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, @@ -3252,14 +3288,14 @@ type oaStreamChunk struct { // Constructor (kept among the first functions by convention) // newOpenAI constructs an OpenAI client using explicit configuration values. // The apiKey may be empty; calls will fail until a valid key is supplied. -func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov10" title="20">{ - if strings.TrimSpace(baseURL) == "" </span><span class="cov7" title="9">{ +func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov9" title="16">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov6" title="5">{ baseURL = "https://api.openai.com/v1" }</span> - <span class="cov10" title="20">if strings.TrimSpace(model) == "" </span><span class="cov6" title="6">{ + <span class="cov9" title="16">if strings.TrimSpace(model) == "" </span><span class="cov3" title="2">{ model = "gpt-4.1" }</span> - <span class="cov10" title="20">return openAIClient{ + <span class="cov9" title="16">return openAIClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, apiKey: apiKey, baseURL: baseURL, @@ -3269,18 +3305,18 @@ func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client <span }</span> } -func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov6" title="6">{ +func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov7" title="8">{ if c.apiKey == "" </span><span class="cov1" title="1">{ return nilStringErr("missing OpenAI API key") }</span> - <span class="cov5" title="5">o := Options{Model: c.defaultModel} + <span class="cov7" title="7">o := Options{Model: c.defaultModel} for _, opt := range opts </span><span class="cov0" title="0">{ opt(&o) }</span> - <span class="cov5" title="5">if o.Model == "" </span><span class="cov0" title="0">{ + <span class="cov7" title="7">if o.Model == "" </span><span class="cov0" title="0">{ o.Model = c.defaultModel }</span> - <span class="cov5" title="5">start := time.Now() + <span class="cov7" title="7">start := time.Now() c.logStart(false, o, messages) req := buildOAChatRequest(o, messages, c.defaultTemperature, false) body, err := json.Marshal(req) @@ -3288,7 +3324,7 @@ func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...Requ c.logf("marshal error: %v", err) return "", err }</span> - <span class="cov5" title="5">endpoint := c.baseURL + "/chat/completions" + <span class="cov7" title="7">endpoint := c.baseURL + "/chat/completions" logging.Logf("llm/openai ", "POST %s", endpoint) resp, err := c.doJSON(ctx, endpoint, body, map[string]string{ "Authorization": "Bearer " + c.apiKey, @@ -3297,41 +3333,41 @@ func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...Requ logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return "", err }</span> - <span class="cov5" title="5">defer resp.Body.Close() - if err := handleOpenAINon2xx(resp, start); err != nil </span><span class="cov1" title="1">{ + <span class="cov7" title="7">defer resp.Body.Close() + if err := handleOpenAINon2xx(resp, start); err != nil </span><span class="cov3" title="2">{ return "", err }</span> - <span class="cov5" title="4">out, err := decodeOpenAIChat(resp, start) + <span class="cov6" title="5">out, err := decodeOpenAIChat(resp, start) if err != nil </span><span class="cov1" title="1">{ return "", err }</span> - <span class="cov4" title="3">if len(out.Choices) == 0 </span><span class="cov1" title="1">{ + <span class="cov5" title="4">if len(out.Choices) == 0 </span><span class="cov1" title="1">{ logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) return "", errors.New("openai: no choices returned") }</span> - <span class="cov3" title="2">content := out.Choices[0].Message.Content + <span class="cov4" title="3">content := out.Choices[0].Message.Content logging.Logf("llm/openai ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) return content, nil</span> } // Provider metadata -func (c openAIClient) Name() string <span class="cov6" title="6">{ return "openai" }</span> -func (c openAIClient) DefaultModel() string <span class="cov6" title="6">{ return c.defaultModel }</span> +func (c openAIClient) Name() string <span class="cov3" title="2">{ return "openai" }</span> +func (c openAIClient) DefaultModel() string <span class="cov3" title="2">{ return c.defaultModel }</span> // Streaming support (optional) -func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov5" title="4">{ +func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov6" title="5">{ if c.apiKey == "" </span><span class="cov0" title="0">{ return errors.New("missing OpenAI API key") }</span> - <span class="cov5" title="4">o := Options{Model: c.defaultModel} + <span class="cov6" title="5">o := Options{Model: c.defaultModel} for _, opt := range opts </span><span class="cov0" title="0">{ opt(&o) }</span> - <span class="cov5" title="4">if o.Model == "" </span><span class="cov0" title="0">{ + <span class="cov6" title="5">if o.Model == "" </span><span class="cov0" title="0">{ o.Model = c.defaultModel }</span> - <span class="cov5" title="4">start := time.Now() + <span class="cov6" title="5">start := time.Now() c.logStart(true, o, messages) req := buildOAChatRequest(o, messages, c.defaultTemperature, true) body, err := json.Marshal(req) @@ -3339,7 +3375,7 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt c.logf("marshal error: %v", err) return err }</span> - <span class="cov5" title="4">endpoint := c.baseURL + "/chat/completions" + <span class="cov6" title="5">endpoint := c.baseURL + "/chat/completions" logging.Logf("llm/openai ", "POST %s (stream)", endpoint) resp, err := c.doJSONWithAccept(ctx, endpoint, body, map[string]string{ "Authorization": "Bearer " + c.apiKey, @@ -3348,15 +3384,15 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return err }</span> - <span class="cov5" title="4">defer resp.Body.Close() + <span class="cov6" title="5">defer resp.Body.Close() if err := handleOpenAINon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="4">if err := parseOpenAIStream(resp, start, onDelta); err != nil </span><span class="cov1" title="1">{ + <span class="cov6" title="5">if err := parseOpenAIStream(resp, start, onDelta); err != nil </span><span class="cov1" title="1">{ return err }</span> - <span class="cov4" title="3">logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start)) + <span class="cov5" title="4">logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start)) return nil</span> } @@ -3364,104 +3400,104 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt func (c openAIClient) logf(format string, args ...any) <span class="cov0" title="0">{ logging.Logf("llm/openai ", format, args...) }</span> // helpers extracted to keep methods small -func (c openAIClient) logStart(stream bool, o Options, messages []Message) <span class="cov7" title="9">{ +func (c openAIClient) logStart(stream bool, o Options, messages []Message) <span class="cov8" title="12">{ logMessages := make([]struct{ Role, Content string }, len(messages)) - for i, m := range messages </span><span class="cov7" title="9">{ + for i, m := range messages </span><span class="cov8" title="12">{ logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} }</span> - <span class="cov7" title="9">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> + <span class="cov8" title="12">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> } -func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool) oaChatRequest <span class="cov8" title="14">{ +func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool) oaChatRequest <span class="cov9" title="15">{ req := oaChatRequest{Model: o.Model, Stream: stream} req.Messages = make([]oaMessage, len(messages)) - for i, m := range messages </span><span class="cov8" title="14">{ + for i, m := range messages </span><span class="cov9" title="15">{ req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} }</span> - <span class="cov8" title="14">if o.Temperature != 0 </span><span class="cov1" title="1">{ + <span class="cov9" title="15">if o.Temperature != 0 </span><span class="cov1" title="1">{ req.Temperature = &o.Temperature - }</span> else<span class="cov8" title="13"> if defaultTemp != nil </span><span class="cov8" title="11">{ + }</span> else<span class="cov9" title="14"> if defaultTemp != nil </span><span class="cov7" title="9">{ t := *defaultTemp req.Temperature = &t }</span> - <span class="cov8" title="14">if o.MaxTokens > 0 </span><span class="cov5" title="5">{ + <span class="cov9" title="15">if o.MaxTokens > 0 </span><span class="cov4" title="3">{ if requiresMaxCompletionTokens(o.Model) </span><span class="cov3" title="2">{ req.MaxCompletionTokens = &o.MaxTokens - }</span> else<span class="cov4" title="3"> { + }</span> else<span class="cov1" title="1"> { req.MaxTokens = &o.MaxTokens }</span> } - <span class="cov8" title="14">if len(o.Stop) > 0 </span><span class="cov3" title="2">{ + <span class="cov9" title="15">if len(o.Stop) > 0 </span><span class="cov0" title="0">{ req.Stop = o.Stop }</span> // Enforce gpt-5 temperature constraints: only default (1.0) is supported. - <span class="cov8" title="14">if requiresMaxCompletionTokens(o.Model) </span><span class="cov3" title="2">{ + <span class="cov9" title="15">if requiresMaxCompletionTokens(o.Model) </span><span class="cov3" title="2">{ if req.Temperature == nil || *req.Temperature != 1.0 </span><span class="cov3" title="2">{ t := 1.0 req.Temperature = &t logging.Logf("llm/openai ", "forcing temperature=1.0 for model=%s (gpt-5 constraint)", o.Model) }</span> } - <span class="cov8" title="14">return req</span> + <span class="cov9" title="15">return req</span> } // requiresMaxCompletionTokens reports whether the given model prefers the // new parameter name "max_completion_tokens" instead of "max_tokens". Newer // models (e.g., gpt-5 family) expect this per OpenAI's API error guidance. -func requiresMaxCompletionTokens(model string) bool <span class="cov9" title="19">{ +func requiresMaxCompletionTokens(model string) bool <span class="cov10" title="18">{ m := strings.ToLower(strings.TrimSpace(model)) return strings.HasPrefix(m, "gpt-5") }</span> -func (c openAIClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov5" title="5">{ +func (c openAIClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov7" title="7">{ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil </span><span class="cov0" title="0">{ return nil, err }</span> - <span class="cov5" title="5">req.Header.Set("Content-Type", "application/json") - for k, v := range headers </span><span class="cov5" title="5">{ + <span class="cov7" title="7">req.Header.Set("Content-Type", "application/json") + for k, v := range headers </span><span class="cov7" title="7">{ req.Header.Set(k, v) }</span> - <span class="cov5" title="5">return c.httpClient.Do(req)</span> + <span class="cov7" title="7">return c.httpClient.Do(req)</span> } -func (c openAIClient) doJSONWithAccept(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) <span class="cov5" title="4">{ +func (c openAIClient) doJSONWithAccept(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) <span class="cov6" title="5">{ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil </span><span class="cov0" title="0">{ return nil, err }</span> - <span class="cov5" title="4">req.Header.Set("Content-Type", "application/json") + <span class="cov6" title="5">req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", accept) - for k, v := range headers </span><span class="cov5" title="4">{ + for k, v := range headers </span><span class="cov6" title="5">{ req.Header.Set(k, v) }</span> - <span class="cov5" title="4">return c.httpClient.Do(req)</span> + <span class="cov6" title="5">return c.httpClient.Do(req)</span> } -func handleOpenAINon2xx(resp *http.Response, start time.Time) error <span class="cov8" title="11">{ - if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov7" title="8">{ +func handleOpenAINon2xx(resp *http.Response, start time.Time) error <span class="cov8" title="13">{ + if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov8" title="10">{ return nil }</span> <span class="cov4" title="3">var apiErr oaChatResponse _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if apiErr.Error != nil && apiErr.Error.Message != "" </span><span class="cov3" title="2">{ + if apiErr.Error != nil && apiErr.Error.Message != "" </span><span class="cov1" title="1">{ logging.Logf("llm/openai ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase) return fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) }</span> - <span class="cov1" title="1">logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + <span class="cov3" title="2">logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) return fmt.Errorf("openai http error: status %d", resp.StatusCode)</span> } -func decodeOpenAIChat(resp *http.Response, start time.Time) (oaChatResponse, error) <span class="cov5" title="4">{ +func decodeOpenAIChat(resp *http.Response, start time.Time) (oaChatResponse, error) <span class="cov6" title="5">{ var out oaChatResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov1" title="1">{ logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return oaChatResponse{}, err }</span> - <span class="cov4" title="3">return out, nil</span> + <span class="cov5" title="4">return out, nil</span> } -func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string)) error <span class="cov5" title="5">{ +func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string)) error <span class="cov6" title="5">{ // Parse SSE: lines starting with "data: " containing JSON or [DONE] scanner := bufio.NewScanner(resp.Body) const maxBuf = 1024 * 1024 @@ -3469,22 +3505,22 @@ func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string scanner.Buffer(buf, maxBuf) for scanner.Scan() </span><span class="cov8" title="11">{ line := scanner.Text() - if !strings.HasPrefix(line, "data: ") </span><span class="cov4" title="3">{ + if !strings.HasPrefix(line, "data: ") </span><span class="cov3" title="2">{ continue</span> } - <span class="cov7" title="8">payload := strings.TrimPrefix(line, "data: ") + <span class="cov7" title="9">payload := strings.TrimPrefix(line, "data: ") if strings.TrimSpace(payload) == "[DONE]" </span><span class="cov4" title="3">{ break</span> } - <span class="cov5" title="5">var chunk oaStreamChunk + <span class="cov6" title="6">var chunk oaStreamChunk if err := json.Unmarshal([]byte(payload), &chunk); err != nil </span><span class="cov3" title="2">{ continue</span> } - <span class="cov4" title="3">if chunk.Error != nil && chunk.Error.Message != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="4">if chunk.Error != nil && chunk.Error.Message != "" </span><span class="cov1" title="1">{ logging.Logf("llm/openai ", "%sstream error: %s%s", logging.AnsiRed, chunk.Error.Message, logging.AnsiBase) return fmt.Errorf("openai stream error: %s", chunk.Error.Message) }</span> - <span class="cov3" title="2">for _, ch := range chunk.Choices </span><span class="cov3" title="2">{ + <span class="cov4" title="3">for _, ch := range chunk.Choices </span><span class="cov4" title="3">{ if ch.Delta.Content != "" </span><span class="cov3" title="2">{ onDelta(ch.Delta.Content) }</span> @@ -3556,8 +3592,8 @@ type Options struct { type RequestOption func(*Options) func WithModel(model string) RequestOption <span class="cov1" title="1">{ return func(o *Options) </span><span class="cov1" title="1">{ o.Model = model }</span> } -func WithTemperature(t float64) RequestOption <span class="cov6" title="12">{ return func(o *Options) </span><span class="cov2" title="2">{ o.Temperature = t }</span> } -func WithMaxTokens(n int) RequestOption <span class="cov10" title="49">{ return func(o *Options) </span><span class="cov2" title="2">{ o.MaxTokens = n }</span> } +func WithTemperature(t float64) RequestOption <span class="cov7" title="15">{ return func(o *Options) </span><span class="cov2" title="2">{ o.Temperature = t }</span> } +func WithMaxTokens(n int) RequestOption <span class="cov10" title="53">{ return func(o *Options) </span><span class="cov2" title="2">{ o.MaxTokens = n }</span> } func WithStop(stop ...string) RequestOption <span class="cov1" title="1">{ return func(o *Options) </span><span class="cov1" title="1">{ o.Stop = append([]string{}, stop...) }</span> } @@ -3582,14 +3618,14 @@ 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) <span class="cov8" title="22">{ +func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) <span class="cov8" title="23">{ p := strings.ToLower(strings.TrimSpace(cfg.Provider)) - if p == "" </span><span class="cov5" title="8">{ + if p == "" </span><span class="cov5" title="9">{ p = "openai" }</span> - <span class="cov8" title="22">switch p </span>{ - case "openai":<span class="cov7" title="15"> - if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov4" title="5">{ + <span class="cov8" title="23">switch p </span>{ + case "openai":<span class="cov7" title="16"> + if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov6" title="10">{ return nil, errors.New("missing OPENAI_API_KEY for provider openai") }</span> // Default temperature selection: @@ -3598,7 +3634,7 @@ func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, erro // The app-wide defaults currently set provider temps to 0.2. // If the user hasn't explicitly overridden and the model is gpt-5*, // upgrade 0.2 → 1.0 to satisfy the requested default for gpt-5. - <span class="cov6" title="10">model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel)) + <span class="cov5" title="6">model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel)) if strings.HasPrefix(model, "gpt-5") </span><span class="cov2" title="2">{ if cfg.OpenAITemperature == nil </span><span class="cov1" title="1">{ v := 1.0 @@ -3607,11 +3643,11 @@ func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, erro v := 1.0 cfg.OpenAITemperature = &v }</span> - } else<span class="cov5" title="8"> if cfg.OpenAITemperature == nil </span><span class="cov5" title="6">{ + } else<span class="cov4" title="4"> if cfg.OpenAITemperature == nil </span><span class="cov3" title="3">{ v := 0.2 cfg.OpenAITemperature = &v }</span> - <span class="cov6" title="10">return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil</span> + <span class="cov5" title="6">return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil</span> case "ollama":<span class="cov3" title="3"> if cfg.OllamaTemperature == nil </span><span class="cov2" title="2">{ t := 0.2 @@ -3685,7 +3721,7 @@ type ChatLogger struct { } // NewChatLogger creates a new ChatLogger for a given provider. -func NewChatLogger(provider string) ChatLogger <span class="cov10" title="43">{ +func NewChatLogger(provider string) ChatLogger <span class="cov10" title="42">{ return ChatLogger{Provider: provider} }</span> @@ -3694,14 +3730,14 @@ func (cl ChatLogger) LogStart(stream bool, model string, temp float64, maxTokens Role string Content string }, -) <span class="cov8" title="24">{ +) <span class="cov8" title="27">{ chatOrStream := "chat" - if stream </span><span class="cov5" title="8">{ + if stream </span><span class="cov6" title="9">{ chatOrStream = "stream" }</span> - <span class="cov8" title="24">Logf("llm/"+cl.Provider+" ", "%s start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", + <span class="cov8" title="27">Logf("llm/"+cl.Provider+" ", "%s start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", chatOrStream, model, temp, maxTokens, len(stop), len(messages)) - for i, m := range messages </span><span class="cov8" title="24">{ + for i, m := range messages </span><span class="cov8" title="27">{ Logf("llm/"+cl.Provider+" ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), AnsiCyan, PreviewForLog(m.Content), AnsiBase) }</span> @@ -3737,11 +3773,11 @@ var std *log.Logger func Bind(l *log.Logger) <span class="cov2" title="3">{ std = l }</span> // Logf prints a formatted message with a module prefix and base ANSI style. -func Logf(prefix, format string, args ...any) <span class="cov10" title="183">{ - if std == nil </span><span class="cov9" title="129">{ +func Logf(prefix, format string, args ...any) <span class="cov10" title="199">{ + if std == nil </span><span class="cov9" title="141">{ return }</span> - <span class="cov7" title="54">msg := fmt.Sprintf(format, args...) + <span class="cov7" title="58">msg := fmt.Sprintf(format, args...) std.Print(AnsiBase + prefix + msg + AnsiReset)</span> } @@ -3750,21 +3786,86 @@ var logPreviewLimit int // 0 means unlimited // SetLogPreviewLimit sets the maximum number of characters to log for // request/response previews. Set to 0 for unlimited. -func SetLogPreviewLimit(n int) <span class="cov4" title="9">{ logPreviewLimit = n }</span> +func SetLogPreviewLimit(n int) <span class="cov5" title="11">{ logPreviewLimit = n }</span> // PreviewForLog returns the string truncated to the configured preview limit. -func PreviewForLog(s string) string <span class="cov6" title="32">{ +func PreviewForLog(s string) string <span class="cov7" title="36">{ if logPreviewLimit > 0 </span><span class="cov2" title="3">{ if len(s) <= logPreviewLimit </span><span class="cov0" title="0">{ return s }</span> <span class="cov2" title="3">return s[:logPreviewLimit] + "…"</span> } - <span class="cov6" title="29">return s</span> + <span class="cov6" title="33">return s</span> +} +</pre> + + <pre class="file" id="file22" style="display: none">package lsp + +import ( + "fmt" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/runtimeconfig" +) + +type chatCommandResult struct { + message string +} + +func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) <span class="cov10" title="8">{ + trimmed := strings.TrimSpace(s.stripTrailingTrigger(prompt)) + if trimmed == "" || !strings.HasPrefix(trimmed, "/") </span><span class="cov10" title="8">{ + return chatCommandResult{}, false + }</span> + + <span class="cov0" title="0">switch </span>{ + case strings.HasPrefix(trimmed, "/reload"):<span class="cov0" title="0"> + return s.handleReloadCommand(), true</span> + case strings.HasPrefix(trimmed, "/help"):<span class="cov0" title="0"> + return s.handleHelpCommand(), true</span> + default:<span class="cov0" title="0"> + return chatCommandResult{message: fmt.Sprintf("Unknown command %q. Try /help?>", trimmed)}, true</span> + } +} + +func (s *Server) handleHelpCommand() chatCommandResult <span class="cov1" title="1">{ + lines := []string{ + "Available slash commands:", + "- /reload?> reload configuration from file (ignores env overrides)", + } + return chatCommandResult{message: strings.Join(lines, "\n")} +}</span> + +func (s *Server) handleReloadCommand() chatCommandResult <span class="cov1" title="1">{ + if s.configStore == nil </span><span class="cov0" title="0">{ + return chatCommandResult{message: "Reload unavailable: no config store"} + }</span> + <span class="cov1" title="1">changes, err := s.configStore.Reload(s.logger, appconfig.LoadOptions{IgnoreEnv: true}) + if err != nil </span><span class="cov0" title="0">{ + s.logger.Printf("config reload failed: %v", err) + return chatCommandResult{message: fmt.Sprintf("Reload failed: %v", err)} + }</span> + <span class="cov1" title="1">summary := formatReloadSummary(changes) + s.logger.Print(summary) + return chatCommandResult{message: summary}</span> +} + +func formatReloadSummary(changes []runtimeconfig.Change) string <span class="cov4" title="2">{ + if len(changes) == 0 </span><span class="cov0" title="0">{ + return "Reloaded config (no changes detected)." + }</span> + <span class="cov4" title="2">lines := make([]string, 0, len(changes)+1) + lines = append(lines, fmt.Sprintf("Reloaded config (%d changes):", len(changes))) + for _, ch := range changes </span><span class="cov5" title="3">{ + lines = append(lines, fmt.Sprintf("- %s: %s → %s", ch.Key, ch.Old, ch.New)) + }</span> + <span class="cov4" title="2">return strings.Join(lines, "\n")</span> } </pre> - <pre class="file" id="file22" style="display: none">// Summary: Builds additional context snippets based on configured mode and truncates text by token heuristic. + <pre class="file" id="file23" style="display: none">// Summary: Builds additional context snippets based on configured mode and truncates text by token heuristic. package lsp import ( @@ -3780,20 +3881,20 @@ import ( // - 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) <span class="cov10" title="13">{ - mode := s.contextMode + mode := s.contextMode() switch mode </span>{ case "minimal":<span class="cov3" title="2"> return "", false</span> case "window":<span class="cov1" title="1"> return s.windowContext(uri, pos), true</span> - case "file-on-new-func":<span class="cov7" title="6"> + case "file-on-new-func":<span class="cov8" title="8"> if newFunc </span><span class="cov3" title="2">{ return s.fullFileContext(uri), true }</span> - <span class="cov5" title="4">return "", false</span> + <span class="cov7" title="6">return "", false</span> case "always-full":<span class="cov3" title="2"> return s.fullFileContext(uri), true</span> - default:<span class="cov3" title="2"> + default:<span class="cov0" title="0"> // fallback to minimal if unknown return "", false</span> } @@ -3806,7 +3907,7 @@ func (s *Server) windowContext(uri string, pos Position) string <span class="cov return "" }</span> <span class="cov3" title="2">n := len(d.lines) - half := s.windowLines / 2 + half := s.windowLines() / 2 start := pos.Line - half if start < 0 </span><span class="cov0" title="0">{ start = 0 @@ -3816,7 +3917,7 @@ func (s *Server) windowContext(uri string, pos Position) string <span class="cov end = n }</span> <span class="cov3" title="2">text := strings.Join(d.lines[start:end], "\n") - return truncateToApproxTokens(text, s.maxContextTokens)</span> + return truncateToApproxTokens(text, s.maxContextTokens())</span> } func (s *Server) fullFileContext(uri string) string <span class="cov5" title="4">{ @@ -3825,7 +3926,7 @@ func (s *Server) fullFileContext(uri string) string <span class="cov5" title="4" logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri) return "" }</span> - <span class="cov5" title="4">return truncateToApproxTokens(d.text, s.maxContextTokens)</span> + <span class="cov5" title="4">return truncateToApproxTokens(d.text, s.maxContextTokens())</span> } // truncateToApproxTokens naively truncates the input to fit approx N tokens. @@ -3850,7 +3951,7 @@ func truncateToApproxTokens(text string, maxTokens int) string <span class="cov7 } </pre> - <pre class="file" id="file23" style="display: none">// Summary: In-memory document model for the LSP; tracks text, lines, and applies edits. + <pre class="file" id="file24" style="display: none">// Summary: In-memory document model for the LSP; tracks text, lines, and applies edits. package lsp import ( @@ -3882,14 +3983,14 @@ func (s *Server) markActivity() <span class="cov3" title="4">{ s.mu.Unlock() }</span> -func (s *Server) getDocument(uri string) *document <span class="cov10" title="82">{ +func (s *Server) getDocument(uri string) *document <span class="cov10" title="85">{ s.mu.RLock() defer s.mu.RUnlock() return s.docs[uri] }</span> // splitLines splits the input string into lines, normalizing line endings to '\n'. -func splitLines(sx string) []string <span class="cov9" title="51">{ +func splitLines(sx string) []string <span class="cov8" title="51">{ sx = strings.ReplaceAll(sx, "\r\n", "\n") return strings.Split(sx, "\n") }</span> @@ -3988,16 +4089,16 @@ func trimLen(s string) string <span class="cov8" title="42">{ <span class="cov8" title="41">return s</span> } -func firstLine(s string) string <span class="cov7" title="25">{ +func firstLine(s string) string <span class="cov7" title="26">{ s = strings.ReplaceAll(s, "\r\n", "\n") if idx := strings.IndexByte(s, '\n'); idx >= 0 </span><span class="cov4" title="6">{ return s[:idx] }</span> - <span class="cov7" title="19">return s</span> + <span class="cov7" title="20">return s</span> } </pre> - <pre class="file" id="file24" style="display: none">// Summary: LSP JSON-RPC handlers; implements core methods and integrates with the LLM client when enabled. + <pre class="file" id="file25" style="display: none">// Summary: LSP JSON-RPC handlers; implements core methods and integrates with the LLM client when enabled. package lsp import ( @@ -4050,7 +4151,8 @@ func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned text string } cands := []cand{} - if t, l, r, ok := findStrictInlineTag(line, s.inlineOpenChar, s.inlineCloseChar); ok </span><span class="cov5" title="6">{ + _, _, openChar, closeChar := s.inlineMarkers() + if t, l, r, ok := findStrictInlineTag(line, openChar, closeChar); ok </span><span class="cov5" title="6">{ cands = append(cands, cand{start: l, end: r, text: t}) }</span> <span class="cov9" title="24">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov2" title="2">{ @@ -4187,33 +4289,33 @@ func (s *Server) reply(id json.RawMessage, result any, err *RespError) <span cla // --- small completion cache (last ~10 entries) --- -func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string <span class="cov7" title="13">{ +func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string <span class="cov8" title="14">{ // Normalize left-of-cursor by trimming trailing spaces/tabs idx := p.Position.Character if idx > len(current) </span><span class="cov0" title="0">{ idx = len(current) }</span> - <span class="cov7" title="13">left := strings.TrimRight(current[:idx], " \t") + <span class="cov8" title="14">left := strings.TrimRight(current[:idx], " \t") right := "" if idx < len(current) </span><span class="cov1" title="1">{ right = current[idx:] }</span> - <span class="cov7" title="13">prov := "" + <span class="cov8" title="14">prov := "" model := "" - if s.llmClient != nil </span><span class="cov7" title="13">{ - prov = s.llmClient.Name() - model = s.llmClient.DefaultModel() + if client := s.currentLLMClient(); client != nil </span><span class="cov8" title="14">{ + prov = client.Name() + model = client.DefaultModel() }</span> - <span class="cov7" title="13">temp := "" - if s.codingTemperature != nil </span><span class="cov0" title="0">{ - temp = fmt.Sprintf("%.3f", *s.codingTemperature) + <span class="cov8" title="14">temp := "" + if tempPtr := s.codingTemperature(); tempPtr != nil </span><span class="cov0" title="0">{ + temp = fmt.Sprintf("%.3f", *tempPtr) }</span> - <span class="cov7" title="13">extra := "" + <span class="cov8" title="14">extra := "" if hasExtra </span><span class="cov0" title="0">{ extra = strings.TrimSpace(extraText) }</span> // Compose a key from essential context parts - <span class="cov7" title="13">return strings.Join([]string{ + <span class="cov8" title="14">return strings.Join([]string{ "v1", // version for future-proofing prov, model, @@ -4230,11 +4332,11 @@ func (s *Server) completionCacheKey(p CompletionParams, above, current, below, f }, "\x1f")</span> // use unit separator to avoid collisions } -func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov6" title="9">{ +func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov7" title="10">{ s.mu.Lock() defer s.mu.Unlock() v, ok := s.compCache[key] - if !ok </span><span class="cov6" title="8">{ + if !ok </span><span class="cov6" title="9">{ return "", false }</span> // move to most-recent @@ -4242,13 +4344,13 @@ func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov6 return v, true</span> } -func (s *Server) completionCachePut(key, value string) <span class="cov7" title="11">{ +func (s *Server) completionCachePut(key, value string) <span class="cov7" title="12">{ s.mu.Lock() defer s.mu.Unlock() - if s.compCache == nil </span><span class="cov3" title="3">{ + if s.compCache == nil </span><span class="cov5" title="5">{ s.compCache = make(map[string]string) }</span> - <span class="cov7" title="11">if _, exists := s.compCache[key]; !exists </span><span class="cov7" title="11">{ + <span class="cov7" title="12">if _, exists := s.compCache[key]; !exists </span><span class="cov7" title="12">{ s.compCacheOrder = append(s.compCacheOrder, key) s.compCache[key] = value if len(s.compCacheOrder) > 10 </span><span class="cov0" title="0">{ @@ -4257,7 +4359,7 @@ func (s *Server) completionCachePut(key, value string) <span class="cov7" title= s.compCacheOrder = s.compCacheOrder[1:] delete(s.compCache, old) }</span> - <span class="cov7" title="11">return</span> + <span class="cov7" title="12">return</span> } // update existing and mark most-recent <span class="cov0" title="0">s.compCache[key] = value @@ -4285,6 +4387,8 @@ func (s *Server) compCacheTouchLocked(key string) <span class="cov1" title="1">{ // CompletionContext if provided and also falls back to inspecting the character // immediately to the left of the cursor. func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span class="cov9" title="25">{ + open, _, openChar, closeChar := s.inlineMarkers() + triggerChars := s.triggerCharacters() // 1) Inspect LSP completion context if present if p.Context != nil </span><span class="cov7" title="11">{ var ctx struct { @@ -4299,7 +4403,7 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span c }</span> // If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'), // do not treat as a trigger source. - <span class="cov7" title="11">if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current, s.inlineOpenChar, s.inlineCloseChar) </span><span class="cov2" title="2">{ + <span class="cov7" title="11">if open != "" && strings.Contains(current, open+open) && !hasDoubleOpenTrigger(current, openChar, closeChar) </span><span class="cov2" title="2">{ return false }</span> // TriggerKind 1 = Invoked (manual). Always allow manual invoke. @@ -4309,12 +4413,12 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span c // TriggerKind 2 is TriggerCharacter per LSP spec <span class="cov4" title="4">if ctx.TriggerKind == 2 </span><span class="cov3" title="3">{ if ctx.TriggerCharacter != "" </span><span class="cov2" title="2">{ - for _, c := range s.triggerChars </span><span class="cov1" title="1">{ - if c == ctx.TriggerCharacter </span><span class="cov1" title="1">{ + for _, c := range triggerChars </span><span class="cov2" title="2">{ + if c == ctx.TriggerCharacter </span><span class="cov2" title="2">{ return true }</span> } - <span class="cov1" title="1">return false</span> + <span class="cov0" title="0">return false</span> } // No character provided but reported as TriggerCharacter; be conservative <span class="cov1" title="1">return false</span> @@ -4327,11 +4431,11 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span c return false }</span> // Bare double-open should not trigger via fallback char either (only when configured) - <span class="cov8" title="15">if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current, s.inlineOpenChar, s.inlineCloseChar) </span><span class="cov3" title="3">{ + <span class="cov8" title="15">if open != "" && strings.Contains(current, open+open) && !hasDoubleOpenTrigger(current, openChar, closeChar) </span><span class="cov3" title="3">{ return false }</span> <span class="cov7" title="12">ch := string(current[idx-1]) - for _, c := range s.triggerChars </span><span class="cov9" title="28">{ + for _, c := range triggerChars </span><span class="cov9" title="28">{ if c == ch </span><span class="cov5" title="6">{ return true }</span> @@ -4339,15 +4443,15 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span c <span class="cov5" title="6">return false</span> } -func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem <span class="cov7" title="12">{ +func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem <span class="cov7" title="13">{ te, filter := computeTextEditAndFilter(cleaned, inParams, current, p) rm := s.collectPromptRemovalEdits(p.TextDocument.URI) label := labelForCompletion(cleaned, filter) detail := "Hexai LLM completion" - if s.llmClient != nil </span><span class="cov7" title="12">{ - detail = "Hexai " + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() + if client := s.currentLLMClient(); client != nil </span><span class="cov7" title="13">{ + detail = "Hexai " + client.Name() + ":" + client.DefaultModel() }</span> - <span class="cov7" title="12">return []CompletionItem{{ + <span class="cov7" title="13">return []CompletionItem{{ Label: label, Kind: 1, Detail: detail, @@ -4444,7 +4548,7 @@ func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem <span c }</span> </pre> - <pre class="file" id="file25" style="display: none">// Summary: Code Action handlers and helpers split from handlers.go for clarity. + <pre class="file" id="file26" style="display: none">// Summary: Code Action handlers and helpers split from handlers.go for clarity. package lsp import ( @@ -4469,7 +4573,7 @@ func (s *Server) handleCodeAction(req Request) <span class="cov4" title="5">{ <span class="cov0" title="0">return</span> } <span class="cov4" title="5">d := s.getDocument(p.TextDocument.URI) - if d == nil || len(d.lines) == 0 || s.llmClient == nil </span><span class="cov2" title="2">{ + if d == nil || len(d.lines) == 0 || s.currentLLMClient() == nil </span><span class="cov2" title="2">{ if len(req.ID) != 0 </span><span class="cov2" title="2">{ s.reply(req.ID, []CodeAction{}, nil) }</span> @@ -4502,11 +4606,12 @@ func (s *Server) handleCodeAction(req Request) <span class="cov4" title="5">{ // appendCustomActions adds user-defined actions depending on scope and availability. func (s *Server) appendCustomActions(actions *[]CodeAction, p CodeActionParams, sel string) <span class="cov3" title="3">{ - if len(s.customActions) == 0 </span><span class="cov1" title="1">{ + customs := s.customActions() + if len(customs) == 0 </span><span class="cov1" title="1">{ return }</span> <span class="cov2" title="2">diags := s.diagnosticsInRange(p.Context, p.Range) - for _, ca := range s.customActions </span><span class="cov3" title="4">{ + for _, ca := range customs </span><span class="cov3" title="4">{ title := strings.TrimSpace(ca.Title) if title == "" </span><span class="cov0" title="0">{ continue</span> @@ -4601,7 +4706,7 @@ func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *Cod } func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class="cov6" title="17">{ - if s.llmClient == nil || len(ca.Data) == 0 </span><span class="cov1" title="1">{ + if s.currentLLMClient() == nil || len(ca.Data) == 0 </span><span class="cov1" title="1">{ return ca, false }</span> <span class="cov6" title="16">var payload struct { @@ -4616,25 +4721,14 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class if err := json.Unmarshal(ca.Data, &payload); err != nil </span><span class="cov0" title="0">{ return ca, false }</span> - <span class="cov6" title="16">switch payload.Type </span>{ + <span class="cov6" title="16">cfg := s.currentConfig() + switch payload.Type </span>{ case "rewrite":<span class="cov3" title="4"> - sys := s.promptRewriteSystem - user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection}) - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov3" title="4">{ - if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov3" title="4">{ - edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} - ca.Edit = &edit - return ca, true - }</span> - } else<span class="cov0" title="0"> { - logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) - }</span> + sys := cfg.PromptCodeActionRewriteSystem + user := renderTemplate(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection}) + return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)</span> case "diagnostics":<span class="cov4" title="5"> - sys := s.promptDiagnosticsSystem + sys := cfg.PromptCodeActionDiagnosticsSystem var b strings.Builder for i, dgn := range payload.Diagnostics </span><span class="cov4" title="6">{ if dgn.Source != "" </span><span class="cov0" title="0">{ @@ -4644,115 +4738,73 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class }</span> } <span class="cov4" title="5">diagList := b.String() - user := renderTemplate(s.promptDiagnosticsUser, map[string]string{"diagnostics": diagList, "selection": payload.Selection}) - ctx, cancel := context.WithTimeout(context.Background(), 22*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov4" title="5">{ - if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov4" title="5">{ - edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} - ca.Edit = &edit - return ca, true - }</span> - } else<span class="cov0" title="0"> { - logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err) - }</span> + user := renderTemplate(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": diagList, "selection": payload.Selection}) + return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 22*time.Second)</span> case "document":<span class="cov3" title="3"> - sys := s.promptDocumentSystem - user := renderTemplate(s.promptDocumentUser, map[string]string{"selection": payload.Selection}) - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov3" title="3">{ - if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov3" title="3">{ - edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} - ca.Edit = &edit - return ca, true - }</span> - } else<span class="cov0" title="0"> { - logging.Logf("lsp ", "codeAction document llm error: %v", err) - }</span> + sys := cfg.PromptCodeActionDocumentSystem + user := renderTemplate(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": payload.Selection}) + return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)</span> case "go_test":<span class="cov0" title="0"> if edit, jumpURI, jumpRange, ok := s.resolveGoTest(payload.URI, payload.Range.Start); ok </span><span class="cov0" title="0">{ ca.Edit = &edit - // After edit is applied, ask client to jump to new test function ca.Command = &Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}} - // Also send a server-initiated showDocument shortly after resolve to cover - // clients that do not execute commands from code actions. s.deferShowDocument(jumpURI, jumpRange) return ca, true }</span> case "simplify":<span class="cov0" title="0"> - sys := s.promptRewriteSystem - // Reuse rewrite user template with a fixed instruction - user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": "Simplify and improve the code while preserving behavior. Return only the improved code.", "selection": payload.Selection}) - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov0" title="0">{ - if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov0" title="0">{ - edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} - ca.Edit = &edit - return ca, true - }</span> - } else<span class="cov0" title="0"> { - logging.Logf("lsp ", "codeAction simplify llm error: %v", err) - }</span> + sys := cfg.PromptCodeActionRewriteSystem + user := renderTemplate(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": "Simplify and improve the code while preserving behavior. Return only the improved code.", "selection": payload.Selection}) + return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)</span> case "custom":<span class="cov3" title="4"> - // Lookup action by ID var action *CustomAction - for i := range s.customActions </span><span class="cov4" title="5">{ - if s.customActions[i].ID == payload.ID </span><span class="cov3" title="4">{ - action = &s.customActions[i] + for _, caDef := range s.customActions() </span><span class="cov4" title="5">{ + if caDef.ID == payload.ID </span><span class="cov3" title="4">{ + action = &caDef break</span> } } <span class="cov3" title="4">if action == nil </span><span class="cov0" title="0">{ return ca, false }</span> - // Build messages <span class="cov3" title="4">var sys, user string if strings.TrimSpace(action.User) != "" </span><span class="cov1" title="1">{ if strings.TrimSpace(action.System) != "" </span><span class="cov0" title="0">{ sys = action.System }</span> else<span class="cov1" title="1"> { - sys = s.promptRewriteSystem + sys = cfg.PromptCodeActionRewriteSystem }</span> <span class="cov1" title="1">var diagList string if len(payload.Diagnostics) > 0 </span><span class="cov1" title="1">{ var b strings.Builder - for i, dgn := range payload.Diagnostics </span><span class="cov1" title="1">{ - if dgn.Source != "" </span><span class="cov0" title="0">{ - fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message) - }</span> else<span class="cov1" title="1"> { - fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message) - }</span> - } + for _, d := range payload.Diagnostics </span><span class="cov1" title="1">{ + fmt.Fprintf(&b, "%s\n", d.Message) + }</span> <span class="cov1" title="1">diagList = b.String()</span> } - <span class="cov1" title="1">user = renderTemplate(action.User, map[string]string{"selection": payload.Selection, "diagnostics": diagList})</span> + <span class="cov1" title="1">user = renderTemplate(action.User, map[string]string{"selection": payload.Selection, "diagnostics": strings.TrimSpace(diagList)})</span> } else<span class="cov3" title="3"> { - // Use rewrite templates with fixed instruction - sys = s.promptRewriteSystem - user = renderTemplate(s.promptRewriteUser, map[string]string{"instruction": action.Instruction, "selection": payload.Selection}) - }</span> - <span class="cov3" title="4">ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov3" title="3">{ - if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov2" title="2">{ - edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} - ca.Edit = &edit - return ca, true - }</span> - } else<span class="cov1" title="1"> { - logging.Logf("lsp ", "codeAction custom id=%s llm error: %v", action.ID, err) + sys = cfg.PromptCodeActionRewriteSystem + user = renderTemplate(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection}) }</span> + <span class="cov3" title="4">return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)</span> } + <span class="cov0" title="0">return ca, false</span> +} + +func (s *Server) completeCodeAction(ca CodeAction, uri string, rng Range, sys, user string, timeout time.Duration) (CodeAction, bool) <span class="cov6" title="16">{ + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + opts := s.llmRequestOpts() + if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov6" title="15">{ + if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov6" title="14">{ + edit := WorkspaceEdit{Changes: map[string][]TextEdit{uri: {{Range: rng, NewText: out}}}} + ca.Edit = &edit + return ca, true + }</span> + } else<span class="cov1" title="1"> { + logging.Logf("lsp ", "codeAction llm error: %v", err) + }</span> <span class="cov2" title="2">return ca, false</span> } @@ -4856,7 +4908,7 @@ func (s *Server) buildGoUnitTestCodeAction(p CodeActionParams) *CodeAction <span // buildDocumentCodeAction offers to document the selected code by injecting comments. func (s *Server) buildDocumentCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov3" title="4">{ - if s.llmClient == nil </span><span class="cov0" title="0">{ + if s.currentLLMClient() == nil </span><span class="cov0" title="0">{ return nil }</span> <span class="cov3" title="4">if strings.TrimSpace(sel) == "" </span><span class="cov1" title="1">{ @@ -5053,9 +5105,10 @@ func findGoFunctionAtLine(lines []string, idx int) (int, int) <span class="cov3" // generateGoTestFunction uses LLM to produce a test function; falls back to a stub when unavailable. func (s *Server) generateGoTestFunction(funcCode string) string <span class="cov3" title="4">{ - if s.llmClient != nil </span><span class="cov2" title="2">{ - sys := s.promptGoTestSystem - user := renderTemplate(s.promptGoTestUser, map[string]string{"function": funcCode}) + if client := s.currentLLMClient(); client != nil </span><span class="cov2" title="2">{ + cfg := s.currentConfig() + sys := cfg.PromptCodeActionGoTestSystem + user := renderTemplate(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode}) ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} @@ -5112,7 +5165,7 @@ func exportName(name string) string <span class="cov2" title="2">{ } </pre> - <pre class="file" id="file26" style="display: none">// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic. + <pre class="file" id="file27" style="display: none">// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic. package lsp import ( @@ -5205,15 +5258,15 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun defer cancel() plan, items, handled := s.prepareCompletionPlan(p, above, current, below, funcCtx, docStr, hasExtra, extraText) - if handled </span><span class="cov6" title="10">{ + if handled </span><span class="cov6" title="9">{ return items, true }</span> - <span class="cov6" title="8">if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, plan.inParams); ok </span><span class="cov1" title="1">{ + <span class="cov6" title="9">if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, plan.inParams); ok </span><span class="cov1" title="1">{ return items, true }</span> - <span class="cov5" title="7">return s.executeChatCompletion(ctx, plan)</span> + <span class="cov6" title="8">return s.executeChatCompletion(ctx, plan)</span> } func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) (completionPlan, []CompletionItem, bool) <span class="cov8" title="18">{ @@ -5227,15 +5280,16 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below hasExtra: hasExtra, extraText: extraText, } - plan.inlinePrompt = lineHasInlinePrompt(current, s.inlineOpenChar, s.inlineCloseChar) - if !plan.inlinePrompt && !s.isTriggerEvent(p, current) </span><span class="cov6" title="9">{ + _, _, openChar, closeChar := s.inlineMarkers() + plan.inlinePrompt = lineHasInlinePrompt(current, openChar, closeChar) + if !plan.inlinePrompt && !s.isTriggerEvent(p, current) </span><span class="cov6" title="8">{ logging.Logf("lsp ", "%scompletion skip=no-trigger line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) return plan, []CompletionItem{}, true }</span> - <span class="cov6" title="9">if s.shouldSuppressForChatTriggerEOL(current, p) </span><span class="cov0" title="0">{ + <span class="cov6" title="10">if s.shouldSuppressForChatTriggerEOL(current, p) </span><span class="cov0" title="0">{ return plan, []CompletionItem{}, true }</span> - <span class="cov6" title="9">plan.inParams = inParamList(current, p.Position.Character) + <span class="cov6" title="10">plan.inParams = inParamList(current, p.Position.Character) plan.manualInvoke = parseManualInvoke(p.Context) plan.cacheKey = s.completionCacheKey(p, above, current, below, funcCtx, plan.inParams, hasExtra, extraText) if cleaned, ok := s.completionCacheGet(plan.cacheKey); ok && strings.TrimSpace(cleaned) != "" </span><span class="cov1" title="1">{ @@ -5244,108 +5298,107 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase) return plan, s.makeCompletionItems(cleaned, plan.inParams, current, p, docStr), true }</span> - <span class="cov6" title="8">if isBareDoubleOpen(current, s.inlineOpenChar, s.inlineCloseChar) || isBareDoubleOpen(below, s.inlineOpenChar, s.inlineCloseChar) </span><span class="cov0" title="0">{ + <span class="cov6" title="9">if isBareDoubleOpen(current, openChar, closeChar) || isBareDoubleOpen(below, openChar, closeChar) </span><span class="cov0" title="0">{ logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) return plan, []CompletionItem{}, true }</span> - <span class="cov6" title="8">if !plan.inParams && !s.prefixHeuristicAllows(plan.inlinePrompt, current, p, plan.manualInvoke) </span><span class="cov0" title="0">{ + <span class="cov6" title="9">if !plan.inParams && !s.prefixHeuristicAllows(plan.inlinePrompt, current, p, plan.manualInvoke) </span><span class="cov0" title="0">{ logging.Logf("lsp ", "%scompletion skip=short-prefix line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) return plan, []CompletionItem{}, true }</span> - <span class="cov6" title="8">return plan, nil, false</span> + <span class="cov6" title="9">return plan, nil, false</span> } -func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan) ([]CompletionItem, bool) <span class="cov5" title="7">{ +func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan) ([]CompletionItem, bool) <span class="cov6" title="8">{ messages := s.buildCompletionMessages(plan.inlinePrompt, plan.hasExtra, plan.extraText, plan.inParams, plan.params, plan.above, plan.current, plan.below, plan.funcCtx) sentSize := 0 - for _, m := range messages </span><span class="cov7" title="14">{ + for _, m := range messages </span><span class="cov7" title="16">{ sentSize += len(m.Content) }</span> - <span class="cov5" title="7">s.incSentCounters(sentSize) - opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} - if s.codingTemperature != nil </span><span class="cov0" title="0">{ - opts = append(opts, llm.WithTemperature(*s.codingTemperature)) - }</span> - <span class="cov5" title="7">s.waitForDebounce(ctx) + <span class="cov6" title="8">s.incSentCounters(sentSize) + opts := s.llmRequestOpts() + s.waitForDebounce(ctx) if !s.waitForThrottle(ctx) </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov5" title="7">if s.llmClient == nil </span><span class="cov0" title="0">{ + <span class="cov6" title="8">client := s.currentLLMClient() + if client == nil </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov5" title="7">logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel()) - text, err := s.llmClient.Chat(ctx, messages, opts...) + <span class="cov6" title="8">logging.Logf("lsp ", "completion llm=requesting model=%s", client.DefaultModel()) + text, err := client.Chat(ctx, messages, opts...) if err != nil </span><span class="cov0" title="0">{ logging.Logf("lsp ", "llm completion error: %v", err) s.logLLMStats() return nil, false }</span> - <span class="cov5" title="7">s.incRecvCounters(len(text)) + <span class="cov6" title="8">s.incRecvCounters(len(text)) s.logLLMStats() trimmed := strings.TrimSpace(text) cleaned := s.postProcessCompletion(trimmed, plan.current[:plan.params.Position.Character], plan.current) if cleaned == "" </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov5" title="7">s.completionCachePut(plan.cacheKey, cleaned) + <span class="cov6" title="8">s.completionCachePut(plan.cacheKey, cleaned) items := s.makeCompletionItems(cleaned, plan.inParams, plan.current, plan.params, plan.docStr) return items, true</span> } // parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion. -func parseManualInvoke(ctx any) bool <span class="cov6" title="10">{ +func parseManualInvoke(ctx any) bool <span class="cov6" title="11">{ if ctx == nil </span><span class="cov4" title="5">{ return false }</span> - <span class="cov4" title="5">var c struct { + <span class="cov5" title="6">var c struct { TriggerKind int `json:"triggerKind"` } if raw, ok := ctx.(json.RawMessage); ok </span><span class="cov4" title="5">{ _ = json.Unmarshal(raw, &c) - }</span> else<span class="cov0" title="0"> { + }</span> else<span class="cov1" title="1"> { b, _ := json.Marshal(ctx) _ = json.Unmarshal(b, &c) }</span> - <span class="cov4" title="5">return c.TriggerKind == 1</span> + <span class="cov5" title="6">return c.TriggerKind == 1</span> } // shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. -func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool <span class="cov7" title="14">{ +func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool <span class="cov7" title="15">{ t := strings.TrimRight(current, " \t") - if s.chatSuffix == "" </span><span class="cov1" title="1">{ + suffix, prefixes, _ := s.chatConfig() + if suffix == "" </span><span class="cov1" title="1">{ return false }</span> - <span class="cov7" title="13">if strings.HasSuffix(t, s.chatSuffix) </span><span class="cov4" title="4">{ - if len(t) < len(s.chatSuffix)+1 </span><span class="cov0" title="0">{ + <span class="cov7" title="14">if strings.HasSuffix(t, suffix) </span><span class="cov4" title="4">{ + if len(t) < len(suffix)+1 </span><span class="cov0" title="0">{ return false }</span> - <span class="cov4" title="4">prev := string(t[len(t)-len(s.chatSuffix)-1]) - for _, pf := range s.chatPrefixes </span><span class="cov6" title="10">{ + <span class="cov4" title="4">prev := string(t[len(t)-len(suffix)-1]) + for _, pf := range prefixes </span><span class="cov6" title="10">{ if prev == pf </span><span class="cov2" title="2">{ logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) return true }</span> } } - <span class="cov6" title="11">return false</span> + <span class="cov7" title="12">return false</span> } // prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply. -func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p CompletionParams, manualInvoke bool) bool <span class="cov7" title="13">{ +func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p CompletionParams, manualInvoke bool) bool <span class="cov7" title="14">{ // Determine the effective cursor index within current line, clamped, and // skip over trailing spaces/tabs to support cases like "type Matrix| ". idx := p.Position.Character if idx > len(current) </span><span class="cov0" title="0">{ idx = len(current) }</span> - <span class="cov7" title="13">allowNoPrefix := inlinePrompt - if idx > 0 </span><span class="cov6" title="11">{ + <span class="cov7" title="14">allowNoPrefix := inlinePrompt + if idx > 0 </span><span class="cov7" title="12">{ ch := current[idx-1] - if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' </span><span class="cov4" title="4">{ + if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' </span><span class="cov4" title="5">{ allowNoPrefix = true }</span> } - <span class="cov7" title="13">if allowNoPrefix </span><span class="cov5" title="6">{ + <span class="cov7" title="14">if allowNoPrefix </span><span class="cov5" title="7">{ return true }</span> // Walk left over whitespace @@ -5360,35 +5413,40 @@ func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p Comp } <span class="cov5" title="7">start := computeWordStart(current, j) min := 1 - if manualInvoke && s.manualInvokeMinPrefix >= 0 </span><span class="cov4" title="5">{ - min = s.manualInvokeMinPrefix - }</span> + if manualInvoke </span><span class="cov4" title="5">{ + if v := s.manualInvokeMinPrefix(); v >= 0 </span><span class="cov4" title="5">{ + min = v + }</span> + } <span class="cov5" title="7">return j-start >= min</span> } // 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) <span class="cov6" title="11">{ - cc, ok := s.llmClient.(llm.CodeCompleter) +func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) <span class="cov7" title="12">{ + client := s.currentLLMClient() + cc, ok := client.(llm.CodeCompleter) if !ok </span><span class="cov5" title="6">{ return nil, false }</span> - <span class="cov4" title="5">before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) + <span class="cov5" title="6">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{ + cfg := s.currentConfig() + _, _, openChar, closeChar := s.inlineMarkers() + prompt := renderTemplate(cfg.PromptNativeCompletion, map[string]string{ "path": path, "before": before, }) lang := "" temp := 0.0 - if s.codingTemperature != nil </span><span class="cov0" title="0">{ - temp = *s.codingTemperature + if cfg.CodingTemperature != nil </span><span class="cov0" title="0">{ + temp = *cfg.CodingTemperature }</span> - <span class="cov4" title="5">prov := "" - if s.llmClient != nil </span><span class="cov4" title="5">{ - prov = s.llmClient.Name() + <span class="cov5" title="6">prov := "" + if client != nil </span><span class="cov5" title="6">{ + prov = client.Name() }</span> - <span class="cov4" title="5">logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) + <span class="cov5" title="6">logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second) defer cancel2() @@ -5398,15 +5456,15 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, return nil, false }</span> // Count approximate payload sizes: prompt+after sent; first suggestion received - <span class="cov4" title="5">sentBytes := len(prompt) + len(after) + <span class="cov5" title="6">sentBytes := len(prompt) + len(after) suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) if err == nil && len(suggestions) > 0 </span><span class="cov4" title="4">{ // Update counters and heartbeat s.incSentCounters(sentBytes) s.incRecvCounters(len(suggestions[0])) // Contribute to global stats (provider-native path) - if s.llmClient != nil </span><span class="cov4" title="4">{ - _ = stats.Update(ctx2, s.llmClient.Name(), s.llmClient.DefaultModel(), sentBytes, len(suggestions[0])) + if client != nil </span><span class="cov4" title="4">{ + _ = stats.Update(ctx2, client.Name(), client.DefaultModel(), sentBytes, len(suggestions[0])) }</span> <span class="cov4" title="4">s.logLLMStats() cleaned := strings.TrimSpace(suggestions[0]) @@ -5415,7 +5473,7 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, if cleaned != "" </span><span class="cov4" title="4">{ cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) }</span> - <span class="cov4" title="4">if cleaned != "" && hasDoubleOpenTrigger(current, s.inlineOpenChar, s.inlineCloseChar) </span><span class="cov1" title="1">{ + <span class="cov4" title="4">if cleaned != "" && hasDoubleOpenTrigger(current, openChar, closeChar) </span><span class="cov1" title="1">{ indent := leadingIndent(current) if indent != "" </span><span class="cov1" title="1">{ cleaned = applyIndent(indent, cleaned) @@ -5427,20 +5485,20 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true }</span> } - } else<span class="cov1" title="1"> if err != nil </span><span class="cov1" title="1">{ + } else<span class="cov2" title="2"> if err != nil </span><span class="cov2" title="2">{ logging.Logf("lsp ", "completion path=codex error=%v (falling back to chat)", err) // Still emit a heartbeat for visibility, even on error s.incSentCounters(sentBytes) s.logLLMStats() }</span> - <span class="cov1" title="1">return nil, false</span> + <span class="cov2" title="2">return nil, false</span> } // 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) <span class="cov10" title="39">{ - d := s.completionDebounce - if d <= 0 </span><span class="cov9" title="37">{ +func (s *Server) waitForDebounce(ctx context.Context) <span class="cov10" title="41">{ + d := s.completionDebounce() + if d <= 0 </span><span class="cov9" title="39">{ return }</span> <span class="cov2" title="2">for </span><span class="cov4" title="4">{ @@ -5468,9 +5526,9 @@ func (s *Server) waitForDebounce(ctx context.Context) <span class="cov10" title= // waitForThrottle enforces a minimum spacing between LLM calls. Returns false // if the context is canceled while waiting. -func (s *Server) waitForThrottle(ctx context.Context) bool <span class="cov10" title="39">{ - interval := s.throttleInterval - if interval <= 0 </span><span class="cov9" title="36">{ +func (s *Server) waitForThrottle(ctx context.Context) bool <span class="cov10" title="41">{ + interval := s.completionThrottle() + if interval <= 0 </span><span class="cov9" title="38">{ return true }</span> <span class="cov3" title="3">var wait time.Duration @@ -5499,8 +5557,7 @@ func (s *Server) waitForThrottle(ctx context.Context) bool <span class="cov10" t } // buildCompletionMessages constructs the LLM messages for completion. -func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message <span class="cov7" title="13">{ - // Vars for templates +func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message <span class="cov7" title="14">{ vars := map[string]string{ "file": p.TextDocument.URI, "function": funcCtx, @@ -5509,51 +5566,53 @@ func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText "below": below, "char": fmt.Sprintf("%d", p.Position.Character), } - sys := s.promptCompSysGeneral - userTpl := s.promptCompUserGeneral + cfg := s.currentConfig() + sys := cfg.PromptCompletionSystemGeneral + userTpl := cfg.PromptCompletionUserGeneral if inParams </span><span class="cov2" title="2">{ - sys = s.promptCompSysParams - userTpl = s.promptCompUserParams + sys = cfg.PromptCompletionSystemParams + userTpl = cfg.PromptCompletionUserParams }</span> - <span class="cov7" title="13">if inlinePrompt && strings.TrimSpace(s.promptCompSysInline) != "" </span><span class="cov1" title="1">{ - sys = s.promptCompSysInline + <span class="cov7" title="14">if inlinePrompt && strings.TrimSpace(cfg.PromptCompletionSystemInline) != "" </span><span class="cov2" title="2">{ + sys = cfg.PromptCompletionSystemInline }</span> - <span class="cov7" title="13">user := renderTemplate(userTpl, vars) + <span class="cov7" title="14">user := renderTemplate(userTpl, vars) messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} if hasExtra && strings.TrimSpace(extraText) != "" </span><span class="cov1" title="1">{ - extra := renderTemplate(s.promptCompExtraHeader, map[string]string{"context": extraText}) + extra := renderTemplate(cfg.PromptCompletionExtraHeader, map[string]string{"context": extraText}) if strings.TrimSpace(extra) == "" </span><span class="cov0" title="0">{ extra = extraText }</span> <span class="cov1" title="1">messages = append(messages, llm.Message{Role: "user", Content: extra})</span> } - <span class="cov7" title="13">return messages</span> + <span class="cov7" title="14">return messages</span> } // postProcessCompletion normalizes and deduplicates completion text and applies indentation rules. -func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string <span class="cov6" title="10">{ +func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string <span class="cov6" title="11">{ cleaned := stripCodeFences(text) if cleaned != "" && strings.ContainsRune(cleaned, '`') </span><span class="cov0" title="0">{ if inline := stripInlineCodeSpan(cleaned); strings.TrimSpace(inline) != "" </span><span class="cov0" title="0">{ cleaned = inline }</span> } - <span class="cov6" title="10">if cleaned != "" </span><span class="cov6" title="10">{ + <span class="cov6" title="11">if cleaned != "" </span><span class="cov6" title="11">{ cleaned = stripDuplicateAssignmentPrefix(leftOfCursor, cleaned) }</span> - <span class="cov6" title="10">if cleaned != "" </span><span class="cov6" title="10">{ + <span class="cov6" title="11">if cleaned != "" </span><span class="cov6" title="11">{ cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) }</span> - <span class="cov6" title="10">if cleaned != "" && hasDoubleOpenTrigger(currentLine, s.inlineOpenChar, s.inlineCloseChar) </span><span class="cov1" title="1">{ + <span class="cov6" title="11">_, _, openChar, closeChar := s.inlineMarkers() + if cleaned != "" && hasDoubleOpenTrigger(currentLine, openChar, closeChar) </span><span class="cov1" title="1">{ if indent := leadingIndent(currentLine); indent != "" </span><span class="cov1" title="1">{ cleaned = applyIndent(indent, cleaned) }</span> } - <span class="cov6" title="10">return cleaned</span> + <span class="cov6" title="11">return cleaned</span> } </pre> - <pre class="file" id="file27" style="display: none">// Summary: Document open/change/close and in-editor chat handlers split out of handlers.go. + <pre class="file" id="file28" style="display: none">// Summary: Document open/change/close and in-editor chat handlers split out of handlers.go. package lsp import ( @@ -5597,42 +5656,42 @@ func (s *Server) handleDidClose(req Request) <span class="cov1" title="1">{ // 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) <span class="cov6" title="7">{ +func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) <span class="cov7" title="8">{ d := s.getDocument(uri) if d == nil </span><span class="cov5" title="4">{ return "", "" }</span> // Clamp indices - <span class="cov4" title="3">line := pos.Line + <span class="cov5" title="4">line := pos.Line if line < 0 </span><span class="cov0" title="0">{ line = 0 }</span> - <span class="cov4" title="3">if line >= len(d.lines) </span><span class="cov1" title="1">{ + <span class="cov5" title="4">if line >= len(d.lines) </span><span class="cov1" title="1">{ line = len(d.lines) - 1 }</span> - <span class="cov4" title="3">col := pos.Character + <span class="cov5" title="4">col := pos.Character if col < 0 </span><span class="cov0" title="0">{ col = 0 }</span> - <span class="cov4" title="3">if col > len(d.lines[line]) </span><span class="cov1" title="1">{ + <span class="cov5" title="4">if col > len(d.lines[line]) </span><span class="cov1" title="1">{ col = len(d.lines[line]) }</span> // Build before - <span class="cov4" title="3">var b strings.Builder - for i := 0; i < line; i++ </span><span class="cov4" title="3">{ + <span class="cov5" title="4">var b strings.Builder + for i := 0; i < line; i++ </span><span class="cov5" title="5">{ b.WriteString(d.lines[i]) b.WriteByte('\n') }</span> - <span class="cov4" title="3">b.WriteString(d.lines[line][:col]) + <span class="cov5" title="4">b.WriteString(d.lines[line][:col]) before := b.String() // Build after var a strings.Builder a.WriteString(d.lines[line][col:]) - for i := line + 1; i < len(d.lines); i++ </span><span class="cov3" title="2">{ + for i := line + 1; i < len(d.lines); i++ </span><span class="cov5" title="4">{ a.WriteByte('\n') a.WriteString(d.lines[i]) }</span> - <span class="cov4" title="3">return before, a.String()</span> + <span class="cov5" title="4">return before, a.String()</span> } // --- in-editor chat (";C ...") --- @@ -5641,32 +5700,30 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) <span // a new trigger pair (e.g., "?>" ",>" ":>" ";>") at EOL and inserts the LLM // reply below. func (s *Server) detectAndHandleChat(uri string) <span class="cov7" title="10">{ - if s.llmClient == nil </span><span class="cov1" title="1">{ - return - }</span> - <span class="cov7" title="9">d := s.getDocument(uri) + d := s.getDocument(uri) if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ return }</span> - <span class="cov7" title="9">for i, raw := range d.lines </span><span class="cov10" title="20">{ + <span class="cov7" title="10">suffix, prefixes, _ := s.chatConfig() + for i, raw := range d.lines </span><span class="cov10" title="22">{ // Find last non-space character index j := len(raw) - 1 - for j >= 0 </span><span class="cov9" title="18">{ + for j >= 0 </span><span class="cov9" title="19">{ if raw[j] == ' ' || raw[j] == '\t' </span><span class="cov0" title="0">{ j-- continue</span> } - <span class="cov9" title="18">break</span> + <span class="cov9" title="19">break</span> } - <span class="cov10" title="20">if j < 0 </span><span class="cov3" title="2">{ + <span class="cov10" title="22">if j < 0 </span><span class="cov4" title="3">{ continue</span> } // Check suffix/prefix according to configuration - <span class="cov9" title="18">if s.chatSuffix == "" </span><span class="cov0" title="0">{ + <span class="cov9" title="19">if suffix == "" </span><span class="cov0" title="0">{ continue</span> } // Last non-space must equal suffix - <span class="cov9" title="18">if string(raw[j]) != s.chatSuffix </span><span class="cov7" title="9">{ + <span class="cov9" title="19">if string(raw[j]) != suffix </span><span class="cov7" title="10">{ continue</span> } // Require at least one char before suffix and that char must be in chatPrefixes @@ -5675,7 +5732,7 @@ func (s *Server) detectAndHandleChat(uri string) <span class="cov7" title="10">{ } <span class="cov7" title="9">prev := string(raw[j-1]) isTrigger := false - for _, pfx := range s.chatPrefixes </span><span class="cov7" title="9">{ + for _, pfx := range prefixes </span><span class="cov7" title="9">{ if prev == pfx </span><span class="cov7" title="9">{ isTrigger = true break</span> @@ -5693,7 +5750,7 @@ func (s *Server) detectAndHandleChat(uri string) <span class="cov7" title="10">{ continue</span> } // Derive prompt by removing only the trailing '>' - <span class="cov7" title="8">removeCount := len(s.chatSuffix) + <span class="cov7" title="8">removeCount := len(suffix) base := raw[:j+1-removeCount] prompt := strings.TrimSpace(base) if prompt == "" </span><span class="cov0" title="0">{ @@ -5701,14 +5758,28 @@ func (s *Server) detectAndHandleChat(uri string) <span class="cov7" title="10">{ } <span class="cov7" title="8">lineIdx := i lastIdx := j - go func(prompt string, remove int) </span><span class="cov7" title="8">{ + if resp, ok := s.chatCommandResponse(uri, lineIdx, prompt); ok </span><span class="cov0" title="0">{ + msg := strings.TrimSpace(resp.message) + if msg != "" </span><span class="cov0" title="0">{ + s.applyChatEdits(uri, lineIdx, lastIdx, removeCount, "> "+msg) + }</span> + <span class="cov0" title="0">return</span> + } + <span class="cov7" title="8">if s.currentLLMClient() == nil </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov7" title="8">go func(prompt string, remove int) </span><span class="cov7" title="8">{ ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() // 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()) + client := s.currentLLMClient() + if client == nil </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov7" title="8">logging.Logf("lsp ", "chat llm=requesting model=%s", client.DefaultModel()) text, err := s.chatWithStats(ctx, msgs, opts...) if err != nil </span><span class="cov0" title="0">{ logging.Logf("lsp ", "chat llm error: %v", err) @@ -5802,24 +5873,25 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) } // stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. -func (s *Server) stripTrailingTrigger(sx string) string <span class="cov7" title="8">{ +func (s *Server) stripTrailingTrigger(sx string) string <span class="cov9" title="16">{ trim := strings.TrimRight(sx, " \t") if len(trim) == 0 </span><span class="cov0" title="0">{ return sx }</span> - <span class="cov7" title="8">if len(trim) >= 2 && s.chatSuffixChar != 0 && trim[len(trim)-1] == s.chatSuffixChar </span><span class="cov5" title="5">{ + <span class="cov9" title="16">_, prefixes, suffixChar := s.chatConfig() + if len(trim) >= 2 && suffixChar != 0 && trim[len(trim)-1] == suffixChar </span><span class="cov5" title="5">{ prev := string(trim[len(trim)-2]) - for _, pf := range s.chatPrefixes </span><span class="cov8" title="11">{ + for _, pf := range prefixes </span><span class="cov7" title="11">{ if prev == pf </span><span class="cov5" title="5">{ return strings.TrimRight(trim[:len(trim)-1], " \t") }</span> } } - <span class="cov4" title="3">last := trim[len(trim)-1] + <span class="cov7" title="11">last := trim[len(trim)-1] switch last </span>{ - case '?', '!', ':':<span class="cov1" title="1"> + case '?', '!', ':':<span class="cov7" title="8"> return strings.TrimRight(trim[:len(trim)-1], " \t")</span> - default:<span class="cov3" title="2"> + default:<span class="cov4" title="3"> return sx</span> } } @@ -5830,7 +5902,8 @@ func (s *Server) stripTrailingTrigger(sx string) string <span class="cov7" title // - optional extra context per general.context_mode (window/full-file/new-func) func (s *Server) buildChatMessages(uri string, pos Position, prompt string) []llm.Message <span class="cov7" title="8">{ // Base system and history - sys := s.promptChatSystem + cfg := s.currentConfig() + sys := cfg.PromptChatSystem // Determine line index for history from position lineIdx := pos.Line history := s.buildChatHistory(uri, lineIdx, prompt) @@ -5840,7 +5913,7 @@ func (s *Server) buildChatMessages(uri string, pos Position, prompt string) []ll newFunc := s.isDefiningNewFunction(uri, pos) if extra, has := s.buildAdditionalContext(newFunc, uri, pos); has && strings.TrimSpace(extra) != "" </span><span class="cov4" title="3">{ // Reuse completion's extra header template to avoid duplication - header := renderTemplate(s.promptCompExtraHeader, map[string]string{"context": extra}) + header := renderTemplate(cfg.PromptCompletionExtraHeader, map[string]string{"context": extra}) if strings.TrimSpace(header) == "" </span><span class="cov0" title="0">{ header = extra }</span> @@ -5862,7 +5935,7 @@ func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) <span class=" }</span> // nextReqID returns a unique json.RawMessage id for server-initiated requests. -func (s *Server) nextReqID() json.RawMessage <span class="cov8" title="11">{ +func (s *Server) nextReqID() json.RawMessage <span class="cov7" title="11">{ s.mu.Lock() s.nextID++ idNum := s.nextID @@ -5899,7 +5972,7 @@ func (s *Server) deferShowDocument(uri string, sel Range) <span class="cov1" tit } </pre> - <pre class="file" id="file28" style="display: none">// Summary: ExecuteCommand handler to support post-edit navigation (jump to generated test). + <pre class="file" id="file29" style="display: none">// Summary: ExecuteCommand handler to support post-edit navigation (jump to generated test). package lsp import ( @@ -5935,7 +6008,7 @@ func (s *Server) handleExecuteCommand(req Request) <span class="cov8" title="1"> } </pre> - <pre class="file" id="file29" style="display: none">// Summary: Initialization and lifecycle handlers split from handlers.go. + <pre class="file" id="file30" style="display: none">// Summary: Initialization and lifecycle handlers split from handlers.go. package lsp import ( @@ -5947,16 +6020,17 @@ import ( ) func (s *Server) handleInitialize(req Request) <span class="cov10" title="2">{ + client := s.currentLLMClient() version := internal.Version - if s.llmClient != nil </span><span class="cov0" title="0">{ - version = version + " [" + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() + "]" + if client != nil </span><span class="cov0" title="0">{ + version = version + " [" + client.Name() + ":" + client.DefaultModel() + "]" }</span> <span class="cov10" title="2">res := InitializeResult{ Capabilities: ServerCapabilities{ TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull CompletionProvider: &CompletionOptions{ ResolveProvider: false, - TriggerCharacters: s.triggerChars, + TriggerCharacters: s.triggerCharacters(), }, CodeActionProvider: CodeActionOptions{ResolveProvider: true}, }, @@ -5968,8 +6042,8 @@ func (s *Server) handleInitialize(req Request) <span class="cov10" title="2">{ func (s *Server) handleInitialized() <span class="cov1" title="1">{ logging.Logf("lsp ", "client initialized") // Emit an initial tmux heartbeat with provider/model - if s.llmClient != nil </span><span class="cov0" title="0">{ - _ = tmx.SetStatus(tmx.FormatLLMStartStatus(s.llmClient.Name(), s.llmClient.DefaultModel())) + if client := s.currentLLMClient(); client != nil </span><span class="cov0" title="0">{ + _ = tmx.SetStatus(tmx.FormatLLMStartStatus(client.Name(), client.DefaultModel())) }</span> } @@ -5983,11 +6057,12 @@ func (s *Server) handleExit() <span class="cov0" title="0">{ }</span> </pre> - <pre class="file" id="file30" style="display: none">// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters). + <pre class="file" id="file31" style="display: none">// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters). package lsp import ( "context" + "fmt" "strings" "time" @@ -5999,83 +6074,88 @@ import ( ) // llmRequestOpts builds request options from server settings. -func (s *Server) llmRequestOpts() []llm.RequestOption <span class="cov7" title="27">{ - opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} - if s.codingTemperature != nil </span><span class="cov1" title="1">{ - temp := *s.codingTemperature - if s.llmClient != nil </span><span class="cov1" title="1">{ - prov := strings.ToLower(strings.TrimSpace(s.llmClient.Name())) - model := strings.ToLower(strings.TrimSpace(s.llmClient.DefaultModel())) +func (s *Server) llmRequestOpts() []llm.RequestOption <span class="cov7" title="35">{ + maxTokens := s.maxTokens() + client := s.currentLLMClient() + tempPtr := s.codingTemperature() + opts := []llm.RequestOption{llm.WithMaxTokens(maxTokens)} + if tempPtr != nil </span><span class="cov1" title="1">{ + temp := *tempPtr + if client != nil </span><span class="cov1" title="1">{ + prov := strings.ToLower(strings.TrimSpace(client.Name())) + model := strings.ToLower(strings.TrimSpace(client.DefaultModel())) if prov == "openai" && strings.HasPrefix(model, "gpt-5") </span><span class="cov1" title="1">{ temp = 1.0 }</span> } <span class="cov1" title="1">opts = append(opts, llm.WithTemperature(temp))</span> } - <span class="cov7" title="27">return opts</span> + <span class="cov7" title="35">return opts</span> } // small helpers for LLM traffic stats -func (s *Server) incSentCounters(n int) <span class="cov8" title="39">{ +func (s *Server) incSentCounters(n int) <span class="cov8" title="41">{ s.mu.Lock() s.llmReqTotal++ s.llmSentBytesTotal += int64(n) s.mu.Unlock() }</span> -func (s *Server) incRecvCounters(n int) <span class="cov8" title="37">{ +func (s *Server) incRecvCounters(n int) <span class="cov8" title="38">{ s.mu.Lock() s.llmRespTotal++ s.llmRespBytesTotal += int64(n) s.mu.Unlock() }</span> -func (s *Server) logLLMStats() <span class="cov8" title="39">{ +func (s *Server) logLLMStats() <span class="cov8" title="41">{ s.mu.RLock() avgSent := int64(0) - if s.llmReqTotal > 0 </span><span class="cov8" title="39">{ + if s.llmReqTotal > 0 </span><span class="cov8" title="41">{ avgSent = s.llmSentBytesTotal / s.llmReqTotal }</span> - <span class="cov8" title="39">avgRecv := int64(0) - if s.llmRespTotal > 0 </span><span class="cov8" title="37">{ + <span class="cov8" title="41">avgRecv := int64(0) + if s.llmRespTotal > 0 </span><span class="cov8" title="38">{ avgRecv = s.llmRespBytesTotal / s.llmRespTotal }</span> - <span class="cov8" title="39">reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal + <span class="cov8" title="41">reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal s.mu.RUnlock() mins := time.Since(s.startTime).Minutes() if mins <= 0 </span><span class="cov0" title="0">{ mins = 0.001 }</span> - <span class="cov8" title="39">rpmLocal := float64(reqs) / mins + <span class="cov8" title="41">rpmLocal := float64(reqs) / mins sentPerMin := float64(sentTot) / mins recvPerMin := float64(recvTot) / mins // 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 </span><span class="cov8" title="38">{ - provider := s.llmClient.Name() - model := s.llmClient.DefaultModel() - // Per-scope rpm estimated from window - scopeReqs := int64(0) - if pe, ok := snap.Providers[provider]; ok </span><span class="cov8" title="38">{ - if mc, ok2 := pe.Models[model]; ok2 </span><span class="cov8" title="38">{ - scopeReqs = mc.Reqs + if err == nil </span><span class="cov8" title="41">{ + if client := s.currentLLMClient(); client != nil </span><span class="cov8" title="40">{ + provider := client.Name() + model := client.DefaultModel() + // Per-scope rpm estimated from window + scopeReqs := int64(0) + if pe, ok := snap.Providers[provider]; ok </span><span class="cov8" title="40">{ + if mc, ok2 := pe.Models[model]; ok2 </span><span class="cov8" title="40">{ + scopeReqs = mc.Reqs + }</span> + } + <span class="cov8" title="40">minsWin := snap.Window.Minutes() + if minsWin <= 0 </span><span class="cov0" title="0">{ + minsWin = 0.001 }</span> + <span class="cov8" title="40">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)</span> } - <span class="cov8" title="38">minsWin := snap.Window.Minutes() - if minsWin <= 0 </span><span class="cov0" title="0">{ - minsWin = 0.001 - }</span> - <span class="cov8" title="38">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)</span> } } // Completion prompt builders and filters -func inParamList(current string, cursor int) bool <span class="cov5" title="12">{ - if !strings.Contains(current, "func ") </span><span class="cov4" title="6">{ +func inParamList(current string, cursor int) bool <span class="cov5" title="13">{ + if !strings.Contains(current, "func ") </span><span class="cov4" title="7">{ return false }</span> <span class="cov4" title="6">open := strings.Index(current, "(") @@ -6084,9 +6164,9 @@ func inParamList(current string, cursor int) bool <span class="cov5" title="12"> } // renderTemplate performs simple {{var}} replacement in a template string. -func renderTemplate(t string, vars map[string]string) string <span class="cov8" title="40">{ return textutil.RenderTemplate(t, vars) }</span> +func renderTemplate(t string, vars map[string]string) string <span class="cov8" title="42">{ return textutil.RenderTemplate(t, vars) }</span> -func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) <span class="cov6" title="17">{ +func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) <span class="cov6" title="18">{ if inParams </span><span class="cov3" title="3">{ open := strings.Index(current, "(") close := strings.Index(current, ")") @@ -6107,25 +6187,25 @@ func computeTextEditAndFilter(cleaned string, inParams bool, current string, p C <span class="cov3" title="3">return te, filter</span> } } - <span class="cov6" title="14">startChar := computeWordStart(current, p.Position.Character) + <span class="cov6" title="15">startChar := computeWordStart(current, p.Position.Character) te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: startChar}, End: Position{Line: p.Position.Line, Character: p.Position.Character}}, NewText: cleaned} filter := strings.TrimLeft(current[startChar:p.Position.Character], " \t") return te, filter</span> } -func computeWordStart(current string, at int) int <span class="cov7" title="24">{ +func computeWordStart(current string, at int) int <span class="cov7" title="25">{ if at > len(current) </span><span class="cov0" title="0">{ at = len(current) }</span> - <span class="cov7" title="24">for at > 0 </span><span class="cov8" title="49">{ + <span class="cov7" title="25">for at > 0 </span><span class="cov8" title="50">{ ch := current[at-1] if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' </span><span class="cov7" title="31">{ at-- continue</span> } - <span class="cov6" title="18">break</span> + <span class="cov6" title="19">break</span> } - <span class="cov7" title="24">return at</span> + <span class="cov7" title="25">return at</span> } func isIdentChar(ch byte) bool <span class="cov7" title="26">{ @@ -6146,17 +6226,19 @@ func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ... return "", context.Canceled }</span> // Perform request - <span class="cov7" title="26">txt, err := s.llmClient.Chat(ctx, msgs, opts...) + <span class="cov7" title="26">client := s.currentLLMClient() + if client == nil </span><span class="cov0" title="0">{ + return "", fmt.Errorf("llm client unavailable") + }</span> + <span class="cov7" title="26">txt, err := client.Chat(ctx, msgs, opts...) if err != nil </span><span class="cov1" title="1">{ s.logLLMStats() return "", err }</span> <span class="cov7" title="25">s.incRecvCounters(len(txt)) // Update global stats cache - if s.llmClient != nil </span><span class="cov7" title="25">{ - _ = stats.Update(ctx, s.llmClient.Name(), s.llmClient.DefaultModel(), sent, len(txt)) - }</span> - <span class="cov7" title="25">s.logLLMStats() + _ = stats.Update(ctx, client.Name(), client.DefaultModel(), sent, len(txt)) + s.logLLMStats() return txt, nil</span> } @@ -6244,11 +6326,11 @@ func findStrictInlineTag(line string, open, close byte) (string, int, int, bool) // isBareDoubleSemicolon reports whether the line contains a standalone // double-semicolon marker with no inline content (";;" possibly with only // whitespace after it). It explicitly excludes the valid form ";;text;". -func isBareDoubleOpen(line string, open, close byte) bool <span class="cov6" title="18">{ +func isBareDoubleOpen(line string, open, close byte) bool <span class="cov6" title="20">{ t := strings.TrimSpace(line) // check for double-open pattern dbl := string([]byte{open, open}) - if !strings.Contains(t, dbl) </span><span class="cov6" title="16">{ + if !strings.Contains(t, dbl) </span><span class="cov6" title="18">{ return false }</span> <span class="cov2" title="2">if hasDoubleOpenTrigger(t, open, close) </span><span class="cov1" title="1">{ @@ -6264,7 +6346,7 @@ func isBareDoubleOpen(line string, open, close byte) bool <span class="cov6" tit } // stripDuplicateAssignmentPrefix removes a duplicated assignment prefix from the suggestion. -func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="19">{ +func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="20">{ s2 := strings.TrimLeft(suggestion, " \t") // Prefer := if present at end of prefix if idx := strings.LastIndex(prefixBeforeCursor, ":="); idx >= 0 && idx+2 <= len(prefixBeforeCursor) </span><span class="cov3" title="4">{ @@ -6282,7 +6364,7 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin } } // Fallback to plain '=' if present - <span class="cov6" title="15">if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 </span><span class="cov2" title="2">{ + <span class="cov6" title="16">if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 </span><span class="cov2" title="2">{ if !(idx > 0 && prefixBeforeCursor[idx-1] == ':') </span><span class="cov2" title="2">{ // not := tail := prefixBeforeCursor[idx+1:] if strings.TrimSpace(tail) == "" </span><span class="cov2" title="2">{ @@ -6298,21 +6380,21 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin } } } - <span class="cov6" title="13">return suggestion</span> + <span class="cov6" title="14">return suggestion</span> } // stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated. -func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="19">{ +func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="20">{ if suggestion == "" </span><span class="cov0" title="0">{ return suggestion }</span> - <span class="cov6" title="19">s := strings.TrimLeft(suggestion, " \t") + <span class="cov6" title="20">s := strings.TrimLeft(suggestion, " \t") p := strings.TrimRight(prefixBeforeCursor, " \t") if p != "" && strings.HasPrefix(s, p) </span><span class="cov4" title="5">{ return strings.TrimLeft(s[len(p):], " \t") }</span> - <span class="cov6" title="14">for k := len(p) - 1; k > 0; k-- </span><span class="cov10" title="100">{ - if !isIdentBoundary(p[k-1]) </span><span class="cov9" title="77">{ + <span class="cov6" title="15">for k := len(p) - 1; k > 0; k-- </span><span class="cov10" title="103">{ + if !isIdentBoundary(p[k-1]) </span><span class="cov9" title="80">{ continue</span> } <span class="cov7" title="23">suf := strings.TrimLeft(p[k:], " \t") @@ -6323,15 +6405,15 @@ func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string < return strings.TrimLeft(s[len(suf):], " \t") }</span> } - <span class="cov6" title="14">return suggestion</span> + <span class="cov6" title="15">return suggestion</span> } -func isIdentBoundary(ch byte) bool <span class="cov10" title="100">{ +func isIdentBoundary(ch byte) bool <span class="cov10" title="103">{ return !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') }</span> // stripCodeFences removes surrounding Markdown code fences from a model response. -func stripCodeFences(s string) string <span class="cov8" title="44">{ return textutil.StripCodeFences(s) }</span> +func stripCodeFences(s string) string <span class="cov8" title="45">{ return textutil.StripCodeFences(s) }</span> // stripInlineCodeSpan returns the contents of the first inline backtick code span if present. func stripInlineCodeSpan(s string) string <span class="cov5" title="11">{ @@ -6352,12 +6434,12 @@ func stripInlineCodeSpan(s string) string <span class="cov5" title="11">{ } // labelForCompletion picks a short, readable label for the completion list. -func labelForCompletion(cleaned, filter string) string <span class="cov6" title="20">{ +func labelForCompletion(cleaned, filter string) string <span class="cov6" title="21">{ label := trimLen(firstLine(cleaned)) if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) </span><span class="cov4" title="5">{ return filter }</span> - <span class="cov6" title="15">return label</span> + <span class="cov6" title="16">return label</span> } // extractRangeText returns the exact text within the given document range. @@ -6406,32 +6488,33 @@ func extractRangeText(d *document, r Range) string <span class="cov4" title="6"> } // collectPromptRemovalEdits returns edits to remove all inline prompt markers. -func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit <span class="cov6" title="13">{ +func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit <span class="cov6" title="14">{ d := s.getDocument(uri) if d == nil || len(d.lines) == 0 </span><span class="cov5" title="11">{ return nil }</span> - <span class="cov2" title="2">var edits []TextEdit - for i, line := range d.lines </span><span class="cov4" title="7">{ - edits = append(edits, promptRemovalEditsForLine(line, i, s.inlineOpenChar, s.inlineCloseChar)...) + <span class="cov3" title="3">var edits []TextEdit + _, _, openChar, closeChar := s.inlineMarkers() + for i, line := range d.lines </span><span class="cov5" title="12">{ + edits = append(edits, promptRemovalEditsForLine(line, i, openChar, closeChar)...) }</span> - <span class="cov2" title="2">return edits</span> + <span class="cov3" title="3">return edits</span> } -func promptRemovalEditsForLine(line string, lineNum int, open, close byte) []TextEdit <span class="cov5" title="11">{ +func promptRemovalEditsForLine(line string, lineNum int, open, close byte) []TextEdit <span class="cov6" title="16">{ if hasDoubleOpenTrigger(line, open, close) </span><span class="cov3" title="4">{ return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} }</span> - <span class="cov4" title="7">return collectSemicolonMarkers(line, lineNum, open, close)</span> + <span class="cov5" title="12">return collectSemicolonMarkers(line, lineNum, open, close)</span> } -func hasDoubleOpenTrigger(line string, open, close byte) bool <span class="cov8" title="58">{ +func hasDoubleOpenTrigger(line string, open, close byte) bool <span class="cov9" title="64">{ pos := 0 - for pos < len(line) </span><span class="cov9" title="61">{ + for pos < len(line) </span><span class="cov9" title="66">{ // look for double-open sequence dbl := string([]byte{open, open}) j := strings.Index(line[pos:], dbl) - if j < 0 </span><span class="cov8" title="37">{ + if j < 0 </span><span class="cov8" title="42">{ return false }</span> <span class="cov7" title="24">j += pos @@ -6456,15 +6539,15 @@ func hasDoubleOpenTrigger(line string, open, close byte) bool <span class="cov8" } <span class="cov5" title="10">return true</span> } - <span class="cov3" title="3">return false</span> + <span class="cov3" title="4">return false</span> } -func collectSemicolonMarkers(line string, lineNum int, open, close byte) []TextEdit <span class="cov5" title="9">{ +func collectSemicolonMarkers(line string, lineNum int, open, close byte) []TextEdit <span class="cov6" title="14">{ var edits []TextEdit startSemi := 0 - for startSemi < len(line) </span><span class="cov6" title="14">{ + for startSemi < len(line) </span><span class="cov6" title="18">{ j := strings.IndexByte(line[startSemi:], open) - if j < 0 </span><span class="cov5" title="8">{ + if j < 0 </span><span class="cov5" title="12">{ break</span> } <span class="cov4" title="6">j += startSemi @@ -6496,11 +6579,11 @@ func collectSemicolonMarkers(line string, lineNum int, open, close byte) []TextE <span class="cov4" title="6">edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) startSemi = endChar</span> } - <span class="cov5" title="9">return edits</span> + <span class="cov6" title="14">return edits</span> } </pre> - <pre class="file" id="file31" style="display: none">// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats. + <pre class="file" id="file32" style="display: none">// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats. package lsp import ( @@ -6512,29 +6595,26 @@ import ( "sync" "time" + "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/runtimeconfig" ) // Server implements a minimal LSP over stdio. type Server struct { - in *bufio.Reader - out io.Writer - outMu sync.Mutex - logger *log.Logger - exited bool - mu sync.RWMutex - docs map[string]*document - logContext bool - llmClient llm.Client - lastInput time.Time - maxTokens int - contextMode string - windowLines int - maxContextTokens int - triggerChars []string - // If set, used as the LSP coding temperature for all LLM calls - codingTemperature *float64 + in *bufio.Reader + out io.Writer + outMu sync.Mutex + logger *log.Logger + exited bool + mu sync.RWMutex + docs map[string]*document + logContext bool + configStore *runtimeconfig.Store + cfg appconfig.App + llmClient llm.Client + lastInput time.Time // LLM request stats llmReqTotal int64 llmSentBytesTotal int64 @@ -6545,58 +6625,18 @@ type Server struct { compCache map[string]string compCacheOrder []string // most-recent at end; cap ~10 // Outgoing JSON-RPC id counter for server-initiated requests - nextID int64 - // Minimum identifier chars required for manual invoke to bypass prefix checks - manualInvokeMinPrefix int - - // Debounce and throttle settings - completionDebounce time.Duration - throttleInterval time.Duration - lastLLMCall time.Time + nextID int64 + lastLLMCall time.Time // Dispatch table for JSON-RPC methods → handler functions handlers map[string]func(Request) - - // Configurable trigger characters - inlineOpen string - inlineClose string - chatSuffix string - chatPrefixes []string - inlineOpenChar byte - inlineCloseChar byte - chatSuffixChar byte - - // Prompt templates - // Completion - promptCompSysGeneral string - promptCompSysParams string - promptCompSysInline string - promptCompUserGeneral string - promptCompUserParams string - promptCompExtraHeader string - // Provider-native code completion - promptNativeCompletion string - // In-editor chat - promptChatSystem string - // Code actions - promptRewriteSystem string - promptDiagnosticsSystem string - promptDocumentSystem string - promptRewriteUser string - promptDiagnosticsUser string - promptDocumentUser string - promptGoTestSystem string - promptGoTestUser string - promptSimplifySystem string - promptSimplifyUser string - - // Custom actions configured by user - customActions []CustomAction } // ServerOptions collects configuration for NewServer to avoid long parameter lists. type ServerOptions struct { LogContext bool + ConfigStore *runtimeconfig.Store + Config *appconfig.App MaxTokens int ContextMode string WindowLines int @@ -6650,109 +6690,13 @@ type CustomAction struct { User string // if set, use this user template } -func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server <span class="cov10" title="8">{ - s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext} - maxTokens := opts.MaxTokens - if maxTokens <= 0 </span><span class="cov9" title="7">{ - maxTokens = 500 - }</span> - <span class="cov10" title="8">s.maxTokens = maxTokens - contextMode := opts.ContextMode - if contextMode == "" </span><span class="cov9" title="7">{ - contextMode = "file-on-new-func" - }</span> - <span class="cov10" title="8">windowLines := opts.WindowLines - if windowLines <= 0 </span><span class="cov9" title="7">{ - windowLines = 120 - }</span> - <span class="cov10" title="8">maxContextTokens := opts.MaxContextTokens - if maxContextTokens <= 0 </span><span class="cov9" title="7">{ - maxContextTokens = 2000 - }</span> - <span class="cov10" title="8">s.contextMode = contextMode - s.windowLines = windowLines - s.maxContextTokens = maxContextTokens - +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server <span class="cov3" title="7">{ + s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext, configStore: opts.ConfigStore} s.startTime = time.Now() - s.llmClient = opts.Client - if len(opts.TriggerCharacters) == 0 </span><span class="cov10" title="8">{ - // Defaults (no space to avoid auto-trigger after whitespace) - s.triggerChars = []string{".", ":", "/", "_", ")", "{"} - }</span> else<span class="cov0" title="0"> { - s.triggerChars = append([]string{}, opts.TriggerCharacters...) - }</span> - <span class="cov10" title="8">s.codingTemperature = opts.CodingTemperature s.compCache = make(map[string]string) - s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix - if opts.CompletionDebounceMs > 0 </span><span class="cov1" title="1">{ - s.completionDebounce = time.Duration(opts.CompletionDebounceMs) * time.Millisecond - }</span> - <span class="cov10" title="8">if opts.CompletionThrottleMs > 0 </span><span class="cov0" title="0">{ - s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond - }</span> - // Trigger character config (with sane defaults if missing) - <span class="cov10" title="8">if strings.TrimSpace(opts.InlineOpen) == "" </span><span class="cov8" title="6">{ - s.inlineOpen = ">" - }</span> else<span class="cov4" title="2"> { - s.inlineOpen = opts.InlineOpen - }</span> - <span class="cov10" title="8">if strings.TrimSpace(opts.InlineClose) == "" </span><span class="cov8" title="6">{ - s.inlineClose = ">" - }</span> else<span class="cov4" title="2"> { - s.inlineClose = opts.InlineClose - }</span> - <span class="cov10" title="8">if strings.TrimSpace(opts.ChatSuffix) == "" </span><span class="cov7" title="5">{ - s.chatSuffix = ">" - }</span> else<span class="cov5" title="3"> { - s.chatSuffix = opts.ChatSuffix - }</span> - <span class="cov10" title="8">if len(opts.ChatPrefixes) == 0 </span><span class="cov7" title="5">{ - s.chatPrefixes = []string{"?", "!", ":", ";"} - }</span> else<span class="cov5" title="3"> { - s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) - }</span> - - // Prompts - <span class="cov10" title="8">s.promptCompSysGeneral = opts.PromptCompSysGeneral - s.promptCompSysParams = opts.PromptCompSysParams - s.promptCompSysInline = opts.PromptCompSysInline - s.promptCompUserGeneral = opts.PromptCompUserGeneral - s.promptCompUserParams = opts.PromptCompUserParams - s.promptCompExtraHeader = opts.PromptCompExtraHeader - s.promptNativeCompletion = opts.PromptNativeCompletion - s.promptChatSystem = opts.PromptChatSystem - s.promptRewriteSystem = opts.PromptRewriteSystem - s.promptDiagnosticsSystem = opts.PromptDiagnosticsSystem - s.promptDocumentSystem = opts.PromptDocumentSystem - s.promptRewriteUser = opts.PromptRewriteUser - s.promptDiagnosticsUser = opts.PromptDiagnosticsUser - s.promptDocumentUser = opts.PromptDocumentUser - s.promptGoTestSystem = opts.PromptGoTestSystem - s.promptGoTestUser = opts.PromptGoTestUser - s.promptSimplifySystem = opts.PromptSimplifySystem - s.promptSimplifyUser = opts.PromptSimplifyUser - - if len(opts.CustomActions) > 0 </span><span class="cov1" title="1">{ - s.customActions = append([]CustomAction{}, opts.CustomActions...) - }</span> - - <span class="cov10" title="8">if s.inlineOpen != "" </span><span class="cov10" title="8">{ - s.inlineOpenChar = s.inlineOpen[0] - }</span> else<span class="cov0" title="0"> { - s.inlineOpenChar = '>' - }</span> - <span class="cov10" title="8">if s.inlineClose != "" </span><span class="cov10" title="8">{ - s.inlineCloseChar = s.inlineClose[0] - }</span> else<span class="cov0" title="0"> { - s.inlineCloseChar = '>' - }</span> - <span class="cov10" title="8">if s.chatSuffix != "" </span><span class="cov10" title="8">{ - s.chatSuffixChar = s.chatSuffix[0] - }</span> else<span class="cov0" title="0"> { - s.chatSuffixChar = '>' - }</span> + s.applyOptions(opts) // Initialize dispatch table - <span class="cov10" title="8">s.handlers = map[string]func(Request){ + s.handlers = map[string]func(Request){ "initialize": s.handleInitialize, "initialized": func(_ Request) </span><span class="cov0" title="0">{ s.handleInitialized() }</span>, "shutdown": s.handleShutdown, @@ -6765,7 +6709,221 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) "codeAction/resolve": s.handleCodeActionResolve, "workspace/executeCommand": s.handleExecuteCommand, } - <span class="cov10" title="8">return s</span> + <span class="cov3" title="7">return s</span> +} + +func (s *Server) applyOptions(opts ServerOptions) <span class="cov4" title="8">{ + s.mu.Lock() + defer s.mu.Unlock() + s.logContext = opts.LogContext + if opts.ConfigStore != nil </span><span class="cov1" title="1">{ + s.configStore = opts.ConfigStore + }</span> + <span class="cov4" title="8">if opts.Config != nil </span><span class="cov2" title="2">{ + s.cfg = *opts.Config + }</span> else<span class="cov3" title="6"> if opts.ConfigStore != nil </span><span class="cov0" title="0">{ + s.cfg = opts.ConfigStore.Snapshot() + }</span> else<span class="cov3" title="6"> { + s.cfg = appconfig.App{} + // populate from legacy ServerOptions fields + s.cfg.MaxTokens = opts.MaxTokens + s.cfg.ContextMode = opts.ContextMode + s.cfg.ContextWindowLines = opts.WindowLines + s.cfg.MaxContextTokens = opts.MaxContextTokens + s.cfg.TriggerCharacters = append([]string{}, opts.TriggerCharacters...) + s.cfg.CodingTemperature = opts.CodingTemperature + s.cfg.ManualInvokeMinPrefix = opts.ManualInvokeMinPrefix + s.cfg.CompletionDebounceMs = opts.CompletionDebounceMs + s.cfg.CompletionThrottleMs = opts.CompletionThrottleMs + s.cfg.InlineOpen = opts.InlineOpen + s.cfg.InlineClose = opts.InlineClose + s.cfg.ChatSuffix = opts.ChatSuffix + s.cfg.ChatPrefixes = append([]string{}, opts.ChatPrefixes...) + s.cfg.PromptCompletionSystemGeneral = opts.PromptCompSysGeneral + s.cfg.PromptCompletionSystemParams = opts.PromptCompSysParams + s.cfg.PromptCompletionSystemInline = opts.PromptCompSysInline + s.cfg.PromptCompletionUserGeneral = opts.PromptCompUserGeneral + s.cfg.PromptCompletionUserParams = opts.PromptCompUserParams + s.cfg.PromptCompletionExtraHeader = opts.PromptCompExtraHeader + s.cfg.PromptNativeCompletion = opts.PromptNativeCompletion + s.cfg.PromptChatSystem = opts.PromptChatSystem + s.cfg.PromptCodeActionRewriteSystem = opts.PromptRewriteSystem + s.cfg.PromptCodeActionDiagnosticsSystem = opts.PromptDiagnosticsSystem + s.cfg.PromptCodeActionDocumentSystem = opts.PromptDocumentSystem + s.cfg.PromptCodeActionRewriteUser = opts.PromptRewriteUser + s.cfg.PromptCodeActionDiagnosticsUser = opts.PromptDiagnosticsUser + s.cfg.PromptCodeActionDocumentUser = opts.PromptDocumentUser + s.cfg.PromptCodeActionGoTestSystem = opts.PromptGoTestSystem + s.cfg.PromptCodeActionGoTestUser = opts.PromptGoTestUser + s.cfg.PromptCodeActionSimplifySystem = opts.PromptSimplifySystem + s.cfg.PromptCodeActionSimplifyUser = opts.PromptSimplifyUser + s.cfg.CustomActions = make([]appconfig.CustomAction, len(opts.CustomActions)) + for i, ca := range opts.CustomActions </span><span class="cov0" title="0">{ + s.cfg.CustomActions[i] = appconfig.CustomAction{ + ID: ca.ID, + Title: ca.Title, + Kind: ca.Kind, + Scope: ca.Scope, + Instruction: ca.Instruction, + System: ca.System, + User: ca.User, + } + }</span> + } + <span class="cov4" title="8">s.llmClient = opts.Client</span> +} + +// ApplyOptions updates the server's configuration at runtime. +func (s *Server) ApplyOptions(opts ServerOptions) <span class="cov1" title="1">{ + s.applyOptions(opts) +}</span> + +func (s *Server) currentLLMClient() llm.Client <span class="cov8" title="199">{ + s.mu.RLock() + defer s.mu.RUnlock() + return s.llmClient +}</span> + +func (s *Server) currentConfig() appconfig.App <span class="cov10" title="407">{ + if s.configStore != nil </span><span class="cov2" title="2">{ + return s.configStore.Snapshot() + }</span> + <span class="cov9" title="405">s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg</span> +} + +func (s *Server) maxTokens() int <span class="cov6" title="35">{ + cfg := s.currentConfig() + if cfg.MaxTokens <= 0 </span><span class="cov6" title="29">{ + return 500 + }</span> + <span class="cov3" title="6">return cfg.MaxTokens</span> +} + +func (s *Server) contextMode() string <span class="cov4" title="13">{ + mode := strings.TrimSpace(s.currentConfig().ContextMode) + if mode == "" </span><span class="cov3" title="4">{ + return "file-on-new-func" + }</span> + <span class="cov4" title="9">return mode</span> +} + +func (s *Server) windowLines() int <span class="cov2" title="2">{ + cfg := s.currentConfig() + if cfg.ContextWindowLines <= 0 </span><span class="cov0" title="0">{ + return 120 + }</span> + <span class="cov2" title="2">return cfg.ContextWindowLines</span> +} + +func (s *Server) maxContextTokens() int <span class="cov3" title="6">{ + cfg := s.currentConfig() + if cfg.MaxContextTokens <= 0 </span><span class="cov0" title="0">{ + return 2000 + }</span> + <span class="cov3" title="6">return cfg.MaxContextTokens</span> +} + +func (s *Server) triggerCharacters() []string <span class="cov5" title="27">{ + cfg := s.currentConfig() + if len(cfg.TriggerCharacters) == 0 </span><span class="cov2" title="3">{ + return []string{".", ":", "/", "_", ")", "{"} + }</span> + <span class="cov5" title="24">return append([]string{}, cfg.TriggerCharacters...)</span> +} + +func (s *Server) codingTemperature() *float64 <span class="cov6" title="49">{ + cfg := s.currentConfig() + return cfg.CodingTemperature +}</span> + +func (s *Server) manualInvokeMinPrefix() int <span class="cov3" title="5">{ + return s.currentConfig().ManualInvokeMinPrefix +}</span> + +func (s *Server) completionDebounce() time.Duration <span class="cov6" title="41">{ + cfg := s.currentConfig() + if cfg.CompletionDebounceMs <= 0 </span><span class="cov6" title="39">{ + return 0 + }</span> + <span class="cov2" title="2">return time.Duration(cfg.CompletionDebounceMs) * time.Millisecond</span> +} + +func (s *Server) completionThrottle() time.Duration <span class="cov6" title="41">{ + cfg := s.currentConfig() + if cfg.CompletionThrottleMs <= 0 </span><span class="cov6" title="38">{ + return 0 + }</span> + <span class="cov2" title="3">return time.Duration(cfg.CompletionThrottleMs) * time.Millisecond</span> +} + +func (s *Server) inlineMarkers() (open string, close string, openChar byte, closeChar byte) <span class="cov7" title="88">{ + cfg := s.currentConfig() + open = strings.TrimSpace(cfg.InlineOpen) + if open == "" </span><span class="cov0" title="0">{ + open = ">" + }</span> + <span class="cov7" title="88">close = strings.TrimSpace(cfg.InlineClose) + if close == "" </span><span class="cov0" title="0">{ + close = ">" + }</span> + <span class="cov7" title="88">openChar = '>' + if len(open) > 0 </span><span class="cov7" title="88">{ + openChar = open[0] + }</span> + <span class="cov7" title="88">closeChar = '>' + if len(close) > 0 </span><span class="cov7" title="88">{ + closeChar = close[0] + }</span> + <span class="cov7" title="88">return open, close, openChar, closeChar</span> +} + +func (s *Server) chatConfig() (suffix string, prefixes []string, suffixChar byte) <span class="cov6" title="44">{ + cfg := s.currentConfig() + suffix = cfg.ChatSuffix + if suffix != "" </span><span class="cov6" title="42">{ + suffix = strings.TrimSpace(suffix) + if suffix == "" </span><span class="cov0" title="0">{ + suffix = ">" + }</span> + } else<span class="cov2" title="2"> { + suffix = "" + }</span> + <span class="cov6" title="44">if len(cfg.ChatPrefixes) == 0 </span><span class="cov0" title="0">{ + prefixes = []string{"?", "!", ":", ";"} + }</span> else<span class="cov6" title="44"> { + prefixes = append([]string{}, cfg.ChatPrefixes...) + }</span> + <span class="cov6" title="44">suffixChar = '>' + if len(suffix) > 0 </span><span class="cov6" title="42">{ + suffixChar = suffix[0] + }</span> + <span class="cov6" title="44">return suffix, prefixes, suffixChar</span> +} + +func (s *Server) promptSet() appconfig.App <span class="cov2" title="2">{ + return s.currentConfig() +}</span> + +func (s *Server) customActions() []CustomAction <span class="cov3" title="7">{ + cfg := s.currentConfig() + if len(cfg.CustomActions) == 0 </span><span class="cov1" title="1">{ + return nil + }</span> + <span class="cov3" title="6">customs := make([]CustomAction, 0, len(cfg.CustomActions)) + for _, ca := range cfg.CustomActions </span><span class="cov4" title="10">{ + customs = append(customs, CustomAction{ + ID: ca.ID, + Title: ca.Title, + Kind: ca.Kind, + Scope: ca.Scope, + Instruction: ca.Instruction, + System: ca.System, + User: ca.User, + }) + }</span> + <span class="cov3" title="6">return customs</span> } func (s *Server) Run() error <span class="cov1" title="1">{ @@ -6794,7 +6952,7 @@ func (s *Server) Run() error <span class="cov1" title="1">{ } </pre> - <pre class="file" id="file32" style="display: none">// Summary: LSP transport utilities to read and write JSON-RPC messages with Content-Length framing. + <pre class="file" id="file33" style="display: none">// Summary: LSP transport utilities to read and write JSON-RPC messages with Content-Length framing. package lsp import ( @@ -6865,7 +7023,187 @@ func (s *Server) writeMessage(v any) <span class="cov10" title="41">{ } </pre> - <pre class="file" id="file33" style="display: none">//go:build !windows + <pre class="file" id="file34" style="display: none">package runtimeconfig + +import ( + "fmt" + "log" + "reflect" + "sort" + "strconv" + "strings" + "sync" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +// Change captures a single configuration delta. +type Change struct { + Key string + Old string + New string +} + +// Listener receives the previous and new application configuration when updates occur. +type Listener func(old appconfig.App, new appconfig.App) + +// Store holds the active runtime configuration and notifies listeners on updates. +type Store struct { + mu sync.RWMutex + cfg appconfig.App + listeners map[int]Listener + nextID int +} + +// New creates a Store seeded with the provided configuration snapshot. +func New(cfg appconfig.App) *Store <span class="cov4" title="11">{ + return &Store{cfg: cfg, listeners: make(map[int]Listener)} +}</span> + +// Snapshot returns the current configuration snapshot. Callers must treat it as read-only. +func (s *Store) Snapshot() appconfig.App <span class="cov3" title="4">{ + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg +}</span> + +// Subscribe registers a listener that will be invoked on configuration changes. +// The returned function removes the listener. +func (s *Store) Subscribe(listener Listener) func() <span class="cov2" title="2">{ + if listener == nil </span><span class="cov0" title="0">{ + return func() </span>{<span class="cov0" title="0">}</span> + } + <span class="cov2" title="2">s.mu.Lock() + id := s.nextID + s.nextID++ + s.listeners[id] = listener + s.mu.Unlock() + return func() </span><span class="cov0" title="0">{ + s.mu.Lock() + delete(s.listeners, id) + s.mu.Unlock() + }</span> +} + +// Set replaces the current configuration with the provided snapshot and notifies listeners. +// It returns the list of detected changes between the previous and new configuration. +func (s *Store) Set(cfg appconfig.App) []Change <span class="cov3" title="4">{ + s.mu.Lock() + old := s.cfg + s.cfg = cfg + listeners := make([]Listener, 0, len(s.listeners)) + for _, l := range s.listeners </span><span class="cov1" title="1">{ + listeners = append(listeners, l) + }</span> + <span class="cov3" title="4">s.mu.Unlock() + + changes := Diff(old, cfg) + for _, l := range listeners </span><span class="cov1" title="1">{ + l(old, cfg) + }</span> + <span class="cov3" title="4">return changes</span> +} + +// Reload re-reads configuration using the supplied options and applies it when valid. +func (s *Store) Reload(logger *log.Logger, opts appconfig.LoadOptions) ([]Change, error) <span class="cov2" title="2">{ + cfg := appconfig.LoadWithOptions(logger, opts) + if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov2" title="2">return s.Set(cfg), nil</span> +} + +// Diff computes a stable, sorted list of key/value changes between two configuration snapshots. +func Diff(oldCfg, newCfg appconfig.App) []Change <span class="cov3" title="4">{ + before := flattenAppConfig(oldCfg) + after := flattenAppConfig(newCfg) + keys := make(map[string]struct{}, len(before)+len(after)) + for k := range before </span><span class="cov7" title="100">{ + keys[k] = struct{}{} + }</span> + <span class="cov3" title="4">for k := range after </span><span class="cov7" title="100">{ + keys[k] = struct{}{} + }</span> + <span class="cov3" title="4">ordered := make([]string, 0, len(keys)) + for k := range keys </span><span class="cov7" title="100">{ + ordered = append(ordered, k) + }</span> + <span class="cov3" title="4">sort.Strings(ordered) + changes := make([]Change, 0, len(ordered)) + for _, k := range ordered </span><span class="cov7" title="100">{ + if before[k] == after[k] </span><span class="cov7" title="95">{ + continue</span> + } + <span class="cov3" title="5">changes = append(changes, Change{Key: k, Old: before[k], New: after[k]})</span> + } + <span class="cov3" title="4">return changes</span> +} + +func flattenAppConfig(cfg appconfig.App) map[string]string <span class="cov4" title="8">{ + result := make(map[string]string) + val := reflect.ValueOf(cfg) + typ := val.Type() + for i := 0; i < typ.NumField(); i++ </span><span class="cov10" title="376">{ + field := typ.Field(i) + key := strings.TrimSpace(field.Tag.Get("toml")) + if key == "" || key == "-" </span><span class="cov8" title="184">{ + switch field.Name </span>{ + case "StatsWindowMinutes":<span class="cov4" title="8"> + key = "stats_window_minutes"</span> + default:<span class="cov8" title="176"> + continue</span> + } + } + <span class="cov9" title="200">if idx := strings.Index(key, ","); idx >= 0 </span><span class="cov0" title="0">{ + key = key[:idx] + }</span> + <span class="cov9" title="200">if key == "" || key == "-" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov9" title="200">result[key] = stringifyValue(val.Field(i))</span> + } + <span class="cov4" title="8">return result</span> +} + +func stringifyValue(v reflect.Value) string <span class="cov9" title="224">{ + if !v.IsValid() </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov9" title="224">switch v.Kind() </span>{ + case reflect.String:<span class="cov7" title="88"> + return v.String()</span> + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:<span class="cov7" title="64"> + return strconv.FormatInt(v.Int(), 10)</span> + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:<span class="cov0" title="0"> + return strconv.FormatUint(v.Uint(), 10)</span> + case reflect.Float32, reflect.Float64:<span class="cov5" title="24"> + return strconv.FormatFloat(v.Float(), 'f', -1, 64)</span> + case reflect.Bool:<span class="cov0" title="0"> + return strconv.FormatBool(v.Bool())</span> + case reflect.Slice:<span class="cov5" title="16"> + if v.IsNil() </span><span class="cov4" title="10">{ + return "" + }</span> + <span class="cov3" title="6">if v.Type().Elem().Kind() == reflect.String </span><span class="cov3" title="6">{ + parts := make([]string, v.Len()) + for i := range parts </span><span class="cov5" title="24">{ + parts[i] = v.Index(i).String() + }</span> + <span class="cov3" title="6">return strings.Join(parts, ",")</span> + } + <span class="cov0" title="0">return fmt.Sprint(v.Interface())</span> + case reflect.Ptr:<span class="cov6" title="32"> + if v.IsNil() </span><span class="cov4" title="8">{ + return "(unset)" + }</span> + <span class="cov5" title="24">return stringifyValue(v.Elem())</span> + default:<span class="cov0" title="0"> + return fmt.Sprint(v.Interface())</span> + } +} +</pre> + + <pre class="file" id="file35" style="display: none">//go:build !windows package stats @@ -6875,22 +7213,22 @@ import ( "golang.org/x/sys/unix" ) -func tryLockFile(fd uintptr) error <span class="cov10" title="227">{ - if err := unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB); err != nil </span><span class="cov9" title="153">{ - if errors.Is(err, unix.EWOULDBLOCK) </span><span class="cov9" title="153">{ +func tryLockFile(fd uintptr) error <span class="cov10" title="213">{ + if err := unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB); err != nil </span><span class="cov9" title="136">{ + if errors.Is(err, unix.EWOULDBLOCK) </span><span class="cov9" title="136">{ return errLockWouldBlock }</span> <span class="cov0" title="0">return err</span> } - <span class="cov8" title="74">return nil</span> + <span class="cov8" title="77">return nil</span> } -func unlockFile(fd uintptr) error <span class="cov8" title="74">{ +func unlockFile(fd uintptr) error <span class="cov8" title="77">{ return unix.Flock(int(fd), unix.LOCK_UN) }</span> </pre> - <pre class="file" id="file34" style="display: none">// Package stats provides a simple, process-safe, on-disk cache of Hexai LLM usage + <pre class="file" id="file36" style="display: none">// 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 @@ -6921,18 +7259,18 @@ var windowSeconds int64 = int64(defaultWindow.Seconds()) var errLockWouldBlock = errors.New("stats: lock would block") // SetWindow sets the sliding window used for pruning and aggregation. -func SetWindow(d time.Duration) <span class="cov5" title="77">{ +func SetWindow(d time.Duration) <span class="cov4" title="82">{ if d < time.Second </span><span class="cov0" title="0">{ d = time.Second }</span> - <span class="cov5" title="77">if d > 24*time.Hour </span><span class="cov0" title="0">{ + <span class="cov4" title="82">if d > 24*time.Hour </span><span class="cov0" title="0">{ d = 24 * time.Hour }</span> - <span class="cov5" title="77">atomic.StoreInt64(&windowSeconds, int64(d.Seconds()))</span> + <span class="cov4" title="82">atomic.StoreInt64(&windowSeconds, int64(d.Seconds()))</span> } // Window returns the current sliding window. -func Window() time.Duration <span class="cov5" title="74">{ return time.Duration(atomic.LoadInt64(&windowSeconds)) * time.Second }</span> +func Window() time.Duration <span class="cov4" title="77">{ return time.Duration(atomic.LoadInt64(&windowSeconds)) * time.Second }</span> // Event represents a single request/response with sizes. type Event struct { @@ -6967,108 +7305,108 @@ type Snapshot struct { } // Update appends one event and prunes old entries under lock. -func Update(ctx context.Context, provider, model string, sentBytes, recvBytes int) error <span class="cov5" title="74">{ +func Update(ctx context.Context, provider, model string, sentBytes, recvBytes int) error <span class="cov4" title="77">{ dir, err := CacheDir() if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="74">if err := os.MkdirAll(dir, 0o755); err != nil </span><span class="cov0" title="0">{ + <span class="cov4" title="77">if err := os.MkdirAll(dir, 0o755); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="74">lockPath := filepath.Join(dir, lockFileName) + <span class="cov4" title="77">lockPath := filepath.Join(dir, lockFileName) f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="74">defer f.Close() + <span class="cov4" title="77">defer f.Close() unlock, err := acquireFileLock(ctx, f) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="74">defer func() </span><span class="cov5" title="74">{ _ = unlock() }</span>() + <span class="cov4" title="77">defer func() </span><span class="cov4" title="77">{ _ = unlock() }</span>() // Read existing file (if any) - <span class="cov5" title="74">path := filepath.Join(dir, fileName) + <span class="cov4" title="77">path := filepath.Join(dir, fileName) var sf File - if b, rerr := os.ReadFile(path); rerr == nil </span><span class="cov5" title="71">{ + if b, rerr := os.ReadFile(path); rerr == nil </span><span class="cov4" title="74">{ _ = json.Unmarshal(b, &sf) }</span> - <span class="cov5" title="74">if sf.Version != fileVersion </span><span class="cov2" title="3">{ + <span class="cov4" title="77">if sf.Version != fileVersion </span><span class="cov1" title="3">{ sf = File{Version: fileVersion} }</span> - <span class="cov5" title="74">now := time.Now() + <span class="cov4" title="77">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 </span><span class="cov5" title="74">{ + if len(sf.Events) > 0 </span><span class="cov4" title="77">{ // Find first >= cutoff i := 0 - for ; i < len(sf.Events); i++ </span><span class="cov5" title="75">{ - if !sf.Events[i].TS.Before(cutoff) </span><span class="cov5" title="74">{ + for ; i < len(sf.Events); i++ </span><span class="cov4" title="78">{ + if !sf.Events[i].TS.Before(cutoff) </span><span class="cov4" title="77">{ break</span> } } - <span class="cov5" title="74">if i > 0 </span><span class="cov1" title="1">{ + <span class="cov4" title="77">if i > 0 </span><span class="cov1" title="1">{ sf.Events = append([]Event(nil), sf.Events[i:]...) }</span> } - <span class="cov5" title="74">sf.UpdatedAt = now + <span class="cov4" title="77">sf.UpdatedAt = now // Write atomically tmp, err := os.CreateTemp(dir, fileName+".tmp.") if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="74">enc := json.NewEncoder(tmp) + <span class="cov4" title="77">enc := json.NewEncoder(tmp) enc.SetEscapeHTML(false) if err := enc.Encode(&sf); err != nil </span><span class="cov0" title="0">{ tmp.Close() os.Remove(tmp.Name()) return err }</span> - <span class="cov5" title="74">if err := tmp.Sync(); err != nil </span><span class="cov0" title="0">{ + <span class="cov4" title="77">if err := tmp.Sync(); err != nil </span><span class="cov0" title="0">{ tmp.Close() os.Remove(tmp.Name()) return err }</span> - <span class="cov5" title="74">if err := tmp.Close(); err != nil </span><span class="cov0" title="0">{ + <span class="cov4" title="77">if err := tmp.Close(); err != nil </span><span class="cov0" title="0">{ os.Remove(tmp.Name()) return err }</span> - <span class="cov5" title="74">if err := os.Rename(tmp.Name(), path); err != nil </span><span class="cov0" title="0">{ + <span class="cov4" title="77">if err := os.Rename(tmp.Name(), path); err != nil </span><span class="cov0" title="0">{ os.Remove(tmp.Name()) return err }</span> - <span class="cov5" title="74">return nil</span> + <span class="cov4" title="77">return nil</span> } -func acquireFileLock(ctx context.Context, f *os.File) (func() error, error) <span class="cov5" title="74">{ +func acquireFileLock(ctx context.Context, f *os.File) (func() error, error) <span class="cov4" title="77">{ fd := f.Fd() - for </span><span class="cov6" title="227">{ + for </span><span class="cov5" title="213">{ err := tryLockFile(fd) - if err == nil </span><span class="cov5" title="74">{ - return func() error </span><span class="cov5" title="74">{ return unlockFile(fd) }</span>, nil + if err == nil </span><span class="cov4" title="77">{ + return func() error </span><span class="cov4" title="77">{ return unlockFile(fd) }</span>, nil } - <span class="cov5" title="153">if errors.Is(err, errLockWouldBlock) </span><span class="cov5" title="153">{ + <span class="cov5" title="136">if errors.Is(err, errLockWouldBlock) </span><span class="cov5" title="136">{ select </span>{ case <-ctx.Done():<span class="cov0" title="0"> return nil, ctx.Err()</span> - case <-time.After(5 * time.Millisecond):<span class="cov5" title="153"></span> + case <-time.After(5 * time.Millisecond):<span class="cov5" title="136"></span> } - <span class="cov5" title="153">continue</span> + <span class="cov5" title="136">continue</span> } <span class="cov0" title="0">return nil, err</span> } } // Snapshot reads and aggregates events within the configured window. -func TakeSnapshot() (Snapshot, error) <span class="cov5" title="64">{ +func TakeSnapshot() (Snapshot, error) <span class="cov4" title="69">{ dir, err := CacheDir() if err != nil </span><span class="cov0" title="0">{ return Snapshot{}, err }</span> - <span class="cov5" title="64">path := filepath.Join(dir, fileName) + <span class="cov4" title="69">path := filepath.Join(dir, fileName) b, err := os.ReadFile(path) if err != nil </span><span class="cov0" title="0">{ if errors.Is(err, os.ErrNotExist) </span><span class="cov0" title="0">{ @@ -7076,30 +7414,30 @@ func TakeSnapshot() (Snapshot, error) <span class="cov5" title="64">{ }</span> <span class="cov0" title="0">return Snapshot{}, err</span> } - <span class="cov5" title="64">var sf File + <span class="cov4" title="69">var sf File if err := json.Unmarshal(b, &sf); err != nil </span><span class="cov0" title="0">{ return Snapshot{}, err }</span> - <span class="cov5" title="64">win := time.Duration(sf.WindowSeconds) * time.Second + <span class="cov4" title="69">win := time.Duration(sf.WindowSeconds) * time.Second if win <= 0 </span><span class="cov0" title="0">{ win = Window() - }</span> else<span class="cov5" title="64"> { + }</span> else<span class="cov4" title="69"> { SetWindow(win) // align process with file window if changed elsewhere }</span> - <span class="cov5" title="64">cutoff := time.Now().Add(-win) + <span class="cov4" title="69">cutoff := time.Now().Add(-win) snap := Snapshot{Providers: make(map[string]ProviderEntry), Window: win} - for _, ev := range sf.Events </span><span class="cov10" title="11074">{ + for _, ev := range sf.Events </span><span class="cov10" title="25908">{ if ev.TS.Before(cutoff) </span><span class="cov0" title="0">{ continue</span> } - <span class="cov10" title="11074">snap.Global.Reqs++ + <span class="cov10" title="25908">snap.Global.Reqs++ snap.Global.Sent += ev.Sent snap.Global.Recv += ev.Recv pe := snap.Providers[ev.Provider] - if pe.Models == nil </span><span class="cov6" title="430">{ + if pe.Models == nil </span><span class="cov6" title="465">{ pe.Models = make(map[string]Counters) }</span> - <span class="cov10" title="11074">pe.Totals.Reqs++ + <span class="cov10" title="25908">pe.Totals.Reqs++ pe.Totals.Sent += ev.Sent pe.Totals.Recv += ev.Recv mc := pe.Models[ev.Model] @@ -7109,37 +7447,37 @@ func TakeSnapshot() (Snapshot, error) <span class="cov5" title="64">{ pe.Models[ev.Model] = mc snap.Providers[ev.Provider] = pe</span> } - <span class="cov5" title="64">mins := win.Minutes() + <span class="cov4" title="69">mins := win.Minutes() if mins <= 0 </span><span class="cov0" title="0">{ mins = 0.001 }</span> - <span class="cov5" title="64">snap.RPM = float64(snap.Global.Reqs) / mins + <span class="cov4" title="69">snap.RPM = float64(snap.Global.Reqs) / mins return snap, nil</span> } // CacheDir resolves the cache directory for stats. -func CacheDir() (string, error) <span class="cov5" title="139">{ - if x := os.Getenv("XDG_CACHE_HOME"); stringsTrim(x) != "" </span><span class="cov4" title="27">{ +func CacheDir() (string, error) <span class="cov5" title="147">{ + if x := os.Getenv("XDG_CACHE_HOME"); stringsTrim(x) != "" </span><span class="cov3" title="27">{ return filepath.Join(x, "hexai"), nil }</span> - <span class="cov5" title="112">home, err := os.UserHomeDir() + <span class="cov5" title="120">home, err := os.UserHomeDir() if err != nil </span><span class="cov0" title="0">{ return "", fmt.Errorf("cannot resolve home: %w", err) }</span> - <span class="cov5" title="112">return filepath.Join(home, ".cache", "hexai"), nil</span> + <span class="cov5" title="120">return filepath.Join(home, ".cache", "hexai"), nil</span> } // stringsTrim is a tiny helper to avoid importing strings everywhere here. -func stringsTrim(s string) string <span class="cov5" title="139">{ +func stringsTrim(s string) string <span class="cov5" title="147">{ i := 0 j := len(s) for i < j && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') </span><span class="cov0" title="0">{ i++ }</span> - <span class="cov5" title="139">for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') </span><span class="cov0" title="0">{ + <span class="cov5" title="147">for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') </span><span class="cov0" title="0">{ j-- }</span> - <span class="cov5" title="139">if i == 0 && j == len(s) </span><span class="cov5" title="139">{ + <span class="cov5" title="147">if i == 0 && j == len(s) </span><span class="cov5" title="147">{ return s }</span> <span class="cov0" title="0">return s[i:j]</span> @@ -7151,7 +7489,7 @@ func (s Snapshot) DebugString() string <span class="cov1" title="1">{ }</span> </pre> - <pre class="file" id="file35" style="display: none">package testutil + <pre class="file" id="file37" style="display: none">package testutil // MultilineDocBlock returns a realistic multi-line documentation block. func MultilineDocBlock() string <span class="cov8" title="1">{ @@ -7179,83 +7517,83 @@ func MalformedJSON() string <span class="cov8" title="1">{ }</span> </pre> - <pre class="file" id="file36" style="display: none">package textutil + <pre class="file" id="file38" style="display: none">package textutil import "fmt" // HumanBytes renders n in a short human-friendly form using base-1000 units. // Examples: 999 -> 999B, 1200 -> 1.2k, 1540000 -> 1.5M -func HumanBytes(n int64) string <span class="cov10" title="128">{ +func HumanBytes(n int64) string <span class="cov10" title="138">{ if n < 1000 </span><span class="cov2" title="2">{ return fmt.Sprintf("%dB", n) }</span> - <span class="cov9" title="126">const unit = 1000.0 + <span class="cov9" title="136">const unit = 1000.0 v := float64(n) suffix := []string{"k", "M", "G", "T"} i := 0 - for v >= unit && i < len(suffix)-1 </span><span class="cov9" title="126">{ + for v >= unit && i < len(suffix)-1 </span><span class="cov9" title="136">{ v /= unit i++ }</span> - <span class="cov9" title="126">s := fmt.Sprintf("%.1f%s", v, suffix[i]) + <span class="cov9" title="136">s := fmt.Sprintf("%.1f%s", v, suffix[i]) // Strip trailing ".0" if len(s) >= 3 && s[len(s)-2:] == ".0" </span><span class="cov0" title="0">{ s = fmt.Sprintf("%d%s", int(v), suffix[i]) }</span> - <span class="cov9" title="126">return s</span> + <span class="cov9" title="136">return s</span> } </pre> - <pre class="file" id="file37" style="display: none">package textutil + <pre class="file" id="file39" style="display: none">package textutil import "strings" // RenderTemplate performs simple {{var}} replacement in a template string. -func RenderTemplate(t string, vars map[string]string) string <span class="cov8" title="58">{ - if t == "" || len(vars) == 0 </span><span class="cov5" title="11">{ +func RenderTemplate(t string, vars map[string]string) string <span class="cov8" title="63">{ + if t == "" || len(vars) == 0 </span><span class="cov3" title="5">{ return t }</span> - <span class="cov8" title="47">out := t - for k, v := range vars </span><span class="cov10" title="115">{ + <span class="cov8" title="58">out := t + for k, v := range vars </span><span class="cov10" title="156">{ out = strings.ReplaceAll(out, "{{"+k+"}}", v) }</span> - <span class="cov8" title="47">return out</span> + <span class="cov8" title="58">return out</span> } // StripCodeFences removes surrounding Markdown triple-backtick fences. -func StripCodeFences(s string) string <span class="cov8" title="65">{ +func StripCodeFences(s string) string <span class="cov8" title="69">{ t := strings.TrimSpace(s) if t == "" </span><span class="cov1" title="1">{ return t }</span> - <span class="cov8" title="64">lines := strings.Split(t, "\n") + <span class="cov8" title="68">lines := strings.Split(t, "\n") start := 0 for start < len(lines) && strings.TrimSpace(lines[start]) == "" </span><span class="cov0" title="0">{ start++ }</span> - <span class="cov8" title="64">end := len(lines) - 1 + <span class="cov8" title="68">end := len(lines) - 1 for end >= 0 && strings.TrimSpace(lines[end]) == "" </span><span class="cov0" title="0">{ end-- }</span> - <span class="cov8" title="64">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ + <span class="cov8" title="68">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ return t }</span> - <span class="cov8" title="64">first := strings.TrimSpace(lines[start]) + <span class="cov8" title="68">first := strings.TrimSpace(lines[start]) last := strings.TrimSpace(lines[end]) if strings.HasPrefix(first, "```") && last == "```" && end > start </span><span class="cov6" title="20">{ inner := strings.Join(lines[start+1:end], "\n") return inner }</span> - <span class="cov8" title="44">return t</span> + <span class="cov7" title="48">return t</span> } // InstructionFromSelection extracts the first inline instruction and returns // (instruction, cleanedSelection). It detects markers on the earliest position // per line in precedence: strict ;text;, /* */, <!-- -->, //, #, --. -func InstructionFromSelection(sel string) (string, string) <span class="cov6" title="14">{ +func InstructionFromSelection(sel string) (string, string) <span class="cov5" title="14">{ lines := strings.Split(sel, "\n") - for idx, line := range lines </span><span class="cov6" title="14">{ - if instr, cleaned, ok := FindFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" </span><span class="cov6" title="14">{ + for idx, line := range lines </span><span class="cov5" title="14">{ + if instr, cleaned, ok := FindFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" </span><span class="cov5" title="14">{ lines[idx] = cleaned return instr, strings.Join(lines, "\n") }</span> @@ -7264,16 +7602,16 @@ func InstructionFromSelection(sel string) (string, string) <span class="cov6" ti } // FindFirstInstructionInLine returns (instruction, cleaned, ok) for a single line. -func FindFirstInstructionInLine(line string) (instr, cleaned string, ok bool) <span class="cov6" title="15">{ +func FindFirstInstructionInLine(line string) (instr, cleaned string, ok bool) <span class="cov5" title="15">{ type cand struct { start, end int text string } cands := []cand{} - if t, l, r, ok := FindStrictInlineTag(line); ok </span><span class="cov4" title="5">{ + if t, l, r, ok := FindStrictInlineTag(line); ok </span><span class="cov3" title="5">{ cands = append(cands, cand{start: l, end: r, text: t}) }</span> - <span class="cov6" title="15">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov2" title="2">{ + <span class="cov5" title="15">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov2" title="2">{ if j := strings.Index(line[i+2:], "*/"); j >= 0 </span><span class="cov2" title="2">{ start := i end := i + 2 + j + 2 @@ -7281,7 +7619,7 @@ func FindFirstInstructionInLine(line string) (instr, cleaned string, ok bool) <s cands = append(cands, cand{start: start, end: end, text: text}) }</span> } - <span class="cov6" title="15">if i := strings.Index(line, "<!--"); i >= 0 </span><span class="cov2" title="2">{ + <span class="cov5" title="15">if i := strings.Index(line, "<!--"); i >= 0 </span><span class="cov2" title="2">{ if j := strings.Index(line[i+4:], "-->"); j >= 0 </span><span class="cov2" title="2">{ start := i end := i + 4 + j + 3 @@ -7289,25 +7627,25 @@ func FindFirstInstructionInLine(line string) (instr, cleaned string, ok bool) <s cands = append(cands, cand{start: start, end: end, text: text}) }</span> } - <span class="cov6" title="15">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov3" title="3">{ + <span class="cov5" title="15">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov2" title="3">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }</span> - <span class="cov6" title="15">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov2" title="2">{ + <span class="cov5" title="15">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov2" title="2">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) }</span> - <span class="cov6" title="15">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov3" title="4">{ + <span class="cov5" title="15">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov3" title="4">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }</span> - <span class="cov6" title="15">if len(cands) == 0 </span><span class="cov0" title="0">{ + <span class="cov5" title="15">if len(cands) == 0 </span><span class="cov0" title="0">{ return "", line, false }</span> - <span class="cov6" title="15">best := cands[0] - for _, c := range cands[1:] </span><span class="cov3" title="3">{ + <span class="cov5" title="15">best := cands[0] + for _, c := range cands[1:] </span><span class="cov2" title="3">{ if c.start >= 0 && (best.start < 0 || c.start < best.start) </span><span class="cov0" title="0">{ best = c }</span> } - <span class="cov6" title="15">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + <span class="cov5" title="15">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") return best.text, cleaned, true</span> } @@ -7320,7 +7658,7 @@ func FindStrictInlineTag(line string) (text string, left, right int, ok bool) <s <span class="cov4" title="8">if i+1 < len(line) && line[i+1] == ' ' </span><span class="cov1" title="1">{ continue</span> } - <span class="cov4" title="7">for j := i + 1; j < len(line); j++ </span><span class="cov8" title="41">{ + <span class="cov4" title="7">for j := i + 1; j < len(line); j++ </span><span class="cov7" title="41">{ if line[j] == ';' </span><span class="cov4" title="6">{ if j-1 >= 0 && line[j-1] == ' ' </span><span class="cov0" title="0">{ continue</span> @@ -7336,7 +7674,7 @@ func FindStrictInlineTag(line string) (text string, left, right int, ok bool) <s } </pre> - <pre class="file" id="file38" style="display: none">package tmux + <pre class="file" id="file40" style="display: none">package tmux import ( "fmt" @@ -7360,30 +7698,30 @@ const ( ) // Enabled reports whether tmux status updates are enabled via env (default: on). -func Enabled() bool <span class="cov7" title="72">{ +func Enabled() bool <span class="cov8" title="77">{ v := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS")) - if v == "" </span><span class="cov7" title="72">{ + if v == "" </span><span class="cov7" title="74">{ return true }</span> - <span class="cov0" title="0">v = strings.ToLower(v) + <span class="cov2" title="3">v = strings.ToLower(v) return v == "1" || v == "true" || v == "yes" || v == "on"</span> } // SetUserOption sets a global tmux user option like @hexai_status to value. -func SetUserOption(key, value string) error <span class="cov7" title="72">{ - if !Enabled() || !HasBinary() || !InSession() </span><span class="cov0" title="0">{ +func SetUserOption(key, value string) error <span class="cov8" title="77">{ + if !Enabled() || !HasBinary() || !InSession() </span><span class="cov2" title="3">{ return nil }</span> - <span class="cov7" title="72">k := strings.TrimPrefix(strings.TrimSpace(key), "@") + <span class="cov7" title="74">k := strings.TrimPrefix(strings.TrimSpace(key), "@") if k == "" </span><span class="cov0" title="0">{ return nil }</span> // Use set-option -g so it appears for all windows - <span class="cov7" title="72">return exec.Command("tmux", "set-option", "-g", "@"+k, value).Run()</span> + <span class="cov7" title="74">return exec.Command("tmux", "set-option", "-g", "@"+k, value).Run()</span> } // SetStatus is a convenience for setting @hexai_status. -func SetStatus(value string) error <span class="cov7" title="72">{ return SetUserOption("hexai_status", applyTheme(value)) }</span> +func SetStatus(value string) error <span class="cov8" title="77">{ return SetUserOption("hexai_status", applyTheme(value)) }</span> // FormatLLMStatsStatus builds a compact tmux status string for LLM heartbeats. // Example: "LLM:gpt-4.1 5r 0.8rpm in12k out34k" @@ -7409,7 +7747,7 @@ func FormatLLMStatsStatusColored(provider, model string, reqs int64, rpm float64 // 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 <span class="cov7" title="62">{ +func FormatGlobalStatusColored(globalReqs int64, globalRPM float64, globalIn, globalOut int64, scopeProvider, scopeModel string, scopeRPM float64, scopeReqs int64, window time.Duration) string <span class="cov7" title="67">{ 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) @@ -7417,7 +7755,7 @@ func FormatGlobalStatusColored(globalReqs int64, globalRPM float64, globalIn, gl if narrowEnabled() || stringsTrim(scopeProvider) == "" || stringsTrim(scopeModel) == "" </span><span class="cov1" title="1">{ return head }</span> - <span class="cov7" title="61">tail := fmt.Sprintf(" | %s:%s %.1frpm %dr", scopeProvider, scopeModel, scopeRPM, scopeReqs) + <span class="cov7" title="66">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 </span><span class="cov1" title="1">{ if len(head) <= ml && len(head)+len(tail) > ml </span><span class="cov0" title="0">{ @@ -7427,15 +7765,15 @@ func FormatGlobalStatusColored(globalReqs int64, globalRPM float64, globalIn, gl return truncateStatus(head, ml) }</span> } - <span class="cov7" title="60">return head + tail</span> + <span class="cov7" title="65">return head + tail</span> } -func humanWindow(d time.Duration) string <span class="cov7" title="62">{ +func humanWindow(d time.Duration) string <span class="cov7" title="67">{ if d <= 0 </span><span class="cov0" title="0">{ return "?" }</span> - <span class="cov7" title="62">mins := int(d.Minutes()) - if mins%60 == 0 </span><span class="cov7" title="60">{ + <span class="cov7" title="67">mins := int(d.Minutes()) + if mins%60 == 0 </span><span class="cov7" title="65">{ return fmt.Sprintf("%dh", mins/60) }</span> <span class="cov2" title="2">if mins >= 60 </span><span class="cov0" title="0">{ @@ -7445,9 +7783,9 @@ func humanWindow(d time.Duration) string <span class="cov7" title="62">{ } // narrowEnabled returns true when HEXAI_TMUX_STATUS_NARROW is truthy (1/true/yes/on). -func narrowEnabled() bool <span class="cov7" title="62">{ +func narrowEnabled() bool <span class="cov7" title="67">{ v := strings.ToLower(stringsTrim(os.Getenv("HEXAI_TMUX_STATUS_NARROW"))) - if v == "" </span><span class="cov7" title="61">{ + if v == "" </span><span class="cov7" title="66">{ return false }</span> <span class="cov1" title="1">switch v </span>{ @@ -7459,9 +7797,9 @@ func narrowEnabled() bool <span class="cov7" title="62">{ } // maxStatusLen returns HEXAI_TMUX_STATUS_MAXLEN parsed as int; 0 disables. -func maxStatusLen() int <span class="cov7" title="61">{ +func maxStatusLen() int <span class="cov7" title="66">{ v := stringsTrim(os.Getenv("HEXAI_TMUX_STATUS_MAXLEN")) - if v == "" </span><span class="cov7" title="60">{ + if v == "" </span><span class="cov7" title="65">{ return 0 }</span> <span class="cov1" title="1">n, err := strconv.Atoi(v) @@ -7484,16 +7822,16 @@ func truncateStatus(s string, n int) string <span class="cov1" title="1">{ <span class="cov1" title="1">return s[:n-1] + "…"</span> } -func stringsTrim(s string) string <span class="cov10" title="245">{ +func stringsTrim(s string) string <span class="cov10" title="265">{ i := 0 j := len(s) for i < j && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') </span><span class="cov0" title="0">{ i++ }</span> - <span class="cov10" title="245">for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') </span><span class="cov0" title="0">{ + <span class="cov10" title="265">for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') </span><span class="cov0" title="0">{ j-- }</span> - <span class="cov10" title="245">if i == 0 && j == len(s) </span><span class="cov10" title="245">{ + <span class="cov10" title="265">if i == 0 && j == len(s) </span><span class="cov10" title="265">{ return s }</span> <span class="cov0" title="0">return s[i:j]</span> @@ -7507,7 +7845,7 @@ func FormatLLMStartStatus(provider, model string) string <span class="cov5" titl // applyTheme wraps the status string with a user-selected tmux style if requested. // Set HEXAI_TMUX_STATUS_THEME=white-on-purple to get white-on-purple background. -func applyTheme(s string) string <span class="cov7" title="72">{ +func applyTheme(s string) string <span class="cov8" title="77">{ theme := strings.ToLower(strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS_THEME"))) // Allow explicit fg/bg override fg := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS_FG")) @@ -7523,23 +7861,23 @@ func applyTheme(s string) string <span class="cov7" title="72">{ baseFG = fg }</span> // bg used as provided (may be empty) - } else<span class="cov7" title="72"> { + } else<span class="cov8" title="77"> { switch theme </span>{ - case "white-on-purple", "purple", "magenta", "white-on-magenta":<span class="cov7" title="72"> + case "white-on-purple", "purple", "magenta", "white-on-magenta":<span class="cov8" title="77"> baseFG, bg, wrap = "white", "magenta", true</span> case "black-on-yellow", "yellow", "black-on-gold":<span class="cov0" title="0"> baseFG, bg, wrap = "black", "yellow", true</span> case "white-on-blue", "blue", "white-on-navy":<span class="cov0" title="0"> baseFG, bg, wrap = "white", "blue", true</span> } - <span class="cov7" title="72">if baseFG == "" </span><span class="cov0" title="0">{ // no theme selected + <span class="cov8" title="77">if baseFG == "" </span><span class="cov0" title="0">{ // no theme selected baseFG = "default" }</span> } // Theme-aware arrow styles - <span class="cov7" title="72">upStyle, downStyle := "#[fg=colour3]", "#[fg=colour2]" // defaults: yellow up, green down - if fg != "" || bg != "" </span><span class="cov7" title="72">{ // explicit override path: match arrows to base fg, bold for visibility + <span class="cov8" title="77">upStyle, downStyle := "#[fg=colour3]", "#[fg=colour2]" // defaults: yellow up, green down + if fg != "" || bg != "" </span><span class="cov8" title="77">{ // explicit override path: match arrows to base fg, bold for visibility upStyle = "#[bold,fg=" + baseFG + "]" downStyle = upStyle }</span> else<span class="cov0" title="0"> { @@ -7554,30 +7892,30 @@ func applyTheme(s string) string <span class="cov7" title="72">{ } // Replace base-foreground and arrow placeholders with selected styles - <span class="cov7" title="72">if strings.Contains(s, baseFGToken) </span><span class="cov7" title="72">{ + <span class="cov8" title="77">if strings.Contains(s, baseFGToken) </span><span class="cov8" title="77">{ s = strings.ReplaceAll(s, baseFGToken, "#[fg="+baseFG+"]") }</span> - <span class="cov7" title="72">if strings.Contains(s, arrowUpToken) </span><span class="cov7" title="60">{ + <span class="cov8" title="77">if strings.Contains(s, arrowUpToken) </span><span class="cov7" title="65">{ s = strings.ReplaceAll(s, arrowUpToken, upStyle) }</span> - <span class="cov7" title="72">if strings.Contains(s, arrowDownToken) </span><span class="cov7" title="60">{ + <span class="cov8" title="77">if strings.Contains(s, arrowDownToken) </span><span class="cov7" title="65">{ s = strings.ReplaceAll(s, arrowDownToken, downStyle) }</span> - <span class="cov7" title="72">if !wrap </span><span class="cov0" title="0">{ + <span class="cov8" title="77">if !wrap </span><span class="cov0" title="0">{ return s }</span> // Wrap with base fg and optional bg, then reset at the end - <span class="cov7" title="72">prefix := "#[fg=" + baseFG - if bg != "" </span><span class="cov7" title="72">{ + <span class="cov8" title="77">prefix := "#[fg=" + baseFG + if bg != "" </span><span class="cov8" title="77">{ prefix += ",bg=" + bg }</span> - <span class="cov7" title="72">prefix += "]" + <span class="cov8" title="77">prefix += "]" return prefix + s + "#[fg=default,bg=default]"</span> } </pre> - <pre class="file" id="file39" style="display: none">package tmux + <pre class="file" id="file41" style="display: none">package tmux import ( "os" @@ -7595,10 +7933,10 @@ var ( command = exec.Command ) -func HasBinary() bool <span class="cov10" title="76">{ _, err := lookPath("tmux"); return err == nil }</span> +func HasBinary() bool <span class="cov10" title="78">{ _, err := lookPath("tmux"); return err == nil }</span> // InSession reports whether we seem to be running inside a tmux session. -func InSession() bool <span class="cov9" title="75">{ return strings.TrimSpace(os.Getenv("TMUX")) != "" }</span> +func InSession() bool <span class="cov9" title="77">{ return strings.TrimSpace(os.Getenv("TMUX")) != "" }</span> // SplitOpts controls how a new pane is created for running a command. type SplitOpts struct { |
