summaryrefslogtreecommitdiff
path: root/docs/coverage.html
diff options
context:
space:
mode:
Diffstat (limited to 'docs/coverage.html')
-rw-r--r--docs/coverage.html3593
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(&amp;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) &gt; 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 &lt; 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 &lt; 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: &gt;text&gt; and &gt;&gt;text&gt;)
+ // Inline prompt trigger characters (default: &gt;!text&gt; and &gt;&gt;!text&gt;)
InlineOpen string `json:"inline_open" toml:"inline_open"`
InlineClose string `json:"inline_close" toml:"inline_close"`
// In-editor chat triggers (default: suffix "&gt;" 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: "&gt;",
+ InlineOpen: "&gt;!",
InlineClose: "&gt;",
ChatSuffix: "&gt;",
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 &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; 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) == "" &amp;&amp; strings.TrimSpace(s.BaseURL) == "" &amp;&amp; s.Temperature == nil &amp;&amp; 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(&amp;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) &gt; 0 </span><span class="cov2" title="3">{
+ <span class="cov4" title="23">if len(fc.Triggers.TriggerCharacters) &gt; 0 </span><span class="cov2" title="4">{
tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters}
out.mergeBasics(&amp;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(&amp;tmp)
}</span>
// chat
- <span class="cov5" title="30">if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) &gt; 0 </span><span class="cov1" title="1">{
+ <span class="cov4" title="23">if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) &gt; 0 </span><span class="cov1" title="1">{
tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes}
out.mergeBasics(&amp;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(&amp;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(&amp;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(&amp;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) &gt; 0 </span><span class="cov4" title="17">{
+ len(fc.Prompts.CodeAction.Custom) &gt; 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) &gt; 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) &gt; 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 &gt; 0 </span><span class="cov0" title="0">{
+ <span class="cov4" title="23">if fc.Stats.WindowMinutes &gt; 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) &amp;&amp; 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(&amp;tables)
// Raw map for validation/presence checks
var raw map[string]any
_ = toml.Unmarshal(b, &amp;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 &amp;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 &amp;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(&amp;out.CompletionConfigs, "models.completion", table["completion"])
+ if ok := appendEntries(&amp;out.CodeActionConfigs, "models.code_action", table["code_action"]); ok </span><span class="cov2" title="4">{
+ if len(out.CodeActionConfigs) &gt; 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(&amp;out.ChatConfigs, "models.chat", table["chat"]) || any
+ any = appendEntries(&amp;out.CLIConfigs, "models.cli", table["cli"]) || any
+ if !any </span><span class="cov0" title="0">{
+ return nil
+ }</span>
+ <span class="cov2" title="4">return &amp;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) &gt; 0</span>
+ default:<span class="cov0" title="0">
+ if cfg, ok := decodeModelEntry(v, path, logger); ok &amp;&amp; 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 &amp;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 == "" &amp;&amp; tempPtr == nil &amp;&amp; provider == "" </span><span class="cov0" title="0">{
+ return nil, false
+ }</span>
+ <span class="cov4" title="14">return &amp;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 &amp;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 &gt; 0 </span><span class="cov5" title="26">{
+func (a *App) mergeBasics(other *App) <span class="cov6" title="59">{
+ if other.MaxTokens &gt; 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 &gt; 0 </span><span class="cov3" title="7">{
+ <span class="cov6" title="59">if other.ContextWindowLines &gt; 0 </span><span class="cov3" title="8">{
a.ContextWindowLines = other.ContextWindowLines
}</span>
- <span class="cov6" title="72">if other.MaxContextTokens &gt; 0 </span><span class="cov3" title="7">{
+ <span class="cov6" title="59">if other.MaxContextTokens &gt; 0 </span><span class="cov3" title="8">{
a.MaxContextTokens = other.MaxContextTokens
}</span>
- <span class="cov6" title="72">if other.LogPreviewLimit &gt;= 0 </span><span class="cov6" title="72">{
+ <span class="cov6" title="59">if other.LogPreviewLimit &gt;= 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 &gt;= 0 </span><span class="cov6" title="72">{
+ <span class="cov6" title="59">if other.ManualInvokeMinPrefix &gt;= 0 </span><span class="cov6" title="59">{
a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix
}</span>
- <span class="cov6" title="72">if other.CompletionDebounceMs &gt; 0 </span><span class="cov3" title="7">{
+ <span class="cov6" title="59">if other.CompletionDebounceMs &gt; 0 </span><span class="cov3" title="8">{
a.CompletionDebounceMs = other.CompletionDebounceMs
}</span>
- <span class="cov6" title="72">if other.CompletionThrottleMs &gt; 0 </span><span class="cov3" title="7">{
+ <span class="cov6" title="59">if other.CompletionThrottleMs &gt; 0 </span><span class="cov3" title="8">{
a.CompletionThrottleMs = other.CompletionThrottleMs
}</span>
- <span class="cov6" title="72">if len(other.TriggerCharacters) &gt; 0 </span><span class="cov3" title="7">{
+ <span class="cov6" title="59">if len(other.TriggerCharacters) &gt; 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) &gt; 0 </span><span class="cov1" title="2">{
+ <span class="cov6" title="59">if len(other.ChatPrefixes) &gt; 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) &gt; 0 </span><span class="cov3" title="7">{
+ a.CompletionConfigs = cloneSurfaceConfigs(other.CompletionConfigs)
+ }</span>
+ <span class="cov5" title="34">if len(other.CodeActionConfigs) &gt; 0 </span><span class="cov3" title="7">{
+ a.CodeActionConfigs = cloneSurfaceConfigs(other.CodeActionConfigs)
+ }</span>
+ <span class="cov5" title="34">if len(other.ChatConfigs) &gt; 0 </span><span class="cov3" title="6">{
+ a.ChatConfigs = cloneSurfaceConfigs(other.ChatConfigs)
+ }</span>
+ <span class="cov5" title="34">if len(other.CLIConfigs) &gt; 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) &gt; 0 </span><span class="cov4" title="16">{
+ <span class="cov5" title="30">if len(other.CustomActions) &gt; 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 != "" &amp;&amp; scope != "selection" &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; !hasUser </span><span class="cov0" title="0">{
+ <span class="cov3" title="6">if !hasInstr &amp;&amp; !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 &amp;f, true</span>
+ <span class="cov3" title="10">return &amp;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 == "" &amp;&amp; !forceUsed </span><span class="cov0" title="0">{
+ <span class="cov2" title="3">if providerLower == "" &amp;&amp; !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 == "" &amp;&amp; !genericUsed </span><span class="cov0" title="0">{
+ <span class="cov3" title="9">if providerLower == "" &amp;&amp; !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 == "" &amp;&amp; provider == "" &amp;&amp; !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 &amp;out</span>
+ <span class="cov3" title="9">return &amp;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" &amp;&amp; strings.HasPrefix(strings.ToLower(model), "gpt-5") &amp;&amp; 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" &amp;&amp; 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 &lt;= 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" &amp;&amp; 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 &gt; 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 &gt; 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) &gt; 0 </span><span class="cov1" title="1">{
+ <span class="cov6" title="4">if len(cfg.CustomActions) &gt; 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) &gt; 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 &gt; 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" &amp;&amp; strings.HasPrefix(strings.ToLower(model), "gpt-5") &amp;&amp; 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" &amp;&amp; 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 &gt; 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) &gt; 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) &gt; 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(&amp;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, &amp;errBuf)
+ if printer != nil </span><span class="cov5" title="4">{
+ printer.Flush(job.index)
+ }</span>
+ <span class="cov5" title="4">results[job.index] = &amp;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 &amp;&amp; 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 &lt;= 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 &lt; 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 &amp;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 &lt; 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 != "" &amp;&amp; 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 &lt; 0 || idx &gt;= 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 &lt; 0 || idx &gt;= 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 &gt;= 0 &amp;&amp; idx &lt; 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) &lt;= 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 &gt; cp.colWidth &amp;&amp; current.Len() &gt; 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() &gt; 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) &lt; 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 &lt; cp.columns; i++ </span><span class="cov9" title="12">{
+ cell := cells[i]
+ width := runewidth.StringWidth(cell)
+ if width &gt; 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 &gt; 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 &lt; 0 || idx &gt;= 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 &lt;= 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 &gt; 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(&amp;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 &gt; 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) &gt; 0 </span><span class="cov3" title="2">{
+ if len(cfg.CustomActions) &gt; 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: &amp;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: &amp;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(&amp;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(&amp;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 = &amp;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 = &amp;t
}</span>
- <span class="cov9" title="15">if o.MaxTokens &gt; 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 &gt; 0 </span><span class="cov6" title="8">{
+ if requiresMaxCompletionTokens(o.Model) </span><span class="cov2" title="2">{
req.MaxCompletionTokens = &amp;o.MaxTokens
- }</span> else<span class="cov1" title="1"> {
+ }</span> else<span class="cov5" title="6"> {
req.MaxTokens = &amp;o.MaxTokens
}</span>
}
- <span class="cov9" title="15">if len(o.Stop) &gt; 0 </span><span class="cov0" title="0">{
+ <span class="cov9" title="22">if len(o.Stop) &gt; 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 = &amp;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 &gt;= 200 &amp;&amp; resp.StatusCode &lt; 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 &gt;= 200 &amp;&amp; resp.StatusCode &lt; 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(&amp;apiErr)
if apiErr.Error != nil &amp;&amp; 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(&amp;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), &amp;chunk); err != nil </span><span class="cov3" title="2">{
+ <span class="cov6" title="7">var chunk oaStreamChunk
+ if err := json.Unmarshal([]byte(payload), &amp;chunk); err != nil </span><span class="cov2" title="2">{
continue</span>
}
- <span class="cov5" title="4">if chunk.Error != nil &amp;&amp; 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 &amp;&amp; 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: &amp;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(&amp;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(&amp;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 = &amp;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 = &amp;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 = &amp;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 &gt; 0 </span><span class="cov2" title="3">{
if len(s) &lt;= 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?&gt;", trimmed)}, true</span>
}
@@ -3834,6 +5014,8 @@ func (s *Server) handleHelpCommand() chatCommandResult <span class="cov1" title=
lines := []string{
"Available slash commands:",
"- /reload?&gt; reload configuration from file (ignores env overrides)",
+ "- /disable?&gt; disable auto-completions for this session",
+ "- /enable?&gt; 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?&gt; 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 &gt;= 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 &amp;&amp; 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 &gt;= 0 </span><span class="cov2" title="2">{
+ <span class="cov8" title="24">if i := strings.Index(line, "/*"); i &gt;= 0 </span><span class="cov2" title="2">{
if j := strings.Index(line[i+2:], "*/"); j &gt;= 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, "&lt;!--"); i &gt;= 0 </span><span class="cov2" title="2">{
+ <span class="cov8" title="24">if i := strings.Index(line, "&lt;!--"); i &gt;= 0 </span><span class="cov2" title="2">{
if j := strings.Index(line[i+4:], "--&gt;"); j &gt;= 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 &gt;= 0 </span><span class="cov4" title="4">{
+ <span class="cov8" title="24">if i := strings.Index(line, "//"); i &gt;= 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 &gt;= 0 </span><span class="cov2" title="2">{
+ <span class="cov8" title="24">if i := strings.Index(line, "#"); i &gt;= 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 &gt;= 0 </span><span class="cov4" title="4">{
+ <span class="cov8" title="24">if i := strings.Index(line, "--"); i &gt;= 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 &gt;= 0 &amp;&amp; (best.start &lt; 0 || c.start &lt; 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 &gt; 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 &lt; len(current) </span><span class="cov1" title="1">{
+ if idx &lt; 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) &gt; 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, &amp;ctx)
}</span> else<span class="cov1" title="1"> {
b, _ := json.Marshal(p.Context)
_ = json.Unmarshal(b, &amp;ctx)
}</span>
- // If configured and the line contains a bare double-open marker (e.g., '&gt;&gt;' with no '&gt;&gt;text&gt;'),
+ // If configured and the line contains a bare double-open marker (e.g., '&gt;&gt;!' with no '&gt;&gt;!text&gt;'),
// do not treat as a trigger source.
- <span class="cov7" title="11">if open != "" &amp;&amp; strings.Contains(current, open+open) &amp;&amp; !hasDoubleOpenTrigger(current, openChar, closeChar) </span><span class="cov2" title="2">{
+ <span class="cov6" title="11">if containsAny(current, doubleSeqs) &amp;&amp; !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 &lt;= 0 || idx &gt; 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 != "" &amp;&amp; strings.Contains(current, open+open) &amp;&amp; !hasDoubleOpenTrigger(current, openChar, closeChar) </span><span class="cov3" title="3">{
+ <span class="cov7" title="15">if containsAny(current, doubleSeqs) &amp;&amp; !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 = &amp;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 &gt; 0 </span><span class="cov2" title="2">{
+ if idx &gt; 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 &gt; 0 </span><span class="cov3" title="4">{
+ <span class="cov2" title="2">if i := strings.Index(rest, "("); i &gt; 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] &gt;= 'a' &amp;&amp; r[0] &lt;= '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, &amp;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 &lt;- 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 := &lt;-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 &lt;- 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 := &lt;-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 &amp;&amp; !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 &amp;&amp; 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) &gt; 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 &amp;&amp; !s.prefixHeuristicAllows(plan.inlinePrompt, current, p, plan.manualInvoke) </span><span class="cov0" title="0">{
+ <span class="cov6" title="11">if !plan.inParams &amp;&amp; !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 &amp;&amp; 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 &gt; len(current) </span><span class="cov0" title="0">{
idx = len(current)
}</span>
- <span class="cov7" title="15">allowNoPrefix := inlinePrompt
- if idx &gt; 0 </span><span class="cov7" title="13">{
+ <span class="cov7" title="16">allowNoPrefix := inlinePrompt
+ if idx &gt; 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 &gt; 0 </span><span class="cov7" title="13">{
+ <span class="cov6" title="8">j := idx
+ for j &gt; 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 &gt;= 0 </span><span class="cov4" title="5">{
min = v
}</span>
}
- <span class="cov5" title="7">return j-start &gt;= min</span>
+ <span class="cov6" title="8">return j-start &gt;= 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 &amp;&amp; len(suggestions) &gt; 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 != "" &amp;&amp; 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) != "" &amp;&amp; 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 &lt;= 0 </span><span class="cov9" title="40">{
+ if d &lt;= 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 &lt;= 0 </span><span class="cov9" title="39">{
+ if interval &lt;= 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 != "" &amp;&amp; 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 != "" &amp;&amp; hasDoubleOpenTrigger(currentLine, openChar, closeChar) </span><span class="cov2" title="2">{
+ <span class="cov7" title="12">openStr, _, openChar, closeChar := s.inlineMarkers()
+ if cleaned != "" &amp;&amp; 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 &gt;= 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 &lt; 0 </span><span class="cov4" title="3">{
+ <span class="cov9" title="23">if j &lt; 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) &gt;= 2 &amp;&amp; suffixChar != 0 &amp;&amp; trim[len(trim)-1] == suffixChar </span><span class="cov5" title="5">{
+ <span class="cov9" title="21">_, prefixes, suffixChar := s.chatConfig()
+ if len(trim) &gt;= 2 &amp;&amp; suffixChar != 0 &amp;&amp; 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" &amp;&amp; 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" &amp;&amp; strings.HasPrefix(strings.ToLower(effectiveModel), "gpt-5") &amp;&amp; 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" &amp;&amp; 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 &gt; 0 </span><span class="cov7" title="42">{
avgSent = s.llmSentBytesTotal / s.llmReqTotal
}</span>
<span class="cov7" title="42">avgRecv := int64(0)
- if s.llmRespTotal &gt; 0 </span><span class="cov7" title="39">{
+ if s.llmRespTotal &gt; 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 &lt;= 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 &gt;= 0 &amp;&amp; cursor &gt; open &amp;&amp; (close == -1 || cursor &lt;= 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 := &amp;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 &gt; len(current) </span><span class="cov0" title="0">{
at = len(current)
}</span>
- <span class="cov6" title="26">for at &gt; 0 </span><span class="cov8" title="51">{
+ <span class="cov6" title="27">for at &gt; 0 </span><span class="cov7" title="54">{
ch := current[at-1]
- if (ch &gt;= 'a' &amp;&amp; ch &lt;= 'z') || (ch &gt;= 'A' &amp;&amp; ch &lt;= 'Z') || (ch &gt;= '0' &amp;&amp; ch &lt;= '9') || ch == '_' </span><span class="cov7" title="31">{
+ if (ch &gt;= 'a' &amp;&amp; ch &lt;= 'z') || (ch &gt;= 'A' &amp;&amp; ch &lt;= 'Z') || (ch &gt;= '0' &amp;&amp; ch &lt;= '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 != "" &amp;&amp; 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 != "" &amp;&amp; open != 0 </span><span class="cov10" title="226">{
+ seq := string(open) + openStr
+ if len(seq) &gt; 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 &lt; 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 &gt;text&gt; (configurable), with no space after the first
+// findStrictInlineTag finds &gt;!text&gt; (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 &lt; len(line) </span><span class="cov9" title="89">{
- // find opening marker
- j := strings.IndexByte(line[pos:], open)
- if j &lt; 0 </span><span class="cov7" title="39">{
+ for pos &lt; len(line) </span><span class="cov8" title="90">{
+ j := strings.IndexByte(line[pos:], openChar)
+ if j &lt; 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 &gt;= 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 &lt; 0 </span><span class="cov1" title="1">{
+ <span class="cov6" title="24">contentStart := j + len(openStr)
+ if contentStart &gt;= 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 &lt; 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 &gt;= 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 &amp;&amp; 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 &lt; 0 </span><span class="cov0" title="0">{
+ return "", 0, 0, false
+ }</span>
+ <span class="cov5" title="17">closeIdx := contentStart + k
+ if closeIdx-1 &gt;= contentStart &amp;&amp; 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 &gt;= 0 &amp;&amp; (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') </span><span class="cov6" title="20">{
+ for start &gt;= 0 &amp;&amp; (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 &gt;= 0 </span><span class="cov2" title="2">{
+ <span class="cov5" title="17">if idx := strings.LastIndex(prefixBeforeCursor, "="); idx &gt;= 0 </span><span class="cov2" title="2">{
if !(idx &gt; 0 &amp;&amp; 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 != "" &amp;&amp; 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 &gt; 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 &gt; 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 &gt;= 'a' &amp;&amp; ch &lt;= 'z') || (ch &gt;= 'A' &amp;&amp; ch &lt;= 'Z') || (ch &gt;= '0' &amp;&amp; ch &lt;= '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 &lt; 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 != "" &amp;&amp; !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 &lt; 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 &lt; 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 &lt; 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 &lt; 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 &gt;= 0 </span><span class="cov6" title="25">{
+ abs := pos + idx
+ if found &lt; 0 || abs &lt; found </span><span class="cov6" title="25">{
+ found = abs
+ seq = cand
+ }</span>
+ }
+ }
+ <span class="cov8" title="86">if found &lt; 0 </span><span class="cov7" title="62">{
return false
}</span>
- <span class="cov6" title="27">j += pos
- contentStart := j + len(dbl)
- if contentStart &gt;= len(line) </span><span class="cov4" title="8">{
+ <span class="cov6" title="24">contentStart := found + len(seq)
+ if contentStart &gt;= 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 &gt;= 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 &lt; 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 &gt;= 0 &amp;&amp; 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 &lt; 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 &lt; len(line) </span><span class="cov5" title="18">{
+ j := strings.Index(line[start:], openStr)
if j &lt; 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 &lt; 0 </span><span class="cov0" title="0">{
+ <span class="cov3" title="6">j += start
+ contentStart := j + len(openStr)
+ if contentStart &gt;= len(line) </span><span class="cov0" title="0">{
break</span>
}
- <span class="cov4" title="6">if j+1 &gt;= 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 &lt; 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 &lt; 0 </span><span class="cov0" title="0">{
+ break</span>
+ }
+ <span class="cov3" title="6">closeIdx := contentStart + k
+ if closeIdx-1 &lt; contentStart || line[closeIdx-1] == ' ' </span><span class="cov0" title="0">{
+ start = closeIdx + 1
continue</span>
}
- <span class="cov4" title="6">if closeIdx-(j+1) &lt; 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 &lt; len(line) &amp;&amp; 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 := &amp;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 &amp;&amp; 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 &amp;&amp; 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 &lt;= 0 </span><span class="cov6" title="30">{
+ if cfg.MaxTokens &lt;= 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 &lt;= 0 </span><span class="cov6" title="40">{
+ if cfg.CompletionDebounceMs &lt;= 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 &lt;= 0 </span><span class="cov6" title="39">{
+ if cfg.CompletionThrottleMs &lt;= 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 = "&gt;"
+ open = "&gt;!"
}</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 = "&gt;"
@@ -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 = '&gt;'
- if len(suffix) &gt; 0 </span><span class="cov6" title="45">{
+ <span class="cov6" title="51">suffixChar = '&gt;'
+ if len(suffix) &gt; 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 &lt; typ.NumField(); i++ </span><span class="cov10" title="564">{
+ for i := 0; i &lt; 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 &gt;= 0 </span><span class="cov0" title="0">{
+ <span class="cov9" title="448">if idx := strings.Index(key, ","); idx &gt;= 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 &lt; 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 &lt; time.Second </span><span class="cov0" title="0">{
d = time.Second
}</span>
- <span class="cov4" title="83">if d &gt; 24*time.Hour </span><span class="cov0" title="0">{
+ <span class="cov5" title="83">if d &gt; 24*time.Hour </span><span class="cov0" title="0">{
d = 24 * time.Hour
}</span>
- <span class="cov4" title="83">atomic.StoreInt64(&amp;windowSeconds, int64(d.Seconds()))</span>
+ <span class="cov5" title="83">atomic.StoreInt64(&amp;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(&amp;windowSeconds)) * time.Second }</span>
+func Window() time.Duration <span class="cov5" title="88">{ return time.Duration(atomic.LoadInt64(&amp;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, &amp;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) &gt; 0 </span><span class="cov4" title="77">{
+ if len(sf.Events) &gt; 0 </span><span class="cov5" title="88">{
// Find first &gt;= cutoff
i := 0
- for ; i &lt; 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 &lt; 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 &gt; 0 </span><span class="cov1" title="1">{
+ <span class="cov5" title="88">if i &gt; 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(&amp;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 &lt;-ctx.Done():<span class="cov0" title="0">
return nil, ctx.Err()</span>
- case &lt;-time.After(5 * time.Millisecond):<span class="cov5" title="136"></span>
+ case &lt;-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, &amp;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 &lt;= 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 &lt;= 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 &lt; j &amp;&amp; (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 &gt; i &amp;&amp; (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 &gt; i &amp;&amp; (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 &amp;&amp; j == len(s) </span><span class="cov5" title="148">{
+ <span class="cov6" title="159">if i == 0 &amp;&amp; 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 -&gt; 999B, 1200 -&gt; 1.2k, 1540000 -&gt; 1.5M
-func HumanBytes(n int64) string <span class="cov10" title="140">{
+func HumanBytes(n int64) string <span class="cov10" title="138">{
if n &lt; 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 &gt;= unit &amp;&amp; i &lt; len(suffix)-1 </span><span class="cov9" title="138">{
+ for v &gt;= unit &amp;&amp; i &lt; 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) &gt;= 3 &amp;&amp; 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 &lt; len(lines) &amp;&amp; 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 &gt;= 0 &amp;&amp; strings.TrimSpace(lines[end]) == "" </span><span class="cov0" title="0">{
end--
}</span>
- <span class="cov8" title="69">if start &gt;= len(lines) || end &lt; 0 || start &gt; end </span><span class="cov0" title="0">{
+ <span class="cov8" title="71">if start &gt;= len(lines) || end &lt; 0 || start &gt; 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, "```") &amp;&amp; last == "```" &amp;&amp; end &gt; 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 &gt; 0 </span><span class="cov1" title="1">{
if len(head) &lt;= ml &amp;&amp; len(head)+len(tail) &gt; 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 &lt;= 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 &gt;= 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 &lt; j &amp;&amp; (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 &gt; i &amp;&amp; (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 &gt; i &amp;&amp; (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 &amp;&amp; j == len(s) </span><span class="cov10" title="269">{
+ <span class="cov10" title="265">if i == 0 &amp;&amp; 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 {