summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/appconfig/config.go488
-rw-r--r--internal/appconfig/config_test.go207
-rw-r--r--internal/hexaicli/run.go42
-rw-r--r--internal/hexailsp/run.go52
-rw-r--r--internal/lsp/build_prompts_table_test.go32
-rw-r--r--internal/lsp/chat_prompt_test.go36
-rw-r--r--internal/lsp/codeaction_prompts_test.go102
-rw-r--r--internal/lsp/completion_messages_test.go16
-rw-r--r--internal/lsp/document_test.go35
-rw-r--r--internal/lsp/handlers_codeaction.go89
-rw-r--r--internal/lsp/handlers_completion.go50
-rw-r--r--internal/lsp/handlers_document.go6
-rw-r--r--internal/lsp/handlers_utils.go29
-rw-r--r--internal/lsp/helpers_more_test.go21
-rw-r--r--internal/lsp/provider_native_success_test.go31
-rw-r--r--internal/lsp/server.go74
-rw-r--r--internal/lsp/testhelper_capture_llm_test.go18
-rw-r--r--internal/version.go2
18 files changed, 1131 insertions, 199 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go
index 9404607..9941cf8 100644
--- a/internal/appconfig/config.go
+++ b/internal/appconfig/config.go
@@ -54,9 +54,35 @@ 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)
@@ -64,7 +90,7 @@ 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,
@@ -81,8 +107,32 @@ 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.
@@ -113,33 +163,352 @@ func Load(logger *log.Logger) App {
}
// Private helpers
+// Sectioned (table-based) file format only.
+type fileConfig struct {
+ // Section tables only (flat keys are not allowed)
+ General sectionGeneral `toml:"general"`
+ Logging sectionLogging `toml:"logging"`
+ Completion sectionCompletion `toml:"completion"`
+ Triggers sectionTriggers `toml:"triggers"`
+ Inline sectionInline `toml:"inline"`
+ Chat sectionChat `toml:"chat"`
+ Provider sectionProvider `toml:"provider"`
+ OpenAI sectionOpenAI `toml:"openai"`
+ Copilot sectionCopilot `toml:"copilot"`
+ Ollama sectionOllama `toml:"ollama"`
+ Prompts sectionPrompts `toml:"prompts"`
+}
+
+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,
+ }
+ 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) {
- 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()
+ 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
+ }
- 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
- }
- if logger != nil {
- logger.Printf("loaded configuration from %s (TOML)", path)
- }
- return &fileCfg, nil
+ 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)
+ a.mergeBasics(other)
+ a.mergeProviderFields(other)
+ a.mergePrompts(other)
}
// mergeBasics merges general (non-provider) fields.
@@ -191,6 +560,69 @@ func (a *App) mergeBasics(other *App) {
}
}
+// 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 != "" {
diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go
index bdf86da..65e6283 100644
--- a/internal/appconfig/config_test.go
+++ b/internal/appconfig/config_test.go
@@ -52,29 +52,44 @@ func TestLoad_Defaults_WithLogger_NoFile_NoEnv(t *testing.T) {
func TestLoad_FileMerge_And_EnvOverride(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
- cfgPath := filepath.Join(dir, "hexai", "config.toml")
- // file configuration in TOML
- writeFile(t, cfgPath, `
+ cfgPath := filepath.Join(dir, "hexai", "config.toml")
+ // file configuration in TOML (sectioned)
+ writeFile(t, cfgPath, `
+[general]
max_tokens = 123
context_mode = "file-on-new-func"
context_window_lines = 50
max_context_tokens = 999
-log_preview_limit = 0
coding_temperature = 0.0
+
+[logging]
+log_preview_limit = 0
+
+[completion]
manual_invoke_min_prefix = 2
completion_debounce_ms = 150
completion_throttle_ms = 300
+
+[triggers]
trigger_characters = [".", ":"]
-provider = "openai"
-openai_base_url = "https://api.example"
-openai_model = "gpt-x"
-openai_temperature = 0.0
-ollama_base_url = "http://ollama"
-ollama_model = "llama"
-ollama_temperature = 0.0
-copilot_base_url = "http://copilot"
-copilot_model = "ghost"
-copilot_temperature = 0.0
+
+[provider]
+name = "openai"
+
+[openai]
+base_url = "https://api.example"
+model = "gpt-x"
+temperature = 0.0
+
+[ollama]
+base_url = "http://ollama"
+model = "llama"
+temperature = 0.0
+
+[copilot]
+base_url = "http://copilot"
+model = "ghost"
+temperature = 0.0
`)
// Env overrides take precedence
@@ -129,13 +144,13 @@ copilot_temperature = 0.0
t.Fatalf("copilot overrides not applied: %+v", cfg)
}
- // Ensure file values would have applied absent env
- // Spot-check: reset env and reload
- for _, k := range []string{
- "HEXAI_MAX_TOKENS", "HEXAI_CONTEXT_MODE", "HEXAI_CONTEXT_WINDOW_LINES", "HEXAI_MAX_CONTEXT_TOKENS", "HEXAI_LOG_PREVIEW_LIMIT", "HEXAI_CODING_TEMPERATURE", "HEXAI_MANUAL_INVOKE_MIN_PREFIX", "HEXAI_COMPLETION_DEBOUNCE_MS", "HEXAI_COMPLETION_THROTTLE_MS", "HEXAI_TRIGGER_CHARACTERS", "HEXAI_PROVIDER", "HEXAI_OPENAI_BASE_URL", "HEXAI_OPENAI_MODEL", "HEXAI_OPENAI_TEMPERATURE", "HEXAI_OLLAMA_BASE_URL", "HEXAI_OLLAMA_MODEL", "HEXAI_OLLAMA_TEMPERATURE", "HEXAI_COPILOT_BASE_URL", "HEXAI_COPILOT_MODEL", "HEXAI_COPILOT_TEMPERATURE",
- } {
- t.Setenv(k, "")
- }
+ // Ensure file values would have applied absent env
+ // Spot-check: reset env and reload
+ for _, k := range []string{
+ "HEXAI_MAX_TOKENS", "HEXAI_CONTEXT_MODE", "HEXAI_CONTEXT_WINDOW_LINES", "HEXAI_MAX_CONTEXT_TOKENS", "HEXAI_LOG_PREVIEW_LIMIT", "HEXAI_CODING_TEMPERATURE", "HEXAI_MANUAL_INVOKE_MIN_PREFIX", "HEXAI_COMPLETION_DEBOUNCE_MS", "HEXAI_COMPLETION_THROTTLE_MS", "HEXAI_TRIGGER_CHARACTERS", "HEXAI_PROVIDER", "HEXAI_OPENAI_BASE_URL", "HEXAI_OPENAI_MODEL", "HEXAI_OPENAI_TEMPERATURE", "HEXAI_OLLAMA_BASE_URL", "HEXAI_OLLAMA_MODEL", "HEXAI_OLLAMA_TEMPERATURE", "HEXAI_COPILOT_BASE_URL", "HEXAI_COPILOT_MODEL", "HEXAI_COPILOT_TEMPERATURE",
+ } {
+ t.Setenv(k, "")
+ }
cfg2 := Load(logger)
if cfg2.MaxTokens != 123 || cfg2.ContextMode != "file-on-new-func" || cfg2.ContextWindowLines != 50 || cfg2.MaxContextTokens != 999 || cfg2.LogPreviewLimit != 0 {
t.Fatalf("file merge not applied: %+v", cfg2)
@@ -175,3 +190,153 @@ func TestLoadFromFile_InvalidTOML(t *testing.T) {
t.Fatalf("expected error for invalid TOML")
}
}
+
+func TestLoad_FileTables_Sectioned(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ cfgPath := filepath.Join(dir, "hexai", "config.toml")
+ content := `
+[general]
+max_tokens = 111
+context_mode = "window"
+context_window_lines = 42
+max_context_tokens = 777
+coding_temperature = 0.1
+
+[logging]
+log_preview_limit = 9
+
+[completion]
+completion_debounce_ms = 123
+completion_throttle_ms = 456
+manual_invoke_min_prefix = 3
+
+[triggers]
+trigger_characters = [".", ":"]
+
+[inline]
+inline_open = ">"
+inline_close = ">"
+
+[chat]
+chat_suffix = ">"
+chat_prefixes = ["?", "!"]
+
+[provider]
+name = "openai"
+
+[openai]
+model = "gpt-x"
+base_url = "https://api.example"
+temperature = 0.0
+
+[ollama]
+model = "mistral"
+base_url = "http://ollama"
+temperature = 0.0
+
+[copilot]
+model = "ghost"
+base_url = "http://copilot"
+temperature = 0.0
+`
+ writeFile(t, cfgPath, content)
+
+ // Ensure no env override interferes with manual_invoke_min_prefix in this test
+ t.Setenv("HEXAI_MANUAL_INVOKE_MIN_PREFIX", "")
+ logger := newLogger()
+ cfg := Load(logger)
+
+ if cfg.MaxTokens != 111 || cfg.ContextMode != "window" || cfg.ContextWindowLines != 42 || cfg.MaxContextTokens != 777 {
+ t.Fatalf("sectioned basics wrong: %+v", cfg)
+ }
+ if cfg.LogPreviewLimit != 9 || cfg.CompletionDebounceMs != 123 || cfg.CompletionThrottleMs != 456 || cfg.ManualInvokeMinPrefix != 3 {
+ t.Fatalf("sectioned ints wrong: %+v", cfg)
+ }
+ if cfg.CodingTemperature == nil || *cfg.CodingTemperature != 0.1 {
+ t.Fatalf("sectioned coding_temperature wrong: %+v", cfg.CodingTemperature)
+ }
+ if want := []string{".", ":"}; !reflect.DeepEqual(cfg.TriggerCharacters, want) {
+ t.Fatalf("sectioned trigger chars wrong: got %v", cfg.TriggerCharacters)
+ }
+ if cfg.Provider != "openai" {
+ t.Fatalf("sectioned provider name wrong: %q", cfg.Provider)
+ }
+ if cfg.OpenAIModel != "gpt-x" || cfg.OpenAIBaseURL != "https://api.example" || cfg.OpenAITemperature == nil || *cfg.OpenAITemperature != 0.0 {
+ t.Fatalf("sectioned openai wrong: %+v", cfg)
+ }
+ if cfg.OllamaModel != "mistral" || cfg.OllamaBaseURL != "http://ollama" || cfg.OllamaTemperature == nil || *cfg.OllamaTemperature != 0.0 {
+ t.Fatalf("sectioned ollama wrong: %+v", cfg)
+ }
+ if cfg.CopilotModel != "ghost" || cfg.CopilotBaseURL != "http://copilot" || cfg.CopilotTemperature == nil || *cfg.CopilotTemperature != 0.0 {
+ t.Fatalf("sectioned copilot wrong: %+v", cfg)
+ }
+}
+
+func TestLoad_FileTables_Prompts_AllSections(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ cfgPath := filepath.Join(dir, "hexai", "config.toml")
+ content := `
+[prompts.completion]
+system_general = "SYS-GENERAL"
+system_params = "SYS-PARAMS"
+system_inline = "SYS-INLINE"
+user_general = "USER-GENERAL {{file}} {{char}}"
+user_params = "USER-PARAMS {{function}}"
+additional_context = "EXTRA {{context}}"
+
+[prompts.provider_native]
+completion = "NATIVE {{path}} {{before}}"
+
+[prompts.chat]
+system = "CHAT-SYS"
+
+[prompts.code_action]
+rewrite_system = "REWRITE-SYS"
+diagnostics_system = "DIAG-SYS"
+document_system = "DOC-SYS"
+rewrite_user = "REWRITE-USER {{instruction}} {{selection}}"
+diagnostics_user = "DIAG-USER {{diagnostics}} {{selection}}"
+document_user = "DOC-USER {{selection}}"
+go_test_system = "GOTEST-SYS"
+go_test_user = "GOTEST-USER {{function}}"
+
+[prompts.cli]
+default_system = "CLI-DEFAULT"
+explain_system = "CLI-EXPLAIN"
+`
+ writeFile(t, cfgPath, content)
+
+ cfg := Load(newLogger())
+
+ // completion
+ if cfg.PromptCompletionSystemGeneral != "SYS-GENERAL" || cfg.PromptCompletionSystemParams != "SYS-PARAMS" || cfg.PromptCompletionSystemInline != "SYS-INLINE" {
+ t.Fatalf("completion system prompts wrong: %+v", cfg)
+ }
+ if cfg.PromptCompletionUserGeneral == "" || cfg.PromptCompletionUserParams == "" || cfg.PromptCompletionExtraHeader == "" {
+ t.Fatalf("completion user/extra prompts not loaded")
+ }
+ // provider-native
+ if cfg.PromptNativeCompletion != "NATIVE {{path}} {{before}}" {
+ t.Fatalf("provider-native prompt wrong: %q", cfg.PromptNativeCompletion)
+ }
+ // chat
+ if cfg.PromptChatSystem != "CHAT-SYS" {
+ t.Fatalf("chat system wrong: %q", cfg.PromptChatSystem)
+ }
+ // code action
+ if cfg.PromptCodeActionRewriteSystem != "REWRITE-SYS" || cfg.PromptCodeActionDiagnosticsSystem != "DIAG-SYS" || cfg.PromptCodeActionDocumentSystem != "DOC-SYS" {
+ t.Fatalf("code action system prompts wrong")
+ }
+ if cfg.PromptCodeActionRewriteUser == "" || cfg.PromptCodeActionDiagnosticsUser == "" || cfg.PromptCodeActionDocumentUser == "" {
+ t.Fatalf("code action user prompts not loaded")
+ }
+ if cfg.PromptCodeActionGoTestSystem != "GOTEST-SYS" || cfg.PromptCodeActionGoTestUser == "" {
+ t.Fatalf("go test prompts wrong")
+ }
+ // CLI
+ if cfg.PromptCLIDefaultSystem != "CLI-DEFAULT" || cfg.PromptCLIExplainSystem != "CLI-EXPLAIN" {
+ t.Fatalf("cli prompts wrong: %q %q", cfg.PromptCLIDefaultSystem, cfg.PromptCLIExplainSystem)
+ }
+}
diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go
index 54cb3ff..ca561bb 100644
--- a/internal/hexaicli/run.go
+++ b/internal/hexaicli/run.go
@@ -23,13 +23,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.
@@ -41,7 +52,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
@@ -109,6 +120,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()
diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go
index a1be5aa..9a69e51 100644
--- a/internal/hexailsp/run.go
+++ b/internal/hexailsp/run.go
@@ -106,21 +106,39 @@ func ensureFactory(factory ServerFactory) ServerFactory {
}
func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) lsp.ServerOptions {
- 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,
+ }
}
diff --git a/internal/lsp/build_prompts_table_test.go b/internal/lsp/build_prompts_table_test.go
index 7e8e5e7..06a3743 100644
--- a/internal/lsp/build_prompts_table_test.go
+++ b/internal/lsp/build_prompts_table_test.go
@@ -3,18 +3,22 @@ package lsp
import "testing"
func TestBuildPrompts_Table(t *testing.T) {
- p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 5, Character: 7}}
- cases := []struct {
- name string
- inParams bool
- }{
- {"generic", false},
- {"in_params", true},
- }
- for _, c := range cases {
- sys, user := buildPrompts(c.inParams, p, "above", "current", "below", "func ctx")
- if sys == "" || user == "" {
- t.Fatalf("%s: prompts empty", c.name)
- }
- }
+ p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 5, Character: 7}}
+ cases := []struct {
+ name string
+ inParams bool
+ }{
+ {"generic", false},
+ {"in_params", true},
+ }
+ for _, c := range cases {
+ s := newTestServer()
+ msgs := s.buildCompletionMessages(false, false, "", c.inParams, p, "above", "current", "below", "func ctx")
+ if len(msgs) < 2 || msgs[0].Role != "system" || msgs[1].Role != "user" {
+ t.Fatalf("%s: unexpected messages", c.name)
+ }
+ if msgs[0].Content == "" || msgs[1].Content == "" {
+ t.Fatalf("%s: prompts empty", c.name)
+ }
+ }
}
diff --git a/internal/lsp/chat_prompt_test.go b/internal/lsp/chat_prompt_test.go
new file mode 100644
index 0000000..f0f5446
--- /dev/null
+++ b/internal/lsp/chat_prompt_test.go
@@ -0,0 +1,36 @@
+package lsp
+
+import (
+ "bytes"
+ "testing"
+ "time"
+)
+
+func TestDetectAndHandleChat_UsesConfiguredSystemPrompt(t *testing.T) {
+ s := newTestServer()
+ cap := &captureLLM{}
+ s.llmClient = cap
+ s.promptChatSystem = "CHAT-SYS"
+ uri := "file:///chat.txt"
+ // Avoid nil writer in applyChatEdits
+ var out bytes.Buffer
+ s.out = &out
+ // Line that should trigger chat: ends with '>' and previous char in prefixes
+ s.setDocument(uri, "help?>\n")
+ s.detectAndHandleChat(uri)
+ // Wait briefly for async goroutine to call Chat
+ for i := 0; i < 20 && len(cap.msgs) == 0; i++ {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if len(cap.msgs) == 0 {
+ t.Fatalf("expected Chat to be called")
+ }
+ if cap.msgs[0].Role != "system" || cap.msgs[0].Content != "CHAT-SYS" {
+ t.Fatalf("unexpected system msg: %+v", cap.msgs[0])
+ }
+ // Last should be user with prompt without trailing '>'
+ last := cap.msgs[len(cap.msgs)-1]
+ if last.Role != "user" || last.Content != "help?" {
+ t.Fatalf("unexpected last user msg: %+v", last)
+ }
+}
diff --git a/internal/lsp/codeaction_prompts_test.go b/internal/lsp/codeaction_prompts_test.go
new file mode 100644
index 0000000..6b2ce8c
--- /dev/null
+++ b/internal/lsp/codeaction_prompts_test.go
@@ -0,0 +1,102 @@
+package lsp
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestResolveCodeAction_UsesRewritePrompts(t *testing.T) {
+ s := newTestServer()
+ cap := &captureLLM{}
+ s.llmClient = cap
+ s.promptRewriteSystem = "RSYS"
+ s.promptRewriteUser = "RUSER {{instruction}} {{selection}}"
+ uri := "file:///x.go"
+ s.setDocument(uri, "package p\nvar a=1\n")
+ payload := struct {
+ Type string `json:"type"`
+ URI string `json:"uri"`
+ Range Range `json:"range"`
+ Instruction string `json:"instruction"`
+ Selection string `json:"selection"`
+ }{Type: "rewrite", URI: uri, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1, Character: 5}}, Instruction: "do it", Selection: "var a"}
+ raw, _ := json.Marshal(payload)
+ ca := CodeAction{Title: "Hexai: rewrite selection", Data: raw}
+ _, _ = s.resolveCodeAction(ca)
+ if len(cap.msgs) < 2 {
+ t.Fatalf("expected chat messages")
+ }
+ if cap.msgs[0].Content != "RSYS" || cap.msgs[1].Role != "user" || cap.msgs[1].Content != "RUSER do it var a" {
+ t.Fatalf("unexpected rewrite prompts: %#v", cap.msgs)
+ }
+}
+
+func TestResolveCodeAction_UsesDiagnosticsPrompts(t *testing.T) {
+ s := newTestServer()
+ cap := &captureLLM{}
+ s.llmClient = cap
+ s.promptDiagnosticsSystem = "DSYS"
+ s.promptDiagnosticsUser = "DUSER {{diagnostics}} {{selection}}"
+ uri := "file:///x.go"
+ s.setDocument(uri, "package p\nvar a=1\n")
+ payload := struct {
+ Type string `json:"type"`
+ URI string `json:"uri"`
+ Range Range `json:"range"`
+ Selection string `json:"selection"`
+ Diagnostics []Diagnostic `json:"diagnostics"`
+ }{Type: "diagnostics", URI: uri, Range: Range{Start: Position{Line: 1}}, Selection: "var a", Diagnostics: []Diagnostic{{Message: "oops1"}, {Message: "oops2"}}}
+ raw, _ := json.Marshal(payload)
+ ca := CodeAction{Title: "Hexai: resolve diagnostics", Data: raw}
+ _, _ = s.resolveCodeAction(ca)
+ if len(cap.msgs) < 2 {
+ t.Fatalf("expected chat messages")
+ }
+ if cap.msgs[0].Content != "DSYS" || cap.msgs[1].Role != "user" {
+ t.Fatalf("unexpected diagnostics prompts: %#v", cap.msgs)
+ }
+ if got := cap.msgs[1].Content; !(contains(got, "oops1") && contains(got, "oops2") && contains(got, "var a")) {
+ t.Fatalf("diagnostics/user content mismatch: %q", got)
+ }
+}
+
+func TestResolveCodeAction_UsesDocumentPrompts(t *testing.T) {
+ s := newTestServer()
+ cap := &captureLLM{}
+ s.llmClient = cap
+ s.promptDocumentSystem = "DOCSYS"
+ s.promptDocumentUser = "DOCUSER {{selection}}"
+ uri := "file:///x.go"
+ s.setDocument(uri, "package p\nvar a=1\n")
+ payload := struct {
+ Type string `json:"type"`
+ URI string `json:"uri"`
+ Range Range `json:"range"`
+ Selection string `json:"selection"`
+ }{Type: "document", URI: uri, Range: Range{Start: Position{Line: 1}}, Selection: "var a"}
+ raw, _ := json.Marshal(payload)
+ ca := CodeAction{Title: "Hexai: document selection", Data: raw}
+ _, _ = s.resolveCodeAction(ca)
+ if len(cap.msgs) < 2 {
+ t.Fatalf("expected chat messages")
+ }
+ if cap.msgs[0].Content != "DOCSYS" || cap.msgs[1].Content != "DOCUSER var a" {
+ t.Fatalf("unexpected document prompts: %#v", cap.msgs)
+ }
+}
+
+func TestGenerateGoTest_UsesPrompts(t *testing.T) {
+ s := newTestServer()
+ cap := &captureLLM{}
+ s.llmClient = cap
+ s.promptGoTestSystem = "GTSYS"
+ s.promptGoTestUser = "GTUSER {{function}}"
+ _ = s.generateGoTestFunction("func Add(a,b int) int {return a+b}")
+ if len(cap.msgs) < 2 {
+ t.Fatalf("expected chat messages")
+ }
+ if cap.msgs[0].Content != "GTSYS" || !contains(cap.msgs[1].Content, "func Add") {
+ t.Fatalf("unexpected gotest prompts: %#v", cap.msgs)
+ }
+}
+
diff --git a/internal/lsp/completion_messages_test.go b/internal/lsp/completion_messages_test.go
index 28908d5..37d4a8d 100644
--- a/internal/lsp/completion_messages_test.go
+++ b/internal/lsp/completion_messages_test.go
@@ -58,12 +58,16 @@ func TestBuildDocString_Contents(t *testing.T) {
}
}
-func TestBuildPrompts_InParams(t *testing.T) {
- p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line: 0, Character: 5}}
- sys, user := buildPrompts(true, p, "a", "func f(x)", "c", "func f(x)")
- if !contains(sys, "function signatures") || !contains(user, "parameter list") {
- t.Fatalf("unexpected in-params prompts")
- }
+func TestBuildCompletionMessages_InParams_UsesParamPrompts(t *testing.T) {
+ s := newTestServer()
+ p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line: 0, Character: 5}}
+ msgs := s.buildCompletionMessages(false, false, "", true, p, "a", "func f(x)", "c", "func f(x)")
+ if len(msgs) < 2 || msgs[0].Role != "system" || msgs[1].Role != "user" {
+ t.Fatalf("unexpected messages")
+ }
+ if !contains(msgs[0].Content, "function signatures") || !contains(msgs[1].Content, "parameter list") {
+ t.Fatalf("unexpected in-params prompts: %#v", msgs)
+ }
}
func TestPostProcessCompletion_CodeFencesAndDuplicates(t *testing.T) {
diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go
index 00e4548..c8b6e2e 100644
--- a/internal/lsp/document_test.go
+++ b/internal/lsp/document_test.go
@@ -9,15 +9,32 @@ import (
)
func newTestServer() *Server {
- s := &Server{
- logger: log.New(io.Discard, "", 0),
- docs: make(map[string]*document),
- inlineOpen: ">",
- inlineClose: ">",
- chatSuffix: ">",
- chatPrefixes: []string{"?", "!", ":", ";"},
- }
- // Keep package-level helpers in sync for tests using free functions
+ s := &Server{
+ logger: log.New(io.Discard, "", 0),
+ docs: make(map[string]*document),
+ inlineOpen: ">",
+ inlineClose: ">",
+ chatSuffix: ">",
+ chatPrefixes: []string{"?", "!", ":", ";"},
+ }
+ // Default prompt templates (mirror app defaults)
+ s.promptCompSysParams = "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."
+ s.promptCompUserParams = "Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: {{function}}\nCurrent line (cursor at {{char}}): {{current}}"
+ s.promptCompSysGeneral = "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)."
+ s.promptCompUserGeneral = "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."
+ s.promptCompSysInline = "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."
+ s.promptCompExtraHeader = "Additional context:\n{{context}}"
+ s.promptNativeCompletion = "// Path: {{path}}\n{{before}}"
+ s.promptChatSystem = "You are a helpful coding assistant. Answer concisely and clearly."
+ s.promptRewriteSystem = "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."
+ s.promptDiagnosticsSystem = "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."
+ s.promptDocumentSystem = "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."
+ s.promptRewriteUser = "Instruction: {{instruction}}\n\nSelected code to transform:\n{{selection}}"
+ s.promptDiagnosticsUser = "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}"
+ s.promptDocumentUser = "Add documentation comments to this code:\n{{selection}}"
+ s.promptGoTestSystem = "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."
+ s.promptGoTestUser = "Function under test:\n{{function}}"
+ // Keep package-level helpers in sync for tests using free functions
inlineOpenChar = '>'
inlineCloseChar = '>'
chatSuffixChar = '>'
diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go
index 27020a0..762190f 100644
--- a/internal/lsp/handlers_codeaction.go
+++ b/internal/lsp/handlers_codeaction.go
@@ -98,12 +98,12 @@ 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}}
+ 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 != "" {
@@ -114,38 +114,37 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) {
} 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()}}
- 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 := "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 != "" {
@@ -467,13 +466,13 @@ func findGoFunctionAtLine(lines []string, idx int) (int, int) {
// generateGoTestFunction uses LLM to produce a test function; falls back to a stub when unavailable.
func (s *Server) generateGoTestFunction(funcCode string) string {
- 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 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 != "" {
diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go
index c6b7d3d..0d48bc0 100644
--- a/internal/lsp/handlers_completion.go
+++ b/internal/lsp/handlers_completion.go
@@ -225,9 +225,13 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams,
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 {
@@ -336,18 +340,34 @@ func (s *Server) waitForThrottle(ctx context.Context) bool {
// buildCompletionMessages constructs the LLM messages for completion.
func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message {
- 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
+ // 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.
diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go
index 6a90919..26b78c0 100644
--- a/internal/lsp/handlers_document.go
+++ b/internal/lsp/handlers_document.go
@@ -156,9 +156,9 @@ func (s *Server) detectAndHandleChat(uri string) {
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())
diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go
index 30a21a5..eafd058 100644
--- a/internal/lsp/handlers_utils.go
+++ b/internal/lsp/handlers_utils.go
@@ -2,12 +2,11 @@
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.
@@ -73,15 +72,17 @@ 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) {
diff --git a/internal/lsp/helpers_more_test.go b/internal/lsp/helpers_more_test.go
index a0b0c26..1bd56d0 100644
--- a/internal/lsp/helpers_more_test.go
+++ b/internal/lsp/helpers_more_test.go
@@ -101,16 +101,17 @@ func TestCollectPromptRemovalEdits_MultiLine(t *testing.T) {
}
}
-func TestInParamListAndBuildPrompts(t *testing.T) {
- cur := "func add(a int, b string) int"
- if !inParamList(cur, 12) {
- t.Fatalf("expected in param list")
- }
- p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 0, Character: 5}}
- sys, user := buildPrompts(false, p, "above", "current", "below", "func add")
- if sys == "" || user == "" {
- t.Fatalf("prompts empty")
- }
+func TestInParamListAndBuildCompletionMessages(t *testing.T) {
+ cur := "func add(a int, b string) int"
+ if !inParamList(cur, 12) {
+ t.Fatalf("expected in param list")
+ }
+ s := newTestServer()
+ p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 0, Character: 5}}
+ msgs := s.buildCompletionMessages(false, false, "", false, p, "above", "current", "below", "func add")
+ if len(msgs) < 2 || msgs[0].Content == "" || msgs[1].Content == "" {
+ t.Fatalf("messages empty")
+ }
}
func TestLabelForCompletion(t *testing.T) {
diff --git a/internal/lsp/provider_native_success_test.go b/internal/lsp/provider_native_success_test.go
index dd1abcd..bfcb0b6 100644
--- a/internal/lsp/provider_native_success_test.go
+++ b/internal/lsp/provider_native_success_test.go
@@ -60,3 +60,34 @@ func TestProviderNativeCompletion_IndentWithDoubleOpen(t *testing.T) {
t.Fatalf("expected indentation applied, got %q", got)
}
}
+
+type fakeCompleterCapture struct{ lastPrompt string }
+
+func (fakeCompleterCapture) Chat(context.Context, []llm.Message, ...llm.RequestOption) (string, error) { return "", nil }
+func (fakeCompleterCapture) Name() string { return "prov" }
+func (fakeCompleterCapture) DefaultModel() string { return "m" }
+func (f *fakeCompleterCapture) CodeCompletion(_ context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) {
+ f.lastPrompt = prompt
+ return []string{"SUG"}, nil
+}
+
+func TestProviderNativeCompletion_UsesPromptTemplate(t *testing.T) {
+ s := newTestServer()
+ cap := &fakeCompleterCapture{}
+ s.llmClient = cap
+ s.promptNativeCompletion = "NATIVE {{path}} {{before}}"
+ uri := "file:///x.go"
+ s.setDocument(uri, "AAA\nBBB\nCCC")
+ current := "fmt."
+ // Cursor at line 1, char 1 -> before should be "AAA\nB"
+ p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Position: Position{Line: 1, Character: 1}}
+ if _, ok := s.tryProviderNativeCompletion(current, p, "", "", "func f(){}", "doc", false, "", false); !ok {
+ t.Fatalf("expected provider-native path")
+ }
+ if cap.lastPrompt == "" {
+ t.Fatalf("expected captured prompt")
+ }
+ if cap.lastPrompt != "NATIVE /x.go AAA\nB" {
+ t.Fatalf("unexpected prompt: %q", cap.lastPrompt)
+ }
+}
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index fa4467b..caaac29 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -58,7 +58,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.
@@ -79,8 +101,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 {
@@ -139,11 +179,29 @@ 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 != "" {
diff --git a/internal/lsp/testhelper_capture_llm_test.go b/internal/lsp/testhelper_capture_llm_test.go
new file mode 100644
index 0000000..3274141
--- /dev/null
+++ b/internal/lsp/testhelper_capture_llm_test.go
@@ -0,0 +1,18 @@
+package lsp
+
+import (
+ "context"
+
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+// captureLLM captures messages sent to Chat for assertions.
+type captureLLM struct{ msgs []llm.Message }
+
+func (c *captureLLM) Chat(_ context.Context, m []llm.Message, _ ...llm.RequestOption) (string, error) {
+ c.msgs = append([]llm.Message{}, m...)
+ return "OK", nil
+}
+func (*captureLLM) Name() string { return "cap" }
+func (*captureLLM) DefaultModel() string { return "m" }
+
diff --git a/internal/version.go b/internal/version.go
index abeac2b..39ed42e 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,4 +1,4 @@
// Summary: Hexai semantic version identifier used by CLI and LSP binaries.
package internal
-const Version = "0.5.0"
+const Version = "0.6.0"