From eb72b06fe8e62cb77af73f6dc558d384a5a5fe80 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 19 Sep 2025 22:52:48 +0300 Subject: fix --- docs/coverage.html | 1347 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 777 insertions(+), 570 deletions(-) (limited to 'docs/coverage.html') diff --git a/docs/coverage.html b/docs/coverage.html index 4c7532e..6828b9a 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -61,7 +61,7 @@ - + @@ -69,9 +69,9 @@ - + - + @@ -79,7 +79,7 @@ - + @@ -87,7 +87,7 @@ - + @@ -107,7 +107,7 @@ - + @@ -115,23 +115,25 @@ - + - + - + - + - + - + - + - + - + + + @@ -347,7 +349,7 @@ type CustomAction struct { } // Constructor: defaults for App (kept first among functions) -func newDefaultConfig() App { +func newDefaultConfig() App { // Coding-friendly default temperature across providers // Users can override per provider in config.toml (including 0.0). t := 0.2 @@ -403,18 +405,18 @@ func newDefaultConfig() App { // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. -func Load(logger *log.Logger) App { +func Load(logger *log.Logger) App { cfg := newDefaultConfig() if logger == nil { return cfg // Return defaults if no logger is provided (e.g. in tests) } - configPath, err := getConfigPath() + configPath, err := getConfigPath() if err != nil { logger.Printf("%v", err) // Even if config path cannot be resolved, still allow env overrides below. - } else { - if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { + } else { + if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { cfg.mergeWith(fileCfg) } // When the config file is missing or invalid, we keep defaults and still @@ -422,10 +424,10 @@ func Load(logger *log.Logger) App { } // Environment overrides (take precedence over file) - if envCfg := loadFromEnv(logger); envCfg != nil { + if envCfg := loadFromEnv(logger); envCfg != nil { cfg.mergeWith(envCfg) } - return cfg + return cfg } // Private helpers @@ -488,9 +490,36 @@ type sectionStats struct { } type sectionOpenAI struct { - Model string `toml:"model"` - BaseURL string `toml:"base_url"` - Temperature *float64 `toml:"temperature"` + Model string `toml:"model"` + BaseURL string `toml:"base_url"` + Temperature *float64 `toml:"temperature"` + Presets map[string]string `toml:"presets"` +} + +func (s sectionOpenAI) isZero() bool { + return strings.TrimSpace(s.Model) == "" && strings.TrimSpace(s.BaseURL) == "" && s.Temperature == nil && len(s.Presets) == 0 +} + +func (s sectionOpenAI) resolvedModel() string { + model := strings.TrimSpace(s.Model) + if model == "" { + return "" + } + if len(s.Presets) == 0 { + return model + } + if mapped := strings.TrimSpace(s.Presets[model]); mapped != "" { + return mapped + } + lower := strings.ToLower(model) + for k, v := range s.Presets { + if strings.ToLower(strings.TrimSpace(k)) == lower { + if mapped := strings.TrimSpace(v); mapped != "" { + return mapped + } + } + } + return model } type sectionCopilot struct { @@ -565,7 +594,7 @@ type sectionTmux struct { CustomMenuHotkey string `toml:"custom_menu_hotkey"` } -func (fc *fileConfig) toApp() App { +func (fc *fileConfig) toApp() App { out := App{} // Merge section: general @@ -581,13 +610,13 @@ func (fc *fileConfig) toApp() App { } // logging - if (fc.Logging != sectionLogging{}) { + if (fc.Logging != sectionLogging{}) { tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit} out.mergeBasics(&tmp) } // completion - if (fc.Completion != sectionCompletion{}) { + if (fc.Completion != sectionCompletion{}) { tmp := App{ CompletionDebounceMs: fc.Completion.CompletionDebounceMs, CompletionThrottleMs: fc.Completion.CompletionThrottleMs, @@ -597,41 +626,41 @@ func (fc *fileConfig) toApp() App { } // triggers - if len(fc.Triggers.TriggerCharacters) > 0 { + if len(fc.Triggers.TriggerCharacters) > 0 { tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters} out.mergeBasics(&tmp) } // inline - if (fc.Inline != sectionInline{}) { + if (fc.Inline != sectionInline{}) { tmp := App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose} out.mergeBasics(&tmp) } // chat - if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 { + if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 { tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes} out.mergeBasics(&tmp) } // provider - if strings.TrimSpace(fc.Provider.Name) != "" { + if strings.TrimSpace(fc.Provider.Name) != "" { tmp := App{Provider: fc.Provider.Name} out.mergeBasics(&tmp) } // openai - if (fc.OpenAI != sectionOpenAI{}) || fc.OpenAI.Temperature != nil { + if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil { tmp := App{ OpenAIBaseURL: fc.OpenAI.BaseURL, - OpenAIModel: fc.OpenAI.Model, + OpenAIModel: fc.OpenAI.resolvedModel(), OpenAITemperature: fc.OpenAI.Temperature, } out.mergeProviderFields(&tmp) } // copilot - if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil { + if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil { tmp := App{ CopilotBaseURL: fc.Copilot.BaseURL, CopilotModel: fc.Copilot.Model, @@ -641,7 +670,7 @@ func (fc *fileConfig) toApp() App { } // ollama - if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil { + if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil { tmp := App{ OllamaBaseURL: fc.Ollama.BaseURL, OllamaModel: fc.Ollama.Model, @@ -652,7 +681,7 @@ func (fc *fileConfig) toApp() App { // prompts // completion - if (fc.Prompts.Completion != sectionPromptsCompletion{}) { + if (fc.Prompts.Completion != sectionPromptsCompletion{}) { if strings.TrimSpace(fc.Prompts.Completion.SystemGeneral) != "" { out.PromptCompletionSystemGeneral = fc.Prompts.Completion.SystemGeneral } @@ -673,11 +702,11 @@ func (fc *fileConfig) toApp() App { } } // chat - if strings.TrimSpace(fc.Prompts.Chat.System) != "" { + if strings.TrimSpace(fc.Prompts.Chat.System) != "" { out.PromptChatSystem = fc.Prompts.Chat.System } // code action - if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" || + 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) != "" || @@ -687,39 +716,39 @@ func (fc *fileConfig) toApp() App { strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" || strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" || strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" || - len(fc.Prompts.CodeAction.Custom) > 0 { + len(fc.Prompts.CodeAction.Custom) > 0 { if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" { out.PromptCodeActionRewriteSystem = fc.Prompts.CodeAction.RewriteSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" { out.PromptCodeActionDiagnosticsSystem = fc.Prompts.CodeAction.DiagnosticsSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" { out.PromptCodeActionDocumentSystem = fc.Prompts.CodeAction.DocumentSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" { out.PromptCodeActionRewriteUser = fc.Prompts.CodeAction.RewriteUser } - if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" { out.PromptCodeActionDiagnosticsUser = fc.Prompts.CodeAction.DiagnosticsUser } - if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" { out.PromptCodeActionDocumentUser = fc.Prompts.CodeAction.DocumentUser } - if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" { out.PromptCodeActionGoTestSystem = fc.Prompts.CodeAction.GoTestSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" { out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser } - if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" { out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem } - if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" { out.PromptCodeActionSimplifyUser = fc.Prompts.CodeAction.SimplifyUser } - if len(fc.Prompts.CodeAction.Custom) > 0 { - for _, ca := range fc.Prompts.CodeAction.Custom { + if len(fc.Prompts.CodeAction.Custom) > 0 { + for _, ca := range fc.Prompts.CodeAction.Custom { out.CustomActions = append(out.CustomActions, CustomAction{ ID: strings.TrimSpace(ca.ID), Title: strings.TrimSpace(ca.Title), @@ -734,7 +763,7 @@ func (fc *fileConfig) toApp() App { } } // cli - if (fc.Prompts.CLI != sectionPromptsCLI{}) { + if (fc.Prompts.CLI != sectionPromptsCLI{}) { if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" { out.PromptCLIDefaultSystem = fc.Prompts.CLI.DefaultSystem } @@ -743,46 +772,46 @@ func (fc *fileConfig) toApp() App { } } // provider-native - if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" { + if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" { out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion } // tmux - if (fc.Tmux != sectionTmux{}) { + if (fc.Tmux != sectionTmux{}) { out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) } // stats - if fc.Stats.WindowMinutes > 0 { + if fc.Stats.WindowMinutes > 0 { out.StatsWindowMinutes = fc.Stats.WindowMinutes } - return out + return out } -func loadFromFile(path string, logger *log.Logger) (*App, error) { +func loadFromFile(path string, logger *log.Logger) (*App, error) { b, err := os.ReadFile(path) - if err != nil { + if err != nil { if !os.IsNotExist(err) && logger != nil { logger.Printf("cannot open TOML config file %s: %v", path, err) } - return nil, err + return nil, err } - var tables fileConfig + var tables fileConfig errTables := toml.NewDecoder(strings.NewReader(string(b))).Decode(&tables) // Raw map for validation/presence checks var raw map[string]any _ = toml.Unmarshal(b, &raw) - if errTables != nil { - if logger != nil { + if errTables != nil { + if logger != nil { logger.Printf("invalid TOML config file %s: %v", path, errTables) } - return nil, errTables + return nil, errTables } // Reject legacy flat keys at top-level (sectioned-only config is allowed) - legacy := map[string]struct{}{ + 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": {}, @@ -791,8 +820,8 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) { - if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable { + for k := range raw { + if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable { continue } if _, isLegacy := legacy[k]; isLegacy { @@ -800,13 +829,13 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) if logger != nil { + if logger != nil { logger.Printf("loaded configuration from %s (TOML)", path) } // Merge order: flat first, then tables (so tables win over zero flat values) // Build App from tables only - tab := tables.toApp() + tab := tables.toApp() // Ensure explicit values from raw map are respected (defensive for ints) if t, ok := raw["completion"].(map[string]any); ok { if v, present := t["manual_invoke_min_prefix"]; present { @@ -820,7 +849,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) if t, ok := raw["logging"].(map[string]any); ok { + if t, ok := raw["logging"].(map[string]any); ok { if v, present := t["log_preview_limit"]; present { switch vv := v.(type) { case int64: @@ -832,10 +861,10 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) return &tab, nil + return &tab, nil } -func (a *App) mergeWith(other *App) { +func (a *App) mergeWith(other *App) { a.mergeBasics(other) a.mergeProviderFields(other) a.mergePrompts(other) @@ -873,95 +902,95 @@ func (a *App) mergeBasics(other *App) { if len(other.TriggerCharacters) > 0 { a.TriggerCharacters = slices.Clone(other.TriggerCharacters) } - if s := strings.TrimSpace(other.InlineOpen); s != "" { + if s := strings.TrimSpace(other.InlineOpen); s != "" { a.InlineOpen = s } - if s := strings.TrimSpace(other.InlineClose); s != "" { + if s := strings.TrimSpace(other.InlineClose); s != "" { a.InlineClose = s } - if s := strings.TrimSpace(other.ChatSuffix); s != "" { + if s := strings.TrimSpace(other.ChatSuffix); s != "" { a.ChatSuffix = s } - if len(other.ChatPrefixes) > 0 { + if len(other.ChatPrefixes) > 0 { a.ChatPrefixes = slices.Clone(other.ChatPrefixes) } - if s := strings.TrimSpace(other.Provider); s != "" { + if s := strings.TrimSpace(other.Provider); s != "" { a.Provider = s } } // mergePrompts copies non-empty prompt templates from other. -func (a *App) mergePrompts(other *App) { +func (a *App) mergePrompts(other *App) { // Completion if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" { a.PromptCompletionSystemGeneral = other.PromptCompletionSystemGeneral } - if strings.TrimSpace(other.PromptCompletionSystemParams) != "" { + if strings.TrimSpace(other.PromptCompletionSystemParams) != "" { a.PromptCompletionSystemParams = other.PromptCompletionSystemParams } - if strings.TrimSpace(other.PromptCompletionSystemInline) != "" { + if strings.TrimSpace(other.PromptCompletionSystemInline) != "" { a.PromptCompletionSystemInline = other.PromptCompletionSystemInline } - if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" { + if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" { a.PromptCompletionUserGeneral = other.PromptCompletionUserGeneral } - if strings.TrimSpace(other.PromptCompletionUserParams) != "" { + if strings.TrimSpace(other.PromptCompletionUserParams) != "" { a.PromptCompletionUserParams = other.PromptCompletionUserParams } - if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" { + if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" { a.PromptCompletionExtraHeader = other.PromptCompletionExtraHeader } // Provider-native - if strings.TrimSpace(other.PromptNativeCompletion) != "" { + if strings.TrimSpace(other.PromptNativeCompletion) != "" { a.PromptNativeCompletion = other.PromptNativeCompletion } // Chat - if strings.TrimSpace(other.PromptChatSystem) != "" { + if strings.TrimSpace(other.PromptChatSystem) != "" { a.PromptChatSystem = other.PromptChatSystem } // Code actions - if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" { a.PromptCodeActionRewriteSystem = other.PromptCodeActionRewriteSystem } - if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" { a.PromptCodeActionDiagnosticsSystem = other.PromptCodeActionDiagnosticsSystem } - if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" { a.PromptCodeActionDocumentSystem = other.PromptCodeActionDocumentSystem } - if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" { + if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" { a.PromptCodeActionRewriteUser = other.PromptCodeActionRewriteUser } - if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" { + if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" { a.PromptCodeActionDiagnosticsUser = other.PromptCodeActionDiagnosticsUser } - if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" { + if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" { a.PromptCodeActionDocumentUser = other.PromptCodeActionDocumentUser } - if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" { a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem } - if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { + if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser } - if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { + if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem } - if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { + if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser } // CLI - if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { + if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem } - if strings.TrimSpace(other.PromptCLIExplainSystem) != "" { + if strings.TrimSpace(other.PromptCLIExplainSystem) != "" { a.PromptCLIExplainSystem = other.PromptCLIExplainSystem } // Custom actions - if len(other.CustomActions) > 0 { + if len(other.CustomActions) > 0 { a.CustomActions = append([]CustomAction{}, other.CustomActions...) } - if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" { + if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" { a.TmuxCustomMenuHotkey = other.TmuxCustomMenuHotkey } } @@ -971,12 +1000,12 @@ func (a App) Validate() error { // Normalize and check duplicates for IDs and hotkeys seenID := make(map[string]struct{}) seenHK := make(map[string]struct{}) - for _, ca := range a.CustomActions { + for _, ca := range a.CustomActions { id := strings.ToLower(strings.TrimSpace(ca.ID)) if id == "" { return fmt.Errorf("config: custom action missing required field id") } - if _, ok := seenID[id]; ok { + if _, ok := seenID[id]; ok { return fmt.Errorf("config: duplicate custom action id: %s", ca.ID) } seenID[id] = struct{}{} @@ -1010,12 +1039,12 @@ func (a App) Validate() error { } } // Tmux custom menu hotkey validation - if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" { + if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" { if len([]rune(hk)) != 1 { return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s", hk) } // built-in hotkeys in tmux TUI: r,i,c,t,p,s - switch strings.ToLower(hk) { + switch strings.ToLower(hk) { case "r", "i", "c", "t", "p", "s": return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s (clashes with built-in)", hk) } @@ -1024,63 +1053,63 @@ func (a App) Validate() error { } // mergeProviderFields merges per-provider configuration. -func (a *App) mergeProviderFields(other *App) { +func (a *App) mergeProviderFields(other *App) { if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" { a.OpenAIBaseURL = s } - if s := strings.TrimSpace(other.OpenAIModel); s != "" { + if s := strings.TrimSpace(other.OpenAIModel); s != "" { a.OpenAIModel = s } - if other.OpenAITemperature != nil { // allow explicit 0.0 + if other.OpenAITemperature != nil { // allow explicit 0.0 a.OpenAITemperature = other.OpenAITemperature } - if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { + if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { a.OllamaBaseURL = s } - if s := strings.TrimSpace(other.OllamaModel); s != "" { + if s := strings.TrimSpace(other.OllamaModel); s != "" { a.OllamaModel = s } - if other.OllamaTemperature != nil { // allow explicit 0.0 + if other.OllamaTemperature != nil { // allow explicit 0.0 a.OllamaTemperature = other.OllamaTemperature } - if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { + if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { a.CopilotBaseURL = s } - if s := strings.TrimSpace(other.CopilotModel); s != "" { + if s := strings.TrimSpace(other.CopilotModel); s != "" { a.CopilotModel = s } - if other.CopilotTemperature != nil { // allow explicit 0.0 + if other.CopilotTemperature != nil { // allow explicit 0.0 a.CopilotTemperature = other.CopilotTemperature } } -func getConfigPath() (string, error) { +func getConfigPath() (string, error) { var configPath string - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") - } else { + } else { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("cannot find user home directory: %v", err) } - configPath = filepath.Join(home, ".config", "hexai", "config.toml") + configPath = filepath.Join(home, ".config", "hexai", "config.toml") } - return configPath, nil + return configPath, nil } // --- 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 { +func loadFromEnv(logger *log.Logger) *App { var out App var any bool // helpers - getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } - parseInt := func(k string) (int, bool) { + getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } + parseInt := func(k string) (int, bool) { v := getenv(k) - if v == "" { + if v == "" { return 0, false } n, err := strconv.Atoi(v) @@ -1092,58 +1121,58 @@ func loadFromEnv(logger *log.Logger) *App { } return n, true } - parseFloatPtr := func(k string) (*float64, bool) { + parseFloatPtr := func(k string) (*float64, bool) { v := getenv(k) - if v == "" { + if v == "" { return nil, false } - f, err := strconv.ParseFloat(v, 64) + f, err := strconv.ParseFloat(v, 64) if err != nil { if logger != nil { logger.Printf("invalid %s: %v", k, err) } return nil, false } - return &f, true + return &f, true } - if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok { + if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok { out.MaxTokens = n any = true } - if s := getenv("HEXAI_CONTEXT_MODE"); s != "" { + if s := getenv("HEXAI_CONTEXT_MODE"); s != "" { out.ContextMode = s any = true } - if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok { + if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok { out.ContextWindowLines = n any = true } - if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok { + if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok { out.MaxContextTokens = n any = true } - if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok { + if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok { out.LogPreviewLimit = n any = true } - if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok { + if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok { out.ManualInvokeMinPrefix = n any = true } - if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok { + if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok { out.CompletionDebounceMs = n any = true } - if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok { + if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok { out.CompletionThrottleMs = n any = true } - if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok { out.CodingTemperature = f any = true } - if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" { + if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" { parts := strings.Split(s, ",") out.TriggerCharacters = nil for _, p := range parts { @@ -1153,19 +1182,19 @@ func loadFromEnv(logger *log.Logger) *App { } any = true } - if s := getenv("HEXAI_INLINE_OPEN"); s != "" { + if s := getenv("HEXAI_INLINE_OPEN"); s != "" { out.InlineOpen = s any = true } - if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { + if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { out.InlineClose = s any = true } - if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { + if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { out.ChatSuffix = s any = true } - if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { + if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { parts := strings.Split(s, ",") out.ChatPrefixes = nil for _, p := range parts { @@ -1175,55 +1204,88 @@ func loadFromEnv(logger *log.Logger) *App { } any = true } - if s := getenv("HEXAI_PROVIDER"); s != "" { + if s := getenv("HEXAI_PROVIDER"); s != "" { out.Provider = s any = true } + 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) { + specific = strings.TrimSpace(specific) + nameLower := strings.ToLower(strings.TrimSpace(providerName)) + if modelForce != "" { + if providerLower == nameLower { + forceUsed = true + return modelForce, true + } + if providerLower == "" && !forceUsed { + forceUsed = true + return modelForce, true + } + } + if specific != "" { + return specific, true + } + if modelGeneric != "" { + if providerLower == nameLower { + return modelGeneric, true + } + if providerLower == "" && !genericUsed { + genericUsed = true + return modelGeneric, true + } + } + return "", false + } + // Provider-specific - if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { + if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { out.OpenAIBaseURL = s any = true } - if s := getenv("HEXAI_OPENAI_MODEL"); s != "" { - out.OpenAIModel = s + if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok { + out.OpenAIModel = model any = true } - if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { out.OpenAITemperature = f any = true } - if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { + if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { out.OllamaBaseURL = s any = true } - if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" { - out.OllamaModel = s + if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok { + out.OllamaModel = model any = true } - if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { out.OllamaTemperature = f any = true } - if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { + if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { out.CopilotBaseURL = s any = true } - if s := getenv("HEXAI_COPILOT_MODEL"); s != "" { - out.CopilotModel = s + if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok { + out.CopilotModel = model any = true } - if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { out.CopilotTemperature = f any = true } - if !any { + if !any { return nil } - return &out + return &out } @@ -1709,19 +1771,26 @@ func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opt // reqOptsFrom builds LLM request options similar to LSP behavior. func reqOptsFrom(cfg appconfig.App) []llm.RequestOption { 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 { - opts = append(opts, llm.WithTemperature(*cfg.CodingTemperature)) - } + temp := *cfg.CodingTemperature + prov := strings.ToLower(strings.TrimSpace(cfg.Provider)) + model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel)) + if prov == "openai" && strings.HasPrefix(model, "gpt-5") { + temp = 1.0 + } + opts = append(opts, llm.WithTemperature(temp)) + } return opts } // Timeout helpers to mirror LSP behavior. func timeout10s(parent context.Context) (context.Context, context.CancelFunc) { - return context.WithTimeout(parent, 10*time.Second) + return context.WithTimeout(parent, 20*time.Second) } func timeout8s(parent context.Context) (context.Context, context.CancelFunc) { - return context.WithTimeout(parent, 8*time.Second) + return context.WithTimeout(parent, 18*time.Second) } @@ -1800,56 +1869,87 @@ func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg a case ActionSkip: return parts.Selection, nil case ActionRewrite: - instr, cleaned := ExtractInstruction(parts.Selection) - if strings.TrimSpace(instr) == "" { - fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: no inline instruction found; echoing input"+logging.AnsiReset) - return parts.Selection, nil - } - cctx, cancel := timeout10s(ctx) - defer cancel() - return runRewrite(cctx, cfg, client, instr, cleaned) + return handleRewriteAction(ctx, parts, cfg, client, stderr) case ActionDiagnostics: - cctx, cancel := timeout10s(ctx) - defer cancel() - return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection) + return handleDiagnosticsAction(ctx, parts, cfg, client) case ActionDocument: - cctx, cancel := timeout10s(ctx) - defer cancel() - return runDocument(cctx, cfg, client, parts.Selection) + return handleDocumentAction(ctx, parts, cfg, client) case ActionGoTest: - cctx, cancel := timeout8s(ctx) - defer cancel() - return runGoTest(cctx, cfg, client, parts.Selection) + return handleGoTestAction(ctx, parts, cfg, client) case ActionSimplify: - cctx, cancel := timeout10s(ctx) - defer cancel() - return runSimplify(cctx, cfg, client, parts.Selection) + return handleSimplifyAction(ctx, parts, cfg, client) case ActionCustom: - cctx, cancel := timeout10s(ctx) - defer cancel() - if selectedCustom != nil { - // Run configured custom action - out, err := runCustom(cctx, cfg, client, *selectedCustom, parts) - selectedCustom = nil // clear after use - return out, err - } - // No selected custom; treat as no-op - return parts.Selection, nil + return handleCustomAction(ctx, parts, cfg, client) case ActionCustomPrompt: - cctx, cancel := timeout10s(ctx) - defer cancel() - // Open editor for free-form instruction - prompt, err := editor.OpenTempAndEdit(nil) - if err != nil || strings.TrimSpace(prompt) == "" { - fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset) - return parts.Selection, nil - } - return runRewrite(cctx, cfg, client, prompt, parts.Selection) + return handleCustomPromptAction(ctx, parts, cfg, client, stderr) default: return parts.Selection, nil } } +func handleRewriteAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { + instr, cleaned := ExtractInstruction(parts.Selection) + if strings.TrimSpace(instr) == "" { + fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: no inline instruction found; echoing input"+logging.AnsiReset) + return parts.Selection, nil + } + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { + return runRewrite(cctx, cfg, client, instr, cleaned) + }) +} + +func handleDiagnosticsAction(ctx context.Cont