diff options
Diffstat (limited to 'internal/appconfig/config.go')
| -rw-r--r-- | internal/appconfig/config.go | 413 |
1 files changed, 237 insertions, 176 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index d19ea18..92fdf19 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -2,14 +2,14 @@ package appconfig import ( - "encoding/json" - "fmt" - "log" - "os" - "path/filepath" - "slices" - "strconv" - "strings" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "slices" + "strconv" + "strings" ) // App holds user-configurable settings read from ~/.config/hexai/config.json. @@ -20,25 +20,25 @@ type App struct { MaxContextTokens int `json:"max_context_tokens"` LogPreviewLimit int `json:"log_preview_limit"` // Single knob for LSP requests; if set, overrides hardcoded temps in LSP. - CodingTemperature *float64 `json:"coding_temperature"` - // Minimum identifier characters required for manual (TriggerKind=1) invoke - // to proceed without structural triggers. 0 means always allow. - ManualInvokeMinPrefix int `json:"manual_invoke_min_prefix"` + CodingTemperature *float64 `json:"coding_temperature"` + // Minimum identifier characters required for manual (TriggerKind=1) invoke + // to proceed without structural triggers. 0 means always allow. + ManualInvokeMinPrefix int `json:"manual_invoke_min_prefix"` - // Completion debounce in milliseconds. When > 0, the server waits until - // there has been no text change for at least this duration before sending - // an LLM completion request. - CompletionDebounceMs int `json:"completion_debounce_ms"` - // Completion throttle in milliseconds. When > 0, caps the minimum spacing - // between LLM requests (both chat and code-completer paths). - CompletionThrottleMs int `json:"completion_throttle_ms"` + // Completion debounce in milliseconds. When > 0, the server waits until + // there has been no text change for at least this duration before sending + // an LLM completion request. + CompletionDebounceMs int `json:"completion_debounce_ms"` + // Completion throttle in milliseconds. When > 0, caps the minimum spacing + // between LLM requests (both chat and code-completer paths). + CompletionThrottleMs int `json:"completion_throttle_ms"` TriggerCharacters []string `json:"trigger_characters"` Provider string `json:"provider"` // Inline prompt trigger characters (default: >text> and >>text>) - InlineOpen string `json:"inline_open"` - InlineClose string `json:"inline_close"` + InlineOpen string `json:"inline_open"` + InlineClose string `json:"inline_close"` // In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;]) ChatSuffix string `json:"chat_suffix"` ChatPrefixes []string `json:"chat_prefixes"` @@ -64,51 +64,51 @@ func newDefaultConfig() App { // Users can override per provider in config.json (including 0.0). t := 0.2 return App{ - MaxTokens: 4000, - ContextMode: "always-full", - ContextWindowLines: 120, - MaxContextTokens: 4000, - LogPreviewLimit: 100, - CodingTemperature: &t, - OpenAITemperature: &t, - OllamaTemperature: &t, - CopilotTemperature: &t, - ManualInvokeMinPrefix: 0, - CompletionDebounceMs: 200, - CompletionThrottleMs: 0, - // Inline/chat trigger defaults - InlineOpen: ">", - InlineClose: ">", - ChatSuffix: ">", - ChatPrefixes: []string{"?", "!", ":", ";"}, - } + MaxTokens: 4000, + ContextMode: "always-full", + ContextWindowLines: 120, + MaxContextTokens: 4000, + LogPreviewLimit: 100, + CodingTemperature: &t, + OpenAITemperature: &t, + OllamaTemperature: &t, + CopilotTemperature: &t, + ManualInvokeMinPrefix: 0, + CompletionDebounceMs: 200, + CompletionThrottleMs: 0, + // Inline/chat trigger defaults + InlineOpen: ">", + InlineClose: ">", + ChatSuffix: ">", + ChatPrefixes: []string{"?", "!", ":", ";"}, + } } // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. func Load(logger *log.Logger) App { - cfg := newDefaultConfig() - if logger == nil { - return cfg // Return defaults if no logger is provided (e.g. in tests) - } + cfg := newDefaultConfig() + if logger == nil { + return cfg // Return defaults if no logger is provided (e.g. in tests) + } - 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 { - cfg.mergeWith(fileCfg) - } - // When the config file is missing or invalid, we keep defaults and still - // apply any environment overrides below. - } + 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 { + cfg.mergeWith(fileCfg) + } + // When the config file is missing or invalid, we keep defaults and still + // apply any environment overrides below. + } - // Environment overrides (take precedence over file) - if envCfg := loadFromEnv(logger); envCfg != nil { - cfg.mergeWith(envCfg) - } - return cfg + // Environment overrides (take precedence over file) + if envCfg := loadFromEnv(logger); envCfg != nil { + cfg.mergeWith(envCfg) + } + return cfg } // Private helpers @@ -134,8 +134,8 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) { } func (a *App) mergeWith(other *App) { - a.mergeBasics(other) - a.mergeProviderFields(other) + a.mergeBasics(other) + a.mergeProviderFields(other) } // mergeBasics merges general (non-provider) fields. @@ -155,32 +155,36 @@ func (a *App) mergeBasics(other *App) { if other.LogPreviewLimit >= 0 { a.LogPreviewLimit = other.LogPreviewLimit } - if other.CodingTemperature != nil { // allow explicit 0.0 - a.CodingTemperature = other.CodingTemperature - } - if other.ManualInvokeMinPrefix >= 0 { - a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix - } - if other.CompletionDebounceMs > 0 { a.CompletionDebounceMs = other.CompletionDebounceMs } - if other.CompletionThrottleMs > 0 { a.CompletionThrottleMs = other.CompletionThrottleMs } - if len(other.TriggerCharacters) > 0 { - a.TriggerCharacters = slices.Clone(other.TriggerCharacters) - } - if s := strings.TrimSpace(other.InlineOpen); s != "" { - a.InlineOpen = s - } - if s := strings.TrimSpace(other.InlineClose); s != "" { - a.InlineClose = s - } - if s := strings.TrimSpace(other.ChatSuffix); s != "" { - a.ChatSuffix = s - } - if len(other.ChatPrefixes) > 0 { - a.ChatPrefixes = slices.Clone(other.ChatPrefixes) - } - if s := strings.TrimSpace(other.Provider); s != "" { - a.Provider = s - } + if other.CodingTemperature != nil { // allow explicit 0.0 + a.CodingTemperature = other.CodingTemperature + } + if other.ManualInvokeMinPrefix >= 0 { + a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix + } + if other.CompletionDebounceMs > 0 { + a.CompletionDebounceMs = other.CompletionDebounceMs + } + if other.CompletionThrottleMs > 0 { + a.CompletionThrottleMs = other.CompletionThrottleMs + } + if len(other.TriggerCharacters) > 0 { + a.TriggerCharacters = slices.Clone(other.TriggerCharacters) + } + if s := strings.TrimSpace(other.InlineOpen); s != "" { + a.InlineOpen = s + } + if s := strings.TrimSpace(other.InlineClose); s != "" { + a.InlineClose = s + } + if s := strings.TrimSpace(other.ChatSuffix); s != "" { + a.ChatSuffix = s + } + if len(other.ChatPrefixes) > 0 { + a.ChatPrefixes = slices.Clone(other.ChatPrefixes) + } + if s := strings.TrimSpace(other.Provider); s != "" { + a.Provider = s + } } // mergeProviderFields merges per-provider configuration. @@ -225,7 +229,7 @@ func getConfigPath() (string, error) { } configPath = filepath.Join(home, ".config", "hexai", "config.json") } - return configPath, nil + return configPath, nil } // --- Environment overrides --- @@ -233,98 +237,155 @@ func getConfigPath() (string, error) { // 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 { - var out App - var any bool + var out App + var any bool - // helpers - getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } - parseInt := func(k string) (int, bool) { - v := getenv(k) - if v == "" { return 0, false } - n, err := strconv.Atoi(v) - if err != nil { if logger != nil { logger.Printf("invalid %s: %v", k, err) } ; return 0, false } - return n, true - } - parseFloatPtr := func(k string) (*float64, bool) { - v := getenv(k) - if v == "" { return nil, false } - 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 - } + // helpers + getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } + parseInt := func(k string) (int, bool) { + v := getenv(k) + if v == "" { + return 0, false + } + n, err := strconv.Atoi(v) + if err != nil { + if logger != nil { + logger.Printf("invalid %s: %v", k, err) + } + return 0, false + } + return n, true + } + parseFloatPtr := func(k string) (*float64, bool) { + v := getenv(k) + if v == "" { + return nil, false + } + 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 + } - if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok { - out.MaxTokens = n; any = true - } - if s := getenv("HEXAI_CONTEXT_MODE"); s != "" { - out.ContextMode = s; any = true - } - if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok { - out.ContextWindowLines = n; any = true - } - if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok { - out.MaxContextTokens = n; any = true - } - if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok { - out.LogPreviewLimit = n; any = true - } - if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok { - out.ManualInvokeMinPrefix = n; any = true - } - if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok { - out.CompletionDebounceMs = n; any = true - } - if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok { - out.CompletionThrottleMs = n; any = true - } - if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok { - out.CodingTemperature = f; any = true - } - if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" { - parts := strings.Split(s, ",") - out.TriggerCharacters = nil - for _, p := range parts { - if t := strings.TrimSpace(p); t != "" { - out.TriggerCharacters = append(out.TriggerCharacters, t) - } - } - any = true - } - if s := getenv("HEXAI_INLINE_OPEN"); s != "" { out.InlineOpen = s; any = true } - if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { out.InlineClose = s; any = true } - if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { out.ChatSuffix = s; any = true } - if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { - parts := strings.Split(s, ",") - out.ChatPrefixes = nil - for _, p := range parts { - if t := strings.TrimSpace(p); t != "" { - out.ChatPrefixes = append(out.ChatPrefixes, t) - } - } - any = true - } - if s := getenv("HEXAI_PROVIDER"); s != "" { - out.Provider = s; any = true - } + if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok { + out.MaxTokens = n + any = true + } + if s := getenv("HEXAI_CONTEXT_MODE"); s != "" { + out.ContextMode = s + any = true + } + if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok { + out.ContextWindowLines = n + any = true + } + if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok { + out.MaxContextTokens = n + any = true + } + if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok { + out.LogPreviewLimit = n + any = true + } + if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok { + out.ManualInvokeMinPrefix = n + any = true + } + if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok { + out.CompletionDebounceMs = n + any = true + } + if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok { + out.CompletionThrottleMs = n + any = true + } + if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok { + out.CodingTemperature = f + any = true + } + if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" { + parts := strings.Split(s, ",") + out.TriggerCharacters = nil + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out.TriggerCharacters = append(out.TriggerCharacters, t) + } + } + any = true + } + if s := getenv("HEXAI_INLINE_OPEN"); s != "" { + out.InlineOpen = s + any = true + } + if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { + out.InlineClose = s + any = true + } + if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { + out.ChatSuffix = s + any = true + } + if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { + parts := strings.Split(s, ",") + out.ChatPrefixes = nil + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out.ChatPrefixes = append(out.ChatPrefixes, t) + } + } + any = true + } + if s := getenv("HEXAI_PROVIDER"); s != "" { + out.Provider = s + any = true + } - // Provider-specific - if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { out.OpenAIBaseURL = s; any = true } - if s := getenv("HEXAI_OPENAI_MODEL"); s != "" { out.OpenAIModel = s; any = true } - if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { out.OpenAITemperature = f; any = true } + // Provider-specific + if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { + out.OpenAIBaseURL = s + any = true + } + if s := getenv("HEXAI_OPENAI_MODEL"); s != "" { + out.OpenAIModel = s + any = true + } + if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { + out.OpenAITemperature = f + any = true + } - if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { out.OllamaBaseURL = s; any = true } - if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" { out.OllamaModel = s; any = true } - if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { out.OllamaTemperature = f; any = true } + if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { + out.OllamaBaseURL = s + any = true + } + if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" { + out.OllamaModel = s + any = true + } + if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { + out.OllamaTemperature = f + any = true + } - if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { out.CopilotBaseURL = s; any = true } - if s := getenv("HEXAI_COPILOT_MODEL"); s != "" { out.CopilotModel = s; any = true } - if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { out.CopilotTemperature = f; any = true } + if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { + out.CopilotBaseURL = s + any = true + } + if s := getenv("HEXAI_COPILOT_MODEL"); s != "" { + out.CopilotModel = s + any = true + } + if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { + out.CopilotTemperature = f + any = true + } - if !any { - return nil - } - return &out + if !any { + return nil + } + return &out } |
