diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-06 23:23:57 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-06 23:23:57 +0200 |
| commit | 0176d2dd4ddce82e9752c2f3ca4023a1c82b221e (patch) | |
| tree | 7c1e1989c698fad2c4025f56abdcd58d2b2d1026 /docs/coverage.html | |
| parent | c8dd76141098a66767883c6eb21dd47ebcf10269 (diff) | |
coverage
Diffstat (limited to 'docs/coverage.html')
| -rw-r--r-- | docs/coverage.html | 4795 |
1 files changed, 2677 insertions, 2118 deletions
diff --git a/docs/coverage.html b/docs/coverage.html index 4526ad1..f0b0e08 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -61,7 +61,7 @@ <option value="file2">codeberg.org/snonux/hexai/cmd/hexai/main.go (61.9%)</option> - <option value="file3">codeberg.org/snonux/hexai/internal/appconfig/config.go (82.5%)</option> + <option value="file3">codeberg.org/snonux/hexai/internal/appconfig/config.go (81.1%)</option> <option value="file4">codeberg.org/snonux/hexai/internal/editor/editor.go (58.3%)</option> @@ -79,67 +79,69 @@ <option value="file11">codeberg.org/snonux/hexai/internal/hexaiaction/tui_delegate.go (100.0%)</option> - <option value="file12">codeberg.org/snonux/hexai/internal/hexaicli/run.go (72.3%)</option> + <option value="file12">codeberg.org/snonux/hexai/internal/hexaicli/run.go (71.6%)</option> - <option value="file13">codeberg.org/snonux/hexai/internal/hexailsp/run.go (88.9%)</option> + <option value="file13">codeberg.org/snonux/hexai/internal/hexailsp/run.go (88.3%)</option> - <option value="file14">codeberg.org/snonux/hexai/internal/llm/copilot.go (82.4%)</option> + <option value="file14">codeberg.org/snonux/hexai/internal/llm/anthropic.go (80.4%)</option> - <option value="file15">codeberg.org/snonux/hexai/internal/llm/ollama.go (89.8%)</option> + <option value="file15">codeberg.org/snonux/hexai/internal/llm/copilot.go (81.6%)</option> - <option value="file16">codeberg.org/snonux/hexai/internal/llm/openai.go (87.1%)</option> + <option value="file16">codeberg.org/snonux/hexai/internal/llm/ollama.go (88.7%)</option> - <option value="file17">codeberg.org/snonux/hexai/internal/llm/openrouter.go (76.2%)</option> + <option value="file17">codeberg.org/snonux/hexai/internal/llm/openai.go (86.4%)</option> - <option value="file18">codeberg.org/snonux/hexai/internal/llm/provider.go (86.0%)</option> + <option value="file18">codeberg.org/snonux/hexai/internal/llm/openrouter.go (75.8%)</option> - <option value="file19">codeberg.org/snonux/hexai/internal/llm/util.go (100.0%)</option> + <option value="file19">codeberg.org/snonux/hexai/internal/llm/provider.go (75.5%)</option> - <option value="file20">codeberg.org/snonux/hexai/internal/llmutils/client.go (100.0%)</option> + <option value="file20">codeberg.org/snonux/hexai/internal/llm/util.go (100.0%)</option> - <option value="file21">codeberg.org/snonux/hexai/internal/logging/chatlogger.go (100.0%)</option> + <option value="file21">codeberg.org/snonux/hexai/internal/llmutils/client.go (100.0%)</option> - <option value="file22">codeberg.org/snonux/hexai/internal/logging/logging.go (90.9%)</option> + <option value="file22">codeberg.org/snonux/hexai/internal/logging/chatlogger.go (100.0%)</option> - <option value="file23">codeberg.org/snonux/hexai/internal/lsp/chat_commands.go (83.3%)</option> + <option value="file23">codeberg.org/snonux/hexai/internal/logging/logging.go (90.9%)</option> - <option value="file24">codeberg.org/snonux/hexai/internal/lsp/context.go (74.4%)</option> + <option value="file24">codeberg.org/snonux/hexai/internal/lsp/chat_commands.go (83.3%)</option> - <option value="file25">codeberg.org/snonux/hexai/internal/lsp/document.go (91.5%)</option> + <option value="file25">codeberg.org/snonux/hexai/internal/lsp/context.go (74.4%)</option> - <option value="file26">codeberg.org/snonux/hexai/internal/lsp/handlers.go (89.8%)</option> + <option value="file26">codeberg.org/snonux/hexai/internal/lsp/document.go (91.5%)</option> - <option value="file27">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (82.0%)</option> + <option value="file27">codeberg.org/snonux/hexai/internal/lsp/handlers.go (89.8%)</option> - <option value="file28">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (79.0%)</option> + <option value="file28">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (82.0%)</option> - <option value="file29">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (78.1%)</option> + <option value="file29">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (76.4%)</option> - <option value="file30">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (75.0%)</option> + <option value="file30">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (78.1%)</option> - <option value="file31">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (66.7%)</option> + <option value="file31">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (75.0%)</option> - <option value="file32">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (85.3%)</option> + <option value="file32">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (66.7%)</option> - <option value="file33">codeberg.org/snonux/hexai/internal/lsp/server.go (82.1%)</option> + <option value="file33">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (85.5%)</option> - <option value="file34">codeberg.org/snonux/hexai/internal/lsp/transport.go (73.0%)</option> + <option value="file34">codeberg.org/snonux/hexai/internal/lsp/server.go (79.8%)</option> - <option value="file35">codeberg.org/snonux/hexai/internal/runtimeconfig/store.go (88.1%)</option> + <option value="file35">codeberg.org/snonux/hexai/internal/lsp/transport.go (73.0%)</option> - <option value="file36">codeberg.org/snonux/hexai/internal/stats/lock_posix.go (83.3%)</option> + <option value="file36">codeberg.org/snonux/hexai/internal/runtimeconfig/store.go (88.1%)</option> - <option value="file37">codeberg.org/snonux/hexai/internal/stats/stats.go (75.8%)</option> + <option value="file37">codeberg.org/snonux/hexai/internal/stats/lock_posix.go (83.3%)</option> - <option value="file38">codeberg.org/snonux/hexai/internal/testutil/fixtures.go (100.0%)</option> + <option value="file38">codeberg.org/snonux/hexai/internal/stats/stats.go (76.0%)</option> - <option value="file39">codeberg.org/snonux/hexai/internal/textutil/human.go (92.3%)</option> + <option value="file39">codeberg.org/snonux/hexai/internal/testutil/fixtures.go (100.0%)</option> - <option value="file40">codeberg.org/snonux/hexai/internal/textutil/textutil.go (90.4%)</option> + <option value="file40">codeberg.org/snonux/hexai/internal/textutil/human.go (92.3%)</option> - <option value="file41">codeberg.org/snonux/hexai/internal/tmux/status.go (76.7%)</option> + <option value="file41">codeberg.org/snonux/hexai/internal/textutil/textutil.go (90.4%)</option> - <option value="file42">codeberg.org/snonux/hexai/internal/tmux/tmux.go (88.6%)</option> + <option value="file42">codeberg.org/snonux/hexai/internal/tmux/status.go (76.7%)</option> + + <option value="file43">codeberg.org/snonux/hexai/internal/tmux/tmux.go (88.6%)</option> </select> </div> @@ -177,13 +179,13 @@ import ( "codeberg.org/snonux/hexai/internal/hexailsp" ) -func main() <span class="cov8" title="1">{ +func main() <span class="cov10" title="3">{ logPath := flag.String("log", "/tmp/hexai-lsp.log", "path to log file (optional)") defaultCfg := defaultConfigPath() configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultCfg)) showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() - if *showVersion </span><span class="cov8" title="1">{ + if *showVersion </span><span class="cov10" title="3">{ log.Println(internal.Version) return }</span> @@ -194,12 +196,12 @@ func main() <span class="cov8" title="1">{ }</span> } -func defaultConfigPath() string <span class="cov8" title="1">{ +func defaultConfigPath() string <span class="cov10" title="3">{ path, err := appconfig.ConfigPath() if err != nil </span><span class="cov0" title="0">{ return "$XDG_CONFIG_HOME/hexai/config.toml" }</span> - <span class="cov8" title="1">return path</span> + <span class="cov10" title="3">return path</span> } </pre> @@ -268,34 +270,34 @@ import ( "codeberg.org/snonux/hexai/internal/hexaicli" ) -func main() <span class="cov8" title="1">{ +func main() <span class="cov10" title="3">{ configPath, remaining := splitConfigPath(os.Args[1:]) logger := log.New(io.Discard, "", 0) cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPath}) cliEntries := cfg.CLIConfigs - if len(cliEntries) == 0 </span><span class="cov8" title="1">{ + if len(cliEntries) == 0 </span><span class="cov10" title="3">{ cliEntries = []appconfig.SurfaceConfig{{Provider: cfg.Provider}} }</span> - <span class="cov8" title="1">fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + <span class="cov10" title="3">fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) defaultPath := defaultConfigPath() configFlag := fs.String("config", configPath, fmt.Sprintf("path to config file (default: %s)", defaultPath)) showVersion := fs.Bool("version", false, "print version and exit") selectedFlags := make([]bool, len(cliEntries)) - for i, entry := range cliEntries </span><span class="cov8" title="1">{ + for i, entry := range cliEntries </span><span class="cov10" title="3">{ name := strconv.Itoa(i) provider := strings.TrimSpace(entry.Provider) - if provider == "" </span><span class="cov8" title="1">{ + if provider == "" </span><span class="cov10" title="3">{ provider = cfg.Provider }</span> - <span class="cov8" title="1">model := strings.TrimSpace(entry.Model) - if model == "" </span><span class="cov8" title="1">{ + <span class="cov10" title="3">model := strings.TrimSpace(entry.Model) + if model == "" </span><span class="cov10" title="3">{ model = pickDefaultModel(cfg, provider) }</span> - <span class="cov8" title="1">desc := fmt.Sprintf("use only provider #%d (%s:%s)", i, provider, model) + <span class="cov10" title="3">desc := fmt.Sprintf("use only provider #%d (%s:%s)", i, provider, model) fs.BoolVar(&selectedFlags[i], name, false, desc)</span> } - <span class="cov8" title="1">_ = fs.Parse(remaining) - if *showVersion </span><span class="cov8" title="1">{ + <span class="cov10" title="3">_ = fs.Parse(remaining) + if *showVersion </span><span class="cov10" title="3">{ fmt.Fprintln(os.Stdout, internal.Version) return }</span> @@ -321,16 +323,16 @@ func main() <span class="cov8" title="1">{ }</span> } -func splitConfigPath(args []string) (string, []string) <span class="cov8" title="1">{ +func splitConfigPath(args []string) (string, []string) <span class="cov10" title="3">{ var path string rest := make([]string, 0, len(args)) skip := false - for i := 0; i < len(args); i++ </span><span class="cov8" title="1">{ + for i := 0; i < len(args); i++ </span><span class="cov10" title="3">{ if skip </span><span class="cov0" title="0">{ skip = false continue</span> } - <span class="cov8" title="1">arg := args[i] + <span class="cov10" title="3">arg := args[i] switch </span>{ case arg == "--config" || arg == "-config":<span class="cov0" title="0"> if i+1 < len(args) </span><span class="cov0" title="0">{ @@ -341,30 +343,30 @@ func splitConfigPath(args []string) (string, []string) <span class="cov8" title= path = arg[len("--config="):]</span> case strings.HasPrefix(arg, "-config="):<span class="cov0" title="0"> path = arg[len("-config="):]</span> - default:<span class="cov8" title="1"> + default:<span class="cov10" title="3"> rest = append(rest, arg)</span> } } - <span class="cov8" title="1">return strings.TrimSpace(path), rest</span> + <span class="cov10" title="3">return strings.TrimSpace(path), rest</span> } -func pickDefaultModel(cfg appconfig.App, provider string) string <span class="cov8" title="1">{ +func pickDefaultModel(cfg appconfig.App, provider string) string <span class="cov10" title="3">{ switch strings.ToLower(strings.TrimSpace(provider)) </span>{ case "ollama":<span class="cov0" title="0"> return strings.TrimSpace(cfg.OllamaModel)</span> case "copilot":<span class="cov0" title="0"> return strings.TrimSpace(cfg.CopilotModel)</span> - default:<span class="cov8" title="1"> + default:<span class="cov10" title="3"> return strings.TrimSpace(cfg.OpenAIModel)</span> } } -func defaultConfigPath() string <span class="cov8" title="1">{ +func defaultConfigPath() string <span class="cov10" title="3">{ cfgPath, err := appconfig.ConfigPath() if err != nil </span><span class="cov0" title="0">{ return "$XDG_CONFIG_HOME/hexai/config.toml" }</span> - <span class="cov8" title="1">return cfgPath</span> + <span class="cov10" title="3">return cfgPath</span> } </pre> @@ -397,6 +399,7 @@ type App struct { ContextWindowLines int `json:"context_window_lines" toml:"context_window_lines"` MaxContextTokens int `json:"max_context_tokens" toml:"max_context_tokens"` LogPreviewLimit int `json:"log_preview_limit" toml:"log_preview_limit"` + RequestTimeout int `json:"request_timeout" toml:"request_timeout"` // Single knob for LSP requests; if set, overrides hardcoded temps in LSP. CodingTemperature *float64 `json:"coding_temperature" toml:"coding_temperature"` // Minimum identifier characters required for manual (TriggerKind=1) invoke @@ -410,6 +413,10 @@ type App struct { // Completion throttle in milliseconds. When > 0, caps the minimum spacing // between LLM requests (both chat and code-completer paths). CompletionThrottleMs int `json:"completion_throttle_ms" toml:"completion_throttle_ms"` + // CompletionWaitAll controls whether to wait for all configured completion + // backends before returning results. When true (default), waits for all + // backends. When false, returns the first result immediately. + CompletionWaitAll *bool `json:"completion_wait_all" toml:"completion_wait_all"` TriggerCharacters []string `json:"trigger_characters" toml:"trigger_characters"` Provider string `json:"provider" toml:"provider"` @@ -438,6 +445,10 @@ type App struct { CopilotModel string `json:"copilot_model" toml:"copilot_model"` // Default temperature for Copilot requests (nil means use provider default) CopilotTemperature *float64 `json:"copilot_temperature" toml:"copilot_temperature"` + AnthropicBaseURL string `json:"anthropic_base_url" toml:"anthropic_base_url"` + AnthropicModel string `json:"anthropic_model" toml:"anthropic_model"` + // Default temperature for Anthropic requests (nil means use provider default) + AnthropicTemperature *float64 `json:"anthropic_temperature" toml:"anthropic_temperature"` // Per-surface provider/model configurations (ordered; first entry is primary) CompletionConfigs []SurfaceConfig `json:"-" toml:"-"` @@ -493,7 +504,7 @@ type CustomAction struct { } // Constructor: defaults for App (kept first among functions) -func newDefaultConfig() App <span class="cov5" title="51">{ +func newDefaultConfig() App <span class="cov6" title="153">{ // Coding-friendly default temperature across providers // Users can override per provider in config.toml (including 0.0). t := 0.2 @@ -503,10 +514,12 @@ func newDefaultConfig() App <span class="cov5" title="51">{ ContextWindowLines: 120, MaxContextTokens: 4000, LogPreviewLimit: 100, + RequestTimeout: 30, CodingTemperature: &t, OpenAITemperature: &t, OllamaTemperature: &t, CopilotTemperature: &t, + AnthropicTemperature: &t, ManualInvokeMinPrefix: 0, CompletionDebounceMs: 800, CompletionThrottleMs: 0, @@ -549,7 +562,7 @@ func newDefaultConfig() App <span class="cov5" title="51">{ // 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="35">{ return LoadWithOptions(logger, LoadOptions{}) }</span> +func Load(logger *log.Logger) App <span class="cov5" title="105">{ return LoadWithOptions(logger, LoadOptions{}) }</span> // LoadOptions tune how configuration is loaded at runtime. type LoadOptions struct { @@ -559,35 +572,35 @@ type LoadOptions struct { } // LoadWithOptions reads configuration and applies the requested loading options. -func LoadWithOptions(logger *log.Logger, opts LoadOptions) App <span class="cov5" title="50">{ +func LoadWithOptions(logger *log.Logger, opts LoadOptions) App <span class="cov6" title="150">{ cfg := newDefaultConfig() - if logger == nil </span><span class="cov4" title="13">{ + if logger == nil </span><span class="cov4" title="39">{ return cfg // Return defaults if no logger is provided (e.g. in tests) }</span> - <span class="cov5" title="37">configPath := strings.TrimSpace(opts.ConfigPath) + <span class="cov6" title="111">configPath := strings.TrimSpace(opts.ConfigPath) if configPath != "" </span><span class="cov0" title="0">{ if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil </span><span class="cov0" title="0">{ cfg.mergeWith(fileCfg) }</span> else<span class="cov0" title="0"> if err != nil </span><span class="cov0" title="0">{ logger.Printf("cannot open config file %s: %v", configPath, err) }</span> - } else<span class="cov5" title="37"> { + } else<span class="cov6" title="111"> { path, err := getConfigPath() if err != nil </span><span class="cov0" title="0">{ logger.Printf("%v", err) - }</span> else<span class="cov5" title="37"> if fileCfg, err := loadFromFile(path, logger); err == nil && fileCfg != nil </span><span class="cov4" title="21">{ + }</span> else<span class="cov6" title="111"> if fileCfg, err := loadFromFile(path, logger); err == nil && fileCfg != nil </span><span class="cov5" title="63">{ cfg.mergeWith(fileCfg) }</span> } - <span class="cov5" title="37">if !opts.IgnoreEnv </span><span class="cov5" title="33">{ + <span class="cov6" title="111">if !opts.IgnoreEnv </span><span class="cov5" title="99">{ // Environment overrides (take precedence over file) - if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov3" title="9">{ + if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov4" title="27">{ cfg.mergeWith(envCfg) }</span> } - <span class="cov5" title="37">return cfg</span> + <span class="cov6" title="111">return cfg</span> } // Private helpers @@ -605,6 +618,7 @@ type fileConfig struct { OpenRouter sectionOpenRouter `toml:"openrouter"` Copilot sectionCopilot `toml:"copilot"` Ollama sectionOllama `toml:"ollama"` + Anthropic sectionAnthropic `toml:"anthropic"` Prompts sectionPrompts `toml:"prompts"` Tmux sectionTmux `toml:"tmux"` Stats sectionStats `toml:"stats"` @@ -616,6 +630,7 @@ type sectionGeneral struct { ContextWindowLines int `toml:"context_window_lines"` MaxContextTokens int `toml:"max_context_tokens"` CodingTemperature *float64 `toml:"coding_temperature"` + RequestTimeout int `toml:"request_timeout"` } type sectionLogging struct { @@ -623,9 +638,10 @@ type sectionLogging struct { } type sectionCompletion struct { - CompletionDebounceMs int `toml:"completion_debounce_ms"` - CompletionThrottleMs int `toml:"completion_throttle_ms"` - ManualInvokeMinPrefix int `toml:"manual_invoke_min_prefix"` + CompletionDebounceMs int `toml:"completion_debounce_ms"` + CompletionThrottleMs int `toml:"completion_throttle_ms"` + ManualInvokeMinPrefix int `toml:"manual_invoke_min_prefix"` + CompletionWaitAll *bool `toml:"completion_wait_all"` } type sectionTriggers struct { @@ -657,19 +673,19 @@ type sectionOpenAI struct { Presets map[string]string `toml:"presets"` } -func (s sectionOpenAI) isZero() bool <span class="cov4" title="23">{ +func (s sectionOpenAI) isZero() bool <span class="cov5" title="69">{ return strings.TrimSpace(s.Model) == "" && strings.TrimSpace(s.BaseURL) == "" && s.Temperature == nil && len(s.Presets) == 0 }</span> -func (s sectionOpenAI) resolvedModel() string <span class="cov3" title="6">{ +func (s sectionOpenAI) resolvedModel() string <span class="cov4" title="18">{ model := strings.TrimSpace(s.Model) if model == "" </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov3" title="6">if len(s.Presets) == 0 </span><span class="cov3" title="5">{ + <span class="cov4" title="18">if len(s.Presets) == 0 </span><span class="cov3" title="15">{ return model }</span> - <span class="cov1" title="1">if mapped := strings.TrimSpace(s.Presets[model]); mapped != "" </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if mapped := strings.TrimSpace(s.Presets[model]); mapped != "" </span><span class="cov2" title="3">{ return mapped }</span> <span class="cov0" title="0">lower := strings.ToLower(model) @@ -701,6 +717,12 @@ type sectionOllama struct { Temperature *float64 `toml:"temperature"` } +type sectionAnthropic struct { + Model string `toml:"model"` + BaseURL string `toml:"base_url"` + Temperature *float64 `toml:"temperature"` +} + // Prompts sections type sectionPrompts struct { Completion sectionPromptsCompletion `toml:"completion"` @@ -761,63 +783,66 @@ type sectionTmux struct { CustomMenuHotkey string `toml:"custom_menu_hotkey"` } -func (fc *fileConfig) toApp() App <span class="cov4" title="23">{ +func (fc *fileConfig) toApp() App <span class="cov5" title="69">{ out := App{} // Merge section: general - if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil </span><span class="cov4" title="12">{ + if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil </span><span class="cov4" title="36">{ tmp := App{ MaxTokens: fc.General.MaxTokens, ContextMode: fc.General.ContextMode, ContextWindowLines: fc.General.ContextWindowLines, MaxContextTokens: fc.General.MaxContextTokens, CodingTemperature: fc.General.CodingTemperature, + RequestTimeout: fc.General.RequestTimeout, } out.mergeBasics(&tmp) }</span> // logging - <span class="cov4" title="23">if (fc.Logging != sectionLogging{}) </span><span class="cov1" title="1">{ + <span class="cov5" title="69">if (fc.Logging != sectionLogging{}) </span><span class="cov2" title="3">{ tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit} out.mergeBasics(&tmp) }</span> // completion - <span class="cov4" title="23">if (fc.Completion != sectionCompletion{}) </span><span class="cov2" title="4">{ + <span class="cov5" title="69">if fc.Completion.CompletionDebounceMs != 0 || fc.Completion.CompletionThrottleMs != 0 || + fc.Completion.ManualInvokeMinPrefix != 0 || fc.Completion.CompletionWaitAll != nil </span><span class="cov3" title="12">{ tmp := App{ CompletionDebounceMs: fc.Completion.CompletionDebounceMs, CompletionThrottleMs: fc.Completion.CompletionThrottleMs, ManualInvokeMinPrefix: fc.Completion.ManualInvokeMinPrefix, + CompletionWaitAll: fc.Completion.CompletionWaitAll, } out.mergeBasics(&tmp) }</span> // triggers - <span class="cov4" title="23">if len(fc.Triggers.TriggerCharacters) > 0 </span><span class="cov2" title="4">{ + <span class="cov5" title="69">if len(fc.Triggers.TriggerCharacters) > 0 </span><span class="cov3" title="12">{ tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters} out.mergeBasics(&tmp) }</span> // inline - <span class="cov4" title="23">if (fc.Inline != sectionInline{}) </span><span class="cov1" title="1">{ + <span class="cov5" title="69">if (fc.Inline != sectionInline{}) </span><span class="cov2" title="3">{ tmp := App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose} out.mergeBasics(&tmp) }</span> // chat - <span class="cov4" title="23">if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 </span><span class="cov1" title="1">{ + <span class="cov5" title="69">if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 </span><span class="cov2" title="3">{ tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes} out.mergeBasics(&tmp) }</span> // provider - <span class="cov4" title="23">if strings.TrimSpace(fc.Provider.Name) != "" </span><span class="cov3" title="6">{ + <span class="cov5" title="69">if strings.TrimSpace(fc.Provider.Name) != "" </span><span class="cov4" title="18">{ tmp := App{Provider: fc.Provider.Name} out.mergeBasics(&tmp) }</span> // openai - <span class="cov4" title="23">if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil </span><span class="cov3" title="6">{ + <span class="cov5" title="69">if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil </span><span class="cov4" title="18">{ tmp := App{ OpenAIBaseURL: fc.OpenAI.BaseURL, OpenAIModel: fc.OpenAI.resolvedModel(), @@ -827,7 +852,7 @@ func (fc *fileConfig) toApp() App <span class="cov4" title="23">{ }</span> // openrouter - <span class="cov4" title="23">if (fc.OpenRouter != sectionOpenRouter{}) || fc.OpenRouter.Temperature != nil </span><span class="cov0" title="0">{ + <span class="cov5" title="69">if (fc.OpenRouter != sectionOpenRouter{}) || fc.OpenRouter.Temperature != nil </span><span class="cov0" title="0">{ tmp := App{ OpenRouterBaseURL: fc.OpenRouter.BaseURL, OpenRouterModel: fc.OpenRouter.Model, @@ -837,7 +862,7 @@ func (fc *fileConfig) toApp() App <span class="cov4" title="23">{ }</span> // copilot - <span class="cov4" title="23">if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil </span><span class="cov2" title="4">{ + <span class="cov5" title="69">if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil </span><span class="cov3" title="12">{ tmp := App{ CopilotBaseURL: fc.Copilot.BaseURL, CopilotModel: fc.Copilot.Model, @@ -847,7 +872,7 @@ func (fc *fileConfig) toApp() App <span class="cov4" title="23">{ }</span> // ollama - <span class="cov4" title="23">if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil </span><span class="cov2" title="4">{ + <span class="cov5" title="69">if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil </span><span class="cov3" title="12">{ tmp := App{ OllamaBaseURL: fc.Ollama.BaseURL, OllamaModel: fc.Ollama.Model, @@ -856,34 +881,44 @@ func (fc *fileConfig) toApp() App <span class="cov4" title="23">{ out.mergeProviderFields(&tmp) }</span> + // anthropic + <span class="cov5" title="69">if (fc.Anthropic != sectionAnthropic{}) || fc.Anthropic.Temperature != nil </span><span class="cov0" title="0">{ + tmp := App{ + AnthropicBaseURL: fc.Anthropic.BaseURL, + AnthropicModel: fc.Anthropic.Model, + AnthropicTemperature: fc.Anthropic.Temperature, + } + out.mergeProviderFields(&tmp) + }</span> + // prompts // completion - <span class="cov4" title="23">if (fc.Prompts.Completion != sectionPromptsCompletion{}) </span><span class="cov1" title="1">{ - if strings.TrimSpace(fc.Prompts.Completion.SystemGeneral) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="69">if (fc.Prompts.Completion != sectionPromptsCompletion{}) </span><span class="cov2" title="3">{ + if strings.TrimSpace(fc.Prompts.Completion.SystemGeneral) != "" </span><span class="cov2" title="3">{ out.PromptCompletionSystemGeneral = fc.Prompts.Completion.SystemGeneral }</span> - <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.Completion.SystemParams) != "" </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if strings.TrimSpace(fc.Prompts.Completion.SystemParams) != "" </span><span class="cov2" title="3">{ out.PromptCompletionSystemParams = fc.Prompts.Completion.SystemParams }</span> - <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.Completion.SystemInline) != "" </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if strings.TrimSpace(fc.Prompts.Completion.SystemInline) != "" </span><span class="cov2" title="3">{ out.PromptCompletionSystemInline = fc.Prompts.Completion.SystemInline }</span> - <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.Completion.UserGeneral) != "" </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if strings.TrimSpace(fc.Prompts.Completion.UserGeneral) != "" </span><span class="cov2" title="3">{ out.PromptCompletionUserGeneral = fc.Prompts.Completion.UserGeneral }</span> - <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.Completion.UserParams) != "" </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if strings.TrimSpace(fc.Prompts.Completion.UserParams) != "" </span><span class="cov2" title="3">{ out.PromptCompletionUserParams = fc.Prompts.Completion.UserParams }</span> - <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.Completion.ExtraHeader) != "" </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if strings.TrimSpace(fc.Prompts.Completion.ExtraHeader) != "" </span><span class="cov2" title="3">{ out.PromptCompletionExtraHeader = fc.Prompts.Completion.ExtraHeader }</span> } // chat - <span class="cov4" title="23">if strings.TrimSpace(fc.Prompts.Chat.System) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="69">if strings.TrimSpace(fc.Prompts.Chat.System) != "" </span><span class="cov2" title="3">{ out.PromptChatSystem = fc.Prompts.Chat.System }</span> // code action - <span class="cov4" title="23">if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" || + <span class="cov5" title="69">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) != "" || @@ -893,39 +928,39 @@ func (fc *fileConfig) toApp() App <span class="cov4" title="23">{ strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" || strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" || strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" || - len(fc.Prompts.CodeAction.Custom) > 0 </span><span class="cov3" title="7">{ - if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" </span><span class="cov1" title="1">{ + len(fc.Prompts.CodeAction.Custom) > 0 </span><span class="cov4" title="21">{ + if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" </span><span class="cov2" title="3">{ out.PromptCodeActionRewriteSystem = fc.Prompts.CodeAction.RewriteSystem }</span> - <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="21">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" </span><span class="cov2" title="3">{ out.PromptCodeActionDiagnosticsSystem = fc.Prompts.CodeAction.DiagnosticsSystem }</span> - <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="21">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" </span><span class="cov2" title="3">{ out.PromptCodeActionDocumentSystem = fc.Prompts.CodeAction.DocumentSystem }</span> - <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="21">if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" </span><span class="cov2" title="3">{ out.PromptCodeActionRewriteUser = fc.Prompts.CodeAction.RewriteUser }</span> - <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="21">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" </span><span class="cov2" title="3">{ out.PromptCodeActionDiagnosticsUser = fc.Prompts.CodeAction.DiagnosticsUser }</span> - <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="21">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" </span><span class="cov2" title="3">{ out.PromptCodeActionDocumentUser = fc.Prompts.CodeAction.DocumentUser }</span> - <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="21">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" </span><span class="cov2" title="3">{ out.PromptCodeActionGoTestSystem = fc.Prompts.CodeAction.GoTestSystem }</span> - <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="21">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" </span><span class="cov2" title="3">{ out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser }</span> - <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" </span><span class="cov0" title="0">{ + <span class="cov4" title="21">if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" </span><span class="cov0" title="0">{ out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem }</span> - <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" </span><span class="cov0" title="0">{ + <span class="cov4" title="21">if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" </span><span class="cov0" title="0">{ out.PromptCodeActionSimplifyUser = fc.Prompts.CodeAction.SimplifyUser }</span> - <span class="cov3" title="7">if len(fc.Prompts.CodeAction.Custom) > 0 </span><span class="cov3" title="6">{ - for _, ca := range fc.Prompts.CodeAction.Custom </span><span class="cov3" title="10">{ + <span class="cov4" title="21">if len(fc.Prompts.CodeAction.Custom) > 0 </span><span class="cov4" title="18">{ + for _, ca := range fc.Prompts.CodeAction.Custom </span><span class="cov4" title="30">{ out.CustomActions = append(out.CustomActions, CustomAction{ ID: strings.TrimSpace(ca.ID), Title: strings.TrimSpace(ca.Title), @@ -940,55 +975,55 @@ func (fc *fileConfig) toApp() App <span class="cov4" title="23">{ } } // cli - <span class="cov4" title="23">if (fc.Prompts.CLI != sectionPromptsCLI{}) </span><span class="cov1" title="1">{ - if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="69">if (fc.Prompts.CLI != sectionPromptsCLI{}) </span><span class="cov2" title="3">{ + if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" </span><span class="cov2" title="3">{ out.PromptCLIDefaultSystem = fc.Prompts.CLI.DefaultSystem }</span> - <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.CLI.ExplainSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if strings.TrimSpace(fc.Prompts.CLI.ExplainSystem) != "" </span><span class="cov2" title="3">{ out.PromptCLIExplainSystem = fc.Prompts.CLI.ExplainSystem }</span> } // provider-native - <span class="cov4" title="23">if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="69">if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" </span><span class="cov2" title="3">{ out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion }</span> // tmux - <span class="cov4" title="23">if (fc.Tmux != sectionTmux{}) </span><span class="cov2" title="3">{ + <span class="cov5" title="69">if (fc.Tmux != sectionTmux{}) </span><span class="cov3" title="9">{ out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) }</span> // stats - <span class="cov4" title="23">if fc.Stats.WindowMinutes > 0 </span><span class="cov0" title="0">{ + <span class="cov5" title="69">if fc.Stats.WindowMinutes > 0 </span><span class="cov0" title="0">{ out.StatsWindowMinutes = fc.Stats.WindowMinutes }</span> - <span class="cov4" title="23">return out</span> + <span class="cov5" title="69">return out</span> } -func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov5" title="40">{ +func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov6" title="120">{ b, err := os.ReadFile(path) - if err != nil </span><span class="cov4" title="16">{ + if err != nil </span><span class="cov5" title="48">{ if !os.IsNotExist(err) && logger != nil </span><span class="cov0" title="0">{ logger.Printf("cannot open TOML config file %s: %v", path, err) }</span> - <span class="cov4" title="16">return nil, err</span> + <span class="cov5" title="48">return nil, err</span> } - <span class="cov4" title="24">var tables fileConfig + <span class="cov5" title="72">var tables fileConfig errTables := toml.NewDecoder(strings.NewReader(string(b))).Decode(&tables) // Raw map for validation/presence checks var raw map[string]any _ = toml.Unmarshal(b, &raw) - if errTables != nil </span><span class="cov1" title="1">{ - if logger != nil </span><span class="cov1" title="1">{ + if errTables != nil </span><span class="cov2" title="3">{ + if logger != nil </span><span class="cov2" title="3">{ logger.Printf("invalid TOML config file %s: %v", path, errTables) }</span> - <span class="cov1" title="1">return nil, errTables</span> + <span class="cov2" title="3">return nil, errTables</span> } // Reject legacy flat keys at top-level (sectioned-only config is allowed) - <span class="cov4" title="23">legacy := map[string]struct{}{ + <span class="cov5" title="69">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": {}, @@ -997,27 +1032,27 @@ 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="60">{ - if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "models": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable </span><span class="cov6" title="57">{ + for k := range raw </span><span class="cov6" title="180">{ + if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "models": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable </span><span class="cov6" title="171">{ continue</span> } - <span class="cov2" title="3">if _, isLegacy := legacy[k]; isLegacy </span><span class="cov0" title="0">{ + <span class="cov3" title="9">if _, isLegacy := legacy[k]; isLegacy </span><span class="cov0" title="0">{ return nil, fmt.Errorf("unsupported flat key '%s' in config; use sectioned tables (see config.toml.example)", k) }</span> } - <span class="cov4" title="23">if logger != nil </span><span class="cov4" title="23">{ + <span class="cov5" title="69">if logger != nil </span><span class="cov5" title="69">{ 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="cov4" title="23">tab := tables.toApp() + <span class="cov5" title="69">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="4">{ - if v, present := t["manual_invoke_min_prefix"]; present </span><span class="cov2" title="4">{ + if t, ok := raw["completion"].(map[string]any); ok </span><span class="cov3" title="12">{ + if v, present := t["manual_invoke_min_prefix"]; present </span><span class="cov3" title="12">{ switch vv := v.(type) </span>{ - case int64:<span class="cov2" title="4"> + case int64:<span class="cov3" title="12"> tab.ManualInvokeMinPrefix = int(vv)</span> case int:<span class="cov0" title="0"> tab.ManualInvokeMinPrefix = vv</span> @@ -1026,10 +1061,10 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co } } } - <span class="cov4" title="23">if t, ok := raw["logging"].(map[string]any); ok </span><span class="cov2" title="4">{ - if v, present := t["log_preview_limit"]; present </span><span class="cov2" title="4">{ + <span class="cov5" title="69">if t, ok := raw["logging"].(map[string]any); ok </span><span class="cov3" title="12">{ + if v, present := t["log_preview_limit"]; present </span><span class="cov3" title="12">{ switch vv := v.(type) </span>{ - case int64:<span class="cov2" title="4"> + case int64:<span class="cov3" title="12"> tab.LogPreviewLimit = int(vv)</span> case int:<span class="cov0" title="0"> tab.LogPreviewLimit = vv</span> @@ -1038,65 +1073,65 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co } } } - <span class="cov4" title="23">if m := parseSurfaceModels(raw, logger); m != nil </span><span class="cov2" title="4">{ + <span class="cov5" title="69">if m := parseSurfaceModels(raw, logger); m != nil </span><span class="cov3" title="12">{ tab.mergeSurfaceModels(m) }</span> - <span class="cov4" title="23">return &tab, nil</span> + <span class="cov5" title="69">return &tab, nil</span> } -func parseSurfaceModels(raw map[string]any, logger *log.Logger) *App <span class="cov4" title="23">{ +func parseSurfaceModels(raw map[string]any, logger *log.Logger) *App <span class="cov5" title="69">{ modelsRaw, ok := raw["models"] - if !ok </span><span class="cov4" title="19">{ + if !ok </span><span class="cov5" title="57">{ return nil }</span> - <span class="cov2" title="4">table, ok := modelsRaw.(map[string]any) + <span class="cov3" title="12">table, ok := modelsRaw.(map[string]any) if !ok </span><span class="cov0" title="0">{ if logger != nil </span><span class="cov0" title="0">{ logger.Printf("config: ignoring models section (expected table, got %T)", modelsRaw) }</span> <span class="cov0" title="0">return nil</span> } - <span class="cov2" title="4">var out App - appendEntries := func(dest *[]SurfaceConfig, key string, val any) bool </span><span class="cov4" title="16">{ + <span class="cov3" title="12">var out App + appendEntries := func(dest *[]SurfaceConfig, key string, val any) bool </span><span class="cov5" title="48">{ entries, ok := parseSurfaceEntries(val, key, logger) - if !ok || len(entries) == 0 </span><span class="cov2" title="3">{ + if !ok || len(entries) == 0 </span><span class="cov3" title="9">{ return false }</span> - <span class="cov4" title="13">*dest = append(*dest, entries...) + <span class="cov4" title="39">*dest = append(*dest, entries...) return true</span> } - <span class="cov2" title="4">any := appendEntries(&out.CompletionConfigs, "models.completion", table["completion"]) - if ok := appendEntries(&out.CodeActionConfigs, "models.code_action", table["code_action"]); ok </span><span class="cov2" title="4">{ - if len(out.CodeActionConfigs) > 1 </span><span class="cov1" title="1">{ - if logger != nil </span><span class="cov1" title="1">{ + <span class="cov3" title="12">any := appendEntries(&out.CompletionConfigs, "models.completion", table["completion"]) + if ok := appendEntries(&out.CodeActionConfigs, "models.code_action", table["code_action"]); ok </span><span class="cov3" title="12">{ + if len(out.CodeActionConfigs) > 1 </span><span class="cov2" title="3">{ + if logger != nil </span><span class="cov2" title="3">{ logger.Printf("config: models.code_action supports a single entry; ignoring %d extra", len(out.CodeActionConfigs)-1) }</span> - <span class="cov1" title="1">out.CodeActionConfigs = out.CodeActionConfigs[:1]</span> + <span class="cov2" title="3">out.CodeActionConfigs = out.CodeActionConfigs[:1]</span> } - <span class="cov2" title="4">any = true</span> + <span class="cov3" title="12">any = true</span> } - <span class="cov2" title="4">any = appendEntries(&out.ChatConfigs, "models.chat", table["chat"]) || any + <span class="cov3" title="12">any = appendEntries(&out.ChatConfigs, "models.chat", table["chat"]) || any any = appendEntries(&out.CLIConfigs, "models.cli", table["cli"]) || any if !any </span><span class="cov0" title="0">{ return nil }</span> - <span class="cov2" title="4">return &out</span> + <span class="cov3" title="12">return &out</span> } -func parseSurfaceEntries(raw any, path string, logger *log.Logger) ([]SurfaceConfig, bool) <span class="cov4" title="16">{ +func parseSurfaceEntries(raw any, path string, logger *log.Logger) ([]SurfaceConfig, bool) <span class="cov5" title="48">{ switch v := raw.(type) </span>{ - case nil:<span class="cov2" title="3"> + case nil:<span class="cov3" title="9"> return nil, false</span> - case []any:<span class="cov4" title="13"> + case []any:<span class="cov4" title="39"> var out []SurfaceConfig - for i, entry := range v </span><span class="cov4" title="14">{ + for i, entry := range v </span><span class="cov5" title="42">{ cfg, ok := decodeModelEntry(entry, fmt.Sprintf("%s[%d]", path, i), logger) if !ok || cfg == nil </span><span class="cov0" title="0">{ continue</span> } - <span class="cov4" title="14">out = append(out, *cfg)</span> + <span class="cov5" title="42">out = append(out, *cfg)</span> } - <span class="cov4" title="13">return out, len(out) > 0</span> + <span class="cov4" title="39">return out, len(out) > 0</span> default:<span class="cov0" title="0"> if cfg, ok := decodeModelEntry(v, path, logger); ok && cfg != nil </span><span class="cov0" title="0">{ return []SurfaceConfig{*cfg}, true @@ -1105,30 +1140,30 @@ func parseSurfaceEntries(raw any, path string, logger *log.Logger) ([]SurfaceCon } } -func cloneSurfaceConfigs(src []SurfaceConfig) []SurfaceConfig <span class="cov5" title="27">{ +func cloneSurfaceConfigs(src []SurfaceConfig) []SurfaceConfig <span class="cov5" title="81">{ if len(src) == 0 </span><span class="cov0" title="0">{ return nil }</span> - <span class="cov5" title="27">out := make([]SurfaceConfig, len(src)) + <span class="cov5" title="81">out := make([]SurfaceConfig, len(src)) copy(out, src) return out</span> } -func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, bool) <span class="cov4" title="14">{ +func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, bool) <span class="cov5" title="42">{ if raw == nil </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov4" title="14">switch v := raw.(type) </span>{ + <span class="cov5" title="42">switch v := raw.(type) </span>{ case string:<span class="cov0" title="0"> model := strings.TrimSpace(v) if model == "" </span><span class="cov0" title="0">{ return nil, false }</span> <span class="cov0" title="0">return &SurfaceConfig{Model: model}, true</span> - case map[string]any:<span class="cov4" title="14"> + case map[string]any:<span class="cov5" title="42"> model := "" provider := "" - if m, ok := v["model"]; ok </span><span class="cov4" title="14">{ + if m, ok := v["model"]; ok </span><span class="cov5" title="42">{ s, ok := m.(string) if !ok </span><span class="cov0" title="0">{ if logger != nil </span><span class="cov0" title="0">{ @@ -1136,9 +1171,9 @@ func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, }</span> <span class="cov0" title="0">return nil, false</span> } - <span class="cov4" title="14">model = strings.TrimSpace(s)</span> + <span class="cov5" title="42">model = strings.TrimSpace(s)</span> } - <span class="cov4" title="14">if pRaw, ok := v["provider"]; ok </span><span class="cov4" title="14">{ + <span class="cov5" title="42">if pRaw, ok := v["provider"]; ok </span><span class="cov5" title="42">{ ps, ok := pRaw.(string) if !ok </span><span class="cov0" title="0">{ if logger != nil </span><span class="cov0" title="0">{ @@ -1146,20 +1181,20 @@ func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, }</span> <span class="cov0" title="0">return nil, false</span> } - <span class="cov4" title="14">provider = strings.TrimSpace(ps)</span> + <span class="cov5" title="42">provider = strings.TrimSpace(ps)</span> } - <span class="cov4" title="14">var tempPtr *float64 - if tRaw, ok := v["temperature"]; ok </span><span class="cov3" title="6">{ + <span class="cov5" title="42">var tempPtr *float64 + if tRaw, ok := v["temperature"]; ok </span><span class="cov4" title="18">{ parsed, ok := parseTemperatureValue(tRaw, path, logger) if !ok </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov3" title="6">tempPtr = parsed</span> + <span class="cov4" title="18">tempPtr = parsed</span> } - <span class="cov4" title="14">if model == "" && tempPtr == nil && provider == "" </span><span class="cov0" title="0">{ + <span class="cov5" title="42">if model == "" && tempPtr == nil && provider == "" </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov4" title="14">return &SurfaceConfig{Provider: provider, Model: model, Temperature: tempPtr}, true</span> + <span class="cov5" title="42">return &SurfaceConfig{Provider: provider, Model: model, Temperature: tempPtr}, true</span> default:<span class="cov0" title="0"> if logger != nil </span><span class="cov0" title="0">{ logger.Printf("config: %s must be a string or table, got %T", path, raw) @@ -1168,9 +1203,9 @@ func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, } } -func parseTemperatureValue(raw any, path string, logger *log.Logger) (*float64, bool) <span class="cov3" title="6">{ +func parseTemperatureValue(raw any, path string, logger *log.Logger) (*float64, bool) <span class="cov4" title="18">{ switch v := raw.(type) </span>{ - case float64:<span class="cov3" title="6"> + case float64:<span class="cov4" title="18"> return floatPtr(v), true</span> case int64:<span class="cov0" title="0"> return floatPtr(float64(v)), true</span> @@ -1195,12 +1230,12 @@ func parseTemperatureValue(raw any, path string, logger *log.Logger) (*float64, } } -func floatPtr(v float64) *float64 <span class="cov3" title="6">{ +func floatPtr(v float64) *float64 <span class="cov4" title="18">{ f := v return &f }</span> -func (a *App) mergeWith(other *App) <span class="cov5" title="30">{ +func (a *App) mergeWith(other *App) <span class="cov5" title="90">{ a.mergeBasics(other) a.mergeProviderFields(other) a.mergeSurfaceModels(other) @@ -1208,360 +1243,370 @@ func (a *App) mergeWith(other *App) <span class="cov5" title="30">{ }</span> // mergeBasics merges general (non-provider) fields. -func (a *App) mergeBasics(other *App) <span class="cov6" title="59">{ - if other.MaxTokens > 0 </span><span class="cov5" title="27">{ +func (a *App) mergeBasics(other *App) <span class="cov6" title="177">{ + if other.MaxTokens > 0 </span><span class="cov5" title="81">{ a.MaxTokens = other.MaxTokens }</span> - <span class="cov6" title="59">if s := strings.TrimSpace(other.ContextMode); s != "" </span><span class="cov3" title="8">{ + <span class="cov6" title="177">if s := strings.TrimSpace(other.ContextMode); s != "" </span><span class="cov4" title="24">{ a.ContextMode = s }</span> - <span class="cov6" title="59">if other.ContextWindowLines > 0 </span><span class="cov3" title="8">{ + <span class="cov6" title="177">if other.ContextWindowLines > 0 </span><span class="cov4" title="24">{ a.ContextWindowLines = other.ContextWindowLines }</span> - <span class="cov6" title="59">if other.MaxContextTokens > 0 </span><span class="cov3" title="8">{ + <span class="cov6" title="177">if other.MaxContextTokens > 0 </span><span class="cov4" title="24">{ a.MaxContextTokens = other.MaxContextTokens }</span> - <span class="cov6" title="59">if other.LogPreviewLimit >= 0 </span><span class="cov6" title="59">{ + <span class="cov6" title="177">if other.LogPreviewLimit >= 0 </span><span class="cov6" title="177">{ a.LogPreviewLimit = other.LogPreviewLimit }</span> - <span class="cov6" title="59">if other.CodingTemperature != nil </span><span class="cov3" title="8">{ // allow explicit 0.0 + <span class="cov6" title="177">if other.RequestTimeout > 0 </span><span class="cov0" title="0">{ + a.RequestTimeout = other.RequestTimeout + }</span> + <span class="cov6" title="177">if other.CodingTemperature != nil </span><span class="cov4" title="24">{ // allow explicit 0.0 a.CodingTemperature = other.CodingTemperature }</span> - <span class="cov6" title="59">if other.ManualInvokeMinPrefix >= 0 </span><span class="cov6" title="59">{ + <span class="cov6" title="177">if other.ManualInvokeMinPrefix >= 0 </span><span class="cov6" title="177">{ a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix }</span> - <span class="cov6" title="59">if other.CompletionDebounceMs > 0 </span><span class="cov3" title="8">{ + <span class="cov6" title="177">if other.CompletionDebounceMs > 0 </span><span class="cov4" title="24">{ a.CompletionDebounceMs = other.CompletionDebounceMs }</span> - <span class="cov6" title="59">if other.CompletionThrottleMs > 0 </span><span class="cov3" title="8">{ + <span class="cov6" title="177">if other.CompletionThrottleMs > 0 </span><span class="cov4" title="24">{ a.CompletionThrottleMs = other.CompletionThrottleMs }</span> - <span class="cov6" title="59">if len(other.TriggerCharacters) > 0 </span><span class="cov3" title="8">{ + <span class="cov6" title="177">if other.CompletionWaitAll != nil </span><span class="cov0" title="0">{ + a.CompletionWaitAll = other.CompletionWaitAll + }</span> + <span class="cov6" title="177">if len(other.TriggerCharacters) > 0 </span><span class="cov4" title="24">{ a.TriggerCharacters = slices.Clone(other.TriggerCharacters) }</span> - <span class="cov6" title="59">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov1" title="2">{ + <span class="cov6" title="177">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov2" title="6">{ a.InlineOpen = s }</span> - <span class="cov6" title="59">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov1" title="2">{ + <span class="cov6" title="177">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov2" title="6">{ a.InlineClose = s }</span> - <span class="cov6" title="59">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov1" title="2">{ + <span class="cov6" title="177">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov2" title="6">{ a.ChatSuffix = s }</span> - <span class="cov6" title="59">if len(other.ChatPrefixes) > 0 </span><span class="cov1" title="2">{ + <span class="cov6" title="177">if len(other.ChatPrefixes) > 0 </span><span class="cov2" title="6">{ a.ChatPrefixes = slices.Clone(other.ChatPrefixes) }</span> - <span class="cov6" title="59">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov4" title="16">{ + <span class="cov6" title="177">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov5" title="48">{ a.Provider = s }</span> } // mergeSurfaceModels copies per-surface model and temperature overrides. -func (a *App) mergeSurfaceModels(other *App) <span class="cov5" title="34">{ - if len(other.CompletionConfigs) > 0 </span><span class="cov3" title="7">{ +func (a *App) mergeSurfaceModels(other *App) <span class="cov5" title="102">{ + if len(other.CompletionConfigs) > 0 </span><span class="cov4" title="21">{ a.CompletionConfigs = cloneSurfaceConfigs(other.CompletionConfigs) }</span> - <span class="cov5" title="34">if len(other.CodeActionConfigs) > 0 </span><span class="cov3" title="7">{ + <span class="cov5" title="102">if len(other.CodeActionConfigs) > 0 </span><span class="cov4" title="21">{ a.CodeActionConfigs = cloneSurfaceConfigs(other.CodeActionConfigs) }</span> - <span class="cov5" title="34">if len(other.ChatConfigs) > 0 </span><span class="cov3" title="6">{ + <span class="cov5" title="102">if len(other.ChatConfigs) > 0 </span><span class="cov4" title="18">{ a.ChatConfigs = cloneSurfaceConfigs(other.ChatConfigs) }</span> - <span class="cov5" title="34">if len(other.CLIConfigs) > 0 </span><span class="cov3" title="7">{ + <span class="cov5" title="102">if len(other.CLIConfigs) > 0 </span><span class="cov4" title="21">{ a.CLIConfigs = cloneSurfaceConfigs(other.CLIConfigs) }</span> } // mergePrompts copies non-empty prompt templates from other. -func (a *App) mergePrompts(other *App) <span class="cov5" title="30">{ +func (a *App) mergePrompts(other *App) <span class="cov5" title="90">{ // Completion - if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" </span><span class="cov1" title="1">{ + if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" </span><span class="cov2" title="3">{ a.PromptCompletionSystemGeneral = other.PromptCompletionSystemGeneral }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCompletionSystemParams) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCompletionSystemParams) != "" </span><span class="cov2" title="3">{ a.PromptCompletionSystemParams = other.PromptCompletionSystemParams }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCompletionSystemInline) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCompletionSystemInline) != "" </span><span class="cov2" title="3">{ a.PromptCompletionSystemInline = other.PromptCompletionSystemInline }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" </span><span class="cov2" title="3">{ a.PromptCompletionUserGeneral = other.PromptCompletionUserGeneral }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCompletionUserParams) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCompletionUserParams) != "" </span><span class="cov2" title="3">{ a.PromptCompletionUserParams = other.PromptCompletionUserParams }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" </span><span class="cov2" title="3">{ a.PromptCompletionExtraHeader = other.PromptCompletionExtraHeader }</span> // Provider-native - <span class="cov5" title="30">if strings.TrimSpace(other.PromptNativeCompletion) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptNativeCompletion) != "" </span><span class="cov2" title="3">{ a.PromptNativeCompletion = other.PromptNativeCompletion }</span> // Chat - <span class="cov5" title="30">if strings.TrimSpace(other.PromptChatSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptChatSystem) != "" </span><span class="cov2" title="3">{ a.PromptChatSystem = other.PromptChatSystem }</span> // Code actions - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" </span><span class="cov2" title="3">{ a.PromptCodeActionRewriteSystem = other.PromptCodeActionRewriteSystem }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" </span><span class="cov2" title="3">{ a.PromptCodeActionDiagnosticsSystem = other.PromptCodeActionDiagnosticsSystem }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" </span><span class="cov2" title="3">{ a.PromptCodeActionDocumentSystem = other.PromptCodeActionDocumentSystem }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" </span><span class="cov2" title="3">{ a.PromptCodeActionRewriteUser = other.PromptCodeActionRewriteUser }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" </span><span class="cov2" title="3">{ a.PromptCodeActionDiagnosticsUser = other.PromptCodeActionDiagnosticsUser }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" </span><span class="cov2" title="3">{ a.PromptCodeActionDocumentUser = other.PromptCodeActionDocumentUser }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" </span><span class="cov2" title="3">{ a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" </span><span class="cov2" title="3">{ a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" </span><span class="cov0" title="0">{ a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" </span><span class="cov0" title="0">{ a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser }</span> // CLI - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" </span><span class="cov2" title="3">{ a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.PromptCLIExplainSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="90">if strings.TrimSpace(other.PromptCLIExplainSystem) != "" </span><span class="cov2" title="3">{ a.PromptCLIExplainSystem = other.PromptCLIExplainSystem }</span> // Custom actions - <span class="cov5" title="30">if len(other.CustomActions) > 0 </span><span class="cov3" title="6">{ + <span class="cov5" title="90">if len(other.CustomActions) > 0 </span><span class="cov4" title="18">{ a.CustomActions = append([]CustomAction{}, other.CustomActions...) }</span> - <span class="cov5" title="30">if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" </span><span class="cov2" title="3">{ + <span class="cov5" title="90">if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" </span><span class="cov3" title="9">{ a.TmuxCustomMenuHotkey = other.TmuxCustomMenuHotkey }</span> } // Validate checks custom actions and tmux settings for duplicates and consistency. -func (a App) Validate() error <span class="cov4" title="24">{ +func (a App) Validate() error <span class="cov5" title="72">{ // Normalize and check duplicates for IDs and hotkeys seenID := make(map[string]struct{}) seenHK := make(map[string]struct{}) - for _, ca := range a.CustomActions </span><span class="cov3" title="9">{ + for _, ca := range a.CustomActions </span><span class="cov4" title="27">{ id := strings.ToLower(strings.TrimSpace(ca.ID)) - if id == "" </span><span class="cov1" title="1">{ + if id == "" </span><span class="cov2" title="3">{ return fmt.Errorf("config: custom action missing required field id") }</span> - <span class="cov3" title="8">if _, ok := seenID[id]; ok </span><span class="cov1" title="1">{ + <span class="cov4" title="24">if _, ok := seenID[id]; ok </span><span class="cov2" title="3">{ return fmt.Errorf("config: duplicate custom action id: %s", ca.ID) }</span> - <span class="cov3" title="7">seenID[id] = struct{}{} + <span class="cov4" title="21">seenID[id] = struct{}{} if strings.TrimSpace(ca.Title) == "" </span><span class="cov0" title="0">{ return fmt.Errorf("config: custom action %s missing required field title", ca.ID) }</span> // Validate scope - <span class="cov3" title="7">scope := strings.TrimSpace(ca.Scope) - if scope != "" && scope != "selection" && scope != "diagnostics" </span><span class="cov1" title="1">{ + <span class="cov4" title="21">scope := strings.TrimSpace(ca.Scope) + if scope != "" && scope != "selection" && scope != "diagnostics" </span><span class="cov2" title="3">{ return fmt.Errorf("config: custom action %s has invalid scope: %s", ca.ID, ca.Scope) }</span> // Instruction vs user - <span class="cov3" title="6">hasInstr := strings.TrimSpace(ca.Instruction) != "" + <span class="cov4" title="18">hasInstr := strings.TrimSpace(ca.Instruction) != "" hasUser := strings.TrimSpace(ca.User) != "" if hasInstr && hasUser </span><span class="cov0" title="0">{ return fmt.Errorf("config: custom action %s must set either instruction or user, not both", ca.ID) }</span> - <span class="cov3" title="6">if !hasInstr && !hasUser </span><span class="cov0" title="0">{ + <span class="cov4" title="18">if !hasInstr && !hasUser </span><span class="cov0" title="0">{ return fmt.Errorf("config: custom action %s requires instruction or user", ca.ID) }</span> // Hotkey unique (case-insensitive), one rune if provided - <span class="cov3" title="6">if hk := strings.TrimSpace(ca.Hotkey); hk != "" </span><span class="cov3" title="5">{ - if []rune(hk) == nil || len([]rune(hk)) != 1 </span><span class="cov1" title="1">{ + <span class="cov4" title="18">if hk := strings.TrimSpace(ca.Hotkey); hk != "" </span><span class="cov3" title="15">{ + if []rune(hk) == nil || len([]rune(hk)) != 1 </span><span class="cov2" title="3">{ return fmt.Errorf("config: custom action %s hotkey must be a single character", ca.ID) }</span> - <span class="cov2" title="4">lhk := strings.ToLower(hk) - if _, ok := seenHK[lhk]; ok </span><span class="cov1" title="1">{ + <span class="cov3" title="12">lhk := strings.ToLower(hk) + if _, ok := seenHK[lhk]; ok </span><span class="cov2" title="3">{ return fmt.Errorf("config: duplicate custom action hotkey: %s", hk) }</span> - <span class="cov2" title="3">seenHK[lhk] = struct{}{}</span> + <span class="cov3" title="9">seenHK[lhk] = struct{}{}</span> } } // Tmux custom menu hotkey validation - <span class="cov4" title="19">if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" </span><span class="cov1" title="2">{ + <span class="cov5" title="57">if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" </span><span class="cov2" title="6">{ if len([]rune(hk)) != 1 </span><span class="cov0" title="0">{ return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s", hk) }</span> // built-in hotkeys in tmux TUI: r,i,c,t,p,s - <span class="cov1" title="2">switch strings.ToLower(hk) </span>{ - case "r", "i", "c", "t", "p", "s":<span class="cov1" title="1"> + <span class="cov2" title="6">switch strings.ToLower(hk) </span>{ + case "r", "i", "c", "t", "p", "s":<span class="cov2" title="3"> return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s (clashes with built-in)", hk)</span> } } - <span class="cov4" title="18">return nil</span> + <span class="cov5" title="54">return nil</span> } // mergeProviderFields merges per-provider configuration. -func (a *App) mergeProviderFields(other *App) <span class="cov5" title="44">{ - if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" </span><span class="cov3" title="8">{ +func (a *App) mergeProviderFields(other *App) <span class="cov6" title="132">{ + if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" </span><span class="cov4" title="24">{ a.OpenAIBaseURL = s }</span> - <span class="cov5" title="44">if s := strings.TrimSpace(other.OpenAIModel); s != "" </span><span class="cov4" title="16">{ + <span class="cov6" title="132">if s := strings.TrimSpace(other.OpenAIModel); s != "" </span><span class="cov5" title="48">{ a.OpenAIModel = s }</span> - <span class="cov5" title="44">if other.OpenAITemperature != nil </span><span class="cov3" title="8">{ // allow explicit 0.0 + <span class="cov6" title="132">if other.OpenAITemperature != nil </span><span class="cov4" title="24">{ // allow explicit 0.0 a.OpenAITemperature = other.OpenAITemperature }</span> - <span class="cov5" title="44">if s := strings.TrimSpace(other.OpenRouterBaseURL); s != "" </span><span class="cov0" title="0">{ + <span class="cov6" title="132">if s := strings.TrimSpace(other.OpenRouterBaseURL); s != "" </span><span class="cov0" title="0">{ a.OpenRouterBaseURL = s }</span> - <span class="cov5" title="44">if s := strings.TrimSpace(other.OpenRouterModel); s != "" </span><span class="cov0" title="0">{ + <span class="cov6" title="132">if s := strings.TrimSpace(other.OpenRouterModel); s != "" </span><span class="cov0" title="0">{ a.OpenRouterModel = s }</span> - <span class="cov5" title="44">if other.OpenRouterTemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 + <span class="cov6" title="132">if other.OpenRouterTemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 a.OpenRouterTemperature = other.OpenRouterTemperature }</span> - <span class="cov5" title="44">if s := strings.TrimSpace(other.OllamaBaseURL); s != "" </span><span class="cov3" title="8">{ + <span class="cov6" title="132">if s := strings.TrimSpace(other.OllamaBaseURL); s != "" </span><span class="cov4" title="24">{ a.OllamaBaseURL = s }</span> - <span class="cov5" title="44">if s := strings.TrimSpace(other.OllamaModel); s != "" </span><span class="cov3" title="8">{ + <span class="cov6" title="132">if s := strings.TrimSpace(other.OllamaModel); s != "" </span><span class="cov4" title="24">{ a.OllamaModel = s }</span> - <span class="cov5" title="44">if other.OllamaTemperature != nil </span><span class="cov3" title="8">{ // allow explicit 0.0 + <span class="cov6" title="132">if other.OllamaTemperature != nil </span><span class="cov4" title="24">{ // allow explicit 0.0 a.OllamaTemperature = other.OllamaTemperature }</span> - <span class="cov5" title="44">if s := strings.TrimSpace(other.CopilotBaseURL); s != "" </span><span class="cov3" title="8">{ + <span class="cov6" title="132">if s := strings.TrimSpace(other.CopilotBaseURL); s != "" </span><span class="cov4" title="24">{ a.CopilotBaseURL = s }</span> - <span class="cov5" title="44">if s := strings.TrimSpace(other.CopilotModel); s != "" </span><span class="cov3" title="8">{ + <span class="cov6" title="132">if s := strings.TrimSpace(other.CopilotModel); s != "" </span><span class="cov4" title="24">{ a.CopilotModel = s }</span> - <span class="cov5" title="44">if other.CopilotTemperature != nil </span><span class="cov3" title="8">{ // allow explicit 0.0 + <span class="cov6" title="132">if other.CopilotTemperature != nil </span><span class="cov4" title="24">{ // allow explicit 0.0 a.CopilotTemperature = other.CopilotTemperature }</span> } -func getConfigPath() (string, error) <span class="cov5" title="38">{ +func getConfigPath() (string, error) <span class="cov6" title="114">{ return ConfigPath() }</span> // ConfigPath returns the default config file path ($XDG_CONFIG_HOME/hexai/config.toml or ~/.config/hexai/config.toml). -func ConfigPath() (string, error) <span class="cov5" title="40">{ +func ConfigPath() (string, error) <span class="cov6" title="120">{ var configPath string - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov5" title="27">{ + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov5" title="81">{ configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") - }</span> else<span class="cov4" title="13"> { + }</span> else<span class="cov4" title="39"> { home, err := os.UserHomeDir() if err != nil </span><span class="cov0" title="0">{ return "", fmt.Errorf("cannot find user home directory: %v", err) }</span> - <span class="cov4" title="13">configPath = filepath.Join(home, ".config", "hexai", "config.toml")</span> + <span class="cov4" title="39">configPath = filepath.Join(home, ".config", "hexai", "config.toml")</span> } - <span class="cov5" title="40">return configPath, nil</span> + <span class="cov6" title="120">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="33">{ +func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="99">{ var out App var any bool // helpers - getenv := func(k string) string </span><span class="cov10" title="1353">{ return strings.TrimSpace(os.Getenv(k)) }</span> - <span class="cov5" title="33">parseInt := func(k string) (int, bool) </span><span class="cov7" title="231">{ + getenv := func(k string) string </span><span class="cov10" title="4455">{ return strings.TrimSpace(os.Getenv(k)) }</span> + <span class="cov5" title="99">parseInt := func(k string) (int, bool) </span><span class="cov8" title="792">{ v := getenv(k) - if v == "" </span><span class="cov7" title="221">{ + if v == "" </span><span class="cov8" title="762">{ return 0, false }</span> - <span class="cov3" title="10">n, err := strconv.Atoi(v) + <span class="cov4" title="30">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="10">return n, true</span> + <span class="cov4" title="30">return n, true</span> } - <span class="cov5" title="33">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov8" title="297">{ + <span class="cov5" title="99">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov8" title="990">{ v := getenv(k) - if v == "" </span><span class="cov8" title="287">{ + if v == "" </span><span class="cov8" title="960">{ return nil, false }</span> - <span class="cov3" title="10">f, err := strconv.ParseFloat(v, 64) + <span class="cov4" title="30">f, err := strconv.ParseFloat(v, 64) 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 nil, false</span> } - <span class="cov3" title="10">return &f, true</span> + <span class="cov4" title="30">return &f, true</span> } - <span class="cov5" title="33">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov2" title="4">{ + <span class="cov5" title="99">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov3" title="12">{ out.MaxTokens = n any = true }</span> - <span class="cov5" title="33">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov2" title="3">{ out.ContextMode = s any = true }</span> - <span class="cov5" title="33">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov2" title="3">{ out.ContextWindowLines = n any = true }</span> - <span class="cov5" title="33">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov2" title="3">{ out.MaxContextTokens = n any = true }</span> - <span class="cov5" title="33">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov2" title="3">{ out.LogPreviewLimit = n any = true }</span> - <span class="cov5" title="33">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if n, ok := parseInt("HEXAI_REQUEST_TIMEOUT"); ok </span><span class="cov0" title="0">{ + out.RequestTimeout = n + any = true + }</span> + <span class="cov5" title="99">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov2" title="3">{ out.ManualInvokeMinPrefix = n any = true }</span> - <span class="cov5" title="33">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov2" title="3">{ out.CompletionDebounceMs = n any = true }</span> - <span class="cov5" title="33">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov2" title="3">{ out.CompletionThrottleMs = n any = true }</span> - <span class="cov5" title="33">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov2" title="3">{ out.CodingTemperature = f any = true }</span> - <span class="cov5" title="33">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov2" title="3">{ parts := strings.Split(s, ",") out.TriggerCharacters = nil - for _, p := range parts </span><span class="cov2" title="3">{ - if t := strings.TrimSpace(p); t != "" </span><span class="cov2" title="3">{ + for _, p := range parts </span><span class="cov3" title="9">{ + if t := strings.TrimSpace(p); t != "" </span><span class="cov3" title="9">{ out.TriggerCharacters = append(out.TriggerCharacters, t) }</span> } - <span class="cov1" title="1">any = true</span> + <span class="cov2" title="3">any = true</span> } - <span class="cov5" title="33">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="99">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ out.InlineOpen = s any = true }</span> - <span class="cov5" title="33">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="99">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ out.InlineClose = s any = true }</span> - <span class="cov5" title="33">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="99">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ out.ChatSuffix = s any = true }</span> - <span class="cov5" title="33">if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="99">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">{ @@ -1571,132 +1616,145 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="33">{ } <span class="cov0" title="0">any = true</span> } - <span class="cov5" title="33">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov3" title="5">{ + <span class="cov5" title="99">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov3" title="15">{ out.Provider = s any = true }</span> - <span class="cov5" title="33">modelForce := strings.TrimSpace(getenv("HEXAI_MODEL_FORCE")) + <span class="cov5" title="99">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="132">{ + pickModel := func(providerName, specific string) (string, bool) </span><span class="cov7" title="495">{ specific = strings.TrimSpace(specific) nameLower := strings.ToLower(strings.TrimSpace(providerName)) - if modelForce != "" </span><span class="cov2" title="4">{ - if providerLower == nameLower </span><span class="cov1" title="1">{ + if modelForce != "" </span><span class="cov3" title="15">{ + if providerLower == nameLower </span><span class="cov2" title="3">{ forceUsed = true return modelForce, true }</span> - <span class="cov2" title="3">if providerLower == "" && !forceUsed </span><span class="cov0" title="0">{ + <span class="cov3" title="12">if providerLower == "" && !forceUsed </span><span class="cov0" title="0">{ forceUsed = true return modelForce, true }</span> } - <span class="cov7" title="131">if specific != "" </span><span class="cov2" title="4">{ + <span class="cov7" title="492">if specific != "" </span><span class="cov3" title="12">{ return specific, true }</span> - <span class="cov7" title="127">if modelGeneric != "" </span><span class="cov3" title="11">{ - if providerLower == nameLower </span><span class="cov1" title="2">{ + <span class="cov7" title="480">if modelGeneric != "" </span><span class="cov5" title="42">{ + if providerLower == nameLower </span><span class="cov2" title="6">{ return modelGeneric, true }</span> - <span class="cov3" title="9">if providerLower == "" && !genericUsed </span><span class="cov0" title="0">{ + <span class="cov4" title="36">if providerLower == "" && !genericUsed </span><span class="cov0" title="0">{ genericUsed = true return modelGeneric, true }</span> } - <span class="cov7" title="125">return "", false</span> + <span class="cov7" title="474">return "", false</span> } // Provider-specific - <span class="cov5" title="33">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov2" title="3">{ out.OpenAIBaseURL = s any = true }</span> - <span class="cov5" title="33">if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok </span><span class="cov3" title="5">{ + <span class="cov5" title="99">if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok </span><span class="cov3" title="15">{ out.OpenAIModel = model any = true }</span> - <span class="cov5" title="33">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov2" title="3">{ out.OpenAITemperature = f any = true }</span> - <span class="cov5" title="33">if s := getenv("HEXAI_OPENROUTER_BASE_URL"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="99">if s := getenv("HEXAI_OPENROUTER_BASE_URL"); s != "" </span><span class="cov0" title="0">{ out.OpenRouterBaseURL = s any = true }</span> - <span class="cov5" title="33">if model, ok := pickModel("openrouter", getenv("HEXAI_OPENROUTER_MODEL")); ok </span><span class="cov0" title="0">{ + <span class="cov5" title="99">if model, ok := pickModel("openrouter", getenv("HEXAI_OPENROUTER_MODEL")); ok </span><span class="cov0" title="0">{ out.OpenRouterModel = model any = true }</span> - <span class="cov5" title="33">if f, ok := parseFloatPtr("HEXAI_OPENROUTER_TEMPERATURE"); ok </span><span class="cov0" title="0">{ + <span class="cov5" title="99">if f, ok := parseFloatPtr("HEXAI_OPENROUTER_TEMPERATURE"); ok </span><span class="cov0" title="0">{ out.OpenRouterTemperature = f any = true }</span> - <span class="cov5" title="33">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov2" title="3">{ out.OllamaBaseURL = s any = true }</span> - <span class="cov5" title="33">if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok </span><span class="cov2" title="3">{ out.OllamaModel = model any = true }</span> - <span class="cov5" title="33">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov2" title="3">{ out.OllamaTemperature = f any = true }</span> - <span class="cov5" title="33">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov2" title="3">{ out.CopilotBaseURL = s any = true }</span> - <span class="cov5" title="33">if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok </span><span class="cov2" title="3">{ out.CopilotModel = model any = true }</span> - <span class="cov5" title="33">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov2" title="3">{ out.CopilotTemperature = f any = true }</span> + <span class="cov5" title="99">if s := getenv("HEXAI_ANTHROPIC_BASE_URL"); s != "" </span><span class="cov0" title="0">{ + out.AnthropicBaseURL = s + any = true + }</span> + <span class="cov5" title="99">if model, ok := pickModel("anthropic", getenv("HEXAI_ANTHROPIC_MODEL")); ok </span><span class="cov0" title="0">{ + out.AnthropicModel = model + any = true + }</span> + <span class="cov5" title="99">if f, ok := parseFloatPtr("HEXAI_ANTHROPIC_TEMPERATURE"); ok </span><span class="cov0" title="0">{ + out.AnthropicTemperature = f + any = true + }</span> + // Per-surface overrides - <span class="cov5" title="33">buildEntry := func(modelKey, tempKey, providerKey string) ([]SurfaceConfig, bool) </span><span class="cov7" title="132">{ + <span class="cov5" title="99">buildEntry := func(modelKey, tempKey, providerKey string) ([]SurfaceConfig, bool) </span><span class="cov7" title="396">{ model := getenv(modelKey) tempPtr, tempSet := parseFloatPtr(tempKey) provider := getenv(providerKey) - if model == "" && provider == "" && !tempSet </span><span class="cov7" title="126">{ + if model == "" && provider == "" && !tempSet </span><span class="cov7" title="378">{ return nil, false }</span> - <span class="cov3" title="6">entry := SurfaceConfig{Provider: provider, Model: model} - if tempSet </span><span class="cov3" title="6">{ + <span class="cov4" title="18">entry := SurfaceConfig{Provider: provider, Model: model} + if tempSet </span><span class="cov4" title="18">{ entry.Temperature = tempPtr }</span> - <span class="cov3" title="6">return []SurfaceConfig{entry}, true</span> + <span class="cov4" title="18">return []SurfaceConfig{entry}, true</span> } - <span class="cov5" title="33">if entries, ok := buildEntry("HEXAI_MODEL_COMPLETION", "HEXAI_TEMPERATURE_COMPLETION", "HEXAI_PROVIDER_COMPLETION"); ok </span><span class="cov1" title="2">{ + <span class="cov5" title="99">if entries, ok := buildEntry("HEXAI_MODEL_COMPLETION", "HEXAI_TEMPERATURE_COMPLETION", "HEXAI_PROVIDER_COMPLETION"); ok </span><span class="cov2" title="6">{ out.CompletionConfigs = entries any = true }</span> - <span class="cov5" title="33">if entries, ok := buildEntry("HEXAI_MODEL_CODE_ACTION", "HEXAI_TEMPERATURE_CODE_ACTION", "HEXAI_PROVIDER_CODE_ACTION"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if entries, ok := buildEntry("HEXAI_MODEL_CODE_ACTION", "HEXAI_TEMPERATURE_CODE_ACTION", "HEXAI_PROVIDER_CODE_ACTION"); ok </span><span class="cov2" title="3">{ out.CodeActionConfigs = entries any = true }</span> - <span class="cov5" title="33">if entries, ok := buildEntry("HEXAI_MODEL_CHAT", "HEXAI_TEMPERATURE_CHAT", "HEXAI_PROVIDER_CHAT"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="99">if entries, ok := buildEntry("HEXAI_MODEL_CHAT", "HEXAI_TEMPERATURE_CHAT", "HEXAI_PROVIDER_CHAT"); ok </span><span class="cov2" title="3">{ out.ChatConfigs = entries any = true }</span> - <span class="cov5" title="33">if entries, ok := buildEntry("HEXAI_MODEL_CLI", "HEXAI_TEMPERATURE_CLI", "HEXAI_PROVIDER_CLI"); ok </span><span class="cov1" title="2">{ + <span class="cov5" title="99">if entries, ok := buildEntry("HEXAI_MODEL_CLI", "HEXAI_TEMPERATURE_CLI", "HEXAI_PROVIDER_CLI"); ok </span><span class="cov2" title="6">{ out.CLIConfigs = entries any = true }</span> - <span class="cov5" title="33">if !any </span><span class="cov4" title="24">{ + <span class="cov5" title="99">if !any </span><span class="cov5" title="72">{ return nil }</span> - <span class="cov3" title="9">return &out</span> + <span class="cov4" title="27">return &out</span> } </pre> @@ -1711,15 +1769,15 @@ import ( ) // Resolve returns the editor command from HEXAI_EDITOR or EDITOR. -func Resolve() (string, error) <span class="cov10" title="5">{ +func Resolve() (string, error) <span class="cov10" title="15">{ ed := strings.TrimSpace(os.Getenv("HEXAI_EDITOR")) - if ed == "" </span><span class="cov1" title="1">{ + if ed == "" </span><span class="cov4" title="3">{ ed = strings.TrimSpace(os.Getenv("EDITOR")) }</span> - <span class="cov10" title="5">if ed == "" </span><span class="cov0" title="0">{ + <span class="cov10" title="15">if ed == "" </span><span class="cov0" title="0">{ return "", errors.New("no editor configured (set HEXAI_EDITOR or EDITOR)") }</span> - <span class="cov10" title="5">return ed, nil</span> + <span class="cov10" title="15">return ed, nil</span> } // RunEditor is the seam that invokes the editor on the given file path. @@ -1735,40 +1793,40 @@ var RunEditor = func(editor, path string) error <span class="cov0" title="0">{ // OpenTempAndEdit creates a temporary .md file, writes initial content if provided, // opens it in the resolved editor, then reads the final content and removes the file. // Returns the trimmed content. -func OpenTempAndEdit(initial []byte) (string, error) <span class="cov7" title="3">{ +func OpenTempAndEdit(initial []byte) (string, error) <span class="cov8" title="9">{ ed, err := Resolve() if err != nil </span><span class="cov0" title="0">{ return "", err }</span> // Create temp file under system temp dir; ensure .md suffix - <span class="cov7" title="3">dir := os.TempDir() + <span class="cov8" title="9">dir := os.TempDir() f, err := os.CreateTemp(dir, "hexai-*.md") if err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov7" title="3">path := f.Name() - defer func() </span><span class="cov7" title="3">{ _ = os.Remove(path) }</span>() - <span class="cov7" title="3">if len(initial) > 0 </span><span class="cov1" title="1">{ + <span class="cov8" title="9">path := f.Name() + defer func() </span><span class="cov8" title="9">{ _ = os.Remove(path) }</span>() + <span class="cov8" title="9">if len(initial) > 0 </span><span class="cov4" title="3">{ if _, err := f.Write(initial); err != nil </span><span class="cov0" title="0">{ _ = f.Close() return "", err }</span> } - <span class="cov7" title="3">if err := f.Sync(); err != nil </span><span class="cov0" title="0">{ + <span class="cov8" title="9">if err := f.Sync(); err != nil </span><span class="cov0" title="0">{ _ = f.Close() return "", err }</span> - <span class="cov7" title="3">if err := f.Close(); err != nil </span><span class="cov0" title="0">{ + <span class="cov8" title="9">if err := f.Close(); err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov7" title="3">if err := RunEditor(ed, path); err != nil </span><span class="cov0" title="0">{ + <span class="cov8" title="9">if err := RunEditor(ed, path); err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov7" title="3">b, err := os.ReadFile(filepath.Clean(path)) + <span class="cov8" title="9">b, err := os.ReadFile(filepath.Clean(path)) if err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov7" title="3">return strings.TrimSpace(string(b)), nil</span> + <span class="cov8" title="9">return strings.TrimSpace(string(b)), nil</span> } </pre> @@ -1798,12 +1856,12 @@ type Options struct { // RunCommand is the CLI orchestrator used by cmd/hexai-tmux-action. It runs in tmux // split-pane mode by default, or child mode when -ui-child is set. -func RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov4" title="2">{ - if opts.UIChild </span><span class="cov1" title="1">{ +func RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov6" title="6">{ + if opts.UIChild </span><span class="cov4" title="3">{ return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr) }</span> // Always use tmux path - <span class="cov1" title="1">return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent)</span> + <span class="cov4" title="3">return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent)</span> } // seams for unit tests @@ -1815,144 +1873,144 @@ var ( ) // openIO returns readers/writers for infile/outfile flags with deferred closers. -func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) <span class="cov7" title="3">{ +func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) <span class="cov8" title="9">{ in := io.Reader(os.Stdin) out := io.Writer(os.Stdout) closeIn := func() </span>{<span class="cov0" title="0">}</span> - <span class="cov7" title="3">closeOut := func() </span>{<span class="cov0" title="0">}</span> - <span class="cov7" title="3">if path := infile; path != "" </span><span class="cov7" title="3">{ + <span class="cov8" title="9">closeOut := func() </span>{<span class="cov0" title="0">}</span> + <span class="cov8" title="9">if path := infile; path != "" </span><span class="cov8" title="9">{ f, err := os.Open(path) if err != nil </span><span class="cov0" title="0">{ return nil, nil, func() </span>{<span class="cov0" title="0">}</span>, func() {<span class="cov0" title="0">}</span>, fmt.Errorf("hexai-tmux-action: cannot open infile: %w", err) } - <span class="cov7" title="3">in = f - closeIn = func() </span><span class="cov7" title="3">{ _ = f.Close() }</span> + <span class="cov8" title="9">in = f + closeIn = func() </span><span class="cov8" title="9">{ _ = f.Close() }</span> } - <span class="cov7" title="3">if path := outfile; path != "" </span><span class="cov7" title="3">{ + <span class="cov8" title="9">if path := outfile; path != "" </span><span class="cov8" title="9">{ f, err := os.Create(path) if err != nil </span><span class="cov0" title="0">{ return nil, nil, func() </span>{<span class="cov0" title="0">}</span>, func() {<span class="cov0" title="0">}</span>, fmt.Errorf("hexai-tmux-action: cannot open outfile: %w", err) } - <span class="cov7" title="3">out = f - closeOut = func() </span><span class="cov7" title="3">{ _ = f.Close() }</span> + <span class="cov8" title="9">out = f + closeOut = func() </span><span class="cov8" title="9">{ _ = f.Close() }</span> } - <span class="cov7" title="3">return in, out, closeIn, closeOut, nil</span> + <span class="cov8" title="9">return in, out, closeIn, closeOut, nil</span> } // runChild runs the interactive flow and writes the final output atomically when outfile is set. -func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error <span class="cov7" title="3">{ - if outfile == "" </span><span class="cov1" title="1">{ +func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error <span class="cov8" title="9">{ + if outfile == "" </span><span class="cov4" title="3">{ // No atomic handoff needed; just run normally to provided stdout var in io.Reader = os.Stdin - if infile != "" </span><span class="cov1" title="1">{ + if infile != "" </span><span class="cov4" title="3">{ f, err := os.Open(infile) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov1" title="1">defer func() </span><span class="cov1" title="1">{ _ = f.Close() }</span>() - <span class="cov1" title="1">in = f</span> + <span class="cov4" title="3">defer func() </span><span class="cov4" title="3">{ _ = f.Close() }</span>() + <span class="cov4" title="3">in = f</span> } - <span class="cov1" title="1">return runFn(ctx, in, stdout, stderr)</span> + <span class="cov4" title="3">return runFn(ctx, in, stdout, stderr)</span> } - <span class="cov4" title="2">tmp := outfile + ".tmp" + <span class="cov6" title="6">tmp := outfile + ".tmp" in, out, closeIn, closeOut, err := openIO(infile, tmp) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov4" title="2">defer closeIn() + <span class="cov6" title="6">defer closeIn() if err := runFn(ctx, in, out, stderr); err != nil </span><span class="cov0" title="0">{ closeOut() if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil </span><span class="cov0" title="0">{ return fmt.Errorf("hexai-tmux-action child: %v; echo failed: %v", err, copyErr) }</span> - } else<span class="cov4" title="2"> { + } else<span class="cov6" title="6"> { closeOut() }</span> - <span class="cov4" title="2">return os.Rename(tmp, outfile)</span> + <span class="cov6" title="6">return os.Rename(tmp, outfile)</span> } -func runInTmuxParent(stdin io.Reader, stdout io.Writer, target, split string, percent int) error <span class="cov8" title="4">{ +func runInTmuxParent(stdin io.Reader, stdout io.Writer, target, split string, percent int) error <span class="cov9" title="12">{ dir, err := os.MkdirTemp("", "hexai-tmux-action-") if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov8" title="4">defer func() </span><span class="cov8" title="4">{ _ = os.RemoveAll(dir) }</span>() - <span class="cov8" title="4">inPath := filepath.Join(dir, "input.txt") + <span class="cov9" title="12">defer func() </span><span class="cov9" title="12">{ _ = os.RemoveAll(dir) }</span>() + <span class="cov9" title="12">inPath := filepath.Join(dir, "input.txt") outPath := filepath.Join(dir, "reply.txt") if err := persistStdin(inPath, stdin); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov8" title="4">exe, err := osExecutableFn() - if err != nil </span><span class="cov1" title="1">{ + <span class="cov9" title="12">exe, err := osExecutableFn() + if err != nil </span><span class="cov4" title="3">{ return err }</span> - <span class="cov7" title="3">argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath} + <span class="cov8" title="9">argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath} opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent} - if err := splitRunFn(opts, argv); err != nil </span><span class="cov1" title="1">{ + if err := splitRunFn(opts, argv); err != nil </span><span class="cov4" title="3">{ return err }</span> - <span class="cov4" title="2">if err := waitForFile(outPath, 60*time.Second); err != nil </span><span class="cov0" title="0">{ + <span class="cov6" title="6">if err := waitForFile(outPath, 60*time.Second); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov4" title="2">return catFileTo(stdout, outPath)</span> + <span class="cov6" title="6">return catFileTo(stdout, outPath)</span> } -func persistStdin(path string, stdin io.Reader) error <span class="cov10" title="5">{ +func persistStdin(path string, stdin io.Reader) error <span class="cov10" title="15">{ f, err := os.Create(path) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov10" title="5">defer func() </span><span class="cov10" title="5">{ _ = f.Close() }</span>() - <span class="cov10" title="5">if _, err := io.Copy(f, stdin); err != nil </span><span class="cov0" title="0">{ + <span class="cov10" title="15">defer func() </span><span class="cov10" title="15">{ _ = f.Close() }</span>() + <span class="cov10" title="15">if _, err := io.Copy(f, stdin); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov10" title="5">return f.Sync()</span> + <span class="cov10" title="15">return f.Sync()</span> } -func waitForFile(path string, timeout time.Duration) error <span class="cov7" title="3">{ +func waitForFile(path string, timeout time.Duration) error <span class="cov8" title="9">{ deadline := time.Now().Add(timeout) - for </span><span class="cov8" title="4">{ - if _, err := os.Stat(path); err == nil </span><span class="cov4" title="2">{ + for </span><span class="cov9" title="12">{ + if _, err := os.Stat(path); err == nil </span><span class="cov6" title="6">{ return nil }</span> - <span class="cov4" title="2">if time.Now().After(deadline) </span><span class="cov1" title="1">{ + <span class="cov6" title="6">if time.Now().After(deadline) </span><span class="cov4" title="3">{ return fmt.Errorf("hexai-tmux-action: timeout waiting for reply file") }</span> - <span class="cov1" title="1">time.Sleep(200 * time.Millisecond)</span> + <span class="cov4" title="3">time.Sleep(200 * time.Millisecond)</span> } } -func catFileTo(w io.Writer, path string) error <span class="cov4" title="2">{ +func catFileTo(w io.Writer, path string) error <span class="cov6" title="6">{ f, err := os.Open(path) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov4" title="2">defer func() </span><span class="cov4" title="2">{ _ = f.Close() }</span>() - <span class="cov4" title="2">_, err = io.Copy(w, f) + <span class="cov6" title="6">defer func() </span><span class="cov6" title="6">{ _ = f.Close() }</span>() + <span class="cov6" title="6">_, err = io.Copy(w, f) return err</span> } // echoThrough no longer used in tmux-only flow, but kept for potential reuse. -func echoThrough(infile, outfile string, stdin io.Reader, stdout io.Writer) error <span class="cov4" title="2">{ - var in io.Reader = stdin - var out io.Writer = stdout - if infile != "" </span><span class="cov1" title="1">{ +func echoThrough(infile, outfile string, stdin io.Reader, stdout io.Writer) error <span class="cov6" title="6">{ + in := stdin + out := stdout + if infile != "" </span><span class="cov4" title="3">{ f, err := os.Open(infile) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov1" title="1">defer func() </span><span class="cov1" title="1">{ _ = f.Close() }</span>() - <span class="cov1" title="1">in = f</span> + <span class="cov4" title="3">defer func() </span><span class="cov4" title="3">{ _ = f.Close() }</span>() + <span class="cov4" title="3">in = f</span> } - <span class="cov4" title="2">if outfile != "" </span><span class="cov1" title="1">{ + <span class="cov6" title="6">if outfile != "" </span><span class="cov4" title="3">{ f, err := os.Create(outfile) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov1" title="1">defer func() </span><span class="cov1" title="1">{ _ = f.Close() }</span>() - <span class="cov1" title="1">out = f</span> + <span class="cov4" title="3">defer func() </span><span class="cov4" title="3">{ _ = f.Close() }</span>() + <span class="cov4" title="3">out = f</span> } - <span class="cov4" title="2">_, err := io.Copy(out, in) + <span class="cov6" title="6">_, err := io.Copy(out, in) return err</span> } </pre> @@ -1976,47 +2034,47 @@ import ( // <rest is selection/code> // // If the header is absent, the entire input is treated as selection. -func ParseInput(r io.Reader) (InputParts, error) <span class="cov7" title="5">{ +func ParseInput(r io.Reader) (InputParts, error) <span class="cov8" title="15">{ b, err := io.ReadAll(bufio.NewReader(r)) if err != nil </span><span class="cov0" title="0">{ return InputParts{}, err }</span> - <span class="cov7" title="5">raw := strings.TrimSpace(string(b)) + <span class="cov8" title="15">raw := strings.TrimSpace(string(b)) if raw == "" </span><span class="cov0" title="0">{ return InputParts{Selection: ""}, nil }</span> - <span class="cov7" title="5">lines := strings.Split(raw, "\n") + <span class="cov8" title="15">lines := strings.Split(raw, "\n") // find a case-insensitive line equal to "diagnostics:" diagsIdx := -1 - for i, ln := range lines </span><span class="cov8" title="6">{ + for i, ln := range lines </span><span class="cov9" title="18">{ t := strings.TrimSpace(strings.ToLower(ln)) - if t == "diagnostics:" </span><span class="cov1" title="1">{ + if t == "diagnostics:" </span><span class="cov4" title="3">{ diagsIdx = i break</span> } } - <span class="cov7" title="5">if diagsIdx < 0 </span><span class="cov7" title="4">{ + <span class="cov8" title="15">if diagsIdx < 0 </span><span class="cov8" title="12">{ return InputParts{Selection: raw}, nil }</span> // collect diagnostics until a blank line or EOF - <span class="cov1" title="1">diags := []string{} + <span class="cov4" title="3">diags := []string{} i := diagsIdx + 1 - for ; i < len(lines); i++ </span><span class="cov5" title="3">{ + for ; i < len(lines); i++ </span><span class="cov7" title="9">{ t := strings.TrimSpace(lines[i]) - if t == "" </span><span class="cov1" title="1">{ + if t == "" </span><span class="cov4" title="3">{ i++ break</span> } - <span class="cov4" title="2">diags = append(diags, t)</span> + <span class="cov6" title="6">diags = append(diags, t)</span> } - <span class="cov1" title="1">sel := strings.Join(lines[i:], "\n") + <span class="cov4" title="3">sel := strings.Join(lines[i:], "\n") sel = strings.TrimSpace(sel) return InputParts{Selection: sel, Diagnostics: diags}, nil</span> } // ExtractInstruction mirrors the LSP instructionFromSelection behavior (subset), // scanning the first line for an instruction marker and removing it from the selection. -func ExtractInstruction(sel string) (string, string) <span class="cov10" title="8">{ return textutil.InstructionFromSelection(sel) }</span> +func ExtractInstruction(sel string) (string, string) <span class="cov10" title="24">{ return textutil.InstructionFromSelection(sel) }</span> // findFirstInstructionInLine follows the same precedence as LSP: // - ;text; (strict) @@ -2043,10 +2101,10 @@ import ( ) // Render performs simple {{var}} replacement like LSP. -func Render(t string, vars map[string]string) string <span class="cov7" title="18">{ return textutil.RenderTemplate(t, vars) }</span> +func Render(t string, vars map[string]string) string <span class="cov8" title="54">{ return textutil.RenderTemplate(t, vars) }</span> // StripFences removes surrounding markdown code fences. -func StripFences(s string) string <span class="cov7" title="19">{ return textutil.StripCodeFences(s) }</span> +func StripFences(s string) string <span class="cov8" title="57">{ return textutil.StripCodeFences(s) }</span> type chatDoer interface { Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) @@ -2060,204 +2118,204 @@ type requestArgs struct { options []llm.RequestOption } -func providerOf(c any) string <span class="cov10" title="54">{ - if n, ok := c.(providerNamer); ok </span><span class="cov5" title="6">{ +func providerOf(c any) string <span class="cov10" title="162">{ + if n, ok := c.(providerNamer); ok </span><span class="cov6" title="18">{ return n.Name() }</span> - <span class="cov9" title="48">return "llm"</span> + <span class="cov9" title="144">return "llm"</span> } -func canonicalProvider(name string) string <span class="cov8" title="24">{ +func canonicalProvider(name string) string <span class="cov8" title="72">{ p := strings.ToLower(strings.TrimSpace(name)) - if p == "" </span><span class="cov7" title="20">{ + if p == "" </span><span class="cov8" title="60">{ return "openai" }</span> - <span class="cov4" title="4">return p</span> + <span class="cov5" title="12">return p</span> } -func defaultModelForProvider(cfg appconfig.App, provider string) string <span class="cov9" title="41">{ +func defaultModelForProvider(cfg appconfig.App, provider string) string <span class="cov9" title="123">{ switch provider </span>{ case "ollama":<span class="cov0" title="0"> return cfg.OllamaModel</span> case "copilot":<span class="cov0" title="0"> return cfg.CopilotModel</span> - default:<span class="cov9" title="41"> + default:<span class="cov9" title="123"> return cfg.OpenAIModel</span> } } -func selectActionTemperature(cfg appconfig.App, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) <span class="cov7" title="22">{ - if entry.Temperature != nil </span><span class="cov1" title="1">{ +func selectActionTemperature(cfg appconfig.App, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) <span class="cov8" title="66">{ + if entry.Temperature != nil </span><span class="cov2" title="3">{ return *entry.Temperature, true }</span> - <span class="cov7" title="21">if cfg.CodingTemperature != nil </span><span class="cov7" title="17">{ + <span class="cov8" title="63">if cfg.CodingTemperature != nil </span><span class="cov7" title="51">{ temp := *cfg.CodingTemperature - if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 </span><span class="cov1" title="1">{ + if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 </span><span class="cov2" title="3">{ temp = 1.0 }</span> - <span class="cov7" title="17">return temp, true</span> + <span class="cov7" title="51">return temp, true</span> } - <span class="cov4" title="4">if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") </span><span class="cov0" title="0">{ + <span class="cov5" title="12">if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") </span><span class="cov0" title="0">{ return 1.0, true }</span> - <span class="cov4" title="4">return 0, false</span> + <span class="cov5" title="12">return 0, false</span> } -func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) <span class="cov5" title="7">{ +func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) <span class="cov6" title="21">{ 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="cov2" title="2">{ +func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) <span class="cov4" title="6">{ var b strings.Builder - for i, d := range diags </span><span class="cov3" title="3">{ + for i, d := range diags </span><span class="cov4" title="9">{ if strings.TrimSpace(d) == "" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov3" title="3">b.WriteString(strings.TrimSpace(d)) - if i < len(diags)-1 </span><span class="cov1" title="1">{ + <span class="cov4" title="9">b.WriteString(strings.TrimSpace(d)) + if i < len(diags)-1 </span><span class="cov2" title="3">{ b.WriteString("\n") }</span> } - <span class="cov2" title="2">sys := cfg.PromptCodeActionDiagnosticsSystem + <span class="cov4" title="6">sys := cfg.PromptCodeActionDiagnosticsSystem user := Render(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": b.String(), "selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))</span> } -func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) <span class="cov2" title="2">{ +func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) <span class="cov4" title="6">{ sys := cfg.PromptCodeActionDocumentSystem user := Render(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": selection}) 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="cov2" title="2">{ +func runSimplify(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) <span class="cov4" title="6">{ sys := cfg.PromptCodeActionSimplifySystem user := Render(cfg.PromptCodeActionSimplifyUser, map[string]string{"selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) }</span> -func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode string) (string, error) <span class="cov2" title="2">{ +func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode string) (string, error) <span class="cov4" title="6">{ sys := cfg.PromptCodeActionGoTestSystem user := Render(cfg.PromptCodeActionGoTestUser, map[string]string{"function": 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="5">{ +func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appconfig.CustomAction, parts InputParts) (string, error) <span class="cov5" title="15">{ // If user template is provided, prefer it and optional system - if strings.TrimSpace(ca.User) != "" </span><span class="cov2" title="2">{ + if strings.TrimSpace(ca.User) != "" </span><span class="cov4" title="6">{ sys := cfg.PromptCodeActionRewriteSystem if strings.TrimSpace(ca.System) != "" </span><span class="cov0" title="0">{ sys = ca.System }</span> // Currently only selection is available in tmux path; diagnostics list not wired - <span class="cov2" title="2">user := Render(ca.User, map[string]string{"selection": parts.Selection, "diagnostics": strings.Join(parts.Diagnostics, "\n")}) + <span class="cov4" title="6">user := Render(ca.User, map[string]string{"selection": parts.Selection, "diagnostics": strings.Join(parts.Diagnostics, "\n")}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))</span> } // Else, use fixed instruction through rewrite template - <span class="cov3" title="3">return runRewrite(ctx, cfg, client, ca.Instruction, parts.Selection)</span> + <span class="cov4" title="9">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">{ +func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) <span class="cov2" title="3">{ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} txt, err := client.Chat(ctx, msgs) if err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov1" title="1">out := strings.TrimSpace(StripFences(txt)) + <span class="cov2" title="3">out := strings.TrimSpace(StripFences(txt)) // Contribute to global stats and update tmux status sent := 0 - for _, m := range msgs </span><span class="cov2" title="2">{ + for _, m := range msgs </span><span class="cov4" title="6">{ sent += len(m.Content) }</span> - <span class="cov1" title="1">recv := len(out) + <span class="cov2" title="3">recv := len(out) _ = stats.Update(ctx, providerOf(client), client.DefaultModel(), sent, recv) - if snap, err := stats.TakeSnapshot(); err == nil </span><span class="cov1" title="1">{ + if snap, err := stats.TakeSnapshot(); err == nil </span><span class="cov2" title="3">{ minsWin := snap.Window.Minutes() if minsWin <= 0 </span><span class="cov0" title="0">{ minsWin = 0.001 }</span> - <span class="cov1" title="1">scopeReqs := int64(0) - if pe, ok := snap.Providers[providerOf(client)]; ok </span><span class="cov1" title="1">{ - if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 </span><span class="cov1" title="1">{ + <span class="cov2" title="3">scopeReqs := int64(0) + if pe, ok := snap.Providers[providerOf(client)]; ok </span><span class="cov2" title="3">{ + if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 </span><span class="cov2" title="3">{ scopeReqs = mc.Reqs }</span> } - <span class="cov1" title="1">scopeRPM := float64(scopeReqs) / minsWin + <span class="cov2" title="3">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="cov1" title="1">return out, nil</span> + <span class="cov2" title="3">return out, nil</span> } -func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, req requestArgs) (string, error) <span class="cov7" title="17">{ +func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, req requestArgs) (string, error) <span class="cov7" title="51">{ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} txt, err := client.Chat(ctx, msgs, req.options...) if err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov7" title="17">out := strings.TrimSpace(StripFences(txt)) + <span class="cov7" title="51">out := strings.TrimSpace(StripFences(txt)) // Contribute to global stats and update tmux status sent := 0 - for _, m := range msgs </span><span class="cov8" title="34">{ + for _, m := range msgs </span><span class="cov9" title="102">{ sent += len(m.Content) }</span> - <span class="cov7" title="17">recv := len(out) + <span class="cov7" title="51">recv := len(out) model := strings.TrimSpace(req.model) - if model == "" </span><span class="cov7" title="17">{ + if model == "" </span><span class="cov7" title="51">{ model = client.DefaultModel() }</span> - <span class="cov7" title="17">_ = stats.Update(ctx, providerOf(client), model, sent, recv) - if snap, err := stats.TakeSnapshot(); err == nil </span><span class="cov7" title="17">{ + <span class="cov7" title="51">_ = stats.Update(ctx, providerOf(client), model, sent, recv) + if snap, err := stats.TakeSnapshot(); err == nil </span><span class="cov7" title="51">{ minsWin := snap.Window.Minutes() if minsWin <= 0 </span><span class="cov0" title="0">{ minsWin = 0.001 }</span> - <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[model]; ok2 </span><span class="cov7" title="17">{ + <span class="cov7" title="51">scopeReqs := int64(0) + if pe, ok := snap.Providers[providerOf(client)]; ok </span><span class="cov7" title="51">{ + if mc, ok2 := pe.Models[model]; ok2 </span><span class="cov7" title="51">{ scopeReqs = mc.Reqs }</span> } - <span class="cov7" title="17">scopeRPM := float64(scopeReqs) / minsWin + <span class="cov7" title="51">scopeRPM := float64(scopeReqs) / minsWin _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, providerOf(client), model, scopeRPM, scopeReqs, snap.Window))</span> } - <span class="cov7" title="17">return out, nil</span> + <span class="cov7" title="51">return out, nil</span> } // reqOptsFrom builds LLM request options similar to LSP behavior. -func reqOptsFrom(cfg appconfig.App) requestArgs <span class="cov7" title="22">{ +func reqOptsFrom(cfg appconfig.App) requestArgs <span class="cov8" title="66">{ opts := make([]llm.RequestOption, 0, 3) - if cfg.MaxTokens > 0 </span><span class="cov7" title="17">{ + if cfg.MaxTokens > 0 </span><span class="cov7" title="51">{ opts = append(opts, llm.WithMaxTokens(cfg.MaxTokens)) }</span> - <span class="cov7" title="22">provider := canonicalProvider(cfg.Provider) + <span class="cov8" title="66">provider := canonicalProvider(cfg.Provider) entries := cfg.CodeActionConfigs - if len(entries) == 0 </span><span class="cov7" title="21">{ + if len(entries) == 0 </span><span class="cov8" title="63">{ entries = []appconfig.SurfaceConfig{{Provider: cfg.Provider, Model: strings.TrimSpace(defaultModelForProvider(cfg, provider))}} }</span> - <span class="cov7" title="22">primary := entries[0] - if strings.TrimSpace(primary.Provider) != "" </span><span class="cov2" title="2">{ + <span class="cov8" title="66">primary := entries[0] + if strings.TrimSpace(primary.Provider) != "" </span><span class="cov4" title="6">{ provider = canonicalProvider(primary.Provider) }</span> - <span class="cov7" title="22">model := strings.TrimSpace(primary.Model) - if model == "" </span><span class="cov7" title="20">{ + <span class="cov8" title="66">model := strings.TrimSpace(primary.Model) + if model == "" </span><span class="cov8" title="60">{ model = strings.TrimSpace(defaultModelForProvider(cfg, provider)) }</span> - <span class="cov7" title="22">if strings.TrimSpace(primary.Model) != "" </span><span class="cov2" title="2">{ + <span class="cov8" title="66">if strings.TrimSpace(primary.Model) != "" </span><span class="cov4" title="6">{ opts = append(opts, llm.WithModel(strings.TrimSpace(primary.Model))) }</span> - <span class="cov7" title="22">if temp, ok := selectActionTemperature(cfg, provider, primary, model); ok </span><span class="cov7" title="18">{ + <span class="cov8" title="66">if temp, ok := selectActionTemperature(cfg, provider, primary, model); ok </span><span class="cov8" title="54">{ opts = append(opts, llm.WithTemperature(temp)) }</span> - <span class="cov7" title="22">return requestArgs{model: model, options: opts}</span> + <span class="cov8" title="66">return requestArgs{model: model, options: opts}</span> } // Timeout helpers to mirror LSP behavior. -func timeout10s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov6" title="10">{ +func timeout10s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov7" title="30">{ return context.WithTimeout(parent, 20*time.Second) }</span> -func timeout8s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov1" title="1">{ +func timeout8s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov2" title="3">{ return context.WithTimeout(parent, 18*time.Second) }</span> </pre> @@ -2293,53 +2351,53 @@ type configPathKey struct{} // to the executor. Cleared after use. var selectedCustom *appconfig.CustomAction -func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov6" title="4">{ +func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov7" title="12">{ logger := log.New(stderr, "hexai-tmux-action ", log.LstdFlags|log.Lmsgprefix) cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPathFromContext(ctx)}) - if cfg.StatsWindowMinutes > 0 </span><span class="cov6" title="4">{ + if cfg.StatsWindowMinutes > 0 </span><span class="cov7" title="12">{ stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) }</span> - <span class="cov6" title="4">if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{ - fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: %v"+logging.AnsiReset+"\n", err) + <span class="cov7" title="12">if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{ + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: %v"+logging.AnsiReset+"\n", err) return err }</span> // Enable custom action submenu with configurable hotkey - <span class="cov6" title="4">if len(cfg.CustomActions) > 0 </span><span class="cov0" title="0">{ + <span class="cov7" title="12">if len(cfg.CustomActions) > 0 </span><span class="cov0" title="0">{ chooseActionFn = func() (ActionKind, error) </span><span class="cov0" title="0">{ return RunTUIWithCustom(cfg.CustomActions, cfg.TmuxCustomMenuHotkey) }</span> } - <span class="cov6" title="4">if len(cfg.CodeActionConfigs) > 0 </span><span class="cov0" title="0">{ + <span class="cov7" title="12">if len(cfg.CodeActionConfigs) > 0 </span><span class="cov0" title="0">{ if provider := strings.TrimSpace(cfg.CodeActionConfigs[0].Provider); provider != "" </span><span class="cov0" title="0">{ cfg.Provider = provider }</span> } - <span class="cov6" title="4">cli, err := newClientFromApp(cfg) - if err != nil </span><span class="cov1" title="1">{ - fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: LLM disabled: %v"+logging.AnsiReset+"\n", err) + <span class="cov7" title="12">cli, err := newClientFromApp(cfg) + if err != nil </span><span class="cov3" title="3">{ + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: LLM disabled: %v"+logging.AnsiReset+"\n", err) return err }</span> - <span class="cov5" title="3">primaryModel := strings.TrimSpace(reqOptsFrom(cfg).model) - if primaryModel == "" </span><span class="cov5" title="3">{ + <span class="cov6" title="9">primaryModel := strings.TrimSpace(reqOptsFrom(cfg).model) + if primaryModel == "" </span><span class="cov6" title="9">{ primaryModel = cli.DefaultModel() }</span> - <span class="cov5" title="3">_ = tmux.SetStatus(tmux.FormatLLMStartStatus(cli.Name(), primaryModel)) + <span class="cov6" title="9">_ = tmux.SetStatus(tmux.FormatLLMStartStatus(cli.Name(), primaryModel)) var client chatDoer = cli parts, err := ParseInput(stdin) if err != nil </span><span class="cov0" title="0">{ - fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: failed to read input"+logging.AnsiReset) + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: failed to read input"+logging.AnsiReset) return err }</span> - <span class="cov5" title="3">if strings.TrimSpace(parts.Selection) == "" </span><span class="cov0" title="0">{ + <span class="cov6" title="9">if strings.TrimSpace(parts.Selection) == "" </span><span class="cov0" title="0">{ return fmt.Errorf("hexai-tmux-action: no input provided on stdin") }</span> - <span class="cov5" title="3">kind, err := chooseActionFn() + <span class="cov6" title="9">kind, err := chooseActionFn() if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="3">out, err := executeAction(ctx, kind, parts, cfg, client, stderr) + <span class="cov6" title="9">out, err := executeAction(ctx, kind, parts, cfg, client, stderr) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="3">io.WriteString(stdout, out) + <span class="cov6" title="9">_, _ = io.WriteString(stdout, out) return nil</span> } @@ -2351,97 +2409,97 @@ func WithConfigPath(ctx context.Context, path string) context.Context <span clas <span class="cov0" title="0">return context.WithValue(ctx, configPathKey{}, strings.TrimSpace(path))</span> } -func configPathFromContext(ctx context.Context) string <span class="cov6" title="4">{ +func configPathFromContext(ctx context.Context) string <span class="cov7" title="12">{ if ctx == nil </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov6" title="4">if v, ok := ctx.Value(configPathKey{}).(string); ok </span><span class="cov0" title="0">{ + <span class="cov7" title="12">if v, ok := ctx.Value(configPathKey{}).(string); ok </span><span class="cov0" title="0">{ return strings.TrimSpace(v) }</span> - <span class="cov6" title="4">return ""</span> + <span class="cov7" title="12">return ""</span> } -func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov9" 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="30">{ switch kind </span>{ - case ActionSkip:<span class="cov3" title="2"> + case ActionSkip:<span class="cov5" title="6"> return parts.Selection, nil</span> - case ActionRewrite:<span class="cov3" title="2"> + case ActionRewrite:<span class="cov5" title="6"> return handleRewriteAction(ctx, parts, cfg, client, stderr)</span> case ActionDiagnostics:<span class="cov0" title="0"> return handleDiagnosticsAction(ctx, parts, cfg, client)</span> - case ActionDocument:<span class="cov1" title="1"> + case ActionDocument:<span class="cov3" title="3"> return handleDocumentAction(ctx, parts, cfg, client)</span> - case ActionGoTest:<span class="cov1" title="1"> + case ActionGoTest:<span class="cov3" title="3"> return handleGoTestAction(ctx, parts, cfg, client)</span> case ActionSimplify:<span class="cov0" title="0"> return handleSimplifyAction(ctx, parts, cfg, client)</span> - case ActionCustom:<span class="cov5" title="3"> + case ActionCustom:<span class="cov6" title="9"> return handleCustomAction(ctx, parts, cfg, client)</span> - case ActionCustomPrompt:<span class="cov1" title="1"> + case ActionCustomPrompt:<span class="cov3" title="3"> return handleCustomPromptAction(ctx, parts, cfg, client, stderr)</span> default:<span class="cov0" title="0"> return parts.Selection, nil</span> } } -func handleRewriteAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov3" title="2">{ +func handleRewriteAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov5" title="6">{ instr, cleaned := ExtractInstruction(parts.Selection) if strings.TrimSpace(instr) == "" </span><span class="cov0" title="0">{ - fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: no inline instruction found; echoing input"+logging.AnsiReset) + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: no inline instruction found; echoing input"+logging.AnsiReset) return parts.Selection, nil }</span> - <span class="cov3" title="2">return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov3" title="2">{ + <span class="cov5" title="6">return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov5" title="6">{ return runRewrite(cctx, cfg, client, instr, cleaned) }</span>) } -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">{ +func handleDiagnosticsAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov3" title="3">{ + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov3" title="3">{ return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection) }</span>) } -func handleDocumentAction(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">{ +func handleDocumentAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov3" title="3">{ + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov3" title="3">{ return runDocument(cctx, cfg, client, parts.Selection) }</span>) } -func handleGoTestAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov1" title="1">{ - return runWithTimeout(ctx, timeout8s, func(cctx context.Context) (string, error) </span><span class="cov1" title="1">{ +func handleGoTestAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov3" title="3">{ + return runWithTimeout(ctx, timeout8s, func(cctx context.Context) (string, error) </span><span class="cov3" title="3">{ return runGoTest(cctx, cfg, client, parts.Selection) }</span>) } -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">{ +func handleSimplifyAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov3" title="3">{ + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov3" title="3">{ return runSimplify(cctx, cfg, client, parts.Selection) }</span>) } -func handleCustomAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov6" title="4">{ +func handleCustomAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov7" title="12">{ if selectedCustom == nil </span><span class="cov0" title="0">{ return parts.Selection, nil }</span> - <span class="cov6" title="4">return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov6" title="4">{ + <span class="cov7" title="12">return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov7" title="12">{ out, err := runCustom(cctx, cfg, client, *selectedCustom, parts) selectedCustom = nil return out, err }</span>) } -func handleCustomPromptAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov1" title="1">{ +func handleCustomPromptAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov3" title="3">{ prompt, err := editor.OpenTempAndEdit(nil) if err != nil || strings.TrimSpace(prompt) == "" </span><span class="cov0" title="0">{ - fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset) + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset) return parts.Selection, nil }</span> - <span class="cov1" title="1">return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov1" title="1">{ + <span class="cov3" title="3">return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov3" title="3">{ return runRewrite(cctx, cfg, client, prompt, parts.Selection) }</span>) } -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">{ +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="33">{ innerCtx, cancel := timeout(ctx) defer cancel() return fn(innerCtx) @@ -2467,9 +2525,9 @@ type item struct { hotkey rune } -func (i item) Title() string <span class="cov1" title="1">{ return i.title }</span> -func (i item) Description() string <span class="cov1" title="1">{ return i.desc }</span> -func (i item) FilterValue() string <span class="cov6" title="3">{ return i.title }</span> +func (i item) Title() string <span class="cov4" title="3">{ return i.title }</span> +func (i item) Description() string <span class="cov4" title="3">{ return i.desc }</span> +func (i item) FilterValue() string <span class="cov7" title="9">{ return i.title }</span> type model struct { list list.Model @@ -2477,7 +2535,7 @@ type model struct { done bool } -func newModel() model <span class="cov10" title="6">{ +func newModel() model <span class="cov10" title="18">{ items := []list.Item{ item{title: "Rewrite selection", desc: "", kind: ActionRewrite, hotkey: 'r'}, item{title: "Simplify and improve", desc: "", kind: ActionSimplify, hotkey: 'i'}, @@ -2494,25 +2552,25 @@ func newModel() model <span class="cov10" title="6">{ return model{list: l} }</span> -func (m model) Init() tea.Cmd <span class="cov1" title="1">{ return nil }</span> +func (m model) Init() tea.Cmd <span class="cov4" title="3">{ return nil }</span> -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) <span class="cov1" title="1">{ +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) <span class="cov4" title="3">{ switch msg := msg.(type) </span>{ case tea.KeyMsg:<span class="cov0" title="0"> return handleKey(m, msg)</span> - case tea.WindowSizeMsg:<span class="cov1" title="1"> + case tea.WindowSizeMsg:<span class="cov4" title="3"> m.list.SetSize(msg.Width, msg.Height)</span> } - <span class="cov1" title="1">var cmd tea.Cmd + <span class="cov4" title="3">var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd</span> } -func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov6" title="3">{ +func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov7" title="9">{ raw := msg.String() low := strings.ToLower(raw) switch low </span>{ - case "esc", "q":<span class="cov1" title="1"> + case "esc", "q":<span class="cov4" title="3"> // Treat ESC and q as Skip/quit m.chosen = ActionSkip m.done = true @@ -2527,16 +2585,16 @@ func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov6" m.list.CursorDown()</span> case "k", "up":<span class="cov0" title="0"> m.list.CursorUp()</span> - case "g", "home":<span class="cov1" title="1"> + case "g", "home":<span class="cov4" title="3"> m.list.Select(0)</span> case "end":<span class="cov0" title="0"> if n := len(m.list.Items()); n > 0 </span><span class="cov0" title="0">{ m.list.Select(n - 1) }</span> - case "s", "r", "c", "t", "i", "p":<span class="cov1" title="1"> + case "s", "r", "c", "t", "i", "p":<span class="cov4" title="3"> items := m.list.Items() - for i := 0; i < len(items); i++ </span><span class="cov1" title="1">{ - if it, ok := items[i].(item); ok && strings.ToLower(string(it.hotkey)) == low </span><span class="cov1" title="1">{ + for i := 0; i < len(items); i++ </span><span class="cov4" title="3">{ + if it, ok := items[i].(item); ok && strings.ToLower(string(it.hotkey)) == low </span><span class="cov4" title="3">{ m.list.Select(i) m.chosen = it.kind m.done = true @@ -2544,19 +2602,19 @@ func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov6" }</span> } } - <span class="cov1" title="1">if raw == "G" </span><span class="cov1" title="1">{ // Shift+G jumps to end - if n := len(m.list.Items()); n > 0 </span><span class="cov1" title="1">{ + <span class="cov4" title="3">if raw == "G" </span><span class="cov4" title="3">{ // Shift+G jumps to end + if n := len(m.list.Items()); n > 0 </span><span class="cov4" title="3">{ m.list.Select(n - 1) }</span> } - <span class="cov1" title="1">return m, nil</span> + <span class="cov4" title="3">return m, nil</span> } -func (m model) View() string <span class="cov1" title="1">{ +func (m model) View() string <span class="cov4" title="3">{ if m.done </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov1" title="1">return m.list.View()</span> + <span class="cov4" title="3">return m.list.View()</span> } // RunTUI returns the chosen ActionKind. @@ -2590,18 +2648,18 @@ import ( // RunTUIWithCustom shows the main menu plus a configurable "Custom actions…" item. // If the user selects that item, it shows a submenu listing user-defined custom actions. // On picking one, it sets selectedCustom and returns ActionCustom. -func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (ActionKind, error) <span class="cov1" title="1">{ +func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (ActionKind, error) <span class="cov6" title="3">{ // When no customs, fall back to default menu if len(customs) == 0 </span><span class="cov0" title="0">{ return RunTUI() }</span> // Build main menu with an extra entry - <span class="cov1" title="1">hk := 'a' - if r, _ := utf8.DecodeRuneInString(menuHotkey); r != utf8.RuneError && r != 0 </span><span class="cov1" title="1">{ + <span class="cov6" title="3">hk := 'a' + if r, _ := utf8.DecodeRuneInString(menuHotkey); r != utf8.RuneError && r != 0 </span><span class="cov6" title="3">{ hk = r }</span> // Create a model with default items plus Custom actions… - <span class="cov1" title="1">m := newModel() + <span class="cov6" title="3">m := newModel() items := m.list.Items() items = append(items, item{title: "Custom actions…", desc: "", kind: ActionCustom, hotkey: hk}) m.list.SetItems(items) @@ -2611,33 +2669,33 @@ func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (Acti if err != nil </span><span class="cov0" title="0">{ return ActionSkip, err }</span> - <span class="cov1" title="1">if mm, ok := md.(model); ok </span><span class="cov1" title="1">{ + <span class="cov6" title="3">if mm, ok := md.(model); ok </span><span class="cov6" title="3">{ // If user chose built-in items (including Custom prompt), return immediately. if mm.chosen != ActionCustom </span><span class="cov0" title="0">{ return mm.chosen, nil }</span> } // Custom submenu: list each action; select one maps to ActionCustom and sets global - <span class="cov1" title="1">sub := newModel() + <span class="cov6" title="3">sub := newModel() subItems := make([]list.Item, 0, len(customs)) - for _, ca := range customs </span><span class="cov10" title="2">{ + for _, ca := range customs </span><span class="cov10" title="6">{ r := rune(0) - if rr, _ := utf8.DecodeRuneInString(ca.Hotkey); rr != utf8.RuneError && rr != 0 </span><span class="cov10" title="2">{ + if rr, _ := utf8.DecodeRuneInString(ca.Hotkey); rr != utf8.RuneError && rr != 0 </span><span class="cov10" title="6">{ r = rr }</span> - <span class="cov10" title="2">subItems = append(subItems, item{title: ca.Title, desc: "", kind: ActionCustom, hotkey: r})</span> + <span class="cov10" title="6">subItems = append(subItems, item{title: ca.Title, desc: "", kind: ActionCustom, hotkey: r})</span> } - <span class="cov1" title="1">sub.list.SetItems(subItems) + <span class="cov6" title="3">sub.list.SetItems(subItems) sp := teaNewProgram(sub) smd, err := sp.Run() if err != nil </span><span class="cov0" title="0">{ return ActionSkip, err }</span> - <span class="cov1" title="1">if sm, ok := smd.(model); ok </span><span class="cov1" title="1">{ - if it, ok := sm.list.SelectedItem().(item); ok </span><span class="cov1" title="1">{ + <span class="cov6" title="3">if sm, ok := smd.(model); ok </span><span class="cov6" title="3">{ + if it, ok := sm.list.SelectedItem().(item); ok </span><span class="cov6" title="3">{ // Map by title - for i := range customs </span><span class="cov1" title="1">{ - if customs[i].Title == it.title </span><span class="cov1" title="1">{ + for i := range customs </span><span class="cov6" title="3">{ + if customs[i].Title == it.title </span><span class="cov6" title="3">{ c := customs[i] selectedCustom = &c return ActionCustom, nil @@ -2674,21 +2732,21 @@ var ( cursorStyle = lipgloss.NewStyle().Bold(true) ) -func (oneLineDelegate) Height() int <span class="cov8" title="28">{ return 1 }</span> -func (oneLineDelegate) Spacing() int <span class="cov10" title="50">{ return 0 }</span> -func (oneLineDelegate) Update(tea.Msg, *list.Model) tea.Cmd <span class="cov1" title="1">{ return nil }</span> -func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) <span class="cov2" title="2">{ +func (oneLineDelegate) Height() int <span class="cov8" title="84">{ return 1 }</span> +func (oneLineDelegate) Spacing() int <span class="cov10" title="150">{ return 0 }</span> +func (oneLineDelegate) Update(tea.Msg, *list.Model) tea.Cmd <span class="cov2" title="3">{ return nil }</span> +func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) <span class="cov4" title="6">{ title := listItem.FilterValue() hk := '?' - if it, ok := listItem.(item); ok </span><span class="cov2" title="2">{ + if it, ok := listItem.(item); ok </span><span class="cov4" title="6">{ hk = it.hotkey }</span> - <span class="cov2" title="2">hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk)) + <span class="cov4" title="6">hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk)) cursor := " " - if index == m.Index() </span><span class="cov2" title="2">{ + if index == m.Index() </span><span class="cov4" title="6">{ cursor = cursorStyle.Render("> ") }</span> - <span class="cov2" title="2">fmt.Fprintf(w, "%s%s%s", cursor, title, hot)</span> + <span class="cov4" title="6">_, _ = fmt.Fprintf(w, "%s%s%s", cursor, title, hot)</span> } </pre> @@ -2751,28 +2809,28 @@ type ( configPathContextKey struct{} ) -func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) <span class="cov7" title="6">{ +func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) <span class="cov8" title="18">{ entries := cfg.CLIConfigs - if len(entries) == 0 </span><span class="cov6" title="5">{ + if len(entries) == 0 </span><span class="cov7" title="15">{ entries = []appconfig.SurfaceConfig{{}} }</span> - <span class="cov7" title="6">jobs := make([]cliJob, 0, len(entries)) - for i, raw := range entries </span><span class="cov7" title="7">{ + <span class="cov8" title="18">jobs := make([]cliJob, 0, len(entries)) + for i, raw := range entries </span><span class="cov8" title="21">{ entry := appconfig.SurfaceConfig{Provider: strings.TrimSpace(raw.Provider), Model: strings.TrimSpace(raw.Model), Temperature: raw.Temperature} provider := entry.Provider - if provider == "" </span><span class="cov6" title="5">{ + if provider == "" </span><span class="cov7" title="15">{ provider = cfg.Provider }</span> - <span class="cov7" title="7">provider = canonicalProvider(provider) + <span class="cov8" title="21">provider = canonicalProvider(provider) derived := cfg derived.Provider = provider switch provider </span>{ - case "openai":<span class="cov7" title="6"> - if entry.Model != "" </span><span class="cov1" title="1">{ + case "openai":<span class="cov8" title="18"> + if entry.Model != "" </span><span class="cov3" title="3">{ derived.OpenAIModel = entry.Model }</span> - case "copilot":<span class="cov1" title="1"> - if entry.Model != "" </span><span class="cov1" title="1">{ + case "copilot":<span class="cov3" title="3"> + if entry.Model != "" </span><span class="cov3" title="3">{ derived.CopilotModel = entry.Model }</span> case "ollama":<span class="cov0" title="0"> @@ -2780,65 +2838,65 @@ func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) <span class="cov7" title= derived.OllamaModel = entry.Model }</span> } - <span class="cov7" title="7">client, err := newClientFromApp(derived) - if err != nil </span><span class="cov1" title="1">{ + <span class="cov8" title="21">client, err := newClientFromApp(derived) + if err != nil </span><span class="cov3" title="3">{ return nil, err }</span> - <span class="cov7" title="6">req := buildCLIRequest(entry, provider, cfg, client) + <span class="cov8" title="18">req := buildCLIRequest(entry, provider, cfg, client) if strings.TrimSpace(req.model) == "" </span><span class="cov0" title="0">{ req.model = strings.TrimSpace(client.DefaultModel()) }</span> - <span class="cov7" title="6">jobs = append(jobs, cliJob{index: i, provider: provider, entry: entry, client: client, req: req})</span> + <span class="cov8" title="18">jobs = append(jobs, cliJob{index: i, provider: provider, entry: entry, client: client, req: req})</span> } - <span class="cov6" title="5">return jobs, nil</span> + <span class="cov7" title="15">return jobs, nil</span> } -func buildCLIRequest(entry appconfig.SurfaceConfig, provider string, cfg appconfig.App, client llm.Client) requestArgs <span class="cov8" title="8">{ +func buildCLIRequest(entry appconfig.SurfaceConfig, provider string, cfg appconfig.App, client llm.Client) requestArgs <span class="cov8" title="24">{ opts := make([]llm.RequestOption, 0, 2) - if cfg.MaxTokens > 0 </span><span class="cov5" title="4">{ + if cfg.MaxTokens > 0 </span><span class="cov7" title="12">{ opts = append(opts, llm.WithMaxTokens(cfg.MaxTokens)) }</span> - <span class="cov8" title="8">model := strings.TrimSpace(entry.Model) - if model == "" </span><span class="cov6" title="5">{ - if client != nil </span><span class="cov6" title="5">{ + <span class="cov8" title="24">model := strings.TrimSpace(entry.Model) + if model == "" </span><span class="cov7" title="15">{ + if client != nil </span><span class="cov7" title="15">{ model = strings.TrimSpace(client.DefaultModel()) }</span> - <span class="cov6" title="5">if model == "" </span><span class="cov0" title="0">{ + <span class="cov7" title="15">if model == "" </span><span class="cov0" title="0">{ model = strings.TrimSpace(defaultModelForProvider(cfg, provider)) }</span> } - <span class="cov8" title="8">if entry.Model != "" </span><span class="cov4" title="3">{ + <span class="cov8" title="24">if entry.Model != "" </span><span class="cov6" title="9">{ opts = append(opts, llm.WithModel(entry.Model)) }</span> - <span class="cov8" title="8">if temp, ok := cliTemperatureFromEntry(cfg, provider, entry, model); ok </span><span class="cov7" title="6">{ + <span class="cov8" title="24">if temp, ok := cliTemperatureFromEntry(cfg, provider, entry, model); ok </span><span class="cov8" title="18">{ opts = append(opts, llm.WithTemperature(temp)) }</span> - <span class="cov8" title="8">return requestArgs{model: model, options: opts}</span> + <span class="cov8" title="24">return requestArgs{model: model, options: opts}</span> } -func cliTemperatureFromEntry(cfg appconfig.App, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) <span class="cov8" title="8">{ - if entry.Temperature != nil </span><span class="cov1" title="1">{ +func cliTemperatureFromEntry(cfg appconfig.App, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) <span class="cov8" title="24">{ + if entry.Temperature != nil </span><span class="cov3" title="3">{ return *entry.Temperature, true }</span> - <span class="cov7" title="7">if cfg.CodingTemperature != nil </span><span class="cov6" title="5">{ + <span class="cov8" title="21">if cfg.CodingTemperature != nil </span><span class="cov7" title="15">{ temp := *cfg.CodingTemperature - if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 </span><span class="cov3" title="2">{ + if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 </span><span class="cov5" title="6">{ temp = 1.0 }</span> - <span class="cov6" title="5">return temp, true</span> + <span class="cov7" title="15">return temp, true</span> } - <span class="cov3" title="2">if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") </span><span class="cov0" title="0">{ + <span class="cov5" title="6">if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") </span><span class="cov0" title="0">{ return 1.0, true }</span> - <span class="cov3" title="2">return 0, false</span> + <span class="cov5" title="6">return 0, false</span> } -func canonicalProvider(name string) string <span class="cov7" title="7">{ +func canonicalProvider(name string) string <span class="cov8" title="21">{ p := strings.ToLower(strings.TrimSpace(name)) - if p == "" </span><span class="cov4" title="3">{ + if p == "" </span><span class="cov6" title="9">{ return "openai" }</span> - <span class="cov5" title="4">return p</span> + <span class="cov7" title="12">return p</span> } func defaultModelForProvider(cfg appconfig.App, provider string) string <span class="cov0" title="0">{ @@ -2854,63 +2912,63 @@ func defaultModelForProvider(cfg appconfig.App, provider string) string <span cl // Run executes the Hexai CLI behavior given arguments and I/O streams. // It assumes flags have already been parsed by the caller. -func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov6" title="5">{ +func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov7" title="15">{ // Load configuration with a logger so file-based config is respected. logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) configPath := configPathFromContext(ctx) cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPath}) - if cfg.StatsWindowMinutes > 0 </span><span class="cov6" title="5">{ + if cfg.StatsWindowMinutes > 0 </span><span class="cov7" title="15">{ stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) }</span> - <span class="cov6" title="5">jobs, err := buildCLIJobs(cfg) - if err != nil </span><span class="cov1" title="1">{ - fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) + <span class="cov7" title="15">jobs, err := buildCLIJobs(cfg) + if err != nil </span><span class="cov3" title="3">{ + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) return err }</span> - <span class="cov5" title="4">if selected := selectionFromContext(ctx); len(selected) > 0 </span><span class="cov0" title="0">{ + <span class="cov7" title="12">if selected := selectionFromContext(ctx); len(selected) > 0 </span><span class="cov0" title="0">{ jobs, err = filterJobsBySelection(jobs, selected) if err != nil </span><span class="cov0" title="0">{ - fmt.Fprintf(stderr, logging.AnsiBase+"hexai: %v"+logging.AnsiReset+"\n", err) + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: %v"+logging.AnsiReset+"\n", err) return err }</span> } - <span class="cov5" title="4">if len(jobs) == 0 </span><span class="cov0" title="0">{ + <span class="cov7" title="12">if len(jobs) == 0 </span><span class="cov0" title="0">{ return fmt.Errorf("hexai: no CLI providers configured") }</span> // Prefer piped stdin when present; only open the editor when there are no args // and no stdin content available. - <span class="cov5" title="4">input, rerr := readInput(stdin, args) - if rerr != nil && len(args) == 0 </span><span class="cov1" title="1">{ - if prompt, eerr := editor.OpenTempAndEdit(nil); eerr == nil && strings.TrimSpace(prompt) != "" </span><span class="cov1" title="1">{ + <span class="cov7" title="12">input, rerr := readInput(stdin, args) + if rerr != nil && len(args) == 0 </span><span class="cov3" title="3">{ + if prompt, eerr := editor.OpenTempAndEdit(nil); eerr == nil && strings.TrimSpace(prompt) != "" </span><span class="cov3" title="3">{ args = []string{prompt} input, rerr = readInput(stdin, args) }</span> } - <span class="cov5" title="4">if rerr != nil </span><span class="cov0" title="0">{ - fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset) + <span class="cov7" title="12">if rerr != nil </span><span class="cov0" title="0">{ + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset) return rerr }</span> - <span class="cov5" title="4">msgs := buildMessagesFromConfig(cfg, input) + <span class="cov7" title="12">msgs := buildMessagesFromConfig(cfg, input) if err := runCLIJobs(ctx, jobs, msgs, input, stdout, stderr); err != nil </span><span class="cov0" title="0">{ - fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) return err }</span> - <span class="cov5" title="4">return nil</span> + <span class="cov7" title="12">return nil</span> } // RunWithClient executes the CLI flow using an already-constructed client. // Useful for testing and embedding. -func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, client llm.Client) error <span class="cov1" title="1">{ +func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, client llm.Client) error <span class="cov3" title="3">{ input, err := readInput(stdin, args) if err != nil </span><span class="cov0" title="0">{ - fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) return err }</span> - <span class="cov1" title="1">req := requestArgs{model: strings.TrimSpace(client.DefaultModel())} + <span class="cov3" title="3">req := requestArgs{model: strings.TrimSpace(client.DefaultModel())} printProviderInfo(stderr, client, req.model) msgs := buildMessages(input) - if err := runChat(ctx, client, req, msgs, input, stdout, stderr); err != nil </span><span class="cov1" title="1">{ - fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) + if err := runChat(ctx, client, req, msgs, input, stdout, stderr); err != nil </span><span class="cov3" title="3">{ + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) return err }</span> <span class="cov0" title="0">return nil</span> @@ -2924,33 +2982,33 @@ type cliJobResult struct { err error } -func runCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input string, stdout, stderr io.Writer) error <span class="cov5" title="4">{ +func runCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input string, stdout, stderr io.Writer) error <span class="cov7" title="12">{ results := make([]*cliJobResult, len(jobs)) var wg sync.WaitGroup var printer *columnPrinter - if len(jobs) > 0 </span><span class="cov5" title="4">{ + if len(jobs) > 0 </span><span class="cov7" title="12">{ printer = newColumnPrinter(stdout, jobs) printer.PrintHeader() }</span> - <span class="cov5" title="4">for _, job := range jobs </span><span class="cov5" title="4">{ + <span class="cov7" title="12">for _, job := range jobs </span><span class="cov7" title="12">{ job := job wg.Add(1) printProviderInfo(stderr, job.client, job.req.model) - go func() </span><span class="cov5" title="4">{ + go func() </span><span class="cov7" title="12">{ defer wg.Done() var errBuf bytes.Buffer var outBuf bytes.Buffer jobMsgs := make([]llm.Message, len(msgs)) copy(jobMsgs, msgs) writer := io.Writer(&outBuf) - if printer != nil </span><span class="cov5" title="4">{ + if printer != nil </span><span class="cov7" title="12">{ writer = printer.Writer(job.index) }</span> - <span class="cov5" title="4">err := runChat(ctx, job.client, job.req, jobMsgs, input, writer, &errBuf) - if printer != nil </span><span class="cov5" title="4">{ + <span class="cov7" title="12">err := runChat(ctx, job.client, job.req, jobMsgs, input, writer, &errBuf) + if printer != nil </span><span class="cov7" title="12">{ printer.Flush(job.index) }</span> - <span class="cov5" title="4">results[job.index] = &cliJobResult{ + <span class="cov7" title="12">results[job.index] = &cliJobResult{ provider: job.client.Name(), model: job.req.model, output: outBuf.String(), @@ -2959,7 +3017,7 @@ func runCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input st }</span> }() } - <span class="cov5" title="4">wg.Wait() + <span class="cov7" title="12">wg.Wait() var firstErr error if printer == nil </span><span class="cov0" title="0">{ printed := false @@ -2989,48 +3047,48 @@ func runCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input st <span class="cov0" title="0">printed = true</span> } } - <span class="cov5" title="4">for _, res := range results </span><span class="cov5" title="4">{ + <span class="cov7" title="12">for _, res := range results </span><span class="cov7" title="12">{ if res == nil </span><span class="cov0" title="0">{ continue</span> } - <span class="cov5" title="4">if res.summary != "" </span><span class="cov5" title="4">{ + <span class="cov7" title="12">if res.summary != "" </span><span class="cov7" title="12">{ summary := strings.TrimLeft(res.summary, "\n") - if summary != "" </span><span class="cov5" title="4">{ + if summary != "" </span><span class="cov7" title="12">{ if _, err := io.WriteString(stderr, summary); err != nil </span><span class="cov0" title="0">{ return err }</span> } } - <span class="cov5" title="4">if res.err != nil </span><span class="cov0" title="0">{ + <span class="cov7" title="12">if res.err != nil </span><span class="cov0" title="0">{ if _, err := fmt.Fprintf(stderr, logging.AnsiBase+"hexai: provider=%s model=%s error: %v"+logging.AnsiReset+"\n", res.provider, res.model, res.err); err != nil </span><span class="cov0" title="0">{ return err }</span> } - <span class="cov5" title="4">if firstErr == nil && res.err != nil </span><span class="cov0" title="0">{ + <span class="cov7" title="12">if firstErr == nil && res.err != nil </span><span class="cov0" title="0">{ firstErr = res.err }</span> } - <span class="cov5" title="4">return firstErr</span> + <span class="cov7" title="12">return firstErr</span> } -func newColumnPrinter(stdout io.Writer, jobs []cliJob) *columnPrinter <span class="cov5" title="4">{ +func newColumnPrinter(stdout io.Writer, jobs []cliJob) *columnPrinter <span class="cov7" title="12">{ cols := len(jobs) width := detectTerminalWidth(stdout) - if width <= 0 </span><span class="cov5" title="4">{ + if width <= 0 </span><span class="cov7" title="12">{ width = 100 }</span> - <span class="cov5" title="4">sepWidth := (cols - 1) * 3 + <span class="cov7" title="12">sepWidth := (cols - 1) * 3 colWidth := (width - sepWidth) / cols if colWidth < 20 </span><span class="cov0" title="0">{ colWidth = 20 }</span> - <span class="cov5" title="4">providers := make([]string, cols) + <span class="cov7" title="12">providers := make([]string, cols) models := make([]string, cols) - for _, job := range jobs </span><span class="cov5" title="4">{ + for _, job := range jobs </span><span class="cov7" title="12">{ providers[job.index] = job.client.Name() models[job.index] = job.req.model }</span> - <span class="cov5" title="4">return &columnPrinter{ + <span class="cov7" title="12">return &columnPrinter{ stdout: stdout, columns: cols, colWidth: colWidth, @@ -3040,34 +3098,34 @@ func newColumnPrinter(stdout io.Writer, jobs []cliJob) *columnPrinter <span clas }</span> } -func detectTerminalWidth(w io.Writer) int <span class="cov5" title="4">{ +func detectTerminalWidth(w io.Writer) int <span class="cov7" title="12">{ type fder interface{ Fd() uintptr } if f, ok := w.(*os.File); ok </span><span class="cov0" title="0">{ if width, _, err := term.GetSize(int(f.Fd())); err == nil </span><span class="cov0" title="0">{ return width }</span> } - <span class="cov5" title="4">if f, ok := w.(fder); ok </span><span class="cov0" title="0">{ + <span class="cov7" title="12">if f, ok := w.(fder); ok </span><span class="cov0" title="0">{ if width, _, err := term.GetSize(int(f.Fd())); err == nil </span><span class="cov0" title="0">{ return width }</span> } - <span class="cov5" title="4">return 0</span> + <span class="cov7" title="12">return 0</span> } -func (cp *columnPrinter) Writer(idx int) io.Writer <span class="cov5" title="4">{ +func (cp *columnPrinter) Writer(idx int) io.Writer <span class="cov7" title="12">{ return columnWriter{printer: cp, index: idx} }</span> -func (cp *columnPrinter) PrintHeader() <span class="cov5" title="4">{ +func (cp *columnPrinter) PrintHeader() <span class="cov7" title="12">{ cp.mu.Lock() defer cp.mu.Unlock() combo := make([]string, cp.columns) - for i := 0; i < cp.columns; i++ </span><span class="cov5" title="4">{ + for i := 0; i < cp.columns; i++ </span><span class="cov7" title="12">{ provider := strings.TrimSpace(cp.providers[i]) model := strings.TrimSpace(cp.models[i]) switch </span>{ - case provider != "" && model != "":<span class="cov5" title="4"> + case provider != "" && model != "":<span class="cov7" title="12"> combo[i] = provider + ":" + model</span> case provider != "":<span class="cov0" title="0"> combo[i] = provider</span> @@ -3077,62 +3135,62 @@ func (cp *columnPrinter) PrintHeader() <span class="cov5" title="4">{ combo[i] = ""</span> } } - <span class="cov5" title="4">cp.writeLine(combo) + <span class="cov7" title="12">cp.writeLine(combo) divider := make([]string, cp.columns) line := strings.Repeat("─", cp.colWidth) - for i := range divider </span><span class="cov5" title="4">{ + for i := range divider </span><span class="cov7" title="12">{ divider[i] = line }</span> - <span class="cov5" title="4">cp.writeLine(divider)</span> + <span class="cov7" title="12">cp.writeLine(divider)</span> } -func (cp *columnPrinter) Flush(idx int) <span class="cov5" title="4">{ +func (cp *columnPrinter) Flush(idx int) <span class="cov7" title="12">{ cp.mu.Lock() defer cp.mu.Unlock() if idx < 0 || idx >= len(cp.partial) </span><span class="cov0" title="0">{ return }</span> - <span class="cov5" title="4">if cp.partial[idx] == "" </span><span class="cov0" title="0">{ + <span class="cov7" title="12">if cp.partial[idx] == "" </span><span class="cov0" title="0">{ return }</span> - <span class="cov5" title="4">cp.emitJobLine(idx, cp.partial[idx]) + <span class="cov7" title="12">cp.emitJobLine(idx, cp.partial[idx]) cp.partial[idx] = ""</span> } -func (w columnWriter) Write(p []byte) (int, error) <span class="cov5" title="4">{ +func (w columnWriter) Write(p []byte) (int, error) <span class="cov7" title="12">{ return w.printer.write(w.index, string(p)) }</span> -func (cp *columnPrinter) write(idx int, data string) (int, error) <span class="cov5" title="4">{ +func (cp *columnPrinter) write(idx int, data string) (int, error) <span class="cov7" title="12">{ cp.mu.Lock() defer cp.mu.Unlock() if idx < 0 || idx >= len(cp.partial) </span><span class="cov0" title="0">{ return len(data), nil }</span> - <span class="cov5" title="4">data = strings.ReplaceAll(data, "\r", "") + <span class="cov7" title="12">data = strings.ReplaceAll(data, "\r", "") cp.partial[idx] += data for strings.Contains(cp.partial[idx], "\n") </span><span class="cov0" title="0">{ line, rest, _ := strings.Cut(cp.partial[idx], "\n") cp.partial[idx] = rest cp.emitJobLine(idx, line) }</span> - <span class="cov5" title="4">return len(data), nil</span> + <span class="cov7" title="12">return len(data), nil</span> } -func (cp *columnPrinter) emitJobLine(idx int, line string) <span class="cov5" title="4">{ +func (cp *columnPrinter) emitJobLine(idx int, line string) <span class="cov7" title="12">{ segments := cp.wrap(line) - for _, seg := range segments </span><span class="cov5" title="4">{ + for _, seg := range segments </span><span class="cov7" title="12">{ cells := make([]string, cp.columns) - if idx >= 0 && idx < len(cells) </span><span class="cov5" title="4">{ + if idx >= 0 && idx < len(cells) </span><span class="cov7" title="12">{ cells[idx] = seg }</span> - <span class="cov5" title="4">cp.writeLine(cells)</span> + <span class="cov7" title="12">cp.writeLine(cells)</span> } } -func (cp *columnPrinter) wrap(text string) []string <span class="cov5" title="4">{ +func (cp *columnPrinter) wrap(text string) []string <span class="cov7" title="12">{ text = strings.ReplaceAll(text, "\t", " ") - if runewidth.StringWidth(text) <= cp.colWidth </span><span class="cov5" title="4">{ + if runewidth.StringWidth(text) <= cp.colWidth </span><span class="cov7" title="12">{ return []string{text} }</span> <span class="cov0" title="0">var lines []string @@ -3157,28 +3215,28 @@ func (cp *columnPrinter) wrap(text string) []string <span class="cov5" title="4" <span class="cov0" title="0">return lines</span> } -func (cp *columnPrinter) writeLine(cells []string) <span class="cov9" title="12">{ +func (cp *columnPrinter) writeLine(cells []string) <span class="cov9" title="36">{ if len(cells) < cp.columns </span><span class="cov0" title="0">{ extra := make([]string, cp.columns-len(cells)) cells = append(cells, extra...) }</span> - <span class="cov9" title="12">var builder strings.Builder - for i := 0; i < cp.columns; i++ </span><span class="cov9" title="12">{ + <span class="cov9" title="36">var builder strings.Builder + for i := 0; i < cp.columns; i++ </span><span class="cov9" title="36">{ cell := cells[i] width := runewidth.StringWidth(cell) if width > cp.colWidth </span><span class="cov0" title="0">{ cell = runewidth.Truncate(cell, cp.colWidth, "…") width = runewidth.StringWidth(cell) }</span> - <span class="cov9" title="12">builder.WriteString(cell) - if pad := cp.colWidth - width; pad > 0 </span><span class="cov8" title="8">{ + <span class="cov9" title="36">builder.WriteString(cell) + if pad := cp.colWidth - width; pad > 0 </span><span class="cov8" title="24">{ builder.WriteString(strings.Repeat(" ", pad)) }</span> - <span class="cov9" title="12">if i != cp.columns-1 </span><span class="cov0" title="0">{ + <span class="cov9" title="36">if i != cp.columns-1 </span><span class="cov0" title="0">{ builder.WriteString(" │ ") }</span> } - <span class="cov9" title="12">builder.WriteByte('\n') + <span class="cov9" title="36">builder.WriteByte('\n') _, _ = cp.stdout.Write([]byte(builder.String()))</span> } @@ -3200,73 +3258,73 @@ func WithCLIConfigPath(ctx context.Context, path string) context.Context <span c <span class="cov0" title="0">return context.WithValue(ctx, configPathContextKey{}, strings.TrimSpace(path))</span> } -func configPathFromContext(ctx context.Context) string <span class="cov6" title="5">{ +func configPathFromContext(ctx context.Context) string <span class="cov7" title="15">{ if ctx == nil </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov6" title="5">if v, ok := ctx.Value(configPathContextKey{}).(string); ok </span><span class="cov0" title="0">{ + <span class="cov7" title="15">if v, ok := ctx.Value(configPathContextKey{}).(string); ok </span><span class="cov0" title="0">{ return strings.TrimSpace(v) }</span> - <span class="cov6" title="5">return ""</span> + <span class="cov7" title="15">return ""</span> } -func selectionFromContext(ctx context.Context) []int <span class="cov5" title="4">{ +func selectionFromContext(ctx context.Context) []int <span class="cov7" title="12">{ if ctx == nil </span><span class="cov0" title="0">{ return nil }</span> - <span class="cov5" title="4">if v, ok := ctx.Value(selectionContextKey{}).([]int); ok </span><span class="cov0" title="0">{ + <span class="cov7" title="12">if v, ok := ctx.Value(selectionContextKey{}).([]int); ok </span><span class="cov0" title="0">{ cpy := make([]int, len(v)) copy(cpy, v) return cpy }</span> - <span class="cov5" title="4">return nil</span> + <span class="cov7" title="12">return nil</span> } -func filterJobsBySelection(jobs []cliJob, indices []int) ([]cliJob, error) <span class="cov3" title="2">{ +func filterJobsBySelection(jobs []cliJob, indices []int) ([]cliJob, error) <span class="cov5" title="6">{ if len(indices) == 0 </span><span class="cov0" title="0">{ return jobs, nil }</span> - <span class="cov3" title="2">filtered := make([]cliJob, 0, len(indices)) + <span class="cov5" title="6">filtered := make([]cliJob, 0, len(indices)) seen := make(map[int]struct{}, len(indices)) - for _, idx := range indices </span><span class="cov4" title="3">{ - if idx < 0 || idx >= len(jobs) </span><span class="cov1" title="1">{ + for _, idx := range indices </span><span class="cov6" title="9">{ + if idx < 0 || idx >= len(jobs) </span><span class="cov3" title="3">{ return nil, fmt.Errorf("provider index %d out of range (0-%d)", idx, len(jobs)-1) }</span> - <span class="cov3" title="2">if _, ok := seen[idx]; ok </span><span class="cov0" title="0">{ + <span class="cov5" title="6">if _, ok := seen[idx]; ok </span><span class="cov0" title="0">{ continue</span> } - <span class="cov3" title="2">clone := jobs[idx] + <span class="cov5" title="6">clone := jobs[idx] filtered = append(filtered, clone) seen[idx] = struct{}{}</span> } - <span class="cov1" title="1">for i := range filtered </span><span class="cov3" title="2">{ + <span class="cov3" title="3">for i := range filtered </span><span class="cov5" title="6">{ filtered[i].index = i }</span> - <span class="cov1" title="1">if len(filtered) == 0 </span><span class="cov0" title="0">{ + <span class="cov3" title="3">if len(filtered) == 0 </span><span class="cov0" title="0">{ return nil, fmt.Errorf("no CLI providers matched selection") }</span> - <span class="cov1" title="1">return filtered, nil</span> + <span class="cov3" title="3">return filtered, nil</span> } // readInput reads from stdin and args, then combines them per CLI rules. -func readInput(stdin io.Reader, args []string) (string, error) <span class="cov9" title="11">{ +func readInput(stdin io.Reader, args []string) (string, error) <span class="cov9" title="33">{ var stdinData string - if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 </span><span class="cov7" title="6">{ + if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 </span><span class="cov8" title="18">{ data, readErr := io.ReadAll(stdin) - if readErr != nil </span><span class="cov1" title="1">{ + if readErr != nil </span><span class="cov3" title="3">{ return "", fmt.Errorf("hexai: failed to read stdin: %w", readErr) }</span> - <span class="cov6" title="5">stdinData = strings.TrimSpace(string(data))</span> + <span class="cov7" title="15">stdinData = strings.TrimSpace(string(data))</span> } - <span class="cov9" title="10">argData := strings.TrimSpace(strings.Join(args, " ")) + <span class="cov9" title="30">argData := strings.TrimSpace(strings.Join(args, " ")) switch </span>{ - case stdinData != "" && argData != "":<span class="cov1" title="1"> + case stdinData != "" && argData != "":<span class="cov3" title="3"> return fmt.Sprintf("%s:\n\n%s", argData, stdinData), nil</span> - case stdinData != "":<span class="cov3" title="2"> + case stdinData != "":<span class="cov5" title="6"> return stdinData, nil</span> - case argData != "":<span class="cov6" title="5"> + case argData != "":<span class="cov7" title="15"> return argData, nil</span> - default:<span class="cov3" title="2"> + default:<span class="cov5" title="6"> return "", fmt.Errorf("hexai: no input provided; pass text as an argument or via stdin")</span> } } @@ -3275,99 +3333,112 @@ func readInput(stdin io.Reader, args []string) (string, error) <span class="cov9 // client construction moved to internal/llmutils // buildMessages creates system and user messages based on input content. -func buildMessages(input string) []llm.Message <span class="cov7" title="6">{ +func buildMessages(input string) []llm.Message <span class="cov8" title="18">{ lower := strings.ToLower(input) system := "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation." - if strings.Contains(lower, "explain") </span><span class="cov1" title="1">{ + if strings.Contains(lower, "explain") </span><span class="cov3" title="3">{ system = "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context." }</span> - <span class="cov7" title="6">return []llm.Message{ + <span class="cov8" title="18">return []llm.Message{ {Role: "system", Content: system}, {Role: "user", Content: input}, }</span> } // buildMessagesFromConfig uses configured CLI system prompts. -func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message <span class="cov7" title="6">{ +func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message <span class="cov8" title="18">{ lower := strings.ToLower(input) system := cfg.PromptCLIDefaultSystem - if strings.Contains(lower, "explain") </span><span class="cov1" title="1">{ - if strings.TrimSpace(cfg.PromptCLIExplainSystem) != "" </span><span class="cov1" title="1">{ + if strings.Contains(lower, "explain") </span><span class="cov3" title="3">{ + if strings.TrimSpace(cfg.PromptCLIExplainSystem) != "" </span><span class="cov3" title="3">{ system = cfg.PromptCLIExplainSystem }</span> } - <span class="cov7" title="6">return []llm.Message{ + <span class="cov8" title="18">return []llm.Message{ {Role: "system", Content: system}, {Role: "user", Content: input}, }</span> } // runChat executes the chat request, handling streaming and summary output. -func runChat(ctx context.Context, client llm.Client, req requestArgs, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error <span class="cov8" title="9">{ +func runChat(ctx context.Context, client llm.Client, req requestArgs, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error <span class="cov9" title="27">{ start := time.Now() // Best-effort tmux status update (colored start heartbeat) model := strings.TrimSpace(req.model) if model == "" </span><span class="cov0" title="0">{ model = client.DefaultModel() }</span> - <span class="cov8" title="9">_ = tmux.SetStatus(tmux.FormatLLMStartStatus(client.Name(), model)) + <span class="cov9" title="27">_ = tmux.SetStatus(tmux.FormatLLMStartStatus(client.Name(), model)) var output string - if s, ok := client.(llm.Streamer); ok </span><span class="cov3" title="2">{ + if s, ok := client.(llm.Streamer); ok </span><span class="cov5" title="6">{ var b strings.Builder - if err := s.ChatStream(ctx, msgs, func(chunk string) </span><span class="cov6" title="5">{ - b.WriteString(chunk) - fmt.Fprint(out, chunk) - }</span>, req.options...); err != nil <span class="cov0" title="0">{ + var streamErr error + if err := s.ChatStream(ctx, msgs, func(chunk string) </span><span class="cov7" title="15">{ + if streamErr != nil </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov7" title="15">b.WriteString(chunk) + if _, err := fmt.Fprint(out, chunk); err != nil </span><span class="cov0" title="0">{ + streamErr = err + }</span> + }, req.options...); err != nil <span class="cov0" title="0">{ return err }</span> - <span class="cov3" title="2">output = b.String()</span> - } else<span class="cov7" title="7"> { + <span class="cov5" title="6">if streamErr != nil </span><span class="cov0" title="0">{ + return streamErr + }</span> + <span class="cov5" title="6">output = b.String()</span> + } else<span class="cov8" title="21"> { txt, err := client.Chat(ctx, msgs, req.options...) - if err != nil </span><span class="cov3" title="2">{ + if err != nil </span><span class="cov5" title="6">{ + return err + }</span> + <span class="cov7" title="15">output = txt + if _, err := fmt.Fprint(out, output); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov6" title="5">output = txt - fmt.Fprint(out, output)</span> } - <span class="cov7" title="7">dur := time.Since(start) + <span class="cov8" title="21">dur := time.Since(start) // Contribute to global stats and update tmux status sent := 0 - for _, m := range msgs </span><span class="cov10" title="13">{ + for _, m := range msgs </span><span class="cov10" title="39">{ sent += len(m.Content) }</span> - <span class="cov7" title="7">recv := len(output) + <span class="cov8" title="21">recv := len(output) _ = stats.Update(ctx, client.Name(), model, sent, recv) snap, _ := stats.TakeSnapshot() minsWin := snap.Window.Minutes() if minsWin <= 0 </span><span class="cov0" title="0">{ minsWin = 0.001 }</span> - <span class="cov7" title="7">scopeReqs := int64(0) - if pe, ok := snap.Providers[client.Name()]; ok </span><span class="cov7" title="7">{ - if mc, ok2 := pe.Models[model]; ok2 </span><span class="cov7" title="7">{ + <span class="cov8" title="21">scopeReqs := int64(0) + if pe, ok := snap.Providers[client.Name()]; ok </span><span class="cov8" title="21">{ + if mc, ok2 := pe.Models[model]; ok2 </span><span class="cov8" title="21">{ scopeReqs = mc.Reqs }</span> } - <span class="cov7" title="7">scopeRPM := float64(scopeReqs) / minsWin - fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d | global Σ reqs=%d rpm=%.2f"+logging.AnsiReset+"\n", - client.Name(), model, dur.Round(time.Millisecond), sent, recv, snap.Global.Reqs, snap.RPM) - _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, client.Name(), model, scopeRPM, scopeReqs, snap.Window)) + <span class="cov8" title="21">scopeRPM := float64(scopeReqs) / minsWin + if _, err := fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d | global Σ reqs=%d rpm=%.2f"+logging.AnsiReset+"\n", + client.Name(), model, dur.Round(time.Millisecond), sent, recv, snap.Global.Reqs, snap.RPM); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="21">_ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, client.Name(), model, scopeRPM, scopeReqs, snap.Window)) return nil</span> } // printProviderInfo writes the provider/model line to stderr. -func printProviderInfo(errw io.Writer, client llm.Client, model string) <span class="cov7" title="6">{ +func printProviderInfo(errw io.Writer, client llm.Client, model string) <span class="cov8" title="18">{ if strings.TrimSpace(model) == "" </span><span class="cov0" title="0">{ model = client.DefaultModel() }</span> - <span class="cov7" title="6">fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), model)</span> + <span class="cov8" title="18">_, _ = fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), model)</span> } // newClientFromConfig is kept for tests; delegates to llmutils. var newClientFromApp = llmutils.NewClientFromApp // Backcompat for tests referencing the older helper name. -func newClientFromConfig(cfg appconfig.App) (llm.Client, error) <span class="cov3" title="2">{ return newClientFromApp(cfg) }</span> +func newClientFromConfig(cfg appconfig.App) (llm.Client, error) <span class="cov5" title="6">{ return newClientFromApp(cfg) }</span> </pre> <pre class="file" id="file13" style="display: none">// Summary: Hexai LSP runner; configures logging, loads config, builds the LLM client, @@ -3398,40 +3469,44 @@ type ServerFactory func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.S // Run configures logging, loads config, builds the LLM client and runs the LSP server. // It is thin and delegates to RunWithFactory for testability. -func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error <span class="cov1" title="1">{ +func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error <span class="cov4" title="3">{ return RunWithConfig(logPath, "", stdin, stdout, stderr) }</span> -func RunWithConfig(logPath string, configPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error <span class="cov1" title="1">{ +func RunWithConfig(logPath string, configPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error <span class="cov4" title="3">{ logger := log.New(stderr, "hexai-lsp ", log.LstdFlags|log.Lmsgprefix) - if strings.TrimSpace(logPath) != "" </span><span class="cov1" title="1">{ + if strings.TrimSpace(logPath) != "" </span><span class="cov4" title="3">{ f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil </span><span class="cov0" title="0">{ logger.Fatalf("failed to open log file: %v", err) }</span> - <span class="cov1" title="1">defer f.Close() - logger.SetOutput(f)</span> + <span class="cov4" title="3">defer func() </span><span class="cov4" title="3">{ + if err := f.Close(); err != nil </span><span class="cov0" title="0">{ + logger.Printf("failed to close log file: %v", err) + }</span> + }() + <span class="cov4" title="3">logger.SetOutput(f)</span> } - <span class="cov1" title="1">logging.Bind(logger) + <span class="cov4" title="3">logging.Bind(logger) loadOpts := appconfig.LoadOptions{ConfigPath: configPath} cfg := appconfig.LoadWithOptions(logger, loadOpts) if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{ logger.Fatalf("invalid config: %v", err) }</span> - <span class="cov1" title="1">if cfg.StatsWindowMinutes > 0 </span><span class="cov1" title="1">{ + <span class="cov4" title="3">if cfg.StatsWindowMinutes > 0 </span><span class="cov4" title="3">{ stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) }</span> - <span class="cov1" title="1">return RunWithFactory(logPath, configPath, stdin, stdout, logger, cfg, nil, nil)</span> + <span class="cov4" title="3">return RunWithFactory(logPath, configPath, stdin, stdout, logger, cfg, nil, nil)</span> } // 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, configPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error <span class="cov9" title="8">{ +func RunWithFactory(logPath string, configPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error <span class="cov9" title="24">{ normalizeLoggingConfig(&cfg) if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{ logger.Fatalf("invalid config: %v", err) }</span> - <span class="cov9" title="8">client = buildClientIfNil(cfg, client) + <span class="cov9" title="24">client = buildClientIfNil(cfg, client) factory = ensureFactory(factory) store := runtimeconfig.New(cfg) @@ -3441,42 +3516,43 @@ func RunWithFactory(logPath string, configPath string, stdin io.Reader, stdout i opts.ConfigLoadOptions = loadOpts opts.ConfigStore = store server := factory(stdin, stdout, logger, opts) - 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">{ + if configurable, ok := server.(interface{ ApplyOptions(lsp.ServerOptions) }); ok </span><span class="cov5" title="6">{ + store.Subscribe(func(oldCfg, newCfg appconfig.App) </span><span class="cov4" title="3">{ 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="cov1" title="1">{ + <span class="cov4" title="3">if newClient := buildClientIfNil(updated, nil); newClient != nil </span><span class="cov4" title="3">{ client = newClient }</span> - <span class="cov1" title="1">opts := makeServerOptions(updated, logContext, client, loadOpts) + <span class="cov4" title="3">opts := makeServerOptions(updated, logContext, client, loadOpts) opts.ConfigStore = store configurable.ApplyOptions(opts)</span> }) } - <span class="cov9" title="8">if err := server.Run(); err != nil </span><span class="cov0" title="0">{ + <span class="cov9" title="24">if err := server.Run(); err != nil </span><span class="cov0" title="0">{ logger.Fatalf("server error: %v", err) }</span> - <span class="cov9" title="8">return nil</span> + <span class="cov9" title="24">return nil</span> } // --- helpers to keep RunWithFactory small --- -func normalizeLoggingConfig(cfg *appconfig.App) <span class="cov10" title="9">{ +func normalizeLoggingConfig(cfg *appconfig.App) <span class="cov10" title="27">{ cfg.ContextMode = strings.ToLower(strings.TrimSpace(cfg.ContextMode)) - if cfg.LogPreviewLimit >= 0 </span><span class="cov10" title="9">{ + if cfg.LogPreviewLimit >= 0 </span><span class="cov10" title="27">{ logging.SetLogPreviewLimit(cfg.LogPreviewLimit) }</span> } -func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client <span class="cov10" title="9">{ - if client != nil </span><span class="cov1" title="1">{ +func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client <span class="cov10" title="27">{ + if client != nil </span><span class="cov4" title="3">{ return client }</span> - <span class="cov9" title="8">llmCfg := llm.Config{ + <span class="cov9" title="24">llmCfg := llm.Config{ Provider: cfg.Provider, + RequestTimeout: cfg.RequestTimeout, OpenAIBaseURL: cfg.OpenAIBaseURL, OpenAIModel: cfg.OpenAIModel, OpenAITemperature: cfg.OpenAITemperature, @@ -3489,41 +3565,49 @@ func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client <span cla CopilotBaseURL: cfg.CopilotBaseURL, CopilotModel: cfg.CopilotModel, CopilotTemperature: cfg.CopilotTemperature, + AnthropicBaseURL: cfg.AnthropicBaseURL, + AnthropicModel: cfg.AnthropicModel, + AnthropicTemperature: cfg.AnthropicTemperature, } // 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="cov9" title="8">{ + if strings.TrimSpace(oaKey) == "" </span><span class="cov9" title="24">{ oaKey = os.Getenv("OPENAI_API_KEY") }</span> // Prefer HEXAI_OPENROUTER_API_KEY; fall back to OPENROUTER_API_KEY - <span class="cov9" title="8">orKey := os.Getenv("HEXAI_OPENROUTER_API_KEY") - if strings.TrimSpace(orKey) == "" </span><span class="cov9" title="8">{ + <span class="cov9" title="24">orKey := os.Getenv("HEXAI_OPENROUTER_API_KEY") + if strings.TrimSpace(orKey) == "" </span><span class="cov9" title="24">{ orKey = os.Getenv("OPENROUTER_API_KEY") }</span> // Prefer HEXAI_COPILOT_API_KEY; fall back to COPILOT_API_KEY - <span class="cov9" title="8">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") - if strings.TrimSpace(cpKey) == "" </span><span class="cov9" title="8">{ + <span class="cov9" title="24">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") + if strings.TrimSpace(cpKey) == "" </span><span class="cov9" title="24">{ cpKey = os.Getenv("COPILOT_API_KEY") }</span> - <span class="cov9" title="8">if c, err := llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey); err != nil </span><span class="cov1" title="1">{ + // Prefer HEXAI_ANTHROPIC_API_KEY; fall back to ANTHROPIC_API_KEY + <span class="cov9" title="24">anKey := os.Getenv("HEXAI_ANTHROPIC_API_KEY") + if strings.TrimSpace(anKey) == "" </span><span class="cov9" title="24">{ + anKey = os.Getenv("ANTHROPIC_API_KEY") + }</span> + <span class="cov9" title="24">if c, err := llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey, anKey); err != nil </span><span class="cov4" title="3">{ logging.Logf("lsp ", "llm disabled: %v", err) return nil - }</span> else<span class="cov8" title="7"> { + }</span> else<span class="cov9" title="21"> { logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel()) return c }</span> } -func ensureFactory(factory ServerFactory) ServerFactory <span class="cov9" title="8">{ - if factory != nil </span><span class="cov8" title="7">{ +func ensureFactory(factory ServerFactory) ServerFactory <span class="cov9" title="24">{ + if factory != nil </span><span class="cov9" title="21">{ 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">{ + <span class="cov4" title="3">return func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner </span><span class="cov4" title="3">{ return lsp.NewServer(r, w, logger, opts) }</span> } -func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, loadOpts appconfig.LoadOptions) lsp.ServerOptions <span class="cov10" title="9">{ +func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, loadOpts appconfig.LoadOptions) lsp.ServerOptions <span class="cov10" title="27">{ // Map custom actions from appconfig to lsp type var customs []lsp.CustomAction if len(cfg.CustomActions) > 0 </span><span class="cov0" title="0">{ @@ -3540,7 +3624,7 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, lo }) }</span> } - <span class="cov10" title="9">return lsp.ServerOptions{ + <span class="cov10" title="27">return lsp.ServerOptions{ ConfigLoadOptions: loadOpts, LogContext: logContext, ConfigStore: nil, @@ -3555,6 +3639,7 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, lo ManualInvokeMinPrefix: cfg.ManualInvokeMinPrefix, CompletionDebounceMs: cfg.CompletionDebounceMs, CompletionThrottleMs: cfg.CompletionThrottleMs, + CompletionWaitAll: cfg.CompletionWaitAll, InlineOpen: cfg.InlineOpen, InlineClose: cfg.InlineClose, ChatSuffix: cfg.ChatSuffix, @@ -3584,7 +3669,354 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, lo } </pre> - <pre class="file" id="file14" style="display: none">// Summary: GitHub Copilot client for chat and Codex-style code completion. + <pre class="file" id="file14" style="display: none">// Summary: Anthropic client implementation using Messages API with optional streaming support. +package llm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/logging" +) + +// anthropicClient implements Client against Anthropic's Messages API. +type anthropicClient struct { + httpClient *http.Client + apiKey string + baseURL string + defaultModel string + chatLogger logging.ChatLogger + defaultTemperature *float64 +} + +type anthropicChatRequest struct { + Model string `json:"model"` + Messages []anthropicMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens"` + Stream bool `json:"stream,omitempty"` + System string `json:"system,omitempty"` +} + +type anthropicMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type anthropicChatResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + StopReason string `json:"stop_reason"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// Streaming event types +type anthropicStreamStart struct { + Type string `json:"type"` + Message struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Model string `json:"model"` + } `json:"message"` +} + +type anthropicStreamDelta struct { + Type string `json:"type"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` +} + +type anthropicStreamError struct { + Type string `json:"type"` + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` +} + +// Ensure anthropicClient implements Client and Streamer. +var ( + _ Client = (*anthropicClient)(nil) + _ Streamer = (*anthropicClient)(nil) +) + +// Constructor +// newAnthropic constructs an Anthropic client using explicit configuration values. +// The apiKey may be empty; calls will fail until a valid key is supplied. +func newAnthropic(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov10" title="33">{ + return newAnthropicWithTimeout(baseURL, model, apiKey, defaultTemp, 0) +}</span> + +func newAnthropicWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client <span class="cov10" title="33">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov3" title="3">{ + baseURL = "https://api.anthropic.com/v1" + }</span> + <span class="cov10" title="33">if strings.TrimSpace(model) == "" </span><span class="cov3" title="3">{ + model = "claude-3-5-sonnet-20241022" + }</span> + <span class="cov10" title="33">if timeoutSec <= 0 </span><span class="cov10" title="33">{ + timeoutSec = 30 + }</span> + <span class="cov10" title="33">return anthropicClient{ + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, + apiKey: apiKey, + baseURL: baseURL, + defaultModel: model, + chatLogger: logging.NewChatLogger("anthropic"), + defaultTemperature: defaultTemp, + }</span> +} + +// Chat sends a request to Anthropic and returns the response. +func (c anthropicClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov7" title="15">{ + if c.apiKey == "" </span><span class="cov3" title="3">{ + return nilStringErr("missing Anthropic API key") + }</span> + <span class="cov7" title="12">o := c.resolveOptions(opts) + start := time.Now() + c.logStart(false, o, messages) + + resp, err := c.sendRequest(ctx, o, messages, false, start) + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov7" title="12">defer func() </span><span class="cov7" title="12">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/anthropic", "failed to close response body: %v", err) + }</span> + }() + + <span class="cov7" title="12">if err := handleAnthropicNon2xx(resp, start); err != nil </span><span class="cov3" title="3">{ + return "", err + }</span> + <span class="cov6" title="9">out, err := decodeAnthropicChat(resp, start) + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov6" title="9">return c.extractContent(out, start)</span> +} + +// Name returns the provider's short name. +func (c anthropicClient) Name() string <span class="cov3" title="3">{ return "anthropic" }</span> + +// DefaultModel returns the configured default model name. +func (c anthropicClient) DefaultModel() string <span class="cov3" title="3">{ return c.defaultModel }</span> + +// ChatStream sends a streaming request and invokes onDelta for each text chunk. +func (c anthropicClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov5" title="6">{ + if c.apiKey == "" </span><span class="cov3" title="3">{ + return errors.New("missing Anthropic API key") + }</span> + <span class="cov3" title="3">o := c.resolveOptions(opts) + start := time.Now() + c.logStart(true, o, messages) + + resp, err := c.sendRequest(ctx, o, messages, true, start) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="3">defer func() </span><span class="cov3" title="3">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/anthropic", "failed to close response body: %v", err) + }</span> + }() + + <span class="cov3" title="3">if err := handleAnthropicNon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="3">if err := parseAnthropicStream(resp, start, onDelta); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="3">logging.Logf("llm/anthropic ", "stream end duration=%s", time.Since(start)) + return nil</span> +} + +// Private helpers + +func (c anthropicClient) resolveOptions(opts []RequestOption) Options <span class="cov7" title="15">{ + o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov3" title="3">{ + opt(&o) + }</span> + <span class="cov7" title="15">if o.Model == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + <span class="cov7" title="15">return o</span> +} + +func (c anthropicClient) sendRequest(ctx context.Context, o Options, messages []Message, stream bool, start time.Time) (*http.Response, error) <span class="cov7" title="15">{ + req := buildAnthropicChatRequest(o, messages, c.defaultModel, c.defaultTemperature, stream) + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + c.logf("marshal error: %v", err) + return nil, err + }</span> + <span class="cov7" title="15">endpoint := c.baseURL + "/messages" + mode := "POST" + if stream </span><span class="cov3" title="3">{ + mode = "POST (stream)" + }</span> + <span class="cov7" title="15">logging.Logf("llm/anthropic ", "%s %s", mode, endpoint) + return c.doJSON(ctx, endpoint, body, map[string]string{ + "x-api-key": c.apiKey, + "anthropic-version": "2023-06-01", + })</span> +} + +func (c anthropicClient) extractContent(out anthropicChatResponse, start time.Time) (string, error) <span class="cov6" title="9">{ + if len(out.Content) == 0 </span><span class="cov3" title="3">{ + logging.Logf("llm/anthropic ", "%sno content returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) + return "", errors.New("anthropic: no content returned") + }</span> + <span class="cov5" title="6">content := out.Content[0].Text + logging.Logf("llm/anthropic ", "success stop_reason=%s size=%d preview=%s%s%s duration=%s", out.StopReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) + return content, nil</span> +} + +func (c anthropicClient) logf(format string, args ...any) <span class="cov0" title="0">{ + logging.Logf("llm/anthropic ", format, args...) +}</span> + +func (c anthropicClient) logStart(stream bool, o Options, messages []Message) <span class="cov7" title="15">{ + logMessages := make([]struct{ Role, Content string }, len(messages)) + for i, m := range messages </span><span class="cov7" title="15">{ + logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} + }</span> + <span class="cov7" title="15">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> +} + +func buildAnthropicChatRequest(o Options, messages []Message, defaultModel string, defaultTemp *float64, stream bool) anthropicChatRequest <span class="cov7" title="15">{ + req := anthropicChatRequest{ + Model: o.Model, + Stream: stream, + MaxTokens: 4096, // Anthropic requires max_tokens + } + // Anthropic requires system messages in a top-level "system" field, not in messages array + var systemParts []string + var nonSystemMessages []Message + for _, m := range messages </span><span class="cov7" title="15">{ + if m.Role == "system" </span><span class="cov0" title="0">{ + systemParts = append(systemParts, m.Content) + }</span> else<span class="cov7" title="15"> { + nonSystemMessages = append(nonSystemMessages, m) + }</span> + } + <span class="cov7" title="15">if len(systemParts) > 0 </span><span class="cov0" title="0">{ + req.System = strings.Join(systemParts, "\n\n") + }</span> + <span class="cov7" title="15">req.Messages = make([]anthropicMessage, len(nonSystemMessages)) + for i, m := range nonSystemMessages </span><span class="cov7" title="15">{ + req.Messages[i] = anthropicMessage{ + Role: m.Role, + Content: m.Content, + } + }</span> + <span class="cov7" title="15">if o.Temperature != 0 </span><span class="cov3" title="3">{ + req.Temperature = &o.Temperature + }</span> else<span class="cov7" title="12"> if defaultTemp != nil </span><span class="cov0" title="0">{ + t := *defaultTemp + req.Temperature = &t + }</span> + <span class="cov7" title="15">if o.MaxTokens > 0 </span><span class="cov0" title="0">{ + req.MaxTokens = o.MaxTokens + }</span> + <span class="cov7" title="15">return req</span> +} + +func (c anthropicClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov7" title="15">{ + 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="cov7" title="15">req.Header.Set("Content-Type", "application/json") + for k, v := range headers </span><span class="cov9" title="30">{ + req.Header.Set(k, v) + }</span> + <span class="cov7" title="15">return c.httpClient.Do(req)</span> +} + +func handleAnthropicNon2xx(resp *http.Response, start time.Time) error <span class="cov7" title="15">{ + if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov7" title="12">{ + return nil + }</span> + <span class="cov3" title="3">var apiErr anthropicChatResponse + _ = json.NewDecoder(resp.Body).Decode(&apiErr) + if apiErr.Error != nil && apiErr.Error.Message != "" </span><span class="cov3" title="3">{ + logging.Logf("llm/anthropic ", "%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("anthropic error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) + }</span> + <span class="cov0" title="0">logging.Logf("llm/anthropic ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + return fmt.Errorf("anthropic http error: status %d", resp.StatusCode)</span> +} + +func decodeAnthropicChat(resp *http.Response, start time.Time) (anthropicChatResponse, error) <span class="cov6" title="9">{ + var out anthropicChatResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/anthropic ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return anthropicChatResponse{}, err + }</span> + <span class="cov6" title="9">return out, nil</span> +} + +func parseAnthropicStream(resp *http.Response, start time.Time, onDelta func(string)) error <span class="cov3" title="3">{ + // Parse server-sent events: lines starting with "data: " containing JSON + scanner := bufio.NewScanner(resp.Body) + const maxBuf = 1024 * 1024 + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, maxBuf) + for scanner.Scan() </span><span class="cov8" title="18">{ + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov8" title="18">payload := strings.TrimPrefix(line, "data: ") + // Check for stream end event + if strings.Contains(payload, "\"type\":\"message_stop\"") </span><span class="cov3" title="3">{ + break</span> + } + // Try to parse as delta event + <span class="cov7" title="15">var delta anthropicStreamDelta + if err := json.Unmarshal([]byte(payload), &delta); err != nil </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov7" title="15">if delta.Type == "content_block_delta" && delta.Delta.Type == "text_delta" && delta.Delta.Text != "" </span><span class="cov6" title="9">{ + onDelta(delta.Delta.Text) + }</span> + // Check for errors in stream + <span class="cov7" title="15">var errEvent anthropicStreamError + if err := json.Unmarshal([]byte(payload), &errEvent); err == nil </span><span class="cov7" title="15">{ + if errEvent.Type == "error" && errEvent.Error.Message != "" </span><span class="cov0" title="0">{ + logging.Logf("llm/anthropic ", "%sstream error: %s%s", logging.AnsiRed, errEvent.Error.Message, logging.AnsiBase) + return fmt.Errorf("anthropic stream error: %s", errEvent.Error.Message) + }</span> + } + } + <span class="cov3" title="3">if err := scanner.Err(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/anthropic ", "%sstream read error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return err + }</span> + <span class="cov3" title="3">return nil</span> +} +</pre> + + <pre class="file" id="file15" style="display: none">// Summary: GitHub Copilot client for chat and Codex-style code completion. package llm import ( @@ -3649,17 +4081,24 @@ type copilotChatResponse struct { } // Constructor (kept among the first functions by convention) -func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov3" title="9">{ - if strings.TrimSpace(baseURL) == "" </span><span class="cov1" title="1">{ +func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov4" title="21">{ + return newCopilotWithTimeout(baseURL, model, apiKey, defaultTemp, 0) +}</span> + +func newCopilotWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client <span class="cov4" title="27">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov2" title="3">{ baseURL = "https://api.githubcopilot.com" }</span> - <span class="cov3" title="9">if strings.TrimSpace(model) == "" </span><span class="cov0" title="0">{ + <span class="cov4" title="27">if strings.TrimSpace(model) == "" </span><span class="cov0" title="0">{ // GitHub Models (Copilot API) commonly supports gpt-4o/gpt-4o-mini. // Default to a broadly available, cost-effective option. model = "gpt-4o-mini" }</span> - <span class="cov3" title="9">return copilotClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, + <span class="cov4" title="27">if timeoutSec <= 0 </span><span class="cov4" title="27">{ + timeoutSec = 30 + }</span> + <span class="cov4" title="27">return copilotClient{ + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, apiKey: apiKey, baseURL: strings.TrimRight(baseURL, "/"), defaultModel: model, @@ -3668,27 +4107,27 @@ func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client <spa }</span> } -func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov3" title="5">{ +func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov4" title="15">{ if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ return nilStringErr("missing Copilot API key") }</span> // Ensure we have a fresh session token - <span class="cov3" title="5">if err := c.ensureSession(ctx); err != nil </span><span class="cov0" title="0">{ + <span class="cov4" title="15">if err := c.ensureSession(ctx); err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov3" title="5">o := Options{Model: c.defaultModel} + <span class="cov4" title="15">o := Options{Model: c.defaultModel} for _, opt := range opts </span><span class="cov0" title="0">{ opt(&o) }</span> - <span class="cov3" title="5">if o.Model == "" </span><span class="cov0" title="0">{ + <span class="cov4" title="15">if o.Model == "" </span><span class="cov0" title="0">{ o.Model = c.defaultModel }</span> - <span class="cov3" title="5">start := time.Now() + <span class="cov4" title="15">start := time.Now() logMessages := make([]struct{ Role, Content string }, len(messages)) - for i, m := range messages </span><span class="cov3" title="5">{ + for i, m := range messages </span><span class="cov4" title="15">{ logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} }</span> - <span class="cov3" title="5">c.chatLogger.LogStart(false, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) + <span class="cov4" title="15">c.chatLogger.LogStart(false, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) req := buildCopilotChatRequest(o, messages, c.defaultTemperature) body, err := json.Marshal(req) @@ -3697,74 +4136,78 @@ func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...Req return "", err }</span> - <span class="cov3" title="5">endpoint := c.baseURL + "/chat/completions" + <span class="cov4" title="15">endpoint := c.baseURL + "/chat/completions" logging.Logf("llm/copilot ", "POST %s", endpoint) resp, err := c.postJSON(ctx, endpoint, body, c.headersChat()) if err != nil </span><span class="cov0" title="0">{ logging.Logf("llm/copilot ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return "", err }</span> - <span class="cov3" title="5">defer resp.Body.Close() - if err := handleCopilotNon2xx(resp, start); err != nil </span><span class="cov1" title="1">{ + <span class="cov4" title="15">defer func() </span><span class="cov4" title="15">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/copilot", "failed to close response body: %v", err) + }</span> + }() + <span class="cov4" title="15">if err := handleCopilotNon2xx(resp, start); err != nil </span><span class="cov2" title="3">{ return "", err }</span> - <span class="cov2" title="4">out, err := decodeCopilotChat(resp, start) - if err != nil </span><span class="cov1" title="1">{ + <span class="cov3" title="12">out, err := decodeCopilotChat(resp, start) + if err != nil </span><span class="cov2" title="3">{ return "", err }</span> - <span class="cov2" title="3">if len(out.Choices) == 0 </span><span class="cov1" title="1">{ + <span class="cov3" title="9">if len(out.Choices) == 0 </span><span class="cov2" title="3">{ logging.Logf("llm/copilot ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) return "", errors.New("copilot: no choices returned") }</span> - <span class="cov1" title="2">content := out.Choices[0].Message.Content + <span class="cov3" title="6">content := out.Choices[0].Message.Content logging.Logf("llm/copilot ", "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 copilotClient) Name() string <span class="cov1" title="1">{ return "copilot" }</span> -func (c copilotClient) DefaultModel() string <span class="cov1" title="1">{ return c.defaultModel }</span> +func (c copilotClient) Name() string <span class="cov2" title="3">{ return "copilot" }</span> +func (c copilotClient) DefaultModel() string <span class="cov2" title="3">{ return c.defaultModel }</span> // helpers -func buildCopilotChatRequest(o Options, messages []Message, defaultTemp *float64) copilotChatRequest <span class="cov3" title="6">{ +func buildCopilotChatRequest(o Options, messages []Message, defaultTemp *float64) copilotChatRequest <span class="cov4" title="18">{ req := copilotChatRequest{Model: o.Model} req.Messages = make([]copilotMessage, len(messages)) - for i, m := range messages </span><span class="cov3" title="6">{ - req.Messages[i] = copilotMessage{Role: m.Role, Content: m.Content} + for i, m := range messages </span><span class="cov4" title="18">{ + req.Messages[i] = copilotMessage(m) }</span> - <span class="cov3" title="6">if o.Temperature != 0 </span><span class="cov0" title="0">{ + <span class="cov4" title="18">if o.Temperature != 0 </span><span class="cov0" title="0">{ req.Temperature = &o.Temperature - }</span> else<span class="cov3" title="6"> if defaultTemp != nil </span><span class="cov3" title="6">{ + }</span> else<span class="cov4" title="18"> if defaultTemp != nil </span><span class="cov4" title="18">{ t := *defaultTemp req.Temperature = &t }</span> - <span class="cov3" title="6">if o.MaxTokens > 0 </span><span class="cov1" title="1">{ + <span class="cov4" title="18">if o.MaxTokens > 0 </span><span class="cov2" title="3">{ req.MaxTokens = &o.MaxTokens }</span> - <span class="cov3" title="6">if len(o.Stop) > 0 </span><span class="cov1" title="1">{ + <span class="cov4" title="18">if len(o.Stop) > 0 </span><span class="cov2" title="3">{ req.Stop = o.Stop }</span> - <span class="cov3" title="6">return req</span> + <span class="cov4" title="18">return req</span> } -func (c copilotClient) postJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov3" title="8">{ +func (c copilotClient) postJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov4" title="24">{ 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="cov3" title="8">for k, v := range headers </span><span class="cov6" title="88">{ + <span class="cov4" title="24">for k, v := range headers </span><span class="cov7" title="264">{ req.Header.Set(k, v) }</span> - <span class="cov3" title="8">return c.httpClient.Do(req)</span> + <span class="cov4" title="24">return c.httpClient.Do(req)</span> } -func handleCopilotNon2xx(resp *http.Response, start time.Time) error <span class="cov3" title="6">{ - if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov2" title="4">{ +func handleCopilotNon2xx(resp *http.Response, start time.Time) error <span class="cov4" title="18">{ + if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov3" title="12">{ return nil }</span> - <span class="cov1" title="2">var apiErr copilotChatResponse + <span class="cov3" title="6">var apiErr copilotChatResponse _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if apiErr.Error != nil && strings.TrimSpace(apiErr.Error.Message) != "" </span><span class="cov1" title="2">{ + if apiErr.Error != nil && strings.TrimSpace(apiErr.Error.Message) != "" </span><span class="cov3" title="6">{ logging.Logf("llm/copilot ", "%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("copilot error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) }</span> @@ -3772,13 +4215,13 @@ func handleCopilotNon2xx(resp *http.Response, start time.Time) error <span class return fmt.Errorf("copilot http error: status %d", resp.StatusCode)</span> } -func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatResponse, error) <span class="cov2" title="4">{ +func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatResponse, error) <span class="cov3" title="12">{ var out copilotChatResponse - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov1" title="1">{ + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov2" title="3">{ logging.Logf("llm/copilot ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return copilotChatResponse{}, err }</span> - <span class="cov2" title="3">return out, nil</span> + <span class="cov3" title="9">return out, nil</span> } // --- Copilot session token management --- @@ -3787,54 +4230,58 @@ type ghCopilotTokenResp struct { Token string `json:"token"` } -func (c *copilotClient) ensureSession(ctx context.Context) error <span class="cov4" title="16">{ +func (c *copilotClient) ensureSession(ctx context.Context) error <span class="cov5" title="48">{ // If token valid for >60s, reuse - if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) </span><span class="cov3" title="8">{ + if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) </span><span class="cov4" title="24">{ return nil }</span> - <span class="cov3" title="8">if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ + <span class="cov4" title="24">if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ return errors.New("missing Copilot API key") }</span> - <span class="cov3" title="8">req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/copilot_internal/v2/token", nil) + <span class="cov4" title="24">req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/copilot_internal/v2/token", nil) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov3" title="8">req.Header.Set("Authorization", "Bearer "+c.apiKey) + <span class="cov4" title="24">req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "hexai/"+appver.Version) resp, err := c.httpClient.Do(req) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov3" title="8">defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 </span><span class="cov0" title="0">{ + <span class="cov4" title="24">defer func() </span><span class="cov4" title="24">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/copilot", "failed to close response body: %v", err) + }</span> + }() + <span class="cov4" title="24">if resp.StatusCode < 200 || resp.StatusCode >= 300 </span><span class="cov0" title="0">{ return fmt.Errorf("copilot token http error: %d", resp.StatusCode) }</span> - <span class="cov3" title="8">var out ghCopilotTokenResp + <span class="cov4" title="24">var out ghCopilotTokenResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov3" title="8">if strings.TrimSpace(out.Token) == "" </span><span class="cov0" title="0">{ + <span class="cov4" title="24">if strings.TrimSpace(out.Token) == "" </span><span class="cov0" title="0">{ return errors.New("empty copilot session token") }</span> // Parse JWT exp - <span class="cov3" title="8">exp := parseJWTExp(out.Token) - if exp.IsZero() </span><span class="cov3" title="8">{ + <span class="cov4" title="24">exp := parseJWTExp(out.Token) + if exp.IsZero() </span><span class="cov4" title="24">{ exp = time.Now().Add(10 * time.Minute) }</span> - <span class="cov3" title="8">c.sessionToken = out.Token + <span class="cov4" title="24">c.sessionToken = out.Token c.tokenExpiry = exp return nil</span> } var jwtExpRe = regexp.MustCompile(`"exp"\s*:\s*([0-9]+)`) // fallback if we can't base64 decode -func parseJWTExp(token string) time.Time <span class="cov3" title="9">{ +func parseJWTExp(token string) time.Time <span class="cov4" title="27">{ parts := strings.Split(token, ".") - if len(parts) < 2 </span><span class="cov3" title="8">{ + if len(parts) < 2 </span><span class="cov4" title="24">{ return time.Time{} }</span> - <span class="cov1" title="1">b, err := base64.RawURLEncoding.DecodeString(parts[1]) + <span class="cov2" title="3">b, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil </span><span class="cov0" title="0">{ if m := jwtExpRe.FindStringSubmatch(token); len(m) == 2 </span><span class="cov0" title="0">{ if n, err2 := parseInt64(m[1]); err2 == nil </span><span class="cov0" title="0">{ @@ -3843,21 +4290,21 @@ func parseJWTExp(token string) time.Time <span class="cov3" title="9">{ } <span class="cov0" title="0">return time.Time{}</span> } - <span class="cov1" title="1">var payload struct { + <span class="cov2" title="3">var payload struct { Exp int64 `json:"exp"` } _ = json.Unmarshal(b, &payload) if payload.Exp == 0 </span><span class="cov0" title="0">{ return time.Time{} }</span> - <span class="cov1" title="1">return time.Unix(payload.Exp, 0)</span> + <span class="cov2" title="3">return time.Unix(payload.Exp, 0)</span> } -func parseInt64(s string) (int64, error) <span class="cov1" title="1">{ var n int64; _, err := fmt.Sscan(s, &n); return n, err }</span> +func parseInt64(s string) (int64, error) <span class="cov2" title="3">{ var n int64; _, err := fmt.Sscan(s, &n); return n, err }</span> // --- Copilot headers --- -func (c *copilotClient) headersChat() map[string]string <span class="cov3" title="5">{ +func (c *copilotClient) headersChat() map[string]string <span class="cov4" title="15">{ _ = c.ensureSession(context.Background()) h := map[string]string{ "Content-Type": "application/json; charset=utf-8", @@ -3875,7 +4322,7 @@ func (c *copilotClient) headersChat() map[string]string <span class="cov3" title return h }</span> -func (c *copilotClient) headersGhost() map[string]string <span class="cov2" title="3">{ +func (c *copilotClient) headersGhost() map[string]string <span class="cov3" title="9">{ _ = c.ensureSession(context.Background()) h := map[string]string{ "Content-Type": "application/json; charset=utf-8", @@ -3893,29 +4340,29 @@ func (c *copilotClient) headersGhost() map[string]string <span class="cov2" titl return h }</span> -func randHex(n int) string <span class="cov6" title="88">{ +func randHex(n int) string <span class="cov7" title="264">{ const hex = "0123456789abcdef" b := make([]byte, n) - for i := range b </span><span class="cov10" title="1024">{ + for i := range b </span><span class="cov10" title="3072">{ b[i] = hex[int(time.Now().UnixNano()+int64(i))%len(hex)] }</span> - <span class="cov6" title="88">return string(b)</span> + <span class="cov7" title="264">return string(b)</span> } // --- Codex-style code completion --- // CodeCompletion implements CodeCompleter; returns up to n suggestions. -func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) <span class="cov2" title="3">{ +func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) <span class="cov3" title="9">{ if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ return nil, errors.New("missing Copilot API key") }</span> - <span class="cov2" title="3">if err := c.ensureSession(ctx); err != nil </span><span class="cov0" title="0">{ + <span class="cov3" title="9">if err := c.ensureSession(ctx); err != nil </span><span class="cov0" title="0">{ return nil, err }</span> - <span class="cov2" title="3">if n <= 0 </span><span class="cov0" title="0">{ + <span class="cov3" title="9">if n <= 0 </span><span class="cov0" title="0">{ n = 1 }</span> - <span class="cov2" title="3">maxTokens := 500 + <span class="cov3" title="9">maxTokens := 500 body := map[string]any{ "extra": map[string]any{ "language": language, @@ -3940,38 +4387,42 @@ func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix if err != nil </span><span class="cov0" title="0">{ return nil, err }</span> - <span class="cov2" title="3">defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 </span><span class="cov0" title="0">{ + <span class="cov3" title="9">defer func() </span><span class="cov3" title="9">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/copilot", "failed to close response body: %v", err) + }</span> + }() + <span class="cov3" title="9">if resp.StatusCode < 200 || resp.StatusCode >= 300 </span><span class="cov0" title="0">{ return nil, fmt.Errorf("copilot codex http error: %d", resp.StatusCode) }</span> // Read all and parse lines that start with "data: " accumulating by index - <span class="cov2" title="3">raw, _ := io.ReadAll(resp.Body) + <span class="cov3" title="9">raw, _ := io.ReadAll(resp.Body) byIndex := make(map[int]string) lines := strings.Split(string(raw), "\n") - for _, ln := range lines </span><span class="cov3" title="10">{ - if !strings.HasPrefix(ln, "data: ") </span><span class="cov2" title="3">{ + for _, ln := range lines </span><span class="cov4" title="30">{ + if !strings.HasPrefix(ln, "data: ") </span><span class="cov3" title="9">{ continue</span> } - <span class="cov3" title="7">var evt struct { + <span class="cov4" title="21">var evt struct { Choices []struct { Index int `json:"index"` Text string `json:"text"` } `json:"choices"` } - if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &evt); err != nil </span><span class="cov2" title="4">{ + if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &evt); err != nil </span><span class="cov3" title="12">{ continue</span> } - <span class="cov2" title="3">for _, ch := range evt.Choices </span><span class="cov2" title="3">{ + <span class="cov3" title="9">for _, ch := range evt.Choices </span><span class="cov3" title="9">{ byIndex[ch.Index] += ch.Text }</span> } - <span class="cov2" title="3">out := make([]string, 0, len(byIndex)) - for i := 0; i < n; i++ </span><span class="cov2" title="4">{ - if s, ok := byIndex[i]; ok && strings.TrimSpace(s) != "" </span><span class="cov2" title="3">{ + <span class="cov3" title="9">out := make([]string, 0, len(byIndex)) + for i := 0; i < n; i++ </span><span class="cov3" title="12">{ + if s, ok := byIndex[i]; ok && strings.TrimSpace(s) != "" </span><span class="cov3" title="9">{ out = append(out, s) }</span> } - <span class="cov2" title="3">return out, nil</span> + <span class="cov3" title="9">return out, nil</span> } // newLineDataReader wraps a streaming body and exposes a JSON decoder that @@ -3979,7 +4430,7 @@ func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix // (no streaming decoder needed; we parse whole body lines) </pre> - <pre class="file" id="file15" style="display: none">// Summary: Ollama client against a local server; supports chat responses and streaming via /api/chat. + <pre class="file" id="file16" style="display: none">// Summary: Ollama client against a local server; supports chat responses and streaming via /api/chat. package llm import ( @@ -4022,15 +4473,22 @@ type ollamaChatResponse struct { } // Constructor (kept among the first functions by convention) -func newOllama(baseURL, model string, defaultTemp *float64) Client <span class="cov10" title="13">{ - if strings.TrimSpace(baseURL) == "" </span><span class="cov3" title="2">{ +func newOllama(baseURL, model string, defaultTemp *float64) Client <span class="cov9" title="30">{ + return newOllamaWithTimeout(baseURL, model, defaultTemp, 0) +}</span> + +func newOllamaWithTimeout(baseURL, model string, defaultTemp *float64, timeoutSec int) Client <span class="cov10" title="39">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov5" title="6">{ baseURL = "http://localhost:11434" }</span> - <span class="cov10" title="13">if strings.TrimSpace(model) == "" </span><span class="cov3" title="2">{ + <span class="cov10" title="39">if strings.TrimSpace(model) == "" </span><span class="cov5" title="6">{ model = "qwen3-coder:30b-a3b-q4_K_M" }</span> - <span class="cov10" title="13">return ollamaClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, + <span class="cov10" title="39">if timeoutSec <= 0 </span><span class="cov9" title="36">{ + timeoutSec = 30 + }</span> + <span class="cov10" title="39">return ollamaClient{ + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, baseURL: strings.TrimRight(baseURL, "/"), defaultModel: model, chatLogger: logging.NewChatLogger("ollama"), @@ -4038,16 +4496,16 @@ func newOllama(baseURL, model string, defaultTemp *float64) Client <span class=" }</span> } -func (c ollamaClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov7" title="6">{ +func (c ollamaClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov8" title="18">{ o := Options{Model: c.defaultModel} for _, opt := range opts </span><span class="cov0" title="0">{ opt(&o) }</span> - <span class="cov7" title="6">if o.Model == "" </span><span class="cov0" title="0">{ + <span class="cov8" title="18">if o.Model == "" </span><span class="cov0" title="0">{ o.Model = c.defaultModel }</span> - <span class="cov7" title="6">start := time.Now() + <span class="cov8" title="18">start := time.Now() c.logStart(false, o, messages) req := buildOllamaRequest(o, messages, c.defaultTemperature, false) body, err := json.Marshal(req) @@ -4055,47 +4513,51 @@ func (c ollamaClient) Chat(ctx context.Context, messages []Message, opts ...Requ return "", err }</span> - <span class="cov7" title="6">endpoint := c.baseURL + "/api/chat" + <span class="cov8" title="18">endpoint := c.baseURL + "/api/chat" logging.Logf("llm/ollama ", "POST %s", endpoint) resp, err := c.doJSON(ctx, endpoint, body) - if err != nil </span><span class="cov1" title="1">{ + if err != nil </span><span class="cov3" title="3">{ logging.Logf("llm/ollama ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return "", err }</span> - <span class="cov6" title="5">defer resp.Body.Close() - if err := handleOllamaNon2xx(resp, start); err != nil </span><span class="cov3" title="2">{ + <span class="cov7" title="15">defer func() </span><span class="cov7" title="15">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/ollama", "failed to close response body: %v", err) + }</span> + }() + <span class="cov7" title="15">if err := handleOllamaNon2xx(resp, start); err != nil </span><span class="cov5" title="6">{ return "", err }</span> - <span class="cov4" title="3">var out ollamaChatResponse - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov1" title="1">{ + <span class="cov6" title="9">var out ollamaChatResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov3" title="3">{ logging.Logf("llm/ollama ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return "", err }</span> - <span class="cov3" title="2">if strings.TrimSpace(out.Message.Content) == "" </span><span class="cov1" title="1">{ + <span class="cov5" title="6">if strings.TrimSpace(out.Message.Content) == "" </span><span class="cov3" title="3">{ logging.Logf("llm/ollama ", "%sempty content returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) return "", errors.New("ollama: empty content") }</span> - <span class="cov1" title="1">content := out.Message.Content + <span class="cov3" title="3">content := out.Message.Content logging.Logf("llm/ollama ", "success size=%d preview=%s%s%s duration=%s", len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) return content, nil</span> } // Provider metadata -func (c ollamaClient) Name() string <span class="cov3" title="2">{ return "ollama" }</span> -func (c ollamaClient) DefaultModel() string <span class="cov3" title="2">{ return c.defaultModel }</span> +func (c ollamaClient) Name() string <span class="cov5" title="6">{ return "ollama" }</span> +func (c ollamaClient) DefaultModel() string <span class="cov5" title="6">{ return c.defaultModel }</span> // Streaming support (optional) -func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov4" title="3">{ +func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov6" title="9">{ o := Options{Model: c.defaultModel} for _, opt := range opts </span><span class="cov0" title="0">{ opt(&o) }</span> - <span class="cov4" title="3">if o.Model == "" </span><span class="cov0" title="0">{ + <span class="cov6" title="9">if o.Model == "" </span><span class="cov0" title="0">{ o.Model = c.defaultModel }</span> - <span class="cov4" title="3">start := time.Now() + <span class="cov6" title="9">start := time.Now() c.logStart(true, o, messages) req := buildOllamaRequest(o, messages, c.defaultTemperature, true) body, err := json.Marshal(req) @@ -4103,101 +4565,105 @@ func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelt return err }</span> - <span class="cov4" title="3">endpoint := c.baseURL + "/api/chat" + <span class="cov6" title="9">endpoint := c.baseURL + "/api/chat" logging.Logf("llm/ollama ", "POST %s (stream)", endpoint) resp, err := c.doJSON(ctx, endpoint, body) if err != nil </span><span class="cov0" title="0">{ logging.Logf("llm/ollama ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return err }</span> - <span class="cov4" title="3">defer resp.Body.Close() - if err := handleOllamaNon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ + <span class="cov6" title="9">defer func() </span><span class="cov6" title="9">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/ollama", "failed to close response body: %v", err) + }</span> + }() + <span class="cov6" title="9">if err := handleOllamaNon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov4" title="3">dec := json.NewDecoder(resp.Body) - for </span><span class="cov5" title="4">{ + <span class="cov6" title="9">dec := json.NewDecoder(resp.Body) + for </span><span class="cov7" title="12">{ var ev ollamaChatResponse - if err := dec.Decode(&ev); err != nil </span><span class="cov1" title="1">{ + if err := dec.Decode(&ev); err != nil </span><span class="cov3" title="3">{ if errors.Is(err, io.EOF) </span><span class="cov0" title="0">{ break</span> } - <span class="cov1" title="1">logging.Logf("llm/ollama ", "%sdecode stream error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + <span class="cov3" title="3">logging.Logf("llm/ollama ", "%sdecode stream error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return err</span> } - <span class="cov4" title="3">if strings.TrimSpace(ev.Error) != "" </span><span class="cov1" title="1">{ + <span class="cov6" title="9">if strings.TrimSpace(ev.Error) != "" </span><span class="cov3" title="3">{ logging.Logf("llm/ollama ", "%sstream event error: %s%s", logging.AnsiRed, ev.Error, logging.AnsiBase) return fmt.Errorf("ollama stream error: %s", ev.Error) }</span> - <span class="cov3" title="2">if s := ev.Message.Content; strings.TrimSpace(s) != "" </span><span class="cov3" title="2">{ + <span class="cov5" title="6">if s := ev.Message.Content; strings.TrimSpace(s) != "" </span><span class="cov5" title="6">{ onDelta(s) }</span> - <span class="cov3" title="2">if ev.Done </span><span class="cov1" title="1">{ + <span class="cov5" title="6">if ev.Done </span><span class="cov3" title="3">{ break</span> } } - <span class="cov1" title="1">logging.Logf("llm/ollama ", "stream end duration=%s", time.Since(start)) + <span class="cov3" title="3">logging.Logf("llm/ollama ", "stream end duration=%s", time.Since(start)) return nil</span> } // helpers to keep methods small -func (c ollamaClient) logStart(stream bool, o Options, messages []Message) <span class="cov8" title="9">{ +func (c ollamaClient) logStart(stream bool, o Options, messages []Message) <span class="cov9" title="27">{ logMessages := make([]struct{ Role, Content string }, len(messages)) - for i, m := range messages </span><span class="cov8" title="9">{ + for i, m := range messages </span><span class="cov9" title="27">{ logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} }</span> - <span class="cov8" title="9">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> + <span class="cov9" title="27">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> } -func buildOllamaRequest(o Options, messages []Message, defaultTemp *float64, stream bool) ollamaChatRequest <span class="cov9" title="12">{ +func buildOllamaRequest(o Options, messages []Message, defaultTemp *float64, stream bool) ollamaChatRequest <span class="cov9" title="36">{ req := ollamaChatRequest{Model: o.Model, Stream: stream} req.Messages = make([]oaMessage, len(messages)) - for i, m := range messages </span><span class="cov9" title="12">{ - req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} + for i, m := range messages </span><span class="cov9" title="36">{ + req.Messages[i] = oaMessage(m) }</span> - <span class="cov9" title="12">optsMap := map[string]any{} - if o.Temperature != 0 </span><span class="cov1" title="1">{ + <span class="cov9" title="36">optsMap := map[string]any{} + if o.Temperature != 0 </span><span class="cov3" title="3">{ optsMap["temperature"] = o.Temperature - }</span> else<span class="cov9" title="11"> if defaultTemp != nil </span><span class="cov4" title="3">{ + }</span> else<span class="cov9" title="33"> if defaultTemp != nil </span><span class="cov6" title="9">{ optsMap["temperature"] = *defaultTemp }</span> - <span class="cov9" title="12">if o.MaxTokens > 0 </span><span class="cov3" title="2">{ + <span class="cov9" title="36">if o.MaxTokens > 0 </span><span class="cov5" title="6">{ optsMap["num_predict"] = o.MaxTokens }</span> - <span class="cov9" title="12">if len(o.Stop) > 0 </span><span class="cov3" title="2">{ + <span class="cov9" title="36">if len(o.Stop) > 0 </span><span class="cov5" title="6">{ optsMap["stop"] = o.Stop }</span> - <span class="cov9" title="12">if len(optsMap) > 0 </span><span class="cov5" title="4">{ + <span class="cov9" title="36">if len(optsMap) > 0 </span><span class="cov7" title="12">{ req.Options = optsMap }</span> - <span class="cov9" title="12">return req</span> + <span class="cov9" title="36">return req</span> } -func (c ollamaClient) doJSON(ctx context.Context, url string, body []byte) (*http.Response, error) <span class="cov8" title="9">{ +func (c ollamaClient) doJSON(ctx context.Context, url string, body []byte) (*http.Response, error) <span class="cov9" title="27">{ 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="cov8" title="9">req.Header.Set("Content-Type", "application/json") + <span class="cov9" title="27">req.Header.Set("Content-Type", "application/json") return c.httpClient.Do(req)</span> } -func handleOllamaNon2xx(resp *http.Response, start time.Time) error <span class="cov8" title="9">{ - if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov7" title="7">{ +func handleOllamaNon2xx(resp *http.Response, start time.Time) error <span class="cov9" title="27">{ + if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov8" title="21">{ return nil }</span> - <span class="cov3" title="2">var apiErr ollamaChatResponse + <span class="cov5" title="6">var apiErr ollamaChatResponse _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if strings.TrimSpace(apiErr.Error) != "" </span><span class="cov1" title="1">{ + if strings.TrimSpace(apiErr.Error) != "" </span><span class="cov3" title="3">{ logging.Logf("llm/ollama ", "%sapi error status=%d msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error, time.Since(start), logging.AnsiBase) return fmt.Errorf("ollama error: %s (status %d)", apiErr.Error, resp.StatusCode) }</span> - <span class="cov1" title="1">logging.Logf("llm/ollama ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + <span class="cov3" title="3">logging.Logf("llm/ollama ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) return fmt.Errorf("ollama http error: status %d", resp.StatusCode)</span> } </pre> - <pre class="file" id="file16" style="display: none">// Summary: OpenAI client implementation for chat completions with optional streaming and detailed logging. + <pre class="file" id="file17" style="display: none">// Summary: OpenAI client implementation for chat completions with optional streaming and detailed logging. package llm import ( @@ -4275,15 +4741,22 @@ 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="cov9" title="26">{ - if strings.TrimSpace(baseURL) == "" </span><span class="cov8" title="15">{ +func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov7" title="30">{ + return newOpenAIWithTimeout(baseURL, model, apiKey, defaultTemp, 0) +}</span> + +func newOpenAIWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client <span class="cov9" title="78">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov8" title="45">{ baseURL = "https://api.openai.com/v1" }</span> - <span class="cov9" title="26">if strings.TrimSpace(model) == "" </span><span class="cov7" title="12">{ + <span class="cov9" title="78">if strings.TrimSpace(model) == "" </span><span class="cov8" title="36">{ model = "gpt-4.1" }</span> - <span class="cov9" title="26">return openAIClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, + <span class="cov9" title="78">if timeoutSec <= 0 </span><span class="cov9" title="69">{ + timeoutSec = 30 + }</span> + <span class="cov9" title="78">return openAIClient{ + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, apiKey: apiKey, baseURL: baseURL, defaultModel: model, @@ -4292,18 +4765,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="cov7" title="13">{ - if c.apiKey == "" </span><span class="cov1" title="1">{ +func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov8" title="39">{ + if c.apiKey == "" </span><span class="cov3" title="3">{ return nilStringErr("missing OpenAI API key") }</span> - <span class="cov7" title="12">o := Options{Model: c.defaultModel} - for _, opt := range opts </span><span class="cov5" title="5">{ + <span class="cov8" title="36">o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov6" title="15">{ opt(&o) }</span> - <span class="cov7" title="12">if o.Model == "" </span><span class="cov0" title="0">{ + <span class="cov8" title="36">if o.Model == "" </span><span class="cov0" title="0">{ o.Model = c.defaultModel }</span> - <span class="cov7" title="12">start := time.Now() + <span class="cov8" title="36">start := time.Now() c.logStart(false, o, messages) req := buildOAChatRequest(o, messages, c.defaultTemperature, false, "llm/openai ") body, err := json.Marshal(req) @@ -4311,7 +4784,7 @@ func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...Requ c.logf("marshal error: %v", err) return "", err }</span> - <span class="cov7" title="12">endpoint := c.baseURL + "/chat/completions" + <span class="cov8" title="36">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, @@ -4320,41 +4793,45 @@ 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="cov7" title="12">defer resp.Body.Close() - if err := handleOpenAINon2xx(resp, start, "llm/openai ", "openai"); err != nil </span><span class="cov2" title="2">{ + <span class="cov8" title="36">defer func() </span><span class="cov8" title="36">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openai", "failed to close response body: %v", err) + }</span> + }() + <span class="cov8" title="36">if err := handleOpenAINon2xx(resp, start, "llm/openai ", "openai"); err != nil </span><span class="cov4" title="6">{ return "", err }</span> - <span class="cov7" title="10">out, err := decodeOpenAIChat(resp, start, "llm/openai ") - if err != nil </span><span class="cov1" title="1">{ + <span class="cov7" title="30">out, err := decodeOpenAIChat(resp, start, "llm/openai ") + if err != nil </span><span class="cov3" title="3">{ return "", err }</span> - <span class="cov6" title="9">if len(out.Choices) == 0 </span><span class="cov1" title="1">{ + <span class="cov7" title="27">if len(out.Choices) == 0 </span><span class="cov3" title="3">{ 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="cov6" title="8">content := out.Choices[0].Message.Content + <span class="cov7" title="24">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="cov7" title="14">{ return "openai" }</span> -func (c openAIClient) DefaultModel() string <span class="cov7" title="12">{ return c.defaultModel }</span> +func (c openAIClient) Name() string <span class="cov8" title="42">{ return "openai" }</span> +func (c openAIClient) DefaultModel() string <span class="cov8" title="36">{ 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="5">{ +func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov6" title="15">{ if c.apiKey == "" </span><span class="cov0" title="0">{ return errors.New("missing OpenAI API key") }</span> - <span class="cov5" title="5">o := Options{Model: c.defaultModel} + <span class="cov6" title="15">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="cov6" title="15">if o.Model == "" </span><span class="cov0" title="0">{ o.Model = c.defaultModel }</span> - <span class="cov5" title="5">start := time.Now() + <span class="cov6" title="15">start := time.Now() c.logStart(true, o, messages) req := buildOAChatRequest(o, messages, c.defaultTemperature, true, "llm/openai ") body, err := json.Marshal(req) @@ -4362,7 +4839,7 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt c.logf("marshal error: %v", err) return err }</span> - <span class="cov5" title="5">endpoint := c.baseURL + "/chat/completions" + <span class="cov6" title="15">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, @@ -4371,15 +4848,19 @@ 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="5">defer resp.Body.Close() - if err := handleOpenAINon2xx(resp, start, "llm/openai ", "openai"); err != nil </span><span class="cov0" title="0">{ + <span class="cov6" title="15">defer func() </span><span class="cov6" title="15">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openai", "failed to close response body: %v", err) + }</span> + }() + <span class="cov6" title="15">if err := handleOpenAINon2xx(resp, start, "llm/openai ", "openai"); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="5">if err := parseOpenAIStream(resp, start, onDelta, "llm/openai ", "openai"); err != nil </span><span class="cov1" title="1">{ + <span class="cov6" title="15">if err := parseOpenAIStream(resp, start, onDelta, "llm/openai ", "openai"); err != nil </span><span class="cov3" title="3">{ return err }</span> - <span class="cov4" title="4">logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start)) + <span class="cov5" title="12">logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start)) return nil</span> } @@ -4387,141 +4868,141 @@ 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="cov8" title="17">{ +func (c openAIClient) logStart(stream bool, o Options, messages []Message) <span class="cov8" title="51">{ logMessages := make([]struct{ Role, Content string }, len(messages)) - for i, m := range messages </span><span class="cov9" title="22">{ + for i, m := range messages </span><span class="cov9" title="66">{ logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} }</span> - <span class="cov8" title="17">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> + <span class="cov8" title="51">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> } -func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool, logPrefix string) oaChatRequest <span class="cov9" title="22">{ +func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool, logPrefix string) oaChatRequest <span class="cov9" title="66">{ req := oaChatRequest{Model: o.Model, Stream: stream} req.Messages = make([]oaMessage, len(messages)) - for i, m := range messages </span><span class="cov9" title="27">{ - req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} + for i, m := range messages </span><span class="cov9" title="81">{ + req.Messages[i] = oaMessage(m) }</span> - <span class="cov9" title="22">if o.Temperature != 0 </span><span class="cov1" title="1">{ + <span class="cov9" title="66">if o.Temperature != 0 </span><span class="cov3" title="3">{ req.Temperature = &o.Temperature - }</span> else<span class="cov9" title="21"> if defaultTemp != nil </span><span class="cov8" title="16">{ + }</span> else<span class="cov9" title="63"> if defaultTemp != nil </span><span class="cov8" title="48">{ t := *defaultTemp req.Temperature = &t }</span> - <span class="cov9" title="22">if o.MaxTokens > 0 </span><span class="cov6" title="8">{ - if requiresMaxCompletionTokens(o.Model) </span><span class="cov2" title="2">{ + <span class="cov9" title="66">if o.MaxTokens > 0 </span><span class="cov7" title="24">{ + if requiresMaxCompletionTokens(o.Model) </span><span class="cov4" title="6">{ req.MaxCompletionTokens = &o.MaxTokens - }</span> else<span class="cov5" title="6"> { + }</span> else<span class="cov6" title="18"> { req.MaxTokens = &o.MaxTokens }</span> } - <span class="cov9" title="22">if len(o.Stop) > 0 </span><span class="cov0" title="0">{ + <span class="cov9" title="66">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="cov9" title="22">if requiresMaxCompletionTokens(o.Model) </span><span class="cov2" title="2">{ - if req.Temperature == nil || *req.Temperature != 1.0 </span><span class="cov2" title="2">{ + <span class="cov9" title="66">if requiresMaxCompletionTokens(o.Model) </span><span class="cov4" title="6">{ + if req.Temperature == nil || *req.Temperature != 1.0 </span><span class="cov4" title="6">{ t := 1.0 req.Temperature = &t logging.Logf(logPrefix, "forcing temperature=1.0 for model=%s (gpt-5 constraint)", o.Model) }</span> } - <span class="cov9" title="22">return req</span> + <span class="cov9" title="66">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="cov10" title="30">{ +func requiresMaxCompletionTokens(model string) bool <span class="cov10" title="90">{ 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="cov7" title="12">{ +func (c openAIClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov8" title="36">{ 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="cov7" title="12">req.Header.Set("Content-Type", "application/json") - for k, v := range headers </span><span class="cov7" title="12">{ + <span class="cov8" title="36">req.Header.Set("Content-Type", "application/json") + for k, v := range headers </span><span class="cov8" title="36">{ req.Header.Set(k, v) }</span> - <span class="cov7" title="12">return c.httpClient.Do(req)</span> + <span class="cov8" title="36">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="5">{ +func (c openAIClient) doJSONWithAccept(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) <span class="cov6" title="15">{ 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") + <span class="cov6" title="15">req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", accept) - for k, v := range headers </span><span class="cov5" title="5">{ + for k, v := range headers </span><span class="cov6" title="15">{ req.Header.Set(k, v) }</span> - <span class="cov5" title="5">return c.httpClient.Do(req)</span> + <span class="cov6" title="15">return c.httpClient.Do(req)</span> } -func handleOpenAINon2xx(resp *http.Response, start time.Time, logPrefix, provider string) error <span class="cov8" title="20">{ - if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov8" title="17">{ +func handleOpenAINon2xx(resp *http.Response, start time.Time, logPrefix, provider string) error <span class="cov9" title="60">{ + if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov8" title="51">{ return nil }</span> - <span class="cov3" title="3">var apiErr oaChatResponse + <span class="cov5" title="9">var apiErr oaChatResponse _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if apiErr.Error != nil && apiErr.Error.Message != "" </span><span class="cov1" title="1">{ + if apiErr.Error != nil && apiErr.Error.Message != "" </span><span class="cov3" title="3">{ logging.Logf(logPrefix, "%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("%s error: %s (status %d)", provider, apiErr.Error.Message, resp.StatusCode) }</span> - <span class="cov2" title="2">logging.Logf(logPrefix, "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + <span class="cov4" title="6">logging.Logf(logPrefix, "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) return fmt.Errorf("%s http error: status %d", provider, resp.StatusCode)</span> } -func decodeOpenAIChat(resp *http.Response, start time.Time, logPrefix string) (oaChatResponse, error) <span class="cov7" title="11">{ +func decodeOpenAIChat(resp *http.Response, start time.Time, logPrefix string) (oaChatResponse, error) <span class="cov7" title="33">{ var out oaChatResponse - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov1" title="1">{ + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov3" title="3">{ logging.Logf(logPrefix, "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return oaChatResponse{}, err }</span> - <span class="cov7" title="10">return out, nil</span> + <span class="cov7" title="30">return out, nil</span> } -func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string), logPrefix, provider string) error <span class="cov5" title="6">{ +func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string), logPrefix, provider string) error <span class="cov6" title="18">{ // Parse SSE: lines starting with "data: " containing JSON or [DONE] scanner := bufio.NewScanner(resp.Body) const maxBuf = 1024 * 1024 buf := make([]byte, 0, 64*1024) scanner.Buffer(buf, maxBuf) - for scanner.Scan() </span><span class="cov7" title="14">{ + for scanner.Scan() </span><span class="cov8" title="42">{ line := scanner.Text() - if !strings.HasPrefix(line, "data: ") </span><span class="cov3" title="3">{ + if !strings.HasPrefix(line, "data: ") </span><span class="cov5" title="9">{ continue</span> } - <span class="cov7" title="11">payload := strings.TrimPrefix(line, "data: ") - if strings.TrimSpace(payload) == "[DONE]" </span><span class="cov4" title="4">{ + <span class="cov7" title="33">payload := strings.TrimPrefix(line, "data: ") + if strings.TrimSpace(payload) == "[DONE]" </span><span class="cov5" title="12">{ break</span> } - <span class="cov6" title="7">var chunk oaStreamChunk - if err := json.Unmarshal([]byte(payload), &chunk); err != nil </span><span class="cov2" title="2">{ + <span class="cov7" title="21">var chunk oaStreamChunk + if err := json.Unmarshal([]byte(payload), &chunk); err != nil </span><span class="cov4" title="6">{ continue</span> } - <span class="cov5" title="5">if chunk.Error != nil && chunk.Error.Message != "" </span><span class="cov1" title="1">{ + <span class="cov6" title="15">if chunk.Error != nil && chunk.Error.Message != "" </span><span class="cov3" title="3">{ logging.Logf(logPrefix, "%sstream error: %s%s", logging.AnsiRed, chunk.Error.Message, logging.AnsiBase) return fmt.Errorf("%s stream error: %s", provider, chunk.Error.Message) }</span> - <span class="cov4" title="4">for _, ch := range chunk.Choices </span><span class="cov4" title="4">{ - if ch.Delta.Content != "" </span><span class="cov3" title="3">{ + <span class="cov5" title="12">for _, ch := range chunk.Choices </span><span class="cov5" title="12">{ + if ch.Delta.Content != "" </span><span class="cov5" title="9">{ onDelta(ch.Delta.Content) }</span> } } - <span class="cov5" title="5">if err := scanner.Err(); err != nil </span><span class="cov0" title="0">{ + <span class="cov6" title="15">if err := scanner.Err(); err != nil </span><span class="cov0" title="0">{ logging.Logf(logPrefix, "%sstream read error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return err }</span> - <span class="cov5" title="5">return nil</span> + <span class="cov6" title="15">return nil</span> } </pre> - <pre class="file" id="file17" style="display: none">// Summary: OpenRouter client implementation leveraging OpenAI-compatible helpers with provider-specific headers. + <pre class="file" id="file18" style="display: none">// Summary: OpenRouter client implementation leveraging OpenAI-compatible helpers with provider-specific headers. package llm import ( @@ -4545,15 +5026,22 @@ type openRouterClient struct { defaultTemperature *float64 } -func newOpenRouter(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov7" title="4">{ - if strings.TrimSpace(baseURL) == "" </span><span class="cov1" title="1">{ +func newOpenRouter(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov8" title="12">{ + return newOpenRouterWithTimeout(baseURL, model, apiKey, defaultTemp, 0) +}</span> + +func newOpenRouterWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client <span class="cov8" title="12">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov4" title="3">{ baseURL = "https://openrouter.ai/api/v1" }</span> - <span class="cov7" title="4">if strings.TrimSpace(model) == "" </span><span class="cov1" title="1">{ + <span class="cov8" title="12">if strings.TrimSpace(model) == "" </span><span class="cov4" title="3">{ model = "openrouter/auto" }</span> - <span class="cov7" title="4">return openRouterClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, + <span class="cov8" title="12">if timeoutSec <= 0 </span><span class="cov8" title="12">{ + timeoutSec = 30 + }</span> + <span class="cov8" title="12">return openRouterClient{ + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, apiKey: apiKey, baseURL: baseURL, defaultModel: model, @@ -4562,18 +5050,18 @@ func newOpenRouter(baseURL, model, apiKey string, defaultTemp *float64) Client < }</span> } -func (c openRouterClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov4" title="2">{ - if strings.TrimSpace(c.apiKey) == "" </span><span class="cov1" title="1">{ +func (c openRouterClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov6" title="6">{ + if strings.TrimSpace(c.apiKey) == "" </span><span class="cov4" title="3">{ return nilStringErr("missing OpenRouter API key") }</span> - <span class="cov1" title="1">o := Options{Model: c.defaultModel} + <span class="cov4" title="3">o := Options{Model: c.defaultModel} for _, opt := range opts </span><span class="cov0" title="0">{ opt(&o) }</span> - <span class="cov1" title="1">if strings.TrimSpace(o.Model) == "" </span><span class="cov0" title="0">{ + <span class="cov4" title="3">if strings.TrimSpace(o.Model) == "" </span><span class="cov0" title="0">{ o.Model = c.defaultModel }</span> - <span class="cov1" title="1">start := time.Now() + <span class="cov4" title="3">start := time.Now() c.logStart(false, o, messages) req := buildOAChatRequest(o, messages, c.defaultTemperature, false, "llm/openrouter ") body, err := json.Marshal(req) @@ -4581,45 +5069,49 @@ func (c openRouterClient) Chat(ctx context.Context, messages []Message, opts ... c.logf("marshal error: %v", err) return "", err }</span> - <span class="cov1" title="1">endpoint := strings.TrimRight(c.baseURL, "/") + "/chat/completions" + <span class="cov4" title="3">endpoint := strings.TrimRight(c.baseURL, "/") + "/chat/completions" logging.Logf("llm/openrouter ", "POST %s", endpoint) resp, err := c.doJSON(ctx, endpoint, body) if err != nil </span><span class="cov0" title="0">{ logging.Logf("llm/openrouter ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return "", err }</span> - <span class="cov1" title="1">defer resp.Body.Close() - if err := handleOpenAINon2xx(resp, start, "llm/openrouter ", "openrouter"); err != nil </span><span class="cov0" title="0">{ + <span class="cov4" title="3">defer func() </span><span class="cov4" title="3">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openrouter", "failed to close response body: %v", err) + }</span> + }() + <span class="cov4" title="3">if err := handleOpenAINon2xx(resp, start, "llm/openrouter ", "openrouter"); err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov1" title="1">out, err := decodeOpenAIChat(resp, start, "llm/openrouter ") + <span class="cov4" title="3">out, err := decodeOpenAIChat(resp, start, "llm/openrouter ") if err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov1" title="1">if len(out.Choices) == 0 </span><span class="cov0" title="0">{ + <span class="cov4" title="3">if len(out.Choices) == 0 </span><span class="cov0" title="0">{ logging.Logf("llm/openrouter ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) return "", errors.New("openrouter: no choices returned") }</span> - <span class="cov1" title="1">content := out.Choices[0].Message.Content + <span class="cov4" title="3">content := out.Choices[0].Message.Content logging.Logf("llm/openrouter ", "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> } -func (c openRouterClient) Name() string <span class="cov1" title="1">{ return "openrouter" }</span> -func (c openRouterClient) DefaultModel() string <span class="cov1" title="1">{ return c.defaultModel }</span> +func (c openRouterClient) Name() string <span class="cov4" title="3">{ return "openrouter" }</span> +func (c openRouterClient) DefaultModel() string <span class="cov4" title="3">{ return c.defaultModel }</span> -func (c openRouterClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov1" title="1">{ +func (c openRouterClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov4" title="3">{ if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ return errors.New("missing OpenRouter API key") }</span> - <span class="cov1" title="1">o := Options{Model: c.defaultModel} + <span class="cov4" title="3">o := Options{Model: c.defaultModel} for _, opt := range opts </span><span class="cov0" title="0">{ opt(&o) }</span> - <span class="cov1" title="1">if strings.TrimSpace(o.Model) == "" </span><span class="cov0" title="0">{ + <span class="cov4" title="3">if strings.TrimSpace(o.Model) == "" </span><span class="cov0" title="0">{ o.Model = c.defaultModel }</span> - <span class="cov1" title="1">start := time.Now() + <span class="cov4" title="3">start := time.Now() c.logStart(true, o, messages) req := buildOAChatRequest(o, messages, c.defaultTemperature, true, "llm/openrouter ") body, err := json.Marshal(req) @@ -4627,37 +5119,41 @@ func (c openRouterClient) ChatStream(ctx context.Context, messages []Message, on c.logf("marshal error: %v", err) return err }</span> - <span class="cov1" title="1">endpoint := strings.TrimRight(c.baseURL, "/") + "/chat/completions" + <span class="cov4" title="3">endpoint := strings.TrimRight(c.baseURL, "/") + "/chat/completions" logging.Logf("llm/openrouter ", "POST %s (stream)", endpoint) resp, err := c.doJSONWithAccept(ctx, endpoint, body, "text/event-stream") if err != nil </span><span class="cov0" title="0">{ logging.Logf("llm/openrouter ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return err }</span> - <span class="cov1" title="1">defer resp.Body.Close() - if err := handleOpenAINon2xx(resp, start, "llm/openrouter ", "openrouter"); err != nil </span><span class="cov0" title="0">{ + <span class="cov4" title="3">defer func() </span><span class="cov4" title="3">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openrouter", "failed to close response body: %v", err) + }</span> + }() + <span class="cov4" title="3">if err := handleOpenAINon2xx(resp, start, "llm/openrouter ", "openrouter"); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov1" title="1">if err := parseOpenAIStream(resp, start, onDelta, "llm/openrouter ", "openrouter"); err != nil </span><span class="cov0" title="0">{ + <span class="cov4" title="3">if err := parseOpenAIStream(resp, start, onDelta, "llm/openrouter ", "openrouter"); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov1" title="1">logging.Logf("llm/openrouter ", "stream end duration=%s", time.Since(start)) + <span class="cov4" title="3">logging.Logf("llm/openrouter ", "stream end duration=%s", time.Since(start)) return nil</span> } -func (c openRouterClient) logf(format string, args ...any) <span class="cov1" title="1">{ +func (c openRouterClient) logf(format string, args ...any) <span class="cov4" title="3">{ logging.Logf("llm/openrouter ", format, args...) }</span> -func (c openRouterClient) logStart(stream bool, o Options, messages []Message) <span class="cov4" title="2">{ +func (c openRouterClient) logStart(stream bool, o Options, messages []Message) <span class="cov6" title="6">{ logMessages := make([]struct{ Role, Content string }, len(messages)) - for i, m := range messages </span><span class="cov4" title="2">{ + for i, m := range messages </span><span class="cov6" title="6">{ logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} }</span> - <span class="cov4" title="2">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> + <span class="cov6" title="6">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> } -func (c openRouterClient) doJSON(ctx context.Context, url string, body []byte) (*http.Response, error) <span class="cov1" title="1">{ +func (c openRouterClient) doJSON(ctx context.Context, url string, body []byte) (*http.Response, error) <span class="cov4" title="3">{ headers := map[string]string{ "Authorization": "Bearer " + c.apiKey, "HTTP-Referer": "https://github.com/snonux/hexai", @@ -4666,7 +5162,7 @@ func (c openRouterClient) doJSON(ctx context.Context, url string, body []byte) ( return c.doJSONWithHeaders(ctx, url, body, headers, "") }</span> -func (c openRouterClient) doJSONWithAccept(ctx context.Context, url string, body []byte, accept string) (*http.Response, error) <span class="cov1" title="1">{ +func (c openRouterClient) doJSONWithAccept(ctx context.Context, url string, body []byte, accept string) (*http.Response, error) <span class="cov4" title="3">{ headers := map[string]string{ "Authorization": "Bearer " + c.apiKey, "HTTP-Referer": "https://github.com/snonux/hexai", @@ -4675,23 +5171,23 @@ func (c openRouterClient) doJSONWithAccept(ctx context.Context, url string, body return c.doJSONWithHeaders(ctx, url, body, headers, accept) }</span> -func (c openRouterClient) doJSONWithHeaders(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) <span class="cov4" title="2">{ +func (c openRouterClient) doJSONWithHeaders(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) <span class="cov6" title="6">{ 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="cov4" title="2">req.Header.Set("Content-Type", "application/json") - if strings.TrimSpace(accept) != "" </span><span class="cov1" title="1">{ + <span class="cov6" title="6">req.Header.Set("Content-Type", "application/json") + if strings.TrimSpace(accept) != "" </span><span class="cov4" title="3">{ req.Header.Set("Accept", accept) }</span> - <span class="cov4" title="2">for k, v := range headers </span><span class="cov10" title="6">{ + <span class="cov6" title="6">for k, v := range headers </span><span class="cov10" title="18">{ req.Header.Set(k, v) }</span> - <span class="cov4" title="2">return c.httpClient.Do(req)</span> + <span class="cov6" title="6">return c.httpClient.Do(req)</span> } </pre> - <pre class="file" id="file18" style="display: none">// Summary: LLM provider interfaces, request options, configuration, and factory to build a client from config. + <pre class="file" id="file19" style="display: none">// Summary: LLM provider interfaces, request options, configuration, and factory to build a client from config. package llm import ( @@ -4748,16 +5244,17 @@ type Options struct { // RequestOption mutates Options. type RequestOption func(*Options) -func WithModel(model string) RequestOption <span class="cov5" title="8">{ return func(o *Options) </span><span class="cov4" title="6">{ o.Model = model }</span> } -func WithTemperature(t float64) RequestOption <span class="cov8" title="27">{ return func(o *Options) </span><span class="cov5" title="7">{ o.Temperature = t }</span> } -func WithMaxTokens(n int) RequestOption <span class="cov10" title="67">{ return func(o *Options) </span><span class="cov5" title="10">{ 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> +func WithModel(model string) RequestOption <span class="cov6" title="24">{ return func(o *Options) </span><span class="cov5" title="18">{ o.Model = model }</span> } +func WithTemperature(t float64) RequestOption <span class="cov8" title="84">{ return func(o *Options) </span><span class="cov6" title="24">{ o.Temperature = t }</span> } +func WithMaxTokens(n int) RequestOption <span class="cov10" title="201">{ return func(o *Options) </span><span class="cov6" title="30">{ o.MaxTokens = n }</span> } +func WithStop(stop ...string) RequestOption <span class="cov2" title="3">{ + return func(o *Options) </span><span class="cov2" title="3">{ o.Stop = append([]string{}, stop...) }</span> } // Config defines provider configuration read from the Hexai config file. type Config struct { - Provider string + Provider string + RequestTimeout int // seconds; 0 means use default (30s) // OpenAI options OpenAIBaseURL string OpenAIModel string @@ -4774,19 +5271,23 @@ type Config struct { CopilotBaseURL string CopilotModel string CopilotTemperature *float64 + // Anthropic options + AnthropicBaseURL string + AnthropicModel string + AnthropicTemperature *float64 } // 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, openRouterAPIKey, copilotAPIKey string) (Client, error) <span class="cov8" title="28">{ +func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, copilotAPIKey, anthropicAPIKey string) (Client, error) <span class="cov8" title="84">{ p := strings.ToLower(strings.TrimSpace(cfg.Provider)) - if p == "" </span><span class="cov5" title="8">{ + if p == "" </span><span class="cov6" title="24">{ p = "openai" }</span> - <span class="cov8" title="28">switch p </span>{ - case "openai":<span class="cov7" title="21"> - if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov4" title="5">{ + <span class="cov8" title="84">switch p </span>{ + case "openai":<span class="cov8" title="63"> + if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov5" title="15">{ return nil, errors.New("missing OPENAI_API_KEY for provider openai") }</span> // Default temperature selection: @@ -4795,20 +5296,20 @@ func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, copilotAPIKey str // 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="16">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">{ + <span class="cov7" title="48">model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel)) + if strings.HasPrefix(model, "gpt-5") </span><span class="cov4" title="6">{ + if cfg.OpenAITemperature == nil </span><span class="cov2" title="3">{ v := 1.0 cfg.OpenAITemperature = &v - }</span> else<span class="cov1" title="1"> if *cfg.OpenAITemperature == 0.2 </span><span class="cov1" title="1">{ + }</span> else<span class="cov2" title="3"> if *cfg.OpenAITemperature == 0.2 </span><span class="cov2" title="3">{ v := 1.0 cfg.OpenAITemperature = &v }</span> - } else<span class="cov6" title="14"> if cfg.OpenAITemperature == nil </span><span class="cov6" title="11">{ + } else<span class="cov7" title="42"> if cfg.OpenAITemperature == nil </span><span class="cov6" title="33">{ v := 0.2 cfg.OpenAITemperature = &v }</span> - <span class="cov6" title="16">return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil</span> + <span class="cov7" title="48">return newOpenAIWithTimeout(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature, cfg.RequestTimeout), nil</span> case "openrouter":<span class="cov0" title="0"> if strings.TrimSpace(openRouterAPIKey) == "" </span><span class="cov0" title="0">{ return nil, errors.New("missing OPENROUTER_API_KEY for provider openrouter") @@ -4817,37 +5318,46 @@ func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, copilotAPIKey str t := 0.2 cfg.OpenRouterTemperature = &t }</span> - <span class="cov0" title="0">return newOpenRouter(cfg.OpenRouterBaseURL, cfg.OpenRouterModel, openRouterAPIKey, cfg.OpenRouterTemperature), nil</span> - case "ollama":<span class="cov3" title="3"> - if cfg.OllamaTemperature == nil </span><span class="cov2" title="2">{ + <span class="cov0" title="0">return newOpenRouterWithTimeout(cfg.OpenRouterBaseURL, cfg.OpenRouterModel, openRouterAPIKey, cfg.OpenRouterTemperature, cfg.RequestTimeout), nil</span> + case "ollama":<span class="cov4" title="9"> + if cfg.OllamaTemperature == nil </span><span class="cov4" title="6">{ t := 0.2 cfg.OllamaTemperature = &t }</span> - <span class="cov3" title="3">return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature), nil</span> - case "copilot":<span class="cov3" title="3"> - if strings.TrimSpace(copilotAPIKey) == "" </span><span class="cov1" title="1">{ + <span class="cov4" title="9">return newOllamaWithTimeout(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature, cfg.RequestTimeout), nil</span> + case "copilot":<span class="cov4" title="9"> + if strings.TrimSpace(copilotAPIKey) == "" </span><span class="cov2" title="3">{ return nil, errors.New("missing COPILOT_API_KEY for provider copilot") }</span> - <span class="cov2" title="2">if cfg.CopilotTemperature == nil </span><span class="cov2" title="2">{ + <span class="cov4" title="6">if cfg.CopilotTemperature == nil </span><span class="cov4" title="6">{ t := 0.2 cfg.CopilotTemperature = &t }</span> - <span class="cov2" title="2">return newCopilot(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature), nil</span> - default:<span class="cov1" title="1"> + <span class="cov4" title="6">return newCopilotWithTimeout(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature, cfg.RequestTimeout), nil</span> + case "anthropic":<span class="cov0" title="0"> + if strings.TrimSpace(anthropicAPIKey) == "" </span><span class="cov0" title="0">{ + return nil, errors.New("missing ANTHROPIC_API_KEY for provider anthropic") + }</span> + <span class="cov0" title="0">if cfg.AnthropicTemperature == nil </span><span class="cov0" title="0">{ + t := 0.2 + cfg.AnthropicTemperature = &t + }</span> + <span class="cov0" title="0">return newAnthropicWithTimeout(cfg.AnthropicBaseURL, cfg.AnthropicModel, anthropicAPIKey, cfg.AnthropicTemperature, cfg.RequestTimeout), nil</span> + default:<span class="cov2" title="3"> return nil, errors.New("unknown LLM provider: " + p)</span> } } </pre> - <pre class="file" id="file19" style="display: none">package llm + <pre class="file" id="file20" style="display: none">package llm import "errors" // small helper to keep return type consistent -func nilStringErr(msg string) (string, error) <span class="cov10" title="3">{ return "", errors.New(msg) }</span> +func nilStringErr(msg string) (string, error) <span class="cov10" title="12">{ return "", errors.New(msg) }</span> </pre> - <pre class="file" id="file20" style="display: none">package llmutils + <pre class="file" id="file21" style="display: none">package llmutils import ( "os" @@ -4858,9 +5368,10 @@ import ( ) // NewClientFromApp builds an llm.Client using app config and environment keys. -func NewClientFromApp(cfg appconfig.App) (llm.Client, error) <span class="cov10" title="6">{ +func NewClientFromApp(cfg appconfig.App) (llm.Client, error) <span class="cov10" title="18">{ llmCfg := llm.Config{ Provider: cfg.Provider, + RequestTimeout: cfg.RequestTimeout, OpenAIBaseURL: cfg.OpenAIBaseURL, OpenAIModel: cfg.OpenAIModel, OpenAITemperature: cfg.OpenAITemperature, @@ -4873,24 +5384,31 @@ func NewClientFromApp(cfg appconfig.App) (llm.Client, error) <span class="cov10" CopilotBaseURL: cfg.CopilotBaseURL, CopilotModel: cfg.CopilotModel, CopilotTemperature: cfg.CopilotTemperature, + AnthropicBaseURL: cfg.AnthropicBaseURL, + AnthropicModel: cfg.AnthropicModel, + AnthropicTemperature: cfg.AnthropicTemperature, } oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") - if strings.TrimSpace(oaKey) == "" </span><span class="cov9" title="5">{ + if strings.TrimSpace(oaKey) == "" </span><span class="cov9" title="15">{ oaKey = os.Getenv("OPENAI_API_KEY") }</span> - <span class="cov10" title="6">orKey := os.Getenv("HEXAI_OPENROUTER_API_KEY") - if strings.TrimSpace(orKey) == "" </span><span class="cov10" title="6">{ + <span class="cov10" title="18">orKey := os.Getenv("HEXAI_OPENROUTER_API_KEY") + if strings.TrimSpace(orKey) == "" </span><span class="cov10" title="18">{ orKey = os.Getenv("OPENROUTER_API_KEY") }</span> - <span class="cov10" title="6">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") - if strings.TrimSpace(cpKey) == "" </span><span class="cov10" title="6">{ + <span class="cov10" title="18">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") + if strings.TrimSpace(cpKey) == "" </span><span class="cov10" title="18">{ cpKey = os.Getenv("COPILOT_API_KEY") }</span> - <span class="cov10" title="6">return llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey)</span> + <span class="cov10" title="18">anKey := os.Getenv("HEXAI_ANTHROPIC_API_KEY") + if strings.TrimSpace(anKey) == "" </span><span class="cov10" title="18">{ + anKey = os.Getenv("ANTHROPIC_API_KEY") + }</span> + <span class="cov10" title="18">return llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey, anKey)</span> } </pre> - <pre class="file" id="file21" style="display: none">package logging + <pre class="file" id="file22" style="display: none">package logging // ChatLogger provides a structured way to log chat interactions. type ChatLogger struct { @@ -4898,7 +5416,7 @@ type ChatLogger struct { } // NewChatLogger creates a new ChatLogger for a given provider. -func NewChatLogger(provider string) ChatLogger <span class="cov10" title="56">{ +func NewChatLogger(provider string) ChatLogger <span class="cov10" title="201">{ return ChatLogger{Provider: provider} }</span> @@ -4907,21 +5425,21 @@ func (cl ChatLogger) LogStart(stream bool, model string, temp float64, maxTokens Role string Content string }, -) <span class="cov8" title="34">{ +) <span class="cov9" title="117">{ chatOrStream := "chat" - if stream </span><span class="cov6" title="10">{ + if stream </span><span class="cov6" title="33">{ chatOrStream = "stream" }</span> - <span class="cov8" title="34">Logf("llm/"+cl.Provider+" ", "%s start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", + <span class="cov9" title="117">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="cov9" title="39">{ + for i, m := range messages </span><span class="cov9" title="132">{ 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> } </pre> - <pre class="file" id="file22" style="display: none">// Summary: ANSI-styled logging utilities with a bound standard logger and configurable preview truncation. + <pre class="file" id="file23" style="display: none">// Summary: ANSI-styled logging utilities with a bound standard logger and configurable preview truncation. package logging import ( @@ -4947,14 +5465,14 @@ const AnsiBase = AnsiBgBlack + AnsiGrey var std *log.Logger // Bind sets the underlying standard logger to use for Logf. -func Bind(l *log.Logger) <span class="cov3" title="4">{ std = l }</span> +func Bind(l *log.Logger) <span class="cov4" title="12">{ 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="227">{ - if std == nil </span><span class="cov9" title="161">{ +func Logf(prefix, format string, args ...any) <span class="cov10" title="741">{ + if std == nil </span><span class="cov9" title="543">{ return }</span> - <span class="cov7" title="66">msg := fmt.Sprintf(format, args...) + <span class="cov8" title="198">msg := fmt.Sprintf(format, args...) std.Print(AnsiBase + prefix + msg + AnsiReset)</span> } @@ -4963,21 +5481,21 @@ 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="11">{ logPreviewLimit = n }</span> +func SetLogPreviewLimit(n int) <span class="cov5" title="33">{ logPreviewLimit = n }</span> // PreviewForLog returns the string truncated to the configured preview limit. -func PreviewForLog(s string) string <span class="cov7" title="54">{ - if logPreviewLimit > 0 </span><span class="cov2" title="3">{ +func PreviewForLog(s string) string <span class="cov8" title="183">{ + if logPreviewLimit > 0 </span><span class="cov3" title="9">{ if len(s) <= logPreviewLimit </span><span class="cov0" title="0">{ return s }</span> - <span class="cov2" title="3">return s[:logPreviewLimit] + "…"</span> + <span class="cov3" title="9">return s[:logPreviewLimit] + "…"</span> } - <span class="cov7" title="51">return s</span> + <span class="cov8" title="174">return s</span> } </pre> - <pre class="file" id="file23" style="display: none">package lsp + <pre class="file" id="file24" style="display: none">package lsp import ( "fmt" @@ -4990,27 +5508,27 @@ type chatCommandResult struct { message string } -func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) <span class="cov10" title="13">{ +func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) <span class="cov10" title="39">{ trimmed := strings.TrimSpace(s.stripTrailingTrigger(prompt)) - if trimmed == "" || !strings.HasPrefix(trimmed, "/") </span><span class="cov8" title="8">{ + if trimmed == "" || !strings.HasPrefix(trimmed, "/") </span><span class="cov8" title="24">{ return chatCommandResult{}, false }</span> - <span class="cov6" title="5">switch </span>{ - case strings.HasPrefix(trimmed, "/reload"):<span class="cov1" title="1"> + <span class="cov7" title="15">switch </span>{ + case strings.HasPrefix(trimmed, "/reload"):<span class="cov3" title="3"> return s.handleReloadCommand(), true</span> case strings.HasPrefix(trimmed, "/help"):<span class="cov0" title="0"> return s.handleHelpCommand(), true</span> - case strings.HasPrefix(trimmed, "/disable"):<span class="cov3" title="2"> + case strings.HasPrefix(trimmed, "/disable"):<span class="cov5" title="6"> return s.handleDisableCompletionCommand(), true</span> - case strings.HasPrefix(trimmed, "/enable"):<span class="cov3" title="2"> + case strings.HasPrefix(trimmed, "/enable"):<span class="cov5" title="6"> return s.handleEnableCompletionCommand(), 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">{ +func (s *Server) handleHelpCommand() chatCommandResult <span class="cov3" title="3">{ lines := []string{ "Available slash commands:", "- /reload?> reload configuration from file (ignores env overrides)", @@ -5020,40 +5538,40 @@ func (s *Server) handleHelpCommand() chatCommandResult <span class="cov1" title= return chatCommandResult{message: strings.Join(lines, "\n")} }</span> -func (s *Server) handleReloadCommand() chatCommandResult <span class="cov3" title="2">{ +func (s *Server) handleReloadCommand() chatCommandResult <span class="cov5" title="6">{ if s.configStore == nil </span><span class="cov0" title="0">{ return chatCommandResult{message: "Reload unavailable: no config store"} }</span> - <span class="cov3" title="2">loadOpts := s.configLoadOpts + <span class="cov5" title="6">loadOpts := s.configLoadOpts loadOpts.IgnoreEnv = true changes, err := s.configStore.Reload(s.logger, loadOpts) 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="cov3" title="2">summary := runtimeconfig.FormatSummary("Reloaded config", changes) + <span class="cov5" title="6">summary := runtimeconfig.FormatSummary("Reloaded config", changes) s.logger.Print(summary) return chatCommandResult{message: summary}</span> } -func (s *Server) handleDisableCompletionCommand() chatCommandResult <span class="cov3" title="2">{ +func (s *Server) handleDisableCompletionCommand() chatCommandResult <span class="cov5" title="6">{ prev := s.setCompletionsDisabled(true) - if prev </span><span class="cov1" title="1">{ + if prev </span><span class="cov3" title="3">{ return chatCommandResult{message: "Auto-completions were already disabled."} }</span> - <span class="cov1" title="1">return chatCommandResult{message: "Auto-completions disabled. Use /enable?> to restore."}</span> + <span class="cov3" title="3">return chatCommandResult{message: "Auto-completions disabled. Use /enable?> to restore."}</span> } -func (s *Server) handleEnableCompletionCommand() chatCommandResult <span class="cov3" title="2">{ +func (s *Server) handleEnableCompletionCommand() chatCommandResult <span class="cov5" title="6">{ prev := s.setCompletionsDisabled(false) - if !prev </span><span class="cov1" title="1">{ + if !prev </span><span class="cov3" title="3">{ return chatCommandResult{message: "Auto-completions are already enabled."} }</span> - <span class="cov1" title="1">return chatCommandResult{message: "Auto-completions enabled."}</span> + <span class="cov3" title="3">return chatCommandResult{message: "Auto-completions enabled."}</span> } </pre> - <pre class="file" id="file24" style="display: none">// Summary: Builds additional context snippets based on configured mode and truncates text by token heuristic. + <pre class="file" id="file25" style="display: none">// Summary: Builds additional context snippets based on configured mode and truncates text by token heuristic. package lsp import ( @@ -5068,19 +5586,19 @@ import ( // - window: include a window of lines around the cursor // - file-on-new-func: include full file only when defining a new function // - always-full: always include the full file -func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) <span class="cov10" title="14">{ +func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) <span class="cov10" title="42">{ mode := s.contextMode() switch mode </span>{ - case "minimal":<span class="cov3" title="2"> + case "minimal":<span class="cov5" title="6"> return "", false</span> - case "window":<span class="cov1" title="1"> + case "window":<span class="cov3" title="3"> return s.windowContext(uri, pos), true</span> - case "file-on-new-func":<span class="cov8" title="9"> - if newFunc </span><span class="cov3" title="2">{ + case "file-on-new-func":<span class="cov8" title="27"> + if newFunc </span><span class="cov5" title="6">{ return s.fullFileContext(uri), true }</span> - <span class="cov7" title="7">return "", false</span> - case "always-full":<span class="cov3" title="2"> + <span class="cov8" title="21">return "", false</span> + case "always-full":<span class="cov5" title="6"> return s.fullFileContext(uri), true</span> default:<span class="cov0" title="0"> // fallback to minimal if unknown @@ -5088,58 +5606,58 @@ func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) } } -func (s *Server) windowContext(uri string, pos Position) string <span class="cov3" title="2">{ +func (s *Server) windowContext(uri string, pos Position) string <span class="cov5" title="6">{ d := s.getDocument(uri) if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ logging.Logf("lsp ", "context: window requested but document not open; skipping uri=%s", uri) return "" }</span> - <span class="cov3" title="2">n := len(d.lines) + <span class="cov5" title="6">n := len(d.lines) half := s.windowLines() / 2 start := pos.Line - half if start < 0 </span><span class="cov0" title="0">{ start = 0 }</span> - <span class="cov3" title="2">end := pos.Line + half + 1 + <span class="cov5" title="6">end := pos.Line + half + 1 if end > n </span><span class="cov0" title="0">{ end = n }</span> - <span class="cov3" title="2">text := strings.Join(d.lines[start:end], "\n") + <span class="cov5" title="6">text := strings.Join(d.lines[start:end], "\n") return truncateToApproxTokens(text, s.maxContextTokens())</span> } -func (s *Server) fullFileContext(uri string) string <span class="cov5" title="4">{ +func (s *Server) fullFileContext(uri string) string <span class="cov6" title="12">{ d := s.getDocument(uri) if d == nil </span><span class="cov0" title="0">{ 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="cov6" title="12">return truncateToApproxTokens(d.text, s.maxContextTokens())</span> } // truncateToApproxTokens naively truncates the input to fit approx N tokens. // Uses 4 chars/token heuristic for speed and determinism. -func truncateToApproxTokens(text string, maxTokens int) string <span class="cov7" title="7">{ +func truncateToApproxTokens(text string, maxTokens int) string <span class="cov8" title="21">{ if maxTokens <= 0 </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov7" title="7">maxChars := maxTokens * 4 - if len(text) <= maxChars </span><span class="cov7" title="6">{ + <span class="cov8" title="21">maxChars := maxTokens * 4 + if len(text) <= maxChars </span><span class="cov7" title="18">{ return text }</span> // try to cut on a line boundary near maxChars - <span class="cov1" title="1">cut := maxChars + <span class="cov3" title="3">cut := maxChars if cut > len(text) </span><span class="cov0" title="0">{ cut = len(text) }</span> - <span class="cov1" title="1">if i := strings.LastIndex(text[:cut], "\n"); i > 0 </span><span class="cov0" title="0">{ + <span class="cov3" title="3">if i := strings.LastIndex(text[:cut], "\n"); i > 0 </span><span class="cov0" title="0">{ cut = i }</span> - <span class="cov1" title="1">return text[:cut]</span> + <span class="cov3" title="3">return text[:cut]</span> } </pre> - <pre class="file" id="file25" style="display: none">// Summary: In-memory document model for the LSP; tracks text, lines, and applies edits. + <pre class="file" id="file26" style="display: none">// Summary: In-memory document model for the LSP; tracks text, lines, and applies edits. package lsp import ( @@ -5153,140 +5671,140 @@ type document struct { lines []string } -func (s *Server) setDocument(uri, text string) <span class="cov8" title="41">{ +func (s *Server) setDocument(uri, text string) <span class="cov8" title="123">{ s.mu.Lock() defer s.mu.Unlock() s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)} }</span> -func (s *Server) deleteDocument(uri string) <span class="cov1" title="1">{ +func (s *Server) deleteDocument(uri string) <span class="cov2" title="3">{ s.mu.Lock() defer s.mu.Unlock() delete(s.docs, uri) }</span> -func (s *Server) markActivity() <span class="cov3" title="4">{ +func (s *Server) markActivity() <span class="cov4" title="12">{ s.mu.Lock() s.lastInput = time.Now() s.mu.Unlock() }</span> -func (s *Server) getDocument(uri string) *document <span class="cov10" title="90">{ +func (s *Server) getDocument(uri string) *document <span class="cov10" title="270">{ 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="cov8" title="53">{ +func splitLines(sx string) []string <span class="cov9" title="159">{ sx = strings.ReplaceAll(sx, "\r\n", "\n") return strings.Split(sx, "\n") }</span> -func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) <span class="cov5" title="8">{ +func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) <span class="cov6" title="24">{ d := s.getDocument(uri) - if d == nil || len(d.lines) == 0 </span><span class="cov1" title="1">{ + if d == nil || len(d.lines) == 0 </span><span class="cov2" title="3">{ return "", "", "", "" }</span> - <span class="cov4" title="7">idx := pos.Line + <span class="cov5" title="21">idx := pos.Line if idx < 0 </span><span class="cov0" title="0">{ idx = 0 }</span> - <span class="cov4" title="7">if idx >= len(d.lines) </span><span class="cov0" title="0">{ + <span class="cov5" title="21">if idx >= len(d.lines) </span><span class="cov0" title="0">{ idx = len(d.lines) - 1 }</span> - <span class="cov4" title="7">current = d.lines[idx] - if idx-1 >= 0 </span><span class="cov4" title="6">{ + <span class="cov5" title="21">current = d.lines[idx] + if idx-1 >= 0 </span><span class="cov5" title="18">{ above = d.lines[idx-1] }</span> - <span class="cov4" title="7">if idx+1 < len(d.lines) </span><span class="cov4" title="6">{ + <span class="cov5" title="21">if idx+1 < len(d.lines) </span><span class="cov5" title="18">{ below = d.lines[idx+1] }</span> - <span class="cov4" title="7">for i := idx; i >= 0; i-- </span><span class="cov5" title="9">{ + <span class="cov5" title="21">for i := idx; i >= 0; i-- </span><span class="cov6" title="27">{ line := strings.TrimSpace(d.lines[i]) - if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) </span><span class="cov4" title="6">{ + if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) </span><span class="cov5" title="18">{ funcCtx = line break</span> } } - <span class="cov4" title="7">return above, current, below, funcCtx</span> + <span class="cov5" title="21">return above, current, below, funcCtx</span> } // isDefiningNewFunction returns true when the cursor appears to be within // a function declaration/signature and before the opening '{' of the body. // Heuristic: find nearest preceding line containing "func "; ensure no '{' // appears before the cursor across those lines. -func (s *Server) isDefiningNewFunction(uri string, pos Position) bool <span class="cov5" title="12">{ +func (s *Server) isDefiningNewFunction(uri string, pos Position) bool <span class="cov6" title="36">{ d := s.getDocument(uri) if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ return false }</span> - <span class="cov5" title="12">idx := pos.Line + <span class="cov6" title="36">idx := pos.Line if idx < 0 </span><span class="cov0" title="0">{ idx = 0 }</span> - <span class="cov5" title="12">if idx >= len(d.lines) </span><span class="cov0" title="0">{ + <span class="cov6" title="36">if idx >= len(d.lines) </span><span class="cov0" title="0">{ idx = len(d.lines) - 1 }</span> // Find signature start - <span class="cov5" title="12">sigStart := -1 - for i := idx; i >= 0; i-- </span><span class="cov7" title="21">{ - if strings.Contains(d.lines[i], "func ") </span><span class="cov3" title="4">{ + <span class="cov6" title="36">sigStart := -1 + for i := idx; i >= 0; i-- </span><span class="cov7" title="63">{ + if strings.Contains(d.lines[i], "func ") </span><span class="cov4" title="12">{ sigStart = i break</span> } // stop if we hit a closing brace which likely ends a previous block - <span class="cov6" title="17">if strings.Contains(d.lines[i], "}") </span><span class="cov0" title="0">{ + <span class="cov7" title="51">if strings.Contains(d.lines[i], "}") </span><span class="cov0" title="0">{ break</span> } } - <span class="cov5" title="12">if sigStart == -1 </span><span class="cov5" title="8">{ + <span class="cov6" title="36">if sigStart == -1 </span><span class="cov6" title="24">{ return false }</span> // Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body - <span class="cov3" title="4">for i := sigStart; i <= idx; i++ </span><span class="cov4" title="6">{ + <span class="cov4" title="12">for i := sigStart; i <= idx; i++ </span><span class="cov5" title="18">{ line := d.lines[i] brace := strings.Index(line, "{") - if brace >= 0 </span><span class="cov2" title="2">{ - if i < idx </span><span class="cov1" title="1">{ + if brace >= 0 </span><span class="cov3" title="6">{ + if i < idx </span><span class="cov2" title="3">{ return false // body started on a previous line }</span> // same line as cursor: if brace position < cursor character, then already in body - <span class="cov1" title="1">if pos.Character > brace </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if pos.Character > brace </span><span class="cov2" title="3">{ return false }</span> } } - <span class="cov2" title="2">return true</span> + <span class="cov3" title="6">return true</span> } -func hasAny(s string, needles []string) bool <span class="cov5" title="9">{ - for _, n := range needles </span><span class="cov7" title="24">{ - if strings.Contains(s, n) </span><span class="cov4" title="6">{ +func hasAny(s string, needles []string) bool <span class="cov6" title="27">{ + for _, n := range needles </span><span class="cov7" title="72">{ + if strings.Contains(s, n) </span><span class="cov5" title="18">{ return true }</span> } - <span class="cov3" title="3">return false</span> + <span class="cov4" title="9">return false</span> } -func trimLen(s string) string <span class="cov8" title="47">{ +func trimLen(s string) string <span class="cov8" title="141">{ s = strings.TrimSpace(s) - if len(s) > 200 </span><span class="cov1" title="1">{ + if len(s) > 200 </span><span class="cov2" title="3">{ return s[:200] + "…" }</span> - <span class="cov8" title="46">return s</span> + <span class="cov8" title="138">return s</span> } -func firstLine(s string) string <span class="cov7" title="25">{ +func firstLine(s string) string <span class="cov7" title="75">{ s = strings.ReplaceAll(s, "\r\n", "\n") - if idx := strings.IndexByte(s, '\n'); idx >= 0 </span><span class="cov4" title="6">{ + if idx := strings.IndexByte(s, '\n'); idx >= 0 </span><span class="cov5" title="18">{ return s[:idx] }</span> - <span class="cov6" title="19">return s</span> + <span class="cov7" title="57">return s</span> } </pre> - <pre class="file" id="file26" style="display: none">// Summary: LSP JSON-RPC handlers; implements core methods and integrates with the LLM client when enabled. + <pre class="file" id="file27" style="display: none">// Summary: LSP JSON-RPC handlers; implements core methods and integrates with the LLM client when enabled. package lsp import ( @@ -5295,12 +5813,12 @@ import ( "strings" ) -func (s *Server) handle(req Request) <span class="cov2" title="2">{ - if h, ok := s.handlers[req.Method]; ok </span><span class="cov1" title="1">{ +func (s *Server) handle(req Request) <span class="cov4" title="6">{ + if h, ok := s.handlers[req.Method]; ok </span><span class="cov2" title="3">{ h(req) return }</span> - <span class="cov1" title="1">if len(req.ID) != 0 </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if len(req.ID) != 0 </span><span class="cov2" title="3">{ s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)}) }</span> } @@ -5313,15 +5831,15 @@ func (s *Server) handle(req Request) <span class="cov2" title="2">{ // Preference order on each line: strict ;text; marker (no inner spaces), then // a line comment (//, #, --). Returns the instruction string and the selection // text cleaned of the matched instruction marker or comment. -func (s *Server) instructionFromSelection(sel string) (string, string) <span class="cov4" title="5">{ +func (s *Server) instructionFromSelection(sel string) (string, string) <span class="cov5" title="15">{ lines := splitLines(sel) - for idx, line := range lines </span><span class="cov4" title="5">{ - if instr, cleaned, ok := s.findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" </span><span class="cov1" title="1">{ + for idx, line := range lines </span><span class="cov5" title="15">{ + if instr, cleaned, ok := s.findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" </span><span class="cov2" title="3">{ lines[idx] = cleaned return instr, strings.Join(lines, "\n") }</span> } - <span class="cov4" title="4">return "", sel</span> + <span class="cov5" title="12">return "", sel</span> } // findFirstInstructionInLine returns the earliest instruction marker on the @@ -5333,52 +5851,52 @@ func (s *Server) instructionFromSelection(sel string) (string, string) <span cla // - // text // - # text // - -- text -func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) <span class="cov8" title="24">{ +func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) <span class="cov8" title="72">{ type cand struct { start, end int text string } cands := []cand{} openStr, _, openChar, closeChar := s.inlineMarkers() - if t, l, r, ok := findStrictInlineTag(line, openStr, openChar, closeChar); ok </span><span class="cov5" title="6">{ + if t, l, r, ok := findStrictInlineTag(line, openStr, openChar, closeChar); ok </span><span class="cov6" title="18">{ cands = append(cands, cand{start: l, end: r, text: t}) }</span> - <span class="cov8" title="24">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">{ + <span class="cov8" title="72">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov4" title="6">{ + if j := strings.Index(line[i+2:], "*/"); j >= 0 </span><span class="cov4" title="6">{ start := i end := i + 2 + j + 2 text := strings.TrimSpace(line[i+2 : i+2+j]) cands = append(cands, cand{start: start, end: end, text: text}) }</span> } - <span class="cov8" title="24">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">{ + <span class="cov8" title="72">if i := strings.Index(line, "<!--"); i >= 0 </span><span class="cov4" title="6">{ + if j := strings.Index(line[i+4:], "-->"); j >= 0 </span><span class="cov4" title="6">{ start := i end := i + 4 + j + 3 text := strings.TrimSpace(line[i+4 : i+4+j]) cands = append(cands, cand{start: start, end: end, text: text}) }</span> } - <span class="cov8" title="24">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov4" title="4">{ + <span class="cov8" title="72">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov5" title="12">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }</span> - <span class="cov8" title="24">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov2" title="2">{ + <span class="cov8" title="72">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov4" title="6">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) }</span> - <span class="cov8" title="24">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov4" title="4">{ + <span class="cov8" title="72">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov5" title="12">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }</span> - <span class="cov8" title="24">if len(cands) == 0 </span><span class="cov5" title="8">{ + <span class="cov8" title="72">if len(cands) == 0 </span><span class="cov6" title="24">{ return "", line, false }</span> // pick earliest start index - <span class="cov7" title="16">best := cands[0] - for _, c := range cands[1:] </span><span class="cov4" title="4">{ - if c.start >= 0 && (best.start < 0 || c.start < best.start) </span><span class="cov1" title="1">{ + <span class="cov7" title="48">best := cands[0] + for _, c := range cands[1:] </span><span class="cov5" title="12">{ + if c.start >= 0 && (best.start < 0 || c.start < best.start) </span><span class="cov2" title="3">{ best = c }</span> } - <span class="cov7" title="16">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + <span class="cov7" title="48">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") return best.text, cleaned, true</span> } @@ -5403,7 +5921,7 @@ func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned // handleCompletion moved to handlers_completion.go -func (s *Server) reply(id json.RawMessage, result any, err *RespError) <span class="cov8" title="31">{ +func (s *Server) reply(id json.RawMessage, result any, err *RespError) <span class="cov9" title="93">{ resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err} s.writeMessage(resp) }</span> @@ -5477,33 +5995,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="cov6" title="11">{ +func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string <span class="cov7" title="33">{ // 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="cov6" title="11">left := strings.TrimRight(current[:idx], " \t") + <span class="cov7" title="33">left := strings.TrimRight(current[:idx], " \t") right := "" if idx < len(current) </span><span class="cov0" title="0">{ right = current[idx:] }</span> - <span class="cov6" title="11">prov := "" + <span class="cov7" title="33">prov := "" model := "" - if client := s.currentLLMClient(); client != nil </span><span class="cov6" title="11">{ + if client := s.currentLLMClient(); client != nil </span><span class="cov7" title="33">{ prov = client.Name() model = client.DefaultModel() }</span> - <span class="cov6" title="11">temp := "" + <span class="cov7" title="33">temp := "" if tempPtr := s.codingTemperature(); tempPtr != nil </span><span class="cov0" title="0">{ temp = fmt.Sprintf("%.3f", *tempPtr) }</span> - <span class="cov6" title="11">extra := "" + <span class="cov7" title="33">extra := "" if hasExtra </span><span class="cov0" title="0">{ extra = strings.TrimSpace(extraText) }</span> // Compose a key from essential context parts - <span class="cov6" title="11">return strings.Join([]string{ + <span class="cov7" title="33">return strings.Join([]string{ "v1", // version for future-proofing prov, model, @@ -5520,25 +6038,25 @@ 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="11">{ +func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov7" title="33">{ s.mu.Lock() defer s.mu.Unlock() v, ok := s.compCache[key] - if !ok </span><span class="cov6" title="10">{ + if !ok </span><span class="cov7" title="30">{ return "", false }</span> // move to most-recent - <span class="cov1" title="1">s.compCacheTouchLocked(key) + <span class="cov2" title="3">s.compCacheTouchLocked(key) return v, true</span> } -func (s *Server) completionCachePut(key, value string) <span class="cov6" title="13">{ +func (s *Server) completionCachePut(key, value string) <span class="cov7" title="39">{ s.mu.Lock() defer s.mu.Unlock() - if s.compCache == nil </span><span class="cov4" title="5">{ + if s.compCache == nil </span><span class="cov5" title="15">{ s.compCache = make(map[string]string) }</span> - <span class="cov6" title="13">if _, exists := s.compCache[key]; !exists </span><span class="cov6" title="13">{ + <span class="cov7" title="39">if _, exists := s.compCache[key]; !exists </span><span class="cov7" title="39">{ s.compCacheOrder = append(s.compCacheOrder, key) s.compCache[key] = value if len(s.compCacheOrder) > 10 </span><span class="cov0" title="0">{ @@ -5547,102 +6065,102 @@ func (s *Server) completionCachePut(key, value string) <span class="cov6" title= s.compCacheOrder = s.compCacheOrder[1:] delete(s.compCache, old) }</span> - <span class="cov6" title="13">return</span> + <span class="cov7" title="39">return</span> } // update existing and mark most-recent <span class="cov0" title="0">s.compCache[key] = value s.compCacheTouchLocked(key)</span> } -func (s *Server) compCacheTouchLocked(key string) <span class="cov1" title="1">{ +func (s *Server) compCacheTouchLocked(key string) <span class="cov2" title="3">{ // assumes s.mu is held // remove any existing occurrence of key in order slice idx := -1 - for i, k := range s.compCacheOrder </span><span class="cov1" title="1">{ - if k == key </span><span class="cov1" title="1">{ + for i, k := range s.compCacheOrder </span><span class="cov2" title="3">{ + if k == key </span><span class="cov2" title="3">{ idx = i break</span> } } - <span class="cov1" title="1">if idx >= 0 </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if idx >= 0 </span><span class="cov2" title="3">{ s.compCacheOrder = append(append([]string{}, s.compCacheOrder[:idx]...), s.compCacheOrder[idx+1:]...) }</span> - <span class="cov1" title="1">s.compCacheOrder = append(s.compCacheOrder, key)</span> + <span class="cov2" title="3">s.compCacheOrder = append(s.compCacheOrder, key)</span> } // isTriggerEvent returns true when the completion request appears to be caused // by typing one of our configured trigger characters. It checks the LSP // 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="cov8" title="25">{ +func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span class="cov8" title="75">{ open, _, openChar, closeChar := s.inlineMarkers() doubleSeqs := doubleOpenSequences(open, openChar, closeChar) triggerChars := s.triggerCharacters() // 1) Inspect LSP completion context if present - if p.Context != nil </span><span class="cov6" title="11">{ + if p.Context != nil </span><span class="cov7" title="33">{ var ctx struct { TriggerKind int `json:"triggerKind"` TriggerCharacter string `json:"triggerCharacter,omitempty"` } - if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov6" title="10">{ + if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov7" title="30">{ _ = json.Unmarshal(raw, &ctx) - }</span> else<span class="cov1" title="1"> { + }</span> else<span class="cov2" title="3"> { b, _ := json.Marshal(p.Context) _ = json.Unmarshal(b, &ctx) }</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="cov6" title="11">if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) </span><span class="cov2" title="2">{ + <span class="cov7" title="33">if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) </span><span class="cov4" title="6">{ return false }</span> // TriggerKind 1 = Invoked (manual). Always allow manual invoke. - <span class="cov6" title="9">if ctx.TriggerKind == 1 </span><span class="cov4" title="5">{ + <span class="cov6" title="27">if ctx.TriggerKind == 1 </span><span class="cov5" title="15">{ return true }</span> // 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 triggerChars </span><span class="cov2" title="2">{ - if c == ctx.TriggerCharacter </span><span class="cov2" title="2">{ + <span class="cov5" title="12">if ctx.TriggerKind == 2 </span><span class="cov4" title="9">{ + if ctx.TriggerCharacter != "" </span><span class="cov4" title="6">{ + for _, c := range triggerChars </span><span class="cov4" title="6">{ + if c == ctx.TriggerCharacter </span><span class="cov4" title="6">{ return true }</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> + <span class="cov2" title="3">return false</span> } // For TriggerForIncomplete (3), require manual char check below } // 2) Fallback: check the character immediately prior to cursor - <span class="cov7" title="15">idx := p.Position.Character + <span class="cov7" title="45">idx := p.Position.Character if idx <= 0 || idx > len(current) </span><span class="cov0" title="0">{ return false }</span> // Bare double-open should not trigger via fallback char either (only when configured) - <span class="cov7" title="15">if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) </span><span class="cov3" title="3">{ + <span class="cov7" title="45">if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) </span><span class="cov4" title="9">{ return false }</span> - <span class="cov6" title="12">ch := string(current[idx-1]) - for _, c := range triggerChars </span><span class="cov8" title="28">{ - if c == ch </span><span class="cov5" title="6">{ + <span class="cov7" title="36">ch := string(current[idx-1]) + for _, c := range triggerChars </span><span class="cov8" title="84">{ + if c == ch </span><span class="cov6" title="18">{ return true }</span> } - <span class="cov5" title="6">return false</span> + <span class="cov6" title="18">return false</span> } -func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string, detail string, sortPrefix string) []CompletionItem <span class="cov7" title="14">{ +func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string, detail string, sortPrefix string) []CompletionItem <span class="cov7" title="42">{ te, filter := computeTextEditAndFilter(cleaned, inParams, current, p) rm := s.collectPromptRemovalEdits(p.TextDocument.URI) label := labelForCompletion(cleaned, filter) if strings.TrimSpace(detail) == "" </span><span class="cov0" title="0">{ detail = "Hexai LLM completion" }</span> - <span class="cov7" title="14">if sortPrefix == "" </span><span class="cov0" title="0">{ + <span class="cov7" title="42">if sortPrefix == "" </span><span class="cov0" title="0">{ sortPrefix = "0000" }</span> - <span class="cov7" title="14">return []CompletionItem{{ + <span class="cov7" title="42">return []CompletionItem{{ Label: label, Kind: 1, Detail: detail, @@ -5655,16 +6173,16 @@ func (s *Server) makeCompletionItems(cleaned string, inParams bool, current stri }}</span> } -func containsAny(haystack string, seqs []string) bool <span class="cov8" title="26">{ - for _, seq := range seqs </span><span class="cov10" title="51">{ +func containsAny(haystack string, seqs []string) bool <span class="cov8" title="78">{ + for _, seq := range seqs </span><span class="cov10" title="153">{ if seq == "" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov10" title="51">if strings.Contains(haystack, seq) </span><span class="cov4" title="5">{ + <span class="cov10" title="153">if strings.Contains(haystack, seq) </span><span class="cov5" title="15">{ return true }</span> } - <span class="cov7" title="21">return false</span> + <span class="cov8" title="63">return false</span> } // small helpers to keep tryLLMCompletion short @@ -5739,7 +6257,7 @@ func containsAny(haystack string, seqs []string) bool <span class="cov8" title=" // labelForCompletion moved to handlers_utils.go -func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem <span class="cov1" title="1">{ +func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem <span class="cov2" title="3">{ return []CompletionItem{{ Label: "hexai-complete", Kind: 1, @@ -5751,7 +6269,7 @@ func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem <span c }</span> </pre> - <pre class="file" id="file27" style="display: none">// Summary: Code Action handlers and helpers split from handlers.go for clarity. + <pre class="file" id="file28" style="display: none">// Summary: Code Action handlers and helpers split from handlers.go for clarity. package lsp import ( @@ -5767,7 +6285,7 @@ import ( "codeberg.org/snonux/hexai/internal/logging" ) -func (s *Server) handleCodeAction(req Request) <span class="cov4" title="5">{ +func (s *Server) handleCodeAction(req Request) <span class="cov5" title="15">{ var p CodeActionParams if err := json.Unmarshal(req.Params, &p); err != nil </span><span class="cov0" title="0">{ if len(req.ID) != 0 </span><span class="cov0" title="0">{ @@ -5775,56 +6293,56 @@ func (s *Server) handleCodeAction(req Request) <span class="cov4" title="5">{ }</span> <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.currentLLMClient() == nil </span><span class="cov2" title="2">{ - if len(req.ID) != 0 </span><span class="cov2" title="2">{ + <span class="cov5" title="15">d := s.getDocument(p.TextDocument.URI) + if d == nil || len(d.lines) == 0 || s.currentLLMClient() == nil </span><span class="cov3" title="6">{ + if len(req.ID) != 0 </span><span class="cov3" title="6">{ s.reply(req.ID, []CodeAction{}, nil) }</span> - <span class="cov2" title="2">return</span> + <span class="cov3" title="6">return</span> } - <span class="cov3" title="3">sel := extractRangeText(d, p.Range) + <span class="cov4" title="9">sel := extractRangeText(d, p.Range) actions := make([]CodeAction, 0, 8) if a := s.buildRewriteCodeAction(p, sel); a != nil </span><span class="cov0" title="0">{ actions = append(actions, *a) }</span> - <span class="cov3" title="3">if a := s.buildDiagnosticsCodeAction(p, sel); a != nil </span><span class="cov2" title="2">{ + <span class="cov4" title="9">if a := s.buildDiagnosticsCodeAction(p, sel); a != nil </span><span class="cov3" title="6">{ actions = append(actions, *a) }</span> - <span class="cov3" title="3">if a := s.buildDocumentCodeAction(p, sel); a != nil </span><span class="cov2" title="2">{ + <span class="cov4" title="9">if a := s.buildDocumentCodeAction(p, sel); a != nil </span><span class="cov3" title="6">{ actions = append(actions, *a) }</span> - <span class="cov3" title="3">if a := s.buildGoUnitTestCodeAction(p); a != nil </span><span class="cov3" title="3">{ + <span class="cov4" title="9">if a := s.buildGoUnitTestCodeAction(p); a != nil </span><span class="cov4" title="9">{ actions = append(actions, *a) }</span> - <span class="cov3" title="3">if a := s.buildSimplifyCodeAction(p, sel); a != nil </span><span class="cov2" title="2">{ + <span class="cov4" title="9">if a := s.buildSimplifyCodeAction(p, sel); a != nil </span><span class="cov3" title="6">{ actions = append(actions, *a) }</span> // Custom actions from config - <span class="cov3" title="3">s.appendCustomActions(&actions, p, sel) - if len(req.ID) != 0 </span><span class="cov3" title="3">{ + <span class="cov4" title="9">s.appendCustomActions(&actions, p, sel) + if len(req.ID) != 0 </span><span class="cov4" title="9">{ s.reply(req.ID, actions, nil) }</span> } // 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">{ +func (s *Server) appendCustomActions(actions *[]CodeAction, p CodeActionParams, sel string) <span class="cov4" title="9">{ customs := s.customActions() - if len(customs) == 0 </span><span class="cov1" title="1">{ + if len(customs) == 0 </span><span class="cov2" title="3">{ return }</span> - <span class="cov2" title="2">diags := s.diagnosticsInRange(p.Context, p.Range) - for _, ca := range customs </span><span class="cov3" title="4">{ + <span class="cov3" title="6">diags := s.diagnosticsInRange(p.Context, p.Range) + for _, ca := range customs </span><span class="cov4" title="12">{ title := strings.TrimSpace(ca.Title) if title == "" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov3" title="4">scope := strings.TrimSpace(strings.ToLower(ca.Scope)) - if scope == "diagnostics" </span><span class="cov2" title="2">{ + <span class="cov4" title="12">scope := strings.TrimSpace(strings.ToLower(ca.Scope)) + if scope == "diagnostics" </span><span class="cov3" title="6">{ if len(diags) == 0 </span><span class="cov0" title="0">{ continue</span> } - <span class="cov2" title="2">payload := struct { + <span class="cov3" title="6">payload := struct { Type string `json:"type"` ID string `json:"id"` URI string `json:"uri"` @@ -5834,17 +6352,17 @@ func (s *Server) appendCustomActions(actions *[]CodeAction, p CodeActionParams, }{Type: "custom", ID: ca.ID, URI: p.TextDocument.URI, Range: p.Range, Selection: sel, Diagnostics: diags} raw, _ := json.Marshal(payload) kind := ca.Kind - if strings.TrimSpace(kind) == "" </span><span class="cov1" title="1">{ + if strings.TrimSpace(kind) == "" </span><span class="cov2" title="3">{ kind = "quickfix" }</span> - <span class="cov2" title="2">*actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw}) + <span class="cov3" title="6">*actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw}) continue</span> } // default: selection - <span class="cov2" title="2">if strings.TrimSpace(sel) == "" </span><span class="cov1" title="1">{ + <span class="cov3" title="6">if strings.TrimSpace(sel) == "" </span><span class="cov2" title="3">{ continue</span> } - <span class="cov1" title="1">payload := struct { + <span class="cov2" title="3">payload := struct { Type string `json:"type"` ID string `json:"id"` URI string `json:"uri"` @@ -5856,15 +6374,15 @@ func (s *Server) appendCustomActions(actions *[]CodeAction, p CodeActionParams, if strings.TrimSpace(kind) == "" </span><span class="cov0" title="0">{ kind = "refactor" }</span> - <span class="cov1" title="1">*actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw})</span> + <span class="cov2" title="3">*actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw})</span> } } -func (s *Server) buildSimplifyCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov3" title="3">{ - if strings.TrimSpace(sel) == "" </span><span class="cov1" title="1">{ +func (s *Server) buildSimplifyCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov4" title="9">{ + if strings.TrimSpace(sel) == "" </span><span class="cov2" title="3">{ return nil }</span> - <span class="cov2" title="2">payload := struct { + <span class="cov3" title="6">payload := struct { Type string `json:"type"` URI string `json:"uri"` Range Range `json:"range"` @@ -5875,8 +6393,8 @@ func (s *Server) buildSimplifyCodeAction(p CodeActionParams, sel string) *CodeAc return &ca</span> } -func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov4" title="5">{ - if instr, cleaned := s.instructionFromSelection(sel); strings.TrimSpace(instr) != "" </span><span class="cov1" title="1">{ +func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov5" title="15">{ + if instr, cleaned := s.instructionFromSelection(sel); strings.TrimSpace(instr) != "" </span><span class="cov2" title="3">{ payload := struct { Type string `json:"type"` URI string `json:"uri"` @@ -5888,15 +6406,15 @@ func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAct ca := CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Data: raw} return &ca }</span> - <span class="cov3" title="4">return nil</span> + <span class="cov4" title="12">return nil</span> } -func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov4" title="6">{ +func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov5" title="18">{ diags := s.diagnosticsInRange(p.Context, p.Range) - if len(diags) == 0 </span><span class="cov2" title="2">{ + if len(diags) == 0 </span><span class="cov3" title="6">{ return nil }</span> - <span class="cov3" title="4">payload := struct { + <span class="cov4" title="12">payload := struct { Type string `json:"type"` URI string `json:"uri"` Range Range `json:"range"` @@ -5908,11 +6426,11 @@ func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *Cod return &ca</span> } -func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class="cov6" title="17">{ - if s.currentLLMClient() == nil || len(ca.Data) == 0 </span><span class="cov1" title="1">{ +func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class="cov7" title="51">{ + if s.currentLLMClient() == nil || len(ca.Data) == 0 </span><span class="cov2" title="3">{ return ca, false }</span> - <span class="cov6" title="16">var payload struct { + <span class="cov7" title="48">var payload struct { Type string `json:"type"` ID string `json:"id"` URI string `json:"uri"` @@ -5924,26 +6442,26 @@ 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">cfg := s.currentConfig() + <span class="cov7" title="48">cfg := s.currentConfig() switch payload.Type </span>{ - case "rewrite":<span class="cov3" title="4"> + case "rewrite":<span class="cov4" title="12"> 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"> + case "diagnostics":<span class="cov5" title="15"> sys := cfg.PromptCodeActionDiagnosticsSystem var b strings.Builder - for i, dgn := range payload.Diagnostics </span><span class="cov4" title="6">{ + for i, dgn := range payload.Diagnostics </span><span class="cov5" title="18">{ 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="cov4" title="6"> { + }</span> else<span class="cov5" title="18"> { fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message) }</span> } - <span class="cov4" title="5">diagList := b.String() + <span class="cov5" title="15">diagList := b.String() 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"> + case "document":<span class="cov4" title="9"> 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> @@ -5958,60 +6476,60 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class 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"> + case "custom":<span class="cov4" title="12"> var action *CustomAction - for _, caDef := range s.customActions() </span><span class="cov4" title="5">{ - if caDef.ID == payload.ID </span><span class="cov3" title="4">{ + for _, caDef := range s.customActions() </span><span class="cov5" title="15">{ + if caDef.ID == payload.ID </span><span class="cov4" title="12">{ action = &caDef break</span> } } - <span class="cov3" title="4">if action == nil </span><span class="cov0" title="0">{ + <span class="cov4" title="12">if action == nil </span><span class="cov0" title="0">{ return ca, false }</span> - <span class="cov3" title="4">var sys, user string - if strings.TrimSpace(action.User) != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="12">var sys, user string + if strings.TrimSpace(action.User) != "" </span><span class="cov2" title="3">{ if strings.TrimSpace(action.System) != "" </span><span class="cov0" title="0">{ sys = action.System - }</span> else<span class="cov1" title="1"> { + }</span> else<span class="cov2" title="3"> { sys = cfg.PromptCodeActionRewriteSystem }</span> - <span class="cov1" title="1">var diagList string - if len(payload.Diagnostics) > 0 </span><span class="cov1" title="1">{ + <span class="cov2" title="3">var diagList string + if len(payload.Diagnostics) > 0 </span><span class="cov2" title="3">{ var b strings.Builder - for _, d := range payload.Diagnostics </span><span class="cov1" title="1">{ + for _, d := range payload.Diagnostics </span><span class="cov2" title="3">{ fmt.Fprintf(&b, "%s\n", d.Message) }</span> - <span class="cov1" title="1">diagList = b.String()</span> + <span class="cov2" title="3">diagList = b.String()</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"> { + <span class="cov2" title="3">user = renderTemplate(action.User, map[string]string{"selection": payload.Selection, "diagnostics": strings.TrimSpace(diagList)})</span> + } else<span class="cov4" title="9"> { 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="cov4" title="12">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">{ +func (s *Server) completeCodeAction(ca CodeAction, uri string, rng Range, sys, user string, timeout time.Duration) (CodeAction, bool) <span class="cov7" title="48">{ ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} spec := s.buildRequestSpec(surfaceCodeAction) - if text, err := s.chatWithStats(ctx, surfaceCodeAction, spec, messages); err == nil </span><span class="cov6" title="15">{ - if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov6" title="14">{ + if text, err := s.chatWithStats(ctx, surfaceCodeAction, spec, messages); err == nil </span><span class="cov6" title="45">{ + if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov6" title="42">{ edit := WorkspaceEdit{Changes: map[string][]TextEdit{uri: {{Range: rng, NewText: out}}}} ca.Edit = &edit return ca, true }</span> - } else<span class="cov1" title="1"> { + } else<span class="cov2" title="3"> { logging.Logf("lsp ", "codeAction llm error: %v", err) }</span> - <span class="cov2" title="2">return ca, false</span> + <span class="cov3" title="6">return ca, false</span> } -func (s *Server) handleCodeActionResolve(req Request) <span class="cov2" title="2">{ +func (s *Server) handleCodeActionResolve(req Request) <span class="cov3" title="6">{ var ca CodeAction if err := json.Unmarshal(req.Params, &ca); err != nil </span><span class="cov0" title="0">{ if len(req.ID) != 0 </span><span class="cov0" title="0">{ @@ -6019,7 +6537,7 @@ func (s *Server) handleCodeActionResolve(req Request) <span class="cov2" title=" }</span> <span class="cov0" title="0">return</span> } - <span class="cov2" title="2">if resolved, ok := s.resolveCodeAction(ca); ok </span><span class="cov2" title="2">{ + <span class="cov3" title="6">if resolved, ok := s.resolveCodeAction(ca); ok </span><span class="cov3" title="6">{ s.reply(req.ID, resolved, nil) return }</span> @@ -6029,77 +6547,77 @@ func (s *Server) handleCodeActionResolve(req Request) <span class="cov2" title=" // diagnosticsInRange parses the CodeAction context and returns diagnostics // that overlap the given selection range. If the context is missing or does // not contain diagnostics, returns an empty slice. -func (s *Server) diagnosticsInRange(ctxRaw json.RawMessage, sel Range) []Diagnostic <span class="cov5" title="11">{ - if len(ctxRaw) == 0 </span><span class="cov3" title="3">{ +func (s *Server) diagnosticsInRange(ctxRaw json.RawMessage, sel Range) []Diagnostic <span class="cov6" title="33">{ + if len(ctxRaw) == 0 </span><span class="cov4" title="9">{ return nil }</span> - <span class="cov5" title="8">var ctx CodeActionContext + <span class="cov5" title="24">var ctx CodeActionContext if err := json.Unmarshal(ctxRaw, &ctx); err != nil </span><span class="cov0" title="0">{ return nil }</span> - <span class="cov5" title="8">if len(ctx.Diagnostics) == 0 </span><span class="cov0" title="0">{ + <span class="cov5" title="24">if len(ctx.Diagnostics) == 0 </span><span class="cov0" title="0">{ return nil }</span> - <span class="cov5" title="8">out := make([]Diagnostic, 0, len(ctx.Diagnostics)) - for _, d := range ctx.Diagnostics </span><span class="cov5" title="11">{ - if rangesOverlap(d.Range, sel) </span><span class="cov5" title="8">{ + <span class="cov5" title="24">out := make([]Diagnostic, 0, len(ctx.Diagnostics)) + for _, d := range ctx.Diagnostics </span><span class="cov6" title="33">{ + if rangesOverlap(d.Range, sel) </span><span class="cov5" title="24">{ out = append(out, d) }</span> } - <span class="cov5" title="8">return out</span> + <span class="cov5" title="24">return out</span> } // rangesOverlap reports whether two LSP ranges overlap at all. -func rangesOverlap(a, b Range) bool <span class="cov6" title="14">{ +func rangesOverlap(a, b Range) bool <span class="cov6" title="42">{ // Normalize ordering - if greaterPos(a.Start, a.End) </span><span class="cov3" title="4">{ + if greaterPos(a.Start, a.End) </span><span class="cov4" title="12">{ a.Start, a.End = a.End, a.Start }</span> - <span class="cov6" title="14">if greaterPos(b.Start, b.End) </span><span class="cov0" title="0">{ + <span class="cov6" title="42">if greaterPos(b.Start, b.End) </span><span class="cov0" title="0">{ b.Start, b.End = b.End, b.Start }</span> // a ends before b starts - <span class="cov6" title="14">if lessPos(a.End, b.Start) </span><span class="cov3" title="3">{ + <span class="cov6" title="42">if lessPos(a.End, b.Start) </span><span class="cov4" title="9">{ return false }</span> // b ends before a starts - <span class="cov5" title="11">if lessPos(b.End, a.Start) </span><span class="cov1" title="1">{ + <span class="cov6" title="33">if lessPos(b.End, a.Start) </span><span class="cov2" title="3">{ return false }</span> - <span class="cov5" title="10">return true</span> + <span class="cov6" title="30">return true</span> } -func lessPos(p, q Position) bool <span class="cov7" title="27">{ - if p.Line != q.Line </span><span class="cov6" title="18">{ +func lessPos(p, q Position) bool <span class="cov7" title="81">{ + if p.Line != q.Line </span><span class="cov7" title="54">{ return p.Line < q.Line }</span> - <span class="cov5" title="9">return p.Character < q.Character</span> + <span class="cov6" title="27">return p.Character < q.Character</span> } -func greaterPos(p, q Position) bool <span class="cov7" title="30">{ - if p.Line != q.Line </span><span class="cov6" title="15">{ +func greaterPos(p, q Position) bool <span class="cov8" title="90">{ + if p.Line != q.Line </span><span class="cov6" title="45">{ return p.Line > q.Line }</span> - <span class="cov6" title="15">return p.Character > q.Character</span> + <span class="cov6" title="45">return p.Character > q.Character</span> } // --- Go unit test code action --- -func (s *Server) buildGoUnitTestCodeAction(p CodeActionParams) *CodeAction <span class="cov4" title="5">{ +func (s *Server) buildGoUnitTestCodeAction(p CodeActionParams) *CodeAction <span class="cov5" title="15">{ uri := p.TextDocument.URI if uri == "" || !strings.HasSuffix(strings.TrimPrefix(uri, "file://"), ".go") </span><span class="cov0" title="0">{ return nil }</span> // Skip if already a _test.go file - <span class="cov4" title="5">if strings.HasSuffix(strings.TrimPrefix(uri, "file://"), "_test.go") </span><span class="cov1" title="1">{ + <span class="cov5" title="15">if strings.HasSuffix(strings.TrimPrefix(uri, "file://"), "_test.go") </span><span class="cov2" title="3">{ return nil }</span> // Heuristic: only offer when a function context is found above the cursor - <span class="cov3" title="4">_, _, _, funcCtx := s.lineContext(uri, p.Range.Start) + <span class="cov4" title="12">_, _, _, funcCtx := s.lineContext(uri, p.Range.Start) if !strings.Contains(funcCtx, "func ") </span><span class="cov0" title="0">{ return nil }</span> - <span class="cov3" title="4">payload := struct { + <span class="cov4" title="12">payload := struct { Type string `json:"type"` URI string `json:"uri"` Range Range `json:"range"` @@ -6110,14 +6628,14 @@ 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">{ +func (s *Server) buildDocumentCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov4" title="12">{ 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">{ + <span class="cov4" title="12">if strings.TrimSpace(sel) == "" </span><span class="cov2" title="3">{ return nil }</span> - <span class="cov3" title="3">payload := struct { + <span class="cov4" title="9">payload := struct { Type string `json:"type"` URI string `json:"uri"` Range Range `json:"range"` @@ -6128,47 +6646,47 @@ func (s *Server) buildDocumentCodeAction(p CodeActionParams, sel string) *CodeAc return &ca</span> } -func (s *Server) resolveGoTest(uri string, pos Position) (WorkspaceEdit, string, Range, bool) <span class="cov3" title="3">{ +func (s *Server) resolveGoTest(uri string, pos Position) (WorkspaceEdit, string, Range, bool) <span class="cov4" title="9">{ path := strings.TrimPrefix(uri, "file://") if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") </span><span class="cov0" title="0">{ return WorkspaceEdit{}, "", Range{}, false }</span> // Load source text - <span class="cov3" title="3">_, lines := s.loadFileText(uri) + <span class="cov4" title="9">_, lines := s.loadFileText(uri) if len(lines) == 0 </span><span class="cov0" title="0">{ return WorkspaceEdit{}, "", Range{}, false }</span> - <span class="cov3" title="3">pkg := parseGoPackageName(lines) + <span class="cov4" title="9">pkg := parseGoPackageName(lines) fnStart, fnEnd := findGoFunctionAtLine(lines, pos.Line) if fnStart < 0 || fnEnd < fnStart </span><span class="cov0" title="0">{ return WorkspaceEdit{}, "", Range{}, false }</span> - <span class="cov3" title="3">funcCode := strings.Join(lines[fnStart:fnEnd+1], "\n") + <span class="cov4" title="9">funcCode := strings.Join(lines[fnStart:fnEnd+1], "\n") testFunc := s.generateGoTestFunction(funcCode) if strings.TrimSpace(testFunc) == "" </span><span class="cov0" title="0">{ return WorkspaceEdit{}, "", Range{}, false }</span> // Determine test file target - <span class="cov3" title="3">testPath := strings.TrimSuffix(path, ".go") + "_test.go" + <span class="cov4" title="9">testPath := strings.TrimSuffix(path, ".go") + "_test.go" testURI := "file://" + testPath // If test file exists, append test at EOF; otherwise, create a new file with package+import - if fileExists(testPath) </span><span class="cov1" title="1">{ + if fileExists(testPath) </span><span class="cov2" title="3">{ // Build an insertion at end of file _, tLines := s.loadFileText(testURI) // Fallback when not open and cannot read: still insert at line 0 lineIdx := 0 col := 0 - if len(tLines) > 0 </span><span class="cov1" title="1">{ + if len(tLines) > 0 </span><span class="cov2" title="3">{ lineIdx = len(tLines) - 1 col = len(tLines[lineIdx]) }</span> - <span class="cov1" title="1">var b strings.Builder + <span class="cov2" title="3">var b strings.Builder // Ensure at least two newlines before the new test if len(tLines) == 0 || (len(tLines) > 0 && !strings.HasSuffix(strings.Join(tLines, "\n"), "\n\n")) </span><span class="cov0" title="0">{ b.WriteString("\n\n") }</span> - <span class="cov1" title="1">b.WriteString(testFunc) + <span class="cov2" title="3">b.WriteString(testFunc) insert := b.String() edit := TextEdit{Range: Range{Start: Position{Line: lineIdx, Character: col}, End: Position{Line: lineIdx, Character: col}}, NewText: insert} we := WorkspaceEdit{Changes: map[string][]TextEdit{testURI: {edit}}} @@ -6178,20 +6696,20 @@ func (s *Server) resolveGoTest(uri string, pos Position) (WorkspaceEdit, string, if strings.HasPrefix(insert, "\n\n") </span><span class="cov0" title="0">{ prefixNL = 2 }</span> - <span class="cov1" title="1">startLine := lineIdx + prefixNL + <span class="cov2" title="3">startLine := lineIdx + prefixNL // If we inserted with two newlines and last line wasn't blank, first newline moves to next line if prefixNL > 0 </span><span class="cov0" title="0">{ startLine = lineIdx + prefixNL }</span> - <span class="cov1" title="1">jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} + <span class="cov2" title="3">jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} return we, testURI, jump, true</span> } // Create new file content - <span class="cov2" title="2">var content strings.Builder + <span class="cov3" title="6">var content strings.Builder if pkg == "" </span><span class="cov0" title="0">{ pkg = filepath.Base(filepath.Dir(path)) }</span> - <span class="cov2" title="2">content.WriteString("package ") + <span class="cov3" title="6">content.WriteString("package ") content.WriteString(pkg) content.WriteString("\n\n") content.WriteString("import (\n\t\"testing\"\n)\n\n") @@ -6206,69 +6724,69 @@ func (s *Server) resolveGoTest(uri string, pos Position) (WorkspaceEdit, string, pre := content.String() idx := strings.Index(pre, "func Test") startLine := 0 - if idx > 0 </span><span class="cov1" title="1">{ + if idx > 0 </span><span class="cov2" title="3">{ before := pre[:idx] startLine = strings.Count(before, "\n") }</span> - <span class="cov2" title="2">jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} + <span class="cov3" title="6">jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} return we, testURI, jump, true</span> } // loadFileText returns the file content and lines. It prefers the open document; otherwise reads from disk. -func (s *Server) loadFileText(uri string) (string, []string) <span class="cov3" title="4">{ - if d := s.getDocument(uri); d != nil </span><span class="cov2" title="2">{ +func (s *Server) loadFileText(uri string) (string, []string) <span class="cov4" title="12">{ + if d := s.getDocument(uri); d != nil </span><span class="cov3" title="6">{ return d.text, append([]string{}, d.lines...) }</span> - <span class="cov2" title="2">path := strings.TrimPrefix(uri, "file://") + <span class="cov3" title="6">path := strings.TrimPrefix(uri, "file://") b, err := os.ReadFile(path) if err != nil </span><span class="cov0" title="0">{ return "", nil }</span> - <span class="cov2" title="2">txt := string(b) + <span class="cov3" title="6">txt := string(b) return txt, splitLines(txt)</span> } -func fileExists(path string) bool <span class="cov3" title="3">{ - if _, err := os.Stat(path); err == nil </span><span class="cov1" title="1">{ +func fileExists(path string) bool <span class="cov4" title="9">{ + if _, err := os.Stat(path); err == nil </span><span class="cov2" title="3">{ return true }</span> - <span class="cov2" title="2">return false</span> + <span class="cov3" title="6">return false</span> } // parseGoPackageName returns the package name from file lines, or empty if not found. -func parseGoPackageName(lines []string) string <span class="cov4" title="5">{ - for _, ln := range lines </span><span class="cov4" title="6">{ +func parseGoPackageName(lines []string) string <span class="cov5" title="15">{ + for _, ln := range lines </span><span class="cov5" title="18">{ t := strings.TrimSpace(ln) - if strings.HasPrefix(t, "package ") </span><span class="cov3" title="4">{ + if strings.HasPrefix(t, "package ") </span><span class="cov4" title="12">{ name := strings.TrimSpace(strings.TrimPrefix(t, "package ")) // strip inline comments - if i := strings.Index(name, " "); i >= 0 </span><span class="cov1" title="1">{ + if i := strings.Index(name, " "); i >= 0 </span><span class="cov2" title="3">{ name = name[:i] }</span> - <span class="cov3" title="4">if i := strings.Index(name, "\t"); i >= 0 </span><span class="cov0" title="0">{ + <span class="cov4" title="12">if i := strings.Index(name, "\t"); i >= 0 </span><span class="cov0" title="0">{ name = name[:i] }</span> - <span class="cov3" title="4">if i := strings.Index(name, "//"); i >= 0 </span><span class="cov0" title="0">{ + <span class="cov4" title="12">if i := strings.Index(name, "//"); i >= 0 </span><span class="cov0" title="0">{ name = strings.TrimSpace(name[:i]) }</span> - <span class="cov3" title="4">return name</span> + <span class="cov4" title="12">return name</span> } } - <span class="cov1" title="1">return ""</span> + <span class="cov2" title="3">return ""</span> } // findGoFunctionAtLine finds the function enclosing or preceding line idx. Returns start and end line indexes. -func findGoFunctionAtLine(lines []string, idx int) (int, int) <span class="cov3" title="4">{ +func findGoFunctionAtLine(lines []string, idx int) (int, int) <span class="cov4" title="12">{ if idx < 0 </span><span class="cov0" title="0">{ idx = 0 }</span> - <span class="cov3" title="4">if idx >= len(lines) </span><span class="cov0" title="0">{ + <span class="cov4" title="12">if idx >= len(lines) </span><span class="cov0" title="0">{ idx = len(lines) - 1 }</span> // find signature start - <span class="cov3" title="4">start := -1 - for i := idx; i >= 0; i-- </span><span class="cov3" title="4">{ - if strings.Contains(lines[i], "func ") </span><span class="cov3" title="4">{ + <span class="cov4" title="12">start := -1 + for i := idx; i >= 0; i-- </span><span class="cov4" title="12">{ + if strings.Contains(lines[i], "func ") </span><span class="cov4" title="12">{ start = i break</span> } @@ -6276,38 +6794,38 @@ func findGoFunctionAtLine(lines []string, idx int) (int, int) <span class="cov3" break</span> } } - <span class="cov3" title="4">if start == -1 </span><span class="cov0" title="0">{ + <span class="cov4" title="12">if start == -1 </span><span class="cov0" title="0">{ return -1, -1 }</span> // find first '{' - <span class="cov3" title="4">depth := 0 + <span class="cov4" title="12">depth := 0 seenOpen := false - for i := start; i < len(lines); i++ </span><span class="cov4" title="5">{ + for i := start; i < len(lines); i++ </span><span class="cov5" title="15">{ ln := lines[i] - for j := 0; j < len(ln); j++ </span><span class="cov10" title="106">{ + for j := 0; j < len(ln); j++ </span><span class="cov10" title="318">{ switch ln[j] </span>{ - case '{':<span class="cov3" title="3"> + case '{':<span class="cov4" title="9"> depth++ seenOpen = true</span> - case '}':<span class="cov3" title="3"> - if depth > 0 </span><span class="cov3" title="3">{ + case '}':<span class="cov4" title="9"> + if depth > 0 </span><span class="cov4" title="9">{ depth-- }</span> - <span class="cov3" title="3">if seenOpen && depth == 0 </span><span class="cov3" title="3">{ + <span class="cov4" title="9">if seenOpen && depth == 0 </span><span class="cov4" title="9">{ return start, i }</span> } } } // if never saw '{', assume single-line prototype; return that line - <span class="cov1" title="1">if !seenOpen </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if !seenOpen </span><span class="cov2" title="3">{ return start, start }</span> <span class="cov0" title="0">return start, -1</span> } // 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">{ +func (s *Server) generateGoTestFunction(funcCode string) string <span class="cov4" title="12">{ spec := s.buildRequestSpec(surfaceCodeAction) cfg := s.currentConfig() sys := cfg.PromptCodeActionGoTestSystem @@ -6315,9 +6833,9 @@ func (s *Server) generateGoTestFunction(funcCode string) string <span class="cov ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - if out, err := s.chatWithStats(ctx, surfaceCodeAction, spec, messages); err == nil </span><span class="cov3" title="4">{ + if out, err := s.chatWithStats(ctx, surfaceCodeAction, spec, messages); err == nil </span><span class="cov4" title="12">{ cleaned := strings.TrimSpace(stripCodeFences(out)) - if cleaned != "" </span><span class="cov3" title="4">{ + if cleaned != "" </span><span class="cov4" title="12">{ return cleaned }</span> } else<span class="cov0" title="0"> { @@ -6332,23 +6850,23 @@ func (s *Server) generateGoTestFunction(funcCode string) string <span class="cov } // deriveGoFuncName extracts function or method name from code. -func deriveGoFuncName(code string) string <span class="cov2" title="2">{ +func deriveGoFuncName(code string) string <span class="cov3" title="6">{ // look for line starting with func line := firstLine(code) line = strings.TrimSpace(line) if !strings.HasPrefix(line, "func ") </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov2" title="2">rest := strings.TrimSpace(strings.TrimPrefix(line, "func ")) + <span class="cov3" title="6">rest := strings.TrimSpace(strings.TrimPrefix(line, "func ")) // method receiver - if strings.HasPrefix(rest, "(") </span><span class="cov1" title="1">{ + if strings.HasPrefix(rest, "(") </span><span class="cov2" title="3">{ // find ")" - if i := strings.Index(rest, ")"); i >= 0 && i+1 < len(rest) </span><span class="cov1" title="1">{ + if i := strings.Index(rest, ")"); i >= 0 && i+1 < len(rest) </span><span class="cov2" title="3">{ rest = strings.TrimSpace(rest[i+1:]) }</span> } // now rest should start with Name( - <span class="cov2" title="2">if i := strings.Index(rest, "("); i > 0 </span><span class="cov2" title="2">{ + <span class="cov3" title="6">if i := strings.Index(rest, "("); i > 0 </span><span class="cov3" title="6">{ return strings.TrimSpace(rest[:i]) }</span> <span class="cov0" title="0">return ""</span> @@ -6366,7 +6884,7 @@ func exportName(name string) string <span class="cov0" title="0">{ } </pre> - <pre class="file" id="file28" style="display: none">// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic. + <pre class="file" id="file29" style="display: none">// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic. package lsp import ( @@ -6397,14 +6915,14 @@ type completionPlan struct { cacheKey string } -func (s *Server) handleCompletion(req Request) <span class="cov3" title="3">{ - if s.completionDisabled() </span><span class="cov1" title="1">{ +func (s *Server) handleCompletion(req Request) <span class="cov5" title="9">{ + if s.completionDisabled() </span><span class="cov3" title="3">{ s.reply(req.ID, CompletionList{IsIncomplete: false, Items: nil}, nil) return }</span> - <span class="cov2" title="2">var p CompletionParams + <span class="cov4" title="6">var p CompletionParams var docStr string - if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov2" title="2">{ + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov4" title="6">{ // Log trigger information for every completion request from client tk, tch := extractTriggerInfo(p) logging.Logf("lsp ", "completion trigger kind=%d char=%q uri=%s line=%d char=%d", @@ -6414,11 +6932,11 @@ func (s *Server) handleCompletion(req Request) <span class="cov3" title="3">{ if s.logContext </span><span class="cov0" title="0">{ s.logCompletionContext(p, above, current, below, funcCtx) }</span> - <span class="cov2" title="2">if s.llmClient != nil </span><span class="cov2" title="2">{ + <span class="cov4" title="6">if s.llmClient != nil </span><span class="cov4" title="6">{ newFunc := s.isDefiningNewFunction(p.TextDocument.URI, p.Position) extra, has := s.buildAdditionalContext(newFunc, p.TextDocument.URI, p.Position) items, ok, incomplete := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, has, extra) - if ok </span><span class="cov2" title="2">{ + if ok </span><span class="cov4" title="6">{ s.reply(req.ID, CompletionList{IsIncomplete: incomplete, Items: items}, nil) return }</span> @@ -6430,51 +6948,51 @@ func (s *Server) handleCompletion(req Request) <span class="cov3" title="3">{ // extractTriggerInfo returns the LSP completion TriggerKind and TriggerCharacter // if provided by the client; when absent it returns zeros. -func extractTriggerInfo(p CompletionParams) (kind int, ch string) <span class="cov3" title="3">{ +func extractTriggerInfo(p CompletionParams) (kind int, ch string) <span class="cov5" title="9">{ if p.Context == nil </span><span class="cov0" title="0">{ return 0, "" }</span> - <span class="cov3" title="3">var ctx struct { + <span class="cov5" title="9">var ctx struct { TriggerKind int `json:"triggerKind"` TriggerCharacter string `json:"triggerCharacter,omitempty"` } - if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov1" title="1">{ + if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov3" title="3">{ _ = json.Unmarshal(raw, &ctx) - }</span> else<span class="cov2" title="2"> { + }</span> else<span class="cov4" title="6"> { b, _ := json.Marshal(p.Context) _ = json.Unmarshal(b, &ctx) }</span> - <span class="cov3" title="3">return ctx.TriggerKind, ctx.TriggerCharacter</span> + <span class="cov5" title="9">return ctx.TriggerKind, ctx.TriggerCharacter</span> } // --- completion helpers --- -func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string <span class="cov3" title="3">{ +func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string <span class="cov5" title="9">{ return fmt.Sprintf("file: %s\nline: %d\nabove: %s\ncurrent: %s\nbelow: %s\nfunction: %s", p.TextDocument.URI, p.Position.Line, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) }</span> -func (s *Server) logCompletionContext(p CompletionParams, above, current, below, funcCtx string) <span class="cov1" title="1">{ +func (s *Server) logCompletionContext(p CompletionParams, above, current, below, funcCtx string) <span class="cov3" title="3">{ logging.Logf("lsp ", "completion ctx uri=%s line=%d char=%d above=%q current=%q below=%q function=%q", p.TextDocument.URI, p.Position.Line, p.Position.Character, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) }</span> -func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool, bool) <span class="cov8" title="19">{ +func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool, bool) <span class="cov8" title="57">{ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) var cancelOnce sync.Once - end := func() </span><span class="cov8" title="19">{ cancelOnce.Do(cancel) }</span> + end := func() </span><span class="cov8" title="57">{ cancelOnce.Do(cancel) }</span> - <span class="cov8" title="19">plan, items, handled := s.prepareCompletionPlan(p, above, current, below, funcCtx, docStr, hasExtra, extraText) - if handled </span><span class="cov6" title="8">{ + <span class="cov8" title="57">plan, items, handled := s.prepareCompletionPlan(p, above, current, below, funcCtx, docStr, hasExtra, extraText) + if handled </span><span class="cov6" title="24">{ end() return items, true, false }</span> - <span class="cov6" title="11">specs := s.buildRequestSpecs(surfaceCompletion) + <span class="cov7" title="33">specs := s.buildRequestSpecs(surfaceCompletion) if len(specs) == 0 </span><span class="cov0" title="0">{ end() return nil, false, false }</span> - <span class="cov6" title="11">type jobResult struct { + <span class="cov7" title="33">type jobResult struct { items []CompletionItem ok bool } @@ -6487,42 +7005,60 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun close(results) return nil, false, false }</span> - <span class="cov6" title="11">for _, spec := range specs </span><span class="cov6" title="11">{ + <span class="cov7" title="33">for _, spec := range specs </span><span class="cov7" title="33">{ spec := spec client := s.clientFor(spec) if client == nil </span><span class="cov0" title="0">{ continue</span> } - <span class="cov6" title="11">started++ + <span class="cov7" title="33">started++ wg.Add(1) - go func(idx int, spec requestSpec, client llm.Client) </span><span class="cov6" title="11">{ + go func(idx int, spec requestSpec, client llm.Client) </span><span class="cov7" title="33">{ defer wg.Done() items, ok := s.runCompletionForSpec(ctx, plan, spec, client) results <- jobResult{items: items, ok: ok} }</span>(spec.index, spec, client) } - <span class="cov6" title="11">if started == 0 </span><span class="cov0" title="0">{ + <span class="cov7" title="33">if started == 0 </span><span class="cov0" title="0">{ end() close(results) return nil, false, false }</span> - <span class="cov6" title="11">go func() </span><span class="cov6" title="11">{ + <span class="cov7" title="33">go func() </span><span class="cov7" title="33">{ wg.Wait() close(results) }</span>() - <span class="cov6" title="11">if started == 1 </span><span class="cov6" title="11">{ + <span class="cov7" title="33">if started == 1 </span><span class="cov7" title="33">{ res := <-results if !res.ok || len(res.items) == 0 </span><span class="cov0" title="0">{ end() return nil, false, false }</span> - <span class="cov6" title="11">end() + <span class="cov7" title="33">end() return res.items, true, false</span> } + <span class="cov0" title="0">waitAll := s.completionWaitAll() + if waitAll </span><span class="cov0" title="0">{ + // Wait for all backends, return combined results + defer end() + combined := make([]CompletionItem, 0) + for res := range results </span><span class="cov0" title="0">{ + if !res.ok || len(res.items) == 0 </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov0" title="0">combined = append(combined, res.items...)</span> + } + <span class="cov0" title="0">if len(combined) == 0 </span><span class="cov0" title="0">{ + return nil, false, false + }</span> + <span class="cov0" title="0">return combined, true, false</span> + } + + // Return first result immediately, store combined for later <span class="cov0" title="0">firstCh := make(chan []CompletionItem, 1) go func(planKey string) </span><span class="cov0" title="0">{ defer end() @@ -6556,7 +7092,7 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun <span class="cov0" title="0">return firstItems, true, true</span> } -func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) (completionPlan, []CompletionItem, bool) <span class="cov8" title="19">{ +func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) (completionPlan, []CompletionItem, bool) <span class="cov8" title="57">{ plan := completionPlan{ params: p, above: above, @@ -6569,39 +7105,39 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below } openStr, _, openChar, closeChar := s.inlineMarkers() plan.inlinePrompt = lineHasInlinePrompt(current, openStr, openChar, closeChar) - if !plan.inlinePrompt && !s.isTriggerEvent(p, current) </span><span class="cov6" title="8">{ + if !plan.inlinePrompt && !s.isTriggerEvent(p, current) </span><span class="cov6" title="24">{ 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="11">if s.shouldSuppressForChatTriggerEOL(current, p) </span><span class="cov0" title="0">{ + <span class="cov7" title="33">if s.shouldSuppressForChatTriggerEOL(current, p) </span><span class="cov0" title="0">{ return plan, []CompletionItem{}, true }</span> - <span class="cov6" title="11">plan.inParams = inParamList(current, p.Position.Character) + <span class="cov7" title="33">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 pending := s.takePendingCompletion(plan.cacheKey); len(pending) > 0 </span><span class="cov0" title="0">{ return plan, pending, true }</span> - <span class="cov6" title="11">if isBareDoubleOpen(current, openStr, openChar, closeChar) || isBareDoubleOpen(below, openStr, openChar, closeChar) </span><span class="cov0" title="0">{ + <span class="cov7" title="33">if isBareDoubleOpen(current, openStr, openChar, closeChar) || isBareDoubleOpen(below, openStr, 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="11">if !plan.inParams && !s.prefixHeuristicAllows(plan.inlinePrompt, current, p, plan.manualInvoke) </span><span class="cov0" title="0">{ + <span class="cov7" title="33">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="11">return plan, nil, false</span> + <span class="cov7" title="33">return plan, nil, false</span> } -func (s *Server) runCompletionForSpec(ctx context.Context, plan completionPlan, spec requestSpec, client llm.Client) ([]CompletionItem, bool) <span class="cov6" title="11">{ +func (s *Server) runCompletionForSpec(ctx context.Context, plan completionPlan, spec requestSpec, client llm.Client) ([]CompletionItem, bool) <span class="cov7" title="33">{ sortPrefix := fmt.Sprintf("%04d", spec.index) modelKey := spec.effectiveModel(client.DefaultModel()) providerKey := spec.provider if providerKey == "" </span><span class="cov0" title="0">{ providerKey = canonicalProvider(client.Name()) }</span> - <span class="cov6" title="11">cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelKey - if cached, ok := s.completionCacheGet(cacheKey); ok && strings.TrimSpace(cached) != "" </span><span class="cov1" title="1">{ + <span class="cov7" title="33">cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelKey + if cached, ok := s.completionCacheGet(cacheKey); ok && strings.TrimSpace(cached) != "" </span><span class="cov3" title="3">{ logging.Logf("lsp ", "completion cache hit uri=%s line=%d char=%d preview=%s%s%s", plan.params.TextDocument.URI, plan.params.Position.Line, plan.params.Position.Character, logging.AnsiGreen, logging.PreviewForLog(cached), logging.AnsiBase) @@ -6609,26 +7145,26 @@ func (s *Server) runCompletionForSpec(ctx context.Context, plan completionPlan, items := s.makeCompletionItems(cached, plan.inParams, plan.current, plan.params, plan.docStr, detail, sortPrefix) return items, true }</span> - <span class="cov6" title="10">if items, ok := s.tryProviderNativeCompletion(ctx, plan, spec, client, sortPrefix); ok </span><span class="cov1" title="1">{ + <span class="cov7" title="30">if items, ok := s.tryProviderNativeCompletion(ctx, plan, spec, client, sortPrefix); ok </span><span class="cov3" title="3">{ return items, true }</span> - <span class="cov6" title="9">return s.executeChatCompletion(ctx, plan, spec, client, sortPrefix)</span> + <span class="cov7" title="27">return s.executeChatCompletion(ctx, plan, spec, client, sortPrefix)</span> } -func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan, spec requestSpec, client llm.Client, sortPrefix string) ([]CompletionItem, bool) <span class="cov6" title="9">{ +func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan, spec requestSpec, client llm.Client, sortPrefix string) ([]CompletionItem, bool) <span class="cov7" title="27">{ 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="cov8" title="18">{ + for _, m := range messages </span><span class="cov8" title="54">{ sentSize += len(m.Content) }</span> - <span class="cov6" title="9">s.incSentCounters(sentSize) + <span class="cov7" title="27">s.incSentCounters(sentSize) text, err := client.Chat(ctx, messages, spec.options...) 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="cov6" title="9">s.incRecvCounters(len(text)) + <span class="cov7" title="27">s.incRecvCounters(len(text)) modelUsed := spec.effectiveModel(client.DefaultModel()) _ = stats.Update(ctx, client.Name(), modelUsed, sentSize, len(text)) s.logLLMStats(modelUsed) @@ -6637,101 +7173,101 @@ func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan, if cleaned == "" </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov6" title="9">detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelUsed) + <span class="cov7" title="27">detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelUsed) providerKey := spec.provider if providerKey == "" </span><span class="cov0" title="0">{ providerKey = canonicalProvider(client.Name()) }</span> - <span class="cov6" title="9">cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelUsed + <span class="cov7" title="27">cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelUsed s.completionCachePut(cacheKey, cleaned) items := s.makeCompletionItems(cleaned, plan.inParams, plan.current, plan.params, plan.docStr, detail, sortPrefix) 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="cov7" title="12">{ - if ctx == nil </span><span class="cov4" title="5">{ +func parseManualInvoke(ctx any) bool <span class="cov7" title="36">{ + if ctx == nil </span><span class="cov6" title="15">{ return false }</span> - <span class="cov5" title="7">var c struct { + <span class="cov6" title="21">var c struct { TriggerKind int `json:"triggerKind"` } - if raw, ok := ctx.(json.RawMessage); ok </span><span class="cov4" title="5">{ + if raw, ok := ctx.(json.RawMessage); ok </span><span class="cov6" title="15">{ _ = json.Unmarshal(raw, &c) - }</span> else<span class="cov2" title="2"> { + }</span> else<span class="cov4" title="6"> { b, _ := json.Marshal(ctx) _ = json.Unmarshal(b, &c) }</span> - <span class="cov5" title="7">return c.TriggerKind == 1</span> + <span class="cov6" title="21">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="16">{ +func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool <span class="cov8" title="48">{ t := strings.TrimRight(current, " \t") suffix, prefixes, _ := s.chatConfig() - if suffix == "" </span><span class="cov1" title="1">{ + if suffix == "" </span><span class="cov3" title="3">{ return false }</span> - <span class="cov7" title="15">if strings.HasSuffix(t, suffix) </span><span class="cov4" title="5">{ + <span class="cov8" title="45">if strings.HasSuffix(t, suffix) </span><span class="cov6" title="15">{ if len(t) < len(suffix)+1 </span><span class="cov0" title="0">{ return false }</span> - <span class="cov4" title="5">prev := string(t[len(t)-len(suffix)-1]) - for _, pf := range prefixes </span><span class="cov7" title="14">{ - if prev == pf </span><span class="cov2" title="2">{ + <span class="cov6" title="15">prev := string(t[len(t)-len(suffix)-1]) + for _, pf := range prefixes </span><span class="cov8" title="42">{ + if prev == pf </span><span class="cov4" title="6">{ logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) return true }</span> } } - <span class="cov7" title="13">return false</span> + <span class="cov7" title="39">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="16">{ +func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p CompletionParams, manualInvoke bool) bool <span class="cov8" title="48">{ // 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="16">allowNoPrefix := inlinePrompt - if idx > 0 </span><span class="cov7" title="14">{ + <span class="cov8" title="48">allowNoPrefix := inlinePrompt + if idx > 0 </span><span class="cov8" title="42">{ ch := current[idx-1] - if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' </span><span class="cov4" title="5">{ + if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' </span><span class="cov6" title="15">{ allowNoPrefix = true }</span> } - <span class="cov7" title="16">if allowNoPrefix </span><span class="cov6" title="8">{ + <span class="cov8" title="48">if allowNoPrefix </span><span class="cov6" title="24">{ return true }</span> // Walk left over whitespace - <span class="cov6" title="8">j := idx - for j > 0 </span><span class="cov9" title="27">{ + <span class="cov6" title="24">j := idx + for j > 0 </span><span class="cov9" title="81">{ c := current[j-1] - if c == ' ' || c == '\t' </span><span class="cov8" title="20">{ + if c == ' ' || c == '\t' </span><span class="cov8" title="60">{ j-- continue</span> } - <span class="cov5" title="7">break</span> + <span class="cov6" title="21">break</span> } - <span class="cov6" title="8">start := computeWordStart(current, j) + <span class="cov6" title="24">start := computeWordStart(current, j) min := 1 - if manualInvoke </span><span class="cov4" title="5">{ - if v := s.manualInvokeMinPrefix(); v >= 0 </span><span class="cov4" title="5">{ + if manualInvoke </span><span class="cov6" title="15">{ + if v := s.manualInvokeMinPrefix(); v >= 0 </span><span class="cov6" title="15">{ min = v }</span> } - <span class="cov6" title="8">return j-start >= min</span> + <span class="cov6" title="24">return j-start >= min</span> } // tryProviderNativeCompletion attempts provider-native completion and returns items when successful. -func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completionPlan, spec requestSpec, client llm.Client, sortPrefix string) ([]CompletionItem, bool) <span class="cov7" title="13">{ +func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completionPlan, spec requestSpec, client llm.Client, sortPrefix string) ([]CompletionItem, bool) <span class="cov7" title="39">{ cc, ok := client.(llm.CodeCompleter) - if !ok </span><span class="cov5" title="7">{ + if !ok </span><span class="cov6" title="21">{ return nil, false }</span> - <span class="cov5" title="6">current := plan.current + <span class="cov6" title="18">current := plan.current p := plan.params before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) path := strings.TrimPrefix(p.TextDocument.URI, "file://") @@ -6745,7 +7281,7 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio if provider == "" </span><span class="cov0" title="0">{ provider = canonicalProvider(cfg.Provider) }</span> - <span class="cov5" title="6">logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", provider, path) + <span class="cov6" title="18">logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", provider, path) ctx2, cancel2 := context.WithTimeout(ctx, 15*time.Second) defer cancel2() sentBytes := len(prompt) + len(after) @@ -6754,14 +7290,14 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio if val, ok := chooseSurfaceTemperature(surfaceCompletion, cfg, spec.entry, provider, modelUsed); ok </span><span class="cov0" title="0">{ tempVal = val }</span> - <span class="cov5" title="6">suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, "", tempVal) - if err != nil || len(suggestions) == 0 </span><span class="cov2" title="2">{ - if err != nil </span><span class="cov2" title="2">{ + <span class="cov6" title="18">suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, "", tempVal) + if err != nil || len(suggestions) == 0 </span><span class="cov4" title="6">{ + if err != nil </span><span class="cov4" title="6">{ logging.Logf("lsp ", "completion path=codex error=%v (falling back)", err) }</span> - <span class="cov2" title="2">return nil, false</span> + <span class="cov4" title="6">return nil, false</span> } - <span class="cov4" title="4">s.incSentCounters(sentBytes) + <span class="cov5" title="12">s.incSentCounters(sentBytes) s.incRecvCounters(len(suggestions[0])) _ = stats.Update(ctx2, client.Name(), modelUsed, sentBytes, len(suggestions[0])) s.logLLMStats(modelUsed) @@ -6769,29 +7305,29 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio if cleaned == "" </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov4" title="4">cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) + <span class="cov5" title="12">cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) if cleaned == "" </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov4" title="4">cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) + <span class="cov5" title="12">cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) if cleaned == "" </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov4" title="4">if strings.TrimSpace(cleaned) != "" && hasDoubleOpenTrigger(current, openStr, openChar, closeChar) </span><span class="cov1" title="1">{ + <span class="cov5" title="12">if strings.TrimSpace(cleaned) != "" && hasDoubleOpenTrigger(current, openStr, openChar, closeChar) </span><span class="cov3" title="3">{ indent := leadingIndent(current) - if indent != "" </span><span class="cov1" title="1">{ + if indent != "" </span><span class="cov3" title="3">{ cleaned = applyIndent(indent, cleaned) }</span> } - <span class="cov4" title="4">if strings.TrimSpace(cleaned) == "" </span><span class="cov0" title="0">{ + <span class="cov5" title="12">if strings.TrimSpace(cleaned) == "" </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov4" title="4">detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelUsed) + <span class="cov5" title="12">detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelUsed) providerKey := provider if providerKey == "" </span><span class="cov0" title="0">{ providerKey = canonicalProvider(client.Name()) }</span> - <span class="cov4" title="4">cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelUsed + <span class="cov5" title="12">cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelUsed s.completionCachePut(cacheKey, cleaned) items := s.makeCompletionItems(cleaned, plan.inParams, current, p, plan.docStr, detail, sortPrefix) return items, true</span> @@ -6799,29 +7335,29 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio // 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="40">{ +func (s *Server) waitForDebounce(ctx context.Context) <span class="cov10" title="120">{ d := s.completionDebounce() - if d <= 0 </span><span class="cov9" title="38">{ + if d <= 0 </span><span class="cov9" title="114">{ return }</span> - <span class="cov2" title="2">for </span><span class="cov4" title="4">{ + <span class="cov4" title="6">for </span><span class="cov5" title="12">{ s.mu.RLock() last := s.lastInput s.mu.RUnlock() if last.IsZero() </span><span class="cov0" title="0">{ return }</span> - <span class="cov4" title="4">since := time.Since(last) - if since >= d </span><span class="cov2" title="2">{ + <span class="cov5" title="12">since := time.Since(last) + if since >= d </span><span class="cov4" title="6">{ return }</span> - <span class="cov2" title="2">rem := d - since + <span class="cov4" title="6">rem := d - since timer := time.NewTimer(rem) select </span>{ case <-ctx.Done():<span class="cov0" title="0"> timer.Stop() return</span> - case <-timer.C:<span class="cov2" title="2"></span> + case <-timer.C:<span class="cov4" title="6"></span> // loop and re-evaluate in case input occurred during sleep } } @@ -6829,17 +7365,17 @@ 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="40">{ +func (s *Server) waitForThrottle(ctx context.Context) bool <span class="cov10" title="120">{ interval := s.completionThrottle() - if interval <= 0 </span><span class="cov9" title="37">{ + if interval <= 0 </span><span class="cov9" title="111">{ return true }</span> - <span class="cov3" title="3">var wait time.Duration - for </span><span class="cov4" title="5">{ + <span class="cov5" title="9">var wait time.Duration + for </span><span class="cov6" title="15">{ s.mu.Lock() next := s.lastLLMCall.Add(interval) now := time.Now() - if now.Before(next) </span><span class="cov2" title="2">{ + if now.Before(next) </span><span class="cov4" title="6">{ wait = next.Sub(now) s.mu.Unlock() timer := time.NewTimer(wait) @@ -6847,20 +7383,20 @@ func (s *Server) waitForThrottle(ctx context.Context) bool <span class="cov10" t case <-ctx.Done():<span class="cov0" title="0"> timer.Stop() return false</span> - case <-timer.C:<span class="cov2" title="2"> + case <-timer.C:<span class="cov4" title="6"> // try again to set the next call time continue</span> } } // we are allowed to proceed now; record this call as the latest - <span class="cov3" title="3">s.lastLLMCall = now + <span class="cov5" title="9">s.lastLLMCall = now s.mu.Unlock() return true</span> } } // 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="15">{ +func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message <span class="cov8" title="45">{ vars := map[string]string{ "file": p.TextDocument.URI, "function": funcCtx, @@ -6872,50 +7408,50 @@ func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText cfg := s.currentConfig() sys := cfg.PromptCompletionSystemGeneral userTpl := cfg.PromptCompletionUserGeneral - if inParams </span><span class="cov2" title="2">{ + if inParams </span><span class="cov4" title="6">{ sys = cfg.PromptCompletionSystemParams userTpl = cfg.PromptCompletionUserParams }</span> - <span class="cov7" title="15">if inlinePrompt && strings.TrimSpace(cfg.PromptCompletionSystemInline) != "" </span><span class="cov2" title="2">{ + <span class="cov8" title="45">if inlinePrompt && strings.TrimSpace(cfg.PromptCompletionSystemInline) != "" </span><span class="cov4" title="6">{ sys = cfg.PromptCompletionSystemInline }</span> - <span class="cov7" title="15">user := renderTemplate(userTpl, vars) + <span class="cov8" title="45">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">{ + if hasExtra && strings.TrimSpace(extraText) != "" </span><span class="cov3" title="3">{ 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="cov3" title="3">messages = append(messages, llm.Message{Role: "user", Content: extra})</span> } - <span class="cov7" title="15">return messages</span> + <span class="cov8" title="45">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="cov7" title="12">{ +func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string <span class="cov7" title="36">{ 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="cov7" title="12">if cleaned != "" </span><span class="cov7" title="12">{ + <span class="cov7" title="36">if cleaned != "" </span><span class="cov7" title="36">{ cleaned = stripDuplicateAssignmentPrefix(leftOfCursor, cleaned) }</span> - <span class="cov7" title="12">if cleaned != "" </span><span class="cov7" title="12">{ + <span class="cov7" title="36">if cleaned != "" </span><span class="cov7" title="36">{ cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) }</span> - <span class="cov7" title="12">openStr, _, openChar, closeChar := s.inlineMarkers() - if cleaned != "" && hasDoubleOpenTrigger(currentLine, openStr, openChar, closeChar) </span><span class="cov2" title="2">{ - if indent := leadingIndent(currentLine); indent != "" </span><span class="cov1" title="1">{ + <span class="cov7" title="36">openStr, _, openChar, closeChar := s.inlineMarkers() + if cleaned != "" && hasDoubleOpenTrigger(currentLine, openStr, openChar, closeChar) </span><span class="cov4" title="6">{ + if indent := leadingIndent(currentLine); indent != "" </span><span class="cov3" title="3">{ cleaned = applyIndent(indent, cleaned) }</span> } - <span class="cov7" title="12">return cleaned</span> + <span class="cov7" title="36">return cleaned</span> } </pre> - <pre class="file" id="file29" style="display: none">// Summary: Document open/change/close and in-editor chat handlers split out of handlers.go. + <pre class="file" id="file30" style="display: none">// Summary: Document open/change/close and in-editor chat handlers split out of handlers.go. package lsp import ( @@ -6928,29 +7464,29 @@ import ( "codeberg.org/snonux/hexai/internal/logging" ) -func (s *Server) handleDidOpen(req Request) <span class="cov1" title="1">{ +func (s *Server) handleDidOpen(req Request) <span class="cov3" title="3">{ var p DidOpenTextDocumentParams - if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov1" title="1">{ + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov3" title="3">{ s.setDocument(p.TextDocument.URI, p.TextDocument.Text) s.markActivity() }</span> } -func (s *Server) handleDidChange(req Request) <span class="cov1" title="1">{ +func (s *Server) handleDidChange(req Request) <span class="cov3" title="3">{ var p DidChangeTextDocumentParams - if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov1" title="1">{ - if len(p.ContentChanges) > 0 </span><span class="cov1" title="1">{ + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov3" title="3">{ + if len(p.ContentChanges) > 0 </span><span class="cov3" title="3">{ s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text) }</span> - <span class="cov1" title="1">s.markActivity() + <span class="cov3" title="3">s.markActivity() // Detect in-editor chat trigger lines and respond inline. s.detectAndHandleChat(p.TextDocument.URI)</span> } } -func (s *Server) handleDidClose(req Request) <span class="cov1" title="1">{ +func (s *Server) handleDidClose(req Request) <span class="cov3" title="3">{ var p DidCloseTextDocumentParams - if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov1" title="1">{ + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov3" title="3">{ s.deleteDocument(p.TextDocument.URI) s.markActivity() }</span> @@ -6959,42 +7495,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="8">{ +func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) <span class="cov7" title="24">{ d := s.getDocument(uri) - if d == nil </span><span class="cov4" title="4">{ + if d == nil </span><span class="cov6" title="12">{ return "", "" }</span> // Clamp indices - <span class="cov4" title="4">line := pos.Line + <span class="cov6" title="12">line := pos.Line if line < 0 </span><span class="cov0" title="0">{ line = 0 }</span> - <span class="cov4" title="4">if line >= len(d.lines) </span><span class="cov1" title="1">{ + <span class="cov6" title="12">if line >= len(d.lines) </span><span class="cov3" title="3">{ line = len(d.lines) - 1 }</span> - <span class="cov4" title="4">col := pos.Character + <span class="cov6" title="12">col := pos.Character if col < 0 </span><span class="cov0" title="0">{ col = 0 }</span> - <span class="cov4" title="4">if col > len(d.lines[line]) </span><span class="cov1" title="1">{ + <span class="cov6" title="12">if col > len(d.lines[line]) </span><span class="cov3" title="3">{ col = len(d.lines[line]) }</span> // Build before - <span class="cov4" title="4">var b strings.Builder - for i := 0; i < line; i++ </span><span class="cov5" title="5">{ + <span class="cov6" title="12">var b strings.Builder + for i := 0; i < line; i++ </span><span class="cov6" title="15">{ b.WriteString(d.lines[i]) b.WriteByte('\n') }</span> - <span class="cov4" title="4">b.WriteString(d.lines[line][:col]) + <span class="cov6" title="12">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="cov4" title="4">{ + for i := line + 1; i < len(d.lines); i++ </span><span class="cov6" title="12">{ a.WriteByte('\n') a.WriteString(d.lines[i]) }</span> - <span class="cov4" title="4">return before, a.String()</span> + <span class="cov6" title="12">return before, a.String()</span> } // --- in-editor chat (";C ...") --- @@ -7002,14 +7538,14 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) <span // detectAndHandleChat scans the current document for any line that starts with // a new trigger pair (e.g., "?>" ",>" ":>" ";>") at EOL and inserts the LLM // reply below. -func (s *Server) detectAndHandleChat(uri string) <span class="cov7" title="11">{ +func (s *Server) detectAndHandleChat(uri string) <span class="cov8" title="33">{ d := s.getDocument(uri) if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ return }</span> - <span class="cov7" title="11">suffix, prefixes, _ := s.chatConfig() + <span class="cov8" title="33">suffix, prefixes, _ := s.chatConfig() openStr, _, openChar, closeChar := s.inlineMarkers() - for i, raw := range d.lines </span><span class="cov9" title="23">{ + for i, raw := range d.lines </span><span class="cov9" title="69">{ if lineHasInlinePrompt(raw, openStr, openChar, closeChar) </span><span class="cov0" title="0">{ if s.currentLLMClient() != nil </span><span class="cov0" title="0">{ pos := Position{Line: i, Character: len(raw)} @@ -7018,67 +7554,67 @@ func (s *Server) detectAndHandleChat(uri string) <span class="cov7" title="11">{ <span class="cov0" title="0">continue</span> } // Find last non-space character index - <span class="cov9" title="23">j := len(raw) - 1 - for j >= 0 </span><span class="cov9" title="20">{ + <span class="cov9" title="69">j := len(raw) - 1 + for j >= 0 </span><span class="cov9" title="60">{ if raw[j] == ' ' || raw[j] == '\t' </span><span class="cov0" title="0">{ j-- continue</span> } - <span class="cov9" title="20">break</span> + <span class="cov9" title="60">break</span> } - <span class="cov9" title="23">if j < 0 </span><span class="cov4" title="3">{ + <span class="cov9" title="69">if j < 0 </span><span class="cov5" title="9">{ continue</span> } // Check suffix and derive the prompt text before validating prefixes - <span class="cov9" title="20">if suffix == "" </span><span class="cov0" title="0">{ + <span class="cov9" title="60">if suffix == "" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov9" title="20">if string(raw[j]) != suffix </span><span class="cov7" title="10">{ + <span class="cov9" title="60">if string(raw[j]) != suffix </span><span class="cov7" title="30">{ continue</span> } - <span class="cov7" title="10">removeCount := len(suffix) + <span class="cov7" title="30">removeCount := len(suffix) base := raw[:j+1-removeCount] prompt := strings.TrimSpace(base) if prompt == "" </span><span class="cov0" title="0">{ continue</span> } // Slash commands (`/foo>`) do not require a prefix trigger. - <span class="cov7" title="10">isCommand := strings.HasPrefix(prompt, "/") - if !isCommand </span><span class="cov7" title="9">{ + <span class="cov7" title="30">isCommand := strings.HasPrefix(prompt, "/") + if !isCommand </span><span class="cov7" title="27">{ // Require at least one char before suffix and that char must be in chatPrefixes if j < 1 </span><span class="cov0" title="0">{ continue</span> } - <span class="cov7" title="9">prev := string(raw[j-1]) + <span class="cov7" title="27">prev := string(raw[j-1]) match := false - for _, pfx := range prefixes </span><span class="cov7" title="9">{ - if prev == pfx </span><span class="cov7" title="9">{ + for _, pfx := range prefixes </span><span class="cov7" title="27">{ + if prev == pfx </span><span class="cov7" title="27">{ match = true break</span> } } - <span class="cov7" title="9">if !match </span><span class="cov0" title="0">{ + <span class="cov7" title="27">if !match </span><span class="cov0" title="0">{ continue</span> } } // Avoid double-answering: if the next non-empty line starts with '>' we skip. - <span class="cov7" title="10">k := i + 1 - for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" </span><span class="cov7" title="11">{ + <span class="cov7" title="30">k := i + 1 + for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" </span><span class="cov8" title="33">{ k++ }</span> - <span class="cov7" title="10">if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") </span><span class="cov1" title="1">{ + <span class="cov7" title="30">if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") </span><span class="cov3" title="3">{ continue</span> } - <span class="cov7" title="9">lineIdx := i + <span class="cov7" title="27">lineIdx := i lastIdx := j - if resp, ok := s.chatCommandResponse(uri, lineIdx, prompt); ok </span><span class="cov1" title="1">{ + if resp, ok := s.chatCommandResponse(uri, lineIdx, prompt); ok </span><span class="cov3" title="3">{ msg := strings.TrimSpace(resp.message) - if msg != "" </span><span class="cov1" title="1">{ + if msg != "" </span><span class="cov3" title="3">{ s.applyChatEdits(uri, lineIdx, lastIdx, removeCount, "> "+msg) }</span> - <span class="cov1" title="1">return</span> + <span class="cov3" title="3">return</span> } - <span class="cov6" title="8">go func(prompt string, remove int) </span><span class="cov6" title="8">{ + <span class="cov7" title="24">go func(prompt string, remove int) </span><span class="cov7" title="24">{ ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() // Build messages with history and context_mode aware extras. @@ -7089,33 +7625,33 @@ func (s *Server) detectAndHandleChat(uri string) <span class="cov7" title="11">{ if client == nil </span><span class="cov0" title="0">{ return }</span> - <span class="cov6" title="8">modelUsed := spec.effectiveModel(client.DefaultModel()) + <span class="cov7" title="24">modelUsed := spec.effectiveModel(client.DefaultModel()) logging.Logf("lsp ", "chat llm=requesting model=%s", modelUsed) text, err := s.chatWithStats(ctx, surfaceChat, spec, msgs) if err != nil </span><span class="cov0" title="0">{ logging.Logf("lsp ", "chat llm error: %v", err) return }</span> - <span class="cov6" title="8">out := strings.TrimSpace(stripCodeFences(text)) + <span class="cov7" title="24">out := strings.TrimSpace(stripCodeFences(text)) if out == "" </span><span class="cov0" title="0">{ return }</span> - <span class="cov6" title="8">s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out)</span> + <span class="cov7" title="24">s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out)</span> }(prompt, removeCount) // Only handle one per change tick to avoid flooding - <span class="cov6" title="8">break</span> + <span class="cov7" title="24">break</span> } } // applyChatEdits removes the triggering punctuation at end of the line and // inserts two newlines followed by a new line with the response prefixed. -func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) <span class="cov7" title="9">{ +func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) <span class="cov7" title="27">{ d := s.getDocument(uri) if d == nil </span><span class="cov0" title="0">{ return }</span> // 1) Delete the trailing punctuation (1 or 2 chars) - <span class="cov7" title="9">delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} + <span class="cov7" title="27">delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} delEnd := Position{Line: lineIdx, Character: lastNonSpace + 1} // 2) Insert two newlines and the response at end-of-line, then one extra blank line insPos := Position{Line: lineIdx, Character: len(d.lines[lineIdx])} @@ -7172,78 +7708,78 @@ func (s *Server) applyInlineCompletion(uri string, item CompletionItem) <span cl // buildChatHistory walks upwards from the current line to collect the most recent // Q/A pairs in the in-editor transcript. Returns messages ending with current prompt. -func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message <span class="cov7" title="9">{ +func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message <span class="cov7" title="27">{ d := s.getDocument(uri) if d == nil </span><span class="cov0" title="0">{ return []llm.Message{{Role: "user", Content: currentPrompt}} }</span> - <span class="cov7" title="9">type pair struct{ q, a string } + <span class="cov7" title="27">type pair struct{ q, a string } pairs := []pair{} i := lineIdx - 1 - for i >= 0 && len(pairs) < 3 </span><span class="cov6" title="7">{ - for i >= 0 && strings.TrimSpace(d.lines[i]) == "" </span><span class="cov1" title="1">{ + for i >= 0 && len(pairs) < 3 </span><span class="cov7" title="21">{ + for i >= 0 && strings.TrimSpace(d.lines[i]) == "" </span><span class="cov3" title="3">{ i-- }</span> - <span class="cov6" title="7">if i < 0 </span><span class="cov0" title="0">{ + <span class="cov7" title="21">if i < 0 </span><span class="cov0" title="0">{ break</span> } - <span class="cov6" title="7">if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") </span><span class="cov5" title="5">{ + <span class="cov7" title="21">if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") </span><span class="cov6" title="15">{ break</span> } - <span class="cov2" title="2">var replyLines []string - for i >= 0 </span><span class="cov4" title="4">{ + <span class="cov4" title="6">var replyLines []string + for i >= 0 </span><span class="cov6" title="12">{ line := strings.TrimSpace(d.lines[i]) - if strings.HasPrefix(line, ">") </span><span class="cov2" title="2">{ + if strings.HasPrefix(line, ">") </span><span class="cov4" title="6">{ replyLines = append([]string{strings.TrimSpace(strings.TrimPrefix(line, ">"))}, replyLines...) i-- continue</span> } - <span class="cov2" title="2">break</span> + <span class="cov4" title="6">break</span> } - <span class="cov2" title="2">for i >= 0 && strings.TrimSpace(d.lines[i]) == "" </span><span class="cov0" title="0">{ + <span class="cov4" title="6">for i >= 0 && strings.TrimSpace(d.lines[i]) == "" </span><span class="cov0" title="0">{ i-- }</span> - <span class="cov2" title="2">if i < 0 </span><span class="cov0" title="0">{ + <span class="cov4" title="6">if i < 0 </span><span class="cov0" title="0">{ break</span> } - <span class="cov2" title="2">q := strings.TrimSpace(d.lines[i]) + <span class="cov4" title="6">q := strings.TrimSpace(d.lines[i]) q = s.stripTrailingTrigger(q) pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) i--</span> } - <span class="cov7" title="9">msgs := make([]llm.Message, 0, len(pairs)*2+1) - for _, p := range pairs </span><span class="cov2" title="2">{ - if strings.TrimSpace(p.q) != "" </span><span class="cov2" title="2">{ + <span class="cov7" title="27">msgs := make([]llm.Message, 0, len(pairs)*2+1) + for _, p := range pairs </span><span class="cov4" title="6">{ + if strings.TrimSpace(p.q) != "" </span><span class="cov4" title="6">{ msgs = append(msgs, llm.Message{Role: "user", Content: p.q}) }</span> - <span class="cov2" title="2">if strings.TrimSpace(p.a) != "" </span><span class="cov2" title="2">{ + <span class="cov4" title="6">if strings.TrimSpace(p.a) != "" </span><span class="cov4" title="6">{ msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) }</span> } - <span class="cov7" title="9">msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) + <span class="cov7" title="27">msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) return msgs</span> } // stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. -func (s *Server) stripTrailingTrigger(sx string) string <span class="cov9" title="21">{ +func (s *Server) stripTrailingTrigger(sx string) string <span class="cov9" title="63">{ trim := strings.TrimRight(sx, " \t") if len(trim) == 0 </span><span class="cov0" title="0">{ return sx }</span> - <span class="cov9" title="21">_, prefixes, suffixChar := s.chatConfig() - if len(trim) >= 2 && suffixChar != 0 && trim[len(trim)-1] == suffixChar </span><span class="cov7" title="9">{ + <span class="cov9" title="63">_, prefixes, suffixChar := s.chatConfig() + if len(trim) >= 2 && suffixChar != 0 && trim[len(trim)-1] == suffixChar </span><span class="cov7" title="27">{ prev := string(trim[len(trim)-2]) - for _, pf := range prefixes </span><span class="cov10" title="27">{ - if prev == pf </span><span class="cov5" title="5">{ + for _, pf := range prefixes </span><span class="cov10" title="81">{ + if prev == pf </span><span class="cov6" title="15">{ return strings.TrimRight(trim[:len(trim)-1], " \t") }</span> } } - <span class="cov8" title="16">last := trim[len(trim)-1] + <span class="cov8" title="48">last := trim[len(trim)-1] switch last </span>{ - case '?', '!', ':':<span class="cov6" title="8"> + case '?', '!', ':':<span class="cov7" title="24"> return strings.TrimRight(trim[:len(trim)-1], " \t")</span> - default:<span class="cov6" title="8"> + default:<span class="cov7" title="24"> return sx</span> } } @@ -7252,7 +7788,7 @@ func (s *Server) stripTrailingTrigger(sx string) string <span class="cov9" title // - system from prompts.chat.system // - rolling in-editor history up to current prompt // - optional extra context per general.context_mode (window/full-file/new-func) -func (s *Server) buildChatMessages(uri string, pos Position, prompt string) []llm.Message <span class="cov6" title="8">{ +func (s *Server) buildChatMessages(uri string, pos Position, prompt string) []llm.Message <span class="cov7" title="24">{ // Base system and history cfg := s.currentConfig() sys := cfg.PromptChatSystem @@ -7263,21 +7799,21 @@ func (s *Server) buildChatMessages(uri string, pos Position, prompt string) []ll msgs := []llm.Message{{Role: "system", Content: sys}} // Optional additional context like completion path (insert before history so last remains the prompt) newFunc := s.isDefiningNewFunction(uri, pos) - if extra, has := s.buildAdditionalContext(newFunc, uri, pos); has && strings.TrimSpace(extra) != "" </span><span class="cov4" title="3">{ + if extra, has := s.buildAdditionalContext(newFunc, uri, pos); has && strings.TrimSpace(extra) != "" </span><span class="cov5" title="9">{ // Reuse completion's extra header template to avoid duplication header := renderTemplate(cfg.PromptCompletionExtraHeader, map[string]string{"context": extra}) if strings.TrimSpace(header) == "" </span><span class="cov0" title="0">{ header = extra }</span> - <span class="cov4" title="3">msgs = append(msgs, llm.Message{Role: "user", Content: header})</span> + <span class="cov5" title="9">msgs = append(msgs, llm.Message{Role: "user", Content: header})</span> } // Then add history (which ends with the current prompt) - <span class="cov6" title="8">msgs = append(msgs, history...) + <span class="cov7" title="24">msgs = append(msgs, history...) return msgs</span> } // clientApplyEdit sends a workspace/applyEdit request to the client. -func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) <span class="cov7" title="9">{ +func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) <span class="cov7" title="27">{ params := ApplyWorkspaceEditParams{Label: label, Edit: edit} id := s.nextReqID() req := Request{JSONRPC: "2.0", ID: id, Method: "workspace/applyEdit"} @@ -7287,7 +7823,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="cov7" title="12">{ +func (s *Server) nextReqID() json.RawMessage <span class="cov8" title="36">{ s.mu.Lock() s.nextID++ idNum := s.nextID @@ -7297,7 +7833,7 @@ func (s *Server) nextReqID() json.RawMessage <span class="cov7" title="12">{ }</span> // clientShowDocument asks the client to open/focus a document and select a range. -func (s *Server) clientShowDocument(uri string, sel *Range) <span class="cov4" title="3">{ +func (s *Server) clientShowDocument(uri string, sel *Range) <span class="cov5" title="9">{ var params struct { URI string `json:"uri"` External bool `json:"external,omitempty"` @@ -7316,41 +7852,41 @@ func (s *Server) clientShowDocument(uri string, sel *Range) <span class="cov4" t // deferShowDocument schedules a showDocument after a short delay to allow the client // time to apply any pending edits (e.g., create the file before focusing it). -func (s *Server) deferShowDocument(uri string, sel Range) <span class="cov1" title="1">{ - go func() </span><span class="cov1" title="1">{ +func (s *Server) deferShowDocument(uri string, sel Range) <span class="cov3" title="3">{ + go func() </span><span class="cov3" title="3">{ time.Sleep(120 * time.Millisecond) s.clientShowDocument(uri, &sel) }</span>() } </pre> - <pre class="file" id="file30" style="display: none">// Summary: ExecuteCommand handler to support post-edit navigation (jump to generated test). + <pre class="file" id="file31" style="display: none">// Summary: ExecuteCommand handler to support post-edit navigation (jump to generated test). package lsp import ( "encoding/json" ) -func (s *Server) handleExecuteCommand(req Request) <span class="cov8" title="1">{ +func (s *Server) handleExecuteCommand(req Request) <span class="cov10" title="3">{ var p ExecuteCommandParams if err := json.Unmarshal(req.Params, &p); err != nil </span><span class="cov0" title="0">{ s.reply(req.ID, nil, nil) return }</span> - <span class="cov8" title="1">switch p.Command </span>{ - case "hexai.showDocument":<span class="cov8" title="1"> - if len(p.Arguments) >= 2 </span><span class="cov8" title="1">{ + <span class="cov10" title="3">switch p.Command </span>{ + case "hexai.showDocument":<span class="cov10" title="3"> + if len(p.Arguments) >= 2 </span><span class="cov10" title="3">{ uri, _ := p.Arguments[0].(string) var r Range // Convert second arg to Range via re-marshal to be robust across clients - if b, err := json.Marshal(p.Arguments[1]); err == nil </span><span class="cov8" title="1">{ + if b, err := json.Marshal(p.Arguments[1]); err == nil </span><span class="cov10" title="3">{ _ = json.Unmarshal(b, &r) }</span> - <span class="cov8" title="1">if uri != "" </span><span class="cov8" title="1">{ + <span class="cov10" title="3">if uri != "" </span><span class="cov10" title="3">{ s.clientShowDocument(uri, &r) }</span> } - <span class="cov8" title="1">s.reply(req.ID, nil, nil) + <span class="cov10" title="3">s.reply(req.ID, nil, nil) return</span> default:<span class="cov0" title="0"> // Unknown command; no-op @@ -7360,7 +7896,7 @@ func (s *Server) handleExecuteCommand(req Request) <span class="cov8" title="1"> } </pre> - <pre class="file" id="file31" style="display: none">// Summary: Initialization and lifecycle handlers split from handlers.go. + <pre class="file" id="file32" style="display: none">// Summary: Initialization and lifecycle handlers split from handlers.go. package lsp import ( @@ -7371,13 +7907,13 @@ import ( tmx "codeberg.org/snonux/hexai/internal/tmux" ) -func (s *Server) handleInitialize(req Request) <span class="cov10" title="2">{ +func (s *Server) handleInitialize(req Request) <span class="cov10" title="6">{ client := s.currentLLMClient() version := internal.Version if client != nil </span><span class="cov0" title="0">{ version = version + " [" + client.Name() + ":" + client.DefaultModel() + "]" }</span> - <span class="cov10" title="2">res := InitializeResult{ + <span class="cov10" title="6">res := InitializeResult{ Capabilities: ServerCapabilities{ TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull CompletionProvider: &CompletionOptions{ @@ -7391,7 +7927,7 @@ func (s *Server) handleInitialize(req Request) <span class="cov10" title="2">{ s.reply(req.ID, res, nil)</span> } -func (s *Server) handleInitialized() <span class="cov1" title="1">{ +func (s *Server) handleInitialized() <span class="cov6" title="3">{ logging.Logf("lsp ", "client initialized") // Emit an initial tmux heartbeat with provider/model if client := s.currentLLMClient(); client != nil </span><span class="cov0" title="0">{ @@ -7399,7 +7935,7 @@ func (s *Server) handleInitialized() <span class="cov1" title="1">{ }</span> } -func (s *Server) handleShutdown(req Request) <span class="cov1" title="1">{ +func (s *Server) handleShutdown(req Request) <span class="cov6" title="3">{ s.reply(req.ID, nil, nil) }</span> @@ -7409,7 +7945,7 @@ func (s *Server) handleExit() <span class="cov0" title="0">{ }</span> </pre> - <pre class="file" id="file32" style="display: none">// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters). + <pre class="file" id="file33" style="display: none">// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters). package lsp import ( @@ -7442,49 +7978,48 @@ type requestSpec struct { index int } -func (r requestSpec) modelOverride() string <span class="cov0" title="0">{ return strings.TrimSpace(r.entry.Model) }</span> -func (r requestSpec) effectiveModel(defaultModel string) string <span class="cov7" title="63">{ +func (r requestSpec) effectiveModel(defaultModel string) string <span class="cov8" title="189">{ if m := strings.TrimSpace(r.entry.Model); m != "" </span><span class="cov0" title="0">{ return m }</span> - <span class="cov7" title="63">if f := strings.TrimSpace(r.fallbackModel); f != "" </span><span class="cov1" title="1">{ + <span class="cov8" title="189">if f := strings.TrimSpace(r.fallbackModel); f != "" </span><span class="cov2" title="3">{ return f }</span> - <span class="cov7" title="62">return strings.TrimSpace(defaultModel)</span> + <span class="cov8" title="186">return strings.TrimSpace(defaultModel)</span> } -func (s *Server) buildRequestSpecs(surface surfaceKind) []requestSpec <span class="cov7" title="44">{ +func (s *Server) buildRequestSpecs(surface surfaceKind) []requestSpec <span class="cov7" title="132">{ cfg := s.currentConfig() entries := surfaceConfigsFor(cfg, surface) - if len(entries) == 0 </span><span class="cov7" title="43">{ + if len(entries) == 0 </span><span class="cov7" title="129">{ entries = []appconfig.SurfaceConfig{{Provider: cfg.Provider}} }</span> - <span class="cov7" title="44">maxTokens := s.maxTokens() + <span class="cov7" title="132">maxTokens := s.maxTokens() specs := make([]requestSpec, 0, len(entries)) - for idx, raw := range entries </span><span class="cov7" title="45">{ + for idx, raw := range entries </span><span class="cov7" title="135">{ entry := appconfig.SurfaceConfig{ Provider: strings.TrimSpace(raw.Provider), Model: strings.TrimSpace(raw.Model), Temperature: raw.Temperature, } provider := entry.Provider - if provider == "" </span><span class="cov7" title="43">{ + if provider == "" </span><span class="cov7" title="129">{ provider = cfg.Provider }</span> - <span class="cov7" title="45">provider = canonicalProvider(provider) + <span class="cov7" title="135">provider = canonicalProvider(provider) fallbackModel := entry.Model - if fallbackModel == "" </span><span class="cov7" title="43">{ + if fallbackModel == "" </span><span class="cov7" title="129">{ fallbackModel = strings.TrimSpace(resolveDefaultModel(cfg, provider)) }</span> - <span class="cov7" title="45">opts := []llm.RequestOption{llm.WithMaxTokens(maxTokens)} - if entry.Model != "" </span><span class="cov2" title="2">{ + <span class="cov7" title="135">opts := []llm.RequestOption{llm.WithMaxTokens(maxTokens)} + if entry.Model != "" </span><span class="cov3" title="6">{ opts = append(opts, llm.WithModel(entry.Model)) }</span> - <span class="cov7" title="45">if temp, ok := chooseSurfaceTemperature(surface, cfg, entry, provider, fallbackModel); ok </span><span class="cov2" title="2">{ + <span class="cov7" title="135">if temp, ok := chooseSurfaceTemperature(surface, cfg, entry, provider, fallbackModel); ok </span><span class="cov3" title="6">{ opts = append(opts, llm.WithTemperature(temp)) }</span> - <span class="cov7" title="45">specs = append(specs, requestSpec{ + <span class="cov7" title="135">specs = append(specs, requestSpec{ provider: provider, entry: entry, fallbackModel: fallbackModel, @@ -7492,10 +8027,10 @@ func (s *Server) buildRequestSpecs(surface surfaceKind) []requestSpec <span clas index: idx, })</span> } - <span class="cov7" title="44">return specs</span> + <span class="cov7" title="132">return specs</span> } -func (s *Server) primaryRequestSpec(surface surfaceKind) requestSpec <span class="cov6" title="32">{ +func (s *Server) primaryRequestSpec(surface surfaceKind) requestSpec <span class="cov7" title="96">{ specs := s.buildRequestSpecs(surface) if len(specs) == 0 </span><span class="cov0" title="0">{ cfg := s.currentConfig() @@ -7503,128 +8038,128 @@ func (s *Server) primaryRequestSpec(surface surfaceKind) requestSpec <span class fallback := strings.TrimSpace(resolveDefaultModel(cfg, provider)) return requestSpec{provider: provider, fallbackModel: fallback, options: []llm.RequestOption{llm.WithMaxTokens(s.maxTokens())}} }</span> - <span class="cov6" title="32">return specs[0]</span> + <span class="cov7" title="96">return specs[0]</span> } // buildRequestSpec is retained for consumers expecting a single-entry helper. -func (s *Server) buildRequestSpec(surface surfaceKind) requestSpec <span class="cov6" title="32">{ +func (s *Server) buildRequestSpec(surface surfaceKind) requestSpec <span class="cov7" title="96">{ return s.primaryRequestSpec(surface) }</span> -func canonicalProvider(name string) string <span class="cov9" title="217">{ +func canonicalProvider(name string) string <span class="cov9" title="651">{ p := strings.ToLower(strings.TrimSpace(name)) - if p == "" </span><span class="cov9" title="163">{ + if p == "" </span><span class="cov9" title="489">{ return "openai" }</span> - <span class="cov7" title="54">return p</span> + <span class="cov8" title="162">return p</span> } -func resolveDefaultModel(cfg appconfig.App, provider string) string <span class="cov7" title="43">{ +func resolveDefaultModel(cfg appconfig.App, provider string) string <span class="cov7" title="129">{ switch provider </span>{ case "ollama":<span class="cov0" title="0"> return strings.TrimSpace(cfg.OllamaModel)</span> case "copilot":<span class="cov0" title="0"> return strings.TrimSpace(cfg.CopilotModel)</span> - default:<span class="cov7" title="43"> + default:<span class="cov7" title="129"> return strings.TrimSpace(cfg.OpenAIModel)</span> } } -func surfaceConfigsFor(cfg appconfig.App, surface surfaceKind) []appconfig.SurfaceConfig <span class="cov7" title="44">{ +func surfaceConfigsFor(cfg appconfig.App, surface surfaceKind) []appconfig.SurfaceConfig <span class="cov7" title="132">{ switch surface </span>{ - case surfaceCompletion:<span class="cov5" title="16"> + case surfaceCompletion:<span class="cov6" title="48"> return cfg.CompletionConfigs</span> - case surfaceCodeAction:<span class="cov5" title="20"> + case surfaceCodeAction:<span class="cov6" title="60"> return cfg.CodeActionConfigs</span> - case surfaceChat:<span class="cov4" title="8"> + case surfaceChat:<span class="cov5" title="24"> return cfg.ChatConfigs</span> default:<span class="cov0" title="0"> return nil</span> } } -func chooseSurfaceTemperature(surface surfaceKind, cfg appconfig.App, entry appconfig.SurfaceConfig, provider string, fallbackModel string) (float64, bool) <span class="cov7" title="51">{ - if entry.Temperature != nil </span><span class="cov1" title="1">{ +func chooseSurfaceTemperature(surface surfaceKind, cfg appconfig.App, entry appconfig.SurfaceConfig, provider string, fallbackModel string) (float64, bool) <span class="cov7" title="153">{ + if entry.Temperature != nil </span><span class="cov2" title="3">{ return *entry.Temperature, true }</span> - <span class="cov7" title="50">if cfg.CodingTemperature != nil </span><span class="cov1" title="1">{ + <span class="cov7" title="150">if cfg.CodingTemperature != nil </span><span class="cov2" title="3">{ temp := *cfg.CodingTemperature effectiveModel := strings.TrimSpace(entry.Model) - if effectiveModel == "" </span><span class="cov1" title="1">{ + if effectiveModel == "" </span><span class="cov2" title="3">{ effectiveModel = strings.TrimSpace(fallbackModel) }</span> - <span class="cov1" title="1">if provider == "openai" && strings.HasPrefix(strings.ToLower(effectiveModel), "gpt-5") && temp == 0.2 </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if provider == "openai" && strings.HasPrefix(strings.ToLower(effectiveModel), "gpt-5") && temp == 0.2 </span><span class="cov2" title="3">{ temp = 1.0 }</span> - <span class="cov1" title="1">return temp, true</span> + <span class="cov2" title="3">return temp, true</span> } - <span class="cov7" title="49">effectiveModel := strings.TrimSpace(entry.Model) - if effectiveModel == "" </span><span class="cov7" title="48">{ + <span class="cov7" title="147">effectiveModel := strings.TrimSpace(entry.Model) + if effectiveModel == "" </span><span class="cov7" title="144">{ effectiveModel = strings.TrimSpace(fallbackModel) }</span> - <span class="cov7" title="49">if provider == "openai" && strings.HasPrefix(strings.ToLower(effectiveModel), "gpt-5") </span><span class="cov0" title="0">{ + <span class="cov7" title="147">if provider == "openai" && strings.HasPrefix(strings.ToLower(effectiveModel), "gpt-5") </span><span class="cov0" title="0">{ return 1.0, true }</span> - <span class="cov7" title="49">return 0, false</span> + <span class="cov7" title="147">return 0, false</span> } // small helpers for LLM traffic stats -func (s *Server) incSentCounters(n int) <span class="cov7" title="42">{ +func (s *Server) incSentCounters(n int) <span class="cov7" title="126">{ s.mu.Lock() s.llmReqTotal++ s.llmSentBytesTotal += int64(n) s.mu.Unlock() }</span> -func (s *Server) incRecvCounters(n int) <span class="cov7" title="41">{ +func (s *Server) incRecvCounters(n int) <span class="cov7" title="123">{ s.mu.Lock() s.llmRespTotal++ s.llmRespBytesTotal += int64(n) s.mu.Unlock() }</span> -func (s *Server) logLLMStats(model string) <span class="cov7" title="42">{ +func (s *Server) logLLMStats(model string) <span class="cov7" title="126">{ s.mu.RLock() avgSent := int64(0) - if s.llmReqTotal > 0 </span><span class="cov7" title="42">{ + if s.llmReqTotal > 0 </span><span class="cov7" title="126">{ avgSent = s.llmSentBytesTotal / s.llmReqTotal }</span> - <span class="cov7" title="42">avgRecv := int64(0) - if s.llmRespTotal > 0 </span><span class="cov7" title="41">{ + <span class="cov7" title="126">avgRecv := int64(0) + if s.llmRespTotal > 0 </span><span class="cov7" title="123">{ avgRecv = s.llmRespBytesTotal / s.llmRespTotal }</span> - <span class="cov7" title="42">reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal + <span class="cov7" title="126">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="cov7" title="42">rpmLocal := float64(reqs) / mins + <span class="cov7" title="126">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 </span><span class="cov7" title="42">{ - if client := s.currentLLMClient(); client != nil </span><span class="cov7" title="40">{ + if err == nil </span><span class="cov7" title="126">{ + if client := s.currentLLMClient(); client != nil </span><span class="cov7" title="120">{ provider := client.Name() modelName := strings.TrimSpace(model) if modelName == "" </span><span class="cov0" title="0">{ modelName = client.DefaultModel() }</span> // Per-scope rpm estimated from window - <span class="cov7" title="40">scopeReqs := int64(0) - if pe, ok := snap.Providers[provider]; ok </span><span class="cov7" title="40">{ - if mc, ok2 := pe.Models[modelName]; ok2 </span><span class="cov6" title="37">{ + <span class="cov7" title="120">scopeReqs := int64(0) + if pe, ok := snap.Providers[provider]; ok </span><span class="cov7" title="120">{ + if mc, ok2 := pe.Models[modelName]; ok2 </span><span class="cov7" title="111">{ scopeReqs = mc.Reqs }</span> } - <span class="cov7" title="40">minsWin := snap.Window.Minutes() + <span class="cov7" title="120">minsWin := snap.Window.Minutes() if minsWin <= 0 </span><span class="cov0" title="0">{ minsWin = 0.001 }</span> - <span class="cov7" title="40">scopeRPM := float64(scopeReqs) / minsWin + <span class="cov7" title="120">scopeRPM := float64(scopeReqs) / minsWin status := tmx.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, provider, modelName, scopeRPM, scopeReqs, snap.Window) _ = tmx.SetStatus(status)</span> } @@ -7632,89 +8167,89 @@ func (s *Server) logLLMStats(model string) <span class="cov7" title="42">{ } // Completion prompt builders and filters -func inParamList(current string, cursor int) bool <span class="cov5" title="14">{ - if !strings.Contains(current, "func ") </span><span class="cov4" title="8">{ +func inParamList(current string, cursor int) bool <span class="cov6" title="42">{ + if !strings.Contains(current, "func ") </span><span class="cov5" title="24">{ return false }</span> - <span class="cov3" title="6">open := strings.Index(current, "(") + <span class="cov4" title="18">open := strings.Index(current, "(") close := strings.Index(current, ")") return open >= 0 && cursor > open && (close == -1 || cursor <= close)</span> } // renderTemplate performs simple {{var}} replacement in a template string. -func renderTemplate(t string, vars map[string]string) string <span class="cov7" title="45">{ return textutil.RenderTemplate(t, vars) }</span> +func renderTemplate(t string, vars map[string]string) string <span class="cov7" title="135">{ return textutil.RenderTemplate(t, vars) }</span> -func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) <span class="cov5" title="19">{ - if inParams </span><span class="cov2" title="3">{ +func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) <span class="cov6" title="57">{ + if inParams </span><span class="cov4" title="9">{ open := strings.Index(current, "(") close := strings.Index(current, ")") - if open >= 0 </span><span class="cov2" title="3">{ + if open >= 0 </span><span class="cov4" title="9">{ left := open + 1 right := len(current) - if close >= 0 && close >= left </span><span class="cov2" title="3">{ + if close >= 0 && close >= left </span><span class="cov4" title="9">{ right = close }</span> - <span class="cov2" title="3">if p.Position.Character < right </span><span class="cov2" title="2">{ + <span class="cov4" title="9">if p.Position.Character < right </span><span class="cov3" title="6">{ right = p.Position.Character }</span> - <span class="cov2" title="3">te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: left}, End: Position{Line: p.Position.Line, Character: right}}, NewText: cleaned} + <span class="cov4" title="9">te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: left}, End: Position{Line: p.Position.Line, Character: right}}, NewText: cleaned} var filter string - if left >= 0 && right >= left && right <= len(current) </span><span class="cov2" title="3">{ + if left >= 0 && right >= left && right <= len(current) </span><span class="cov4" title="9">{ filter = strings.TrimLeft(current[left:right], " \t") }</span> - <span class="cov2" title="3">return te, filter</span> + <span class="cov4" title="9">return te, filter</span> } } - <span class="cov5" title="16">startChar := computeWordStart(current, p.Position.Character) + <span class="cov6" title="48">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="cov6" title="27">{ +func computeWordStart(current string, at int) int <span class="cov7" title="81">{ if at > len(current) </span><span class="cov0" title="0">{ at = len(current) }</span> - <span class="cov6" title="27">for at > 0 </span><span class="cov7" title="54">{ + <span class="cov7" title="81">for at > 0 </span><span class="cov8" title="162">{ ch := current[at-1] - if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' </span><span class="cov6" title="34">{ + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' </span><span class="cov7" title="102">{ at-- continue</span> } - <span class="cov5" title="20">break</span> + <span class="cov6" title="60">break</span> } - <span class="cov6" title="27">return at</span> + <span class="cov7" title="81">return at</span> } -func isIdentChar(ch byte) bool <span class="cov6" title="26">{ +func isIdentChar(ch byte) bool <span class="cov9" title="525">{ return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' }</span> // chatWithStats wraps llmClient.Chat to increment counters and emit a tmux heartbeat. -func (s *Server) chatWithStats(ctx context.Context, surface surfaceKind, spec requestSpec, msgs []llm.Message) (string, error) <span class="cov6" title="28">{ +func (s *Server) chatWithStats(ctx context.Context, surface surfaceKind, spec requestSpec, msgs []llm.Message) (string, error) <span class="cov7" title="84">{ // Count bytes sent sent := 0 - for _, m := range msgs </span><span class="cov7" title="59">{ + for _, m := range msgs </span><span class="cov8" title="177">{ sent += len(m.Content) }</span> - <span class="cov6" title="28">s.incSentCounters(sent) + <span class="cov7" title="84">s.incSentCounters(sent) // Debounce/throttle if configured (reuse completion gates) s.waitForDebounce(ctx) if !s.waitForThrottle(ctx) </span><span class="cov0" title="0">{ return "", context.Canceled }</span> // Perform request - <span class="cov6" title="28">client := s.clientFor(spec) + <span class="cov7" title="84">client := s.clientFor(spec) if client == nil </span><span class="cov0" title="0">{ return "", fmt.Errorf("llm client unavailable") }</span> - <span class="cov6" title="28">modelUsed := spec.effectiveModel(client.DefaultModel()) + <span class="cov7" title="84">modelUsed := spec.effectiveModel(client.DefaultModel()) txt, err := client.Chat(ctx, msgs, spec.options...) - if err != nil </span><span class="cov1" title="1">{ + if err != nil </span><span class="cov2" title="3">{ s.logLLMStats(modelUsed) return "", err }</span> - <span class="cov6" title="27">s.incRecvCounters(len(txt)) + <span class="cov7" title="81">s.incRecvCounters(len(txt)) // Update global stats cache _ = stats.Update(ctx, client.Name(), modelUsed, sent, len(txt)) s.logLLMStats(modelUsed) @@ -7723,68 +8258,68 @@ func (s *Server) chatWithStats(ctx context.Context, surface surfaceKind, spec re // Inline prompt utilities -func lineHasInlinePrompt(line string, openStr string, open, close byte) bool <span class="cov7" title="45">{ +func lineHasInlinePrompt(line string, openStr string, open, close byte) bool <span class="cov7" title="135">{ if openStr == "" </span><span class="cov0" title="0">{ openStr = string(open) }</span> - <span class="cov7" title="45">if _, _, _, ok := findStrictInlineTag(line, openStr, open, close); ok </span><span class="cov3" title="5">{ + <span class="cov7" title="135">if _, _, _, ok := findStrictInlineTag(line, openStr, open, close); ok </span><span class="cov4" title="15">{ return true }</span> - <span class="cov7" title="40">return hasDoubleOpenTrigger(line, openStr, open, close)</span> + <span class="cov7" title="120">return hasDoubleOpenTrigger(line, openStr, open, close)</span> } -func doubleOpenSequences(openStr string, open, close byte) []string <span class="cov10" title="226">{ +func doubleOpenSequences(openStr string, open, close byte) []string <span class="cov10" title="678">{ seen := make(map[string]struct{}, 2) var seqs []string - if openStr != "" && close != 0 </span><span class="cov10" title="226">{ + if openStr != "" && close != 0 </span><span class="cov10" title="678">{ seq := openStr + string(close) - if _, ok := seen[seq]; !ok </span><span class="cov10" title="226">{ + if _, ok := seen[seq]; !ok </span><span class="cov10" title="678">{ seen[seq] = struct{}{} seqs = append(seqs, seq) }</span> } - <span class="cov10" title="226">if openStr != "" && open != 0 </span><span class="cov10" title="226">{ + <span class="cov10" title="678">if openStr != "" && open != 0 </span><span class="cov10" title="678">{ seq := string(open) + openStr - if len(seq) > len(openStr) </span><span class="cov10" title="226">{ - if _, ok := seen[seq]; !ok </span><span class="cov9" title="223">{ + if len(seq) > len(openStr) </span><span class="cov10" title="678">{ + if _, ok := seen[seq]; !ok </span><span class="cov9" title="669">{ seen[seq] = struct{}{} seqs = append(seqs, seq) }</span> } } - <span class="cov10" title="226">return seqs</span> + <span class="cov10" title="678">return seqs</span> } -func leadingIndent(line string) string <span class="cov3" title="5">{ +func leadingIndent(line string) string <span class="cov4" title="15">{ i := 0 - for i < len(line) </span><span class="cov5" title="15">{ - if line[i] == ' ' || line[i] == '\t' </span><span class="cov4" title="10">{ + for i < len(line) </span><span class="cov6" title="45">{ + if line[i] == ' ' || line[i] == '\t' </span><span class="cov5" title="30">{ i++ continue</span> } - <span class="cov3" title="5">break</span> + <span class="cov4" title="15">break</span> } - <span class="cov3" title="5">if i == 0 </span><span class="cov1" title="1">{ + <span class="cov4" title="15">if i == 0 </span><span class="cov2" title="3">{ return "" }</span> - <span class="cov3" title="4">return line[:i]</span> + <span class="cov4" title="12">return line[:i]</span> } -func applyIndent(indent, suggestion string) string <span class="cov3" title="4">{ +func applyIndent(indent, suggestion string) string <span class="cov4" title="12">{ if indent == "" || suggestion == "" </span><span class="cov0" title="0">{ return suggestion }</span> - <span class="cov3" title="4">lines := splitLines(suggestion) - for i, ln := range lines </span><span class="cov4" title="10">{ - if strings.TrimSpace(ln) == "" </span><span class="cov1" title="1">{ + <span class="cov4" title="12">lines := splitLines(suggestion) + for i, ln := range lines </span><span class="cov5" title="30">{ + if strings.TrimSpace(ln) == "" </span><span class="cov2" title="3">{ continue</span> } - <span class="cov4" title="9">if strings.HasPrefix(ln, indent) </span><span class="cov0" title="0">{ + <span class="cov5" title="27">if strings.HasPrefix(ln, indent) </span><span class="cov0" title="0">{ continue</span> } - <span class="cov4" title="9">lines[i] = indent + ln</span> + <span class="cov5" title="27">lines[i] = indent + ln</span> } - <span class="cov3" title="4">return strings.Join(lines, "\n")</span> + <span class="cov4" title="12">return strings.Join(lines, "\n")</span> } // --- Inline marker parsing and general string utilities --- @@ -7792,35 +8327,35 @@ func applyIndent(indent, suggestion string) string <span class="cov3" title="4"> // findStrictInlineTag finds >!text> (configurable), with no space after the first // opening marker and no space immediately before the closing marker. Returns the // text between markers, the start index, the end index just after closing, and ok. -func findStrictInlineTag(line string, openStr string, open, close byte) (string, int, int, bool) <span class="cov8" title="76">{ +func findStrictInlineTag(line string, openStr string, open, close byte) (string, int, int, bool) <span class="cov8" title="228">{ if openStr == "" </span><span class="cov0" title="0">{ openStr = string(open) }</span> - <span class="cov8" title="76">if openStr == "" </span><span class="cov0" title="0">{ + <span class="cov8" title="228">if openStr == "" </span><span class="cov0" title="0">{ return "", 0, 0, false }</span> - <span class="cov8" title="76">openChar := open + <span class="cov8" title="228">openChar := open if openChar == 0 </span><span class="cov0" title="0">{ openChar = openStr[0] }</span> - <span class="cov8" title="76">doubleSeqs := doubleOpenSequences(openStr, openChar, close) + <span class="cov8" title="228">doubleSeqs := doubleOpenSequences(openStr, openChar, close) pos := 0 - for pos < len(line) </span><span class="cov8" title="90">{ + for pos < len(line) </span><span class="cov8" title="270">{ j := strings.IndexByte(line[pos:], openChar) - if j < 0 </span><span class="cov7" title="40">{ + if j < 0 </span><span class="cov7" title="120">{ return "", 0, 0, false }</span> - <span class="cov7" title="50">j += pos - if !strings.HasPrefix(line[j:], openStr) </span><span class="cov6" title="26">{ + <span class="cov7" title="150">j += pos + if !strings.HasPrefix(line[j:], openStr) </span><span class="cov7" title="78">{ pos = j + 1 continue</span> } - <span class="cov6" title="24">contentStart := j + len(openStr) - if contentStart >= len(line) </span><span class="cov2" title="2">{ + <span class="cov6" title="72">contentStart := j + len(openStr) + if contentStart >= len(line) </span><span class="cov3" title="6">{ return "", 0, 0, false }</span> - <span class="cov6" title="22">doubleHit := false - for _, seq := range doubleSeqs </span><span class="cov7" title="44">{ + <span class="cov6" title="66">doubleHit := false + for _, seq := range doubleSeqs </span><span class="cov7" title="132">{ if strings.HasPrefix(line[j:], seq) </span><span class="cov0" title="0">{ doubleHit = true contentStart += len(seq) - len(openStr) @@ -7830,337 +8365,337 @@ func findStrictInlineTag(line string, openStr string, open, close byte) (string, <span class="cov0" title="0">break</span> } } - <span class="cov6" title="22">next := line[contentStart] - if next == ' ' </span><span class="cov3" title="5">{ + <span class="cov6" title="66">next := line[contentStart] + if next == ' ' </span><span class="cov4" title="15">{ pos = contentStart + 1 continue</span> } - <span class="cov5" title="17">if !doubleHit && next == close </span><span class="cov0" title="0">{ + <span class="cov6" title="51">if !doubleHit && next == close </span><span class="cov0" title="0">{ pos = contentStart + 1 continue</span> } - <span class="cov5" title="17">k := strings.IndexByte(line[contentStart:], close) + <span class="cov6" title="51">k := strings.IndexByte(line[contentStart:], close) if k < 0 </span><span class="cov0" title="0">{ return "", 0, 0, false }</span> - <span class="cov5" title="17">closeIdx := contentStart + k - if closeIdx-1 >= contentStart && line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ + <span class="cov6" title="51">closeIdx := contentStart + k + if closeIdx-1 >= contentStart && line[closeIdx-1] == ' ' </span><span class="cov2" title="3">{ pos = closeIdx + 1 continue</span> } - <span class="cov5" title="16">inner := strings.TrimSpace(line[contentStart:closeIdx]) + <span class="cov6" title="48">inner := strings.TrimSpace(line[contentStart:closeIdx]) if inner == "" </span><span class="cov0" title="0">{ pos = closeIdx + 1 continue</span> } - <span class="cov5" title="16">end := closeIdx + 1 + <span class="cov6" title="48">end := closeIdx + 1 return inner, j, end, true</span> } - <span class="cov5" title="18">return "", 0, 0, false</span> + <span class="cov6" title="54">return "", 0, 0, false</span> } // 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, openStr string, open, close byte) bool <span class="cov6" title="24">{ +func isBareDoubleOpen(line string, openStr string, open, close byte) bool <span class="cov6" title="72">{ t := strings.TrimSpace(line) if openStr == "" </span><span class="cov0" title="0">{ openStr = string(open) }</span> - <span class="cov6" title="24">if openStr == "" </span><span class="cov0" title="0">{ + <span class="cov6" title="72">if openStr == "" </span><span class="cov0" title="0">{ return false }</span> - <span class="cov6" title="24">for _, seq := range doubleOpenSequences(openStr, open, close) </span><span class="cov7" title="48">{ - if strings.HasPrefix(t, seq) </span><span class="cov2" title="2">{ + <span class="cov6" title="72">for _, seq := range doubleOpenSequences(openStr, open, close) </span><span class="cov7" title="144">{ + if strings.HasPrefix(t, seq) </span><span class="cov3" title="6">{ rest := strings.TrimSpace(t[len(seq):]) - if rest == "" || rest == string(close) </span><span class="cov1" title="1">{ + if rest == "" || rest == string(close) </span><span class="cov2" title="3">{ return true }</span> } } - <span class="cov6" title="23">return false</span> + <span class="cov6" title="69">return false</span> } // stripDuplicateAssignmentPrefix removes a duplicated assignment prefix from the suggestion. -func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="21">{ +func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="63">{ 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">{ + if idx := strings.LastIndex(prefixBeforeCursor, ":="); idx >= 0 && idx+2 <= len(prefixBeforeCursor) </span><span class="cov4" title="12">{ tail := prefixBeforeCursor[idx+2:] - if strings.TrimSpace(tail) == "" </span><span class="cov3" title="4">{ + if strings.TrimSpace(tail) == "" </span><span class="cov4" title="12">{ start := idx - 1 - for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') </span><span class="cov5" title="20">{ + for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') </span><span class="cov6" title="60">{ start-- }</span> - <span class="cov3" title="4">start++ + <span class="cov4" title="12">start++ seg := strings.TrimRight(prefixBeforeCursor[start:idx+2], " \t") - if strings.HasPrefix(s2, seg) </span><span class="cov3" title="4">{ + if strings.HasPrefix(s2, seg) </span><span class="cov4" title="12">{ return strings.TrimLeft(s2[len(seg):], " \t") }</span> } } // Fallback to plain '=' if present - <span class="cov5" title="17">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 := + <span class="cov6" title="51">if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 </span><span class="cov3" title="6">{ + if idx <= 0 || prefixBeforeCursor[idx-1] != ':' </span><span class="cov3" title="6">{ // not := tail := prefixBeforeCursor[idx+1:] - if strings.TrimSpace(tail) == "" </span><span class="cov2" title="2">{ + if strings.TrimSpace(tail) == "" </span><span class="cov3" title="6">{ start := idx - 1 - for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') </span><span class="cov3" title="4">{ + for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') </span><span class="cov4" title="12">{ start-- }</span> - <span class="cov2" title="2">start++ + <span class="cov3" title="6">start++ seg := strings.TrimRight(prefixBeforeCursor[start:idx+1], " \t") - if strings.HasPrefix(s2, seg) </span><span class="cov2" title="2">{ + if strings.HasPrefix(s2, seg) </span><span class="cov3" title="6">{ return strings.TrimLeft(s2[len(seg):], " \t") }</span> } } } - <span class="cov5" title="15">return suggestion</span> + <span class="cov6" title="45">return suggestion</span> } // stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated. -func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="21">{ +func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="63">{ if suggestion == "" </span><span class="cov0" title="0">{ return suggestion }</span> - <span class="cov6" title="21">s := strings.TrimLeft(suggestion, " \t") + <span class="cov6" title="63">s := strings.TrimLeft(suggestion, " \t") p := strings.TrimRight(prefixBeforeCursor, " \t") - if p != "" && strings.HasPrefix(s, p) </span><span class="cov3" title="5">{ + if p != "" && strings.HasPrefix(s, p) </span><span class="cov4" title="15">{ return strings.TrimLeft(s[len(p):], " \t") }</span> - <span class="cov5" title="16">for k := len(p) - 1; k > 0; k-- </span><span class="cov9" title="149">{ - if !isIdentBoundary(p[k-1]) </span><span class="cov8" title="116">{ + <span class="cov6" title="48">for k := len(p) - 1; k > 0; k-- </span><span class="cov9" title="447">{ + if !isIdentBoundary(p[k-1]) </span><span class="cov9" title="348">{ continue</span> } - <span class="cov6" title="33">suf := strings.TrimLeft(p[k:], " \t") + <span class="cov7" title="99">suf := strings.TrimLeft(p[k:], " \t") if suf == "" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov6" title="33">if strings.HasPrefix(s, suf) </span><span class="cov0" title="0">{ + <span class="cov7" title="99">if strings.HasPrefix(s, suf) </span><span class="cov0" title="0">{ return strings.TrimLeft(s[len(suf):], " \t") }</span> } - <span class="cov5" title="16">return suggestion</span> + <span class="cov6" title="48">return suggestion</span> } -func isIdentBoundary(ch byte) bool <span class="cov9" title="149">{ - return !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') +func isIdentBoundary(ch byte) bool <span class="cov9" title="447">{ + return !isIdentChar(ch) }</span> // stripCodeFences removes surrounding Markdown code fences from a model response. -func stripCodeFences(s string) string <span class="cov7" title="48">{ return textutil.StripCodeFences(s) }</span> +func stripCodeFences(s string) string <span class="cov7" title="144">{ 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="cov4" title="11">{ +func stripInlineCodeSpan(s string) string <span class="cov5" title="33">{ t := strings.TrimSpace(s) if t == "" </span><span class="cov0" title="0">{ return t }</span> - <span class="cov4" title="11">i := strings.IndexByte(t, '`') - if i < 0 </span><span class="cov2" title="2">{ + <span class="cov5" title="33">i := strings.IndexByte(t, '`') + if i < 0 </span><span class="cov3" title="6">{ return t }</span> - <span class="cov4" title="9">jrel := strings.IndexByte(t[i+1:], '`') - if jrel < 0 </span><span class="cov2" title="2">{ + <span class="cov5" title="27">jrel := strings.IndexByte(t[i+1:], '`') + if jrel < 0 </span><span class="cov3" title="6">{ return t }</span> - <span class="cov4" title="7">j := i + 1 + jrel + <span class="cov5" title="21">j := i + 1 + jrel return t[i+1 : j]</span> } // labelForCompletion picks a short, readable label for the completion list. -func labelForCompletion(cleaned, filter string) string <span class="cov6" title="22">{ +func labelForCompletion(cleaned, filter string) string <span class="cov6" title="66">{ label := trimLen(firstLine(cleaned)) - if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) </span><span class="cov3" title="5">{ + if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) </span><span class="cov4" title="15">{ return filter }</span> - <span class="cov5" title="17">return label</span> + <span class="cov6" title="51">return label</span> } // extractRangeText returns the exact text within the given document range. -func extractRangeText(d *document, r Range) string <span class="cov3" title="6">{ - if r.Start.Line == r.End.Line </span><span class="cov3" title="5">{ +func extractRangeText(d *document, r Range) string <span class="cov4" title="18">{ + if r.Start.Line == r.End.Line </span><span class="cov4" title="15">{ line := d.lines[r.Start.Line] if r.Start.Character < 0 </span><span class="cov0" title="0">{ r.Start.Character = 0 }</span> - <span class="cov3" title="5">if r.End.Character > len(line) </span><span class="cov0" title="0">{ + <span class="cov4" title="15">if r.End.Character > len(line) </span><span class="cov0" title="0">{ r.End.Character = len(line) }</span> - <span class="cov3" title="5">if r.Start.Character > r.End.Character </span><span class="cov1" title="1">{ + <span class="cov4" title="15">if r.Start.Character > r.End.Character </span><span class="cov2" title="3">{ return "" }</span> - <span class="cov3" title="4">return line[r.Start.Character:r.End.Character]</span> + <span class="cov4" title="12">return line[r.Start.Character:r.End.Character]</span> } - <span class="cov1" title="1">var b strings.Builder + <span class="cov2" title="3">var b strings.Builder // first line first := d.lines[r.Start.Line] if r.Start.Character < 0 </span><span class="cov0" title="0">{ r.Start.Character = 0 }</span> - <span class="cov1" title="1">if r.Start.Character > len(first) </span><span class="cov0" title="0">{ + <span class="cov2" title="3">if r.Start.Character > len(first) </span><span class="cov0" title="0">{ r.Start.Character = len(first) }</span> - <span class="cov1" title="1">b.WriteString(first[r.Start.Character:]) + <span class="cov2" title="3">b.WriteString(first[r.Start.Character:]) b.WriteString("\n") // middle lines - for i := r.Start.Line + 1; i < r.End.Line; i++ </span><span class="cov1" title="1">{ + for i := r.Start.Line + 1; i < r.End.Line; i++ </span><span class="cov2" title="3">{ b.WriteString(d.lines[i]) - if i+1 <= r.End.Line </span><span class="cov1" title="1">{ + if i+1 <= r.End.Line </span><span class="cov2" title="3">{ b.WriteString("\n") }</span> } // last line - <span class="cov1" title="1">last := d.lines[r.End.Line] + <span class="cov2" title="3">last := d.lines[r.End.Line] if r.End.Character < 0 </span><span class="cov0" title="0">{ r.End.Character = 0 }</span> - <span class="cov1" title="1">if r.End.Character > len(last) </span><span class="cov0" title="0">{ + <span class="cov2" title="3">if r.End.Character > len(last) </span><span class="cov0" title="0">{ r.End.Character = len(last) }</span> - <span class="cov1" title="1">b.WriteString(last[:r.End.Character]) + <span class="cov2" title="3">b.WriteString(last[:r.End.Character]) return b.String()</span> } // collectPromptRemovalEdits returns edits to remove all inline prompt markers. -func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit <span class="cov5" title="15">{ +func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit <span class="cov6" title="45">{ d := s.getDocument(uri) - if d == nil || len(d.lines) == 0 </span><span class="cov4" title="11">{ + if d == nil || len(d.lines) == 0 </span><span class="cov5" title="33">{ return nil }</span> - <span class="cov3" title="4">var edits []TextEdit + <span class="cov4" title="12">var edits []TextEdit openStr, _, openChar, closeChar := s.inlineMarkers() - for i, line := range d.lines </span><span class="cov5" title="13">{ + for i, line := range d.lines </span><span class="cov6" title="39">{ edits = append(edits, promptRemovalEditsForLine(line, i, openStr, openChar, closeChar)...) }</span> - <span class="cov3" title="4">return edits</span> + <span class="cov4" title="12">return edits</span> } -func promptRemovalEditsForLine(line string, lineNum int, openStr string, open, close byte) []TextEdit <span class="cov5" title="17">{ - if hasDoubleOpenTrigger(line, openStr, open, close) </span><span class="cov3" title="5">{ +func promptRemovalEditsForLine(line string, lineNum int, openStr string, open, close byte) []TextEdit <span class="cov6" title="51">{ + if hasDoubleOpenTrigger(line, openStr, open, close) </span><span class="cov4" title="15">{ return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} }</span> - <span class="cov5" title="12">return collectSemicolonMarkers(line, lineNum, openStr, open, close)</span> + <span class="cov5" title="36">return collectSemicolonMarkers(line, lineNum, openStr, open, close)</span> } -func hasDoubleOpenTrigger(line string, openStr string, open, close byte) bool <span class="cov8" title="87">{ +func hasDoubleOpenTrigger(line string, openStr string, open, close byte) bool <span class="cov8" title="261">{ if openStr == "" </span><span class="cov0" title="0">{ openStr = string(open) }</span> - <span class="cov8" title="87">if openStr == "" </span><span class="cov0" title="0">{ + <span class="cov8" title="261">if openStr == "" </span><span class="cov0" title="0">{ return false }</span> - <span class="cov8" title="87">seqs := doubleOpenSequences(openStr, open, close) + <span class="cov8" title="261">seqs := doubleOpenSequences(openStr, open, close) if len(seqs) == 0 </span><span class="cov0" title="0">{ return false }</span> - <span class="cov8" title="87">pos := 0 - for pos < len(line) </span><span class="cov8" title="86">{ + <span class="cov8" title="261">pos := 0 + for pos < len(line) </span><span class="cov8" title="258">{ found := -1 var seq string - for _, cand := range seqs </span><span class="cov9" title="171">{ + for _, cand := range seqs </span><span class="cov9" title="513">{ if cand == "" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov9" title="171">if idx := strings.Index(line[pos:], cand); idx >= 0 </span><span class="cov6" title="25">{ + <span class="cov9" title="513">if idx := strings.Index(line[pos:], cand); idx >= 0 </span><span class="cov6" title="75">{ abs := pos + idx - if found < 0 || abs < found </span><span class="cov6" title="25">{ + if found < 0 || abs < found </span><span class="cov6" title="75">{ found = abs seq = cand }</span> } } - <span class="cov8" title="86">if found < 0 </span><span class="cov7" title="62">{ + <span class="cov8" title="258">if found < 0 </span><span class="cov8" title="186">{ return false }</span> - <span class="cov6" title="24">contentStart := found + len(seq) - if contentStart >= len(line) </span><span class="cov4" title="7">{ + <span class="cov6" title="72">contentStart := found + len(seq) + if contentStart >= len(line) </span><span class="cov5" title="21">{ return false }</span> - <span class="cov5" title="17">first := line[contentStart] - if first == ' ' || first == close || first == open </span><span class="cov3" title="5">{ + <span class="cov6" title="51">first := line[contentStart] + if first == ' ' || first == close || first == open </span><span class="cov4" title="15">{ pos = contentStart + 1 continue</span> } - <span class="cov5" title="12">if contentStart+1 >= len(line) </span><span class="cov0" title="0">{ + <span class="cov5" title="36">if contentStart+1 >= len(line) </span><span class="cov0" title="0">{ return false }</span> - <span class="cov5" title="12">k := strings.IndexByte(line[contentStart+1:], close) + <span class="cov5" title="36">k := strings.IndexByte(line[contentStart+1:], close) if k < 0 </span><span class="cov0" title="0">{ return false }</span> - <span class="cov5" title="12">closeIdx := contentStart + 1 + k - if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ + <span class="cov5" title="36">closeIdx := contentStart + 1 + k + if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' </span><span class="cov2" title="3">{ pos = closeIdx + 1 continue</span> } - <span class="cov4" title="11">return true</span> + <span class="cov5" title="33">return true</span> } - <span class="cov4" title="7">return false</span> + <span class="cov5" title="21">return false</span> } -func collectSemicolonMarkers(line string, lineNum int, openStr string, open, close byte) []TextEdit <span class="cov5" title="14">{ +func collectSemicolonMarkers(line string, lineNum int, openStr string, open, close byte) []TextEdit <span class="cov6" title="42">{ if openStr == "" </span><span class="cov0" title="0">{ openStr = string(open) }</span> - <span class="cov5" title="14">if openStr == "" </span><span class="cov0" title="0">{ + <span class="cov6" title="42">if openStr == "" </span><span class="cov0" title="0">{ return nil }</span> - <span class="cov5" title="14">var edits []TextEdit + <span class="cov6" title="42">var edits []TextEdit start := 0 doubleSeqs := doubleOpenSequences(openStr, open, close) - for start < len(line) </span><span class="cov5" title="18">{ + for start < len(line) </span><span class="cov6" title="54">{ j := strings.Index(line[start:], openStr) - if j < 0 </span><span class="cov5" title="12">{ + if j < 0 </span><span class="cov5" title="36">{ break</span> } - <span class="cov3" title="6">j += start + <span class="cov4" title="18">j += start contentStart := j + len(openStr) if contentStart >= len(line) </span><span class="cov0" title="0">{ break</span> } - <span class="cov3" title="6">next := line[contentStart] + <span class="cov4" title="18">next := line[contentStart] if next == ' ' </span><span class="cov0" title="0">{ start = j + 1 continue</span> } - <span class="cov3" title="6">skipDouble := false - for _, seq := range doubleSeqs </span><span class="cov4" title="11">{ + <span class="cov4" title="18">skipDouble := false + for _, seq := range doubleSeqs </span><span class="cov5" title="33">{ if strings.HasPrefix(line[j:], seq) </span><span class="cov0" title="0">{ skipDouble = true break</span> } } - <span class="cov3" title="6">if skipDouble </span><span class="cov0" title="0">{ + <span class="cov4" title="18">if skipDouble </span><span class="cov0" title="0">{ start = j + 1 continue</span> } - <span class="cov3" title="6">k := strings.IndexByte(line[contentStart:], close) + <span class="cov4" title="18">k := strings.IndexByte(line[contentStart:], close) if k < 0 </span><span class="cov0" title="0">{ break</span> } - <span class="cov3" title="6">closeIdx := contentStart + k + <span class="cov4" title="18">closeIdx := contentStart + k if closeIdx-1 < contentStart || line[closeIdx-1] == ' ' </span><span class="cov0" title="0">{ start = closeIdx + 1 continue</span> } - <span class="cov3" title="6">if closeIdx == contentStart </span><span class="cov0" title="0">{ + <span class="cov4" title="18">if closeIdx == contentStart </span><span class="cov0" title="0">{ start = closeIdx + 1 continue</span> } - <span class="cov3" title="6">endChar := closeIdx + 1 - if endChar < len(line) && line[endChar] == ' ' </span><span class="cov3" title="4">{ + <span class="cov4" title="18">endChar := closeIdx + 1 + if endChar < len(line) && line[endChar] == ' ' </span><span class="cov4" title="12">{ endChar++ }</span> - <span class="cov3" title="6">edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) + <span class="cov4" title="18">edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) start = endChar</span> } - <span class="cov5" title="14">return edits</span> + <span class="cov6" title="42">return edits</span> } </pre> - <pre class="file" id="file33" style="display: none">// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats. + <pre class="file" id="file34" style="display: none">// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats. package lsp import ( @@ -8233,6 +8768,7 @@ type ServerOptions struct { ManualInvokeMinPrefix int CompletionDebounceMs int CompletionThrottleMs int + CompletionWaitAll *bool // Inline/chat triggers InlineOpen string @@ -8275,7 +8811,7 @@ 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="cov4" title="8">{ +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server <span class="cov4" title="24">{ 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.compCache = make(map[string]string) @@ -8295,22 +8831,22 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) "codeAction/resolve": s.handleCodeActionResolve, "workspace/executeCommand": s.handleExecuteCommand, } - <span class="cov4" title="8">return s</span> + <span class="cov4" title="24">return s</span> } -func (s *Server) applyOptions(opts ServerOptions) <span class="cov4" title="9">{ +func (s *Server) applyOptions(opts ServerOptions) <span class="cov5" title="27">{ s.mu.Lock() defer s.mu.Unlock() s.logContext = opts.LogContext s.configLoadOpts = opts.ConfigLoadOptions - if opts.ConfigStore != nil </span><span class="cov1" title="1">{ + if opts.ConfigStore != nil </span><span class="cov2" title="3">{ s.configStore = opts.ConfigStore }</span> - <span class="cov4" title="9">if opts.Config != nil </span><span class="cov2" title="2">{ + <span class="cov5" title="27">if opts.Config != nil </span><span class="cov3" title="6">{ s.cfg = *opts.Config - }</span> else<span class="cov3" title="7"> if opts.ConfigStore != nil </span><span class="cov0" title="0">{ + }</span> else<span class="cov4" title="21"> if opts.ConfigStore != nil </span><span class="cov0" title="0">{ s.cfg = opts.ConfigStore.Snapshot() - }</span> else<span class="cov3" title="7"> { + }</span> else<span class="cov4" title="21"> { s.cfg = appconfig.App{} // populate from legacy ServerOptions fields s.cfg.MaxTokens = opts.MaxTokens @@ -8322,6 +8858,7 @@ func (s *Server) applyOptions(opts ServerOptions) <span class="cov4" title="9">{ s.cfg.ManualInvokeMinPrefix = opts.ManualInvokeMinPrefix s.cfg.CompletionDebounceMs = opts.CompletionDebounceMs s.cfg.CompletionThrottleMs = opts.CompletionThrottleMs + s.cfg.CompletionWaitAll = opts.CompletionWaitAll s.cfg.InlineOpen = opts.InlineOpen s.cfg.InlineClose = opts.InlineClose s.cfg.ChatSuffix = opts.ChatSuffix @@ -8357,29 +8894,30 @@ func (s *Server) applyOptions(opts ServerOptions) <span class="cov4" title="9">{ } }</span> } - <span class="cov4" title="9">s.llmClient = opts.Client - if opts.Client != nil </span><span class="cov2" title="2">{ + <span class="cov5" title="27">s.llmClient = opts.Client + if opts.Client != nil </span><span class="cov3" title="6">{ s.llmProvider = canonicalProvider(opts.Client.Name()) - }</span> else<span class="cov3" title="7"> { + }</span> else<span class="cov4" title="21"> { s.llmProvider = canonicalProvider(s.cfg.Provider) }</span> - <span class="cov4" title="9">s.altClients = make(map[string]llm.Client)</span> + <span class="cov5" title="27">s.altClients = make(map[string]llm.Client)</span> } // ApplyOptions updates the server's configuration at runtime. -func (s *Server) ApplyOptions(opts ServerOptions) <span class="cov1" title="1">{ +func (s *Server) ApplyOptions(opts ServerOptions) <span class="cov2" title="3">{ s.applyOptions(opts) }</span> -func (s *Server) currentLLMClient() llm.Client <span class="cov7" title="83">{ +func (s *Server) currentLLMClient() llm.Client <span class="cov7" title="249">{ s.mu.RLock() defer s.mu.RUnlock() return s.llmClient }</span> -func newClientForProvider(cfg appconfig.App, provider string) (llm.Client, error) <span class="cov3" title="5">{ +func newClientForProvider(cfg appconfig.App, provider string) (llm.Client, error) <span class="cov4" title="15">{ llmCfg := llm.Config{ Provider: provider, + RequestTimeout: cfg.RequestTimeout, OpenAIBaseURL: cfg.OpenAIBaseURL, OpenAIModel: cfg.OpenAIModel, OpenAITemperature: cfg.OpenAITemperature, @@ -8392,54 +8930,61 @@ func newClientForProvider(cfg appconfig.App, provider string) (llm.Client, error CopilotBaseURL: cfg.CopilotBaseURL, CopilotModel: cfg.CopilotModel, CopilotTemperature: cfg.CopilotTemperature, + AnthropicBaseURL: cfg.AnthropicBaseURL, + AnthropicModel: cfg.AnthropicModel, + AnthropicTemperature: cfg.AnthropicTemperature, } oaKey := strings.TrimSpace(os.Getenv("HEXAI_OPENAI_API_KEY")) - if oaKey == "" </span><span class="cov3" title="5">{ + if oaKey == "" </span><span class="cov4" title="15">{ oaKey = strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) }</span> - <span class="cov3" title="5">orKey := strings.TrimSpace(os.Getenv("HEXAI_OPENROUTER_API_KEY")) - if orKey == "" </span><span class="cov3" title="5">{ + <span class="cov4" title="15">orKey := strings.TrimSpace(os.Getenv("HEXAI_OPENROUTER_API_KEY")) + if orKey == "" </span><span class="cov4" title="15">{ orKey = strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")) }</span> - <span class="cov3" title="5">cpKey := strings.TrimSpace(os.Getenv("HEXAI_COPILOT_API_KEY")) - if cpKey == "" </span><span class="cov3" title="5">{ + <span class="cov4" title="15">cpKey := strings.TrimSpace(os.Getenv("HEXAI_COPILOT_API_KEY")) + if cpKey == "" </span><span class="cov4" title="15">{ cpKey = strings.TrimSpace(os.Getenv("COPILOT_API_KEY")) }</span> - <span class="cov3" title="5">return llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey)</span> + <span class="cov4" title="15">anKey := strings.TrimSpace(os.Getenv("HEXAI_ANTHROPIC_API_KEY")) + if anKey == "" </span><span class="cov4" title="15">{ + anKey = strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) + }</span> + <span class="cov4" title="15">return llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey, anKey)</span> } -func (s *Server) clientFor(spec requestSpec) llm.Client <span class="cov6" title="47">{ +func (s *Server) clientFor(spec requestSpec) llm.Client <span class="cov7" title="141">{ provider := canonicalProvider(spec.provider) s.mu.RLock() baseProvider := s.llmProvider baseClient := s.llmClient - if baseClient != nil && strings.TrimSpace(baseProvider) == "" </span><span class="cov2" title="3">{ + if baseClient != nil && strings.TrimSpace(baseProvider) == "" </span><span class="cov3" title="9">{ baseProvider = canonicalProvider(baseClient.Name()) }</span> - <span class="cov6" title="47">if provider == "" </span><span class="cov0" title="0">{ + <span class="cov7" title="141">if provider == "" </span><span class="cov0" title="0">{ provider = baseProvider }</span> - <span class="cov6" title="47">if provider == baseProvider && baseClient != nil </span><span class="cov6" title="42">{ + <span class="cov7" title="141">if provider == baseProvider && baseClient != nil </span><span class="cov7" title="126">{ s.mu.RUnlock() return baseClient }</span> - <span class="cov3" title="5">if c, ok := s.altClients[provider]; ok </span><span class="cov0" title="0">{ + <span class="cov4" title="15">if c, ok := s.altClients[provider]; ok </span><span class="cov0" title="0">{ s.mu.RUnlock() return c }</span> - <span class="cov3" title="5">cfg := s.cfg + <span class="cov4" title="15">cfg := s.cfg store := s.configStore s.mu.RUnlock() if store != nil </span><span class="cov0" title="0">{ cfg = store.Snapshot() }</span> - <span class="cov3" title="5">cfg.Provider = provider + <span class="cov4" title="15">cfg.Provider = provider modelOverride := strings.TrimSpace(spec.entry.Model) switch provider </span>{ - case "openai":<span class="cov3" title="5"> + case "openai":<span class="cov4" title="15"> if modelOverride != "" </span><span class="cov0" title="0">{ cfg.OpenAIModel = modelOverride - }</span> else<span class="cov3" title="5"> if spec.fallbackModel != "" </span><span class="cov0" title="0">{ + }</span> else<span class="cov4" title="15"> if spec.fallbackModel != "" </span><span class="cov0" title="0">{ cfg.OpenAIModel = spec.fallbackModel }</span> case "openrouter":<span class="cov0" title="0"> @@ -8460,8 +9005,14 @@ func (s *Server) clientFor(spec requestSpec) llm.Client <span class="cov6" title }</span> else<span class="cov0" title="0"> if spec.fallbackModel != "" </span><span class="cov0" title="0">{ cfg.OllamaModel = spec.fallbackModel }</span> + case "anthropic":<span class="cov0" title="0"> + if modelOverride != "" </span><span class="cov0" title="0">{ + cfg.AnthropicModel = modelOverride + }</span> else<span class="cov0" title="0"> if spec.fallbackModel != "" </span><span class="cov0" title="0">{ + cfg.AnthropicModel = spec.fallbackModel + }</span> } - <span class="cov3" title="5">client, err := newClientForProvider(cfg, provider) + <span class="cov4" title="15">client, err := newClientForProvider(cfg, provider) if err != nil </span><span class="cov0" title="0">{ logging.Logf("lsp ", "failed to build client for provider=%s: %v", provider, err) if baseClient != nil </span><span class="cov0" title="0">{ @@ -8469,49 +9020,49 @@ func (s *Server) clientFor(spec requestSpec) llm.Client <span class="cov6" title }</span> <span class="cov0" title="0">return nil</span> } - <span class="cov3" title="5">s.mu.Lock() + <span class="cov4" title="15">s.mu.Lock() defer s.mu.Unlock() - if provider == s.llmProvider </span><span class="cov1" title="1">{ - if s.llmClient == nil </span><span class="cov1" title="1">{ + if provider == s.llmProvider </span><span class="cov2" title="3">{ + if s.llmClient == nil </span><span class="cov2" title="3">{ s.llmClient = client s.llmProvider = provider }</span> - <span class="cov1" title="1">return s.llmClient</span> + <span class="cov2" title="3">return s.llmClient</span> } - <span class="cov3" title="4">if existing, ok := s.altClients[provider]; ok </span><span class="cov0" title="0">{ + <span class="cov4" title="12">if existing, ok := s.altClients[provider]; ok </span><span class="cov0" title="0">{ return existing }</span> - <span class="cov3" title="4">if s.altClients == nil </span><span class="cov3" title="4">{ + <span class="cov4" title="12">if s.altClients == nil </span><span class="cov4" title="12">{ s.altClients = make(map[string]llm.Client) }</span> - <span class="cov3" title="4">s.altClients[provider] = client + <span class="cov4" title="12">s.altClients[provider] = client return client</span> } -func (s *Server) currentConfig() appconfig.App <span class="cov10" title="445">{ - if s.configStore != nil </span><span class="cov3" title="5">{ +func (s *Server) currentConfig() appconfig.App <span class="cov10" title="1335">{ + if s.configStore != nil </span><span class="cov4" title="15">{ return s.configStore.Snapshot() }</span> - <span class="cov9" title="440">s.mu.RLock() + <span class="cov9" title="1320">s.mu.RLock() defer s.mu.RUnlock() return s.cfg</span> } -func (s *Server) storePendingCompletion(key string, items []CompletionItem) <span class="cov1" title="1">{ +func (s *Server) storePendingCompletion(key string, items []CompletionItem) <span class="cov2" title="3">{ if len(items) == 0 </span><span class="cov0" title="0">{ return }</span> - <span class="cov1" title="1">cpy := make([]CompletionItem, len(items)) + <span class="cov2" title="3">cpy := make([]CompletionItem, len(items)) copy(cpy, items) s.mu.Lock() - if s.pendingCompletions == nil </span><span class="cov1" title="1">{ + if s.pendingCompletions == nil </span><span class="cov2" title="3">{ s.pendingCompletions = make(map[string][]CompletionItem) }</span> - <span class="cov1" title="1">s.pendingCompletions[key] = cpy + <span class="cov2" title="3">s.pendingCompletions[key] = cpy s.mu.Unlock()</span> } -func (s *Server) setCompletionsDisabled(disabled bool) bool <span class="cov3" title="6">{ +func (s *Server) setCompletionsDisabled(disabled bool) bool <span class="cov4" title="18">{ s.mu.Lock() prev := s.completionsDisabled s.completionsDisabled = disabled @@ -8519,148 +9070,156 @@ func (s *Server) setCompletionsDisabled(disabled bool) bool <span class="cov3" t return prev }</span> -func (s *Server) completionDisabled() bool <span class="cov3" title="6">{ +func (s *Server) completionDisabled() bool <span class="cov4" title="18">{ s.mu.RLock() defer s.mu.RUnlock() return s.completionsDisabled }</span> -func (s *Server) takePendingCompletion(key string) []CompletionItem <span class="cov4" title="12">{ +func (s *Server) takePendingCompletion(key string) []CompletionItem <span class="cov5" title="36">{ s.mu.Lock() defer s.mu.Unlock() - if len(s.pendingCompletions) == 0 </span><span class="cov4" title="11">{ + if len(s.pendingCompletions) == 0 </span><span class="cov5" title="33">{ return nil }</span> - <span class="cov1" title="1">items, ok := s.pendingCompletions[key] + <span class="cov2" title="3">items, ok := s.pendingCompletions[key] if !ok </span><span class="cov0" title="0">{ return nil }</span> - <span class="cov1" title="1">delete(s.pendingCompletions, key) + <span class="cov2" title="3">delete(s.pendingCompletions, key) cpy := make([]CompletionItem, len(items)) copy(cpy, items) return cpy</span> } -func (s *Server) maxTokens() int <span class="cov6" title="44">{ +func (s *Server) maxTokens() int <span class="cov7" title="132">{ cfg := s.currentConfig() - if cfg.MaxTokens <= 0 </span><span class="cov6" title="36">{ + if cfg.MaxTokens <= 0 </span><span class="cov6" title="108">{ return 500 }</span> - <span class="cov4" title="8">return cfg.MaxTokens</span> + <span class="cov4" title="24">return cfg.MaxTokens</span> } -func (s *Server) contextMode() string <span class="cov4" title="14">{ +func (s *Server) contextMode() string <span class="cov5" title="42">{ mode := strings.TrimSpace(s.currentConfig().ContextMode) - if mode == "" </span><span class="cov3" title="5">{ + if mode == "" </span><span class="cov4" title="15">{ return "file-on-new-func" }</span> - <span class="cov4" title="9">return mode</span> + <span class="cov5" title="27">return mode</span> } -func (s *Server) windowLines() int <span class="cov2" title="2">{ +func (s *Server) windowLines() int <span class="cov3" title="6">{ 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> + <span class="cov3" title="6">return cfg.ContextWindowLines</span> } -func (s *Server) maxContextTokens() int <span class="cov3" title="6">{ +func (s *Server) maxContextTokens() int <span class="cov4" title="18">{ 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> + <span class="cov4" title="18">return cfg.MaxContextTokens</span> } -func (s *Server) triggerCharacters() []string <span class="cov5" title="27">{ +func (s *Server) triggerCharacters() []string <span class="cov6" title="81">{ cfg := s.currentConfig() - if len(cfg.TriggerCharacters) == 0 </span><span class="cov2" title="3">{ + if len(cfg.TriggerCharacters) == 0 </span><span class="cov3" title="9">{ return []string{".", ":", "/", "_", ")", "{"} }</span> - <span class="cov5" title="24">return append([]string{}, cfg.TriggerCharacters...)</span> + <span class="cov6" title="72">return append([]string{}, cfg.TriggerCharacters...)</span> } -func (s *Server) codingTemperature() *float64 <span class="cov4" title="11">{ +func (s *Server) codingTemperature() *float64 <span class="cov5" title="33">{ cfg := s.currentConfig() return cfg.CodingTemperature }</span> -func (s *Server) manualInvokeMinPrefix() int <span class="cov3" title="5">{ +func (s *Server) manualInvokeMinPrefix() int <span class="cov4" title="15">{ return s.currentConfig().ManualInvokeMinPrefix }</span> -func (s *Server) completionDebounce() time.Duration <span class="cov6" title="40">{ +func (s *Server) completionDebounce() time.Duration <span class="cov6" title="120">{ cfg := s.currentConfig() - if cfg.CompletionDebounceMs <= 0 </span><span class="cov6" title="38">{ + if cfg.CompletionDebounceMs <= 0 </span><span class="cov6" title="114">{ return 0 }</span> - <span class="cov2" title="2">return time.Duration(cfg.CompletionDebounceMs) * time.Millisecond</span> + <span class="cov3" title="6">return time.Duration(cfg.CompletionDebounceMs) * time.Millisecond</span> } -func (s *Server) completionThrottle() time.Duration <span class="cov6" title="40">{ +func (s *Server) completionThrottle() time.Duration <span class="cov6" title="120">{ cfg := s.currentConfig() - if cfg.CompletionThrottleMs <= 0 </span><span class="cov6" title="37">{ + if cfg.CompletionThrottleMs <= 0 </span><span class="cov6" title="111">{ return 0 }</span> - <span class="cov2" title="3">return time.Duration(cfg.CompletionThrottleMs) * time.Millisecond</span> + <span class="cov3" title="9">return time.Duration(cfg.CompletionThrottleMs) * time.Millisecond</span> } -func (s *Server) inlineMarkers() (open string, close string, openChar byte, closeChar byte) <span class="cov7" title="102">{ +func (s *Server) completionWaitAll() bool <span class="cov0" title="0">{ + cfg := s.currentConfig() + if cfg.CompletionWaitAll == nil </span><span class="cov0" title="0">{ + return true // default: wait for all backends + }</span> + <span class="cov0" title="0">return *cfg.CompletionWaitAll</span> +} + +func (s *Server) inlineMarkers() (open string, close string, openChar byte, closeChar byte) <span class="cov8" title="306">{ cfg := s.currentConfig() open = strings.TrimSpace(cfg.InlineOpen) - if open == "" </span><span class="cov2" title="2">{ + if open == "" </span><span class="cov3" title="6">{ open = ">!" }</span> - <span class="cov7" title="102">close = strings.TrimSpace(cfg.InlineClose) - if close == "" </span><span class="cov2" title="2">{ + <span class="cov8" title="306">close = strings.TrimSpace(cfg.InlineClose) + if close == "" </span><span class="cov3" title="6">{ close = ">" }</span> - <span class="cov7" title="102">openChar = '>' - if len(open) > 0 </span><span class="cov7" title="102">{ + <span class="cov8" title="306">openChar = '>' + if len(open) > 0 </span><span class="cov8" title="306">{ openChar = open[0] }</span> - <span class="cov7" title="102">closeChar = '>' - if len(close) > 0 </span><span class="cov7" title="102">{ + <span class="cov8" title="306">closeChar = '>' + if len(close) > 0 </span><span class="cov8" title="306">{ closeChar = close[0] }</span> - <span class="cov7" title="102">return open, close, openChar, closeChar</span> + <span class="cov8" title="306">return open, close, openChar, closeChar</span> } -func (s *Server) chatConfig() (suffix string, prefixes []string, suffixChar byte) <span class="cov6" title="51">{ +func (s *Server) chatConfig() (suffix string, prefixes []string, suffixChar byte) <span class="cov7" title="153">{ cfg := s.currentConfig() suffix = cfg.ChatSuffix - if suffix != "" </span><span class="cov6" title="49">{ + if suffix != "" </span><span class="cov7" title="147">{ suffix = strings.TrimSpace(suffix) if suffix == "" </span><span class="cov0" title="0">{ suffix = ">" }</span> - } else<span class="cov2" title="2"> { + } else<span class="cov3" title="6"> { suffix = "" }</span> - <span class="cov6" title="51">if len(cfg.ChatPrefixes) == 0 </span><span class="cov0" title="0">{ + <span class="cov7" title="153">if len(cfg.ChatPrefixes) == 0 </span><span class="cov0" title="0">{ prefixes = []string{"?", "!", ":", ";"} - }</span> else<span class="cov6" title="51"> { + }</span> else<span class="cov7" title="153"> { prefixes = append([]string{}, cfg.ChatPrefixes...) }</span> - <span class="cov6" title="51">suffixChar = '>' - if len(suffix) > 0 </span><span class="cov6" title="49">{ + <span class="cov7" title="153">suffixChar = '>' + if len(suffix) > 0 </span><span class="cov7" title="147">{ suffixChar = suffix[0] }</span> - <span class="cov6" title="51">return suffix, prefixes, suffixChar</span> + <span class="cov7" title="153">return suffix, prefixes, suffixChar</span> } -func (s *Server) promptSet() appconfig.App <span class="cov2" title="2">{ +func (s *Server) promptSet() appconfig.App <span class="cov3" title="6">{ return s.currentConfig() }</span> -func (s *Server) customActions() []CustomAction <span class="cov3" title="7">{ +func (s *Server) customActions() []CustomAction <span class="cov4" title="21">{ cfg := s.currentConfig() - if len(cfg.CustomActions) == 0 </span><span class="cov1" title="1">{ + if len(cfg.CustomActions) == 0 </span><span class="cov2" title="3">{ 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">{ + <span class="cov4" title="18">customs := make([]CustomAction, 0, len(cfg.CustomActions)) + for _, ca := range cfg.CustomActions </span><span class="cov5" title="30">{ customs = append(customs, CustomAction{ ID: ca.ID, Title: ca.Title, @@ -8671,13 +9230,13 @@ func (s *Server) customActions() []CustomAction <span class="cov3" title="7">{ User: ca.User, }) }</span> - <span class="cov3" title="6">return customs</span> + <span class="cov4" title="18">return customs</span> } -func (s *Server) Run() error <span class="cov1" title="1">{ - for </span><span class="cov1" title="1">{ +func (s *Server) Run() error <span class="cov2" title="3">{ + for </span><span class="cov2" title="3">{ body, err := s.readMessage() - if err == io.EOF </span><span class="cov1" title="1">{ + if err == io.EOF </span><span class="cov2" title="3">{ return nil }</span> <span class="cov0" title="0">if err != nil </span><span class="cov0" title="0">{ @@ -8700,7 +9259,7 @@ func (s *Server) Run() error <span class="cov1" title="1">{ } </pre> - <pre class="file" id="file34" style="display: none">// Summary: LSP transport utilities to read and write JSON-RPC messages with Content-Length framing. + <pre class="file" id="file35" style="display: none">// Summary: LSP transport utilities to read and write JSON-RPC messages with Content-Length framing. package lsp import ( @@ -8714,43 +9273,43 @@ import ( "codeberg.org/snonux/hexai/internal/logging" ) -func (s *Server) readMessage() ([]byte, error) <span class="cov2" title="2">{ +func (s *Server) readMessage() ([]byte, error) <span class="cov4" title="6">{ tp := textproto.NewReader(s.in) var contentLength int - for </span><span class="cov3" title="3">{ + for </span><span class="cov5" title="9">{ line, err := tp.ReadLine() - if err != nil </span><span class="cov1" title="1">{ + if err != nil </span><span class="cov3" title="3">{ return nil, err }</span> - <span class="cov2" title="2">if line == "" </span><span class="cov1" title="1">{ // end of headers + <span class="cov4" title="6">if line == "" </span><span class="cov3" title="3">{ // end of headers break</span> } - <span class="cov1" title="1">parts := strings.SplitN(line, ":", 2) + <span class="cov3" title="3">parts := strings.SplitN(line, ":", 2) if len(parts) != 2 </span><span class="cov0" title="0">{ continue</span> } - <span class="cov1" title="1">key := strings.TrimSpace(strings.ToLower(parts[0])) + <span class="cov3" title="3">key := strings.TrimSpace(strings.ToLower(parts[0])) val := strings.TrimSpace(parts[1]) switch key </span>{ - case "content-length":<span class="cov1" title="1"> + case "content-length":<span class="cov3" title="3"> n, err := strconv.Atoi(val) if err != nil </span><span class="cov0" title="0">{ return nil, fmt.Errorf("invalid Content-Length: %v", err) }</span> - <span class="cov1" title="1">contentLength = n</span> + <span class="cov3" title="3">contentLength = n</span> } } - <span class="cov1" title="1">if contentLength <= 0 </span><span class="cov0" title="0">{ + <span class="cov3" title="3">if contentLength <= 0 </span><span class="cov0" title="0">{ return nil, fmt.Errorf("missing or invalid Content-Length") }</span> - <span class="cov1" title="1">buf := make([]byte, contentLength) + <span class="cov3" title="3">buf := make([]byte, contentLength) if _, err := io.ReadFull(s.in, buf); err != nil </span><span class="cov0" title="0">{ return nil, err }</span> - <span class="cov1" title="1">return buf, nil</span> + <span class="cov3" title="3">return buf, nil</span> } -func (s *Server) writeMessage(v any) <span class="cov10" title="44">{ +func (s *Server) writeMessage(v any) <span class="cov10" title="132">{ s.outMu.Lock() defer s.outMu.Unlock() @@ -8759,19 +9318,19 @@ func (s *Server) writeMessage(v any) <span class="cov10" title="44">{ logging.Logf("lsp ", "marshal error: %v", err) return }</span> - <span class="cov10" title="44">header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) + <span class="cov10" title="132">header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) if _, err := io.WriteString(s.out, header); err != nil </span><span class="cov0" title="0">{ logging.Logf("lsp ", "write header error: %v", err) return }</span> - <span class="cov10" title="44">if _, err := s.out.Write(data); err != nil </span><span class="cov0" title="0">{ + <span class="cov10" title="132">if _, err := s.out.Write(data); err != nil </span><span class="cov0" title="0">{ logging.Logf("lsp ", "write body error: %v", err) return }</span> } </pre> - <pre class="file" id="file35" style="display: none">package runtimeconfig + <pre class="file" id="file36" style="display: none">package runtimeconfig import ( "fmt" @@ -8804,12 +9363,12 @@ type Store struct { } // New creates a Store seeded with the provided configuration snapshot. -func New(cfg appconfig.App) *Store <span class="cov4" title="13">{ +func New(cfg appconfig.App) *Store <span class="cov5" title="39">{ 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="7">{ +func (s *Store) Snapshot() appconfig.App <span class="cov4" title="21">{ s.mu.RLock() defer s.mu.RUnlock() return s.cfg @@ -8817,11 +9376,11 @@ func (s *Store) Snapshot() appconfig.App <span class="cov3" title="7">{ // 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="cov1" title="2">{ +func (s *Store) Subscribe(listener Listener) func() <span class="cov3" title="6">{ if listener == nil </span><span class="cov0" title="0">{ return func() </span>{<span class="cov0" title="0">}</span> } - <span class="cov1" title="2">s.mu.Lock() + <span class="cov3" title="6">s.mu.Lock() id := s.nextID s.nextID++ s.listeners[id] = listener @@ -8835,164 +9394,164 @@ func (s *Store) Subscribe(listener Listener) func() <span class="cov1" title="2" // 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="6">{ +func (s *Store) Set(cfg appconfig.App) []Change <span class="cov4" title="18">{ 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">{ + for _, l := range s.listeners </span><span class="cov2" title="3">{ listeners = append(listeners, l) }</span> - <span class="cov3" title="6">s.mu.Unlock() + <span class="cov4" title="18">s.mu.Unlock() changes := Diff(old, cfg) - for _, l := range listeners </span><span class="cov1" title="1">{ + for _, l := range listeners </span><span class="cov2" title="3">{ l(old, cfg) }</span> - <span class="cov3" title="6">return changes</span> + <span class="cov4" title="18">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="4">{ +func (s *Store) Reload(logger *log.Logger, opts appconfig.LoadOptions) ([]Change, error) <span class="cov3" title="12">{ 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="4">changes := s.Set(cfg) - if logger != nil </span><span class="cov2" title="4">{ + <span class="cov3" title="12">changes := s.Set(cfg) + if logger != nil </span><span class="cov3" title="12">{ logger.Print(FormatSummary("Reloaded config", changes)) }</span> - <span class="cov2" title="4">return changes, nil</span> + <span class="cov3" title="12">return changes, 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="7">{ +func Diff(oldCfg, newCfg appconfig.App) []Change <span class="cov4" title="21">{ before := flattenAppConfig(oldCfg) after := flattenAppConfig(newCfg) keys := make(map[string]struct{}, len(before)+len(after)) - for k := range before </span><span class="cov8" title="224">{ + for k := range before </span><span class="cov8" title="777">{ keys[k] = struct{}{} }</span> - <span class="cov3" title="7">for k := range after </span><span class="cov8" title="224">{ + <span class="cov4" title="21">for k := range after </span><span class="cov8" title="777">{ keys[k] = struct{}{} }</span> - <span class="cov3" title="7">ordered := make([]string, 0, len(keys)) - for k := range keys </span><span class="cov8" title="224">{ + <span class="cov4" title="21">ordered := make([]string, 0, len(keys)) + for k := range keys </span><span class="cov8" title="777">{ ordered = append(ordered, k) }</span> - <span class="cov3" title="7">sort.Strings(ordered) + <span class="cov4" title="21">sort.Strings(ordered) changes := make([]Change, 0, len(ordered)) - for _, k := range ordered </span><span class="cov8" title="224">{ - if before[k] == after[k] </span><span class="cov8" title="217">{ + for _, k := range ordered </span><span class="cov8" title="777">{ + if before[k] == after[k] </span><span class="cov8" title="756">{ continue</span> } - <span class="cov3" title="7">changes = append(changes, Change{Key: k, Old: before[k], New: after[k]})</span> + <span class="cov4" title="21">changes = append(changes, Change{Key: k, Old: before[k], New: after[k]})</span> } - <span class="cov3" title="7">return changes</span> + <span class="cov4" title="21">return changes</span> } -func flattenAppConfig(cfg appconfig.App) map[string]string <span class="cov4" title="14">{ +func flattenAppConfig(cfg appconfig.App) map[string]string <span class="cov5" title="42">{ result := make(map[string]string) val := reflect.ValueOf(cfg) typ := val.Type() - for i := 0; i < typ.NumField(); i++ </span><span class="cov10" title="756">{ + for i := 0; i < typ.NumField(); i++ </span><span class="cov10" title="2478">{ field := typ.Field(i) key := strings.TrimSpace(field.Tag.Get("toml")) - if key == "" || key == "-" </span><span class="cov9" title="378">{ + if key == "" || key == "-" </span><span class="cov9" title="1134">{ switch field.Name </span>{ - case "StatsWindowMinutes":<span class="cov4" title="14"> + case "StatsWindowMinutes":<span class="cov5" title="42"> key = "stats_window_minutes"</span> - case "CompletionConfigs":<span class="cov4" title="14"> + case "CompletionConfigs":<span class="cov5" title="42"> key = "completion_configs"</span> - case "CodeActionConfigs":<span class="cov4" title="14"> + case "CodeActionConfigs":<span class="cov5" title="42"> key = "code_action_configs"</span> - case "ChatConfigs":<span class="cov4" title="14"> + case "ChatConfigs":<span class="cov5" title="42"> key = "chat_configs"</span> - case "CLIConfigs":<span class="cov4" title="14"> + case "CLIConfigs":<span class="cov5" title="42"> key = "cli_configs"</span> - default:<span class="cov8" title="308"> + default:<span class="cov8" title="924"> continue</span> } } - <span class="cov9" title="448">if idx := strings.Index(key, ","); idx >= 0 </span><span class="cov0" title="0">{ + <span class="cov9" title="1554">if idx := strings.Index(key, ","); idx >= 0 </span><span class="cov0" title="0">{ key = key[:idx] }</span> - <span class="cov9" title="448">if key == "" || key == "-" </span><span class="cov0" title="0">{ + <span class="cov9" title="1554">if key == "" || key == "-" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov9" title="448">result[key] = stringifyValue(val.Field(i))</span> + <span class="cov9" title="1554">result[key] = stringifyValue(val.Field(i))</span> } - <span class="cov4" title="14">return result</span> + <span class="cov5" title="42">return result</span> } -func stringifyValue(v reflect.Value) string <span class="cov9" title="488">{ +func stringifyValue(v reflect.Value) string <span class="cov9" title="1704">{ if !v.IsValid() </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov9" title="488">switch v.Kind() </span>{ - case reflect.String:<span class="cov8" title="182"> + <span class="cov9" title="1704">switch v.Kind() </span>{ + case reflect.String:<span class="cov8" title="630"> return v.String()</span> - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:<span class="cov7" title="112"> + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:<span class="cov7" title="378"> 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="cov6" title="40"> + case reflect.Float32, reflect.Float64:<span class="cov6" title="150"> 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="cov7" title="84"> - if v.IsNil() </span><span class="cov6" title="72">{ + case reflect.Slice:<span class="cov7" title="252"> + if v.IsNil() </span><span class="cov7" title="216">{ return "" }</span> - <span class="cov4" title="12">if v.Type().Elem().Kind() == reflect.String </span><span class="cov4" title="10">{ + <span class="cov5" title="36">if v.Type().Elem().Kind() == reflect.String </span><span class="cov4" title="30">{ parts := make([]string, v.Len()) - for i := range parts </span><span class="cov6" title="40">{ + for i := range parts </span><span class="cov6" title="120">{ parts[i] = v.Index(i).String() }</span> - <span class="cov4" title="10">return strings.Join(parts, ",")</span> + <span class="cov4" title="30">return strings.Join(parts, ",")</span> } - <span class="cov1" title="2">if v.Type().Elem() == reflect.TypeOf(appconfig.SurfaceConfig{}) </span><span class="cov1" title="2">{ + <span class="cov3" title="6">if v.Type().Elem() == reflect.TypeOf(appconfig.SurfaceConfig{}) </span><span class="cov3" title="6">{ parts := make([]string, 0, v.Len()) - for i := 0; i < v.Len(); i++ </span><span class="cov1" title="2">{ + for i := 0; i < v.Len(); i++ </span><span class="cov3" title="6">{ entry := v.Index(i).Interface().(appconfig.SurfaceConfig) segment := strings.TrimSpace(entry.Provider) - if segment != "" </span><span class="cov1" title="2">{ + if segment != "" </span><span class="cov3" title="6">{ segment += ":" }</span> - <span class="cov1" title="2">segment += strings.TrimSpace(entry.Model) + <span class="cov3" title="6">segment += strings.TrimSpace(entry.Model) if entry.Temperature != nil </span><span class="cov0" title="0">{ segment += fmt.Sprintf("@%.3f", *entry.Temperature) }</span> - <span class="cov1" title="2">parts = append(parts, segment)</span> + <span class="cov3" title="6">parts = append(parts, segment)</span> } - <span class="cov1" title="2">return strings.Join(parts, "|")</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="70"> - if v.IsNil() </span><span class="cov5" title="30">{ + case reflect.Ptr:<span class="cov7" title="294"> + if v.IsNil() </span><span class="cov6" title="144">{ return "(unset)" }</span> - <span class="cov6" title="40">return stringifyValue(v.Elem())</span> + <span class="cov6" title="150">return stringifyValue(v.Elem())</span> default:<span class="cov0" title="0"> return fmt.Sprint(v.Interface())</span> } } // FormatSummary creates a human-readable summary for configuration changes. -func FormatSummary(prefix string, changes []Change) string <span class="cov3" title="7">{ - if len(changes) == 0 </span><span class="cov1" title="2">{ +func FormatSummary(prefix string, changes []Change) string <span class="cov4" title="21">{ + if len(changes) == 0 </span><span class="cov3" title="6">{ return fmt.Sprintf("%s (no changes detected).", prefix) }</span> - <span class="cov3" title="5">lines := make([]string, 0, len(changes)+1) + <span class="cov4" title="15">lines := make([]string, 0, len(changes)+1) lines = append(lines, fmt.Sprintf("%s (%d changes):", prefix, len(changes))) - for _, ch := range changes </span><span class="cov3" title="6">{ + for _, ch := range changes </span><span class="cov4" title="18">{ lines = append(lines, fmt.Sprintf("- %s: %s → %s", ch.Key, ch.Old, ch.New)) }</span> - <span class="cov3" title="5">return strings.Join(lines, "\n")</span> + <span class="cov4" title="15">return strings.Join(lines, "\n")</span> } </pre> - <pre class="file" id="file36" style="display: none">//go:build !windows + <pre class="file" id="file37" style="display: none">//go:build !windows package stats @@ -9002,22 +9561,22 @@ import ( "golang.org/x/sys/unix" ) -func tryLockFile(fd uintptr) error <span class="cov10" title="208">{ - if err := unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB); err != nil </span><span class="cov9" title="120">{ - if errors.Is(err, unix.EWOULDBLOCK) </span><span class="cov9" title="120">{ +func tryLockFile(fd uintptr) error <span class="cov10" title="653">{ + if err := unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB); err != nil </span><span class="cov9" title="389">{ + if errors.Is(err, unix.EWOULDBLOCK) </span><span class="cov9" title="389">{ return errLockWouldBlock }</span> <span class="cov0" title="0">return err</span> } - <span class="cov8" title="88">return nil</span> + <span class="cov8" title="264">return nil</span> } -func unlockFile(fd uintptr) error <span class="cov8" title="88">{ +func unlockFile(fd uintptr) error <span class="cov8" title="264">{ return unix.Flock(int(fd), unix.LOCK_UN) }</span> </pre> - <pre class="file" id="file37" style="display: none">// Package stats provides a simple, process-safe, on-disk cache of Hexai LLM usage + <pre class="file" id="file38" 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 @@ -9048,18 +9607,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="83">{ +func SetWindow(d time.Duration) <span class="cov5" title="249">{ if d < time.Second </span><span class="cov0" title="0">{ d = time.Second }</span> - <span class="cov5" title="83">if d > 24*time.Hour </span><span class="cov0" title="0">{ + <span class="cov5" title="249">if d > 24*time.Hour </span><span class="cov0" title="0">{ d = 24 * time.Hour }</span> - <span class="cov5" title="83">atomic.StoreInt64(&windowSeconds, int64(d.Seconds()))</span> + <span class="cov5" title="249">atomic.StoreInt64(&windowSeconds, int64(d.Seconds()))</span> } // Window returns the current sliding window. -func Window() time.Duration <span class="cov5" title="88">{ return time.Duration(atomic.LoadInt64(&windowSeconds)) * time.Second }</span> +func Window() time.Duration <span class="cov5" title="264">{ return time.Duration(atomic.LoadInt64(&windowSeconds)) * time.Second }</span> // Event represents a single request/response with sizes. type Event struct { @@ -9094,108 +9653,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="88">{ +func Update(ctx context.Context, provider, model string, sentBytes, recvBytes int) error <span class="cov5" title="264">{ dir, err := CacheDir() if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="88">if err := os.MkdirAll(dir, 0o755); err != nil </span><span class="cov0" title="0">{ + <span class="cov5" title="264">if err := os.MkdirAll(dir, 0o755); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="88">lockPath := filepath.Join(dir, lockFileName) + <span class="cov5" title="264">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="88">defer f.Close() - unlock, err := acquireFileLock(ctx, f) + <span class="cov5" title="264">defer func() </span><span class="cov5" title="264">{ _ = f.Close() }</span>() + <span class="cov5" title="264">unlock, err := acquireFileLock(ctx, f) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov5" title="88">defer func() </span><span class="cov5" title="88">{ _ = unlock() }</span>() + <span class="cov5" title="264">defer func() </span><span class="cov5" title="264">{ _ = unlock() }</span>() // Read existing file (if any) - <span class="cov5" title="88">path := filepath.Join(dir, fileName) + <span class="cov5" title="264">path := filepath.Join(dir, fileName) var sf File - if b, rerr := os.ReadFile(path); rerr == nil </span><span class="cov5" title="85">{ + if b, rerr := os.ReadFile(path); rerr == nil </span><span class="cov5" title="255">{ _ = json.Unmarshal(b, &sf) }</span> - <span class="cov5" title="88">if sf.Version != fileVersion </span><span class="cov2" title="3">{ + <span class="cov5" title="264">if sf.Version != fileVersion </span><span class="cov2" title="9">{ sf = File{Version: fileVersion} }</span> - <span class="cov5" title="88">now := time.Now() + <span class="cov5" title="264">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="88">{ + if len(sf.Events) > 0 </span><span class="cov5" title="264">{ // Find first >= cutoff i := 0 - for ; i < len(sf.Events); i++ </span><span class="cov5" title="89">{ - if !sf.Events[i].TS.Before(cutoff) </span><span class="cov5" title="88">{ + for ; i < len(sf.Events); i++ </span><span class="cov5" title="267">{ + if !sf.Events[i].TS.Before(cutoff) </span><span class="cov5" title="264">{ break</span> } } - <span class="cov5" title="88">if i > 0 </span><span class="cov1" title="1">{ + <span class="cov5" title="264">if i > 0 </span><span class="cov1" title="3">{ sf.Events = append([]Event(nil), sf.Events[i:]...) }</span> } - <span class="cov5" title="88">sf.UpdatedAt = now + <span class="cov5" title="264">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="88">enc := json.NewEncoder(tmp) + <span class="cov5" title="264">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()) + _ = tmp.Close() + _ = os.Remove(tmp.Name()) return err }</span> - <span class="cov5" title="88">if err := tmp.Sync(); err != nil </span><span class="cov0" title="0">{ - tmp.Close() - os.Remove(tmp.Name()) + <span class="cov5" title="264">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="88">if err := tmp.Close(); err != nil </span><span class="cov0" title="0">{ - os.Remove(tmp.Name()) + <span class="cov5" title="264">if err := tmp.Close(); err != nil </span><span class="cov0" title="0">{ + _ = os.Remove(tmp.Name()) return err }</span> - <span class="cov5" title="88">if err := os.Rename(tmp.Name(), path); err != nil </span><span class="cov0" title="0">{ - os.Remove(tmp.Name()) + <span class="cov5" title="264">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="88">return nil</span> + <span class="cov5" title="264">return nil</span> } -func acquireFileLock(ctx context.Context, f *os.File) (func() error, error) <span class="cov5" title="88">{ +func acquireFileLock(ctx context.Context, f *os.File) (func() error, error) <span class="cov5" title="264">{ fd := f.Fd() - for </span><span class="cov6" title="208">{ + for </span><span class="cov6" title="653">{ err := tryLockFile(fd) - if err == nil </span><span class="cov5" title="88">{ - return func() error </span><span class="cov5" title="88">{ return unlockFile(fd) }</span>, nil + if err == nil </span><span class="cov5" title="264">{ + return func() error </span><span class="cov5" title="264">{ return unlockFile(fd) }</span>, nil } - <span class="cov6" title="120">if errors.Is(err, errLockWouldBlock) </span><span class="cov6" title="120">{ + <span class="cov6" title="389">if errors.Is(err, errLockWouldBlock) </span><span class="cov6" title="389">{ select </span>{ case <-ctx.Done():<span class="cov0" title="0"> return nil, ctx.Err()</span> - case <-time.After(5 * time.Millisecond):<span class="cov6" title="120"></span> + case <-time.After(5 * time.Millisecond):<span class="cov6" title="389"></span> } - <span class="cov6" title="120">continue</span> + <span class="cov6" title="389">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="70">{ +func TakeSnapshot() (Snapshot, error) <span class="cov5" title="210">{ dir, err := CacheDir() if err != nil </span><span class="cov0" title="0">{ return Snapshot{}, err }</span> - <span class="cov5" title="70">path := filepath.Join(dir, fileName) + <span class="cov5" title="210">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">{ @@ -9203,30 +9762,30 @@ func TakeSnapshot() (Snapshot, error) <span class="cov5" title="70">{ }</span> <span class="cov0" title="0">return Snapshot{}, err</span> } - <span class="cov5" title="70">var sf File + <span class="cov5" title="210">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="70">win := time.Duration(sf.WindowSeconds) * time.Second + <span class="cov5" title="210">win := time.Duration(sf.WindowSeconds) * time.Second if win <= 0 </span><span class="cov0" title="0">{ win = Window() - }</span> else<span class="cov5" title="70"> { + }</span> else<span class="cov5" title="210"> { SetWindow(win) // align process with file window if changed elsewhere }</span> - <span class="cov5" title="70">cutoff := time.Now().Add(-win) + <span class="cov5" title="210">cutoff := time.Now().Add(-win) snap := Snapshot{Providers: make(map[string]ProviderEntry), Window: win} - for _, ev := range sf.Events </span><span class="cov10" title="5523">{ + for _, ev := range sf.Events </span><span class="cov10" title="27576">{ if ev.TS.Before(cutoff) </span><span class="cov0" title="0">{ continue</span> } - <span class="cov10" title="5523">snap.Global.Reqs++ + <span class="cov10" title="27576">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="cov7" title="434">{ + if pe.Models == nil </span><span class="cov7" title="1410">{ pe.Models = make(map[string]Counters) }</span> - <span class="cov10" title="5523">pe.Totals.Reqs++ + <span class="cov10" title="27576">pe.Totals.Reqs++ pe.Totals.Sent += ev.Sent pe.Totals.Recv += ev.Recv mc := pe.Models[ev.Model] @@ -9236,153 +9795,153 @@ func TakeSnapshot() (Snapshot, error) <span class="cov5" title="70">{ pe.Models[ev.Model] = mc snap.Providers[ev.Provider] = pe</span> } - <span class="cov5" title="70">mins := win.Minutes() + <span class="cov5" title="210">mins := win.Minutes() if mins <= 0 </span><span class="cov0" title="0">{ mins = 0.001 }</span> - <span class="cov5" title="70">snap.RPM = float64(snap.Global.Reqs) / mins + <span class="cov5" title="210">snap.RPM = float64(snap.Global.Reqs) / mins return snap, nil</span> } // CacheDir resolves the cache directory for stats. -func CacheDir() (string, error) <span class="cov6" title="159">{ - if x := os.Getenv("XDG_CACHE_HOME"); stringsTrim(x) != "" </span><span class="cov4" title="27">{ +func CacheDir() (string, error) <span class="cov6" title="477">{ + if x := os.Getenv("XDG_CACHE_HOME"); stringsTrim(x) != "" </span><span class="cov4" title="81">{ return filepath.Join(x, "hexai"), nil }</span> - <span class="cov6" title="132">home, err := os.UserHomeDir() + <span class="cov6" title="396">home, err := os.UserHomeDir() if err != nil </span><span class="cov0" title="0">{ return "", fmt.Errorf("cannot resolve home: %w", err) }</span> - <span class="cov6" title="132">return filepath.Join(home, ".cache", "hexai"), nil</span> + <span class="cov6" title="396">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="cov6" title="159">{ +func stringsTrim(s string) string <span class="cov6" title="477">{ 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="cov6" title="159">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="cov6" title="477">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="cov6" title="159">if i == 0 && j == len(s) </span><span class="cov6" title="159">{ + <span class="cov6" title="477">if i == 0 && j == len(s) </span><span class="cov6" title="477">{ return s }</span> <span class="cov0" title="0">return s[i:j]</span> } // DebugString returns a compact single-line view of a snapshot (useful for logs). -func (s Snapshot) DebugString() string <span class="cov1" title="1">{ +func (s Snapshot) DebugString() string <span class="cov1" title="3">{ return "Σ reqs=" + strconv.FormatInt(s.Global.Reqs, 10) + " rpm=" + fmt.Sprintf("%.2f", s.RPM) }</span> </pre> - <pre class="file" id="file38" style="display: none">package testutil + <pre class="file" id="file39" style="display: none">package testutil // MultilineDocBlock returns a realistic multi-line documentation block. -func MultilineDocBlock() string <span class="cov8" title="1">{ +func MultilineDocBlock() string <span class="cov10" title="3">{ return "// add adds two numbers\n// returns their sum" }</span> // MultilineChatReply returns a multi-line assistant reply for chat tests. -func MultilineChatReply() string <span class="cov8" title="1">{ +func MultilineChatReply() string <span class="cov10" title="3">{ return "Hello, world!\nThis is a multi-line reply." }</span> // MultilineFunctionSuggestion returns a more realistic multi-line function body suggestion. -func MultilineFunctionSuggestion() string <span class="cov8" title="1">{ +func MultilineFunctionSuggestion() string <span class="cov10" title="3">{ return "(ctx context.Context, input string) (*CustData, error) {\n // TODO: implement\n return &CustData{}, nil\n}" }</span> // MarkdownCodeFence returns a fenced markdown snippet used in post-processing tests. -func MarkdownCodeFence() string <span class="cov8" title="1">{ +func MarkdownCodeFence() string <span class="cov10" title="3">{ return "```go\nname := value\n```" }</span> // MalformedJSON returns a deliberately malformed JSON string. -func MalformedJSON() string <span class="cov8" title="1">{ +func MalformedJSON() string <span class="cov10" title="3">{ return "{\"choices\":[{\"delta\":{\"content\":\"oops\"}}]" }</span> </pre> - <pre class="file" id="file39" style="display: none">package textutil + <pre class="file" id="file40" 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="138">{ - if n < 1000 </span><span class="cov2" title="2">{ +func HumanBytes(n int64) string <span class="cov10" title="414">{ + if n < 1000 </span><span class="cov7" title="88">{ return fmt.Sprintf("%dB", n) }</span> - <span class="cov9" title="136">const unit = 1000.0 + <span class="cov9" title="326">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="136">{ + for v >= unit && i < len(suffix)-1 </span><span class="cov9" title="326">{ v /= unit i++ }</span> - <span class="cov9" title="136">s := fmt.Sprintf("%.1f%s", v, suffix[i]) + <span class="cov9" title="326">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="136">return s</span> + <span class="cov9" title="326">return s</span> } </pre> - <pre class="file" id="file40" style="display: none">package textutil + <pre class="file" id="file41" 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="66">{ - if t == "" || len(vars) == 0 </span><span class="cov4" title="7">{ +func RenderTemplate(t string, vars map[string]string) string <span class="cov8" title="198">{ + if t == "" || len(vars) == 0 </span><span class="cov5" title="21">{ return t }</span> - <span class="cov8" title="59">out := t - for k, v := range vars </span><span class="cov10" title="157">{ + <span class="cov8" title="177">out := t + for k, v := range vars </span><span class="cov10" title="471">{ out = strings.ReplaceAll(out, "{{"+k+"}}", v) }</span> - <span class="cov8" title="59">return out</span> + <span class="cov8" title="177">return out</span> } // StripCodeFences removes surrounding Markdown triple-backtick fences. -func StripCodeFences(s string) string <span class="cov8" title="72">{ +func StripCodeFences(s string) string <span class="cov8" title="216">{ t := strings.TrimSpace(s) - if t == "" </span><span class="cov1" title="1">{ + if t == "" </span><span class="cov2" title="3">{ return t }</span> - <span class="cov8" title="71">lines := strings.Split(t, "\n") + <span class="cov8" title="213">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="71">end := len(lines) - 1 + <span class="cov8" title="213">end := len(lines) - 1 for end >= 0 && strings.TrimSpace(lines[end]) == "" </span><span class="cov0" title="0">{ end-- }</span> - <span class="cov8" title="71">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ + <span class="cov8" title="213">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ return t }</span> - <span class="cov8" title="71">first := strings.TrimSpace(lines[start]) + <span class="cov8" title="213">first := strings.TrimSpace(lines[start]) last := strings.TrimSpace(lines[end]) - if strings.HasPrefix(first, "```") && last == "```" && end > start </span><span class="cov6" title="20">{ + if strings.HasPrefix(first, "```") && last == "```" && end > start </span><span class="cov6" title="60">{ inner := strings.Join(lines[start+1:end], "\n") return inner }</span> - <span class="cov7" title="51">return t</span> + <span class="cov8" title="153">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="cov5" title="14">{ +func InstructionFromSelection(sel string) (string, string) <span class="cov6" title="42">{ lines := strings.Split(sel, "\n") - 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">{ + for idx, line := range lines </span><span class="cov6" title="42">{ + if instr, cleaned, ok := FindFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" </span><span class="cov6" title="42">{ lines[idx] = cleaned return instr, strings.Join(lines, "\n") }</span> @@ -9391,79 +9950,79 @@ func InstructionFromSelection(sel string) (string, string) <span class="cov5" ti } // FindFirstInstructionInLine returns (instruction, cleaned, ok) for a single line. -func FindFirstInstructionInLine(line string) (instr, cleaned string, ok bool) <span class="cov5" title="15">{ +func FindFirstInstructionInLine(line string) (instr, cleaned string, ok bool) <span class="cov6" title="45">{ type cand struct { start, end int text string } cands := []cand{} - if t, l, r, ok := FindStrictInlineTag(line); ok </span><span class="cov3" title="5">{ + if t, l, r, ok := FindStrictInlineTag(line); ok </span><span class="cov4" title="15">{ cands = append(cands, cand{start: l, end: r, text: t}) }</span> - <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">{ + <span class="cov6" title="45">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov3" title="6">{ + if j := strings.Index(line[i+2:], "*/"); j >= 0 </span><span class="cov3" title="6">{ start := i end := i + 2 + j + 2 text := strings.TrimSpace(line[i+2 : i+2+j]) cands = append(cands, cand{start: start, end: end, text: text}) }</span> } - <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">{ + <span class="cov6" title="45">if i := strings.Index(line, "<!--"); i >= 0 </span><span class="cov3" title="6">{ + if j := strings.Index(line[i+4:], "-->"); j >= 0 </span><span class="cov3" title="6">{ start := i end := i + 4 + j + 3 text := strings.TrimSpace(line[i+4 : i+4+j]) cands = append(cands, cand{start: start, end: end, text: text}) }</span> } - <span class="cov5" title="15">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov2" title="3">{ + <span class="cov6" title="45">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov4" title="9">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }</span> - <span class="cov5" title="15">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov2" title="2">{ + <span class="cov6" title="45">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov3" title="6">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) }</span> - <span class="cov5" title="15">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov3" title="4">{ + <span class="cov6" title="45">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov4" title="12">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }</span> - <span class="cov5" title="15">if len(cands) == 0 </span><span class="cov0" title="0">{ + <span class="cov6" title="45">if len(cands) == 0 </span><span class="cov0" title="0">{ return "", line, false }</span> - <span class="cov5" title="15">best := cands[0] - for _, c := range cands[1:] </span><span class="cov2" title="3">{ + <span class="cov6" title="45">best := cands[0] + for _, c := range cands[1:] </span><span class="cov4" title="9">{ if c.start >= 0 && (best.start < 0 || c.start < best.start) </span><span class="cov0" title="0">{ best = c }</span> } - <span class="cov5" title="15">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + <span class="cov6" title="45">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") return best.text, cleaned, true</span> } // FindStrictInlineTag finds ;text; with no spaces after/before semicolons. -func FindStrictInlineTag(line string) (text string, left, right int, ok bool) <span class="cov6" title="17">{ - for i := 0; i < len(line); i++ </span><span class="cov9" title="113">{ - if line[i] != ';' </span><span class="cov9" title="105">{ +func FindStrictInlineTag(line string) (text string, left, right int, ok bool) <span class="cov6" title="51">{ + for i := 0; i < len(line); i++ </span><span class="cov9" title="339">{ + if line[i] != ';' </span><span class="cov9" title="315">{ continue</span> } - <span class="cov4" title="8">if i+1 < len(line) && line[i+1] == ' ' </span><span class="cov1" title="1">{ + <span class="cov5" title="24">if i+1 < len(line) && line[i+1] == ' ' </span><span class="cov2" title="3">{ continue</span> } - <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">{ + <span class="cov5" title="21">for j := i + 1; j < len(line); j++ </span><span class="cov8" title="123">{ + if line[j] == ';' </span><span class="cov5" title="18">{ if j-1 >= 0 && line[j-1] == ' ' </span><span class="cov0" title="0">{ continue</span> } - <span class="cov4" title="6">inner := strings.TrimSpace(line[i+1 : j]) - if inner != "" </span><span class="cov4" title="6">{ + <span class="cov5" title="18">inner := strings.TrimSpace(line[i+1 : j]) + if inner != "" </span><span class="cov5" title="18">{ return inner, i, j + 1, true }</span> } } } - <span class="cov5" title="11">return "", -1, -1, false</span> + <span class="cov6" title="33">return "", -1, -1, false</span> } </pre> - <pre class="file" id="file41" style="display: none">package tmux + <pre class="file" id="file42" style="display: none">package tmux import ( "fmt" @@ -9487,41 +10046,41 @@ const ( ) // Enabled reports whether tmux status updates are enabled via env (default: on). -func Enabled() bool <span class="cov8" title="77">{ +func Enabled() bool <span class="cov8" title="231">{ v := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS")) - if v == "" </span><span class="cov7" title="74">{ + if v == "" </span><span class="cov8" title="222">{ return true }</span> - <span class="cov2" title="3">v = strings.ToLower(v) + <span class="cov3" title="9">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="cov8" title="77">{ - if !Enabled() || !HasBinary() || !InSession() </span><span class="cov2" title="3">{ +func SetUserOption(key, value string) error <span class="cov8" title="231">{ + if !Enabled() || !HasBinary() || !InSession() </span><span class="cov3" title="9">{ return nil }</span> - <span class="cov7" title="74">k := strings.TrimPrefix(strings.TrimSpace(key), "@") + <span class="cov8" title="222">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="74">return exec.Command("tmux", "set-option", "-g", "@"+k, value).Run()</span> + <span class="cov8" title="222">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="cov8" title="77">{ return SetUserOption("hexai_status", applyTheme(value)) }</span> +func SetStatus(value string) error <span class="cov8" title="231">{ 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" -func FormatLLMStatsStatus(model string, reqs int64, rpm float64, inBytes, outBytes int64) string <span class="cov1" title="1">{ +func FormatLLMStatsStatus(model string, reqs int64, rpm float64, inBytes, outBytes int64) string <span class="cov2" title="3">{ return fmt.Sprintf("LLM:%s %dr %.1frpm in%s out%s", model, reqs, rpm, textutil.HumanBytes(inBytes), textutil.HumanBytes(outBytes)) }</span> // FormatLLMStatsStatusColored is like FormatLLMStatsStatus but includes provider and // tmux color segments for readability. Uses up/down arrows for bytes. // Example (with colors): "LLM:openai:gpt-4.1 ↑12k ↓34k 0.8rpm 5r" -func FormatLLMStatsStatusColored(provider, model string, reqs int64, rpm float64, inBytes, outBytes int64) string <span class="cov1" title="1">{ +func FormatLLMStatsStatusColored(provider, model string, reqs int64, rpm float64, inBytes, outBytes int64) string <span class="cov2" title="3">{ in := textutil.HumanBytes(inBytes) out := textutil.HumanBytes(outBytes) // Keep it compact; colorize prefix and arrows; use fg resets so a themed bg can persist. @@ -9536,49 +10095,49 @@ 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="67">{ +func FormatGlobalStatusColored(globalReqs int64, globalRPM float64, globalIn, globalOut int64, scopeProvider, scopeModel string, scopeRPM float64, scopeReqs int64, window time.Duration) string <span class="cov8" title="201">{ gin := textutil.HumanBytes(globalIn) gout := textutil.HumanBytes(globalOut) head := fmt.Sprintf("%sΣ@%s %s↑%s%s %s↓%s%s %.1frpm", baseFGToken, humanWindow(window), arrowUpToken, baseFGToken, gin, arrowDownToken, baseFGToken, gout, globalRPM) // Narrow modes: only show Σ head - if narrowEnabled() || stringsTrim(scopeProvider) == "" || stringsTrim(scopeModel) == "" </span><span class="cov1" title="1">{ + if narrowEnabled() || stringsTrim(scopeProvider) == "" || stringsTrim(scopeModel) == "" </span><span class="cov2" title="3">{ return head }</span> - <span class="cov7" title="66">tail := fmt.Sprintf(" | %s:%s %.1frpm %dr", scopeProvider, scopeModel, scopeRPM, scopeReqs) + <span class="cov8" title="198">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 ml := maxStatusLen(); ml > 0 </span><span class="cov2" title="3">{ if len(head) <= ml && len(head)+len(tail) > ml </span><span class="cov0" title="0">{ return head }</span> - <span class="cov1" title="1">if len(head) > ml </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if len(head) > ml </span><span class="cov2" title="3">{ return truncateStatus(head, ml) }</span> } - <span class="cov7" title="65">return head + tail</span> + <span class="cov8" title="195">return head + tail</span> } -func humanWindow(d time.Duration) string <span class="cov7" title="67">{ +func humanWindow(d time.Duration) string <span class="cov8" title="201">{ if d <= 0 </span><span class="cov0" title="0">{ return "?" }</span> - <span class="cov7" title="67">mins := int(d.Minutes()) - if mins%60 == 0 </span><span class="cov7" title="65">{ + <span class="cov8" title="201">mins := int(d.Minutes()) + if mins%60 == 0 </span><span class="cov8" title="195">{ return fmt.Sprintf("%dh", mins/60) }</span> - <span class="cov2" title="2">if mins >= 60 </span><span class="cov0" title="0">{ + <span class="cov3" title="6">if mins >= 60 </span><span class="cov0" title="0">{ return fmt.Sprintf("%dm", mins) }</span> - <span class="cov2" title="2">return fmt.Sprintf("%dm", mins)</span> + <span class="cov3" title="6">return fmt.Sprintf("%dm", mins)</span> } // narrowEnabled returns true when HEXAI_TMUX_STATUS_NARROW is truthy (1/true/yes/on). -func narrowEnabled() bool <span class="cov7" title="67">{ +func narrowEnabled() bool <span class="cov8" title="201">{ v := strings.ToLower(stringsTrim(os.Getenv("HEXAI_TMUX_STATUS_NARROW"))) - if v == "" </span><span class="cov7" title="66">{ + if v == "" </span><span class="cov8" title="198">{ return false }</span> - <span class="cov1" title="1">switch v </span>{ - case "1", "true", "yes", "on":<span class="cov1" title="1"> + <span class="cov2" title="3">switch v </span>{ + case "1", "true", "yes", "on":<span class="cov2" title="3"> return true</span> default:<span class="cov0" title="0"> return false</span> @@ -9586,41 +10145,41 @@ func narrowEnabled() bool <span class="cov7" title="67">{ } // maxStatusLen returns HEXAI_TMUX_STATUS_MAXLEN parsed as int; 0 disables. -func maxStatusLen() int <span class="cov7" title="66">{ +func maxStatusLen() int <span class="cov8" title="198">{ v := stringsTrim(os.Getenv("HEXAI_TMUX_STATUS_MAXLEN")) - if v == "" </span><span class="cov7" title="65">{ + if v == "" </span><span class="cov8" title="195">{ return 0 }</span> - <span class="cov1" title="1">n, err := strconv.Atoi(v) + <span class="cov2" title="3">n, err := strconv.Atoi(v) if err != nil || n <= 0 </span><span class="cov0" title="0">{ return 0 }</span> - <span class="cov1" title="1">return n</span> + <span class="cov2" title="3">return n</span> } -func truncateStatus(s string, n int) string <span class="cov1" title="1">{ +func truncateStatus(s string, n int) string <span class="cov2" title="3">{ if n <= 0 </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov1" title="1">if len(s) <= n </span><span class="cov0" title="0">{ + <span class="cov2" title="3">if len(s) <= n </span><span class="cov0" title="0">{ return s }</span> - <span class="cov1" title="1">if n <= 1 </span><span class="cov0" title="0">{ + <span class="cov2" title="3">if n <= 1 </span><span class="cov0" title="0">{ return s[:n] }</span> - <span class="cov1" title="1">return s[:n-1] + "…"</span> + <span class="cov2" title="3">return s[:n-1] + "…"</span> } -func stringsTrim(s string) string <span class="cov10" title="265">{ +func stringsTrim(s string) string <span class="cov10" title="795">{ 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="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">{ + <span class="cov10" title="795">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="265">if i == 0 && j == len(s) </span><span class="cov10" title="265">{ + <span class="cov10" title="795">if i == 0 && j == len(s) </span><span class="cov10" title="795">{ return s }</span> <span class="cov0" title="0">return s[i:j]</span> @@ -9628,13 +10187,13 @@ func stringsTrim(s string) string <span class="cov10" title="265">{ // FormatLLMStartStatus renders a short colored heartbeat at start/initialize time. // Example: "LLM:openai:gpt-4.1 ⏳" -func FormatLLMStartStatus(provider, model string) string <span class="cov5" title="12">{ +func FormatLLMStartStatus(provider, model string) string <span class="cov5" title="36">{ return fmt.Sprintf("%sLLM:%s:%s #[fg=colour11]⏳%s", baseFGToken, provider, model, baseFGToken) }</span> // 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="cov8" title="77">{ +func applyTheme(s string) string <span class="cov8" title="231">{ 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")) @@ -9650,23 +10209,23 @@ func applyTheme(s string) string <span class="cov8" title="77">{ baseFG = fg }</span> // bg used as provided (may be empty) - } else<span class="cov8" title="77"> { + } else<span class="cov8" title="231"> { switch theme </span>{ - case "white-on-purple", "purple", "magenta", "white-on-magenta":<span class="cov8" title="77"> + case "white-on-purple", "purple", "magenta", "white-on-magenta":<span class="cov8" title="231"> 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="cov8" title="77">if baseFG == "" </span><span class="cov0" title="0">{ // no theme selected + <span class="cov8" title="231">if baseFG == "" </span><span class="cov0" title="0">{ // no theme selected baseFG = "default" }</span> } // Theme-aware arrow styles - <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 + <span class="cov8" title="231">upStyle, downStyle := "#[fg=colour3]", "#[fg=colour2]" // defaults: yellow up, green down + if fg != "" || bg != "" </span><span class="cov8" title="231">{ // explicit override path: match arrows to base fg, bold for visibility upStyle = "#[bold,fg=" + baseFG + "]" downStyle = upStyle }</span> else<span class="cov0" title="0"> { @@ -9681,30 +10240,30 @@ func applyTheme(s string) string <span class="cov8" title="77">{ } // Replace base-foreground and arrow placeholders with selected styles - <span class="cov8" title="77">if strings.Contains(s, baseFGToken) </span><span class="cov8" title="77">{ + <span class="cov8" title="231">if strings.Contains(s, baseFGToken) </span><span class="cov8" title="231">{ s = strings.ReplaceAll(s, baseFGToken, "#[fg="+baseFG+"]") }</span> - <span class="cov8" title="77">if strings.Contains(s, arrowUpToken) </span><span class="cov7" title="65">{ + <span class="cov8" title="231">if strings.Contains(s, arrowUpToken) </span><span class="cov8" title="195">{ s = strings.ReplaceAll(s, arrowUpToken, upStyle) }</span> - <span class="cov8" title="77">if strings.Contains(s, arrowDownToken) </span><span class="cov7" title="65">{ + <span class="cov8" title="231">if strings.Contains(s, arrowDownToken) </span><span class="cov8" title="195">{ s = strings.ReplaceAll(s, arrowDownToken, downStyle) }</span> - <span class="cov8" title="77">if !wrap </span><span class="cov0" title="0">{ + <span class="cov8" title="231">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="cov8" title="77">prefix := "#[fg=" + baseFG - if bg != "" </span><span class="cov8" title="77">{ + <span class="cov8" title="231">prefix := "#[fg=" + baseFG + if bg != "" </span><span class="cov8" title="231">{ prefix += ",bg=" + bg }</span> - <span class="cov8" title="77">prefix += "]" + <span class="cov8" title="231">prefix += "]" return prefix + s + "#[fg=default,bg=default]"</span> } </pre> - <pre class="file" id="file42" style="display: none">package tmux + <pre class="file" id="file43" style="display: none">package tmux import ( "os" @@ -9714,7 +10273,7 @@ import ( ) // Available reports whether tmux is available and we appear to be in a tmux session. -func Available() bool <span class="cov2" title="2">{ return HasBinary() && InSession() }</span> +func Available() bool <span class="cov3" title="6">{ return HasBinary() && InSession() }</span> // HasBinary reports whether the tmux binary is on PATH. var ( @@ -9722,10 +10281,10 @@ var ( command = exec.Command ) -func HasBinary() bool <span class="cov10" title="78">{ _, err := lookPath("tmux"); return err == nil }</span> +func HasBinary() bool <span class="cov10" title="234">{ _, 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="77">{ return strings.TrimSpace(os.Getenv("TMUX")) != "" }</span> +func InSession() bool <span class="cov9" title="231">{ return strings.TrimSpace(os.Getenv("TMUX")) != "" }</span> // SplitOpts controls how a new pane is created for running a command. type SplitOpts struct { @@ -9736,24 +10295,24 @@ type SplitOpts struct { // SplitRun splits the current tmux window and runs argv in the new pane. // It returns once tmux has launched the child process. -func SplitRun(opts SplitOpts, argv []string) error <span class="cov1" title="1">{ +func SplitRun(opts SplitOpts, argv []string) error <span class="cov2" title="3">{ if len(argv) == 0 </span><span class="cov0" title="0">{ return nil }</span> - <span class="cov1" title="1">args := []string{"split-window"} - if opts.Vertical </span><span class="cov1" title="1">{ + <span class="cov2" title="3">args := []string{"split-window"} + if opts.Vertical </span><span class="cov2" title="3">{ args = append(args, "-v") }</span> else<span class="cov0" title="0"> { args = append(args, "-h") }</span> - <span class="cov1" title="1">if opts.Percent > 0 && opts.Percent <= 100 </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if opts.Percent > 0 && opts.Percent <= 100 </span><span class="cov2" title="3">{ args = append(args, "-p", strconv.Itoa(opts.Percent)) }</span> - <span class="cov1" title="1">if strings.TrimSpace(opts.Target) != "" </span><span class="cov1" title="1">{ + <span class="cov2" title="3">if strings.TrimSpace(opts.Target) != "" </span><span class="cov2" title="3">{ args = append(args, "-t", opts.Target) }</span> // tmux takes a single command string. Use a conservative shell join. - <span class="cov1" title="1">cmdStr := shellJoin(argv) + <span class="cov2" title="3">cmdStr := shellJoin(argv) args = append(args, cmdStr) c := command("tmux", args...) return c.Run()</span> @@ -9761,35 +10320,35 @@ func SplitRun(opts SplitOpts, argv []string) error <span class="cov1" title="1"> // shellJoin quotes argv elements for safe use in a single shell command string. // It avoids interpretation by wrapping in single quotes and escaping embedded single quotes. -func shellJoin(argv []string) string <span class="cov1" title="1">{ +func shellJoin(argv []string) string <span class="cov2" title="3">{ out := make([]string, 0, len(argv)) - for _, a := range argv </span><span class="cov3" title="4">{ + for _, a := range argv </span><span class="cov5" title="12">{ if a == "" </span><span class="cov0" title="0">{ out = append(out, "''") continue</span> } - <span class="cov3" title="4">if isSafeBare(a) </span><span class="cov2" title="2">{ + <span class="cov5" title="12">if isSafeBare(a) </span><span class="cov3" title="6">{ out = append(out, a) continue</span> } // single-quote wrapping with escaped single quotes // ' => '\'' (close, escaped quote, reopen) - <span class="cov2" title="2">esc := strings.ReplaceAll(a, "'", "'\\''") + <span class="cov3" title="6">esc := strings.ReplaceAll(a, "'", "'\\''") out = append(out, "'"+esc+"'")</span> } - <span class="cov1" title="1">return strings.Join(out, " ")</span> + <span class="cov2" title="3">return strings.Join(out, " ")</span> } // isSafeBare returns true if a contains only safe characters for bare words. -func isSafeBare(s string) bool <span class="cov3" title="4">{ - for i := 0; i < len(s); i++ </span><span class="cov7" title="27">{ +func isSafeBare(s string) bool <span class="cov5" title="12">{ + for i := 0; i < len(s); i++ </span><span class="cov8" title="81">{ b := s[i] - if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '-' || b == '_' || b == '.' || b == '/' || b == ':' </span><span class="cov7" title="25">{ + if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '-' || b == '_' || b == '.' || b == '/' || b == ':' </span><span class="cov8" title="75">{ continue</span> } - <span class="cov2" title="2">return false</span> + <span class="cov3" title="6">return false</span> } - <span class="cov2" title="2">return true</span> + <span class="cov3" title="6">return true</span> } </pre> |
