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 | |
| parent | 127844a4ee481590ef53b6777d34bf2114cb3ab1 (diff) | |
Add runtime config store and reload command
44 files changed, 23302 insertions, 18371 deletions
@@ -0,0 +1,25 @@ +# Runtime Model Configuration Plan + +Implement a /reload> endpoint that reloads all the configuration from hexai.toml and updates the running application's state without requiring a restart. + +## Progress +- [x] Phase 1 – Design approach for making settings dynamically changeable +- [x] Phase 2 – Implement dynamic configuration plumbing +- [x] Phase 3 – Expose `/reload>` command and emit change summary (file values override env on reload) + +## Phase 1 Notes (in progress) +- Current config flow: each entry point calls `appconfig.Load(logger)` which merges defaults + `config.toml` + env overrides, then copies fields into long-lived structs (e.g. `lsp.Server`). +- LSP server captures many scalar copies (`maxTokens`, prompts, triggers, stats window), so runtime changes require a reapply step that updates these cached fields plus the `llm.Client` instance and stats window. +- Proposed shape: introduce a central runtime config manager wrapping `appconfig.App` with an `RWMutex`, diff helpers, and subscription callbacks. All components pull the latest snapshot or subscribe to updates instead of keeping independent copies. +- Reload path should reuse shared loader logic that can optionally skip env overrides so `/reload>` can make file values authoritative. +- Applying updates must be atomic per component (e.g. server lock + swap) and should emit a structured list of changed settings for user feedback. +- Manager responsibilities: (1) hold current snapshot, (2) surface `Subscribe(func(old, new appconfig.App))` for live update hooks, (3) expose `Reload(ctx)` that re-parses `config.toml`, produces diff keys, updates snapshot, and returns the changes for logging/UX. +- LSP integration: pass manager into `RunWithFactory`, add `Server.applyAppConfig(cfg appconfig.App)` guarded by lock, rebuild `llm.Client` when provider/model change, refresh prompts/triggers/debounce/temps, and re-run `initializeModelConfig`/stats window. +- Logging/stats: ensure updates propagate (e.g., call `stats.SetWindow` within manager or server update when `StatsWindowMinutes` changes) so runtime metrics align with new config. +- `/reload>` command: hook into existing chat command detection (extend `chatCommandResponse`) to invoke manager `Reload`, then write the diff summary back to the buffer as a synthetic assistant response. + +## Phase 2 Notes (progress) +- Added `appconfig.LoadWithOptions` to support skipping env overrides and introduced a `runtimeconfig.Store` (subscribe + diff) as the runtime configuration backbone. +- `hexailsp.RunWithFactory` now builds a shared store, subscribes the LSP server to config updates, and rebuilds the LLM client + stats/log settings when config changes. +- Implemented CLI-style slash commands in the LSP (`/reload>` currently) so runtime reloads can be triggered without restarting; reload skips env overrides and reports diffed keys. +- Remaining work: surface change summaries inside the server logs, ensure other entry points (CLI/action) share the same store, and add verification around env override precedence. diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index e03ca81..dba529c 100644 --- a/SCRATCHPAD.md +++ b/SCRATCHPAD.md @@ -4,25 +4,12 @@ This document shows future items and items in progress. Already completed ones a ## Features -* [ ] For in-editor chat add a way to print current hexai status such as - * active LLM - * active stats - * current config printed out - * could use a special keyword like /status?TRIGGER (e.g. > as TRIGGER) - * what other slasm commands could we think of? -* [ ] Be able to switch LLMs ad-hoc. +* [ ] hexai cli to keep context for the follow-up question/prompt? +* [/] Be able to switch LLMs ad-hoc by re-reading the config. ## More -* [ ] Configure hexai-lsp multiple times, but with different LLM backends? E.g. a remote cloud one and a local one? +* [ ] Exclude the test coverage files from git and wipe them from the history * [/] Review documentation * [/] Manual review the code -* [ ] Useful: https://deepwiki.com/helix-editor/helix/4.3-language-server-protocol -* [/] Code review with another LLM - -## Additional Ideas (Nice to Have) - -* **LSP: Inlay Hints (`textDocument/inlayHint`)**: Use AI to provide dynamic, inline hints like inferred types, performance warnings (e.g., `O(n^2)`), or security alerts. -* **LSP: Hover (`textDocument/hover`)**: Enrich hover popups with AI-generated natural language explanations of code, usage examples, and complexity analysis. -* **LSP: Semantic Tokens (`textDocument/semanticTokens`)**: Implement semantic tokenization to build a deeper understanding of the code, which would improve the accuracy and context-awareness of all other AI features. -* **LSP: Rename (`textDocument/rename`)**: Add safe rename capabilities, potentially enhanced with AI to proactively suggest better, more descriptive names for variables and functions. +* [ ] ASCIInema: Record and share terminal sessions for demos and bug reports 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 < |
