From a48079fae6bb19d7c931f275901670cd5839ab5c Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 6 Sep 2025 11:57:45 +0300 Subject: chore(version): bump to 0.6.0; configurable prompts via config + tests --- docs/coverage.html | 1250 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 902 insertions(+), 348 deletions(-) (limited to 'docs/coverage.html') diff --git a/docs/coverage.html b/docs/coverage.html index 49b89df..d22ef74 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -59,9 +59,9 @@ - + - + @@ -83,11 +83,11 @@ - + - + @@ -97,7 +97,7 @@ - + @@ -234,17 +234,43 @@ type App struct { // Default temperature for Ollama requests (nil means use provider default) OllamaTemperature *float64 `json:"ollama_temperature" toml:"ollama_temperature"` CopilotBaseURL string `json:"copilot_base_url" toml:"copilot_base_url"` - CopilotModel string `json:"copilot_model" toml:"copilot_model"` - // Default temperature for Copilot requests (nil means use provider default) - CopilotTemperature *float64 `json:"copilot_temperature" toml:"copilot_temperature"` + CopilotModel string `json:"copilot_model" toml:"copilot_model"` + // Default temperature for Copilot requests (nil means use provider default) + CopilotTemperature *float64 `json:"copilot_temperature" toml:"copilot_temperature"` + + // 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:"-"` + // CLI + PromptCLIDefaultSystem string `json:"-" toml:"-"` + PromptCLIExplainSystem string `json:"-" toml:"-"` } // Constructor: defaults for App (kept first among functions) -func newDefaultConfig() App { +func newDefaultConfig() App { // Coding-friendly default temperature across providers // Users can override per provider in config.toml (including 0.0). t := 0.2 - return App{ + return App{ MaxTokens: 4000, ContextMode: "always-full", ContextWindowLines: 120, @@ -261,24 +287,48 @@ func newDefaultConfig() App { InlineOpen: ">", InlineClose: ">", ChatSuffix: ">", - ChatPrefixes: []string{"?", "!", ":", ";"}, - } + 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}}", + + 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.", + } } // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. -func Load(logger *log.Logger) App { +func Load(logger *log.Logger) App { cfg := newDefaultConfig() - if logger == nil { + if logger == nil { return cfg // Return defaults if no logger is provided (e.g. in tests) } - configPath, err := getConfigPath() + configPath, err := getConfigPath() if err != nil { logger.Printf("%v", err) // Even if config path cannot be resolved, still allow env overrides below. - } else { - if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { + } else { + if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { cfg.mergeWith(fileCfg) } // When the config file is missing or invalid, we keep defaults and still @@ -286,125 +336,507 @@ func Load(logger *log.Logger) App { } // Environment overrides (take precedence over file) - if envCfg := loadFromEnv(logger); envCfg != nil { + if envCfg := loadFromEnv(logger); envCfg != nil { cfg.mergeWith(envCfg) } - return cfg + return cfg } // Private helpers -func loadFromFile(path string, logger *log.Logger) (*App, error) { - f, err := os.Open(path) - if err != nil { - if !os.IsNotExist(err) && logger != nil { - logger.Printf("cannot open TOML config file %s: %v", path, err) - } - return nil, err - } - defer f.Close() +// 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"` + Copilot sectionCopilot `toml:"copilot"` + Ollama sectionOllama `toml:"ollama"` + Prompts sectionPrompts `toml:"prompts"` +} - dec := toml.NewDecoder(f) - var fileCfg App - if err := dec.Decode(&fileCfg); err != nil { - if logger != nil { - logger.Printf("invalid TOML config file %s: %v", path, err) - } - return nil, err +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"` +} + +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"` +} + +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 sectionOpenAI struct { + Model string `toml:"model"` + BaseURL string `toml:"base_url"` + Temperature *float64 `toml:"temperature"` +} + +type sectionCopilot struct { + Model string `toml:"model"` + BaseURL string `toml:"base_url"` + Temperature *float64 `toml:"temperature"` +} + +type sectionOllama 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"` +} + +type sectionPromptsCLI struct { + DefaultSystem string `toml:"default_system"` + ExplainSystem string `toml:"explain_system"` +} + +type sectionPromptsProviderNative struct { + Completion string `toml:"completion"` +} + +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, } - if logger != nil { - logger.Printf("loaded configuration from %s (TOML)", path) - } - return &fileCfg, nil + out.mergeBasics(&tmp) + } + + // logging + if (fc.Logging != sectionLogging{}) { + tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit} + out.mergeBasics(&tmp) + } + + // completion + if (fc.Completion != sectionCompletion{}) { + tmp := App{ + CompletionDebounceMs: fc.Completion.CompletionDebounceMs, + CompletionThrottleMs: fc.Completion.CompletionThrottleMs, + ManualInvokeMinPrefix: fc.Completion.ManualInvokeMinPrefix, + } + 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 != sectionOpenAI{}) || fc.OpenAI.Temperature != nil { + tmp := App{ + OpenAIBaseURL: fc.OpenAI.BaseURL, + OpenAIModel: fc.OpenAI.Model, + OpenAITemperature: fc.OpenAI.Temperature, + } + out.mergeProviderFields(&tmp) + } + + // copilot + if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil { + tmp := App{ + CopilotBaseURL: fc.Copilot.BaseURL, + CopilotModel: fc.Copilot.Model, + CopilotTemperature: fc.Copilot.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) + } + + // 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 (fc.Prompts.CodeAction != sectionPromptsCodeAction{}) { + 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 + } + } + // 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 + } + + return out +} + +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": {}, + "copilot_model": {}, "copilot_base_url": {}, "copilot_temperature": {}, + } + for k := range raw { + if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable { + continue + } + if _, isLegacy := legacy[k]; isLegacy { + 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) + } + } + } + return &tab, nil } -func (a *App) mergeWith(other *App) { - a.mergeBasics(other) - a.mergeProviderFields(other) +func (a *App) mergeWith(other *App) { + a.mergeBasics(other) + a.mergeProviderFields(other) + a.mergePrompts(other) } // mergeBasics merges general (non-provider) fields. -func (a *App) mergeBasics(other *App) { - if other.MaxTokens > 0 { +func (a *App) mergeBasics(other *App) { + if other.MaxTokens > 0 { a.MaxTokens = other.MaxTokens } - if s := strings.TrimSpace(other.ContextMode); s != "" { + if s := strings.TrimSpace(other.ContextMode); s != "" { a.ContextMode = s } - if other.ContextWindowLines > 0 { + if other.ContextWindowLines > 0 { a.ContextWindowLines = other.ContextWindowLines } - if other.MaxContextTokens > 0 { + if other.MaxContextTokens > 0 { a.MaxContextTokens = other.MaxContextTokens } - if other.LogPreviewLimit >= 0 { + if other.LogPreviewLimit >= 0 { a.LogPreviewLimit = other.LogPreviewLimit } - if other.CodingTemperature != nil { // allow explicit 0.0 + if other.CodingTemperature != nil { // allow explicit 0.0 a.CodingTemperature = other.CodingTemperature } - if other.ManualInvokeMinPrefix >= 0 { + if other.ManualInvokeMinPrefix >= 0 { a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix } - if other.CompletionDebounceMs > 0 { + if other.CompletionDebounceMs > 0 { a.CompletionDebounceMs = other.CompletionDebounceMs } - if other.CompletionThrottleMs > 0 { + if other.CompletionThrottleMs > 0 { a.CompletionThrottleMs = other.CompletionThrottleMs } - if len(other.TriggerCharacters) > 0 { + if len(other.TriggerCharacters) > 0 { a.TriggerCharacters = slices.Clone(other.TriggerCharacters) } - if s := strings.TrimSpace(other.InlineOpen); s != "" { + if s := strings.TrimSpace(other.InlineOpen); s != "" { a.InlineOpen = s } - if s := strings.TrimSpace(other.InlineClose); s != "" { + if s := strings.TrimSpace(other.InlineClose); s != "" { a.InlineClose = s } - if s := strings.TrimSpace(other.ChatSuffix); s != "" { + if s := strings.TrimSpace(other.ChatSuffix); s != "" { a.ChatSuffix = s } - if len(other.ChatPrefixes) > 0 { + if len(other.ChatPrefixes) > 0 { a.ChatPrefixes = slices.Clone(other.ChatPrefixes) } - if s := strings.TrimSpace(other.Provider); s != "" { + if s := strings.TrimSpace(other.Provider); s != "" { a.Provider = s } } +// mergePrompts copies non-empty prompt templates from other. +func (a *App) mergePrompts(other *App) { + // 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 + } + // CLI + if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { + a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem + } + if strings.TrimSpace(other.PromptCLIExplainSystem) != "" { + a.PromptCLIExplainSystem = other.PromptCLIExplainSystem + } +} + // mergeProviderFields merges per-provider configuration. -func (a *App) mergeProviderFields(other *App) { - if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" { +func (a *App) mergeProviderFields(other *App) { + if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" { a.OpenAIBaseURL = s } - if s := strings.TrimSpace(other.OpenAIModel); s != "" { + if s := strings.TrimSpace(other.OpenAIModel); s != "" { a.OpenAIModel = s } - if other.OpenAITemperature != nil { // allow explicit 0.0 + if other.OpenAITemperature != nil { // allow explicit 0.0 a.OpenAITemperature = other.OpenAITemperature } - if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { + if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { a.OllamaBaseURL = s } - if s := strings.TrimSpace(other.OllamaModel); s != "" { + if s := strings.TrimSpace(other.OllamaModel); s != "" { a.OllamaModel = s } - if other.OllamaTemperature != nil { // allow explicit 0.0 + if other.OllamaTemperature != nil { // allow explicit 0.0 a.OllamaTemperature = other.OllamaTemperature } - if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { + if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { a.CopilotBaseURL = s } - if s := strings.TrimSpace(other.CopilotModel); s != "" { + if s := strings.TrimSpace(other.CopilotModel); s != "" { a.CopilotModel = s } - if other.CopilotTemperature != nil { // allow explicit 0.0 + if other.CopilotTemperature != nil { // allow explicit 0.0 a.CopilotTemperature = other.CopilotTemperature } } -func getConfigPath() (string, error) { +func getConfigPath() (string, error) { var configPath string - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") } else { home, err := os.UserHomeDir() @@ -413,22 +845,22 @@ func getConfigPath() (string, error) { } configPath = filepath.Join(home, ".config", "hexai", "config.toml") } - return configPath, nil + return configPath, nil } // --- Environment overrides --- // loadFromEnv constructs an App containing only fields set via HEXAI_* env vars. // These values should take precedence over file config when merged. -func loadFromEnv(logger *log.Logger) *App { +func loadFromEnv(logger *log.Logger) *App { var out App var any bool // helpers - getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } - parseInt := func(k string) (int, bool) { + getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } + parseInt := func(k string) (int, bool) { v := getenv(k) - if v == "" { + if v == "" { return 0, false } n, err := strconv.Atoi(v) @@ -440,9 +872,9 @@ func loadFromEnv(logger *log.Logger) *App { } return n, true } - parseFloatPtr := func(k string) (*float64, bool) { + parseFloatPtr := func(k string) (*float64, bool) { v := getenv(k) - if v == "" { + if v == "" { return nil, false } f, err := strconv.ParseFloat(v, 64) @@ -455,65 +887,65 @@ func loadFromEnv(logger *log.Logger) *App { return &f, true } - if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok { + if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok { out.MaxTokens = n any = true } - if s := getenv("HEXAI_CONTEXT_MODE"); s != "" { + if s := getenv("HEXAI_CONTEXT_MODE"); s != "" { out.ContextMode = s any = true } - if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok { + if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok { out.ContextWindowLines = n any = true } - if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok { + if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok { out.MaxContextTokens = n any = true } - if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok { + if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok { out.LogPreviewLimit = n any = true } - if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok { + if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok { out.ManualInvokeMinPrefix = n any = true } - if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok { + if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok { out.CompletionDebounceMs = n any = true } - if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok { + if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok { out.CompletionThrottleMs = n any = true } - if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok { out.CodingTemperature = f any = true } - if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" { + if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" { parts := strings.Split(s, ",") out.TriggerCharacters = nil - for _, p := range parts { - if t := strings.TrimSpace(p); t != "" { + 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 != "" { + if s := getenv("HEXAI_INLINE_OPEN"); s != "" { out.InlineOpen = s any = true } - if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { + if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { out.InlineClose = s any = true } - if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { + if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { out.ChatSuffix = s any = true } - if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { + if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { parts := strings.Split(s, ",") out.ChatPrefixes = nil for _, p := range parts { @@ -523,52 +955,52 @@ func loadFromEnv(logger *log.Logger) *App { } any = true } - if s := getenv("HEXAI_PROVIDER"); s != "" { + if s := getenv("HEXAI_PROVIDER"); s != "" { out.Provider = s any = true } // Provider-specific - if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { + if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { out.OpenAIBaseURL = s any = true } - if s := getenv("HEXAI_OPENAI_MODEL"); s != "" { + if s := getenv("HEXAI_OPENAI_MODEL"); s != "" { out.OpenAIModel = s any = true } - if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { out.OpenAITemperature = f any = true } - if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { + if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { out.OllamaBaseURL = s any = true } - if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" { + if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" { out.OllamaModel = s any = true } - if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { out.OllamaTemperature = f any = true } - if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { + if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { out.CopilotBaseURL = s any = true } - if s := getenv("HEXAI_COPILOT_MODEL"); s != "" { + if s := getenv("HEXAI_COPILOT_MODEL"); s != "" { out.CopilotModel = s any = true } - if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { out.CopilotTemperature = f any = true } - if !any { + if !any { return nil } return &out @@ -600,13 +1032,24 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. // Load configuration with a logger so file-based config is respected. logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) cfg := appconfig.Load(logger) - client, err := newClientFromConfig(cfg) - if err != nil { - fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) - return err - } - - return RunWithClient(ctx, args, stdin, stdout, stderr, client) + client, err := newClientFromConfig(cfg) + if err != nil { + fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) + return err + } + // Inline the flow here to use configured CLI prompts. + input, rerr := readInput(stdin, args) + if rerr != nil { + fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset) + return rerr + } + printProviderInfo(stderr, client) + msgs := buildMessagesFromConfig(cfg, input) + if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil { + fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) + return err + } + return nil } // RunWithClient executes the CLI flow using an already-constructed client. @@ -618,7 +1061,7 @@ func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, return err } printProviderInfo(stderr, client) - msgs := buildMessages(input) + msgs := buildMessages(input) if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil { fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) return err @@ -686,6 +1129,21 @@ func buildMessages(input string) []llm.Message { } } +// buildMessagesFromConfig uses configured CLI system prompts. +func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message { + lower := strings.ToLower(input) + system := cfg.PromptCLIDefaultSystem + if strings.Contains(lower, "explain") { + if strings.TrimSpace(cfg.PromptCLIExplainSystem) != "" { + system = cfg.PromptCLIExplainSystem + } + } + return []llm.Message{ + {Role: "system", Content: system}, + {Role: "user", Content: input}, + } +} + // runChat executes the chat request, handling streaming and summary output. func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error { start := time.Now() @@ -827,23 +1285,41 @@ func ensureFactory(factory ServerFactory) ServerFactory { - return lsp.ServerOptions{ - LogContext: logContext, - MaxTokens: cfg.MaxTokens, - ContextMode: cfg.ContextMode, - WindowLines: cfg.ContextWindowLines, - MaxContextTokens: cfg.MaxContextTokens, - CodingTemperature: cfg.CodingTemperature, - Client: client, - TriggerCharacters: cfg.TriggerCharacters, - ManualInvokeMinPrefix: cfg.ManualInvokeMinPrefix, - CompletionDebounceMs: cfg.CompletionDebounceMs, - CompletionThrottleMs: cfg.CompletionThrottleMs, - InlineOpen: cfg.InlineOpen, - InlineClose: cfg.InlineClose, - ChatSuffix: cfg.ChatSuffix, - ChatPrefixes: cfg.ChatPrefixes, - } + return lsp.ServerOptions{ + LogContext: logContext, + MaxTokens: cfg.MaxTokens, + ContextMode: cfg.ContextMode, + WindowLines: cfg.ContextWindowLines, + MaxContextTokens: cfg.MaxContextTokens, + CodingTemperature: cfg.CodingTemperature, + Client: client, + TriggerCharacters: cfg.TriggerCharacters, + ManualInvokeMinPrefix: cfg.ManualInvokeMinPrefix, + CompletionDebounceMs: cfg.CompletionDebounceMs, + CompletionThrottleMs: cfg.CompletionThrottleMs, + InlineOpen: cfg.InlineOpen, + InlineClose: cfg.InlineClose, + ChatSuffix: cfg.ChatSuffix, + ChatPrefixes: cfg.ChatPrefixes, + + // Prompts + PromptCompSysGeneral: cfg.PromptCompletionSystemGeneral, + PromptCompSysParams: cfg.PromptCompletionSystemParams, + PromptCompSysInline: cfg.PromptCompletionSystemInline, + PromptCompUserGeneral: cfg.PromptCompletionUserGeneral, + PromptCompUserParams: cfg.PromptCompletionUserParams, + PromptCompExtraHeader: cfg.PromptCompletionExtraHeader, + PromptNativeCompletion: cfg.PromptNativeCompletion, + PromptChatSystem: cfg.PromptChatSystem, + PromptRewriteSystem: cfg.PromptCodeActionRewriteSystem, + PromptDiagnosticsSystem: cfg.PromptCodeActionDiagnosticsSystem, + PromptDocumentSystem: cfg.PromptCodeActionDocumentSystem, + PromptRewriteUser: cfg.PromptCodeActionRewriteUser, + PromptDiagnosticsUser: cfg.PromptCodeActionDiagnosticsUser, + PromptDocumentUser: cfg.PromptCodeActionDocumentUser, + PromptGoTestSystem: cfg.PromptCodeActionGoTestSystem, + PromptGoTestUser: cfg.PromptCodeActionGoTestUser, + } } @@ -1822,7 +2298,7 @@ type RequestOption func(*Options) func WithModel(model string) RequestOption { return func(o *Options) { o.Model = model } } func WithTemperature(t float64) RequestOption { return func(o *Options) { o.Temperature = t } } -func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } +func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } func WithStop(stop ...string) RequestOption { return func(o *Options) { o.Stop = append([]string{}, stop...) } } @@ -1849,16 +2325,16 @@ type Config struct { // by the caller; other environment-based configuration is not used. func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) { p := strings.ToLower(strings.TrimSpace(cfg.Provider)) - if p == "" { + if p == "" { p = "openai" } switch p { case "openai": - if strings.TrimSpace(openAIAPIKey) == "" { + if strings.TrimSpace(openAIAPIKey) == "" { return nil, errors.New("missing OPENAI_API_KEY for provider openai") } // Set coding-friendly default temperature if none provided - if cfg.OpenAITemperature == nil { + if cfg.OpenAITemperature == nil { t := 0.2 cfg.OpenAITemperature = &t } @@ -1869,7 +2345,7 @@ func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, erro cfg.OllamaTemperature = &t } return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature), nil - case "copilot": + case "copilot": if strings.TrimSpace(copilotAPIKey) == "" { return nil, errors.New("missing COPILOT_API_KEY for provider copilot") } @@ -1952,11 +2428,11 @@ var std *log.Logger func Bind(l *log.Logger) { std = l } // Logf prints a formatted message with a module prefix and base ANSI style. -func Logf(prefix, format string, args ...any) { - if std == nil { +func Logf(prefix, format string, args ...any) { + if std == nil { return } - msg := fmt.Sprintf(format, args...) + msg := fmt.Sprintf(format, args...) std.Print(AnsiBase + prefix + msg + AnsiReset) } @@ -2079,7 +2555,7 @@ type document struct { lines []string } -func (s *Server) setDocument(uri, text string) { +func (s *Server) setDocument(uri, text string) { s.mu.Lock() defer s.mu.Unlock() s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)} @@ -2097,14 +2573,14 @@ func (s *Server) markActivity() { s.mu.Unlock() } -func (s *Server) getDocument(uri string) *document { +func (s *Server) getDocument(uri string) *document { s.mu.RLock() defer s.mu.RUnlock() return s.docs[uri] } // splitLines splits the input string into lines, normalizing line endings to '\n'. -func splitLines(sx string) []string { +func splitLines(sx string) []string { sx = strings.ReplaceAll(sx, "\r\n", "\n") return strings.Split(sx, "\n") } @@ -2195,20 +2671,20 @@ func hasAny(s string, needles []string) bool { return false } -func trimLen(s string) string { +func trimLen(s string) string { s = strings.TrimSpace(s) if len(s) > 200 { return s[:200] + "…" } - return s + return s } -func firstLine(s string) string { +func firstLine(s string) string { s = strings.ReplaceAll(s, "\r\n", "\n") if idx := strings.IndexByte(s, '\n'); idx >= 0 {