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 { return s[:idx] } - return s + return s } @@ -2402,33 +2878,33 @@ func (s *Server) reply(id json.RawMessage, result any, err *RespError) { +func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string { // Normalize left-of-cursor by trimming trailing spaces/tabs idx := p.Position.Character if idx > len(current) { idx = len(current) } - left := strings.TrimRight(current[:idx], " \t") + left := strings.TrimRight(current[:idx], " \t") right := "" - if idx < len(current) { + if idx < len(current) { right = current[idx:] } - prov := "" + prov := "" model := "" - if s.llmClient != nil { + if s.llmClient != nil { prov = s.llmClient.Name() model = s.llmClient.DefaultModel() } - temp := "" + temp := "" if s.codingTemperature != nil { temp = fmt.Sprintf("%.3f", *s.codingTemperature) } - extra := "" + extra := "" if hasExtra { extra = strings.TrimSpace(extraText) } // Compose a key from essential context parts - return strings.Join([]string{ + return strings.Join([]string{ "v1", // version for future-proofing prov, model, @@ -2457,13 +2933,13 @@ func (s *Server) completionCacheGet(key string) (string, bool) { +func (s *Server) completionCachePut(key, value string) { s.mu.Lock() defer s.mu.Unlock() - if s.compCache == nil { + if s.compCache == nil { s.compCache = make(map[string]string) } - if _, exists := s.compCache[key]; !exists { + if _, exists := s.compCache[key]; !exists { s.compCacheOrder = append(s.compCacheOrder, key) s.compCache[key] = value if len(s.compCacheOrder) > 10 { @@ -2472,7 +2948,7 @@ func (s *Server) completionCachePut(key, value string) - return + return } // update existing and mark most-recent s.compCache[key] = value @@ -2554,15 +3030,15 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool return false } -func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem { +func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem { te, filter := computeTextEditAndFilter(cleaned, inParams, current, p) rm := s.collectPromptRemovalEdits(p.TextDocument.URI) label := labelForCompletion(cleaned, filter) detail := "Hexai LLM completion" - if s.llmClient != nil { + if s.llmClient != nil { detail = "Hexai " + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() } - return []CompletionItem{{ + return []CompletionItem{{ Label: label, Kind: 1, Detail: detail, @@ -2743,11 +3219,11 @@ func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *Cod return &ca } -func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { +func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { if s.llmClient == nil || len(ca.Data) == 0 { return ca, false } - var payload struct { + var payload struct { Type string `json:"type"` URI string `json:"uri"` Range Range `json:"range"` @@ -2758,39 +3234,13 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { return ca, false } - switch payload.Type { - case "rewrite": - sys := "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." - user := fmt.Sprintf("Instruction: %s\n\nSelected code to transform:\n%s", payload.Instruction, payload.Selection) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { - if out := stripCodeFences(strings.TrimSpace(text)); out != "" { - edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} - ca.Edit = &edit - return ca, true - } - } else { - logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) - } - case "diagnostics": - sys := "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." - var b strings.Builder - b.WriteString("Diagnostics to resolve (selection only):\n") - for i, dgn := range payload.Diagnostics { - if dgn.Source != "" { - fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message) - } else { - fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message) - } - } - b.WriteString("\nSelected code:\n") - b.WriteString(payload.Selection) - ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: b.String()}} + switch payload.Type { + case "rewrite": + sys := s.promptRewriteSystem + user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} opts := s.llmRequestOpts() if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { if out := stripCodeFences(strings.TrimSpace(text)); out != "" { @@ -2799,17 +3249,42 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) } else { - logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err) + logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) } - case "document": - sys := "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." - user := "Add documentation comments to this code:\n" + payload.Selection - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + case "diagnostics": + sys := s.promptDiagnosticsSystem + var b strings.Builder + for i, dgn := range payload.Diagnostics { + if dgn.Source != "" { + fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message) + } else { + fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message) + } + } + diagList := b.String() + user := renderTemplate(s.promptDiagnosticsUser, map[string]string{"diagnostics": diagList, "selection": payload.Selection}) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + opts := s.llmRequestOpts() + if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { + if out := stripCodeFences(strings.TrimSpace(text)); out != "" { + edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} + ca.Edit = &edit + return ca, true + } + } else { + logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err) + } + case "document": + sys := s.promptDocumentSystem + user := renderTemplate(s.promptDocumentUser, map[string]string{"selection": payload.Selection}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} opts := s.llmRequestOpts() - if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { - if out := stripCodeFences(strings.TrimSpace(text)); out != "" { + if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { + if out := stripCodeFences(strings.TrimSpace(text)); out != "" { edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} ca.Edit = &edit return ca, true @@ -3127,17 +3602,17 @@ func findGoFunctionAtLine(lines []string, idx int) (int, int) { - if s.llmClient != nil { - sys := "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." - user := "Function under test:\n" + funcCode - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if out, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { +func (s *Server) generateGoTestFunction(funcCode string) string { + if s.llmClient != nil { + sys := s.promptGoTestSystem + user := renderTemplate(s.promptGoTestUser, map[string]string{"function": funcCode}) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + opts := s.llmRequestOpts() + if out, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { cleaned := strings.TrimSpace(stripCodeFences(out)) - if cleaned != "" { + if cleaned != "" { return cleaned } } else { @@ -3409,24 +3884,28 @@ func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p Comp } // tryProviderNativeCompletion attempts provider-native completion and returns items when successful. -func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) { +func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) { cc, ok := s.llmClient.(llm.CodeCompleter) if !ok { return nil, false } - before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) - path := strings.TrimPrefix(p.TextDocument.URI, "file://") - prompt := "// Path: " + path + "\n" + before + before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) + path := strings.TrimPrefix(p.TextDocument.URI, "file://") + // Build provider-native prompt from template + prompt := renderTemplate(s.promptNativeCompletion, map[string]string{ + "path": path, + "before": before, + }) lang := "" temp := 0.0 if s.codingTemperature != nil { temp = *s.codingTemperature } - prov := "" - if s.llmClient != nil { + prov := "" + if s.llmClient != nil { prov = s.llmClient.Name() } - logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) + logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second) defer cancel2() @@ -3435,21 +3914,21 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, if !s.waitForThrottle(ctx2) { return nil, false } - suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) - if err == nil && len(suggestions) > 0 { + suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) + if err == nil && len(suggestions) > 0 { cleaned := strings.TrimSpace(suggestions[0]) - if cleaned != "" { + if cleaned != "" { cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) - if cleaned != "" { + if cleaned != "" { cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) } - if cleaned != "" && hasDoubleOpenTrigger(current) { + if cleaned != "" && hasDoubleOpenTrigger(current) { indent := leadingIndent(current) if indent != "" { cleaned = applyIndent(indent, cleaned) } } - if strings.TrimSpace(cleaned) != "" { + if strings.TrimSpace(cleaned) != "" { key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) s.completionCachePut(key, cleaned) return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true @@ -3463,9 +3942,9 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, // waitForDebounce sleeps until there has been no input activity for at least // completionDebounce. If debounce is zero or ctx is done, it returns promptly. -func (s *Server) waitForDebounce(ctx context.Context) { +func (s *Server) waitForDebounce(ctx context.Context) { d := s.completionDebounce - if d <= 0 { + if d <= 0 { return } for { @@ -3493,9 +3972,9 @@ func (s *Server) waitForDebounce(ctx context.Context) { +func (s *Server) waitForThrottle(ctx context.Context) bool { interval := s.throttleInterval - if interval <= 0 { + if interval <= 0 { return true } var wait time.Duration @@ -3524,19 +4003,35 @@ func (s *Server) waitForThrottle(ctx context.Context) bool { - sysPrompt, userPrompt := buildPrompts(inParams, p, above, current, below, funcCtx) - messages := []llm.Message{ - {Role: "system", Content: sysPrompt}, - {Role: "user", Content: userPrompt}, - } - if hasExtra && extraText != "" { - messages = append(messages, llm.Message{Role: "user", Content: "Additional context:\n" + extraText}) - } - if inlinePrompt { - messages[0].Content = "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." - } - return messages +func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message { + // Vars for templates + vars := map[string]string{ + "file": p.TextDocument.URI, + "function": funcCtx, + "above": above, + "current": current, + "below": below, + "char": fmt.Sprintf("%d", p.Position.Character), + } + sys := s.promptCompSysGeneral + userTpl := s.promptCompUserGeneral + if inParams { + sys = s.promptCompSysParams + userTpl = s.promptCompUserParams + } + if inlinePrompt && strings.TrimSpace(s.promptCompSysInline) != "" { + sys = s.promptCompSysInline + } + user := renderTemplate(userTpl, vars) + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + if hasExtra && strings.TrimSpace(extraText) != "" { + extra := renderTemplate(s.promptCompExtraHeader, map[string]string{"context": extraText}) + if strings.TrimSpace(extra) == "" { + extra = extraText + } + messages = append(messages, llm.Message{Role: "user", Content: extra}) + } + return messages } // postProcessCompletion normalizes and deduplicates completion text and applies indentation rules. @@ -3613,42 +4108,42 @@ func (s *Server) handleDidClose(req Request) { // docBeforeAfter returns the full document text split at the given position. // The returned strings are the text before the cursor (inclusive of anything // left of the position) and the text after the cursor. -func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { +func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { d := s.getDocument(uri) if d == nil { return "", "" } // Clamp indices - line := pos.Line + line := pos.Line if line < 0 { line = 0 } - if line >= len(d.lines) { + if line >= len(d.lines) { line = len(d.lines) - 1 } - col := pos.Character + col := pos.Character if col < 0 { col = 0 } - if col > len(d.lines[line]) { + if col > len(d.lines[line]) { col = len(d.lines[line]) } // Build before - var b strings.Builder - for i := 0; i < line; i++ { + var b strings.Builder + for i := 0; i < line; i++ { b.WriteString(d.lines[i]) b.WriteByte('\n') } - b.WriteString(d.lines[line][:col]) + b.WriteString(d.lines[line][:col]) before := b.String() // Build after var a strings.Builder a.WriteString(d.lines[line][col:]) - for i := line + 1; i < len(d.lines); i++ { + for i := line + 1; i < len(d.lines); i++ { a.WriteByte('\n') a.WriteString(d.lines[i]) } - return before, a.String() + return before, a.String() } // --- in-editor chat (";C ...") --- @@ -3656,73 +4151,73 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { +func (s *Server) detectAndHandleChat(uri string) { if s.llmClient == nil { return } - d := s.getDocument(uri) + d := s.getDocument(uri) if d == nil || len(d.lines) == 0 { return } - for i, raw := range d.lines { + for i, raw := range d.lines { // Find last non-space character index j := len(raw) - 1 - for j >= 0 { + for j >= 0 { if raw[j] == ' ' || raw[j] == '\t' { j-- continue } - break + break } - if j < 0 { + if j < 0 { continue } // Check suffix/prefix according to configuration - if s.chatSuffix == "" { + if s.chatSuffix == "" { continue } // Last non-space must equal suffix - if string(raw[j]) != s.chatSuffix { + if string(raw[j]) != s.chatSuffix { continue } // Require at least one char before suffix and that char must be in chatPrefixes - if j < 1 { + if j < 1 { continue } - prev := string(raw[j-1]) + prev := string(raw[j-1]) isTrigger := false - for _, pfx := range s.chatPrefixes { - if prev == pfx { + for _, pfx := range s.chatPrefixes { + if prev == pfx { isTrigger = true break } } - if !isTrigger { + if !isTrigger { continue } // Avoid double-answering: if the next non-empty line starts with '>' we skip. - k := i + 1 - for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { + k := i + 1 + for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { k++ } - if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { + if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { continue } // Derive prompt by removing only the trailing '>' - removeCount := len(s.chatSuffix) + removeCount := len(s.chatSuffix) base := raw[:j+1-removeCount] prompt := strings.TrimSpace(base) if prompt == "" { continue } - lineIdx := i + lineIdx := i lastIdx := j - go func(prompt string, remove int) { + go func(prompt string, remove int) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - sys := "You are a helpful coding assistant. Answer concisely and clearly." - // Build short conversation history from the document above this line - history := s.buildChatHistory(uri, lineIdx, prompt) + sys := s.promptChatSystem + // Build short conversation history from the document above this line + history := s.buildChatHistory(uri, lineIdx, prompt) msgs := append([]llm.Message{{Role: "system", Content: sys}}, history...) opts := s.llmRequestOpts() logging.Logf("lsp ", "chat llm=requesting model=%s", s.llmClient.DefaultModel()) @@ -3731,26 +4226,26 @@ func (s *Server) detectAndHandleChat(uri string) { logging.Logf("lsp ", "chat llm error: %v", err) return } - out := strings.TrimSpace(stripCodeFences(text)) + out := strings.TrimSpace(stripCodeFences(text)) if out == "" { return } - s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out) + s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out) }(prompt, removeCount) // Only handle one per change tick to avoid flooding - break + break } } // applyChatEdits removes the triggering punctuation at end of the line and // inserts two newlines followed by a new line with the response prefixed. -func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) { +func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) { d := s.getDocument(uri) if d == nil { return } // 1) Delete the trailing punctuation (1 or 2 chars) - delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} + delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} delEnd := Position{Line: lineIdx, Character: lastNonSpace + 1} // 2) Insert two newlines and the response at end-of-line, then one extra blank line insPos := Position{Line: lineIdx, Character: len(d.lines[lineIdx])} @@ -3766,12 +4261,12 @@ func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, remov // buildChatHistory walks upwards from the current line to collect the most recent // Q/A pairs in the in-editor transcript. Returns messages ending with current prompt. -func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message { +func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message { d := s.getDocument(uri) if d == nil { return []llm.Message{{Role: "user", Content: currentPrompt}} } - type pair struct{ q, a string } + type pair struct{ q, a string } pairs := []pair{} i := lineIdx - 1 for i >= 0 && len(pairs) < 3 { @@ -3805,7 +4300,7 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) i-- } - msgs := make([]llm.Message, 0, len(pairs)*2+1) + msgs := make([]llm.Message, 0, len(pairs)*2+1) for _, p := range pairs { if strings.TrimSpace(p.q) != "" { msgs = append(msgs, llm.Message{Role: "user", Content: p.q}) @@ -3814,7 +4309,7 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) } } - msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) + msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) return msgs } @@ -3844,7 +4339,7 @@ func stripTrailingTrigger(sx string) string { } // clientApplyEdit sends a workspace/applyEdit request to the client. -func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { +func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { params := ApplyWorkspaceEditParams{Label: label, Edit: edit} id := s.nextReqID() req := Request{JSONRPC: "2.0", ID: id, Method: "workspace/applyEdit"} @@ -3854,7 +4349,7 @@ func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { +func (s *Server) nextReqID() json.RawMessage { s.mu.Lock() s.nextID++ idNum := s.nextID @@ -3974,12 +4469,11 @@ func (s *Server) handleExit() { package lsp import ( - "fmt" - "strings" - "time" + "strings" + "time" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" ) // Configurable inline trigger characters (default to '>') used by free helpers below. @@ -3990,12 +4484,12 @@ var ( ) // llmRequestOpts builds request options from server settings. -func (s *Server) llmRequestOpts() []llm.RequestOption { +func (s *Server) llmRequestOpts() []llm.RequestOption { opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} if s.codingTemperature != nil { opts = append(opts, llm.WithTemperature(*s.codingTemperature)) } - return opts + return opts } // small helpers for LLM traffic stats @@ -4045,18 +4539,20 @@ func inParamList(current string, cursor int) bool return open >= 0 && cursor > open && (close == -1 || cursor <= close) } -func buildPrompts(inParams bool, p CompletionParams, above, current, below, funcCtx string) (string, string) { - if inParams { - sys := "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." - user := fmt.Sprintf("Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: %s\nCurrent line (cursor at %d): %s", funcCtx, p.Position.Character, current) - return sys, user - } - sys := "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)." - user := fmt.Sprintf("Provide the next likely code to insert at the cursor.\nFile: %s\nFunction/context: %s\nAbove line: %s\nCurrent line (cursor at character %d): %s\nBelow line: %s\nOnly return the completion snippet.", p.TextDocument.URI, funcCtx, above, p.Position.Character, current, below) - return sys, user +// renderTemplate performs simple {{var}} replacement in a template string. +func renderTemplate(t string, vars map[string]string) string { + if t == "" { + return t + } + out := t + for k, v := range vars { + placeholder := "{{" + k + "}}" + out = strings.ReplaceAll(out, placeholder, v) + } + return out } -func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) { +func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) { if inParams { open := strings.Index(current, "(") close := strings.Index(current, ")") @@ -4077,25 +4573,25 @@ func computeTextEditAndFilter(cleaned string, inParams bool, current string, p C return te, filter } } - startChar := computeWordStart(current, p.Position.Character) + startChar := computeWordStart(current, p.Position.Character) te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: startChar}, End: Position{Line: p.Position.Line, Character: p.Position.Character}}, NewText: cleaned} filter := strings.TrimLeft(current[startChar:p.Position.Character], " \t") return te, filter } -func computeWordStart(current string, at int) int { +func computeWordStart(current string, at int) int { if at > len(current) { at = len(current) } - for at > 0 { + for at > 0 { ch := current[at-1] - if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' { + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' { at-- continue } break } - return at + return at } func isIdentChar(ch byte) bool { @@ -4205,7 +4701,7 @@ func isBareDoubleOpen(line string) bool { } // stripDuplicateAssignmentPrefix removes a duplicated assignment prefix from the suggestion. -func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string { +func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string { s2 := strings.TrimLeft(suggestion, " \t") // Prefer := if present at end of prefix if idx := strings.LastIndex(prefixBeforeCursor, ":="); idx >= 0 && idx+2 <= len(prefixBeforeCursor) { @@ -4223,7 +4719,7 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin } } // Fallback to plain '=' if present - if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 { + if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 { if !(idx > 0 && prefixBeforeCursor[idx-1] == ':') { // not := tail := prefixBeforeCursor[idx+1:] if strings.TrimSpace(tail) == "" { @@ -4239,20 +4735,20 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin } } } - return suggestion + return suggestion } // stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated. -func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string { +func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string { if suggestion == "" { return suggestion } - s := strings.TrimLeft(suggestion, " \t") + s := strings.TrimLeft(suggestion, " \t") p := strings.TrimRight(prefixBeforeCursor, " \t") if p != "" && strings.HasPrefix(s, p) { return strings.TrimLeft(s[len(p):], " \t") } - for k := len(p) - 1; k > 0; k-- { + for k := len(p) - 1; k > 0; k-- { if !isIdentBoundary(p[k-1]) { continue } @@ -4264,7 +4760,7 @@ func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string < return strings.TrimLeft(s[len(suf):], " \t") } } - return suggestion + return suggestion } func isIdentBoundary(ch byte) bool { @@ -4272,30 +4768,30 @@ func isIdentBoundary(ch byte) bool { } // stripCodeFences removes surrounding Markdown code fences from a model response. -func stripCodeFences(s string) string { +func stripCodeFences(s string) string { t := strings.TrimSpace(s) if t == "" { return t } - lines := splitLines(t) + lines := splitLines(t) start := 0 for start < len(lines) && strings.TrimSpace(lines[start]) == "" { start++ } - end := len(lines) - 1 + end := len(lines) - 1 for end >= 0 && strings.TrimSpace(lines[end]) == "" { end-- } - if start >= len(lines) || end < 0 || start > end { + if start >= len(lines) || end < 0 || start > end { return t } - first := strings.TrimSpace(lines[start]) + first := strings.TrimSpace(lines[start]) last := strings.TrimSpace(lines[end]) if strings.HasPrefix(first, "```") && last == "```" && end > start { inner := strings.Join(lines[start+1:end], "\n") return inner } - return t + return t } // stripInlineCodeSpan returns the contents of the first inline backtick code span if present. @@ -4317,9 +4813,9 @@ func stripInlineCodeSpan(s string) string { } // labelForCompletion picks a short, readable label for the completion list. -func labelForCompletion(cleaned, filter string) string { +func labelForCompletion(cleaned, filter string) string { label := trimLen(firstLine(cleaned)) - if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) { + if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) { return filter } return label @@ -4371,32 +4867,32 @@ func extractRangeText(d *document, r Range) string } // collectPromptRemovalEdits returns edits to remove all inline prompt markers. -func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit { +func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit { d := s.getDocument(uri) if d == nil || len(d.lines) == 0 { return nil } - var edits []TextEdit - for i, line := range d.lines { + var edits []TextEdit + for i, line := range d.lines { edits = append(edits, promptRemovalEditsForLine(line, i)...) } - return edits + return edits } -func promptRemovalEditsForLine(line string, lineNum int) []TextEdit { +func promptRemovalEditsForLine(line string, lineNum int) []TextEdit { if hasDoubleOpenTrigger(line) { return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} } - return collectSemicolonMarkers(line, lineNum) + return collectSemicolonMarkers(line, lineNum) } -func hasDoubleOpenTrigger(line string) bool { +func hasDoubleOpenTrigger(line string) bool { pos := 0 - for pos < len(line) { + for pos < len(line) { // look for double-open sequence dbl := string([]byte{inlineOpenChar, inlineOpenChar}) j := strings.Index(line[pos:], dbl) - if j < 0 { + if j < 0 { return false } j += pos @@ -4424,12 +4920,12 @@ func hasDoubleOpenTrigger(line string) bool { return false } -func collectSemicolonMarkers(line string, lineNum int) []TextEdit { +func collectSemicolonMarkers(line string, lineNum int) []TextEdit { var edits []TextEdit startSemi := 0 - for startSemi < len(line) { + for startSemi < len(line) { j := strings.IndexByte(line[startSemi:], inlineOpenChar) - if j < 0 { + if j < 0 { break } j += startSemi @@ -4461,7 +4957,7 @@ func collectSemicolonMarkers(line string, lineNum int) []TextEdit edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) startSemi = endChar } - return edits + return edits } @@ -4525,7 +5021,29 @@ type Server struct { inlineOpen string inlineClose string chatSuffix string - chatPrefixes []string + chatPrefixes []string + + // Prompt templates + // Completion + promptCompSysGeneral string + promptCompSysParams string + promptCompSysInline string + promptCompUserGeneral string + promptCompUserParams string + promptCompExtraHeader string + // Provider-native code completion + promptNativeCompletion string + // In-editor chat + promptChatSystem string + // Code actions + promptRewriteSystem string + promptDiagnosticsSystem string + promptDocumentSystem string + promptRewriteUser string + promptDiagnosticsUser string + promptDocumentUser string + promptGoTestSystem string + promptGoTestUser string } // ServerOptions collects configuration for NewServer to avoid long parameter lists. @@ -4546,8 +5064,26 @@ type ServerOptions struct { // Inline/chat triggers InlineOpen string InlineClose string - ChatSuffix string - ChatPrefixes []string + ChatSuffix string + ChatPrefixes []string + + // Prompt templates + PromptCompSysGeneral string + PromptCompSysParams string + PromptCompSysInline string + PromptCompUserGeneral string + PromptCompUserParams string + PromptCompExtraHeader string + PromptNativeCompletion string + PromptChatSystem string + PromptRewriteSystem string + PromptDiagnosticsSystem string + PromptDocumentSystem string + PromptRewriteUser string + PromptDiagnosticsUser string + PromptDocumentUser string + PromptGoTestSystem string + PromptGoTestUser string } func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server { @@ -4606,14 +5142,32 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) } else { s.chatSuffix = opts.ChatSuffix } - if len(opts.ChatPrefixes) == 0 { - s.chatPrefixes = []string{"?", "!", ":", ";"} - } else { - s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) - } + if len(opts.ChatPrefixes) == 0 { + s.chatPrefixes = []string{"?", "!", ":", ";"} + } else { + s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) + } + + // Prompts + s.promptCompSysGeneral = opts.PromptCompSysGeneral + s.promptCompSysParams = opts.PromptCompSysParams + s.promptCompSysInline = opts.PromptCompSysInline + s.promptCompUserGeneral = opts.PromptCompUserGeneral + s.promptCompUserParams = opts.PromptCompUserParams + s.promptCompExtraHeader = opts.PromptCompExtraHeader + s.promptNativeCompletion = opts.PromptNativeCompletion + s.promptChatSystem = opts.PromptChatSystem + s.promptRewriteSystem = opts.PromptRewriteSystem + s.promptDiagnosticsSystem = opts.PromptDiagnosticsSystem + s.promptDocumentSystem = opts.PromptDocumentSystem + s.promptRewriteUser = opts.PromptRewriteUser + s.promptDiagnosticsUser = opts.PromptDiagnosticsUser + s.promptDocumentUser = opts.PromptDocumentUser + s.promptGoTestSystem = opts.PromptGoTestSystem + s.promptGoTestUser = opts.PromptGoTestUser // Assign package-level inline trigger chars for free helper functions - if s.inlineOpen != "" { + if s.inlineOpen != "" { inlineOpenChar = s.inlineOpen[0] } if s.inlineClose != "" { @@ -4718,18 +5272,18 @@ func (s *Server) readMessage() ([]byte, error) { return buf, nil } -func (s *Server) writeMessage(v any) { +func (s *Server) writeMessage(v any) { data, err := json.Marshal(v) if err != nil { logging.Logf("lsp ", "marshal error: %v", err) return } - header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) if _, err := io.WriteString(s.out, header); err != nil { logging.Logf("lsp ", "write header error: %v", err) return } - if _, err := s.out.Write(data); err != nil { + if _, err := s.out.Write(data); err != nil { logging.Logf("lsp ", "write body error: %v", err) return } -- cgit v1.2.3