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 +- docs/coverage.out | 34221 ++++++++++++++++++++++++++------------------------- 2 files changed, 18480 insertions(+), 17088 deletions(-) (limited to 'docs') 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.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { + return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection) + }) +} + +func handleDocumentAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { + return runDocument(cctx, cfg, client, parts.Selection) + }) +} + +func handleGoTestAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { + return runWithTimeout(ctx, timeout8s, func(cctx context.Context) (string, error) { + return runGoTest(cctx, cfg, client, parts.Selection) + }) +} + +func handleSimplifyAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { + return runSimplify(cctx, cfg, client, parts.Selection) + }) +} + +func handleCustomAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { + if selectedCustom == nil { + return parts.Selection, nil + } + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { + out, err := runCustom(cctx, cfg, client, *selectedCustom, parts) + selectedCustom = nil + return out, err + }) +} + +func handleCustomPromptAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { + 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 runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { + return runRewrite(cctx, cfg, client, prompt, parts.Selection) + }) +} + +func runWithTimeout(ctx context.Context, timeout func(context.Context) (context.Context, context.CancelFunc), fn func(context.Context) (string, error)) (string, error) { + innerCtx, cancel := timeout(ctx) + defer cancel() + return fn(innerCtx) +} + // client construction is shared via internal/llmutils @@ -2100,7 +2200,6 @@ func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem lis package hexaicli import ( - "bufio" "context" "fmt" "io" @@ -2120,39 +2219,38 @@ import ( // 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 { +func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { // Load configuration with a logger so file-based config is respected. logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) cfg := appconfig.Load(logger) - if cfg.StatsWindowMinutes > 0 { + if cfg.StatsWindowMinutes > 0 { stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) } - client, err := newClientFromApp(cfg) + client, err := newClientFromApp(cfg) if err != nil { fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) return err } - // No args: open editor to capture a prompt, then combine with stdin as usual. - if len(args) == 0 { + // Prefer piped stdin when present; only open the editor when there are no args + // and no stdin content available. + input, rerr := readInput(stdin, args) + if rerr != nil && len(args) == 0 { if prompt, eerr := editor.OpenTempAndEdit(nil); eerr == nil && strings.TrimSpace(prompt) != "" { args = []string{prompt} - } else { - // If editor fails or empty, continue; readInput will likely error if no stdin either. + input, rerr = readInput(stdin, args) } } - // Inline the flow here to use configured CLI prompts. - input, rerr := readInput(stdin, args) - if rerr != nil { + if rerr != nil { fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset) return rerr } - printProviderInfo(stderr, client) + printProviderInfo(stderr, client) msgs := buildMessagesFromConfig(cfg, input) if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil { fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) return err } - return nil + return nil } // RunWithClient executes the CLI flow using an already-constructed client. @@ -2173,21 +2271,24 @@ func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, } // readInput reads from stdin and args, then combines them per CLI rules. -func readInput(stdin io.Reader, args []string) (string, error) { +func readInput(stdin io.Reader, args []string) (string, error) { var stdinData string - if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 { - b, _ := io.ReadAll(bufio.NewReader(stdin)) - stdinData = strings.TrimSpace(string(b)) - } - argData := strings.TrimSpace(strings.Join(args, " ")) + if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 { + data, readErr := io.ReadAll(stdin) + if readErr != nil { + return "", fmt.Errorf("hexai: failed to read stdin: %w", readErr) + } + stdinData = strings.TrimSpace(string(data)) + } + argData := strings.TrimSpace(strings.Join(args, " ")) switch { case stdinData != "" && argData != "": return fmt.Sprintf("%s:\n\n%s", argData, stdinData), nil - case stdinData != "": + case stdinData != "": return stdinData, nil - case argData != "": + case argData != "": return argData, nil - default: + default: return "", fmt.Errorf("hexai: no input provided; pass text as an argument or via stdin") } } @@ -2196,20 +2297,20 @@ func readInput(stdin io.Reader, args []string) (string, error) { +func buildMessages(input string) []llm.Message { lower := strings.ToLower(input) system := "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation." if strings.Contains(lower, "explain") { system = "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context." } - return []llm.Message{ + return []llm.Message{ {Role: "system", Content: system}, {Role: "user", Content: input}, } } // buildMessagesFromConfig uses configured CLI system prompts. -func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message { +func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message { lower := strings.ToLower(input) system := cfg.PromptCLIDefaultSystem if strings.Contains(lower, "explain") { @@ -2217,55 +2318,55 @@ func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message } - return []llm.Message{ + return []llm.Message{ {Role: "system", Content: system}, {Role: "user", Content: input}, } } // 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 { +func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error { start := time.Now() // Best-effort tmux status update (colored start heartbeat) _ = tmux.SetStatus(tmux.FormatLLMStartStatus(client.Name(), client.DefaultModel())) var output string if s, ok := client.(llm.Streamer); ok { var b strings.Builder - if err := s.ChatStream(ctx, msgs, func(chunk string) { + if err := s.ChatStream(ctx, msgs, func(chunk string) { b.WriteString(chunk) fmt.Fprint(out, chunk) }); err != nil { return err } output = b.String() - } else { + } else { txt, err := client.Chat(ctx, msgs) if err != nil { return err } - output = txt + output = txt fmt.Fprint(out, output) } - dur := time.Since(start) + dur := time.Since(start) // Contribute to global stats and update tmux status sent := 0 - for _, m := range msgs { + for _, m := range msgs { sent += len(m.Content) } - recv := len(output) + recv := len(output) _ = stats.Update(ctx, client.Name(), client.DefaultModel(), sent, recv) snap, _ := stats.TakeSnapshot() minsWin := snap.Window.Minutes() if minsWin <= 0 { minsWin = 0.001 } - scopeReqs := int64(0) - if pe, ok := snap.Providers[client.Name()]; ok { - if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 { + scopeReqs := int64(0) + if pe, ok := snap.Providers[client.Name()]; ok { + if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 { scopeReqs = mc.Reqs } } - scopeRPM := float64(scopeReqs) / minsWin + 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)) @@ -2273,7 +2374,7 @@ func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input s } // printProviderInfo writes the provider/model line to stderr. -func printProviderInfo(errw io.Writer, client llm.Client) { +func printProviderInfo(errw io.Writer, client llm.Client) { fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), client.DefaultModel()) } @@ -3101,12 +3202,13 @@ type openAIClient struct { } type oaChatRequest struct { - Model string `json:"model"` - Messages []oaMessage `json:"messages"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - Stop []string `json:"stop,omitempty"` - Stream bool `json:"stream,omitempty"` + Model string `json:"model"` + Messages []oaMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` + Stop []string `json:"stop,omitempty"` + Stream bool `json:"stream,omitempty"` } type oaMessage struct { @@ -3150,14 +3252,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 { - if strings.TrimSpace(baseURL) == "" { +func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client { + if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.openai.com/v1" } - if strings.TrimSpace(model) == "" { + if strings.TrimSpace(model) == "" { model = "gpt-4.1" } - return openAIClient{ + return openAIClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, apiKey: apiKey, baseURL: baseURL, @@ -3171,14 +3273,14 @@ func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...Requ if c.apiKey == "" { return nilStringErr("missing OpenAI API key") } - o := Options{Model: c.defaultModel} + o := Options{Model: c.defaultModel} for _, opt := range opts { opt(&o) } - if o.Model == "" { + if o.Model == "" { o.Model = c.defaultModel } - start := time.Now() + start := time.Now() c.logStart(false, o, messages) req := buildOAChatRequest(o, messages, c.defaultTemperature, false) body, err := json.Marshal(req) @@ -3186,7 +3288,7 @@ func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...Requ c.logf("marshal error: %v", err) return "", err } - endpoint := c.baseURL + "/chat/completions" + 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, @@ -3195,7 +3297,7 @@ 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 } - defer resp.Body.Close() + defer resp.Body.Close() if err := handleOpenAINon2xx(resp, start); err != nil { return "", err } @@ -3270,37 +3372,57 @@ func (c openAIClient) logStart(stream bool, o Options, messages []Message) c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) } -func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool) oaChatRequest { +func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool) oaChatRequest { req := oaChatRequest{Model: o.Model, Stream: stream} req.Messages = make([]oaMessage, len(messages)) - for i, m := range messages { + for i, m := range messages { req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} } - if o.Temperature != 0 { + if o.Temperature != 0 { req.Temperature = &o.Temperature - } else if defaultTemp != nil { + } else if defaultTemp != nil { t := *defaultTemp req.Temperature = &t } - if o.MaxTokens > 0 { - req.MaxTokens = &o.MaxTokens - } - if len(o.Stop) > 0 { + if o.MaxTokens > 0 { + if requiresMaxCompletionTokens(o.Model) { + req.MaxCompletionTokens = &o.MaxTokens + } else { + req.MaxTokens = &o.MaxTokens + } + } + if len(o.Stop) > 0 { req.Stop = o.Stop } - return req + // Enforce gpt-5 temperature constraints: only default (1.0) is supported. + if requiresMaxCompletionTokens(o.Model) { + if req.Temperature == nil || *req.Temperature != 1.0 { + t := 1.0 + req.Temperature = &t + logging.Logf("llm/openai ", "forcing temperature=1.0 for model=%s (gpt-5 constraint)", o.Model) + } + } + return req } -func (c openAIClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) { +// 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 { + m := strings.ToLower(strings.TrimSpace(model)) + return strings.HasPrefix(m, "gpt-5") +} + +func (c openAIClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/json") - for k, v := range headers { + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { req.Header.Set(k, v) } - return c.httpClient.Do(req) + return c.httpClient.Do(req) } func (c openAIClient) doJSONWithAccept(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) { @@ -3339,7 +3461,7 @@ func decodeOpenAIChat(resp *http.Response, start time.Time) (oaChatResponse, err return out, nil } -func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string)) error { +func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string)) error { // Parse SSE: lines starting with "data: " containing JSON or [DONE] scanner := bufio.NewScanner(resp.Body) const maxBuf = 1024 * 1024 @@ -3354,7 +3476,7 @@ func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string if strings.TrimSpace(payload) == "[DONE]" { break } - var chunk oaStreamChunk + var chunk oaStreamChunk if err := json.Unmarshal([]byte(payload), &chunk); err != nil { continue } @@ -3434,8 +3556,8 @@ type Options struct { type RequestOption func(*Options) func WithModel(model string) RequestOption { return func(o *Options) { o.Model = model } } -func WithTemperature(t float64) RequestOption { return func(o *Options) { o.Temperature = t } } -func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } +func WithTemperature(t float64) RequestOption { return func(o *Options) { o.Temperature = t } } +func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } func WithStop(stop ...string) RequestOption { return func(o *Options) { o.Stop = append([]string{}, stop...) } } @@ -3460,22 +3582,36 @@ 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) { +func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) { p := strings.ToLower(strings.TrimSpace(cfg.Provider)) if p == "" { p = "openai" } - switch p { - case "openai": + switch p { + case "openai": if strings.TrimSpace(openAIAPIKey) == "" { return nil, errors.New("missing OPENAI_API_KEY for provider openai") } - // Set coding-friendly default temperature if none provided - if cfg.OpenAITemperature == nil { - t := 0.2 - cfg.OpenAITemperature = &t + // Default temperature selection: + // - When model is gpt-5*, prefer 1.0 by default (more exploratory). + // - Otherwise, prefer 0.2 by default (coding friendly). + // 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. + model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel)) + if strings.HasPrefix(model, "gpt-5") { + if cfg.OpenAITemperature == nil { + v := 1.0 + cfg.OpenAITemperature = &v + } else if *cfg.OpenAITemperature == 0.2 { + v := 1.0 + cfg.OpenAITemperature = &v + } + } else if cfg.OpenAITemperature == nil { + v := 0.2 + cfg.OpenAITemperature = &v } - return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil + return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil case "ollama": if cfg.OllamaTemperature == nil { t := 0.2 @@ -3549,7 +3685,7 @@ type ChatLogger struct { } // NewChatLogger creates a new ChatLogger for a given provider. -func NewChatLogger(provider string) ChatLogger { +func NewChatLogger(provider string) ChatLogger { return ChatLogger{Provider: provider} } @@ -3560,7 +3696,7 @@ func (cl ChatLogger) LogStart(stream bool, model string, temp float64, maxTokens }, ) { chatOrStream := "chat" - if stream { + if stream { chatOrStream = "stream" } Logf("llm/"+cl.Provider+" ", "%s start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", @@ -3601,8 +3737,8 @@ var std *log.Logger func Bind(l *log.Logger) { std = l } // Logf prints a formatted message with a module prefix and base ANSI style. -func Logf(prefix, format string, args ...any) { - if std == nil { +func Logf(prefix, format string, args ...any) { + if std == nil { return } msg := fmt.Sprintf(format, args...) @@ -3617,7 +3753,7 @@ var logPreviewLimit int // 0 means unlimited func SetLogPreviewLimit(n int) { logPreviewLimit = n } // PreviewForLog returns the string truncated to the configured preview limit. -func PreviewForLog(s string) string { +func PreviewForLog(s string) string { if logPreviewLimit > 0 { if len(s) <= logPreviewLimit { return s @@ -3888,10 +4024,10 @@ func (s *Server) handle(req Request) { // 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 instructionFromSelection(sel string) (string, string) { +func (s *Server) instructionFromSelection(sel string) (string, string) { lines := splitLines(sel) for idx, line := range lines { - if instr, cleaned, ok := findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" { + if instr, cleaned, ok := s.findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" { lines[idx] = cleaned return instr, strings.Join(lines, "\n") } @@ -3908,16 +4044,16 @@ func instructionFromSelection(sel string) (string, string) { +func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) { type cand struct { start, end int text string } cands := []cand{} - if t, l, r, ok := findStrictInlineTag(line); ok { + if t, l, r, ok := findStrictInlineTag(line, s.inlineOpenChar, s.inlineCloseChar); ok { cands = append(cands, cand{start: l, end: r, text: t}) } - if i := strings.Index(line, "/*"); i >= 0 { + if i := strings.Index(line, "/*"); i >= 0 { if j := strings.Index(line[i+2:], "*/"); j >= 0 { start := i end := i + 2 + j + 2 @@ -3925,7 +4061,7 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b cands = append(cands, cand{start: start, end: end, text: text}) } } - if i := strings.Index(line, "<!--"); i >= 0 { + if i := strings.Index(line, "<!--"); i >= 0 { if j := strings.Index(line[i+4:], "-->"); j >= 0 { start := i end := i + 4 + j + 3 @@ -3933,26 +4069,26 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b cands = append(cands, cand{start: start, end: end, text: text}) } } - if i := strings.Index(line, "//"); i >= 0 { + if i := strings.Index(line, "//"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) } - if i := strings.Index(line, "#"); i >= 0 { + if i := strings.Index(line, "#"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) } - if i := strings.Index(line, "--"); i >= 0 { + if i := strings.Index(line, "--"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) } - if len(cands) == 0 { + if len(cands) == 0 { return "", line, false } // pick earliest start index - best := cands[0] + best := cands[0] for _, c := range cands[1:] { if c.start >= 0 && (best.start < 0 || c.start < best.start) { best = c } } - cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") return best.text, cleaned, true } @@ -3977,7 +4113,7 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b // handleCompletion moved to handlers_completion.go -func (s *Server) reply(id json.RawMessage, result any, err *RespError) { +func (s *Server) reply(id json.RawMessage, result any, err *RespError) { resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err} s.writeMessage(resp) } @@ -4051,33 +4187,33 @@ func (s *Server) reply(id json.RawMessage, result any, err *RespError) { +func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string { // Normalize left-of-cursor by trimming trailing spaces/tabs idx := p.Position.Character if idx > len(current) { idx = len(current) } - left := strings.TrimRight(current[:idx], " \t") + left := strings.TrimRight(current[:idx], " \t") right := "" if idx < len(current) { right = current[idx:] } - prov := "" + prov := "" model := "" - if s.llmClient != nil { + if s.llmClient != nil { prov = s.llmClient.Name() model = s.llmClient.DefaultModel() } - temp := "" + temp := "" if s.codingTemperature != nil { temp = fmt.Sprintf("%.3f", *s.codingTemperature) } - extra := "" + extra := "" if hasExtra { extra = strings.TrimSpace(extraText) } // Compose a key from essential context parts - return strings.Join([]string{ + return strings.Join([]string{ "v1", // version for future-proofing prov, model, @@ -4094,11 +4230,11 @@ func (s *Server) completionCacheKey(p CompletionParams, above, current, below, f }, "\x1f") // use unit separator to avoid collisions } -func (s *Server) completionCacheGet(key string) (string, bool) { +func (s *Server) completionCacheGet(key string) (string, bool) { s.mu.Lock() defer s.mu.Unlock() v, ok := s.compCache[key] - if !ok { + if !ok { return "", false } // move to most-recent @@ -4155,7 +4291,7 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool { + if raw, ok := p.Context.(json.RawMessage); ok { _ = json.Unmarshal(raw, &ctx) } else { b, _ := json.Marshal(p.Context) @@ -4163,11 +4299,11 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool // If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'), // do not treat as a trigger source. - if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current) { + if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current, s.inlineOpenChar, s.inlineCloseChar) { return false } // TriggerKind 1 = Invoked (manual). Always allow manual invoke. - if ctx.TriggerKind == 1 { + if ctx.TriggerKind == 1 { return true } // TriggerKind 2 is TriggerCharacter per LSP spec @@ -4186,21 +4322,21 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool idx := p.Position.Character + idx := p.Position.Character if idx <= 0 || idx > len(current) { return false } // Bare double-open should not trigger via fallback char either (only when configured) - if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current) { + if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current, s.inlineOpenChar, s.inlineCloseChar) { return false } - ch := string(current[idx-1]) - for _, c := range s.triggerChars { + ch := string(current[idx-1]) + for _, c := range s.triggerChars { if c == ch { return true } } - return false + return false } func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem { @@ -4432,7 +4568,7 @@ func (s *Server) buildSimplifyCodeAction(p CodeActionParams, sel string) *CodeAc } func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction { - if instr, cleaned := instructionFromSelection(sel); strings.TrimSpace(instr) != "" { + if instr, cleaned := s.instructionFromSelection(sel); strings.TrimSpace(instr) != "" { payload := struct { Type string `json:"type"` URI string `json:"uri"` @@ -4484,7 +4620,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) sys := s.promptRewriteSystem user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection}) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} opts := s.llmRequestOpts() @@ -4509,7 +4645,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) diagList := b.String() user := renderTemplate(s.promptDiagnosticsUser, map[string]string{"diagnostics": diagList, "selection": payload.Selection}) - ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 22*time.Second) defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} opts := s.llmRequestOpts() @@ -4525,7 +4661,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) sys := s.promptDocumentSystem user := renderTemplate(s.promptDocumentUser, map[string]string{"selection": payload.Selection}) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} opts := s.llmRequestOpts() @@ -4552,7 +4688,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} opts := s.llmRequestOpts() @@ -4920,7 +5056,7 @@ func (s *Server) generateGoTestFunction(funcCode string) string { sys := s.promptGoTestSystem user := renderTemplate(s.promptGoTestUser, map[string]string{"function": funcCode}) - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + 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() @@ -4991,6 +5127,21 @@ import ( "codeberg.org/snonux/hexai/internal/stats" ) +type completionPlan struct { + params CompletionParams + above string + current string + below string + funcCtx string + docStr string + hasExtra bool + extraText string + inlinePrompt bool + inParams bool + manualInvoke bool + cacheKey string +} + func (s *Server) handleCompletion(req Request) { var p CompletionParams var docStr string @@ -5050,47 +5201,62 @@ func (s *Server) logCompletionContext(p CompletionParams, above, current, below, } func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) { - ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() - inlinePrompt := lineHasInlinePrompt(current) - if !inlinePrompt && !s.isTriggerEvent(p, current) { - 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 []CompletionItem{}, true + plan, items, handled := s.prepareCompletionPlan(p, above, current, below, funcCtx, docStr, hasExtra, extraText) + if handled { + return items, true } - if s.shouldSuppressForChatTriggerEOL(current, p) { - return []CompletionItem{}, true + + if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, plan.inParams); ok { + return items, true } - inParams := inParamList(current, p.Position.Character) - manualInvoke := parseManualInvoke(p.Context) + return s.executeChatCompletion(ctx, plan) +} - // Cache fast-path - key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) - if cleaned, ok := s.completionCacheGet(key); ok && strings.TrimSpace(cleaned) != "" { +func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) (completionPlan, []CompletionItem, bool) { + plan := completionPlan{ + params: p, + above: above, + current: current, + below: below, + funcCtx: funcCtx, + docStr: docStr, + hasExtra: hasExtra, + extraText: extraText, + } + plan.inlinePrompt = lineHasInlinePrompt(current, s.inlineOpenChar, s.inlineCloseChar) + if !plan.inlinePrompt && !s.isTriggerEvent(p, current) { + 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 + } + if s.shouldSuppressForChatTriggerEOL(current, p) { + return plan, []CompletionItem{}, true + } + plan.inParams = inParamList(current, p.Position.Character) + plan.manualInvoke = parseManualInvoke(p.Context) + plan.cacheKey = s.completionCacheKey(p, above, current, below, funcCtx, plan.inParams, hasExtra, extraText) + if cleaned, ok := s.completionCacheGet(plan.cacheKey); ok && strings.TrimSpace(cleaned) != "" { 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 s.makeCompletionItems(cleaned, inParams, current, p, docStr), true + return plan, s.makeCompletionItems(cleaned, plan.inParams, current, p, docStr), true } - if isBareDoubleOpen(current) || isBareDoubleOpen(below) { + if isBareDoubleOpen(current, s.inlineOpenChar, s.inlineCloseChar) || isBareDoubleOpen(below, s.inlineOpenChar, s.inlineCloseChar) { 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 []CompletionItem{}, true + return plan, []CompletionItem{}, true } - - if !inParams && !s.prefixHeuristicAllows(inlinePrompt, current, p, manualInvoke) { + if !plan.inParams && !s.prefixHeuristicAllows(plan.inlinePrompt, current, p, plan.manualInvoke) { 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 []CompletionItem{}, true - } - - // Provider-native path - if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, inParams); ok { - return items, true + return plan, []CompletionItem{}, true } + return plan, nil, false +} - // Chat path - messages := s.buildCompletionMessages(inlinePrompt, hasExtra, extraText, inParams, p, above, current, below, funcCtx) - // Counters and options +func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan) ([]CompletionItem, bool) { + 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 { sentSize += len(m.Content) @@ -5100,13 +5266,14 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun if s.codingTemperature != nil { opts = append(opts, llm.WithTemperature(*s.codingTemperature)) } - // Debounce and throttle before making the LLM call s.waitForDebounce(ctx) if !s.waitForThrottle(ctx) { return nil, false } + if s.llmClient == nil { + return nil, false + } logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel()) - text, err := s.llmClient.Chat(ctx, messages, opts...) if err != nil { logging.Logf("lsp ", "llm completion error: %v", err) @@ -5115,39 +5282,40 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun } s.incRecvCounters(len(text)) s.logLLMStats() - - cleaned := s.postProcessCompletion(strings.TrimSpace(text), current[:p.Position.Character], current) + trimmed := strings.TrimSpace(text) + cleaned := s.postProcessCompletion(trimmed, plan.current[:plan.params.Position.Character], plan.current) if cleaned == "" { return nil, false } - s.completionCachePut(key, cleaned) - return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true + s.completionCachePut(plan.cacheKey, cleaned) + items := s.makeCompletionItems(cleaned, plan.inParams, plan.current, plan.params, plan.docStr) + return items, true } // parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion. -func parseManualInvoke(ctx any) bool { +func parseManualInvoke(ctx any) bool { if ctx == nil { return false } - var c struct { + var c struct { TriggerKind int `json:"triggerKind"` } - if raw, ok := ctx.(json.RawMessage); ok { + if raw, ok := ctx.(json.RawMessage); ok { _ = json.Unmarshal(raw, &c) } else { b, _ := json.Marshal(ctx) _ = json.Unmarshal(b, &c) } - return c.TriggerKind == 1 + return c.TriggerKind == 1 } // shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. -func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool { +func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool { t := strings.TrimRight(current, " \t") - if s.chatSuffix == "" { + if s.chatSuffix == "" { return false } - if strings.HasSuffix(t, s.chatSuffix) { + if strings.HasSuffix(t, s.chatSuffix) { if len(t) < len(s.chatSuffix)+1 { return false } @@ -5159,7 +5327,7 @@ func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionPar } } } - return false + return false } // prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply. @@ -5221,7 +5389,7 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, prov = s.llmClient.Name() } logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) - ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second) + ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second) defer cancel2() // Debounce and throttle prior to provider-native call @@ -5247,7 +5415,7 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, if cleaned != "" { cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) } - if cleaned != "" && hasDoubleOpenTrigger(current) { + if cleaned != "" && hasDoubleOpenTrigger(current, s.inlineOpenChar, s.inlineCloseChar) { indent := leadingIndent(current) if indent != "" { cleaned = applyIndent(indent, cleaned) @@ -5376,7 +5544,7 @@ func (s *Server) postProcessCompletion(text string, leftOfCursor string, current if cleaned != "" { cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) } - if cleaned != "" && hasDoubleOpenTrigger(currentLine) { + if cleaned != "" && hasDoubleOpenTrigger(currentLine, s.inlineOpenChar, s.inlineCloseChar) { if indent := leadingIndent(currentLine); indent != "" { cleaned = applyIndent(indent, cleaned) } @@ -5398,13 +5566,6 @@ import ( "codeberg.org/snonux/hexai/internal/logging" ) -// Package-level chat trigger vars for helpers without Server receiver. -// NewServer assigns these from configuration on startup. -var ( - chatSuffixChar byte = '>' - chatPrefixSingles = []string{"?", "!", ":", ";"} -) - func (s *Server) handleDidOpen(req Request) { var p DidOpenTextDocumentParams if err := json.Unmarshal(req.Params, &p); err == nil { @@ -5501,34 +5662,34 @@ func (s *Server) detectAndHandleChat(uri string) { continue } // Check suffix/prefix according to configuration - if s.chatSuffix == "" { + if s.chatSuffix == "" { continue } // Last non-space must equal suffix - if string(raw[j]) != s.chatSuffix { + if string(raw[j]) != s.chatSuffix { continue } // Require at least one char before suffix and that char must be in chatPrefixes - if j < 1 { + if j < 1 { continue } - prev := string(raw[j-1]) + prev := string(raw[j-1]) isTrigger := false - for _, pfx := range s.chatPrefixes { - if prev == pfx { + for _, pfx := range s.chatPrefixes { + if prev == pfx { isTrigger = true break } } - if !isTrigger { + if !isTrigger { continue } // Avoid double-answering: if the next non-empty line starts with '>' we skip. - k := i + 1 + k := i + 1 for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { k++ } - if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { + if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { continue } // Derive prompt by removing only the trailing '>' @@ -5541,7 +5702,7 @@ func (s *Server) detectAndHandleChat(uri string) { lineIdx := i lastIdx := j go func(prompt string, remove int) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + 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} @@ -5623,7 +5784,7 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) break } q := strings.TrimSpace(d.lines[i]) - q = stripTrailingTrigger(q) + q = s.stripTrailingTrigger(q) pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) i-- } @@ -5641,25 +5802,23 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) } // stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. -func stripTrailingTrigger(sx string) string { - s := strings.TrimRight(sx, " \t") - if len(s) == 0 { +func (s *Server) stripTrailingTrigger(sx string) string { + trim := strings.TrimRight(sx, " \t") + if len(trim) == 0 { return sx } - // Configurable suffix removal when preceded by configured prefixes - if len(s) >= 2 && s[len(s)-1] == chatSuffixChar { - prev := string(s[len(s)-2]) - for _, pf := range chatPrefixSingles { + if len(trim) >= 2 && s.chatSuffixChar != 0 && trim[len(trim)-1] == s.chatSuffixChar { + prev := string(trim[len(trim)-2]) + for _, pf := range s.chatPrefixes { if prev == pf { - return strings.TrimRight(s[:len(s)-1], " \t") + return strings.TrimRight(trim[:len(trim)-1], " \t") } } } - // Legacy: remove one trailing punctuation (?, !, :) to build history nicely - last := s[len(s)-1] + last := trim[len(trim)-1] switch last { case '?', '!', ':': - return strings.TrimRight(s[:len(s)-1], " \t") + return strings.TrimRight(trim[:len(trim)-1], " \t") default: return sx } @@ -5839,20 +5998,21 @@ import ( tmx "codeberg.org/snonux/hexai/internal/tmux" ) -// Configurable inline trigger characters (default to '>') used by free helpers below. -// NewServer assigns these based on ServerOptions. -var ( - inlineOpenChar byte = '>' - inlineCloseChar byte = '>' -) - // llmRequestOpts builds request options from server settings. -func (s *Server) llmRequestOpts() []llm.RequestOption { +func (s *Server) llmRequestOpts() []llm.RequestOption { opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} - if s.codingTemperature != nil { - opts = append(opts, llm.WithTemperature(*s.codingTemperature)) - } - return opts + if s.codingTemperature != nil { + temp := *s.codingTemperature + if s.llmClient != nil { + prov := strings.ToLower(strings.TrimSpace(s.llmClient.Name())) + model := strings.ToLower(strings.TrimSpace(s.llmClient.DefaultModel())) + if prov == "openai" && strings.HasPrefix(model, "gpt-5") { + temp = 1.0 + } + } + opts = append(opts, llm.WithTemperature(temp)) + } + return opts } // small helpers for LLM traffic stats @@ -5914,8 +6074,8 @@ func (s *Server) logLLMStats() { } // Completion prompt builders and filters -func inParamList(current string, cursor int) bool { - if !strings.Contains(current, "func ") { +func inParamList(current string, cursor int) bool { + if !strings.Contains(current, "func ") { return false } open := strings.Index(current, "(") @@ -6001,11 +6161,12 @@ func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ... } // Inline prompt utilities -func lineHasInlinePrompt(line string) bool { - if _, _, _, ok := findStrictInlineTag(line); ok { + +func lineHasInlinePrompt(line string, open, close byte) bool { + if _, _, _, ok := findStrictInlineTag(line, open, close); ok { return true } - return hasDoubleOpenTrigger(line) + return hasDoubleOpenTrigger(line, open, close) } func leadingIndent(line string) string { @@ -6045,22 +6206,22 @@ func applyIndent(indent, suggestion string) string // findStrictInlineTag finds >text> (configurable), with no space after the first // opening marker and no space immediately before the closing marker. Returns the // text between markers, the start index, the end index just after closing, and ok. -func findStrictInlineTag(line string) (string, int, int, bool) { +func findStrictInlineTag(line string, open, close byte) (string, int, int, bool) { pos := 0 for pos < len(line) { // find opening marker - j := strings.IndexByte(line[pos:], inlineOpenChar) + j := strings.IndexByte(line[pos:], open) if j < 0 { return "", 0, 0, false } j += pos // ensure single open (not double) and non-space after - if j+1 >= len(line) || line[j+1] == inlineOpenChar || line[j+1] == ' ' { + if j+1 >= len(line) || line[j+1] == open || line[j+1] == ' ' { pos = j + 1 continue } // find closing marker - k := strings.IndexByte(line[j+1:], inlineCloseChar) + k := strings.IndexByte(line[j+1:], close) if k < 0 { return "", 0, 0, false } @@ -6083,19 +6244,19 @@ func findStrictInlineTag(line string) (string, int, int, bool) { +func isBareDoubleOpen(line string, open, close byte) bool { t := strings.TrimSpace(line) // check for double-open pattern - dbl := string([]byte{inlineOpenChar, inlineOpenChar}) + dbl := string([]byte{open, open}) if !strings.Contains(t, dbl) { return false } - if hasDoubleOpenTrigger(t) { + if hasDoubleOpenTrigger(t, open, close) { return false } - if strings.HasPrefix(t, dbl) { + if strings.HasPrefix(t, dbl) { rest := strings.TrimSpace(t[len(dbl):]) - if rest == "" || rest == ";" { + if rest == "" || rest == ";" { return true } } @@ -6252,39 +6413,39 @@ func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit var edits []TextEdit for i, line := range d.lines { - edits = append(edits, promptRemovalEditsForLine(line, i)...) + edits = append(edits, promptRemovalEditsForLine(line, i, s.inlineOpenChar, s.inlineCloseChar)...) } return edits } -func promptRemovalEditsForLine(line string, lineNum int) []TextEdit { - if hasDoubleOpenTrigger(line) { +func promptRemovalEditsForLine(line string, lineNum int, open, close byte) []TextEdit { + if hasDoubleOpenTrigger(line, open, close) { return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} } - return collectSemicolonMarkers(line, lineNum) + return collectSemicolonMarkers(line, lineNum, open, close) } -func hasDoubleOpenTrigger(line string) bool { +func hasDoubleOpenTrigger(line string, open, close byte) bool { pos := 0 - for pos < len(line) { + for pos < len(line) { // look for double-open sequence - dbl := string([]byte{inlineOpenChar, inlineOpenChar}) + dbl := string([]byte{open, open}) j := strings.Index(line[pos:], dbl) - if j < 0 { + if j < 0 { return false } - j += pos + j += pos contentStart := j + len(dbl) - if contentStart >= len(line) { + if contentStart >= len(line) { return false } - first := line[contentStart] - if first == ' ' || first == inlineOpenChar { + first := line[contentStart] + if first == ' ' || first == open { pos = contentStart + 1 continue } // find closing - k := strings.IndexByte(line[contentStart+1:], inlineCloseChar) + k := strings.IndexByte(line[contentStart+1:], close) if k < 0 { return false } @@ -6298,16 +6459,16 @@ func hasDoubleOpenTrigger(line string) bool { return false } -func collectSemicolonMarkers(line string, lineNum int) []TextEdit { +func collectSemicolonMarkers(line string, lineNum int, open, close byte) []TextEdit { var edits []TextEdit startSemi := 0 for startSemi < len(line) { - j := strings.IndexByte(line[startSemi:], inlineOpenChar) + j := strings.IndexByte(line[startSemi:], open) if j < 0 { break } j += startSemi - k := strings.IndexByte(line[j+1:], inlineCloseChar) + k := strings.IndexByte(line[j+1:], close) if k < 0 { break } @@ -6315,7 +6476,7 @@ func collectSemicolonMarkers(line string, lineNum int) []TextEdit if line[j+1] == inlineOpenChar { // skip double-open start + if line[j+1] == open { // skip double-open start startSemi = j + 2 continue } @@ -6359,6 +6520,7 @@ import ( type Server struct { in *bufio.Reader out io.Writer + outMu sync.Mutex logger *log.Logger exited bool mu sync.RWMutex @@ -6396,10 +6558,13 @@ type Server struct { handlers map[string]func(Request) // Configurable trigger characters - inlineOpen string - inlineClose string - chatSuffix string - chatPrefixes []string + inlineOpen string + inlineClose string + chatSuffix string + chatPrefixes []string + inlineOpenChar byte + inlineCloseChar byte + chatSuffixChar byte // Prompt templates // Completion @@ -6485,70 +6650,70 @@ type CustomAction struct { User string // if set, use this user template } -func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server { +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server { s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext} maxTokens := opts.MaxTokens - if maxTokens <= 0 { + if maxTokens <= 0 { maxTokens = 500 } - s.maxTokens = maxTokens + s.maxTokens = maxTokens contextMode := opts.ContextMode - if contextMode == "" { + if contextMode == "" { contextMode = "file-on-new-func" } - windowLines := opts.WindowLines - if windowLines <= 0 { + windowLines := opts.WindowLines + if windowLines <= 0 { windowLines = 120 } - maxContextTokens := opts.MaxContextTokens - if maxContextTokens <= 0 { + maxContextTokens := opts.MaxContextTokens + if maxContextTokens <= 0 { maxContextTokens = 2000 } - s.contextMode = contextMode + s.contextMode = contextMode s.windowLines = windowLines s.maxContextTokens = maxContextTokens s.startTime = time.Now() s.llmClient = opts.Client - if len(opts.TriggerCharacters) == 0 { + if len(opts.TriggerCharacters) == 0 { // Defaults (no space to avoid auto-trigger after whitespace) s.triggerChars = []string{".", ":", "/", "_", ")", "{"} } else { s.triggerChars = append([]string{}, opts.TriggerCharacters...) } - s.codingTemperature = opts.CodingTemperature + s.codingTemperature = opts.CodingTemperature s.compCache = make(map[string]string) s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix if opts.CompletionDebounceMs > 0 { s.completionDebounce = time.Duration(opts.CompletionDebounceMs) * time.Millisecond } - if opts.CompletionThrottleMs > 0 { + if opts.CompletionThrottleMs > 0 { s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond } // Trigger character config (with sane defaults if missing) - if strings.TrimSpace(opts.InlineOpen) == "" { + if strings.TrimSpace(opts.InlineOpen) == "" { s.inlineOpen = ">" } else { s.inlineOpen = opts.InlineOpen } - if strings.TrimSpace(opts.InlineClose) == "" { + if strings.TrimSpace(opts.InlineClose) == "" { s.inlineClose = ">" } else { s.inlineClose = opts.InlineClose } - if strings.TrimSpace(opts.ChatSuffix) == "" { + if strings.TrimSpace(opts.ChatSuffix) == "" { s.chatSuffix = ">" - } else { + } else { s.chatSuffix = opts.ChatSuffix } - if len(opts.ChatPrefixes) == 0 { + if len(opts.ChatPrefixes) == 0 { s.chatPrefixes = []string{"?", "!", ":", ";"} - } else { + } else { s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) } // Prompts - s.promptCompSysGeneral = opts.PromptCompSysGeneral + s.promptCompSysGeneral = opts.PromptCompSysGeneral s.promptCompSysParams = opts.PromptCompSysParams s.promptCompSysInline = opts.PromptCompSysInline s.promptCompUserGeneral = opts.PromptCompUserGeneral @@ -6571,21 +6736,23 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) s.customActions = append([]CustomAction{}, opts.CustomActions...) } - // Assign package-level inline trigger chars for free helper functions - if s.inlineOpen != "" { - inlineOpenChar = s.inlineOpen[0] - } - if s.inlineClose != "" { - inlineCloseChar = s.inlineClose[0] + if s.inlineOpen != "" { + s.inlineOpenChar = s.inlineOpen[0] + } else { + s.inlineOpenChar = '>' } - if s.chatSuffix != "" { - chatSuffixChar = s.chatSuffix[0] + if s.inlineClose != "" { + s.inlineCloseChar = s.inlineClose[0] + } else { + s.inlineCloseChar = '>' } - if len(s.chatPrefixes) > 0 { - chatPrefixSingles = append([]string{}, s.chatPrefixes...) + if s.chatSuffix != "" { + s.chatSuffixChar = s.chatSuffix[0] + } else { + s.chatSuffixChar = '>' } // Initialize dispatch table - s.handlers = map[string]func(Request){ + s.handlers = map[string]func(Request){ "initialize": s.handleInitialize, "initialized": func(_ Request) { s.handleInitialized() }, "shutdown": s.handleShutdown, @@ -6598,7 +6765,7 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) "codeAction/resolve": s.handleCodeActionResolve, "workspace/executeCommand": s.handleExecuteCommand, } - return s + return s } func (s *Server) Run() error { @@ -6644,7 +6811,7 @@ import ( func (s *Server) readMessage() ([]byte, error) { tp := textproto.NewReader(s.in) var contentLength int - for { + for { line, err := tp.ReadLine() if err != nil { return nil, err @@ -6677,25 +6844,53 @@ func (s *Server) readMessage() ([]byte, error) { return buf, nil } -func (s *Server) writeMessage(v any) { +func (s *Server) writeMessage(v any) { + s.outMu.Lock() + defer s.outMu.Unlock() + data, err := json.Marshal(v) if err != nil { logging.Logf("lsp ", "marshal error: %v", err) return } - header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) if _, err := io.WriteString(s.out, header); err != nil { logging.Logf("lsp ", "write header error: %v", err) return } - if _, err := s.out.Write(data); err != nil { + if _, err := s.out.Write(data); err != nil { logging.Logf("lsp ", "write body error: %v", err) return } } -