diff options
Diffstat (limited to 'docs/coverage.html')
| -rw-r--r-- | docs/coverage.html | 3593 |
1 files changed, 2663 insertions, 930 deletions
diff --git a/docs/coverage.html b/docs/coverage.html index 36775ce..4526ad1 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -55,13 +55,13 @@ <div id="nav"> <select id="files"> - <option value="file0">codeberg.org/snonux/hexai/cmd/hexai-lsp/main.go (75.0%)</option> + <option value="file0">codeberg.org/snonux/hexai/cmd/hexai-lsp/main.go (73.3%)</option> <option value="file1">codeberg.org/snonux/hexai/cmd/hexai-tmux-action/main.go (0.0%)</option> - <option value="file2">codeberg.org/snonux/hexai/cmd/hexai/main.go (71.4%)</option> + <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 (88.9%)</option> + <option value="file3">codeberg.org/snonux/hexai/internal/appconfig/config.go (82.5%)</option> <option value="file4">codeberg.org/snonux/hexai/internal/editor/editor.go (58.3%)</option> @@ -69,9 +69,9 @@ <option value="file6">codeberg.org/snonux/hexai/internal/hexaiaction/parse.go (92.6%)</option> - <option value="file7">codeberg.org/snonux/hexai/internal/hexaiaction/prompts.go (92.0%)</option> + <option value="file7">codeberg.org/snonux/hexai/internal/hexaiaction/prompts.go (92.3%)</option> - <option value="file8">codeberg.org/snonux/hexai/internal/hexaiaction/run.go (76.8%)</option> + <option value="file8">codeberg.org/snonux/hexai/internal/hexaiaction/run.go (71.1%)</option> <option value="file9">codeberg.org/snonux/hexai/internal/hexaiaction/tui.go (65.5%)</option> @@ -79,65 +79,67 @@ <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 (90.0%)</option> + <option value="file12">codeberg.org/snonux/hexai/internal/hexaicli/run.go (72.3%)</option> - <option value="file13">codeberg.org/snonux/hexai/internal/hexailsp/run.go (90.8%)</option> + <option value="file13">codeberg.org/snonux/hexai/internal/hexailsp/run.go (88.9%)</option> <option value="file14">codeberg.org/snonux/hexai/internal/llm/copilot.go (82.4%)</option> <option value="file15">codeberg.org/snonux/hexai/internal/llm/ollama.go (89.8%)</option> - <option value="file16">codeberg.org/snonux/hexai/internal/llm/openai.go (86.4%)</option> + <option value="file16">codeberg.org/snonux/hexai/internal/llm/openai.go (87.1%)</option> - <option value="file17">codeberg.org/snonux/hexai/internal/llm/provider.go (100.0%)</option> + <option value="file17">codeberg.org/snonux/hexai/internal/llm/openrouter.go (76.2%)</option> - <option value="file18">codeberg.org/snonux/hexai/internal/llm/util.go (100.0%)</option> + <option value="file18">codeberg.org/snonux/hexai/internal/llm/provider.go (86.0%)</option> - <option value="file19">codeberg.org/snonux/hexai/internal/llmutils/client.go (100.0%)</option> + <option value="file19">codeberg.org/snonux/hexai/internal/llm/util.go (100.0%)</option> - <option value="file20">codeberg.org/snonux/hexai/internal/logging/chatlogger.go (100.0%)</option> + <option value="file20">codeberg.org/snonux/hexai/internal/llmutils/client.go (100.0%)</option> - <option value="file21">codeberg.org/snonux/hexai/internal/logging/logging.go (90.9%)</option> + <option value="file21">codeberg.org/snonux/hexai/internal/logging/chatlogger.go (100.0%)</option> - <option value="file22">codeberg.org/snonux/hexai/internal/lsp/chat_commands.go (72.2%)</option> + <option value="file22">codeberg.org/snonux/hexai/internal/logging/logging.go (90.9%)</option> - <option value="file23">codeberg.org/snonux/hexai/internal/lsp/context.go (74.4%)</option> + <option value="file23">codeberg.org/snonux/hexai/internal/lsp/chat_commands.go (83.3%)</option> - <option value="file24">codeberg.org/snonux/hexai/internal/lsp/document.go (91.5%)</option> + <option value="file24">codeberg.org/snonux/hexai/internal/lsp/context.go (74.4%)</option> - <option value="file25">codeberg.org/snonux/hexai/internal/lsp/handlers.go (92.2%)</option> + <option value="file25">codeberg.org/snonux/hexai/internal/lsp/document.go (91.5%)</option> - <option value="file26">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (84.1%)</option> + <option value="file26">codeberg.org/snonux/hexai/internal/lsp/handlers.go (89.8%)</option> - <option value="file27">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (88.8%)</option> + <option value="file27">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (82.0%)</option> - <option value="file28">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (77.7%)</option> + <option value="file28">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (79.0%)</option> - <option value="file29">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (75.0%)</option> + <option value="file29">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (78.1%)</option> - <option value="file30">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (66.7%)</option> + <option value="file30">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (75.0%)</option> - <option value="file31">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (90.2%)</option> + <option value="file31">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (66.7%)</option> - <option value="file32">codeberg.org/snonux/hexai/internal/lsp/server.go (86.8%)</option> + <option value="file32">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (85.3%)</option> - <option value="file33">codeberg.org/snonux/hexai/internal/lsp/transport.go (73.0%)</option> + <option value="file33">codeberg.org/snonux/hexai/internal/lsp/server.go (82.1%)</option> - <option value="file34">codeberg.org/snonux/hexai/internal/runtimeconfig/store.go (87.1%)</option> + <option value="file34">codeberg.org/snonux/hexai/internal/lsp/transport.go (73.0%)</option> - <option value="file35">codeberg.org/snonux/hexai/internal/stats/lock_posix.go (83.3%)</option> + <option value="file35">codeberg.org/snonux/hexai/internal/runtimeconfig/store.go (88.1%)</option> - <option value="file36">codeberg.org/snonux/hexai/internal/stats/stats.go (75.8%)</option> + <option value="file36">codeberg.org/snonux/hexai/internal/stats/lock_posix.go (83.3%)</option> - <option value="file37">codeberg.org/snonux/hexai/internal/testutil/fixtures.go (100.0%)</option> + <option value="file37">codeberg.org/snonux/hexai/internal/stats/stats.go (75.8%)</option> - <option value="file38">codeberg.org/snonux/hexai/internal/textutil/human.go (92.3%)</option> + <option value="file38">codeberg.org/snonux/hexai/internal/testutil/fixtures.go (100.0%)</option> - <option value="file39">codeberg.org/snonux/hexai/internal/textutil/textutil.go (90.4%)</option> + <option value="file39">codeberg.org/snonux/hexai/internal/textutil/human.go (92.3%)</option> - <option value="file40">codeberg.org/snonux/hexai/internal/tmux/status.go (76.7%)</option> + <option value="file40">codeberg.org/snonux/hexai/internal/textutil/textutil.go (90.4%)</option> - <option value="file41">codeberg.org/snonux/hexai/internal/tmux/tmux.go (88.6%)</option> + <option value="file41">codeberg.org/snonux/hexai/internal/tmux/status.go (76.7%)</option> + + <option value="file42">codeberg.org/snonux/hexai/internal/tmux/tmux.go (88.6%)</option> </select> </div> @@ -165,15 +167,20 @@ package main import ( "flag" + "fmt" "log" "os" + "strings" "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/hexailsp" ) func main() <span class="cov8" title="1">{ 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">{ @@ -181,10 +188,19 @@ func main() <span class="cov8" title="1">{ return }</span> - <span class="cov0" title="0">if err := hexailsp.Run(*logPath, os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ + <span class="cov0" title="0">path := strings.TrimSpace(*configPath) + if err := hexailsp.RunWithConfig(*logPath, path, os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ log.Fatalf("server error: %v", err) }</span> } + +func defaultConfigPath() string <span class="cov8" title="1">{ + 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> +} </pre> <pre class="file" id="file1" style="display: none">package main @@ -194,7 +210,9 @@ import ( "flag" "fmt" "os" + "strings" + "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/hexaiaction" ) @@ -202,6 +220,8 @@ func main() <span class="cov0" title="0">{ infile := flag.String("infile", "", "Read input from this file instead of stdin") outfile := flag.String("outfile", "", "Write output to this file instead of stdout") uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically") + defaultPath := defaultConfigPath() + configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath)) tmuxTarget := flag.String("tmux-target", "", "tmux split target (advanced)") tmuxSplit := flag.String("tmux-split", "v", "tmux split orientation: v or h") tmuxPercent := flag.Int("tmux-percent", 33, "tmux split size percentage (1-100)") @@ -211,11 +231,23 @@ func main() <span class="cov0" title="0">{ Infile: *infile, Outfile: *outfile, UIChild: *uiChild, TmuxTarget: *tmuxTarget, TmuxSplit: *tmuxSplit, TmuxPercent: *tmuxPercent, } - if err := hexaiaction.RunCommand(context.Background(), opts, os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ + ctx := context.Background() + if path := strings.TrimSpace(*configPath); path != "" </span><span class="cov0" title="0">{ + ctx = hexaiaction.WithConfigPath(ctx, path) + }</span> + <span class="cov0" title="0">if err := hexaiaction.RunCommand(ctx, opts, os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ fmt.Fprintln(os.Stderr, err) os.Exit(1) }</span> } + +func defaultConfigPath() string <span class="cov0" title="0">{ + path, err := appconfig.ConfigPath() + if err != nil </span><span class="cov0" title="0">{ + return "$XDG_CONFIG_HOME/hexai/config.toml" + }</span> + <span class="cov0" title="0">return path</span> +} </pre> <pre class="file" id="file2" style="display: none">// Summary: Hexai CLI entrypoint; parses flags and delegates to internal/hexaicli. @@ -225,24 +257,115 @@ import ( "context" "flag" "fmt" + "io" + "log" "os" + "strconv" + "strings" "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/hexaicli" ) func main() <span class="cov8" title="1">{ - showVersion := flag.Bool("version", false, "print version and exit") - flag.Parse() + 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">{ + cliEntries = []appconfig.SurfaceConfig{{Provider: cfg.Provider}} + }</span> + <span class="cov8" title="1">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">{ + name := strconv.Itoa(i) + provider := strings.TrimSpace(entry.Provider) + if provider == "" </span><span class="cov8" title="1">{ + provider = cfg.Provider + }</span> + <span class="cov8" title="1">model := strings.TrimSpace(entry.Model) + if model == "" </span><span class="cov8" title="1">{ + model = pickDefaultModel(cfg, provider) + }</span> + <span class="cov8" title="1">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">{ fmt.Fprintln(os.Stdout, internal.Version) return }</span> - - <span class="cov0" title="0">if err := hexaicli.Run(context.Background(), flag.Args(), os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ + <span class="cov0" title="0">var selection []int + for i, sel := range selectedFlags </span><span class="cov0" title="0">{ + if sel </span><span class="cov0" title="0">{ + selection = append(selection, i) + }</span> + } + <span class="cov0" title="0">finalPath := strings.TrimSpace(*configFlag) + if finalPath == "" </span><span class="cov0" title="0">{ + finalPath = configPath + }</span> + <span class="cov0" title="0">ctx := context.Background() + if finalPath != "" </span><span class="cov0" title="0">{ + ctx = hexaicli.WithCLIConfigPath(ctx, finalPath) + }</span> + <span class="cov0" title="0">if len(selection) > 0 </span><span class="cov0" title="0">{ + ctx = hexaicli.WithCLISelection(ctx, selection) + }</span> + <span class="cov0" title="0">if err := hexaicli.Run(ctx, fs.Args(), os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ os.Exit(1) }</span> } + +func splitConfigPath(args []string) (string, []string) <span class="cov8" title="1">{ + var path string + rest := make([]string, 0, len(args)) + skip := false + for i := 0; i < len(args); i++ </span><span class="cov8" title="1">{ + if skip </span><span class="cov0" title="0">{ + skip = false + continue</span> + } + <span class="cov8" title="1">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">{ + path = args[i+1] + skip = true + }</span> + case strings.HasPrefix(arg, "--config="):<span class="cov0" title="0"> + 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"> + rest = append(rest, arg)</span> + } + } + <span class="cov8" title="1">return strings.TrimSpace(path), rest</span> +} + +func pickDefaultModel(cfg appconfig.App, provider string) string <span class="cov8" title="1">{ + 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"> + return strings.TrimSpace(cfg.OpenAIModel)</span> + } +} + +func defaultConfigPath() string <span class="cov8" title="1">{ + 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> +} </pre> <pre class="file" id="file3" style="display: none">// Summary: Application configuration model and loader; reads ~/.config/hexai/config.toml and merges defaults. @@ -260,6 +383,13 @@ import ( "github.com/pelletier/go-toml/v2" ) +// SurfaceConfig describes a provider/model pairing (with optional temperature). +type SurfaceConfig struct { + Provider string + Model string + Temperature *float64 +} + // App holds user-configurable settings read from ~/.config/hexai/config.toml. type App struct { MaxTokens int `json:"max_tokens" toml:"max_tokens"` @@ -284,7 +414,7 @@ type App struct { TriggerCharacters []string `json:"trigger_characters" toml:"trigger_characters"` Provider string `json:"provider" toml:"provider"` - // Inline prompt trigger characters (default: >text> and >>text>) + // Inline prompt trigger characters (default: >!text> and >>!text>) InlineOpen string `json:"inline_open" toml:"inline_open"` InlineClose string `json:"inline_close" toml:"inline_close"` // In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;]) @@ -296,8 +426,12 @@ type App struct { OpenAIModel string `json:"openai_model" toml:"openai_model"` // Default temperature for OpenAI requests (nil means use provider default) OpenAITemperature *float64 `json:"openai_temperature" toml:"openai_temperature"` - OllamaBaseURL string `json:"ollama_base_url" toml:"ollama_base_url"` - OllamaModel string `json:"ollama_model" toml:"ollama_model"` + OpenRouterBaseURL string `json:"openrouter_base_url" toml:"openrouter_base_url"` + OpenRouterModel string `json:"openrouter_model" toml:"openrouter_model"` + // Default temperature for OpenRouter requests (nil means use provider default) + OpenRouterTemperature *float64 `json:"openrouter_temperature" toml:"openrouter_temperature"` + OllamaBaseURL string `json:"ollama_base_url" toml:"ollama_base_url"` + OllamaModel string `json:"ollama_model" toml:"ollama_model"` // Default temperature for Ollama requests (nil means use provider default) OllamaTemperature *float64 `json:"ollama_temperature" toml:"ollama_temperature"` CopilotBaseURL string `json:"copilot_base_url" toml:"copilot_base_url"` @@ -305,6 +439,12 @@ type App struct { // Default temperature for Copilot requests (nil means use provider default) CopilotTemperature *float64 `json:"copilot_temperature" toml:"copilot_temperature"` + // Per-surface provider/model configurations (ordered; first entry is primary) + CompletionConfigs []SurfaceConfig `json:"-" toml:"-"` + CodeActionConfigs []SurfaceConfig `json:"-" toml:"-"` + ChatConfigs []SurfaceConfig `json:"-" toml:"-"` + CLIConfigs []SurfaceConfig `json:"-" toml:"-"` + // Prompt templates (configured only via file; no env overrides) // Completion/chat/code action/CLI prompt strings. See config.toml.example for placeholders. // Completion @@ -353,7 +493,7 @@ type CustomAction struct { } // Constructor: defaults for App (kept first among functions) -func newDefaultConfig() App <span class="cov6" title="49">{ +func newDefaultConfig() App <span class="cov5" title="51">{ // Coding-friendly default temperature across providers // Users can override per provider in config.toml (including 0.0). t := 0.2 @@ -371,7 +511,7 @@ func newDefaultConfig() App <span class="cov6" title="49">{ CompletionDebounceMs: 800, CompletionThrottleMs: 0, // Inline/chat trigger defaults - InlineOpen: ">", + InlineOpen: ">!", InlineClose: ">", ChatSuffix: ">", ChatPrefixes: []string{"?", "!", ":", ";"}, @@ -409,40 +549,45 @@ func newDefaultConfig() App <span class="cov6" title="49">{ // 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="cov6" title="44">{ return LoadWithOptions(logger, LoadOptions{}) }</span> +func Load(logger *log.Logger) App <span class="cov5" title="35">{ return LoadWithOptions(logger, LoadOptions{}) }</span> // LoadOptions tune how configuration is loaded at runtime. type LoadOptions struct { // IgnoreEnv skips applying environment overrides when true. - IgnoreEnv bool + IgnoreEnv bool + ConfigPath string } // LoadWithOptions reads configuration and applies the requested loading options. -func LoadWithOptions(logger *log.Logger, opts LoadOptions) App <span class="cov6" title="48">{ +func LoadWithOptions(logger *log.Logger, opts LoadOptions) App <span class="cov5" title="50">{ cfg := newDefaultConfig() if logger == nil </span><span class="cov4" title="13">{ return cfg // Return defaults if no logger is provided (e.g. in tests) }</span> - <span class="cov5" title="35">configPath, err := getConfigPath() - if err != nil </span><span class="cov0" title="0">{ - logger.Printf("%v", err) - // Even if config path cannot be resolved, keep defaults and optionally apply env overrides below. - }</span> else<span class="cov5" title="35"> { - if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil </span><span class="cov5" title="30">{ + <span class="cov5" title="37">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"> { + 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">{ cfg.mergeWith(fileCfg) }</span> - // When the config file is missing or invalid, we keep defaults and still - // apply any environment overrides below (unless disabled). } - <span class="cov5" title="35">if !opts.IgnoreEnv </span><span class="cov5" title="31">{ + <span class="cov5" title="37">if !opts.IgnoreEnv </span><span class="cov5" title="33">{ // Environment overrides (take precedence over file) - if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov3" title="8">{ + if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov3" title="9">{ cfg.mergeWith(envCfg) }</span> } - <span class="cov5" title="35">return cfg</span> + <span class="cov5" title="37">return cfg</span> } // Private helpers @@ -457,6 +602,7 @@ type fileConfig struct { Chat sectionChat `toml:"chat"` Provider sectionProvider `toml:"provider"` OpenAI sectionOpenAI `toml:"openai"` + OpenRouter sectionOpenRouter `toml:"openrouter"` Copilot sectionCopilot `toml:"copilot"` Ollama sectionOllama `toml:"ollama"` Prompts sectionPrompts `toml:"prompts"` @@ -511,16 +657,16 @@ type sectionOpenAI struct { Presets map[string]string `toml:"presets"` } -func (s sectionOpenAI) isZero() bool <span class="cov5" title="30">{ +func (s sectionOpenAI) isZero() bool <span class="cov4" title="23">{ return strings.TrimSpace(s.Model) == "" && strings.TrimSpace(s.BaseURL) == "" && s.Temperature == nil && len(s.Presets) == 0 }</span> -func (s sectionOpenAI) resolvedModel() string <span class="cov4" title="14">{ +func (s sectionOpenAI) resolvedModel() string <span class="cov3" title="6">{ model := strings.TrimSpace(s.Model) if model == "" </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov4" title="14">if len(s.Presets) == 0 </span><span class="cov4" title="13">{ + <span class="cov3" title="6">if len(s.Presets) == 0 </span><span class="cov3" title="5">{ return model }</span> <span class="cov1" title="1">if mapped := strings.TrimSpace(s.Presets[model]); mapped != "" </span><span class="cov1" title="1">{ @@ -537,6 +683,12 @@ func (s sectionOpenAI) resolvedModel() string <span class="cov4" title="14">{ <span class="cov0" title="0">return model</span> } +type sectionOpenRouter struct { + Model string `toml:"model"` + BaseURL string `toml:"base_url"` + Temperature *float64 `toml:"temperature"` +} + type sectionCopilot struct { Model string `toml:"model"` BaseURL string `toml:"base_url"` @@ -609,11 +761,11 @@ type sectionTmux struct { CustomMenuHotkey string `toml:"custom_menu_hotkey"` } -func (fc *fileConfig) toApp() App <span class="cov5" title="30">{ +func (fc *fileConfig) toApp() App <span class="cov4" title="23">{ out := App{} // Merge section: general - if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil </span><span class="cov4" title="11">{ + if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil </span><span class="cov4" title="12">{ tmp := App{ MaxTokens: fc.General.MaxTokens, ContextMode: fc.General.ContextMode, @@ -625,13 +777,13 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="30">{ }</span> // logging - <span class="cov5" title="30">if (fc.Logging != sectionLogging{}) </span><span class="cov1" title="1">{ + <span class="cov4" title="23">if (fc.Logging != sectionLogging{}) </span><span class="cov1" title="1">{ tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit} out.mergeBasics(&tmp) }</span> // completion - <span class="cov5" title="30">if (fc.Completion != sectionCompletion{}) </span><span class="cov2" title="3">{ + <span class="cov4" title="23">if (fc.Completion != sectionCompletion{}) </span><span class="cov2" title="4">{ tmp := App{ CompletionDebounceMs: fc.Completion.CompletionDebounceMs, CompletionThrottleMs: fc.Completion.CompletionThrottleMs, @@ -641,31 +793,31 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="30">{ }</span> // triggers - <span class="cov5" title="30">if len(fc.Triggers.TriggerCharacters) > 0 </span><span class="cov2" title="3">{ + <span class="cov4" title="23">if len(fc.Triggers.TriggerCharacters) > 0 </span><span class="cov2" title="4">{ tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters} out.mergeBasics(&tmp) }</span> // inline - <span class="cov5" title="30">if (fc.Inline != sectionInline{}) </span><span class="cov4" title="11">{ + <span class="cov4" title="23">if (fc.Inline != sectionInline{}) </span><span class="cov1" title="1">{ tmp := App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose} out.mergeBasics(&tmp) }</span> // chat - <span class="cov5" title="30">if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 </span><span class="cov1" title="1">{ + <span class="cov4" title="23">if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 </span><span class="cov1" title="1">{ tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes} out.mergeBasics(&tmp) }</span> // provider - <span class="cov5" title="30">if strings.TrimSpace(fc.Provider.Name) != "" </span><span class="cov2" title="4">{ + <span class="cov4" title="23">if strings.TrimSpace(fc.Provider.Name) != "" </span><span class="cov3" title="6">{ tmp := App{Provider: fc.Provider.Name} out.mergeBasics(&tmp) }</span> // openai - <span class="cov5" title="30">if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil </span><span class="cov4" title="14">{ + <span class="cov4" title="23">if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil </span><span class="cov3" title="6">{ tmp := App{ OpenAIBaseURL: fc.OpenAI.BaseURL, OpenAIModel: fc.OpenAI.resolvedModel(), @@ -674,8 +826,18 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="30">{ out.mergeProviderFields(&tmp) }</span> + // openrouter + <span class="cov4" title="23">if (fc.OpenRouter != sectionOpenRouter{}) || fc.OpenRouter.Temperature != nil </span><span class="cov0" title="0">{ + tmp := App{ + OpenRouterBaseURL: fc.OpenRouter.BaseURL, + OpenRouterModel: fc.OpenRouter.Model, + OpenRouterTemperature: fc.OpenRouter.Temperature, + } + out.mergeProviderFields(&tmp) + }</span> + // copilot - <span class="cov5" title="30">if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil </span><span class="cov2" title="3">{ + <span class="cov4" title="23">if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil </span><span class="cov2" title="4">{ tmp := App{ CopilotBaseURL: fc.Copilot.BaseURL, CopilotModel: fc.Copilot.Model, @@ -685,7 +847,7 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="30">{ }</span> // ollama - <span class="cov5" title="30">if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil </span><span class="cov2" title="3">{ + <span class="cov4" title="23">if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil </span><span class="cov2" title="4">{ tmp := App{ OllamaBaseURL: fc.Ollama.BaseURL, OllamaModel: fc.Ollama.Model, @@ -696,7 +858,7 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="30">{ // prompts // completion - <span class="cov5" title="30">if (fc.Prompts.Completion != sectionPromptsCompletion{}) </span><span class="cov1" title="1">{ + <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">{ out.PromptCompletionSystemGeneral = fc.Prompts.Completion.SystemGeneral }</span> @@ -717,11 +879,11 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="30">{ }</span> } // chat - <span class="cov5" title="30">if strings.TrimSpace(fc.Prompts.Chat.System) != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="23">if strings.TrimSpace(fc.Prompts.Chat.System) != "" </span><span class="cov1" title="1">{ out.PromptChatSystem = fc.Prompts.Chat.System }</span> // code action - <span class="cov5" title="30">if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" || + <span class="cov4" title="23">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) != "" || @@ -731,39 +893,39 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="30">{ 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="cov4" title="17">{ + 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">{ out.PromptCodeActionRewriteSystem = fc.Prompts.CodeAction.RewriteSystem }</span> - <span class="cov4" title="17">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" </span><span class="cov1" title="1">{ out.PromptCodeActionDiagnosticsSystem = fc.Prompts.CodeAction.DiagnosticsSystem }</span> - <span class="cov4" title="17">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" </span><span class="cov1" title="1">{ out.PromptCodeActionDocumentSystem = fc.Prompts.CodeAction.DocumentSystem }</span> - <span class="cov4" title="17">if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" </span><span class="cov1" title="1">{ + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" </span><span class="cov1" title="1">{ out.PromptCodeActionRewriteUser = fc.Prompts.CodeAction.RewriteUser }</span> - <span class="cov4" title="17">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" </span><span class="cov1" title="1">{ + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" </span><span class="cov1" title="1">{ out.PromptCodeActionDiagnosticsUser = fc.Prompts.CodeAction.DiagnosticsUser }</span> - <span class="cov4" title="17">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" </span><span class="cov1" title="1">{ + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" </span><span class="cov1" title="1">{ out.PromptCodeActionDocumentUser = fc.Prompts.CodeAction.DocumentUser }</span> - <span class="cov4" title="17">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" </span><span class="cov1" title="1">{ out.PromptCodeActionGoTestSystem = fc.Prompts.CodeAction.GoTestSystem }</span> - <span class="cov4" title="17">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" </span><span class="cov1" title="1">{ + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" </span><span class="cov1" title="1">{ out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser }</span> - <span class="cov4" title="17">if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" </span><span class="cov0" title="0">{ + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" </span><span class="cov0" title="0">{ out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem }</span> - <span class="cov4" title="17">if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" </span><span class="cov0" title="0">{ + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" </span><span class="cov0" title="0">{ out.PromptCodeActionSimplifyUser = fc.Prompts.CodeAction.SimplifyUser }</span> - <span class="cov4" title="17">if len(fc.Prompts.CodeAction.Custom) > 0 </span><span class="cov4" title="16">{ - for _, ca := range fc.Prompts.CodeAction.Custom </span><span class="cov5" title="30">{ + <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">{ out.CustomActions = append(out.CustomActions, CustomAction{ ID: strings.TrimSpace(ca.ID), Title: strings.TrimSpace(ca.Title), @@ -778,7 +940,7 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="30">{ } } // cli - <span class="cov5" title="30">if (fc.Prompts.CLI != sectionPromptsCLI{}) </span><span class="cov1" title="1">{ + <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">{ out.PromptCLIDefaultSystem = fc.Prompts.CLI.DefaultSystem }</span> @@ -787,46 +949,46 @@ func (fc *fileConfig) toApp() App <span class="cov5" title="30">{ }</span> } // provider-native - <span class="cov5" title="30">if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="23">if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" </span><span class="cov1" title="1">{ out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion }</span> // tmux - <span class="cov5" title="30">if (fc.Tmux != sectionTmux{}) </span><span class="cov2" title="3">{ + <span class="cov4" title="23">if (fc.Tmux != sectionTmux{}) </span><span class="cov2" title="3">{ out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) }</span> // stats - <span class="cov5" title="30">if fc.Stats.WindowMinutes > 0 </span><span class="cov0" title="0">{ + <span class="cov4" title="23">if fc.Stats.WindowMinutes > 0 </span><span class="cov0" title="0">{ out.StatsWindowMinutes = fc.Stats.WindowMinutes }</span> - <span class="cov5" title="30">return out</span> + <span class="cov4" title="23">return out</span> } -func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov5" title="36">{ +func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov5" title="40">{ b, err := os.ReadFile(path) - if err != nil </span><span class="cov2" title="4">{ + if err != nil </span><span class="cov4" title="16">{ 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="cov2" title="4">return nil, err</span> + <span class="cov4" title="16">return nil, err</span> } - <span class="cov5" title="32">var tables fileConfig + <span class="cov4" title="24">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="2">{ - if logger != nil </span><span class="cov1" title="2">{ + if errTables != nil </span><span class="cov1" title="1">{ + if logger != nil </span><span class="cov1" title="1">{ logger.Printf("invalid TOML config file %s: %v", path, errTables) }</span> - <span class="cov1" title="2">return nil, errTables</span> + <span class="cov1" title="1">return nil, errTables</span> } // Reject legacy flat keys at top-level (sectioned-only config is allowed) - <span class="cov5" title="30">legacy := map[string]struct{}{ + <span class="cov4" title="23">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": {}, @@ -835,8 +997,8 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co "ollama_model": {}, "ollama_base_url": {}, "ollama_temperature": {}, "copilot_model": {}, "copilot_base_url": {}, "copilot_temperature": {}, } - for k := range raw </span><span class="cov6" title="76">{ - if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable </span><span class="cov6" title="73">{ + 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">{ continue</span> } <span class="cov2" title="3">if _, isLegacy := legacy[k]; isLegacy </span><span class="cov0" title="0">{ @@ -844,18 +1006,18 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co }</span> } - <span class="cov5" title="30">if logger != nil </span><span class="cov5" title="30">{ + <span class="cov4" title="23">if logger != nil </span><span class="cov4" title="23">{ logger.Printf("loaded configuration from %s (TOML)", path) }</span> // Merge order: flat first, then tables (so tables win over zero flat values) // Build App from tables only - <span class="cov5" title="30">tab := tables.toApp() + <span class="cov4" title="23">tab := tables.toApp() // Ensure explicit values from raw map are respected (defensive for ints) - if t, ok := raw["completion"].(map[string]any); ok </span><span class="cov2" title="3">{ - if v, present := t["manual_invoke_min_prefix"]; present </span><span class="cov2" title="3">{ + 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">{ switch vv := v.(type) </span>{ - case int64:<span class="cov2" title="3"> + case int64:<span class="cov2" title="4"> tab.ManualInvokeMinPrefix = int(vv)</span> case int:<span class="cov0" title="0"> tab.ManualInvokeMinPrefix = vv</span> @@ -864,10 +1026,10 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co } } } - <span class="cov5" title="30">if t, ok := raw["logging"].(map[string]any); ok </span><span class="cov2" title="3">{ - if v, present := t["log_preview_limit"]; present </span><span class="cov2" title="3">{ + <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">{ switch vv := v.(type) </span>{ - case int64:<span class="cov2" title="3"> + case int64:<span class="cov2" title="4"> tab.LogPreviewLimit = int(vv)</span> case int:<span class="cov0" title="0"> tab.LogPreviewLimit = vv</span> @@ -876,181 +1038,357 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co } } } - <span class="cov5" title="30">return &tab, nil</span> + <span class="cov4" title="23">if m := parseSurfaceModels(raw, logger); m != nil </span><span class="cov2" title="4">{ + tab.mergeSurfaceModels(m) + }</span> + <span class="cov4" title="23">return &tab, nil</span> +} + +func parseSurfaceModels(raw map[string]any, logger *log.Logger) *App <span class="cov4" title="23">{ + modelsRaw, ok := raw["models"] + if !ok </span><span class="cov4" title="19">{ + return nil + }</span> + <span class="cov2" title="4">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">{ + entries, ok := parseSurfaceEntries(val, key, logger) + if !ok || len(entries) == 0 </span><span class="cov2" title="3">{ + return false + }</span> + <span class="cov4" title="13">*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">{ + 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="4">any = true</span> + } + <span class="cov2" title="4">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> +} + +func parseSurfaceEntries(raw any, path string, logger *log.Logger) ([]SurfaceConfig, bool) <span class="cov4" title="16">{ + switch v := raw.(type) </span>{ + case nil:<span class="cov2" title="3"> + return nil, false</span> + case []any:<span class="cov4" title="13"> + var out []SurfaceConfig + for i, entry := range v </span><span class="cov4" title="14">{ + 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="cov4" title="13">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 + }</span> + <span class="cov0" title="0">return nil, false</span> + } +} + +func cloneSurfaceConfigs(src []SurfaceConfig) []SurfaceConfig <span class="cov5" title="27">{ + if len(src) == 0 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov5" title="27">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">{ + if raw == nil </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov4" title="14">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"> + model := "" + provider := "" + if m, ok := v["model"]; ok </span><span class="cov4" title="14">{ + s, ok := m.(string) + if !ok </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("config: %s.model must be a string", path) + }</span> + <span class="cov0" title="0">return nil, false</span> + } + <span class="cov4" title="14">model = strings.TrimSpace(s)</span> + } + <span class="cov4" title="14">if pRaw, ok := v["provider"]; ok </span><span class="cov4" title="14">{ + ps, ok := pRaw.(string) + if !ok </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("config: %s.provider must be a string", path) + }</span> + <span class="cov0" title="0">return nil, false</span> + } + <span class="cov4" title="14">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">{ + 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="14">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> + 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) + }</span> + <span class="cov0" title="0">return nil, false</span> + } } -func (a *App) mergeWith(other *App) <span class="cov5" title="38">{ +func parseTemperatureValue(raw any, path string, logger *log.Logger) (*float64, bool) <span class="cov3" title="6">{ + switch v := raw.(type) </span>{ + case float64:<span class="cov3" title="6"> + return floatPtr(v), true</span> + case int64:<span class="cov0" title="0"> + return floatPtr(float64(v)), true</span> + case string:<span class="cov0" title="0"> + s := strings.TrimSpace(v) + if s == "" </span><span class="cov0" title="0">{ + return nil, true + }</span> + <span class="cov0" title="0">f, err := strconv.ParseFloat(s, 64) + if err != nil </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("config: %s.temperature invalid: %v", path, err) + }</span> + <span class="cov0" title="0">return nil, false</span> + } + <span class="cov0" title="0">return floatPtr(f), true</span> + default:<span class="cov0" title="0"> + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("config: %s.temperature must be numeric or string, got %T", path, raw) + }</span> + <span class="cov0" title="0">return nil, false</span> + } +} + +func floatPtr(v float64) *float64 <span class="cov3" title="6">{ + f := v + return &f +}</span> + +func (a *App) mergeWith(other *App) <span class="cov5" title="30">{ a.mergeBasics(other) a.mergeProviderFields(other) + a.mergeSurfaceModels(other) a.mergePrompts(other) }</span> // mergeBasics merges general (non-provider) fields. -func (a *App) mergeBasics(other *App) <span class="cov6" title="72">{ - if other.MaxTokens > 0 </span><span class="cov5" title="26">{ +func (a *App) mergeBasics(other *App) <span class="cov6" title="59">{ + if other.MaxTokens > 0 </span><span class="cov5" title="27">{ a.MaxTokens = other.MaxTokens }</span> - <span class="cov6" title="72">if s := strings.TrimSpace(other.ContextMode); s != "" </span><span class="cov3" title="7">{ + <span class="cov6" title="59">if s := strings.TrimSpace(other.ContextMode); s != "" </span><span class="cov3" title="8">{ a.ContextMode = s }</span> - <span class="cov6" title="72">if other.ContextWindowLines > 0 </span><span class="cov3" title="7">{ + <span class="cov6" title="59">if other.ContextWindowLines > 0 </span><span class="cov3" title="8">{ a.ContextWindowLines = other.ContextWindowLines }</span> - <span class="cov6" title="72">if other.MaxContextTokens > 0 </span><span class="cov3" title="7">{ + <span class="cov6" title="59">if other.MaxContextTokens > 0 </span><span class="cov3" title="8">{ a.MaxContextTokens = other.MaxContextTokens }</span> - <span class="cov6" title="72">if other.LogPreviewLimit >= 0 </span><span class="cov6" title="72">{ + <span class="cov6" title="59">if other.LogPreviewLimit >= 0 </span><span class="cov6" title="59">{ a.LogPreviewLimit = other.LogPreviewLimit }</span> - <span class="cov6" title="72">if other.CodingTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0 + <span class="cov6" title="59">if other.CodingTemperature != nil </span><span class="cov3" title="8">{ // allow explicit 0.0 a.CodingTemperature = other.CodingTemperature }</span> - <span class="cov6" title="72">if other.ManualInvokeMinPrefix >= 0 </span><span class="cov6" title="72">{ + <span class="cov6" title="59">if other.ManualInvokeMinPrefix >= 0 </span><span class="cov6" title="59">{ a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix }</span> - <span class="cov6" title="72">if other.CompletionDebounceMs > 0 </span><span class="cov3" title="7">{ + <span class="cov6" title="59">if other.CompletionDebounceMs > 0 </span><span class="cov3" title="8">{ a.CompletionDebounceMs = other.CompletionDebounceMs }</span> - <span class="cov6" title="72">if other.CompletionThrottleMs > 0 </span><span class="cov3" title="7">{ + <span class="cov6" title="59">if other.CompletionThrottleMs > 0 </span><span class="cov3" title="8">{ a.CompletionThrottleMs = other.CompletionThrottleMs }</span> - <span class="cov6" title="72">if len(other.TriggerCharacters) > 0 </span><span class="cov3" title="7">{ + <span class="cov6" title="59">if len(other.TriggerCharacters) > 0 </span><span class="cov3" title="8">{ a.TriggerCharacters = slices.Clone(other.TriggerCharacters) }</span> - <span class="cov6" title="72">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov5" title="22">{ + <span class="cov6" title="59">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov1" title="2">{ a.InlineOpen = s }</span> - <span class="cov6" title="72">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov5" title="22">{ + <span class="cov6" title="59">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov1" title="2">{ a.InlineClose = s }</span> - <span class="cov6" title="72">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov1" title="2">{ + <span class="cov6" title="59">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov1" title="2">{ a.ChatSuffix = s }</span> - <span class="cov6" title="72">if len(other.ChatPrefixes) > 0 </span><span class="cov1" title="2">{ + <span class="cov6" title="59">if len(other.ChatPrefixes) > 0 </span><span class="cov1" title="2">{ a.ChatPrefixes = slices.Clone(other.ChatPrefixes) }</span> - <span class="cov6" title="72">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov4" title="13">{ + <span class="cov6" title="59">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov4" title="16">{ 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">{ + a.CompletionConfigs = cloneSurfaceConfigs(other.CompletionConfigs) + }</span> + <span class="cov5" title="34">if len(other.CodeActionConfigs) > 0 </span><span class="cov3" title="7">{ + a.CodeActionConfigs = cloneSurfaceConfigs(other.CodeActionConfigs) + }</span> + <span class="cov5" title="34">if len(other.ChatConfigs) > 0 </span><span class="cov3" title="6">{ + a.ChatConfigs = cloneSurfaceConfigs(other.ChatConfigs) + }</span> + <span class="cov5" title="34">if len(other.CLIConfigs) > 0 </span><span class="cov3" title="7">{ + a.CLIConfigs = cloneSurfaceConfigs(other.CLIConfigs) + }</span> +} + // mergePrompts copies non-empty prompt templates from other. -func (a *App) mergePrompts(other *App) <span class="cov5" title="38">{ +func (a *App) mergePrompts(other *App) <span class="cov5" title="30">{ // Completion if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" </span><span class="cov1" title="1">{ a.PromptCompletionSystemGeneral = other.PromptCompletionSystemGeneral }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCompletionSystemParams) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCompletionSystemParams) != "" </span><span class="cov1" title="1">{ a.PromptCompletionSystemParams = other.PromptCompletionSystemParams }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCompletionSystemInline) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCompletionSystemInline) != "" </span><span class="cov1" title="1">{ a.PromptCompletionSystemInline = other.PromptCompletionSystemInline }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" </span><span class="cov1" title="1">{ a.PromptCompletionUserGeneral = other.PromptCompletionUserGeneral }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCompletionUserParams) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCompletionUserParams) != "" </span><span class="cov1" title="1">{ a.PromptCompletionUserParams = other.PromptCompletionUserParams }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" </span><span class="cov1" title="1">{ a.PromptCompletionExtraHeader = other.PromptCompletionExtraHeader }</span> // Provider-native - <span class="cov5" title="38">if strings.TrimSpace(other.PromptNativeCompletion) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptNativeCompletion) != "" </span><span class="cov1" title="1">{ a.PromptNativeCompletion = other.PromptNativeCompletion }</span> // Chat - <span class="cov5" title="38">if strings.TrimSpace(other.PromptChatSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptChatSystem) != "" </span><span class="cov1" title="1">{ a.PromptChatSystem = other.PromptChatSystem }</span> // Code actions - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionRewriteSystem = other.PromptCodeActionRewriteSystem }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionDiagnosticsSystem = other.PromptCodeActionDiagnosticsSystem }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionDocumentSystem = other.PromptCodeActionDocumentSystem }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionRewriteUser = other.PromptCodeActionRewriteUser }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionDiagnosticsUser = other.PromptCodeActionDiagnosticsUser }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionDocumentUser = other.PromptCodeActionDocumentUser }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" </span><span class="cov1" title="1">{ a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" </span><span class="cov0" title="0">{ a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" </span><span class="cov0" title="0">{ a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser }</span> // CLI - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" </span><span class="cov1" title="1">{ a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.PromptCLIExplainSystem) != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="30">if strings.TrimSpace(other.PromptCLIExplainSystem) != "" </span><span class="cov1" title="1">{ a.PromptCLIExplainSystem = other.PromptCLIExplainSystem }</span> // Custom actions - <span class="cov5" title="38">if len(other.CustomActions) > 0 </span><span class="cov4" title="16">{ + <span class="cov5" title="30">if len(other.CustomActions) > 0 </span><span class="cov3" title="6">{ a.CustomActions = append([]CustomAction{}, other.CustomActions...) }</span> - <span class="cov5" title="38">if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" </span><span class="cov2" title="3">{ + <span class="cov5" title="30">if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" </span><span class="cov2" title="3">{ a.TmuxCustomMenuHotkey = other.TmuxCustomMenuHotkey }</span> } // Validate checks custom actions and tmux settings for duplicates and consistency. -func (a App) Validate() error <span class="cov5" title="24">{ +func (a App) Validate() error <span class="cov4" title="24">{ // 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="cov4" title="17">{ + for _, ca := range a.CustomActions </span><span class="cov3" title="9">{ id := strings.ToLower(strings.TrimSpace(ca.ID)) if id == "" </span><span class="cov1" title="1">{ return fmt.Errorf("config: custom action missing required field id") }</span> - <span class="cov4" title="16">if _, ok := seenID[id]; ok </span><span class="cov1" title="1">{ + <span class="cov3" title="8">if _, ok := seenID[id]; ok </span><span class="cov1" title="1">{ return fmt.Errorf("config: duplicate custom action id: %s", ca.ID) }</span> - <span class="cov4" title="15">seenID[id] = struct{}{} + <span class="cov3" title="7">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="cov4" title="15">scope := strings.TrimSpace(ca.Scope) + <span class="cov3" title="7">scope := strings.TrimSpace(ca.Scope) if scope != "" && scope != "selection" && scope != "diagnostics" </span><span class="cov1" title="1">{ return fmt.Errorf("config: custom action %s has invalid scope: %s", ca.ID, ca.Scope) }</span> // Instruction vs user - <span class="cov4" title="14">hasInstr := strings.TrimSpace(ca.Instruction) != "" + <span class="cov3" title="6">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="cov4" title="14">if !hasInstr && !hasUser </span><span class="cov0" title="0">{ + <span class="cov3" title="6">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="cov4" title="14">if hk := strings.TrimSpace(ca.Hotkey); hk != "" </span><span class="cov4" title="13">{ + <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">{ return fmt.Errorf("config: custom action %s hotkey must be a single character", ca.ID) }</span> - <span class="cov4" title="12">lhk := strings.ToLower(hk) + <span class="cov2" title="4">lhk := strings.ToLower(hk) if _, ok := seenHK[lhk]; ok </span><span class="cov1" title="1">{ return fmt.Errorf("config: duplicate custom action hotkey: %s", hk) }</span> - <span class="cov4" title="11">seenHK[lhk] = struct{}{}</span> + <span class="cov2" title="3">seenHK[lhk] = struct{}{}</span> } } // Tmux custom menu hotkey validation @@ -1068,126 +1406,140 @@ func (a App) Validate() error <span class="cov5" title="24">{ } // mergeProviderFields merges per-provider configuration. -func (a *App) mergeProviderFields(other *App) <span class="cov6" title="58">{ - if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" </span><span class="cov5" title="27">{ +func (a *App) mergeProviderFields(other *App) <span class="cov5" title="44">{ + if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" </span><span class="cov3" title="8">{ a.OpenAIBaseURL = s }</span> - <span class="cov6" title="58">if s := strings.TrimSpace(other.OpenAIModel); s != "" </span><span class="cov5" title="33">{ + <span class="cov5" title="44">if s := strings.TrimSpace(other.OpenAIModel); s != "" </span><span class="cov4" title="16">{ a.OpenAIModel = s }</span> - <span class="cov6" title="58">if other.OpenAITemperature != nil </span><span class="cov5" title="27">{ // allow explicit 0.0 + <span class="cov5" title="44">if other.OpenAITemperature != nil </span><span class="cov3" title="8">{ // allow explicit 0.0 a.OpenAITemperature = other.OpenAITemperature }</span> - <span class="cov6" title="58">if s := strings.TrimSpace(other.OllamaBaseURL); s != "" </span><span class="cov3" title="7">{ + <span class="cov5" title="44">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">{ + a.OpenRouterModel = s + }</span> + <span class="cov5" title="44">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">{ a.OllamaBaseURL = s }</span> - <span class="cov6" title="58">if s := strings.TrimSpace(other.OllamaModel); s != "" </span><span class="cov3" title="7">{ + <span class="cov5" title="44">if s := strings.TrimSpace(other.OllamaModel); s != "" </span><span class="cov3" title="8">{ a.OllamaModel = s }</span> - <span class="cov6" title="58">if other.OllamaTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0 + <span class="cov5" title="44">if other.OllamaTemperature != nil </span><span class="cov3" title="8">{ // allow explicit 0.0 a.OllamaTemperature = other.OllamaTemperature }</span> - <span class="cov6" title="58">if s := strings.TrimSpace(other.CopilotBaseURL); s != "" </span><span class="cov3" title="7">{ + <span class="cov5" title="44">if s := strings.TrimSpace(other.CopilotBaseURL); s != "" </span><span class="cov3" title="8">{ a.CopilotBaseURL = s }</span> - <span class="cov6" title="58">if s := strings.TrimSpace(other.CopilotModel); s != "" </span><span class="cov3" title="7">{ + <span class="cov5" title="44">if s := strings.TrimSpace(other.CopilotModel); s != "" </span><span class="cov3" title="8">{ a.CopilotModel = s }</span> - <span class="cov6" title="58">if other.CopilotTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0 + <span class="cov5" title="44">if other.CopilotTemperature != nil </span><span class="cov3" title="8">{ // allow explicit 0.0 a.CopilotTemperature = other.CopilotTemperature }</span> } -func getConfigPath() (string, error) <span class="cov5" title="36">{ +func getConfigPath() (string, error) <span class="cov5" title="38">{ + 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">{ var configPath string - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov5" title="26">{ + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov5" title="27">{ configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") - }</span> else<span class="cov4" title="10"> { + }</span> else<span class="cov4" title="13"> { 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="10">configPath = filepath.Join(home, ".config", "hexai", "config.toml")</span> + <span class="cov4" title="13">configPath = filepath.Join(home, ".config", "hexai", "config.toml")</span> } - <span class="cov5" title="36">return configPath, nil</span> + <span class="cov5" title="40">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="31">{ +func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="33">{ var out App var any bool // helpers - getenv := func(k string) string </span><span class="cov10" title="806">{ return strings.TrimSpace(os.Getenv(k)) }</span> - <span class="cov5" title="31">parseInt := func(k string) (int, bool) </span><span class="cov8" title="217">{ + 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">{ v := getenv(k) - if v == "" </span><span class="cov8" title="207">{ + if v == "" </span><span class="cov7" title="221">{ return 0, false }</span> - <span class="cov4" title="10">n, err := strconv.Atoi(v) + <span class="cov3" title="10">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="cov4" title="10">return n, true</span> + <span class="cov3" title="10">return n, true</span> } - <span class="cov5" title="31">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov7" title="124">{ + <span class="cov5" title="33">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov8" title="297">{ v := getenv(k) - if v == "" </span><span class="cov7" title="120">{ + if v == "" </span><span class="cov8" title="287">{ return nil, false }</span> - <span class="cov2" title="4">f, err := strconv.ParseFloat(v, 64) + <span class="cov3" title="10">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="cov2" title="4">return &f, true</span> + <span class="cov3" title="10">return &f, true</span> } - <span class="cov5" title="31">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov2" title="4">{ + <span class="cov5" title="33">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov2" title="4">{ out.MaxTokens = n any = true }</span> - <span class="cov5" title="31">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{ out.ContextMode = s any = true }</span> - <span class="cov5" title="31">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov1" title="1">{ out.ContextWindowLines = n any = true }</span> - <span class="cov5" title="31">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov1" title="1">{ out.MaxContextTokens = n any = true }</span> - <span class="cov5" title="31">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov1" title="1">{ out.LogPreviewLimit = n any = true }</span> - <span class="cov5" title="31">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov1" title="1">{ out.ManualInvokeMinPrefix = n any = true }</span> - <span class="cov5" title="31">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov1" title="1">{ out.CompletionDebounceMs = n any = true }</span> - <span class="cov5" title="31">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov1" title="1">{ out.CompletionThrottleMs = n any = true }</span> - <span class="cov5" title="31">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.CodingTemperature = f any = true }</span> - <span class="cov5" title="31">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov1" title="1">{ parts := strings.Split(s, ",") out.TriggerCharacters = nil for _, p := range parts </span><span class="cov2" title="3">{ @@ -1197,19 +1549,19 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="31">{ } <span class="cov1" title="1">any = true</span> } - <span class="cov5" title="31">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="33">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ out.InlineOpen = s any = true }</span> - <span class="cov5" title="31">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="33">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ out.InlineClose = s any = true }</span> - <span class="cov5" title="31">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="33">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ out.ChatSuffix = s any = true }</span> - <span class="cov5" title="31">if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" </span><span class="cov0" title="0">{ + <span class="cov5" title="33">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">{ @@ -1219,88 +1571,132 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="31">{ } <span class="cov0" title="0">any = true</span> } - <span class="cov5" title="31">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov3" title="5">{ + <span class="cov5" title="33">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov3" title="5">{ out.Provider = s any = true }</span> - <span class="cov5" title="31">modelForce := strings.TrimSpace(getenv("HEXAI_MODEL_FORCE")) + <span class="cov5" title="33">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="93">{ + pickModel := func(providerName, specific string) (string, bool) </span><span class="cov7" title="132">{ specific = strings.TrimSpace(specific) nameLower := strings.ToLower(strings.TrimSpace(providerName)) - if modelForce != "" </span><span class="cov2" title="3">{ + if modelForce != "" </span><span class="cov2" title="4">{ if providerLower == nameLower </span><span class="cov1" title="1">{ forceUsed = true return modelForce, true }</span> - <span class="cov1" title="2">if providerLower == "" && !forceUsed </span><span class="cov0" title="0">{ + <span class="cov2" title="3">if providerLower == "" && !forceUsed </span><span class="cov0" title="0">{ forceUsed = true return modelForce, true }</span> } - <span class="cov7" title="92">if specific != "" </span><span class="cov2" title="4">{ + <span class="cov7" title="131">if specific != "" </span><span class="cov2" title="4">{ return specific, true }</span> - <span class="cov7" title="88">if modelGeneric != "" </span><span class="cov3" title="8">{ + <span class="cov7" title="127">if modelGeneric != "" </span><span class="cov3" title="11">{ if providerLower == nameLower </span><span class="cov1" title="2">{ return modelGeneric, true }</span> - <span class="cov3" title="6">if providerLower == "" && !genericUsed </span><span class="cov0" title="0">{ + <span class="cov3" title="9">if providerLower == "" && !genericUsed </span><span class="cov0" title="0">{ genericUsed = true return modelGeneric, true }</span> } - <span class="cov6" title="86">return "", false</span> + <span class="cov7" title="125">return "", false</span> } // Provider-specific - <span class="cov5" title="31">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{ out.OpenAIBaseURL = s any = true }</span> - <span class="cov5" title="31">if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok </span><span class="cov3" title="5">{ + <span class="cov5" title="33">if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok </span><span class="cov3" title="5">{ out.OpenAIModel = model any = true }</span> - <span class="cov5" title="31">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.OpenAITemperature = f any = true }</span> - <span class="cov5" title="31">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">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">{ + 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">{ + 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">{ out.OllamaBaseURL = s any = true }</span> - <span class="cov5" title="31">if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok </span><span class="cov1" title="1">{ out.OllamaModel = model any = true }</span> - <span class="cov5" title="31">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.OllamaTemperature = f any = true }</span> - <span class="cov5" title="31">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov1" title="1">{ out.CopilotBaseURL = s any = true }</span> - <span class="cov5" title="31">if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok </span><span class="cov1" title="1">{ out.CopilotModel = model any = true }</span> - <span class="cov5" title="31">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov5" title="33">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.CopilotTemperature = f any = true }</span> - <span class="cov5" title="31">if !any </span><span class="cov5" title="23">{ + // Per-surface overrides + <span class="cov5" title="33">buildEntry := func(modelKey, tempKey, providerKey string) ([]SurfaceConfig, bool) </span><span class="cov7" title="132">{ + model := getenv(modelKey) + tempPtr, tempSet := parseFloatPtr(tempKey) + provider := getenv(providerKey) + if model == "" && provider == "" && !tempSet </span><span class="cov7" title="126">{ + return nil, false + }</span> + <span class="cov3" title="6">entry := SurfaceConfig{Provider: provider, Model: model} + if tempSet </span><span class="cov3" title="6">{ + entry.Temperature = tempPtr + }</span> + <span class="cov3" title="6">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">{ + 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">{ + 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">{ + 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">{ + out.CLIConfigs = entries + any = true + }</span> + + <span class="cov5" title="33">if !any </span><span class="cov4" title="24">{ return nil }</span> - <span class="cov3" title="8">return &out</span> + <span class="cov3" title="9">return &out</span> } </pre> @@ -1659,6 +2055,11 @@ type chatDoer interface { type providerNamer interface{ Name() string } +type requestArgs struct { + model string + 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">{ return n.Name() @@ -1666,6 +2067,42 @@ func providerOf(c any) string <span class="cov10" title="54">{ <span class="cov9" title="48">return "llm"</span> } +func canonicalProvider(name string) string <span class="cov8" title="24">{ + p := strings.ToLower(strings.TrimSpace(name)) + if p == "" </span><span class="cov7" title="20">{ + return "openai" + }</span> + <span class="cov4" title="4">return p</span> +} + +func defaultModelForProvider(cfg appconfig.App, provider string) string <span class="cov9" title="41">{ + 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"> + 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">{ + return *entry.Temperature, true + }</span> + <span class="cov7" title="21">if cfg.CodingTemperature != nil </span><span class="cov7" title="17">{ + temp := *cfg.CodingTemperature + if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 </span><span class="cov1" title="1">{ + temp = 1.0 + }</span> + <span class="cov7" title="17">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">{ + return 1.0, true + }</span> + <span class="cov4" title="4">return 0, false</span> +} + func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) <span class="cov5" title="7">{ sys := cfg.PromptCodeActionRewriteSystem user := Render(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": instruction, "selection": selection}) @@ -1752,9 +2189,9 @@ func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, er <span class="cov1" title="1">return out, nil</span> } -func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) <span class="cov7" title="17">{ +func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, req requestArgs) (string, error) <span class="cov7" title="17">{ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - txt, err := client.Chat(ctx, msgs, opts...) + txt, err := client.Chat(ctx, msgs, req.options...) if err != nil </span><span class="cov0" title="0">{ return "", err }</span> @@ -1765,7 +2202,11 @@ func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opt sent += len(m.Content) }</span> <span class="cov7" title="17">recv := len(out) - _ = stats.Update(ctx, providerOf(client), client.DefaultModel(), sent, recv) + model := strings.TrimSpace(req.model) + if model == "" </span><span class="cov7" title="17">{ + 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">{ minsWin := snap.Window.Minutes() if minsWin <= 0 </span><span class="cov0" title="0">{ @@ -1773,30 +2214,42 @@ func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opt }</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[client.DefaultModel()]; ok2 </span><span class="cov7" title="17">{ + if mc, ok2 := pe.Models[model]; ok2 </span><span class="cov7" title="17">{ scopeReqs = mc.Reqs }</span> } <span class="cov7" title="17">scopeRPM := float64(scopeReqs) / minsWin - _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, providerOf(client), client.DefaultModel(), scopeRPM, scopeReqs, snap.Window))</span> + _ = 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> } // reqOptsFrom builds LLM request options similar to LSP behavior. -func reqOptsFrom(cfg appconfig.App) []llm.RequestOption <span class="cov7" title="17">{ - opts := []llm.RequestOption{llm.WithMaxTokens(cfg.MaxTokens)} - // Apply temperature, with special-case for gpt-5 (default temp must be 1.0) - if cfg.CodingTemperature != nil </span><span class="cov6" title="13">{ - temp := *cfg.CodingTemperature - prov := strings.ToLower(strings.TrimSpace(cfg.Provider)) - model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel)) - if prov == "openai" && strings.HasPrefix(model, "gpt-5") </span><span class="cov0" title="0">{ - temp = 1.0 - }</span> - <span class="cov6" title="13">opts = append(opts, llm.WithTemperature(temp))</span> - } - <span class="cov7" title="17">return opts</span> +func reqOptsFrom(cfg appconfig.App) requestArgs <span class="cov7" title="22">{ + opts := make([]llm.RequestOption, 0, 3) + if cfg.MaxTokens > 0 </span><span class="cov7" title="17">{ + opts = append(opts, llm.WithMaxTokens(cfg.MaxTokens)) + }</span> + <span class="cov7" title="22">provider := canonicalProvider(cfg.Provider) + entries := cfg.CodeActionConfigs + if len(entries) == 0 </span><span class="cov7" title="21">{ + 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">{ + provider = canonicalProvider(primary.Provider) + }</span> + <span class="cov7" title="22">model := strings.TrimSpace(primary.Model) + if model == "" </span><span class="cov7" title="20">{ + model = strings.TrimSpace(defaultModelForProvider(cfg, provider)) + }</span> + <span class="cov7" title="22">if strings.TrimSpace(primary.Model) != "" </span><span class="cov2" title="2">{ + 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">{ + opts = append(opts, llm.WithTemperature(temp)) + }</span> + <span class="cov7" title="22">return requestArgs{model: model, options: opts}</span> } // Timeout helpers to mirror LSP behavior. @@ -1834,13 +2287,15 @@ var ( newClientFromApp = llmutils.NewClientFromApp ) +type configPathKey struct{} + // selectedCustom carries the chosen custom action (if any) from the TUI submenu // 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">{ logger := log.New(stderr, "hexai-tmux-action ", log.LstdFlags|log.Lmsgprefix) - cfg := appconfig.Load(logger) + cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPathFromContext(ctx)}) if cfg.StatsWindowMinutes > 0 </span><span class="cov6" title="4">{ stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) }</span> @@ -1849,15 +2304,24 @@ func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error < return err }</span> // Enable custom action submenu with configurable hotkey - <span class="cov6" title="4">if len(cfg.CustomActions) > 0 </span><span class="cov1" title="1">{ + <span class="cov6" title="4">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">{ + 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) return err }</span> - <span class="cov5" title="3">_ = tmux.SetStatus(tmux.FormatLLMStartStatus(cli.Name(), cli.DefaultModel())) + <span class="cov5" title="3">primaryModel := strings.TrimSpace(reqOptsFrom(cfg).model) + if primaryModel == "" </span><span class="cov5" title="3">{ + primaryModel = cli.DefaultModel() + }</span> + <span class="cov5" title="3">_ = tmux.SetStatus(tmux.FormatLLMStartStatus(cli.Name(), primaryModel)) var client chatDoer = cli parts, err := ParseInput(stdin) if err != nil </span><span class="cov0" title="0">{ @@ -1879,6 +2343,24 @@ func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error < return nil</span> } +// WithConfigPath attaches a config path override to the context for Run/RunCommand. +func WithConfigPath(ctx context.Context, path string) context.Context <span class="cov0" title="0">{ + if ctx == nil </span><span class="cov0" title="0">{ + ctx = context.Background() + }</span> + <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">{ + 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">{ + return strings.TrimSpace(v) + }</span> + <span class="cov6" title="4">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">{ switch kind </span>{ case ActionSkip:<span class="cov3" title="2"> @@ -2215,12 +2697,14 @@ func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem lis package hexaicli import ( + "bytes" "context" "fmt" "io" "log" "os" "strings" + "sync" "time" "codeberg.org/snonux/hexai/internal/appconfig" @@ -2230,22 +2714,169 @@ import ( "codeberg.org/snonux/hexai/internal/logging" "codeberg.org/snonux/hexai/internal/stats" "codeberg.org/snonux/hexai/internal/tmux" + "github.com/mattn/go-runewidth" + "golang.org/x/term" ) +type requestArgs struct { + model string + options []llm.RequestOption +} + +type cliJob struct { + index int + provider string + entry appconfig.SurfaceConfig + client llm.Client + req requestArgs +} + +type columnPrinter struct { + mu sync.Mutex + stdout io.Writer + columns int + colWidth int + partial []string + providers []string + models []string +} + +type columnWriter struct { + printer *columnPrinter + index int +} + +type ( + selectionContextKey struct{} + configPathContextKey struct{} +) + +func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) <span class="cov7" title="6">{ + entries := cfg.CLIConfigs + if len(entries) == 0 </span><span class="cov6" title="5">{ + 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">{ + 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">{ + provider = cfg.Provider + }</span> + <span class="cov7" title="7">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">{ + derived.OpenAIModel = entry.Model + }</span> + case "copilot":<span class="cov1" title="1"> + if entry.Model != "" </span><span class="cov1" title="1">{ + derived.CopilotModel = entry.Model + }</span> + case "ollama":<span class="cov0" title="0"> + if entry.Model != "" </span><span class="cov0" title="0">{ + derived.OllamaModel = entry.Model + }</span> + } + <span class="cov7" title="7">client, err := newClientFromApp(derived) + if err != nil </span><span class="cov1" title="1">{ + return nil, err + }</span> + <span class="cov7" title="6">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="cov6" title="5">return jobs, nil</span> +} + +func buildCLIRequest(entry appconfig.SurfaceConfig, provider string, cfg appconfig.App, client llm.Client) requestArgs <span class="cov8" title="8">{ + opts := make([]llm.RequestOption, 0, 2) + if cfg.MaxTokens > 0 </span><span class="cov5" title="4">{ + 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">{ + model = strings.TrimSpace(client.DefaultModel()) + }</span> + <span class="cov6" title="5">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">{ + 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">{ + opts = append(opts, llm.WithTemperature(temp)) + }</span> + <span class="cov8" title="8">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">{ + return *entry.Temperature, true + }</span> + <span class="cov7" title="7">if cfg.CodingTemperature != nil </span><span class="cov6" title="5">{ + temp := *cfg.CodingTemperature + if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 </span><span class="cov3" title="2">{ + temp = 1.0 + }</span> + <span class="cov6" title="5">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">{ + return 1.0, true + }</span> + <span class="cov3" title="2">return 0, false</span> +} + +func canonicalProvider(name string) string <span class="cov7" title="7">{ + p := strings.ToLower(strings.TrimSpace(name)) + if p == "" </span><span class="cov4" title="3">{ + return "openai" + }</span> + <span class="cov5" title="4">return p</span> +} + +func defaultModelForProvider(cfg appconfig.App, provider string) string <span class="cov0" title="0">{ + 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="cov0" title="0"> + return cfg.OpenAIModel</span> + } +} + // 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">{ // Load configuration with a logger so file-based config is respected. logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) - cfg := appconfig.Load(logger) + configPath := configPathFromContext(ctx) + cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPath}) if cfg.StatsWindowMinutes > 0 </span><span class="cov6" title="5">{ stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) }</span> - <span class="cov6" title="5">client, err := newClientFromApp(cfg) + <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) return err }</span> + <span class="cov5" title="4">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) + return err + }</span> + } + <span class="cov5" title="4">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) @@ -2259,9 +2890,8 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset) return rerr }</span> - <span class="cov5" title="4">printProviderInfo(stderr, client) - msgs := buildMessagesFromConfig(cfg, input) - if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil </span><span class="cov0" title="0">{ + <span class="cov5" title="4">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) return err }</span> @@ -2276,15 +2906,348 @@ func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) return err }</span> - <span class="cov1" title="1">printProviderInfo(stderr, client) + <span class="cov1" title="1">req := requestArgs{model: strings.TrimSpace(client.DefaultModel())} + printProviderInfo(stderr, client, req.model) msgs := buildMessages(input) - if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil </span><span class="cov1" title="1">{ + 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) return err }</span> <span class="cov0" title="0">return nil</span> } +type cliJobResult struct { + provider string + model string + output string + summary string + err error +} + +func runCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input string, stdout, stderr io.Writer) error <span class="cov5" title="4">{ + results := make([]*cliJobResult, len(jobs)) + var wg sync.WaitGroup + var printer *columnPrinter + if len(jobs) > 0 </span><span class="cov5" title="4">{ + printer = newColumnPrinter(stdout, jobs) + printer.PrintHeader() + }</span> + <span class="cov5" title="4">for _, job := range jobs </span><span class="cov5" title="4">{ + job := job + wg.Add(1) + printProviderInfo(stderr, job.client, job.req.model) + go func() </span><span class="cov5" title="4">{ + 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">{ + 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">{ + printer.Flush(job.index) + }</span> + <span class="cov5" title="4">results[job.index] = &cliJobResult{ + provider: job.client.Name(), + model: job.req.model, + output: outBuf.String(), + summary: errBuf.String(), + err: err, + }</span> + }() + } + <span class="cov5" title="4">wg.Wait() + var firstErr error + if printer == nil </span><span class="cov0" title="0">{ + printed := false + for _, res := range results </span><span class="cov0" title="0">{ + if res == nil </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov0" title="0">if printed </span><span class="cov0" title="0">{ + if _, err := io.WriteString(stdout, "\n"); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + } + <span class="cov0" title="0">heading := fmt.Sprintf("=== %s:%s ===\n", res.provider, res.model) + if _, err := io.WriteString(stdout, heading); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov0" title="0">if res.output != "" </span><span class="cov0" title="0">{ + if _, err := io.WriteString(stdout, res.output); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov0" title="0">if !strings.HasSuffix(res.output, "\n") </span><span class="cov0" title="0">{ + if _, err := io.WriteString(stdout, "\n"); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + } + } + <span class="cov0" title="0">printed = true</span> + } + } + <span class="cov5" title="4">for _, res := range results </span><span class="cov5" title="4">{ + 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">{ + summary := strings.TrimLeft(res.summary, "\n") + if summary != "" </span><span class="cov5" title="4">{ + 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">{ + 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">{ + firstErr = res.err + }</span> + } + <span class="cov5" title="4">return firstErr</span> +} + +func newColumnPrinter(stdout io.Writer, jobs []cliJob) *columnPrinter <span class="cov5" title="4">{ + cols := len(jobs) + width := detectTerminalWidth(stdout) + if width <= 0 </span><span class="cov5" title="4">{ + width = 100 + }</span> + <span class="cov5" title="4">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) + models := make([]string, cols) + for _, job := range jobs </span><span class="cov5" title="4">{ + providers[job.index] = job.client.Name() + models[job.index] = job.req.model + }</span> + <span class="cov5" title="4">return &columnPrinter{ + stdout: stdout, + columns: cols, + colWidth: colWidth, + partial: make([]string, cols), + providers: providers, + models: models, + }</span> +} + +func detectTerminalWidth(w io.Writer) int <span class="cov5" title="4">{ + 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">{ + 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> +} + +func (cp *columnPrinter) Writer(idx int) io.Writer <span class="cov5" title="4">{ + return columnWriter{printer: cp, index: idx} +}</span> + +func (cp *columnPrinter) PrintHeader() <span class="cov5" title="4">{ + 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">{ + provider := strings.TrimSpace(cp.providers[i]) + model := strings.TrimSpace(cp.models[i]) + switch </span>{ + case provider != "" && model != "":<span class="cov5" title="4"> + combo[i] = provider + ":" + model</span> + case provider != "":<span class="cov0" title="0"> + combo[i] = provider</span> + case model != "":<span class="cov0" title="0"> + combo[i] = model</span> + default:<span class="cov0" title="0"> + combo[i] = ""</span> + } + } + <span class="cov5" title="4">cp.writeLine(combo) + divider := make([]string, cp.columns) + line := strings.Repeat("─", cp.colWidth) + for i := range divider </span><span class="cov5" title="4">{ + divider[i] = line + }</span> + <span class="cov5" title="4">cp.writeLine(divider)</span> +} + +func (cp *columnPrinter) Flush(idx int) <span class="cov5" title="4">{ + 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">{ + return + }</span> + <span class="cov5" title="4">cp.emitJobLine(idx, cp.partial[idx]) + cp.partial[idx] = ""</span> +} + +func (w columnWriter) Write(p []byte) (int, error) <span class="cov5" title="4">{ + return w.printer.write(w.index, string(p)) +}</span> + +func (cp *columnPrinter) write(idx int, data string) (int, error) <span class="cov5" title="4">{ + 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", "") + 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> +} + +func (cp *columnPrinter) emitJobLine(idx int, line string) <span class="cov5" title="4">{ + segments := cp.wrap(line) + for _, seg := range segments </span><span class="cov5" title="4">{ + cells := make([]string, cp.columns) + if idx >= 0 && idx < len(cells) </span><span class="cov5" title="4">{ + cells[idx] = seg + }</span> + <span class="cov5" title="4">cp.writeLine(cells)</span> + } +} + +func (cp *columnPrinter) wrap(text string) []string <span class="cov5" title="4">{ + text = strings.ReplaceAll(text, "\t", " ") + if runewidth.StringWidth(text) <= cp.colWidth </span><span class="cov5" title="4">{ + return []string{text} + }</span> + <span class="cov0" title="0">var lines []string + var current strings.Builder + width := 0 + for _, r := range text </span><span class="cov0" title="0">{ + rw := runewidth.RuneWidth(r) + if width+rw > cp.colWidth && current.Len() > 0 </span><span class="cov0" title="0">{ + lines = append(lines, current.String()) + current.Reset() + width = 0 + }</span> + <span class="cov0" title="0">current.WriteRune(r) + width += rw</span> + } + <span class="cov0" title="0">if current.Len() > 0 </span><span class="cov0" title="0">{ + lines = append(lines, current.String()) + }</span> + <span class="cov0" title="0">if len(lines) == 0 </span><span class="cov0" title="0">{ + lines = append(lines, "") + }</span> + <span class="cov0" title="0">return lines</span> +} + +func (cp *columnPrinter) writeLine(cells []string) <span class="cov9" title="12">{ + 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">{ + 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">{ + builder.WriteString(strings.Repeat(" ", pad)) + }</span> + <span class="cov9" title="12">if i != cp.columns-1 </span><span class="cov0" title="0">{ + builder.WriteString(" │ ") + }</span> + } + <span class="cov9" title="12">builder.WriteByte('\n') + _, _ = cp.stdout.Write([]byte(builder.String()))</span> +} + +// WithCLISelection injects provider indices into the context so Run only executes those jobs. +func WithCLISelection(ctx context.Context, indices []int) context.Context <span class="cov0" title="0">{ + if ctx == nil </span><span class="cov0" title="0">{ + ctx = context.Background() + }</span> + <span class="cov0" title="0">cpy := make([]int, len(indices)) + copy(cpy, indices) + return context.WithValue(ctx, selectionContextKey{}, cpy)</span> +} + +// WithCLIConfigPath returns a context that carries the config file path override. +func WithCLIConfigPath(ctx context.Context, path string) context.Context <span class="cov0" title="0">{ + if ctx == nil </span><span class="cov0" title="0">{ + ctx = context.Background() + }</span> + <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">{ + 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">{ + return strings.TrimSpace(v) + }</span> + <span class="cov6" title="5">return ""</span> +} + +func selectionFromContext(ctx context.Context) []int <span class="cov5" title="4">{ + 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">{ + cpy := make([]int, len(v)) + copy(cpy, v) + return cpy + }</span> + <span class="cov5" title="4">return nil</span> +} + +func filterJobsBySelection(jobs []cliJob, indices []int) ([]cliJob, error) <span class="cov3" title="2">{ + 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)) + 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">{ + 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">{ + continue</span> + } + <span class="cov3" title="2">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">{ + filtered[i].index = i + }</span> + <span class="cov1" title="1">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> +} + // 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">{ var stdinData string @@ -2340,22 +3303,26 @@ func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message <spa } // runChat executes the chat request, handling streaming and summary output. -func runChat(ctx context.Context, client llm.Client, 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="cov8" title="9">{ start := time.Now() // Best-effort tmux status update (colored start heartbeat) - _ = tmux.SetStatus(tmux.FormatLLMStartStatus(client.Name(), client.DefaultModel())) + 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)) var output string if s, ok := client.(llm.Streamer); ok </span><span class="cov3" title="2">{ 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>); err != nil <span class="cov0" title="0">{ + }</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"> { - txt, err := client.Chat(ctx, msgs) + txt, err := client.Chat(ctx, msgs, req.options...) if err != nil </span><span class="cov3" title="2">{ return err }</span> @@ -2369,7 +3336,7 @@ func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input s sent += len(m.Content) }</span> <span class="cov7" title="7">recv := len(output) - _ = stats.Update(ctx, client.Name(), client.DefaultModel(), sent, recv) + _ = stats.Update(ctx, client.Name(), model, sent, recv) snap, _ := stats.TakeSnapshot() minsWin := snap.Window.Minutes() if minsWin <= 0 </span><span class="cov0" title="0">{ @@ -2377,21 +3344,24 @@ func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input s }</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[client.DefaultModel()]; ok2 </span><span class="cov7" title="7">{ + if mc, ok2 := pe.Models[model]; ok2 </span><span class="cov7" title="7">{ 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(), client.DefaultModel(), dur.Round(time.Millisecond), sent, recv, snap.Global.Reqs, snap.RPM) - _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, client.Name(), client.DefaultModel(), scopeRPM, scopeReqs, snap.Window)) + 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)) return nil</span> } // printProviderInfo writes the provider/model line to stderr. -func printProviderInfo(errw io.Writer, client llm.Client) <span class="cov7" title="6">{ - fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), client.DefaultModel()) -}</span> +func printProviderInfo(errw io.Writer, client llm.Client, model string) <span class="cov7" title="6">{ + 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> +} // newClientFromConfig is kept for tests; delegates to llmutils. var newClientFromApp = llmutils.NewClientFromApp @@ -2427,7 +3397,12 @@ 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">{ + 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">{ logger := log.New(stderr, "hexai-lsp ", log.LstdFlags|log.Lmsgprefix) if strings.TrimSpace(logPath) != "" </span><span class="cov1" title="1">{ f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) @@ -2438,19 +3413,20 @@ func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) er logger.SetOutput(f)</span> } <span class="cov1" title="1">logging.Bind(logger) - cfg := appconfig.Load(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">{ stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) }</span> - <span class="cov1" title="1">return RunWithFactory(logPath, stdin, stdout, logger, cfg, nil, nil)</span> + <span class="cov1" title="1">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, 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="8">{ normalizeLoggingConfig(&cfg) if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{ logger.Fatalf("invalid config: %v", err) @@ -2460,7 +3436,9 @@ func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *l store := runtimeconfig.New(cfg) logContext := strings.TrimSpace(logPath) != "" - opts := makeServerOptions(cfg, logContext, client) + loadOpts := appconfig.LoadOptions{ConfigPath: strings.TrimSpace(configPath)} + opts := makeServerOptions(cfg, logContext, client, loadOpts) + 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">{ @@ -2470,10 +3448,10 @@ func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *l if updated.StatsWindowMinutes > 0 </span><span class="cov0" title="0">{ stats.SetWindow(time.Duration(updated.StatsWindowMinutes) * time.Minute) }</span> - <span class="cov1" title="1">if newClient := buildClientIfNil(updated, nil); newClient != nil </span><span class="cov0" title="0">{ + <span class="cov1" title="1">if newClient := buildClientIfNil(updated, nil); newClient != nil </span><span class="cov1" title="1">{ client = newClient }</span> - <span class="cov1" title="1">opts := makeServerOptions(updated, logContext, client) + <span class="cov1" title="1">opts := makeServerOptions(updated, logContext, client, loadOpts) opts.ConfigStore = store configurable.ApplyOptions(opts)</span> }) @@ -2498,31 +3476,39 @@ func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client <span cla return client }</span> <span class="cov9" title="8">llmCfg := llm.Config{ - Provider: cfg.Provider, - OpenAIBaseURL: cfg.OpenAIBaseURL, - OpenAIModel: cfg.OpenAIModel, - OpenAITemperature: cfg.OpenAITemperature, - OllamaBaseURL: cfg.OllamaBaseURL, - OllamaModel: cfg.OllamaModel, - OllamaTemperature: cfg.OllamaTemperature, - CopilotBaseURL: cfg.CopilotBaseURL, - CopilotModel: cfg.CopilotModel, - CopilotTemperature: cfg.CopilotTemperature, + Provider: cfg.Provider, + OpenAIBaseURL: cfg.OpenAIBaseURL, + OpenAIModel: cfg.OpenAIModel, + OpenAITemperature: cfg.OpenAITemperature, + OpenRouterBaseURL: cfg.OpenRouterBaseURL, + OpenRouterModel: cfg.OpenRouterModel, + OpenRouterTemperature: cfg.OpenRouterTemperature, + OllamaBaseURL: cfg.OllamaBaseURL, + OllamaModel: cfg.OllamaModel, + OllamaTemperature: cfg.OllamaTemperature, + CopilotBaseURL: cfg.CopilotBaseURL, + CopilotModel: cfg.CopilotModel, + CopilotTemperature: cfg.CopilotTemperature, } // 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">{ 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">{ + 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">{ cpKey = os.Getenv("COPILOT_API_KEY") }</span> - <span class="cov9" title="8">if c, err := llm.NewFromConfig(llmCfg, oaKey, cpKey); err != nil </span><span class="cov8" title="6">{ + <span class="cov9" title="8">if c, err := llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey); err != nil </span><span class="cov1" title="1">{ logging.Logf("lsp ", "llm disabled: %v", err) return nil - }</span> else<span class="cov3" title="2"> { + }</span> else<span class="cov8" title="7"> { logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel()) return c }</span> @@ -2537,12 +3523,12 @@ func ensureFactory(factory ServerFactory) ServerFactory <span class="cov9" title }</span> } -func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) 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="9">{ // Map custom actions from appconfig to lsp type var customs []lsp.CustomAction - if len(cfg.CustomActions) > 0 </span><span class="cov3" title="2">{ + if len(cfg.CustomActions) > 0 </span><span class="cov0" title="0">{ customs = make([]lsp.CustomAction, 0, len(cfg.CustomActions)) - for _, ca := range cfg.CustomActions </span><span class="cov6" title="4">{ + for _, ca := range cfg.CustomActions </span><span class="cov0" title="0">{ customs = append(customs, lsp.CustomAction{ ID: ca.ID, Title: ca.Title, @@ -2555,6 +3541,7 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) ls }</span> } <span class="cov10" title="9">return lsp.ServerOptions{ + ConfigLoadOptions: loadOpts, LogContext: logContext, ConfigStore: nil, Config: &cfg, @@ -3288,14 +4275,14 @@ type oaStreamChunk struct { // Constructor (kept among the first functions by convention) // newOpenAI constructs an OpenAI client using explicit configuration values. // The apiKey may be empty; calls will fail until a valid key is supplied. -func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov9" title="16">{ - if strings.TrimSpace(baseURL) == "" </span><span class="cov6" title="5">{ +func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov9" title="26">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov8" title="15">{ baseURL = "https://api.openai.com/v1" }</span> - <span class="cov9" title="16">if strings.TrimSpace(model) == "" </span><span class="cov3" title="2">{ + <span class="cov9" title="26">if strings.TrimSpace(model) == "" </span><span class="cov7" title="12">{ model = "gpt-4.1" }</span> - <span class="cov9" title="16">return openAIClient{ + <span class="cov9" title="26">return openAIClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, apiKey: apiKey, baseURL: baseURL, @@ -3305,26 +4292,26 @@ 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="8">{ +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">{ return nilStringErr("missing OpenAI API key") }</span> - <span class="cov7" title="7">o := Options{Model: c.defaultModel} - for _, opt := range opts </span><span class="cov0" title="0">{ + <span class="cov7" title="12">o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov5" title="5">{ opt(&o) }</span> - <span class="cov7" title="7">if o.Model == "" </span><span class="cov0" title="0">{ + <span class="cov7" title="12">if o.Model == "" </span><span class="cov0" title="0">{ o.Model = c.defaultModel }</span> - <span class="cov7" title="7">start := time.Now() + <span class="cov7" title="12">start := time.Now() c.logStart(false, o, messages) - req := buildOAChatRequest(o, messages, c.defaultTemperature, false) + req := buildOAChatRequest(o, messages, c.defaultTemperature, false, "llm/openai ") body, err := json.Marshal(req) if err != nil </span><span class="cov0" title="0">{ c.logf("marshal error: %v", err) return "", err }</span> - <span class="cov7" title="7">endpoint := c.baseURL + "/chat/completions" + <span class="cov7" title="12">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, @@ -3333,49 +4320,49 @@ 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="7">defer resp.Body.Close() - if err := handleOpenAINon2xx(resp, start); err != nil </span><span class="cov3" title="2">{ + <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">{ return "", err }</span> - <span class="cov6" title="5">out, err := decodeOpenAIChat(resp, start) + <span class="cov7" title="10">out, err := decodeOpenAIChat(resp, start, "llm/openai ") if err != nil </span><span class="cov1" title="1">{ return "", err }</span> - <span class="cov5" title="4">if len(out.Choices) == 0 </span><span class="cov1" title="1">{ + <span class="cov6" title="9">if len(out.Choices) == 0 </span><span class="cov1" title="1">{ logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) return "", errors.New("openai: no choices returned") }</span> - <span class="cov4" title="3">content := out.Choices[0].Message.Content + <span class="cov6" title="8">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="cov3" title="2">{ return "openai" }</span> -func (c openAIClient) DefaultModel() string <span class="cov3" title="2">{ return c.defaultModel }</span> +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> // Streaming support (optional) -func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov6" title="5">{ +func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov5" title="5">{ if c.apiKey == "" </span><span class="cov0" title="0">{ return errors.New("missing OpenAI API key") }</span> - <span class="cov6" title="5">o := Options{Model: c.defaultModel} + <span class="cov5" title="5">o := Options{Model: c.defaultModel} for _, opt := range opts </span><span class="cov0" title="0">{ opt(&o) }</span> - <span class="cov6" title="5">if o.Model == "" </span><span class="cov0" title="0">{ + <span class="cov5" title="5">if o.Model == "" </span><span class="cov0" title="0">{ o.Model = c.defaultModel }</span> - <span class="cov6" title="5">start := time.Now() + <span class="cov5" title="5">start := time.Now() c.logStart(true, o, messages) - req := buildOAChatRequest(o, messages, c.defaultTemperature, true) + req := buildOAChatRequest(o, messages, c.defaultTemperature, true, "llm/openai ") body, err := json.Marshal(req) if err != nil </span><span class="cov0" title="0">{ c.logf("marshal error: %v", err) return err }</span> - <span class="cov6" title="5">endpoint := c.baseURL + "/chat/completions" + <span class="cov5" title="5">endpoint := c.baseURL + "/chat/completions" logging.Logf("llm/openai ", "POST %s (stream)", endpoint) resp, err := c.doJSONWithAccept(ctx, endpoint, body, map[string]string{ "Authorization": "Bearer " + c.apiKey, @@ -3384,15 +4371,15 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return err }</span> - <span class="cov6" title="5">defer resp.Body.Close() - if err := handleOpenAINon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ + <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">{ return err }</span> - <span class="cov6" title="5">if err := parseOpenAIStream(resp, start, onDelta); err != nil </span><span class="cov1" title="1">{ + <span class="cov5" title="5">if err := parseOpenAIStream(resp, start, onDelta, "llm/openai ", "openai"); err != nil </span><span class="cov1" title="1">{ return err }</span> - <span class="cov5" title="4">logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start)) + <span class="cov4" title="4">logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start)) return nil</span> } @@ -3400,141 +4387,311 @@ 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="12">{ +func (c openAIClient) logStart(stream bool, o Options, messages []Message) <span class="cov8" title="17">{ logMessages := make([]struct{ Role, Content string }, len(messages)) - for i, m := range messages </span><span class="cov8" title="12">{ + for i, m := range messages </span><span class="cov9" title="22">{ logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} }</span> - <span class="cov8" title="12">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> + <span class="cov8" title="17">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> } -func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool) oaChatRequest <span class="cov9" title="15">{ +func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool, logPrefix string) oaChatRequest <span class="cov9" title="22">{ req := oaChatRequest{Model: o.Model, Stream: stream} req.Messages = make([]oaMessage, len(messages)) - for i, m := range messages </span><span class="cov9" title="15">{ + for i, m := range messages </span><span class="cov9" title="27">{ req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} }</span> - <span class="cov9" title="15">if o.Temperature != 0 </span><span class="cov1" title="1">{ + <span class="cov9" title="22">if o.Temperature != 0 </span><span class="cov1" title="1">{ req.Temperature = &o.Temperature - }</span> else<span class="cov9" title="14"> if defaultTemp != nil </span><span class="cov7" title="9">{ + }</span> else<span class="cov9" title="21"> if defaultTemp != nil </span><span class="cov8" title="16">{ t := *defaultTemp req.Temperature = &t }</span> - <span class="cov9" title="15">if o.MaxTokens > 0 </span><span class="cov4" title="3">{ - if requiresMaxCompletionTokens(o.Model) </span><span class="cov3" title="2">{ + <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">{ req.MaxCompletionTokens = &o.MaxTokens - }</span> else<span class="cov1" title="1"> { + }</span> else<span class="cov5" title="6"> { req.MaxTokens = &o.MaxTokens }</span> } - <span class="cov9" title="15">if len(o.Stop) > 0 </span><span class="cov0" title="0">{ + <span class="cov9" title="22">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="15">if requiresMaxCompletionTokens(o.Model) </span><span class="cov3" title="2">{ - if req.Temperature == nil || *req.Temperature != 1.0 </span><span class="cov3" title="2">{ + <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">{ t := 1.0 req.Temperature = &t - logging.Logf("llm/openai ", "forcing temperature=1.0 for model=%s (gpt-5 constraint)", o.Model) + logging.Logf(logPrefix, "forcing temperature=1.0 for model=%s (gpt-5 constraint)", o.Model) }</span> } - <span class="cov9" title="15">return req</span> + <span class="cov9" title="22">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="18">{ +func requiresMaxCompletionTokens(model string) bool <span class="cov10" title="30">{ 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="7">{ +func (c openAIClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov7" title="12">{ 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="7">req.Header.Set("Content-Type", "application/json") - for k, v := range headers </span><span class="cov7" title="7">{ + <span class="cov7" title="12">req.Header.Set("Content-Type", "application/json") + for k, v := range headers </span><span class="cov7" title="12">{ req.Header.Set(k, v) }</span> - <span class="cov7" title="7">return c.httpClient.Do(req)</span> + <span class="cov7" title="12">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="cov6" title="5">{ +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">{ 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="cov6" title="5">req.Header.Set("Content-Type", "application/json") + <span class="cov5" title="5">req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", accept) - for k, v := range headers </span><span class="cov6" title="5">{ + for k, v := range headers </span><span class="cov5" title="5">{ req.Header.Set(k, v) }</span> - <span class="cov6" title="5">return c.httpClient.Do(req)</span> + <span class="cov5" title="5">return c.httpClient.Do(req)</span> } -func handleOpenAINon2xx(resp *http.Response, start time.Time) error <span class="cov8" title="13">{ - if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov8" title="10">{ +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">{ return nil }</span> - <span class="cov4" title="3">var apiErr oaChatResponse + <span class="cov3" title="3">var apiErr oaChatResponse _ = json.NewDecoder(resp.Body).Decode(&apiErr) if apiErr.Error != nil && apiErr.Error.Message != "" </span><span class="cov1" title="1">{ - logging.Logf("llm/openai ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase) - return fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) + 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="cov3" title="2">logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) - return fmt.Errorf("openai http error: status %d", resp.StatusCode)</span> + <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) + return fmt.Errorf("%s http error: status %d", provider, resp.StatusCode)</span> } -func decodeOpenAIChat(resp *http.Response, start time.Time) (oaChatResponse, error) <span class="cov6" title="5">{ +func decodeOpenAIChat(resp *http.Response, start time.Time, logPrefix string) (oaChatResponse, error) <span class="cov7" title="11">{ var out oaChatResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov1" title="1">{ - logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + logging.Logf(logPrefix, "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return oaChatResponse{}, err }</span> - <span class="cov5" title="4">return out, nil</span> + <span class="cov7" title="10">return out, nil</span> } -func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string)) error <span class="cov6" title="5">{ +func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string), logPrefix, provider string) error <span class="cov5" title="6">{ // 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="cov8" title="11">{ + for scanner.Scan() </span><span class="cov7" title="14">{ line := scanner.Text() - if !strings.HasPrefix(line, "data: ") </span><span class="cov3" title="2">{ + if !strings.HasPrefix(line, "data: ") </span><span class="cov3" title="3">{ continue</span> } - <span class="cov7" title="9">payload := strings.TrimPrefix(line, "data: ") - if strings.TrimSpace(payload) == "[DONE]" </span><span class="cov4" title="3">{ + <span class="cov7" title="11">payload := strings.TrimPrefix(line, "data: ") + if strings.TrimSpace(payload) == "[DONE]" </span><span class="cov4" title="4">{ break</span> } - <span class="cov6" title="6">var chunk oaStreamChunk - if err := json.Unmarshal([]byte(payload), &chunk); err != nil </span><span class="cov3" title="2">{ + <span class="cov6" title="7">var chunk oaStreamChunk + if err := json.Unmarshal([]byte(payload), &chunk); err != nil </span><span class="cov2" title="2">{ continue</span> } - <span class="cov5" title="4">if chunk.Error != nil && chunk.Error.Message != "" </span><span class="cov1" title="1">{ - logging.Logf("llm/openai ", "%sstream error: %s%s", logging.AnsiRed, chunk.Error.Message, logging.AnsiBase) - return fmt.Errorf("openai stream error: %s", chunk.Error.Message) + <span class="cov5" title="5">if chunk.Error != nil && chunk.Error.Message != "" </span><span class="cov1" title="1">{ + 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="3">for _, ch := range chunk.Choices </span><span class="cov4" title="3">{ - if ch.Delta.Content != "" </span><span class="cov3" title="2">{ + <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">{ onDelta(ch.Delta.Content) }</span> } } - <span class="cov5" title="4">if err := scanner.Err(); err != nil </span><span class="cov0" title="0">{ - logging.Logf("llm/openai ", "%sstream read error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + <span class="cov5" title="5">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="4">return nil</span> + <span class="cov5" title="5">return nil</span> +} +</pre> + + <pre class="file" id="file17" style="display: none">// Summary: OpenRouter client implementation leveraging OpenAI-compatible helpers with provider-specific headers. +package llm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/logging" +) + +type openRouterClient struct { + httpClient *http.Client + apiKey string + baseURL string + defaultModel string + chatLogger logging.ChatLogger + 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">{ + baseURL = "https://openrouter.ai/api/v1" + }</span> + <span class="cov7" title="4">if strings.TrimSpace(model) == "" </span><span class="cov1" title="1">{ + model = "openrouter/auto" + }</span> + <span class="cov7" title="4">return openRouterClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + apiKey: apiKey, + baseURL: baseURL, + defaultModel: model, + chatLogger: logging.NewChatLogger("openrouter"), + defaultTemperature: defaultTemp, + }</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">{ + return nilStringErr("missing OpenRouter API key") + }</span> + <span class="cov1" title="1">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">{ + o.Model = c.defaultModel + }</span> + <span class="cov1" title="1">start := time.Now() + c.logStart(false, o, messages) + req := buildOAChatRequest(o, messages, c.defaultTemperature, false, "llm/openrouter ") + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + c.logf("marshal error: %v", err) + return "", err + }</span> + <span class="cov1" title="1">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">{ + return "", err + }</span> + <span class="cov1" title="1">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">{ + 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 + 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) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov1" title="1">{ + 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} + 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">{ + o.Model = c.defaultModel + }</span> + <span class="cov1" title="1">start := time.Now() + c.logStart(true, o, messages) + req := buildOAChatRequest(o, messages, c.defaultTemperature, true, "llm/openrouter ") + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + c.logf("marshal error: %v", err) + return err + }</span> + <span class="cov1" title="1">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">{ + 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">{ + return err + }</span> + <span class="cov1" title="1">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">{ + logging.Logf("llm/openrouter ", format, args...) +}</span> + +func (c openRouterClient) logStart(stream bool, o Options, messages []Message) <span class="cov4" title="2">{ + logMessages := make([]struct{ Role, Content string }, len(messages)) + for i, m := range messages </span><span class="cov4" title="2">{ + 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> +} + +func (c openRouterClient) doJSON(ctx context.Context, url string, body []byte) (*http.Response, error) <span class="cov1" title="1">{ + headers := map[string]string{ + "Authorization": "Bearer " + c.apiKey, + "HTTP-Referer": "https://github.com/snonux/hexai", + "X-Title": "Hexai", + } + 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">{ + headers := map[string]string{ + "Authorization": "Bearer " + c.apiKey, + "HTTP-Referer": "https://github.com/snonux/hexai", + "X-Title": "Hexai", + } + 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">{ + 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">{ + req.Header.Set("Accept", accept) + }</span> + <span class="cov4" title="2">for k, v := range headers </span><span class="cov10" title="6">{ + req.Header.Set(k, v) + }</span> + <span class="cov4" title="2">return c.httpClient.Do(req)</span> } </pre> - <pre class="file" id="file17" style="display: none">// Summary: LLM provider interfaces, request options, configuration, and factory to build a client from config. + <pre class="file" id="file18" style="display: none">// Summary: LLM provider interfaces, request options, configuration, and factory to build a client from config. package llm import ( @@ -3591,9 +4748,9 @@ type Options struct { // RequestOption mutates Options. type RequestOption func(*Options) -func WithModel(model string) RequestOption <span class="cov1" title="1">{ return func(o *Options) </span><span class="cov1" title="1">{ o.Model = model }</span> } -func WithTemperature(t float64) RequestOption <span class="cov7" title="15">{ return func(o *Options) </span><span class="cov2" title="2">{ o.Temperature = t }</span> } -func WithMaxTokens(n int) RequestOption <span class="cov10" title="54">{ return func(o *Options) </span><span class="cov2" title="2">{ o.MaxTokens = n }</span> } +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> } @@ -3605,6 +4762,10 @@ type Config struct { OpenAIBaseURL string OpenAIModel string OpenAITemperature *float64 + // OpenRouter options + OpenRouterBaseURL string + OpenRouterModel string + OpenRouterTemperature *float64 // Ollama options OllamaBaseURL string OllamaModel string @@ -3618,14 +4779,14 @@ type Config struct { // NewFromConfig creates an LLM client using only the supplied configuration. // The OpenAI API key is supplied separately and may be read from the environment // by the caller; other environment-based configuration is not used. -func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) <span class="cov8" title="23">{ +func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, copilotAPIKey string) (Client, error) <span class="cov8" title="28">{ p := strings.ToLower(strings.TrimSpace(cfg.Provider)) - if p == "" </span><span class="cov5" title="9">{ + if p == "" </span><span class="cov5" title="8">{ p = "openai" }</span> - <span class="cov8" title="23">switch p </span>{ - case "openai":<span class="cov7" title="16"> - if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov6" title="10">{ + <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">{ return nil, errors.New("missing OPENAI_API_KEY for provider openai") }</span> // Default temperature selection: @@ -3634,7 +4795,7 @@ func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, erro // The app-wide defaults currently set provider temps to 0.2. // If the user hasn't explicitly overridden and the model is gpt-5*, // upgrade 0.2 → 1.0 to satisfy the requested default for gpt-5. - <span class="cov5" title="6">model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel)) + <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">{ v := 1.0 @@ -3643,11 +4804,20 @@ func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, erro v := 1.0 cfg.OpenAITemperature = &v }</span> - } else<span class="cov4" title="4"> if cfg.OpenAITemperature == nil </span><span class="cov3" title="3">{ + } else<span class="cov6" title="14"> if cfg.OpenAITemperature == nil </span><span class="cov6" title="11">{ v := 0.2 cfg.OpenAITemperature = &v }</span> - <span class="cov5" title="6">return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil</span> + <span class="cov6" title="16">return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), 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") + }</span> + <span class="cov0" title="0">if cfg.OpenRouterTemperature == nil </span><span class="cov0" title="0">{ + 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">{ t := 0.2 @@ -3669,15 +4839,15 @@ func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, erro } </pre> - <pre class="file" id="file18" style="display: none">package llm + <pre class="file" id="file19" style="display: none">package llm import "errors" // small helper to keep return type consistent -func nilStringErr(msg string) (string, error) <span class="cov10" title="2">{ return "", errors.New(msg) }</span> +func nilStringErr(msg string) (string, error) <span class="cov10" title="3">{ return "", errors.New(msg) }</span> </pre> - <pre class="file" id="file19" style="display: none">package llmutils + <pre class="file" id="file20" style="display: none">package llmutils import ( "os" @@ -3690,30 +4860,37 @@ 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">{ llmCfg := llm.Config{ - Provider: cfg.Provider, - OpenAIBaseURL: cfg.OpenAIBaseURL, - OpenAIModel: cfg.OpenAIModel, - OpenAITemperature: cfg.OpenAITemperature, - OllamaBaseURL: cfg.OllamaBaseURL, - OllamaModel: cfg.OllamaModel, - OllamaTemperature: cfg.OllamaTemperature, - CopilotBaseURL: cfg.CopilotBaseURL, - CopilotModel: cfg.CopilotModel, - CopilotTemperature: cfg.CopilotTemperature, + Provider: cfg.Provider, + OpenAIBaseURL: cfg.OpenAIBaseURL, + OpenAIModel: cfg.OpenAIModel, + OpenAITemperature: cfg.OpenAITemperature, + OpenRouterBaseURL: cfg.OpenRouterBaseURL, + OpenRouterModel: cfg.OpenRouterModel, + OpenRouterTemperature: cfg.OpenRouterTemperature, + OllamaBaseURL: cfg.OllamaBaseURL, + OllamaModel: cfg.OllamaModel, + OllamaTemperature: cfg.OllamaTemperature, + CopilotBaseURL: cfg.CopilotBaseURL, + CopilotModel: cfg.CopilotModel, + CopilotTemperature: cfg.CopilotTemperature, } oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") if strings.TrimSpace(oaKey) == "" </span><span class="cov9" title="5">{ 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">{ + 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">{ cpKey = os.Getenv("COPILOT_API_KEY") }</span> - <span class="cov10" title="6">return llm.NewFromConfig(llmCfg, oaKey, cpKey)</span> + <span class="cov10" title="6">return llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey)</span> } </pre> - <pre class="file" id="file20" style="display: none">package logging + <pre class="file" id="file21" style="display: none">package logging // ChatLogger provides a structured way to log chat interactions. type ChatLogger struct { @@ -3721,7 +4898,7 @@ type ChatLogger struct { } // NewChatLogger creates a new ChatLogger for a given provider. -func NewChatLogger(provider string) ChatLogger <span class="cov10" title="42">{ +func NewChatLogger(provider string) ChatLogger <span class="cov10" title="56">{ return ChatLogger{Provider: provider} }</span> @@ -3730,21 +4907,21 @@ func (cl ChatLogger) LogStart(stream bool, model string, temp float64, maxTokens Role string Content string }, -) <span class="cov8" title="27">{ +) <span class="cov8" title="34">{ chatOrStream := "chat" - if stream </span><span class="cov6" title="9">{ + if stream </span><span class="cov6" title="10">{ chatOrStream = "stream" }</span> - <span class="cov8" title="27">Logf("llm/"+cl.Provider+" ", "%s start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", + <span class="cov8" title="34">Logf("llm/"+cl.Provider+" ", "%s start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", chatOrStream, model, temp, maxTokens, len(stop), len(messages)) - for i, m := range messages </span><span class="cov8" title="27">{ + for i, m := range messages </span><span class="cov9" title="39">{ 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="file21" style="display: none">// Summary: ANSI-styled logging utilities with a bound standard logger and configurable preview truncation. + <pre class="file" id="file22" style="display: none">// Summary: ANSI-styled logging utilities with a bound standard logger and configurable preview truncation. package logging import ( @@ -3770,14 +4947,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="cov2" title="3">{ std = l }</span> +func Bind(l *log.Logger) <span class="cov3" title="4">{ 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="202">{ - if std == nil </span><span class="cov9" title="141">{ +func Logf(prefix, format string, args ...any) <span class="cov10" title="227">{ + if std == nil </span><span class="cov9" title="161">{ return }</span> - <span class="cov7" title="61">msg := fmt.Sprintf(format, args...) + <span class="cov7" title="66">msg := fmt.Sprintf(format, args...) std.Print(AnsiBase + prefix + msg + AnsiReset)</span> } @@ -3786,27 +4963,26 @@ 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="cov5" title="11">{ logPreviewLimit = n }</span> +func SetLogPreviewLimit(n int) <span class="cov4" title="11">{ logPreviewLimit = n }</span> // PreviewForLog returns the string truncated to the configured preview limit. -func PreviewForLog(s string) string <span class="cov7" title="36">{ +func PreviewForLog(s string) string <span class="cov7" title="54">{ if logPreviewLimit > 0 </span><span class="cov2" title="3">{ if len(s) <= logPreviewLimit </span><span class="cov0" title="0">{ return s }</span> <span class="cov2" title="3">return s[:logPreviewLimit] + "…"</span> } - <span class="cov6" title="33">return s</span> + <span class="cov7" title="51">return s</span> } </pre> - <pre class="file" id="file22" style="display: none">package lsp + <pre class="file" id="file23" style="display: none">package lsp import ( "fmt" "strings" - "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/runtimeconfig" ) @@ -3814,17 +4990,21 @@ type chatCommandResult struct { message string } -func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) <span class="cov10" title="9">{ +func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) <span class="cov10" title="13">{ trimmed := strings.TrimSpace(s.stripTrailingTrigger(prompt)) - if trimmed == "" || !strings.HasPrefix(trimmed, "/") </span><span class="cov9" title="8">{ + if trimmed == "" || !strings.HasPrefix(trimmed, "/") </span><span class="cov8" title="8">{ return chatCommandResult{}, false }</span> - <span class="cov1" title="1">switch </span>{ + <span class="cov6" title="5">switch </span>{ case strings.HasPrefix(trimmed, "/reload"):<span class="cov1" title="1"> 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"> + return s.handleDisableCompletionCommand(), true</span> + case strings.HasPrefix(trimmed, "/enable"):<span class="cov3" title="2"> + return s.handleEnableCompletionCommand(), true</span> default:<span class="cov0" title="0"> return chatCommandResult{message: fmt.Sprintf("Unknown command %q. Try /help?>", trimmed)}, true</span> } @@ -3834,6 +5014,8 @@ func (s *Server) handleHelpCommand() chatCommandResult <span class="cov1" title= lines := []string{ "Available slash commands:", "- /reload?> reload configuration from file (ignores env overrides)", + "- /disable?> disable auto-completions for this session", + "- /enable?> re-enable auto-completions", } return chatCommandResult{message: strings.Join(lines, "\n")} }</span> @@ -3842,7 +5024,9 @@ func (s *Server) handleReloadCommand() chatCommandResult <span class="cov3" titl if s.configStore == nil </span><span class="cov0" title="0">{ return chatCommandResult{message: "Reload unavailable: no config store"} }</span> - <span class="cov3" title="2">changes, err := s.configStore.Reload(s.logger, appconfig.LoadOptions{IgnoreEnv: true}) + <span class="cov3" title="2">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)} @@ -3851,9 +5035,25 @@ func (s *Server) handleReloadCommand() chatCommandResult <span class="cov3" titl s.logger.Print(summary) return chatCommandResult{message: summary}</span> } + +func (s *Server) handleDisableCompletionCommand() chatCommandResult <span class="cov3" title="2">{ + prev := s.setCompletionsDisabled(true) + if prev </span><span class="cov1" title="1">{ + 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> +} + +func (s *Server) handleEnableCompletionCommand() chatCommandResult <span class="cov3" title="2">{ + prev := s.setCompletionsDisabled(false) + if !prev </span><span class="cov1" title="1">{ + return chatCommandResult{message: "Auto-completions are already enabled."} + }</span> + <span class="cov1" title="1">return chatCommandResult{message: "Auto-completions enabled."}</span> +} </pre> - <pre class="file" id="file23" style="display: none">// Summary: Builds additional context snippets based on configured mode and truncates text by token heuristic. + <pre class="file" id="file24" style="display: none">// Summary: Builds additional context snippets based on configured mode and truncates text by token heuristic. package lsp import ( @@ -3939,7 +5139,7 @@ func truncateToApproxTokens(text string, maxTokens int) string <span class="cov7 } </pre> - <pre class="file" id="file24" style="display: none">// Summary: In-memory document model for the LSP; tracks text, lines, and applies edits. + <pre class="file" id="file25" style="display: none">// Summary: In-memory document model for the LSP; tracks text, lines, and applies edits. package lsp import ( @@ -4077,16 +5277,16 @@ func trimLen(s string) string <span class="cov8" title="47">{ <span class="cov8" title="46">return s</span> } -func firstLine(s string) string <span class="cov7" title="27">{ +func firstLine(s string) string <span class="cov7" title="25">{ s = strings.ReplaceAll(s, "\r\n", "\n") if idx := strings.IndexByte(s, '\n'); idx >= 0 </span><span class="cov4" title="6">{ return s[:idx] }</span> - <span class="cov7" title="21">return s</span> + <span class="cov6" title="19">return s</span> } </pre> - <pre class="file" id="file25" style="display: none">// Summary: LSP JSON-RPC handlers; implements core methods and integrates with the LLM client when enabled. + <pre class="file" id="file26" style="display: none">// Summary: LSP JSON-RPC handlers; implements core methods and integrates with the LLM client when enabled. package lsp import ( @@ -4113,9 +5313,9 @@ 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="cov5" title="5">{ +func (s *Server) instructionFromSelection(sel string) (string, string) <span class="cov4" title="5">{ lines := splitLines(sel) - for idx, line := range lines </span><span class="cov5" title="5">{ + 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">{ lines[idx] = cleaned return instr, strings.Join(lines, "\n") @@ -4133,17 +5333,17 @@ 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="cov9" title="24">{ +func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) <span class="cov8" title="24">{ type cand struct { start, end int text string } cands := []cand{} - _, _, openChar, closeChar := s.inlineMarkers() - if t, l, r, ok := findStrictInlineTag(line, openChar, closeChar); ok </span><span class="cov5" title="6">{ + openStr, _, openChar, closeChar := s.inlineMarkers() + if t, l, r, ok := findStrictInlineTag(line, openStr, openChar, closeChar); ok </span><span class="cov5" title="6">{ cands = append(cands, cand{start: l, end: r, text: t}) }</span> - <span class="cov9" title="24">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov2" title="2">{ + <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">{ start := i end := i + 2 + j + 2 @@ -4151,7 +5351,7 @@ func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned cands = append(cands, cand{start: start, end: end, text: text}) }</span> } - <span class="cov9" title="24">if i := strings.Index(line, "<!--"); i >= 0 </span><span class="cov2" title="2">{ + <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">{ start := i end := i + 4 + j + 3 @@ -4159,26 +5359,26 @@ func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned cands = append(cands, cand{start: start, end: end, text: text}) }</span> } - <span class="cov9" title="24">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov4" title="4">{ + <span class="cov8" title="24">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov4" title="4">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }</span> - <span class="cov9" title="24">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov2" title="2">{ + <span class="cov8" title="24">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov2" title="2">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) }</span> - <span class="cov9" title="24">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov4" title="4">{ + <span class="cov8" title="24">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov4" title="4">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }</span> - <span class="cov9" title="24">if len(cands) == 0 </span><span class="cov6" title="8">{ + <span class="cov8" title="24">if len(cands) == 0 </span><span class="cov5" title="8">{ return "", line, false }</span> // pick earliest start index - <span class="cov8" title="16">best := cands[0] + <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">{ best = c }</span> } - <span class="cov8" title="16">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + <span class="cov7" title="16">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") return best.text, cleaned, true</span> } @@ -4203,7 +5403,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="cov10" title="30">{ +func (s *Server) reply(id json.RawMessage, result any, err *RespError) <span class="cov8" title="31">{ resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err} s.writeMessage(resp) }</span> @@ -4277,33 +5477,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="cov8" title="15">{ +func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string <span class="cov6" title="11">{ // 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="cov8" title="15">left := strings.TrimRight(current[:idx], " \t") + <span class="cov6" title="11">left := strings.TrimRight(current[:idx], " \t") right := "" - if idx < len(current) </span><span class="cov1" title="1">{ + if idx < len(current) </span><span class="cov0" title="0">{ right = current[idx:] }</span> - <span class="cov8" title="15">prov := "" + <span class="cov6" title="11">prov := "" model := "" - if client := s.currentLLMClient(); client != nil </span><span class="cov8" title="15">{ + if client := s.currentLLMClient(); client != nil </span><span class="cov6" title="11">{ prov = client.Name() model = client.DefaultModel() }</span> - <span class="cov8" title="15">temp := "" + <span class="cov6" title="11">temp := "" if tempPtr := s.codingTemperature(); tempPtr != nil </span><span class="cov0" title="0">{ temp = fmt.Sprintf("%.3f", *tempPtr) }</span> - <span class="cov8" title="15">extra := "" + <span class="cov6" title="11">extra := "" if hasExtra </span><span class="cov0" title="0">{ extra = strings.TrimSpace(extraText) }</span> // Compose a key from essential context parts - <span class="cov8" title="15">return strings.Join([]string{ + <span class="cov6" title="11">return strings.Join([]string{ "v1", // version for future-proofing prov, model, @@ -4320,11 +5520,11 @@ func (s *Server) completionCacheKey(p CompletionParams, above, current, below, f }, "\x1f")</span> // use unit separator to avoid collisions } -func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov7" title="11">{ +func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov6" title="11">{ s.mu.Lock() defer s.mu.Unlock() v, ok := s.compCache[key] - if !ok </span><span class="cov7" title="10">{ + if !ok </span><span class="cov6" title="10">{ return "", false }</span> // move to most-recent @@ -4332,13 +5532,13 @@ func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov7 return v, true</span> } -func (s *Server) completionCachePut(key, value string) <span class="cov7" title="13">{ +func (s *Server) completionCachePut(key, value string) <span class="cov6" title="13">{ s.mu.Lock() defer s.mu.Unlock() - if s.compCache == nil </span><span class="cov5" title="5">{ + if s.compCache == nil </span><span class="cov4" title="5">{ s.compCache = make(map[string]string) }</span> - <span class="cov7" title="13">if _, exists := s.compCache[key]; !exists </span><span class="cov7" title="13">{ + <span class="cov6" title="13">if _, exists := s.compCache[key]; !exists </span><span class="cov6" title="13">{ s.compCacheOrder = append(s.compCacheOrder, key) s.compCache[key] = value if len(s.compCacheOrder) > 10 </span><span class="cov0" title="0">{ @@ -4347,7 +5547,7 @@ func (s *Server) completionCachePut(key, value string) <span class="cov7" title= s.compCacheOrder = s.compCacheOrder[1:] delete(s.compCache, old) }</span> - <span class="cov7" title="13">return</span> + <span class="cov6" title="13">return</span> } // update existing and mark most-recent <span class="cov0" title="0">s.compCache[key] = value @@ -4374,28 +5574,29 @@ func (s *Server) compCacheTouchLocked(key string) <span class="cov1" title="1">{ // 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="cov9" title="25">{ +func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span class="cov8" title="25">{ 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="cov7" title="11">{ + if p.Context != nil </span><span class="cov6" title="11">{ var ctx struct { TriggerKind int `json:"triggerKind"` TriggerCharacter string `json:"triggerCharacter,omitempty"` } - if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov7" title="10">{ + if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov6" title="10">{ _ = json.Unmarshal(raw, &ctx) }</span> else<span class="cov1" title="1"> { 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>'), + // If configured and the line contains a bare double-open marker (e.g., '>>!' with no '>>!text>'), // do not treat as a trigger source. - <span class="cov7" title="11">if open != "" && strings.Contains(current, open+open) && !hasDoubleOpenTrigger(current, openChar, closeChar) </span><span class="cov2" title="2">{ + <span class="cov6" title="11">if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) </span><span class="cov2" title="2">{ return false }</span> // TriggerKind 1 = Invoked (manual). Always allow manual invoke. - <span class="cov6" title="9">if ctx.TriggerKind == 1 </span><span class="cov5" title="5">{ + <span class="cov6" title="9">if ctx.TriggerKind == 1 </span><span class="cov4" title="5">{ return true }</span> // TriggerKind 2 is TriggerCharacter per LSP spec @@ -4414,16 +5615,16 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span c // For TriggerForIncomplete (3), require manual char check below } // 2) Fallback: check the character immediately prior to cursor - <span class="cov8" title="15">idx := p.Position.Character + <span class="cov7" title="15">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="cov8" title="15">if open != "" && strings.Contains(current, open+open) && !hasDoubleOpenTrigger(current, openChar, closeChar) </span><span class="cov3" title="3">{ + <span class="cov7" title="15">if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) </span><span class="cov3" title="3">{ return false }</span> - <span class="cov7" title="12">ch := string(current[idx-1]) - for _, c := range triggerChars </span><span class="cov9" title="28">{ + <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">{ return true }</span> @@ -4431,13 +5632,15 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span c <span class="cov5" title="6">return false</span> } -func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem <span class="cov7" title="14">{ +func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string, detail string, sortPrefix string) []CompletionItem <span class="cov7" title="14">{ te, filter := computeTextEditAndFilter(cleaned, inParams, current, p) rm := s.collectPromptRemovalEdits(p.TextDocument.URI) label := labelForCompletion(cleaned, filter) - detail := "Hexai LLM completion" - if client := s.currentLLMClient(); client != nil </span><span class="cov7" title="14">{ - detail = "Hexai " + client.Name() + ":" + client.DefaultModel() + 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">{ + sortPrefix = "0000" }</span> <span class="cov7" title="14">return []CompletionItem{{ Label: label, @@ -4447,11 +5650,23 @@ func (s *Server) makeCompletionItems(cleaned string, inParams bool, current stri FilterText: strings.TrimLeft(filter, " \t"), TextEdit: te, AdditionalTextEdits: rm, - SortText: "0000", + SortText: sortPrefix, Documentation: docStr, }}</span> } +func containsAny(haystack string, seqs []string) bool <span class="cov8" title="26">{ + for _, seq := range seqs </span><span class="cov10" title="51">{ + 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">{ + return true + }</span> + } + <span class="cov7" title="21">return false</span> +} + // small helpers to keep tryLLMCompletion short // LLM stats helpers moved to handlers_utils.go @@ -4536,7 +5751,7 @@ func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem <span c }</span> </pre> - <pre class="file" id="file26" style="display: none">// Summary: Code Action handlers and helpers split from handlers.go for clarity. + <pre class="file" id="file27" style="display: none">// Summary: Code Action handlers and helpers split from handlers.go for clarity. package lsp import ( @@ -4783,8 +5998,8 @@ func (s *Server) completeCodeAction(ca CodeAction, uri string, rng Range, sys, u ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov6" title="15">{ + 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">{ edit := WorkspaceEdit{Changes: map[string][]TextEdit{uri: {{Range: rng, NewText: out}}}} ca.Edit = &edit @@ -4991,7 +6206,7 @@ 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="cov2" title="2">{ + if idx > 0 </span><span class="cov1" title="1">{ before := pre[:idx] startLine = strings.Count(before, "\n") }</span> @@ -5093,40 +6308,38 @@ func findGoFunctionAtLine(lines []string, idx int) (int, int) <span class="cov3" // generateGoTestFunction uses LLM to produce a test function; falls back to a stub when unavailable. func (s *Server) generateGoTestFunction(funcCode string) string <span class="cov3" title="4">{ - if client := s.currentLLMClient(); client != nil </span><span class="cov2" title="2">{ - cfg := s.currentConfig() - sys := cfg.PromptCodeActionGoTestSystem - user := renderTemplate(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode}) - ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if out, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov2" title="2">{ - cleaned := strings.TrimSpace(stripCodeFences(out)) - if cleaned != "" </span><span class="cov2" title="2">{ - return cleaned - }</span> - } else<span class="cov0" title="0"> { - logging.Logf("lsp ", "codeAction go_test llm error: %v", err) + spec := s.buildRequestSpec(surfaceCodeAction) + cfg := s.currentConfig() + sys := cfg.PromptCodeActionGoTestSystem + user := renderTemplate(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode}) + ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + if out, err := s.chatWithStats(ctx, surfaceCodeAction, spec, messages); err == nil </span><span class="cov3" title="4">{ + cleaned := strings.TrimSpace(stripCodeFences(out)) + if cleaned != "" </span><span class="cov3" title="4">{ + return cleaned }</span> - } + } else<span class="cov0" title="0"> { + logging.Logf("lsp ", "codeAction go_test llm error: %v", err) + }</span> // Fallback stub - <span class="cov2" title="2">name := deriveGoFuncName(funcCode) + <span class="cov0" title="0">name := deriveGoFuncName(funcCode) if name == "" </span><span class="cov0" title="0">{ name = "Function" }</span> - <span class="cov2" title="2">return fmt.Sprintf("func Test%s(t *testing.T) {\n\t// TODO: implement tests for %s\n}\n", exportName(name), name)</span> + <span class="cov0" title="0">return fmt.Sprintf("func Test%s(t *testing.T) {\n\t// TODO: implement tests for %s\n}\n", exportName(name), name)</span> } // deriveGoFuncName extracts function or method name from code. -func deriveGoFuncName(code string) string <span class="cov3" title="4">{ +func deriveGoFuncName(code string) string <span class="cov2" title="2">{ // 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="cov3" title="4">rest := strings.TrimSpace(strings.TrimPrefix(line, "func ")) + <span class="cov2" title="2">rest := strings.TrimSpace(strings.TrimPrefix(line, "func ")) // method receiver if strings.HasPrefix(rest, "(") </span><span class="cov1" title="1">{ // find ")" @@ -5135,25 +6348,25 @@ func deriveGoFuncName(code string) string <span class="cov3" title="4">{ }</span> } // now rest should start with Name( - <span class="cov3" title="4">if i := strings.Index(rest, "("); i > 0 </span><span class="cov3" title="4">{ + <span class="cov2" title="2">if i := strings.Index(rest, "("); i > 0 </span><span class="cov2" title="2">{ return strings.TrimSpace(rest[:i]) }</span> <span class="cov0" title="0">return ""</span> } -func exportName(name string) string <span class="cov2" title="2">{ +func exportName(name string) string <span class="cov0" title="0">{ if name == "" </span><span class="cov0" title="0">{ return name }</span> - <span class="cov2" title="2">r := []rune(name) + <span class="cov0" title="0">r := []rune(name) if r[0] >= 'a' && r[0] <= 'z' </span><span class="cov0" title="0">{ r[0] = r[0] - ('a' - 'A') }</span> - <span class="cov2" title="2">return string(r)</span> + <span class="cov0" title="0">return string(r)</span> } </pre> - <pre class="file" id="file27" style="display: none">// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic. + <pre class="file" id="file28" style="display: none">// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic. package lsp import ( @@ -5161,6 +6374,7 @@ import ( "encoding/json" "fmt" "strings" + "sync" "time" "codeberg.org/snonux/hexai/internal/llm" @@ -5183,8 +6397,12 @@ type completionPlan struct { cacheKey string } -func (s *Server) handleCompletion(req Request) <span class="cov2" title="2">{ - var p CompletionParams +func (s *Server) handleCompletion(req Request) <span class="cov3" title="3">{ + if s.completionDisabled() </span><span class="cov1" title="1">{ + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: nil}, nil) + return + }</span> + <span class="cov2" title="2">var p CompletionParams var docStr string if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov2" title="2">{ // Log trigger information for every completion request from client @@ -5199,9 +6417,9 @@ func (s *Server) handleCompletion(req Request) <span class="cov2" title="2">{ <span class="cov2" title="2">if s.llmClient != nil </span><span class="cov2" title="2">{ newFunc := s.isDefiningNewFunction(p.TextDocument.URI, p.Position) extra, has := s.buildAdditionalContext(newFunc, p.TextDocument.URI, p.Position) - items, ok := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, has, extra) + items, ok, incomplete := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, has, extra) if ok </span><span class="cov2" title="2">{ - s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil) + s.reply(req.ID, CompletionList{IsIncomplete: incomplete, Items: items}, nil) return }</span> } @@ -5241,20 +6459,101 @@ func (s *Server) logCompletionContext(p CompletionParams, above, current, below, 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) <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="19">{ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) - defer cancel() + var cancelOnce sync.Once + end := func() </span><span class="cov8" title="19">{ 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">{ + end() + return items, true, false + }</span> + <span class="cov6" title="11">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 { + items []CompletionItem + ok bool + } + results := make(chan jobResult, len(specs)) + var wg sync.WaitGroup + started := 0 + s.waitForDebounce(ctx) + if !s.waitForThrottle(ctx) </span><span class="cov0" title="0">{ + end() + close(results) + return nil, false, false + }</span> + <span class="cov6" title="11">for _, spec := range specs </span><span class="cov6" title="11">{ + spec := spec + client := s.clientFor(spec) + if client == nil </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov6" title="11">started++ + wg.Add(1) + go func(idx int, spec requestSpec, client llm.Client) </span><span class="cov6" title="11">{ + defer wg.Done() + items, ok := s.runCompletionForSpec(ctx, plan, spec, client) + results <- jobResult{items: items, ok: ok} + }</span>(spec.index, spec, client) + } - plan, items, handled := s.prepareCompletionPlan(p, above, current, below, funcCtx, docStr, hasExtra, extraText) - if handled </span><span class="cov6" title="9">{ - return items, true + <span class="cov6" title="11">if started == 0 </span><span class="cov0" title="0">{ + end() + close(results) + return nil, false, false }</span> - <span class="cov6" title="10">if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, plan.inParams); ok </span><span class="cov1" title="1">{ - return items, true - }</span> + <span class="cov6" title="11">go func() </span><span class="cov6" title="11">{ + wg.Wait() + close(results) + }</span>() + + <span class="cov6" title="11">if started == 1 </span><span class="cov6" title="11">{ + 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() + return res.items, true, false</span> + } - <span class="cov6" title="9">return s.executeChatCompletion(ctx, plan)</span> + <span class="cov0" title="0">firstCh := make(chan []CompletionItem, 1) + go func(planKey string) </span><span class="cov0" title="0">{ + defer end() + combined := make([]CompletionItem, 0) + firstSent := false + 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...) + if !firstSent </span><span class="cov0" title="0">{ + first := make([]CompletionItem, len(res.items)) + copy(first, res.items) + firstCh <- first + firstSent = true + }</span> + } + <span class="cov0" title="0">if !firstSent </span><span class="cov0" title="0">{ + close(firstCh) + return + }</span> + <span class="cov0" title="0">s.storePendingCompletion(planKey, combined) + close(firstCh)</span> + }(plan.cacheKey) + + <span class="cov0" title="0">firstItems, ok := <-firstCh + if !ok || len(firstItems) == 0 </span><span class="cov0" title="0">{ + end() + return nil, false, false + }</span> + <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">{ @@ -5268,8 +6567,8 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below hasExtra: hasExtra, extraText: extraText, } - _, _, openChar, closeChar := s.inlineMarkers() - plan.inlinePrompt = lineHasInlinePrompt(current, openChar, closeChar) + 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">{ 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 @@ -5280,60 +6579,77 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below <span class="cov6" title="11">plan.inParams = inParamList(current, p.Position.Character) plan.manualInvoke = parseManualInvoke(p.Context) plan.cacheKey = s.completionCacheKey(p, above, current, below, funcCtx, plan.inParams, hasExtra, extraText) - if cleaned, ok := s.completionCacheGet(plan.cacheKey); ok && strings.TrimSpace(cleaned) != "" </span><span class="cov1" title="1">{ - logging.Logf("lsp ", "completion cache hit uri=%s line=%d char=%d preview=%s%s%s", - p.TextDocument.URI, p.Position.Line, p.Position.Character, - logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase) - return plan, s.makeCompletionItems(cleaned, plan.inParams, current, p, docStr), true + if pending := s.takePendingCompletion(plan.cacheKey); len(pending) > 0 </span><span class="cov0" title="0">{ + return plan, pending, true }</span> - <span class="cov6" title="10">if isBareDoubleOpen(current, openChar, closeChar) || isBareDoubleOpen(below, openChar, closeChar) </span><span class="cov0" title="0">{ + <span class="cov6" title="11">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="10">if !plan.inParams && !s.prefixHeuristicAllows(plan.inlinePrompt, current, p, plan.manualInvoke) </span><span class="cov0" title="0">{ + <span class="cov6" title="11">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="10">return plan, nil, false</span> + <span class="cov6" title="11">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">{ + 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">{ + 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) + detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelKey) + 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">{ + return items, true + }</span> + <span class="cov6" title="9">return s.executeChatCompletion(ctx, plan, spec, client, sortPrefix)</span> } -func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan) ([]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="cov6" title="9">{ messages := s.buildCompletionMessages(plan.inlinePrompt, plan.hasExtra, plan.extraText, plan.inParams, plan.params, plan.above, plan.current, plan.below, plan.funcCtx) sentSize := 0 - for _, m := range messages </span><span class="cov7" title="18">{ + for _, m := range messages </span><span class="cov8" title="18">{ sentSize += len(m.Content) }</span> <span class="cov6" title="9">s.incSentCounters(sentSize) - opts := s.llmRequestOpts() - s.waitForDebounce(ctx) - if !s.waitForThrottle(ctx) </span><span class="cov0" title="0">{ - return nil, false - }</span> - <span class="cov6" title="9">client := s.currentLLMClient() - if client == nil </span><span class="cov0" title="0">{ - return nil, false - }</span> - <span class="cov6" title="9">logging.Logf("lsp ", "completion llm=requesting model=%s", client.DefaultModel()) - text, err := client.Chat(ctx, messages, opts...) + 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() + s.logLLMStats("") return nil, false }</span> <span class="cov6" title="9">s.incRecvCounters(len(text)) - s.logLLMStats() + modelUsed := spec.effectiveModel(client.DefaultModel()) + _ = stats.Update(ctx, client.Name(), modelUsed, sentSize, len(text)) + s.logLLMStats(modelUsed) trimmed := strings.TrimSpace(text) cleaned := s.postProcessCompletion(trimmed, plan.current[:plan.params.Position.Character], plan.current) if cleaned == "" </span><span class="cov0" title="0">{ return nil, false }</span> - <span class="cov6" title="9">s.completionCachePut(plan.cacheKey, cleaned) - items := s.makeCompletionItems(cleaned, plan.inParams, plan.current, plan.params, plan.docStr) + <span class="cov6" title="9">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 + 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="cov6" title="12">{ +func parseManualInvoke(ctx any) bool <span class="cov7" title="12">{ if ctx == nil </span><span class="cov4" title="5">{ return false }</span> @@ -5372,121 +6688,120 @@ func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionPar } // 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="15">{ +func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p CompletionParams, manualInvoke bool) bool <span class="cov7" title="16">{ // 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="15">allowNoPrefix := inlinePrompt - if idx > 0 </span><span class="cov7" title="13">{ + <span class="cov7" title="16">allowNoPrefix := inlinePrompt + if idx > 0 </span><span class="cov7" title="14">{ ch := current[idx-1] if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' </span><span class="cov4" title="5">{ allowNoPrefix = true }</span> } - <span class="cov7" title="15">if allowNoPrefix </span><span class="cov6" title="8">{ + <span class="cov7" title="16">if allowNoPrefix </span><span class="cov6" title="8">{ return true }</span> // Walk left over whitespace - <span class="cov5" title="7">j := idx - for j > 0 </span><span class="cov7" title="13">{ + <span class="cov6" title="8">j := idx + for j > 0 </span><span class="cov9" title="27">{ c := current[j-1] - if c == ' ' || c == '\t' </span><span class="cov5" title="7">{ + if c == ' ' || c == '\t' </span><span class="cov8" title="20">{ j-- continue</span> } - <span class="cov5" title="6">break</span> + <span class="cov5" title="7">break</span> } - <span class="cov5" title="7">start := computeWordStart(current, j) + <span class="cov6" title="8">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">{ min = v }</span> } - <span class="cov5" title="7">return j-start >= min</span> + <span class="cov6" title="8">return j-start >= min</span> } // tryProviderNativeCompletion attempts provider-native completion and returns items when successful. -func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) <span class="cov7" title="13">{ - client := s.currentLLMClient() +func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completionPlan, spec requestSpec, client llm.Client, sortPrefix string) ([]CompletionItem, bool) <span class="cov7" title="13">{ cc, ok := client.(llm.CodeCompleter) if !ok </span><span class="cov5" title="7">{ return nil, false }</span> - <span class="cov5" title="6">before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) + <span class="cov5" title="6">current := plan.current + p := plan.params + before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) path := strings.TrimPrefix(p.TextDocument.URI, "file://") - // Build provider-native prompt from template cfg := s.currentConfig() - _, _, openChar, closeChar := s.inlineMarkers() + openStr, _, openChar, closeChar := s.inlineMarkers() prompt := renderTemplate(cfg.PromptNativeCompletion, map[string]string{ "path": path, "before": before, }) - lang := "" - temp := 0.0 - if cfg.CodingTemperature != nil </span><span class="cov0" title="0">{ - temp = *cfg.CodingTemperature + provider := spec.provider + if provider == "" </span><span class="cov0" title="0">{ + provider = canonicalProvider(cfg.Provider) }</span> - <span class="cov5" title="6">prov := "" - if client != nil </span><span class="cov5" title="6">{ - prov = client.Name() - }</span> - <span class="cov5" title="6">logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) - ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second) + <span class="cov5" title="6">logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", provider, path) + ctx2, cancel2 := context.WithTimeout(ctx, 15*time.Second) defer cancel2() - - // Debounce and throttle prior to provider-native call - s.waitForDebounce(ctx2) - if !s.waitForThrottle(ctx2) </span><span class="cov0" title="0">{ + sentBytes := len(prompt) + len(after) + modelUsed := spec.effectiveModel(client.DefaultModel()) + tempVal := 0.0 + 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">{ + 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="4">s.incSentCounters(sentBytes) + s.incRecvCounters(len(suggestions[0])) + _ = stats.Update(ctx2, client.Name(), modelUsed, sentBytes, len(suggestions[0])) + s.logLLMStats(modelUsed) + cleaned := strings.TrimSpace(suggestions[0]) + if cleaned == "" </span><span class="cov0" title="0">{ return nil, false }</span> - // Count approximate payload sizes: prompt+after sent; first suggestion received - <span class="cov5" title="6">sentBytes := len(prompt) + len(after) - suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) - if err == nil && len(suggestions) > 0 </span><span class="cov4" title="4">{ - // Update counters and heartbeat - s.incSentCounters(sentBytes) - s.incRecvCounters(len(suggestions[0])) - // Contribute to global stats (provider-native path) - if client != nil </span><span class="cov4" title="4">{ - _ = stats.Update(ctx2, client.Name(), client.DefaultModel(), sentBytes, len(suggestions[0])) - }</span> - <span class="cov4" title="4">s.logLLMStats() - cleaned := strings.TrimSpace(suggestions[0]) - if cleaned != "" </span><span class="cov4" title="4">{ - cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) - if cleaned != "" </span><span class="cov4" title="4">{ - cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) - }</span> - <span class="cov4" title="4">if cleaned != "" && hasDoubleOpenTrigger(current, openChar, closeChar) </span><span class="cov1" title="1">{ - indent := leadingIndent(current) - if indent != "" </span><span class="cov1" title="1">{ - cleaned = applyIndent(indent, cleaned) - }</span> - } - <span class="cov4" title="4">if strings.TrimSpace(cleaned) != "" </span><span class="cov4" title="4">{ - key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) - s.completionCachePut(key, cleaned) - return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true - }</span> - } - } else<span class="cov2" title="2"> if err != nil </span><span class="cov2" title="2">{ - logging.Logf("lsp ", "completion path=codex error=%v (falling back to chat)", err) - // Still emit a heartbeat for visibility, even on error - s.incSentCounters(sentBytes) - s.logLLMStats() + <span class="cov4" title="4">cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) + if cleaned == "" </span><span class="cov0" title="0">{ + return nil, false }</span> - <span class="cov2" title="2">return nil, false</span> + <span class="cov4" title="4">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">{ + indent := leadingIndent(current) + if indent != "" </span><span class="cov1" title="1">{ + cleaned = applyIndent(indent, cleaned) + }</span> + } + <span class="cov4" title="4">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) + 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 + s.completionCachePut(cacheKey, cleaned) + items := s.makeCompletionItems(cleaned, plan.inParams, current, p, plan.docStr, detail, sortPrefix) + return items, true</span> } // waitForDebounce sleeps until there has been no input activity for at least // completionDebounce. If debounce is zero or ctx is done, it returns promptly. -func (s *Server) waitForDebounce(ctx context.Context) <span class="cov10" title="42">{ +func (s *Server) waitForDebounce(ctx context.Context) <span class="cov10" title="40">{ d := s.completionDebounce() - if d <= 0 </span><span class="cov9" title="40">{ + if d <= 0 </span><span class="cov9" title="38">{ return }</span> <span class="cov2" title="2">for </span><span class="cov4" title="4">{ @@ -5514,9 +6829,9 @@ func (s *Server) waitForDebounce(ctx context.Context) <span class="cov10" title= // waitForThrottle enforces a minimum spacing between LLM calls. Returns false // if the context is canceled while waiting. -func (s *Server) waitForThrottle(ctx context.Context) bool <span class="cov10" title="42">{ +func (s *Server) waitForThrottle(ctx context.Context) bool <span class="cov10" title="40">{ interval := s.completionThrottle() - if interval <= 0 </span><span class="cov9" title="39">{ + if interval <= 0 </span><span class="cov9" title="37">{ return true }</span> <span class="cov3" title="3">var wait time.Duration @@ -5577,30 +6892,30 @@ func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText } // postProcessCompletion normalizes and deduplicates completion text and applies indentation rules. -func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string <span class="cov6" title="12">{ +func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string <span class="cov7" title="12">{ cleaned := stripCodeFences(text) if cleaned != "" && strings.ContainsRune(cleaned, '`') </span><span class="cov0" title="0">{ if inline := stripInlineCodeSpan(cleaned); strings.TrimSpace(inline) != "" </span><span class="cov0" title="0">{ cleaned = inline }</span> } - <span class="cov6" title="12">if cleaned != "" </span><span class="cov6" title="12">{ + <span class="cov7" title="12">if cleaned != "" </span><span class="cov7" title="12">{ cleaned = stripDuplicateAssignmentPrefix(leftOfCursor, cleaned) }</span> - <span class="cov6" title="12">if cleaned != "" </span><span class="cov6" title="12">{ + <span class="cov7" title="12">if cleaned != "" </span><span class="cov7" title="12">{ cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) }</span> - <span class="cov6" title="12">_, _, openChar, closeChar := s.inlineMarkers() - if cleaned != "" && hasDoubleOpenTrigger(currentLine, openChar, closeChar) </span><span class="cov2" title="2">{ + <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">{ cleaned = applyIndent(indent, cleaned) }</span> } - <span class="cov6" title="12">return cleaned</span> + <span class="cov7" title="12">return cleaned</span> } </pre> - <pre class="file" id="file28" style="display: none">// Summary: Document open/change/close and in-editor chat handlers split out of handlers.go. + <pre class="file" id="file29" style="display: none">// Summary: Document open/change/close and in-editor chat handlers split out of handlers.go. package lsp import ( @@ -5693,9 +7008,9 @@ func (s *Server) detectAndHandleChat(uri string) <span class="cov7" title="11">{ return }</span> <span class="cov7" title="11">suffix, prefixes, _ := s.chatConfig() - _, _, openChar, closeChar := s.inlineMarkers() - for i, raw := range d.lines </span><span class="cov10" title="23">{ - if lineHasInlinePrompt(raw, openChar, closeChar) </span><span class="cov0" title="0">{ + openStr, _, openChar, closeChar := s.inlineMarkers() + for i, raw := range d.lines </span><span class="cov9" title="23">{ + 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)} go s.runInlinePrompt(uri, pos) @@ -5703,7 +7018,7 @@ 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="cov10" title="23">j := len(raw) - 1 + <span class="cov9" title="23">j := len(raw) - 1 for j >= 0 </span><span class="cov9" title="20">{ if raw[j] == ' ' || raw[j] == '\t' </span><span class="cov0" title="0">{ j-- @@ -5711,7 +7026,7 @@ func (s *Server) detectAndHandleChat(uri string) <span class="cov7" title="11">{ } <span class="cov9" title="20">break</span> } - <span class="cov10" title="23">if j < 0 </span><span class="cov4" title="3">{ + <span class="cov9" title="23">if j < 0 </span><span class="cov4" title="3">{ continue</span> } // Check suffix and derive the prompt text before validating prefixes @@ -5763,22 +7078,20 @@ func (s *Server) detectAndHandleChat(uri string) <span class="cov7" title="11">{ }</span> <span class="cov1" title="1">return</span> } - <span class="cov6" title="8">if s.currentLLMClient() == nil </span><span class="cov0" title="0">{ - continue</span> - } <span class="cov6" title="8">go func(prompt string, remove int) </span><span class="cov6" title="8">{ ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() // Build messages with history and context_mode aware extras. pos := Position{Line: lineIdx, Character: lastIdx + 1} msgs := s.buildChatMessages(uri, pos, prompt) - opts := s.llmRequestOpts() - client := s.currentLLMClient() + spec := s.buildRequestSpec(surfaceChat) + client := s.clientFor(spec) if client == nil </span><span class="cov0" title="0">{ return }</span> - <span class="cov6" title="8">logging.Logf("lsp ", "chat llm=requesting model=%s", client.DefaultModel()) - text, err := s.chatWithStats(ctx, msgs, opts...) + <span class="cov6" title="8">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 @@ -5825,8 +7138,8 @@ func (s *Server) runInlinePrompt(uri string, pos Position) <span class="cov0" ti return }</span> <span class="cov0" title="0">line := d.lines[pos.Line] - _, _, openChar, closeChar := s.inlineMarkers() - if !lineHasInlinePrompt(line, openChar, closeChar) </span><span class="cov0" title="0">{ + openStr, _, openChar, closeChar := s.inlineMarkers() + if !lineHasInlinePrompt(line, openStr, openChar, closeChar) </span><span class="cov0" title="0">{ return }</span> <span class="cov0" title="0">p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Position: Position{Line: pos.Line, Character: len(line)}} @@ -5835,7 +7148,7 @@ func (s *Server) runInlinePrompt(uri string, pos Position) <span class="cov0" ti docStr := s.buildDocString(p, above, current, below, funcCtx) newFunc := s.isDefiningNewFunction(uri, p.Position) extra, hasExtra := s.buildAdditionalContext(newFunc, uri, p.Position) - items, ok := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, hasExtra, extra) + items, ok, _ := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, hasExtra, extra) if !ok || len(items) == 0 </span><span class="cov0" title="0">{ return }</span> @@ -5912,25 +7225,25 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) } // stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. -func (s *Server) stripTrailingTrigger(sx string) string <span class="cov9" title="17">{ +func (s *Server) stripTrailingTrigger(sx string) string <span class="cov9" title="21">{ trim := strings.TrimRight(sx, " \t") if len(trim) == 0 </span><span class="cov0" title="0">{ return sx }</span> - <span class="cov9" title="17">_, prefixes, suffixChar := s.chatConfig() - if len(trim) >= 2 && suffixChar != 0 && trim[len(trim)-1] == suffixChar </span><span class="cov5" title="5">{ + <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">{ prev := string(trim[len(trim)-2]) - for _, pf := range prefixes </span><span class="cov7" title="11">{ + for _, pf := range prefixes </span><span class="cov10" title="27">{ if prev == pf </span><span class="cov5" title="5">{ return strings.TrimRight(trim[:len(trim)-1], " \t") }</span> } } - <span class="cov8" title="12">last := trim[len(trim)-1] + <span class="cov8" title="16">last := trim[len(trim)-1] switch last </span>{ case '?', '!', ':':<span class="cov6" title="8"> return strings.TrimRight(trim[:len(trim)-1], " \t")</span> - default:<span class="cov4" title="4"> + default:<span class="cov6" title="8"> return sx</span> } } @@ -5974,7 +7287,7 @@ func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) <span class=" }</span> // nextReqID returns a unique json.RawMessage id for server-initiated requests. -func (s *Server) nextReqID() json.RawMessage <span class="cov8" title="12">{ +func (s *Server) nextReqID() json.RawMessage <span class="cov7" title="12">{ s.mu.Lock() s.nextID++ idNum := s.nextID @@ -6011,7 +7324,7 @@ func (s *Server) deferShowDocument(uri string, sel Range) <span class="cov1" tit } </pre> - <pre class="file" id="file29" style="display: none">// Summary: ExecuteCommand handler to support post-edit navigation (jump to generated test). + <pre class="file" id="file30" style="display: none">// Summary: ExecuteCommand handler to support post-edit navigation (jump to generated test). package lsp import ( @@ -6047,7 +7360,7 @@ func (s *Server) handleExecuteCommand(req Request) <span class="cov8" title="1"> } </pre> - <pre class="file" id="file30" style="display: none">// Summary: Initialization and lifecycle handlers split from handlers.go. + <pre class="file" id="file31" style="display: none">// Summary: Initialization and lifecycle handlers split from handlers.go. package lsp import ( @@ -6096,7 +7409,7 @@ func (s *Server) handleExit() <span class="cov0" title="0">{ }</span> </pre> - <pre class="file" id="file31" style="display: none">// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters). + <pre class="file" id="file32" style="display: none">// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters). package lsp import ( @@ -6105,6 +7418,7 @@ import ( "strings" "time" + "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/logging" "codeberg.org/snonux/hexai/internal/stats" @@ -6112,24 +7426,146 @@ import ( tmx "codeberg.org/snonux/hexai/internal/tmux" ) -// llmRequestOpts builds request options from server settings. -func (s *Server) llmRequestOpts() []llm.RequestOption <span class="cov7" title="36">{ - maxTokens := s.maxTokens() - client := s.currentLLMClient() - tempPtr := s.codingTemperature() - opts := []llm.RequestOption{llm.WithMaxTokens(maxTokens)} - if tempPtr != nil </span><span class="cov1" title="1">{ - temp := *tempPtr - if client != nil </span><span class="cov1" title="1">{ - prov := strings.ToLower(strings.TrimSpace(client.Name())) - model := strings.ToLower(strings.TrimSpace(client.DefaultModel())) - if prov == "openai" && strings.HasPrefix(model, "gpt-5") </span><span class="cov1" title="1">{ - temp = 1.0 - }</span> +type surfaceKind string + +const ( + surfaceCompletion surfaceKind = "completion" + surfaceCodeAction surfaceKind = "code_action" + surfaceChat surfaceKind = "chat" +) + +type requestSpec struct { + provider string + entry appconfig.SurfaceConfig + fallbackModel string + options []llm.RequestOption + 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">{ + 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">{ + return f + }</span> + <span class="cov7" title="62">return strings.TrimSpace(defaultModel)</span> +} + +func (s *Server) buildRequestSpecs(surface surfaceKind) []requestSpec <span class="cov7" title="44">{ + cfg := s.currentConfig() + entries := surfaceConfigsFor(cfg, surface) + if len(entries) == 0 </span><span class="cov7" title="43">{ + entries = []appconfig.SurfaceConfig{{Provider: cfg.Provider}} + }</span> + <span class="cov7" title="44">maxTokens := s.maxTokens() + specs := make([]requestSpec, 0, len(entries)) + for idx, raw := range entries </span><span class="cov7" title="45">{ + entry := appconfig.SurfaceConfig{ + Provider: strings.TrimSpace(raw.Provider), + Model: strings.TrimSpace(raw.Model), + Temperature: raw.Temperature, } - <span class="cov1" title="1">opts = append(opts, llm.WithTemperature(temp))</span> + provider := entry.Provider + if provider == "" </span><span class="cov7" title="43">{ + provider = cfg.Provider + }</span> + <span class="cov7" title="45">provider = canonicalProvider(provider) + fallbackModel := entry.Model + if fallbackModel == "" </span><span class="cov7" title="43">{ + 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">{ + 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">{ + opts = append(opts, llm.WithTemperature(temp)) + }</span> + <span class="cov7" title="45">specs = append(specs, requestSpec{ + provider: provider, + entry: entry, + fallbackModel: fallbackModel, + options: opts, + index: idx, + })</span> + } + <span class="cov7" title="44">return specs</span> +} + +func (s *Server) primaryRequestSpec(surface surfaceKind) requestSpec <span class="cov6" title="32">{ + specs := s.buildRequestSpecs(surface) + if len(specs) == 0 </span><span class="cov0" title="0">{ + cfg := s.currentConfig() + provider := canonicalProvider(cfg.Provider) + 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> +} + +// buildRequestSpec is retained for consumers expecting a single-entry helper. +func (s *Server) buildRequestSpec(surface surfaceKind) requestSpec <span class="cov6" title="32">{ + return s.primaryRequestSpec(surface) +}</span> + +func canonicalProvider(name string) string <span class="cov9" title="217">{ + p := strings.ToLower(strings.TrimSpace(name)) + if p == "" </span><span class="cov9" title="163">{ + return "openai" + }</span> + <span class="cov7" title="54">return p</span> +} + +func resolveDefaultModel(cfg appconfig.App, provider string) string <span class="cov7" title="43">{ + 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"> + return strings.TrimSpace(cfg.OpenAIModel)</span> + } +} + +func surfaceConfigsFor(cfg appconfig.App, surface surfaceKind) []appconfig.SurfaceConfig <span class="cov7" title="44">{ + switch surface </span>{ + case surfaceCompletion:<span class="cov5" title="16"> + return cfg.CompletionConfigs</span> + case surfaceCodeAction:<span class="cov5" title="20"> + return cfg.CodeActionConfigs</span> + case surfaceChat:<span class="cov4" title="8"> + return cfg.ChatConfigs</span> + default:<span class="cov0" title="0"> + return nil</span> } - <span class="cov7" title="36">return opts</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">{ + return *entry.Temperature, true + }</span> + <span class="cov7" title="50">if cfg.CodingTemperature != nil </span><span class="cov1" title="1">{ + temp := *cfg.CodingTemperature + effectiveModel := strings.TrimSpace(entry.Model) + if effectiveModel == "" </span><span class="cov1" title="1">{ + 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">{ + temp = 1.0 + }</span> + <span class="cov1" title="1">return temp, true</span> + } + <span class="cov7" title="49">effectiveModel := strings.TrimSpace(entry.Model) + if effectiveModel == "" </span><span class="cov7" title="48">{ + 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">{ + return 1.0, true + }</span> + <span class="cov7" title="49">return 0, false</span> } // small helpers for LLM traffic stats @@ -6140,21 +7576,21 @@ func (s *Server) incSentCounters(n int) <span class="cov7" title="42">{ s.mu.Unlock() }</span> -func (s *Server) incRecvCounters(n int) <span class="cov7" title="39">{ +func (s *Server) incRecvCounters(n int) <span class="cov7" title="41">{ s.mu.Lock() s.llmRespTotal++ s.llmRespBytesTotal += int64(n) s.mu.Unlock() }</span> -func (s *Server) logLLMStats() <span class="cov7" title="42">{ +func (s *Server) logLLMStats(model string) <span class="cov7" title="42">{ s.mu.RLock() avgSent := int64(0) if s.llmReqTotal > 0 </span><span class="cov7" title="42">{ avgSent = s.llmSentBytesTotal / s.llmReqTotal }</span> <span class="cov7" title="42">avgRecv := int64(0) - if s.llmRespTotal > 0 </span><span class="cov7" title="39">{ + if s.llmRespTotal > 0 </span><span class="cov7" title="41">{ avgRecv = s.llmRespBytesTotal / s.llmRespTotal }</span> <span class="cov7" title="42">reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal @@ -6171,22 +7607,25 @@ func (s *Server) logLLMStats() <span class="cov7" title="42">{ // 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="41">{ + if client := s.currentLLMClient(); client != nil </span><span class="cov7" title="40">{ provider := client.Name() - model := client.DefaultModel() + modelName := strings.TrimSpace(model) + if modelName == "" </span><span class="cov0" title="0">{ + modelName = client.DefaultModel() + }</span> // Per-scope rpm estimated from window - scopeReqs := int64(0) - if pe, ok := snap.Providers[provider]; ok </span><span class="cov7" title="41">{ - if mc, ok2 := pe.Models[model]; ok2 </span><span class="cov7" title="40">{ + <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">{ scopeReqs = mc.Reqs }</span> } - <span class="cov7" title="41">minsWin := snap.Window.Minutes() + <span class="cov7" title="40">minsWin := snap.Window.Minutes() if minsWin <= 0 </span><span class="cov0" title="0">{ minsWin = 0.001 }</span> - <span class="cov7" title="41">scopeRPM := float64(scopeReqs) / minsWin - status := tmx.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, provider, model, scopeRPM, scopeReqs, snap.Window) + <span class="cov7" title="40">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> } } @@ -6197,15 +7636,15 @@ func inParamList(current string, cursor int) bool <span class="cov5" title="14"> if !strings.Contains(current, "func ") </span><span class="cov4" title="8">{ return false }</span> - <span class="cov4" title="6">open := strings.Index(current, "(") + <span class="cov3" title="6">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="43">{ return textutil.RenderTemplate(t, vars) }</span> +func renderTemplate(t string, vars map[string]string) string <span class="cov7" title="45">{ return textutil.RenderTemplate(t, vars) }</span> -func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) <span class="cov6" title="19">{ +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">{ open := strings.Index(current, "(") close := strings.Index(current, ")") @@ -6226,25 +7665,25 @@ func computeTextEditAndFilter(cleaned string, inParams bool, current string, p C <span class="cov2" title="3">return te, filter</span> } } - <span class="cov6" title="16">startChar := computeWordStart(current, p.Position.Character) + <span class="cov5" title="16">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="26">{ +func computeWordStart(current string, at int) int <span class="cov6" title="27">{ if at > len(current) </span><span class="cov0" title="0">{ at = len(current) }</span> - <span class="cov6" title="26">for at > 0 </span><span class="cov8" title="51">{ + <span class="cov6" title="27">for at > 0 </span><span class="cov7" title="54">{ ch := current[at-1] - if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' </span><span class="cov7" title="31">{ + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' </span><span class="cov6" title="34">{ at-- continue</span> } - <span class="cov6" title="20">break</span> + <span class="cov5" title="20">break</span> } - <span class="cov6" title="26">return at</span> + <span class="cov6" title="27">return at</span> } func isIdentChar(ch byte) bool <span class="cov6" title="26">{ @@ -6252,48 +7691,74 @@ func isIdentChar(ch byte) bool <span class="cov6" title="26">{ }</span> // chatWithStats wraps llmClient.Chat to increment counters and emit a tmux heartbeat. -func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) <span class="cov6" title="26">{ +func (s *Server) chatWithStats(ctx context.Context, surface surfaceKind, spec requestSpec, msgs []llm.Message) (string, error) <span class="cov6" title="28">{ // Count bytes sent sent := 0 - for _, m := range msgs </span><span class="cov8" title="55">{ + for _, m := range msgs </span><span class="cov7" title="59">{ sent += len(m.Content) }</span> - <span class="cov6" title="26">s.incSentCounters(sent) + <span class="cov6" title="28">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="26">client := s.currentLLMClient() + <span class="cov6" title="28">client := s.clientFor(spec) if client == nil </span><span class="cov0" title="0">{ return "", fmt.Errorf("llm client unavailable") }</span> - <span class="cov6" title="26">txt, err := client.Chat(ctx, msgs, opts...) + <span class="cov6" title="28">modelUsed := spec.effectiveModel(client.DefaultModel()) + txt, err := client.Chat(ctx, msgs, spec.options...) if err != nil </span><span class="cov1" title="1">{ - s.logLLMStats() + s.logLLMStats(modelUsed) return "", err }</span> - <span class="cov6" title="25">s.incRecvCounters(len(txt)) + <span class="cov6" title="27">s.incRecvCounters(len(txt)) // Update global stats cache - _ = stats.Update(ctx, client.Name(), client.DefaultModel(), sent, len(txt)) - s.logLLMStats() + _ = stats.Update(ctx, client.Name(), modelUsed, sent, len(txt)) + s.logLLMStats(modelUsed) return txt, nil</span> } // Inline prompt utilities -func lineHasInlinePrompt(line string, open, close byte) bool <span class="cov7" title="45">{ - if _, _, _, ok := findStrictInlineTag(line, open, close); ok </span><span class="cov3" title="5">{ +func lineHasInlinePrompt(line string, openStr string, open, close byte) bool <span class="cov7" title="45">{ + 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">{ return true }</span> - <span class="cov7" title="40">return hasDoubleOpenTrigger(line, open, close)</span> + <span class="cov7" title="40">return hasDoubleOpenTrigger(line, openStr, open, close)</span> +} + +func doubleOpenSequences(openStr string, open, close byte) []string <span class="cov10" title="226">{ + seen := make(map[string]struct{}, 2) + var seqs []string + if openStr != "" && close != 0 </span><span class="cov10" title="226">{ + seq := openStr + string(close) + if _, ok := seen[seq]; !ok </span><span class="cov10" title="226">{ + seen[seq] = struct{}{} + seqs = append(seqs, seq) + }</span> + } + <span class="cov10" title="226">if openStr != "" && open != 0 </span><span class="cov10" title="226">{ + 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">{ + seen[seq] = struct{}{} + seqs = append(seqs, seq) + }</span> + } + } + <span class="cov10" title="226">return seqs</span> } func leadingIndent(line string) string <span class="cov3" title="5">{ i := 0 for i < len(line) </span><span class="cov5" title="15">{ - if line[i] == ' ' || line[i] == '\t' </span><span class="cov5" title="10">{ + if line[i] == ' ' || line[i] == '\t' </span><span class="cov4" title="10">{ i++ continue</span> } @@ -6310,7 +7775,7 @@ func applyIndent(indent, suggestion string) string <span class="cov3" title="4"> return suggestion }</span> <span class="cov3" title="4">lines := splitLines(suggestion) - for i, ln := range lines </span><span class="cov5" title="10">{ + for i, ln := range lines </span><span class="cov4" title="10">{ if strings.TrimSpace(ln) == "" </span><span class="cov1" title="1">{ continue</span> } @@ -6324,64 +7789,96 @@ func applyIndent(indent, suggestion string) string <span class="cov3" title="4"> // --- Inline marker parsing and general string utilities --- -// findStrictInlineTag finds >text> (configurable), with no space after the first +// 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, 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="76">{ + 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">{ + return "", 0, 0, false + }</span> + <span class="cov8" title="76">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) pos := 0 - for pos < len(line) </span><span class="cov9" title="89">{ - // find opening marker - j := strings.IndexByte(line[pos:], open) - if j < 0 </span><span class="cov7" title="39">{ + for pos < len(line) </span><span class="cov8" title="90">{ + j := strings.IndexByte(line[pos:], openChar) + if j < 0 </span><span class="cov7" title="40">{ return "", 0, 0, false }</span> - <span class="cov8" title="50">j += pos - // ensure single open (not double) and non-space after - if j+1 >= len(line) || line[j+1] == open || line[j+1] == ' ' </span><span class="cov7" title="32">{ + <span class="cov7" title="50">j += pos + if !strings.HasPrefix(line[j:], openStr) </span><span class="cov6" title="26">{ pos = j + 1 continue</span> } - // find closing marker - <span class="cov6" title="18">k := strings.IndexByte(line[j+1:], close) - if k < 0 </span><span class="cov1" title="1">{ + <span class="cov6" title="24">contentStart := j + len(openStr) + if contentStart >= len(line) </span><span class="cov2" title="2">{ return "", 0, 0, false }</span> - <span class="cov6" title="17">closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ + <span class="cov6" title="22">doubleHit := false + for _, seq := range doubleSeqs </span><span class="cov7" title="44">{ + if strings.HasPrefix(line[j:], seq) </span><span class="cov0" title="0">{ + doubleHit = true + contentStart += len(seq) - len(openStr) + if contentStart >= len(line) </span><span class="cov0" title="0">{ + return "", 0, 0, false + }</span> + <span class="cov0" title="0">break</span> + } + } + <span class="cov6" title="22">next := line[contentStart] + if next == ' ' </span><span class="cov3" title="5">{ + pos = contentStart + 1 + continue</span> + } + <span class="cov5" title="17">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) + 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">{ pos = closeIdx + 1 continue</span> } - <span class="cov6" title="16">inner := strings.TrimSpace(line[j+1 : closeIdx]) + <span class="cov5" title="16">inner := strings.TrimSpace(line[contentStart:closeIdx]) if inner == "" </span><span class="cov0" title="0">{ pos = closeIdx + 1 continue</span> } - <span class="cov6" title="16">end := closeIdx + 1 + <span class="cov5" title="16">end := closeIdx + 1 return inner, j, end, true</span> } - <span class="cov6" title="20">return "", 0, 0, false</span> + <span class="cov5" title="18">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, open, close byte) bool <span class="cov6" title="22">{ +func isBareDoubleOpen(line string, openStr string, open, close byte) bool <span class="cov6" title="24">{ t := strings.TrimSpace(line) - // check for double-open pattern - dbl := string([]byte{open, open}) - if !strings.Contains(t, dbl) </span><span class="cov6" title="19">{ - return false + if openStr == "" </span><span class="cov0" title="0">{ + openStr = string(open) }</span> - <span class="cov2" title="3">if hasDoubleOpenTrigger(t, open, close) </span><span class="cov2" title="2">{ + <span class="cov6" title="24">if openStr == "" </span><span class="cov0" title="0">{ return false }</span> - <span class="cov1" title="1">if strings.HasPrefix(t, dbl) </span><span class="cov1" title="1">{ - rest := strings.TrimSpace(t[len(dbl):]) - if rest == "" || rest == ";" </span><span class="cov1" title="1">{ - return true - }</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">{ + rest := strings.TrimSpace(t[len(seq):]) + if rest == "" || rest == string(close) </span><span class="cov1" title="1">{ + return true + }</span> + } } - <span class="cov0" title="0">return false</span> + <span class="cov6" title="23">return false</span> } // stripDuplicateAssignmentPrefix removes a duplicated assignment prefix from the suggestion. @@ -6392,7 +7889,7 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin tail := prefixBeforeCursor[idx+2:] if strings.TrimSpace(tail) == "" </span><span class="cov3" title="4">{ start := idx - 1 - for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') </span><span class="cov6" title="20">{ + for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') </span><span class="cov5" title="20">{ start-- }</span> <span class="cov3" title="4">start++ @@ -6403,7 +7900,7 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin } } // Fallback to plain '=' if present - <span class="cov6" title="17">if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 </span><span class="cov2" title="2">{ + <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 := tail := prefixBeforeCursor[idx+1:] if strings.TrimSpace(tail) == "" </span><span class="cov2" title="2">{ @@ -6432,35 +7929,35 @@ func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string < if p != "" && strings.HasPrefix(s, p) </span><span class="cov3" title="5">{ return strings.TrimLeft(s[len(p):], " \t") }</span> - <span class="cov6" title="16">for k := len(p) - 1; k > 0; k-- </span><span class="cov10" title="146">{ - if !isIdentBoundary(p[k-1]) </span><span class="cov9" title="116">{ + <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">{ continue</span> } - <span class="cov7" title="30">suf := strings.TrimLeft(p[k:], " \t") + <span class="cov6" title="33">suf := strings.TrimLeft(p[k:], " \t") if suf == "" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov7" title="30">if strings.HasPrefix(s, suf) </span><span class="cov0" title="0">{ + <span class="cov6" title="33">if strings.HasPrefix(s, suf) </span><span class="cov0" title="0">{ return strings.TrimLeft(s[len(suf):], " \t") }</span> } - <span class="cov6" title="16">return suggestion</span> + <span class="cov5" title="16">return suggestion</span> } -func isIdentBoundary(ch byte) bool <span class="cov10" title="146">{ +func isIdentBoundary(ch byte) bool <span class="cov9" title="149">{ return !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') }</span> // stripCodeFences removes surrounding Markdown code fences from a model response. -func stripCodeFences(s string) string <span class="cov7" title="46">{ return textutil.StripCodeFences(s) }</span> +func stripCodeFences(s string) string <span class="cov7" title="48">{ return textutil.StripCodeFences(s) }</span> // stripInlineCodeSpan returns the contents of the first inline backtick code span if present. -func stripInlineCodeSpan(s string) string <span class="cov5" title="11">{ +func stripInlineCodeSpan(s string) string <span class="cov4" title="11">{ t := strings.TrimSpace(s) if t == "" </span><span class="cov0" title="0">{ return t }</span> - <span class="cov5" title="11">i := strings.IndexByte(t, '`') + <span class="cov4" title="11">i := strings.IndexByte(t, '`') if i < 0 </span><span class="cov2" title="2">{ return t }</span> @@ -6478,11 +7975,11 @@ func labelForCompletion(cleaned, filter string) string <span class="cov6" title= if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) </span><span class="cov3" title="5">{ return filter }</span> - <span class="cov6" title="17">return label</span> + <span class="cov5" title="17">return label</span> } // extractRangeText returns the exact text within the given document range. -func extractRangeText(d *document, r Range) string <span class="cov4" title="6">{ +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">{ line := d.lines[r.Start.Line] if r.Start.Character < 0 </span><span class="cov0" title="0">{ @@ -6529,100 +8026,141 @@ func extractRangeText(d *document, r Range) string <span class="cov4" title="6"> // collectPromptRemovalEdits returns edits to remove all inline prompt markers. func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit <span class="cov5" title="15">{ d := s.getDocument(uri) - if d == nil || len(d.lines) == 0 </span><span class="cov5" title="11">{ + if d == nil || len(d.lines) == 0 </span><span class="cov4" title="11">{ return nil }</span> <span class="cov3" title="4">var edits []TextEdit - _, _, openChar, closeChar := s.inlineMarkers() + openStr, _, openChar, closeChar := s.inlineMarkers() for i, line := range d.lines </span><span class="cov5" title="13">{ - edits = append(edits, promptRemovalEditsForLine(line, i, openChar, closeChar)...) + edits = append(edits, promptRemovalEditsForLine(line, i, openStr, openChar, closeChar)...) }</span> <span class="cov3" title="4">return edits</span> } -func promptRemovalEditsForLine(line string, lineNum int, open, close byte) []TextEdit <span class="cov6" title="17">{ - if hasDoubleOpenTrigger(line, open, close) </span><span class="cov3" title="5">{ +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">{ 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, open, close)</span> + <span class="cov5" title="12">return collectSemicolonMarkers(line, lineNum, openStr, open, close)</span> } -func hasDoubleOpenTrigger(line string, open, close byte) bool <span class="cov9" title="90">{ - pos := 0 - for pos < len(line) </span><span class="cov9" title="89">{ - // look for double-open sequence - dbl := string([]byte{open, open}) - j := strings.Index(line[pos:], dbl) - if j < 0 </span><span class="cov8" title="62">{ +func hasDoubleOpenTrigger(line string, openStr string, open, close byte) bool <span class="cov8" title="87">{ + 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">{ + return false + }</span> + <span class="cov8" title="87">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">{ + found := -1 + var seq string + for _, cand := range seqs </span><span class="cov9" title="171">{ + 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">{ + abs := pos + idx + if found < 0 || abs < found </span><span class="cov6" title="25">{ + found = abs + seq = cand + }</span> + } + } + <span class="cov8" title="86">if found < 0 </span><span class="cov7" title="62">{ return false }</span> - <span class="cov6" title="27">j += pos - contentStart := j + len(dbl) - if contentStart >= len(line) </span><span class="cov4" title="8">{ + <span class="cov6" title="24">contentStart := found + len(seq) + if contentStart >= len(line) </span><span class="cov4" title="7">{ return false }</span> - <span class="cov6" title="19">first := line[contentStart] - if first == ' ' || first == open </span><span class="cov3" title="5">{ + <span class="cov5" title="17">first := line[contentStart] + if first == ' ' || first == close || first == open </span><span class="cov3" title="5">{ pos = contentStart + 1 continue</span> } - // find closing - <span class="cov5" title="14">k := strings.IndexByte(line[contentStart+1:], close) + <span class="cov5" title="12">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) if k < 0 </span><span class="cov0" title="0">{ return false }</span> - <span class="cov5" title="14">closeIdx := contentStart + 1 + k + <span class="cov5" title="12">closeIdx := contentStart + 1 + k if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ pos = closeIdx + 1 continue</span> } - <span class="cov5" title="13">return true</span> + <span class="cov4" title="11">return true</span> } <span class="cov4" title="7">return false</span> } -func collectSemicolonMarkers(line string, lineNum int, open, close byte) []TextEdit <span class="cov5" title="14">{ - var edits []TextEdit - startSemi := 0 - for startSemi < len(line) </span><span class="cov6" title="18">{ - j := strings.IndexByte(line[startSemi:], open) +func collectSemicolonMarkers(line string, lineNum int, openStr string, open, close byte) []TextEdit <span class="cov5" title="14">{ + 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">{ + return nil + }</span> + <span class="cov5" title="14">var edits []TextEdit + start := 0 + doubleSeqs := doubleOpenSequences(openStr, open, close) + for start < len(line) </span><span class="cov5" title="18">{ + j := strings.Index(line[start:], openStr) if j < 0 </span><span class="cov5" title="12">{ break</span> } - <span class="cov4" title="6">j += startSemi - k := strings.IndexByte(line[j+1:], close) - if k < 0 </span><span class="cov0" title="0">{ + <span class="cov3" title="6">j += start + contentStart := j + len(openStr) + if contentStart >= len(line) </span><span class="cov0" title="0">{ break</span> } - <span class="cov4" title="6">if j+1 >= len(line) || line[j+1] == ' ' </span><span class="cov0" title="0">{ - startSemi = j + 1 + <span class="cov3" title="6">next := line[contentStart] + if next == ' ' </span><span class="cov0" title="0">{ + start = j + 1 continue</span> } - <span class="cov4" title="6">if line[j+1] == open </span><span class="cov0" title="0">{ // skip double-open start - startSemi = j + 2 + <span class="cov3" title="6">skipDouble := false + for _, seq := range doubleSeqs </span><span class="cov4" title="11">{ + 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">{ + start = j + 1 continue</span> } - <span class="cov4" title="6">closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' </span><span class="cov0" title="0">{ - startSemi = closeIdx + 1 + <span class="cov3" title="6">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 + if closeIdx-1 < contentStart || line[closeIdx-1] == ' ' </span><span class="cov0" title="0">{ + start = closeIdx + 1 continue</span> } - <span class="cov4" title="6">if closeIdx-(j+1) < 1 </span><span class="cov0" title="0">{ - startSemi = closeIdx + 1 + <span class="cov3" title="6">if closeIdx == contentStart </span><span class="cov0" title="0">{ + start = closeIdx + 1 continue</span> } - <span class="cov4" title="6">endChar := closeIdx + 1 + <span class="cov3" title="6">endChar := closeIdx + 1 if endChar < len(line) && line[endChar] == ' ' </span><span class="cov3" title="4">{ endChar++ }</span> - <span class="cov4" title="6">edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) - startSemi = endChar</span> + <span class="cov3" title="6">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> } </pre> - <pre class="file" id="file32" style="display: none">// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats. + <pre class="file" id="file33" style="display: none">// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats. package lsp import ( @@ -6630,6 +8168,7 @@ import ( "encoding/json" "io" "log" + "os" "strings" "sync" "time" @@ -6653,6 +8192,8 @@ type Server struct { configStore *runtimeconfig.Store cfg appconfig.App llmClient llm.Client + llmProvider string + altClients map[string]llm.Client lastInput time.Time // LLM request stats llmReqTotal int64 @@ -6661,25 +8202,30 @@ type Server struct { llmRespBytesTotal int64 startTime time.Time // Small LRU cache for recent code completion outputs (keyed by context) - compCache map[string]string - compCacheOrder []string // most-recent at end; cap ~10 + compCache map[string]string + compCacheOrder []string // most-recent at end; cap ~10 + pendingCompletions map[string][]CompletionItem + configLoadOpts appconfig.LoadOptions // Outgoing JSON-RPC id counter for server-initiated requests nextID int64 lastLLMCall time.Time + completionsDisabled bool + // Dispatch table for JSON-RPC methods → handler functions handlers map[string]func(Request) } // ServerOptions collects configuration for NewServer to avoid long parameter lists. type ServerOptions struct { - LogContext bool - ConfigStore *runtimeconfig.Store - Config *appconfig.App - MaxTokens int - ContextMode string - WindowLines int - MaxContextTokens int + LogContext bool + ConfigStore *runtimeconfig.Store + Config *appconfig.App + MaxTokens int + ContextMode string + WindowLines int + MaxContextTokens int + ConfigLoadOptions appconfig.LoadOptions Client llm.Client TriggerCharacters []string @@ -6733,6 +8279,7 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) 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) + s.pendingCompletions = make(map[string][]CompletionItem) s.applyOptions(opts) // Initialize dispatch table s.handlers = map[string]func(Request){ @@ -6755,6 +8302,7 @@ func (s *Server) applyOptions(opts ServerOptions) <span class="cov4" title="9">{ 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">{ s.configStore = opts.ConfigStore }</span> @@ -6809,7 +8357,13 @@ func (s *Server) applyOptions(opts ServerOptions) <span class="cov4" title="9">{ } }</span> } - <span class="cov4" title="9">s.llmClient = opts.Client</span> + <span class="cov4" title="9">s.llmClient = opts.Client + if opts.Client != nil </span><span class="cov2" title="2">{ + s.llmProvider = canonicalProvider(opts.Client.Name()) + }</span> else<span class="cov3" title="7"> { + s.llmProvider = canonicalProvider(s.cfg.Provider) + }</span> + <span class="cov4" title="9">s.altClients = make(map[string]llm.Client)</span> } // ApplyOptions updates the server's configuration at runtime. @@ -6817,27 +8371,182 @@ func (s *Server) ApplyOptions(opts ServerOptions) <span class="cov1" title="1">{ s.applyOptions(opts) }</span> -func (s *Server) currentLLMClient() llm.Client <span class="cov8" title="205">{ +func (s *Server) currentLLMClient() llm.Client <span class="cov7" title="83">{ s.mu.RLock() defer s.mu.RUnlock() return s.llmClient }</span> -func (s *Server) currentConfig() appconfig.App <span class="cov10" title="431">{ +func newClientForProvider(cfg appconfig.App, provider string) (llm.Client, error) <span class="cov3" title="5">{ + llmCfg := llm.Config{ + Provider: provider, + OpenAIBaseURL: cfg.OpenAIBaseURL, + OpenAIModel: cfg.OpenAIModel, + OpenAITemperature: cfg.OpenAITemperature, + OpenRouterBaseURL: cfg.OpenRouterBaseURL, + OpenRouterModel: cfg.OpenRouterModel, + OpenRouterTemperature: cfg.OpenRouterTemperature, + OllamaBaseURL: cfg.OllamaBaseURL, + OllamaModel: cfg.OllamaModel, + OllamaTemperature: cfg.OllamaTemperature, + CopilotBaseURL: cfg.CopilotBaseURL, + CopilotModel: cfg.CopilotModel, + CopilotTemperature: cfg.CopilotTemperature, + } + oaKey := strings.TrimSpace(os.Getenv("HEXAI_OPENAI_API_KEY")) + if oaKey == "" </span><span class="cov3" title="5">{ + 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">{ + 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">{ + cpKey = strings.TrimSpace(os.Getenv("COPILOT_API_KEY")) + }</span> + <span class="cov3" title="5">return llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey)</span> +} + +func (s *Server) clientFor(spec requestSpec) llm.Client <span class="cov6" title="47">{ + 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">{ + baseProvider = canonicalProvider(baseClient.Name()) + }</span> + <span class="cov6" title="47">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">{ + s.mu.RUnlock() + return baseClient + }</span> + <span class="cov3" title="5">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 + 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 + modelOverride := strings.TrimSpace(spec.entry.Model) + switch provider </span>{ + case "openai":<span class="cov3" title="5"> + 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">{ + cfg.OpenAIModel = spec.fallbackModel + }</span> + case "openrouter":<span class="cov0" title="0"> + if modelOverride != "" </span><span class="cov0" title="0">{ + cfg.OpenRouterModel = modelOverride + }</span> else<span class="cov0" title="0"> if spec.fallbackModel != "" </span><span class="cov0" title="0">{ + cfg.OpenRouterModel = spec.fallbackModel + }</span> + case "copilot":<span class="cov0" title="0"> + if modelOverride != "" </span><span class="cov0" title="0">{ + cfg.CopilotModel = modelOverride + }</span> else<span class="cov0" title="0"> if spec.fallbackModel != "" </span><span class="cov0" title="0">{ + cfg.CopilotModel = spec.fallbackModel + }</span> + case "ollama":<span class="cov0" title="0"> + if modelOverride != "" </span><span class="cov0" title="0">{ + cfg.OllamaModel = modelOverride + }</span> else<span class="cov0" title="0"> if spec.fallbackModel != "" </span><span class="cov0" title="0">{ + cfg.OllamaModel = spec.fallbackModel + }</span> + } + <span class="cov3" title="5">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">{ + return baseClient + }</span> + <span class="cov0" title="0">return nil</span> + } + <span class="cov3" title="5">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">{ + s.llmClient = client + s.llmProvider = provider + }</span> + <span class="cov1" title="1">return s.llmClient</span> + } + <span class="cov3" title="4">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">{ + s.altClients = make(map[string]llm.Client) + }</span> + <span class="cov3" title="4">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">{ return s.configStore.Snapshot() }</span> - <span class="cov9" title="426">s.mu.RLock() + <span class="cov9" title="440">s.mu.RLock() defer s.mu.RUnlock() return s.cfg</span> } -func (s *Server) maxTokens() int <span class="cov6" title="36">{ +func (s *Server) storePendingCompletion(key string, items []CompletionItem) <span class="cov1" title="1">{ + if len(items) == 0 </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov1" title="1">cpy := make([]CompletionItem, len(items)) + copy(cpy, items) + s.mu.Lock() + if s.pendingCompletions == nil </span><span class="cov1" title="1">{ + s.pendingCompletions = make(map[string][]CompletionItem) + }</span> + <span class="cov1" title="1">s.pendingCompletions[key] = cpy + s.mu.Unlock()</span> +} + +func (s *Server) setCompletionsDisabled(disabled bool) bool <span class="cov3" title="6">{ + s.mu.Lock() + prev := s.completionsDisabled + s.completionsDisabled = disabled + s.mu.Unlock() + return prev +}</span> + +func (s *Server) completionDisabled() bool <span class="cov3" title="6">{ + s.mu.RLock() + defer s.mu.RUnlock() + return s.completionsDisabled +}</span> + +func (s *Server) takePendingCompletion(key string) []CompletionItem <span class="cov4" title="12">{ + s.mu.Lock() + defer s.mu.Unlock() + if len(s.pendingCompletions) == 0 </span><span class="cov4" title="11">{ + return nil + }</span> + <span class="cov1" title="1">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) + cpy := make([]CompletionItem, len(items)) + copy(cpy, items) + return cpy</span> +} + +func (s *Server) maxTokens() int <span class="cov6" title="44">{ cfg := s.currentConfig() - if cfg.MaxTokens <= 0 </span><span class="cov6" title="30">{ + if cfg.MaxTokens <= 0 </span><span class="cov6" title="36">{ return 500 }</span> - <span class="cov3" title="6">return cfg.MaxTokens</span> + <span class="cov4" title="8">return cfg.MaxTokens</span> } func (s *Server) contextMode() string <span class="cov4" title="14">{ @@ -6872,7 +8581,7 @@ func (s *Server) triggerCharacters() []string <span class="cov5" title="27">{ <span class="cov5" title="24">return append([]string{}, cfg.TriggerCharacters...)</span> } -func (s *Server) codingTemperature() *float64 <span class="cov6" title="51">{ +func (s *Server) codingTemperature() *float64 <span class="cov4" title="11">{ cfg := s.currentConfig() return cfg.CodingTemperature }</span> @@ -6881,17 +8590,17 @@ func (s *Server) manualInvokeMinPrefix() int <span class="cov3" title="5">{ return s.currentConfig().ManualInvokeMinPrefix }</span> -func (s *Server) completionDebounce() time.Duration <span class="cov6" title="42">{ +func (s *Server) completionDebounce() time.Duration <span class="cov6" title="40">{ cfg := s.currentConfig() - if cfg.CompletionDebounceMs <= 0 </span><span class="cov6" title="40">{ + if cfg.CompletionDebounceMs <= 0 </span><span class="cov6" title="38">{ return 0 }</span> <span class="cov2" title="2">return time.Duration(cfg.CompletionDebounceMs) * time.Millisecond</span> } -func (s *Server) completionThrottle() time.Duration <span class="cov6" title="42">{ +func (s *Server) completionThrottle() time.Duration <span class="cov6" title="40">{ cfg := s.currentConfig() - if cfg.CompletionThrottleMs <= 0 </span><span class="cov6" title="39">{ + if cfg.CompletionThrottleMs <= 0 </span><span class="cov6" title="37">{ return 0 }</span> <span class="cov2" title="3">return time.Duration(cfg.CompletionThrottleMs) * time.Millisecond</span> @@ -6901,7 +8610,7 @@ func (s *Server) inlineMarkers() (open string, close string, openChar byte, clos cfg := s.currentConfig() open = strings.TrimSpace(cfg.InlineOpen) if open == "" </span><span class="cov2" title="2">{ - open = ">" + open = ">!" }</span> <span class="cov7" title="102">close = strings.TrimSpace(cfg.InlineClose) if close == "" </span><span class="cov2" title="2">{ @@ -6918,10 +8627,10 @@ func (s *Server) inlineMarkers() (open string, close string, openChar byte, clos <span class="cov7" title="102">return open, close, openChar, closeChar</span> } -func (s *Server) chatConfig() (suffix string, prefixes []string, suffixChar byte) <span class="cov6" title="47">{ +func (s *Server) chatConfig() (suffix string, prefixes []string, suffixChar byte) <span class="cov6" title="51">{ cfg := s.currentConfig() suffix = cfg.ChatSuffix - if suffix != "" </span><span class="cov6" title="45">{ + if suffix != "" </span><span class="cov6" title="49">{ suffix = strings.TrimSpace(suffix) if suffix == "" </span><span class="cov0" title="0">{ suffix = ">" @@ -6929,16 +8638,16 @@ func (s *Server) chatConfig() (suffix string, prefixes []string, suffixChar byte } else<span class="cov2" title="2"> { suffix = "" }</span> - <span class="cov6" title="47">if len(cfg.ChatPrefixes) == 0 </span><span class="cov0" title="0">{ + <span class="cov6" title="51">if len(cfg.ChatPrefixes) == 0 </span><span class="cov0" title="0">{ prefixes = []string{"?", "!", ":", ";"} - }</span> else<span class="cov6" title="47"> { + }</span> else<span class="cov6" title="51"> { prefixes = append([]string{}, cfg.ChatPrefixes...) }</span> - <span class="cov6" title="47">suffixChar = '>' - if len(suffix) > 0 </span><span class="cov6" title="45">{ + <span class="cov6" title="51">suffixChar = '>' + if len(suffix) > 0 </span><span class="cov6" title="49">{ suffixChar = suffix[0] }</span> - <span class="cov6" title="47">return suffix, prefixes, suffixChar</span> + <span class="cov6" title="51">return suffix, prefixes, suffixChar</span> } func (s *Server) promptSet() appconfig.App <span class="cov2" title="2">{ @@ -6991,7 +8700,7 @@ func (s *Server) Run() error <span class="cov1" title="1">{ } </pre> - <pre class="file" id="file33" style="display: none">// Summary: LSP transport utilities to read and write JSON-RPC messages with Content-Length framing. + <pre class="file" id="file34" style="display: none">// Summary: LSP transport utilities to read and write JSON-RPC messages with Content-Length framing. package lsp import ( @@ -7041,7 +8750,7 @@ func (s *Server) readMessage() ([]byte, error) <span class="cov2" title="2">{ <span class="cov1" title="1">return buf, nil</span> } -func (s *Server) writeMessage(v any) <span class="cov10" title="43">{ +func (s *Server) writeMessage(v any) <span class="cov10" title="44">{ s.outMu.Lock() defer s.outMu.Unlock() @@ -7050,19 +8759,19 @@ func (s *Server) writeMessage(v any) <span class="cov10" title="43">{ logging.Logf("lsp ", "marshal error: %v", err) return }</span> - <span class="cov10" title="43">header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) + <span class="cov10" title="44">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="43">if _, err := s.out.Write(data); err != nil </span><span class="cov0" title="0">{ + <span class="cov10" title="44">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="file34" style="display: none">package runtimeconfig + <pre class="file" id="file35" style="display: none">package runtimeconfig import ( "fmt" @@ -7157,65 +8866,73 @@ func (s *Store) Reload(logger *log.Logger, opts appconfig.LoadOptions) ([]Change } // 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="6">{ +func Diff(oldCfg, newCfg appconfig.App) []Change <span class="cov3" title="7">{ before := flattenAppConfig(oldCfg) after := flattenAppConfig(newCfg) keys := make(map[string]struct{}, len(before)+len(after)) - for k := range before </span><span class="cov8" title="150">{ + for k := range before </span><span class="cov8" title="224">{ keys[k] = struct{}{} }</span> - <span class="cov3" title="6">for k := range after </span><span class="cov8" title="150">{ + <span class="cov3" title="7">for k := range after </span><span class="cov8" title="224">{ keys[k] = struct{}{} }</span> - <span class="cov3" title="6">ordered := make([]string, 0, len(keys)) - for k := range keys </span><span class="cov8" title="150">{ + <span class="cov3" title="7">ordered := make([]string, 0, len(keys)) + for k := range keys </span><span class="cov8" title="224">{ ordered = append(ordered, k) }</span> - <span class="cov3" title="6">sort.Strings(ordered) + <span class="cov3" title="7">sort.Strings(ordered) changes := make([]Change, 0, len(ordered)) - for _, k := range ordered </span><span class="cov8" title="150">{ - if before[k] == after[k] </span><span class="cov8" title="144">{ + for _, k := range ordered </span><span class="cov8" title="224">{ + if before[k] == after[k] </span><span class="cov8" title="217">{ continue</span> } - <span class="cov3" title="6">changes = append(changes, Change{Key: k, Old: before[k], New: after[k]})</span> + <span class="cov3" title="7">changes = append(changes, Change{Key: k, Old: before[k], New: after[k]})</span> } - <span class="cov3" title="6">return changes</span> + <span class="cov3" title="7">return changes</span> } -func flattenAppConfig(cfg appconfig.App) map[string]string <span class="cov4" title="12">{ +func flattenAppConfig(cfg appconfig.App) map[string]string <span class="cov4" title="14">{ result := make(map[string]string) val := reflect.ValueOf(cfg) typ := val.Type() - for i := 0; i < typ.NumField(); i++ </span><span class="cov10" title="564">{ + for i := 0; i < typ.NumField(); i++ </span><span class="cov10" title="756">{ field := typ.Field(i) key := strings.TrimSpace(field.Tag.Get("toml")) - if key == "" || key == "-" </span><span class="cov8" title="276">{ + if key == "" || key == "-" </span><span class="cov9" title="378">{ switch field.Name </span>{ - case "StatsWindowMinutes":<span class="cov4" title="12"> + case "StatsWindowMinutes":<span class="cov4" title="14"> key = "stats_window_minutes"</span> - default:<span class="cov8" title="264"> + case "CompletionConfigs":<span class="cov4" title="14"> + key = "completion_configs"</span> + case "CodeActionConfigs":<span class="cov4" title="14"> + key = "code_action_configs"</span> + case "ChatConfigs":<span class="cov4" title="14"> + key = "chat_configs"</span> + case "CLIConfigs":<span class="cov4" title="14"> + key = "cli_configs"</span> + default:<span class="cov8" title="308"> continue</span> } } - <span class="cov9" title="300">if idx := strings.Index(key, ","); idx >= 0 </span><span class="cov0" title="0">{ + <span class="cov9" title="448">if idx := strings.Index(key, ","); idx >= 0 </span><span class="cov0" title="0">{ key = key[:idx] }</span> - <span class="cov9" title="300">if key == "" || key == "-" </span><span class="cov0" title="0">{ + <span class="cov9" title="448">if key == "" || key == "-" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov9" title="300">result[key] = stringifyValue(val.Field(i))</span> + <span class="cov9" title="448">result[key] = stringifyValue(val.Field(i))</span> } - <span class="cov4" title="12">return result</span> + <span class="cov4" title="14">return result</span> } -func stringifyValue(v reflect.Value) string <span class="cov9" title="340">{ +func stringifyValue(v reflect.Value) string <span class="cov9" title="488">{ if !v.IsValid() </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov9" title="340">switch v.Kind() </span>{ - case reflect.String:<span class="cov7" title="132"> + <span class="cov9" title="488">switch v.Kind() </span>{ + case reflect.String:<span class="cov8" title="182"> return v.String()</span> - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:<span class="cov7" title="96"> + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:<span class="cov7" title="112"> 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> @@ -7223,20 +8940,36 @@ func stringifyValue(v reflect.Value) string <span class="cov9" title="340">{ return strconv.FormatFloat(v.Float(), 'f', -1, 64)</span> case reflect.Bool:<span class="cov0" title="0"> return strconv.FormatBool(v.Bool())</span> - case reflect.Slice:<span class="cov5" title="24"> - if v.IsNil() </span><span class="cov4" title="14">{ + case reflect.Slice:<span class="cov7" title="84"> + if v.IsNil() </span><span class="cov6" title="72">{ return "" }</span> - <span class="cov4" title="10">if v.Type().Elem().Kind() == reflect.String </span><span class="cov4" title="10">{ + <span class="cov4" title="12">if v.Type().Elem().Kind() == reflect.String </span><span class="cov4" title="10">{ parts := make([]string, v.Len()) for i := range parts </span><span class="cov6" title="40">{ parts[i] = v.Index(i).String() }</span> <span class="cov4" title="10">return strings.Join(parts, ",")</span> } + <span class="cov1" title="2">if v.Type().Elem() == reflect.TypeOf(appconfig.SurfaceConfig{}) </span><span class="cov1" title="2">{ + parts := make([]string, 0, v.Len()) + for i := 0; i < v.Len(); i++ </span><span class="cov1" title="2">{ + entry := v.Index(i).Interface().(appconfig.SurfaceConfig) + segment := strings.TrimSpace(entry.Provider) + if segment != "" </span><span class="cov1" title="2">{ + segment += ":" + }</span> + <span class="cov1" title="2">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="cov1" title="2">return strings.Join(parts, "|")</span> + } <span class="cov0" title="0">return fmt.Sprint(v.Interface())</span> - case reflect.Ptr:<span class="cov6" title="48"> - if v.IsNil() </span><span class="cov3" title="8">{ + case reflect.Ptr:<span class="cov6" title="70"> + if v.IsNil() </span><span class="cov5" title="30">{ return "(unset)" }</span> <span class="cov6" title="40">return stringifyValue(v.Elem())</span> @@ -7259,7 +8992,7 @@ func FormatSummary(prefix string, changes []Change) string <span class="cov3" ti } </pre> - <pre class="file" id="file35" style="display: none">//go:build !windows + <pre class="file" id="file36" style="display: none">//go:build !windows package stats @@ -7269,22 +9002,22 @@ import ( "golang.org/x/sys/unix" ) -func tryLockFile(fd uintptr) error <span class="cov10" title="213">{ - if err := unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB); err != nil </span><span class="cov9" title="136">{ - if errors.Is(err, unix.EWOULDBLOCK) </span><span class="cov9" title="136">{ +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">{ return errLockWouldBlock }</span> <span class="cov0" title="0">return err</span> } - <span class="cov8" title="77">return nil</span> + <span class="cov8" title="88">return nil</span> } -func unlockFile(fd uintptr) error <span class="cov8" title="77">{ +func unlockFile(fd uintptr) error <span class="cov8" title="88">{ return unix.Flock(int(fd), unix.LOCK_UN) }</span> </pre> - <pre class="file" id="file36" style="display: none">// Package stats provides a simple, process-safe, on-disk cache of Hexai LLM usage + <pre class="file" id="file37" 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 @@ -7315,18 +9048,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="cov4" title="83">{ +func SetWindow(d time.Duration) <span class="cov5" title="83">{ if d < time.Second </span><span class="cov0" title="0">{ d = time.Second }</span> - <span class="cov4" title="83">if d > 24*time.Hour </span><span class="cov0" title="0">{ + <span class="cov5" title="83">if d > 24*time.Hour </span><span class="cov0" title="0">{ d = 24 * time.Hour }</span> - <span class="cov4" title="83">atomic.StoreInt64(&windowSeconds, int64(d.Seconds()))</span> + <span class="cov5" title="83">atomic.StoreInt64(&windowSeconds, int64(d.Seconds()))</span> } // Window returns the current sliding window. -func Window() time.Duration <span class="cov4" title="77">{ return time.Duration(atomic.LoadInt64(&windowSeconds)) * time.Second }</span> +func Window() time.Duration <span class="cov5" title="88">{ return time.Duration(atomic.LoadInt64(&windowSeconds)) * time.Second }</span> // Event represents a single request/response with sizes. type Event struct { @@ -7361,108 +9094,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="cov4" title="77">{ +func Update(ctx context.Context, provider, model string, sentBytes, recvBytes int) error <span class="cov5" title="88">{ dir, err := CacheDir() if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov4" title="77">if err := os.MkdirAll(dir, 0o755); err != nil </span><span class="cov0" title="0">{ + <span class="cov5" title="88">if err := os.MkdirAll(dir, 0o755); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov4" title="77">lockPath := filepath.Join(dir, lockFileName) + <span class="cov5" title="88">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="cov4" title="77">defer f.Close() + <span class="cov5" title="88">defer f.Close() unlock, err := acquireFileLock(ctx, f) if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov4" title="77">defer func() </span><span class="cov4" title="77">{ _ = unlock() }</span>() + <span class="cov5" title="88">defer func() </span><span class="cov5" title="88">{ _ = unlock() }</span>() // Read existing file (if any) - <span class="cov4" title="77">path := filepath.Join(dir, fileName) + <span class="cov5" title="88">path := filepath.Join(dir, fileName) var sf File - if b, rerr := os.ReadFile(path); rerr == nil </span><span class="cov4" title="74">{ + if b, rerr := os.ReadFile(path); rerr == nil </span><span class="cov5" title="85">{ _ = json.Unmarshal(b, &sf) }</span> - <span class="cov4" title="77">if sf.Version != fileVersion </span><span class="cov1" title="3">{ + <span class="cov5" title="88">if sf.Version != fileVersion </span><span class="cov2" title="3">{ sf = File{Version: fileVersion} }</span> - <span class="cov4" title="77">now := time.Now() + <span class="cov5" title="88">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="cov4" title="77">{ + if len(sf.Events) > 0 </span><span class="cov5" title="88">{ // Find first >= cutoff i := 0 - for ; i < len(sf.Events); i++ </span><span class="cov4" title="78">{ - if !sf.Events[i].TS.Before(cutoff) </span><span class="cov4" title="77">{ + 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">{ break</span> } } - <span class="cov4" title="77">if i > 0 </span><span class="cov1" title="1">{ + <span class="cov5" title="88">if i > 0 </span><span class="cov1" title="1">{ sf.Events = append([]Event(nil), sf.Events[i:]...) }</span> } - <span class="cov4" title="77">sf.UpdatedAt = now + <span class="cov5" title="88">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="cov4" title="77">enc := json.NewEncoder(tmp) + <span class="cov5" title="88">enc := json.NewEncoder(tmp) enc.SetEscapeHTML(false) if err := enc.Encode(&sf); err != nil </span><span class="cov0" title="0">{ tmp.Close() os.Remove(tmp.Name()) return err }</span> - <span class="cov4" title="77">if err := tmp.Sync(); err != nil </span><span class="cov0" title="0">{ + <span class="cov5" title="88">if err := tmp.Sync(); err != nil </span><span class="cov0" title="0">{ tmp.Close() os.Remove(tmp.Name()) return err }</span> - <span class="cov4" title="77">if err := tmp.Close(); err != nil </span><span class="cov0" title="0">{ + <span class="cov5" title="88">if err := tmp.Close(); err != nil </span><span class="cov0" title="0">{ os.Remove(tmp.Name()) return err }</span> - <span class="cov4" title="77">if err := os.Rename(tmp.Name(), path); err != nil </span><span class="cov0" title="0">{ + <span class="cov5" title="88">if err := os.Rename(tmp.Name(), path); err != nil </span><span class="cov0" title="0">{ os.Remove(tmp.Name()) return err }</span> - <span class="cov4" title="77">return nil</span> + <span class="cov5" title="88">return nil</span> } -func acquireFileLock(ctx context.Context, f *os.File) (func() error, error) <span class="cov4" title="77">{ +func acquireFileLock(ctx context.Context, f *os.File) (func() error, error) <span class="cov5" title="88">{ fd := f.Fd() - for </span><span class="cov5" title="213">{ + for </span><span class="cov6" title="208">{ err := tryLockFile(fd) - if err == nil </span><span class="cov4" title="77">{ - return func() error </span><span class="cov4" title="77">{ return unlockFile(fd) }</span>, nil + if err == nil </span><span class="cov5" title="88">{ + return func() error </span><span class="cov5" title="88">{ return unlockFile(fd) }</span>, nil } - <span class="cov5" title="136">if errors.Is(err, errLockWouldBlock) </span><span class="cov5" title="136">{ + <span class="cov6" title="120">if errors.Is(err, errLockWouldBlock) </span><span class="cov6" title="120">{ select </span>{ case <-ctx.Done():<span class="cov0" title="0"> return nil, ctx.Err()</span> - case <-time.After(5 * time.Millisecond):<span class="cov5" title="136"></span> + case <-time.After(5 * time.Millisecond):<span class="cov6" title="120"></span> } - <span class="cov5" title="136">continue</span> + <span class="cov6" title="120">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="cov4" title="70">{ +func TakeSnapshot() (Snapshot, error) <span class="cov5" title="70">{ dir, err := CacheDir() if err != nil </span><span class="cov0" title="0">{ return Snapshot{}, err }</span> - <span class="cov4" title="70">path := filepath.Join(dir, fileName) + <span class="cov5" title="70">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">{ @@ -7470,30 +9203,30 @@ func TakeSnapshot() (Snapshot, error) <span class="cov4" title="70">{ }</span> <span class="cov0" title="0">return Snapshot{}, err</span> } - <span class="cov4" title="70">var sf File + <span class="cov5" title="70">var sf File if err := json.Unmarshal(b, &sf); err != nil </span><span class="cov0" title="0">{ return Snapshot{}, err }</span> - <span class="cov4" title="70">win := time.Duration(sf.WindowSeconds) * time.Second + <span class="cov5" title="70">win := time.Duration(sf.WindowSeconds) * time.Second if win <= 0 </span><span class="cov0" title="0">{ win = Window() - }</span> else<span class="cov4" title="70"> { + }</span> else<span class="cov5" title="70"> { SetWindow(win) // align process with file window if changed elsewhere }</span> - <span class="cov4" title="70">cutoff := time.Now().Add(-win) + <span class="cov5" title="70">cutoff := time.Now().Add(-win) snap := Snapshot{Providers: make(map[string]ProviderEntry), Window: win} - for _, ev := range sf.Events </span><span class="cov10" title="22097">{ + for _, ev := range sf.Events </span><span class="cov10" title="5523">{ if ev.TS.Before(cutoff) </span><span class="cov0" title="0">{ continue</span> } - <span class="cov10" title="22097">snap.Global.Reqs++ + <span class="cov10" title="5523">snap.Global.Reqs++ snap.Global.Sent += ev.Sent snap.Global.Recv += ev.Recv pe := snap.Providers[ev.Provider] - if pe.Models == nil </span><span class="cov6" title="472">{ + if pe.Models == nil </span><span class="cov7" title="434">{ pe.Models = make(map[string]Counters) }</span> - <span class="cov10" title="22097">pe.Totals.Reqs++ + <span class="cov10" title="5523">pe.Totals.Reqs++ pe.Totals.Sent += ev.Sent pe.Totals.Recv += ev.Recv mc := pe.Models[ev.Model] @@ -7503,37 +9236,37 @@ func TakeSnapshot() (Snapshot, error) <span class="cov4" title="70">{ pe.Models[ev.Model] = mc snap.Providers[ev.Provider] = pe</span> } - <span class="cov4" title="70">mins := win.Minutes() + <span class="cov5" title="70">mins := win.Minutes() if mins <= 0 </span><span class="cov0" title="0">{ mins = 0.001 }</span> - <span class="cov4" title="70">snap.RPM = float64(snap.Global.Reqs) / mins + <span class="cov5" title="70">snap.RPM = float64(snap.Global.Reqs) / mins return snap, nil</span> } // CacheDir resolves the cache directory for stats. -func CacheDir() (string, error) <span class="cov5" title="148">{ - if x := os.Getenv("XDG_CACHE_HOME"); stringsTrim(x) != "" </span><span class="cov3" title="27">{ +func CacheDir() (string, error) <span class="cov6" title="159">{ + if x := os.Getenv("XDG_CACHE_HOME"); stringsTrim(x) != "" </span><span class="cov4" title="27">{ return filepath.Join(x, "hexai"), nil }</span> - <span class="cov5" title="121">home, err := os.UserHomeDir() + <span class="cov6" title="132">home, err := os.UserHomeDir() if err != nil </span><span class="cov0" title="0">{ return "", fmt.Errorf("cannot resolve home: %w", err) }</span> - <span class="cov5" title="121">return filepath.Join(home, ".cache", "hexai"), nil</span> + <span class="cov6" title="132">return filepath.Join(home, ".cache", "hexai"), nil</span> } // stringsTrim is a tiny helper to avoid importing strings everywhere here. -func stringsTrim(s string) string <span class="cov5" title="148">{ +func stringsTrim(s string) string <span class="cov6" title="159">{ i := 0 j := len(s) for i < j && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') </span><span class="cov0" title="0">{ i++ }</span> - <span class="cov5" title="148">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="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">{ j-- }</span> - <span class="cov5" title="148">if i == 0 && j == len(s) </span><span class="cov5" title="148">{ + <span class="cov6" title="159">if i == 0 && j == len(s) </span><span class="cov6" title="159">{ return s }</span> <span class="cov0" title="0">return s[i:j]</span> @@ -7545,7 +9278,7 @@ func (s Snapshot) DebugString() string <span class="cov1" title="1">{ }</span> </pre> - <pre class="file" id="file37" style="display: none">package testutil + <pre class="file" id="file38" style="display: none">package testutil // MultilineDocBlock returns a realistic multi-line documentation block. func MultilineDocBlock() string <span class="cov8" title="1">{ @@ -7573,74 +9306,74 @@ func MalformedJSON() string <span class="cov8" title="1">{ }</span> </pre> - <pre class="file" id="file38" style="display: none">package textutil + <pre class="file" id="file39" 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="140">{ +func HumanBytes(n int64) string <span class="cov10" title="138">{ if n < 1000 </span><span class="cov2" title="2">{ return fmt.Sprintf("%dB", n) }</span> - <span class="cov9" title="138">const unit = 1000.0 + <span class="cov9" title="136">const unit = 1000.0 v := float64(n) suffix := []string{"k", "M", "G", "T"} i := 0 - for v >= unit && i < len(suffix)-1 </span><span class="cov9" title="138">{ + for v >= unit && i < len(suffix)-1 </span><span class="cov9" title="136">{ v /= unit i++ }</span> - <span class="cov9" title="138">s := fmt.Sprintf("%.1f%s", v, suffix[i]) + <span class="cov9" title="136">s := fmt.Sprintf("%.1f%s", v, suffix[i]) // Strip trailing ".0" if len(s) >= 3 && s[len(s)-2:] == ".0" </span><span class="cov0" title="0">{ s = fmt.Sprintf("%d%s", int(v), suffix[i]) }</span> - <span class="cov9" title="138">return s</span> + <span class="cov9" title="136">return s</span> } </pre> - <pre class="file" id="file39" style="display: none">package textutil + <pre class="file" id="file40" 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="64">{ - if t == "" || len(vars) == 0 </span><span class="cov4" title="6">{ +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">{ return t }</span> - <span class="cov8" title="58">out := t - for k, v := range vars </span><span class="cov10" title="156">{ + <span class="cov8" title="59">out := t + for k, v := range vars </span><span class="cov10" title="157">{ out = strings.ReplaceAll(out, "{{"+k+"}}", v) }</span> - <span class="cov8" title="58">return out</span> + <span class="cov8" title="59">return out</span> } // StripCodeFences removes surrounding Markdown triple-backtick fences. -func StripCodeFences(s string) string <span class="cov8" title="70">{ +func StripCodeFences(s string) string <span class="cov8" title="72">{ t := strings.TrimSpace(s) if t == "" </span><span class="cov1" title="1">{ return t }</span> - <span class="cov8" title="69">lines := strings.Split(t, "\n") + <span class="cov8" title="71">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="69">end := len(lines) - 1 + <span class="cov8" title="71">end := len(lines) - 1 for end >= 0 && strings.TrimSpace(lines[end]) == "" </span><span class="cov0" title="0">{ end-- }</span> - <span class="cov8" title="69">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ + <span class="cov8" title="71">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ return t }</span> - <span class="cov8" title="69">first := strings.TrimSpace(lines[start]) + <span class="cov8" title="71">first := strings.TrimSpace(lines[start]) last := strings.TrimSpace(lines[end]) if strings.HasPrefix(first, "```") && last == "```" && end > start </span><span class="cov6" title="20">{ inner := strings.Join(lines[start+1:end], "\n") return inner }</span> - <span class="cov7" title="49">return t</span> + <span class="cov7" title="51">return t</span> } // InstructionFromSelection extracts the first inline instruction and returns @@ -7730,7 +9463,7 @@ func FindStrictInlineTag(line string) (text string, left, right int, ok bool) <s } </pre> - <pre class="file" id="file40" style="display: none">package tmux + <pre class="file" id="file41" style="display: none">package tmux import ( "fmt" @@ -7754,9 +9487,9 @@ const ( ) // Enabled reports whether tmux status updates are enabled via env (default: on). -func Enabled() bool <span class="cov8" title="78">{ +func Enabled() bool <span class="cov8" title="77">{ v := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS")) - if v == "" </span><span class="cov7" title="75">{ + if v == "" </span><span class="cov7" title="74">{ return true }</span> <span class="cov2" title="3">v = strings.ToLower(v) @@ -7764,20 +9497,20 @@ func Enabled() bool <span class="cov8" title="78">{ } // SetUserOption sets a global tmux user option like @hexai_status to value. -func SetUserOption(key, value string) error <span class="cov8" title="78">{ +func SetUserOption(key, value string) error <span class="cov8" title="77">{ if !Enabled() || !HasBinary() || !InSession() </span><span class="cov2" title="3">{ return nil }</span> - <span class="cov7" title="75">k := strings.TrimPrefix(strings.TrimSpace(key), "@") + <span class="cov7" title="74">k := strings.TrimPrefix(strings.TrimSpace(key), "@") if k == "" </span><span class="cov0" title="0">{ return nil }</span> // Use set-option -g so it appears for all windows - <span class="cov7" title="75">return exec.Command("tmux", "set-option", "-g", "@"+k, value).Run()</span> + <span class="cov7" title="74">return exec.Command("tmux", "set-option", "-g", "@"+k, value).Run()</span> } // SetStatus is a convenience for setting @hexai_status. -func SetStatus(value string) error <span class="cov8" title="78">{ return SetUserOption("hexai_status", applyTheme(value)) }</span> +func SetStatus(value string) error <span class="cov8" title="77">{ return SetUserOption("hexai_status", applyTheme(value)) }</span> // FormatLLMStatsStatus builds a compact tmux status string for LLM heartbeats. // Example: "LLM:gpt-4.1 5r 0.8rpm in12k out34k" @@ -7803,7 +9536,7 @@ func FormatLLMStatsStatusColored(provider, model string, reqs int64, rpm float64 // scoped provider:model tail. The window indicator (e.g., Σ@1h) should be composed // by the caller if needed; this function focuses on numbers and labels. // Example: "Σ ↑120k ↓340k 4.2rpm | openai:gpt-4.1 3.1rpm 80r" -func FormatGlobalStatusColored(globalReqs int64, globalRPM float64, globalIn, globalOut int64, scopeProvider, scopeModel string, scopeRPM float64, scopeReqs int64, window time.Duration) string <span class="cov7" title="68">{ +func FormatGlobalStatusColored(globalReqs int64, globalRPM float64, globalIn, globalOut int64, scopeProvider, scopeModel string, scopeRPM float64, scopeReqs int64, window time.Duration) string <span class="cov7" title="67">{ gin := textutil.HumanBytes(globalIn) gout := textutil.HumanBytes(globalOut) head := fmt.Sprintf("%sΣ@%s %s↑%s%s %s↓%s%s %.1frpm", baseFGToken, humanWindow(window), arrowUpToken, baseFGToken, gin, arrowDownToken, baseFGToken, gout, globalRPM) @@ -7811,7 +9544,7 @@ func FormatGlobalStatusColored(globalReqs int64, globalRPM float64, globalIn, gl if narrowEnabled() || stringsTrim(scopeProvider) == "" || stringsTrim(scopeModel) == "" </span><span class="cov1" title="1">{ return head }</span> - <span class="cov7" title="67">tail := fmt.Sprintf(" | %s:%s %.1frpm %dr", scopeProvider, scopeModel, scopeRPM, scopeReqs) + <span class="cov7" title="66">tail := fmt.Sprintf(" | %s:%s %.1frpm %dr", scopeProvider, scopeModel, scopeRPM, scopeReqs) // Respect max length when configured: drop tail if it would overflow if ml := maxStatusLen(); ml > 0 </span><span class="cov1" title="1">{ if len(head) <= ml && len(head)+len(tail) > ml </span><span class="cov0" title="0">{ @@ -7821,15 +9554,15 @@ func FormatGlobalStatusColored(globalReqs int64, globalRPM float64, globalIn, gl return truncateStatus(head, ml) }</span> } - <span class="cov7" title="66">return head + tail</span> + <span class="cov7" title="65">return head + tail</span> } -func humanWindow(d time.Duration) string <span class="cov7" title="68">{ +func humanWindow(d time.Duration) string <span class="cov7" title="67">{ if d <= 0 </span><span class="cov0" title="0">{ return "?" }</span> - <span class="cov7" title="68">mins := int(d.Minutes()) - if mins%60 == 0 </span><span class="cov7" title="66">{ + <span class="cov7" title="67">mins := int(d.Minutes()) + if mins%60 == 0 </span><span class="cov7" title="65">{ return fmt.Sprintf("%dh", mins/60) }</span> <span class="cov2" title="2">if mins >= 60 </span><span class="cov0" title="0">{ @@ -7839,9 +9572,9 @@ func humanWindow(d time.Duration) string <span class="cov7" title="68">{ } // narrowEnabled returns true when HEXAI_TMUX_STATUS_NARROW is truthy (1/true/yes/on). -func narrowEnabled() bool <span class="cov7" title="68">{ +func narrowEnabled() bool <span class="cov7" title="67">{ v := strings.ToLower(stringsTrim(os.Getenv("HEXAI_TMUX_STATUS_NARROW"))) - if v == "" </span><span class="cov7" title="67">{ + if v == "" </span><span class="cov7" title="66">{ return false }</span> <span class="cov1" title="1">switch v </span>{ @@ -7853,9 +9586,9 @@ func narrowEnabled() bool <span class="cov7" title="68">{ } // maxStatusLen returns HEXAI_TMUX_STATUS_MAXLEN parsed as int; 0 disables. -func maxStatusLen() int <span class="cov7" title="67">{ +func maxStatusLen() int <span class="cov7" title="66">{ v := stringsTrim(os.Getenv("HEXAI_TMUX_STATUS_MAXLEN")) - if v == "" </span><span class="cov7" title="66">{ + if v == "" </span><span class="cov7" title="65">{ return 0 }</span> <span class="cov1" title="1">n, err := strconv.Atoi(v) @@ -7878,16 +9611,16 @@ func truncateStatus(s string, n int) string <span class="cov1" title="1">{ <span class="cov1" title="1">return s[:n-1] + "…"</span> } -func stringsTrim(s string) string <span class="cov10" title="269">{ +func stringsTrim(s string) string <span class="cov10" title="265">{ i := 0 j := len(s) for i < j && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') </span><span class="cov0" title="0">{ i++ }</span> - <span class="cov10" title="269">for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') </span><span class="cov0" title="0">{ + <span class="cov10" title="265">for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') </span><span class="cov0" title="0">{ j-- }</span> - <span class="cov10" title="269">if i == 0 && j == len(s) </span><span class="cov10" title="269">{ + <span class="cov10" title="265">if i == 0 && j == len(s) </span><span class="cov10" title="265">{ return s }</span> <span class="cov0" title="0">return s[i:j]</span> @@ -7895,13 +9628,13 @@ func stringsTrim(s string) string <span class="cov10" title="269">{ // FormatLLMStartStatus renders a short colored heartbeat at start/initialize time. // Example: "LLM:openai:gpt-4.1 ⏳" -func FormatLLMStartStatus(provider, model string) string <span class="cov4" title="12">{ +func FormatLLMStartStatus(provider, model string) string <span class="cov5" title="12">{ 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="78">{ +func applyTheme(s string) string <span class="cov8" title="77">{ theme := strings.ToLower(strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS_THEME"))) // Allow explicit fg/bg override fg := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS_FG")) @@ -7917,23 +9650,23 @@ func applyTheme(s string) string <span class="cov8" title="78">{ baseFG = fg }</span> // bg used as provided (may be empty) - } else<span class="cov8" title="78"> { + } else<span class="cov8" title="77"> { switch theme </span>{ - case "white-on-purple", "purple", "magenta", "white-on-magenta":<span class="cov8" title="78"> + case "white-on-purple", "purple", "magenta", "white-on-magenta":<span class="cov8" title="77"> baseFG, bg, wrap = "white", "magenta", true</span> case "black-on-yellow", "yellow", "black-on-gold":<span class="cov0" title="0"> baseFG, bg, wrap = "black", "yellow", true</span> case "white-on-blue", "blue", "white-on-navy":<span class="cov0" title="0"> baseFG, bg, wrap = "white", "blue", true</span> } - <span class="cov8" title="78">if baseFG == "" </span><span class="cov0" title="0">{ // no theme selected + <span class="cov8" title="77">if baseFG == "" </span><span class="cov0" title="0">{ // no theme selected baseFG = "default" }</span> } // Theme-aware arrow styles - <span class="cov8" title="78">upStyle, downStyle := "#[fg=colour3]", "#[fg=colour2]" // defaults: yellow up, green down - if fg != "" || bg != "" </span><span class="cov8" title="78">{ // explicit override path: match arrows to base fg, bold for visibility + <span class="cov8" title="77">upStyle, downStyle := "#[fg=colour3]", "#[fg=colour2]" // defaults: yellow up, green down + if fg != "" || bg != "" </span><span class="cov8" title="77">{ // explicit override path: match arrows to base fg, bold for visibility upStyle = "#[bold,fg=" + baseFG + "]" downStyle = upStyle }</span> else<span class="cov0" title="0"> { @@ -7948,30 +9681,30 @@ func applyTheme(s string) string <span class="cov8" title="78">{ } // Replace base-foreground and arrow placeholders with selected styles - <span class="cov8" title="78">if strings.Contains(s, baseFGToken) </span><span class="cov8" title="78">{ + <span class="cov8" title="77">if strings.Contains(s, baseFGToken) </span><span class="cov8" title="77">{ s = strings.ReplaceAll(s, baseFGToken, "#[fg="+baseFG+"]") }</span> - <span class="cov8" title="78">if strings.Contains(s, arrowUpToken) </span><span class="cov7" title="66">{ + <span class="cov8" title="77">if strings.Contains(s, arrowUpToken) </span><span class="cov7" title="65">{ s = strings.ReplaceAll(s, arrowUpToken, upStyle) }</span> - <span class="cov8" title="78">if strings.Contains(s, arrowDownToken) </span><span class="cov7" title="66">{ + <span class="cov8" title="77">if strings.Contains(s, arrowDownToken) </span><span class="cov7" title="65">{ s = strings.ReplaceAll(s, arrowDownToken, downStyle) }</span> - <span class="cov8" title="78">if !wrap </span><span class="cov0" title="0">{ + <span class="cov8" title="77">if !wrap </span><span class="cov0" title="0">{ return s }</span> // Wrap with base fg and optional bg, then reset at the end - <span class="cov8" title="78">prefix := "#[fg=" + baseFG - if bg != "" </span><span class="cov8" title="78">{ + <span class="cov8" title="77">prefix := "#[fg=" + baseFG + if bg != "" </span><span class="cov8" title="77">{ prefix += ",bg=" + bg }</span> - <span class="cov8" title="78">prefix += "]" + <span class="cov8" title="77">prefix += "]" return prefix + s + "#[fg=default,bg=default]"</span> } </pre> - <pre class="file" id="file41" style="display: none">package tmux + <pre class="file" id="file42" style="display: none">package tmux import ( "os" @@ -7989,10 +9722,10 @@ var ( command = exec.Command ) -func HasBinary() bool <span class="cov10" title="79">{ _, err := lookPath("tmux"); return err == nil }</span> +func HasBinary() bool <span class="cov10" title="78">{ _, err := lookPath("tmux"); return err == nil }</span> // InSession reports whether we seem to be running inside a tmux session. -func InSession() bool <span class="cov9" title="78">{ return strings.TrimSpace(os.Getenv("TMUX")) != "" }</span> +func InSession() bool <span class="cov9" title="77">{ return strings.TrimSpace(os.Getenv("TMUX")) != "" }</span> // SplitOpts controls how a new pane is created for running a command. type SplitOpts struct { |
