diff options
| -rw-r--r-- | internal/appconfig/config.go | 1636 | ||||
| -rw-r--r-- | internal/appconfig/config_load.go | 932 | ||||
| -rw-r--r-- | internal/appconfig/config_merge.go | 242 | ||||
| -rw-r--r-- | internal/appconfig/config_types.go | 438 | ||||
| -rw-r--r-- | internal/appconfig/config_validate.go | 98 |
5 files changed, 1710 insertions, 1636 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go deleted file mode 100644 index 490ed4e..0000000 --- a/internal/appconfig/config.go +++ /dev/null @@ -1,1636 +0,0 @@ -// Summary: Application configuration model and loader; reads ~/.config/hexai/config.toml and merges defaults. -package appconfig - -import ( - "fmt" - "log" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - - "github.com/pelletier/go-toml/v2" -) - -// SurfaceConfig describes a provider/model pairing (with optional temperature). -type SurfaceConfig struct { - Provider string - Model string - Temperature *float64 -} - -// App holds user-configurable settings read from ~/.config/hexai/config.toml. -type App struct { - MaxTokens int `json:"max_tokens" toml:"max_tokens"` - ContextMode string `json:"context_mode" toml:"context_mode"` - ContextWindowLines int `json:"context_window_lines" toml:"context_window_lines"` - MaxContextTokens int `json:"max_context_tokens" toml:"max_context_tokens"` - LogPreviewLimit int `json:"log_preview_limit" toml:"log_preview_limit"` - RequestTimeout int `json:"request_timeout" toml:"request_timeout"` - // Single knob for LSP requests; if set, overrides hardcoded temps in LSP. - CodingTemperature *float64 `json:"coding_temperature" toml:"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" toml:"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" toml:"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" toml:"completion_throttle_ms"` - // CompletionWaitAll controls whether to wait for all configured completion - // backends before returning results. When true (default), waits for all - // backends. When false, returns the first result immediately. - CompletionWaitAll *bool `json:"completion_wait_all" toml:"completion_wait_all"` - - TriggerCharacters []string `json:"trigger_characters" toml:"trigger_characters"` - Provider string `json:"provider" toml:"provider"` - - // Inline prompt trigger characters (default: >!text> and >>!text>) - InlineOpen string `json:"inline_open" toml:"inline_open"` - InlineClose string `json:"inline_close" toml:"inline_close"` - // In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;]) - ChatSuffix string `json:"chat_suffix" toml:"chat_suffix"` - ChatPrefixes []string `json:"chat_prefixes" toml:"chat_prefixes"` - - // Provider-specific options - OpenAIBaseURL string `json:"openai_base_url" toml:"openai_base_url"` - OpenAIModel string `json:"openai_model" toml:"openai_model"` - // Default temperature for OpenAI requests (nil means use provider default) - OpenAITemperature *float64 `json:"openai_temperature" toml:"openai_temperature"` - OpenRouterBaseURL string `json:"openrouter_base_url" toml:"openrouter_base_url"` - OpenRouterModel string `json:"openrouter_model" toml:"openrouter_model"` - // Default temperature for OpenRouter requests (nil means use provider default) - OpenRouterTemperature *float64 `json:"openrouter_temperature" toml:"openrouter_temperature"` - OllamaBaseURL string `json:"ollama_base_url" toml:"ollama_base_url"` - OllamaModel string `json:"ollama_model" toml:"ollama_model"` - // Default temperature for Ollama requests (nil means use provider default) - OllamaTemperature *float64 `json:"ollama_temperature" toml:"ollama_temperature"` - AnthropicBaseURL string `json:"anthropic_base_url" toml:"anthropic_base_url"` - AnthropicModel string `json:"anthropic_model" toml:"anthropic_model"` - // Default temperature for Anthropic requests (nil means use provider default) - AnthropicTemperature *float64 `json:"anthropic_temperature" toml:"anthropic_temperature"` - - // Per-surface provider/model configurations (ordered; first entry is primary) - CompletionConfigs []SurfaceConfig `json:"-" toml:"-"` - CodeActionConfigs []SurfaceConfig `json:"-" toml:"-"` - ChatConfigs []SurfaceConfig `json:"-" toml:"-"` - CLIConfigs []SurfaceConfig `json:"-" toml:"-"` - - // Prompt templates (configured only via file; no env overrides) - // Completion/chat/code action/CLI prompt strings. See config.toml.example for placeholders. - // Completion - PromptCompletionSystemGeneral string `json:"-" toml:"-"` - PromptCompletionSystemParams string `json:"-" toml:"-"` - PromptCompletionSystemInline string `json:"-" toml:"-"` - PromptCompletionUserGeneral string `json:"-" toml:"-"` - PromptCompletionUserParams string `json:"-" toml:"-"` - PromptCompletionExtraHeader string `json:"-" toml:"-"` - // Provider-native code-completer - PromptNativeCompletion string `json:"-" toml:"-"` - // In-editor chat - PromptChatSystem string `json:"-" toml:"-"` - // Code actions - PromptCodeActionRewriteSystem string `json:"-" toml:"-"` - PromptCodeActionDiagnosticsSystem string `json:"-" toml:"-"` - PromptCodeActionDocumentSystem string `json:"-" toml:"-"` - PromptCodeActionRewriteUser string `json:"-" toml:"-"` - PromptCodeActionDiagnosticsUser string `json:"-" toml:"-"` - PromptCodeActionDocumentUser string `json:"-" toml:"-"` - PromptCodeActionGoTestSystem string `json:"-" toml:"-"` - PromptCodeActionGoTestUser string `json:"-" toml:"-"` - PromptCodeActionSimplifySystem string `json:"-" toml:"-"` - PromptCodeActionSimplifyUser string `json:"-" toml:"-"` - // CLI - PromptCLIDefaultSystem string `json:"-" toml:"-"` - PromptCLIExplainSystem string `json:"-" toml:"-"` - - // Custom code actions and tmux integration - CustomActions []CustomAction `json:"-" toml:"-"` - TmuxCustomMenuHotkey string `json:"-" toml:"-"` - // Stats - StatsWindowMinutes int `json:"-" toml:"-"` - - // Ignore: gitignore-aware file filtering for LSP - IgnoreGitignore *bool `json:"-" toml:"-"` - IgnoreExtraPatterns []string `json:"-" toml:"-"` - IgnoreLSPNotify *bool `json:"-" toml:"-"` - - // TmuxEdit: popup editor settings for hexai-tmux-edit - TmuxEditPopupWidth string `json:"-" toml:"-"` - TmuxEditPopupHeight string `json:"-" toml:"-"` - TmuxEditDefaultAgent string `json:"-" toml:"-"` - TmuxEditAgents []TmuxEditAgentCfg `json:"-" toml:"-"` - - // MCP: Model Context Protocol server settings - MCPPromptsDir string `json:"-" toml:"-"` // Directory for prompt storage - MCPSlashCommandSync bool `json:"-" toml:"-"` // Enable slash command sync - MCPSlashCommandDir string `json:"-" toml:"-"` // Directory for slash command files -} - -// CustomAction describes a user-defined code action. -type CustomAction struct { - ID string - Title string - Kind string // optional; default "refactor" - Scope string // "selection" (default) | "diagnostics" - Hotkey string // optional, used by tmux submenu - Instruction string // optional; if set and User is empty, use global rewrite templates - System string // optional; used only when User is set - User string // optional; if set, render with available vars -} - -// TmuxEditAgentCfg describes an AI agent's detection and interaction patterns -// for the tmux popup editor (hexai-tmux-edit). -type TmuxEditAgentCfg struct { - Name string - DisplayName string - DetectPattern string - SectionPattern string - PromptPattern string - StripPatterns []string - ClearFirst *bool - ClearKeys string - NewlineKeys string - SubmitKeys string -} - -// Constructor: defaults for App (kept first among functions) -func newDefaultConfig() App { - // Coding-friendly default temperature across providers - // Users can override per provider in config.toml (including 0.0). - t := 0.2 - return App{ - MaxTokens: 4000, - ContextMode: "always-full", - ContextWindowLines: 120, - MaxContextTokens: 4000, - LogPreviewLimit: 100, - RequestTimeout: 30, - CodingTemperature: &t, - OpenAITemperature: &t, - OllamaTemperature: &t, - AnthropicTemperature: &t, - ManualInvokeMinPrefix: 0, - CompletionDebounceMs: 800, - CompletionThrottleMs: 0, - // Inline/chat trigger defaults - InlineOpen: ">!", - InlineClose: ">", - ChatSuffix: ">", - ChatPrefixes: []string{"?", "!", ":", ";"}, - - // Default prompt templates (match current hard-coded strings) - PromptCompletionSystemParams: "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types.", - PromptCompletionUserParams: "Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: {{function}}\nCurrent line (cursor at {{char}}): {{current}}", - PromptCompletionSystemGeneral: "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression).", - PromptCompletionUserGeneral: "Provide the next likely code to insert at the cursor.\nFile: {{file}}\nFunction/context: {{function}}\nAbove line: {{above}}\nCurrent line (cursor at character {{char}}): {{current}}\nBelow line: {{below}}\nOnly return the completion snippet.", - PromptCompletionSystemInline: "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only.", - PromptCompletionExtraHeader: "Additional context:\n{{context}}", - - PromptNativeCompletion: "// Path: {{path}}\n{{before}}", - - PromptChatSystem: "You are a helpful coding assistant. Answer concisely and clearly.", - - PromptCodeActionRewriteSystem: "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable.", - PromptCodeActionDiagnosticsSystem: "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes.", - PromptCodeActionDocumentSystem: "You are a precise code documentation engine. Add idiomatic documentation comments to the given code. Preserve exact behavior and formatting as much as possible. Return only the updated code with comments, no prose or backticks.", - PromptCodeActionRewriteUser: "Instruction: {{instruction}}\n\nSelected code to transform:\n{{selection}}", - PromptCodeActionDiagnosticsUser: "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}", - PromptCodeActionDocumentUser: "Add documentation comments to this code:\n{{selection}}", - PromptCodeActionGoTestSystem: "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic.", - PromptCodeActionGoTestUser: "Function under test:\n{{function}}", - PromptCodeActionSimplifySystem: "You are a precise code improvement engine. Simplify and improve the given code while preserving behavior. Return only the improved code with no prose or backticks.", - PromptCodeActionSimplifyUser: "Improve this code:\n{{selection}}", - - PromptCLIDefaultSystem: "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.", - PromptCLIExplainSystem: "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.", - - // Stats - StatsWindowMinutes: 60, - - // Ignore: respect .gitignore by default, notify in LSP by default - IgnoreGitignore: boolPtr(true), - IgnoreLSPNotify: boolPtr(true), - } -} - -func boolPtr(b bool) *bool { return &b } - -// Load reads configuration from a file and merges with defaults. -// It respects the XDG Base Directory Specification. -func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{}) } - -// LoadOptions tune how configuration is loaded at runtime. -type LoadOptions struct { - // IgnoreEnv skips applying environment overrides when true. - IgnoreEnv bool - // ConfigPath overrides the global config file path (e.g. via --config flag). - ConfigPath string - // ProjectRoot overrides the project root directory for locating .hexaiconfig.toml. - // When empty, findGitRoot() is used to auto-detect from the current working directory. - ProjectRoot string -} - -// LoadWithOptions reads configuration and applies the requested loading options. -func LoadWithOptions(logger *log.Logger, opts LoadOptions) App { - cfg := newDefaultConfig() - if logger == nil { - return cfg // Return defaults if no logger is provided (e.g. in tests) - } - - // Step 1: Load global config file - configPath := strings.TrimSpace(opts.ConfigPath) - if configPath != "" { - if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { - cfg.mergeWith(fileCfg) - } else if err != nil { - logger.Printf("cannot open config file %s: %v", configPath, err) - } - } else { - path, err := getConfigPath() - if err != nil { - logger.Printf("%v", err) - } else if fileCfg, err := loadFromFile(path, logger); err == nil && fileCfg != nil { - cfg.mergeWith(fileCfg) - } - } - - // Step 2: Load per-project config (.hexaiconfig.toml at git repo root). - // Project config overrides global config but is itself overridden by env vars. - loadProjectConfig(logger, opts, &cfg) - - // Step 3: Environment overrides (always take precedence over all config files) - if !opts.IgnoreEnv { - if envCfg := loadFromEnv(logger); envCfg != nil { - cfg.mergeWith(envCfg) - } - } - return cfg -} - -// loadProjectConfig attempts to load .hexaiconfig.toml from the project root and -// merges it into cfg. Uses opts.ProjectRoot if set, otherwise auto-detects via FindGitRoot(). -func loadProjectConfig(logger *log.Logger, opts LoadOptions, cfg *App) { - projectRoot := strings.TrimSpace(opts.ProjectRoot) - if projectRoot == "" { - projectRoot = FindGitRoot() - } - if projectRoot == "" { - return - } - projectCfgPath := filepath.Join(projectRoot, ProjectConfigFilename) - if projCfg, err := loadFromFile(projectCfgPath, logger); err == nil && projCfg != nil { - cfg.mergeWith(projCfg) - } -} - -// Private helpers -// Sectioned (table-based) file format only. -type fileConfig struct { - // Section tables only (flat keys are not allowed) - General sectionGeneral `toml:"general"` - Logging sectionLogging `toml:"logging"` - Completion sectionCompletion `toml:"completion"` - Triggers sectionTriggers `toml:"triggers"` - Inline sectionInline `toml:"inline"` - Chat sectionChat `toml:"chat"` - Provider sectionProvider `toml:"provider"` - OpenAI sectionOpenAI `toml:"openai"` - OpenRouter sectionOpenRouter `toml:"openrouter"` - Ollama sectionOllama `toml:"ollama"` - Anthropic sectionAnthropic `toml:"anthropic"` - Prompts sectionPrompts `toml:"prompts"` - Tmux sectionTmux `toml:"tmux"` - Stats sectionStats `toml:"stats"` - Ignore sectionIgnore `toml:"ignore"` - TmuxEdit sectionTmuxEdit `toml:"tmux_edit"` - MCP sectionMCP `toml:"mcp"` -} - -type sectionGeneral struct { - MaxTokens int `toml:"max_tokens"` - ContextMode string `toml:"context_mode"` - ContextWindowLines int `toml:"context_window_lines"` - MaxContextTokens int `toml:"max_context_tokens"` - CodingTemperature *float64 `toml:"coding_temperature"` - RequestTimeout int `toml:"request_timeout"` -} - -type sectionLogging struct { - LogPreviewLimit int `toml:"log_preview_limit"` -} - -type sectionCompletion struct { - CompletionDebounceMs int `toml:"completion_debounce_ms"` - CompletionThrottleMs int `toml:"completion_throttle_ms"` - ManualInvokeMinPrefix int `toml:"manual_invoke_min_prefix"` - CompletionWaitAll *bool `toml:"completion_wait_all"` -} - -type sectionTriggers struct { - TriggerCharacters []string `toml:"trigger_characters"` -} - -type sectionInline struct { - InlineOpen string `toml:"inline_open"` - InlineClose string `toml:"inline_close"` -} - -type sectionChat struct { - ChatSuffix string `toml:"chat_suffix"` - ChatPrefixes []string `toml:"chat_prefixes"` -} - -type sectionProvider struct { - Name string `toml:"name"` -} - -type sectionStats struct { - WindowMinutes int `toml:"window_minutes"` -} - -// sectionIgnore controls gitignore-aware file filtering. Files matching -// these patterns are skipped for completions and code actions. -type sectionIgnore struct { - Gitignore *bool `toml:"gitignore"` - ExtraPatterns []string `toml:"extra_patterns"` - LSPNotifyIgnored *bool `toml:"lsp_notify_ignored"` -} - -// sectionTmuxEdit configures the tmux popup editor feature (hexai-tmux-edit). -type sectionTmuxEdit struct { - PopupWidth string `toml:"popup_width"` - PopupHeight string `toml:"popup_height"` - DefaultAgent string `toml:"default_agent"` - Agents []sectionTmuxEditAgent `toml:"agents"` -} - -// sectionTmuxEditAgent defines detection and interaction patterns for one AI agent. -type sectionTmuxEditAgent struct { - Name string `toml:"name"` - DisplayName string `toml:"display_name"` - DetectPattern string `toml:"detect_pattern"` - SectionPattern string `toml:"section_pattern"` - PromptPattern string `toml:"prompt_pattern"` - StripPatterns []string `toml:"strip_patterns"` - ClearFirst *bool `toml:"clear_first"` - ClearKeys string `toml:"clear_keys"` - NewlineKeys string `toml:"newline_keys"` - SubmitKeys string `toml:"submit_keys"` -} - -// sectionMCP configures the MCP server settings. -type sectionMCP struct { - PromptsDir string `toml:"prompts_dir"` - SlashCommandSync bool `toml:"slashcommand_sync"` - SlashCommandDir string `toml:"slashcommand_dir"` -} - -type sectionOpenAI struct { - 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 sectionOpenRouter struct { - Model string `toml:"model"` - BaseURL string `toml:"base_url"` - Temperature *float64 `toml:"temperature"` -} - -type sectionOllama struct { - Model string `toml:"model"` - BaseURL string `toml:"base_url"` - Temperature *float64 `toml:"temperature"` -} - -type sectionAnthropic struct { - Model string `toml:"model"` - BaseURL string `toml:"base_url"` - Temperature *float64 `toml:"temperature"` -} - -// Prompts sections -type sectionPrompts struct { - Completion sectionPromptsCompletion `toml:"completion"` - Chat sectionPromptsChat `toml:"chat"` - CodeAction sectionPromptsCodeAction `toml:"code_action"` - CLI sectionPromptsCLI `toml:"cli"` - ProviderNative sectionPromptsProviderNative `toml:"provider_native"` -} - -type sectionPromptsCompletion struct { - SystemGeneral string `toml:"system_general"` - SystemParams string `toml:"system_params"` - SystemInline string `toml:"system_inline"` - UserGeneral string `toml:"user_general"` - UserParams string `toml:"user_params"` - ExtraHeader string `toml:"additional_context"` -} - -type sectionPromptsChat struct { - System string `toml:"system"` -} - -type sectionPromptsCodeAction struct { - RewriteSystem string `toml:"rewrite_system"` - DiagnosticsSystem string `toml:"diagnostics_system"` - DocumentSystem string `toml:"document_system"` - RewriteUser string `toml:"rewrite_user"` - DiagnosticsUser string `toml:"diagnostics_user"` - DocumentUser string `toml:"document_user"` - GoTestSystem string `toml:"go_test_system"` - GoTestUser string `toml:"go_test_user"` - SimplifySystem string `toml:"simplify_system"` - SimplifyUser string `toml:"simplify_user"` - Custom []sectionCustomAction `toml:"custom"` -} - -type sectionPromptsCLI struct { - DefaultSystem string `toml:"default_system"` - ExplainSystem string `toml:"explain_system"` -} - -type sectionPromptsProviderNative struct { - Completion string `toml:"completion"` -} - -type sectionCustomAction struct { - ID string `toml:"id"` - Title string `toml:"title"` - Kind string `toml:"kind"` - Scope string `toml:"scope"` - Hotkey string `toml:"hotkey"` - Instruction string `toml:"instruction"` - System string `toml:"system"` - User string `toml:"user"` -} - -type sectionTmux struct { - CustomMenuHotkey string `toml:"custom_menu_hotkey"` -} - -func (fc *fileConfig) toApp() App { - out := App{} - - // Merge section: general - if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil { - tmp := App{ - MaxTokens: fc.General.MaxTokens, - ContextMode: fc.General.ContextMode, - ContextWindowLines: fc.General.ContextWindowLines, - MaxContextTokens: fc.General.MaxContextTokens, - CodingTemperature: fc.General.CodingTemperature, - RequestTimeout: fc.General.RequestTimeout, - } - out.mergeBasics(&tmp) - } - - // logging - if (fc.Logging != sectionLogging{}) { - tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit} - out.mergeBasics(&tmp) - } - - // completion - if fc.Completion.CompletionDebounceMs != 0 || fc.Completion.CompletionThrottleMs != 0 || - fc.Completion.ManualInvokeMinPrefix != 0 || fc.Completion.CompletionWaitAll != nil { - tmp := App{ - CompletionDebounceMs: fc.Completion.CompletionDebounceMs, - CompletionThrottleMs: fc.Completion.CompletionThrottleMs, - ManualInvokeMinPrefix: fc.Completion.ManualInvokeMinPrefix, - CompletionWaitAll: fc.Completion.CompletionWaitAll, - } - out.mergeBasics(&tmp) - } - - // triggers - if len(fc.Triggers.TriggerCharacters) > 0 { - tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters} - out.mergeBasics(&tmp) - } - - // inline - 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 { - tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes} - out.mergeBasics(&tmp) - } - - // provider - if strings.TrimSpace(fc.Provider.Name) != "" { - tmp := App{Provider: fc.Provider.Name} - out.mergeBasics(&tmp) - } - - // openai - if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil { - tmp := App{ - OpenAIBaseURL: fc.OpenAI.BaseURL, - OpenAIModel: fc.OpenAI.resolvedModel(), - OpenAITemperature: fc.OpenAI.Temperature, - } - out.mergeProviderFields(&tmp) - } - - // openrouter - if (fc.OpenRouter != sectionOpenRouter{}) || fc.OpenRouter.Temperature != nil { - tmp := App{ - OpenRouterBaseURL: fc.OpenRouter.BaseURL, - OpenRouterModel: fc.OpenRouter.Model, - OpenRouterTemperature: fc.OpenRouter.Temperature, - } - out.mergeProviderFields(&tmp) - } - - // ollama - if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil { - tmp := App{ - OllamaBaseURL: fc.Ollama.BaseURL, - OllamaModel: fc.Ollama.Model, - OllamaTemperature: fc.Ollama.Temperature, - } - out.mergeProviderFields(&tmp) - } - - // anthropic - if (fc.Anthropic != sectionAnthropic{}) || fc.Anthropic.Temperature != nil { - tmp := App{ - AnthropicBaseURL: fc.Anthropic.BaseURL, - AnthropicModel: fc.Anthropic.Model, - AnthropicTemperature: fc.Anthropic.Temperature, - } - out.mergeProviderFields(&tmp) - } - - // prompts - // completion - if (fc.Prompts.Completion != sectionPromptsCompletion{}) { - if strings.TrimSpace(fc.Prompts.Completion.SystemGeneral) != "" { - out.PromptCompletionSystemGeneral = fc.Prompts.Completion.SystemGeneral - } - if strings.TrimSpace(fc.Prompts.Completion.SystemParams) != "" { - out.PromptCompletionSystemParams = fc.Prompts.Completion.SystemParams - } - if strings.TrimSpace(fc.Prompts.Completion.SystemInline) != "" { - out.PromptCompletionSystemInline = fc.Prompts.Completion.SystemInline - } - if strings.TrimSpace(fc.Prompts.Completion.UserGeneral) != "" { - out.PromptCompletionUserGeneral = fc.Prompts.Completion.UserGeneral - } - if strings.TrimSpace(fc.Prompts.Completion.UserParams) != "" { - out.PromptCompletionUserParams = fc.Prompts.Completion.UserParams - } - if strings.TrimSpace(fc.Prompts.Completion.ExtraHeader) != "" { - out.PromptCompletionExtraHeader = fc.Prompts.Completion.ExtraHeader - } - } - // chat - if strings.TrimSpace(fc.Prompts.Chat.System) != "" { - out.PromptChatSystem = fc.Prompts.Chat.System - } - // code action - 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) != "" || - strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" || - strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" || - strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" || - strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" || - strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" || - strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" || - 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) != "" { - out.PromptCodeActionDiagnosticsSystem = fc.Prompts.CodeAction.DiagnosticsSystem - } - if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" { - out.PromptCodeActionDocumentSystem = fc.Prompts.CodeAction.DocumentSystem - } - if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" { - out.PromptCodeActionRewriteUser = fc.Prompts.CodeAction.RewriteUser - } - if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" { - out.PromptCodeActionDiagnosticsUser = fc.Prompts.CodeAction.DiagnosticsUser - } - if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" { - out.PromptCodeActionDocumentUser = fc.Prompts.CodeAction.DocumentUser - } - if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" { - out.PromptCodeActionGoTestSystem = fc.Prompts.CodeAction.GoTestSystem - } - if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" { - out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser - } - if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" { - out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem - } - 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 { - out.CustomActions = append(out.CustomActions, CustomAction{ - ID: strings.TrimSpace(ca.ID), - Title: strings.TrimSpace(ca.Title), - Kind: strings.TrimSpace(ca.Kind), - Scope: strings.ToLower(strings.TrimSpace(ca.Scope)), - Hotkey: strings.TrimSpace(ca.Hotkey), - Instruction: ca.Instruction, - System: ca.System, - User: ca.User, - }) - } - } - } - // cli - if (fc.Prompts.CLI != sectionPromptsCLI{}) { - if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" { - out.PromptCLIDefaultSystem = fc.Prompts.CLI.DefaultSystem - } - if strings.TrimSpace(fc.Prompts.CLI.ExplainSystem) != "" { - out.PromptCLIExplainSystem = fc.Prompts.CLI.ExplainSystem - } - } - // provider-native - if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" { - out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion - } - - // tmux - if (fc.Tmux != sectionTmux{}) { - out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) - } - - // stats - if fc.Stats.WindowMinutes > 0 { - out.StatsWindowMinutes = fc.Stats.WindowMinutes - } - - // ignore - if fc.Ignore.Gitignore != nil || len(fc.Ignore.ExtraPatterns) > 0 || fc.Ignore.LSPNotifyIgnored != nil { - tmp := App{ - IgnoreGitignore: fc.Ignore.Gitignore, - IgnoreExtraPatterns: fc.Ignore.ExtraPatterns, - IgnoreLSPNotify: fc.Ignore.LSPNotifyIgnored, - } - out.mergeBasics(&tmp) - } - - // tmux_edit - fc.applyTmuxEdit(&out) - - // mcp - if strings.TrimSpace(fc.MCP.PromptsDir) != "" { - out.MCPPromptsDir = strings.TrimSpace(fc.MCP.PromptsDir) - } - if fc.MCP.SlashCommandSync { - out.MCPSlashCommandSync = fc.MCP.SlashCommandSync - } - if strings.TrimSpace(fc.MCP.SlashCommandDir) != "" { - out.MCPSlashCommandDir = strings.TrimSpace(fc.MCP.SlashCommandDir) - } - - return out -} - -// applyTmuxEdit converts the [tmux_edit] section into App fields. -func (fc *fileConfig) applyTmuxEdit(out *App) { - te := fc.TmuxEdit - if strings.TrimSpace(te.PopupWidth) != "" { - out.TmuxEditPopupWidth = strings.TrimSpace(te.PopupWidth) - } - if strings.TrimSpace(te.PopupHeight) != "" { - out.TmuxEditPopupHeight = strings.TrimSpace(te.PopupHeight) - } - if strings.TrimSpace(te.DefaultAgent) != "" { - out.TmuxEditDefaultAgent = strings.TrimSpace(te.DefaultAgent) - } - for _, a := range te.Agents { - if strings.TrimSpace(a.Name) == "" { - continue - } - out.TmuxEditAgents = append(out.TmuxEditAgents, TmuxEditAgentCfg{ - Name: strings.TrimSpace(a.Name), - DisplayName: strings.TrimSpace(a.DisplayName), - DetectPattern: strings.TrimSpace(a.DetectPattern), - SectionPattern: strings.TrimSpace(a.SectionPattern), - PromptPattern: strings.TrimSpace(a.PromptPattern), - StripPatterns: a.StripPatterns, - ClearFirst: a.ClearFirst, - ClearKeys: strings.TrimSpace(a.ClearKeys), - NewlineKeys: strings.TrimSpace(a.NewlineKeys), - SubmitKeys: strings.TrimSpace(a.SubmitKeys), - }) - } -} - -func loadFromFile(path string, logger *log.Logger) (*App, error) { - b, err := os.ReadFile(path) - if err != nil { - if !os.IsNotExist(err) && logger != nil { - logger.Printf("cannot open TOML config file %s: %v", path, err) - } - return nil, err - } - - 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 { - logger.Printf("invalid TOML config file %s: %v", path, errTables) - } - return nil, errTables - } - - // Reject legacy flat keys at top-level (sectioned-only config is allowed) - 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": {}, - "chat_suffix": {}, "chat_prefixes": {}, "coding_temperature": {}, "provider": {}, - "openai_model": {}, "openai_base_url": {}, "openai_temperature": {}, - "ollama_model": {}, "ollama_base_url": {}, "ollama_temperature": {}, - } - for k := range raw { - if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "models": {}, "openai": {}, "ollama": {}, "prompts": {}}[k]; isTable { - continue - } - if _, isLegacy := legacy[k]; isLegacy { - return nil, fmt.Errorf("unsupported flat key '%s' in config; use sectioned tables (see config.toml.example)", k) - } - } - - 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() - // 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 { - switch vv := v.(type) { - case int64: - tab.ManualInvokeMinPrefix = int(vv) - case int: - tab.ManualInvokeMinPrefix = vv - case float64: - tab.ManualInvokeMinPrefix = int(vv) - } - } - } - if t, ok := raw["logging"].(map[string]any); ok { - if v, present := t["log_preview_limit"]; present { - switch vv := v.(type) { - case int64: - tab.LogPreviewLimit = int(vv) - case int: - tab.LogPreviewLimit = vv - case float64: - tab.LogPreviewLimit = int(vv) - } - } - } - if m := parseSurfaceModels(raw, logger); m != nil { - tab.mergeSurfaceModels(m) - } - return &tab, nil -} - -func parseSurfaceModels(raw map[string]any, logger *log.Logger) *App { - modelsRaw, ok := raw["models"] - if !ok { - return nil - } - table, ok := modelsRaw.(map[string]any) - if !ok { - if logger != nil { - logger.Printf("config: ignoring models section (expected table, got %T)", modelsRaw) - } - return nil - } - var out App - appendEntries := func(dest *[]SurfaceConfig, key string, val any) bool { - entries, ok := parseSurfaceEntries(val, key, logger) - if !ok || len(entries) == 0 { - return false - } - *dest = append(*dest, entries...) - return true - } - any := appendEntries(&out.CompletionConfigs, "models.completion", table["completion"]) - if ok := appendEntries(&out.CodeActionConfigs, "models.code_action", table["code_action"]); ok { - if len(out.CodeActionConfigs) > 1 { - if logger != nil { - logger.Printf("config: models.code_action supports a single entry; ignoring %d extra", len(out.CodeActionConfigs)-1) - } - out.CodeActionConfigs = out.CodeActionConfigs[:1] - } - any = true - } - any = appendEntries(&out.ChatConfigs, "models.chat", table["chat"]) || any - any = appendEntries(&out.CLIConfigs, "models.cli", table["cli"]) || any - if !any { - return nil - } - return &out -} - -func parseSurfaceEntries(raw any, path string, logger *log.Logger) ([]SurfaceConfig, bool) { - switch v := raw.(type) { - case nil: - return nil, false - case []any: - var out []SurfaceConfig - for i, entry := range v { - cfg, ok := decodeModelEntry(entry, fmt.Sprintf("%s[%d]", path, i), logger) - if !ok || cfg == nil { - continue - } - out = append(out, *cfg) - } - return out, len(out) > 0 - default: - if cfg, ok := decodeModelEntry(v, path, logger); ok && cfg != nil { - return []SurfaceConfig{*cfg}, true - } - return nil, false - } -} - -func cloneSurfaceConfigs(src []SurfaceConfig) []SurfaceConfig { - if len(src) == 0 { - return nil - } - out := make([]SurfaceConfig, len(src)) - copy(out, src) - return out -} - -func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, bool) { - if raw == nil { - return nil, false - } - switch v := raw.(type) { - case string: - model := strings.TrimSpace(v) - if model == "" { - return nil, false - } - return &SurfaceConfig{Model: model}, true - case map[string]any: - model := "" - provider := "" - if m, ok := v["model"]; ok { - s, ok := m.(string) - if !ok { - if logger != nil { - logger.Printf("config: %s.model must be a string", path) - } - return nil, false - } - model = strings.TrimSpace(s) - } - if pRaw, ok := v["provider"]; ok { - ps, ok := pRaw.(string) - if !ok { - if logger != nil { - logger.Printf("config: %s.provider must be a string", path) - } - return nil, false - } - provider = strings.TrimSpace(ps) - } - var tempPtr *float64 - if tRaw, ok := v["temperature"]; ok { - parsed, ok := parseTemperatureValue(tRaw, path, logger) - if !ok { - return nil, false - } - tempPtr = parsed - } - if model == "" && tempPtr == nil && provider == "" { - return nil, false - } - return &SurfaceConfig{Provider: provider, Model: model, Temperature: tempPtr}, true - default: - if logger != nil { - logger.Printf("config: %s must be a string or table, got %T", path, raw) - } - return nil, false - } -} - -func parseTemperatureValue(raw any, path string, logger *log.Logger) (*float64, bool) { - switch v := raw.(type) { - case float64: - return floatPtr(v), true - case int64: - return floatPtr(float64(v)), true - case string: - s := strings.TrimSpace(v) - if s == "" { - return nil, true - } - f, err := strconv.ParseFloat(s, 64) - if err != nil { - if logger != nil { - logger.Printf("config: %s.temperature invalid: %v", path, err) - } - return nil, false - } - return floatPtr(f), true - default: - if logger != nil { - logger.Printf("config: %s.temperature must be numeric or string, got %T", path, raw) - } - return nil, false - } -} - -func floatPtr(v float64) *float64 { - f := v - return &f -} - -func (a *App) mergeWith(other *App) { - a.mergeBasics(other) - a.mergeProviderFields(other) - a.mergeSurfaceModels(other) - a.mergePrompts(other) - a.mergeTmuxEdit(other) -} - -// mergeBasics merges general (non-provider) fields. -func (a *App) mergeBasics(other *App) { - if other.MaxTokens > 0 { - a.MaxTokens = other.MaxTokens - } - if s := strings.TrimSpace(other.ContextMode); s != "" { - a.ContextMode = s - } - if other.ContextWindowLines > 0 { - a.ContextWindowLines = other.ContextWindowLines - } - if other.MaxContextTokens > 0 { - a.MaxContextTokens = other.MaxContextTokens - } - if other.LogPreviewLimit >= 0 { - a.LogPreviewLimit = other.LogPreviewLimit - } - if other.RequestTimeout > 0 { - a.RequestTimeout = other.RequestTimeout - } - 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 other.CompletionWaitAll != nil { - a.CompletionWaitAll = other.CompletionWaitAll - } - 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 - } - // Ignore settings - if other.IgnoreGitignore != nil { - a.IgnoreGitignore = other.IgnoreGitignore - } - if len(other.IgnoreExtraPatterns) > 0 { - a.IgnoreExtraPatterns = slices.Clone(other.IgnoreExtraPatterns) - } - if other.IgnoreLSPNotify != nil { - a.IgnoreLSPNotify = other.IgnoreLSPNotify - } - // MCP settings - if s := strings.TrimSpace(other.MCPPromptsDir); s != "" { - a.MCPPromptsDir = s - } - if other.MCPSlashCommandSync { - a.MCPSlashCommandSync = other.MCPSlashCommandSync - } - if s := strings.TrimSpace(other.MCPSlashCommandDir); s != "" { - a.MCPSlashCommandDir = s - } -} - -// mergeSurfaceModels copies per-surface model and temperature overrides. -func (a *App) mergeSurfaceModels(other *App) { - if len(other.CompletionConfigs) > 0 { - a.CompletionConfigs = cloneSurfaceConfigs(other.CompletionConfigs) - } - if len(other.CodeActionConfigs) > 0 { - a.CodeActionConfigs = cloneSurfaceConfigs(other.CodeActionConfigs) - } - if len(other.ChatConfigs) > 0 { - a.ChatConfigs = cloneSurfaceConfigs(other.ChatConfigs) - } - if len(other.CLIConfigs) > 0 { - a.CLIConfigs = cloneSurfaceConfigs(other.CLIConfigs) - } -} - -// mergePrompts copies non-empty prompt templates from other. -func (a *App) mergePrompts(other *App) { - // Completion - if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" { - a.PromptCompletionSystemGeneral = other.PromptCompletionSystemGeneral - } - if strings.TrimSpace(other.PromptCompletionSystemParams) != "" { - a.PromptCompletionSystemParams = other.PromptCompletionSystemParams - } - if strings.TrimSpace(other.PromptCompletionSystemInline) != "" { - a.PromptCompletionSystemInline = other.PromptCompletionSystemInline - } - if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" { - a.PromptCompletionUserGeneral = other.PromptCompletionUserGeneral - } - if strings.TrimSpace(other.PromptCompletionUserParams) != "" { - a.PromptCompletionUserParams = other.PromptCompletionUserParams - } - if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" { - a.PromptCompletionExtraHeader = other.PromptCompletionExtraHeader - } - // Provider-native - if strings.TrimSpace(other.PromptNativeCompletion) != "" { - a.PromptNativeCompletion = other.PromptNativeCompletion - } - // Chat - if strings.TrimSpace(other.PromptChatSystem) != "" { - a.PromptChatSystem = other.PromptChatSystem - } - // Code actions - if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" { - a.PromptCodeActionRewriteSystem = other.PromptCodeActionRewriteSystem - } - if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" { - a.PromptCodeActionDiagnosticsSystem = other.PromptCodeActionDiagnosticsSystem - } - if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" { - a.PromptCodeActionDocumentSystem = other.PromptCodeActionDocumentSystem - } - if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" { - a.PromptCodeActionRewriteUser = other.PromptCodeActionRewriteUser - } - if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" { - a.PromptCodeActionDiagnosticsUser = other.PromptCodeActionDiagnosticsUser - } - if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" { - a.PromptCodeActionDocumentUser = other.PromptCodeActionDocumentUser - } - if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" { - a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem - } - if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { - a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser - } - if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { - a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem - } - if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { - a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser - } - // CLI - if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { - a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem - } - if strings.TrimSpace(other.PromptCLIExplainSystem) != "" { - a.PromptCLIExplainSystem = other.PromptCLIExplainSystem - } - // Custom actions - if len(other.CustomActions) > 0 { - a.CustomActions = append([]CustomAction{}, other.CustomActions...) - } - if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" { - a.TmuxCustomMenuHotkey = other.TmuxCustomMenuHotkey - } -} - -// Validate checks custom actions and tmux settings for duplicates and consistency. -// mergeTmuxEdit copies non-empty tmux edit settings from other. -func (a *App) mergeTmuxEdit(other *App) { - if s := strings.TrimSpace(other.TmuxEditPopupWidth); s != "" { - a.TmuxEditPopupWidth = s - } - if s := strings.TrimSpace(other.TmuxEditPopupHeight); s != "" { - a.TmuxEditPopupHeight = s - } - if s := strings.TrimSpace(other.TmuxEditDefaultAgent); s != "" { - a.TmuxEditDefaultAgent = s - } - if len(other.TmuxEditAgents) > 0 { - a.TmuxEditAgents = append([]TmuxEditAgentCfg{}, other.TmuxEditAgents...) - } -} - -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 { - id := strings.ToLower(strings.TrimSpace(ca.ID)) - if id == "" { - return fmt.Errorf("config: custom action missing required field id") - } - if _, ok := seenID[id]; ok { - return fmt.Errorf("config: duplicate custom action id: %s", ca.ID) - } - seenID[id] = struct{}{} - if strings.TrimSpace(ca.Title) == "" { - return fmt.Errorf("config: custom action %s missing required field title", ca.ID) - } - // Validate scope - scope := strings.TrimSpace(ca.Scope) - if scope != "" && scope != "selection" && scope != "diagnostics" { - return fmt.Errorf("config: custom action %s has invalid scope: %s", ca.ID, ca.Scope) - } - // Instruction vs user - hasInstr := strings.TrimSpace(ca.Instruction) != "" - hasUser := strings.TrimSpace(ca.User) != "" - if hasInstr && hasUser { - return fmt.Errorf("config: custom action %s must set either instruction or user, not both", ca.ID) - } - if !hasInstr && !hasUser { - return fmt.Errorf("config: custom action %s requires instruction or user", ca.ID) - } - // Hotkey unique (case-insensitive), one rune if provided - if hk := strings.TrimSpace(ca.Hotkey); hk != "" { - if []rune(hk) == nil || len([]rune(hk)) != 1 { - return fmt.Errorf("config: custom action %s hotkey must be a single character", ca.ID) - } - lhk := strings.ToLower(hk) - if _, ok := seenHK[lhk]; ok { - return fmt.Errorf("config: duplicate custom action hotkey: %s", hk) - } - seenHK[lhk] = struct{}{} - } - } - // Tmux custom menu hotkey validation - 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) { - case "r", "i", "c", "t", "p", "s": - return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s (clashes with built-in)", hk) - } - } - return nil -} - -// mergeProviderFields merges per-provider configuration. -func (a *App) mergeProviderFields(other *App) { - if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" { - a.OpenAIBaseURL = s - } - if s := strings.TrimSpace(other.OpenAIModel); s != "" { - a.OpenAIModel = s - } - if other.OpenAITemperature != nil { // allow explicit 0.0 - a.OpenAITemperature = other.OpenAITemperature - } - if s := strings.TrimSpace(other.OpenRouterBaseURL); s != "" { - a.OpenRouterBaseURL = s - } - if s := strings.TrimSpace(other.OpenRouterModel); s != "" { - a.OpenRouterModel = s - } - if other.OpenRouterTemperature != nil { // allow explicit 0.0 - a.OpenRouterTemperature = other.OpenRouterTemperature - } - if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { - a.OllamaBaseURL = s - } - if s := strings.TrimSpace(other.OllamaModel); s != "" { - a.OllamaModel = s - } - if other.OllamaTemperature != nil { // allow explicit 0.0 - a.OllamaTemperature = other.OllamaTemperature - } - if s := strings.TrimSpace(other.AnthropicBaseURL); s != "" { - a.AnthropicBaseURL = s - } - if s := strings.TrimSpace(other.AnthropicModel); s != "" { - a.AnthropicModel = s - } - if other.AnthropicTemperature != nil { // allow explicit 0.0 - a.AnthropicTemperature = other.AnthropicTemperature - } -} - -func getConfigPath() (string, error) { - return ConfigPath() -} - -// ConfigPath returns the default config file path ($XDG_CONFIG_HOME/hexai/config.toml or ~/.config/hexai/config.toml). -func ConfigPath() (string, error) { - var configPath string - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { - configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") - } 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") - } - return configPath, nil -} - -// StateDir returns the XDG state directory for hexai (~/.local/hexai/state by default). -// Creates the directory if it doesn't exist. This is used for persistent state data -// like logs and history that should survive reboots. -func StateDir() (string, error) { - stateHome := os.Getenv("XDG_STATE_HOME") - if stateHome == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("cannot find user home directory: %v", err) - } - stateHome = filepath.Join(home, ".local", "hexai") - } - - stateDir := filepath.Join(stateHome, "state") - if err := os.MkdirAll(stateDir, 0o755); err != nil { - return "", fmt.Errorf("cannot create state directory: %v", err) - } - - return stateDir, nil -} - -// ProjectConfigFilename is the name of the per-project config file placed at a git repo root. -const ProjectConfigFilename = ".hexaiconfig.toml" - -// ProjectConfigPath returns the path to the per-project config file if a git repository -// root is detected from the current working directory. Returns empty string otherwise. -func ProjectConfigPath() string { - root := FindGitRoot() - if root == "" { - return "" - } - return filepath.Join(root, ProjectConfigFilename) -} - -// FindGitRoot walks up from the current working directory to find the nearest -// .git directory or file (worktrees use a .git file), returning its parent -// path or "" if none is found. -func FindGitRoot() string { - dir, err := os.Getwd() - if err != nil { - return "" - } - for { - if info, err := os.Stat(filepath.Join(dir, ".git")); err == nil && - (info.IsDir() || info.Mode().IsRegular()) { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { - return "" // reached filesystem root - } - dir = parent - } -} - -// --- 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 { - 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 - } - - 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_REQUEST_TIMEOUT"); ok { - out.RequestTimeout = 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 - } - - 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 != "" { - out.OpenAIBaseURL = s - any = true - } - if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok { - out.OpenAIModel = model - any = true - } - if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { - out.OpenAITemperature = f - any = true - } - - if s := getenv("HEXAI_OPENROUTER_BASE_URL"); s != "" { - out.OpenRouterBaseURL = s - any = true - } - if model, ok := pickModel("openrouter", getenv("HEXAI_OPENROUTER_MODEL")); ok { - out.OpenRouterModel = model - any = true - } - if f, ok := parseFloatPtr("HEXAI_OPENROUTER_TEMPERATURE"); ok { - out.OpenRouterTemperature = f - any = true - } - - if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { - out.OllamaBaseURL = s - any = true - } - if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok { - out.OllamaModel = model - any = true - } - if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { - out.OllamaTemperature = f - any = true - } - - if s := getenv("HEXAI_ANTHROPIC_BASE_URL"); s != "" { - out.AnthropicBaseURL = s - any = true - } - if model, ok := pickModel("anthropic", getenv("HEXAI_ANTHROPIC_MODEL")); ok { - out.AnthropicModel = model - any = true - } - if f, ok := parseFloatPtr("HEXAI_ANTHROPIC_TEMPERATURE"); ok { - out.AnthropicTemperature = f - any = true - } - - // Per-surface overrides - buildEntry := func(modelKey, tempKey, providerKey string) ([]SurfaceConfig, bool) { - model := getenv(modelKey) - tempPtr, tempSet := parseFloatPtr(tempKey) - provider := getenv(providerKey) - if model == "" && provider == "" && !tempSet { - return nil, false - } - entry := SurfaceConfig{Provider: provider, Model: model} - if tempSet { - entry.Temperature = tempPtr - } - return []SurfaceConfig{entry}, true - } - if entries, ok := buildEntry("HEXAI_MODEL_COMPLETION", "HEXAI_TEMPERATURE_COMPLETION", "HEXAI_PROVIDER_COMPLETION"); ok { - out.CompletionConfigs = entries - any = true - } - if entries, ok := buildEntry("HEXAI_MODEL_CODE_ACTION", "HEXAI_TEMPERATURE_CODE_ACTION", "HEXAI_PROVIDER_CODE_ACTION"); ok { - out.CodeActionConfigs = entries - any = true - } - if entries, ok := buildEntry("HEXAI_MODEL_CHAT", "HEXAI_TEMPERATURE_CHAT", "HEXAI_PROVIDER_CHAT"); ok { - out.ChatConfigs = entries - any = true - } - if entries, ok := buildEntry("HEXAI_MODEL_CLI", "HEXAI_TEMPERATURE_CLI", "HEXAI_PROVIDER_CLI"); ok { - out.CLIConfigs = entries - any = true - } - - // Ignore settings (bool: "true"/"1" or "false"/"0") - if s := getenv("HEXAI_IGNORE_GITIGNORE"); s != "" { - b := s == "true" || s == "1" - out.IgnoreGitignore = &b - any = true - } - if s := getenv("HEXAI_IGNORE_EXTRA_PATTERNS"); s != "" { - parts := strings.Split(s, ",") - out.IgnoreExtraPatterns = nil - for _, p := range parts { - if t := strings.TrimSpace(p); t != "" { - out.IgnoreExtraPatterns = append(out.IgnoreExtraPatterns, t) - } - } - any = true - } - if s := getenv("HEXAI_IGNORE_LSP_NOTIFY"); s != "" { - b := s == "true" || s == "1" - out.IgnoreLSPNotify = &b - any = true - } - - // MCP settings - if s := getenv("HEXAI_MCP_PROMPTS_DIR"); s != "" { - out.MCPPromptsDir = s - any = true - } - if s := getenv("HEXAI_MCP_SLASHCOMMAND_SYNC"); s != "" { - b := s == "true" || s == "1" - out.MCPSlashCommandSync = b - any = true - } - if s := getenv("HEXAI_MCP_SLASHCOMMAND_DIR"); s != "" { - out.MCPSlashCommandDir = s - any = true - } - - if !any { - return nil - } - return &out -} diff --git a/internal/appconfig/config_load.go b/internal/appconfig/config_load.go new file mode 100644 index 0000000..79f77c7 --- /dev/null +++ b/internal/appconfig/config_load.go @@ -0,0 +1,932 @@ +package appconfig + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +// ProjectConfigFilename is the name of the per-project config file placed at a git repo root. +const ProjectConfigFilename = ".hexaiconfig.toml" + +// Load reads configuration from a file and merges with defaults. +// It respects the XDG Base Directory Specification. +func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{}) } + +// LoadWithOptions reads configuration and applies the requested loading options. +func LoadWithOptions(logger *log.Logger, opts LoadOptions) App { + cfg := newDefaultConfig() + if logger == nil { + return cfg // Return defaults if no logger is provided (e.g. in tests) + } + + // Step 1: Load global config file + configPath := strings.TrimSpace(opts.ConfigPath) + if configPath != "" { + if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { + cfg.mergeWith(fileCfg) + } else if err != nil { + logger.Printf("cannot open config file %s: %v", configPath, err) + } + } else { + path, err := getConfigPath() + if err != nil { + logger.Printf("%v", err) + } else if fileCfg, err := loadFromFile(path, logger); err == nil && fileCfg != nil { + cfg.mergeWith(fileCfg) + } + } + + // Step 2: Load per-project config (.hexaiconfig.toml at git repo root). + // Project config overrides global config but is itself overridden by env vars. + loadProjectConfig(logger, opts, &cfg) + + // Step 3: Environment overrides (always take precedence over all config files) + if !opts.IgnoreEnv { + if envCfg := loadFromEnv(logger); envCfg != nil { + cfg.mergeWith(envCfg) + } + } + return cfg +} + +// ConfigPath returns the default config file path +// ($XDG_CONFIG_HOME/hexai/config.toml or ~/.config/hexai/config.toml). +func ConfigPath() (string, error) { + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + return filepath.Join(xdgConfigHome, "hexai", "config.toml"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot find user home directory: %v", err) + } + return filepath.Join(home, ".config", "hexai", "config.toml"), nil +} + +// StateDir returns the XDG state directory for hexai (~/.local/hexai/state by default). +// Creates the directory if it doesn't exist. This is used for persistent state data +// like logs and history that should survive reboots. +func StateDir() (string, error) { + stateHome := os.Getenv("XDG_STATE_HOME") + if stateHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot find user home directory: %v", err) + } + stateHome = filepath.Join(home, ".local", "hexai") + } + + stateDir := filepath.Join(stateHome, "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return "", fmt.Errorf("cannot create state directory: %v", err) + } + return stateDir, nil +} + +// ProjectConfigPath returns the path to the per-project config file if a git repository +// root is detected from the current working directory. Returns empty string otherwise. +func ProjectConfigPath() string { + root := FindGitRoot() + if root == "" { + return "" + } + return filepath.Join(root, ProjectConfigFilename) +} + +// FindGitRoot walks up from the current working directory to find the nearest +// .git directory or file (worktrees use a .git file), returning its parent +// path or "" if none is found. +func FindGitRoot() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + for { + if info, statErr := os.Stat(filepath.Join(dir, ".git")); statErr == nil && + (info.IsDir() || info.Mode().IsRegular()) { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" // reached filesystem root + } + dir = parent + } +} + +func getConfigPath() (string, error) { + return ConfigPath() +} + +// loadProjectConfig attempts to load .hexaiconfig.toml from the project root and +// merges it into cfg. Uses opts.ProjectRoot if set, otherwise auto-detects via FindGitRoot(). +func loadProjectConfig(logger *log.Logger, opts LoadOptions, cfg *App) { + projectRoot := strings.TrimSpace(opts.ProjectRoot) + if projectRoot == "" { + projectRoot = FindGitRoot() + } + if projectRoot == "" { + return + } + projectCfgPath := filepath.Join(projectRoot, ProjectConfigFilename) + if projCfg, err := loadFromFile(projectCfgPath, logger); err == nil && projCfg != nil { + cfg.mergeWith(projCfg) + } +} + +func loadFromFile(path string, logger *log.Logger) (*App, error) { + b, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) && logger != nil { + logger.Printf("cannot open TOML config file %s: %v", path, err) + } + return nil, err + } + + 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 { + logger.Printf("invalid TOML config file %s: %v", path, errTables) + } + return nil, errTables + } + + // Reject legacy flat keys at top-level (sectioned-only config is allowed) + 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": {}, + "chat_suffix": {}, "chat_prefixes": {}, "coding_temperature": {}, "provider": {}, + "openai_model": {}, "openai_base_url": {}, "openai_temperature": {}, + "ollama_model": {}, "ollama_base_url": {}, "ollama_temperature": {}, + } + for k := range raw { + if _, isTable := map[string]struct{}{ + "general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, + "chat": {}, "provider": {}, "models": {}, "openai": {}, "ollama": {}, "prompts": {}, + }[k]; isTable { + continue + } + if _, isLegacy := legacy[k]; isLegacy { + return nil, fmt.Errorf("unsupported flat key '%s' in config; use sectioned tables (see config.toml.example)", k) + } + } + + if logger != nil { + logger.Printf("loaded configuration from %s (TOML)", path) + } + + // Build App from tables only + 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 { + switch vv := v.(type) { + case int64: + tab.ManualInvokeMinPrefix = int(vv) + case int: + tab.ManualInvokeMinPrefix = vv + case float64: + tab.ManualInvokeMinPrefix = int(vv) + } + } + } + if t, ok := raw["logging"].(map[string]any); ok { + if v, present := t["log_preview_limit"]; present { + switch vv := v.(type) { + case int64: + tab.LogPreviewLimit = int(vv) + case int: + tab.LogPreviewLimit = vv + case float64: + tab.LogPreviewLimit = int(vv) + } + } + } + if m := parseSurfaceModels(raw, logger); m != nil { + tab.mergeSurfaceModels(m) + } + return &tab, nil +} + +func (fc *fileConfig) toApp() App { + out := App{} + applyCoreSections(fc, &out) + applyProviderSections(fc, &out) + applyPromptSections(fc, &out) + applyFeatureSections(fc, &out) + return out +} + +func applyCoreSections(fc *fileConfig, out *App) { + applyGeneralSection(fc, out) + applyLoggingSection(fc, out) + applyCompletionSection(fc, out) + applyTriggerSection(fc, out) + applyInlineSection(fc, out) + applyChatSection(fc, out) + applyProviderNameSection(fc, out) + applyIgnoreSection(fc, out) +} + +func applyProviderSections(fc *fileConfig, out *App) { + applyOpenAISection(fc, out) + applyOpenRouterSection(fc, out) + applyOllamaSection(fc, out) + applyAnthropicSection(fc, out) +} + +func applyPromptSections(fc *fileConfig, out *App) { + applyPromptCompletion(fc, out) + applyPromptChat(fc, out) + applyPromptCodeAction(fc, out) + applyPromptCLI(fc, out) + applyPromptProviderNative(fc, out) +} + +func applyFeatureSections(fc *fileConfig, out *App) { + applyTmuxSection(fc, out) + applyStatsSection(fc, out) + fc.applyTmuxEdit(out) + applyMCPSection(fc, out) +} + +func applyGeneralSection(fc *fileConfig, out *App) { + if (fc.General == sectionGeneral{}) && fc.General.CodingTemperature == nil { + return + } + tmp := App{ + MaxTokens: fc.General.MaxTokens, + ContextMode: fc.General.ContextMode, + ContextWindowLines: fc.General.ContextWindowLines, + MaxContextTokens: fc.General.MaxContextTokens, + CodingTemperature: fc.General.CodingTemperature, + RequestTimeout: fc.General.RequestTimeout, + } + out.mergeBasics(&tmp) +} + +func applyLoggingSection(fc *fileConfig, out *App) { + if fc.Logging == (sectionLogging{}) { + return + } + out.mergeBasics(&App{LogPreviewLimit: fc.Logging.LogPreviewLimit}) +} + +func applyCompletionSection(fc *fileConfig, out *App) { + if fc.Completion.CompletionDebounceMs == 0 && + fc.Completion.CompletionThrottleMs == 0 && + fc.Completion.ManualInvokeMinPrefix == 0 && + fc.Completion.CompletionWaitAll == nil { + return + } + tmp := App{ + CompletionDebounceMs: fc.Completion.CompletionDebounceMs, + CompletionThrottleMs: fc.Completion.CompletionThrottleMs, + ManualInvokeMinPrefix: fc.Completion.ManualInvokeMinPrefix, + CompletionWaitAll: fc.Completion.CompletionWaitAll, + } + out.mergeBasics(&tmp) +} + +func applyTriggerSection(fc *fileConfig, out *App) { + if len(fc.Triggers.TriggerCharacters) == 0 { + return + } + out.mergeBasics(&App{TriggerCharacters: fc.Triggers.TriggerCharacters}) +} + +func applyInlineSection(fc *fileConfig, out *App) { + if fc.Inline == (sectionInline{}) { + return + } + out.mergeBasics(&App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose}) +} + +func applyChatSection(fc *fileConfig, out *App) { + if strings.TrimSpace(fc.Chat.ChatSuffix) == "" && len(fc.Chat.ChatPrefixes) == 0 { + return + } + out.mergeBasics(&App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes}) +} + +func applyProviderNameSection(fc *fileConfig, out *App) { + if strings.TrimSpace(fc.Provider.Name) == "" { + return + } + out.mergeBasics(&App{Provider: fc.Provider.Name}) +} + +func applyIgnoreSection(fc *fileConfig, out *App) { + if fc.Ignore.Gitignore == nil && len(fc.Ignore.ExtraPatterns) == 0 && fc.Ignore.LSPNotifyIgnored == nil { + return + } + tmp := App{ + IgnoreGitignore: fc.Ignore.Gitignore, + IgnoreExtraPatterns: fc.Ignore.ExtraPatterns, + IgnoreLSPNotify: fc.Ignore.LSPNotifyIgnored, + } + out.mergeBasics(&tmp) +} + +func applyOpenAISection(fc *fileConfig, out *App) { + if fc.OpenAI.isZero() && fc.OpenAI.Temperature == nil { + return + } + tmp := App{ + OpenAIBaseURL: fc.OpenAI.BaseURL, + OpenAIModel: fc.OpenAI.resolvedModel(), + OpenAITemperature: fc.OpenAI.Temperature, + } + out.mergeProviderFields(&tmp) +} + +func applyOpenRouterSection(fc *fileConfig, out *App) { + if fc.OpenRouter == (sectionOpenRouter{}) && fc.OpenRouter.Temperature == nil { + return + } + tmp := App{ + OpenRouterBaseURL: fc.OpenRouter.BaseURL, + OpenRouterModel: fc.OpenRouter.Model, + OpenRouterTemperature: fc.OpenRouter.Temperature, + } + out.mergeProviderFields(&tmp) +} + +func applyOllamaSection(fc *fileConfig, out *App) { + if fc.Ollama == (sectionOllama{}) && fc.Ollama.Temperature == nil { + return + } + tmp := App{ + OllamaBaseURL: fc.Ollama.BaseURL, + OllamaModel: fc.Ollama.Model, + OllamaTemperature: fc.Ollama.Temperature, + } + out.mergeProviderFields(&tmp) +} + +func applyAnthropicSection(fc *fileConfig, out *App) { + if fc.Anthropic == (sectionAnthropic{}) && fc.Anthropic.Temperature == nil { + return + } + tmp := App{ + AnthropicBaseURL: fc.Anthropic.BaseURL, + AnthropicModel: fc.Anthropic.Model, + AnthropicTemperature: fc.Anthropic.Temperature, + } + out.mergeProviderFields(&tmp) +} + +func applyPromptCompletion(fc *fileConfig, out *App) { + if fc.Prompts.Completion == (sectionPromptsCompletion{}) { + return + } + setIfNotBlank(&out.PromptCompletionSystemGeneral, fc.Prompts.Completion.SystemGeneral) + setIfNotBlank(&out.PromptCompletionSystemParams, fc.Prompts.Completion.SystemParams) + setIfNotBlank(&out.PromptCompletionSystemInline, fc.Prompts.Completion.SystemInline) + setIfNotBlank(&out.PromptCompletionUserGeneral, fc.Prompts.Completion.UserGeneral) + setIfNotBlank(&out.PromptCompletionUserParams, fc.Prompts.Completion.UserParams) + setIfNotBlank(&out.PromptCompletionExtraHeader, fc.Prompts.Completion.ExtraHeader) +} + +func applyPromptChat(fc *fileConfig, out *App) { + setIfNotBlank(&out.PromptChatSystem, fc.Prompts.Chat.System) +} + +func applyPromptCodeAction(fc *fileConfig, out *App) { + ca := fc.Prompts.CodeAction + if strings.TrimSpace(ca.RewriteSystem) == "" && + strings.TrimSpace(ca.DiagnosticsSystem) == "" && + strings.TrimSpace(ca.DocumentSystem) == "" && + strings.TrimSpace(ca.RewriteUser) == "" && + strings.TrimSpace(ca.DiagnosticsUser) == "" && + strings.TrimSpace(ca.DocumentUser) == "" && + strings.TrimSpace(ca.GoTestSystem) == "" && + strings.TrimSpace(ca.GoTestUser) == "" && + strings.TrimSpace(ca.SimplifySystem) == "" && + strings.TrimSpace(ca.SimplifyUser) == "" && + len(ca.Custom) == 0 { + return + } + setIfNotBlank(&out.PromptCodeActionRewriteSystem, ca.RewriteSystem) + setIfNotBlank(&out.PromptCodeActionDiagnosticsSystem, ca.DiagnosticsSystem) + setIfNotBlank(&out.PromptCodeActionDocumentSystem, ca.DocumentSystem) + setIfNotBlank(&out.PromptCodeActionRewriteUser, ca.RewriteUser) + setIfNotBlank(&out.PromptCodeActionDiagnosticsUser, ca.DiagnosticsUser) + setIfNotBlank(&out.PromptCodeActionDocumentUser, ca.DocumentUser) + setIfNotBlank(&out.PromptCodeActionGoTestSystem, ca.GoTestSystem) + setIfNotBlank(&out.PromptCodeActionGoTestUser, ca.GoTestUser) + setIfNotBlank(&out.PromptCodeActionSimplifySystem, ca.SimplifySystem) + setIfNotBlank(&out.PromptCodeActionSimplifyUser, ca.SimplifyUser) + if len(ca.Custom) > 0 { + out.CustomActions = append(out.CustomActions, toCustomActions(ca.Custom)...) + } +} + +func applyPromptCLI(fc *fileConfig, out *App) { + if fc.Prompts.CLI == (sectionPromptsCLI{}) { + return + } + setIfNotBlank(&out.PromptCLIDefaultSystem, fc.Prompts.CLI.DefaultSystem) + setIfNotBlank(&out.PromptCLIExplainSystem, fc.Prompts.CLI.ExplainSystem) +} + +func applyPromptProviderNative(fc *fileConfig, out *App) { + setIfNotBlank(&out.PromptNativeCompletion, fc.Prompts.ProviderNative.Completion) +} + +func applyTmuxSection(fc *fileConfig, out *App) { + if fc.Tmux == (sectionTmux{}) { + return + } + out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) +} + +func applyStatsSection(fc *fileConfig, out *App) { + if fc.Stats.WindowMinutes > 0 { + out.StatsWindowMinutes = fc.Stats.WindowMinutes + } +} + +func applyMCPSection(fc *fileConfig, out *App) { + if strings.TrimSpace(fc.MCP.PromptsDir) != "" { + out.MCPPromptsDir = strings.TrimSpace(fc.MCP.PromptsDir) + } + if fc.MCP.SlashCommandSync { + out.MCPSlashCommandSync = fc.MCP.SlashCommandSync + } + if strings.TrimSpace(fc.MCP.SlashCommandDir) != "" { + out.MCPSlashCommandDir = strings.TrimSpace(fc.MCP.SlashCommandDir) + } +} + +func toCustomActions(custom []sectionCustomAction) []CustomAction { + out := make([]CustomAction, 0, len(custom)) + for _, ca := range custom { + out = append(out, CustomAction{ + ID: strings.TrimSpace(ca.ID), + Title: strings.TrimSpace(ca.Title), + Kind: strings.TrimSpace(ca.Kind), + Scope: strings.ToLower(strings.TrimSpace(ca.Scope)), + Hotkey: strings.TrimSpace(ca.Hotkey), + Instruction: ca.Instruction, + System: ca.System, + User: ca.User, + }) + } + return out +} + +func setIfNotBlank(dst *string, value string) { + if strings.TrimSpace(value) != "" { + *dst = value + } +} + +// applyTmuxEdit converts the [tmux_edit] section into App fields. +func (fc *fileConfig) applyTmuxEdit(out *App) { + te := fc.TmuxEdit + if strings.TrimSpace(te.PopupWidth) != "" { + out.TmuxEditPopupWidth = strings.TrimSpace(te.PopupWidth) + } + if strings.TrimSpace(te.PopupHeight) != "" { + out.TmuxEditPopupHeight = strings.TrimSpace(te.PopupHeight) + } + if strings.TrimSpace(te.DefaultAgent) != "" { + out.TmuxEditDefaultAgent = strings.TrimSpace(te.DefaultAgent) + } + for _, a := range te.Agents { + if strings.TrimSpace(a.Name) == "" { + continue + } + out.TmuxEditAgents = append(out.TmuxEditAgents, TmuxEditAgentCfg{ + Name: strings.TrimSpace(a.Name), + DisplayName: strings.TrimSpace(a.DisplayName), + DetectPattern: strings.TrimSpace(a.DetectPattern), + SectionPattern: strings.TrimSpace(a.SectionPattern), + PromptPattern: strings.TrimSpace(a.PromptPattern), + StripPatterns: a.StripPatterns, + ClearFirst: a.ClearFirst, + ClearKeys: strings.TrimSpace(a.ClearKeys), + NewlineKeys: strings.TrimSpace(a.NewlineKeys), + SubmitKeys: strings.TrimSpace(a.SubmitKeys), + }) + } +} + +func parseSurfaceModels(raw map[string]any, logger *log.Logger) *App { + modelsRaw, ok := raw["models"] + if !ok { + return nil + } + table, ok := modelsRaw.(map[string]any) + if !ok { + if logger != nil { + logger.Printf("config: ignoring models section (expected table, got %T)", modelsRaw) + } + return nil + } + var out App + appendEntries := func(dest *[]SurfaceConfig, key string, val any) bool { + entries, ok := parseSurfaceEntries(val, key, logger) + if !ok || len(entries) == 0 { + return false + } + *dest = append(*dest, entries...) + return true + } + any := appendEntries(&out.CompletionConfigs, "models.completion", table["completion"]) + if ok := appendEntries(&out.CodeActionConfigs, "models.code_action", table["code_action"]); ok { + if len(out.CodeActionConfigs) > 1 { + if logger != nil { + logger.Printf("config: models.code_action supports a single entry; ignoring %d extra", len(out.CodeActionConfigs)-1) + } + out.CodeActionConfigs = out.CodeActionConfigs[:1] + } + any = true + } + any = appendEntries(&out.ChatConfigs, "models.chat", table["chat"]) || any + any = appendEntries(&out.CLIConfigs, "models.cli", table["cli"]) || any + if !any { + return nil + } + return &out +} + +func parseSurfaceEntries(raw any, path string, logger *log.Logger) ([]SurfaceConfig, bool) { + switch v := raw.(type) { + case nil: + return nil, false + case []any: + var out []SurfaceConfig + for i, entry := range v { + cfg, ok := decodeModelEntry(entry, fmt.Sprintf("%s[%d]", path, i), logger) + if !ok || cfg == nil { + continue + } + out = append(out, *cfg) + } + return out, len(out) > 0 + default: + if cfg, ok := decodeModelEntry(v, path, logger); ok && cfg != nil { + return []SurfaceConfig{*cfg}, true + } + return nil, false + } +} + +func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, bool) { + if raw == nil { + return nil, false + } + switch v := raw.(type) { + case string: + model := strings.TrimSpace(v) + if model == "" { + return nil, false + } + return &SurfaceConfig{Model: model}, true + case map[string]any: + model := "" + provider := "" + if m, ok := v["model"]; ok { + s, ok := m.(string) + if !ok { + if logger != nil { + logger.Printf("config: %s.model must be a string", path) + } + return nil, false + } + model = strings.TrimSpace(s) + } + if pRaw, ok := v["provider"]; ok { + ps, ok := pRaw.(string) + if !ok { + if logger != nil { + logger.Printf("config: %s.provider must be a string", path) + } + return nil, false + } + provider = strings.TrimSpace(ps) + } + var tempPtr *float64 + if tRaw, ok := v["temperature"]; ok { + parsed, ok := parseTemperatureValue(tRaw, path, logger) + if !ok { + return nil, false + } + tempPtr = parsed + } + if model == "" && tempPtr == nil && provider == "" { + return nil, false + } + return &SurfaceConfig{Provider: provider, Model: model, Temperature: tempPtr}, true + default: + if logger != nil { + logger.Printf("config: %s must be a string or table, got %T", path, raw) + } + return nil, false + } +} + +func parseTemperatureValue(raw any, path string, logger *log.Logger) (*float64, bool) { + switch v := raw.(type) { + case float64: + return floatPtr(v), true + case int64: + return floatPtr(float64(v)), true + case string: + s := strings.TrimSpace(v) + if s == "" { + return nil, true + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + if logger != nil { + logger.Printf("config: %s.temperature invalid: %v", path, err) + } + return nil, false + } + return floatPtr(f), true + default: + if logger != nil { + logger.Printf("config: %s.temperature must be numeric or string, got %T", path, raw) + } + return nil, false + } +} + +func floatPtr(v float64) *float64 { + f := v + return &f +} + +// --- 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 { + var out App + any := applyCoreEnv(&out, logger) + any = applyProviderEnv(&out, logger) || any + any = applySurfaceEnv(&out, logger) || any + any = applyIgnoreEnv(&out) || any + any = applyMCPEnv(&out) || any + if !any { + return nil + } + return &out +} + +func applyCoreEnv(out *App, logger *log.Logger) bool { + any := false + any = applyEnvInt(&out.MaxTokens, "HEXAI_MAX_TOKENS", logger) || any + any = applyEnvString(&out.ContextMode, "HEXAI_CONTEXT_MODE") || any + any = applyEnvInt(&out.ContextWindowLines, "HEXAI_CONTEXT_WINDOW_LINES", logger) || any + any = applyEnvInt(&out.MaxContextTokens, "HEXAI_MAX_CONTEXT_TOKENS", logger) || any + any = applyEnvInt(&out.LogPreviewLimit, "HEXAI_LOG_PREVIEW_LIMIT", logger) || any + any = applyEnvInt(&out.RequestTimeout, "HEXAI_REQUEST_TIMEOUT", logger) || any + any = applyEnvInt(&out.ManualInvokeMinPrefix, "HEXAI_MANUAL_INVOKE_MIN_PREFIX", logger) || any + any = applyEnvInt(&out.CompletionDebounceMs, "HEXAI_COMPLETION_DEBOUNCE_MS", logger) || any + any = applyEnvInt(&out.CompletionThrottleMs, "HEXAI_COMPLETION_THROTTLE_MS", logger) || any + any = applyEnvFloat(&out.CodingTemperature, "HEXAI_CODING_TEMPERATURE", logger) || any + any = applyEnvCSV(&out.TriggerCharacters, "HEXAI_TRIGGER_CHARACTERS") || any + any = applyEnvString(&out.InlineOpen, "HEXAI_INLINE_OPEN") || any + any = applyEnvString(&out.InlineClose, "HEXAI_INLINE_CLOSE") || any + any = applyEnvString(&out.ChatSuffix, "HEXAI_CHAT_SUFFIX") || any + any = applyEnvCSV(&out.ChatPrefixes, "HEXAI_CHAT_PREFIXES") || any + any = applyEnvString(&out.Provider, "HEXAI_PROVIDER") || any + return any +} + +func applyProviderEnv(out *App, logger *log.Logger) bool { + picker := newModelPicker(out.Provider) + any := false + any = applyEnvString(&out.OpenAIBaseURL, "HEXAI_OPENAI_BASE_URL") || any + if model, ok := picker.pick("openai", getenvTrim("HEXAI_OPENAI_MODEL")); ok { + out.OpenAIModel = model + any = true + } + any = applyEnvFloat(&out.OpenAITemperature, "HEXAI_OPENAI_TEMPERATURE", logger) || any + + any = applyEnvString(&out.OpenRouterBaseURL, "HEXAI_OPENROUTER_BASE_URL") || any + if model, ok := picker.pick("openrouter", getenvTrim("HEXAI_OPENROUTER_MODEL")); ok { + out.OpenRouterModel = model + any = true + } + any = applyEnvFloat(&out.OpenRouterTemperature, "HEXAI_OPENROUTER_TEMPERATURE", logger) || any + + any = applyEnvString(&out.OllamaBaseURL, "HEXAI_OLLAMA_BASE_URL") || any + if model, ok := picker.pick("ollama", getenvTrim("HEXAI_OLLAMA_MODEL")); ok { + out.OllamaModel = model + any = true + } + any = applyEnvFloat(&out.OllamaTemperature, "HEXAI_OLLAMA_TEMPERATURE", logger) || any + + any = applyEnvString(&out.AnthropicBaseURL, "HEXAI_ANTHROPIC_BASE_URL") || any + if model, ok := picker.pick("anthropic", getenvTrim("HEXAI_ANTHROPIC_MODEL")); ok { + out.AnthropicModel = model + any = true + } + any = applyEnvFloat(&out.AnthropicTemperature, "HEXAI_ANTHROPIC_TEMPERATURE", logger) || any + return any +} + +func applySurfaceEnv(out *App, logger *log.Logger) bool { + any := false + if entries, ok := buildSurfaceEntryFromEnv("HEXAI_MODEL_COMPLETION", "HEXAI_TEMPERATURE_COMPLETION", "HEXAI_PROVIDER_COMPLETION", logger); ok { + out.CompletionConfigs = entries + any = true + } + if entries, ok := buildSurfaceEntryFromEnv("HEXAI_MODEL_CODE_ACTION", "HEXAI_TEMPERATURE_CODE_ACTION", "HEXAI_PROVIDER_CODE_ACTION", logger); ok { + out.CodeActionConfigs = entries + any = true + } + if entries, ok := buildSurfaceEntryFromEnv("HEXAI_MODEL_CHAT", "HEXAI_TEMPERATURE_CHAT", "HEXAI_PROVIDER_CHAT", logger); ok { + out.ChatConfigs = entries + any = true + } + if entries, ok := buildSurfaceEntryFromEnv("HEXAI_MODEL_CLI", "HEXAI_TEMPERATURE_CLI", "HEXAI_PROVIDER_CLI", logger); ok { + out.CLIConfigs = entries + any = true + } + return any +} + +func applyIgnoreEnv(out *App) bool { + any := false + any = applyEnvBoolPtr(&out.IgnoreGitignore, "HEXAI_IGNORE_GITIGNORE") || any + any = applyEnvCSV(&out.IgnoreExtraPatterns, "HEXAI_IGNORE_EXTRA_PATTERNS") || any + any = applyEnvBoolPtr(&out.IgnoreLSPNotify, "HEXAI_IGNORE_LSP_NOTIFY") || any + return any +} + +func applyMCPEnv(out *App) bool { + any := false + any = applyEnvString(&out.MCPPromptsDir, "HEXAI_MCP_PROMPTS_DIR") || any + any = applyEnvBool(&out.MCPSlashCommandSync, "HEXAI_MCP_SLASHCOMMAND_SYNC") || any + any = applyEnvString(&out.MCPSlashCommandDir, "HEXAI_MCP_SLASHCOMMAND_DIR") || any + return any +} + +func buildSurfaceEntryFromEnv(modelKey, tempKey, providerKey string, logger *log.Logger) ([]SurfaceConfig, bool) { + model := getenvTrim(modelKey) + tempPtr, tempSet := parseEnvFloatPtr(tempKey, logger) + provider := getenvTrim(providerKey) + if model == "" && provider == "" && !tempSet { + return nil, false + } + entry := SurfaceConfig{Provider: provider, Model: model} + if tempSet { + entry.Temperature = tempPtr + } + return []SurfaceConfig{entry}, true +} + +func applyEnvString(target *string, key string) bool { + value := getenvTrim(key) + if value == "" { + return false + } + *target = value + return true +} + +func applyEnvInt(target *int, key string, logger *log.Logger) bool { + value, ok := parseEnvInt(key, logger) + if !ok { + return false + } + *target = value + return true +} + +func applyEnvFloat(target **float64, key string, logger *log.Logger) bool { + value, ok := parseEnvFloatPtr(key, logger) + if !ok { + return false + } + *target = value + return true +} + +func applyEnvCSV(target *[]string, key string) bool { + value := getenvTrim(key) + if value == "" { + return false + } + parts := strings.Split(value, ",") + *target = nil + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + *target = append(*target, t) + } + } + return true +} + +func applyEnvBool(target *bool, key string) bool { + value := getenvTrim(key) + if value == "" { + return false + } + *target = value == "true" || value == "1" + return true +} + +func applyEnvBoolPtr(target **bool, key string) bool { + value := getenvTrim(key) + if value == "" { + return false + } + parsed := value == "true" || value == "1" + *target = &parsed + return true +} + +func getenvTrim(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} + +func parseEnvInt(key string, logger *log.Logger) (int, bool) { + value := getenvTrim(key) + if value == "" { + return 0, false + } + n, err := strconv.Atoi(value) + if err != nil { + if logger != nil { + logger.Printf("invalid %s: %v", key, err) + } + return 0, false + } + return n, true +} + +func parseEnvFloatPtr(key string, logger *log.Logger) (*float64, bool) { + value := getenvTrim(key) + if value == "" { + return nil, false + } + f, err := strconv.ParseFloat(value, 64) + if err != nil { + if logger != nil { + logger.Printf("invalid %s: %v", key, err) + } + return nil, false + } + return &f, true +} + +type modelPicker struct { + providerLower string + modelForce string + modelGeneric string + forceUsed bool + genericUsed bool +} + +func newModelPicker(provider string) *modelPicker { + return &modelPicker{ + providerLower: strings.ToLower(strings.TrimSpace(provider)), + modelForce: getenvTrim("HEXAI_MODEL_FORCE"), + modelGeneric: getenvTrim("HEXAI_MODEL"), + } +} + +func (p *modelPicker) pick(providerName, specific string) (string, bool) { + specific = strings.TrimSpace(specific) + nameLower := strings.ToLower(strings.TrimSpace(providerName)) + if p.modelForce != "" { + if p.providerLower == nameLower { + p.forceUsed = true + return p.modelForce, true + } + if p.providerLower == "" && !p.forceUsed { + p.forceUsed = true + return p.modelForce, true + } + } + if specific != "" { + return specific, true + } + if p.modelGeneric != "" { + if p.providerLower == nameLower { + return p.modelGeneric, true + } + if p.providerLower == "" && !p.genericUsed { + p.genericUsed = true + return p.modelGeneric, true + } + } + return "", false +} diff --git a/internal/appconfig/config_merge.go b/internal/appconfig/config_merge.go new file mode 100644 index 0000000..d474fb7 --- /dev/null +++ b/internal/appconfig/config_merge.go @@ -0,0 +1,242 @@ +package appconfig + +import ( + "slices" + "strings" +) + +func (a *App) mergeWith(other *App) { + a.mergeBasics(other) + a.mergeProviderFields(other) + a.mergeSurfaceModels(other) + a.mergePrompts(other) + a.mergeTmuxEdit(other) +} + +// mergeBasics merges general (non-provider) fields. +func (a *App) mergeBasics(other *App) { + mergeBasicCore(a, other) + mergeBasicCompletion(a, other) + mergeBasicInteraction(a, other) + mergeBasicRuntime(a, other) +} + +func mergeBasicCore(dst, src *App) { + if src.MaxTokens > 0 { + dst.MaxTokens = src.MaxTokens + } + if s := strings.TrimSpace(src.ContextMode); s != "" { + dst.ContextMode = s + } + if src.ContextWindowLines > 0 { + dst.ContextWindowLines = src.ContextWindowLines + } + if src.MaxContextTokens > 0 { + dst.MaxContextTokens = src.MaxContextTokens + } + if src.LogPreviewLimit >= 0 { + dst.LogPreviewLimit = src.LogPreviewLimit + } + if src.RequestTimeout > 0 { + dst.RequestTimeout = src.RequestTimeout + } + if src.CodingTemperature != nil { // allow explicit 0.0 + dst.CodingTemperature = src.CodingTemperature + } +} + +func mergeBasicCompletion(dst, src *App) { + if src.ManualInvokeMinPrefix >= 0 { + dst.ManualInvokeMinPrefix = src.ManualInvokeMinPrefix + } + if src.CompletionDebounceMs > 0 { + dst.CompletionDebounceMs = src.CompletionDebounceMs + } + if src.CompletionThrottleMs > 0 { + dst.CompletionThrottleMs = src.CompletionThrottleMs + } + if src.CompletionWaitAll != nil { + dst.CompletionWaitAll = src.CompletionWaitAll + } +} + +func mergeBasicInteraction(dst, src *App) { + if len(src.TriggerCharacters) > 0 { + dst.TriggerCharacters = slices.Clone(src.TriggerCharacters) + } + if s := strings.TrimSpace(src.InlineOpen); s != "" { + dst.InlineOpen = s + } + if s := strings.TrimSpace(src.InlineClose); s != "" { + dst.InlineClose = s + } + if s := strings.TrimSpace(src.ChatSuffix); s != "" { + dst.ChatSuffix = s + } + if len(src.ChatPrefixes) > 0 { + dst.ChatPrefixes = slices.Clone(src.ChatPrefixes) + } + if s := strings.TrimSpace(src.Provider); s != "" { + dst.Provider = s + } +} + +func mergeBasicRuntime(dst, src *App) { + if src.IgnoreGitignore != nil { + dst.IgnoreGitignore = src.IgnoreGitignore + } + if len(src.IgnoreExtraPatterns) > 0 { + dst.IgnoreExtraPatterns = slices.Clone(src.IgnoreExtraPatterns) + } + if src.IgnoreLSPNotify != nil { + dst.IgnoreLSPNotify = src.IgnoreLSPNotify + } + if s := strings.TrimSpace(src.MCPPromptsDir); s != "" { + dst.MCPPromptsDir = s + } + if src.MCPSlashCommandSync { + dst.MCPSlashCommandSync = src.MCPSlashCommandSync + } + if s := strings.TrimSpace(src.MCPSlashCommandDir); s != "" { + dst.MCPSlashCommandDir = s + } +} + +// mergeProviderFields merges per-provider configuration. +func (a *App) mergeProviderFields(other *App) { + if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" { + a.OpenAIBaseURL = s + } + if s := strings.TrimSpace(other.OpenAIModel); s != "" { + a.OpenAIModel = s + } + if other.OpenAITemperature != nil { // allow explicit 0.0 + a.OpenAITemperature = other.OpenAITemperature + } + if s := strings.TrimSpace(other.OpenRouterBaseURL); s != "" { + a.OpenRouterBaseURL = s + } + if s := strings.TrimSpace(other.OpenRouterModel); s != "" { + a.OpenRouterModel = s + } + if other.OpenRouterTemperature != nil { // allow explicit 0.0 + a.OpenRouterTemperature = other.OpenRouterTemperature + } + if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { + a.OllamaBaseURL = s + } + if s := strings.TrimSpace(other.OllamaModel); s != "" { + a.OllamaModel = s + } + if other.OllamaTemperature != nil { // allow explicit 0.0 + a.OllamaTemperature = other.OllamaTemperature + } + if s := strings.TrimSpace(other.AnthropicBaseURL); s != "" { + a.AnthropicBaseURL = s + } + if s := strings.TrimSpace(other.AnthropicModel); s != "" { + a.AnthropicModel = s + } + if other.AnthropicTemperature != nil { // allow explicit 0.0 + a.AnthropicTemperature = other.AnthropicTemperature + } +} + +// mergeSurfaceModels copies per-surface model and temperature overrides. +func (a *App) mergeSurfaceModels(other *App) { + if len(other.CompletionConfigs) > 0 { + a.CompletionConfigs = cloneSurfaceConfigs(other.CompletionConfigs) + } + if len(other.CodeActionConfigs) > 0 { + a.CodeActionConfigs = cloneSurfaceConfigs(other.CodeActionConfigs) + } + if len(other.ChatConfigs) > 0 { + a.ChatConfigs = cloneSurfaceConfigs(other.ChatConfigs) + } + if len(other.CLIConfigs) > 0 { + a.CLIConfigs = cloneSurfaceConfigs(other.CLIConfigs) + } +} + +func cloneSurfaceConfigs(src []SurfaceConfig) []SurfaceConfig { + if len(src) == 0 { + return nil + } + out := make([]SurfaceConfig, len(src)) + copy(out, src) + return out +} + +// mergePrompts copies non-empty prompt templates from other. +func (a *App) mergePrompts(other *App) { + mergeCompletionPrompts(a, other) + mergeProviderNativePrompt(a, other) + mergeChatPrompt(a, other) + mergeCodeActionPrompts(a, other) + mergeCLIPrompts(a, other) + mergeCustomActionPrompts(a, other) +} + +func mergeCompletionPrompts(dst, src *App) { + mergeStringField(&dst.PromptCompletionSystemGeneral, src.PromptCompletionSystemGeneral) + mergeStringField(&dst.PromptCompletionSystemParams, src.PromptCompletionSystemParams) + mergeStringField(&dst.PromptCompletionSystemInline, src.PromptCompletionSystemInline) + mergeStringField(&dst.PromptCompletionUserGeneral, src.PromptCompletionUserGeneral) + mergeStringField(&dst.PromptCompletionUserParams, src.PromptCompletionUserParams) + mergeStringField(&dst.PromptCompletionExtraHeader, src.PromptCompletionExtraHeader) +} + +func mergeProviderNativePrompt(dst, src *App) { + mergeStringField(&dst.PromptNativeCompletion, src.PromptNativeCompletion) +} + +func mergeChatPrompt(dst, src *App) { + mergeStringField(&dst.PromptChatSystem, src.PromptChatSystem) +} + +func mergeCodeActionPrompts(dst, src *App) { + mergeStringField(&dst.PromptCodeActionRewriteSystem, src.PromptCodeActionRewriteSystem) + mergeStringField(&dst.PromptCodeActionDiagnosticsSystem, src.PromptCodeActionDiagnosticsSystem) + mergeStringField(&dst.PromptCodeActionDocumentSystem, src.PromptCodeActionDocumentSystem) + mergeStringField(&dst.PromptCodeActionRewriteUser, src.PromptCodeActionRewriteUser) + mergeStringField(&dst.PromptCodeActionDiagnosticsUser, src.PromptCodeActionDiagnosticsUser) + mergeStringField(&dst.PromptCodeActionDocumentUser, src.PromptCodeActionDocumentUser) + mergeStringField(&dst.PromptCodeActionGoTestSystem, src.PromptCodeActionGoTestSystem) + mergeStringField(&dst.PromptCodeActionGoTestUser, src.PromptCodeActionGoTestUser) + mergeStringField(&dst.PromptCodeActionSimplifySystem, src.PromptCodeActionSimplifySystem) + mergeStringField(&dst.PromptCodeActionSimplifyUser, src.PromptCodeActionSimplifyUser) +} + +func mergeCLIPrompts(dst, src *App) { + mergeStringField(&dst.PromptCLIDefaultSystem, src.PromptCLIDefaultSystem) + mergeStringField(&dst.PromptCLIExplainSystem, src.PromptCLIExplainSystem) +} + +func mergeCustomActionPrompts(dst, src *App) { + if len(src.CustomActions) > 0 { + dst.CustomActions = append([]CustomAction{}, src.CustomActions...) + } + mergeStringField(&dst.TmuxCustomMenuHotkey, src.TmuxCustomMenuHotkey) +} + +func mergeStringField(dst *string, src string) { + if strings.TrimSpace(src) != "" { + *dst = src + } +} + +// mergeTmuxEdit copies non-empty tmux edit settings from other. +func (a *App) mergeTmuxEdit(other *App) { + if s := strings.TrimSpace(other.TmuxEditPopupWidth); s != "" { + a.TmuxEditPopupWidth = s + } + if s := strings.TrimSpace(other.TmuxEditPopupHeight); s != "" { + a.TmuxEditPopupHeight = s + } + if s := strings.TrimSpace(other.TmuxEditDefaultAgent); s != "" { + a.TmuxEditDefaultAgent = s + } + if len(other.TmuxEditAgents) > 0 { + a.TmuxEditAgents = append([]TmuxEditAgentCfg{}, other.TmuxEditAgents...) + } +} diff --git a/internal/appconfig/config_types.go b/internal/appconfig/config_types.go new file mode 100644 index 0000000..92dad25 --- /dev/null +++ b/internal/appconfig/config_types.go @@ -0,0 +1,438 @@ +// Summary: Application configuration model and defaults. +package appconfig + +import "strings" + +// SurfaceConfig describes a provider/model pairing (with optional temperature). +type SurfaceConfig struct { + Provider string + Model string + Temperature *float64 +} + +// App holds user-configurable settings read from ~/.config/hexai/config.toml. +type App struct { + MaxTokens int `json:"max_tokens" toml:"max_tokens"` + ContextMode string `json:"context_mode" toml:"context_mode"` + ContextWindowLines int `json:"context_window_lines" toml:"context_window_lines"` + MaxContextTokens int `json:"max_context_tokens" toml:"max_context_tokens"` + LogPreviewLimit int `json:"log_preview_limit" toml:"log_preview_limit"` + RequestTimeout int `json:"request_timeout" toml:"request_timeout"` + // Single knob for LSP requests; if set, overrides hardcoded temps in LSP. + CodingTemperature *float64 `json:"coding_temperature" toml:"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" toml:"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" toml:"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" toml:"completion_throttle_ms"` + // CompletionWaitAll controls whether to wait for all configured completion + // backends before returning results. When true (default), waits for all + // backends. When false, returns the first result immediately. + CompletionWaitAll *bool `json:"completion_wait_all" toml:"completion_wait_all"` + + TriggerCharacters []string `json:"trigger_characters" toml:"trigger_characters"` + Provider string `json:"provider" toml:"provider"` + + // Inline prompt trigger characters (default: >!text> and >>!text>) + InlineOpen string `json:"inline_open" toml:"inline_open"` + InlineClose string `json:"inline_close" toml:"inline_close"` + // In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;]) + ChatSuffix string `json:"chat_suffix" toml:"chat_suffix"` + ChatPrefixes []string `json:"chat_prefixes" toml:"chat_prefixes"` + + // Provider-specific options + OpenAIBaseURL string `json:"openai_base_url" toml:"openai_base_url"` + OpenAIModel string `json:"openai_model" toml:"openai_model"` + // Default temperature for OpenAI requests (nil means use provider default) + OpenAITemperature *float64 `json:"openai_temperature" toml:"openai_temperature"` + OpenRouterBaseURL string `json:"openrouter_base_url" toml:"openrouter_base_url"` + OpenRouterModel string `json:"openrouter_model" toml:"openrouter_model"` + // Default temperature for OpenRouter requests (nil means use provider default) + OpenRouterTemperature *float64 `json:"openrouter_temperature" toml:"openrouter_temperature"` + OllamaBaseURL string `json:"ollama_base_url" toml:"ollama_base_url"` + OllamaModel string `json:"ollama_model" toml:"ollama_model"` + // Default temperature for Ollama requests (nil means use provider default) + OllamaTemperature *float64 `json:"ollama_temperature" toml:"ollama_temperature"` + AnthropicBaseURL string `json:"anthropic_base_url" toml:"anthropic_base_url"` + AnthropicModel string `json:"anthropic_model" toml:"anthropic_model"` + // Default temperature for Anthropic requests (nil means use provider default) + AnthropicTemperature *float64 `json:"anthropic_temperature" toml:"anthropic_temperature"` + + // Per-surface provider/model configurations (ordered; first entry is primary) + CompletionConfigs []SurfaceConfig `json:"-" toml:"-"` + CodeActionConfigs []SurfaceConfig `json:"-" toml:"-"` + ChatConfigs []SurfaceConfig `json:"-" toml:"-"` + CLIConfigs []SurfaceConfig `json:"-" toml:"-"` + + // Prompt templates (configured only via file; no env overrides) + // Completion/chat/code action/CLI prompt strings. See config.toml.example for placeholders. + // Completion + PromptCompletionSystemGeneral string `json:"-" toml:"-"` + PromptCompletionSystemParams string `json:"-" toml:"-"` + PromptCompletionSystemInline string `json:"-" toml:"-"` + PromptCompletionUserGeneral string `json:"-" toml:"-"` + PromptCompletionUserParams string `json:"-" toml:"-"` + PromptCompletionExtraHeader string `json:"-" toml:"-"` + // Provider-native code-completer + PromptNativeCompletion string `json:"-" toml:"-"` + // In-editor chat + PromptChatSystem string `json:"-" toml:"-"` + // Code actions + PromptCodeActionRewriteSystem string `json:"-" toml:"-"` + PromptCodeActionDiagnosticsSystem string `json:"-" toml:"-"` + PromptCodeActionDocumentSystem string `json:"-" toml:"-"` + PromptCodeActionRewriteUser string `json:"-" toml:"-"` + PromptCodeActionDiagnosticsUser string `json:"-" toml:"-"` + PromptCodeActionDocumentUser string `json:"-" toml:"-"` + PromptCodeActionGoTestSystem string `json:"-" toml:"-"` + PromptCodeActionGoTestUser string `json:"-" toml:"-"` + PromptCodeActionSimplifySystem string `json:"-" toml:"-"` + PromptCodeActionSimplifyUser string `json:"-" toml:"-"` + // CLI + PromptCLIDefaultSystem string `json:"-" toml:"-"` + PromptCLIExplainSystem string `json:"-" toml:"-"` + + // Custom code actions and tmux integration + CustomActions []CustomAction `json:"-" toml:"-"` + TmuxCustomMenuHotkey string `json:"-" toml:"-"` + // Stats + StatsWindowMinutes int `json:"-" toml:"-"` + + // Ignore: gitignore-aware file filtering for LSP + IgnoreGitignore *bool `json:"-" toml:"-"` + IgnoreExtraPatterns []string `json:"-" toml:"-"` + IgnoreLSPNotify *bool `json:"-" toml:"-"` + + // TmuxEdit: popup editor settings for hexai-tmux-edit + TmuxEditPopupWidth string `json:"-" toml:"-"` + TmuxEditPopupHeight string `json:"-" toml:"-"` + TmuxEditDefaultAgent string `json:"-" toml:"-"` + TmuxEditAgents []TmuxEditAgentCfg `json:"-" toml:"-"` + + // MCP: Model Context Protocol server settings + MCPPromptsDir string `json:"-" toml:"-"` // Directory for prompt storage + MCPSlashCommandSync bool `json:"-" toml:"-"` // Enable slash command sync + MCPSlashCommandDir string `json:"-" toml:"-"` // Directory for slash command files +} + +// CustomAction describes a user-defined code action. +type CustomAction struct { + ID string + Title string + Kind string // optional; default "refactor" + Scope string // "selection" (default) | "diagnostics" + Hotkey string // optional, used by tmux submenu + Instruction string // optional; if set and User is empty, use global rewrite templates + System string // optional; used only when User is set + User string // optional; if set, render with available vars +} + +// TmuxEditAgentCfg describes an AI agent's detection and interaction patterns +// for the tmux popup editor (hexai-tmux-edit). +type TmuxEditAgentCfg struct { + Name string + DisplayName string + DetectPattern string + SectionPattern string + PromptPattern string + StripPatterns []string + ClearFirst *bool + ClearKeys string + NewlineKeys string + SubmitKeys string +} + +// LoadOptions tune how configuration is loaded at runtime. +type LoadOptions struct { + // IgnoreEnv skips applying environment overrides when true. + IgnoreEnv bool + // ConfigPath overrides the global config file path (e.g. via --config flag). + ConfigPath string + // ProjectRoot overrides the project root directory for locating .hexaiconfig.toml. + // When empty, FindGitRoot() is used to auto-detect from the current working directory. + ProjectRoot string +} + +// Constructor: defaults for App (kept first among functions) +func newDefaultConfig() App { + // Coding-friendly default temperature across providers + // Users can override per provider in config.toml (including 0.0). + t := 0.2 + return App{ + MaxTokens: 4000, + ContextMode: "always-full", + ContextWindowLines: 120, + MaxContextTokens: 4000, + LogPreviewLimit: 100, + RequestTimeout: 30, + CodingTemperature: &t, + OpenAITemperature: &t, + OllamaTemperature: &t, + AnthropicTemperature: &t, + ManualInvokeMinPrefix: 0, + CompletionDebounceMs: 800, + CompletionThrottleMs: 0, + // Inline/chat trigger defaults + InlineOpen: ">!", + InlineClose: ">", + ChatSuffix: ">", + ChatPrefixes: []string{"?", "!", ":", ";"}, + + // Default prompt templates (match current hard-coded strings) + PromptCompletionSystemParams: "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types.", + PromptCompletionUserParams: "Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: {{function}}\nCurrent line (cursor at {{char}}): {{current}}", + PromptCompletionSystemGeneral: "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression).", + PromptCompletionUserGeneral: "Provide the next likely code to insert at the cursor.\nFile: {{file}}\nFunction/context: {{function}}\nAbove line: {{above}}\nCurrent line (cursor at character {{char}}): {{current}}\nBelow line: {{below}}\nOnly return the completion snippet.", + PromptCompletionSystemInline: "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only.", + PromptCompletionExtraHeader: "Additional context:\n{{context}}", + + PromptNativeCompletion: "// Path: {{path}}\n{{before}}", + + PromptChatSystem: "You are a helpful coding assistant. Answer concisely and clearly.", + + PromptCodeActionRewriteSystem: "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable.", + PromptCodeActionDiagnosticsSystem: "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes.", + PromptCodeActionDocumentSystem: "You are a precise code documentation engine. Add idiomatic documentation comments to the given code. Preserve exact behavior and formatting as much as possible. Return only the updated code with comments, no prose or backticks.", + PromptCodeActionRewriteUser: "Instruction: {{instruction}}\n\nSelected code to transform:\n{{selection}}", + PromptCodeActionDiagnosticsUser: "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}", + PromptCodeActionDocumentUser: "Add documentation comments to this code:\n{{selection}}", + PromptCodeActionGoTestSystem: "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic.", + PromptCodeActionGoTestUser: "Function under test:\n{{function}}", + PromptCodeActionSimplifySystem: "You are a precise code improvement engine. Simplify and improve the given code while preserving behavior. Return only the improved code with no prose or backticks.", + PromptCodeActionSimplifyUser: "Improve this code:\n{{selection}}", + + PromptCLIDefaultSystem: "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.", + PromptCLIExplainSystem: "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.", + + // Stats + StatsWindowMinutes: 60, + + // Ignore: respect .gitignore by default, notify in LSP by default + IgnoreGitignore: boolPtr(true), + IgnoreLSPNotify: boolPtr(true), + } +} + +func boolPtr(b bool) *bool { return &b } + +// Private helpers +// Sectioned (table-based) file format only. +type fileConfig struct { + // Section tables only (flat keys are not allowed) + General sectionGeneral `toml:"general"` + Logging sectionLogging `toml:"logging"` + Completion sectionCompletion `toml:"completion"` + Triggers sectionTriggers `toml:"triggers"` + Inline sectionInline `toml:"inline"` + Chat sectionChat `toml:"chat"` + Provider sectionProvider `toml:"provider"` + OpenAI sectionOpenAI `toml:"openai"` + OpenRouter sectionOpenRouter `toml:"openrouter"` + Ollama sectionOllama `toml:"ollama"` + Anthropic sectionAnthropic `toml:"anthropic"` + Prompts sectionPrompts `toml:"prompts"` + Tmux sectionTmux `toml:"tmux"` + Stats sectionStats `toml:"stats"` + Ignore sectionIgnore `toml:"ignore"` + TmuxEdit sectionTmuxEdit `toml:"tmux_edit"` + MCP sectionMCP `toml:"mcp"` +} + +type sectionGeneral struct { + MaxTokens int `toml:"max_tokens"` + ContextMode string `toml:"context_mode"` + ContextWindowLines int `toml:"context_window_lines"` + MaxContextTokens int `toml:"max_context_tokens"` + CodingTemperature *float64 `toml:"coding_temperature"` + RequestTimeout int `toml:"request_timeout"` +} + +type sectionLogging struct { + LogPreviewLimit int `toml:"log_preview_limit"` +} + +type sectionCompletion struct { + CompletionDebounceMs int `toml:"completion_debounce_ms"` + CompletionThrottleMs int `toml:"completion_throttle_ms"` + ManualInvokeMinPrefix int `toml:"manual_invoke_min_prefix"` + CompletionWaitAll *bool `toml:"completion_wait_all"` +} + +type sectionTriggers struct { + TriggerCharacters []string `toml:"trigger_characters"` +} + +type sectionInline struct { + InlineOpen string `toml:"inline_open"` + InlineClose string `toml:"inline_close"` +} + +type sectionChat struct { + ChatSuffix string `toml:"chat_suffix"` + ChatPrefixes []string `toml:"chat_prefixes"` +} + +type sectionProvider struct { + Name string `toml:"name"` +} + +type sectionStats struct { + WindowMinutes int `toml:"window_minutes"` +} + +// sectionIgnore controls gitignore-aware file filtering. Files matching +// these patterns are skipped for completions and code actions. +type sectionIgnore struct { + Gitignore *bool `toml:"gitignore"` + ExtraPatterns []string `toml:"extra_patterns"` + LSPNotifyIgnored *bool `toml:"lsp_notify_ignored"` +} + +// sectionTmuxEdit configures the tmux popup editor feature (hexai-tmux-edit). +type sectionTmuxEdit struct { + PopupWidth string `toml:"popup_width"` + PopupHeight string `toml:"popup_height"` + DefaultAgent string `toml:"default_agent"` + Agents []sectionTmuxEditAgent `toml:"agents"` +} + +// sectionTmuxEditAgent defines detection and interaction patterns for one AI agent. +type sectionTmuxEditAgent struct { + Name string `toml:"name"` + DisplayName string `toml:"display_name"` + DetectPattern string `toml:"detect_pattern"` + SectionPattern string `toml:"section_pattern"` + PromptPattern string `toml:"prompt_pattern"` + StripPatterns []string `toml:"strip_patterns"` + ClearFirst *bool `toml:"clear_first"` + ClearKeys string `toml:"clear_keys"` + NewlineKeys string `toml:"newline_keys"` + SubmitKeys string `toml:"submit_keys"` +} + +// sectionMCP configures the MCP server settings. +type sectionMCP struct { + PromptsDir string `toml:"prompts_dir"` + SlashCommandSync bool `toml:"slashcommand_sync"` + SlashCommandDir string `toml:"slashcommand_dir"` +} + +type sectionOpenAI struct { + 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 sectionOpenRouter struct { + Model string `toml:"model"` + BaseURL string `toml:"base_url"` + Temperature *float64 `toml:"temperature"` +} + +type sectionOllama struct { + Model string `toml:"model"` + BaseURL string `toml:"base_url"` + Temperature *float64 `toml:"temperature"` +} + +type sectionAnthropic struct { + Model string `toml:"model"` + BaseURL string `toml:"base_url"` + Temperature *float64 `toml:"temperature"` +} + +// Prompts sections +type sectionPrompts struct { + Completion sectionPromptsCompletion `toml:"completion"` + Chat sectionPromptsChat `toml:"chat"` + CodeAction sectionPromptsCodeAction `toml:"code_action"` + CLI sectionPromptsCLI `toml:"cli"` + ProviderNative sectionPromptsProviderNative `toml:"provider_native"` +} + +type sectionPromptsCompletion struct { + SystemGeneral string `toml:"system_general"` + SystemParams string `toml:"system_params"` + SystemInline string `toml:"system_inline"` + UserGeneral string `toml:"user_general"` + UserParams string `toml:"user_params"` + ExtraHeader string `toml:"additional_context"` +} + +type sectionPromptsChat struct { + System string `toml:"system"` +} + +type sectionPromptsCodeAction struct { + RewriteSystem string `toml:"rewrite_system"` + DiagnosticsSystem string `toml:"diagnostics_system"` + DocumentSystem string `toml:"document_system"` + RewriteUser string `toml:"rewrite_user"` + DiagnosticsUser string `toml:"diagnostics_user"` + DocumentUser string `toml:"document_user"` + GoTestSystem string `toml:"go_test_system"` + GoTestUser string `toml:"go_test_user"` + SimplifySystem string `toml:"simplify_system"` + SimplifyUser string `toml:"simplify_user"` + Custom []sectionCustomAction `toml:"custom"` +} + +type sectionPromptsCLI struct { + DefaultSystem string `toml:"default_system"` + ExplainSystem string `toml:"explain_system"` +} + +type sectionPromptsProviderNative struct { + Completion string `toml:"completion"` +} + +type sectionCustomAction struct { + ID string `toml:"id"` + Title string `toml:"title"` + Kind string `toml:"kind"` + Scope string `toml:"scope"` + Hotkey string `toml:"hotkey"` + Instruction string `toml:"instruction"` + System string `toml:"system"` + User string `toml:"user"` +} + +type sectionTmux struct { + CustomMenuHotkey string `toml:"custom_menu_hotkey"` +} diff --git a/internal/appconfig/config_validate.go b/internal/appconfig/config_validate.go new file mode 100644 index 0000000..f5e698f --- /dev/null +++ b/internal/appconfig/config_validate.go @@ -0,0 +1,98 @@ +package appconfig + +import ( + "fmt" + "strings" +) + +// Validate checks custom actions and tmux settings for duplicates and consistency. +func (a App) Validate() error { + if err := validateCustomActions(a.CustomActions); err != nil { + return err + } + return validateTmuxCustomMenuHotkey(a.TmuxCustomMenuHotkey) +} + +func validateCustomActions(actions []CustomAction) error { + seenID := make(map[string]struct{}) + seenHK := make(map[string]struct{}) + for _, action := range actions { + if err := validateCustomAction(action, seenID, seenHK); err != nil { + return err + } + } + return nil +} + +func validateCustomAction(action CustomAction, seenID, seenHK map[string]struct{}) error { + id := strings.ToLower(strings.TrimSpace(action.ID)) + if id == "" { + return fmt.Errorf("config: custom action missing required field id") + } + if _, exists := seenID[id]; exists { + return fmt.Errorf("config: duplicate custom action id: %s", action.ID) + } + seenID[id] = struct{}{} + + if strings.TrimSpace(action.Title) == "" { + return fmt.Errorf("config: custom action %s missing required field title", action.ID) + } + if err := validateCustomScope(action); err != nil { + return err + } + if err := validateCustomInstructionOrUser(action); err != nil { + return err + } + return validateCustomHotkey(action, seenHK) +} + +func validateCustomScope(action CustomAction) error { + scope := strings.TrimSpace(action.Scope) + if scope == "" || scope == "selection" || scope == "diagnostics" { + return nil + } + return fmt.Errorf("config: custom action %s has invalid scope: %s", action.ID, action.Scope) +} + +func validateCustomInstructionOrUser(action CustomAction) error { + hasInstr := strings.TrimSpace(action.Instruction) != "" + hasUser := strings.TrimSpace(action.User) != "" + if hasInstr && hasUser { + return fmt.Errorf("config: custom action %s must set either instruction or user, not both", action.ID) + } + if !hasInstr && !hasUser { + return fmt.Errorf("config: custom action %s requires instruction or user", action.ID) + } + return nil +} + +func validateCustomHotkey(action CustomAction, seenHK map[string]struct{}) error { + hk := strings.TrimSpace(action.Hotkey) + if hk == "" { + return nil + } + if len([]rune(hk)) != 1 { + return fmt.Errorf("config: custom action %s hotkey must be a single character", action.ID) + } + lower := strings.ToLower(hk) + if _, exists := seenHK[lower]; exists { + return fmt.Errorf("config: duplicate custom action hotkey: %s", hk) + } + seenHK[lower] = struct{}{} + return nil +} + +func validateTmuxCustomMenuHotkey(hotkey string) error { + hk := strings.TrimSpace(hotkey) + if hk == "" { + return nil + } + if len([]rune(hk)) != 1 { + return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s", 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) + } + return nil +} |
