diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-24 23:21:43 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-24 23:21:43 +0300 |
| commit | c3c71345db9086392cd9b7529c7f5287009c226e (patch) | |
| tree | d227894ab900d6050cbe1418984526088a692db5 /internal/lsp | |
| parent | 127844a4ee481590ef53b6777d34bf2114cb3ab1 (diff) | |
Add runtime config store and reload command
Diffstat (limited to 'internal/lsp')
31 files changed, 869 insertions, 513 deletions
diff --git a/internal/lsp/chat_commands.go b/internal/lsp/chat_commands.go new file mode 100644 index 0000000..31347e9 --- /dev/null +++ b/internal/lsp/chat_commands.go @@ -0,0 +1,63 @@ +package lsp + +import ( + "fmt" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/runtimeconfig" +) + +type chatCommandResult struct { + message string +} + +func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) { + trimmed := strings.TrimSpace(s.stripTrailingTrigger(prompt)) + if trimmed == "" || !strings.HasPrefix(trimmed, "/") { + return chatCommandResult{}, false + } + + switch { + case strings.HasPrefix(trimmed, "/reload"): + return s.handleReloadCommand(), true + case strings.HasPrefix(trimmed, "/help"): + return s.handleHelpCommand(), true + default: + return chatCommandResult{message: fmt.Sprintf("Unknown command %q. Try /help?>", trimmed)}, true + } +} + +func (s *Server) handleHelpCommand() chatCommandResult { + lines := []string{ + "Available slash commands:", + "- /reload?> reload configuration from file (ignores env overrides)", + } + return chatCommandResult{message: strings.Join(lines, "\n")} +} + +func (s *Server) handleReloadCommand() chatCommandResult { + if s.configStore == nil { + return chatCommandResult{message: "Reload unavailable: no config store"} + } + changes, err := s.configStore.Reload(s.logger, appconfig.LoadOptions{IgnoreEnv: true}) + if err != nil { + s.logger.Printf("config reload failed: %v", err) + return chatCommandResult{message: fmt.Sprintf("Reload failed: %v", err)} + } + summary := formatReloadSummary(changes) + s.logger.Print(summary) + return chatCommandResult{message: summary} +} + +func formatReloadSummary(changes []runtimeconfig.Change) string { + if len(changes) == 0 { + return "Reloaded config (no changes detected)." + } + lines := make([]string, 0, len(changes)+1) + lines = append(lines, fmt.Sprintf("Reloaded config (%d changes):", len(changes))) + for _, ch := range changes { + lines = append(lines, fmt.Sprintf("- %s: %s → %s", ch.Key, ch.Old, ch.New)) + } + return strings.Join(lines, "\n") +} diff --git a/internal/lsp/chat_commands_test.go b/internal/lsp/chat_commands_test.go new file mode 100644 index 0000000..bedfaed --- /dev/null +++ b/internal/lsp/chat_commands_test.go @@ -0,0 +1,82 @@ +package lsp + +import ( + "bytes" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/runtimeconfig" +) + +func TestFormatReloadSummary(t *testing.T) { + changes := []runtimeconfig.Change{ + {Key: "max_tokens", Old: "200", New: "128"}, + {Key: "provider", Old: "openai", New: "ollama"}, + } + got := formatReloadSummary(changes) + if !strings.Contains(got, "Reloaded config (2 changes):") { + t.Fatalf("expected change count line, got %q", got) + } + if !strings.Contains(got, "max_tokens: 200") || !strings.Contains(got, "provider: openai") { + t.Fatalf("expected formatted entries, got %q", got) + } +} + +func TestHandleHelpCommandListsReload(t *testing.T) { + s := newTestServer() + res := s.handleHelpCommand() + if !strings.Contains(res.message, "/reload?>") { + t.Fatalf("expected reload command in help output: %q", res.message) + } +} + +func TestHandleReloadCommandReloadsStore(t *testing.T) { + tmp := t.TempDir() + configDir := filepath.Join(tmp, "hexai") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + configPath := filepath.Join(configDir, "config.toml") + if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 64\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("HEXAI_MAX_TOKENS", "321") + + var logBuf bytes.Buffer + logger := log.New(&logBuf, "", 0) + + initial := appconfig.Load(logger) + if initial.MaxTokens != 321 { + t.Fatalf("expected env override to win initial load, got %d", initial.MaxTokens) + } + + store := runtimeconfig.New(initial) + + s := newTestServer() + s.logger = logger + s.configStore = store + + if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 128\n"), 0o644); err != nil { + t.Fatalf("update config: %v", err) + } + + res := s.handleReloadCommand() + if !strings.Contains(res.message, "Reloaded config (1 changes):") { + t.Fatalf("unexpected reload summary: %q", res.message) + } + if !strings.Contains(res.message, "max_tokens: 321") || !strings.Contains(res.message, "128") { + t.Fatalf("expected diff for max_tokens: %q", res.message) + } + if store.Snapshot().MaxTokens != 128 { + t.Fatalf("expected snapshot to reflect new value, got %d", store.Snapshot().MaxTokens) + } + if !strings.Contains(logBuf.String(), "Reloaded config") { + t.Fatalf("expected summary logged, got %q", logBuf.String()) + } +} diff --git a/internal/lsp/chat_context_mode_test.go b/internal/lsp/chat_context_mode_test.go index 85fa4a9..895c2f3 100644 --- a/internal/lsp/chat_context_mode_test.go +++ b/internal/lsp/chat_context_mode_test.go @@ -11,9 +11,9 @@ import ( func TestChat_RespectsContextModeWindow(t *testing.T) { s := newTestServer() // Configure window mode with small window - s.contextMode = "window" - s.windowLines = 2 - s.maxContextTokens = 2000 + s.cfg.ContextMode = "window" + s.cfg.ContextWindowLines = 2 + s.cfg.MaxContextTokens = 2000 cap := &captureLLM{} s.llmClient = cap var out bytes.Buffer @@ -54,8 +54,8 @@ func TestChat_RespectsContextModeWindow(t *testing.T) { func TestChat_ContextModeMinimal_NoExtra(t *testing.T) { s := newTestServer() - s.contextMode = "minimal" - s.maxContextTokens = 2000 + s.cfg.ContextMode = "minimal" + s.cfg.MaxContextTokens = 2000 cap := &captureLLM{} s.llmClient = cap var out bytes.Buffer @@ -78,8 +78,8 @@ func TestChat_ContextModeMinimal_NoExtra(t *testing.T) { func TestChat_ContextModeAlwaysFull_AddsExtra(t *testing.T) { s := newTestServer() - s.contextMode = "always-full" - s.maxContextTokens = 2000 + s.cfg.ContextMode = "always-full" + s.cfg.MaxContextTokens = 2000 cap := &captureLLM{} s.llmClient = cap var out bytes.Buffer @@ -108,8 +108,8 @@ func TestChat_ContextModeAlwaysFull_AddsExtra(t *testing.T) { func TestChat_ContextModeFileOnNewFunc_NoExtraWithoutSignature(t *testing.T) { s := newTestServer() - s.contextMode = "file-on-new-func" - s.maxContextTokens = 2000 + s.cfg.ContextMode = "file-on-new-func" + s.cfg.MaxContextTokens = 2000 cap := &captureLLM{} s.llmClient = cap var out bytes.Buffer @@ -129,8 +129,8 @@ func TestChat_ContextModeFileOnNewFunc_NoExtraWithoutSignature(t *testing.T) { func TestChat_ContextModeFileOnNewFunc_WithSignature_AddsExtra(t *testing.T) { s := newTestServer() - s.contextMode = "file-on-new-func" - s.maxContextTokens = 2000 + s.cfg.ContextMode = "file-on-new-func" + s.cfg.MaxContextTokens = 2000 cap := &captureLLM{} s.llmClient = cap var out bytes.Buffer diff --git a/internal/lsp/chat_prompt_test.go b/internal/lsp/chat_prompt_test.go index 25767ab..1f7b266 100644 --- a/internal/lsp/chat_prompt_test.go +++ b/internal/lsp/chat_prompt_test.go @@ -10,7 +10,9 @@ func TestDetectAndHandleChat_UsesConfiguredSystemPrompt(t *testing.T) { s := newTestServer() cap := &captureLLM{} s.llmClient = cap - s.promptChatSystem = "CHAT-SYS" + cfg := s.cfg + cfg.PromptChatSystem = "CHAT-SYS" + s.cfg = cfg uri := "file:///chat.txt" // Avoid nil writer in applyChatEdits var out bytes.Buffer diff --git a/internal/lsp/chat_trigger_suppression_test.go b/internal/lsp/chat_trigger_suppression_test.go index 8d016d1..9f9f5bc 100644 --- a/internal/lsp/chat_trigger_suppression_test.go +++ b/internal/lsp/chat_trigger_suppression_test.go @@ -4,7 +4,10 @@ import "testing" // Ensure completion is suppressed when a chat trigger is at EOL (?>,!>,:>,;>) func TestCompletionSuppressedOnChatTriggerEOL(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + s := newTestServer() + s.cfg.MaxTokens = 32 + s.cfg.TriggerCharacters = []string{".", ":", "/", "_"} + s.compCache = make(map[string]string) initServerDefaults(s) s.llmClient = &countingLLM{} tests := []string{"What now?>", "Explain!>", "Refactor:>", "note ;>"} diff --git a/internal/lsp/codeaction_custom_errors_test.go b/internal/lsp/codeaction_custom_errors_test.go index ca6111f..c572542 100644 --- a/internal/lsp/codeaction_custom_errors_test.go +++ b/internal/lsp/codeaction_custom_errors_test.go @@ -7,13 +7,16 @@ import ( "errors" "testing" + "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/llm" ) func TestResolveCodeAction_Custom_UnknownID(t *testing.T) { s := newTestServer() // No matching custom action configured - s.customActions = []CustomAction{{ID: "known", Title: "Known", Instruction: "x"}} + cfg := s.cfg + cfg.CustomActions = []appconfig.CustomAction{{ID: "known", Title: "Known", Instruction: "x"}} + s.cfg = cfg uri := "file:///t.go" payload := struct { Type string `json:"type"` @@ -41,7 +44,9 @@ func TestResolveCodeAction_Custom_EmptyAndError(t *testing.T) { // empty output case s1 := newTestServer() s1.llmClient = fakeLLM{resp: " \n\n"} - s1.customActions = []CustomAction{{ID: "empty", Title: "Empty", Instruction: "x"}} + cfg1 := s1.cfg + cfg1.CustomActions = []appconfig.CustomAction{{ID: "empty", Title: "Empty", Instruction: "x"}} + s1.cfg = cfg1 raw1, _ := json.Marshal(struct { Type, ID, URI, Selection string Range Range @@ -53,7 +58,9 @@ func TestResolveCodeAction_Custom_EmptyAndError(t *testing.T) { // error case s2 := newTestServer() s2.llmClient = errLLM{} - s2.customActions = []CustomAction{{ID: "err", Title: "Err", Instruction: "x"}} + cfg2 := s2.cfg + cfg2.CustomActions = []appconfig.CustomAction{{ID: "err", Title: "Err", Instruction: "x"}} + s2.cfg = cfg2 raw2, _ := json.Marshal(struct { Type, ID, URI, Selection string Range Range @@ -67,10 +74,12 @@ func TestHandleCodeAction_Custom_SelectionSuppressedWhenEmpty(t *testing.T) { s := newTestServer() s.llmClient = fakeLLM{resp: "IGN"} // One selection-scoped and one diagnostics-scoped custom - s.customActions = []CustomAction{ + cfg := s.cfg + cfg.CustomActions = []appconfig.CustomAction{ {ID: "sel", Title: "Sel", Scope: "selection", Instruction: "x"}, {ID: "diag", Title: "Diag", Scope: "diagnostics", User: "{{diagnostics}}"}, } + s.cfg = cfg uri := "file:///t.go" s.setDocument(uri, "package p\nfunc f(){}\n") // Empty selection range (start==end) diff --git a/internal/lsp/codeaction_custom_test.go b/internal/lsp/codeaction_custom_test.go index 1ea4c3c..ea8ae82 100644 --- a/internal/lsp/codeaction_custom_test.go +++ b/internal/lsp/codeaction_custom_test.go @@ -7,6 +7,8 @@ import ( "log" "strings" "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" ) // local copy of captureResponse for this test file @@ -27,24 +29,23 @@ func capResp(t *testing.T, buf *bytes.Buffer) Response { func TestHandleCodeAction_ListsCustomActions(t *testing.T) { var out bytes.Buffer - s := &Server{ - logger: log.New(io.Discard, "", 0), - docs: make(map[string]*document), - out: &out, - inlineOpen: ">", - inlineClose: ">", - inlineOpenChar: '>', - inlineCloseChar: '>', - chatSuffix: ">", - chatSuffixChar: '>', - chatPrefixes: []string{"?", "!", ":", ";"}, + cfg := appconfig.App{ + InlineOpen: ">", + InlineClose: ">", + ChatSuffix: ">", + ChatPrefixes: []string{"?", "!", ":", ";"}, + CustomActions: []appconfig.CustomAction{ + {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"}, + {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix:\n{{diagnostics}}\n\n{{selection}}"}, + }, } - s.llmClient = fakeLLM{resp: "IGN"} - // Inject two custom actions - s.customActions = []CustomAction{ - {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"}, - {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix:\n{{diagnostics}}\n\n{{selection}}"}, + s := &Server{ + logger: log.New(io.Discard, "", 0), + docs: make(map[string]*document), + out: &out, + cfg: cfg, } + s.llmClient = fakeLLM{resp: "ok"} // Prepare document and params uri := "file:///t.go" s.setDocument(uri, "package x\n\nfunc f(){}\n") @@ -82,11 +83,12 @@ func TestHandleCodeAction_ListsCustomActions(t *testing.T) { func TestResolveCodeAction_CustomInstructionAndUser(t *testing.T) { s := newTestServer() s.llmClient = fakeLLM{resp: "REPLACED"} - // one instruction-based and one user-based - s.customActions = []CustomAction{ + cfg := s.cfg + cfg.CustomActions = []appconfig.CustomAction{ {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"}, {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix: {{diagnostics}}\n{{selection}}"}, } + s.cfg = cfg uri := "file:///t.go" p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1, Character: 3}}} diff --git a/internal/lsp/codeaction_prompts_test.go b/internal/lsp/codeaction_prompts_test.go index bbfad10..c5fd5e2 100644 --- a/internal/lsp/codeaction_prompts_test.go +++ b/internal/lsp/codeaction_prompts_test.go @@ -9,8 +9,10 @@ func TestResolveCodeAction_UsesRewritePrompts(t *testing.T) { s := newTestServer() cap := &captureLLM{} s.llmClient = cap - s.promptRewriteSystem = "RSYS" - s.promptRewriteUser = "RUSER {{instruction}} {{selection}}" + cfg := s.cfg + cfg.PromptCodeActionRewriteSystem = "RSYS" + cfg.PromptCodeActionRewriteUser = "RUSER {{instruction}} {{selection}}" + s.cfg = cfg uri := "file:///x.go" s.setDocument(uri, "package p\nvar a=1\n") payload := struct { @@ -35,8 +37,10 @@ func TestResolveCodeAction_UsesDiagnosticsPrompts(t *testing.T) { s := newTestServer() cap := &captureLLM{} s.llmClient = cap - s.promptDiagnosticsSystem = "DSYS" - s.promptDiagnosticsUser = "DUSER {{diagnostics}} {{selection}}" + cfg := s.cfg + cfg.PromptCodeActionDiagnosticsSystem = "DSYS" + cfg.PromptCodeActionDiagnosticsUser = "DUSER {{diagnostics}} {{selection}}" + s.cfg = cfg uri := "file:///x.go" s.setDocument(uri, "package p\nvar a=1\n") payload := struct { @@ -64,8 +68,10 @@ func TestResolveCodeAction_UsesDocumentPrompts(t *testing.T) { s := newTestServer() cap := &captureLLM{} s.llmClient = cap - s.promptDocumentSystem = "DOCSYS" - s.promptDocumentUser = "DOCUSER {{selection}}" + cfg := s.cfg + cfg.PromptCodeActionDocumentSystem = "DOCSYS" + cfg.PromptCodeActionDocumentUser = "DOCUSER {{selection}}" + s.cfg = cfg uri := "file:///x.go" s.setDocument(uri, "package p\nvar a=1\n") payload := struct { @@ -89,8 +95,10 @@ func TestGenerateGoTest_UsesPrompts(t *testing.T) { s := newTestServer() cap := &captureLLM{} s.llmClient = cap - s.promptGoTestSystem = "GTSYS" - s.promptGoTestUser = "GTUSER {{function}}" + cfg := s.cfg + cfg.PromptCodeActionGoTestSystem = "GTSYS" + cfg.PromptCodeActionGoTestUser = "GTUSER {{function}}" + s.cfg = cfg _ = s.generateGoTestFunction("func Add(a,b int) int {return a+b}") if len(cap.msgs) < 2 { t.Fatalf("expected chat messages") diff --git a/internal/lsp/completion_cache_test.go b/internal/lsp/completion_cache_test.go index 65631f9..057b5c5 100644 --- a/internal/lsp/completion_cache_test.go +++ b/internal/lsp/completion_cache_test.go @@ -12,9 +12,13 @@ import ( func TestCompletionCache_IgnoresWhitespaceBeforeCursor(t *testing.T) { var buf bytes.Buffer logger := log.New(&buf, "", 0) - s := NewServer(bytes.NewBuffer(nil), &buf, logger, ServerOptions{}) + s := newTestServer() + s.logger = logger + s.out = &buf logging.Bind(logger) - s.triggerChars = []string{" ", "."} + cfg := s.cfg + cfg.TriggerCharacters = []string{" ", "."} + s.cfg = cfg fake := &countingLLM{} s.llmClient = fake diff --git a/internal/lsp/completion_codex_path_test.go b/internal/lsp/completion_codex_path_test.go index 6c0a60f..ea27c6e 100644 --- a/internal/lsp/completion_codex_path_test.go +++ b/internal/lsp/completion_codex_path_test.go @@ -39,7 +39,10 @@ func (f *fakeCodeLLM) Name() string { return "fake" } func (f *fakeCodeLLM) DefaultModel() string { return "m" } func TestTryLLMCompletion_PrefersCodeCompleterOverChat(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string)} + s := newTestServer() + s.cfg.MaxTokens = 32 + s.cfg.TriggerCharacters = []string{"."} + s.compCache = make(map[string]string) initServerDefaults(s) fake := &fakeCodeLLM{result: "DoThing()"} s.llmClient = fake @@ -58,7 +61,10 @@ func TestTryLLMCompletion_PrefersCodeCompleterOverChat(t *testing.T) { } func TestTryLLMCompletion_FallsBackToChatOnCodeCompleterError(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string)} + s := newTestServer() + s.cfg.MaxTokens = 32 + s.cfg.TriggerCharacters = []string{"."} + s.compCache = make(map[string]string) initServerDefaults(s) fake := &fakeCodeLLM{result: "DoThing()", codeErr: errors.New("boom")} s.llmClient = fake diff --git a/internal/lsp/completion_messages_test.go b/internal/lsp/completion_messages_test.go index 20aac69..f0c693c 100644 --- a/internal/lsp/completion_messages_test.go +++ b/internal/lsp/completion_messages_test.go @@ -37,7 +37,7 @@ func TestBuildCompletionMessages_ExtraContextIncluded(t *testing.T) { func TestPrefixHeuristic_AllVariants(t *testing.T) { s := newTestServer() // manual invoke requires at least min prefix; set to 2 - s.manualInvokeMinPrefix = 2 + s.cfg.ManualInvokeMinPrefix = 2 cur := "a" p := CompletionParams{Position: Position{Line: 0, Character: 1}} if s.prefixHeuristicAllows(false, cur, p, true) { diff --git a/internal/lsp/completion_prefix_strip_test.go b/internal/lsp/completion_prefix_strip_test.go index acc7921..6173d6f 100644 --- a/internal/lsp/completion_prefix_strip_test.go +++ b/internal/lsp/completion_prefix_strip_test.go @@ -41,8 +41,12 @@ func TestStripDuplicateAssignmentPrefix_AssignAndWalrus(t *testing.T) { } func TestTryLLMCompletion_ManualInvokeAfterWhitespace_Allows(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} - initServerDefaults(s) + s := newTestServer() + s.compCache = make(map[string]string) + cfg := s.cfg + cfg.MaxTokens = 32 + cfg.TriggerCharacters = []string{".", ":", "/", "_"} + s.cfg = cfg s.llmClient = fakeLLM{resp: tut.MultilineFunctionSuggestion()} line := "func fib(i int) " // cursor after space p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://x.go"}} @@ -58,8 +62,12 @@ func TestTryLLMCompletion_ManualInvokeAfterWhitespace_Allows(t *testing.T) { } func TestTryLLMCompletion_InlinePromptAlwaysTriggers(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} - initServerDefaults(s) + s := newTestServer() + s.compCache = make(map[string]string) + cfg := s.cfg + cfg.MaxTokens = 32 + cfg.TriggerCharacters = []string{".", ":", "/", "_"} + s.cfg = cfg s.llmClient = fakeLLM{resp: "replacement"} line := "prefix >do something> suffix" // No trigger char immediately before cursor; place cursor at end @@ -71,17 +79,12 @@ func TestTryLLMCompletion_InlinePromptAlwaysTriggers(t *testing.T) { } func TestTryLLMCompletion_DoubleOpenEmpty_DoesNotAutoTrigger(t *testing.T) { - s := &Server{ - maxTokens: 32, - triggerChars: []string{".", ":", "/", "_"}, - compCache: make(map[string]string), - inlineOpen: ">", - inlineClose: ">", - inlineOpenChar: '>', - inlineCloseChar: '>', - } - initServerDefaults(s) - initServerDefaults(s) + s := newTestServer() + s.compCache = make(map[string]string) + cfg := s.cfg + cfg.MaxTokens = 32 + cfg.TriggerCharacters = []string{".", ":", "/", "_"} + s.cfg = cfg fake := &countingLLM{} s.llmClient = fake line := ">> " // empty content after double-open should not force-trigger @@ -114,15 +117,12 @@ func TestHasDoubleSemicolonTrigger_Variants(t *testing.T) { } func TestBareDoubleOpenPreventsAutoTriggerEvenWithOtherTriggers(t *testing.T) { - s := &Server{ - maxTokens: 32, - triggerChars: []string{".", ":", "/", "_"}, - compCache: make(map[string]string), - inlineOpen: ">", - inlineClose: ">", - inlineOpenChar: '>', - inlineCloseChar: '>', - } + s := newTestServer() + s.compCache = make(map[string]string) + cfg := s.cfg + cfg.MaxTokens = 32 + cfg.TriggerCharacters = []string{".", ":", "/", "_"} + s.cfg = cfg fake := &countingLLM{} s.llmClient = fake // Place a '.' earlier but also include bare double-open at end; should not auto-trigger @@ -141,8 +141,12 @@ func TestBareDoubleOpenPreventsAutoTriggerEvenWithOtherTriggers(t *testing.T) { } func TestBareDoubleOpenOnNextLine_PreventsAutoTrigger(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} - initServerDefaults(s) + s := newTestServer() + s.compCache = make(map[string]string) + cfg := s.cfg + cfg.MaxTokens = 32 + cfg.TriggerCharacters = []string{".", ":", "/", "_"} + s.cfg = cfg fake := &countingLLM{} s.llmClient = fake current := "expression := flag.String(\"expression\", \"\", \"Expression to evaluate\")" @@ -161,8 +165,12 @@ func TestBareDoubleOpenOnNextLine_PreventsAutoTrigger(t *testing.T) { } func TestBareDoubleOpenPreventsManualInvoke(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} - initServerDefaults(s) + s := newTestServer() + s.compCache = make(map[string]string) + cfg := s.cfg + cfg.MaxTokens = 32 + cfg.TriggerCharacters = []string{".", ":", "/", "_"} + s.cfg = cfg fake := &countingLLM{} s.llmClient = fake line := ">>" diff --git a/internal/lsp/context.go b/internal/lsp/context.go index 5a4983c..8b584fb 100644 --- a/internal/lsp/context.go +++ b/internal/lsp/context.go @@ -14,7 +14,7 @@ import ( // - file-on-new-func: include full file only when defining a new function // - always-full: always include the full file func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) { - mode := s.contextMode + mode := s.contextMode() switch mode { case "minimal": return "", false @@ -40,7 +40,7 @@ func (s *Server) windowContext(uri string, pos Position) string { return "" } n := len(d.lines) - half := s.windowLines / 2 + half := s.windowLines() / 2 start := pos.Line - half if start < 0 { start = 0 @@ -50,7 +50,7 @@ func (s *Server) windowContext(uri string, pos Position) string { end = n } text := strings.Join(d.lines[start:end], "\n") - return truncateToApproxTokens(text, s.maxContextTokens) + return truncateToApproxTokens(text, s.maxContextTokens()) } func (s *Server) fullFileContext(uri string) string { @@ -59,7 +59,7 @@ func (s *Server) fullFileContext(uri string) string { logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri) return "" } - return truncateToApproxTokens(d.text, s.maxContextTokens) + return truncateToApproxTokens(d.text, s.maxContextTokens()) } // truncateToApproxTokens naively truncates the input to fit approx N tokens. diff --git a/internal/lsp/context_test.go b/internal/lsp/context_test.go index dcda042..875eec9 100644 --- a/internal/lsp/context_test.go +++ b/internal/lsp/context_test.go @@ -9,8 +9,8 @@ import ( func TestWindowContext_Bounds(t *testing.T) { s := newTestServer() - s.windowLines = 4 // half=2 - s.maxContextTokens = 9999 + s.cfg.ContextWindowLines = 4 // half=2 + s.cfg.MaxContextTokens = 9999 lines := make([]string, 10) for i := 0; i < 10; i++ { lines[i] = "L" + strconv.Itoa(i) @@ -28,7 +28,7 @@ func TestWindowContext_Bounds(t *testing.T) { func TestBuildAdditionalContext_Minimal(t *testing.T) { s := newTestServer() - s.contextMode = "minimal" + s.cfg.ContextMode = "minimal" if ctx, ok := s.buildAdditionalContext(false, "file:///x.go", Position{}); ok || ctx != "" { t.Fatalf("expected no context in minimal mode; got ok=%v ctx=%q", ok, ctx) } @@ -36,8 +36,8 @@ func TestBuildAdditionalContext_Minimal(t *testing.T) { func TestBuildAdditionalContext_FileOnNewFunc(t *testing.T) { s := newTestServer() - s.contextMode = "file-on-new-func" - s.maxContextTokens = 9999 + s.cfg.ContextMode = "file-on-new-func" + s.cfg.MaxContextTokens = 9999 uri := "file:///x.go" body := "package x\n\nfunc a(){}\n" s.setDocument(uri, body) @@ -51,8 +51,8 @@ func TestBuildAdditionalContext_FileOnNewFunc(t *testing.T) { func TestBuildAdditionalContext_AlwaysFull(t *testing.T) { s := newTestServer() - s.contextMode = "always-full" - s.maxContextTokens = 9999 + s.cfg.ContextMode = "always-full" + s.cfg.MaxContextTokens = 9999 uri := "file:///x.go" body := "line1\nline2\n" s.setDocument(uri, body) diff --git a/internal/lsp/debounce_throttle_more_test.go b/internal/lsp/debounce_throttle_more_test.go index ed61336..7657cab 100644 --- a/internal/lsp/debounce_throttle_more_test.go +++ b/internal/lsp/debounce_throttle_more_test.go @@ -8,7 +8,9 @@ import ( func TestWaitForDebounce_WaitsRoughlyDebounce(t *testing.T) { s := newTestServer() - s.completionDebounce = 20 * time.Millisecond + cfg := s.cfg + cfg.CompletionDebounceMs = 20 + s.cfg = cfg s.mu.Lock() s.lastInput = time.Now() s.mu.Unlock() @@ -21,7 +23,9 @@ func TestWaitForDebounce_WaitsRoughlyDebounce(t *testing.T) { func TestWaitForThrottle_WaitsRoughlyInterval(t *testing.T) { s := newTestServer() - s.throttleInterval = 20 * time.Millisecond + cfg := s.cfg + cfg.CompletionThrottleMs = 20 + s.cfg = cfg s.mu.Lock() s.lastLLMCall = time.Now() s.mu.Unlock() diff --git a/internal/lsp/debounce_throttle_test.go b/internal/lsp/debounce_throttle_test.go index 0b49b1b..81a2c1a 100644 --- a/internal/lsp/debounce_throttle_test.go +++ b/internal/lsp/debounce_throttle_test.go @@ -22,9 +22,11 @@ func (t *timeLLM) DefaultModel() string { return "m" } func TestCompletionDebounce_WaitsUntilQuiet(t *testing.T) { s := newTestServer() s.compCache = make(map[string]string) - s.triggerChars = []string{".", ":", "/", "_"} - s.maxTokens = 32 - s.completionDebounce = 30 * time.Millisecond + cfg := s.cfg |
